novadocs
Concepts

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.
Optimistic concurrency with a session
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.

Ruleanyfenced
Token-less open or one-shotAlways admittedRefused while a token is installed
Open or one-shot presenting a tokenAdmitted iff it equals the installed tokenAdmitted iff it equals the installed token
Who installsFence onlyFence 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.

Fence, then open presenting the new 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)
From the shell
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-42

A 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.md
  • docs/adr/0039-fence-only-token-installation.md
  • proto/nova/v1/dataplane.proto
  • client/fence.go, client/producer.go

On this page