Concepts
Concurrency
Handling concurrent writers
Nova gives you two ways to handle writers that collide on one stream.
match_seq_num works per append. The batch says which sequence number
it expects to land at; if the tail has moved, that one batch is
rejected and nothing else changes. Any number of writers can share the
stream, and each of them finds out, write by write, whether it saw the
tail it thought it did.
A fencing token works per writer. Installing a token with Fence
decides who may write at all: a writer presenting the installed token
is admitted, and every other writer is refused on every append until
the next Fence. Use it when one writer at a time should own the
stream and a replacement must lock its predecessor out.
The two combine cleanly. The token settles which writer is current;
match_seq_num settles whether a particular write can go in, which is
what makes a retry after a lost connection exactly-once instead of
at-least-once. A single elected writer whose individual appends must
also build on the exact tail uses both: fence, open presenting the
token, then append with match_seq_num.
Optimistic concurrency
A batch may carry match_seq_num: the sequence number its first record
must receive. The check runs at the sequencer's assignment cursor, the
one point that serializes concurrent admissions, so two racing
conditional appends cannot both pass.
- A hit appends the batch normally.
- A miss rejects only that batch and reports the committed tail;
nothing is appended, the session lives, and later pipelined batches are
unaffected. The next value to try is
tail.seq_num.
res, err := p.AppendMatch(ctx, expected, client.Bodies(payload)...)
var seq *client.SequenceError
if errors.As(err, &seq) {
// Lost the race: seq.Tail.SeqNum is the current tail.
// seq.Resent == false means nothing was appended — definitive.
// seq.Resent == true means the batch was resent after a reconnect
// and an earlier attempt may have landed: read back to decide.
}The one-shot form, Client.AppendMatch, is always definitive: one call
is one wire attempt, so a miss proves nothing was appended. Over HTTP a
miss is 412 Precondition Failed with the tail in the body.
A conditional append is exactly-once across retries: if the SDK reconnects and resends it, the resend re-evaluates its match and misses rather than duplicating. A plain append is at-least-once: a resend whose original landed appends again. A conditional batch never merges with other staged appends, so the match always names its own first record.
Conflicts can be spurious (a rejection caused by records that were assigned but never acknowledged, then discarded at failover), and that is safe: optimistic control permits a false conflict, never a false success.
Pessimistic concurrency
A fencing token is an optional string of at most 36 UTF-8 bytes that a writer presents when it opens a session or makes a one-shot append. Presenting never installs anything; the only installer is the Fence verb, which installs, rotates, or clears the stream's token.
| Rule | any | fenced |
|---|---|---|
| Token-less open or one-shot | Always admitted | Refused while a token is installed |
| Open or one-shot presenting a token | Admitted iff it equals the installed token | Admitted iff it equals the installed token |
| Who installs | Fence only | Fence only |
The stream's leader serves Fence, which is strong: by the time it returns, every live session whose presented token no longer matches has been superseded; its appends fail, its close is inert. It answers the tail as the admission boundary: everything at or below it was admitted under the previous token. An equal token is an idempotent no-op; an empty token clears (and supersedes token-presenting sessions too, since their token no longer matches the empty one).
Takeover on a fenced stream is fence-then-open: pick a fresh token,
Fence it (that kicks the incumbent), then open presenting it. You never
need to read the current token.
_, err := c.Fence(ctx, addr, "gen-42")
p, err := c.OpenStream(ctx, addr, client.WithFencingToken("gen-42"))
// One-shots present through a view:
res, err := c.Presenting("gen-42").Append(ctx, addr, client.Bodies(b)...)
// Discover the installed token without provoking a mismatch:
tok, err := c.FencingToken(ctx, addr)nova fence nova://demo/events gen-42 # install or rotate
nova fence nova://demo/events # clear
printf 'x\n' | nova append nova://demo/events --fencing-token gen-42A mismatch is FailedPrecondition with a typed detail carrying the
installed token: 412 over HTTP with fencing_token in the body,
client.FenceError{Installed} (matching client.ErrFencedOut) in Go.
It is concurrency control, not authorization, and it is permanent for the
presenter: only another Fence changes what stands.
Policy, not safety
Fencing tokens are cooperative coordination between your own writers. The guarantee that a deposed leader cannot append a late write is epoch fencing, enforced in the storage layer regardless of tokens or policy; see guarantees.
Source documents
docs/design/028-fence.mddocs/adr/0039-fence-only-token-installation.mdproto/nova/v1/dataplane.protoclient/fence.go,client/producer.go