Skip to content

Commit 65c2f9f

Browse files
committed
[subscriber] fix replay dedup, instance identity, and error classification from review
1 parent f69608a commit 65c2f9f

17 files changed

Lines changed: 516 additions & 100 deletions

subscriber/AGENTS.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,19 @@ uv run ruff format --check subscriber/ tests/ # 仅检查,不修改
2626
# 类型检查(mypy strict)
2727
uv run mypy subscriber/
2828

29-
# 启动进程(默认连接本机 vLLM)
30-
uv run python -m subscriber
29+
# 启动进程(SPECTRUM_DEPLOYMENT_NAME 与 --kvcm-base-url 为启动必填)
30+
export SPECTRUM_DEPLOYMENT_NAME=my-deployment
31+
uv run python -m subscriber --kvcm-base-url http://10.0.0.1:8080
3132

3233
# 指定参数启动
33-
uv run python -m subscriber \
34+
SPECTRUM_DEPLOYMENT_NAME=my-deployment uv run python -m subscriber \
3435
--zmq-pub-endpoint tcp://localhost:5557 \
3536
--zmq-replay-endpoint tcp://localhost:5558 \
36-
--kvcm-addr 10.0.0.1:50051 \
37+
--kvcm-base-url spectrum://vs-example:6382 \
3738
--engine-type vllm
3839

39-
# 使用配置文件
40-
uv run python -m subscriber --config config.yaml
40+
# 使用配置文件(kvcm_base_url 可写在 yaml 中)
41+
SPECTRUM_DEPLOYMENT_NAME=my-deployment uv run python -m subscriber --config config.yaml
4142
```
4243

4344
## 质量门禁(Quality Gates)
@@ -87,8 +88,9 @@ proto/client 测试和 `uv run mypy subscriber/`,确认 protobuf 3.20.3
8788
- **发送门控**:向 kvcm 转发实时或 replay batch 前必须通过 `EngineHealthCoordinator.wait_ready_epoch()` 获取当前可发送 epoch。
8889
- **分阶段 span 计时**:从 event 到手到发送 kvcm 的每个阶段用 span 埋点,可拓展。载体 `EngineEventBatch(batches, timer)` 由 adapter yield:timer 起源于 adapter 开始处理该 event,adapter 标记自身阶段(live=`decode`、replay=`replay_fetch`),`send_incremental_events` 在同一 timer 上续标 `queue_wait`/`gate_wait`/`block_filter`/`metadata_learn`/`kvcm_send`。加新阶段只需在相应位置多一次 `timer.mark("stage")`。span 上报是 best-effort,`StageTimer.mark/spans``report()` 契约永不抛异常,绝不阻断转发主路径。
8990
- **Metrics 上报规范**:所有指标通过 `subscriber/metrics/` 包(`_base.py` 中的 `_dashlog_counter` / `_dashlog_gauge`)统一出口上报,自动添加 `kvcache_subscriber_` 前缀。维度信息(endpoint、status、reason 等)通过 `tags` 参数传递(对应 Prometheus labels),不要编码进 metric name。dashlog 本地不可编译时所有 report 函数为 no-op(`try: import dashlog / except ImportError` fallback)。新增或修改指标后必须同步更新 `docs/metrics.json` 指标目录。
90-
- **Replay 语义**:检测到 seq gap 时,先聚合所有 replay batch 一次性 yield,再 yield 当前实时消息。replay 使用 DEALER socket(非 REQ),避免 async 下严格一问一答的阻塞问题。
91-
- **配置优先级**:CLI 显式参数 > `--config yaml` 文件 > dataclass 默认值。
91+
- **Replay 语义**:检测到 seq gap 时,先聚合 replay batch 一次性 yield,再 yield 当前实时消息。vLLM replay 端会返回所有 `seq >= gap_start_seq` 的缓冲消息(包含触发 gap 的当前实时消息及更新消息),必须过滤只转发缺失区间 `[gap_start_seq, current_seq - 1]`(其余会以实时消息到达,直接转发会重复);过滤后仍要 drain 到 END marker。replay 成功完成(返回列表,含空列表)后立即把 `_last_seq` 推进到 `current_seq - 1`,与实时消息 decode 成败解耦,避免同一 gap 被重复 replay。replay 中途 abort(超时、异常、帧格式错误、decode 失败)必须调用 `_replace_replay_socket()` 更换 DEALER socket,防止残留帧污染下一次 replay。replay 使用 DEALER socket(非 REQ),避免 async 下严格一问一答的阻塞问题。
92+
- **配置优先级**:CLI 显式参数 > `--config yaml` 文件 > dataclass 默认值;`kvcm_base_url` 为必填项(只能由 CLI 或配置文件提供),空值在 `validate()` 阶段直接报错。
93+
- **启动必需的身份环境变量**`SPECTRUM_DEPLOYMENT_NAME` 必须为非空唯一部署标识;`KvcmClient.start()` 会校验,缺失/空白直接失败(fatal),因为空值会退化为 `_<block_size>` 这类被无关实例共享的 instance_id,破坏"KVCache 仅在同一 instance_id 内复用"的隔离不变量。
9294
- **Lint/类型检查禁用规则**:禁止通过内联注释(如 `# noqa``# type: ignore`)禁用 ruff 或 mypy 检查。自动生成的代码(如 protobuf 生成文件)应在 `pyproject.toml``ruff.toml` 中通过 `exclude` / `per-file-ignores` 排除整个文件;只有确实无法通过修改代码解决的极少数情况,才允许使用注释禁用,且必须在注释中说明原因。
9395
- **Docstring 规范**:通用接口/模块定义(后续要实现某个接口的 placeholder)、抽象基类的 public 方法、复杂实现逻辑必须有 docstring,说明契约、默认行为和关键不变量。简单的内部 helper 和自解释代码不需要。
9496
- **设计文档落盘目录**:所有 spec / 设计文档写入 `docs/superpowers/specs/`,命名格式为 `YYYY-MM-DD_slug.md`(如 `2026-07-21_engine-node-metadata-rpc.md`)。不要在项目根目录或其他位置创建 `specs/` 目录。
@@ -102,7 +104,7 @@ subscriber 与同机 DashServing "同生同死":subscriber 未 active 时 Dash
102104
**核心不变量(修改代码前必须理解):**
103105

104106
- **引擎健康只由 `GetWorkerStatus.alive` 驱动**:不用 HTTP `/readiness` 做引擎健康判断,不用 DashServing 探针反馈 engine DEAD。
105-
- **KVCM 是 lossy 旁路,不是门控**:KVCM 不可用不阻塞转发、不影响探针、不改变 subscriber 状态。发送失败丢弃并计数,恢复后自动续传。不要为 KVCM 加 generation/snapshot/condition 等复杂机制。
107+
- **KVCM 是 lossy 旁路,不是门控(运行期)**进入 serving 后,KVCM 不可用不阻塞转发、不影响探针、不改变 subscriber 状态。发送失败丢弃并计数,恢复后自动续传。不要为 KVCM 加 generation/snapshot/condition 等复杂机制**启动期例外(有意设计)**`_graceful_startup` 第 3 步会等待 KVCM 注册成功(`_wait_kvcm_registered`)才启动 pipeline 并上报 active——启动时需要 KVCM 注册成功才能够启动,避免在从未注册的情况下导流;等待期间 subscriber 保持 starting(readiness 503),并周期性打印 "still waiting for kvcm registration" 警告。不要把这个启动等待当作 bug"修掉"
106108
- **seq_id 线性化**:状态上报靠单调递增 seq_id 防乱序。低 seq 的 active 不能复活已接受的 inactive。不要加 session_id 除非需要支持独立重启 subscriber。
107109
- **HostDown 幂等**`AllBlocksCleared` 每个 sendable epoch 最多发一次。冷启动失败不发。
108110
- **同生同死探针语义**:starting → readiness 503 / liveness 200(startup grace);active → 200/200;inactive/failed/TTL expired → 503/503。
@@ -144,3 +146,4 @@ class SGLangAdapter(AbstractEngineAdapter):
144146
| 编号 | 内容 | 文件 | 阶段 |
145147
|---|---|---|---|
146148
| S-4 | subscriber 不可用恢复策略:kvcm SDK 心跳、TTL、session/checkpoint | kvcm SDK / kvcm 服务端 / subscriber | DONE(heartbeat + TTL + state reporter 已实现;session/checkpoint 见 TODO(independent-restart)) |
149+
| S-5 | 多 DP(`data_parallel_size > 1`)事件订阅:config 已按 rank 生成 per-rank ZMQ endpoint,但 `VllmIncrementalSource` 目前只订阅 `dp_endpoints[0]`(rank 0),rank 1+ 的 KV 事件不会被转发 | subscriber/engine/vllm/incremental.py | TODO(`subscriber_health_enabled=true` 时已强制 `data_parallel_size == 1`;实现多 DP 前不要放开该限制) |

subscriber/README.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,21 @@ uv sync
1010

1111
## Running
1212

13-
```bash
14-
# With defaults
15-
uv run python -m subscriber
13+
Required at startup:
14+
15+
- `SPECTRUM_DEPLOYMENT_NAME` — unique deployment identity used to build the
16+
KVCM `instance_id`; startup fails if missing or blank.
17+
- KVCM base URL — via `--kvcm-base-url` or the config file; a blank value is
18+
rejected by config validation.
1619

20+
```bash
1721
# With CLI args
18-
uv run python -m subscriber --zmq-pub-endpoint tcp://localhost:5557 --kvcm-addr 10.0.0.1:50051
22+
SPECTRUM_DEPLOYMENT_NAME=my-deployment uv run python -m subscriber \
23+
--zmq-pub-endpoint tcp://localhost:5557 \
24+
--kvcm-base-url spectrum://vs-example:6382
1925

20-
# With config file
21-
uv run python -m subscriber --config config.yaml
26+
# With config file (kvcm_base_url can be set in the yaml)
27+
SPECTRUM_DEPLOYMENT_NAME=my-deployment uv run python -m subscriber --config config.yaml
2228
```
2329

2430
## Development

subscriber/subscriber/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ def validate(self) -> None:
273273
raise ValueError("kv_event_merge_max_queue_items must be >= 1")
274274
if not 1 <= self.host_port <= 65535:
275275
raise ValueError("host_port must be in [1, 65535]")
276+
if not self.kvcm_base_url.strip():
277+
raise ValueError("kvcm_base_url is required")
276278
if self.kvcm_heartbeat_interval_s <= 0:
277279
raise ValueError("kvcm_heartbeat_interval_s must be > 0")
278280
if self.kvcm_request_timeout_s <= 0:

subscriber/subscriber/engine/vllm/control.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,8 @@ async def watch_liveness(self) -> AsyncGenerator[LivenessEvent, None]:
203203
consecutive_failures = 0
204204

205205
was_healthy = is_healthy
206-
await asyncio.sleep(self._config.engine_health_interval_s)
207206
yield event
207+
await asyncio.sleep(self._config.engine_health_interval_s)
208208

209209
async def fetch_kv_cache_group_metadata(self) -> KvCacheGroupMetadata:
210210
"""Fetch per-group metadata with bounded exponential-backoff retries.

subscriber/subscriber/engine/vllm/incremental.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,12 @@ async def subscribe(self) -> AsyncGenerator[EngineEventBatch, None]:
246246
)
247247
if generation != self._generation:
248248
continue
249+
if replay_batches is not None:
250+
# The gap is resolved up to the live message: every
251+
# missing batch still buffered was forwarded and the
252+
# rest is permanently lost. Advance now so a live
253+
# decode failure below cannot replay the same range.
254+
self._last_seq = seq - 1
249255
if replay_batches:
250256
replay_telemetry.mark("replay_fetch")
251257
yield EngineEventBatch(
@@ -454,7 +460,17 @@ async def _replay_missing_batches(
454460
return None
455461
if generation != self._generation:
456462
return None
457-
if len(frames) != 3:
463+
if len(frames) == 4:
464+
# vLLM PR #45177 added topic to replay replies. ROUTER
465+
# consumes client_id, so DEALER receives
466+
# (delimiter, topic, seq_bytes, payload).
467+
_, _, replay_seq_bytes, replay_payload = frames
468+
elif len(frames) == 3:
469+
# Before vLLM PR #45177, replay replies had no topic and
470+
# DEALER received (delimiter, seq_bytes, payload).
471+
_, replay_seq_bytes, replay_payload = frames
472+
else:
473+
self._replace_replay_socket(dealer, generation)
458474
logger.warning(
459475
"dropping malformed kv event replay frames",
460476
step="zmq_replay",
@@ -466,7 +482,7 @@ async def _replay_missing_batches(
466482
},
467483
)
468484
return None
469-
if frames[1] == _END_SEQ:
485+
if replay_seq_bytes == _END_SEQ:
470486
if logger.is_debug_enabled():
471487
logger.debug(
472488
"completed vLLM ZMQ replay",
@@ -479,9 +495,15 @@ async def _replay_missing_batches(
479495
},
480496
)
481497
return replay_batches
482-
replay_seq = int.from_bytes(frames[1], "big")
498+
replay_seq = int.from_bytes(replay_seq_bytes, "big")
499+
# The replay endpoint returns every buffered batch with
500+
# seq >= gap_start_seq, including the live batch that exposed the
501+
# gap and anything newer; those arrive (or arrived) live, so only
502+
# the missing range is forwarded here. Keep draining to END.
503+
if replay_seq < gap_start_seq or replay_seq >= current_seq:
504+
continue
483505
batch = self._msgpack_helper.decode(
484-
frames[2],
506+
replay_payload,
485507
step="zmq_replay",
486508
tags={
487509
"gap_start_seq": gap_start_seq,
@@ -490,6 +512,7 @@ async def _replay_missing_batches(
490512
},
491513
)
492514
if batch is None:
515+
self._replace_replay_socket(dealer, generation)
493516
return None
494517
if logger.is_debug_enabled():
495518
logger.debug(

subscriber/subscriber/kvcm/client.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ def __init__(
8989
self._supported_mediums = supported_mediums
9090
self._group_by_idx: dict[int, KvCacheGroupSpec] | None = (
9191
{spec.group_idx: spec for spec in group_metadata}
92-
if group_metadata is not None
92+
# An empty metadata tuple is a valid topology; it registers the
93+
# same default location spec as the no-metadata path because KVCM
94+
# rejects empty location_spec_infos.
95+
if group_metadata
9396
else None
9497
)
9598
self._manager_client: AbstractKvCacheManagerClient = (
@@ -131,19 +134,34 @@ def is_registered(self) -> bool:
131134

132135
def _base_url(self) -> str:
133136
if not self._config.kvcm_base_url:
134-
raise ValueError(
135-
"kvcm_base_url is required (pass --kvcm-base-url or set "
136-
"KV_EVENT_SUBSCRIBER_KVCM_BASE_URL)"
137-
)
137+
raise ValueError("kvcm_base_url is required")
138138
return self._config.kvcm_base_url
139139

140140
def _instance_group(self) -> str:
141141
return self._config.kvcm_instance_group
142142

143143
def _instance_id(self) -> str:
144-
deployment = os.environ.get("SPECTRUM_DEPLOYMENT_NAME", "")
144+
deployment = self._require_deployment_name()
145145
return f"{deployment}_{self._effective_block_size()}"
146146

147+
@staticmethod
148+
def _require_deployment_name() -> str:
149+
"""Return the unique deployment identity, rejecting a blank value.
150+
151+
A blank SPECTRUM_DEPLOYMENT_NAME would produce a degenerate
152+
instance_id such as ``_16`` shared by every replica missing the
153+
variable, breaking the cross-instance KVCache isolation invariant.
154+
"""
155+
156+
deployment = os.environ.get("SPECTRUM_DEPLOYMENT_NAME", "").strip()
157+
if not deployment:
158+
raise ValueError(
159+
"SPECTRUM_DEPLOYMENT_NAME must be set to a unique deployment "
160+
"identity; a blank value would share one KVCM instance_id "
161+
"across unrelated engine instances"
162+
)
163+
return deployment
164+
147165
def _host_ip_port(self) -> str:
148166
if self._host_ip_port_value is not None:
149167
return self._host_ip_port_value
@@ -315,6 +333,7 @@ def _report_events_for_batches(
315333
)
316334

317335
async def start(self) -> None:
336+
self._require_deployment_name()
318337
self._host_ip_port_value = await resolve_host_ip_port(self._config.host_port)
319338
await self._manager_client.start()
320339
self._started = True

subscriber/subscriber/kvcm/manager_client.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from subscriber import logger
1717
from subscriber.kvcm.base import AbstractKvCacheManagerClient
18+
from subscriber.kvcm.errors import KvcmUnavailableError
1819
from subscriber.kvcm.service_discovery import (
1920
ServiceDiscovery,
2021
create_service_discovery,
@@ -151,15 +152,19 @@ async def is_ready(self) -> bool:
151152
endpoint = self._service_discovery.get_one_endpoint()
152153
if endpoint is None:
153154
return False
154-
self.base_url = f"http://{endpoint.host}"
155-
logger.info(
156-
"service discovery recovered manager endpoint",
157-
step=_DISCOVERY_STEP,
158-
tags={
159-
"discovery_type": self._service_discovery.get_type(),
160-
"base_url": self.base_url,
161-
},
162-
)
155+
async with self._leader_lock:
156+
# Re-check under the lock: a concurrent leader discovery may have
157+
# resolved base_url already, and it must not be clobbered.
158+
if not self.base_url.startswith(("http://", "https://")):
159+
self.base_url = f"http://{endpoint.host}"
160+
logger.info(
161+
"service discovery recovered manager endpoint",
162+
step=_DISCOVERY_STEP,
163+
tags={
164+
"discovery_type": self._service_discovery.get_type(),
165+
"base_url": self.base_url,
166+
},
167+
)
163168
return True
164169

165170
# ----- leader discovery internals -----
@@ -424,6 +429,14 @@ async def _request(
424429
step=_REQUEST_STEP,
425430
tags={"endpoint": endpoint},
426431
)
432+
# A leader failover in progress is a transient outage, not
433+
# a report rejection: check_response would raise the
434+
# rejected-report RuntimeError and the batch would be
435+
# dropped permanently instead of retried.
436+
raise KvcmUnavailableError(
437+
f"KVCM leader unavailable for {endpoint}: "
438+
"SERVER_NOT_LEADER and leader discovery exhausted"
439+
)
427440

428441
if check_response:
429442
status = payload.get("header", {}).get("status", {})

subscriber/subscriber/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ async def _report_active_until_accepted(self) -> None:
433433
return
434434
except (
435435
httpx.TransportError,
436+
httpx.HTTPStatusError,
436437
TimeoutError,
437438
OSError,
438439
) as exc:

subscriber/subscriber/metrics/_base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,21 @@ def _dashlog_counter(
3434
) -> None:
3535
# dashlog EASClient::AddCounters silently drops data when OTel is
3636
# disabled; route counters through Gauge to reach /realtime_metrics.
37+
# Limitation: the reset (and idle-key zeroing) is driven lazily by the
38+
# next counter that fires, so if every counter goes idle the last window
39+
# values remain until any counter fires again.
3740
global _counter_last_reset_s
3841
now = time.monotonic()
42+
key = (name, tuple(sorted((tags or {}).items())))
3943
if now - _counter_last_reset_s >= _RESET_INTERVAL_S:
44+
stale_keys = [k for k in _counter_accumulators if k != key]
4045
_counter_accumulators.clear()
4146
_counter_last_reset_s = now
42-
key = (name, tuple(sorted((tags or {}).items())))
47+
for stale_name, stale_tags in stale_keys:
48+
try:
49+
_dashlog_gauge(stale_name, 0.0, tags=dict(stale_tags) or None)
50+
except Exception:
51+
pass
4352
accumulated = _counter_accumulators.get(key, 0.0) + value
4453
_counter_accumulators[key] = accumulated
4554
try:

0 commit comments

Comments
 (0)