Skip to content

[Router] Prewarm a booting replica's cache-aware tree from a warm sibling - #39750

Closed
Kangyan-Zhou wants to merge 10 commits into
sgl-project:mainfrom
Kangyan-Zhou:router-peer-bootstrap
Closed

Kangyan-Zhou wants to merge 10 commits into
sgl-project:mainfrom
Kangyan-Zhou:router-peer-bootstrap

Conversation

@Kangyan-Zhou

@Kangyan-Zhou Kangyan-Zhou commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Superseded — do not review this branch

Rebased onto current main (329 upstream commits, including the 32-shard tree
from #39167 and the policies/kv_events → state/kv_events move) and re-split
into a 13-PR stack, heads and bases all on this repo:

#40687 · #40688 · #40689 · #40690 · #40691 · #40692 · #40693 · #40694 · #40695 · #40696 · #40697 · #40698 · #40699

Start at #40687. The split is exact — git diff between this branch's rebased
tip and the top of the stack is empty over experimental/sgl-router.

Draft. Opened as one branch so the shape is reviewable end to end; the
ten commits are cut to be split into a stacked series (sizes below) and
nothing depends on them landing together.

Why

A router replica subscribes to each worker's KV topic mid-stream. ZMQ SUB
delivers deltas from wherever the publisher has reached, so every block already
resident in the engine's radix cache is invisible to that replica — and engines
publish BlockStored only as they insert, so a prefix cached hours ago is
never re-announced. A fresh replica gets the deep blocks and none of the roots.

It is worse than one slow replica. A cache-blind router scatters prefixes the
warm replicas were keeping consolidated, so it degrades the engines' locality
for everyone. A rolling update does that to every replica in turn.

The effect is not subtle. After a router rollout the average prefix overlap per
routing decision falls to near zero and the engines' prefix-cache hit rate
follows it down, while an oracle replay of the same teed traffic shows the
workload's reuse is exactly where it was. The routers simply cannot find it.

What

A booting replica pulls a tree snapshot from a warm sibling over HTTP and
grafts it beneath its live delta stream, holding /readyz until the sweep
settles. Off unless --kv-peer-selector is set.

commit total code tests
1 tree snapshot surface — export_snapshot / restore_snapshot 805 354 451
2 GET /internal/kv_snapshot producer + cache + gzip + cursors_only 904 592 312
3 --kv-peer-selector, the peer registry, _peers metrics 463 234 229
4 EndpointSlice peer watch + self-exclusion 950 337 613
5 VettedSnapshot, BootstrapTracker, the fetch client 2224 1511 713
6 the consumer: coordinator, sweep, hold-back, graft 4520 1955 2565
7 the splice probe — ask the fleet when nothing witnessed the graft 673 381 292
8 component test: a bootstrapped replica answers like its source 649 0 649
9 k8s e2e + KV-publishing fake worker + manifest 924 0 924
10 /readyz gate, seed-required gate, bootstrap metrics 792 344 448

Commit 6 is the one unit that does not divide further: producer and consumer are
joined by the pump's PumpControl channel, so any cut between the sweep and the
graft would have to ship an intermediate state that is not in the design.

Ordering is the design

The subscriber starts first, before any fetch, so no delta falls into the
gap between the peer's export and our first received batch. The peer's cursor
then does two jobs: as a FILTER it drops the held batches the snapshot already
reflects, and as a WATERMARK it proves the stream resumes at cursor + 1.

A hole there means a delta was lost, and a lost BlockRemoved is a permanent
false cache hit
— silent, where a cold rank is merely slow and visible in the
metrics. So every edge resolves to Failed for the affected rank: unreachable
peer, an older image's 404, a peer still cold, a block-size mismatch, an
unknown worker, a sequence gap. Run cold, never run wrong.

Two paths deliberately break that symmetry, both bounded. Hold-back overflow
discards the queued prefix (replaying a discontiguous one is worse than
resuming live). And a graft that no batch and no peer can speak to is KEPT,
tallied warm_unwitnessed rather than warm — discarding on a timer would
cost every quiet fleet its warm tree, which is the regression this prevents.
Silence is not evidence, so an unproven rank asks the fleet: any peer's
cursor above the watermark proves a batch was published that we never saw.

One sweep, not one per worker

A snapshot is fleet-wide, so sweeping from add_worker re-downloads the same
document once per engine — on a large fleet that is one fetch of a
multi-megabyte body per engine per booting replica, most of them timing out. A
coordinator takes whatever is queued, awaits ONE sweep, and merges everything
that arrived while it ran. The fetch IS the batching window, so it costs no
latency, and it is safe because the invariant is a sequence condition
adjudicated per rank.

Three fetch bounds, not one

reqwest's timeout is a TOTAL request bound, so one value had to serve two
opposite jobs. Sized to cut a peer that has gone away, it also cuts a large
snapshot that is transferring perfectly well: every fetch times out, every peer
is booked unreachable, and the sweep abandons every rank — more likely the
warmer, i.e. the more useful, the tree becomes
. Raising the deadline only buys
more timeouts, because the per-fetch value is min(deadline / 4, cap). Now
connect_timeout cuts a peer that is gone, read_timeout cuts one that stopped
sending mid-body, and the total bounds only a transfer that is progressing.

Readiness

/readyz holds 503 until bootstrap settles, bounded by
--kv-bootstrap-timeout-ms and latching so a later scale-up cannot un-ready a
serving replica. --kv-bootstrap-seed-required (off by default) additionally
refuses readiness when a sweep proved siblings were present and their tree
could not be pulled, so a failed seed stalls a rolling update instead of
completing it with cache-blind replicas.

Which verdict may gate is the whole safety argument: NoPeers (first deploy)
and FleetCold (every sibling proved it holds nothing) must never gate —
an unready replica leaves its own EndpointSlice, so gating there deadlocks the
fleet with no automatic exit. Only TimedOut over a non-empty candidate set
counts, the hold is bounded at 3× the budget, and it latches open on the first
ready 200.

Configuration

--kv-peer-selector <label selector>        # enables it; unset = today's behaviour
--kv-bootstrap-timeout-ms <ms>             # default 5000, max 600000
--kv-bootstrap-fetch-timeout-cap-ms <ms>   # default 120000
--kv-bootstrap-seed-required               # off by default

Needs get/list/watch on EndpointSlices for the router's own Service, and
POD_NAME/POD_NAMESPACE/POD_IP from the downward API for self-exclusion
(see tests/e2e/k8s_integration/manifests/kv-bootstrap.yaml).

New series: sgl_router_kv_tree_nodes, ..._bootstrap_settled,
..._bootstrap_seed_failed, ..._bootstrap_state, ..._peer_snapshot_total,
..._bootstrap_rank_total, ..._bootstrap_sweep_total,
..._bootstrap_peers{,_synced}.

Tests

cargo test: 828 lib + 4 + 74 component + 101 proxy. Every commit passes on
its own
— cargo fmt --check, cargo clippy --all-targets -- -D warnings and
the lib suite were run at each of the ten commits, not only on the tip. New k8s
e2e (test_kv_peer_bootstrap.py + a KV-publishing fake worker) asserts a booting
replica reaches the same match_prefix answers as the warm replica it copied
from.

Not yet run: the k8s e2e needs a kind cluster; it is verified by
construction only.

Review note

A review of the finished branch found 8 defects, all fixed in the commit that
introduced them. The one worth naming: settled() latched on a deadline armed
at construction, and /metrics or any peer probe reads it — so on a fleet
with slow worker discovery the readiness gate died before the first rank
registered and the feature was silently a no-op. Reproduced with a failing test
before fixing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb


CI States

Latest PR Test (Base): ❌ Missing run-ci label -- add it to run CI tests.
Latest PR Test (Extra): ❌ Blocked -- run-ci is required first.
Latest PR Test (AMD ROCm 10): ➖ No AMD PR run found for this commit.

A router replica subscribes to each worker's KV topic mid-stream, so every
block already resident in the engine's radix cache is invisible to it. The
recovery paths for that — pulling tree state from a warm sibling, or later
from the worker itself — all need the same thing first: a way to get the
tree out and back in without losing what it knows.

Add that surface and nothing else. `export_snapshot` emits a flat,
parent-linked node list in DFS pre-order; `restore_snapshot` grafts one back,
creating nodes that are absent and unioning carriers into nodes that are
present, so restoring onto a live tree is well defined.

Records are parent-linked by INDEX, not by block hash. The same hash
legitimately occupies several tree positions, so a hash-keyed replay would
land in `resolve_parent`'s ambiguous branch and could graft a chain under the
wrong node. Full root-to-node hash paths would be unambiguous too, and
quadratic in depth against chains that run thousands of blocks deep.

Tiers ride along per carrier, parallel to `workers` by index, and
`SnapshotNode::retain_carriers` is the only correct way to filter that pair —
filtering `workers` alone silently re-pairs every later carrier with another
carrier's tiers. An absent `tiers` list reads as device, which is what a
carrier list meant before tiering existed, so the format needs no version bump
in either direction. A tier entry naming only bits this build does not rank is
DROPPED rather than folded onto device: folding would make a future tier's
carrier a preferred device owner here, which is the trade `Tiers::for_store`
already refuses to make for an unknown `medium`.

A snapshot arrives over the network, so shape is validated before any
mutation: backward parent references, in-range worker indices, and a tier list
that either pairs one-to-one with its carriers or is absent. `restore_snapshot`
stays `pub(super)` — the ids in a worker table must be resolved against the
live worker set, never deserialized straight off the wire.

Both passes release the tree lock every 4096 records. The tree is one lock, so
an unchunked walk of a fleet-sized tree would stall the event pump — and with
it every publisher cursor — for the walk's whole duration. The result is a
point-in-time aggregate rather than one consistent instant, which is what a
consumer grafting beneath a live delta stream already tolerates. Restore is
safe across the seam because it runs on the pump, the only writer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Sep 16, 2026
kzhou-radixark and others added 9 commits September 16, 2026 10:52
Nothing consumes this yet. It is the producer half of peer bootstrap: a
replica that already holds a warm tree makes it fetchable, so a sibling
starting later can graft it instead of routing cache-blind until traffic
re-stores every block the engines already hold.

`GET /internal/kv_snapshot` answers a `PeerSnapshot` — worker table, per-rank
cursors, and the tree. The cursors are half the payload's purpose: a graft is
only sound if the consumer can prove its own live stream resumes at
`cursor + 1`, so the body has to say where the producer's stream stopped.

`404` when this router maintains no local tree (an external Indexer is the
routing signal, or cache-aware routing is off). A consumer reads that exactly
as it reads an unreachable peer, which is also what an older router image
answers for an unknown path, so a mixed-version fleet degrades to cold boots
rather than errors.

Three things the endpoint refuses to claim. `producer_ready` is false unless
the tree is non-empty AND the hashing config is fully established: an empty
tree would let two replicas in a rolling update bootstrap off each other and
both inherit nothing, and a half-published config — `block_size` visible
before the bigram flag, a real window in `add_worker` — would stamp unigram
hashing on a bigram fleet and hand the recipient blocks it can never match.
`BlockSizeOracle::hash_config` is the both-or-neither read that closes it.

`?max_age_ms=N` lets the CONSUMER state its freshness requirement, because
only the consumer knows when its ranks began holding. Staleness is a
correctness input here, not a preference: an export older than the consumer's
subscribe leaves the events in between in neither place, and the graft has to
be thrown away. The build is single-flighted behind an async mutex and the
encoded body is cached, so a boot herd shares one tree walk; the entry is
stamped BEFORE the cursors are read, so it never claims to cover more of the
publisher's stream than it does.

`?cursors_only=true` answers the cursor table alone, read live with no cache.
A splice probe asks one question — is your cursor for this rank above my
watermark? — and paying a full tree export for it costs a walk, a
multi-megabyte serialise and a compress, per peer per probe. That table is
deliberately a SUPERSET of the export's, which is filtered to ranks still
carrying nodes: a graft recipient needs a block source, but a witness only
needs to have seen the publisher's stream. Its `nodes` is always empty, which
makes the body ungraftable by construction rather than by discipline.

The route carries its own gzip layer rather than the app carrying one. It is
the only response here big enough to be worth compressing, and route-scoping
means the proxy path pays no per-response predicate check and does not depend
on tower-http continuing to exempt `text/event-stream`. Quality is `Fastest`:
the body is highly repetitive, so level 1 already shrinks it several-fold, and
this runs on a replica serving traffic during a boot herd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
The config surface for peer bootstrap, and the registry a discovered peer set
lands in. Nothing watches EndpointSlices yet and nothing fetches a snapshot, so
an operator who sets the selector gets a validated config, a warning-free boot
and `sgl_router_kv_bootstrap_peers 0` — no behaviour changes here.

`PeerRegistry` exists because an empty peer set has two meanings and conflating
them is the failure mode. "The watch has not delivered yet" is routine — worker
discovery regularly wins that race — and treating it as "I have no siblings"
makes a joining replica give up before its peers are even known. It tracks
`synced` for that, and `ever_had_peers` for the other direction: an
EndpointSlice repack deletes the last slice before its replacement arrives, and
during a rolling update every sibling can be notReady at once, so a dip to
empty after siblings were seen is transient. Candidates come back shuffled so
simultaneous boots do not all stampede whichever peer sorts first.

The selector is rejected unless the router maintains its own radix tree and
finds workers through Kubernetes: with an external Indexer there is nothing to
graft into, and without EndpointSlices there is no way to find a sibling. Both
would otherwise be accepted and silently ignored, leaving cold boots with no
explanation. `--kv-bootstrap-timeout-ms` is bounded at 600s because
`Instant::now() + Duration::from_millis(n)` panics on overflow — an unbounded
value would abort the process at startup instead of failing to parse.

`sgl_router_kv_bootstrap_peers` plus its `_synced` companion make both readings
of a zero visible. An RBAC gap on the router's own Service — the likeliest
misconfiguration, since the existing worker Role already covers it
namespace-wide — shows up as `synced 0` that never becomes 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
The other half of peer discovery: a second EndpointSlice watch, scoped by
`--kv-peer-selector`, keeps `PeerRegistry` current. Still nothing consumes a
snapshot — the consumer lands next — but with this an operator can set the
selector and confirm discovery works before any behaviour depends on it.

A replica must not offer itself as a bootstrap source, and POD_IP alone cannot
do that. It carries only the pod's PRIMARY address, so on a dual-stack Service
the pod's secondary-family entry survives an IP filter — which then latches
"siblings exist" and stops a genuinely lone replica from ever concluding it is
alone. POD_NAME matched against each endpoint's `targetRef` excludes the whole
pod; POD_IP stays as the fallback for endpoints without one. With neither set
the replica stays in its own peer list, which is wasteful rather than wrong,
and says so at WARN.

The watch buffers a relist and publishes only on `InitDone`. Publishing each
`InitApply` would expose a partially rebuilt set, and an empty partial marks
the registry synced — the exact state that turns a bootstrap into a cold boot.
The regression test asserts mid-relist, because checking only the settled state
passes with the bug present.

It starts before worker discovery, because a bootstrap consults the peer set the
moment the first worker appears: an empty set there means that worker's ranks
skip bootstrap and run cold. Failure to start is non-fatal — routing does not
depend on peer discovery, and losing it means replicas boot cold, which is the
pre-existing behaviour.

Deployments that want self-exclusion need POD_NAME/POD_NAMESPACE/POD_IP from the
downward API; the manifest that wires them ships with the e2e test in the
consumer change, where there is something for it to exercise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
The consumer's half of the wire contract, with no consumer yet: the type that
turns a peer's `PeerSnapshot` into something graftable, the per-rank state that
tracks a bootstrap, and the client that fetches a body. The pump that uses them
lands next.

The trust boundary is enforced by types rather than by discipline. A snapshot
arrives over the network as `PeerSnapshot`, carrying `WireWorker` rather than
`KvWorkerId`; `VettedSnapshot::from_wire` is the only bridge, and it resolves
every wire identity against the locally discovered worker set, so a peer cannot
introduce a `KvWorkerId` the local registry does not already know — which would
otherwise let routing resolve to a non-registered endpoint. `VettedSnapshot`'s
fields are private and `graft_into` is the only route to the tree's restore path
from outside the module, so format, block-size and parent-bound checks cannot be
skipped by a caller that assembles nodes itself. That is also what retires
`restore_snapshot`'s `#[allow(dead_code)]`.

Vetting rejects rather than repairs: an unknown format, a producer that is
itself cold or not ready, an out-of-range or forward parent reference, tiers
that do not pair with workers, a block-size mismatch, or a snapshot that prunes
to nothing. A rank whose snapshot is rejected runs cold, which is slow and
visible; a snapshot grafted on a hope can hide a missed `BlockRemoved` as a
permanent false cache hit, which is silent.

`BootstrapTracker` holds per-rank state and the outcome tallies. Its epoch is
per rank, not process-wide: a process-wide counter would let one worker's re-add
invalidate another worker's in-flight task. `RankOutcome` and `SnapshotOutcome`
are deliberately separate tallies — one counts ranks that finished bootstrapping,
the other counts peers that were asked — because a single peer answers for many
ranks and summing them together would make neither readable.

Three bounds on a fetch, not one. `reqwest`'s `timeout` is a TOTAL request bound,
so a single value has to serve two opposite jobs: cutting a peer that is gone,
and allowing a large body to finish. Sized for the first, it rejects every peer
whose snapshot is big enough to be worth having — the failure gets MORE likely
the warmer, i.e. the more useful, the fleet's tree becomes. So `connect_timeout`
cuts a peer that is gone, `read_timeout` cuts one that stopped sending mid-body,
and the total bounds only a transfer that is progressing.

`flate2` moves from dev-dependencies to dependencies for the inflate path, used
directly rather than through reqwest's `gzip` feature: that feature is crate-wide,
so enabling it would make every client in the process advertise gzip and
auto-decode — including the proxy client carrying SSE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
…tream

A booting replica now holds each rank's batches while it pulls a snapshot from
a warm sibling, grafts that snapshot underneath them, and releases the queue —
so it starts with the fleet's prefixes instead of relearning them from traffic.

Ordering is the design. The subscriber starts FIRST, before any fetch, so no
delta can fall into the gap between the peer's export and our first received
batch. The peer's cursor then does two jobs: as a FILTER it drops the held
batches the snapshot already reflects, and as a WATERMARK it proves the stream
resumes at `cursor + 1`. A hole there means a delta was lost, and a lost
`BlockRemoved` is a permanent false cache hit — silent, where a cold rank is
merely slow and visible in the metrics. So every edge resolves to `Failed` for
the affected rank: unreachable peer, an older image's 404, a peer still cold, a
block-size mismatch, an unknown worker, a sequence gap. Run cold, never wrong.

Hold-back overflow deliberately breaks that symmetry: it discards the queued
prefix, because replaying a discontiguous one is worse than resuming live.

One sweep at a time, not one per worker. A snapshot is fleet-wide, so sweeping
from `add_worker` re-downloads the same document once per engine — on a fleet of
any size that is one fetch of a multi-megabyte body per engine per booting
replica, most of them timing out. A coordinator takes whatever is queued, awaits
ONE sweep, and merges everything that arrived while it ran. The fetch IS the
batching window, so this costs no latency, and it is safe because the invariant
is a sequence condition adjudicated per rank — a rank whose publisher advanced
in between is discarded to `gap`, never spliced over a hole. Freshness travels
with the batch: the strictest `holding_since` in it becomes the `max_age_ms` the
fetch asks for, so a peer serving from cache rebuilds rather than handing back
an export older than some rank in the batch.

A gap is the costliest failure — a snapshot fetched, grafted, then thrown away
because the live stream did not join its watermark — so a gapped rank is handed
back for one more sweep, capped at one retry, stamped NOW and refused a
ride-along on a sweep whose request predates it. Without both stamps the retry
is adjudicated against a snapshot taken before the gap and re-gaps by
construction.

Grafts run on the pump, like every other tree write. `remove_worker`'s teardown
moves there too: `apply_snapshot` samples its gates once and then does O(nodes)
of work, so off-pump teardown could let a graft finish after `clear_worker` and
re-insert carriers nothing would ever reclaim. Three gates reject a stale
graft — wrong incarnation, no longer live, no longer pending — and the epoch is
per rank, because a process-wide counter would let one worker's re-add
invalidate another's in-flight task.

When the held queue is empty at graft time there is nothing to check the
watermark against, so the check is deferred to whichever batch arrives first.
A rank that then goes quiet leaves the question open; the follow-up commit asks
the fleet instead of guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
A graft whose held queue was empty leaves its watermark unchecked until a live
batch arrives, and nothing guarantees one ever does. An idle rank would serve
grafted state forever with its continuity unproven, and a `BlockRemoved` lost in
the subscribe window would survive as a permanent false cache hit that no later
event corrects.

Silence is not evidence, so the bounded wait ends in a QUESTION, not a verdict.
Reaching the deferred path means nothing arrived between subscribing and
grafting, and the subscriber is live before the fetch — so silence is far more
often "this rank published nothing" than "we lost a delta". Discarding on a timer
would cost every quiet fleet its warm tree, which is the regression this feature
exists to prevent.

So the pump asks the fleet whether the publisher moved past the watermark. Any
peer's cursor is admissible: sequence numbers are the publisher's, so a peer
reporting one above ours proves a batch was emitted that we never saw. A peer too
cold to bootstrap from is still a valid witness, which is why the probe reads the
wire cursor directly instead of vetting. It asks for the cursor table alone, not
a snapshot — the question is answered completely by one integer per rank, and
fetching a tree to read it made the proof cost scale with the tree, so a fleet
large enough to need bootstrap was also the fleet that could not afford to prove
it.

Only a witness ABOVE the watermark discards. A graft no batch and no peer can
speak to is KEPT, tallied `warm_unwitnessed` rather than `warm`, after
`MAX_UNKNOWN_PROBES` unanswerable rounds — without a stop, a single-replica
deployment would re-probe forever and its verdict would never resolve in the
metrics.

The probe runs off-pump because it does network I/O; the verdict comes back over
the pump's own control channel so the tree write stays on the single writer. An
outstanding probe blocks relaunch inside its timeout and stops blocking past it:
a verdict that never lands (a panicked probe task, say) would otherwise freeze
the rank in `Recovered` with no outcome ever recorded.

Also re-attaches `demote_unproven_rank`'s doc comment, which sat above
`requeue_gapped_rank` describing the wrong function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
…t copied

Component-level proof over the real transport: an axum server serving
`/internal/kv_snapshot`, a reqwest client fetching it, JSON over a loopback
socket. The interesting failure modes live in the wire format and the vetting
step, not in the tree algebra — that is already covered by the unit tests on
`export_snapshot` / `restore_snapshot`.

The equivalence assertion is deliberately behavioural: for a large query set,
`match_prefix` must return the same matched length and the same carrier set on
both replicas. That is the property routing actually depends on; comparing node
counts alone would pass while routing diverged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
A kind-cluster proof of the two questions the feature exists to answer: a
replica joining a warm fleet ends up with the same cache-aware view as the
replicas it bootstrapped from, and it keeps up with new engine events afterwards
— the snapshot is spliced UNDER the live stream, not substituted for it. Plus
the rolling-update hazard: new replicas must not bootstrap from each other and
inherit an empty tree.

Ships the KV-publishing fake worker the test drives, and the manifest that wires
`POD_NAME`/`POD_NAMESPACE`/`POD_IP` from the downward API and the EndpointSlice
RBAC the peer watch needs — deferred from the discovery commit to here, where
there is something for it to exercise.

Three anti-flake decisions, because the naive version of this test is flaky:
events are driven by a POST rather than a timer, so nothing is ever in flight
the test did not ask for; views are compared only after every replica's cursor
has provably caught up to the worker's `last_seq`, never on a fixed sleep; and
nothing asserts on a transient mid-rollout state — the scale-up case is asserted
directly and the rollout case only after `rollout status` completes. Views are
compared canonically, since snapshot node order follows per-shard hash-map
iteration.

Not yet run: this needs a kind cluster, so it is verified by construction only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
…vable

A replica that boots with an empty KV tree does not just route cache-blind
itself — its dispatches scatter the prefixes the warm replicas were keeping
consolidated, so it degrades the engines' locality for the whole fleet. A
rolling update does that to every replica in turn, and with nothing in the
metrics to attribute the hit-rate drop to.

`/readyz` grows two conditions. The third holds 503 until peer bootstrap has
settled: a snapshot grafted, or every sibling having proved it has nothing, or
`--kv-bootstrap-timeout-ms` giving up. It latches, so a later scale-up can
never drag an already-serving replica back out of the Service. It is inert
unless both cache-aware routing and a peer selector are configured, so nothing
changes for a fleet that has not opted in.

The fourth is `--kv-bootstrap-seed-required`, off by default, and it exists
because condition 3 settles either way once the deadline expires — that escape
is load-bearing, and it is also why `settled()` cannot tell "seeded" from "gave
up". This one can: it refuses readiness when a sweep proved siblings were there
and their tree could not be pulled. A NotReady pod leaves the Service, so a
failed seed then STALLS a rolling update with the previous generation still
serving, instead of completing it with cache-blind replicas.

Which verdict may gate is the entire safety argument. `NoPeers` (first deploy,
single replica) and `FleetCold` (every sibling PROVED it holds nothing) are
legitimate nothing-to-inherit outcomes; gating on either bricks a boot that has
nothing to inherit — and because an unready replica leaves its own
EndpointSlice, every sibling would then see an empty peer set: a fleet-wide
deadlock with no automatic exit. Only `TimedOut` over a non-empty candidate set
counts. Two further rails cover the case the verdict genuinely cannot
distinguish, a simultaneous fleet-wide restart where every peer is booting: the
hold is bounded at three times the bootstrap budget and then opens with a WARN,
and it latches open the first time `/readyz` answers 200.

The tests assert over the verdict class rather than by example, because the
wrong answer in either direction is silent — gate too little and a bad rollout
ships, gate too much and the fleet deadlocks.

The metrics make the whole path attributable. `bootstrap_state` per rank, so a
rank stuck pending (holding its events back, heading for overflow) is visible;
`peer_snapshot_total` by fetch outcome, where a fleet pinned at `unreachable`
with a warm tree means the per-fetch bound is too small for the body rather
than a network fault; `bootstrap_rank_total` once per rank at its final
verdict; `bootstrap_sweep_total` per sweep, which is what separates a healthy
early settle on a cold fleet from burning the whole deadline — the rank
outcomes fold both into `abandoned`. `seed_failed` is emitted whether or not
the gate is armed: with it off, that gauge is the only thing that says the seed
did not land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZorgAox1CpLNHSjzpcxdb
@ShangmingCai

Copy link
Copy Markdown
Collaborator

Closed as all split PRs have been fixed and merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants