Exactly-Once Writes
Network retries can produce duplicate appends. Ursula provides server-side deduplication so your application doesn't need its own bookkeeping. Three headers form the identity:
Producer-Id: a stable client identifier (UUID, hostname, etc.).Producer-Epoch: bumped on producer restart. Must be ≥ the last epoch the server saw from this producer.Producer-Seq: a per-epoch sequence number. Starts at0for a new epoch and must increase by exactly1per append.
Both epoch and seq are capped at 2^53 − 1 so they survive a round-trip through JSON.
How dedup works
The server records an exact receipt for every accepted sequence in the current epoch from each Producer-Id per stream, within a per-stream receipt window of 1,024 items, and always keeps each producer's newest receipt. On an append:
- Delayed duplicate (same epoch and any seq already accepted): silently deduplicated. The response carries that sequence's original byte and record ranges, even when newer sequences have committed, as long as its receipt is in the window. The newest sequence always gets its ranges. An older duplicate whose receipt left the window is still deduplicated (never written twice) and answered
204withProducer-Seqbut without range headers. - Next in sequence (
seq = last_seq + 1): accepted. - Out of order (seq skips ahead): rejected with
409. The response carriesProducer-Expected-Seq, the sequence the server wanted, andProducer-Received-Seq. - Epoch regression (epoch less than the last accepted one): rejected with
403 Forbidden. The response'sProducer-Epochis the current epoch. A producer that gets403has been replaced by a newer instance and should stop writing.
Restart and epoch hygiene
When a producer crashes and restarts, bump Producer-Epoch. Otherwise the server still expects the next contiguous seq within the old epoch, and the producer (which has lost its in-memory seq counter) will collide. A new epoch starts the seq counter back at 0.
Per-stream state
Dedup state is scoped per stream. The same Producer-Id writing to two different streams maintains two independent epoch/seq counters, so you do not have to elect a single global writer per producer.
The server retains exact receipts for the current epoch per (producer_id, stream) pair, bounded by the stream's receipt window. Older epochs and their receipts are dropped once a higher epoch is observed. A producer that has not written to a stream for 7 days is forgotten: its next append must start at sequence 0 (a later sequence gets 409), so bump the epoch or restart the sequence after long pauses. A stream holds at most 4,096 producers: a new producer beyond that replaces the least recently seen producer idle for at least an hour, and when none has been idle that long its append is rejected with 429 and a producer_limit body.
When you don't need this
For append-only workloads where each write is independent (event logging, audit trails), exactly-once headers are optional. Just POST without them. Add them when retries are real and double-applies would be visible.
Related
- Conditional writes: coordinate multiple writers with
Stream-Seq, and combine it with producer headers to publish committed facts - API: append