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 retryable 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, .. }.
The two kinds
Section titled “The two kinds”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 means your condition matched a write that is committing in the same group
commit as yours. Several writes drain into one batch, and each is checked against the ones staged
ahead of it, with the same precise matching as the durable check (event types and tags on a single
event). The match is real, but the event that beat you is not durable yet and has no position, and
if its batch fails it never lands at all. This is retryable: true. Rebuild your model from the
current tail and decide again: the retry sees the now-durable event (or its absence) and gets the
durable answer.
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.
Boundary vs existence conflicts
Section titled “Boundary vs existence conflicts”retryable is one axis; the error code is a second: which clause of the condition was violated.
A Conflict is a boundary conflict: an event matched fail_if_events_match after your after
cursor. Handle it as above (rebuild, and if durable, surface).
An AlreadyExists is an existence conflict: an event matched a fail_if_exists clause, so the
guarded key already exists in the log. It follows the same retryable rule as any conflict: a
durable duplicate is terminal (retryable: false) and means “already applied”, so the right
response is to treat it as a successful no-op rather than to rebuild and retry; a same-batch race
is retryable: true and resolves to the durable verdict on retry, exactly like a boundary
same-batch conflict. See Idempotency and deduplication.
The retry loop
Section titled “The retry loop”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.clone())?])?), QueryItem::with_tags(Tags::new([Tag::new(student_tag.clone())?])?), ]); let (events, watermark) = client.read_all(query.clone(), Position::ZERO, None)?;
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: a write committing alongside ours matched, but it is not durable yet. // Retry with a fresh read to get the durable verdict. 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 resolves once the contending batch commits, so a small retry count is enough; an unbounded retry would only matter under sustained contention on the same decision, which is the case you want to surface, not hide.
Why same-batch is still retryable
Section titled “Why same-batch is still retryable”A same-batch match is exact, so why not report it like a durable one? Because there is nothing durable to point at yet. The conflicting event has no committed position, and the batch carrying it can still fail, in which case your write was turned away over an event that never existed. Marking it retryable keeps that case recoverable. 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.
