The Race Condition Between Bid Acceptance and Partnership Activation
The Acceptance Flow
When a brand accepts a bid, three things need to happen in sequence:
- Update the bid status from
SUBMITTEDtoACCEPTED - Create a
PartnershipEntitylinking the brand and publisher - Notify the publisher that their bid was accepted
Here's what the code looked like:
@Transactional
public void acceptBid(Long bidId, Long brandId) {
BidEntity bid = bidRepository.findById(bidId)
.orElseThrow(() -> new ResourceNotFoundException("Bid not found"));
validateBidOwnership(bid, brandId);
validateBidStatus(bid, BidStatus.SUBMITTED);
bid.setStatus(BidStatus.ACCEPTED);
bidRepository.save(bid);
PartnershipEntity partnership = createPartnership(bid);
partnershipRepository.save(partnership);
notificationService.notifyPublisherBidAccepted(bid);
}
Looks clean. Each step follows logically. The @Transactional annotation wraps everything in a single database transaction. That's the problem.
The Root Cause
notificationService.notifyPublisherBidAccepted() makes an external HTTP call. Sometimes that call fails. Network timeout, email service down, rate limit hit -- doesn't matter. When it throws an exception, Spring's transaction manager catches it and rolls back the entire transaction. The bid goes back to SUBMITTED. The partnership entity gets deleted. The brand sees no change.
And because the notification failure is the last step, there's no error visible to the brand. The endpoint already committed to returning a response. Depending on your error handling, this either swallows the exception entirely or returns a generic 500 that doesn't explain what happened.
The same pattern existed on the other side too:
@Transactional
public void submitBid(BidRequest request) {
BidEntity bid = createBid(request);
bidRepository.save(bid);
notificationService.notifyBrandNewBid(bid); // This can fail too
}
A publisher submits a bid, the notification to the brand fails, and the entire bid submission rolls back. The publisher thinks something went wrong. They submit again. Now you've got duplicate attempts and confused users.
The Fix
Notifications are best-effort. They should never roll back a successful database operation.
@Transactional
public void acceptBid(Long bidId, Long brandId) {
BidEntity bid = bidRepository.findById(bidId)
.orElseThrow(() -> new ResourceNotFoundException("Bid not found"));
validateBidOwnership(bid, brandId);
validateBidStatus(bid, BidStatus.SUBMITTED);
bid.setStatus(BidStatus.ACCEPTED);
bidRepository.save(bid);
PartnershipEntity partnership = createPartnership(bid);
partnershipRepository.save(partnership);
try {
notificationService.notifyPublisherBidAccepted(bid);
} catch (Exception e) {
log.error("Failed to notify publisher for bid {}: {}", bidId, e.getMessage());
// Notification failure is logged but does NOT roll back the transaction
}
}
Same treatment for notifyBrandNewBid():
try {
notificationService.notifyBrandNewBid(bid);
} catch (Exception e) {
log.error("Failed to notify brand for bid {}: {}", bid.getId(), e.getMessage());
}
The database operation succeeds. The notification is attempted. If the notification fails, we log it and move on. A retry mechanism or dead letter queue can pick it up later.
The Adjacent Bug: Commission Math With Null Values
While debugging the bid acceptance flow, I found another issue. When creating a partnership from an accepted bid, the commission calculation assumed conversionValue was always present and positive:
BigDecimal commission = conversionValue.multiply(commissionRate);
If a bid came through with a null or zero conversion value -- possible during testing or from a malformed API request -- this would throw a NullPointerException or produce a nonsensical commission.
The fix is a guard clause before the math:
if (conversionValue == null || conversionValue.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Conversion value must be positive, got: " + conversionValue
);
}
Fail fast, fail loud. Don't let bad data propagate into commission calculations where it'll be much harder to trace.
The Display Priority Problem
One more thing surfaced during this investigation. A publisher can have multiple bids on the same campaign. They submit a bid, then withdraw it, then submit a new one. Now they have two bids: one WITHDRAWN, one SUBMITTED. The UI was showing whichever bid it found first in the database. Sometimes that was the withdrawn one. The publisher would see "WITHDRAWN" and think their new bid didn't go through.
The fix was a displayPriority() method on BidStatus:
public enum BidStatus {
ACCEPTED(1),
SUBMITTED(2),
COUNTERED(3),
WITHDRAWN(4),
REJECTED(5),
EXPIRED(6);
private final int displayPriority;
BidStatus(int displayPriority) {
this.displayPriority = displayPriority;
}
public int displayPriority() {
return displayPriority;
}
}
When rendering the UI, sort bids by displayPriority() and show the highest priority status. ACCEPTED beats everything. SUBMITTED beats WITHDRAWN. The publisher sees the status that actually matters.
The Takeaway
The pattern is simple: never let best-effort operations participate in your transaction boundary. Notifications, analytics events, audit logs, webhook fires -- if they fail, your core business operation should still succeed.
I think of it as the "airplane mode test." If the notification service went completely offline, should the brand still be able to accept a bid? Obviously yes. So the notification can't be inside the transaction.
Spring's @Transactional makes it easy to wrap everything in one big transaction. That's a feature when all your operations are database writes. It's a footgun when you mix in external calls.
Ever had a notification failure silently undo a user action? What was the blast radius? I'd love to hear about it in the comments.
Building jo4.io -- an affiliate marketplace where bid acceptance actually works.
Comments
No comments yet. Start the discussion.