Fan-out Engine¶
Fan-out looks like magic from outside — attach a child, copies appear — but it's a deliberately boring machine: per-partition cursors tailing the parent's committed log, with durable positions and an at-least-once commit protocol. No double-publish from producers, no broker-side subscriptions; just log readers that never lose their place.
Where the work runs¶
For each (parent partition × child), one cursor goroutine runs on the node that owns that parent partition — so slab reads are always local disk, and the cursor's durable state lives in the same directory (same durability domain) as the log it tails:
flowchart LR
subgraph owner["owner of orders/p3"]
LOG[("orders/p3 log")]
CUR1["cursor → analytics"] --> LOG
CUR2["cursor → retry (1h delay)"] --> LOG
OFF[("fanout-analytics.offset<br/>fanout-retry.offset")]
end
CUR1 -->|"commit batch"| A[("analytics/p* owners")]
CUR2 -->|"commit batch"| R[("retry/p* owners")]
A reconciler on every node diffs desired cursors (from the metastore: links × owned partitions) against running cursors once a second — cursors spawn on attach, stop on detach/delete/ownership change.
The cursor loop: commit-before-advance¶
flowchart TD
READ["read slab of committed parent records<br/>(fill-or-linger batching)"] --> REKEY["re-key each record with the<br/>child's partitioner (key preserved)"]
REKEY --> COMMIT["commit per-child-partition batches<br/>(local or one RPC to the owner)"]
COMMIT -->|all batches acked| PERSIST["persist cursor offset"]
PERSIST --> READ
COMMIT -->|any failure| RETRY["back off, re-read from<br/>unadvanced offset"] --> READ
The invariant is the whole guarantee: the cursor's durable offset only advances past records whose child commits were acknowledged (and child commits are the same fsync-and-verify as any produce). A crash mid-flight re-commits the last slab — duplicates into the child, never a gap.
Attach epochs: why re-attach never replays¶
Each attachment gets a fresh epoch ID, stamped into the cursor's offset file. Detach + re-attach must start from the parent's current tail (the client contract says "no backfill"), so a cursor that finds an offset file from a different epoch refuses to resume it. Epochs turn "is this my state?" from a guess into an equality check.
Tail-anchoring — the act of skipping to the tail and overwriting the offset file — is the engine's only destructive move, and chaos testing showed a stale metastore replica can fabricate exactly the epoch mismatch that triggers it. So an anchor now requires the Raft leader to confirm the epoch (with the barrier rule for self-leaders); anything unconfirmed defers, and the reconciler retries a second later. Cursor offset files get the same protection before deletion. The incident that forced this is told in Cluster Lifecycle.
The delay gate¶
A delay child's cursor adds one filter: only read records whose parent commit time is ≤ now − delay.
flowchart LR
subgraph parent log
r1["r₁ committed 12:00:00"] --> r2["r₂ committed 12:00:01"] --> r3["r₃ committed 12:00:02"]
end
GATE{"now − delay ≥ commit time?"} -->|"yes: fan out"| r1
GATE -->|"not yet: sleep until r₂ is due"| r2
Because commit times are monotonic per partition (assigned under the partition lock — a storage-engine property), the first not-yet-due record proves everything behind it isn't due either. So an idle delay cursor is O(1): peek the head, sleep until its due time (capped so gauges stay fresh). No timer wheels, no scan-the-backlog polling — a million pending delayed messages cost the same as one.
Delivery is therefore never early (the gate is checked against the owner's clock at read time) and usually lands within a second of due (long-poll wakeups + the linger window).
Edge behaviors¶
- Drop-behind: if a cursor falls behind the parent's retention (child down for days), aged-out offsets are skipped and counted on an explicit loss metric — bounded, alarmed loss instead of a wedged parent. The retention floor (
≥ delay + 1hfor delay children) makes this unreachable in sane configs. - Dead child-partition owner: fan-out never reroutes to a sibling child partition (unlike produce, a cursor can afford to wait — rerouting would scatter a key's records across child partitions for no availability gain); the cursor stalls on that bucket and retries until the owner returns.
- Lag observability:
fanout_lag_messages(parent HWM − cursor) is the health signal for normal children;fanout_due_lag_seconds(how far behind the due frontier) is the one for delay children — raw offset lag on a delay child is permanently ≈ rate × delay by design.
The numbers¶
| Constant | Value |
|---|---|
| Reconcile interval (desired vs running cursors) | 1s |
| Slab long-poll / retry backoff | 1s / 1s |
| Batch caps | 4,096 records / 4 MiB (fanout.max_batch_records/bytes) |
| Linger to fatten a partial batch | 25ms |
| Delay cursor max sleep (metadata freshness bound) | 30s (defaultFanoutDueWakeCap) |
| Orphan cursor-file sweep | every 30th reconcile pass |
| Max children per parent / max delay | 108 / 1 year |
| Retention floor for a delay child's parent | delay + 1h (topic.MinRetentionMs) |
| Attach epoch | 8 random bytes, hex — e.g. 67953471cc57a32a |
The cursor's durable state, in full¶
One JSON file per (parent partition, child), living next to the log it indexes:
topics/orders/p00003/fanout-analytics.offset
{"epoch":"67953471cc57a32a","next_offset":98332}
That's the entire recovery story: epoch says which attachment this position belongs to; next_offset says where to resume. Written via atomic temp+rename only after the batch below it is committed to the child (commit-before-advance). Everything else — running goroutines, batches in flight, lag gauges — is disposable.
Reading a slab: fill-or-linger¶
readBatch long-polls the parent for up to 1s, then tops up until the batch hits 4,096 records / 4 MiB or a 25ms linger expires — so a busy parent produces fat child commits (one fsync each on the child side) while a trickling parent still ships within ~25ms. For a delay child, every read carries MaxCommittedAt = now − delay; the reader stops at the first undue record and reports when it becomes due, which is what lets the cursor sleep instead of spin.