Skip to content

Serve the engine's identity and start time on GET /engine - #5

Merged
zhanghaohit merged 3 commits into
mainfrom
feat/engine-start-time
Oct 16, 2026
Merged

zhanghaohit merged 3 commits into
mainfrom
feat/engine-start-time

Conversation

@zhanghaohit

@zhanghaohit zhanghaohit commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

A new endpoint on the sidecar's existing port:

$ curl -s http://<pod-ip>:9090/engine
{"engine_id":"pst-1791943641.29","started_at":"2026-10-14T02:07:21.29Z","source":"process_start_time",
 "restarts":1,"watcher_started_at":"2026-10-14T01:52:10.48Z"}

restarts counts the engine restarts this sidecar has seen since watcher_started_at (its own start); it resets with the sidecar, so engine_id remains the field a client should compare.

503 until the engine has been identified, so "not known yet" is never read as an id.

Why

A client that keeps state tied to one engine process -- a prefill/decode router holding transfer sessions with its workers -- needs to know when that process was replaced. The kubelet restarts the engine container in place: the pod, its IP and its endpoints stay the same, so there is nothing to watch from outside. The sidecar already scrapes the engine every poll, so it can say which instance it is looking at.

Where the identity comes from

source taken from started_at id survives a sidecar restart
process_start_time vLLM's process_start_time_seconds the engine's own start time yes
startup_fingerprint sglang's startup_time_seconds / startup_cuda_graph_time_seconds (>= 0.5.19) when the sidecar first saw the instance yes
observed neither available; minted by the sidecar, renewed on counter regression when the sidecar first saw the instance no

sglang publishes no start timestamp, only per-phase startup durations. They are set once per process and measured to the nanosecond, so they work as a fingerprint. They appear over several scrapes during startup, so new series joining is the same process; only a changed value or a vanished series is a new one. Consumers should compare engine_id, not started_at.

Reading the engine process directly (the way ps does) is not possible: containers in a pod have separate pid namespaces. shareProcessNamespace would allow it but makes the pause container pid 1, which breaks charts whose preStop signals pid 1 and kills everything else it can see.

Behaviour change

A restart seen this way resets the verdict state machine (fresh baseline, warm-up grace period re-armed). Counter regression, the only restart signal so far, misses an engine that was idle (counters 0 before and after) or whose new process outran the old count within one poll.

Testing

  • Unit tests with metric lines taken from a real vLLM (v0.29 nightly) and sglang (0.5.19): restart without counter regression (with a control showing the counters alone miss it), idle restart, fingerprint growing across scrapes without a restart, id stable across sidecar restarts, exact response shape.
  • smoke_engine_test.sh (added to CI): the real binary against a fake engine, for both sources -- 503 before the first scrape, id changes on engine restart, same id after a sidecar restart.
  • Existing tests and smoke tests unchanged and passing.

A client that keeps state tied to one engine process, such as a
prefill/decode router, has to notice when that process is replaced. The
kubelet restarts the engine container in place, so the pod, its IP and its
endpoints do not change and there is nothing to watch from outside.

The sidecar already scrapes the engine every poll, so it now reports which
instance it sees: {"engine_id", "started_at", "source"}, or 503 before the
engine has been identified. engine_id changes exactly when the engine
process is replaced. It comes from vLLM's process_start_time_seconds (an
exact start time) or from sglang's startup_* gauges, which are durations set
once per process and work as a fingerprint that survives sidecar restarts.
With neither, the sidecar mints an id, which does not.

A restart seen this way also resets the verdict state machine, re-arming
the warm-up grace period. Counter regression alone misses an engine that was
idle, or whose new process outran the old count within one poll.
@zhanghaohit

Copy link
Copy Markdown
Collaborator Author

Tested 0.1.27-rc1 on a test cluster with real engines, deployed from the published charts (vllm 0.8.12 pd-colocate: prefill + decode + router in one pod; sglang 0.9.14 single engine; A100, a 27B FP8 model). All checks passed:

vLLM prefill (:9090) vLLM decode (:9091) sglang
source process_start_time process_start_time startup_fingerprint
started_at vs kubelet startedAt -0.74s -0.44s +406s (first seen after model load, as documented)
same answer from another pod via pod IP yes yes yes
engine container restarted (SIGTERM to pid 1) new id, started_at within 0.22s of the new startedAt new id, within 0.02s new id
the other engine in the pod during that restart id unchanged id unchanged --
hang-watcher container restarted id unchanged id unchanged id unchanged (started_at moved, as documented for this source)

While a restarted engine is still loading, /engine keeps returning the previous id; the new one appears once the new process serves /metrics.

Script: real engines on the test cluster, run by hand (not part of CI).

…seen on /engine

restarts counts engine restarts seen since watcher_started_at. It resets
with the sidecar, so engine_id stays the field to compare; the two make the
endpoint readable on its own when debugging.
… it is weak

- A startup series missing from a scrape no longer counts as an sglang
  restart. Phases are reported late during startup, and a /metrics body cut
  off at the read limit loses its tail; treating either as a new process
  would also reset the hang verdict state and re-arm the warm-up period.
  Only a series seen before with a different value is a new process, and
  late phases join the remembered set so a later change in them is caught.
- Log the identity source when it changes, with a warning for observed. It
  is what vLLM falls back to with --api-server-count > 1, whose
  multiprocess metrics registry has no process collector; README says so.
- Attach noteEngine's doc comment to the function.
@zhanghaohit

Copy link
Copy Markdown
Collaborator Author

Re-tested with 0.1.27-rc2 (adds restarts and watcher_started_at) on the same real-engine setup. All checks passed again, plus:

vLLM prefill vLLM decode sglang
restarts after one engine container restart 1 1 1
restarts after the sidecar container restarted 0 0 0
watcher_started_at vs kubelet startedAt of the sidecar container +0.73s +0.74s +0.13s
started_at vs kubelet startedAt of the engine container (before / after restart) +0.04s / -0.85s -0.44s / -0.53s first seen after load, as documented

The latest commit (802aab3, review fixes: missing startup series no longer read as a restart, a warning when the identity falls back to observed) is covered by unit and smoke tests; it is not in rc2.

@remotego remotego left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lgtm

@zhanghaohit
zhanghaohit merged commit 27ee926 into main Oct 16, 2026
2 checks passed
Mario-LuX pushed a commit to Mario-LuX/modelsphere-helm-charts that referenced this pull request Oct 23, 2026
…e engine restart

1f0d236 taught the pd-ds router's drift probe to fire on engine /health
so the router restarts the moment a newly-scheduled engine is actually
usable, and no earlier. That probe keys on *IP set growth*. It misses
the case where an engine's IP is unchanged but the process behind it
was replaced.

That case is real on vllm + mooncake: the router pulls each prefill's
engine_id off its /query endpoint once at boot (vllm_pd_router.rs) and
pins it into every kv_transfer_params. When the kubelet restarts a
crashed engine in place, the pod keeps its IP, DNS shows no growth,
but the engine's new process has a fresh engine_id -- the router
keeps addressing a dead incarnation and transfers hang or fail.
NIXL dodges this because its handshake is lazy per-transfer.

The fix piggybacks on the hang-watcher sidecar that already runs on
every engine pod. With modelsphere/hang-watcher#5 the sidecar serves
GET /engine on its existing healthz port, returning
{"restarts", "watcher_started_at", ...} where the pair is a stable
identity of the engine process the sidecar is watching. No new RBAC,
no router-version dependency, no engine-side changes required.

Boot script (pd-ds.yaml): after DNS resolves the upstream IPs, also
query each engine's /engine endpoint and record
"<ip>|<restarts>|<watcher_started_at>" per line in the existing
/run/pd-router/<role>-ips file. 404 on /engine = old sidecar without
the endpoint, record "<ip>||" sentinel. 503 / conn refused / timeout
= transient: retry up to 90min, then fall through to the same
sentinel and proceed (fail-open, mirrors the DNS budget). The sentinel
makes the runtime probe's answer "no identity info", identical to
the hang-watcher-disabled render.

Liveness probe (_pod.tpl): per tick, re-query /engine on each argv'd
IP. Fires iff:
  - both sides returned a real tuple and they differ  (in-place swap)
  - boot recorded sentinel but live returns real      (late-arriving
    identity; restart so the router re-snapshots with identity)
and the usual /health gate passes (mirrors 1f0d236: don't restart
into a still-loading engine).

hangWatcher.enabled=false renders HW_PORT=0 into the probe and boot
script, short-circuiting every query to the sentinel: one-line switch,
single code path.

Old-sidecar compat: /engine 404s, boot records sentinel, liveness
never has a live real tuple to compare against -> drift probe
degrades to pre-patch behavior (IP growth only), still useful.

Chart versions: sglang 0.9.17 -> 0.9.18, vllm 0.8.16 -> 0.8.17.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants