novadocs
Concepts

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:

StartMeaning
seq_numThe sequence number to start at
timestamp_msThe first record stamped at or after this time, resolved once server-side
tail_offsettail − offset records back from the tail, clamped at 0: "the last N records"

and takes four knobs:

KnobMeaning
limitMaximum records returned; clamped to the star's cap (8192 by default)
max_bytesCap on the response's payload bytes; clamped to the star's cap (8 MiB by default); 0 takes the maximum
clampPull 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_msExclusive 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).

The three starts in Go
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)
The same over HTTP
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_OWNER and 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.

Subscribe from the start
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.TimestampMs
curl -s localhost:8080/v1/streams/demo/events/records/tail
nova check-tail nova://demo/events

It 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 from vended_until to 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:

ModeEntry carriesFor
presigned (default)A short-lived URL signed by the star; the consumer supplies its own RangeAny consumer; the only per-tenant-safe mode
rawstorage_bucket and object_key; the consumer fetches under its own storage identityTrust-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

LimitDefaultBehavior at the cap
Records per read response8192 (--max-read-records)The request's limit is clamped
Bytes per read response8 MiB (--max-read-bytes)The request's max_bytes is clamped; one record always serves
Subscriptions per star16384New ones shed with ResourceExhausted (429 over HTTP)
SSE connections per HTTP host4096New ones shed with 503 and Retry-After
Plan entries per response256more is set; re-plan from vended_until

Source documents

  • proto/nova/v1/dataplane.proto
  • docs/design/024-readplan.md
  • docs/design/029-wake-on-ack.md
  • docs/adr/0017-linearizable-reads.md
  • client/read.go, client/subscribe.go, client/checktail.go

On this page