Concepts
Subscribing
Ordered live subscriptions that resume exactly; reads by sequence number, time, or tail offset; CheckTail; and read plans
Any star serves reads of a stream's history, from any point in it. The tail is different: a live subscription, and any read that reaches the tail, is served by the stream's leader, which is where new records are acknowledged. A subscription follows the stream in order and resumes exactly after a reconnect. This page covers the read verbs and their knobs, how subscriptions behave across reconnects and leader moves, how to ask for the committed tail, and the read plan, the advanced verb that hands a consumer object-storage locations for settled history.
Read from a sequence number, a time, or the tail
A Read names one start position and a limit:
| Start | Meaning |
|---|---|
seq_num | The sequence number to start at |
timestamp_ms | The first record stamped at or after this time, resolved once server-side |
tail_offset | tail − offset records back from the tail, clamped at 0: "the last N records" |
and takes four knobs:
| Knob | Meaning |
|---|---|
limit | Maximum records returned; clamped to the star's cap (8192 by default) |
max_bytes | Cap on the response's payload bytes; clamped to the star's cap (8 MiB by default); 0 takes the maximum |
clamp | Pull an out-of-window start into the readable range: a start below the trim watermark reads from the earliest retained record; a start past the tail reads from the tail |
until_ms | Exclusive upper time bound: records stamped at or after it are not returned; since stamps are monotone the cut is a prefix |
Every response carries the records (empty means caught up) and the
tail as of the read. The star always returns at least one record, however
large, so a reader can never wedge on a record bigger than its byte ask.
A start below the trim watermark without clamp fails as OutOfRange
(client.ErrTrimmed; 410 Gone over HTTP).
r, err := c.Read(ctx, addr, 0, 100) // from seq 0
r, err = c.ReadFrom(ctx, addr, sinceMs, 100) // from a time
r, err = c.ReadTail(ctx, addr, 10, 10, client.WithClamp()) // the last 10
// r.Records, r.Tail; options: WithClamp, WithUntil(ms), WithMaxBytes(n)curl -s 'localhost:8080/v1/streams/demo/events/records?seq_num=0&limit=100'
curl -s 'localhost:8080/v1/streams/demo/events/records?timestamp_ms=1724500000000'
curl -s 'localhost:8080/v1/streams/demo/events/records?tail_offset=10&clamp=true'Over HTTP, format=base64 carries every body base64-encoded in body;
the default is honest raw: a valid UTF-8 body rides body, anything
else rides body_b64, decided per record (and per header field).
Which star serves a read
You may ask any star, but any star serves only history: reads of the tail always land on the stream's leader. Where a position sits decides which case you are in, and you never declare it:
- Settled history comes from object storage, read by whichever star you asked through the shared object cache.
- The tail window, records acknowledged but not yet covered by
committed metadata, is leader-only: the leader serves it from
memory while a leadership exists (a vacant stream's committed state
serves anywhere). Another star answers
NOT_OWNERand the SDK follows it below your call; over HTTP the data bridge proxies one hop and the Gateway follows the redirect, so you never see it.
A page never spans the two: a read starting below the frontier returns short at it, and the next request classifies fresh. All of this is invisible to the caller except as latency.
Subscribe: ordered live tailing
Subscribe opens a server stream that delivers batches of records in
order from a start position, seq_num or timestamp_ms, and keeps
delivering as records commit. Time is resolved once; the cursor is always
the sequence number from then on.
for records, err := range c.Subscribe(ctx, addr, 0) {
if err != nil {
return err // trimmed, not found, or the retry budget exhausted
}
for _, r := range records {
handle(r)
}
}The SDK heals the subscription transparently. A broken connection reconnects within the retry budget (default 30 s, reset by every delivery) and resumes at the last delivered sequence number + 1, never re-resolving time, so a reconnect neither skips nor repeats a record. The iterator ends quietly when you cancel the context or the server ends the stream cleanly, and with an error when the stream is trimmed under it, deleted, or the budget runs out.
On the stream's leader a subscription parks on the stream's wake and receives its records the moment a flight acknowledges, with no polling. A subscription elsewhere polls, backing off to 250 ms, so first-record latency on a stream with no leader is at most that. When the leader departs under a parked subscription, the subscription ends with a redirect and the SDK reopens wherever the stream is served next, at its cursor.
Over HTTP a subscription is SSE: GET …/records/subscribe?seq_num=N
(or timestamp_ms=). Each event's data: is one {records, tail}
batch and its id: is the next sequence number: the resume cursor.
Reconnect with Last-Event-ID set to the last id you saw and the stream
resumes exactly there; the header wins over the query's start. A comment
heartbeat arrives every 15 s to defeat idle timeouts; when the caller's
credential expires the server sends event: auth-expired and ends the
stream, and a reconnect with a fresh token resumes at Last-Event-ID.
CheckTail: the committed tail
CheckTail answers where the stream ends: seq_num is the sequence
number the next record will receive (0 on a fresh stream) and
timestamp_ms the last committed record's time (0 when none is known:
fresh, or trimmed away). The stream's leader serves it from its
acknowledgement horizon; when no leader holds the stream, any star
answers exactly from committed state, since nothing can be in flight.
tail, err := c.CheckTail(ctx, addr) // tail.SeqNum, tail.TimestampMscurl -s localhost:8080/v1/streams/demo/events/records/tail
nova check-tail nova://demo/eventsIt never creates a stream, never moves leadership, and is the resolution point for a producer that chose not to retry an ambiguous append: check the tail, read the suffix, decide.
Read plans
ReadPlan is the advanced verb for consumers that would rather fetch
settled bytes from object storage themselves than have them proxied
through the fleet. For a stream range it answers a partition of
responsibility:
- Vended entries: the settled objects covering the range's settled prefix, each with its location, footer byte window, payload size, first sequence number, and timestamp bounds; entries tile, one ending where the next begins.
vended_until: the first sequence number not vended; from there up, ask the ordinary verbs. Present even with no entries; absent only for a timestamp start above the settled ceiling.more: the plan was cut at the entry cap (256 by default); re-plan fromvended_untilto keep vending.expires_at_unix_ms: the plan's validity.
A plan is a read capability, authorized exactly as a Read on the same
target, served by any star, and it carries no tail: it says where
settled bytes are, never what is visible. Starts are seq_num or
timestamp_ms; there is no tail_offset, so resolve one with CheckTail.
Entries come in one of two vending modes, a star-level choice:
| Mode | Entry carries | For |
|---|---|---|
presigned (default) | A short-lived URL signed by the star; the consumer supplies its own Range | Any consumer; the only per-tenant-safe mode |
raw | storage_bucket and object_key; the consumer fetches under its own storage identity | Trust-domain consumers only |
--readplan-vending picks the mode and --readplan-ttl (default 5 m)
the validity, at most half the auditor's read grace, so the auditor
never reclaims a vended object under a live plan. A fetch that finds its object gone
re-plans from its cursor. The Gateway is the plan's consumer today: with
--serve-plan it serves settled reads and SSE catch-up by fetching vended
bytes and hands off to the ordinary verbs at vended_until. ReadPlan is
gRPC-only; it has no HTTP endpoint.
Caps and shedding
| Limit | Default | Behavior at the cap |
|---|---|---|
| Records per read response | 8192 (--max-read-records) | The request's limit is clamped |
| Bytes per read response | 8 MiB (--max-read-bytes) | The request's max_bytes is clamped; one record always serves |
| Subscriptions per star | 16384 | New ones shed with ResourceExhausted (429 over HTTP) |
| SSE connections per HTTP host | 4096 | New ones shed with 503 and Retry-After |
| Plan entries per response | 256 | more is set; re-plan from vended_until |
Source documents
proto/nova/v1/dataplane.protodocs/design/024-readplan.mddocs/design/029-wake-on-ack.mddocs/adr/0017-linearizable-reads.mdclient/read.go,client/subscribe.go,client/checktail.go