Concepts
Producers
Producer policy, sessions and one-shot appends, automatic batching, and what happens when the leader moves
There are two ways to append: open a persistent producer session
and stream batches through it, or append records one off with a single
call. This page covers both, along with how a stream admits writers
(its producer policy), how the SDK batches, and how producers follow
the stream's leader. Conditional appends with match_seq_num and
fencing tokens are on the concurrency
page.
Producer policy: Any or Fenced
A stream's producer policy is its sharing discipline for writers, set at creation from the request or the bucket's defaults (and changeable later by a reconfigure, which hands the leadership off):
any (default) | fenced | |
|---|---|---|
| Concurrent sessions | Many; their batches interleave in arrival order through one sequencer | Exactly one |
| Fencing token | Cooperative: binds only writers that present one | Mandatory while a token is installed |
| Last handle closes | Leadership stays warm for the idle timeout (30 s) so a reconnect rejoins cheaply | Leadership releases at once, so a handoff never waits |
| One-shot appends | Interleave freely with sessions | Sequential: each briefly holds the one handle; a concurrent one is refused (retryable) |
Choose fenced when your application has a single logical writer per
stream and wants nova to refuse a second one; choose any when many
uncoordinated clients append to one stream.
Sessions and one-shot appends
A producer session is a bidirectional gRPC stream (AppendSession):
an open frame naming the stream by (bucket, key) and an
optional fencing token, then batches, each answered in order by an ack or
a rejection. The session is the wire: it dies with the connection, and a
reconnect re-handshakes. Sessions exist only on gRPC; the HTTP surface has
no session verb.
A one-shot append (Append) is one unary call: one batch, one durable
result. It is what POST /v1/streams/{bucket}/{key}/records maps to. On
the star it joins the stream's leadership per call; on any streams the
leadership stays warm between calls, so a burst pays the join once.
p, err := c.OpenStream(ctx, client.Address{Bucket: "demo", Key: "events"})
res, err := p.Append(ctx, client.Bodies([]byte("a"), []byte("b"))...)
err = p.Close(ctx)
res, err = c.Append(ctx, client.Address{Bucket: "demo", Key: "events"},
client.AppendRecord{Body: []byte("c"), Headers: []client.Header{{Name: []byte("k"), Value: []byte("v")}}})Both return an AppendResult{Start, End, Tail}: Start is the first
appended record, End one past the last, Tail the committed tail as of
the ack. Hold a session for pipelined throughput; use a one-shot for
convenience or from a request handler.
The SDK batches for you
A Producer stages appends and a single send loop cuts wire batches
whenever a slot in the in-flight window frees: natural batching.
An idle append goes out immediately, so batching adds no latency; under
load, whatever accumulated while the window was full rides the next slot
as one batch. The numbers:
| Property | Value |
|---|---|
| Wire batch cap | 1 MiB |
| In-flight window | Advertised by the star at open (default 16 batches) |
| Staged buffer bound | 1 MiB; a caller blocks when that much is staged behind a full window |
WithBatchLinger(d) | Wait up to d (at most 1 s) for a fuller batch; default 0 |
WithBatchSize(n) | Lower the accumulation target from 1 MiB |
Every caller resolves with exactly its own records' range, even when its
append was merged with others. Append blocks until durable;
AppendAsync returns a channel. Close flushes what is staged and waits
for the window to drain.
nova append uses a session with a 5 ms linger by default (--linger 0
cuts immediately).
Producers follow the leader
The stream's leader, the star holding its leadership, serves appends.
Any other star answers NOT_OWNER with the holder's address,
and the SDK follows it below your call, remembering the holder for the
stream until a failure from it. Over HTTP you never see a redirect: the
data bridge proxies a foreign-led stream one hop to its holder, and the
Gateway follows redirects itself. Redirects dial the star's advertised
address verbatim, so it must resolve from wherever clients run; one that
cannot surfaces at once as client.ErrHolderUnresolvable.
When the leader moves
Leadership moves on a drain, a rebalance, a handoff, or a crash. The
session breaks; the SDK reconnects within its retry budget
(Options.RetryBudget, default 30 s), re-handshakes at the stream's new
leader, and resends the unacknowledged tail in order: plain batches as
fresh appends (at-least-once), conditional batches re-evaluated
(exactly-once). Past the budget the producer is broken: every pending and
future append resolves client.ErrUnavailable, and you open a new one.
A refusal no retry can cure (fenced out, not found, an invalid record)
surfaces immediately instead.
An append whose outcome is ambiguous (the wire died between send and
ack) resolves the same way on every plane: CheckTail, read the
suffix from the last position you know, look for your record, and resend
only if it is absent.
Source documents
docs/adr/0020-client-natural-batching.mdproto/nova/v1/dataplane.protoclient/producer.go,client/oneshot.go