Skip to content

Handling conflicts

When an append is rejected by its condition, the rejection comes in two kinds, and treating them the same is a real bug. One is advisory and you should retry it. The other is terminal and retrying it verbatim will fail again. The client tells you which through ClientError::Server { retryable, conflict_position, .. }.

A durable conflict means a real event matching your condition landed since the position you read at. Your decision was made against stale state. This is retryable: false, and it carries the conflict_position of the event that beat you. Retrying the exact same append is pointless: the conflicting event is still there. You have to rebuild your decision model against the new tail and decide again, which may now come out differently (the seat you wanted is taken).

A same-batch conflict is advisory. Several writes drain into one group commit, and within that window the store uses a conservative, tag-only check that cannot see event types, so it can reject two writes that a precise check would have allowed. This is retryable: true. A retry re-reads the now-durable state and gets the precise answer, which is often “no real conflict, proceed”.

Collapsing the two into a bare “it conflicted” loses this. Retrying a durable conflict spins; not retrying a same-batch conflict fails a write that should have succeeded.

Rebuild the model from the current tail each attempt. Retry a same-batch conflict. On a durable conflict, surface it: the loop has already re-read, so the next decision is made against the state that beat you.

for _attempt in 0..MAX_ATTEMPTS {
// Rebuild the decision model from the current tail on each attempt.
let query = Query::items([
QueryItem::with_tags(Tags::new([Tag::new(&course_tag)?])?),
QueryItem::with_tags(Tags::new([Tag::new(&student_tag)?])?),
]);
let (events, watermark) = client.read_all(query.clone(), Position::ZERO)?;
let seats_used = events
.iter()
.filter(|seq| {
seq.event().event_type() == "StudentEnrolled"
&& seq.event().tags().any(|t| t == course_tag.as_str())
})
.count();
if seats_used >= SEAT_LIMIT {
return Ok(Reserve::Full);
}
let event = Event::new(
"StudentEnrolled",
&[course_tag.as_str(), student_tag.as_str()],
b"{}".to_vec(),
)?;
let guard = AppendCondition::new(query).after(watermark);
match client.append([event], Some(guard)) {
Ok(result) => return Ok(Reserve::Ok(result.first)),
// Same-batch: advisory. The tag-only staged check cannot see event type, so this may
// be a false alarm. Retry immediately with a fresh read.
Err(ClientError::Server {
retryable: true, ..
}) => continue,
// Durable: a real conflicting event landed since we read. Terminal for this attempt;
// the caller (or the next loop turn) must decide again against the changed tail.
Err(ClientError::Server {
retryable: false,
conflict_position,
..
}) => return Ok(Reserve::Conflicted(conflict_position)),
Err(err) => return Err(Box::new(err)),
}
}
Ok(Reserve::Conflicted(None))

The loop is bounded. A same-batch conflict clears once the contending batch commits, so a small retry count is enough; an unbounded retry would only matter under sustained contention on the exact same tags, which is the case you want to surface, not hide.

The same-batch check is deliberately pessimistic: it would rather reject a write that was actually fine than accept one that was not, because a false rejection is recoverable by a retry while a false acceptance is a durability bug. This is safe only if callers honour the retry contract, which is why this page exists and why the distinction is worth getting right in a shared helper rather than at every call site.

For how the two-arm check works inside the writer, see Architecture.