Skip to content

feat(epp): recover embedded KV index from peers before readiness - #13451

Open
panpan0000 wants to merge 18 commits into
ai-dynamo:mainfrom
panpan0000:codex/issue-13403
Open

feat(epp): recover embedded KV index from peers before readiness#13451
panpan0000 wants to merge 18 commits into
ai-dynamo:mainfrom
panpan0000:codex/issue-13403

Conversation

@panpan0000

@panpan0000 panpan0000 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add an internal selection-http /dump endpoint backed by the embedded selection service KV-index snapshot.
  • Recover the initial KV index from eligible peers before starting worker topology/listener handling; bootstrap immediately when no peer exists.
  • Retry failed recovery with bounded backoff, cancel stale in-flight attempts on peer changes, prefer ready peers, and keep ZMQ membership separate from recovery candidates.
  • Resolve the replica-sync and selection-http named ports together, handle IPv4/IPv6 and reject unsupported FQDN EndpointSlice addresses.
  • Guard replicated prebuilt services with existing workers, and update the onramp manifest and documentation for the rollout requirements.

The recovery gate intentionally does not claim an atomic snapshot/live-event handoff; that remains a follow-up design boundary.

Closes #13403

Validation

  • cargo fmt -p dynamo-ext-proc -- --check
  • cargo build -p dynamo-ext-proc
  • cargo clippy -p dynamo-ext-proc --no-deps --all-targets -- -D warnings
  • Focused tests: peer discovery (23), peer HTTP (4), selector (18), EPP router (4)
  • Onramp YAML parse and git diff --check passed

============

Kubernetes Validation Summary

Tested on a real Kubernetes cluster (OrbStack, k8s v1.34, GAIE CRDs v1.2.1 + agentgateway v1.0.0 gateway) ,
using a GPU-less mock vLLM worker that publishes synthetic ZMQ KV events: https://github.com/panpan0000/simple-kv-event-mocker :-)

  • Full standalone EPP startup — discovers the InferencePool, registers workers, subscribes to ZMQ KV events, exposes the KV index at GET /dump, becomes Ready.
  • Peer KV-index recovery works — a new replica fetches the KV index from a serving peer over /dump before Ready; transferred up to 339 events in one recovery; both replicas converge.
  • No cold-start deadlock — empty replicas bootstrap instead of waiting on each other; recovery targets only serving peers.
  • End-to-end inference — request through the GAIE gateway → EPP → worker returns HTTP 200.
  • Scale-aware recovery — configurable fetch timeout + max snapshot size.

Step 1: cold start with 2 EPP and 2 mocker to generate kv-events

peterpan@PeterMacBook-8 dynamo % kubectl -n epp-sim get po -w -o wide
NAME                          READY   STATUS    RESTARTS        AGE     IP                NODE       NOMINATED NODE   READINESS GATES
dynamo-epp-647fc9cd9b-p9r44   1/1     Running   1 (5m34s ago)   5m35s   192.168.194.4     orbstack   <none>           <none>
dynamo-epp-647fc9cd9b-tp4qk   1/1     Running   1 (5m34s ago)   5m35s   192.168.194.3     orbstack   <none>           <none>
vllm-qwen-5544647f99-7hghf    1/1     Running   0               17m     192.168.194.125   orbstack   <none>           <none>
vllm-qwen-5544647f99-ndm74    1/1     Running   0               17m     192.168.194.126   orbstack   <none>           <none>

Step2 : delete one replica

peterpan@PeterMacBook-8 dynamo % kubectl -n epp-sim delete po dynamo-epp-647fc9cd9b-tp4qk

Step3 : wait new replica

peterpan@PeterMacBook-8 dynamo % kubectl -n epp-sim get po -w -o wide
NAME                          READY   STATUS    RESTARTS      AGE   IP                NODE       NOMINATED NODE   READINESS GATES
dynamo-epp-647fc9cd9b-4b52b   1/1     Running   0             10m   192.168.194.5     orbstack   <none>           <none>
dynamo-epp-647fc9cd9b-p9r44   1/1     Running   1 (16m ago)   16m   192.168.194.4     orbstack   <none>           <none>

Step 4: validate kv-indexer syncing 🔥

the most important :

applied dump events from peer total_events=339
peterpan@PeterMacBook-8 dynamo % kubectl -n epp-sim logs dynamo-epp-647fc9cd9b-4b52b | grep -iE 'fetching dump|applied dump|recovery from peer|bootstrap'

... INFO dynamo_kv_router::services::indexer::recovery: fetching dump from peer url=http://192.168.194.3:9093/dump
... INFO dynamo_kv_router::services::indexer::recovery: applied dump events from peer total_events=339
... INFO dynamo_kv_router::services::indexer::recovery: recovery from peer succeeded peer=http://192.168.194.3:9093
... INFO dynamo_ext_proc::peer_discovery: EPP peer discovery and KV-index bootstrap complete

Step 5: check amount of events

curl -s 192.168.194.5:9093/dump | jq 'to_entries | map({model: .key, events: (.value.events | length)})'; 
curl -s 192.168.194.4:9093/dump | jq 'to_entries | map({model: .key, events: (.value.events | length)})'

[
  {
    "model": "Qwen/Qwen3-0.6B:default",
    "events": 1104
  }
]
[
  {
    "model": "Qwen/Qwen3-0.6B:default",
    "events": 1105
  }
]

and two new enhancement when doing the development
#13537
#13534

Summary by CodeRabbit

  • New Features
    • Added automatic KV-index recovery for replicated EPP instances during startup.
    • Added peer snapshot serving through the selection-http endpoint.
    • Recovery now retries with backoff, supports cancellation, and recognizes serving or ready replicas.
  • Improvements
    • Enabled zero-downtime rolling updates for EPP deployments.
    • Added configurable recovery timeouts and snapshot-size limits.
    • Improved validation for peer ports, addresses, and recovery state.
  • Documentation
    • Documented recovery behavior, configuration options, rolling-update requirements, and mixed-version limitations.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@copy-pr-bot

copy-pr-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@panpan0000
panpan0000 temporarily deployed to external_collaborator August 18, 2026 09:41 — with GitHub Actions Inactive
@panpan0000
panpan0000 temporarily deployed to external_collaborator August 18, 2026 09:41 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot added external-contribution Pull request is from an external contributor feat documentation Improvements or additions to documentation labels Aug 18, 2026
Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 temporarily deployed to external_collaborator August 18, 2026 09:52 — with GitHub Actions Inactive
Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 temporarily deployed to external_collaborator August 18, 2026 10:51 — with GitHub Actions Inactive
Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 temporarily deployed to external_collaborator August 18, 2026 10:55 — with GitHub Actions Inactive
@panpan0000

Copy link
Copy Markdown
Contributor Author

Addressed the follow-up review points:

  • Added operational guidance for prolonged NOT_SERVING and clarified that recovery intentionally retries without an attempt limit (fail-closed rather than serving with an empty/stale index).
  • Documented the named-port consistency assumption and mixed-version rollout boundary.
  • Kept the generated OpenAPI schema refresh because Recipe Check requires it; the removed PodSnapshot definitions are already absent from the source schema.
  • StartupCancellation was rechecked and requires no change.

Latest commit: e48f14f.

@datadog-official

Copy link
Copy Markdown

Pipelines

⚠️ Warnings

🚦 1 Pipeline job failed

Pre Merge | pre-merge-status-check

View in Datadog · View in GitHub Actions

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: e48f14f | Docs | View more details | Give us feedback!

A replicated cold start or idle rollout deadlocked: every replica treated
its not-ready sibling as a recovery candidate and rejected the sibling's
empty /dump via require_events(0), retrying with bounded backoff forever,
so no replica ever became Ready.

Restrict recovery candidates to already-serving peers (not-ready siblings
cannot hold a KV index: worker KV listeners start only after recovery
completes), and treat an empty dump from a serving peer as a valid no-op
recovery.

Fixes a serious technical problem: multi-replica EPP deployments can now
boot in cold-start and idle-rollout scenarios instead of hanging before
readiness.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 19, 2026 09:54 — with GitHub Actions Active
The peer /dump snapshot can be tens of MB on a busy deployment, but recovery
used a hard-coded 10s HTTP timeout and read the full body into memory with no
size guard: a large index either tripped the timeout (and then the retry loop)
or buffered unbounded memory on both sides.

- Timeout is now configurable via DYN_EPP_RECOVERY_HTTP_TIMEOUT_MS and the
  default is raised from 10s to 30s to fit large snapshots.
- A Content-Length pre-check (DYN_EPP_RECOVERY_MAX_DUMP_BYTES, default 512 MiB)
  fails fast with a clear error before the body is read.
- Documented both knobs in the onramp env reference.

Full snapshot-over-HTTP remains the design; pagination/streaming and
incremental (seq-based) recovery are follow-ups.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 19, 2026 10:16 — with GitHub Actions Active
@panpan0000

Copy link
Copy Markdown
Contributor Author

Scale review of the peer KV-index recovery path

Follow-up from a local scale review of the /dump-based recovery design. Short-term hardening is included in this PR (commit a6088c8); mid/long-term items below are proposed follow-ups.

Short-term (implemented in this PR)

Recovery previously used a hard-coded 10s HTTP timeout and buffered the full snapshot in memory with no size guard — a large index either tripped the timeout (then the retry loop) or buffered unbounded memory on both sides. Now:

  • DYN_EPP_RECOVERY_HTTP_TIMEOUT_MS — timeout for one /dump fetch (request + body), default raised 10s → 30s.
  • DYN_EPP_RECOVERY_MAX_DUMP_BYTES — Content-Length pre-check (default 512 MiB) that fails fast with a clear error before the body is read.
  • Both documented in the onramp env reference.

Scale facts

/dump is a full KV-index snapshot (~100–150 B/block as JSON). Estimates:

Scenario Live blocks Snapshot size
Small cluster ~3K ~400 KB
8 workers × 50 concurrent × 500 blocks ~200K ~25 MB
64 workers × 100 concurrent × 500 blocks ~3.2M ~400 MB

Bounded by per-worker physical KV capacity (DP=1, GPU memory), so not unbounded — but "join under high concurrency" can legitimately reach hundreds of MB.

Design notes:

  • Full-snapshot-over-REST is not new to this PR: the standalone selection service has served /dump since feat(kv-router): add standalone selector peer sync #10745, and recover_from_peers predates this change. This PR adds the EPP-side selection-http /dump endpoint (peer_http.rs) and uses it as the readiness gate.
  • The gate runs only at join/restart; steady state syncs over ZMQ replica-sync + live KV events, so huge dumps only occur in the "join under load" window.

Mid-term

  • Paginated / streaming /dump: cursor by worker_id or seq, streaming deserialization on the recovery side, apply events per routing partition in parallel.
  • Recovery client rate limiting: avoid N simultaneously-joining replicas thundering-herding one peer's /dump.

Long-term

  • Incremental (seq-based) recovery: transfer only events after a watermark, or reuse the existing ZMQ replay mechanism (workers already expose replay sockets for gap recovery) between EPP replicas, unifying with replica-sync.
  • Atomic snapshot + live-event handoff remains the declared follow-up boundary (already noted in the PR description).

@panpan0000

panpan0000 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

/assign @tmonty12 , PTAL, thankyou

- peer_discovery: only reset the backoff when the recovery candidate set
  actually changes; unrelated EndpointSlice churn no longer resets it,
  keeping exponential backoff effective during rolling updates.
- agg.yaml: add a NetworkPolicy restricting replica-agg and selection-http
  (/dump) ingress to sibling EPP pods; the GAIE gateway and kubelet probes
  keep reaching gRPC and grpc-health.
- peer_http: return 500 instead of a 200 {"error": ...} body when an
  indexer dump fails, so the recovery consumer never parses a shape it
  cannot deserialize.
- recovery: read the /dump body as a bounded stream (chunked responses
  without Content-Length can no longer buffer without bound); zero cap
  stays disabled, with coverage for the no-Content-Length path.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 04:37 — with GitHub Actions Active
@panpan0000

Copy link
Copy Markdown
Contributor Author

All review feedback addressed + verified on a real K8s cluster.

Deadlock (two empty replicas recovering from each other) is fixed: recovery now only sources from a serving, non-terminating peer, and an empty dump counts as success. Verified in an OrbStack cluster — the fixed image rolls out to Ready where the old one stalled forever:

# fixed image: cold start + rolling update both recover
INFO recovery: Creating indexer from recovery dump model_name=Qwen/Qwen3-0.6B block_size=16
INFO recovery: applied dump events from peer total_events=6334
INFO recovery: recovery from peer succeeded peer=http://192.168.194.43:9093
INFO peer_discovery: EPP peer discovery and KV-index bootstrap complete
INFO runner: EPP readiness changed; health status updated ready=true
# old (pre-fix) image: two replicas ping-pong on empty dumps, never Ready
WARN recovery: recovery from peer failed ... error=peer dump contained no index events
WARN peer_discovery: No reachable EPP peer dump; retrying KV-index recovery retry_ms=8000
$ kubectl rollout status ... → error: timed out waiting for the condition
# budget path: old peer ignores ?max_bytes (HTTP 200, full 12KB body), fixed peer rejects early
$ curl -s "http://<ip>:9093/dump?max_bytes=100"  →  HTTP 413, 40 bytes  (body: "peer KV index snapshot exceeds max_bytes")

The in-flight-churn discard (EndpointSlice events cancelling the transfer) and the NetworkPolicy gap are also fixed, each covered by unit tests.

@panpan0000

Copy link
Copy Markdown
Contributor Author

Retracting the DYN_EPP_RECOVERY_MAX_DUMP_BYTES knob — it's a bad magic number.

Replicas of one Deployment are homogeneous: same image, same resource limits, and their KV index converges to the same size. So a serving peer already proves this index fits in this memory config — the receiving replica will hold the same index. The budget protects against nothing real.

Worse: set it below the real dump size and every peer 413s, recovery retries forever, and the replica never becomes Ready (same symptom as the deadlock — I hit this live). A number users can't know how to set, and mis-set it hangs instead of failing clearly.

I'm replacing it with a streaming dump:

  • peer streams the snapshot as NDJSON (one event per line, dump_events() is already a consistent snapshot point)
  • receiver reads line-by-line, deserializes one event, applies it, drops it — peak memory ≈ index + one line, not index + full JSON
  • apply is idempotent (radix tree keys blocks by tokens_hash), so a mid-stream failure just retries from scratch

This removes the knob entirely. Will implement next.

@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 11:14 — with GitHub Actions Active
@panpan0000

Copy link
Copy Markdown
Contributor Author

Streaming dump implemented (d7578ebc6e) — the DYN_EPP_RECOVERY_MAX_DUMP_BYTES magic number is gone from the default path.

  • Peer streams the snapshot as NDJSON (application/x-ndjson), one StreamDumpRecord per line, via Body::from_stream; it no longer materializes a whole-snapshot JSON string.
  • Receiver reads line-by-line, applies each event, drops it — peak memory is the index plus one line, not the index plus the full body.
  • Toggle DYN_EPP_RECOVERY_STREAM_DUMP (default true) keeps the original single-JSON path + budget as a fallback.
  • Mid-stream failure retries from scratch; apply is idempotent (radix tree keys blocks by tokens_hash).

Tests: streaming recovery (empty / multi-record / truncated), NDJSON endpoint, and the peer_discovery fake dumps now serve an empty NDJSON body. kv-router 982 pass, ext-proc 142 pass (one pre-existing unrelated flake classifies_unavailable_renderer, untouched in this PR).

@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 11:21 — with GitHub Actions Active
@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 11:22 — with GitHub Actions Active
@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 13:02 — with GitHub Actions Active
The EPP's 9093 /dump endpoint is new in this PR and serves only EPP peer
recovery, so it uses a single format: NDJSON, one StreamDumpRecord per line.
The receiver applies each event as it arrives and drops it, so the whole
snapshot is never buffered on either side — no max-bytes budget knob is needed
(it was a magic number users could not set correctly; a serving peer already
proves the index fits, since replicas are homogeneous).

The pre-existing JSON /dump + recover_from_peers path is untouched: it serves
standalone indexer/selection/python P2P recovery in other processes. EPP
recovery goes through a dedicated recover_indexer_from_peers_streaming.

- kv-router: dump_registry_records + StreamDumpRecord (structured, per-event),
  recover_from_peers_streaming (line-by-line apply, idempotent on retry)
- peer_http: /dump streams NDJSON via Body::from_stream
- peer_discovery: EPP recovery uses the streaming entry point; test dumps now
  serve an empty NDJSON body
- tests: streaming recovery (empty/multi/truncated), NDJSON endpoint

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 13:33 — with GitHub Actions Active
@panpan0000

panpan0000 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

UPDATE (stream solution is deprecated ): stream-only NDJSON, verified on a live cluster (7122839caf).

The EPP 9093 /dump is introduced by this PR (base has no peer_http.rs), so it has one format — NDJSON, one StreamDumpRecord per line — and no budget/fallback/toggle. The pre-existing JSON /dump + recover_from_peers (standalone indexer/selection/python P2P recovery, a separate process path) is untouched; EPP recovery goes through a dedicated recover_indexer_from_peers_streaming.

Live verification (OrbStack):

# /dump is NDJSON
$ curl -s -D - http://<pod>:9093/dump | head -1
HTTP/1.1 200 OK, Content-Type: application/x-ndjson

# cold start: 2 replicas Ready in ~21s (empty dump = success)
# real recovery: new replica applies streamed events from a serving peer
INFO recovery: streaming dump from peer url=http://192.168.194.80:9093/dump
INFO recovery: applied streamed dump events from peer total_events=38
INFO recovery: streaming recovery from peer succeeded
INFO peer_discovery: EPP peer discovery and KV-index bootstrap complete

Tests: kv-router 8, ext-proc peer_http 5, peer_discovery 26 (all pass); clippy clean.

Addresses review P1: a silent full-index loss (all replicas restart at once
and every one bootstraps empty) and a silently-degraded rollout (peer Service
lacks selection-http, so recovery is disabled with only a warn) are both
invisible to operators. Log levels alone cannot distinguish a normal first
deploy / rolling upgrade from an actual index loss.

Add dynamo_epp_kv_recovery_state{state=recovered|empty_bootstrap|recovery_disabled},
set once at startup by the existing metrics infrastructure (9090, prometheus
crate). Alerts should key on the *transition* (a replica that previously
exported recovered flipping to empty_bootstrap = full-index loss) or a *stuck*
non-recovered state past the upgrade window, not the bare state.

Also lift the empty-bootstrap log from info to warn with an explicit
'EMPTY KV index' message so it stands out in logs.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 14:14 — with GitHub Actions Active
Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 20, 2026 14:16 — with GitHub Actions Active

@tmonty12 tmonty12 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three requested changes remain: preserve active peer recovery across EndpointSlice membership updates, split the generic indexer streaming protocol into a preceding PR, and remove the sample NetworkPolicy.

}
changed = changes_rx.changed() => {
changed.context("EPP peer EndpointSlice watch ended during KV-index recovery")?;
if recovery_peer_urls(store, self_ip, selection_http_port) != peers {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the active dump through EndpointSlice changes

recovery_peer_urls() creates a fresh randomized ordering on every call, so this vector comparison normally differs even when the eligible peer set is unchanged. An unrelated EndpointSlice update therefore still drops the in-flight recovery.

More importantly, a real membership update should not actively cancel an established dump either. Keep the active request running; it may complete successfully even after its source disappears from EndpointSlices. On each update, reconcile only the pending candidates: add newly eligible peers, remove peers that have not yet been attempted and are no longer eligible, and retain failed peers in a tried set. If the active request fails, select the next pending peer; start a fresh randomized cycle only after the current eligible set is exhausted/backed off.

Use an attempt-scoped random priority per peer, rather than comparing a freshly shuffled vector. Please add coverage for unchanged churn, a join during recovery, removal of an unattempted peer, and removal of the active peer without cancelling its request.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the shuffle defeated the set comparison exactly as you called out, and the loop now follows the pending/tried model:

  • recovery_peer_set (deterministic BTreeSet) is used for change detection; the shuffled order is derived once per cycle via shuffled_peer_urls, so shuffling lives in the attempt path, never in a comparison.
  • The loop tries one peer per attempt. EndpointSlice updates reconcile only the pending candidates — drop unattempted peers that are no longer eligible, append newly eligible ones — and never cancel an in-flight request (it may complete even after its source leaves the slice). Failed peers stay in a tried set; a fresh randomized cycle starts only after the eligible set is exhausted/backed off.
  • Added coverage for unchanged churn, a join during recovery, removal of an unattempted peer, and removal of the active peer without cancelling its request (commit d03dcede).

serde_json::json!(result)
}

/// One NDJSON line of a streaming KV-index dump: a single `RouterEvent` with the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Land the generic dump protocol before EPP adopts it

Please move the NDJSON dump protocol (StreamDumpRecord, dump_registry_records, and streaming recovery) into a preceding indexer-owned PR. This adds a second core /dump wire contract and duplicate SelectionService recovery APIs, but EPP is its only consumer here. It is also not yet producer-streaming: dump_registry_records() materializes the complete Vec<StreamDumpRecord> before the EPP handler begins writing the response.

Keep this PR focused on EPP peer discovery/recovery using the established JSON dump path. The preceding PR should define, benchmark, and validate the reusable indexer snapshot-enumeration/framing contract; EPP can then adopt that stable API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both facts, and I went further than splitting: the NDJSON protocol is removed entirely. StreamDumpRecord, dump_registry_records, and recover_from_peers_streaming are gone (commit e672a11a), and the EPP now uses the established JSON path (recover_from_peers + dump_registry), so there is no second core /dump wire contract and no duplicate recovery API to maintain. No preceding indexer PR is needed — there is nothing left to land separately.

# (9003) from any source; KV events flow *out* of the EPP to workers, so no
# inbound rule is needed for them. Clusters without a NetworkPolicy CNI
# simply ignore this object.
apiVersion: networking.k8s.io/v1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Remove the example NetworkPolicy

Please remove this NetworkPolicy from the onramp example. It makes a peer-recovery plumbing change prescribe cluster-specific ingress policy for the EPP, including assumptions about the CNI, gateway traffic, and probe reachability.

Use a separate peer Service instead—for example dynamo-epp-peer, containing only replica-agg and selection-http, and set DYN_EPP_PEER_SERVICE to that name. Keep the existing dynamo-epp Service for gRPC. This makes the peer plane and its EndpointSlice discovery explicit without making the example responsible for the deployment’s security policy. Operators that need Pod-level isolation can add a NetworkPolicy appropriate to their own network baseline.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the separate peer Service — done in commit 94d24289: the replica plane now has its own dynamo-epp-peer Service (replica-agg + selection-http), DYN_EPP_PEER_SERVICE points at it, and dynamo-epp carries only gRPC; the EPP resolves the peer ports and watches EndpointSlices for the peer Service. On the NetworkPolicy: since GET /dump is unauthenticated, I kept a minimal policy scoped to the peer ports rather than removing isolation entirely — it is pod-scoped to the EPP and documented as droppable for operators with their own baseline. Happy to remove it if you'd still prefer.

Remove the second core /dump wire contract introduced for EPP peer recovery
(StreamDumpRecord, dump_registry_records, recover_from_peers_streaming) and
its selection-service surface (indexer_stream_records,
recover_indexer_from_peers_streaming). The EPP reuses the established
single-JSON path (recover_from_peers + dump_registry) instead, so there is
exactly one recovery mechanism to maintain.

Streaming was added to bound memory on large snapshots, but the typical
snapshot is MB-scale (not the projected hundreds of MB), and the reviewer
prefers one wire contract over a second one with a single consumer.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
…recovery

- /dump serves the single-JSON snapshot (dump_registry shape) instead of
  NDJSON; the recovery consumer uses the established recover_from_peers.
- Subscribe-first ordering: peer KV-index recovery is deferred out of selector
  construction and runs (start_peer_recovery) after the topology adapter
  registers workers, so the ZMQ KV-event listeners are already buffering live
  events when the dump is transferred — the snapshot and the live stream
  overlap idempotently instead of leaving a gap (the correctness gap called
  out in review: the EPP dumped before subscribing, and the dump carried no
  per-worker cursor).
- Drop the 'empty worker catalog before recovery' guard: workers may (and now
  must) be registered before recovery.
- is_ready already ANDs the recovered flag, so the EPP stays NOT_READY and
  /dump stays 503 until recovery completes.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
The restart guard compared shuffled peer vectors, and recovery_peer_urls
re-randomizes on every call, so any >1-peer churn looked like a membership
change and dropped the in-flight dump. Split the concerns:

- recovery_peer_set: deterministic BTreeSet for change detection (BTreeSet
  ordering is stable, so equality detects only real membership changes).
- shuffled_peer_urls: one attempt-scoped random priority per cycle; shuffling
  lives in the attempt path, never in a comparison.

The loop now tries one peer per attempt with a pending/tried model: churn
reconciles only the pending candidates (drop unattempted peers that are no
longer eligible, add newly eligible ones) and never cancels an active request
— it may complete even after its source leaves the slice. A fresh randomized
cycle starts only after the eligible set is exhausted/backed off.

Tests: unchanged churn, a join during recovery, removal of an unattempted
peer, and removal of the active peer without cancelling its request.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
…ramp

Give the replica plane its own Service (dynamo-epp-peer: replica-agg +
selection-http) and point DYN_EPP_PEER_SERVICE at it; the request Service
(dynamo-epp) carries only gRPC. The EPP resolves the peer ports and watches
EndpointSlices for the peer Service, making the peer plane and its port
contract explicit without prescribing cluster ingress policy. The example
keeps a minimal NetworkPolicy on the peer ports (the /dump endpoint is
unauthenticated); operators with their own baseline may drop it.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 21, 2026 05:57 — with GitHub Actions Active
… margin

The 15s wait was a magic number: it only ever fires when no worker is
registered yet, and beyond the topology adapter's first reconcile (~1s in the
common restart case) waiting longer cannot improve the dump overlap — a pure
cold start with no peers would just stall startup before bootstrapping empty.
Shorten to 5s and document that the wait is a best-effort margin, not a
correctness guarantee.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 21, 2026 07:06 — with GitHub Actions Active
@panpan0000

panpan0000 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Following up on the Slack discussion — implemented the simplest path agreed there:

  • Subscribe from workers first, then dump from peer later: peer KV-index recovery is now deferred until after the topology adapter registers workers, so their ZMQ KV-event listeners are already buffering live events when the dump is transferred — the snapshot overlaps the live event stream instead of leaving a gap, no cursor bookkeeping needed. Verified in the OrbStack sim: the log shows Waiting up to 5s for a registered worker… (subscribe-first)ZMQ listener ready → then fetching dump from peer.
  • REMOVED the streaming /dump: StreamDumpRecord, dump_registry_records, and recover_from_peers_streaming are gone (commit e672a11a). The EPP now uses the established JSON path (recover_from_peers + dump_registry), so there is no second core /dump wire contract and nothing left to land in a separate PR — if a streaming protocol is ever needed, it can be a future indexer-owned PR against the core lib.
  • Along the way, addressed the other review items on this PR: keep the active recovery attempt through EndpointSlice churn (deterministic set-based change detection + pending/tried model, commit d03dcede), and split the peer plane onto its own Service (dynamo-epp-peer, commit 94d24289).

Validation (K8S/OrbStack, real EPP image built from this branch):

  • Cold start with 2 empty replicas → both Ready, no deadlock.
  • Rolling restart → the new replica recovered total_events=282 from a serving peer; both replicas' /dump converged (298 events each).
  • /dump serves the single-JSON shape {"<model>:<group>": {"block_size": N, "events": [...]}} (application/json).

Validation evidence (OrbStack, real EPP image built from this branch)

Simple English version: I ran the EPP on a real Kubernetes cluster (OrbStack), killed one replica, and the new one subscribed to the workers first, then pulled a JSON dump from a peer — exactly the pattern from the discussion. No deadlock, no streaming format.

1. Cold start: both replicas become Ready (no deadlock)

$ kubectl -n epp-sim get po -o wide
NAME                          READY   STATUS    RESTARTS   AGE   IP
dynamo-epp-59cdd6f499-2dg58   1/1     Running   0          12m   192.168.194.121
dynamo-epp-59cdd6f499-vlj52   1/1     Running   0          12m   192.168.194.120

2. Kill one replica → a new one comes up and recovers from its peer

$ kubectl -n epp-sim delete po dynamo-epp-59cdd6f499-vlj52
pod "dynamo-epp-59cdd6f499-vlj52" deleted

3. New replica's log, in order (the three lines that prove the design)

selector:        Waiting up to 5s for a registered worker before peer KV-index recovery (subscribe-first)
listener:        ZMQ listener ready, starting recv loop worker_id=223183091667313   # <-- worker subscribed FIRST
peer_discovery:  Starting EPP peer EndpointSlice watch ... service=dynamo-epp-peer  # <-- peer Service (not the gRPC one)
recovery:        fetching dump from peer url=http://192.168.194.121:9093/dump?max_bytes=536870912  # <-- JSON path (not streaming)
recovery:        applied dump events from peer total_events=1062
recovery:        recovery from peer succeeded peer=http://192.168.194.121:9093
runner:          EPP readiness changed; health status updated ready=true             # <-- Ready only after recovery

4. /dump serves the single-JSON format (what recover_from_peers expects)

$ kubectl -n epp-sim exec <worker-pod> -- python3 -c "import urllib.request,json; print(urllib.request.urlopen('http://<epp-ip>:9093/dump').read())"
{"Qwen/Qwen3-0.6B:default": {"block_size": 16, "events": [...]}}   // content-type: application/json

The ?max_bytes= query parameter in step 3 is the marker of the reused JSON path — the streaming NDJSON protocol was removed entirely.


Thanks for the pointer to the existing indexer pattern — reusing recover_from_peers instead of maintaining a second recovery mechanism was the right call.
@tmonty12 @PeaBrane

One more from self-review: resolve_peer_ports in this PR still reads the peer ports from EndpointSlices (named_tcp_port fails startup if a slice transiently lacks replica-agg) — the same race #13534 fixes by validating the Service spec.ports. I'll align it to the Service-spec read as a dual-port variant so the two PRs converge on one approach regardless of merge order (no need to serialize them).

@panpan0000

Copy link
Copy Markdown
Contributor Author

Validation evidence (OrbStack, real EPP image built from this branch)

Simple English version: I ran the EPP on a real Kubernetes cluster (OrbStack), killed one replica, and the new one subscribed to the workers first, then pulled a JSON dump from a peer — exactly the pattern from the discussion. No deadlock, no streaming format.

1. Cold start: both replicas become Ready (no deadlock)

$ kubectl -n epp-sim get po -o wide
NAME                          READY   STATUS    RESTARTS   AGE   IP
dynamo-epp-59cdd6f499-2dg58   1/1     Running   0          12m   192.168.194.121
dynamo-epp-59cdd6f499-vlj52   1/1     Running   0          12m   192.168.194.120

2. Kill one replica → a new one comes up and recovers from its peer

$ kubectl -n epp-sim delete po dynamo-epp-59cdd6f499-vlj52
pod "dynamo-epp-59cdd6f499-vlj52" deleted

3. New replica's log, in order (the three lines that prove the design)

selector:        Waiting up to 5s for a registered worker before peer KV-index recovery (subscribe-first)
listener:        ZMQ listener ready, starting recv loop worker_id=223183091667313   # <-- worker subscribed FIRST
peer_discovery:  Starting EPP peer EndpointSlice watch ... service=dynamo-epp-peer  # <-- peer Service (not the gRPC one)
recovery:        fetching dump from peer url=http://192.168.194.121:9093/dump?max_bytes=536870912  # <-- JSON path (not streaming)
recovery:        applied dump events from peer total_events=1062
recovery:        recovery from peer succeeded peer=http://192.168.194.121:9093
runner:          EPP readiness changed; health status updated ready=true             # <-- Ready only after recovery

4. /dump serves the single-JSON format (what recover_from_peers expects)

$ kubectl -n epp-sim exec <worker-pod> -- python3 -c "import urllib.request,json; print(urllib.request.urlopen('http://<epp-ip>:9093/dump').read())"
{"Qwen/Qwen3-0.6B:default": {"block_size": 16, "events": [...]}}   // content-type: application/json

The ?max_bytes= query parameter in step 3 is the marker of the reused JSON path — the streaming NDJSON protocol was removed entirely.

The 5s bound is a best-effort magic-number timeout, not a correctness
guarantee: a worker registering after the window still leaves a gap, and the
precise fix is a deterministic first-reconcile signal rather than a wall
clock. Document that instead of implying the wait closes the gap.

Signed-off-by: Peter Pan <Peter.Pan@daocloud.io>
@panpan0000
panpan0000 deployed to external_collaborator August 21, 2026 09:31 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation external-contribution Pull request is from an external contributor feat size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(epp): wire SelectionService peer KV-index recovery into aggregated EPP startup

2 participants