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 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, .. }.

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.

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.

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.

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.