已合并
支持Nodemanager原生拉起引擎,解除Engine Server层依赖 #698
支持Nodemanager原生拉起引擎,解除Engine Server层依赖 #698
已合并
tobking创建于 23 天前
127 个文件变更+7168-3884
@@ -38,6 +38,11 @@ Endpoint (ABC) → MgmtEndpoint / InferEndpoint — HTTP server lifecycl
38InferEndpoint.get_lifespan() → VLLMEndpoint / SGLangEndpoint — engine-specific startup38InferEndpoint.get_lifespan() → VLLMEndpoint / SGLangEndpoint — engine-specific startup
39```39```
40 40 
41+The transitional EngineServer reuses the CLI configuration adapters owned by
42+`motor/node_manager/core/services/native_engine/backends/`. Process launch and supervision for the
43+new direct-native path remain in `NativeEngineService`; the classes in this document describe only
44+the legacy in-process EngineServer path.
45+ 
41### Config → CLI Args Pipeline46### Config → CLI Args Pipeline
42 47 
431. `ConfigFactory` looks up the engine's `IConfig` in an explicit `_ENGINE_CONFIG_MAP` dict (`vllm` / `sglang` → module paths); `parse()` runs the full `initialize()``convert()``validate()` pipeline481. `ConfigFactory` looks up the engine's `IConfig` in an explicit `_ENGINE_CONFIG_MAP` dict (`vllm` / `sglang` → module paths); `parse()` runs the full `initialize()``convert()``validate()` pipeline
@@ -176,7 +181,7 @@ Layer 4: SimInference (proactive health)
176| File | Role |181| File | Role |
177|------|------|182|------|------|
178| `motor/engine_server/cli/main.py` | Entry point: CLI arg parsing, factory wiring, start Mgmt+Infer endpoints |183| `motor/engine_server/cli/main.py` | Entry point: CLI arg parsing, factory wiring, start Mgmt+Infer endpoints |
179-| `motor/engine_server/core/config.py` | `IConfig` ABC: `initialize()`, `validate()`, `convert()`, `get_args()` |184+| `motor/node_manager/core/services/native_engine/backends/base.py` | Shared `IConfig` ABC used by the transitional EngineServer and native backends |
180| `motor/engine_server/core/engine.py` | `Engine` ABC: `launch()`, `shutdown()` |185| `motor/engine_server/core/engine.py` | `Engine` ABC: `launch()`, `shutdown()` |
181| `motor/engine_server/core/endpoint.py` | `Endpoint` ABC: `run()`, uvicorn lifecycle |186| `motor/engine_server/core/endpoint.py` | `Endpoint` ABC: `run()`, uvicorn lifecycle |
182| `motor/engine_server/core/infer_endpoint.py` | `InferEndpoint`: FastAPI app, uvicorn, route registration, lifespan, dispatch adapter wiring, TLS |187| `motor/engine_server/core/infer_endpoint.py` | `InferEndpoint`: FastAPI app, uvicorn, route registration, lifespan, dispatch adapter wiring, TLS |
@@ -186,12 +191,13 @@ Layer 4: SimInference (proactive health)
186| `motor/engine_server/core/dispatch_adapter/` | Dispatch adapter subsystem: `base.py` (534), `vllm_adapter.py` (340), `normalization.py` (220), `sglang_adapter.py` (49), `factory.py` (23) |191| `motor/engine_server/core/dispatch_adapter/` | Dispatch adapter subsystem: `base.py` (534), `vllm_adapter.py` (340), `normalization.py` (220), `sglang_adapter.py` (49), `factory.py` (23) |
187| `motor/engine_server/core/snapshot_sentinel.py` | `SnapshotSentinel` thread: checkpoint wait + suspend/resume driving |192| `motor/engine_server/core/snapshot_sentinel.py` | `SnapshotSentinel` thread: checkpoint wait + suspend/resume driving |
188| `motor/engine_server/core/snapshot_monitor.py` | `SnapshotMonitor`: suspend/unlock/resume completion states |193| `motor/engine_server/core/snapshot_monitor.py` | `SnapshotMonitor`: suspend/unlock/resume completion states |
189-| `motor/engine_server/core/vllm/vllm_config.py` | `VLLMConfig`: field mapping, DP address, KV transfer, D2D config |194+| `motor/node_manager/core/services/native_engine/backends/vllm/config.py` | `VLLMConfig`: field mapping, DP address, KV transfer, D2D config |
190| `motor/engine_server/core/vllm/vllm_engine.py` | `VLLMEngine`: `AsyncEngineArgs.from_cli_args` + `AsyncLLM.from_vllm_config`, headless PCP follower |195| `motor/engine_server/core/vllm/vllm_engine.py` | `VLLMEngine`: `AsyncEngineArgs.from_cli_args` + `AsyncLLM.from_vllm_config`, headless PCP follower |
191| `motor/engine_server/core/vllm/vllm_endpoint.py` | `VLLMEndpoint`: `_vllm_lifespan` context manager, route init |196| `motor/engine_server/core/vllm/vllm_endpoint.py` | `VLLMEndpoint`: `_vllm_lifespan` context manager, route init |
192| `motor/engine_server/core/vllm/vllm_openai_compat.py` | OpenAI-compat shims (model lists, request mapping) for the vLLM backend |197| `motor/engine_server/core/vllm/vllm_openai_compat.py` | OpenAI-compat shims (model lists, request mapping) for the vLLM backend |
193-| `motor/engine_server/core/sglang/` | `SGLangConfig`, `SGLangEngine`, `SGLangEndpoint` |198+| `motor/node_manager/core/services/native_engine/backends/sglang/config.py` | `SGLangConfig`: native CLI conversion and validation |
194-| `motor/engine_server/factory/config_factory.py` | `ConfigFactory`: explicit `_ENGINE_CONFIG_MAP` + `parse()` (initialize→convert→validate) |199+| `motor/engine_server/core/sglang/` | Transitional `SGLangEngine` and `SGLangEndpoint` |
200+| `motor/node_manager/core/services/native_engine/config_factory.py` | `ConfigFactory`: explicit `_ENGINE_CONFIG_MAP` + `parse()` (initialize→convert→validate) |
195| `motor/engine_server/factory/endpoint_factory.py` | InferEndpoint loading by engine name |201| `motor/engine_server/factory/endpoint_factory.py` | InferEndpoint loading by engine name |
196| `motor/config/endpoint.py` | `EndpointConfig`: engine_type, host, port, mgmt_port, role, dp_rank, master_dp_ip, node_rank, d2d_peer_ips, snapshot_metadata, deploy_config; `HealthCheckConfig`: timeout, retry attempts, npu_usage_threshold, enable_virtual_inference, max_failure_count |202| `motor/config/endpoint.py` | `EndpointConfig`: engine_type, host, port, mgmt_port, role, dp_rank, master_dp_ip, node_rank, d2d_peer_ips, snapshot_metadata, deploy_config; `HealthCheckConfig`: timeout, retry attempts, npu_usage_threshold, enable_virtual_inference, max_failure_count |
197 203 
@@ -201,11 +207,12 @@ Layer 4: SimInference (proactive health)
201 207 
202To add support for a new engine (e.g., "trtllm"):208To add support for a new engine (e.g., "trtllm"):
203 209 
204-1. Create `motor/engine_server/core/{engine}/` directory210+1. Create `motor/node_manager/core/services/native_engine/backends/{engine}/config.py`
205-2. Implement `{engine}_config.py`: subclass `IConfig`, implement `initialize()`/`validate()`/`convert()`211+2. Implement the engine config adapter as an `IConfig` subclass and register it in `ConfigFactory._ENGINE_CONFIG_MAP`
206-3. Implement `{engine}_engine.py`: subclass `Engine`, wrap engine client creation in `launch()`212+3. For transitional EngineServer support, create `motor/engine_server/core/{engine}/`
207-4. Implement `{engine}_endpoint.py`: subclass `InferEndpoint`, implement `get_lifespan()` + `init_request_handlers()`213+4. Implement `{engine}_engine.py`: subclass `Engine`, wrap engine client creation in `launch()`
208-5. Register engine name module path in `ConfigFactory._ENGINE_CONFIG_MAP` and `EndpointFactory`214+5. Implement `{engine}_endpoint.py`: subclass `InferEndpoint`, implement `get_lifespan()` + `init_request_handlers()`
215+6. Register the transitional endpoint in `EndpointFactory`
209 216 
210### Other Rules217### Other Rules
211 218 
@@ -214,7 +221,7 @@ To add support for a new engine (e.g., "trtllm"):
214- **PD disaggregation**: KV transfer config is set up during `IConfig.initialize()` based on the endpoint's role (prefill=producer, decode=consumer) and the selected connector (Mooncake/Multi/UCM/AscendStore).221- **PD disaggregation**: KV transfer config is set up during `IConfig.initialize()` based on the endpoint's role (prefill=producer, decode=consumer) and the selected connector (Mooncake/Multi/UCM/AscendStore).
215- **Device pinning**: handled by NodeManager via `ASCEND_RT_VISIBLE_DEVICES` env var. EngineServer must NOT manage device assignment — it inherits visibility from the parent process.222- **Device pinning**: handled by NodeManager via `ASCEND_RT_VISIBLE_DEVICES` env var. EngineServer must NOT manage device assignment — it inherits visibility from the parent process.
216- **Prometheus multiprocess**: `PROMETHEUS_MULTIPROC_DIR` must be set in `main()` before any metric import. Each engine_server writes its own metrics file to this directory.223- **Prometheus multiprocess**: `PROMETHEUS_MULTIPROC_DIR` must be set in `main()` before any metric import. Each engine_server writes its own metrics file to this directory.
217-- **Port ranges**: ports are validated in `NodeManager`'s `EngineService._check_params()` (business_port in [1024, 65535]) before the engine process is spawned.224+- **Port ranges**: `EndpointConfig.validate()` validates business and management ports before the engine process is spawned.
218 225 
219## Testing226## Testing
220 227 
@@ -1,238 +1,273 @@
1# NodeManager Module — Architecture & Implementation1# NodeManager Module — Architecture & Implementation
2 2 
3-## Architecture: Sidecar Daemon3+## Architecture: Native Runtime Sidecar
4 4 
5-One NodeManager process per NPU pod/container. Its job is to manage the lifecycle of `engine_server` subprocesses on the local machine and report health to the Controller.5+One NodeManager process runs in each inference pod. It receives lifecycle commands from
6+Controller, starts one native vLLM or SGLang process per endpoint, supervises the complete
7+process group, probes the native `/health` endpoint, and reports endpoint status through
8+Controller heartbeats.
6 9 
7``` text10``` text
8-NodeManager Process (sidecar, one per pod) — NodeManager(Application) main loop11+NodeManager (Application)
912
10├── NodeManagerAPI (FastAPI thread)13├── NodeManagerAPI (FastAPI thread)
11-│ POST /node-manager/start — spawn engines with StartCmdMsg14+│ POST /node-manager/start — validate StartCmdMsg and launch native engines
12-│ POST /node-manager/stop — kill engines15+│ POST /node-manager/stop — stop native process groups
13-│ POST /node-manager/pause — pause endpoints (snapshot/upgrade flow)16+│ POST /node-manager/pause — mark endpoints PAUSED for PreStop
14-│ POST /node-manager/resume — resume endpoints17+│ POST /node-manager/resume — restore PAUSED endpoints
15-│ GET /node-manager/status — current engine states18+│ GET /node-manager/status — report endpoint readiness
16-│ GET /readiness — k8s readiness probe19+│ GET /readiness — Kubernetes readiness
17-│ (TLS via mgmt_tls_config)
1820
19-├── EngineManager (ThreadSafeSingleton)21+├── Daemon
20-Registration protocol: POST /controller/register (with retry)22+service registry for engine and optional KV-store services
21-Ranktable file writing: saves ranktable JSON for engine RPC23+5-second service monitor health_check()
2224
23-├── Daemon (ThreadSafeSingleton)25+├── NativeEngineService
24-Service registry orchestration (core/services/registry.py):26+builds LaunchContext, selects Native Engine Backend, delegates lifecycle to ProcessSupervisor
25-│ "engine" services → EngineService (subprocess.Popen, device pinning)
26-│ "kv-store" backends → memcache/lifecycle services
27-│ Device pinning via ASCEND_RT_VISIBLE_DEVICES env var
28-│ SIGKILL on stop (no graceful shutdown — engines are stateless)
29-│ 5s process monitor thread → svc.health_check() for every service
3027
31-├── HeartbeatManager (ThreadSafeSingleton)28+├── ProcessSupervisor
32-Two daemon threads:29+subprocess.Popen(start_new_session=True)
33- _engine_server_status_thread poll each engine GET /status every interval30+ owns RuntimeProcess records, process groups and native health probes
34-_heartbeat_report_thread — POST /controller/heartbeat every interval
35-│ Fault detection: 120s grace period + 5 consecutive abnormal reports → suicide
3631
37-── FaultReporter (ThreadSafeSingleton)32+── HeartbeatManager
38- HTTP poll GET {business_port}/fault_tolerance/status per engine33+│ polls ProcessSupervisor every second
39- (vLLM FT REST API) report_software_fault to Controller34+│ reports Controller heartbeat at configured interval
35+│ preserves STARTING/STOPPING/PAUSED semantics and suicide threshold
36+
37+└── FaultReporter
38+ optional GET {business_port}/fault_tolerance/status polling for engine software faults
40```39```
41 40 
42-All core components use `ThreadSafeSingleton` `__new__` + `threading.Lock` ensures one instance per process with thread-safe lazy initialization.41+`motor/node_manager/core/services/native_engine/` is the native engine service boundary. Its
42+engine backends are stateless converters: they turn a validated `LaunchContext` into an immutable
43+`LaunchSpec` containing a `CommandSpec` and a `ProbeSpec`. The Coordinator and Controller do not
44+depend on these node-local runtime states.
43 45 
44## Complete Lifecycle46## Complete Lifecycle
45 47 
46-### Phase 1: Startup & Registration48+### Phase 1: Startup and Registration
47 49 
48``` text50``` text
49- 51+main.py
50-1. main.py is a thin wrapper (41 lines): load config port setup → NodeManager(config).run()52+ NodeManagerConfig.from_json()
51- 53+ → port allocation / configuration validation
52- The real orchestration lives in NodeManager(Application) (motor/node_manager/node_manager.py)54+ NodeManager(Application).run()
53- and the Application base class (motor/common/app/application.py): signal handlers,55+ init_modules(): Daemon, NodeManagerAPI, EngineManager, HeartbeatManager
54- daemon tick loop, module init/start, graceful shutdown. There is no separate
55- suicide_procedure() function — suicide is driven by the tick loop (see Phase 4).
56- 
57-2. NodeManager.init_modules() — registration order:
58- Daemon → NodeManagerAPI → [EngineManager → HeartbeatManager]
59- 
60- EngineManager/HeartbeatManager are registered ONLY when daemon.has_engine
61- is True. A KV-only pod (kv_cache_store_config mode="separated") registers
62- just Daemon + NodeManagerAPI.
63- 
64-3. EngineManager._register() [background thread]
65- 
66- Loop:
67- wait_until_api_ready(timeout=30.0) # NodeManagerAPI must be serving
68- POST /controller/register (with instance metadata, capabilities)
69- → 200: registration accepted, break
70- → non-200: retry indefinitely with exponential backoff (2, 4, 8, 16, 32s, capped at 32s)
71- Registration failure does not send SIGTERM / suicide the process.
72- 
73-4. Main loop (Application.run()) blocks until stop_event
74- - SIGTERM/SIGINT → cleanup and exit
75- - stdin EOF → cleanup and exit
76- - each daemon tick also checks the HeartbeatManager suicide flag
77- 
78```56```
79 57 
80-### Phase 2: Receiving Start Command58+`EngineManager` and `HeartbeatManager` are initialized only when the active service registry
59+contains an engine. A KV-only pod (`kv_cache_store_config.mode="separated"`) starts the KV service
60+and NodeManager API without native engine registration or endpoint heartbeats.
61+ 
62+`EngineManager` registers the pod with Controller in a background loop. It waits for the API-ready
63+event, posts `/controller/register`, and retries indefinitely with exponential backoff starting at
64+2 seconds and capped at 32 seconds. Registration failure does not terminate NodeManager; normal
65+shutdown and heartbeat-triggered recovery remain controlled by the application lifecycle.
66+ 
67+### Phase 2: Start Command and Native Launch
68+ 
69+Controller sends `POST /node-manager/start` with `StartCmdMsg`:
81 70 
82``` text71``` text
83-Controller → POST /node-manager/start (StartCmdMsg)72+{
84- {73+ instance_id, job_name, role,
85- instance_id: int,74+ endpoints: [{id, ip, business_port, mgmt_port, dp_rank, headless, ...}],
86- endpoints: [Endpoint], # host, business_port, mgmt_port, dp_rank, role75+ master_dp_ip, node_rank, d2d_peer_ips, ranktable
87- ranktable: {...}, # for distributed RPC76+}
88- job_name: str,
89- role: str, # prefill / decode / union
90- master_dp_ip: str,
91- d2d_peer_ips: [str],
92- node_rank: int,
93- }
94- 
95-1. EngineManager.parse_start_cmd(msg):
96- - Validates all fields (endpoint IPs, ports in range)
97- - Stores instance_id, endpoints, role, master_dp_ip, d2d_peer_ips, node_rank
98- - Writes ranktable JSON to file (for engine RPC initialization)
99- 
100-2. Daemon.pull_engine(pd_role_info, endpoints, instance_id, ...):
101- - Phase 1: run PreparableService.prepare() on KV-store services
102- - Phase 2: EngineService.pull() — for each endpoint:
103- 
104- - Device pinning (_calc_visible_device_ids, core/services/engine.py):
105- start_device = i * local_world_size % device_num
106- visible = local_world_size consecutive devices from start_device
107- (wraps around when past the end)
108- ASCEND_RT_VISIBLE_DEVICES is set ONLY when enable_multi_endpoints
109- is enabled; the logic moved from Daemon into EngineService.
110- 
111- - subprocess.Popen (shell=False, with env):
112- engine_server --dp-rank <id> --instance-id <id> --role <role>
113- --host <ip> --port <business_port> --mgmt-port <mgmt_port>
114- --master-dp-ip <ip> --node-rank <rank> --config-path <path>
115- optional flags appended when configured:
116- --snapshot-metadata / --kv-port / --dp-rpc-port /
117- --lookup-rpc-port / --d2d-peer-ips
118- - Track PID in self.engine_pids list
119- 
120-3. HeartbeatManager.start() — takes NO arguments:
121- - Starts _engine_server_status_thread
122- - Starts _heartbeat_report_thread
123- - instance/role/endpoint fields are set afterwards via
124- update_endpoint(StartCmdMsg)
125- 
126```77```
127 78 
128-### Phase 3: Steady State79+The API parses and validates the message, then `Daemon.pull_engine()` runs preparable KV-store
80+services before asking `NativeEngineService.pull()` to launch each endpoint.
81+ 
82+For each endpoint, `NativeEngineService`:
83+ 
84+1. Builds an immutable `LaunchContext` with role, rank, host, ports, distributed setup, D2D peers,
85+ environment, and the endpoint's `headless` flag.
86+2. Selects `VllmBackend` or `SGLangBackend` from the configured engine type.
87+3. Rebuilds and validates the role-specific `EndpointConfig` and native engine configuration.
88+4. Creates a `CommandSpec` and a `ProbeSpec`.
89+5. Calls `ProcessSupervisor.start()`.
90+ 
91+The native commands are:
129 92 
130``` text93``` text
131-Every heartbeat_interval _engine_server_status_thread:94+vllm serve <native vLLM arguments>
132- For each endpoint:95+python3 -m sglang.launch_server <native SGLang arguments>
133- GET http://{ip}:{mgmt_port}/status
134- → parse EndpointStatus (initial / normal / abnormal / paused / wait2start)
135- → update self._endpoints[i].status
136- 
137-Every heartbeat_interval (default 3s) — _heartbeat_report_thread:
138- POST /controller/heartbeat
139- body: HeartbeatMsg {
140- job_name, ins_id, ip,
141- status: {endpoint_id: endpoint_status} # dict keyed by endpoint_id
142- }
143```96```
144 97 
145-### Phase 4: Fault Detection & Suicide98+No `engine_server` process is inserted by the Native Runtime path.
99+ 
100+Device pinning is applied by `NativeEngineService` when `enable_multi_endpoints` is enabled:
101+`ASCEND_RT_VISIBLE_DEVICES` contains the endpoint's calculated local device range. `POD_IP` is
102+used as the default `VLLM_HOST_IP` when it is not already set. When the Mooncake IPv6 experiment is
103+enabled, `MC_USE_IPV6=1` is supplied unless the environment already defines it.
104+ 
105+### Native Engine Backend Contract
106+ 
107+| Type | Responsibility |
108+|------|----------------|
109+| `LaunchContext` | Immutable normalized input for one endpoint launch |
110+| `CommandSpec` | Immutable argv, environment and optional working directory |
111+| `ProbeSpec` | Native health path, request timeout, startup timeout, retry limit, TLS and headless mode |
112+| `LaunchSpec` | Pair of `CommandSpec` and `ProbeSpec` |
113+| `VllmBackend` | Builds `vllm serve`; Native P/D only accepts the supported HANDOFF profile |
114+| `SGLangBackend` | Builds `python3 -m sglang.launch_server`; encode role is rejected |
115+ 
116+The backend configuration adapters flatten engine-specific JSON into native CLI arguments. They
117+must call the engine configuration converter and validator before returning a launch specification.
118+`None` values are omitted from CLI arguments. SGLang receives `enable-metrics=true` because
119+Coordinator metrics and PreStop drain depend on the native metrics endpoint.
120+ 
121+For SGLang P/D roles, the backend sets `disaggregation-mode=prefill|decode`; union uses
122+`disaggregation-mode=null`. Multi-node configurations validate `nnodes` and require
123+`master_dp_ip`, which is formatted with the shared IPv4/IPv6 address helper.
124+ 
125+### Probe and Runtime State
126+ 
127+`ProbeSpec.max_attempts` is the configured
128+`health_check_config.health_collector_timeout_retry_attempts` value. It counts the first request
129+and retries only `requests` timeout failures (including exceptions wrapped by `SafeHTTPSClient`).
130+HTTP status failures, TLS errors, connection errors and other exceptions are not retried.
131+ 
132+`ProcessSupervisor.state()` follows this state model:
146 133 
147``` text134``` text
148-Grace period: 120s hardcoded from engine start135+start STARTING
149- (engines need time to load models — don't kill them during warmup)136+ ├─ headless process alive RUNNING
150- 137+ ├─ /health success → READY
151-After grace period:138+ ├─ process exits → STOPPED
152- If any endpoint.status == ABNORMAL:139+ └─ startup timeout expires while probe fails → UNHEALTHY
153- _consecutive_abnormal_count += 1
154- Else:
155- _consecutive_abnormal_count = 0
156- 
157- If _consecutive_abnormal_count >= 5 (5 consecutive reports):
158- _should_suicide = True
159- → NodeManager._on_daemon_tick() detects the flag → stop_event.set()
160- → main loop exits → shutdown() gracefully stops all modules
161- (Daemon.stop SIGKILLs engine subprocesses — no os._exit(-1))
162- → run() returns exit code -1
163- → Kubernetes restarts the pod → fresh registration
164- 
165-HTTP 503 from Controller:
166- → Controller restarted → HeartbeatManager._reregister()
167- → POST /controller/reregister (ReregisterMsg) — single attempt, no backoff;
168- a later heartbeat exception triggers the next retry
169```140```
170 141 
142+During `startup_timeout` a failed probe keeps `STARTING`; this prevents slow model loading from
143+being reported as a fault. A headless process is only proven alive, not independently ready. The
144+heartbeat layer maps `RUNNING` to `WAIT2START` and `READY` to `NORMAL`.
145+ 
146+`ProcessSupervisor` protects state updates with a lock and performs HTTP probes outside the lock.
147+Each launch uses `start_new_session=True` on POSIX, caches the process-group ID, and treats the
148+process group as the lifecycle unit. A stopped or dead record is removed exactly once; dead
149+launchers trigger cleanup of the cached group so workers are not leaked.
150+ 
151+### Phase 3: Heartbeat and Status Reporting
152+ 
153+`HeartbeatManager` snapshots endpoint records under `_endpoint_lock`, probes each native endpoint
154+through `Daemon.get_engine_runtime_state()`, then commits results only if the endpoint generation
155+has not changed. HTTP probing is therefore not performed while holding the endpoint lock.
156+ 
157+Status mapping:
158+ 
159+| Native runtime state | Controller endpoint status |
160+|----------------------|----------------------------|
161+| `STARTING` / `STOPPING` | Preserve `INITIAL` or the last status |
162+| `RUNNING` | `WAIT2START` |
163+| `READY` | `NORMAL` |
164+| `UNHEALTHY` / `STOPPED` | `ABNORMAL` |
165+| manual `PAUSED` | Preserve `PAUSED` |
166+ 
167+The heartbeat body is `HeartbeatMsg(job_name, ins_id, ip, status)` where `status` is keyed by
168+endpoint ID. Controller readiness requires routable endpoints to be `NORMAL`; headless members
169+must at least be alive and reported as `WAIT2START`.
170+ 
171+`POST /node-manager/pause` marks all endpoints `PAUSED` and returns native metrics URLs for
172+non-headless endpoints. `resume` changes only `PAUSED` records back to `NORMAL`.
173+ 
174+### Phase 4: Fault Detection and Recovery
175+ 
176+`Daemon` calls each service's `health_check()` every 5 seconds. If `NativeEngineService` observes a dead
177+native launcher, it removes the record, cleans the process group, and requests one Pod-level
178+recovery through `SIGTERM` when `motor_restart_engine` is enabled. `_recovery_requested` prevents
179+duplicate recovery signals until a successful new pull resets it.
180+ 
181+`HeartbeatManager` counts consecutive successful heartbeat reports containing `ABNORMAL` endpoints.
182+After five consecutive abnormal reports it sets the suicide flag. The main application tick sees
183+the flag, stops modules, and exits for platform rescheduling.
184+ 
185+Controller HTTP 503 triggers the existing re-registration path. Heartbeat HTTP requests use a
186+shared long-lived client with bounded retry behavior in `ControllerApiClient`.
187+ 
171### Phase 5: Shutdown188### Phase 5: Shutdown
172 189 
173-``` text190+Shutdown stops modules in reverse initialization order. `Daemon.stop()` asks each service to stop;
174-NodeManager.shutdown() stop modules in reverse registration order:191+`NativeEngineService` marks records `STOPPING`, sends SIGTERM to each native process group, and waits up to
175- HeartbeatManager.stop() → join threads192+the configured grace period for the launcher. It then checks the cached PGID: a group still present after
176- EngineManager.stop() → stop registration thread193+the launcher exits is force-killed too, so surviving workers cannot retain NPU, port or memory resources.
177- NodeManagerAPI.stop() → shutdown FastAPI194+Records are removed after cleanup so concurrent status reads cannot report a stopped process as a fresh endpoint.
178- Daemon.stop() → stop 5s monitor thread, then stop each service
179- in reverse registration order (SIGKILL engine PIDs)
180- (there is no NodeManagerConfig.stop() — the config object has no lifecycle)
181-```
182 195 
183-### Snapshot Restore196+### Snapshot Boundary
184 197 
185-When `is_restored_from_host_side_snapshot()` returns True:198+The Native Runtime path currently does not support container snapshot suspend/resume or native
199+engine restore. `snapshot_config.enable_snapshot=true` is rejected during NodeManager config
200+validation, and `snapshot_metadata_path` is not consumed by Native Runtime.
186 201 
187-- `_checkpoint_done_inspect_retry_count` tracks checkpoint readiness polling202+Legacy snapshot helpers and EngineServer compatibility code remain in the repository for the
188-- `_register_after_restore()` single POST /controller/register, no backoff;203+transition period, but they are not a supported Native Runtime launch path. Do not add new native
189- on failure the next heartbeat-report exception triggers it again204+runtime behavior that depends on `engine_server` management endpoints or snapshot metadata until a
190-- `_start_after_restore()` does NOT exist — after restore the Controller205+separate runtime contract is defined.
191- re-issues a fresh StartCmdMsg via /node-manager/start, and the engine
192- resumes through the snapshot routes (see engine-server.md)
193 206 
194## Key Files207## Key Files
195 208 
196| File | Role |209| File | Role |
197|------|------|210|------|------|
198-| `motor/node_manager/main.py` | Thin wrapper: config port setup `NodeManager(config).run()` |211+| `motor/node_manager/main.py` | Thin process entrypoint: config, port allocation and `NodeManager.run()` |
199-| `motor/node_manager/node_manager.py` | `NodeManager(Application)`: init_modules, daemon tick, suicide detection, shutdown |212+| `motor/node_manager/node_manager.py` | Application lifecycle, module initialization, suicide handling and shutdown |
200-| `motor/node_manager/api_server/node_manager_api.py` | FastAPI: `/node-manager/start`, `/stop`, `/pause`, `/resume`, `/status`, `/readiness` (TLS via `mgmt_tls_config`) |213+| `motor/node_manager/api_server/node_manager_api.py` | FastAPI start/stop/pause/resume/status/readiness APIs |
201-| `motor/node_manager/core/engine_manager.py` | Registration thread with exponential backoff + ranktable file writing + StartCmdMsg handling |214+| `motor/node_manager/core/daemon.py` | Service discovery, preparable-service ordering and process monitor |
202-| `motor/node_manager/core/daemon.py` | Service registry orchestration (engine + KV-store services), 5s process monitor thread |215+| `motor/node_manager/core/services/native_engine/service.py` | LaunchContext creation, device pinning, native launch and recovery request |
203-| `motor/node_manager/core/services/engine.py` | `EngineService`: subprocess.Popen, device pinning (`_calc_visible_device_ids`), CLI args, `_check_params` (business_port range), PID-death → SIGTERM self |216+| `motor/node_manager/core/services/native_engine/models.py` | LaunchContext, CommandSpec, ProbeSpec, LaunchSpec and RuntimeState |
204-| `motor/node_manager/core/services/registry.py` | `@register_service` decorator + `_MODULE_MAP`; discovers active services by pod profile |217+| `motor/node_manager/core/services/native_engine/factory.py` | Selects the stateless vLLM/SGLang backend by engine type |
205-| `motor/node_manager/core/services/memcache/` | KV-store (memcache) service implementation |218+| `motor/node_manager/core/services/native_engine/config_factory.py` | Lazily loads engine-specific CLI configuration adapters |
206-| `motor/node_manager/core/heartbeat_manager.py` | Two daemon threads: status polling + heartbeat reporting, fault detection state machine, reregister |219+| `motor/node_manager/core/services/native_engine/backends/` | vLLM/SGLang command construction, configuration conversion and validation |
207-| `motor/node_manager/core/fault_reporter.py` | HTTP poll of engine `GET /fault_tolerance/status` (business port) `report_software_fault` to Controller |220+| `motor/node_manager/core/services/native_engine/supervisor.py` | Process groups, bounded native health probes and runtime state ownership |
208-| `motor/node_manager/api_client/controller_api_client.py` | Sync HTTP client to Controller: `/register`, `/reregister`, `/heartbeat` |221+| `motor/node_manager/core/services/registry.py` | Service registration and backend discovery |
209-| `motor/node_manager/api_client/engine_server_api_client.py` | Sync HTTP client: `GET /status` on engine's mgmt port |222+| `motor/node_manager/core/services/memcache/` | Optional KV-store service implementation |
210-| `motor/config/node_manager.py` | `NodeManagerConfig`: BasicConfig, APIConfig, EndpointConfig, SnapshotConfig, SingleContainerConfig, PortAllocatorConfig |223+| `motor/node_manager/core/heartbeat_manager.py` | Native state polling, status mapping, heartbeat and suicide threshold |
224+| `motor/node_manager/core/engine_manager.py` | Controller registration, StartCmdMsg validation, ranktable and legacy transition hooks |
225+| `motor/node_manager/api_client/controller_api_client.py` | Controller register, reregister and heartbeat HTTP client |
226+| `motor/node_manager/core/fault_reporter.py` | Optional native engine software-fault polling |
227+| `motor/config/node_manager.py` | NodeManager schema, endpoint derivation, ports and snapshot validation |
211 228 
212-## Port Allocation229+## Port and Address Rules
213 230 
214-- `service_ports[i] = base_port + i * 2` (even ports inference API)231+- Inference service ports and management ports are allocated from the NodeManager port allocator;
215-- `mgmt_ports[i] = base_port + i * 2 + 1` (odd ports — management API)232+ native runtime health probes use the endpoint's `business_port`.
216-- Ports validated in range [1024, 65535]233+- Ports are validated before launch. The shared address helpers bracket IPv6 literals when building
217-- Endpoint count: `min(dp_size, device_num // local_world_size)`234+ URLs and distributed addresses.
218-- When `port_allocator_config.enable`, runtime probing (motor/common/utils/port_allocator.py,235+- `master_dp_ip`, D2D peer addresses and endpoint hosts are passed through the common address
219- `apply_node_manager_ports`) probes the host (`allocate_auto` with scan_range +236+ formatting utilities; do not concatenate IPv6 host and port strings manually.
220- probe_timeout) and re-allocates any busy port: node_manager_port, each237+- In multi-endpoint mode, device visibility is calculated from `local_world_size`; single-container
221- service/mgmt port, and in single-container mode kv_port, lookup_rpc_port,238+ offsets are applied after local device selection.
222- dp_rpc_port.
223 239 
224## Development Rules240## Development Rules
225 241 
226-- **New core components** use `ThreadSafeSingleton` pattern (`__new__` + double-checked locking with `threading.Lock`).242+- Keep native process lifecycle behind `Daemon` `NativeEngineService` `ProcessSupervisor`; do not call
227-- **Module lifecycle**: init-then-start pattern — construct in `init_modules()`, start via API command, stop in `shutdown()`.243+ `subprocess.Popen` from API handlers or native engine backends.
228-- **Fault thresholds**: keep configurable constants: grace period (120s, hardcoded in heartbeat_manager), consecutive abnormal count (5), heartbeat interval (default 3s) — do not hardcode deeper in the code.244+- Native engine backends remain stateless and return immutable launch specifications.
229-- **Engine process management** always through Daemon EngineService; never `subprocess.Popen` elsewhere. Daemon tracks all PIDs for cleanup.245+- Add a regression test for every new state transition, CLI mapping, probe policy or cleanup path.
230-- **Device pinning**: `ASCEND_RT_VISIBLE_DEVICES` env var set per engine subprocess by `EngineService._calc_visible_device_ids` (only when `enable_multi_endpoints`).246+- Keep health probing outside endpoint locks; commit results under lock with generation checks.
231-- **New engine/KV-store backends** add a service module with `@register_service` and an entry in the registry's `_MODULE_MAP`; Daemon stays unchanged.247+- Health retries must remain bounded and timeout-only. Do not retry explicit HTTP rejection, TLS or
232-- **Snapshot support**: check `is_restored_from_host_side_snapshot()` before registration; use longer timeouts during restore.248+ configuration errors.
249+- Preserve the distinction between process liveness (`RUNNING`/`WAIT2START`) and service readiness
250+ (`READY`/`NORMAL`).
251+- New engine backends should add a backend/config package and tests without coupling `Daemon` to the
252+ engine-specific CLI.
253+- Native Runtime snapshot support is intentionally disabled until its lifecycle contract is defined.
233 254 
234## Testing255## Testing
235 256 
236```bash257```bash
258+# Focused backend and supervisor tests
259+bash tests/run_tests.sh --serial tests/node_manager/core/services/native_engine/
260+ 
261+# NodeManager module tests
237bash tests/run_tests.sh tests/node_manager/262bash tests/run_tests.sh tests/node_manager/
238```263```
264+ 
265+Important test areas:
266+ 
267+- `tests/node_manager/core/services/native_engine/test_supervisor.py`: process-group ownership, state races, bounded
268+ timeout retries, headless liveness and cleanup.
269+- `tests/node_manager/core/services/native_engine/test_backends.py`: vLLM/SGLang command construction and ProbeSpec
270+ mapping.
271+- `tests/node_manager/core/test_heartbeat_manager.py`: generation-safe status mapping and heartbeat
272+ behavior.
273+- `tests/node_manager/core/services/native_engine/test_service.py`: recovery latch and process-death handling.
@@ -4,13 +4,17 @@
4 4 
5在本仓库中,**Engine Server** 指可执行入口 **`engine_server`**(`setup.py` 中 entry point:`engine_server = motor.engine_server.cli.main:main`),实现该功能的脚本路径为 `motor/engine_server/cli/main.py`。5在本仓库中,**Engine Server** 指可执行入口 **`engine_server`**(`setup.py` 中 entry point:`engine_server = motor.engine_server.cli.main:main`),实现该功能的脚本路径为 `motor/engine_server/cli/main.py`。
6 6 
7+> `EngineServer` 是过渡期兼容链路。Node Manager 的直接原生路径由
8+> `motor/node_manager/core/services/native_engine/` 管理,并直接拉起 `vllm serve` 或
9+> `python3 -m sglang.launch_server`,不会插入 `engine_server` 进程。
10+ 
7主要功能如下:11主要功能如下:
8 12 
9-- **解析端点配置**:`EndpointConfig.init_endpoint_config()`,经 `ConfigFactory` 得到具体引擎配置(`motor/engine_server/factory/config_factory.py` 中按 `vllm` / `sglang` 等类型选择配置类)。13+- **解析端点配置**:`EndpointConfig.init_endpoint_config()`,经 `ConfigFactory` 得到具体引擎配置(`motor/node_manager/core/services/native_engine/config_factory.py` 中按 `vllm` / `sglang` 等类型选择配置类)。
10- **管理面 HTTP(MgmtEndpoint)**`motor/engine_server/core/mgmt_endpoint.py` 内在 `mgmt_port` 上启动 uvicorn,挂载 Prometheus 相关路由及 **`GET /status`**(路径常量 `STATUS_INTERFACE`,值为 `/status`)。状态字段键为 `STATUS_KEY` 对应常量(与同文件 `NORMAL_STATUS` / `ABNORMAL_STATUS` / `INIT_STATUS` 等配合使用)。可选 **TLS**`mgmt_tls_config.enable_tls`)。14- **管理面 HTTP(MgmtEndpoint)**`motor/engine_server/core/mgmt_endpoint.py` 内在 `mgmt_port` 上启动 uvicorn,挂载 Prometheus 相关路由及 **`GET /status`**(路径常量 `STATUS_INTERFACE`,值为 `/status`)。状态字段键为 `STATUS_KEY` 对应常量(与同文件 `NORMAL_STATUS` / `ABNORMAL_STATUS` / `INIT_STATUS` 等配合使用)。可选 **TLS**`mgmt_tls_config.enable_tls`)。
11- **推理面(InferEndpoint)**:由 `EndpointFactory.get_infer_endpoint(config)` 按引擎类型构造(如 `VLLMEndpoint``SGLangEndpoint`,见 `motor/engine_server/factory/endpoint_factory.py`),与 `MgmtEndpoint` 并行 `run()`,主线程在 `infer_endpoint.wait()` 阻塞直至退出,再 `shutdown` 两端点。15- **推理面(InferEndpoint)**:由 `EndpointFactory.get_infer_endpoint(config)` 按引擎类型构造(如 `VLLMEndpoint``SGLangEndpoint`,见 `motor/engine_server/factory/endpoint_factory.py`),与 `MgmtEndpoint` 并行 `run()`,主线程在 `infer_endpoint.wait()` 阻塞直至退出,再 `shutdown` 两端点。
12 16 
13-Node Manager 侧通过子进程命令 **`engine_server`** 拉起本进程,参数在 `motor/node_manager/core/daemon.py` `pull_engine` 中,包括 `--dp-rank`、`--instance-id`、`--role`、`--host`、`--port`、`--mgmt-port`、`--master-dp-ip`、`--config-path`(值为 `Env.user_config_path`);单器模式下还会追加 `--kv-port`、`--dp-rpc-port` 等17+旧部署入口可通过子进程命令 **`engine_server`** 拉起本进程;新 Node Manager 原生路径不再组装该命令。以下 Engine Server 参数说明仅用于兼链路和本地调试
14 18 
15## 与周边组件的关系19## 与周边组件的关系
16 20 
@@ -2,12 +2,12 @@
2 2 
3## 功能介绍3## 功能介绍
4 4 
5-Node Manager 是部署在推理节点上的管理进程,负责连接 Controller 与本节点的 Engine Server。进程入口为 `motor/node_manager/main.py`,核心逻辑在 `motor/node_manager/node_manager.py`(`NodeManager` 类),继承自 `motor/common/app/application.py`(`Application` 基类)。主要职责如下:5+Node Manager 是部署在推理节点上的管理进程,负责连接 Controller 与本节点的原生 vLLM/SGLang 引擎。进程入口为 `motor/node_manager/main.py`,核心逻辑在 `motor/node_manager/node_manager.py`(`NodeManager` 类),继承自 `motor/common/app/application.py`(`Application` 基类)。主要职责如下:
jason lyu
jason lyujason lyu19 天前

大幅度改动,要刷新skill,建议使用motor-dev的skill进行刷新

likedislike
tobking
tobking
19 天前 评论:
6 6 
71. 加载节点配置,完成端口分配并启动管理面 HTTP 服务。71. 加载节点配置,完成端口分配并启动管理面 HTTP 服务。
82. 向 Controller 注册节点,接收 Controller 下发的实例启动命令。82. 向 Controller 注册节点,接收 Controller 下发的实例启动命令。
9-3. 按 endpoint 拉起、记录和停止 `engine_server` 子进程。9+3. 按 endpoint 直接拉起、监管和停止原生引擎进程
10-4. 轮询 Engine Server 状态并向 Controller 上报心跳。10+4. 轮询原生业务端口的健康接口并向 Controller 上报心跳。
115. 处理优雅暂停、配置热更新、容器快照恢复和软件故障上报。115. 处理优雅暂停、配置热更新、容器快照恢复和软件故障上报。
12 12 
13### 组件结构13### 组件结构
@@ -18,14 +18,15 @@ Node Manager 是部署在推理节点上的管理进程,负责连接 Controlle
18| `NodeManager` | `motor/node_manager/node_manager.py` | `Application` 子类:组装模块并运行 daemon loop,每 tick 检查自杀标志 |18| `NodeManager` | `motor/node_manager/node_manager.py` | `Application` 子类:组装模块并运行 daemon loop,每 tick 检查自杀标志 |
19| `NodeManagerConfig` | `motor/config/node_manager.py` | 加载、校验和重载节点配置,推导 endpoint 数量与端口 |19| `NodeManagerConfig` | `motor/config/node_manager.py` | 加载、校验和重载节点配置,推导 endpoint 数量与端口 |
20| `NodeManagerAPI` | `motor/node_manager/api_server/node_manager_api.py` | 在后台线程中运行 FastAPI/uvicorn,提供启动、停止和探针接口 |20| `NodeManagerAPI` | `motor/node_manager/api_server/node_manager_api.py` | 在后台线程中运行 FastAPI/uvicorn,提供启动、停止和探针接口 |
21-| `Daemon` | `motor/node_manager/core/daemon.py` | 服务编排器:根据配置发现并实例化 Engine 和 KV-store 服务,维护进程监控器 |21+| `Daemon` | `motor/node_manager/core/daemon.py` | 服务编排器:根据配置发现并实例化原生引擎和 KV-store 服务,维护进程监控器 |
22-| `EngineService` | `motor/node_manager/core/services/engine.py` | Engine 子进程生命周期管理:组装命令、拉起/追踪/停止 `engine_server` 进程 |22+| `NativeEngineService` | `motor/node_manager/core/services/native_engine/service.py` | 构造不可变 LaunchContext,选择 Native Engine Backend 并管理原引擎生命周期 |
23| `LocalService` | `motor/node_manager/core/services/memcache/lifecycle.py` | memcache 后端生命周期管理:配置准备、子进程拉起(通过 `memcache/worker.py`)、健康检查与重启 |23| `LocalService` | `motor/node_manager/core/services/memcache/lifecycle.py` | memcache 后端生命周期管理:配置准备、子进程拉起(通过 `memcache/worker.py`)、健康检查与重启 |
24+| `ProcessSupervisor` | `motor/node_manager/core/services/native_engine/supervisor.py` | 创建独立进程组,维护运行态、原生健康探测和完整进程树清理 |
25+| `VllmBackend` / `SGLangBackend` | `motor/node_manager/core/services/native_engine/backends/` | 将统一启动上下文转换为引擎原生命令与 ProbeSpec |
24| `EngineManager` | `motor/node_manager/core/engine_manager.py` | 注册/重注册、校验启动命令、处理 ranktable、快照元数据和故障上报 |26| `EngineManager` | `motor/node_manager/core/engine_manager.py` | 注册/重注册、校验启动命令、处理 ranktable、快照元数据和故障上报 |
25| `HeartbeatManager` | `motor/node_manager/core/heartbeat_manager.py` | 轮询 endpoint 状态、上报心跳、维护暂停/恢复状态并触发异常自杀 |27| `HeartbeatManager` | `motor/node_manager/core/heartbeat_manager.py` | 轮询 endpoint 状态、上报心跳、维护暂停/恢复状态并触发异常自杀 |
26| `FaultReporter` | `motor/node_manager/core/fault_reporter.py` | 轮询引擎 FT 状态接口并上报软件故障给 Controller |28| `FaultReporter` | `motor/node_manager/core/fault_reporter.py` | 轮询引擎 FT 状态接口并上报软件故障给 Controller |
27| `ControllerApiClient` | `motor/node_manager/api_client/controller_api_client.py` | 调用 Controller 的注册、重注册、心跳和故障上报接口 |29| `ControllerApiClient` | `motor/node_manager/api_client/controller_api_client.py` | 调用 Controller 的注册、重注册、心跳和故障上报接口 |
28-| `EngineServerApiClient` | `motor/node_manager/api_client/engine_server_api_client.py` | 调用 Engine Server 管理面的 `GET /status` |
29 30 
30`Daemon``EngineManager``HeartbeatManager` 均为线程安全单例。HTTP 路由和后台线程通过这些单例共享实例、endpoint 和进程状态。`Application``NodeManager` 不是单例,由 `main.py` 显式创建。31`Daemon``EngineManager``HeartbeatManager` 均为线程安全单例。HTTP 路由和后台线程通过这些单例共享实例、endpoint 和进程状态。`Application``NodeManager` 不是单例,由 `main.py` 显式创建。
31 32 
@@ -54,18 +55,20 @@ Controller 调用 `POST /node-manager/start` 后,处理流程为:
542. 校验 `job_name`、endpoint 数量以及每个 endpoint 的 IP 是否与本节点配置一致。552. 校验 `job_name`、endpoint 数量以及每个 endpoint 的 IP 是否与本节点配置一致。
553. 保存 `instance_id`、endpoints、`node_rank` 和 D2D peer 信息;如配置了 `RANKTABLE_PATH`,将实例 ranktable 写入该文件。563. 保存 `instance_id`、endpoints、`node_rank` 和 D2D peer 信息;如配置了 `RANKTABLE_PATH`,将实例 ranktable 写入该文件。
564. 准备快照运行目录和元数据。574. 准备快照运行目录和元数据。
57-5. `Daemon.pull_engine()` 为每个 endpoint 拉起一个 `engine_server` 进程。58+5. `Daemon.pull_engine()` 为每个 endpoint 直接拉起一个原生 vLLM 或 SGLang 进程
586. 更新 `HeartbeatManager` 中的 endpoint,并启动状态轮询和心跳线程。596. 更新 `HeartbeatManager` 中的 endpoint,并启动状态轮询和心跳线程。
597. 启动 `EngineManager` 中的 `FaultReporter`(仅在故障容忍功能开启时生效)。607. 启动 `EngineManager` 中的 `FaultReporter`(仅在故障容忍功能开启时生效)。
60 61 
61-从宿主机侧快照恢复时,第 5 步不会再次拉起 Engine Server,而是更新恢复元数据、endpoint 和恢复状态。62+从宿主机侧快照恢复时,第 5 步不会再次拉起引擎,而是更新恢复元数据、endpoint 和恢复状态。
62 63 
63### 停止与重调度64### 停止与重调度
64 65 
65-- 收到 `SIGINT`、`SIGTERM` 或标准输入命令 `stop` 时,`Application._handle_signal()` 设置 `stop_event`daemon loop 退出后执行 `shutdown()`:按注册逆序调用每个模块的 `stop()`,然后停止配置 watcher。66+- 收到 `SIGINT`、`SIGTERM` 或标准输入命令 `stop` 时,`Application._handle_signal()` 设置 `stop_event`daemon loop 退出后停止配置 watcher,并按初始化的逆序停止模块
66-- `Daemon.stop()` 遍历所有 service 调用 `stop()`:`EngineService.stop()` 对记录的 Engine Server PID 发送 `SIGKILL`;`LocalService.stop()` 对 memcache worker 子进程发送 `SIGKILL`。67+- `Daemon.stop()` 通过 `ProcessSupervisor` 向所有原生引擎进程组发送 `SIGTERM`;宽限期后仍未退出时发送 `SIGKILL` 清理完整进程树
67-- 任一 endpoint 连续 5 个心跳周期保持 `ABNORMAL` 时,`HeartbeatManager` 设置自杀标志。daemon loop 每 tick 检查该标志,触发后 `stop_event.set()` 并返回 `-1`,用于触发重调度68+- `Daemon.stop()` 遍历其他已启用 service;例如 `LocalService.stop()` 会停止 memcache worker 子进程
68-- `exit_code` 默认返回 `-1`与旧行为一致(-1 表示 rescheduling)69+- 任一原生引擎进程异常退出时只触发一次 Pod 级恢复不在 Pod 内重启单个 rank
70+- 任一 endpoint 连续 5 个心跳周期保持 `ABNORMAL` 时,`HeartbeatManager` 设置自杀标志。主线程执行清理后返回 `-1`,用于触发重调度。
71+- 当前 `main()` 正常退出路径同样返回 `-1`;源码注释约定 `-1` 表示 rescheduling、`0` 表示 restart。
69 72 
70## Node Manager HTTP API73## Node Manager HTTP API
71 74 
@@ -73,14 +76,14 @@ Node Manager API 默认监听 `api_config.pod_ip:api_config.node_manager_port`
73 76 
74| 方法 | 路径 | 响应 | 说明 |77| 方法 | 路径 | 响应 | 说明 |
75|------|------|----------|------|78|------|------|----------|------|
76-| `POST` | `/node-manager/start` | `200 {}` | 校验启动命令并拉起 Engine Server;快照恢复时执行恢复准备 |79+| `POST` | `/node-manager/start` | `200 {}` | 校验启动命令并拉起原生引擎;快照恢复时执行恢复准备 |
77-| `POST` | `/node-manager/stop` | `200 {"message": "All engine processes stopped successfully."}` | 停止当前 Node Manager 记录的全部 Engine Server 进程 |80+| `POST` | `/node-manager/stop` | `200 {"message": "All engine processes stopped successfully."}` | 停止当前 Node Manager 监管的全部原生引擎进程 |
78-| `POST` | `/node-manager/pause` | `200 {"status":"ok", ...}` | 将全部 endpoint 标记为 `PAUSED`,并返回 Engine Server 管理地址 |81+| `POST` | `/node-manager/pause` | `200 {"status":"ok", ...}` | 将全部 endpoint 标记为 `PAUSED`,并返回 headless 原生引擎 metrics URL |
79| `POST` | `/node-manager/resume` | `200 {"status":"ok", ...}` | 仅将 `PAUSED` endpoint 恢复为 `NORMAL` |82| `POST` | `/node-manager/resume` | `200 {"status":"ok", ...}` | 仅将 `PAUSED` endpoint 恢复为 `NORMAL` |
80| `GET` | `/node-manager/status` | `200 {"status": true/false}` | 返回全部 endpoint 是否为 `NORMAL`;无 endpoint 时为 `false` |83| `GET` | `/node-manager/status` | `200 {"status": true/false}` | 返回全部 endpoint 是否为 `NORMAL`;无 endpoint 时为 `false` |
81| `GET` | `/readiness` | `200``503` | Kubernetes Readiness Probe 接口。实例节点 Pod 默认不配置该探针;仅在容器快照默认应用场景下配置,用于判断执行容器 checkpoint 前的稳态点。未到达稳态点时返回 `503`,到达后返回 `200` |84| `GET` | `/readiness` | `200``503` | Kubernetes Readiness Probe 接口。实例节点 Pod 默认不配置该探针;仅在容器快照默认应用场景下配置,用于判断执行容器 checkpoint 前的稳态点。未到达稳态点时返回 `503`,到达后返回 `200` |
82 85 
83-`/node-manager/pause` 用于 PreStop 优雅下线:暂停状态会使 readiness 失败,并通过心跳通知 Controller;状态轮询不会用 Engine Server 返回值覆盖手动设置的 `PAUSED`。如果 PreStop 被取消,可调用 `/node-manager/resume` 恢复调度。86+`/node-manager/pause` 用于 PreStop 优雅下线:暂停状态会使 readiness 失败,并通过心跳通知 Controller;原生健康轮询不会覆盖手动设置的 `PAUSED`。响应中的 `engine_metrics_targets` 使用原生业务端口,并排除 headless 成员。如果 PreStop 被取消,可调用 `/node-manager/resume` 恢复调度。
84 87 
85`/readiness` 仅用于快照默认应用场景,即 MindCluster 实例重调度,不作为 Node Manager 的通用健康检查接口。MindCluster 通过该接口查询实例节点是否到达稳态点。在容器快照的用户自定义应用场景中,可调用 `/node-manager/status` 查询稳态点;接口返回 `200 {"status": true}` 表示已到达稳态点。88`/readiness` 仅用于快照默认应用场景,即 MindCluster 实例重调度,不作为 Node Manager 的通用健康检查接口。MindCluster 通过该接口查询实例节点是否到达稳态点。在容器快照的用户自定义应用场景中,可调用 `/node-manager/status` 查询稳态点;接口返回 `200 {"status": true}` 表示已到达稳态点。
86 89 
@@ -91,7 +94,7 @@ Node Manager API 默认监听 `api_config.pod_ip:api_config.node_manager_port`
91| `job_name` | string | 是 | 实例任务名,必须与本节点配置一致 |94| `job_name` | string | 是 | 实例任务名,必须与本节点配置一致 |
92| `role` | string | 是 | 实例角色,如 `prefill``decode``union` |95| `role` | string | 是 | 实例角色,如 `prefill``decode``union` |
93| `instance_id` | int | 是 | Controller 分配的实例 ID |96| `instance_id` | int | 是 | Controller 分配的实例 ID |
94-| `endpoints` | array | 是 | 本节点管理的 endpoint;元素包含 `id`、`ip`、`business_port`、`mgmt_port` |97+| `endpoints` | array | 是 | 本节点管理的 endpoint;元素包含 `id`、`ip`、`business_port`、`mgmt_port`,SGLang PD endpoint 还可包含 `bootstrap_port` |
95| `master_dp_ip` | string | 是 | 数据并行主节点 IP |98| `master_dp_ip` | string | 是 | 数据并行主节点 IP |
96| `ranktable` | object/null | 否 | 实例级 ranktable,默认 `null` |99| `ranktable` | object/null | 否 | 实例级 ranktable,默认 `null` |
97| `d2d_peer_ips` | array/null | 否 | D2D 权重传输对端,Controller 使用 `<endpoint_id>:<peer_ip>` 编码,默认 `null` |100| `d2d_peer_ips` | array/null | 否 | D2D 权重传输对端,Controller 使用 `<endpoint_id>:<peer_ip>` 编码,默认 `null` |
@@ -101,7 +104,7 @@ Node Manager API 默认监听 `api_config.pod_ip:api_config.node_manager_port`
101 104 
102- 启动命令内部解析异常:`400 Invalid start command payload`105- 启动命令内部解析异常:`400 Invalid start command payload`
103- `job_name`、endpoint 数量或 endpoint IP 校验失败:`422 Start command validation failed`106- `job_name`、endpoint 数量或 endpoint IP 校验失败:`422 Start command validation failed`
104-- Engine Server 拉起失败:`500 Failed to start engine server`。107+- 原生引擎拉起失败:`500 Failed to start native engine`。
105- 请求 JSON/Pydantic 字段解析异常会被外层异常处理转换为通用 `500`108- 请求 JSON/Pydantic 字段解析异常会被外层异常处理转换为通用 `500`
106- `/readiness` 在 endpoint 尚未健康或快照恢复后尚未启动时返回 `503`109- `/readiness` 在 endpoint 尚未健康或快照恢复后尚未启动时返回 `503`
107 110 
@@ -114,44 +117,36 @@ Node Manager API 默认监听 `api_config.pod_ip:api_config.node_manager_port`
114| Node Manager → Controller | `POST /controller/register` | 上报角色、模型、端口、并行配置、ranktable、`nnodes` 和快照主节点标记 |117| Node Manager → Controller | `POST /controller/register` | 上报角色、模型、端口、并行配置、ranktable、`nnodes` 和快照主节点标记 |
115| Node Manager → Controller | `POST /controller/reregister` | Controller 重启并对心跳返回 `503` 时,携带实例与 endpoint 信息重新注册 |118| Node Manager → Controller | `POST /controller/reregister` | Controller 重启并对心跳返回 `503` 时,携带实例与 endpoint 信息重新注册 |
116| Node Manager → Controller | `POST /controller/heartbeat` | 按 `heartbeat_interval_seconds` 上报各 endpoint 状态 |119| Node Manager → Controller | `POST /controller/heartbeat` | 按 `heartbeat_interval_seconds` 上报各 endpoint 状态 |
117-| Node Manager → Controller | `POST /controller/report_software_fault` | 转发 Engine Server 软件故障 |120+| Node Manager → Controller | `POST /controller/report_software_fault` | 转发引擎已有的软件故障信号 |
118 121 
119心跳使用长连接客户端,单次超时为 5 秒;TCP 请求失败时重建连接,并按 1 秒、2 秒退避重试两次。122心跳使用长连接客户端,单次超时为 5 秒;TCP 请求失败时重建连接,并按 1 秒、2 秒退避重试两次。
120 123 
121-### Engine Server124+### 原生引擎运行态
122 125 
123-`HeartbeatManager` 启动时先等待各 endpoint 管理端口可连接最长等待 60 秒之后每秒调用一次 Engine Server `GET /status`,单次请求超时5 126+`HeartbeatManager` 每秒读取 `ProcessSupervisor` 的运行态。非 headless endpoint 使用原生 `business_port/health`并沿用 `infer_tls_config`headless 成员不强造 HTTP frontend,仅检查进程存活并上报 `WAIT2START`。Controller 仅在所有可路由 endpoint `NORMAL`、所有 headless 成员至少已上报 `WAIT2START` 时将实例置为可用
124 127 
125状态轮询具有以下保护逻辑:128状态轮询具有以下保护逻辑:
126 129 
127-- Engine Server 启动后的 120宽限期内,探测到 `ABNORMAL` 时保留原状态130+- 进程拉起后进入 `STARTING`;在 `health_check_config.startup_timeout`(默认 1800内,业务端口尚未监听只表示模型仍在加载
131+- headless 进程存活时进入 `RUNNING` 并上报 `WAIT2START`,不以进程存活冒充独立服务就绪;进程退出仍上报 `ABNORMAL`
132+- 原生 `/health` 成功后进入 `READY` 并向 Controller 上报 `NORMAL`;单次请求超时仅按 `health_collector_timeout_retry_attempts` 限定次数重试,进程退出或启动窗口外重试耗尽后仍不可用则进入 `UNHEALTHY`
128- 更新 endpoint 时使用 generation 标记,避免旧探测结果覆盖 Controller 新下发的数据。133- 更新 endpoint 时使用 generation 标记,避免旧探测结果覆盖 Controller 新下发的数据。
129- 手动设置的 `PAUSED` 状态不会被轮询结果覆盖。134- 手动设置的 `PAUSED` 状态不会被轮询结果覆盖。
130-- Engine Server 返回未知状态或无效响应时,按 `ABNORMAL` 处理。
131 135 
132-## Engine Server 拉起参数136+## 原生引擎启动
133 137 
134-`Daemon.pull_engine()` 为每个 endpoint 执行138+`NativeEngineService` 构造 `LaunchContext`,Native Engine Backend 加载角色对应配置并直接生成原生命令
135 139 
136```text140```text
137-engine_server \141+vllm serve <model> <native vLLM args...>
138- --dp-rank <endpoint.id> \142+ 
139- --instance-id <instance_id> \143+python3 -m sglang.launch_server <native SGLang args...>
140- --role <prefill|decode|union> \
141- --host <endpoint.ip> \
142- --port <endpoint.business_port> \
143- --mgmt-port <endpoint.mgmt_port> \
144- --master-dp-ip <master_dp_ip> \
145- --node-rank <node_rank> \
146- --config-path <USER_CONFIG_PATH>
147```144```
148 145 
149-其他参数和环境变量146+统一上下文和环境规则
150 147 
151- 多 endpoint 模式下,按 `local_world_size` 为每个进程计算 `ASCEND_RT_VISIBLE_DEVICES`;设备编号超出末尾时循环分配。148- 多 endpoint 模式下,按 `local_world_size` 为每个进程计算 `ASCEND_RT_VISIBLE_DEVICES`;设备编号超出末尾时循环分配。
152-- 单容器模式追加 `--kv-port`、`--dp-rpc-port`,配置存在时追加 `--lookup-rpc-port`149+- 单容器端口、D2D peer角色、DP rank 和 node rank 先写入 `LaunchContext`,再由对应 Backend 映射为原生参数
153-- 开启快照时追加 `--snapshot-metadata`
154-- D2D peer 按 endpoint ID 过滤后,以逗号分隔并通过 `--d2d-peer-ips` 传递。
155- 环境中存在 `POD_IP` 且未设置 `VLLM_HOST_IP` 时,自动设置 `VLLM_HOST_IP=POD_IP`150- 环境中存在 `POD_IP` 且未设置 `VLLM_HOST_IP` 时,自动设置 `VLLM_HOST_IP=POD_IP`
156- `MOONCAKE_ASCEND_IPV6_EXPERIMENT=1` 时,默认设置 `MC_USE_IPV6=1`151- `MOONCAKE_ASCEND_IPV6_EXPERIMENT=1` 时,默认设置 `MC_USE_IPV6=1`
157 152 
@@ -159,9 +154,9 @@ endpoint 的业务端口必须处于 `[1024, 65535]`,IP 必须是合法的 IPv
159 154 
160### 跨节点 PCP155### 跨节点 PCP
161 156 
162-Node Manager 始终将 Controller 分配的 `node_rank` 作为 `--node-rank` 给 Engine Server,并将 `master_dp_ip` 作为 `--master-dp-ip` 传递157+Node Manager 始终将 Controller 分配的 `node_rank` `master_dp_ip` Native Engine Backend
163 158 
164-对于 vLLM,Engine Server 在引擎配置包含 `nnodes > 1` 且配置了 `master_port`(兼容 `master-port`)时启用跨节点 PCP:159+对于 vLLM,引擎配置包含 `nnodes > 1` 且配置了 `master_port`(兼容 `master-port`)时启用跨节点 PCP:
165 160 
166- `master_addr` 使用 `master_dp_ip`161- `master_addr` 使用 `master_dp_ip`
167- `node_rank == 0` 为主节点。162- `node_rank == 0` 为主节点。
@@ -179,19 +174,21 @@ Node Manager 从 `engine_config.nnodes` 推导每节点 `local_world_size`。当
179|--------|--------|------|174|--------|--------|------|
180| `api_config.pod_ip` | `Env.pod_ip``127.0.0.1` | 注册地址和 API 监听地址 |175| `api_config.pod_ip` | `Env.pod_ip``127.0.0.1` | 注册地址和 API 监听地址 |
181| `api_config.node_manager_port` | `1026` | Node Manager 管理端口 |176| `api_config.node_manager_port` | `1026` | Node Manager 管理端口 |
182-| `endpoint_config.base_port` | `10000` | Engine Server 端口基址;业务/管理端口按偶数/奇数生成 |177+| `endpoint_config.base_port` | `10000` | 当前控制面端口基址;业务/管理端口按偶数/奇数生成,管理端口将在剩余消费者迁移后删除 |
183| `basic_config.heartbeat_interval_seconds` | `3` | 向 Controller 上报心跳的周期 |178| `basic_config.heartbeat_interval_seconds` | `3` | 向 Controller 上报心跳的周期 |
184| `basic_config.daemon_loop_interval` | `5.0` | daemon loop 检查间隔(秒),控制自杀标志轮询和 stdin 检查频率,支持热更新 |179| `basic_config.daemon_loop_interval` | `5.0` | daemon loop 检查间隔(秒),控制自杀标志轮询和 stdin 检查频率,支持热更新 |
185| `basic_config.enable_multi_endpoints` | `true` | 是否按 DP 和设备数创建多个 endpoint |180| `basic_config.enable_multi_endpoints` | `true` | 是否按 DP 和设备数创建多个 endpoint |
186| `basic_config.nnodes` | `1` | 从 `engine_config.nnodes` 派生的跨节点数量 |181| `basic_config.nnodes` | `1` | 从 `engine_config.nnodes` 派生的跨节点数量 |
187| `kv_cache_store_config.mode` | `combined` | 部署模式:`combined` 表示 Engine 与 KV-store 在同一 Pod,`separated` 表示 KV-store 独立 Pod(不拉 Engine、不注册、不心跳) |182| `kv_cache_store_config.mode` | `combined` | 部署模式:`combined` 表示 Engine 与 KV-store 在同一 Pod,`separated` 表示 KV-store 独立 Pod(不拉 Engine、不注册、不心跳) |
188-| `mgmt_tls_config.enable_tls` | `false` | Node Manager、Controller Engine Server 管理面通信是否启用 TLS |183+| `mgmt_tls_config.enable_tls` | `false` | Node Manager Controller 管理面通信是否启用 TLS |
184+| `health_check_config.startup_timeout` | `1800` | 原生引擎模型加载启动窗口,窗口内 `/health` 未监听不判死 |
185+| `health_check_config.health_collector_timeout_retry_attempts` | `3` | 单次原生 `/health` 请求超时后的最大尝试次数;仅超时重试,包含首次请求 |
189| `fault_tolerance_config.enable_fault_tolerance` | `false` | 显式开启软件故障轮询;引擎 user config 检测到 FT 时自动开启,无需配置 |186| `fault_tolerance_config.enable_fault_tolerance` | `false` | 显式开启软件故障轮询;引擎 user config 检测到 FT 时自动开启,无需配置 |
190| `fault_tolerance_config.poll_interval_sec` | `5.0` | 轮询引擎 FT 状态的时间间隔(秒) |187| `fault_tolerance_config.poll_interval_sec` | `5.0` | 轮询引擎 FT 状态的时间间隔(秒) |
191| `fault_tolerance_config.poll_timeout_sec` | `5.0` | 单次轮询的 HTTP 超时(秒) |188| `fault_tolerance_config.poll_timeout_sec` | `5.0` | 单次轮询的 HTTP 超时(秒) |
192| `fault_tolerance_config.max_poll_failures` | `3` | 连续轮询失败阈值,达到后按 `dead` 上报 |189| `fault_tolerance_config.max_poll_failures` | `3` | 连续轮询失败阈值,达到后按 `dead` 上报 |
193-| `snapshot_config.enable_snapshot` | `false` | 是否启用容器快照流程 |190+| `snapshot_config.enable_snapshot` | `false` | 原生引擎运行时尚不支持容器快照;设为 `true` 会在配置校验阶段失败 |
194-| `snapshot_config.snapshot_metadata_path` | 空 | 自定义快照元数据路径;用户需先创建并挂载该文件。为空进入快照默认应用场景,即 MindCluster 实例重调度 |191+| `snapshot_config.snapshot_metadata_path` | 空 | 预留字段;当前原生引擎运行不消费该路径 |
195| `port_allocator_config.enable` | `true` | 是否在启动时自动检查并调整端口 |192| `port_allocator_config.enable` | `true` | 是否在启动时自动检查并调整端口 |
196 193 
197`endpoint_num``service_ports``mgmt_ports``device_num``parallel_config``model_name``engine_type``dispatch_capabilities` 主要由部署配置与引擎配置派生。`dispatch_capabilities` 不接受用户直接覆盖。194`endpoint_num``service_ports``mgmt_ports``device_num``parallel_config``model_name``engine_type``dispatch_capabilities` 主要由部署配置与引擎配置派生。`dispatch_capabilities` 不接受用户直接覆盖。
@@ -220,7 +217,21 @@ services/
220 __init__.py217 __init__.py
221 protocols.py ← DaemonService, PreparableService(接口契约)218 protocols.py ← DaemonService, PreparableService(接口契约)
222 registry.py ← _ServiceRegistry(服务发现与注册中心)219 registry.py ← _ServiceRegistry(服务发现与注册中心)
223- engine.py ← EngineService(Engine 子进程生命周期)220+ native_engine/
221+ __init__.py
222+ service.py ← NativeEngineService(原生引擎服务编排)
223+ models.py ← LaunchContext、LaunchSpec、ProbeSpec 和 RuntimeState
224+ supervisor.py ← 公共进程组、健康探测和状态管理
225+ factory.py ← 按 engine_type 选择 Backend
226+ config_factory.py ← 延迟加载引擎配置转换器
227+ backends/
228+ base.py ← NativeEngineBackend 和 IConfig 接口
229+ vllm/
230+ backend.py ← vLLM 启动策略
231+ config.py ← vLLM 参数转换与校验
232+ sglang/
233+ backend.py ← SGLang 启动策略
234+ config.py ← SGLang 参数转换与校验
224 memcache/235 memcache/
225 __init__.py236 __init__.py
226 worker.py ← memcache worker 子进程入口(DistributedObjectStore)237 worker.py ← memcache worker 子进程入口(DistributedObjectStore)
@@ -235,7 +246,7 @@ services/
235from motor.node_manager.core.services.registry import register_service246from motor.node_manager.core.services.registry import register_service
236 247 
237@register_service("engine", backend="engine")248@register_service("engine", backend="engine")
238-class EngineService:249+class NativeEngineService:
239 ...250 ...
240 251 
241@register_service("kv_store", backend="memcache", prepare_priority=10)252@register_service("kv_store", backend="memcache", prepare_priority=10)
@@ -255,6 +266,17 @@ class LocalService:
255 266 
256`registry.discover(services)` 解析逗号分隔的服务列表,导入对应模块的 `@register_service` 触发注册。267`registry.discover(services)` 解析逗号分隔的服务列表,导入对应模块的 `@register_service` 触发注册。
257 268 
269+### 新增原生引擎 Backend
270+ 
271+vLLM 和 SGLang 是 `NativeEngineService` 的引擎 Backend,不是独立的 Daemon service。新增原生引擎时:
272+ 
273+1.`native_engine/backends/<engine>/` 中实现 `backend.py``config.py`
274+2. Backend 只负责引擎差异化的配置转换、启动命令、角色校验和探针规格。
275+3.`native_engine/factory.py` 注册 Backend,在 `native_engine/config_factory.py` 注册配置转换器。
276+4. 进程启动、进程组清理、健康探测和状态机继续复用公共 `ProcessSupervisor`
277+ 
278+不要在引擎 Backend 中直接调用 `subprocess.Popen`,也不要为每个引擎复制停止和探测状态机。
279+ 
258### 新增后端280### 新增后端
259 281 
260新增 KV 后端只需两步,无需修改现有代码:282新增 KV 后端只需两步,无需修改现有代码:
@@ -299,32 +321,11 @@ GET http://{endpoint.ip}:{endpoint.business_port}/fault_tolerance/status
299 321 
300## 容器快照322## 容器快照
301 323 
302-开启 `snapshot_config.enable_snapshot` 后:324+当前 Node Manager 已切换为直接拉起原生 vLLM/SGLang,但原生 `suspend/resume/device-unlock`
325+控制接口尚未接入。为避免仅校验 metadata 文件、实际却未执行快照操作,配置
326+`snapshot_config.enable_snapshot=true` 会在启动前明确失败。
303 327 
304-- Node Manager 不支持配置热更新, 不启动配置文件 watcher328+ Native Engine Backend 实现原生快照控制契约后,再恢复 metadata、checkpoint barrier 和恢复编排
305-- 配置为空时进入快照默认应用场景,即 MindCluster 实例重调度:容器快照镜像由 MindCluster 制作;MindCluster 通过 ConfigMap 挂载快照元数据,NodeManager 将挂载文件复制到默认可写路径 `/snapshot/snapshot_metadata.json` 后交给 Engine Server 使用。
306-- 自定义路径场景下,用户需预先创建并挂载快照元数据文件;框架读取或更新该文件,并将其路径传给 Engine Server,不负责该文件的创建和挂载。
307-- 快照制作阶段,Engine Server 完成 suspend 后,其管理面状态由 `INIT` 变为 `NORMAL`。当本节点全部 Engine Server 均完成 suspend 时,表示实例节点容器已到达稳态点:快照默认应用场景通过 `/readiness` 返回 `200` 判断;用户自定义应用场景通过 `/node-manager/status` 返回 `200 {"status": true}` 判断。
308-- 查询到稳态点后,对实例节点容器执行 checkpoint,并保存容器 Host 快照镜像。
309-- 处于容器快照镜像 checkpoint 过程中的实例无法提供服务, 当 NodeManager 状态已正常但 checkpoint 尚未完成时,此时暂停向 Controller 上报心跳。
310-- 快照恢复后,先从元数据恢复 `job_name``namespace`,刷新 Pod IP 与 Controller DNS,再重新注册。
311-- Controller 再次调用 `/node-manager/start` 时,Node Manager 只准备快照恢复阶段需要的 `model_load_path``data_parallel_master_ip` 元数据,但不重新创建 Engine Server 进程。
312-- 快照恢复后未收到启动命令前,readiness 始终为未就绪。
313- 
314-### 快照元数据字段
315- 
316-快照元数据文件必须是 JSON 对象,以下字段的值均为字符串。自定义应用场景下,用户需按字段所处阶段提前准备元数据。
317- 
318-| 字段 | 使用阶段 | 准备要求 | 说明 |
319-|------|----------|----------|------|
320-| `model_save_path` | 快照制作 | 制作容器快照前必须准备 | Device 快照保存时,容器内运行时权重的落盘路径,必须是宿主机挂载路径 |
321-| `model_load_path` | 快照恢复 | 从容器快照恢复前必须准备 | Device 快照恢复时,容器内运行时权重的加载路径,必须是宿主机挂载路径 |
322-| `job_name` | 快照恢复 | 从容器快照恢复前必须准备 | 恢复后注册时用于更新 Node Manager 的任务名 |
323-| `namespace` | 快照恢复 | Controller 使用集群内 `.svc.cluster.local` DNS 时必须准备 | 恢复后注册时用于将 Controller DNS 更新到快照所属 namespace;非集群 DNS 场景可不配置 |
324-| `data_parallel_master_ip` | 快照恢复 | 可不预先配置, 由controller下发 | 优先使用文件中的值;未配置时,Node Manager 写入 Controller 下发的 `master_dp_ip` |
325-| `checkpoint` | 快照制作 | Host 侧 checkpoint 完成后写入 | 用户或 MindCluster 将其更新为 `"done"`,框架据此解锁 Device 并恢复冷启动实例业务 |
326- 
327-因此,自定义应用场景从容器快照恢复前,至少需要准备 `model_load_path``job_name`;使用集群内 Controller DNS 时还需准备 `namespace`。元数据中的其他未知字段不会被 Node Manager 使用。
328 329 
329## 使用样例330## 使用样例
330 331 
@@ -350,8 +351,8 @@ curl -i http://127.0.0.1:1026/readiness
350- 日志持续出现 `Registration attempt N failed`:检查 Controller DNS、端口、TLS 配置和网络连通性;Node Manager 会持续重试注册,不会因此退出。351- 日志持续出现 `Registration attempt N failed`:检查 Controller DNS、端口、TLS 配置和网络连通性;Node Manager 会持续重试注册,不会因此退出。
351- 日志出现 `Start command validation failed`:检查 Controller 下发的 `job_name`、endpoint 数量和 endpoint IP 是否与 Node Manager 配置一致。352- 日志出现 `Start command validation failed`:检查 Controller 下发的 `job_name`、endpoint 数量和 endpoint IP 是否与 Node Manager 配置一致。
352- 日志出现 `Invalid endpoint parameters`:检查 endpoint IP 与业务端口,业务端口必须处于 `[1024, 65535]`353- 日志出现 `Invalid endpoint parameters`:检查 endpoint IP 与业务端口,业务端口必须处于 `[1024, 65535]`
353-- 日志出现 `Engine process exited immediately`:Engine Server 在 `Popen` 后立即退出,需继续检查 Engine Server 日志、配置路径和启动参数。354+- 日志出现 `Engine process exited immediately`:原生引擎在 `Popen` 后立即退出,需继续检查原生引擎日志、配置路径和启动参数。
354- `/readiness` 返回 `503`:无 endpoint、存在非 `NORMAL` endpoint、处于 `PAUSED`,或快照恢复后尚未收到启动命令。355- `/readiness` 返回 `503`:无 endpoint、存在非 `NORMAL` endpoint、处于 `PAUSED`,或快照恢复后尚未收到启动命令。
355-- 连续出现 `Consecutive abnormal heartbeat count: 5/5`:Node Manager 将清理 Engine Server 并以 `-1` 退出触发重调度。356+- 连续出现 `Consecutive abnormal heartbeat count: 5/5`:Node Manager 将清理原生引擎进程组并以 `-1` 退出触发 Pod 级重调度。
356 357 
357相关单元测试位于 `tests/node_manager/`;优雅暂停流程测试位于 `tests/e2e/test_prestop_e2e.py`358相关单元测试位于 `tests/node_manager/`;优雅暂停流程测试位于 `tests/e2e/test_prestop_e2e.py`
@@ -280,10 +280,10 @@ IP与端口参见[内部接口的IP/端口](./README.md#内部接口的ip端口)
280Coordinator 在识别到实例 `engine_type=sglang` 时,会在发往业务口的请求中直接注入原生字段:280Coordinator 在识别到实例 `engine_type=sglang` 时,会在发往业务口的请求中直接注入原生字段:
281 281 
282- `bootstrap_host`:Prefill 实例 IP282- `bootstrap_host`:Prefill 实例 IP
283-- `bootstrap_port`:环境变量 `DISAGGREGATION_BOOTSTRAP_PORT`(Coordinator 与引擎 Pod 需一致)283+- `bootstrap_port`:Prefill Endpoint 注册的原生 PD bootstrap 端口
284- `bootstrap_room`:由 `pair_id` + `attempt_seq` 稳定派生284- `bootstrap_room`:由 `pair_id` + `attempt_seq` 稳定派生
285 285 
286-此时**不会**再附带 `_motor_dispatch`。因此 Coordinator 对 SGLang 存在引擎协议耦合;vLLM PD 仍走 `_motor_dispatch` / InferEndpoint(非 native)路径286+此时**不会**再附带 `_motor_dispatch`。SGLang vLLM 的原生 PD 请求差异由 Coordinator 的协议适配器处理
287 287 
288### 取消 / stop288### 取消 / stop
289 289 
@@ -219,7 +219,6 @@ DEFAULT_MMC_LOCAL_CONFIG_PATH = "/usr/local/Ascend/pyMotor/conf/mmc-local-inproc
219ENV_MMC_LOCAL_SERVICE_MODE = "MMC_LOCAL_SERVICE_MODE"219ENV_MMC_LOCAL_SERVICE_MODE = "MMC_LOCAL_SERVICE_MODE"
220MMC_LOCAL_SERVICE_CONFIG_KEY = "local_service_mode"220MMC_LOCAL_SERVICE_CONFIG_KEY = "local_service_mode"
221 221 
222-ENV_DISAGGREGATION_BOOTSTRAP_PORT = "DISAGGREGATION_BOOTSTRAP_PORT"
223ENV_ASCEND_MF_STORE_URL = "ASCEND_MF_STORE_URL"222ENV_ASCEND_MF_STORE_URL = "ASCEND_MF_STORE_URL"
G
Gganglv19 天前

严重程度: 建议

问题: 删除 DISAGGREGATION_BOOTSTRAP_PORT 环境变量的唯一注入来源,但 engine_server 进程内的 SGLang dispatch 适配器仍硬依赖该变量,形成断链。

原因: 本 PR 删除此处常量定义及 coordinator.py/infer_service.py 的注入逻辑,并删除了协调器侧唯一消费方 sglang_native_dispatch.py;但 motor/engine_server/core/dispatch_adapter/sglang_adapter.py:30-34 仍从进程环境读取该变量(未设置时对 SGLang dispatch 请求返回 500),tests/engine_server/core/test_dispatch_adapter.py 仍为其保留测试,说明该路径仍被视为可运行。若仍以旧方式部署 engine_server 走 _motor_dispatch/SGLang 适配器必然 500;且文档(engine_server_interfaces.md)声称 bootstrap_port 来自“Prefill Endpoint 注册的原生 PD bootstrap 端口”,与实现不一致。

怎么改: 二选一:a) 若 engine_server 的 SGLang 进程内 dispatch 路径已废弃,删除 sglang_adapter.py 及其测试;b) 若保留,则改为从请求体/上下文(endpoint 注册的 bootstrap_port)读取,删除环境变量依赖,与文档保持一致。

likedislike
tobking
tobking
19 天前 评论:
224ENV_ASCEND_MF_STORE_PORT = "ASCEND_MF_STORE_PORT"223ENV_ASCEND_MF_STORE_PORT = "ASCEND_MF_STORE_PORT"
225ENV_ASCEND_MF_TRANSFER_PROTOCOL = "ASCEND_MF_TRANSFER_PROTOCOL"224ENV_ASCEND_MF_TRANSFER_PROTOCOL = "ASCEND_MF_TRANSFER_PROTOCOL"
@@ -69,16 +69,6 @@ def modify_coordinator_deployment(deployment_data, user_config):
69 69 
70 container[C.ENV].extend(k8s_utils.build_kv_store_env_items())70 container[C.ENV].extend(k8s_utils.build_kv_store_env_items())
71 71 
72- disaggregation_bootstrap_port = (
73- user_config.get(C.MOTOR_ENGINE_PREFILL_CONFIG, {})
74- .get(C.ENGINE_CONFIG, {})
75- .get("disaggregation_bootstrap_port", "")
76- )
77- if disaggregation_bootstrap_port:
78- container[C.ENV].append(
79- {C.NAME: C.ENV_DISAGGREGATION_BOOTSTRAP_PORT, C.VALUE: str(disaggregation_bootstrap_port)}
80- )
81- 
82 modify_coordinator_replicas(deployment_data, user_config)72 modify_coordinator_replicas(deployment_data, user_config)
83 pod_spec = deployment_data[C.SPEC][C.TEMPLATE][C.SPEC]73 pod_spec = deployment_data[C.SPEC][C.TEMPLATE][C.SPEC]
84 apply_node_selector_override(pod_spec, deploy_config, C.COORDINATOR_NODE_SELECTOR)74 apply_node_selector_override(pod_spec, deploy_config, C.COORDINATOR_NODE_SELECTOR)
@@ -169,19 +169,6 @@ def _configure_coordinator_role(infer_doc, user_config):
169 }169 }
170 )170 )
171 171 
172- disaggregation_bootstrap_port = (
173- user_config.get(C.MOTOR_ENGINE_PREFILL_CONFIG, {})
174- .get(C.ENGINE_CONFIG, {})
175- .get("disaggregation_bootstrap_port", "")
176- )
177- if disaggregation_bootstrap_port:
178- coordinator_env.append(
179- {
180- C.NAME: C.ENV_DISAGGREGATION_BOOTSTRAP_PORT,
181- C.VALUE: str(disaggregation_bootstrap_port),
182- }
183- )
184- 
185 if coordinator_env:172 if coordinator_env:
186 set_container_env(container, coordinator_env)173 set_container_env(container, coordinator_env)
187 174 
@@ -70,7 +70,7 @@ NodeManager 端口从 `user_config.json` 读取,缺省为 **1026**。
70 70 
71| 接口 | 说明 |71| 接口 | 说明 |
72|------|------|72|------|------|
73-| `POST /node-manager/pause` | PreStop 调用,返回 `engine_mgmt_addrs` |73+| `POST /node-manager/pause` | PreStop 调用,返回 headless 原生引擎的 `engine_metrics_targets` |
74| `POST /node-manager/resume` | PreStop 取消时恢复(如滚动回滚) |74| `POST /node-manager/resume` | PreStop 取消时恢复(如滚动回滚) |
75 75 
76## 日志76## 日志
@@ -14,8 +14,8 @@ Prestop graceful shutdown for Kubernetes PreStop hook.
14Flow:14Flow:
151. Read NodeManager port from user_config.json (api_config.node_manager_port)151. Read NodeManager port from user_config.json (api_config.node_manager_port)
162. POST /node-manager/pause to local NodeManager → endpoints set to PAUSED162. POST /node-manager/pause to local NodeManager → endpoints set to PAUSED
17-3. Read engine_mgmt_addrs from the pause response17+3. Read native engine_metrics_targets from the pause response
18-4. Poll engine /metrics locally (num_requests_waiting / running) until18+4. Poll native engine /metrics (num_requests_waiting / running) until
19 all drain to zero, then exit (Pod terminates).19 all drain to zero, then exit (Pod terminates).
20 20 
21Config is read from CONFIG_PATH or CONFIGMAP_PATH env var (same pattern as probe.py).21Config is read from CONFIG_PATH or CONFIGMAP_PATH env var (same pattern as probe.py).
@@ -105,6 +105,31 @@ def get_nm_port(config):
105 return DEFAULT_NM_PORT105 return DEFAULT_NM_PORT
106 106 
107 107 
108+def get_infer_tls_config(config):
109+ """Return the inference TLS section used by native engine endpoints."""
110+ tls_config = get_val_by_key_path(config, "motor_deploy_config.tls_config.infer_tls_config")
111+ return tls_config if isinstance(tls_config, dict) else {}
112+ 
113+ 
114+def build_curl_tls_args(tls_config):
115+ """Map Motor inference TLS fields to curl arguments."""
116+ if not tls_config.get("enable_tls", False):
117+ return []
118+ 
119+ args = []
120+ field_to_option = (
121+ ("ca_file", "--cacert"),
122+ ("cert_file", "--cert"),
123+ ("key_file", "--key"),
124+ ("crl_file", "--crlfile"),
125+ )
126+ for field, option in field_to_option:
127+ value = tls_config.get(field)
128+ if value:
129+ args.extend([option, str(value)])
130+ return args
131+ 
132+ 
108def _http_post_json(url, timeout=10):133def _http_post_json(url, timeout=10):
109 """HTTP POST via curl. Returns (status_code, body_text) or (None, None)."""134 """HTTP POST via curl. Returns (status_code, body_text) or (None, None)."""
110 try:135 try:
@@ -143,11 +168,14 @@ def _http_post_json(url, timeout=10):
143 return None, None168 return None, None
144 169 
145 170 
146-def _http_get_text(url, timeout=5):171+def _http_get_text(url, timeout=5, extra_args=None):
147 """HTTP GET via curl. Returns response body text or None."""172 """HTTP GET via curl. Returns response body text or None."""
148 try:173 try:
174+ command = ["curl", "-s", "-X", "GET", url]
175+ command.extend(extra_args or [])
176+ command.extend(["--connect-timeout", str(timeout), "--max-time", str(timeout)])
149 result = subprocess.run( # nosec B607177 result = subprocess.run( # nosec B607
150- ["curl", "-s", "-X", "GET", url, "--connect-timeout", str(timeout), "--max-time", str(timeout)],178+ command,
151 capture_output=True,179 capture_output=True,
152 text=True,180 text=True,
153 timeout=timeout + 2,181 timeout=timeout + 2,
@@ -180,15 +208,52 @@ def send_pause(node_manager_url, config):
180 return None208 return None
181 209 
182 210 
183-def get_engine_metrics(engine_mgmt_addr):211+ENGINE_METRIC_NAMES = {
184- """Query engine /metrics locally and sum num_requests_waiting/running."""212+ "vllm": ("vllm:num_requests_waiting", "vllm:num_requests_running"),
185- url = f"http://{engine_mgmt_addr}/metrics"213+ "sglang": ("sglang:num_waiting_reqs", "sglang:num_running_reqs"),
186- text = _http_get_text(url, timeout=10)214+}
215+ 
216+ 
217+def get_engine_type(config):
218+ """Read the active engine type from a role-specific or union section."""
219+ role = os.environ.get("ROLE", "").strip().lower()
220+ section_names = {
221+ "prefill": ("motor_engine_prefill_config",),
222+ "decode": ("motor_engine_decode_config",),
223+ "union": ("motor_engine_union_config",),
224+ "both": ("motor_engine_union_config",),
225+ }.get(role, ())
226+ candidates = section_names + (
227+ "motor_engine_union_config",
228+ "motor_engine_prefill_config",
229+ "motor_engine_decode_config",
230+ )
231+ for section_name in candidates:
232+ section = config.get(section_name)
233+ if isinstance(section, dict) and section.get("engine_type"):
234+ return str(section["engine_type"]).strip().lower()
235+ return "vllm"
236+ 
237+ 
238+def get_engine_metrics(metrics_target, tls_config, engine_type="vllm"):
239+ """Query a native engine metrics URL and sum waiting/running requests."""
240+ text = _http_get_text(
241+ metrics_target,
242+ timeout=10,
243+ extra_args=build_curl_tls_args(tls_config),
244+ )
187 if not text:245 if not text:
188 return None246 return None
189 247 
248+ metric_names = ENGINE_METRIC_NAMES.get(str(engine_type).strip().lower())
249+ if metric_names is None:
250+ logger.error("Unsupported engine type for prestop metrics: %s", engine_type)
251+ return None
252+ waiting_name, running_name = metric_names
190 waiting = 0253 waiting = 0
191 running = 0254 running = 0
255+ found_waiting = False
256+ found_running = False
192 for line in text.split("\n"):257 for line in text.split("\n"):
193 if line.startswith("#") or not line.strip():258 if line.startswith("#") or not line.strip():
194 continue259 continue
@@ -196,14 +261,57 @@ def get_engine_metrics(engine_mgmt_addr):
196 if not m:261 if not m:
197 continue262 continue
198 val = int(float(m.group(1)))263 val = int(float(m.group(1)))
199- if "num_requests_waiting" in line:264+ metric_name = line.split("{", 1)[0].split(None, 1)[0]
265+ if metric_name == waiting_name:
200 waiting += val266 waiting += val
201- elif "num_requests_running" in line:267+ found_waiting = True
268+ elif metric_name == running_name:
202 running += val269 running += val
270+ found_running = True
271+ 
272+ if not found_waiting or not found_running:
273+ logger.warning(
274+ "Required %s drain metrics are missing from %s",
275+ engine_type,
276+ metrics_target,
277+ )
278+ return None
203 279 
204 return {"waiting": waiting, "running": running}280 return {"waiting": waiting, "running": running}
205 281 
206 282 
283+def wait_for_engine_drain(engine_metrics_targets, tls_config, engine_type, max_wait, poll_interval):
284+ """Wait until every engine reports zero active requests or the deadline expires."""
285+ start_time = time.monotonic()
286+ while time.monotonic() - start_time < max_wait:
287+ total_waiting = 0
288+ total_running = 0
289+ metrics_available = True
290+ 
291+ for target in engine_metrics_targets:
292+ metrics = get_engine_metrics(target, tls_config, engine_type)
293+ if metrics is None:
294+ logger.warning("Engine drain metrics unavailable from %s; retrying", target)
295+ metrics_available = False
296+ break
297+ total_waiting += metrics["waiting"]
298+ total_running += metrics["running"]
299+ 
300+ if metrics_available:
301+ total_active = total_waiting + total_running
302+ logger.info("active=%d (waiting=%d, running=%d)", total_active, total_waiting, total_running)
303+ if total_active == 0:
304+ elapsed = time.monotonic() - start_time
305+ logger.info("All requests drained after %.1fs", elapsed)
306+ return True
307+ 
308+ time.sleep(poll_interval)
309+ 
310+ elapsed = time.monotonic() - start_time
311+ logger.info("Timeout after %.1fs, stopping anyway", elapsed)
312+ return False
313+ 
314+ 
207def main():315def main():
208 """Main prestop function.316 """Main prestop function.
209 317 
@@ -244,47 +352,24 @@ def main():
244 logger.warning("Terminate request failed, exiting anyway")352 logger.warning("Terminate request failed, exiting anyway")
245 sys.exit(0)353 sys.exit(0)
246 354 
247- engine_mgmt_addrs = response.get("engine_mgmt_addrs", [])355+ engine_metrics_targets = response.get("engine_metrics_targets", [])
248- if not engine_mgmt_addrs:356+ if not engine_metrics_targets:
249- logger.error("No engine_mgmt_addrs in pause response, exiting")357+ logger.error("No engine_metrics_targets in pause response, exiting")
250 sys.exit(0)358 sys.exit(0)
251 359 
252- logger.info("Engine mgmt addresses: %s", engine_mgmt_addrs)360+ logger.info("Native engine metrics targets: %s", engine_metrics_targets)
361+ infer_tls_config = get_infer_tls_config(config)
362+ engine_type = get_engine_type(config)
253 363 
254 # Step 2: Poll engine /metrics locally until requests drain364 # Step 2: Poll engine /metrics locally until requests drain
255 logger.info("Polling engine metrics...")365 logger.info("Polling engine metrics...")
256- start_time = time.time()366+ wait_for_engine_drain(
257- 367+ engine_metrics_targets,
258- while time.time() - start_time < args.max_wait:368+ infer_tls_config,
259- total_waiting = 0369+ engine_type,
260- total_running = 0370+ args.max_wait,
261- all_ok = True371+ args.poll_interval,
262- 372+ )
263- for addr in engine_mgmt_addrs:
264- metrics = get_engine_metrics(addr)
265- if metrics is None:
266- logger.debug("Engine %s unreachable", addr)
267- all_ok = False
268- break
269- total_waiting += metrics["waiting"]
270- total_running += metrics["running"]
271- 
272- if not all_ok:
273- logger.warning("Engine unreachable, exiting")
274- break
275- 
276- total_active = total_waiting + total_running
277- logger.info("active=%d (waiting=%d, running=%d)", total_active, total_waiting, total_running)
278- 
279- if total_active == 0:
280- elapsed = time.time() - start_time
281- logger.info("All requests drained after %.1fs", elapsed)
282- break
283- 
284- time.sleep(args.poll_interval)
285- else:
286- elapsed = time.time() - start_time
287- logger.info("Timeout after %.1fs, stopping anyway", elapsed)
288 373 
289 sys.exit(0)374 sys.exit(0)
290 375 
@@ -56,7 +56,7 @@ cp -n .env.example .env # launch.sh 在无 .env 时也会自动从 .env.exampl
56| 观测能力 | 是否需改 pyMotor 配置 | 需要的配置 |56| 观测能力 | 是否需改 pyMotor 配置 | 需要的配置 |
57|----------|----------------------|-----------|57|----------|----------------------|-----------|
58| Coordinator 基础指标(指标总览 / KV 缓存的请求数、KV、吞吐、延迟等) | **否** | Coordinator 默认在管理端口暴露 `/metrics``/instance/metrics`,无需额外配置;只需保证该端口可被观测机或主机端口转发访问 |58| Coordinator 基础指标(指标总览 / KV 缓存的请求数、KV、吞吐、延迟等) | **否** | Coordinator 默认在管理端口暴露 `/metrics``/instance/metrics`,无需额外配置;只需保证该端口可被观测机或主机端口转发访问 |
59-| Engine / vLLM 指标 | **否**(默认开启) | Engine 管理端口(默认 `10001`)暴露 `/metrics`;保证端口可达即可 |59+| Engine / vLLM 指标 | **否**(默认开启) | 原生引擎业务端口暴露 `/metrics`;Coordinator 使用 `infer_tls_config` 直接采集,需保证业务端口可达 |
60| Tracing(Tempo 链路) | **是** | 见下方「1.4.1 Tracing 接入」 |60| Tracing(Tempo 链路) | **是** | 见下方「1.4.1 Tracing 接入」 |
61| 引擎性能剖析(`vllm_profiling_*`) | **是** | 需安装并开启 `ms_service_metric`,见下方「1.4.2 Profiling 接入」 |61| 引擎性能剖析(`vllm_profiling_*`) | **是** | 需安装并开启 `ms_service_metric`,见下方「1.4.2 Profiling 接入」 |
62 62 
@@ -329,7 +329,7 @@ MOTOR_NAMESPACE=<namespace> ./launch.sh --native
329export MOTOR_NAMESPACE=<namespace> # K8s namespace / job_id329export MOTOR_NAMESPACE=<namespace> # K8s namespace / job_id
330export MOTOR_NODE_IP=<node-ip> # NodePort 访问 IP330export MOTOR_NODE_IP=<node-ip> # NodePort 访问 IP
331export MOTOR_USER_CONFIG=/path/user_config.json331export MOTOR_USER_CONFIG=/path/user_config.json
332-export MOTOR_ENGINE_MGMT_PORT=10001 # Engine /metrics 管理端口,默认 10001332+export MOTOR_ENGINE_BUSINESS_PORT=10000 # 原生引擎 /metrics 业务端口,默认 10000
333export OBS_HOST=<obs-host> # pyMotor 上报 tracing / OTLP 的观测主机333export OBS_HOST=<obs-host> # pyMotor 上报 tracing / OTLP 的观测主机
334export OBS_STACK_MODE=minimal|full # 未传 --minimal/--full 时生效334export OBS_STACK_MODE=minimal|full # 未传 --minimal/--full 时生效
335export PROXY_SH=/path/to/pymotor-proxy.env # native runtime 下载二进制(dotenv 格式,可选;见 §2.4)335export PROXY_SH=/path/to/pymotor-proxy.env # native runtime 下载二进制(dotenv 格式,可选;见 §2.4)
@@ -366,7 +366,7 @@ launch.sh
366- **Namespace**`--namespace` / `MOTOR_NAMESPACE``user_config.json``motor_deploy_config.job_id` → 扫描含 Coordinator observability NodePort 的 namespace。366- **Namespace**`--namespace` / `MOTOR_NAMESPACE``user_config.json``motor_deploy_config.job_id` → 扫描含 Coordinator observability NodePort 的 namespace。
367- **Node IP**`--node-ip` / `MOTOR_NODE_IP` → Coordinator Pod `hostIP` → Kubernetes Node `InternalIP`367- **Node IP**`--node-ip` / `MOTOR_NODE_IP` → Coordinator Pod `hostIP` → Kubernetes Node `InternalIP`
368- **Coordinator**:自动发现 observability NodePort(默认服务端口 `1027`),生成 `/metrics``type=instance|role|dp|node` 等指标端点。368- **Coordinator**:自动发现 observability NodePort(默认服务端口 `1027`),生成 `/metrics``type=instance|role|dp|node` 等指标端点。
369-- **Engine**:优先 Engine metrics NodePort;无 NodePort 时回退 PodIP + `MOTOR_ENGINE_MGMT_PORT`(默认 `10001`),识别 `vllm-p0` / `vllm-d0` 等命名并推断 `pd_role` 与 `instance_id`。369+- **Engine**:优先原生引擎 metrics NodePort;无 NodePort 时回退 PodIP + `MOTOR_ENGINE_BUSINESS_PORT`(默认 `10000`),识别 `vllm-p0` / `vllm-d0` 等命名并推断 `pd_role` 与 `instance_id`。
370- **Tracing**:写入 `OBS_HOST``OTLP_HTTP_ENDPOINT=http://<obs-host>:4318/v1/traces``OTLP_GRPC_ENDPOINT=http://<obs-host>:4317`370- **Tracing**:写入 `OBS_HOST``OTLP_HTTP_ENDPOINT=http://<obs-host>:4318/v1/traces``OTLP_GRPC_ENDPOINT=http://<obs-host>:4317`
371 371 
372### 3.7 默认端口372### 3.7 默认端口
@@ -9,7 +9,7 @@ cd "${SCRIPT_DIR}"
9NAMESPACE="${MOTOR_NAMESPACE:-}"9NAMESPACE="${MOTOR_NAMESPACE:-}"
10NODE_IP="${MOTOR_NODE_IP:-}"10NODE_IP="${MOTOR_NODE_IP:-}"
11USER_CONFIG="${MOTOR_USER_CONFIG:-}"11USER_CONFIG="${MOTOR_USER_CONFIG:-}"
12-ENGINE_MGMT_PORT="${MOTOR_ENGINE_MGMT_PORT:-10001}"12+ENGINE_BUSINESS_PORT="${MOTOR_ENGINE_BUSINESS_PORT:-10000}"
13OBS_HOST_INPUT="${OBS_HOST:-}"13OBS_HOST_INPUT="${OBS_HOST:-}"
14 14 
15FORCE_NATIVE=015FORCE_NATIVE=0
@@ -36,7 +36,7 @@ Environment:
36 MOTOR_NAMESPACE36 MOTOR_NAMESPACE
37 MOTOR_NODE_IP37 MOTOR_NODE_IP
38 MOTOR_USER_CONFIG38 MOTOR_USER_CONFIG
39- MOTOR_ENGINE_MGMT_PORT39+ MOTOR_ENGINE_BUSINESS_PORT
40 OBS_HOST40 OBS_HOST
41 PROXY_SH dotenv file for native binary downloads only (see SERVICE_GUIDE.md §2.4)41 PROXY_SH dotenv file for native binary downloads only (see SERVICE_GUIDE.md §2.4)
42 42 
@@ -108,7 +108,7 @@ DISCOVERY_RUNTIME="docker"
108if [[ "${FORCE_NATIVE}" -eq 1 ]]; then108if [[ "${FORCE_NATIVE}" -eq 1 ]]; then
109 DISCOVERY_RUNTIME="native"109 DISCOVERY_RUNTIME="native"
110fi110fi
111-DISCOVERY_CMD=(python3 "./scripts/discover-targets.py" "--output-dir" "./generated" "--engine-mgmt-port" "${ENGINE_MGMT_PORT}" "--runtime" "${DISCOVERY_RUNTIME}")111+DISCOVERY_CMD=(python3 "./scripts/discover-targets.py" "--output-dir" "./generated" "--engine-business-port" "${ENGINE_BUSINESS_PORT}" "--runtime" "${DISCOVERY_RUNTIME}")
112[[ -n "${NAMESPACE}" ]] && DISCOVERY_CMD+=("--namespace" "${NAMESPACE}")112[[ -n "${NAMESPACE}" ]] && DISCOVERY_CMD+=("--namespace" "${NAMESPACE}")
113[[ -n "${NODE_IP}" ]] && DISCOVERY_CMD+=("--node-ip" "${NODE_IP}")113[[ -n "${NODE_IP}" ]] && DISCOVERY_CMD+=("--node-ip" "${NODE_IP}")
114[[ -n "${USER_CONFIG}" ]] && DISCOVERY_CMD+=("--user-config" "${USER_CONFIG}")114[[ -n "${USER_CONFIG}" ]] && DISCOVERY_CMD+=("--user-config" "${USER_CONFIG}")
@@ -134,7 +134,7 @@ fi
134run_native() {134run_native() {
135 echo "[launch] starting native runtime..."135 echo "[launch] starting native runtime..."
136 echo "[launch] refreshing discovery for native runtime..."136 echo "[launch] refreshing discovery for native runtime..."
137- NATIVE_DISCOVERY_CMD=(python3 "./scripts/discover-targets.py" "--output-dir" "./generated" "--engine-mgmt-port" "${ENGINE_MGMT_PORT}" "--runtime" "native")137+ NATIVE_DISCOVERY_CMD=(python3 "./scripts/discover-targets.py" "--output-dir" "./generated" "--engine-business-port" "${ENGINE_BUSINESS_PORT}" "--runtime" "native")
138 [[ -n "${NAMESPACE}" ]] && NATIVE_DISCOVERY_CMD+=("--namespace" "${NAMESPACE}")138 [[ -n "${NAMESPACE}" ]] && NATIVE_DISCOVERY_CMD+=("--namespace" "${NAMESPACE}")
139 [[ -n "${NODE_IP}" ]] && NATIVE_DISCOVERY_CMD+=("--node-ip" "${NODE_IP}")139 [[ -n "${NODE_IP}" ]] && NATIVE_DISCOVERY_CMD+=("--node-ip" "${NODE_IP}")
140 [[ -n "${USER_CONFIG}" ]] && NATIVE_DISCOVERY_CMD+=("--user-config" "${USER_CONFIG}")140 [[ -n "${USER_CONFIG}" ]] && NATIVE_DISCOVERY_CMD+=("--user-config" "${USER_CONFIG}")
@@ -1,5 +1,12 @@
1-#!/usr/bin/env python31+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# -*- coding: utf-8 -*-2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
3 10 
4"""11"""
5Auto-discover pyMotor observability endpoints and generate runtime configs.12Auto-discover pyMotor observability endpoints and generate runtime configs.
@@ -16,10 +23,10 @@ import subprocess
16import sys23import sys
17from dataclasses import dataclass24from dataclasses import dataclass
18from pathlib import Path25from pathlib import Path
19-from typing import Any, Dict, List, Optional, Sequence, Tuple26+from typing import Any, Sequence
20 27 
21DEFAULT_COORDINATOR_PORT = 102728DEFAULT_COORDINATOR_PORT = 1027
22-DEFAULT_ENGINE_PORT = 1000129+DEFAULT_ENGINE_PORT = 10000
23DEFAULT_OBS_HOST = "localhost"30DEFAULT_OBS_HOST = "localhost"
24DEFAULT_PROMETHEUS_PORT = 909031DEFAULT_PROMETHEUS_PORT = 9090
25DEFAULT_GRAFANA_PORT = 300032DEFAULT_GRAFANA_PORT = 3000
@@ -44,14 +51,14 @@ _PROXY_ENV_KEYS = (
44)51)
45 52 
46 53 
47-def _kubectl_env() -> Dict[str, str]:54+def _kubectl_env() -> dict[str, str]:
48 env = os.environ.copy()55 env = os.environ.copy()
49 for key in _PROXY_ENV_KEYS:56 for key in _PROXY_ENV_KEYS:
50 env.pop(key, None)57 env.pop(key, None)
51 return env58 return env
52 59 
53 60 
54-def _kubectl_path() -> Optional[str]:61+def _kubectl_path() -> str | None:
55 return shutil.which("kubectl")62 return shutil.which("kubectl")
56 63 
57 64 
@@ -86,9 +93,9 @@ class DiscoveryResult:
86 coordinator_target: str93 coordinator_target: str
87 coordinator_pod_name: str94 coordinator_pod_name: str
88 coordinator_is_primary: bool95 coordinator_is_primary: bool
89- engine_targets: List[Dict[str, Any]]96+ engine_targets: list[dict[str, Any]]
90- port_forwards: List[PortForwardSpec]97+ port_forwards: list[PortForwardSpec]
91- warnings: List[str]98+ warnings: list[str]
92 99 
93 @property100 @property
94 def prefill_count(self) -> int:101 def prefill_count(self) -> int:
@@ -99,7 +106,7 @@ class DiscoveryResult:
99 return sum(1 for item in self.engine_targets if item["labels"].get("pd_role") == "decode")106 return sum(1 for item in self.engine_targets if item["labels"].get("pd_role") == "decode")
100 107 
101 108 
102-def _run_kubectl_json(args: Sequence[str]) -> Dict[str, Any]:109+def _run_kubectl_json(args: Sequence[str]) -> dict[str, Any]:
103 kubectl = _kubectl_path()110 kubectl = _kubectl_path()
104 if kubectl is None:111 if kubectl is None:
105 raise FileNotFoundError("kubectl not found in PATH")112 raise FileNotFoundError("kubectl not found in PATH")
@@ -108,7 +115,7 @@ def _run_kubectl_json(args: Sequence[str]) -> Dict[str, Any]:
108 return json.loads(output.stdout)115 return json.loads(output.stdout)
109 116 
110 117 
111-def _read_user_config_job_id(path: Optional[str]) -> Optional[str]:118+def _read_user_config_job_id(path: str | None) -> str | None:
112 if not path:119 if not path:
113 return None120 return None
114 cfg = Path(path)121 cfg = Path(path)
@@ -150,15 +157,15 @@ def _is_kubectl_ready() -> bool:
150 return False157 return False
151 158 
152 159 
153-def _service_name(service: Dict[str, Any]) -> str:160+def _service_name(service: dict[str, Any]) -> str:
154 return str(service.get("metadata", {}).get("name", ""))161 return str(service.get("metadata", {}).get("name", ""))
155 162 
156 163 
157-def _service_namespace(service: Dict[str, Any]) -> str:164+def _service_namespace(service: dict[str, Any]) -> str:
158 return str(service.get("metadata", {}).get("namespace", ""))165 return str(service.get("metadata", {}).get("namespace", ""))
159 166 
160 167 
161-def _service_ports(service: Dict[str, Any]) -> List[Dict[str, Any]]:168+def _service_ports(service: dict[str, Any]) -> list[dict[str, Any]]:
162 spec = service.get("spec", {})169 spec = service.get("spec", {})
163 ports = spec.get("ports", [])170 ports = spec.get("ports", [])
164 return ports if isinstance(ports, list) else []171 return ports if isinstance(ports, list) else []
@@ -175,7 +182,7 @@ def _is_engine_pod(name: str) -> bool:
175 return _has_keyword(name, ("engine", "mindie-motor-engine"))182 return _has_keyword(name, ("engine", "mindie-motor-engine"))
176 183 
177 184 
178-def _infer_engine_identity_from_pod(name: str, counters: Dict[str, int]) -> Tuple[str, str]:185+def _infer_engine_identity_from_pod(name: str, counters: dict[str, int]) -> tuple[str, str]:
179 match = ENGINE_POD_RE.search(name)186 match = ENGINE_POD_RE.search(name)
180 if match:187 if match:
181 role_char = (match.group("role") or "p").lower()188 role_char = (match.group("role") or "p").lower()
@@ -185,7 +192,7 @@ def _infer_engine_identity_from_pod(name: str, counters: Dict[str, int]) -> Tupl
185 return _infer_engine_identity(name, counters)192 return _infer_engine_identity(name, counters)
186 193 
187 194 
188-def _pod_ready_counts(pod: Dict[str, Any]) -> Tuple[int, int]:195+def _pod_ready_counts(pod: dict[str, Any]) -> tuple[int, int]:
189 """Return (ready, total) container counts, matching kubectl READY column."""196 """Return (ready, total) container counts, matching kubectl READY column."""
190 statuses = pod.get("status", {}).get("containerStatuses") or []197 statuses = pod.get("status", {}).get("containerStatuses") or []
191 if not statuses:198 if not statuses:
@@ -195,13 +202,13 @@ def _pod_ready_counts(pod: Dict[str, Any]) -> Tuple[int, int]:
195 return ready, total202 return ready, total
196 203 
197 204 
198-def _is_running_coordinator_pod(pod: Dict[str, Any]) -> bool:205+def _is_running_coordinator_pod(pod: dict[str, Any]) -> bool:
199 phase = str(pod.get("status", {}).get("phase", ""))206 phase = str(pod.get("status", {}).get("phase", ""))
200 pod_ip = str(pod.get("status", {}).get("podIP", ""))207 pod_ip = str(pod.get("status", {}).get("podIP", ""))
201 return phase == "Running" and bool(pod_ip)208 return phase == "Running" and bool(pod_ip)
202 209 
203 210 
204-def _is_primary_coordinator_pod(pod: Dict[str, Any]) -> bool:211+def _is_primary_coordinator_pod(pod: dict[str, Any]) -> bool:
205 """Primary coordinator in HA: kubectl READY shows N/N with N > 0 (e.g. 1/1)."""212 """Primary coordinator in HA: kubectl READY shows N/N with N > 0 (e.g. 1/1)."""
206 if not _is_running_coordinator_pod(pod):213 if not _is_running_coordinator_pod(pod):
207 return False214 return False
@@ -209,9 +216,9 @@ def _is_primary_coordinator_pod(pod: Dict[str, Any]) -> bool:
209 return total > 0 and ready == total216 return total > 0 and ready == total
210 217 
211 218 
212-def _list_coordinator_pods(namespace: str) -> List[Dict[str, Any]]:219+def _list_coordinator_pods(namespace: str) -> list[dict[str, Any]]:
213 pod_json = _run_kubectl_json(["get", "pods", "-n", namespace])220 pod_json = _run_kubectl_json(["get", "pods", "-n", namespace])
214- pods: List[Dict[str, Any]] = []221+ pods: list[dict[str, Any]] = []
215 for pod in pod_json.get("items", []):222 for pod in pod_json.get("items", []):
216 pod_name = str(pod.get("metadata", {}).get("name", ""))223 pod_name = str(pod.get("metadata", {}).get("name", ""))
217 if _has_keyword(pod_name, COORDINATOR_POD_KEYWORDS):224 if _has_keyword(pod_name, COORDINATOR_POD_KEYWORDS):
@@ -222,9 +229,9 @@ def _list_coordinator_pods(namespace: str) -> List[Dict[str, Any]]:
222def _discover_coordinator_pod(229def _discover_coordinator_pod(
223 namespace: str,230 namespace: str,
224 port: int = DEFAULT_COORDINATOR_PORT,231 port: int = DEFAULT_COORDINATOR_PORT,
225-) -> Optional[CoordinatorPodMatch]:232+) -> CoordinatorPodMatch | None:
226- primary_pods: List[Dict[str, Any]] = []233+ primary_pods: list[dict[str, Any]] = []
227- fallback_pods: List[Dict[str, Any]] = []234+ fallback_pods: list[dict[str, Any]] = []
228 for pod in _list_coordinator_pods(namespace):235 for pod in _list_coordinator_pods(namespace):
229 if _is_primary_coordinator_pod(pod):236 if _is_primary_coordinator_pod(pod):
230 primary_pods.append(pod)237 primary_pods.append(pod)
@@ -246,12 +253,12 @@ def _discover_coordinator_pod(
246 )253 )
247 254 
248 255 
249-def _find_namespace_from_services() -> Optional[str]:256+def _find_namespace_from_services() -> str | None:
250 try:257 try:
251 svc_json = _run_kubectl_json(["get", "svc", "-A"])258 svc_json = _run_kubectl_json(["get", "svc", "-A"])
252 except subprocess.CalledProcessError:259 except subprocess.CalledProcessError:
253 return None260 return None
254- candidates: List[str] = []261+ candidates: list[str] = []
255 for svc in svc_json.get("items", []):262 for svc in svc_json.get("items", []):
256 name = _service_name(svc)263 name = _service_name(svc)
257 if not _has_keyword(name, ("coordinator", "mindie-motor-coordinator")):264 if not _has_keyword(name, ("coordinator", "mindie-motor-coordinator")):
@@ -295,7 +302,7 @@ def _has_explicit_namespace(args: argparse.Namespace) -> bool:
295 return bool(args.namespace or os.getenv("MOTOR_NAMESPACE"))302 return bool(args.namespace or os.getenv("MOTOR_NAMESPACE"))
296 303 
297 304 
298-def _resolve_node_ip(namespace: str, args: argparse.Namespace, warnings: List[str]) -> str:305+def _resolve_node_ip(namespace: str, args: argparse.Namespace, warnings: list[str]) -> str:
299 if args.node_ip:306 if args.node_ip:
300 return args.node_ip307 return args.node_ip
301 env_node_ip = os.getenv("MOTOR_NODE_IP")308 env_node_ip = os.getenv("MOTOR_NODE_IP")
@@ -344,10 +351,10 @@ def _resolve_node_ip(namespace: str, args: argparse.Namespace, warnings: List[st
344 351 
345 352 
346def _match_nodeport_for_service(353def _match_nodeport_for_service(
347- service: Dict[str, Any],354+ service: dict[str, Any],
348 expected_port: int,355 expected_port: int,
349 allow_keywords: Sequence[str],356 allow_keywords: Sequence[str],
350-) -> Optional[int]:357+) -> int | None:
351 for port in _service_ports(service):358 for port in _service_ports(service):
352 node_port = port.get("nodePort")359 node_port = port.get("nodePort")
353 if not node_port:360 if not node_port:
@@ -361,13 +368,13 @@ def _match_nodeport_for_service(
361 368 
362 369 
363def _find_service_nodeport(370def _find_service_nodeport(
364- services: Sequence[Dict[str, Any]],371+ services: Sequence[dict[str, Any]],
365 expected_port: int,372 expected_port: int,
366 service_keywords: Sequence[str],373 service_keywords: Sequence[str],
367 port_keywords: Sequence[str],374 port_keywords: Sequence[str],
368-) -> Optional[Tuple[str, int]]:375+) -> tuple[str, int] | None:
369- best_match: Optional[Tuple[str, int]] = None376+ best_match: tuple[str, int] | None = None
370- fallback_match: Optional[Tuple[str, int]] = None377+ fallback_match: tuple[str, int] | None = None
371 for svc in services:378 for svc in services:
372 name = _service_name(svc)379 name = _service_name(svc)
373 matched = _match_nodeport_for_service(svc, expected_port, port_keywords)380 matched = _match_nodeport_for_service(svc, expected_port, port_keywords)
@@ -381,7 +388,7 @@ def _find_service_nodeport(
381 return best_match or fallback_match388 return best_match or fallback_match
382 389 
383 390 
384-def _infer_engine_identity(name: str, counters: Dict[str, int]) -> Tuple[str, str]:391+def _infer_engine_identity(name: str, counters: dict[str, int]) -> tuple[str, str]:
385 lowered = name.lower()392 lowered = name.lower()
386 pd_role = "prefill"393 pd_role = "prefill"
387 if "decode" in lowered:394 if "decode" in lowered:
@@ -402,7 +409,7 @@ def _infer_engine_identity(name: str, counters: Dict[str, int]) -> Tuple[str, st
402 return pd_role, instance_id409 return pd_role, instance_id
403 410 
404 411 
405-def _infer_dp_rank(name: str, labels: Optional[Dict[str, Any]] = None) -> str:412+def _infer_dp_rank(name: str, labels: dict[str, Any] | None = None) -> str:
406 labels = labels or {}413 labels = labels or {}
407 for key in ("dp_rank", "dp-rank", "rank"):414 for key in ("dp_rank", "dp-rank", "rank"):
408 if labels.get(key) is not None:415 if labels.get(key) is not None:
@@ -415,12 +422,12 @@ def _infer_dp_rank(name: str, labels: Optional[Dict[str, Any]] = None) -> str:
415 422 
416 423 
417def _discover_engine_targets_from_services(424def _discover_engine_targets_from_services(
418- services: Sequence[Dict[str, Any]],425+ services: Sequence[dict[str, Any]],
419 node_ip: str,426 node_ip: str,
420 engine_port: int,427 engine_port: int,
421 cluster_label: str,428 cluster_label: str,
422-) -> List[Dict[str, Any]]:429+) -> list[dict[str, Any]]:
423- targets: List[Dict[str, Any]] = []430+ targets: list[dict[str, Any]] = []
424 counters = {"prefill": 0, "decode": 0}431 counters = {"prefill": 0, "decode": 0}
425 for svc in services:432 for svc in services:
426 name = _service_name(svc)433 name = _service_name(svc)
@@ -429,7 +436,7 @@ def _discover_engine_targets_from_services(
429 node_port = _match_nodeport_for_service(436 node_port = _match_nodeport_for_service(
430 svc,437 svc,
431 expected_port=engine_port,438 expected_port=engine_port,
432- allow_keywords=("metrics", "mgmt", "manage", "prometheus"),439+ allow_keywords=("metrics", "http", "api", "prometheus"),
433 )440 )
434 if node_port is None:441 if node_port is None:
435 continue442 continue
@@ -454,9 +461,9 @@ def _discover_engine_targets_from_pods(
454 namespace: str,461 namespace: str,
455 engine_port: int,462 engine_port: int,
456 cluster_label: str,463 cluster_label: str,
457-) -> List[Dict[str, Any]]:464+) -> list[dict[str, Any]]:
458 pod_json = _run_kubectl_json(["get", "pods", "-n", namespace])465 pod_json = _run_kubectl_json(["get", "pods", "-n", namespace])
459- targets: List[Dict[str, Any]] = []466+ targets: list[dict[str, Any]] = []
460 counters = {"prefill": 0, "decode": 0}467 counters = {"prefill": 0, "decode": 0}
461 for pod in pod_json.get("items", []):468 for pod in pod_json.get("items", []):
462 metadata = pod.get("metadata", {})469 metadata = pod.get("metadata", {})
@@ -490,7 +497,7 @@ def _discover_engine_targets_from_pods(
490 return targets497 return targets
491 498 
492 499 
493-def _split_target(target: str) -> Tuple[str, int]:500+def _split_target(target: str) -> tuple[str, int]:
494 host, port = target.rsplit(":", 1)501 host, port = target.rsplit(":", 1)
495 return host.strip("[]"), int(port)502 return host.strip("[]"), int(port)
496 503 
@@ -502,7 +509,7 @@ def _register_docker_port_forward(
502 remote_port: int,509 remote_port: int,
503 pod_name: str,510 pod_name: str,
504 next_port: int,511 next_port: int,
505-) -> Tuple[str, int]:512+) -> tuple[str, int]:
506 local_port = next_port513 local_port = next_port
507 result.port_forwards.append(514 result.port_forwards.append(
508 PortForwardSpec(515 PortForwardSpec(
@@ -553,9 +560,9 @@ def _apply_docker_gateway(result: DiscoveryResult, base_port: int) -> None:
553 560 
554 561 
555def _discover(namespace: str, node_ip: str, args: argparse.Namespace) -> DiscoveryResult:562def _discover(namespace: str, node_ip: str, args: argparse.Namespace) -> DiscoveryResult:
556- warnings: List[str] = []563+ warnings: list[str] = []
557 obs_host = args.obs_host or os.getenv("OBS_HOST") or node_ip or DEFAULT_OBS_HOST564 obs_host = args.obs_host or os.getenv("OBS_HOST") or node_ip or DEFAULT_OBS_HOST
558- engine_port = args.engine_mgmt_port565+ engine_port = args.engine_business_port
559 cluster_label = namespace566 cluster_label = namespace
560 runtime = args.runtime567 runtime = args.runtime
561 568 
@@ -564,8 +571,8 @@ def _discover(namespace: str, node_ip: str, args: argparse.Namespace) -> Discove
564 coordinator_port = DEFAULT_COORDINATOR_PORT571 coordinator_port = DEFAULT_COORDINATOR_PORT
565 coordinator_pod_name = ""572 coordinator_pod_name = ""
566 coordinator_is_primary = False573 coordinator_is_primary = False
567- engine_targets: List[Dict[str, Any]] = []574+ engine_targets: list[dict[str, Any]] = []
568- port_forwards: List[PortForwardSpec] = []575+ port_forwards: list[PortForwardSpec] = []
569 576 
570 if _is_kubectl_ready():577 if _is_kubectl_ready():
571 try:578 try:
@@ -710,11 +717,11 @@ def _discover(namespace: str, node_ip: str, args: argparse.Namespace) -> Discove
710 717 
711def _render_job(718def _render_job(
712 name: str,719 name: str,
713- targets: Sequence[Dict[str, Any]],720+ targets: Sequence[dict[str, Any]],
714- metrics_path: Optional[str] = None,721+ metrics_path: str | None = None,
715 honor_labels: bool = False,722 honor_labels: bool = False,
716-) -> List[str]:723+) -> list[str]:
717- lines: List[str] = [f" - job_name: {name}"]724+ lines: list[str] = [f" - job_name: {name}"]
718 if metrics_path:725 if metrics_path:
719 lines.append(f" metrics_path: {metrics_path}")726 lines.append(f" metrics_path: {metrics_path}")
720 if honor_labels:727 if honor_labels:
@@ -933,10 +940,10 @@ def _parse_args() -> argparse.Namespace:
933 help="First local port for Docker PodIP bridge forwards (default: 19000).",940 help="First local port for Docker PodIP bridge forwards (default: 19000).",
934 )941 )
935 parser.add_argument(942 parser.add_argument(
936- "--engine-mgmt-port",943+ "--engine-business-port",
937 type=int,944 type=int,
938- default=int(os.getenv("MOTOR_ENGINE_MGMT_PORT", str(DEFAULT_ENGINE_PORT))),945+ default=int(os.getenv("MOTOR_ENGINE_BUSINESS_PORT", str(DEFAULT_ENGINE_PORT))),
939- help="Engine management metrics port (default: 10001).",946+ help="Native engine business port exposing /metrics (default: 10000).",
940 )947 )
941 parser.add_argument(948 parser.add_argument(
942 "--output-dir",949 "--output-dir",
@@ -948,7 +955,7 @@ def _parse_args() -> argparse.Namespace:
948 955 
949def main() -> int:956def main() -> int:
950 args = _parse_args()957 args = _parse_args()
951- warnings: List[str] = []958+ warnings: list[str] = []
952 try:959 try:
953 namespace = _resolve_namespace(args)960 namespace = _resolve_namespace(args)
954 if _has_explicit_namespace(args) and not _is_kubectl_ready():961 if _has_explicit_namespace(args) and not _is_kubectl_ready():
Rmotor/engine_server/constants/constants.pymotor/common/engine_constants.py+5-0
@@ -8,6 +8,11 @@
8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11+from motor.common.constants import (
12+ CHAT_COMPLETION_PREFIX as CHAT_COMPLETION_PREFIX,
13+ COMPLETION_PREFIX as COMPLETION_PREFIX,
14+)
15+ 
11# log dir permission16# log dir permission
12MOTOR_CUSTOM_ZMQ_PRIVILEGE = 0o64017MOTOR_CUSTOM_ZMQ_PRIVILEGE = 0o640
13MOTOR_CUSTOM_ZMQ_DIR_PRIVILEGE = 0o75018MOTOR_CUSTOM_ZMQ_DIR_PRIVILEGE = 0o750
@@ -9,7 +9,7 @@
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11from enum import Enum11from enum import Enum
12-from typing import Any, Iterable, Literal12+from typing import Any, Literal
13 13 
14from pydantic import BaseModel, Field, field_validator14from pydantic import BaseModel, Field, field_validator
15 15 
@@ -100,49 +100,6 @@ def dispatch_capabilities_for_profile(profile: DispatchProfile) -> list[str]:
100 return []100 return []
101 101 
102 102 
103-def dispatch_plans_from_capabilities(values: Iterable[object] | None) -> set[DispatchPlan]:
104- """Normalize advertised capability values into supported dispatch plans."""
105- plans: set[DispatchPlan] = set()
106- if values is None:
107- return plans
108- if isinstance(values, (str, bytes)):
109- values = (values,)
110- try:
111- iterator = iter(values)
112- except TypeError:
113- return plans
114- for value in iterator:
115- try:
116- plans.add(DispatchPlan(str(value)))
117- except ValueError:
118- continue
119- return plans
120- 
121- 
122-def shared_dispatch_plans(prefill: Any, decode: Any) -> set[DispatchPlan]:
123- """Return dispatch plans advertised by both instances."""
124- prefill_plans = dispatch_plans_from_capabilities(getattr(prefill, "dispatch_capabilities", None))
125- decode_plans = dispatch_plans_from_capabilities(getattr(decode, "dispatch_capabilities", None))
126- return prefill_plans & decode_plans
127- 
128- 
129-def dispatch_plan_union(instances: Iterable[Any]) -> set[DispatchPlan]:
130- """Union of dispatch plans advertised across instances; parses each instance once."""
131- union: set[DispatchPlan] = set()
132- for instance in instances:
133- union |= dispatch_plans_from_capabilities(getattr(instance, "dispatch_capabilities", None))
134- return union
135- 
136- 
137-def has_compatible_dispatch_pair(prefill_instances: Iterable[Any], decode_instances: Iterable[Any]) -> bool:
138- """Whether at least one P/D instance pair advertises a shared dispatch plan.
139- 
140- A shared plan exists iff the prefill and decode plan unions intersect, so this
141- runs in O(P+D) without enumerating instance pairs.
142- """
143- return bool(dispatch_plan_union(prefill_instances) & dispatch_plan_union(decode_instances))
144- 
145- 
146def _classify_vllm_kv_transfer_config(kv_transfer_config: Any) -> DispatchProfile:103def _classify_vllm_kv_transfer_config(kv_transfer_config: Any) -> DispatchProfile:
147 if not isinstance(kv_transfer_config, dict):104 if not isinstance(kv_transfer_config, dict):
148 return DispatchProfile.UNKNOWN105 return DispatchProfile.UNKNOWN
@@ -213,6 +170,12 @@ class DispatchEndpoint(BaseModel):
213 instance_id: int = Field(..., ge=0, description="Scheduler instance identifier for the target engine")170 instance_id: int = Field(..., ge=0, description="Scheduler instance identifier for the target engine")
214 endpoint_id: int = Field(..., ge=0, description="Endpoint identifier within the instance")171 endpoint_id: int = Field(..., ge=0, description="Endpoint identifier within the instance")
215 url: str = Field(..., min_length=1, description="Base HTTP URL for dispatch and stop calls to the engine")172 url: str = Field(..., min_length=1, description="Base HTTP URL for dispatch and stop calls to the engine")
173+ bootstrap_port: int | None = Field(
174+ default=None,
175+ ge=1,
176+ le=65535,
177+ description="Engine-native PD bootstrap port when it differs from the inference URL port",
178+ )
216 179 
217 180 
218class DispatchEndpoints(BaseModel):181class DispatchEndpoints(BaseModel):
@@ -67,6 +67,7 @@ class Endpoint(BaseModel):
67 ip: str = Field(..., description="IP address")67 ip: str = Field(..., description="IP address")
68 business_port: str = Field(..., description="Business port")68 business_port: str = Field(..., description="Business port")
69 mgmt_port: str = Field(..., description="Management port")69 mgmt_port: str = Field(..., description="Management port")
70+ bootstrap_port: int | None = Field(default=None, ge=1, le=65535, description="Native PD bootstrap port")
70 status: EndpointStatus = Field(default=EndpointStatus.INITIAL, description="Endpoint status")71 status: EndpointStatus = Field(default=EndpointStatus.INITIAL, description="Endpoint status")
71 device_infos: list[DeviceInfo] = Field(default_factory=list, description="List of DeviceInfo")72 device_infos: list[DeviceInfo] = Field(default_factory=list, description="List of DeviceInfo")
72 hb_timestamp: float = Field(default=0, description="Last heartbeat timestamp")73 hb_timestamp: float = Field(default=0, description="Last heartbeat timestamp")
@@ -79,6 +80,7 @@ class Endpoint(BaseModel):
79 ip: str,80 ip: str,
80 business_port: str,81 business_port: str,
81 mgmt_port: str,82 mgmt_port: str,
83+ bootstrap_port: int | None = None,
82 status: EndpointStatus | None = None,84 status: EndpointStatus | None = None,
83 device_infos: list[DeviceInfo] | None = None,85 device_infos: list[DeviceInfo] | None = None,
84 hb_timestamp: float | None = None,86 hb_timestamp: float | None = None,
@@ -90,6 +92,7 @@ class Endpoint(BaseModel):
90 ip=ip,92 ip=ip,
91 business_port=business_port,93 business_port=business_port,
92 mgmt_port=mgmt_port,94 mgmt_port=mgmt_port,
95+ bootstrap_port=bootstrap_port,
93 status=status if status is not None else EndpointStatus.INITIAL,96 status=status if status is not None else EndpointStatus.INITIAL,
94 device_infos=device_infos if device_infos is not None else [],97 device_infos=device_infos if device_infos is not None else [],
95 hb_timestamp=hb_timestamp if hb_timestamp is not None else time.time(),98 hb_timestamp=hb_timestamp if hb_timestamp is not None else time.time(),
@@ -51,6 +51,7 @@ class RegisterMsg(BaseModel):
51 pod_ip: str = Field(..., description="Pod IP address")51 pod_ip: str = Field(..., description="Pod IP address")
52 business_port: list[str] = Field(..., description="Business port for all endpoints managed by this nm")52 business_port: list[str] = Field(..., description="Business port for all endpoints managed by this nm")
53 mgmt_port: list[str] = Field(..., description="Management port for all endpoints managed by this nm")53 mgmt_port: list[str] = Field(..., description="Management port for all endpoints managed by this nm")
54+ bootstrap_port: int | None = Field(default=None, ge=1, le=65535, description="Native PD bootstrap port")
54 nm_port: str = Field(..., description="Node manager communication port")55 nm_port: str = Field(..., description="Node manager communication port")
55 parallel_config: ParallelConfig = Field(..., description="Parallel configuration")56 parallel_config: ParallelConfig = Field(..., description="Parallel configuration")
56 enable_multi_endpoints: bool = Field(default=True, description="Whether to enable multi-endpoints mode")57 enable_multi_endpoints: bool = Field(default=True, description="Whether to enable multi-endpoints mode")
@@ -340,12 +340,24 @@ class Instance(BaseModel):
340 return True340 return True
341 341 
342 def is_all_endpoints_ready(self) -> bool:342 def is_all_endpoints_ready(self) -> bool:
343+ """Return whether routable endpoints and their headless workers are ready.
344+ 
345+ Headless PCP workers expose no HTTP readiness endpoint. Their
346+ ``WAIT2START`` status confirms process liveness without advertising a
347+ routable service; the non-headless master remains the readiness gate.
348+ """
343 with self._lock:349 with self._lock:
350+ has_routable_endpoint = False
344 for pod_endpoints in self.endpoints.values():351 for pod_endpoints in self.endpoints.values():
345 for endpoint in pod_endpoints.values():352 for endpoint in pod_endpoints.values():
353+ if endpoint.headless:
354+ if endpoint.status not in (EndpointStatus.NORMAL, EndpointStatus.WAIT2START):
355+ return False
356+ continue
357+ has_routable_endpoint = True
346 if endpoint.status != EndpointStatus.NORMAL:358 if endpoint.status != EndpointStatus.NORMAL:
347 return False359 return False
348- return True360+ return has_routable_endpoint
349 361 
350 def is_have_one_endpoint_abnormal(self) -> bool:362 def is_have_one_endpoint_abnormal(self) -> bool:
351 abnormal_endpoints: dict[str, list[int]] = {} # pod_ip -> [endpoint_id]363 abnormal_endpoints: dict[str, list[int]] = {} # pod_ip -> [endpoint_id]
Rmotor/engine_server/utils/ip.pymotor/common/utils/ip.py+0-0
文件重命名但无更改。
Rmotor/engine_server/utils/validators.pymotor/common/utils/validators.py+11-3
@@ -12,8 +12,16 @@ import os
12from typing import Callable, Any12from typing import Callable, Any
13from typing import List, Tuple13from typing import List, Tuple
14 14 
15-from motor.engine_server.constants.constants import (MAX_SIZE, MIN_SIZE, MIN_RANK_SIZE, MAX_RANK_SIZE,15+from motor.common.engine_constants import (
16- MAX_FILE_NUMS, MIN_DEVICE_NUM, MAX_DEVICE_NUM, MUSK_PRIVILEGE)16+ MAX_DEVICE_NUM,
17+ MAX_FILE_NUMS,
18+ MAX_RANK_SIZE,
19+ MAX_SIZE,
20+ MIN_DEVICE_NUM,
21+ MIN_RANK_SIZE,
22+ MIN_SIZE,
23+ MUSK_PRIVILEGE,
24+)
17 25 
18 26 
19class Validator:27class Validator:
@@ -50,7 +58,7 @@ class Validator:
50 if self.is_valid_state is None:58 if self.is_valid_state is None:
51 try:59 try:
52 self.check()60 self.check()
53- except Exception as e:61+ except Exception:
54 self.is_valid_state = False62 self.is_valid_state = False
55 return self.is_valid_state63 return self.is_valid_state
56 64 
@@ -933,7 +933,7 @@ class CoordinatorConfig:
933 return reload_dataclass_config_from_json(933 return reload_dataclass_config_from_json(
934 self,934 self,
935 self.from_json,935 self.from_json,
936- skip=frozenset({"worker_index", "worker_metaserver_port"}),936+ skip=frozenset({"worker_index"}),
937 skip_private=True,937 skip_private=True,
938 )938 )
939 939 
@@ -20,9 +20,9 @@ from motor.common.resources.dispatch import DISPATCH_PROFILE_KEY
20from motor.config.config_utils import _update_engine_server_tls_config20from motor.config.config_utils import _update_engine_server_tls_config
21from motor.config.resolver import ConfigResolver, normalize_keys21from motor.config.resolver import ConfigResolver, normalize_keys
22from motor.config.tls_config import TLSConfig22from motor.config.tls_config import TLSConfig
23-from motor.engine_server.constants import constants23+from motor.common import engine_constants as constants
24-from motor.engine_server.utils.ip import ip_valid_check, port_valid_check24+from motor.common.utils.ip import ip_valid_check, port_valid_check
25-from motor.engine_server.utils.validators import FileValidator25+from motor.common.utils.validators import FileValidator
26 26 
27logger = get_logger(__name__)27logger = get_logger(__name__)
28 28 
@@ -111,6 +111,9 @@ class HealthCheckConfig:
111 health_collector_timeout: int = 5111 health_collector_timeout: int = 5
112 # Max attempts for /health probe when request times out (timeout-only retry).112 # Max attempts for /health probe when request times out (timeout-only retry).
113 health_collector_timeout_retry_attempts: int = 3113 health_collector_timeout_retry_attempts: int = 3
114+ # Native engines can spend a long time loading model weights before their
115+ # HTTP endpoint starts accepting connections.
116+ startup_timeout: int = 1800
114 npu_usage_threshold: int = 3117 npu_usage_threshold: int = 3
115 enable_virtual_inference: bool = False118 enable_virtual_inference: bool = False
116 max_failure_count: int = 6119 max_failure_count: int = 6
@@ -129,6 +132,7 @@ class HealthCheckConfig:
129 self.health_collector_timeout_retry_attempts = self._as_positive_int(132 self.health_collector_timeout_retry_attempts = self._as_positive_int(
130 "health_collector_timeout_retry_attempts", self.health_collector_timeout_retry_attempts133 "health_collector_timeout_retry_attempts", self.health_collector_timeout_retry_attempts
131 )134 )
135+ self.startup_timeout = self._as_positive_int("startup_timeout", self.startup_timeout)
132 136 
133 @classmethod137 @classmethod
134 def from_dict(cls, data: dict[str, Any]) -> "HealthCheckConfig":138 def from_dict(cls, data: dict[str, Any]) -> "HealthCheckConfig":
@@ -62,6 +62,8 @@ MULTICONNECTOR = "MultiConnector"
62KV_CONNECTOR_EXTRA_CONFIG_KEY = "kv_connector_extra_config"62KV_CONNECTOR_EXTRA_CONFIG_KEY = "kv_connector_extra_config"
63CONNECTORS_KEY = "connectors"63CONNECTORS_KEY = "connectors"
64KV_PORT_KEY = "kv_port"64KV_PORT_KEY = "kv_port"
65+DISAGGREGATION_BOOTSTRAP_PORT_KEY = "disaggregation_bootstrap_port"
66+DISAGGREGATION_BOOTSTRAP_PORT_CLI_KEY = "disaggregation-bootstrap-port"
65LOOPUP_RPC_PORT_KEY = "lookup_rpc_port"67LOOPUP_RPC_PORT_KEY = "lookup_rpc_port"
66UCM_CONNECTOR = "UCMConnector"68UCM_CONNECTOR = "UCMConnector"
67SERVER_LIST = "server_list"69SERVER_LIST = "server_list"
@@ -154,6 +156,7 @@ class EndpointConfig:
154 base_port: int = 10000156 base_port: int = 10000
155 mgmt_ports: list[str] = field(default_factory=list)157 mgmt_ports: list[str] = field(default_factory=list)
156 service_ports: list[str] = field(default_factory=list)158 service_ports: list[str] = field(default_factory=list)
159+ bootstrap_port: int | None = None
157 160 
158 161 
159@dataclass162@dataclass
@@ -381,6 +384,7 @@ class NodeManagerConfig:
381 config_data = raw384 config_data = raw
382 385 
383 cls._update_from_config_data(config, config_data)386 cls._update_from_config_data(config, config_data)
387+ cls._set_native_bootstrap_port(config, raw)
384 else:388 else:
385 logger.warning("Config file does not exist, using default configuration: %s", config_path_obj)389 logger.warning("Config file does not exist, using default configuration: %s", config_path_obj)
386 390 
@@ -506,6 +510,46 @@ class NodeManagerConfig:
506 510 
jason lyu
jason lyujason lyu19 天前

_set_native_bootstrap_port 按 Env.role 取单一生效 engine 段,encode/union 无 bootstrap 会漏配。

likedislike
tobking
tobking
19 天前 评论:
507 return config_data511 return config_data
508 512 
513+ @classmethod
514+ def _set_native_bootstrap_port(cls, config: "NodeManagerConfig", user_cfg: dict[str, Any]) -> None:
515+ """Resolve SGLang bootstrap metadata from the selected engine section."""
516+ engine_key = {
517+ "encode": MOTOR_ENGINE_ENCODE_CONFIG_KEY,
518+ "prefill": MOTOR_ENGINE_PREFILL_CONFIG_KEY,
519+ "decode": MOTOR_ENGINE_DECODE_CONFIG_KEY,
520+ "union": MOTOR_ENGINE_UNION_CONFIG_KEY,
521+ "both": MOTOR_ENGINE_UNION_CONFIG_KEY,
522+ }.get(Env.role)
523+ engine_section = user_cfg.get(engine_key, {}) if engine_key else {}
524+ if not isinstance(engine_section, dict):
525+ return
526+ engine_config = engine_section.get(ENGINE_CONFIG_KEY, {})
527+ if not isinstance(engine_config, dict):
528+ return
529+ 
530+ if str(engine_section.get(ENGINE_TYPE_KEY, "")).strip().lower() != ENGINE_TYPE_SGLANG:
531+ return
532+ port = engine_config.get(
533+ DISAGGREGATION_BOOTSTRAP_PORT_KEY,
534+ engine_config.get(DISAGGREGATION_BOOTSTRAP_PORT_CLI_KEY),
535+ )
536+ config.endpoint_config.bootstrap_port = cls._parse_native_port(
537+ DISAGGREGATION_BOOTSTRAP_PORT_KEY,
538+ port,
539+ )
540+ 
541+ @staticmethod
542+ def _parse_native_port(field_name: str, value: Any) -> int | None:
543+ if value in (None, ""):
544+ return None
545+ try:
546+ port = int(value)
547+ except (TypeError, ValueError) as exc:
548+ raise ValueError(f"{field_name} must be an integer port") from exc
549+ if not 1 <= port <= 65535:
550+ raise ValueError(f"{field_name} must be in range 1-65535")
551+ return port
552+ 
509 @classmethod553 @classmethod
510 def _infer_dispatch_capabilities(cls, engine_config: dict[str, Any]) -> list[str]:554 def _infer_dispatch_capabilities(cls, engine_config: dict[str, Any]) -> list[str]:
511 """Infer Motor dispatch capabilities from engine-native config."""555 """Infer Motor dispatch capabilities from engine-native config."""
@@ -521,7 +565,9 @@ class NodeManagerConfig:
521 native_engine_config,565 native_engine_config,
522 explicit_profile=engine_config.get(DISPATCH_PROFILE_KEY),566 explicit_profile=engine_config.get(DISPATCH_PROFILE_KEY),
523 )567 )
524- capabilities = dispatch_capabilities_for_profile(profile)568+ # The native vLLM runtime implements only the explicit handoff contract.
569+ # Do not advertise trigger/concurrent support that runtime validation rejects.
570+ capabilities = dispatch_capabilities_for_profile(profile) if profile == DispatchProfile.HANDOFF else []
525 if not capabilities and profile == DispatchProfile.UNKNOWN:571 if not capabilities and profile == DispatchProfile.UNKNOWN:
526 logger.warning(572 logger.warning(
527 "Unable to infer vLLM dispatch capability from kv_transfer_config. "573 "Unable to infer vLLM dispatch capability from kv_transfer_config. "
@@ -747,6 +793,9 @@ class NodeManagerConfig:
747 if self.basic_config.heartbeat_interval_seconds <= 0:793 if self.basic_config.heartbeat_interval_seconds <= 0:
jason lyu
jason lyujason lyu19 天前

enable_snapshot 报错推迟到 validate(),config 加载成功但启动才失败,建议校验前置。

likedislike
tobking
tobking
19 天前 评论:
748 errors.append("heartbeat_interval_seconds must be greater than 0")794 errors.append("heartbeat_interval_seconds must be greater than 0")
749 795 
796+ if self.snapshot_config.enable_snapshot:
G
Gganglv19 天前

严重程度: 提示

问题: snapshot 能力被移除(配置期硬校验),但快照相关代码路径未同步清理,成不可达死代码。

原因: 此处新增无条件校验 enable_snapshot 必须为 false,但同一 PR 中:engine_manager.py 仍保留 engine_suspend_prepare/engine_suspend/engine_resume/register_prepare_after_restore 整套方法,node_manager_api.py:80、86 仍在 start/stop 流程中调用它们,node_manager.py:77 仍按 enable_snapshot 分支关闭配置 watcher,engine_server/core/ 下 snapshot_monitor.py、snapshot_sentinel.py 也仍在。基线 enable_snapshot=true 是可用的(engine_server 子进程拉起时追加 --snapshot-metadata),本次改为配置校验期硬失败是破坏性变更。存量开启快照的部署升级后 NodeManager 拒绝启动,且残留死代码易误导后续维护者误以为快照仍受支持。

怎么改: 在同一 PR 内清理死代码(删除 engine_manager 的快照方法及其调用点、node_manager.py:77 的 watcher snapshot 分支、engine_server 的 snapshot_monitor/snapshot_sentinel),或至少将校验错误信息改为明确提示“该能力已在原生引擎运行时移除”并附迁移说明,同时更新 node_manager.md 中 /readiness 段落(仍写着“用于快照默认应用场景”)。

likedislike
tobking
tobking
19 天前 评论:
797+ errors.append("Native engine runtime does not support snapshot yet; enable_snapshot must be false")
798+ 
750 # Validate logging configuration799 # Validate logging configuration
751 valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR"]800 valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR"]
752 if self.logging_config.log_level.upper() not in valid_log_levels:801 if self.logging_config.log_level.upper() not in valid_log_levels:
@@ -489,6 +489,7 @@ class InstanceAssembler(ThreadSafeSingleton):
489 ip=msg.pod_ip,489 ip=msg.pod_ip,
490 business_port=msg.business_port[0],490 business_port=msg.business_port[0],
491 mgmt_port=msg.mgmt_port[0],491 mgmt_port=msg.mgmt_port[0],
492+ bootstrap_port=msg.bootstrap_port,
492 device_infos=device_infos,493 device_infos=device_infos,
493 )494 )
494 }495 }
@@ -533,6 +534,7 @@ class InstanceAssembler(ThreadSafeSingleton):
533 ip=msg.pod_ip,534 ip=msg.pod_ip,
534 business_port=port,535 business_port=port,
535 mgmt_port=msg.mgmt_port[i],536 mgmt_port=msg.mgmt_port[i],
537+ bootstrap_port=msg.bootstrap_port,
536 device_infos=device_infos,538 device_infos=device_infos,
537 )539 )
538 logger.debug("Built %d endpoints for pod %s", len(pod_endpoints), msg.pod_ip)540 logger.debug("Built %d endpoints for pod %s", len(pod_endpoints), msg.pod_ip)
Rmotor/coordinator/api_client/engine_server_api_client.pymotor/coordinator/api_client/native_engine_api_client.py+17-35
@@ -1,5 +1,4 @@
1-# -*- coding: utf-8 -*-1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
3# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
4# You can use this software according to the terms and conditions of the Mulan PSL v2.3# You can use this software according to the terms and conditions of the Mulan PSL v2.
5# You may obtain a copy of Mulan PSL v2 at:4# You may obtain a copy of Mulan PSL v2 at:
@@ -11,49 +10,32 @@
11 10 
12from motor.common.http.http_client import SafeHTTPSClient11from motor.common.http.http_client import SafeHTTPSClient
13from motor.common.logger import get_logger12from motor.common.logger import get_logger
14-from motor.common.logger.rate_limited_logger import RateLimitedLogger13+from motor.config.tls_config import TLSConfig
15-from motor.config.coordinator import CoordinatorConfig
16 14 
17logger = get_logger(__name__)15logger = get_logger(__name__)
18-_rl = RateLimitedLogger(logger)
19 16 
20 17 
21-class EngineServerApiClient:18+class NativeEngineApiClient:
22- tls_config = CoordinatorConfig.from_json().mgmt_tls_config19+ """Read native engine operational endpoints over the inference channel."""
23 20 
24 @staticmethod21 @staticmethod
25- def query_metrics(address: str):22+ def query_metrics(address: str, tls_config: TLSConfig | None) -> str:
G
Gganglv19 天前

严重程度: 提示

问题: query_metrics 返回 response.text 前未检查 status_code,非 200 错误页会被当作 Prometheus 文本。

原因: 基线 engine_server_api_client 的 query_metrics 明确检查 if response.status_code == 200 并在非 2xx 时告警返回空串;新实现非 200 响应体(如 404/502 的 HTML 错误页)直接交给 metrics_collector 的解析器,导致整实例指标丢弃并产生误导性日志。另注意:sglang 引擎需 --enable-metrics 启动才挂载 /metrics(vllm 默认挂载),未开启时本接口静默返回空串,实例级指标(TTFT 等)全部缺失。

怎么改: 恢复状态码检查:if response.status_code != 200: logger.warning(...); return "",与旧客户端行为对齐;并在 NodeManager 拉起 sglang 的模板/文档中明确要求 --enable-metrics,或对返回空串打 warning 级日志(当前 debug 太隐蔽)。

likedislike
tobking
tobking
19 天前 评论:
26- client_args = EngineServerApiClient._generate_client_args(address)
27 try:23 try:
28- client = SafeHTTPSClient(timeout=2, **client_args)24+ with SafeHTTPSClient(address=address, tls_config=tls_config, timeout=2) as client:
29- response = client.do_get("/metrics")25+ response = client.do_get("/metrics")
30- if response.status_code == 200:26+ if response.status_code != 200:
31- data = response.text
32- return data
33- else:
34 logger.warning(27 logger.warning(
35- "Coordinator->EngineServer query_metrics non-2xx. "28+ "Coordinator native metrics request returned status=%s. address=%s",
36- "address=%s, status_code=%s. "
37- "Possible causes: 1) engine_server not ready 2) wrong endpoint 3) auth failure.",
38- address,
39 response.status_code,29 response.status_code,
30+ address,
40 )31 )
41- except Exception as e:32+ return ""
33+ return response.text
34+ except Exception as err:
42 logger.warning(35 logger.warning(
43- "Coordinator->EngineServer query_metrics failed. address=%s, error=%s. "36+ "Coordinator native metrics request failed. address=%s, error=%s. "
44- "Possible causes: 1) engine_server down 2) network unreachable 3) tls mismatch. "37+ "Check native engine readiness, network reachability, and inference TLS configuration.",
45- "Check: ping %s, engine_server process status.",
46- address,
47- e,
48 address,38 address,
39+ err,
49 )40 )
50- 41+ return ""
51- return ""
52- 
53- @classmethod
54- def _generate_client_args(cls, address) -> dict[str, str]:
55- client_ars = {
56- "address": f"{address}",
57- "tls_config": cls.tls_config,
58- }
59- return client_ars
@@ -501,6 +501,7 @@ class InferenceServer(BaseCoordinatorServer):
501 self.coordinator_config,501 self.coordinator_config,
502 scheduler=self._get_scheduler_client(),502 scheduler=self._get_scheduler_client(),
503 request_manager=request_manager,503 request_manager=request_manager,
504+ request_json=body_json,
504 )505 )
505 except HTTPException:506 except HTTPException:
506 raise507 raise
@@ -531,6 +532,7 @@ class InferenceServer(BaseCoordinatorServer):
531 self.coordinator_config,532 self.coordinator_config,
532 scheduler=self._get_scheduler_client(),533 scheduler=self._get_scheduler_client(),
533 request_manager=request_manager,534 request_manager=request_manager,
535+ request_json=body_json,
534 )536 )
535 except HTTPException:537 except HTTPException:
536 raise538 raise
@@ -13,7 +13,6 @@ from typing import Mapping
13 13 
14from motor.common.logger import get_logger14from motor.common.logger import get_logger
15from motor.common.logger.rate_limited_logger import RateLimitedLogger15from motor.common.logger.rate_limited_logger import RateLimitedLogger
16-from motor.common.resources.dispatch import has_compatible_dispatch_pair
17from motor.common.resources.instance import Instance, PDRole, Workload, Endpoint16from motor.common.resources.instance import Instance, PDRole, Workload, Endpoint
18from motor.common.resources.http_msg_spec import EventType17from motor.common.resources.http_msg_spec import EventType
19from motor.config.coordinator import CoordinatorConfig18from motor.config.coordinator import CoordinatorConfig
@@ -361,49 +360,8 @@ class InstanceManager:
361 len(self._decode_pool),360 len(self._decode_pool),
362 len(self._hybrid_pool),361 len(self._hybrid_pool),
363 )362 )
364- self._log_dispatch_capabilities()
365 return result363 return result
366 364 
367- def _log_dispatch_capabilities(self) -> None:
368- """Log per-instance dispatch_capabilities and flag an incompatible P/D pool.
369- 
370- Readiness gates on a shared P/D dispatch capability; when P and D are both present
371- but advertise no common capability the service stays not-ready with a vague
372- instances_status=unknown. This makes the actual capabilities (empty vs mismatched) visible.
373- """
374- 
375- def _role(inst: Instance) -> str:
376- role = getattr(inst, "role", None)
377- return role.value if hasattr(role, "value") else str(role)
378- 
379- summary = ", ".join(
380- f"{inst.id}({_role(inst)})={list(getattr(inst, 'dispatch_capabilities', []) or [])}"
381- for pool in (self._prefill_pool, self._decode_pool, self._encode_pool, self._hybrid_pool)
382- for inst in pool.values()
383- )
384- logger.info("Instance dispatch_capabilities: %s", summary or "(none)")
385- 
386- if (
387- self._prefill_pool
388- and self._decode_pool
389- and not has_compatible_dispatch_pair(self._prefill_pool.values(), self._decode_pool.values())
390- ):
391- if self._hybrid_pool:
392- logger.warning(
393- "P/D instances are online but advertise no shared dispatch capability; "
394- "requests will fall back to PDHybridRouter via union instances. "
395- "Check the engine kv_connector is recognized or set dispatch_profile explicitly. "
396- "capabilities: %s",
397- summary or "(none)",
398- )
399- else:
400- logger.warning(
401- "P/D instances are online but advertise no shared dispatch capability "
402- "(readiness will report instances_status=unknown). Check the engine kv_connector "
403- "is recognized or set dispatch_profile explicitly. capabilities: %s",
404- summary or "(none)",
405- )
406- 
407 def _find_available_pool(self, instance_id: int) -> dict[int, Instance] | None:365 def _find_available_pool(self, instance_id: int) -> dict[int, Instance] | None:
408 # This is a private method that should only be called within locked contexts366 # This is a private method that should only be called within locked contexts
409 instance = self._available_pool.get(instance_id)367 instance = self._available_pool.get(instance_id)
@@ -19,7 +19,6 @@ from typing import Iterable, Protocol
19 19 
20from pydantic import BaseModel20from pydantic import BaseModel
21 21 
22-from motor.common.resources.dispatch import has_compatible_dispatch_pair
23from motor.common.resources.endpoint import Endpoint, Workload, WorkloadAction22from motor.common.resources.endpoint import Endpoint, Workload, WorkloadAction
24from motor.common.resources.instance import Instance, PDRole23from motor.common.resources.instance import Instance, PDRole
25from motor.coordinator.models.request import RequestInfo24from motor.coordinator.models.request import RequestInfo
@@ -53,7 +52,7 @@ class InstanceReadiness(str, Enum):
53 52 
54 53 
55def readiness_from_instances(instances: Iterable[Instance]) -> InstanceReadiness:54def readiness_from_instances(instances: Iterable[Instance]) -> InstanceReadiness:
56- """Infer readiness from available roles and compatible P/D dispatch capabilities."""55+ """Infer readiness from the available native-engine roles."""
57 encode_instances = []56 encode_instances = []
58 prefill_instances = []57 prefill_instances = []
59 decode_instances = []58 decode_instances = []
@@ -70,13 +69,10 @@ def readiness_from_instances(instances: Iterable[Instance]) -> InstanceReadiness
70 elif role_value in (PDRole.ROLE_U.value, "both", "hybrid"):69 elif role_value in (PDRole.ROLE_U.value, "both", "hybrid"):
71 union_instances.append(instance)70 union_instances.append(instance)
72 71 
73- has_compatible_pd = has_compatible_dispatch_pair(prefill_instances, decode_instances)72+ if prefill_instances and decode_instances:
74- if has_compatible_pd:
75 return InstanceReadiness.REQUIRED_MET_EPD if encode_instances else InstanceReadiness.REQUIRED_MET73 return InstanceReadiness.REQUIRED_MET_EPD if encode_instances else InstanceReadiness.REQUIRED_MET
76 if union_instances:74 if union_instances:
77 return InstanceReadiness.REQUIRED_MET75 return InstanceReadiness.REQUIRED_MET
78- if prefill_instances and decode_instances:
79- return InstanceReadiness.UNKNOWN
80 if encode_instances and prefill_instances:76 if encode_instances and prefill_instances:
81 return InstanceReadiness.ENCODE_PREFILL77 return InstanceReadiness.ENCODE_PREFILL
82 if prefill_instances:78 if prefill_instances:
@@ -126,10 +122,12 @@ class SchedulingFacade(Protocol):
126 req_info: RequestInfo,122 req_info: RequestInfo,
127 *,123 *,
128 target_instance_id: int | None = None,124 target_instance_id: int | None = None,
125+ required_engine_type: str | None = None,
129 ) -> tuple[Instance, Endpoint, Workload] | None:126 ) -> tuple[Instance, Endpoint, Workload] | None:
130 """127 """
131 Atomic: select instance + one workload allocation (ALLOCATION).128 Atomic: select instance + one workload allocation (ALLOCATION).
132 When target_instance_id is set, pin to that instance (skip policy selection).129 When target_instance_id is set, pin to that instance (skip policy selection).
130+ When required_engine_type is set, only matching engine instances are eligible.
133 Returns (instance, endpoint, allocation_workload). Caller records allocation_workload for release.131 Returns (instance, endpoint, allocation_workload). Caller records allocation_workload for release.
134 """132 """
135 ...133 ...
@@ -153,10 +151,6 @@ class SchedulingFacade(Protocol):
153 """Return roles currently present in the scheduler's local instance view."""151 """Return roles currently present in the scheduler's local instance view."""
154 ...152 ...
155 153 
156- async def has_compatible_pd_pair(self) -> bool:
157- """Return whether the local scheduler view contains a compatible P/D pair."""
158- ...
159- 
160 async def report_cb_event(self, instance_id: int, event: str) -> None:154 async def report_cb_event(self, instance_id: int, event: str) -> None:
161 """Report a circuit-breaker event ("failure" | "success") for an instance.155 """Report a circuit-breaker event ("failure" | "success") for an instance.
162 156 
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
27logger = get_logger(__name__)27logger = get_logger(__name__)
28 28 
29# (p_instance_id or None, d_instance_id)29# (p_instance_id or None, d_instance_id)
30-# p_instance_id 为 None 表示 CDP/PD_SEPARATE 模式下 coordinator 侧不感知 P 实例30+# p_instance_id 为 None 表示请求由单个 Hybrid/Union 实例完成。
31PDGroupKey = tuple[int | None, int]31PDGroupKey = tuple[int | None, int]
32 32 
33 33 
@@ -20,9 +20,6 @@ Unknown metrics fall back to type-based defaults (gauge→sum, counter→sum, hi
20from dataclasses import dataclass20from dataclasses import dataclass
21from enum import Enum21from enum import Enum
22 22 
23-from motor.common.resources.dispatch import DispatchPlan
24- 
25- 
26# ---------------------------------------------------------------------------23# ---------------------------------------------------------------------------
27# Semantic taxonomy24# Semantic taxonomy
28# ---------------------------------------------------------------------------25# ---------------------------------------------------------------------------
@@ -257,24 +254,11 @@ class MetricRegistry:
257 def get_effective_role_scope(254 def get_effective_role_scope(
258 cls,255 cls,
259 metric_name: str,256 metric_name: str,
260- dispatch_capabilities: set[str] | None = None,
261 ) -> str | None:257 ) -> str | None:
262- """Get the effective role scope for a metric, considering connector capabilities.258+ """Get the configured role scope for a metric."""
263- 
264- Handoff connectors expose meaningful TTFT on both P and D instances.
265- Concurrent connectors use D's TTFT as the authoritative service value.
266- """
267 config = cls.get_semantic(metric_name)259 config = cls.get_semantic(metric_name)
268 if config is None or config.role_scope is None:260 if config is None or config.role_scope is None:
269 return None261 return None
270- 
271- if (
272- metric_name == "vllm:time_to_first_token_seconds"
273- and dispatch_capabilities
274- and DispatchPlan.PREFILL_HANDOFF_DECODE.value in dispatch_capabilities
275- ):
276- return None
277- 
278 return config.role_scope262 return config.role_scope
279 263 
280 @classmethod264 @classmethod
@@ -35,7 +35,7 @@ class AggregationScope(Enum):
35class AggregationContext:35class AggregationContext:
36 scope: AggregationScope = AggregationScope.INSTANCE36 scope: AggregationScope = AggregationScope.INSTANCE
37 instance_roles: dict[int, str] | None = None37 instance_roles: dict[int, str] | None = None
38- instance_dispatch_capabilities: dict[int, set[str]] | None = None38+ instance_engine_types: dict[int, str] | None = None
39 ins_ids: list[int] | None = None39 ins_ids: list[int] | None = None
40 40 
41 41 
@@ -17,14 +17,13 @@ from collections.abc import Callable
17from typing import Any17from typing import Any
18import requests18import requests
19 19 
20-from motor.common.resources.dispatch import DispatchPlan
21from motor.common.resources.instance import Instance20from motor.common.resources.instance import Instance
22from motor.common.logger import get_logger21from motor.common.logger import get_logger
23from motor.common.logger.rate_limited_logger import RateLimitedLogger22from motor.common.logger.rate_limited_logger import RateLimitedLogger
24from motor.common.utils.net import format_address23from motor.common.utils.net import format_address
25from motor.common.utils.singleton import ThreadSafeSingleton24from motor.common.utils.singleton import ThreadSafeSingleton
26from motor.config.coordinator import CoordinatorConfig25from motor.config.coordinator import CoordinatorConfig
27-from motor.coordinator.api_client.engine_server_api_client import EngineServerApiClient26+from motor.coordinator.api_client.native_engine_api_client import NativeEngineApiClient
28from motor.coordinator.metrics.metric_types import (27from motor.coordinator.metrics.metric_types import (
29 AggregationContext,28 AggregationContext,
30 AggregationScope,29 AggregationScope,
@@ -243,6 +242,7 @@ class MetricsCollector(ThreadSafeSingleton):
243 config = CoordinatorConfig()242 config = CoordinatorConfig()
244 self._prometheus_metrics_config = config.prometheus_metrics_config243 self._prometheus_metrics_config = config.prometheus_metrics_config
245 self._deploy_config = config.deploy_config244 self._deploy_config = config.deploy_config
245+ self._infer_tls_config = config.infer_tls_config
246 246 
247 # Initial metrics state247 # Initial metrics state
248 self._inactive_instance_metrics_aggregate: dict[str, list[Metric]] = {}248 self._inactive_instance_metrics_aggregate: dict[str, list[Metric]] = {}
@@ -301,6 +301,7 @@ class MetricsCollector(ThreadSafeSingleton):
301 with self._config_lock:301 with self._config_lock:
302 self._prometheus_metrics_config = config.prometheus_metrics_config302 self._prometheus_metrics_config = config.prometheus_metrics_config
303 self._deploy_config = config.deploy_config303 self._deploy_config = config.deploy_config
304+ self._infer_tls_config = config.infer_tls_config
304 logger.info("MetricsCollector configuration updated")305 logger.info("MetricsCollector configuration updated")
305 306 
306 def get_metrics(307 def get_metrics(
@@ -845,7 +846,7 @@ class MetricsCollector(ThreadSafeSingleton):
845 collect["role"] = ins_info.role846 collect["role"] = ins_info.role
846 collect["job_name"] = ins_info.job_name847 collect["job_name"] = ins_info.job_name
847 collect["model_name"] = ins_info.model_name848 collect["model_name"] = ins_info.model_name
848- collect["dispatch_capabilities"] = list(ins_info.dispatch_capabilities or [])849+ collect["engine_type"] = ins_info.engine_type
849 collects[ins_info.id] = collect850 collects[ins_info.id] = collect
850 851 
851 return collects852 return collects
@@ -868,7 +869,10 @@ class MetricsCollector(ThreadSafeSingleton):
868 collect = {"endpoints": {}}869 collect = {"endpoints": {}}
869 870 
870 for en_info in ins_info.get_all_endpoints():871 for en_info in ins_info.get_all_endpoints():
871- metrics_str = EngineServerApiClient.query_metrics(f"{en_info.ip}:{en_info.mgmt_port}")872+ metrics_str = NativeEngineApiClient.query_metrics(
873+ format_address(en_info.ip, en_info.business_port),
874+ self._infer_tls_config,
875+ )
872 if not metrics_str:876 if not metrics_str:
873 return {}877 return {}
874 collect["endpoints"][en_info.id] = {878 collect["endpoints"][en_info.id] = {
@@ -882,7 +886,7 @@ class MetricsCollector(ThreadSafeSingleton):
882 self,886 self,
883 collects: dict[int, dict[str, Any]],887 collects: dict[int, dict[str, Any]],
884 instance_roles: dict[int, str],888 instance_roles: dict[int, str],
885- instance_dispatch_capabilities: dict[int, set[str]],889+ instance_engine_types: dict[int, str],
886 ) -> list[Metric]:890 ) -> list[Metric]:
887 """Aggreagte metrics of all instances."""891 """Aggreagte metrics of all instances."""
888 892 
@@ -909,7 +913,7 @@ class MetricsCollector(ThreadSafeSingleton):
909 scope=AggregationScope.SERVICE,913 scope=AggregationScope.SERVICE,
910 ins_ids=ins_ids,914 ins_ids=ins_ids,
911 instance_roles=instance_roles,915 instance_roles=instance_roles,
912- instance_dispatch_capabilities=instance_dispatch_capabilities,916+ instance_engine_types=instance_engine_types,
913 )917 )
914 return self._aggregate_metrics(aggr_input, ctx=ctx)918 return self._aggregate_metrics(aggr_input, ctx=ctx)
915 919 
@@ -943,8 +947,7 @@ class MetricsCollector(ThreadSafeSingleton):
943 result: list[Metric] = []947 result: list[Metric] = []
944 for name, entries in aggr_input.items():948 for name, entries in aggr_input.items():
945 if ctx is not None and ctx.scope == AggregationScope.SERVICE and ctx.instance_roles is not None:949 if ctx is not None and ctx.scope == AggregationScope.SERVICE and ctx.instance_roles is not None:
946- if name == "vllm:time_to_first_token_seconds" and ctx.instance_dispatch_capabilities is not None:950+ if name == "vllm:time_to_first_token_seconds" and ctx.instance_engine_types is not None:
947- handoff = DispatchPlan.PREFILL_HANDOFF_DECODE.value
948 entries = [951 entries = [
949 (ins_id, m)952 (ins_id, m)
950 for ins_id, m in entries953 for ins_id, m in entries
@@ -953,7 +956,7 @@ class MetricsCollector(ThreadSafeSingleton):
953 or ctx.instance_roles.get(ins_id) in {"decode", "union", "both", "hybrid"}956 or ctx.instance_roles.get(ins_id) in {"decode", "union", "both", "hybrid"}
954 or (957 or (
955 ctx.instance_roles.get(ins_id) == "prefill"958 ctx.instance_roles.get(ins_id) == "prefill"
956- and handoff in ctx.instance_dispatch_capabilities.get(ins_id, set())959+ and ctx.instance_engine_types.get(ins_id, "").strip().lower() == "vllm"
957 )960 )
958 )961 )
959 ]962 ]
@@ -993,13 +996,11 @@ class MetricsCollector(ThreadSafeSingleton):
993 for ins_id, metrics_list in instance_metrics.items():996 for ins_id, metrics_list in instance_metrics.items():
994 self._instance_metrics_cached[ins_id] = {self.METRICS_KEY: metrics_list}997 self._instance_metrics_cached[ins_id] = {self.METRICS_KEY: metrics_list}
995 instance_roles = {ins_id: data.get("role", "") for ins_id, data in collects.items() if isinstance(data, dict)}998 instance_roles = {ins_id: data.get("role", "") for ins_id, data in collects.items() if isinstance(data, dict)}
996- instance_dispatch_capabilities = {999+ instance_engine_types = {
997- ins_id: set(data.get("dispatch_capabilities", []))1000+ ins_id: str(data.get("engine_type", "")) for ins_id, data in collects.items() if isinstance(data, dict)
998- for ins_id, data in collects.items()
999- if isinstance(data, dict)
1000 }1001 }
1001 aggregate = self._aggregation_engine.post_process(1002 aggregate = self._aggregation_engine.post_process(
1002- self._aggregate_metrics_all_instance(collects, instance_roles, instance_dispatch_capabilities)1003+ self._aggregate_metrics_all_instance(collects, instance_roles, instance_engine_types)
1003 )1004 )
1004 with self._config_lock:1005 with self._config_lock:
1005 deploy_config = self._deploy_config1006 deploy_config = self._deploy_config
@@ -92,9 +92,6 @@ class RequestInfo(BaseModel):
92 prompt_tokens_details: dict = Field(default={}, description="prefill prompt_tokens_details")92 prompt_tokens_details: dict = Field(default={}, description="prefill prompt_tokens_details")
93 prompt_token_ids: list = Field(default=[], description="prefill prompt_token_ids")93 prompt_token_ids: list = Field(default=[], description="prefill prompt_token_ids")
94 cached_token_ids: list = Field(default=[], description="Cached token_ids")94 cached_token_ids: list = Field(default=[], description="Cached token_ids")
95- p_instance_id: int | None = Field(
96- default=None, description="P instance ID set by metaserver handler in CDP-like modes"
97- )
98 scheduling_constraint: SchedulingConstraint | None = Field(95 scheduling_constraint: SchedulingConstraint | None = Field(
99 default=None,96 default=None,
100 description="Internal pin-to-instance constraint (e.g. precision probe); not from client API",97 description="Internal pin-to-instance constraint (e.g. precision probe); not from client API",
@@ -1,9 +1,20 @@
1-# -*- coding: utf-8 -*-
2# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.1# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
3# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
4 3 
5"""Coordinator HTTP routing layer: dispatch entry and strategy implementations."""4"""Coordinator HTTP routing layer: dispatch entry and strategy implementations."""
6 5 
6+from typing import TYPE_CHECKING
7+ 
8+if TYPE_CHECKING:
9+ from motor.coordinator.router.dispatch import handle_request
10+ 
7__all__ = ["handle_request"]11__all__ = ["handle_request"]
8 12 
9-from motor.coordinator.router.dispatch import handle_request13+ 
14+def __getattr__(name: str):
15+ """Load the HTTP entry lazily so adapter imports do not initialize the full domain graph."""
16+ if name == "handle_request":
17+ from motor.coordinator.router.dispatch import handle_request
18+ 
19+ return handle_request
20+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -1,9 +1,23 @@
1-# -*- coding: utf-8 -*-
2# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.1# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
3# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
4 3 
5"""Response format adapters (OpenAI Completion <-> Chat)."""4"""Response format adapters (OpenAI Completion <-> Chat)."""
6 5 
6+from typing import TYPE_CHECKING
7+ 
8+if TYPE_CHECKING:
9+ from motor.coordinator.router.adapters.completion_to_chat import (
10+ adapt_completion_nonstream_to_chat,
11+ adapt_completion_stream_chunk_to_chat,
12+ is_completion_like_stream_chunk,
13+ )
14+ from motor.coordinator.router.adapters.stream import (
15+ encode_stream_chunk_bytes,
16+ parse_stream_chunk_json,
17+ strip_nonstream_response_body_for_client,
18+ strip_stream_chunk_bytes_for_client,
19+ )
20+ 
7__all__ = [21__all__ = [
8 "adapt_completion_nonstream_to_chat",22 "adapt_completion_nonstream_to_chat",
9 "adapt_completion_stream_chunk_to_chat",23 "adapt_completion_stream_chunk_to_chat",
@@ -14,15 +28,28 @@ __all__ = [
14 "strip_stream_chunk_bytes_for_client",28 "strip_stream_chunk_bytes_for_client",
15]29]
16 30 
17-from motor.coordinator.router.adapters.completion_to_chat import (
18- adapt_completion_nonstream_to_chat,
19- adapt_completion_stream_chunk_to_chat,
20- is_completion_like_stream_chunk,
21-)
22 31 
23-from motor.coordinator.router.adapters.stream import (32+_COMPLETION_EXPORTS = {
24- encode_stream_chunk_bytes,33+ "adapt_completion_nonstream_to_chat",
25- parse_stream_chunk_json,34+ "adapt_completion_stream_chunk_to_chat",
26- strip_nonstream_response_body_for_client,35+ "is_completion_like_stream_chunk",
27- strip_stream_chunk_bytes_for_client,36+}
28-)37+_STREAM_EXPORTS = {
38+ "encode_stream_chunk_bytes",
39+ "parse_stream_chunk_json",
40+ "strip_nonstream_response_body_for_client",
41+ "strip_stream_chunk_bytes_for_client",
42+}
43+ 
44+ 
45+def __getattr__(name: str):
46+ """Resolve adapter exports lazily to keep protocol-only imports dependency-free."""
47+ if name in _COMPLETION_EXPORTS:
48+ from motor.coordinator.router.adapters import completion_to_chat
49+ 
50+ return getattr(completion_to_chat, name)
51+ if name in _STREAM_EXPORTS:
52+ from motor.coordinator.router.adapters import stream
53+ 
54+ return getattr(stream, name)
55+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -1,4 +1,3 @@
1-# -*- coding: utf-8 -*-
2# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.1# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
3# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
4# You can use this software according to the terms and conditions of the Mulan PSL v2.3# You can use this software according to the terms and conditions of the Mulan PSL v2.
@@ -30,6 +29,14 @@ def _chat_completion_id(req_id: str) -> str:
30 return f"chatcmpl-{base}"29 return f"chatcmpl-{base}"
31 30 
32 31 
32+def is_completion_like_body(body: dict[str, Any]) -> bool:
33+ """Return whether a non-stream response has the OpenAI Completion shape."""
34+ if body.get("object") == "text_completion":
35+ return True
36+ choices = body.get(OpenAIField.CHOICES) or []
37+ return bool(choices and isinstance(choices[0], dict) and OpenAIField.TEXT in choices[0])
38+ 
39+ 
33def is_completion_like_stream_chunk(chunk_json: dict[str, Any]) -> bool:40def is_completion_like_stream_chunk(chunk_json: dict[str, Any]) -> bool:
34 """True if chunk looks like a text_completion stream object (not chat chunk)."""41 """True if chunk looks like a text_completion stream object (not chat chunk)."""
35 if chunk_json.get("object") == "text_completion":42 if chunk_json.get("object") == "text_completion":
@@ -0,0 +1,261 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Stateless native-engine protocol mapping for PD requests."""
12+ 
13+import hashlib
14+from collections.abc import Mapping
15+from copy import deepcopy
16+from dataclasses import dataclass
17+from enum import Enum
18+from types import MappingProxyType
19+from typing import Any, Protocol
20+ 
21+ 
22+class CoordinationMode(str, Enum):
23+ HANDOFF = "handoff"
24+ BOOTSTRAP = "bootstrap"
25+ 
26+ 
27+@dataclass(frozen=True)
28+class EngineEndpointMetadata:
29+ host: str
30+ bootstrap_port: int | None = None
31+ 
32+ 
33+@dataclass(frozen=True)
34+class LegContext:
35+ engine_request_id: str
36+ pair_id: str
37+ attempt_seq: int
38+ api: str
39+ endpoint: EngineEndpointMetadata
40+ peer_endpoint: EngineEndpointMetadata | None = None
41+ 
42+ 
43+@dataclass(frozen=True)
44+class EngineRequest:
45+ api: str
46+ body: dict[str, Any]
47+ 
48+ 
49+@dataclass(frozen=True)
50+class PrefillMetadata:
51+ handoff_ticket: dict[str, Any] | None = None
52+ usage: dict[str, Any] | None = None
53+ 
54+ 
55+class EngineProtocolError(RuntimeError):
56+ """A native engine response or request cannot satisfy the PD contract."""
57+ 
58+ def __init__(self, *, engine_type: str, phase: str, message: str) -> None:
59+ self.engine_type = engine_type
60+ self.phase = phase
61+ self.message = message
62+ super().__init__(f"{engine_type} {phase} protocol error: {message}")
63+ 
64+ 
65+class PDProtocolAdapter(Protocol):
66+ engine_type: str
67+ coordination_mode: CoordinationMode
68+ internal_response_fields: frozenset[str]
69+ 
70+ def build_prefill_request(
71+ self,
72+ request: Mapping[str, Any],
73+ context: LegContext,
74+ ) -> EngineRequest: ...
75+ 
76+ def parse_prefill_response(
77+ self,
78+ response: dict[str, Any],
79+ ) -> PrefillMetadata: ...
80+ 
81+ def build_decode_request(
82+ self,
83+ request: Mapping[str, Any],
84+ context: LegContext,
85+ prefill: PrefillMetadata | None,
86+ ) -> EngineRequest: ...
87+ 
88+ def inject_request_id(self, body: dict[str, Any], request_id: str) -> None: ...
89+ 
90+ def build_abort_request(self, context: LegContext) -> EngineRequest | None: ...
91+ 
92+ 
93+class VllmProtocolAdapter:
94+ engine_type = "vllm"
95+ coordination_mode = CoordinationMode.HANDOFF
96+ internal_response_fields = frozenset({"kv_transfer_params"})
97+ 
98+ def build_prefill_request(
99+ self,
100+ request: Mapping[str, Any],
101+ context: LegContext,
102+ ) -> EngineRequest:
103+ body = deepcopy(dict(request))
104+ self.inject_request_id(body, context.engine_request_id)
105+ body["stream"] = False
106+ body["max_tokens"] = 1
107+ body["min_tokens"] = 1
108+ body.pop("stream_options", None)
109+ if "max_completion_tokens" in body:
110+ body["max_completion_tokens"] = 1
111+ body["kv_transfer_params"] = {
112+ "do_remote_decode": True,
113+ "do_remote_prefill": False,
114+ "remote_engine_id": None,
115+ "remote_block_ids": None,
116+ "remote_host": None,
117+ "remote_port": None,
118+ }
119+ return EngineRequest(api=context.api, body=body)
120+ 
121+ def parse_prefill_response(
122+ self,
123+ response: dict[str, Any],
124+ ) -> PrefillMetadata:
125+ kv_params = response.get("kv_transfer_params")
126+ if not isinstance(kv_params, dict) or not kv_params:
127+ raise EngineProtocolError(
128+ engine_type=self.engine_type,
129+ phase="prefill",
130+ message="Missing kv_transfer_params",
131+ )
132+ if kv_params.get("do_remote_prefill") is not True:
133+ raise EngineProtocolError(
134+ engine_type=self.engine_type,
135+ phase="prefill",
136+ message="do_remote_prefill must be true",
137+ )
138+ usage = response.get("usage")
139+ return PrefillMetadata(
140+ handoff_ticket=deepcopy(kv_params),
141+ usage=deepcopy(usage) if isinstance(usage, dict) else None,
142+ )
143+ 
144+ def build_decode_request(
145+ self,
146+ request: Mapping[str, Any],
147+ context: LegContext,
148+ prefill: PrefillMetadata | None,
149+ ) -> EngineRequest:
150+ if prefill is None or not prefill.handoff_ticket:
151+ raise EngineProtocolError(
152+ engine_type=self.engine_type,
153+ phase="decode",
154+ message="Missing handoff ticket",
155+ )
156+ body = deepcopy(dict(request))
157+ self.inject_request_id(body, context.engine_request_id)
158+ body["kv_transfer_params"] = deepcopy(prefill.handoff_ticket)
159+ return EngineRequest(api=context.api, body=body)
160+ 
161+ def inject_request_id(self, body: dict[str, Any], request_id: str) -> None:
162+ body.pop("rid", None)
163+ body["request_id"] = request_id
164+ 
165+ def build_abort_request(self, context: LegContext) -> EngineRequest | None:
166+ del context
167+ 
168+ 
169+class SglangProtocolAdapter:
170+ engine_type = "sglang"
171+ coordination_mode = CoordinationMode.BOOTSTRAP
172+ internal_response_fields = frozenset({"bootstrap_host", "bootstrap_port", "bootstrap_room"})
173+ 
174+ def build_prefill_request(
G
Gganglv19 天前

严重程度: 建议

问题: sglang P 腿不再强制 stream=False,_record_bootstrap_prefill_response 用 splitlines 解析缓冲 SSE,多行 data 帧会被拆坏导致 usage 静默丢失。

原因: 此构建方法不重写请求的 stream 字段(客户端 stream=True 时 P 腿也是流式请求),基线 unified_pd.py 强制 req["stream"] = False。配套的 _record_bootstrap_prefill_response(unified_pd.py:179-201)对 P 腿响应先 try response.json(),ValueError 时用 splitlines() 逐行 parse_stream_chunk_json 消费缓冲的 SSE 文本。SSE 规范允许一个事件由多行 data: 组成(多行被连接成一个 data 值),splitlines 会拆开分别解析,非完整 JSON 的帧被静默跳过——usage 帧(含 prompt_tokens_details 展开后较长)恰好最容易多行,prefill 腿的 cached tokens 统计可能偶发丢失。

怎么改: 用 SSE 标准解析替代 splitlines:按 \n\n 切分事件块,对块内多行 data 用 "\n".join 合并后再 parse_stream_chunk_json;或 P 腿统一改写为 stream=False(保留基线语义,绕开 SSE 缓冲路径)。建议在 test_pd_protocol_adapter.py 补一条用例固定 P 腿流式场景的 usage 帧解析行为。

likedislike
tobking
tobking
19 天前 评论:
175+ self,
176+ request: Mapping[str, Any],
177+ context: LegContext,
178+ ) -> EngineRequest:
179+ engine_request = self._build_request(request, context, context.endpoint, phase="prefill")
180+ engine_request.body["stream"] = False
181+ engine_request.body.pop("stream_options", None)
182+ return engine_request
183+ 
184+ def parse_prefill_response(
185+ self,
186+ response: dict[str, Any],
187+ ) -> PrefillMetadata:
188+ usage = response.get("usage")
189+ return PrefillMetadata(usage=deepcopy(usage) if isinstance(usage, dict) else None)
190+ 
191+ def build_decode_request(
192+ self,
193+ request: Mapping[str, Any],
194+ context: LegContext,
195+ prefill: PrefillMetadata | None,
196+ ) -> EngineRequest:
197+ del prefill
198+ if context.peer_endpoint is None:
199+ raise EngineProtocolError(
200+ engine_type=self.engine_type,
201+ phase="decode",
202+ message="Missing prefill endpoint metadata",
203+ )
204+ return self._build_request(request, context, context.peer_endpoint, phase="decode")
205+ 
206+ def inject_request_id(self, body: dict[str, Any], request_id: str) -> None:
207+ body.pop("request_id", None)
208+ body["rid"] = request_id
209+ 
210+ def build_abort_request(self, context: LegContext) -> EngineRequest | None:
211+ return EngineRequest(api="abort_request", body={"rid": context.engine_request_id})
212+ 
213+ def _build_request(
214+ self,
215+ request: Mapping[str, Any],
216+ context: LegContext,
217+ prefill_endpoint: EngineEndpointMetadata,
218+ *,
219+ phase: str,
220+ ) -> EngineRequest:
221+ self._validate_prefill_endpoint(prefill_endpoint, phase=phase)
222+ body = deepcopy(dict(request))
223+ self.inject_request_id(body, context.engine_request_id)
224+ body.update(
225+ {
226+ "bootstrap_host": prefill_endpoint.host,
227+ "bootstrap_port": prefill_endpoint.bootstrap_port,
228+ "bootstrap_room": self._stable_bootstrap_room(context.pair_id, context.attempt_seq),
229+ }
230+ )
231+ return EngineRequest(api=context.api, body=body)
232+ 
233+ @classmethod
234+ def _validate_prefill_endpoint(cls, endpoint: EngineEndpointMetadata, *, phase: str) -> None:
235+ if not endpoint.host:
236+ raise EngineProtocolError(
237+ engine_type=cls.engine_type,
238+ phase=phase,
239+ message="Missing prefill bootstrap host",
240+ )
241+ port = endpoint.bootstrap_port
242+ if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
243+ raise EngineProtocolError(
244+ engine_type=cls.engine_type,
245+ phase=phase,
246+ message="Missing or invalid prefill bootstrap port",
247+ )
248+ 
249+ @staticmethod
250+ def _stable_bootstrap_room(pair_id: str, attempt_seq: int) -> int:
251+ raw = f"{pair_id}:{attempt_seq}".encode("utf-8")
252+ digest = hashlib.blake2b(raw, digest_size=8).digest()
253+ return int.from_bytes(digest, "big") & ((1 << 63) - 1)
254+ 
255+ 
256+ADAPTERS: Mapping[str, PDProtocolAdapter] = MappingProxyType(
257+ {
258+ VllmProtocolAdapter.engine_type: VllmProtocolAdapter(),
259+ SglangProtocolAdapter.engine_type: SglangProtocolAdapter(),
260+ }
261+)
@@ -179,29 +179,23 @@ async def select_router_class(
179) -> type["BaseRouter"]:179) -> type["BaseRouter"]:
180 """Select the router implementation from the live instance topology.180 """Select the router implementation from the live instance topology.
181 181 
182- Routing is derived from the roles currently present plus whether a P/D pair shares a182+ Routing is derived from the live roles and circuit-breaker state. Native protocol
183- dispatch capability no deploy_mode. Shared by user traffic (handle_request) and the183+ selection happens inside UnifiedPDRouter from the selected instance engine_type.
184- internal precision probe so both route identically.184+ Shared by user traffic and the internal precision probe so both route identically.
185 185 
186 Raises HTTPException(503) when no routable topology is available.186 Raises HTTPException(503) when no routable topology is available.
187 """187 """
188 roles = await scheduler.get_available_instance_roles()188 roles = await scheduler.get_available_instance_roles()
189 has_pd_roles = PDRole.ROLE_P in roles and PDRole.ROLE_D in roles189 has_pd_roles = PDRole.ROLE_P in roles and PDRole.ROLE_D in roles
190- has_compatible_pair = False190+ has_routable_pd_pair = has_pd_roles
191 if has_pd_roles:191 if has_pd_roles:
192- compatibility_check = getattr(scheduler, "has_compatible_pd_pair", None)192+ get_unblocked = getattr(scheduler, "get_unblocked_instances", None)
193- has_compatible_pair = await compatibility_check() if compatibility_check is not None else True193+ if get_unblocked is not None:
194- if has_compatible_pair:194+ unblocked_p = await get_unblocked(PDRole.ROLE_P)
195- # Check circuit breaker: only treat as compatible if there are195+ unblocked_d = await get_unblocked(PDRole.ROLE_D)
196- # non-blocked instances for BOTH roles196+ has_routable_pd_pair = bool(unblocked_p and unblocked_d)
197- get_unblocked = getattr(scheduler, "get_unblocked_instances", None)
198- if get_unblocked is not None:
199- unblocked_p = await get_unblocked(PDRole.ROLE_P)
200- unblocked_d = await get_unblocked(PDRole.ROLE_D)
201- if not unblocked_p or not unblocked_d:
202- has_compatible_pair = False
203 197 
204- if has_compatible_pair:198+ if has_routable_pd_pair:
205 return UnifiedPDRouter199 return UnifiedPDRouter
206 200 
207 # Degrade to hybrid mode if any unblocked instance is available201 # Degrade to hybrid mode if any unblocked instance is available
@@ -225,7 +219,7 @@ async def select_router_class(
225 is_hybrid_deploy = _is_pd_hybrid_deploy(config)219 is_hybrid_deploy = _is_pd_hybrid_deploy(config)
226 if not fallback_enabled and not is_hybrid_deploy:220 if not fallback_enabled and not is_hybrid_deploy:
227 if has_pd_roles:221 if has_pd_roles:
228- message = "PD separate service has no compatible P/D pair and fallback to hybrid is disabled"222+ message = "PD separate service has no circuit-breaker-available P/D pair and fallback is disabled"
229 else:223 else:
230 message = "PD separate service is unavailable and fallback to hybrid is disabled"224 message = "PD separate service is unavailable and fallback to hybrid is disabled"
231 if req_info is not None:225 if req_info is not None:
@@ -234,17 +228,13 @@ async def select_router_class(
234 raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=message)228 raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=message)
235 229 
236 if PDRole.ROLE_U in roles or PDRole.ROLE_P in roles:230 if PDRole.ROLE_U in roles or PDRole.ROLE_P in roles:
237- if has_pd_roles and not has_compatible_pair and PDRole.ROLE_U in roles:231+ if has_pd_roles and not has_routable_pd_pair and PDRole.ROLE_U in roles:
238- message = (232+ message = "P/D instances are unavailable; falling back to PDHybridRouter via union instances"
239- "P/D instances are online but advertise no shared dispatch capability; "
240- "falling back to PDHybridRouter via union instances. "
241- "Check the engine kv_connector is recognized or set dispatch_profile explicitly."
242- )
243 if req_info is not None:233 if req_info is not None:
244 req_info.trace_obj.set_trace_error_message(message)234 req_info.trace_obj.set_trace_error_message(message)
245 logger.warning(message)235 logger.warning(message)
246- elif has_pd_roles and not has_compatible_pair and req_info is not None:236+ elif has_pd_roles and not has_routable_pd_pair and req_info is not None:
247- error_message = "PD separate service degraded to hybrid: P or D instances circuit-broken or incompatible"237+ error_message = "PD separate service degraded to hybrid: P or D instances are circuit-broken"
248 req_info.trace_obj.set_trace_error_message(error_message)238 req_info.trace_obj.set_trace_error_message(error_message)
249 logger.warning(error_message)239 logger.warning(error_message)
250 elif req_info is not None and PDRole.ROLE_U not in roles:240 elif req_info is not None and PDRole.ROLE_U not in roles:
@@ -265,12 +255,14 @@ async def handle_request(
265 scheduler=None,255 scheduler=None,
266 *,256 *,
267 request_manager: RequestManager,257 request_manager: RequestManager,
258+ request_json: dict | None = None,
268) -> Response:259) -> Response:
269 """Handle incoming requests and route them to appropriate router implementation260 """Handle incoming requests and route them to appropriate router implementation
270 261 
271 Args:262 Args:
272 raw_request: The incoming FastAPI request object263 raw_request: The incoming FastAPI request object
273 request_manager: RequestManager instance (required, injected by InferenceServer)264 request_manager: RequestManager instance (required, injected by InferenceServer)
265+ request_json: Body already parsed by the API ingress, when available
274 266 
275 Returns:267 Returns:
276 Response: The response from the selected router implementation (stream, non-stream, or error)268 Response: The response from the selected router implementation (stream, non-stream, or error)
@@ -279,7 +271,7 @@ async def handle_request(
279 HTTPException: If request body is empty or request fail271 HTTPException: If request body is empty or request fail
280 """272 """
281 273 
282- req_info = await __create_request_info(raw_request, request_manager)274+ req_info = await __create_request_info(raw_request, request_manager, request_json=request_json)
283 275 
284 if TracerManager().contains_trace_headers(raw_request.headers):276 if TracerManager().contains_trace_headers(raw_request.headers):
285 req_info.trace_obj.parent_context = TracerManager().extract_trace_context(raw_request.headers)277 req_info.trace_obj.parent_context = TracerManager().extract_trace_context(raw_request.headers)
@@ -344,16 +336,18 @@ async def handle_request(
344async def __create_request_info(336async def __create_request_info(
345 raw_request: Request,337 raw_request: Request,
346 request_manager: RequestManager,338 request_manager: RequestManager,
339+ request_json: dict | None = None,
347) -> RequestInfo:340) -> RequestInfo:
348 request_body = await raw_request.body()341 request_body = await raw_request.body()
349 if not request_body:342 if not request_body:
350 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty request body")343 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty request body")
351 344 
352- try:345+ if request_json is None:
353- request_json = await raw_request.json()346+ try:
354- except Exception as e:347+ request_json = await raw_request.json()
355- logger.warning("JSON parse failed: %s", e)348+ except Exception as e:
356- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON format") from e349+ logger.warning("JSON parse failed: %s", e)
350+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON format") from e
357 351 
358 if not request_json:352 if not request_json:
359 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty request json")353 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty request json")
@@ -1,53 +0,0 @@
1-# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# MindIE is licensed under Mulan PSL v2.
3-# You can use this software according to the terms and conditions of the Mulan PSL v2.
4-# You may obtain a copy of Mulan PSL v2 at:
5-# http://license.coscl.org.cn/MulanPSL2
6-# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7-# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8-# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9-# See the Mulan PSL v2 for more details.
10- 
11-from motor.common.resources.dispatch import DispatchPlan, dispatch_plans_from_capabilities, shared_dispatch_plans
12-from motor.coordinator.domain import ScheduledResource
13- 
14- 
15-class DispatchPlanNotSupported(RuntimeError):
16- pass
17- 
18- 
19-def select_dispatch_plan_for_pair(
20- *,
21- prefill: ScheduledResource | None,
22- decode: ScheduledResource | None,
23-) -> DispatchPlan:
24- explicit_plan = _select_explicit_plan(prefill, decode)
25- if explicit_plan is not None:
26- return explicit_plan
27- raise DispatchPlanNotSupported(
28- "Selected P/D instances do not advertise a shared dispatch capability; "
29- "configure a supported engine connector or dispatch_profile"
30- )
31- 
32- 
33-def _select_explicit_plan(
34- prefill: ScheduledResource | None,
35- decode: ScheduledResource | None,
36-) -> DispatchPlan | None:
37- prefill_instance = prefill.instance if prefill is not None else None
38- decode_instance = decode.instance if decode is not None else None
39- prefill_plans = dispatch_plans_from_capabilities(getattr(prefill_instance, "dispatch_capabilities", None))
40- decode_plans = dispatch_plans_from_capabilities(getattr(decode_instance, "dispatch_capabilities", None))
41- if not prefill_plans or not decode_plans:
42- return None
43- 
44- supported = shared_dispatch_plans(prefill_instance, decode_instance)
45- 
46- preferred = [
47- DispatchPlan.CONCURRENT_ENGINE_SYNC,
48- DispatchPlan.PREFILL_HANDOFF_DECODE,
49- ]
50- for plan in preferred:
51- if plan in supported:
52- return plan
53- raise DispatchPlanNotSupported("Selected P/D instances have no shared dispatch capability")
@@ -9,20 +9,11 @@
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11import asyncio11import asyncio
12-import time
13import uuid12import uuid
14from dataclasses import dataclass, field13from dataclasses import dataclass, field
15from enum import Enum14from enum import Enum
16 15 
17from motor.common.http import HTTPClientPool16from motor.common.http import HTTPClientPool
18-from motor.common.resources.dispatch import (
19- DispatchEndpoint,
20- DispatchEndpoints,
21- MotorDispatch,
22- PrefillContextBudget,
23-)
24-from motor.common.resources.instance import PDRole
25-from motor.common.utils.net import format_address
26from motor.config.coordinator import CoordinatorConfig17from motor.config.coordinator import CoordinatorConfig
27from motor.coordinator.domain import ScheduledResource18from motor.coordinator.domain import ScheduledResource
28 19 
@@ -41,6 +32,12 @@ class AttemptState(str, Enum):
41 STOPPED = "stopped"32 STOPPED = "stopped"
42 33 
43 34 
35+class AttemptStopReason(str, Enum):
36+ CLIENT_DISCONNECT = "client_disconnect"
37+ PEER_FAILED = "peer_failed"
38+ OTHER = "other"
39+ 
40+ 
44@dataclass41@dataclass
45class AttemptReleaseFlags:42class AttemptReleaseFlags:
46 prefill_tokens: bool = False43 prefill_tokens: bool = False
@@ -52,20 +49,18 @@ class AttemptContext:
52 root_request_id: str49 root_request_id: str
53 attempt_seq: int50 attempt_seq: int
54 pair_id: str51 pair_id: str
55- prefill_context_budget: PrefillContextBudget | None = None
56 prefill_resource: ScheduledResource | None = None52 prefill_resource: ScheduledResource | None = None
57 decode_resource: ScheduledResource | None = None53 decode_resource: ScheduledResource | None = None
58 state: AttemptState = AttemptState.CREATED54 state: AttemptState = AttemptState.CREATED
59- first_visible_sent: bool = False
60- stop_sent: bool = False
61 release_flags: AttemptReleaseFlags = field(default_factory=AttemptReleaseFlags)55 release_flags: AttemptReleaseFlags = field(default_factory=AttemptReleaseFlags)
62- created_at: float = field(default_factory=time.time)
63- updated_at: float = field(default_factory=time.time)
64 prefill_task: asyncio.Task | None = None56 prefill_task: asyncio.Task | None = None
65 decode_task: asyncio.Task | None = None57 decode_task: asyncio.Task | None = None
58+ prefill_dispatched: bool = False
59+ prefill_completed: bool = False
60+ decode_dispatched: bool = False
61+ decode_completed: bool = False
66 stop_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)62 stop_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
67 config: CoordinatorConfig | None = None63 config: CoordinatorConfig | None = None
68- fail_reason: str | None = None
69 64 
70 def transition(self, state: AttemptState) -> bool:65 def transition(self, state: AttemptState) -> bool:
71 if self.state == AttemptState.STOPPED:66 if self.state == AttemptState.STOPPED:
@@ -73,37 +68,16 @@ class AttemptContext:
73 if self.state == AttemptState.STOPPING:68 if self.state == AttemptState.STOPPING:
74 if state == AttemptState.STOPPED:69 if state == AttemptState.STOPPED:
75 self.state = state70 self.state = state
76- self.updated_at = time.time()
77 return True71 return True
78 return state == AttemptState.STOPPING72 return state == AttemptState.STOPPING
79 if self.state == AttemptState.DONE:73 if self.state == AttemptState.DONE:
80 return state == AttemptState.DONE74 return state == AttemptState.DONE
81 self.state = state75 self.state = state
82- self.updated_at = time.time()
83- if state == AttemptState.FIRST_VISIBLE:
84- self.first_visible_sent = True
85 return True76 return True
86 77 
87 def stop(self) -> None:78 def stop(self) -> None:
88 if self.state not in (AttemptState.DONE, AttemptState.STOPPED):79 if self.state not in (AttemptState.DONE, AttemptState.STOPPED):
89 self.state = AttemptState.STOPPING80 self.state = AttemptState.STOPPING
90- self.stop_sent = True
91- self.updated_at = time.time()
92- 
93- def dispatch_for(self, role: PDRole, dispatch_mode: str) -> MotorDispatch:
94- return MotorDispatch(
95- root_request_id=self.root_request_id,
96- engine_request_id=f"{self.root_request_id}#a{self.attempt_seq}",
97- pair_id=self.pair_id,
98- attempt_seq=self.attempt_seq,
99- role="prefill" if role == PDRole.ROLE_P else "decode",
100- dispatch_mode=dispatch_mode,
101- prefill_context_budget=self.prefill_context_budget,
102- endpoints=DispatchEndpoints(
103- prefill=_dispatch_endpoint(self.prefill_resource),
104- decode=_dispatch_endpoint(self.decode_resource),
105- ),
106- )
107 81 
108 def register_prefill_task(self, task: asyncio.Task) -> asyncio.Task:82 def register_prefill_task(self, task: asyncio.Task) -> asyncio.Task:
109 self.prefill_task = task83 self.prefill_task = task
@@ -113,25 +87,45 @@ class AttemptContext:
113 self.decode_task = task87 self.decode_task = task
114 return task88 return task
115 89 
90+ def mark_dispatched(self, role: str) -> None:
91+ if role == "prefill":
92+ self.prefill_dispatched = True
93+ else:
94+ self.decode_dispatched = True
95+ 
96+ def mark_completed(self, role: str) -> None:
97+ if role == "prefill":
98+ self.prefill_completed = True
99+ else:
100+ self.decode_completed = True
101+ 
102+ def needs_abort(self, role: str) -> bool:
103+ if role == "prefill":
104+ return self.prefill_dispatched and not self.prefill_completed
105+ return self.decode_dispatched and not self.decode_completed
106+ 
116 async def cancel(self, reason: str = ""):107 async def cancel(self, reason: str = ""):
117- self.fail_reason = reason108+ tasks = []
118- task = []
119 if self.prefill_task and not self.prefill_task.done() and not self.prefill_task.cancelled():109 if self.prefill_task and not self.prefill_task.done() and not self.prefill_task.cancelled():
120 logger.info(110 logger.info(
121- f"Cancelling prefill task: {self.prefill_resource.endpoint.ip} {self.prefill_resource.instance.job_name}"111+ "Cancelling prefill task: %s %s because %s",
122- f" because {reason}"112+ self.prefill_resource.endpoint.ip,
113+ self.prefill_resource.instance.job_name,
114+ reason,
123 )115 )
124 self.prefill_task.cancel(msg=reason)116 self.prefill_task.cancel(msg=reason)
125- task.append(self.prefill_task)117+ tasks.append(self.prefill_task)
126 if self.decode_task and not self.decode_task.done() and not self.decode_task.cancelled():118 if self.decode_task and not self.decode_task.done() and not self.decode_task.cancelled():
127 logger.info(119 logger.info(
128- f"Cancelling decode task: {self.decode_resource.endpoint.ip} {self.decode_resource.instance.job_name}"120+ "Cancelling decode task: %s %s because %s",
129- f" because {reason}"121+ self.decode_resource.endpoint.ip,
122+ self.decode_resource.instance.job_name,
123+ reason,
130 )124 )
131 self.decode_task.cancel(msg=reason)125 self.decode_task.cancel(msg=reason)
132- task.append(self.decode_task)126+ tasks.append(self.decode_task)
133- if task:127+ if tasks:
134- await asyncio.gather(*task, return_exceptions=True)128+ await asyncio.gather(*tasks, return_exceptions=True)
135 129 
136 def register_canceller(self):130 def register_canceller(self):
137 pool = HTTPClientPool()131 pool = HTTPClientPool()
@@ -168,8 +162,7 @@ class AttemptContext:
168 )162 )
169 pool.unregister_canceller(d_key, self.pair_id)163 pool.unregister_canceller(d_key, self.pair_id)
170 except Exception as e:164 except Exception as e:
171- logger.error(f"Unregister error {e=}")165+ logger.error("Unregister error: %s", e)
172- pass
173 166 
174 def unregister_prefill_canceller(self):167 def unregister_prefill_canceller(self):
175 try:168 try:
@@ -182,8 +175,7 @@ class AttemptContext:
182 )175 )
183 pool.unregister_canceller(p_key, self.pair_id)176 pool.unregister_canceller(p_key, self.pair_id)
184 except Exception as e:177 except Exception as e:
185- logger.error(f"Unregister error {e=}")178+ logger.error("Unregister error: %s", e)
186- pass
187 179 
188 def register_decode_canceller(self):180 def register_decode_canceller(self):
189 if not self.decode_resource:181 if not self.decode_resource:
@@ -198,47 +190,23 @@ class AttemptContext:
198 190 
199 191 
200class PDDispatchSession:192class PDDispatchSession:
201- def __init__(193+ def __init__(self, root_request_id: str) -> None:
202- self,
203- root_request_id: str,
204- prefill_context_budget: PrefillContextBudget | None = None,
205- ) -> None:
206 self.root_request_id = root_request_id194 self.root_request_id = root_request_id
207- self.prefill_context_budget = prefill_context_budget
208 self._attempt_seq = 0195 self._attempt_seq = 0
209- self.attempts: dict[int, AttemptContext] = {}
210 196 
211 def new_attempt(197 def new_attempt(
212 self,198 self,
213 prefill_resource: ScheduledResource | None,199 prefill_resource: ScheduledResource | None,
214 decode_resource: ScheduledResource | None,200 decode_resource: ScheduledResource | None,
215 config: CoordinatorConfig,201 config: CoordinatorConfig,
216- *,
217- consumed_output_tokens: int = 0,
218 ) -> AttemptContext:202 ) -> AttemptContext:
219 self._attempt_seq += 1203 self._attempt_seq += 1
220- budget = self.prefill_context_budget
221- if budget is not None:
222- budget = budget.after_output_tokens(consumed_output_tokens)
223 attempt = AttemptContext(204 attempt = AttemptContext(
224 root_request_id=self.root_request_id,205 root_request_id=self.root_request_id,
225 attempt_seq=self._attempt_seq,206 attempt_seq=self._attempt_seq,
226 pair_id=uuid.uuid4().hex,207 pair_id=uuid.uuid4().hex,
227- prefill_context_budget=budget,
228 prefill_resource=prefill_resource,208 prefill_resource=prefill_resource,
229 decode_resource=decode_resource,209 decode_resource=decode_resource,
230 config=config,210 config=config,
231 )211 )
232- self.attempts[attempt.attempt_seq] = attempt
233 return attempt212 return attempt
234- 
235- 
236-def _dispatch_endpoint(resource: ScheduledResource | None) -> DispatchEndpoint | None:
237- if not resource or not resource.instance or not resource.endpoint:
238- return None
239- endpoint = resource.endpoint
240- return DispatchEndpoint(
241- instance_id=int(resource.instance.id),
242- endpoint_id=int(endpoint.id),
243- url=f"http://{format_address(endpoint.ip, endpoint.business_port)}",
244- )
@@ -37,7 +37,7 @@ def build_decode_sample(
37 """Construct a DecodeSample from request_info and context.37 """Construct a DecodeSample from request_info and context.
38 38 
39 Args:39 Args:
40- p_instance_id: P instance id (None if unknown in CDP mode).40+ p_instance_id: P instance id (None for a Hybrid/Union request).
41 d_instance_id: D instance id.41 d_instance_id: D instance id.
42 request_info: Per-request mutable dict populated by42 request_info: Per-request mutable dict populated by
43 update_token_id_cache / update_logprob_cache.43 update_token_id_cache / update_logprob_cache.
@@ -8,7 +8,8 @@
8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11-"""Shared helpers for PD/CDP rescheduler (token-cache retry and request rewrite).11+"""Shared helpers for P/D rescheduling (token-cache retry and request rewrite).
12+ 
12 13 
13Performance note: per-chunk JSON re-serialization is CPU-bound; ``async`` does not14Performance note: per-chunk JSON re-serialization is CPU-bound; ``async`` does not
14make it faster. Mitigations here use :mod:`msgspec` (already a dependency) for15make it faster. Mitigations here use :mod:`msgspec` (already a dependency) for
@@ -1,77 +0,0 @@
1-# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# MindIE is licensed under Mulan PSL v2.
3-# You can use this software according to the terms and conditions of the Mulan PSL v2.
4-# You may obtain a copy of Mulan PSL v2 at:
5-# http://license.coscl.org.cn/MulanPSL2
6-# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7-# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8-# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9-# See the Mulan PSL v2 for more details.
10- 
11-from __future__ import annotations
12- 
13-import hashlib
14-import os
15-from typing import Any
16- 
17-from motor.coordinator.domain import ScheduledResource
18-from motor.coordinator.router.dispatch_session import AttemptContext
19- 
20-_SGLANG_ENGINE_TYPE = "sglang"
21- 
22- 
23-def is_sglang_resource(resource: ScheduledResource | None) -> bool:
24- """Return True if the scheduled resource reports engine_type=sglang."""
25- if resource is None or resource.instance is None:
26- return False
27- engine_type = str(getattr(resource.instance, "engine_type", "") or "").strip().lower()
28- return engine_type == _SGLANG_ENGINE_TYPE
29- 
30- 
31-def ensure_sglang_pd_pair(attempt: AttemptContext) -> None:
32- """Require both P and D legs to be SGLang for native bootstrap PD."""
33- prefill = attempt.prefill_resource
34- decode = attempt.decode_resource
35- if not is_sglang_resource(prefill) or not is_sglang_resource(decode):
36- prefill_engine = prefill.instance.engine_type if prefill and prefill.instance else None
37- decode_engine = decode.instance.engine_type if decode and decode.instance else None
38- raise RuntimeError(
39- "SGLang native PD requires both prefill and decode engine_type=sglang, "
40- f"got prefill={prefill_engine!r} decode={decode_engine!r}."
41- )
42- 
43- 
44-def _prefill_bootstrap_host(attempt: AttemptContext) -> str:
45- """Return Prefill endpoint IP."""
46- resource = attempt.prefill_resource
47- if resource is None or resource.endpoint is None:
48- raise RuntimeError("SGLang PD requires a scheduled prefill endpoint for bootstrap_host")
49- return resource.endpoint.ip
50- 
51- 
52-def _bootstrap_port() -> str:
53- """Read and validate DISAGGREGATION_BOOTSTRAP_PORT."""
54- raw = os.getenv("DISAGGREGATION_BOOTSTRAP_PORT", "").strip()
55- try:
56- port = int(raw)
57- except ValueError as e:
58- raise RuntimeError(f"DISAGGREGATION_BOOTSTRAP_PORT must be an integer (e.g. 8998), got {raw!r}.") from e
59- if not 1 <= port <= 65535:
60- raise RuntimeError(f"DISAGGREGATION_BOOTSTRAP_PORT must be in range 1-65535, got {port}.")
61- return str(port)
62- 
63- 
64-def _stable_bootstrap_room(pair_id: str, attempt_seq: int) -> int:
65- """Derive a stable positive int63 bootstrap_room for a P/D attempt."""
66- raw = f"{pair_id}:{attempt_seq}".encode("utf-8")
67- digest = hashlib.blake2b(raw, digest_size=8).digest()
68- return int.from_bytes(digest, "big") & ((1 << 63) - 1)
69- 
70- 
71-def inject_sglang_pd_fields(req: dict[str, Any], attempt: AttemptContext) -> None:
72- """Mutate ``req`` in place with stock SGLang PD fields (no ``_motor_dispatch``)."""
73- ensure_sglang_pd_pair(attempt)
74- req["bootstrap_host"] = _prefill_bootstrap_host(attempt)
75- req["bootstrap_port"] = _bootstrap_port()
76- req["bootstrap_room"] = _stable_bootstrap_room(attempt.pair_id, attempt.attempt_seq)
77- req.setdefault("request_id", f"{attempt.root_request_id}#a{attempt.attempt_seq}")
@@ -1,155 +0,0 @@
1-# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# MindIE is licensed under Mulan PSL v2.
3-# You can use this software according to the terms and conditions of the Mulan PSL v2.
4-# You may obtain a copy of Mulan PSL v2 at:
5-# http://license.coscl.org.cn/MulanPSL2
6-# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7-# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8-# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9-# See the Mulan PSL v2 for more details.
10- 
11-import time
12- 
13-import httpx
14- 
15-from motor.common.http.http_client import HTTPClientPool
16-from motor.common.logger import get_logger
17-from motor.common.resources.dispatch import (
18- DispatchStopReason,
19- DispatchStopRequest,
20- DispatchStopResponse,
21- DispatchStopState,
22-)
23-from motor.config.coordinator import CoordinatorConfig
24-from motor.coordinator.domain import ScheduledResource
25-from motor.coordinator.router.dispatch_session import AttemptContext
26-from motor.coordinator.router.sglang_native_dispatch import is_sglang_resource
27- 
28-logger = get_logger(__name__)
29- 
30- 
31-def _engine_request_id(attempt: AttemptContext) -> str:
32- return f"{attempt.root_request_id}#a{attempt.attempt_seq}"
33- 
34- 
35-class DispatchStopClient:
36- def __init__(self, config: CoordinatorConfig) -> None:
37- self._config = config
38- 
39- async def stop(
40- self,
41- resource: ScheduledResource,
42- attempt: AttemptContext,
43- reason: DispatchStopReason,
44- timeout: float = 1.0,
45- ) -> DispatchStopResponse | None:
46- if not resource or not resource.endpoint:
47- return None
48- 
49- if is_sglang_resource(resource):
50- return await self._stop_sglang_native(resource, attempt, reason, timeout)
51- return await self._stop_motor_dispatch(resource, attempt, reason, timeout)
52- 
53- async def _stop_sglang_native(
54- self,
55- resource: ScheduledResource,
56- attempt: AttemptContext,
57- reason: DispatchStopReason,
58- timeout: float,
59- ) -> DispatchStopResponse | None:
60- """Abort via stock SGLang ``POST /abort_request`` (no InferEndpoint stop API)."""
61- endpoint = resource.endpoint
62- engine_request_id = _engine_request_id(attempt)
63- try:
64- client = await HTTPClientPool().get_client(
65- ip=endpoint.ip,
66- port=endpoint.business_port,
67- tls_config=self._config.infer_tls_config,
68- )
69- response = await client.post(
70- "/abort_request",
71- json={"rid": engine_request_id},
72- timeout=timeout,
73- )
74- response.raise_for_status()
75- logger.info(
76- "SGLang abort_request accepted root_request_id=%s attempt_seq=%s endpoint=%s:%s rid=%s reason=%s",
77- attempt.root_request_id,
78- attempt.attempt_seq,
79- endpoint.ip,
80- endpoint.business_port,
81- engine_request_id,
82- reason.value,
83- )
84- return DispatchStopResponse(
85- root_request_id=attempt.root_request_id,
86- attempt_seq=attempt.attempt_seq,
87- accepted=True,
88- state=DispatchStopState.STOPPED,
89- message="sglang:/abort_request",
90- )
91- except httpx.HTTPError as e:
92- logger.warning(
93- "SGLang abort_request failed root_request_id=%s attempt_seq=%s endpoint=%s:%s rid=%s error=%s",
94- attempt.root_request_id,
95- attempt.attempt_seq,
96- endpoint.ip,
97- endpoint.business_port,
98- engine_request_id,
99- e,
100- )
101- except Exception as e:
102- logger.warning(
103- "SGLang abort_request unexpected error root_request_id=%s attempt_seq=%s error=%s",
104- attempt.root_request_id,
105- attempt.attempt_seq,
106- e,
107- )
108- return None
109- 
110- async def _stop_motor_dispatch(
111- self,
112- resource: ScheduledResource,
113- attempt: AttemptContext,
114- reason: DispatchStopReason,
115- timeout: float,
116- ) -> DispatchStopResponse | None:
117- endpoint = resource.endpoint
118- request = DispatchStopRequest(
119- root_request_id=attempt.root_request_id,
120- engine_request_id=_engine_request_id(attempt),
121- attempt_seq=attempt.attempt_seq,
122- pair_id=attempt.pair_id,
123- reason=reason.value,
124- sent_at_ms=int(time.time() * 1000),
125- )
126- try:
127- client = await HTTPClientPool().get_client(
128- ip=endpoint.ip,
129- port=endpoint.business_port,
130- tls_config=self._config.infer_tls_config,
131- )
132- response = await client.post(
133- "/v1/dispatch/stop",
134- json=request.model_dump(mode="json"),
135- timeout=timeout,
136- )
137- response.raise_for_status()
138- return DispatchStopResponse.model_validate(response.json())
139- except httpx.HTTPError as e:
140- logger.warning(
141- "Dispatch stop failed root_request_id=%s attempt_seq=%s endpoint=%s:%s error=%s",
142- attempt.root_request_id,
143- attempt.attempt_seq,
144- endpoint.ip,
145- endpoint.business_port,
146- e,
147- )
148- except Exception as e:
149- logger.warning(
150- "Dispatch stop response invalid root_request_id=%s attempt_seq=%s error=%s",
151- attempt.root_request_id,
152- attempt.attempt_seq,
153- e,
154- )
155- return None
@@ -35,7 +35,6 @@ from motor.coordinator.models.constants import (
35 OpenAIField,35 OpenAIField,
36 REQUEST_ID_KEY,36 REQUEST_ID_KEY,
37)37)
38-from motor.common.resources.dispatch import MOTOR_DISPATCH_KEY
39from motor.coordinator.models.response import ErrorResponse38from motor.coordinator.models.response import ErrorResponse
40from motor.coordinator.domain import ScheduledResource39from motor.coordinator.domain import ScheduledResource
41from motor.coordinator.models.request import RequestInfo, ReqState40from motor.coordinator.models.request import RequestInfo, ReqState
@@ -106,7 +105,7 @@ class RequestLoggerAdapter(logging.LoggerAdapter):
106 105 
107@dataclass106@dataclass
108class RecomputeState:107class RecomputeState:
109- """Per-request recompute counters and flags (PD/CDP routers)."""108+ """Per-request recompute counters and flags for P/D routers."""
110 109 
111 retry_count: int = 0110 retry_count: int = 0
112 wants_retry: bool = False111 wants_retry: bool = False
@@ -180,9 +179,8 @@ class BaseRouter(ABC):
180 return p_req179 return p_req
181 180 
182 def _forward_request_id(self, req_data: dict) -> str:181 def _forward_request_id(self, req_data: dict) -> str:
183- dispatch_data = req_data.get(MOTOR_DISPATCH_KEY)182+ for field in ("request_id", "rid"):
184- if isinstance(dispatch_data, dict):183+ engine_request_id = req_data.get(field)
185- engine_request_id = dispatch_data.get("engine_request_id")
186 if isinstance(engine_request_id, str) and engine_request_id:184 if isinstance(engine_request_id, str) and engine_request_id:
187 return engine_request_id185 return engine_request_id
188 return self.req_info.req_id186 return self.req_info.req_id
@@ -642,7 +640,7 @@ class BaseRouter(ABC):
642 trace_obj = self.req_info.trace_obj640 trace_obj = self.req_info.trace_obj
643 headers = trace_obj.get_trace_headers_dict(self.is_meta)641 headers = trace_obj.get_trace_headers_dict(self.is_meta)
644 trace_context = TracerManager().extract_trace_context(headers)642 trace_context = TracerManager().extract_trace_context(headers)
645- with TracerManager().tracer.start_as_current_span("CDP_Encode", context=trace_context) as span:643+ with TracerManager().tracer.start_as_current_span("PD_Encode", context=trace_context) as span:
646 self.is_meta = True644 self.is_meta = True
647 trace_obj.meta_span = span645 trace_obj.meta_span = span
648 trace_obj.meta_trace_headers = TracerManager().inject_trace_context()646 trace_obj.meta_trace_headers = TracerManager().inject_trace_context()
@@ -20,16 +20,6 @@ from fastapi.responses import JSONResponse, Response
20 20 
21import motor.common.utils.error as cancel_error21import motor.common.utils.error as cancel_error
22from motor.common.utils.error import RequestCancelledError22from motor.common.utils.error import RequestCancelledError
23-from motor.common.resources.dispatch import (
24- DispatchPlan,
25- DispatchStopReason,
26- dispatch_plans_from_capabilities,
27- MOTOR_DISPATCH_KEY,
28- MOTOR_PREFILL_RESULT_KEY,
29- PrefillResult,
30- PrefillResultStatus,
31- PrefillContextBudget,
32-)
33from motor.common.resources.endpoint import WorkloadAction23from motor.common.resources.endpoint import WorkloadAction
34from motor.common.resources.instance import PDRole24from motor.common.resources.instance import PDRole
35from motor.config.coordinator import CoordinatorConfig25from motor.config.coordinator import CoordinatorConfig
@@ -39,19 +29,13 @@ from motor.coordinator.domain import (
39 UpdateWorkloadParams,29 UpdateWorkloadParams,
40)30)
41from motor.coordinator.domain.request_manager import RequestManager31from motor.coordinator.domain.request_manager import RequestManager
42-from motor.coordinator.models.constants import OpenAIField
43from motor.coordinator.models.request import RequestInfo, ReqState32from motor.coordinator.models.request import RequestInfo, ReqState
44from motor.coordinator.router.dispatch_session import (33from motor.coordinator.router.dispatch_session import (
45 AttemptContext,34 AttemptContext,
46 AttemptState,35 AttemptState,
36+ AttemptStopReason,
47 PDDispatchSession,37 PDDispatchSession,
48)38)
49-from motor.coordinator.router.dispatch_capability import select_dispatch_plan_for_pair
50-from motor.coordinator.router.sglang_native_dispatch import (
51- inject_sglang_pd_fields,
52- is_sglang_resource,
53-)
54-from motor.coordinator.router.stop_client import DispatchStopClient
55from motor.coordinator.router.strategies.base import BaseRouter, check_cancel_error39from motor.coordinator.router.strategies.base import BaseRouter, check_cancel_error
56from motor.coordinator.router.strategies.pd_hybrid import PDHybridRouter40from motor.coordinator.router.strategies.pd_hybrid import PDHybridRouter
57from motor.coordinator.router.rescheduler.rescheduler import (41from motor.coordinator.router.rescheduler.rescheduler import (
@@ -60,9 +44,25 @@ from motor.coordinator.router.rescheduler.rescheduler import (
60)44)
61from motor.coordinator.router.workload import WorkloadActionHandler45from motor.coordinator.router.workload import WorkloadActionHandler
62from motor.coordinator.router.precision_sample.request import inject_logprobs46from motor.coordinator.router.precision_sample.request import inject_logprobs
47+from motor.coordinator.router.adapters.completion_to_chat import (
48+ adapt_completion_nonstream_to_chat,
49+ is_completion_like_body,
50+)
51+from motor.coordinator.router.adapters.pd_protocol import (
52+ ADAPTERS,
53+ CoordinationMode,
54+ EngineEndpointMetadata,
55+ EngineProtocolError,
56+ EngineRequest,
57+ LegContext,
58+ PDProtocolAdapter,
59+ PrefillMetadata,
60+)
63from motor.coordinator.router.adapters.stream import (61from motor.coordinator.router.adapters.stream import (
64 parse_stream_chunk_json,62 parse_stream_chunk_json,
65 encode_stream_chunk_bytes,63 encode_stream_chunk_bytes,
64+ strip_nonstream_response_body_for_client,
65+ strip_stream_chunk_bytes_for_client,
66)66)
67from motor.coordinator.router.stream_response import (67from motor.coordinator.router.stream_response import (
68 CommitAwareStreamingResponse,68 CommitAwareStreamingResponse,
@@ -74,6 +74,8 @@ from motor.coordinator.router.upstream_error import (
74 is_retryable_upstream_error,74 is_retryable_upstream_error,
75)75)
76 76 
77+_SGLANG_PROTOCOL_ADAPTER = ADAPTERS["sglang"]
78+ 
77 79 
78@dataclass(frozen=True)80@dataclass(frozen=True)
79class ReleaseWorkItem:81class ReleaseWorkItem:
@@ -114,14 +116,12 @@ class _ReleaseTaskRecord:
114 116 
115 117 
116class UnifiedPDRouter(BaseRouter):118class UnifiedPDRouter(BaseRouter):
117- """Unified P/D pair router behind feature flag.119+ """Native-engine P/D router.
118 120 
119- Coordinator owns lifecycle orchestration; EngineServer dispatch adapters own121+ Coordinator owns lifecycle orchestration and uses stateless protocol adapters
120- engine-specific request and response normalization.122+ for native-engine request mapping.
121 """123 """
122 124 
123- _DISPATCH_MODE = "pd_pair"
124- 
125 # Background workload-release RPC retry policy.125 # Background workload-release RPC retry policy.
126 _RELEASE_RPC_ATTEMPTS = 3126 _RELEASE_RPC_ATTEMPTS = 3
127 _RELEASE_RPC_BACKOFF_BASE_S = 0.05127 _RELEASE_RPC_BACKOFF_BASE_S = 0.05
@@ -177,6 +177,30 @@ class UnifiedPDRouter(BaseRouter):
177 self._capture_prompt_tokens_details(body)177 self._capture_prompt_tokens_details(body)
178 self.req_info.update_state(ReqState.PREFILL_END)178 self.req_info.update_state(ReqState.PREFILL_END)
179 179 
180+ def _record_bootstrap_prefill_response(
181+ self,
182+ attempt: AttemptContext,
183+ response: httpx.Response,
184+ ) -> None:
185+ if not self._is_native_sglang_attempt(attempt):
186+ self._record_prefill_complete(response.json())
187+ return
188+ 
189+ try:
190+ body = response.json()
191+ except ValueError:
192+ # SGLang's native gateway forwards the client's stream flag to both
193+ # legs. A streaming P response is internal: consume its SSE frames,
194+ # retain usage metadata when present, and never forward P chunks.
195+ for line in response.content.splitlines():
196+ frame_body = parse_stream_chunk_json(line, self.logger)
197+ if frame_body is not None:
198+ self._capture_prompt_tokens_details(frame_body)
199+ self.req_info.update_state(ReqState.PREFILL_END)
200+ return
201+ 
202+ self._record_prefill_complete(body)
203+ 
180 def _merge_prompt_tokens_details(self, body: dict[str, Any]) -> bool:204 def _merge_prompt_tokens_details(self, body: dict[str, Any]) -> bool:
181 details = self.req_info.prompt_tokens_details205 details = self.req_info.prompt_tokens_details
182 usage = body.get("usage")206 usage = body.get("usage")
@@ -199,6 +223,60 @@ class UnifiedPDRouter(BaseRouter):
199 return chunk223 return chunk
200 return encode_stream_chunk_bytes(chunk, chunk_json)224 return encode_stream_chunk_bytes(chunk, chunk_json)
201 225 
226+ @staticmethod
227+ def _is_native_sglang_attempt(attempt: AttemptContext | None) -> bool:
228+ return UnifiedPDRouter._adapter_for_attempt(attempt) is _SGLANG_PROTOCOL_ADAPTER
229+ 
230+ @staticmethod
231+ def _adapter_for_attempt(attempt: AttemptContext | None) -> PDProtocolAdapter | None:
232+ if attempt is None or attempt.prefill_resource is None:
233+ return None
234+ engine_type = getattr(attempt.prefill_resource.instance, "engine_type", None)
235+ if not isinstance(engine_type, str):
236+ return None
237+ normalized = engine_type.strip().lower()
238+ return ADAPTERS.get(normalized)
239+ 
240+ @staticmethod
241+ def _require_adapter_for_attempt(attempt: AttemptContext) -> PDProtocolAdapter:
242+ adapter = UnifiedPDRouter._adapter_for_attempt(attempt)
243+ if adapter is not None:
244+ return adapter
245+ engine_type = None
246+ if attempt.prefill_resource is not None:
247+ engine_type = getattr(attempt.prefill_resource.instance, "engine_type", None)
248+ raise RuntimeError(f"Unsupported native engine type for P/D coordination: {engine_type!r}")
249+ 
250+ @staticmethod
251+ def _strip_native_internal_fields_from_body(body: dict[str, Any], attempt: AttemptContext) -> None:
252+ adapter = UnifiedPDRouter._adapter_for_attempt(attempt)
253+ if adapter is None:
254+ return
255+ for field in adapter.internal_response_fields:
256+ body.pop(field, None)
257+ 
258+ def _strip_native_internal_fields_from_stream_chunk(
259+ self,
260+ chunk: bytes,
261+ attempt: AttemptContext,
262+ ) -> bytes:
263+ adapter = self._adapter_for_attempt(attempt)
264+ if adapter is None:
265+ return chunk
266+ fields = adapter.internal_response_fields
267+ if not any(f'"{field}"'.encode() in chunk for field in fields):
268+ return chunk
269+ chunk_json = parse_stream_chunk_json(chunk, self.logger)
270+ if chunk_json is None:
271+ self.logger.warning("Dropping invalid native stream chunk containing internal protocol fields")
272+ return b""
273+ mutated = False
274+ for field in fields:
275+ if field in chunk_json:
276+ chunk_json.pop(field)
277+ mutated = True
278+ return encode_stream_chunk_bytes(chunk, chunk_json) if mutated else chunk
279+ 
202 async def handle_request(self) -> Response:280 async def handle_request(self) -> Response:
203 await self.do_encode()281 await self.do_encode()
204 self.is_meta = False282 self.is_meta = False
@@ -235,6 +313,8 @@ class UnifiedPDRouter(BaseRouter):
235 Shared by the non-stream / stream-restart / stream-resume fallback gates so the313 Shared by the non-stream / stream-restart / stream-resume fallback gates so the
236 ``get_unblocked_instances`` availability probe stays in one place.314 ``get_unblocked_instances`` availability probe stays in one place.
237 """315 """
316+ if not self.config.scheduler_config.enable_pd_separation_fallback_to_hybrid:
317+ return False
238 get_unblocked = getattr(self._scheduler, "get_unblocked_instances", None)318 get_unblocked = getattr(self._scheduler, "get_unblocked_instances", None)
239 if get_unblocked is None:319 if get_unblocked is None:
240 return False320 return False
@@ -244,12 +324,12 @@ class UnifiedPDRouter(BaseRouter):
244 unblocked_p = await get_unblocked(PDRole.ROLE_P)324 unblocked_p = await get_unblocked(PDRole.ROLE_P)
245 return bool(unblocked_u or unblocked_p)325 return bool(unblocked_u or unblocked_p)
246 326 
247- async def _should_trigger_hybrid_fallback_nonstream(self) -> bool:327+ async def _should_use_hybrid_fallback_nonstream(self) -> bool:
248 if self.req_info.req_data.get("stream", False):328 if self.req_info.req_data.get("stream", False):
249 return False329 return False
250 return await self._hybrid_fallback_feasible()330 return await self._hybrid_fallback_feasible()
251 331 
252- async def _should_trigger_hybrid_fallback_stream_restart(self) -> bool:332+ async def _should_use_hybrid_fallback_stream_restart(self) -> bool:
253 if not self.req_info.req_data.get("stream", False):333 if not self.req_info.req_data.get("stream", False):
254 return False334 return False
255 if self._hybrid_stream_fallback_attempted:335 if self._hybrid_stream_fallback_attempted:
@@ -260,7 +340,7 @@ class UnifiedPDRouter(BaseRouter):
260 return False340 return False
261 return await self._hybrid_fallback_feasible()341 return await self._hybrid_fallback_feasible()
262 342 
263- async def _should_trigger_hybrid_fallback_stream_resume(self) -> bool:343+ async def _should_use_hybrid_fallback_stream_resume(self) -> bool:
264 """Post-commit continuation gate: visible output already sent, decode pool gone.344 """Post-commit continuation gate: visible output already sent, decode pool gone.
265 345 
266 Requires a replayable token cache (prompt + generated ids) so a single hybrid346 Requires a replayable token cache (prompt + generated ids) so a single hybrid
@@ -367,15 +447,12 @@ class UnifiedPDRouter(BaseRouter):
367 trace_obj = self.req_info.trace_obj447 trace_obj = self.req_info.trace_obj
368 with self._trace_span("UnifiedPD_Stream", True):448 with self._trace_span("UnifiedPD_Stream", True):
369 max_retry = max(self.config.exception_config.transport_retry_limit, 1)449 max_retry = max(self.config.exception_config.transport_retry_limit, 1)
370- session = PDDispatchSession(450+ session = PDDispatchSession(self.req_info.req_id)
371- self.req_info.req_id,
372- prefill_context_budget=self._prefill_context_budget(),
373- )
374 451 
375 async with self._manage_request_context():452 async with self._manage_request_context():
376 for attempt_index in range(max_retry):453 for attempt_index in range(max_retry):
377 attempt: AttemptContext | None = None454 attempt: AttemptContext | None = None
378- cleanup_reason = DispatchStopReason.OTHER455+ cleanup_reason = AttemptStopReason.OTHER
379 try:456 try:
380 self._active_retry_plan = None457 self._active_retry_plan = None
381 if attempt_index > 0:458 if attempt_index > 0:
@@ -387,7 +464,7 @@ class UnifiedPDRouter(BaseRouter):
387 if not self._stream_commit_controller.commit_sealed:464 if not self._stream_commit_controller.commit_sealed:
388 self._stream_commit_controller.begin_attempt(attempt.attempt_seq)465 self._stream_commit_controller.begin_attempt(attempt.attempt_seq)
389 attempt.register_canceller()466 attempt.register_canceller()
390- dispatch_plan = self._select_dispatch_plan(attempt)467+ coordination_mode = self._select_coordination_mode(attempt)
391 if attempt_index > 0:468 if attempt_index > 0:
392 self.logger.warning(469 self.logger.warning(
393 "Rescheduling %d/%d: P=[%s], D=[%s]",470 "Rescheduling %d/%d: P=[%s], D=[%s]",
@@ -397,7 +474,7 @@ class UnifiedPDRouter(BaseRouter):
397 self._resource_label(attempt.decode_resource),474 self._resource_label(attempt.decode_resource),
398 )475 )
399 attempt.transition(AttemptState.DISPATCHING)476 attempt.transition(AttemptState.DISPATCHING)
400- async with aclosing(self._run_stream_attempt(attempt, dispatch_plan)) as attempt_stream:477+ async with aclosing(self._run_stream_attempt(attempt, coordination_mode)) as attempt_stream:
401 async for chunk in attempt_stream:478 async for chunk in attempt_stream:
402 attempt.transition(AttemptState.FIRST_VISIBLE)479 attempt.transition(AttemptState.FIRST_VISIBLE)
403 yield chunk480 yield chunk
@@ -414,7 +491,7 @@ class UnifiedPDRouter(BaseRouter):
414 self.logger.info(trace_obj.set_end_and_ttft_tpot())491 self.logger.info(trace_obj.set_end_and_ttft_tpot())
415 return492 return
416 except GeneratorExit:493 except GeneratorExit:
417- cleanup_reason = DispatchStopReason.CLIENT_DISCONNECT494+ cleanup_reason = AttemptStopReason.CLIENT_DISCONNECT
418 raise495 raise
419 except (asyncio.CancelledError, Exception) as e:496 except (asyncio.CancelledError, Exception) as e:
420 error, retry = await self._process_response_error(497 error, retry = await self._process_response_error(
@@ -423,14 +500,14 @@ class UnifiedPDRouter(BaseRouter):
423 e,500 e,
424 allow_retry=self._stream_retry_allowed(),501 allow_retry=self._stream_retry_allowed(),
425 )502 )
426- if await self._should_trigger_hybrid_fallback_stream_restart():503+ if await self._should_use_hybrid_fallback_stream_restart():
427 async with aclosing(504 async with aclosing(
428 self._run_hybrid_fallback_stream_restart(attempt, attempt_index)505 self._run_hybrid_fallback_stream_restart(attempt, attempt_index)
429 ) as fallback_stream:506 ) as fallback_stream:
430 async for chunk in fallback_stream:507 async for chunk in fallback_stream:
431 yield chunk508 yield chunk
432 return509 return
433- if await self._should_trigger_hybrid_fallback_stream_resume():510+ if await self._should_use_hybrid_fallback_stream_resume():
434 async with aclosing(511 async with aclosing(
435 self._run_hybrid_fallback_stream_resume(attempt, attempt_index)512 self._run_hybrid_fallback_stream_resume(attempt, attempt_index)
436 ) as fallback_stream:513 ) as fallback_stream:
@@ -453,10 +530,7 @@ class UnifiedPDRouter(BaseRouter):
453 trace_obj = self.req_info.trace_obj530 trace_obj = self.req_info.trace_obj
454 with self._trace_span("UnifiedPD", False):531 with self._trace_span("UnifiedPD", False):
455 max_retry = max(self.config.exception_config.transport_retry_limit, 1)532 max_retry = max(self.config.exception_config.transport_retry_limit, 1)
456- session = PDDispatchSession(533+ session = PDDispatchSession(self.req_info.req_id)
457- self.req_info.req_id,
458- prefill_context_budget=self._prefill_context_budget(),
459- )
460 534 
461 async with self._manage_request_context():535 async with self._manage_request_context():
462 for attempt_index in range(max_retry):536 for attempt_index in range(max_retry):
@@ -464,7 +538,7 @@ class UnifiedPDRouter(BaseRouter):
464 try:538 try:
465 attempt = await self._create_attempt(session)539 attempt = await self._create_attempt(session)
466 attempt.register_canceller()540 attempt.register_canceller()
467- dispatch_plan = self._select_dispatch_plan(attempt)541+ coordination_mode = self._select_coordination_mode(attempt)
468 if attempt_index > 0:542 if attempt_index > 0:
469 self.rescheduler.retry_count = attempt_index543 self.rescheduler.retry_count = attempt_index
470 self.logger.warning(544 self.logger.warning(
@@ -475,14 +549,21 @@ class UnifiedPDRouter(BaseRouter):
475 self._resource_label(attempt.decode_resource),549 self._resource_label(attempt.decode_resource),
476 )550 )
477 attempt.transition(AttemptState.DISPATCHING)551 attempt.transition(AttemptState.DISPATCHING)
478- body = await self._run_nonstream_attempt(attempt, dispatch_plan)552+ body = await self._run_nonstream_attempt(attempt, coordination_mode)
479 attempt.unregister_canceller()553 attempt.unregister_canceller()
480 attempt.transition(AttemptState.DONE)554 attempt.transition(AttemptState.DONE)
481 self._merge_prompt_tokens_details(body)555 self._merge_prompt_tokens_details(body)
556+ if "chat" in self.req_info.effective_entry_api() and is_completion_like_body(body):
557+ adapt_completion_nonstream_to_chat(body, req_id=self.req_info.req_id)
558+ self._strip_native_internal_fields_from_body(body, attempt)
559+ strip_nonstream_response_body_for_client(
560+ body,
561+ client_return_token_ids=self.req_info.client_expects_token_ids,
562+ )
482 return JSONResponse(content=body)563 return JSONResponse(content=body)
483 except (asyncio.CancelledError, Exception) as e:564 except (asyncio.CancelledError, Exception) as e:
484 error, retry = await self._process_response_error(attempt, attempt_index, e)565 error, retry = await self._process_response_error(attempt, attempt_index, e)
485- if await self._should_trigger_hybrid_fallback_nonstream():566+ if await self._should_use_hybrid_fallback_nonstream():
486 return await self._run_hybrid_fallback_nonstream()567 return await self._run_hybrid_fallback_nonstream()
487 if not retry:568 if not retry:
488 raise error569 raise error
@@ -512,7 +593,7 @@ class UnifiedPDRouter(BaseRouter):
512 error = RequestCancelledError(reason_str)593 error = RequestCancelledError(reason_str)
513 label = f"Unified PD cancelled {attempt_index}/{max_retry}"594 label = f"Unified PD cancelled {attempt_index}/{max_retry}"
514 else:595 else:
515- reason = DispatchStopReason.PEER_FAILED596+ reason = AttemptStopReason.PEER_FAILED
516 reason_str = str(error)597 reason_str = str(error)
517 retry = allow_retry and attempt_index < max_retry - 1598 retry = allow_retry and attempt_index < max_retry - 1
518 if isinstance(error, HTTPException):599 if isinstance(error, HTTPException):
@@ -542,12 +623,12 @@ class UnifiedPDRouter(BaseRouter):
542 return error, retry623 return error, retry
543 624 
544 @staticmethod625 @staticmethod
545- def _cancel_stop_reason(reason: str) -> DispatchStopReason:626+ def _cancel_stop_reason(reason: str) -> AttemptStopReason:
546 if reason.startswith(cancel_error.NODE_FAULT):627 if reason.startswith(cancel_error.NODE_FAULT):
547- return DispatchStopReason.PEER_FAILED628+ return AttemptStopReason.PEER_FAILED
548 if reason == cancel_error.CLIENT_DISCONNECT:629 if reason == cancel_error.CLIENT_DISCONNECT:
549- return DispatchStopReason.CLIENT_DISCONNECT630+ return AttemptStopReason.CLIENT_DISCONNECT
550- return DispatchStopReason.OTHER631+ return AttemptStopReason.OTHER
551 632 
552 @staticmethod633 @staticmethod
553 def _resource_label(resource: ScheduledResource | None) -> str:634 def _resource_label(resource: ScheduledResource | None) -> str:
@@ -557,24 +638,20 @@ class UnifiedPDRouter(BaseRouter):
557 638 
558 async def _create_attempt(self, session: PDDispatchSession) -> AttemptContext:639 async def _create_attempt(self, session: PDDispatchSession) -> AttemptContext:
559 attempt_seq = session._attempt_seq + 1640 attempt_seq = session._attempt_seq + 1
560- consumed_output_tokens = (
561- self._active_retry_plan.cached_output_tokens if self._active_retry_plan is not None else 0
562- )
563 p_resource = await self._prepare_attempt_resource(PDRole.ROLE_P, attempt_seq)641 p_resource = await self._prepare_attempt_resource(PDRole.ROLE_P, attempt_seq)
564 642 
565 # Handoff connectors (CPCD-style) do not need a concrete decode endpoint while prefill runs.643 # Handoff connectors (CPCD-style) do not need a concrete decode endpoint while prefill runs.
566 # Allocate D after prefill completes so long prompts do not reserve stale decode workload for644 # Allocate D after prefill completes so long prompts do not reserve stale decode workload for
567 # the entire prefill window. Concurrent connectors still allocate both legs up front.645 # the entire prefill window. Concurrent connectors still allocate both legs up front.
568 if self._should_defer_decode_allocation(p_resource):646 if self._should_defer_decode_allocation(p_resource):
569- return session.new_attempt(647+ return session.new_attempt(p_resource, None, self.config)
570- p_resource,
571- None,
572- self.config,
573- consumed_output_tokens=consumed_output_tokens,
574- )
575 648 
576 try:649 try:
577- d_resource = await self._prepare_attempt_resource(PDRole.ROLE_D, attempt_seq)650+ d_resource = await self._prepare_attempt_resource(
651+ PDRole.ROLE_D,
652+ attempt_seq,
653+ required_engine_type=str(p_resource.instance.engine_type),
654+ )
578 except Exception as e:655 except Exception as e:
579 error_message = (656 error_message = (
580 f"Unified PD D allocation failed after P allocated "657 f"Unified PD D allocation failed after P allocated "
@@ -584,12 +661,7 @@ class UnifiedPDRouter(BaseRouter):
584 self.logger.warning(error_message)661 self.logger.warning(error_message)
585 await self._release_attempt_resource(p_resource, attempt_seq, WorkloadAction.RELEASE_TOKENS)662 await self._release_attempt_resource(p_resource, attempt_seq, WorkloadAction.RELEASE_TOKENS)
586 raise663 raise
587- return session.new_attempt(664+ return session.new_attempt(p_resource, d_resource, self.config)
588- p_resource,
589- d_resource,
590- self.config,
591- consumed_output_tokens=consumed_output_tokens,
592- )
593 665 
594 @staticmethod666 @staticmethod
595 def _should_defer_decode_allocation(667 def _should_defer_decode_allocation(
@@ -597,22 +669,25 @@ class UnifiedPDRouter(BaseRouter):
597 ) -> bool:669 ) -> bool:
598 if prefill_resource is None:670 if prefill_resource is None:
599 return False671 return False
600- plans = dispatch_plans_from_capabilities(getattr(prefill_resource.instance, "dispatch_capabilities", None))672+ engine_type = getattr(prefill_resource.instance, "engine_type", None)
601- return DispatchPlan.PREFILL_HANDOFF_DECODE in plans and DispatchPlan.CONCURRENT_ENGINE_SYNC not in plans673+ if not isinstance(engine_type, str):
674+ return False
675+ adapter = ADAPTERS.get(engine_type.strip().lower())
676+ return adapter is not None and adapter.coordination_mode == CoordinationMode.HANDOFF
602 677 
603 async def _run_stream_attempt(678 async def _run_stream_attempt(
604- self, attempt: AttemptContext, dispatch_plan: DispatchPlan679+ self, attempt: AttemptContext, coordination_mode: CoordinationMode
605 ) -> AsyncGenerator[str, None]:680 ) -> AsyncGenerator[str, None]:
606- if dispatch_plan == DispatchPlan.PREFILL_HANDOFF_DECODE:681+ if coordination_mode == CoordinationMode.HANDOFF:
607 run_func = self._run_handoff_stream_attempt682 run_func = self._run_handoff_stream_attempt
608 else:683 else:
609- run_func = self._run_concurrent_stream_attempt684+ run_func = self._run_bootstrap_stream_attempt
610 async with aclosing(run_func(attempt)) as attempt_stream:685 async with aclosing(run_func(attempt)) as attempt_stream:
611 async for chunk in attempt_stream:686 async for chunk in attempt_stream:
612 yield chunk687 yield chunk
613 return688 return
614 689 
615- async def _run_concurrent_stream_attempt(self, attempt: AttemptContext) -> AsyncGenerator[str, None]:690+ async def _run_bootstrap_stream_attempt(self, attempt: AttemptContext) -> AsyncGenerator[str, None]:
616 attempt.transition(AttemptState.ACTIVE)691 attempt.transition(AttemptState.ACTIVE)
617 p_req, p_api = self._request_for_attempt(attempt, PDRole.ROLE_P)692 p_req, p_api = self._request_for_attempt(attempt, PDRole.ROLE_P)
618 d_req, d_api = self._request_for_attempt(attempt, PDRole.ROLE_D)693 d_req, d_api = self._request_for_attempt(attempt, PDRole.ROLE_D)
@@ -626,15 +701,19 @@ class UnifiedPDRouter(BaseRouter):
626 701 
627 async def prefill_task():702 async def prefill_task():
628 try:703 try:
704+ attempt.mark_dispatched(PDRole.ROLE_P.value)
629 response = await self.forward_request(705 response = await self.forward_request(
630 p_api, p_req, p_client, self.config.exception_config.first_token_timeout706 p_api, p_req, p_client, self.config.exception_config.first_token_timeout
631 )707 )
632- self._record_prefill_complete(response.json())708+ attempt.mark_completed(PDRole.ROLE_P.value)
709+ self._record_bootstrap_prefill_response(attempt, response)
633 self._stream_commit_controller.mark_ready("prefill", attempt.attempt_seq)710 self._stream_commit_controller.mark_ready("prefill", attempt.attempt_seq)
634 await self._scheduler.report_cb_event(p_instance_id, "success")711 await self._scheduler.report_cb_event(p_instance_id, "success")
635 except asyncio.CancelledError: # pylint: disable=try-except-raise712 except asyncio.CancelledError: # pylint: disable=try-except-raise
636 raise713 raise
637 except Exception as e:714 except Exception as e:
715+ if isinstance(e, UpstreamHTTPError):
716+ attempt.mark_completed(PDRole.ROLE_P.value)
638 if is_cb_reportable_failure(e):717 if is_cb_reportable_failure(e):
639 await self._scheduler.report_cb_event(p_instance_id, "failure")718 await self._scheduler.report_cb_event(p_instance_id, "failure")
640 raise719 raise
@@ -704,6 +783,7 @@ class UnifiedPDRouter(BaseRouter):
704 783 
705 async def decode_task() -> None:784 async def decode_task() -> None:
706 try:785 try:
786+ attempt.mark_dispatched(PDRole.ROLE_D.value)
707 async for chunk in self.forward_stream_request(787 async for chunk in self.forward_stream_request(
708 d_api,788 d_api,
709 d_req,789 d_req,
@@ -713,6 +793,7 @@ class UnifiedPDRouter(BaseRouter):
713 ):793 ):
714 if chunk:794 if chunk:
715 await queue.put(chunk)795 await queue.put(chunk)
796+ attempt.mark_completed(PDRole.ROLE_D.value)
716 if not terminal.done():797 if not terminal.done():
717 terminal.set_result(("done", None))798 terminal.set_result(("done", None))
718 await self._scheduler.report_cb_event(d_instance_id, "success")799 await self._scheduler.report_cb_event(d_instance_id, "success")
@@ -720,6 +801,8 @@ class UnifiedPDRouter(BaseRouter):
720 if not terminal.done():801 if not terminal.done():
721 terminal.set_result(("cancel", e))802 terminal.set_result(("cancel", e))
722 except Exception as e:803 except Exception as e:
804+ if isinstance(e, UpstreamHTTPError):
805+ attempt.mark_completed(PDRole.ROLE_D.value)
723 if is_cb_reportable_failure(e):806 if is_cb_reportable_failure(e):
724 await self._scheduler.report_cb_event(d_instance_id, "failure")807 await self._scheduler.report_cb_event(d_instance_id, "failure")
725 if not terminal.done():808 if not terminal.done():
@@ -810,6 +893,11 @@ class UnifiedPDRouter(BaseRouter):
810 )893 )
811 else:894 else:
812 value = self._merge_prompt_tokens_details_into_stream_chunk(value)895 value = self._merge_prompt_tokens_details_into_stream_chunk(value)
896+ value = strip_stream_chunk_bytes_for_client(
897+ value,
898+ client_return_token_ids=self.req_info.client_expects_token_ids,
899+ )
900+ value = self._strip_native_internal_fields_from_stream_chunk(value, attempt)
813 yield value901 yield value
814 elif key == "done":902 elif key == "done":
815 if prefill_task is not None:903 if prefill_task is not None:
@@ -837,13 +925,15 @@ class UnifiedPDRouter(BaseRouter):
837 if not terminal.done():925 if not terminal.done():
838 terminal.cancel()926 terminal.cancel()
839 927 
840- async def _run_nonstream_attempt(self, attempt: AttemptContext, dispatch_plan: DispatchPlan) -> dict[str, Any]:928+ async def _run_nonstream_attempt(
841- if dispatch_plan == DispatchPlan.PREFILL_HANDOFF_DECODE:929+ self, attempt: AttemptContext, coordination_mode: CoordinationMode
930+ ) -> dict[str, Any]:
931+ if coordination_mode == CoordinationMode.HANDOFF:
842 return await self._run_handoff_nonstream_attempt(attempt)932 return await self._run_handoff_nonstream_attempt(attempt)
843 else:933 else:
844- return await self._run_concurrent_nonstream_attempt(attempt)934+ return await self._run_bootstrap_nonstream_attempt(attempt)
845 935 
846- async def _run_concurrent_nonstream_attempt(self, attempt: AttemptContext) -> dict[str, Any]:936+ async def _run_bootstrap_nonstream_attempt(self, attempt: AttemptContext) -> dict[str, Any]:
847 attempt.transition(AttemptState.ACTIVE)937 attempt.transition(AttemptState.ACTIVE)
848 p_req, p_api = self._request_for_attempt(attempt, PDRole.ROLE_P)938 p_req, p_api = self._request_for_attempt(attempt, PDRole.ROLE_P)
849 d_req, d_api = self._request_for_attempt(attempt, PDRole.ROLE_D)939 d_req, d_api = self._request_for_attempt(attempt, PDRole.ROLE_D)
@@ -856,14 +946,18 @@ class UnifiedPDRouter(BaseRouter):
856 946 
857 async def prefill_task():947 async def prefill_task():
858 try:948 try:
949+ attempt.mark_dispatched(PDRole.ROLE_P.value)
859 response = await self.forward_request(950 response = await self.forward_request(
860 p_api, p_req, p_client, self.config.exception_config.first_token_timeout951 p_api, p_req, p_client, self.config.exception_config.first_token_timeout
861 )952 )
862- self._record_prefill_complete(response.json())953+ attempt.mark_completed(PDRole.ROLE_P.value)
954+ self._record_bootstrap_prefill_response(attempt, response)
863 await self._scheduler.report_cb_event(p_instance_id, "success")955 await self._scheduler.report_cb_event(p_instance_id, "success")
864 except asyncio.CancelledError: # pylint: disable=try-except-raise956 except asyncio.CancelledError: # pylint: disable=try-except-raise
865 raise957 raise
866 except Exception as e:958 except Exception as e:
959+ if isinstance(e, UpstreamHTTPError):
960+ attempt.mark_completed(PDRole.ROLE_P.value)
867 if is_cb_reportable_failure(e):961 if is_cb_reportable_failure(e):
868 await self._scheduler.report_cb_event(p_instance_id, "failure")962 await self._scheduler.report_cb_event(p_instance_id, "failure")
869 raise963 raise
@@ -892,14 +986,18 @@ class UnifiedPDRouter(BaseRouter):
892 986 
893 async def decode_task() -> tuple[Any, Any]:987 async def decode_task() -> tuple[Any, Any]:
894 try:988 try:
989+ attempt.mark_dispatched(PDRole.ROLE_D.value)
895 response = await self.forward_request(990 response = await self.forward_request(
896 d_api, d_req, d_client, self.config.exception_config.infer_timeout991 d_api, d_req, d_client, self.config.exception_config.infer_timeout
897 )992 )
993+ attempt.mark_completed(PDRole.ROLE_D.value)
898 await self._scheduler.report_cb_event(d_instance_id, "success")994 await self._scheduler.report_cb_event(d_instance_id, "success")
899 return response.json(), None995 return response.json(), None
900 except asyncio.CancelledError: # pylint: disable=try-except-raise996 except asyncio.CancelledError: # pylint: disable=try-except-raise
901 raise997 raise
902 except Exception as e:998 except Exception as e:
999+ if isinstance(e, UpstreamHTTPError):
1000+ attempt.mark_completed(PDRole.ROLE_D.value)
903 if is_cb_reportable_failure(e):1001 if is_cb_reportable_failure(e):
904 await self._scheduler.report_cb_event(d_instance_id, "failure")1002 await self._scheduler.report_cb_event(d_instance_id, "failure")
905 return None, e1003 return None, e
@@ -1011,7 +1109,12 @@ class UnifiedPDRouter(BaseRouter):
1011 if attempt.decode_resource is not None:1109 if attempt.decode_resource is not None:
1012 return False1110 return False
1013 start = time.perf_counter()1111 start = time.perf_counter()
1014- d_resource = await self._prepare_attempt_resource(PDRole.ROLE_D, attempt.attempt_seq)1112+ adapter = self._require_adapter_for_attempt(attempt)
1113+ d_resource = await self._prepare_attempt_resource(
1114+ PDRole.ROLE_D,
1115+ attempt.attempt_seq,
1116+ required_engine_type=adapter.engine_type,
1117+ )
1015 elapsed_ms = (time.perf_counter() - start) * 10001118 elapsed_ms = (time.perf_counter() - start) * 1000
1016 self.logger.info(1119 self.logger.info(
1017 "Scheduling latency stage=late_select_d elapsed_ms=%.2f instance_id=%s endpoint_id=%s req_id=%s",1120 "Scheduling latency stage=late_select_d elapsed_ms=%.2f instance_id=%s endpoint_id=%s req_id=%s",
@@ -1022,12 +1125,10 @@ class UnifiedPDRouter(BaseRouter):
1022 )1125 )
1023 attempt.decode_resource = d_resource1126 attempt.decode_resource = d_resource
1024 try:1127 try:
1025- dispatch_plan = select_dispatch_plan_for_pair(1128+ adapter = self._require_adapter_for_attempt(attempt)
1026- prefill=attempt.prefill_resource,1129+ decode_engine_type = getattr(attempt.decode_resource.instance, "engine_type", None)
1027- decode=attempt.decode_resource,1130+ if not isinstance(decode_engine_type, str) or decode_engine_type.strip().lower() != adapter.engine_type:
1028- )1131+ raise RuntimeError(f"{adapter.engine_type} handoff requires a matching decode instance")
1029- if dispatch_plan != DispatchPlan.PREFILL_HANDOFF_DECODE:
1030- raise RuntimeError(f"Late decode allocation selected unsupported plan: {dispatch_plan}")
1031 except Exception:1132 except Exception:
1032 await self._release_attempt_resource(1133 await self._release_attempt_resource(
1033 d_resource,1134 d_resource,
@@ -1039,7 +1140,11 @@ class UnifiedPDRouter(BaseRouter):
1039 raise1140 raise
1040 return True1141 return True
1041 1142 
1042- async def _await_handoff_prefill(self, attempt: AttemptContext, p_client) -> PrefillResult:1143+ async def _await_handoff_prefill(
1144+ self,
1145+ attempt: AttemptContext,
1146+ p_client,
1147+ ) -> PrefillMetadata:
1043 p_instance_id = attempt.prefill_resource.instance.id1148 p_instance_id = attempt.prefill_resource.instance.id
1044 1149 
1045 async def prefill_task():1150 async def prefill_task():
@@ -1063,15 +1168,23 @@ class UnifiedPDRouter(BaseRouter):
1063 attempt: AttemptContext,1168 attempt: AttemptContext,
1064 role: PDRole,1169 role: PDRole,
1065 *,1170 *,
1066- prefill_result: PrefillResult | None = None,1171+ prefill_result: PrefillMetadata | None = None,
1067 ) -> (dict[str, Any], str):1172 ) -> (dict[str, Any], str):
1173+ req, api = self._base_request_for_attempt(role)
1174+ adapter = self._require_adapter_for_attempt(attempt)
1175+ return self._native_request_for_attempt(
1176+ attempt,
1177+ role,
1178+ adapter=adapter,
1179+ req=req,
1180+ api=api,
1181+ prefill_metadata=prefill_result,
1182+ )
1183+ 
1184+ def _base_request_for_attempt(self, role: PDRole) -> tuple[dict[str, Any], str]:
1068 api = self.req_info.entry_api1185 api = self.req_info.entry_api
1069 req = self.req_info.req_data.copy()1186 req = self.req_info.req_data.copy()
1070 stream = self.req_info.req_data.get("stream", False)1187 stream = self.req_info.req_data.get("stream", False)
1071- req["request_id"] = f"{attempt.root_request_id}#a{attempt.attempt_seq}"
1072- if role == PDRole.ROLE_P:
1073- req["stream"] = False
1074- req = self._apply_prefill_params(req, set_min_tokens=False)
1075 if stream and self.config.exception_config.reschedule_enabled:1188 if stream and self.config.exception_config.reschedule_enabled:
1076 req["return_token_ids"] = True1189 req["return_token_ids"] = True
1077 if self._active_retry_plan is not None:1190 if self._active_retry_plan is not None:
@@ -1086,82 +1199,121 @@ class UnifiedPDRouter(BaseRouter):
1086 and self._sampling_manager is not None1199 and self._sampling_manager is not None
1087 ):1200 ):
1088 inject_logprobs(req, self.config.precision_detection_config, req_id=self.req_info.req_id)1201 inject_logprobs(req, self.config.precision_detection_config, req_id=self.req_info.req_id)
1089- # SGLang pure-native: stock launch_server needs bootstrap_* on the business1202+ return req, api
1090- # port. Do not attach _motor_dispatch (no InferEndpoint adapter in native mode).
1091- # Prefill endpoint must still be held here so Decode-leg inject can read
1092- # bootstrap_host from attempt.prefill_resource.
1093- target = attempt.prefill_resource if role == PDRole.ROLE_P else attempt.decode_resource
1094- if is_sglang_resource(target):
1095- inject_sglang_pd_fields(req, attempt)
1096- return (req, api)
1097- req[MOTOR_DISPATCH_KEY] = attempt.dispatch_for(role, self._DISPATCH_MODE).model_dump(mode="json")
1098- if prefill_result is not None:
1099- req[MOTOR_PREFILL_RESULT_KEY] = prefill_result.model_dump(mode="json")
1100- return (req, api)
1101 1203 
1102- def _prefill_context_budget(self) -> PrefillContextBudget | None:1204+ def _native_request_for_attempt(
1103- """Return the client budget before the prefill leg is rewritten to one token."""1205+ self,
1104- for field in (OpenAIField.MAX_COMPLETION_TOKENS, OpenAIField.MAX_TOKENS):1206+ attempt: AttemptContext,
1105- value = self.req_info.req_data.get(field)1207+ role: PDRole,
1106- if isinstance(value, int) and not isinstance(value, bool) and value >= 0:1208+ *,
1107- return PrefillContextBudget(1209+ adapter: PDProtocolAdapter,
1108- max_output_tokens=value,1210+ req: dict[str, Any],
1109- parameter=field.value,1211+ api: str,
1110- )1212+ prefill_metadata: PrefillMetadata | None = None,
1111- return None1213+ ) -> tuple[dict[str, Any], str]:
1214+ context = self._native_leg_context(attempt, role, api)
1215+ if role == PDRole.ROLE_P:
1216+ engine_request = adapter.build_prefill_request(req, context)
1217+ else:
1218+ engine_request = adapter.build_decode_request(req, context, prefill_metadata)
1219+ return engine_request.body, engine_request.api
1112 1220 
1113- async def _request_prefill_result(self, attempt: AttemptContext, p_client) -> PrefillResult:1221+ @staticmethod
1222+ def _native_leg_context(attempt: AttemptContext, role: PDRole, api: str) -> LegContext:
1223+ resource = attempt.prefill_resource if role == PDRole.ROLE_P else attempt.decode_resource
1224+ if resource is None:
1225+ raise RuntimeError(f"Missing {role.value} resource for native engine request")
1226+ peer_resource = attempt.decode_resource if role == PDRole.ROLE_P else attempt.prefill_resource
1227+ return LegContext(
1228+ engine_request_id=f"{attempt.root_request_id}#a{attempt.attempt_seq}",
1229+ pair_id=attempt.pair_id,
1230+ attempt_seq=attempt.attempt_seq,
1231+ api=api,
1232+ endpoint=UnifiedPDRouter._native_endpoint_metadata(resource),
1233+ peer_endpoint=(
1234+ UnifiedPDRouter._native_endpoint_metadata(peer_resource) if peer_resource is not None else None
1235+ ),
1236+ )
1237+ 
1238+ @staticmethod
1239+ def _native_endpoint_metadata(resource: ScheduledResource) -> EngineEndpointMetadata:
1240+ endpoint = resource.endpoint
1241+ return EngineEndpointMetadata(
1242+ host=endpoint.ip,
1243+ bootstrap_port=endpoint.bootstrap_port,
1244+ )
1245+ 
1246+ async def _request_prefill_result(
1247+ self,
1248+ attempt: AttemptContext,
1249+ p_client,
1250+ ) -> PrefillMetadata:
1114 p_req, p_api = self._request_for_attempt(attempt, PDRole.ROLE_P)1251 p_req, p_api = self._request_for_attempt(attempt, PDRole.ROLE_P)
1115- response = await self.forward_request(p_api, p_req, p_client, self.config.exception_config.first_token_timeout)1252+ attempt.mark_dispatched(PDRole.ROLE_P.value)
1253+ try:
1254+ response = await self.forward_request(
1255+ p_api,
1256+ p_req,
1257+ p_client,
1258+ self.config.exception_config.first_token_timeout,
1259+ )
1260+ except UpstreamHTTPError:
1261+ attempt.mark_completed(PDRole.ROLE_P.value)
1262+ raise
1263+ attempt.mark_completed(PDRole.ROLE_P.value)
1116 response_body = response.json()1264 response_body = response.json()
1117- self._capture_prompt_tokens_details(response_body)1265+ adapter = self._require_adapter_for_attempt(attempt)
1118- prefill_result = PrefillResult.model_validate(response_body)1266+ try:
1119- self._validate_prefill_result(attempt, prefill_result, expected_status=PrefillResultStatus.COMPLETED)1267+ prefill_metadata = adapter.parse_prefill_response(response_body)
1268+ except EngineProtocolError as error:
1269+ raise UpstreamHTTPError(
1270+ status_code=502,
1271+ body=str(error).encode("utf-8"),
1272+ headers={"content-type": "text/plain; charset=utf-8"},
1273+ phase="prefill",
1274+ ) from error
1275+ if prefill_metadata.usage is not None:
1276+ self._capture_prompt_tokens_details({"usage": prefill_metadata.usage})
1120 self.req_info.update_state(ReqState.PREFILL_END)1277 self.req_info.update_state(ReqState.PREFILL_END)
1121 if self._stream_commit_controller is not None:1278 if self._stream_commit_controller is not None:
1122 self._stream_commit_controller.mark_ready("prefill", attempt.attempt_seq)1279 self._stream_commit_controller.mark_ready("prefill", attempt.attempt_seq)
1123- return prefill_result1280+ return prefill_metadata
1124 1281 
1125- @staticmethod1282+ def _select_coordination_mode(self, attempt: AttemptContext) -> CoordinationMode:
G
Gganglv19 天前

严重程度: 提示

问题: 混合 engine_type 的 P/D 池在实例分配后才报裸 RuntimeError(HTTP 500 且无诊断信息)。

原因: _select_coordination_mode 在 prefill 与 decode 实例 engine_type 不匹配时 raise RuntimeError;dispatch.py 的 select_router_class 改用 get_unblocked_instances 后不再做 capability 门控(基线的 has_compatible_pd_pair/has_compatible_dispatch_pair 已删),混合池(vllm P + sglang D)会被路由到 UnifiedPDRouter,请求在已分配实例、开始调度后才炸出 500,且错误信息不含哪个实例类型不匹配。旧架构下 dispatch_capability 机制在实例上报阶段就能发现类型不匹配并以 503/降级提示暴露,新架构把检测推迟到请求路径,诊断信息变差。

怎么改: 把 RuntimeError 换成统一的 5xx 响应并带上实例标识(engine_type 与实例地址),或在 select_router_class 阶段对 P/D 池做一次 engine_type 一致性预检,不匹配时直接返回 503 并打 warning 日志。

likedislike
tobking
tobking
19 天前 评论:
1126- def _validate_prefill_result(1283+ adapter = self._require_adapter_for_attempt(attempt)
1127- attempt: AttemptContext,1284+ if adapter.coordination_mode == CoordinationMode.BOOTSTRAP and attempt.decode_resource is None:
1128- prefill_result: PrefillResult,1285+ raise RuntimeError(f"{adapter.engine_type} bootstrap requires a decode instance")
1286+ if attempt.decode_resource is not None:
1287+ decode_engine_type = getattr(attempt.decode_resource.instance, "engine_type", None)
1288+ if not isinstance(decode_engine_type, str) or decode_engine_type.strip().lower() != adapter.engine_type:
1289+ raise RuntimeError(
1290+ f"P/D engine types must match: prefill={adapter.engine_type}, decode={decode_engine_type!r}"
1291+ )
1292+ return adapter.coordination_mode
1293+ 
1294+ async def _prepare_attempt_resource(
1295+ self,
1296+ role: PDRole,
1297+ attempt_seq: int,
1129 *,1298 *,
1130- expected_status: PrefillResultStatus,1299+ required_engine_type: str | None = None,
1131- ) -> None:1300+ ) -> ScheduledResource:
1132- if (
1133- prefill_result.root_request_id != attempt.root_request_id
1134- or prefill_result.pair_id != attempt.pair_id
1135- or prefill_result.attempt_seq != attempt.attempt_seq
1136- ):
1137- raise RuntimeError("PrefillResult does not match current dispatch attempt")
1138- if prefill_result.status != expected_status.value:
1139- raise RuntimeError(f"Unexpected PrefillResult status: {prefill_result.status}")
1140- 
1141- def _select_dispatch_plan(self, attempt: AttemptContext) -> DispatchPlan:
1142- if attempt.decode_resource is None:
1143- if self._should_defer_decode_allocation(attempt.prefill_resource):
1144- return DispatchPlan.PREFILL_HANDOFF_DECODE
1145- raise RuntimeError("Decode resource is required before selecting a concurrent P/D dispatch plan")
1146- return select_dispatch_plan_for_pair(
1147- prefill=attempt.prefill_resource,
1148- decode=attempt.decode_resource,
1149- )
1150- 
1151- async def _prepare_attempt_resource(self, role: PDRole, attempt_seq: int) -> ScheduledResource:
1152 self.req_info.update_state(ReqState.P_SCHEDULING if role == PDRole.ROLE_P else ReqState.D_SCHEDULING)1301 self.req_info.update_state(ReqState.P_SCHEDULING if role == PDRole.ROLE_P else ReqState.D_SCHEDULING)
1153 target_instance_id = None1302 target_instance_id = None
1154 constraint = self.req_info.scheduling_constraint1303 constraint = self.req_info.scheduling_constraint
1155 if constraint is not None:1304 if constraint is not None:
1156 target_instance_id = constraint.target_for_role(role)1305 target_instance_id = constraint.target_for_role(role)
1157- result = await self._scheduler.select_and_allocate(1306+ scheduler_kwargs = {"target_instance_id": target_instance_id}
1158- role,1307+ if required_engine_type is not None:
1159- self.req_info,1308+ scheduler_kwargs["required_engine_type"] = required_engine_type
1160- target_instance_id=target_instance_id,1309+ result = await self._scheduler.select_and_allocate(role, self.req_info, **scheduler_kwargs)
1161- )
1162 if result is None:1310 if result is None:
1163 error_message = f"No instance available for role {role}"1311 error_message = f"No instance available for role {role}"
1312+ if required_engine_type is not None:
1313+ error_message += f" with engine_type={required_engine_type}"
1164 self.req_info.trace_obj.set_trace_error_message(error_message)1314 self.req_info.trace_obj.set_trace_error_message(error_message)
1315+ if required_engine_type is not None:
1316+ raise HTTPException(status_code=503, detail=error_message)
1165 raise RuntimeError(error_message)1317 raise RuntimeError(error_message)
1166 ins, endpoint, workload = result1318 ins, endpoint, workload = result
1167 await self._record_attempt_workload(attempt_seq, role, workload)1319 await self._record_attempt_workload(attempt_seq, role, workload)
@@ -1587,7 +1739,7 @@ class UnifiedPDRouter(BaseRouter):
1587 return "release_d_tokens"1739 return "release_d_tokens"
1588 return f"release_{role.value}_{action.value.lower()}"1740 return f"release_{role.value}_{action.value.lower()}"
1589 1741 
1590- async def _stop_attempt(self, attempt: AttemptContext | None, reason: DispatchStopReason) -> None:1742+ async def _stop_attempt(self, attempt: AttemptContext | None, reason: AttemptStopReason) -> None:
G
Gganglv19 天前

严重程度: 建议

问题: _stop_attempt 移除引擎侧 abort RPC:sglang 非流式腿断连/对端失败后引擎会跑满整个生成,造成算力浪费与并发槽位占用。

原因: 新版只做本地 attempt.cancel() + _release_attempt,无任何引擎侧 stop RPC。基线 _stop_attempt 会构造 DispatchStopClient 同时 stop 双腿:sglang 腿 POST /abort_request {"rid": engine_request_id},vllm 腿 POST /v1/dispatch/stop。核对了 vendored sglang 源码:/abort_request 只中止“挂有 background abort task 的流式请求”;本 PR 中 sglang P/D 腿在客户端非流式场景下均为非流式请求,一旦开始生成没有任何机制能中途停止。客户端断开(CLIENT_DISCONNECT)或对端失败(PEER_FAILED)后,引擎仍会把请求生成到完成(可能数秒到数十秒),同时占用引擎并发槽位加剧排队。另外 attempt.cancel() 中 asyncio.gather 无超时,若 P 腿任务不响应取消会一直挂起。

怎么改: 恢复对 sglang 腿的 abort 调用,rid 与 SglangProtocolAdapter.inject_request_id(pd_protocol.py:198-200)注入格式保持一致(f"{root_request_id}#a{attempt_seq}"):

if reason in (AttemptStopReason.CLIENT_DISCONNECT, AttemptStopReason.PEER_FAILED):
    for role, inst in ((PDRole.PREFILL, attempt.prefill_instance),
                       (PDRole.DECODE, attempt.decode_instance)):
        if inst is not None and inst.engine_type == "sglang":
            rid = f"{attempt.root_request_id}#a{attempt.attempt_seq}"
            try:
                await client.post("/abort_request", json={"rid": rid})
            except Exception:
                logger.warning("sglang abort failed for rid=%s", rid, exc_info=True)

若不做,至少在 PR 描述中明确标注该能力回退。

likedislike
tobking
tobking
19 天前 评论:
1591 if attempt is None:1743 if attempt is None:
1592 return1744 return
1593 async with attempt.stop_lock:1745 async with attempt.stop_lock:
@@ -1596,15 +1748,8 @@ class UnifiedPDRouter(BaseRouter):
1596 return1748 return
1597 1749 
1598 attempt.stop()1750 attempt.stop()
1751+ await self._abort_native_attempt(attempt)
1599 await attempt.cancel(reason.value)1752 await attempt.cancel(reason.value)
1600- client = DispatchStopClient(self.config)
1601- tasks = []
1602- if attempt.prefill_resource:
1603- tasks.append(client.stop(attempt.prefill_resource, attempt, reason))
1604- if attempt.decode_resource:
1605- tasks.append(client.stop(attempt.decode_resource, attempt, reason))
1606- if tasks:
1607- await asyncio.gather(*tasks, return_exceptions=True)
1608 await self._release_attempt(attempt, wait=False)1753 await self._release_attempt(attempt, wait=False)
1609 try:1754 try:
1610 await self._drain_release_tasks()1755 await self._drain_release_tasks()
@@ -1617,6 +1762,55 @@ class UnifiedPDRouter(BaseRouter):
1617 )1762 )
1618 attempt.transition(AttemptState.STOPPED)1763 attempt.transition(AttemptState.STOPPED)
1619 1764 
1765+ async def _abort_native_attempt(self, attempt: AttemptContext) -> None:
1766+ """Best-effort native cancellation; failures never mask the original stop reason."""
1767+ try:
1768+ adapter = self._require_adapter_for_attempt(attempt)
1769+ except Exception:
1770+ return
1771+ aborts = []
1772+ for role, resource in (
1773+ (PDRole.ROLE_P, attempt.prefill_resource),
1774+ (PDRole.ROLE_D, attempt.decode_resource),
1775+ ):
1776+ if resource is None or not attempt.needs_abort(role.value):
1777+ continue
1778+ context = self._native_leg_context(attempt, role, self.req_info.entry_api)
1779+ abort_request = adapter.build_abort_request(context)
1780+ if abort_request is not None:
1781+ aborts.append(self._send_native_abort(resource, abort_request))
1782+ if aborts:
1783+ await asyncio.gather(*aborts)
1784+ 
1785+ async def _send_native_abort(self, resource: ScheduledResource, request: EngineRequest) -> None:
1786+ try:
1787+ async with self._client_for(resource) as client:
1788+ timeout = min(float(self.config.exception_config.first_token_timeout), 1.0)
1789+ response = await asyncio.wait_for(
1790+ client.post(
1791+ f"/{request.api}",
1792+ json=request.body,
1793+ timeout=timeout,
1794+ ),
1795+ timeout=timeout,
1796+ )
1797+ if response.status_code >= 400:
1798+ self.logger.warning(
1799+ "Native abort rejected engine=%s instance_id=%s endpoint_id=%s status=%s",
1800+ resource.instance.engine_type,
1801+ resource.instance.id,
1802+ resource.endpoint.id,
1803+ response.status_code,
1804+ )
1805+ except Exception as err:
1806+ self.logger.warning(
1807+ "Native abort failed engine=%s instance_id=%s endpoint_id=%s error=%s",
1808+ resource.instance.engine_type,
1809+ resource.instance.id,
1810+ resource.endpoint.id,
1811+ err,
1812+ )
1813+ 
1620 def _client_for(self, resource: ScheduledResource):1814 def _client_for(self, resource: ScheduledResource):
1621 if resource is None:1815 if resource is None:
1622 raise RuntimeError("Scheduled resource is missing")1816 raise RuntimeError("Scheduled resource is missing")
@@ -1661,49 +1855,6 @@ class UnifiedPDRouter(BaseRouter):
1661 if await self._sampling_manager.confirm_sample((p_id, d_id), time.time()):1855 if await self._sampling_manager.confirm_sample((p_id, d_id), time.time()):
1662 await self._submit_token_sample(p_id, d_id, info, attempt.decode_resource)1856 await self._submit_token_sample(p_id, d_id, info, attempt.decode_resource)
1663 1857 
1664- # ------------------------------------------------------------------
1665- # Metaserver forward entry point (CDP mode: D-side prefill → P instance)
1666- # ------------------------------------------------------------------
1667- 
1668- async def handle_metaserver_request(self) -> dict[str, Any]:
1669- self.is_meta = True
1670- schedule_resource: ScheduledResource = None
1671- try:
1672- schedule_resource = await self.prepare_resource(PDRole.ROLE_P)
1673- req_data = self.req_info.req_data.copy()
1674- req_data["stream"] = False
1675- async with self._client_for(schedule_resource) as client:
1676- response = await self.forward_request(
1677- self.req_info.api,
1678- req_data,
1679- client,
1680- self.config.exception_config.first_token_timeout,
1681- )
1682- resp_json = response.json()
1683- self.logger.debug("Prefill response received")
1684- self._capture_prompt_tokens_details(resp_json)
1685- self.req_info.update_state(ReqState.PREFILL_END)
1686- if hasattr(self.req_info, "p_instance_id"):
1687- self.req_info.p_instance_id = schedule_resource.instance.id
1688- return resp_json
1689- except asyncio.CancelledError:
1690- self.req_info.trace_obj.set_trace_prompt(self.req_info.req_data)
1691- self.logger.info("Metaserver request was cancelled")
1692- self.req_info.cancel_scope()
1693- raise
1694- except Exception:
1695- self.req_info.trace_obj.set_trace_prompt(self.req_info.req_data)
1696- self.req_info.cancel_scope()
1697- self.req_info.update_state(ReqState.EXCEPTION)
1698- raise
1699- finally:
1700- if schedule_resource and self.req_info.state != ReqState.PREFILL_END:
1701- if not await self.release_all(schedule_resource):
1702- self.logger.debug(
1703- "release_all(prefill) returned False instance_id=%s",
1704- schedule_resource.instance.id,
1705- )
1706- 
1707 @staticmethod1858 @staticmethod
1708 async def _cancel_task_quietly(task: asyncio.Task | None) -> None:1859 async def _cancel_task_quietly(task: asyncio.Task | None) -> None:
1709 if task is None or task.done():1860 if task is None or task.done():
@@ -80,9 +80,8 @@ def is_cb_reportable_failure(error: BaseException) -> bool:
80 penalize the instance. HTTP 5xx errors indicate instance health issues.80 penalize the instance. HTTP 5xx errors indicate instance health issues.
81 Connection-level errors always indicate instance issues.81 Connection-level errors always indicate instance issues.
82 82 
83- The engine_server (serving_error.py) is the single source of truth for classifying exceptions83+ Native engines classify request-validation failures as 4xx and engine faults as 5xx.
84- as client errors vs engine faults: it maps all known request-validation errors to 4xx before84+ The coordinator therefore only needs to inspect the upstream status code.
85- they reach the coordinator. The coordinator therefore only needs to inspect the status code.
86 """85 """
87 if isinstance(error, UpstreamHTTPError):86 if isinstance(error, UpstreamHTTPError):
88 return error.status_code >= 50087 return error.status_code >= 500
@@ -20,9 +20,6 @@ from typing import Any, Awaitable, Callable
20import msgspec20import msgspec
21import zmq21import zmq
22 22 
23-from motor.common.resources.dispatch import (
24- has_compatible_dispatch_pair,
25-)
26from motor.common.resources.instance import Instance, PDRole23from motor.common.resources.instance import Instance, PDRole
G
Gganglv19 天前

严重程度: 提示

问题: 删除 has_compatible_pd_pair/has_compatible_dispatch_pair 的全部生产调用点后,motor/common/resources/dispatch.py:137has_compatible_dispatch_pair 成死代码。

原因: 本 PR 删除此处的 import 与方法(连同 scheduler.py 的对应调用、dispatch_capability.py),但 has_compatible_dispatch_pair 函数本身仍保留在 motor/common/resources/dispatch.py,仅剩 tests/common/resources/test_dispatch_compatibility.py 引用。生产代码不再使用该函数,后续重构容易误以为是活跃 API。

怎么改: 若测试仍有价值,保留并在 dispatch.py 代码注释标注仅测试使用;否则连测试一起删除。

likedislike
tobking
tobking
19 天前 评论:
27from motor.common.resources.endpoint import Endpoint, Workload24from motor.common.resources.endpoint import Endpoint, Workload
28from motor.coordinator.domain import (25from motor.coordinator.domain import (
@@ -740,10 +737,13 @@ class AsyncSchedulerClient:
740 req_info: RequestInfo,737 req_info: RequestInfo,
741 role: PDRole | None = None,738 role: PDRole | None = None,
742 top_k: int = 1,739 top_k: int = 1,
740+ required_engine_type: str | None = None,
743 ) -> tuple[list[tuple[Instance, Endpoint, float]], str]:741 ) -> tuple[list[tuple[Instance, Endpoint, float]], str]:
744 """Select endpoint candidates from cache or fresh instances."""742 """Select endpoint candidates from cache or fresh instances."""
745 cache_role = role if role is not None else PDRole.ROLE_U743 cache_role = role if role is not None else PDRole.ROLE_U
746- cached_instances = self._cache.get_instances(cache_role)744+ cached_instances = self._filter_instances_by_engine_type(
745+ self._cache.get_instances(cache_role), required_engine_type
746+ )
747 if cached_instances:747 if cached_instances:
748 # Cache stores instances sorted by id (see replace_all call sites); use as-is for RR748 # Cache stores instances sorted by id (see replace_all call sites); use as-is for RR
749 candidates, candidate_policy = self._select_endpoint_candidates_from_list_with_policy(749 candidates, candidate_policy = self._select_endpoint_candidates_from_list_with_policy(
@@ -762,7 +762,9 @@ class AsyncSchedulerClient:
762 return [], self._scheduler_type or CANDIDATE_POLICY_ROUND_ROBIN762 return [], self._scheduler_type or CANDIDATE_POLICY_ROUND_ROBIN
763 763 
764 # get_available_instances already wrote sorted list to cache; build sorted list once for this path764 # get_available_instances already wrote sorted list to cache; build sorted list once for this path
765- instance_list = sorted(instances.values(), key=lambda i: i.id)765+ instance_list = self._filter_instances_by_engine_type(
766+ sorted(instances.values(), key=lambda i: i.id), required_engine_type
767+ )
766 candidates, candidate_policy = self._select_endpoint_candidates_from_list_with_policy(768 candidates, candidate_policy = self._select_endpoint_candidates_from_list_with_policy(
767 instance_list, cache_role, req_info, top_k=top_k769 instance_list, cache_role, req_info, top_k=top_k
768 )770 )
@@ -775,6 +777,17 @@ class AsyncSchedulerClient:
775 )777 )
776 return candidates, candidate_policy778 return candidates, candidate_policy
777 779 
780+ @staticmethod
781+ def _filter_instances_by_engine_type(instances: list[Instance], required_engine_type: str | None) -> list[Instance]:
782+ normalized = str(required_engine_type or "").strip().lower()
783+ if not normalized:
784+ return instances
785+ return [
786+ instance
787+ for instance in instances
788+ if str(getattr(instance, "engine_type", "")).strip().lower() == normalized
789+ ]
790+ 
778 async def _refresh_cache_from_workload_reader(self, role: PDRole | None = None) -> None:791 async def _refresh_cache_from_workload_reader(self, role: PDRole | None = None) -> None:
779 """Patch live workload into the local cache and pull a fresh instance list on792 """Patch live workload into the local cache and pull a fresh instance list on
780 heartbeat-stale or instance-version change.793 heartbeat-stale or instance-version change.
@@ -824,6 +837,7 @@ class AsyncSchedulerClient:
824 req_info: RequestInfo,837 req_info: RequestInfo,
825 *,838 *,
826 target_instance_id: int | None = None,839 target_instance_id: int | None = None,
840+ required_engine_type: str | None = None,
827 ) -> tuple[Instance, Endpoint, Workload] | None:841 ) -> tuple[Instance, Endpoint, Workload] | None:
828 """Select instance locally + ALLOCATE_ONLY RPC. Allocation workload is decided here (RR=zero, LB=demand)."""842 """Select instance locally + ALLOCATE_ONLY RPC. Allocation workload is decided here (RR=zero, LB=demand)."""
829 role_str = role.value if role is not None else (getattr(PDRole.ROLE_U, "value", "union"))843 role_str = role.value if role is not None else (getattr(PDRole.ROLE_U, "value", "union"))
@@ -834,8 +848,16 @@ class AsyncSchedulerClient:
834 # affinity-discounted prefill cost so the scheduler re-ranks globally by fresh load.848 # affinity-discounted prefill cost so the scheduler re-ranks globally by fresh load.
835 global_affinity = False849 global_affinity = False
836 850 
851+ normalized_engine_type = str(required_engine_type or "").strip().lower()
852+ 
837 if target_instance_id is not None:853 if target_instance_id is not None:
838 instances = await self.get_available_instances(role)854 instances = await self.get_available_instances(role)
855+ if normalized_engine_type:
856+ instances = {
857+ instance_id: candidate
858+ for instance_id, candidate in instances.items()
859+ if str(getattr(candidate, "engine_type", "")).strip().lower() == normalized_engine_type
860+ }
839 instance = resolve_pinned_instance(instances, target_instance_id)861 instance = resolve_pinned_instance(instances, target_instance_id)
840 if instance is None:862 if instance is None:
841 logger.warning(863 logger.warning(
@@ -877,7 +899,12 @@ class AsyncSchedulerClient:
877 (899 (
878 candidates,900 candidates,
879 candidate_policy,901 candidate_policy,
880- ) = await self._select_endpoint_candidates_with_policy(req_info, role, top_k=request_top_k)902+ ) = await self._select_endpoint_candidates_with_policy(
903+ req_info,
904+ role,
905+ top_k=request_top_k,
906+ required_engine_type=normalized_engine_type or None,
907+ )
881 if not candidates:908 if not candidates:
882 return None909 return None
883 instance, endpoint, _ = candidates[0]910 instance, endpoint, _ = candidates[0]
@@ -893,6 +920,13 @@ class AsyncSchedulerClient:
893 and any(rec[2] is not None for rec in affinity_debug.values())920 and any(rec[2] is not None for rec in affinity_debug.values())
894 )921 )
895 if global_affinity:922 if global_affinity:
923+ allowed_instance_ids = {
924+ candidate.id
925+ for candidate in self._filter_instances_by_engine_type(
926+ self._cache.get_instances(role),
927+ normalized_engine_type or None,
928+ )
929+ }
896 candidate_endpoints = [930 candidate_endpoints = [
897 {931 {
898 "instance_id": ins_id,932 "instance_id": ins_id,
@@ -901,7 +935,7 @@ class AsyncSchedulerClient:
901 "prefill_cost": rec[2],935 "prefill_cost": rec[2],
902 }936 }
903 for (ins_id, ep_id), rec in affinity_debug.items()937 for (ins_id, ep_id), rec in affinity_debug.items()
904- if rec[2] is not None938+ if rec[2] is not None and ins_id in allowed_instance_ids
905 ]939 ]
906 elif candidate_policy == CANDIDATE_POLICY_KV_CACHE_AFFINITY and isinstance(affinity_debug, dict):940 elif candidate_policy == CANDIDATE_POLICY_KV_CACHE_AFFINITY and isinstance(affinity_debug, dict):
907 # load_gated (and other affinity modes without prefill_cost): still forward941 # load_gated (and other affinity modes without prefill_cost): still forward
@@ -949,6 +983,7 @@ class AsyncSchedulerClient:
949 # Non-affinity demand as a raw float (RR sends 0.0). Affinity overrides via isl/matched.983 # Non-affinity demand as a raw float (RR sends 0.0). Affinity overrides via isl/matched.
950 "workload_active_tokens": workload.active_tokens,984 "workload_active_tokens": workload.active_tokens,
951 "candidate_policy": candidate_policy,985 "candidate_policy": candidate_policy,
986+ "required_engine_type": normalized_engine_type or None,
952 }987 }
953 if isl > 0:988 if isl > 0:
954 req_data["isl"] = isl989 req_data["isl"] = isl
@@ -1337,23 +1372,6 @@ class AsyncSchedulerClient:
1337 roles = self._roles_from_cache()1372 roles = self._roles_from_cache()
1338 return roles1373 return roles
1339 1374 
1340- async def has_compatible_pd_pair(self) -> bool:
1341- """Return whether cached P/D pools contain a compatible pair.
1342- 
1343- Assumes the cache was warmed by a preceding get_available_instance_roles in the same
1344- routing decision; falls back to a warm-up fetch if both pools look empty.
1345- """
1346- prefill = self._cache.get_instances(PDRole.ROLE_P)
1347- decode = self._cache.get_instances(PDRole.ROLE_D)
1348- if not prefill and not decode:
1349- try:
1350- await self.get_available_instances(None)
1351- except Exception as e:
1352- logger.debug("has_compatible_pd_pair: warm-up fetch failed: %s", e)
1353- prefill = self._cache.get_instances(PDRole.ROLE_P)
1354- decode = self._cache.get_instances(PDRole.ROLE_D)
1355- return has_compatible_dispatch_pair(prefill, decode)
1356- 
1357 async def get_unblocked_instances(self, role: PDRole) -> list[int]:1375 async def get_unblocked_instances(self, role: PDRole) -> list[int]:
1358 """Return instance IDs of the given role that are NOT blocked by circuit breaker."""1376 """Return instance IDs of the given role that are NOT blocked by circuit breaker."""
1359 cached = self._cache.get_instances(role)1377 cached = self._cache.get_instances(role)
@@ -133,6 +133,7 @@ _KEY_MATCHED_TOKENS = "matched_tokens"
133_KEY_PREFILL_COST = "prefill_cost"133_KEY_PREFILL_COST = "prefill_cost"
134_KEY_LOAD_WEIGHT = "load_weight"134_KEY_LOAD_WEIGHT = "load_weight"
135_KEY_PREFILL_LOAD_SCALE = "prefill_load_scale"135_KEY_PREFILL_LOAD_SCALE = "prefill_load_scale"
136+_KEY_REQUIRED_ENGINE_TYPE = "required_engine_type"
136 137 
137 138 
138def _should_log_scheduling_sample(sample_key: str) -> bool:139def _should_log_scheduling_sample(sample_key: str) -> bool:
@@ -169,7 +170,6 @@ def _serialize_instance_minimal(instance: Instance | None) -> dict:
169 "job_name": instance.job_name,170 "job_name": instance.job_name,
170 "model_name": instance.model_name,171 "model_name": instance.model_name,
171 "engine_type": instance.engine_type,172 "engine_type": instance.engine_type,
172- "dispatch_capabilities": list(instance.dispatch_capabilities or []),
173 }173 }
174 174 
175 175 
@@ -182,6 +182,7 @@ def _serialize_endpoint_minimal(endpoint: Endpoint | None) -> dict:
182 "ip": endpoint.ip,182 "ip": endpoint.ip,
183 "business_port": endpoint.business_port,183 "business_port": endpoint.business_port,
184 "mgmt_port": getattr(endpoint, "mgmt_port", "") or "",184 "mgmt_port": getattr(endpoint, "mgmt_port", "") or "",
185+ "bootstrap_port": endpoint.bootstrap_port,
185 }186 }
186 if hasattr(endpoint, "status") and endpoint.status is not None:187 if hasattr(endpoint, "status") and endpoint.status is not None:
187 out["status"] = endpoint.status.value if hasattr(endpoint.status, "value") else str(endpoint.status)188 out["status"] = endpoint.status.value if hasattr(endpoint.status, "value") else str(endpoint.status)
@@ -660,6 +661,7 @@ class _SchedulerRequestDispatcher:
660 worker_load_weight = self._parse_optional_float(request.data.get(_KEY_LOAD_WEIGHT))661 worker_load_weight = self._parse_optional_float(request.data.get(_KEY_LOAD_WEIGHT))
661 worker_prefill_load_scale = self._parse_optional_float(request.data.get(_KEY_PREFILL_LOAD_SCALE))662 worker_prefill_load_scale = self._parse_optional_float(request.data.get(_KEY_PREFILL_LOAD_SCALE))
662 isl = self._parse_optional_float(request.data.get(_KEY_ISL))663 isl = self._parse_optional_float(request.data.get(_KEY_ISL))
664+ required_engine_type = str(request.data.get(_KEY_REQUIRED_ENGINE_TYPE) or "").strip().lower() or None
663 665 
664 if instance_id is None or endpoint_id is None:666 if instance_id is None or endpoint_id is None:
665 return SchedulerResponse(667 return SchedulerResponse(
@@ -714,7 +716,7 @@ class _SchedulerRequestDispatcher:
714 role,716 role,
715 )717 )
716 selected = (718 selected = (
717- self._select_valid_candidate(selected_candidate, role)719+ self._select_valid_candidate(selected_candidate, role, required_engine_type)
718 if fast_path720 if fast_path
719 else self._select_authoritative_allocate_candidate(721 else self._select_authoritative_allocate_candidate(
720 selected_candidate,722 selected_candidate,
@@ -724,6 +726,7 @@ class _SchedulerRequestDispatcher:
724 affinity_candidates,726 affinity_candidates,
725 worker_prefill_load_scale,727 worker_prefill_load_scale,
726 worker_load_weight,728 worker_load_weight,
729+ required_engine_type,
727 )730 )
728 )731 )
729 if fast_path and selected is None:732 if fast_path and selected is None:
@@ -735,6 +738,7 @@ class _SchedulerRequestDispatcher:
735 affinity_candidates,738 affinity_candidates,
736 worker_prefill_load_scale,739 worker_prefill_load_scale,
737 worker_load_weight,740 worker_load_weight,
741+ required_engine_type,
738 )742 )
739 fast_path = False743 fast_path = False
740 if selected is None:744 if selected is None:
@@ -909,9 +913,10 @@ class _SchedulerRequestDispatcher:
909 self,913 self,
910 candidate: tuple[int, int],914 candidate: tuple[int, int],
911 role: PDRole,915 role: PDRole,
916+ required_engine_type: str | None = None,
912 ) -> tuple[Instance, Endpoint, float] | None:917 ) -> tuple[Instance, Endpoint, float] | None:
913 """Select the best candidate using SchedulerServer's current workload ledger."""918 """Select the best candidate using SchedulerServer's current workload ledger."""
914- return self._select_valid_candidate(candidate, role)919+ return self._select_valid_candidate(candidate, role, required_engine_type)
915 920 
916 def _select_authoritative_allocate_candidate(921 def _select_authoritative_allocate_candidate(
917 self,922 self,
@@ -922,6 +927,7 @@ class _SchedulerRequestDispatcher:
922 affinity_candidates: list[tuple[int, int, float]] | None = None,927 affinity_candidates: list[tuple[int, int, float]] | None = None,
923 prefill_load_scale: float | None = None,928 prefill_load_scale: float | None = None,
924 load_weight: float | None = None,929 load_weight: float | None = None,
930+ required_engine_type: str | None = None,
925 ) -> tuple[Instance, Endpoint, float] | None:931 ) -> tuple[Instance, Endpoint, float] | None:
926 """932 """
927 Select allocation target using SchedulerServer's authoritative workload view.933 Select allocation target using SchedulerServer's authoritative workload view.
@@ -934,19 +940,21 @@ class _SchedulerRequestDispatcher:
934 worker's ranked alternates". Other policies keep the worker-proposed endpoint.940 worker's ranked alternates". Other policies keep the worker-proposed endpoint.
935 """941 """
936 if self._should_scan_global_load_balance(candidate_policy):942 if self._should_scan_global_load_balance(candidate_policy):
937- selected = self._select_global_load_balance_candidate(role)943+ selected = self._select_global_load_balance_candidate(role, required_engine_type)
938 if selected is not None:944 if selected is not None:
939 return selected945 return selected
940 if candidate_policy == CANDIDATE_POLICY_KV_CACHE_AFFINITY:946 if candidate_policy == CANDIDATE_POLICY_KV_CACHE_AFFINITY:
941 if affinity_candidates:947 if affinity_candidates:
942- selected = self._select_affinity_global(affinity_candidates, role, prefill_load_scale, load_weight)948+ selected = self._select_affinity_global(
949+ affinity_candidates, role, prefill_load_scale, load_weight, required_engine_type
950+ )
943 if selected is not None:951 if selected is not None:
944 return selected952 return selected
945 elif len(candidates) > 1:953 elif len(candidates) > 1:
946- selected = self._select_lowest_load_among_candidates(candidates, role)954+ selected = self._select_lowest_load_among_candidates(candidates, role, required_engine_type)
947 if selected is not None:955 if selected is not None:
948 return selected956 return selected
949- return self._select_authoritative_candidate(candidate, role)957+ return self._select_authoritative_candidate(candidate, role, required_engine_type)
950 958 
951 def _select_affinity_global(959 def _select_affinity_global(
952 self,960 self,
@@ -954,6 +962,7 @@ class _SchedulerRequestDispatcher:
954 role: PDRole,962 role: PDRole,
955 prefill_load_scale: float | None,963 prefill_load_scale: float | None,
956 load_weight: float | None,964 load_weight: float | None,
965+ required_engine_type: str | None = None,
957 ) -> tuple[Instance, Endpoint, float] | None:966 ) -> tuple[Instance, Endpoint, float] | None:
958 """967 """
959 Global kv_cache_affinity unified selection over EVERY worker-reported endpoint.968 Global kv_cache_affinity unified selection over EVERY worker-reported endpoint.
@@ -974,6 +983,8 @@ class _SchedulerRequestDispatcher:
974 if found is None:983 if found is None:
975 continue984 continue
976 instance, endpoint = found985 instance, endpoint = found
986+ if not self._matches_engine_type(instance, required_engine_type):
987+ continue
977 try:988 try:
978 instance_role = PDRole(instance.role)989 instance_role = PDRole(instance.role)
979 except ValueError:990 except ValueError:
@@ -1008,6 +1019,7 @@ class _SchedulerRequestDispatcher:
1008 self,1019 self,
1009 candidates: list[tuple[int, int]],1020 candidates: list[tuple[int, int]],
1010 role: PDRole,1021 role: PDRole,
1022+ required_engine_type: str | None = None,
1011 ) -> tuple[Instance, Endpoint, float] | None:1023 ) -> tuple[Instance, Endpoint, float] | None:
1012 """1024 """
1013 Among the worker's affinity-ranked candidates, pick the lowest current endpoint score from1025 Among the worker's affinity-ranked candidates, pick the lowest current endpoint score from
@@ -1022,6 +1034,8 @@ class _SchedulerRequestDispatcher:
1022 if found is None:1034 if found is None:
1023 continue1035 continue
1024 instance, endpoint = found1036 instance, endpoint = found
1037+ if not self._matches_engine_type(instance, required_engine_type):
1038+ continue
1025 try:1039 try:
1026 instance_role = PDRole(instance.role)1040 instance_role = PDRole(instance.role)
1027 except ValueError:1041 except ValueError:
@@ -1207,13 +1221,18 @@ class _SchedulerRequestDispatcher:
1207 def _select_global_load_balance_candidate(1221 def _select_global_load_balance_candidate(
1208 self,1222 self,
1209 role: PDRole,1223 role: PDRole,
1224+ required_engine_type: str | None = None,
1210 ) -> tuple[Instance, Endpoint, float] | None:1225 ) -> tuple[Instance, Endpoint, float] | None:
1211 """Select the globally lowest-score endpoint for role from SchedulerServer's local pool.1226 """Select the globally lowest-score endpoint for role from SchedulerServer's local pool.
1212 1227 
1213 Circuit-broken endpoints are filtered so the authoritative re-scan never picks one1228 Circuit-broken endpoints are filtered so the authoritative re-scan never picks one
1214 that the local PUB cache may not yet know about.1229 that the local PUB cache may not yet know about.
1215 """1230 """
1216- instances = self._instance_manager.get_available_instances(role).values()1231+ instances = [
1232+ instance
1233+ for instance in self._instance_manager.get_available_instances(role).values()
1234+ if self._matches_engine_type(instance, required_engine_type)
1235+ ]
1217 candidates = LoadBalancePolicy.select_endpoint_candidates_from_list(1236 candidates = LoadBalancePolicy.select_endpoint_candidates_from_list(
1218 instances,1237 instances,
1219 role=role,1238 role=role,
@@ -1258,6 +1277,7 @@ class _SchedulerRequestDispatcher:
1258 self,1277 self,
1259 candidate: tuple[int, int],1278 candidate: tuple[int, int],
1260 role: PDRole,1279 role: PDRole,
1280+ required_engine_type: str | None = None,
1261 ) -> tuple[Instance, Endpoint, float] | None:1281 ) -> tuple[Instance, Endpoint, float] | None:
1262 """1282 """
1263 Validate one worker-selected candidate and calculate its current score for observability.1283 Validate one worker-selected candidate and calculate its current score for observability.
@@ -1272,6 +1292,8 @@ class _SchedulerRequestDispatcher:
1272 if found is None:1292 if found is None:
1273 return None1293 return None
1274 instance, endpoint = found1294 instance, endpoint = found
1295+ if not self._matches_engine_type(instance, required_engine_type):
1296+ return None
1275 try:1297 try:
1276 instance_role = PDRole(instance.role)1298 instance_role = PDRole(instance.role)
1277 except ValueError:1299 except ValueError:
@@ -1295,6 +1317,12 @@ class _SchedulerRequestDispatcher:
1295 return None1317 return None
1296 return (instance, endpoint, score)1318 return (instance, endpoint, score)
1297 1319 
1320+ @staticmethod
1321+ def _matches_engine_type(instance: Instance, required_engine_type: str | None) -> bool:
1322+ if not required_engine_type:
1323+ return True
1324+ return str(getattr(instance, "engine_type", "")).strip().lower() == required_engine_type
1325+ 
1298 def _find_available_instance_endpoint(1326 def _find_available_instance_endpoint(
1299 self,1327 self,
1300 instance_id: int,1328 instance_id: int,
@@ -11,9 +11,6 @@
11import asyncio11import asyncio
12import uuid12import uuid
13 13 
14-from motor.common.resources.dispatch import (
15- has_compatible_dispatch_pair,
16-)
17from motor.common.resources.instance import Instance, PDRole14from motor.common.resources.instance import Instance, PDRole
18from motor.common.resources.endpoint import WorkloadAction, Workload15from motor.common.resources.endpoint import WorkloadAction, Workload
19from motor.coordinator.domain import (16from motor.coordinator.domain import (
@@ -116,6 +113,7 @@ class Scheduler:
116 req_info: RequestInfo,113 req_info: RequestInfo,
117 *,114 *,
118 target_instance_id: int | None = None,115 target_instance_id: int | None = None,
116+ required_engine_type: str | None = None,
119 ):117 ):
120 """118 """
121 Atomic: select instance + one workload allocation (ALLOCATION).119 Atomic: select instance + one workload allocation (ALLOCATION).
@@ -125,8 +123,15 @@ class Scheduler:
125 (Instance, Endpoint, Workload) tuple or None (no instance or update_workload failed).123 (Instance, Endpoint, Workload) tuple or None (no instance or update_workload failed).
126 The returned Workload is what was allocated; caller records it for release.124 The returned Workload is what was allocated; caller records it for release.
127 """125 """
126+ pool = self._instance_provider.get_available_instances(role)
127+ if required_engine_type is not None:
128+ normalized_engine_type = required_engine_type.strip().lower()
129+ pool = {
130+ instance_id: instance
131+ for instance_id, instance in pool.items()
132+ if str(getattr(instance, "engine_type", "")).strip().lower() == normalized_engine_type
133+ }
128 if target_instance_id is not None:134 if target_instance_id is not None:
129- pool = self._instance_provider.get_available_instances(role)
130 instance = resolve_pinned_instance(pool, target_instance_id)135 instance = resolve_pinned_instance(pool, target_instance_id)
131 if instance is None:136 if instance is None:
132 logger.warning(137 logger.warning(
@@ -147,7 +152,11 @@ class Scheduler:
147 )152 )
148 return None153 return None
149 else:154 else:
150- r = self._scheduling_policy.select_instance_and_endpoint(role)155+ r = self._scheduling_policy.select_instance_and_endpoint_from_list(
156+ list(pool.values()),
157+ role,
158+ req_info,
159+ )
151 result = (await r) if asyncio.iscoroutine(r) else r160 result = (await r) if asyncio.iscoroutine(r) else r
152 if result is None:161 if result is None:
153 return None162 return None
@@ -240,12 +249,6 @@ class Scheduler:
240 roles.add(aliases[normalized])249 roles.add(aliases[normalized])
241 return roles250 return roles
242 251 
243- async def has_compatible_pd_pair(self) -> bool:
244- """Return whether the in-process instance view has a compatible P/D pair."""
245- prefill = self._instance_provider.get_available_instances(PDRole.ROLE_P).values()
246- decode = self._instance_provider.get_available_instances(PDRole.ROLE_D).values()
247- return has_compatible_dispatch_pair(prefill, decode)
248- 
249 async def get_unblocked_instances(self, role: PDRole) -> list[int]:252 async def get_unblocked_instances(self, role: PDRole) -> list[int]:
250 """Return all instance IDs for the role (in-process scheduler has no CB)."""253 """Return all instance IDs for the role (in-process scheduler has no CB)."""
251 return [inst.id for inst in self._instance_provider.get_available_instances(role).values()]254 return [inst.id for inst in self._instance_provider.get_available_instances(role).values()]
@@ -44,7 +44,7 @@ set_process_title(f"EngineServer-DP{_dp_rank_from_argv()}")
44# ruff: noqa: E40244# ruff: noqa: E402
45from motor.common.logger import get_logger45from motor.common.logger import get_logger
46from motor.config.endpoint import EndpointConfig46from motor.config.endpoint import EndpointConfig
47-from motor.engine_server.factory.config_factory import ConfigFactory47+from motor.node_manager.core.services.native_engine.config_factory import ConfigFactory
48from motor.engine_server.factory.endpoint_factory import EndpointFactory48from motor.engine_server.factory.endpoint_factory import EndpointFactory
49from motor.engine_server.utils.prometheus import setup_multiprocess_prometheus49from motor.engine_server.utils.prometheus import setup_multiprocess_prometheus
50 50 
@@ -31,7 +31,7 @@ from motor.common.resources.dispatch import (
31 MotorDispatch,31 MotorDispatch,
32 PrefillResult,32 PrefillResult,
33)33)
34-from motor.engine_server.core.config import IConfig34+from motor.node_manager.core.services.native_engine.backends.base import IConfig
35from motor.engine_server.core.vllm.prefill_context_validation import PrefillContextCheck35from motor.engine_server.core.vllm.prefill_context_validation import PrefillContextCheck
36from motor.engine_server.core.errors.sanitizer import sanitize_error_message36from motor.engine_server.core.errors.sanitizer import sanitize_error_message
37 37 
@@ -8,7 +8,7 @@
8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11-from motor.engine_server.core.config import IConfig11+from motor.node_manager.core.services.native_engine.backends.base import IConfig
12from motor.engine_server.core.dispatch_adapter.base import DispatchAdapter12from motor.engine_server.core.dispatch_adapter.base import DispatchAdapter
13from motor.engine_server.core.dispatch_adapter.sglang_adapter import SGLangDispatchAdapter13from motor.engine_server.core.dispatch_adapter.sglang_adapter import SGLangDispatchAdapter
14from motor.engine_server.core.dispatch_adapter.vllm_adapter import VLLMDispatchAdapter14from motor.engine_server.core.dispatch_adapter.vllm_adapter import VLLMDispatchAdapter
@@ -9,7 +9,6 @@
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11import hashlib11import hashlib
12-import os
13from typing import Any12from typing import Any
14from urllib.parse import urlparse13from urllib.parse import urlparse
15 14 
@@ -27,11 +26,11 @@ class SGLangDispatchAdapter(DispatchAdapter):
27 return body26 return body
28 27 
29 parsed = urlparse(prefill.url)28 parsed = urlparse(prefill.url)
30- bootstrap_port = os.getenv("DISAGGREGATION_BOOTSTRAP_PORT", "").strip()29+ bootstrap_port = prefill.bootstrap_port
31- if not bootstrap_port:30+ if bootstrap_port is None:
32 raise HTTPException(31 raise HTTPException(
33 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,32 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
34- detail=("DISAGGREGATION_BOOTSTRAP_PORT must be set for SGLang dispatch requests."),33+ detail=("Prefill endpoint bootstrap_port is required for SGLang dispatch requests."),
35 )34 )
36 body.update(35 body.update(
37 {36 {
@@ -14,7 +14,7 @@ from motor.common.http.http_client import AsyncSafeHTTPSClient
14from motor.common.logger import get_logger14from motor.common.logger import get_logger
15from motor.common.utils.snapshot_utils import is_restored_from_host_side_snapshot, get_pod_ip15from motor.common.utils.snapshot_utils import is_restored_from_host_side_snapshot, get_pod_ip
16from motor.config.endpoint import EndpointConfig16from motor.config.endpoint import EndpointConfig
17-from motor.engine_server.utils.ip import build_endpoint17+from motor.common.utils.ip import build_endpoint
18 18 
19logger = get_logger(__name__)19logger = get_logger(__name__)
20 20 
@@ -24,7 +24,7 @@ from motor.common.http.cert_util import CertUtil
24from motor.common.logger import get_logger24from motor.common.logger import get_logger
25from motor.common.resources.dispatch import DispatchStopState, MotorDispatch25from motor.common.resources.dispatch import DispatchStopState, MotorDispatch
26from motor.engine_server.core.dispatch_adapter import create_dispatch_adapter26from motor.engine_server.core.dispatch_adapter import create_dispatch_adapter
27-from motor.engine_server.core.config import IConfig27+from motor.node_manager.core.services.native_engine.backends.base import IConfig
28from motor.engine_server.core.dispatch_adapter.base import DispatchResponseContext28from motor.engine_server.core.dispatch_adapter.base import DispatchResponseContext
29from motor.common.utils.net import format_address29from motor.common.utils.net import format_address
30from motor.engine_server.core.endpoint import Endpoint30from motor.engine_server.core.endpoint import Endpoint
@@ -24,13 +24,13 @@ from motor.common.http.cert_util import CertUtil
24from motor.common.logger import get_logger24from motor.common.logger import get_logger
25from motor.common.utils.net import format_address25from motor.common.utils.net import format_address
26from motor.config.endpoint import EndpointConfig26from motor.config.endpoint import EndpointConfig
27-from motor.engine_server.core.config import IConfig27+from motor.node_manager.core.services.native_engine.backends.base import IConfig
28from motor.engine_server.core.endpoint import Endpoint28from motor.engine_server.core.endpoint import Endpoint
29from motor.engine_server.core.health_collector import HealthCollector29from motor.engine_server.core.health_collector import HealthCollector
30from motor.engine_server.core.snapshot_monitor import SnapshotMonitor30from motor.engine_server.core.snapshot_monitor import SnapshotMonitor
31from motor.engine_server.core.sim_inference import SimInference31from motor.engine_server.core.sim_inference import SimInference
32-from motor.engine_server.constants import constants32+from motor.common import engine_constants as constants
33-from motor.engine_server.constants.constants import (33+from motor.common.engine_constants import (
34 METRICS_INTERFACE,34 METRICS_INTERFACE,
35 STATUS_INTERFACE,35 STATUS_INTERFACE,
36 STATUS_KEY,36 STATUS_KEY,
@@ -22,7 +22,7 @@
22from typing import Any22from typing import Any
23 23 
24from motor.common.logger import get_logger24from motor.common.logger import get_logger
25-from motor.engine_server.core.config import IConfig25+from motor.node_manager.core.services.native_engine.backends.base import IConfig
26from motor.engine_server.core.engine import Engine26from motor.engine_server.core.engine import Engine
27 27 
28logger = get_logger(__name__)28logger = get_logger(__name__)
@@ -33,13 +33,13 @@ def _kill_engine_children() -> None:
33 try:33 try:
34 from sglang.srt.utils import kill_process_tree34 from sglang.srt.utils import kill_process_tree
35 import os35 import os
36+ 
36 kill_process_tree(os.getpid(), include_parent=False)37 kill_process_tree(os.getpid(), include_parent=False)
37 except Exception as e:38 except Exception as e:
38 logger.exception("Error killing SGLang engine child processes: %s", e)39 logger.exception("Error killing SGLang engine child processes: %s", e)
39 40 
40 41 
41class SGLangEngine(Engine):42class SGLangEngine(Engine):
42- 
43 def __init__(self, config: IConfig):43 def __init__(self, config: IConfig):
44 self._template_manager: Any | None = None44 self._template_manager: Any | None = None
45 self._tokenizer_manager: Any | None = None45 self._tokenizer_manager: Any | None = None
@@ -47,7 +47,7 @@ class SGLangEngine(Engine):
47 self.config = config47 self.config = config
48 48 
49 def launch(self) -> Any:49 def launch(self) -> Any:
50- from sglang.srt.entrypoints.engine import (_launch_subprocesses)50+ from sglang.srt.entrypoints.engine import _launch_subprocesses
51 51 
52 server_args = self.config.get_args()52 server_args = self.config.get_args()
53 if server_args is None:53 if server_args is None:
@@ -74,16 +74,14 @@ class SGLangEngine(Engine):
74 74 
75 if server_args.tokenizer_worker_num > 1:75 if server_args.tokenizer_worker_num > 1:
76 from sglang.srt.managers.multi_tokenizer_mixin import write_data_for_multi_tokenizer76 from sglang.srt.managers.multi_tokenizer_mixin import write_data_for_multi_tokenizer
77- self._multi_tokenizer_args_shm = write_data_for_multi_tokenizer(77+ 
78- port_args, server_args, scheduler_infos[0]78+ self._multi_tokenizer_args_shm = write_data_for_multi_tokenizer(port_args, server_args, scheduler_infos[0])
79- )
80 79 
81 return self._tokenizer_manager, self._template_manager80 return self._tokenizer_manager, self._template_manager
82 81 
83 def shutdown(self) -> None:82 def shutdown(self) -> None:
84 if self._multi_tokenizer_args_shm is not None:83 if self._multi_tokenizer_args_shm is not None:
85 self._multi_tokenizer_args_shm.close()84 self._multi_tokenizer_args_shm.close()
86- self._multi_tokenizer_args_shm = None85+ self._multi_tokenizer_args_shm = None
87- self._tokenizer_manager.socket_mapping.clear_all_sockets()86+ self._tokenizer_manager.socket_mapping.clear_all_sockets()
88 _kill_engine_children()87 _kill_engine_children()
89- 
@@ -18,10 +18,10 @@ from motor.common.resources.dispatch import DispatchProfile, infer_vllm_dispatch
18from motor.common.http.http_client import AsyncSafeHTTPSClient18from motor.common.http.http_client import AsyncSafeHTTPSClient
19from motor.common.logger import get_logger19from motor.common.logger import get_logger
20from motor.common.utils.net import format_address20from motor.common.utils.net import format_address
21-from motor.engine_server.core.config import IConfig21+from motor.node_manager.core.services.native_engine.backends.base import IConfig
22from motor.engine_server.utils.ai_cube import get_ai_cube_usage, is_ai_cube_usage_watch_supported22from motor.engine_server.utils.ai_cube import get_ai_cube_usage, is_ai_cube_usage_watch_supported
23-from motor.engine_server.constants import constants23+from motor.common import engine_constants as constants
24-from motor.engine_server.utils.ip import build_endpoint24+from motor.common.utils.ip import build_endpoint
25from motor.common.utils.snapshot_utils import is_restored_from_host_side_snapshot, get_pod_ip25from motor.common.utils.snapshot_utils import is_restored_from_host_side_snapshot, get_pod_ip
26 26 
27logger = get_logger(__name__)27logger = get_logger(__name__)
@@ -21,7 +21,7 @@ from motor.common.utils.snapshot_utils import (
21 get_pod_ip,21 get_pod_ip,
22 RETRY_LOG_FREQUENCY,22 RETRY_LOG_FREQUENCY,
23)23)
24-from motor.engine_server.utils.ip import build_endpoint24+from motor.common.utils.ip import build_endpoint
25 25 
26logger = get_logger(__name__)26logger = get_logger(__name__)
27 27 
@@ -18,7 +18,7 @@ from vllm.v1.executor.multiproc_executor import MultiprocExecutor
18 18 
19from motor.common.logger import get_logger19from motor.common.logger import get_logger
20from motor.common.logger import attach_to_vllm_logger20from motor.common.logger import attach_to_vllm_logger
21-from motor.engine_server.core.config import IConfig21+from motor.node_manager.core.services.native_engine.backends.base import IConfig
22from motor.engine_server.core.engine import Engine22from motor.engine_server.core.engine import Engine
23from motor.engine_server.core.vllm.vllm_openai_compat import cli_env_setup23from motor.engine_server.core.vllm.vllm_openai_compat import cli_env_setup
24 24 
@@ -21,7 +21,7 @@ import importlib
21from typing import Any21from typing import Any
22 22 
23from motor.common.logger import get_logger23from motor.common.logger import get_logger
24-from motor.engine_server.core.config import IConfig24+from motor.node_manager.core.services.native_engine.backends.base import IConfig
25 25 
26logger = get_logger(__name__)26logger = get_logger(__name__)
27 27 
@@ -37,10 +37,7 @@ class EndpointFactory:
37 target = self._CREATOR_MAP.get(engine_type)37 target = self._CREATOR_MAP.get(engine_type)
38 if not target:38 if not target:
39 supported_types = list(self._CREATOR_MAP.keys())39 supported_types = list(self._CREATOR_MAP.keys())
40- raise ValueError(40+ raise ValueError(f"Unsupported engine type: {engine_type}. Supported types are: {supported_types}.")
41- f"Unsupported engine type: {engine_type}. "
42- f"Supported types are: {supported_types}."
43- )
44 41 
45 try:42 try:
46 module_path, class_name = target.rsplit(".", 1)43 module_path, class_name = target.rsplit(".", 1)
@@ -1,40 +0,0 @@
1-# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
2-# MindIE is licensed under Mulan PSL v2.
3-# You can use this software according to the terms and conditions of the Mulan PSL v2.
4-# You may obtain a copy of Mulan PSL v2 at:
5-# http://license.coscl.org.cn/MulanPSL2
6-# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7-# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8-# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9-# See the Mulan PSL v2 for more details.
10- 
11-from motor.common.http.http_client import SafeHTTPSClient
12-from motor.common.logger import get_logger
13-from motor.common.logger.rate_limited_logger import RateLimitedLogger
14-from motor.config.node_manager import NodeManagerConfig
15- 
16-logger = get_logger(__name__)
17-_rl = RateLimitedLogger(logger)
18- 
19- 
20-class EngineServerApiClient:
21- tls_config = NodeManagerConfig.from_json().mgmt_tls_config
22- 
23- @staticmethod
24- def query_status(address: str):
25- client_args = EngineServerApiClient._generate_client_args(address)
26- client = SafeHTTPSClient(**client_args, timeout=5)
27- response = client.get("/status")
28- _rl.record_success(f"node_manager.engine_server.query_status.{address}")
29- _rl.emit_periodic(
30- f"node_manager.engine_server.query_status.{address}",
31- "NodeManager->EngineServer query_status periodic summary: succeeded {count} times in last 60s",
32- level="DEBUG",
33- )
34- logger.debug(f"Query engine server status success, response: {response}, address: {client_args['address']}")
35- return response
36- 
37- @classmethod
38- def _generate_client_args(cls, address: str) -> dict[str, str]:
39- client_ars = {"address": f"{address}", "tls_config": cls.tls_config}
40- return client_ars
@@ -99,7 +99,7 @@ async def start_instance(request: Request):
99 except Exception as pull_err:99 except Exception as pull_err:
100 logger.error("Failed to pull engine: %s", pull_err)100 logger.error("Failed to pull engine: %s", pull_err)
101 raise HTTPException(101 raise HTTPException(
102- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start engine server"102+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start native engine"
103 ) from pull_err103 ) from pull_err
104 104 
105 # Start KV store service (if backend is configured)105 # Start KV store service (if backend is configured)
@@ -154,11 +154,11 @@ async def pause_instance(request: Request):
154 try:154 try:
155 await asyncio.to_thread(HeartbeatManager().pause_all_endpoints)155 await asyncio.to_thread(HeartbeatManager().pause_all_endpoints)
156 hm = HeartbeatManager()156 hm = HeartbeatManager()
157- engine_mgmt_addrs = hm.get_engine_mgmt_addrs()157+ engine_metrics_targets = hm.get_engine_metrics_targets()
158 content = {158 content = {
159 "status": "ok",159 "status": "ok",
160 "message": "Endpoints set to PAUSED",160 "message": "Endpoints set to PAUSED",
161- "engine_mgmt_addrs": engine_mgmt_addrs,161+ "engine_metrics_targets": engine_metrics_targets,
162 }162 }
163 return Response(status_code=status.HTTP_200_OK, content=json.dumps(content))163 return Response(status_code=status.HTTP_200_OK, content=json.dumps(content))
164 except Exception as err:164 except Exception as err:
@@ -12,6 +12,7 @@ import threading
12 12 
13from motor.common.resources.instance import PDRole13from motor.common.resources.instance import PDRole
14from motor.common.resources.endpoint import Endpoint14from motor.common.resources.endpoint import Endpoint
15+from motor.node_manager.core.services.native_engine.models import RuntimeState
15from motor.common.utils.singleton import ThreadSafeSingleton16from motor.common.utils.singleton import ThreadSafeSingleton
16from motor.common.logger import get_logger17from motor.common.logger import get_logger
17from motor.config.node_manager import NodeManagerConfig18from motor.config.node_manager import NodeManagerConfig
@@ -125,6 +126,19 @@ class Daemon(ThreadSafeSingleton):
125 """True when the engine service is active (i.e. this pod runs inference)."""126 """True when the engine service is active (i.e. this pod runs inference)."""
126 return SERVICE_ENGINE in self._services127 return SERVICE_ENGINE in self._services
127 128 
129+ def get_engine_runtime_state(self, endpoint: Endpoint) -> RuntimeState:
130+ """Return the native runtime state for one locally managed endpoint."""
131+ engine = self._services.get(SERVICE_ENGINE)
132+ if engine is None:
133+ return RuntimeState.STOPPED
134+ return engine.runtime_state(endpoint) # type: ignore[attr-defined]
135+ 
136+ def get_engine_metrics_target(self, endpoint: Endpoint) -> str | None:
137+ engine = self._services.get(SERVICE_ENGINE)
138+ if engine is None:
139+ return None
140+ return engine.metrics_target(endpoint) # type: ignore[attr-defined]
141+ 
128 def stop(self) -> None:142 def stop(self) -> None:
129 self._monitor_stop.set()143 self._monitor_stop.set()
130 if self._monitor_thread is not None and self._monitor_thread.is_alive():144 if self._monitor_thread is not None and self._monitor_thread.is_alive():
@@ -364,6 +364,7 @@ class EngineManager(ThreadSafeSingleton):
364 pod_ip = self._config.api_config.pod_ip364 pod_ip = self._config.api_config.pod_ip
365 business_port = self._config.endpoint_config.service_ports365 business_port = self._config.endpoint_config.service_ports
366 mgmt_port = self._config.endpoint_config.mgmt_ports366 mgmt_port = self._config.endpoint_config.mgmt_ports
367+ bootstrap_port = self._config.endpoint_config.bootstrap_port
367 node_manager_port = self._config.api_config.node_manager_port368 node_manager_port = self._config.api_config.node_manager_port
368 parallel_config = self._config.basic_config.parallel_config369 parallel_config = self._config.basic_config.parallel_config
369 enable_multi_endpoints = self._config.basic_config.enable_multi_endpoints370 enable_multi_endpoints = self._config.basic_config.enable_multi_endpoints
@@ -379,6 +380,7 @@ class EngineManager(ThreadSafeSingleton):
379 pod_ip=pod_ip,380 pod_ip=pod_ip,
380 business_port=business_port,381 business_port=business_port,
381 mgmt_port=mgmt_port,382 mgmt_port=mgmt_port,
383+ bootstrap_port=bootstrap_port,
382 nm_port=str(node_manager_port),384 nm_port=str(node_manager_port),
383 parallel_config=parallel_config,385 parallel_config=parallel_config,
384 enable_multi_endpoints=enable_multi_endpoints,386 enable_multi_endpoints=enable_multi_endpoints,
@@ -8,10 +8,8 @@
8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11-import os
12import threading11import threading
13import time12import time
14-import socket
15 13 
16from motor.common.resources.endpoint import Endpoint, EndpointStatus14from motor.common.resources.endpoint import Endpoint, EndpointStatus
17from motor.common.resources.http_msg_spec import StartCmdMsg, HeartbeatMsg15from motor.common.resources.http_msg_spec import StartCmdMsg, HeartbeatMsg
@@ -22,9 +20,9 @@ from motor.common.utils.singleton import ThreadSafeSingleton
22from motor.common.utils.snapshot_utils import is_restored_from_host_side_snapshot, RETRY_LOG_FREQUENCY20from motor.common.utils.snapshot_utils import is_restored_from_host_side_snapshot, RETRY_LOG_FREQUENCY
23from motor.config.node_manager import NodeManagerConfig21from motor.config.node_manager import NodeManagerConfig
24from motor.node_manager.api_client.controller_api_client import ControllerApiClient22from motor.node_manager.api_client.controller_api_client import ControllerApiClient
25-from motor.node_manager.api_client.engine_server_api_client import EngineServerApiClient
26from motor.node_manager.core.engine_manager import EngineManager23from motor.node_manager.core.engine_manager import EngineManager
27from motor.node_manager.core.daemon import Daemon24from motor.node_manager.core.daemon import Daemon
25+from motor.node_manager.core.services.native_engine.models import RuntimeState
28 26 
29 27 
30logger = get_logger(__name__)28logger = get_logger(__name__)
@@ -55,14 +53,12 @@ class HeartbeatManager(ThreadSafeSingleton):
55 daemon=True,53 daemon=True,
56 name="heartbeat_report",54 name="heartbeat_report",
57 )55 )
58- self._engine_server_status_thread = threading.Thread(56+ self._engine_status_thread = threading.Thread(
59 target=self._refresh_endpoints_status_loop,57 target=self._refresh_endpoints_status_loop,
60 daemon=True,58 daemon=True,
61 name="endpoint_status_fetch",59 name="endpoint_status_fetch",
62 )60 )
63 self._thread_started = False61 self._thread_started = False
64- self._engine_status_thread_start_time = None
65- self._is_within_grace_period = True
66 self._consecutive_abnormal_count = 062 self._consecutive_abnormal_count = 0
67 self._abnormal_count_lock = threading.Lock()63 self._abnormal_count_lock = threading.Lock()
68 self._should_suicide = False64 self._should_suicide = False
@@ -81,8 +77,7 @@ class HeartbeatManager(ThreadSafeSingleton):
81 def start(self):77 def start(self):
82 if self._thread_started is False:78 if self._thread_started is False:
83 self._heartbeat_report_thread.start()79 self._heartbeat_report_thread.start()
84- self._engine_server_status_thread.start()80+ self._engine_status_thread.start()
85- self._engine_status_thread_start_time = time.time()
86 self._thread_started = True81 self._thread_started = True
87 else:82 else:
88 logger.info("Heartbeat thread has been started...")83 logger.info("Heartbeat thread has been started...")
@@ -109,9 +104,6 @@ class HeartbeatManager(ThreadSafeSingleton):
109 # Reset suicide flag when endpoints are updated104 # Reset suicide flag when endpoints are updated
110 with self._suicide_lock:105 with self._suicide_lock:
111 self._should_suicide = False106 self._should_suicide = False
112- self._is_within_grace_period = True
113- if self._thread_started:
114- self._engine_status_thread_start_time = time.time()
115 107 
116 def should_suicide(self) -> bool:108 def should_suicide(self) -> bool:
117 """109 """
@@ -125,8 +117,8 @@ class HeartbeatManager(ThreadSafeSingleton):
125 self.stop_event.set()117 self.stop_event.set()
126 if self._heartbeat_report_thread.is_alive():118 if self._heartbeat_report_thread.is_alive():
127 self._heartbeat_report_thread.join(timeout=2.0)119 self._heartbeat_report_thread.join(timeout=2.0)
128- if self._engine_server_status_thread.is_alive():120+ if self._engine_status_thread.is_alive():
129- self._engine_server_status_thread.join(timeout=2.0)121+ self._engine_status_thread.join(timeout=2.0)
130 logger.info("HeartBeatManager stopped.")122 logger.info("HeartBeatManager stopped.")
131 123 
132 def check_all_endpoints_normal(self) -> bool:124 def check_all_endpoints_normal(self) -> bool:
@@ -166,10 +158,12 @@ class HeartbeatManager(ThreadSafeSingleton):
166 endpoint.status = EndpointStatus.PAUSED158 endpoint.status = EndpointStatus.PAUSED
167 logger.info("All endpoints set to PAUSED for graceful shutdown")159 logger.info("All endpoints set to PAUSED for graceful shutdown")
168 160 
169- def get_engine_mgmt_addrs(self) -> list[str]:161+ def get_engine_metrics_targets(self) -> list[str]:
170- """Return engine management addresses for local metrics polling."""162+ """Return native metrics URLs for routable local endpoints."""
171 with self._endpoint_lock:163 with self._endpoint_lock:
172- return [format_address(ep.ip, ep.mgmt_port) for ep in self._endpoints]164+ endpoints = [endpoint for endpoint in self._endpoints if not endpoint.headless]
165+ daemon = Daemon()
166+ return [target for endpoint in endpoints if (target := daemon.get_engine_metrics_target(endpoint)) is not None]
173 167 
174 def resume_all_endpoints(self) -> None:168 def resume_all_endpoints(self) -> None:
175 """Resume all endpoints from PAUSED back to NORMAL status.169 """Resume all endpoints from PAUSED back to NORMAL status.
@@ -192,35 +186,11 @@ class HeartbeatManager(ThreadSafeSingleton):
192 self._is_started_after_restore = is_started186 self._is_started_after_restore = is_started
193 187 
194 def _refresh_endpoints_status_loop(self) -> None:188 def _refresh_endpoints_status_loop(self) -> None:
195- # Poll each engine server's mgmt port until it responds (max 60s)
196- self._wait_for_engine_servers_ready(timeout=60)
197 while not self.stop_event.is_set():189 while not self.stop_event.is_set():
198- self._get_engine_server_status()190+ self._refresh_native_engine_status()
199- time.sleep(1)191+ self.stop_event.wait(1)
200 192 
201- def _wait_for_engine_servers_ready(self, timeout: float = 60) -> None:193+ def _refresh_native_engine_status(self) -> None:
202- """Poll each endpoint's mgmt port until it accepts connections or timeout."""
203- with self._endpoint_lock:
204- endpoints = list(self._endpoints)
205- 
206- deadline = time.time() + timeout
207- daemon = Daemon()
208- for endpoint in endpoints:
209- address = f"{endpoint.ip}:{endpoint.mgmt_port}"
210- logger.info("Waiting for engine server at %s to become ready...", address)
211- while not self.stop_event.is_set() and time.time() < deadline:
212- try:
213- with socket.create_connection((endpoint.ip, int(endpoint.mgmt_port)), timeout=2):
214- logger.info("Engine server at %s is ready.", address)
215- break
216- except (OSError, ConnectionRefusedError, TimeoutError):
217- # Check if engine process is still alive
218- if not any(os.path.isdir(f"/proc/{pid}") for pid in daemon.engine_pids):
219- logger.error("Engine process for %s is no longer running, aborting wait.", address)
220- break
221- time.sleep(1)
222- 
223- def _get_engine_server_status(self) -> None:
224 with self._endpoint_lock:194 with self._endpoint_lock:
225 endpoints_snapshot = list(self._endpoints)195 endpoints_snapshot = list(self._endpoints)
226 generation_at_start = self._endpoints_generation196 generation_at_start = self._endpoints_generation
@@ -228,50 +198,26 @@ class HeartbeatManager(ThreadSafeSingleton):
228 if not endpoints_snapshot:198 if not endpoints_snapshot:
229 return199 return
230 200 
231- # Check if within one minute after startup
232- if self._is_within_grace_period and self._engine_status_thread_start_time is not None:
233- elapsed_time = time.time() - self._engine_status_thread_start_time
234- self._is_within_grace_period = elapsed_time < 120
235- 
236 updated_endpoints = []201 updated_endpoints = []
237- client = None202+ daemon = Daemon()
238 for item in endpoints_snapshot:203 for item in endpoints_snapshot:
239 original_status = item.status204 original_status = item.status
240- client = None
241- detected_status = None
242- engine_server_base_url = format_address(item.ip, item.mgmt_port)
243 try:205 try:
244- response = EngineServerApiClient.query_status(engine_server_base_url)206+ runtime_state = daemon.get_engine_runtime_state(item)
245- if isinstance(response, dict) and "status" in response:
246- status_value = response.get("status")
247- try:
248- detected_status = EndpointStatus(status_value)
249- except ValueError:
250- logger.error(
251- "Invalid status value '%s' from Engine Server %d: %s",
252- status_value,
253- item.id,
254- engine_server_base_url,
255- )
256- detected_status = EndpointStatus.ABNORMAL
257- else:
258- logger.error(
259- "Invalid response format from Engine Server%d: %s: %s",
260- item.id,
261- engine_server_base_url,
262- response,
263- )
264- detected_status = EndpointStatus.ABNORMAL
265 except Exception as e:207 except Exception as e:
266- if not self._is_within_grace_period:208+ logger.error(
267- logger.error("Failed to get engine server status from %s: %s", engine_server_base_url, e)209+ "Failed to probe native engine at %s: %s",
268- detected_status = EndpointStatus.ABNORMAL210+ format_address(item.ip, item.business_port),
269- finally:211+ e,
270- if client is not None:212+ )
271- try:213+ runtime_state = RuntimeState.UNHEALTHY
272- client.close()214+ 
273- except Exception as e:215+ detected_status = {
274- logger.error("Failed to close client: %s", e)216+ RuntimeState.RUNNING: EndpointStatus.WAIT2START,
217+ RuntimeState.READY: EndpointStatus.NORMAL,
218+ RuntimeState.UNHEALTHY: EndpointStatus.ABNORMAL,
219+ RuntimeState.STOPPED: EndpointStatus.ABNORMAL,
220+ }.get(runtime_state)
275 221 
276 if is_restored_from_host_side_snapshot() and item.ip != self._config.api_config.pod_ip:222 if is_restored_from_host_side_snapshot() and item.ip != self._config.api_config.pod_ip:
277 # If restored from host side snapshot and not started after restore(pod_ip do not refresh yet), keep original status223 # If restored from host side snapshot and not started after restore(pod_ip do not refresh yet), keep original status
@@ -281,11 +227,13 @@ class HeartbeatManager(ThreadSafeSingleton):
281 original_status,227 original_status,
282 )228 )
283 item.status = original_status229 item.status = original_status
284- elif self._is_within_grace_period and detected_status == EndpointStatus.ABNORMAL:230+ elif runtime_state in (RuntimeState.STARTING, RuntimeState.STOPPING):
G
Gganglv19 天前

严重程度: 提示

问题: RuntimeState.STOPPING 分支是死代码,且停止中的引擎会被报 ABNORMAL。

原因: supervisor.state() 永远不会返回 STOPPING(该状态只在 _stop_runtimes 内部瞬态设置,探测失败时因 runtime.state != STARTING 走 else 分支置 UNHEALTHY),因此心跳侧 runtime_state in (STARTING, STOPPING) 中 STOPPING 永不命中;stop_instance 后心跳仍在跑,探测失败 → UNHEALTHY → ABNORMAL → 5 次后自杀。意图是“停止不是故障”,但实现未达意(行为与基线一致,属语义标注与实现不一致)。

怎么改: 要么让 state() 在 STOPPING 时短路返回 STOPPING(探测前先查 state),要么删掉心跳侧的死分支并补充注释说明“停止中按 ABNORMAL 处理”。

likedislike
tobking
tobking
19 天前 评论:
285- # If within grace period and abnormal status detected, do not update status231+ # Loading is not a failure. Keep INITIAL (or the last reported
232+ # status) until the native readiness endpoint succeeds.
286 logger.debug(233 logger.debug(
287- "Engine server %s status is abnormal within grace period, keeping original status: %s",234+ "Native engine %s is %s, keeping status %s",
288- engine_server_base_url,235+ format_address(item.ip, item.business_port),
236+ runtime_state.value,
289 original_status,237 original_status,
290 )238 )
291 item.status = original_status239 item.status = original_status
@@ -297,7 +245,7 @@ class HeartbeatManager(ThreadSafeSingleton):
297 245 
298 if item.status != original_status:246 if item.status != original_status:
299 logger.info(247 logger.info(
300- "Engine Server rank %d, status change from %s to %s ",248+ "Native engine rank %d, status change from %s to %s ",
301 item.id,249 item.id,
302 original_status,250 original_status,
303 item.status,251 item.status,
@@ -316,8 +264,7 @@ class HeartbeatManager(ThreadSafeSingleton):
316 is_normal = True264 is_normal = True
317 try:265 try:
318 with self._endpoint_lock:266 with self._endpoint_lock:
319- # Check if any endpoint has abnormal status (only after grace period)267+ # Check actual endpoint status, not the reported status.
320- # Check actual endpoint status, not the reported status
321 has_abnormal = any(item.status == EndpointStatus.ABNORMAL for item in self._endpoints)268 has_abnormal = any(item.status == EndpointStatus.ABNORMAL for item in self._endpoints)
322 is_normal = all(item.status == EndpointStatus.NORMAL for item in self._endpoints)269 is_normal = all(item.status == EndpointStatus.NORMAL for item in self._endpoints)
323 270 
@@ -0,0 +1,11 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""NodeManager service for launching and supervising native inference engines."""
Rmotor/engine_server/constants/__init__.pymotor/node_manager/core/services/native_engine/backends/__init__.py+4-4
@@ -1,6 +1,4 @@
1-#!/usr/bin/env python31+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# -*- coding: utf-8 -*-
3-# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
4# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
5# You can use this software according to the terms and conditions of the Mulan PSL v2.3# You can use this software according to the terms and conditions of the Mulan PSL v2.
6# You may obtain a copy of Mulan PSL v2 at:4# You may obtain a copy of Mulan PSL v2 at:
@@ -8,4 +6,6 @@
8# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,6# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
9# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,7# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
10# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
11-# See the Mulan PSL v2 for more details.9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Engine-specific native launch backends."""
@@ -0,0 +1,132 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+import argparse
12+from abc import ABC, abstractmethod
13+from typing import Protocol
14+ 
15+from motor.common.logger import get_logger
16+from motor.config.endpoint import EndpointConfig
17+from motor.node_manager.core.services.native_engine.models import (
18+ CommandSpec,
19+ LaunchContext,
20+ LaunchSpec,
21+ ProbeSpec,
22+)
23+ 
24+logger = get_logger(__name__)
25+ 
26+supported_engine = ["vllm", "sglang"]
27+supported_role = ["prefill", "decode", "union"]
28+ 
29+ 
30+class IConfig(ABC):
31+ @abstractmethod
32+ def initialize(self):
33+ pass
34+ 
35+ @abstractmethod
36+ def validate(self):
37+ pass
38+ 
39+ @abstractmethod
40+ def convert(self):
41+ pass
42+ 
43+ @abstractmethod
44+ def get_args(self) -> argparse.Namespace | None:
45+ pass
46+ 
47+ @abstractmethod
48+ def get_endpoint_config(self) -> EndpointConfig | None:
49+ pass
50+ 
51+ @abstractmethod
52+ def get_cli_args(self) -> list[str]:
53+ """Return the CLI argument list suitable for native engine launch (vllm serve / sglang.launch_server)."""
54+ pass
55+ 
56+ 
57+class NativeEngineBackend(Protocol):
58+ """Stateless conversion from launch context to a native engine launch specification."""
59+ 
60+ engine_type: str
61+ 
62+ def prepare(self, context: LaunchContext) -> LaunchSpec:
63+ """Validate one endpoint and build its immutable launch specification."""
64+ ...
65+ 
66+ 
67+class BaseNativeEngineBackend:
68+ """Shared native launch-spec construction for engine-specific backends."""
69+ 
70+ engine_type: str
71+ command_prefix: tuple[str, ...]
72+ 
73+ def prepare(self, context: LaunchContext) -> LaunchSpec:
74+ self._validate_context(context)
75+ endpoint_config = build_endpoint_config(context, self.engine_type)
76+ if endpoint_config.engine_type != self.engine_type:
77+ raise ValueError(
78+ f"Configured engine type {endpoint_config.engine_type} does not match "
79+ f"Node Manager engine type {self.engine_type}"
80+ )
81+ self._validate_endpoint_config(endpoint_config)
82+ 
83+ # Import lazily so ConfigFactory can type against IConfig without a module cycle.
84+ from motor.node_manager.core.services.native_engine.config_factory import ConfigFactory
85+ 
86+ config = ConfigFactory(endpoint_config=endpoint_config).build_cli_config()
G
Gganglv19 天前

严重程度: 建议

问题: 原生路径不执行 convert()/validate():非法 CLI 参数只有在 vllm/sglang 进程退出时才以笼统错误暴露。

原因: 此处只调用 ConfigFactory.build_cli_config()(仅 initialize),factory.py:52-57parse()(convert+validate,含 vllm 的 validate_parsed_serve_args)在原生路径从未被调用。用户 engine_config 里写错的参数(拼错 key、非法取值、None 值被 _add_argument_to_list 拼成 "--key None" 字面量)直接进入 vllm serve,进程以 code 2 退出后 supervisor 只抛 “Engine process exited immediately with code N”,真实错误只在继承的 stdout 里,对 controller 只剩笼统的 “Failed to pull engine”。基线 in-process 路径在 engine_server 内 parse 阶段就会给出 argparse 报错。

怎么改: 在 prepare()(或 start() 前)以“仅校验不启动”方式跑一遍参数校验:对 _get_param_list 结果做类型/None 值检查,None 值直接过滤而不是转字符串:

def _add_argument_to_list(arg_list, key, value):
    if value is None:
        return
    ...

失败时抛带具体参数名的 ValueError,让 pull 的报错包含真实原因。

likedislike
tobking
tobking
19 天前 评论:
87+ config.convert()
88+ config.validate()
89+ health_config = endpoint_config.deploy_config.health_check_config
90+ return LaunchSpec(
91+ command=CommandSpec(
92+ argv=self.command_prefix + tuple(config.get_cli_args()),
93+ env=context.environment,
94+ ),
95+ probe=ProbeSpec(
96+ path="/health",
97+ timeout_seconds=float(health_config.health_collector_timeout),
G
Gganglv19 天前

严重程度: 提示

问题: health_collector_timeout_retry_attempts 配置被静默弃用;健康探针无超时重试且每次新建 Session/SSL context。

原因: 此处只取 health_collector_timeout;supervisor.py:86-95 每次 state() 新建 SafeHTTPSClient(新 requests.Session,TLS 时新 SSL context)做单次 do_get。基线 HealthCollector 对超时按 health_collector_timeout_retry_attempts(默认 3)重试。HealthCheckConfig 仍保留该字段,但新探针完全忽略,配置字段失效;多个 endpoint 在模型加载期(默认 startup_timeout 1800s)每秒各建一个 Session+SSL context,属不必要的开销。

怎么改: supervisor 内缓存按 (host,port,tls) 复用的客户端,或在 state() 中对超时类异常按 attempts 重试一次;同时决定该配置字段的去留(删除或实现)。

likedislike
tobking
tobking
19 天前 评论:
98+ startup_timeout_seconds=float(health_config.startup_timeout),
99+ max_attempts=health_config.health_collector_timeout_retry_attempts,
100+ tls_config=endpoint_config.deploy_config.infer_tls_config,
101+ process_only=context.headless,
102+ ),
103+ )
104+ 
105+ def _validate_context(self, context: LaunchContext) -> None:
106+ pass
107+ 
108+ def _validate_endpoint_config(self, endpoint_config: EndpointConfig) -> None:
109+ pass
110+ 
111+ 
112+def build_endpoint_config(context: LaunchContext, engine_type: str) -> EndpointConfig:
113+ """Rebuild and validate one role-specific endpoint configuration."""
114+ endpoint_config = EndpointConfig(
115+ engine_type=engine_type,
116+ host=context.host,
117+ role=context.role.value,
118+ kv_port=context.kv_port,
119+ lookup_rpc_port=context.lookup_rpc_port,
120+ master_dp_ip=context.master_dp_ip,
121+ dp_rpc_port=context.dp_rpc_port,
122+ port=context.business_port,
123+ mgmt_port=context.mgmt_port,
124+ instance_id=context.instance_id,
125+ dp_rank=context.dp_rank,
126+ node_rank=context.node_rank,
127+ config_path=context.config_path,
128+ d2d_peer_ips=",".join(context.d2d_peer_ips) if context.d2d_peer_ips else None,
129+ )
130+ endpoint_config.validate()
131+ endpoint_config.load_deploy_config()
132+ return endpoint_config
@@ -0,0 +1,11 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Native SGLang backend."""
@@ -0,0 +1,24 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+from motor.common.resources.instance import PDRole
12+from motor.node_manager.core.services.native_engine.backends.base import BaseNativeEngineBackend
13+from motor.node_manager.core.services.native_engine.models import LaunchContext
14+ 
15+ 
16+class SGLangBackend(BaseNativeEngineBackend):
17+ """Build and validate native SGLang launch specifications."""
18+ 
19+ engine_type = "sglang"
20+ command_prefix = ("python3", "-m", "sglang.launch_server")
21+ 
22+ def _validate_context(self, context: LaunchContext) -> None:
23+ if context.role == PDRole.ROLE_E:
24+ raise ValueError("SGLang encode role is not supported")
Rmotor/engine_server/core/sglang/sglang_config.pymotor/node_manager/core/services/native_engine/backends/sglang/config.py+20-6
@@ -10,21 +10,22 @@
10 10 
11import argparse11import argparse
12import json12import json
13-import sys
14from dataclasses import dataclass13from dataclasses import dataclass
15from typing import Any14from typing import Any
16 15 
17from motor.common.logger import get_logger16from motor.common.logger import get_logger
18from motor.common.utils.net import format_address17from motor.common.utils.net import format_address
19from motor.config.endpoint import EndpointConfig18from motor.config.endpoint import EndpointConfig
20-from motor.engine_server.constants import constants19+from motor.common import engine_constants as constants
21-from motor.engine_server.core.config import IConfig20+from motor.node_manager.core.services.native_engine.backends.base import IConfig
22 21 
23logger = get_logger(__name__)22logger = get_logger(__name__)
24 23 
25 24 
26def _add_argument_to_list(arg_list: list, key: str, value: Any):25def _add_argument_to_list(arg_list: list, key: str, value: Any):
27 """Append key-value to arg_list as CLI args (e.g. --key value)."""26 """Append key-value to arg_list as CLI args (e.g. --key value)."""
27+ if value is None:
28+ return
28 if isinstance(value, bool):29 if isinstance(value, bool):
29 if value:30 if value:
30 arg_list.append(f"--{key}")31 arg_list.append(f"--{key}")
@@ -58,12 +59,11 @@ class SGLangConfig(IConfig):
58 arg_list = self._get_param_list()59 arg_list = self._get_param_list()
59 logger.info("engine server sglang arg_list: %s", arg_list)60 logger.info("engine server sglang arg_list: %s", arg_list)
60 61 
61- sys.argv = ["serve"] + arg_list
62 from sglang.srt.server_args import ServerArgs62 from sglang.srt.server_args import ServerArgs
63 63 
64 parser = argparse.ArgumentParser()64 parser = argparse.ArgumentParser()
65 ServerArgs.add_cli_args(parser)65 ServerArgs.add_cli_args(parser)
66- raw_args = parser.parse_args()66+ raw_args = parser.parse_args(arg_list)
67 self.args = ServerArgs.from_cli_args(raw_args)67 self.args = ServerArgs.from_cli_args(raw_args)
68 68 
69 def get_args(self) -> argparse.Namespace:69 def get_args(self) -> argparse.Namespace:
@@ -87,11 +87,25 @@ class SGLangConfig(IConfig):
87 role = self.endpoint_config.role87 role = self.endpoint_config.role
88 88 
89 flattened.update(deploy_config.engine_config.configs)89 flattened.update(deploy_config.engine_config.configs)
90+ # Coordinator metrics aggregation and Kubernetes drain depend on this endpoint.
91+ flattened["enable-metrics"] = True
90 92 
91 flattened["host"] = self.endpoint_config.host93 flattened["host"] = self.endpoint_config.host
92 flattened["port"] = self.endpoint_config.port94 flattened["port"] = self.endpoint_config.port
93 95 
94- if flattened.get("nnodes", 1) > 1:96+ raw_nnodes = flattened.get("nnodes", 1)
97+ try:
98+ nnodes = int(raw_nnodes)
99+ except (TypeError, ValueError) as exc:
100+ raise ValueError(f"nnodes must be an integer, got {raw_nnodes!r}") from exc
101+ if nnodes < 1:
102+ raise ValueError(f"nnodes must be greater than 0, got {nnodes}")
103+ if "nnodes" in flattened:
104+ flattened["nnodes"] = nnodes
105+ 
106+ if nnodes > 1:
107+ if not self.endpoint_config.master_dp_ip:
108+ raise ValueError("master_dp_ip is required when nnodes > 1")
95 parallel_config = deploy_config.get_parallel_config(role)109 parallel_config = deploy_config.get_parallel_config(role)
96 flattened["dist-init-addr"] = format_address(self.endpoint_config.master_dp_ip, parallel_config.dp_rpc_port)110 flattened["dist-init-addr"] = format_address(self.endpoint_config.master_dp_ip, parallel_config.dp_rpc_port)
97 flattened["node-rank"] = self.endpoint_config.node_rank111 flattened["node-rank"] = self.endpoint_config.node_rank
@@ -0,0 +1,11 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Native vLLM backend."""
@@ -0,0 +1,34 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+from motor.common.resources.dispatch import DispatchProfile, classify_vllm_dispatch_profile
12+from motor.common.resources.instance import PDRole
13+from motor.config.endpoint import EndpointConfig
14+from motor.node_manager.core.services.native_engine.backends.base import BaseNativeEngineBackend
15+ 
16+ 
17+class VllmBackend(BaseNativeEngineBackend):
18+ """Build and validate native ``vllm serve`` launch specifications."""
19+ 
20+ engine_type = "vllm"
21+ command_prefix = ("vllm", "serve")
22+ 
23+ def _validate_endpoint_config(self, endpoint_config: EndpointConfig) -> None:
24+ if endpoint_config.role not in (PDRole.ROLE_P.value, PDRole.ROLE_D.value):
25+ return
26+ deploy_config = endpoint_config.deploy_config
27+ profile = classify_vllm_dispatch_profile(
28+ deploy_config.engine_config,
29+ explicit_profile=deploy_config.dispatch_profile,
30+ )
31+ if profile != DispatchProfile.HANDOFF:
32+ raise ValueError(
33+ f"Native vLLM P/D launch only supports handoff connectors; resolved dispatch profile is {profile.value}"
34+ )
Rmotor/engine_server/core/vllm/vllm_config.pymotor/node_manager/core/services/native_engine/backends/vllm/config.py+9-8
@@ -9,23 +9,22 @@
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11import argparse11import argparse
12-import sys
13import json12import json
14from typing import Any13from typing import Any
15from dataclasses import dataclass, field14from dataclasses import dataclass, field
16 15 
17-from vllm.entrypoints.openai.cli_args import make_arg_parser, validate_parsed_serve_args
18- 
19from motor.config.endpoint import EndpointConfig16from motor.config.endpoint import EndpointConfig
20-from motor.engine_server.core.config import IConfig17+from motor.node_manager.core.services.native_engine.backends.base import IConfig
21from motor.common.logger import get_logger18from motor.common.logger import get_logger
22from motor.common.utils.net import format_address19from motor.common.utils.net import format_address
23-from motor.engine_server.constants import constants20+from motor.common import engine_constants as constants
24 21 
25logger = get_logger(__name__)22logger = get_logger(__name__)
26 23 
27 24 
28def _add_argument_to_list(arg_list: list, key: str, value: Any):25def _add_argument_to_list(arg_list: list, key: str, value: Any):
26+ if value is None:
27+ return
29 if isinstance(value, bool):28 if isinstance(value, bool):
30 if value:29 if value:
31 arg_list.append(f"--{key}")30 arg_list.append(f"--{key}")
@@ -78,21 +77,23 @@ class VLLMConfig(IConfig):
78 77 
79 def validate(self):78 def validate(self):
80 if self.args is not None:79 if self.args is not None:
80+ from vllm.entrypoints.openai.cli_args import validate_parsed_serve_args
81+ 
81 validate_parsed_serve_args(self.args)82 validate_parsed_serve_args(self.args)
82 83 
83 def convert(self):84 def convert(self):
84 arg_list = self._get_param_list()85 arg_list = self._get_param_list()
85 logger.info(f'engine server parsed arg_list: {arg_list}')86 logger.info(f'engine server parsed arg_list: {arg_list}')
86 87 
87- sys.argv = ["serve"] + arg_list
88- 
89 try:88 try:
90 from vllm.utils import FlexibleArgumentParser89 from vllm.utils import FlexibleArgumentParser
91 except ImportError:90 except ImportError:
92 from vllm.utils.argparse_utils import FlexibleArgumentParser91 from vllm.utils.argparse_utils import FlexibleArgumentParser
92+ from vllm.entrypoints.openai.cli_args import make_arg_parser
93+ 
93 parser = FlexibleArgumentParser(description="vLLM parser")94 parser = FlexibleArgumentParser(description="vLLM parser")
94 parser = make_arg_parser(parser)95 parser = make_arg_parser(parser)
95- self.args = parser.parse_args()96+ self.args = parser.parse_args(arg_list)
96 97 
97 def get_args(self) -> argparse.Namespace:98 def get_args(self) -> argparse.Namespace:
98 return self.args99 return self.args
Rmotor/engine_server/factory/config_factory.pymotor/node_manager/core/services/native_engine/config_factory.py+15-10
@@ -11,22 +11,22 @@
11import importlib11import importlib
12 12 
13from motor.config.endpoint import EndpointConfig13from motor.config.endpoint import EndpointConfig
14-from motor.engine_server.core.config import IConfig14+from motor.node_manager.core.services.native_engine.backends.base import IConfig
15-from motor.common.logger import get_logger
16- 
17-logger = get_logger(__name__)
18 15 
19 16 
20class ConfigFactory:17class ConfigFactory:
18+ """Lazily construct engine-specific native CLI configuration adapters."""
19+ 
21 _ENGINE_CONFIG_MAP: dict[str, str] = {20 _ENGINE_CONFIG_MAP: dict[str, str] = {
22- "vllm": "motor.engine_server.core.vllm.vllm_config.VLLMConfig",21+ "vllm": "motor.node_manager.core.services.native_engine.backends.vllm.config.VLLMConfig",
23- "sglang": "motor.engine_server.core.sglang.sglang_config.SGLangConfig",22+ "sglang": "motor.node_manager.core.services.native_engine.backends.sglang.config.SGLangConfig",
24 }23 }
25 24 
26 def __init__(self, endpoint_config: EndpointConfig):25 def __init__(self, endpoint_config: EndpointConfig):
27 self.endpoint_config = endpoint_config26 self.endpoint_config = endpoint_config
28 27 
29- def parse(self) -> IConfig:28+ def build_cli_config(self) -> IConfig:
29+ """Build native CLI configuration without importing engine parsers."""
30 engine_type = self.endpoint_config.engine_type30 engine_type = self.endpoint_config.engine_type
31 config_class_path = self._ENGINE_CONFIG_MAP.get(engine_type)31 config_class_path = self._ENGINE_CONFIG_MAP.get(engine_type)
32 32 
@@ -38,14 +38,19 @@ class ConfigFactory:
38 )38 )
39 39 
40 try:40 try:
41- module_path, class_name = config_class_path.rsplit('.', 1)41+ module_path, class_name = config_class_path.rsplit(".", 1)
42 module = importlib.import_module(module_path)42 module = importlib.import_module(module_path)
43 config_class = getattr(module, class_name)43 config_class = getattr(module, class_name)
44 44 
45 config_instance = config_class(endpoint_config=self.endpoint_config)45 config_instance = config_class(endpoint_config=self.endpoint_config)
46 config_instance.initialize()46 config_instance.initialize()
47- config_instance.convert()
48- config_instance.validate()
49 return config_instance47 return config_instance
50 except (ImportError, AttributeError) as e:48 except (ImportError, AttributeError) as e:
51 raise ValueError(f"Failed to load config class for {engine_type}") from e49 raise ValueError(f"Failed to load config class for {engine_type}") from e
50+ 
51+ def parse(self) -> IConfig:
52+ """Build and validate the transitional in-process EngineServer configuration."""
53+ config_instance = self.build_cli_config()
54+ config_instance.convert()
55+ config_instance.validate()
56+ return config_instance
Rmotor/engine_server/core/config.pymotor/node_manager/core/services/native_engine/factory.py+14-33
@@ -8,40 +8,21 @@
8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11-import argparse11+from motor.node_manager.core.services.native_engine.backends.base import NativeEngineBackend
12-from abc import ABC, abstractmethod12+from motor.node_manager.core.services.native_engine.backends.sglang.backend import SGLangBackend
13- 13+from motor.node_manager.core.services.native_engine.backends.vllm.backend import VllmBackend
14-from motor.common.logger import get_logger
15-from motor.config.endpoint import EndpointConfig
16- 
17-logger = get_logger(__name__)
18- 
19-supported_engine = ["vllm", "sglang"]
20-supported_role = ["prefill", "decode", "union"]
21 14 
22 15 
23-class IConfig(ABC):16+_BACKENDS: dict[str, NativeEngineBackend] = {
24- @abstractmethod17+ VllmBackend.engine_type: VllmBackend(),
25- def initialize(self):18+ SGLangBackend.engine_type: SGLangBackend(),
26- pass19+}
27 20 
28- @abstractmethod
29- def validate(self):
30- pass
31 21 
32- @abstractmethod22+def get_backend(engine_type: str | None) -> NativeEngineBackend:
33- def convert(self):23+ """Return the stateless backend for the configured native engine type."""
34- pass24+ normalized = str(engine_type or "").strip().lower()
35- 25+ backend = _BACKENDS.get(normalized)
36- @abstractmethod26+ if backend is None:
37- def get_args(self) -> argparse.Namespace | None:27+ raise ValueError(f"Unsupported engine type: {engine_type}")
38- pass28+ return backend
39- 
40- @abstractmethod
41- def get_endpoint_config(self) -> EndpointConfig | None:
42- pass
43- 
44- @abstractmethod
45- def get_cli_args(self) -> list[str]:
46- """Return the CLI argument list suitable for native engine launch (vllm serve / sglang.launch_server)."""
47- pass
@@ -0,0 +1,104 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+from collections.abc import Mapping
12+from dataclasses import dataclass
13+from enum import Enum
14+from types import MappingProxyType
15+ 
16+from motor.common.resources.instance import PDRole
17+from motor.config.tls_config import TLSConfig
18+ 
19+ 
20+@dataclass(frozen=True)
21+class LaunchContext:
22+ """Complete, immutable input required to build one native engine command."""
23+ 
24+ role: PDRole
25+ instance_id: int
26+ dp_rank: int
27+ node_rank: int
28+ host: str
29+ business_port: int
30+ mgmt_port: int
31+ config_path: str
32+ master_dp_ip: str | None
33+ kv_port: int | None
34+ lookup_rpc_port: int | None
35+ dp_rpc_port: int | None
36+ d2d_peer_ips: tuple[str, ...]
37+ environment: Mapping[str, str]
38+ headless: bool = False
39+ 
40+ def __post_init__(self) -> None:
41+ object.__setattr__(self, "d2d_peer_ips", tuple(self.d2d_peer_ips))
42+ object.__setattr__(self, "environment", MappingProxyType(dict(self.environment)))
43+ 
44+ 
45+@dataclass(frozen=True)
46+class CommandSpec:
47+ """Native process invocation produced by a runtime adapter."""
48+ 
49+ argv: tuple[str, ...]
50+ env: Mapping[str, str]
51+ cwd: str | None = None
52+ 
53+ def __post_init__(self) -> None:
54+ object.__setattr__(self, "argv", tuple(self.argv))
55+ object.__setattr__(self, "env", MappingProxyType(dict(self.env)))
56+ 
57+ 
58+@dataclass(frozen=True)
59+class ProbeSpec:
60+ """Native readiness probe associated with one engine process."""
61+ 
62+ path: str
63+ timeout_seconds: float
64+ startup_timeout_seconds: float
65+ max_attempts: int = 1
66+ tls_config: TLSConfig | None = None
67+ process_only: bool = False
68+ 
69+ def __post_init__(self) -> None:
70+ if not isinstance(self.max_attempts, int) or isinstance(self.max_attempts, bool) or self.max_attempts < 1:
71+ raise ValueError("max_attempts must be a positive integer")
72+ 
73+ 
74+@dataclass(frozen=True)
75+class LaunchSpec:
76+ """Command and readiness probe built from one validated engine config."""
77+ 
78+ command: CommandSpec
79+ probe: ProbeSpec
80+ 
81+ 
82+class RuntimeState(str, Enum):
83+ """Node-local lifecycle state; it is not part of the control-plane API."""
84+ 
85+ STARTING = "starting"
86+ RUNNING = "running"
87+ READY = "ready"
88+ UNHEALTHY = "unhealthy"
89+ STOPPING = "stopping"
90+ STOPPED = "stopped"
91+ 
92+ 
93+@dataclass
94+class RuntimeProcess:
95+ """Mutable process record owned exclusively by ProcessSupervisor."""
96+ 
97+ endpoint_id: int
98+ process: object
99+ command: CommandSpec
100+ probe: ProbeSpec
101+ started_at: float
102+ process_group_id: int | None = None
103+ state: RuntimeState = RuntimeState.STARTING
104+ ready_at: float | None = None
Rmotor/node_manager/core/services/engine.pymotor/node_manager/core/services/native_engine/service.py+111-155
@@ -8,37 +8,32 @@
8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11-import ipaddress
12import os11import os
13import signal12import signal
14-import subprocess
15import threading13import threading
16 14 
17-from motor.common.resources.instance import PDRole
18from motor.common.resources.endpoint import Endpoint15from motor.common.resources.endpoint import Endpoint
16+from motor.common.resources.instance import PDRole
19from motor.common.utils.env import Env17from motor.common.utils.env import Env
20from motor.common.logger import get_logger18from motor.common.logger import get_logger
21-from motor.common.utils.snapshot_utils import MOTOR_SNAPSHOT_METADATA_PATH19+from motor.common.utils.net import format_address
22-from motor.node_manager.core.services.registry import register_service, SERVICE_ENGINE20+from motor.node_manager.core.services.native_engine.factory import get_backend
21+from motor.node_manager.core.services.native_engine.models import LaunchContext, RuntimeState
22+from motor.node_manager.core.services.native_engine.supervisor import ProcessSupervisor
23+from motor.node_manager.core.services.registry import SERVICE_ENGINE, register_service
23 24 
24logger = get_logger(__name__)25logger = get_logger(__name__)
25-MAX_PORT = 65535
26-MIN_PORT = 1024
27 26 
28 27 
29-def _create_engine(hardware_type: str, config):28+def _create_native_engine(hardware_type: str, config):
30- """Factory for EngineService — keeps constructor details out of the daemon."""29+ """Factory for NativeEngineService — keeps constructor details out of the daemon."""
31- return EngineService(30+ del hardware_type
32- hardware_type=hardware_type,31+ return NativeEngineService(
32+ engine_type=config.basic_config.engine_type,
33+ config_path=config.config_path,
33 device_num=config.basic_config.device_num,34 device_num=config.basic_config.device_num,
34 parallel_config=config.basic_config.parallel_config,35 parallel_config=config.basic_config.parallel_config,
35 enable_multi_endpoints=config.basic_config.enable_multi_endpoints,36 enable_multi_endpoints=config.basic_config.enable_multi_endpoints,
36- enable_snapshot=config.snapshot_config.enable_snapshot,
37- snapshot_metadata_path=(
38- config.snapshot_config.snapshot_metadata_path
39- if config.snapshot_config.snapshot_metadata_path != ""
40- else MOTOR_SNAPSHOT_METADATA_PATH
41- ),
42 single_container_flag=config.single_container_config.single_container_flag,37 single_container_flag=config.single_container_config.single_container_flag,
43 device_offset=config.single_container_config.device_offset,38 device_offset=config.single_container_config.device_offset,
44 kv_port=config.single_container_config.kv_port,39 kv_port=config.single_container_config.kv_port,
@@ -47,60 +42,39 @@ def _create_engine(hardware_type: str, config):
47 )42 )
48 43 
49 44 
50-@register_service(SERVICE_ENGINE, backend="engine", factory=_create_engine)45+@register_service(SERVICE_ENGINE, backend="engine", factory=_create_native_engine)
51-class EngineService:46+class NativeEngineService:
52 """Manage engine subprocess lifecycle: start, track PIDs, stop."""47 """Manage engine subprocess lifecycle: start, track PIDs, stop."""
53 48 
54 def __init__(49 def __init__(
55 self,50 self,
56- hardware_type: str,51+ engine_type: str,
52+ config_path: str,
57 device_num: int,53 device_num: int,
58 parallel_config,54 parallel_config,
59 enable_multi_endpoints: bool,55 enable_multi_endpoints: bool,
60- enable_snapshot: bool,
61- snapshot_metadata_path: str,
62 single_container_flag: bool = False,56 single_container_flag: bool = False,
63 device_offset: int = 0,57 device_offset: int = 0,
64 kv_port: int | None = None,58 kv_port: int | None = None,
65 lookup_rpc_port: int | None = None,59 lookup_rpc_port: int | None = None,
66 dp_rpc_port: int | None = None,60 dp_rpc_port: int | None = None,
67 ):61 ):
68- self.hardware_type = hardware_type62+ self.engine_type = str(engine_type).strip().lower()
63+ self.config_path = config_path
69 self.device_num = device_num64 self.device_num = device_num
70 self.parallel_config = parallel_config65 self.parallel_config = parallel_config
71 self.enable_multi_endpoints = enable_multi_endpoints66 self.enable_multi_endpoints = enable_multi_endpoints
72- self.enable_snapshot = enable_snapshot
73- self.snapshot_metadata_path = snapshot_metadata_path
74 self.single_container_flag = single_container_flag67 self.single_container_flag = single_container_flag
75 self.device_offset = device_offset68 self.device_offset = device_offset
76 self.kv_port = kv_port69 self.kv_port = kv_port
77 self.lookup_rpc_port = lookup_rpc_port70 self.lookup_rpc_port = lookup_rpc_port
78 self.dp_rpc_port = dp_rpc_port71 self.dp_rpc_port = dp_rpc_port
72+ self.backend = get_backend(self.engine_type)
73+ self.supervisor = ProcessSupervisor()
74+ self._pull_lock = threading.Lock()
79 75 
80 self.restart_on_failure = Env.motor_restart_engine76 self.restart_on_failure = Env.motor_restart_engine
81- 77+ self._recovery_requested = False
82- self.engine_pids: list[int] = []
83- self._pids_lock = threading.Lock()
84- 
85- @staticmethod
86- def _check_params(params: Endpoint) -> bool:
87- try:
88- port = int(params.business_port)
89- if not (MIN_PORT <= port <= MAX_PORT):
90- logger.error("Port %s is out of valid range", port)
91- return False
92- except ValueError:
93- logger.error("Invalid port value: %s", params.business_port)
94- return False
95- try:
96- ipaddress.ip_address(params.ip)
97- except ValueError:
98- logger.error("Invalid IP address: %s", params.ip)
99- return False
100- except Exception as e:
101- logger.error("Error validating IP address %s: %s", params.ip, e)
102- return False
103- return True
104 78 
105 def pull(79 def pull(
106 self,80 self,
@@ -111,133 +85,103 @@ class EngineService:
111 d2d_peer_ips: list[str] | None = None,85 d2d_peer_ips: list[str] | None = None,
112 node_rank: int = 0,86 node_rank: int = 0,
113 ):87 ):
114- """Launch engine_server subprocesses for every endpoint on this node."""88+ """Launch native engine subprocesses for every endpoint on this node."""
89+ with self._pull_lock:
90+ self._pull(pd_role_info, endpoints_info, instance_id, master_dp_ip, d2d_peer_ips, node_rank)
91+ 
92+ def _pull(
93+ self,
94+ pd_role_info: PDRole,
95+ endpoints_info: list[Endpoint],
96+ instance_id: int,
97+ master_dp_ip: str,
98+ d2d_peer_ips: list[str] | None,
99+ node_rank: int,
100+ ) -> None:
101+ started_endpoint_ids: list[int] = []
115 try:102 try:
116- env = os.environ.copy()103+ base_env = os.environ.copy()
117- pod_ip = env.get("POD_IP")104+ pod_ip = base_env.get("POD_IP")
118- if pod_ip and not env.get("VLLM_HOST_IP"):105+ if pod_ip and not base_env.get("VLLM_HOST_IP"):
119- env["VLLM_HOST_IP"] = pod_ip106+ base_env["VLLM_HOST_IP"] = pod_ip
120- if env.get("MOONCAKE_ASCEND_IPV6_EXPERIMENT") == "1":107+ if base_env.get("MOONCAKE_ASCEND_IPV6_EXPERIMENT") == "1":
121- env["MC_USE_IPV6"] = env.get("MC_USE_IPV6", "1")108+ base_env["MC_USE_IPV6"] = base_env.get("MC_USE_IPV6", "1")
122 device_size = self.device_num109 device_size = self.device_num
123 for i, endpoint in enumerate(endpoints_info):110 for i, endpoint in enumerate(endpoints_info):
124- if not self._check_params(endpoint):111+ env = base_env.copy()
125- raise ValueError("Invalid endpoint parameters")
126- 
127 if self.enable_multi_endpoints:112 if self.enable_multi_endpoints:
128 device_ids_str = self._calc_visible_device_ids(i, device_size)113 device_ids_str = self._calc_visible_device_ids(i, device_size)
129 logger.info("Device IDs: %s", device_ids_str)114 logger.info("Device IDs: %s", device_ids_str)
130 env["ASCEND_RT_VISIBLE_DEVICES"] = device_ids_str115 env["ASCEND_RT_VISIBLE_DEVICES"] = device_ids_str
131 116 
132- cmd = [117+ peer_ips = self._get_d2d_peer_ips(endpoint.id, d2d_peer_ips)
133- "engine_server",
134- "--dp-rank",
135- str(endpoint.id),
136- "--instance-id",
137- str(instance_id),
138- "--role",
139- str(pd_role_info.value),
140- "--host",
141- str(endpoint.ip),
142- "--port",
143- str(int(endpoint.business_port)),
144- "--mgmt-port",
145- str(int(endpoint.mgmt_port)),
146- "--master-dp-ip",
147- master_dp_ip,
148- "--node-rank",
149- str(node_rank),
150- "--config-path",
151- str(Env.user_config_path),
152- ]
153- if self.enable_snapshot:
154- cmd.extend(["--snapshot-metadata", self.snapshot_metadata_path])
155- if self.single_container_flag:
156- if self.kv_port is not None:
157- cmd.extend(["--kv-port", str(self.kv_port)])
158- if self.dp_rpc_port is not None:
159- cmd.extend(["--dp-rpc-port", str(self.dp_rpc_port)])
160- if self.lookup_rpc_port is not None:
161- cmd.extend(["--lookup-rpc-port", str(self.lookup_rpc_port)])
162 if d2d_peer_ips:118 if d2d_peer_ips:
163- ep_id = str(endpoint.id)119+ logger.info("D2D peer IPs for ep_id %s: %s", endpoint.id, list(peer_ips))
164- peer_ips = []120+ 
165- for entry in d2d_peer_ips:121+ context = LaunchContext(
166- encoded_ep_id, ip = entry.split(":", 1)122+ role=pd_role_info,
167- if encoded_ep_id == ep_id:123+ instance_id=instance_id,
168- peer_ips.append(ip)124+ dp_rank=endpoint.id,
169- if peer_ips:125+ node_rank=node_rank,
170- cmd.extend(["--d2d-peer-ips", ",".join(peer_ips)])126+ host=endpoint.ip,
171- logger.info("D2D peer IPs for ep_id %s: %s", endpoint.id, peer_ips)127+ business_port=int(endpoint.business_port),
128+ mgmt_port=int(endpoint.mgmt_port),
129+ config_path=self.config_path,
130+ master_dp_ip=master_dp_ip,
131+ kv_port=self.kv_port if self.single_container_flag else None,
132+ lookup_rpc_port=self.lookup_rpc_port if self.single_container_flag else None,
133+ dp_rpc_port=self.dp_rpc_port if self.single_container_flag else None,
134+ d2d_peer_ips=peer_ips,
135+ environment=env,
136+ headless=endpoint.headless,
137+ )
138+ launch_spec = self.backend.prepare(context)
139+ cmd = list(launch_spec.command.argv)
172 logger.info(" ".join(cmd))140 logger.info(" ".join(cmd))
173- process = subprocess.Popen(cmd, shell=False, env=env) # pylint: disable=consider-using-with141+ if self.supervisor.start(endpoint.id, launch_spec.command, launch_spec.probe):
174- if process.poll() is not None:142+ started_endpoint_ids.append(endpoint.id)
175- raise RuntimeError("Engine process exited immediately with code %s" % process.returncode)143+ 
176- with self._pids_lock:144+ self._recovery_requested = False
177- self.engine_pids.append(process.pid)
178 145 
179 except Exception as e:146 except Exception as e:
180- self.stop()147+ for endpoint_id in reversed(started_endpoint_ids):
148+ self.supervisor.stop(endpoint_id)
181 raise RuntimeError("Failed to pull engine: %s" % e) from e149 raise RuntimeError("Failed to pull engine: %s" % e) from e
182 150 
183 def stop(self) -> list[int]:151 def stop(self) -> list[int]:
184- """Kill all engine processes and return the list of PIDs that were killed."""152+ """Gracefully stop all native process groups, then force-kill on timeout."""
185- with self._pids_lock:153+ return self.supervisor.stop_all()
186- pids = list(self.engine_pids)
187- self.engine_pids.clear()
188- for pid in pids:
189- try:
190- os.kill(pid, signal.SIGKILL)
191- logger.info("Killed engine process with PID: %s", pid)
192- except ProcessLookupError:
193- logger.info("Process %s already terminated", pid)
194- except PermissionError:
195- logger.error("No permission to kill process %s", pid)
196- except Exception as e:
197- logger.error("Failed to kill process %s: %s", pid, e)
198- return pids
199 154 
200 def pid_list(self) -> list[int]:155 def pid_list(self) -> list[int]:
201- with self._pids_lock:156+ return self.supervisor.pid_list()
202- return list(self.engine_pids)
203 157 
204- def remove_pid(self, pid: int) -> None:158+ def runtime_state(self, endpoint: Endpoint) -> RuntimeState:
205- with self._pids_lock:159+ return self.supervisor.state(endpoint.id, endpoint.ip, int(endpoint.business_port))
206- if pid in self.engine_pids:160+ 
207- self.engine_pids.remove(pid)161+ def metrics_target(self, endpoint: Endpoint) -> str | None:
162+ """Return the native metrics URL for a routable local endpoint."""
163+ if endpoint.headless:
164+ return None
165+ probe = self.supervisor.probe_spec(endpoint.id)
166+ if probe is None:
167+ return None
168+ scheme = "https" if probe.tls_config and probe.tls_config.enable_tls else "http"
169+ return f"{scheme}://{format_address(endpoint.ip, endpoint.business_port)}/metrics"
208 170 
209 def health_check(self) -> None:171 def health_check(self) -> None:
210- """Check engine PIDs; trigger pod restart on failure (DaemonService protocol).172+ """Trigger Pod-level recovery when any native engine process exits."""
211- 173+ dead_pids = self.supervisor.dead_pids()
212- Two-layer suicide strategy:174+ if not dead_pids:
213- 1. Fast path (here): individual engine subprocess death → immediate SIGTERM175+ return
214- to own process, controlled by ``Env.motor_restart_engine``. This gives176+ logger.warning(
215- ~5 s detection latency via the Daemon process monitor loop.177+ "Engine PIDs %s died (restart_on_failure=%s)",
216- 178+ dead_pids,
217- 2. Slow path (HeartbeatManager): after 5 consecutive abnormal heartbeats,179+ self.restart_on_failure,
218- HeartbeatManager sets its ``should_suicide`` flag; the main loop calls180+ )
219- ``suicide_procedure()`` which returns exit code -1 (pod rescheduling).181+ if self.restart_on_failure and not self._recovery_requested:
G
Gganglv19 天前

严重程度: 建议

问题: _recovery_requested 一次性置位后不复位:首次引擎死亡触发自杀后,后续引擎死亡不再触发 Pod 级恢复。

原因: health_check()if self.restart_on_failure and not self._recovery_requested: 置位后调用 os.kill(os.getpid(), SIGTERM),之后该 flag 永不复位。若首次 SIGTERM 被进程内信号处理器捕获/延迟处理(例如停止流程进行中、或 os.kill 抛异常被上层捕获),后续再有引擎进程死亡时 _recovery_requested 仍为 True,Pod 级恢复被永久抑制。基线每次发现引擎 PID 死亡都触发 os.kill(pid, SIGTERM),无一次性限制(幂等,无副作用)。

怎么改: 在 pull()/_pull() 成功入口将 self._recovery_requested = False 复位(新一轮拉起意味着新一轮故障恢复语义);或删除该 flag 恢复基线语义,SIGTERM 自杀是幂等的,重复触发无副作用。

likedislike
tobking
tobking
19 天前 评论:
220- This catches controller-level node unhealthiness that may not involve182+ self._recovery_requested = True
221- an individual engine PID dying.183+ logger.info("Engine failure requires Pod-level recovery")
222- 184+ os.kill(os.getpid(), signal.SIGTERM)
223- These two paths are independent: the fast path handles local engine crashes
224- immediately; the slow path handles orchestration-level failure detection.
225- """
226- for pid in self.pid_list():
227- try:
228- os.kill(pid, 0)
229- except ProcessLookupError:
230- logger.warning(
231- "Engine PID %s died (restart_on_failure=%s)",
232- pid,
233- self.restart_on_failure,
234- )
235- self.remove_pid(pid)
236- if self.restart_on_failure:
237- logger.info("Engine restart requested — triggering suicide for k8s pod restart")
238- os.kill(os.getpid(), signal.SIGTERM)
239- except PermissionError:
240- pass
241 185 
242 def _calc_visible_device_ids(self, index: int, device_size: int) -> str:186 def _calc_visible_device_ids(self, index: int, device_size: int) -> str:
243 local_world_size = self.parallel_config.local_world_size187 local_world_size = self.parallel_config.local_world_size
@@ -250,3 +194,15 @@ class EngineService:
250 if self.single_container_flag:194 if self.single_container_flag:
251 device_ids = [x + self.device_offset for x in device_ids]195 device_ids = [x + self.device_offset for x in device_ids]
252 return ",".join(map(str, device_ids))196 return ",".join(map(str, device_ids))
197+ 
198+ @staticmethod
199+ def _get_d2d_peer_ips(endpoint_id: int, d2d_peer_ips: list[str] | None) -> tuple[str, ...]:
200+ if not d2d_peer_ips:
201+ return ()
202+ endpoint_id_text = str(endpoint_id)
203+ peers = []
204+ for entry in d2d_peer_ips:
205+ encoded_endpoint_id, ip = entry.split(":", 1)
206+ if encoded_endpoint_id == endpoint_id_text:
207+ peers.append(ip)
208+ return tuple(peers)
@@ -0,0 +1,261 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+import os
12+import signal
13+import subprocess
14+import threading
15+import time
16+ 
17+import requests
18+ 
19+from motor.common.http.http_client import SafeHTTPSClient
20+from motor.common.logger import get_logger
21+from motor.common.utils.net import format_address
22+from motor.node_manager.core.services.native_engine.models import CommandSpec, ProbeSpec, RuntimeProcess, RuntimeState
23+ 
24+logger = get_logger(__name__)
25+ 
26+ 
27+class ProcessSupervisor:
28+ """Own native engine process groups and their node-local runtime state."""
29+ 
30+ def __init__(self, stop_grace_seconds: float = 10.0) -> None:
31+ self._processes: dict[int, RuntimeProcess] = {}
32+ self._lock = threading.Lock()
33+ self._stop_grace_seconds = stop_grace_seconds
34+ 
35+ def start(self, endpoint_id: int, command: CommandSpec, probe: ProbeSpec) -> bool:
36+ """Start one endpoint atomically; return False when it is already running."""
37+ with self._lock:
38+ existing = self._processes.get(endpoint_id)
39+ if existing is not None and existing.process.poll() is None:
40+ if existing.command == command and existing.probe == probe:
41+ return False
42+ raise RuntimeError(f"Engine endpoint {endpoint_id} is already running with a different launch spec")
43+ self._processes.pop(endpoint_id, None)
44+ 
45+ process = subprocess.Popen( # pylint: disable=consider-using-with
46+ list(command.argv),
47+ shell=False,
48+ env=dict(command.env),
49+ cwd=command.cwd,
50+ start_new_session=True,
51+ )
52+ if process.poll() is not None:
53+ raise RuntimeError(f"Engine process exited immediately with code {process.returncode}")
54+ 
55+ self._processes[endpoint_id] = RuntimeProcess(
56+ endpoint_id=endpoint_id,
57+ process=process,
58+ command=command,
59+ probe=probe,
60+ started_at=time.monotonic(),
61+ process_group_id=process.pid if os.name == "posix" else None,
62+ )
63+ return True
64+ 
65+ def pid_list(self) -> list[int]:
66+ with self._lock:
67+ return [runtime.process.pid for runtime in self._processes.values()]
68+ 
69+ def probe_spec(self, endpoint_id: int) -> ProbeSpec | None:
70+ with self._lock:
71+ runtime = self._processes.get(endpoint_id)
72+ return runtime.probe if runtime is not None else None
73+ 
74+ def state(self, endpoint_id: int, host: str, port: int) -> RuntimeState:
G
Gganglv19 天前

严重程度: 建议

问题: RuntimeProcess.state/ready_at_lock 之外被多线程写,无同步。

原因: state()(心跳线程每 1s 调用)在锁内取 runtime 后、锁外写 runtime.state/ready_atdead_pids()(daemon monitor 线程每 5s 调用)同样锁外写 state。两个线程并发修改同一 RuntimeProcess 的 state,瞬时可能读到中间态(如“刚被 dead_pids 置 STOPPED、又被 state 探测成功置 READY”),状态跳变日志错乱;当前最终一致不会产生功能性错误,但属裸数据竞争,后续在此之上加逻辑容易踩坑。

怎么改: 把 state/ready_at 的读写收敛到锁内,或给 RuntimeProcess 加专用小锁:

class RuntimeProcess:
    def set_state(self, state: RuntimeState) -> None:
        with self._lock:
            self.state = state

state() 的 HTTP 探测可在锁外进行,但回写统一走 set_state。

likedislike
tobking
tobking
19 天前 评论:
75+ with self._lock:
76+ runtime = self._processes.get(endpoint_id)
77+ if runtime is None:
78+ return RuntimeState.STOPPED
79+ if runtime.state == RuntimeState.STOPPING:
80+ return RuntimeState.STOPPING
81+ 
82+ process = runtime.process
83+ if process.poll() is not None:
84+ return self._commit_state(endpoint_id, runtime, RuntimeState.STOPPED)
85+ if runtime.probe.process_only:
G
Gganglv19 天前

严重程度: 严重

问题: headless 端点(跨节点 PCP slave)失去“引擎加载完成”就绪门控,进程一 spawn 即上报 READY→NORMAL。

原因: state()if runtime.probe.process_only: runtime.state = RuntimeState.READY 只判断进程存活;adapters.py:53process_onlyendpoint.headless 绑定(controller 在 instance_assembler.py 对跨节点 PCP slave 置 headless=True),心跳侧 READY→NORMAL。基线上每个端点(含 slave)走 engine_server + mgmt 端口真实状态查询,controller 的实例就绪判定会等待引擎真正加载完成;PR 后 slave 的 NORMAL 只代表“进程存活”,PD 流量可能被路由到模型仍在加载(分钟级)的 slave,出现连接拒绝/超时。headless 无 API 端口可探测是设计所迫,但“存活”≠“可服务”。

怎么改: 与作者确认 vllm --headless 模式下的就绪信号来源。若需门控,可对 headless 端点采用聚合语义:依赖同实例 master 端点 READY 才放行,或增加对 KV connector 端口的二次探测;至少应在心跳侧对 headless 端点保持“依赖 master 就绪”的聚合判断,避免 spawn 即 NORMAL。

likedislike
tobking
tobking
19 天前 评论:
86+ return self._commit_state(endpoint_id, runtime, RuntimeState.RUNNING)
87+ try:
88+ address = format_address(host, port)
89+ with SafeHTTPSClient(
90+ address=address,
91+ tls_config=runtime.probe.tls_config,
92+ timeout=runtime.probe.timeout_seconds,
93+ ) as client:
94+ for attempt in range(1, runtime.probe.max_attempts + 1):
95+ try:
96+ client.do_get(runtime.probe.path)
97+ break
98+ except Exception as err:
99+ if not self._is_timeout_error(err) or attempt == runtime.probe.max_attempts:
100+ raise
101+ logger.debug(
102+ "Native health probe timed out for endpoint %s (attempt %s/%s), retrying",
103+ endpoint_id,
104+ attempt,
105+ runtime.probe.max_attempts,
106+ )
107+ return self._commit_state(endpoint_id, runtime, RuntimeState.READY, ready=True)
108+ except Exception as err:
109+ elapsed = time.monotonic() - runtime.started_at
110+ if runtime.state == RuntimeState.STARTING and elapsed < runtime.probe.startup_timeout_seconds:
111+ logger.debug(
112+ "Engine endpoint %s is still starting after %.1fs: %s",
113+ endpoint_id,
114+ elapsed,
115+ err,
116+ )
117+ return self._current_state(endpoint_id, runtime)
118+ logger.warning("Native health probe failed for endpoint %s: %s", endpoint_id, err)
119+ return self._commit_state(endpoint_id, runtime, RuntimeState.UNHEALTHY)
120+ 
121+ def dead_pids(self) -> list[int]:
G
Gganglv19 天前

严重程度: 建议

问题: 死进程记录永不从 _processes 移除:dead_pids()/pid_list() 对已死进程持续返回结果。

原因: dead_pids() 只把 state 置 STOPPED,不移除字典条目;配合 engine.py:169-182(health_check 每 5s 由 daemon monitor 线程调用),默认 MOTOR_RESTART_ENGINE=0 时引擎死后每次 monitor tick 都重复打 “Engine PIDs %s died” warning,直到 pod 被异常心跳自杀重启;pid_list() 上报给 controller 的是僵尸 PID,metrics_target() 指向已死端口。基线在 health_check 检测到死亡后调用了 remove_pid,重复检测被抑制,PR 属行为回归。

怎么改: 在 dead_pids() 中把已确认死亡的记录移出字典:

def dead_pids(self) -> list[int]:
    with self._lock:
        dead = [r for r in self._processes.values() if r.process.poll() is not None]
        for r in dead:
            r.state = RuntimeState.STOPPED
            self._processes.pop(r.endpoint_id, None)
    return [r.process.pid for r in dead]

注意保持 runtime_state() 对无记录端点返回 STOPPED 的语义不变。

likedislike
tobking
tobking
19 天前 评论:
122+ with self._lock:
123+ dead = []
124+ dead_runtimes = []
125+ for endpoint_id, runtime in list(self._processes.items()):
126+ if runtime.state == RuntimeState.STOPPING or runtime.process.poll() is None:
127+ continue
128+ runtime.state = RuntimeState.STOPPED
129+ dead.append(runtime.process.pid)
130+ dead_runtimes.append(runtime)
131+ self._processes.pop(endpoint_id, None)
132+ 
133+ # The launcher may exit before its workers. Clean the cached process group
134+ # after removing the record so the same death is reported only once.
135+ for runtime in dead_runtimes:
136+ self._kill_group(runtime)
137+ return dead
138+ 
139+ def stop_all(self) -> list[int]:
140+ with self._lock:
141+ runtimes = list(self._processes.values())
142+ for runtime in runtimes:
143+ runtime.state = RuntimeState.STOPPING
144+ 
145+ stopped = self._stop_runtimes(runtimes)
146+ self._remove_stopped(runtimes)
147+ return stopped
148+ 
149+ def stop(self, endpoint_id: int) -> int | None:
150+ """Stop one endpoint without affecting unrelated engine processes."""
151+ with self._lock:
152+ runtime = self._processes.get(endpoint_id)
153+ if runtime is not None:
154+ runtime.state = RuntimeState.STOPPING
155+ if runtime is None:
156+ return None
157+ stopped = self._stop_runtimes([runtime])[0]
158+ self._remove_stopped([runtime])
159+ return stopped
160+ 
161+ def _stop_runtimes(self, runtimes: list[RuntimeProcess]) -> list[int]:
G
Gganglv19 天前

严重程度: 建议

问题: 进程组清理依赖 leader 存活:leader 先退而 worker 存活时,既收不到 SIGTERM 也收不到 SIGKILL,引擎 worker 泄漏。

原因: _stop_runtimes 只对 leader 做 process.wait(timeout),超时才走 _kill_group;而 _terminate_group/_kill_group 均通过 os.killpg(os.getpgid(process.pid)) 取进程组,leader 已死时 os.getpgid 抛 ProcessLookupError 被静默 pass。两个场景:(1) leader 崩溃但 worker 存活时,wait 立即返回(无 TimeoutExpired),不会 SIGKILL;(2) 正常停止中 leader 10s 内退出但某 worker 卡住,SIGKILL 升级永远不触发。vllm serve(multiproc executor)与 sglang.launch_server 都会派生 worker 子进程,残留 worker 继续占用 NPU 与端口,下一次 pull 会因端口占用/设备忙失败。docstring「force-kill on timeout」与实际行为不符。

怎么改: start() 时缓存 pgid(Popen 成功后立即 os.getpgid(process.pid) 存入 RuntimeProcess),停止时用缓存 pgid 发信号;wait() 正常返回后追加组存活检查并升级:

# _stop_runtimes 中
try:
    runtime.process.wait(timeout=remaining)
except subprocess.TimeoutExpired:
    self._kill_group_by_pgid(runtime.pgid)
else:
    try:
        os.killpg(runtime.pgid, 0)  # 组内仍有成员
    except ProcessLookupError:
        pass
    else:
        self._kill_group_by_pgid(runtime.pgid)
likedislike
162+ for runtime in runtimes:
163+ runtime.state = RuntimeState.STOPPING
164+ self._terminate_group(runtime)
165+ 
166+ deadline = time.monotonic() + self._stop_grace_seconds
167+ for runtime in runtimes:
168+ remaining = max(0.0, deadline - time.monotonic())
169+ try:
170+ runtime.process.wait(timeout=remaining)
171+ except subprocess.TimeoutExpired:
172+ self._kill_group(runtime)
173+ else:
174+ # The launcher can exit while child workers still hold the same PGID.
175+ # Reap the remaining group instead of treating a leader exit as cleanup.
176+ if self._group_exists(runtime):
177+ self._kill_group(runtime)
178+ return [runtime.process.pid for runtime in runtimes]
179+ 
180+ def _current_state(self, endpoint_id: int, runtime: RuntimeProcess) -> RuntimeState:
181+ with self._lock:
182+ current = self._processes.get(endpoint_id)
183+ return runtime.state if current is runtime else RuntimeState.STOPPED
184+ 
185+ def _commit_state(
186+ self,
187+ endpoint_id: int,
188+ runtime: RuntimeProcess,
189+ state: RuntimeState,
190+ *,
191+ ready: bool = False,
192+ ) -> RuntimeState:
193+ with self._lock:
194+ current = self._processes.get(endpoint_id)
195+ if current is not runtime:
196+ return RuntimeState.STOPPED
197+ if runtime.state == RuntimeState.STOPPING:
198+ return RuntimeState.STOPPING
199+ runtime.state = state
200+ if ready:
201+ runtime.ready_at = runtime.ready_at or time.monotonic()
202+ return runtime.state
203+ 
204+ def _remove_stopped(self, runtimes: list[RuntimeProcess]) -> None:
205+ with self._lock:
206+ for runtime in runtimes:
207+ if self._processes.get(runtime.endpoint_id) is runtime:
208+ runtime.state = RuntimeState.STOPPED
209+ self._processes.pop(runtime.endpoint_id, None)
210+ 
211+ @staticmethod
212+ def _is_timeout_error(error: Exception) -> bool:
213+ """Return whether SafeHTTPSClient surfaced a requests timeout."""
214+ current: BaseException | None = error
215+ while current is not None:
216+ if isinstance(current, requests.exceptions.Timeout):
217+ return True
218+ current = current.__cause__
219+ return False
220+ 
221+ @staticmethod
222+ def _terminate_group(runtime: RuntimeProcess) -> None:
223+ process = runtime.process
224+ try:
225+ if os.name == "posix" and runtime.process_group_id is not None:
226+ getattr(os, "killpg")(runtime.process_group_id, signal.SIGTERM)
227+ else:
228+ process.terminate()
229+ except ProcessLookupError:
230+ pass
231+ except (OSError, PermissionError) as err:
232+ logger.warning("Failed to terminate engine process group %s: %s", process.pid, err)
233+ 
234+ @staticmethod
235+ def _kill_group(runtime: RuntimeProcess) -> None:
236+ process = runtime.process
237+ try:
238+ if os.name == "posix" and runtime.process_group_id is not None:
239+ getattr(os, "killpg")(runtime.process_group_id, getattr(signal, "SIGKILL"))
240+ else:
241+ process.kill()
242+ except ProcessLookupError:
243+ pass
244+ except (OSError, PermissionError) as err:
245+ logger.error("Failed to kill engine process group %s: %s", process.pid, err)
246+ 
247+ @staticmethod
248+ def _group_exists(runtime: RuntimeProcess) -> bool:
249+ """Return whether a cached POSIX process group still has a member."""
250+ if os.name != "posix" or runtime.process_group_id is None:
251+ return False
252+ try:
253+ getattr(os, "killpg")(runtime.process_group_id, 0)
254+ except ProcessLookupError:
255+ return False
256+ except PermissionError:
257+ return True
258+ except OSError as err:
259+ logger.warning("Failed to inspect engine process group %s: %s", runtime.process_group_id, err)
260+ return True
261+ return True
@@ -41,7 +41,7 @@ class PreparableService(DaemonService, Protocol):
41 """A service that needs pre-flight preparation before the engine starts."""41 """A service that needs pre-flight preparation before the engine starts."""
42 42 
43 def prepare(self, **kwargs) -> None:43 def prepare(self, **kwargs) -> None:
44- """Run before ``EngineService.pull()``.44+ """Run before ``NativeEngineService.pull()``.
45 45 
46 *kwargs* include ``endpoints_count`` (int) so the service can46 *kwargs* include ``endpoints_count`` (int) so the service can
47 divide per-node DRAM across DP ranks.47 divide per-node DRAM across DP ranks.
@@ -15,7 +15,7 @@ Usage::
15 from motor.node_manager.core.services.registry import register_service15 from motor.node_manager.core.services.registry import register_service
16 16 
17 @register_service("engine", backend="engine")17 @register_service("engine", backend="engine")
18- class EngineService:18+ class NativeEngineService:
19 ...19 ...
20 20 
21 @register_service("kv_store", backend="memcache", prepare_priority=10)21 @register_service("kv_store", backend="memcache", prepare_priority=10)
@@ -49,7 +49,7 @@ SERVICE_KV_STORE: str = "kv_store"
49# that backend appears in the ``Env.kv_store_backend`` list (comma-separated).49# that backend appears in the ``Env.kv_store_backend`` list (comma-separated).
50# ``None`` key = always-active (imported unconditionally).50# ``None`` key = always-active (imported unconditionally).
51_DEFAULT_MODULE_MAP: dict[str | None, list[str]] = {51_DEFAULT_MODULE_MAP: dict[str | None, list[str]] = {
52- "engine": ["motor.node_manager.core.services.engine"],52+ "engine": ["motor.node_manager.core.services.native_engine.service"],
53 "memcache": ["motor.node_manager.core.services.memcache.lifecycle"],53 "memcache": ["motor.node_manager.core.services.memcache.lifecycle"],
54}54}
55 55 
@@ -8,62 +8,15 @@
8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11-"""Unit tests for dispatch helpers in motor.common.resources.dispatch."""11+"""Unit tests for dispatch-profile inference."""
12 12 
13-from dataclasses import dataclass, field
14from types import SimpleNamespace13from types import SimpleNamespace
15 14 
16from motor.common.resources.dispatch import (15from motor.common.resources.dispatch import (
17- DispatchPlan,
18 DispatchProfile,16 DispatchProfile,
19- dispatch_plan_union,
20- has_compatible_dispatch_pair,
21 infer_vllm_dispatch_profile_from_config,17 infer_vllm_dispatch_profile_from_config,
22- shared_dispatch_plans,
23)18)
24 19 
25-CONCURRENT = DispatchPlan.CONCURRENT_ENGINE_SYNC.value
26-HANDOFF = DispatchPlan.PREFILL_HANDOFF_DECODE.value
27- 
28- 
29-@dataclass
30-class _Inst:
31- """Minimal stand-in carrying only the dispatch_capabilities attribute the helpers read."""
32- 
33- id: int = 0
34- dispatch_capabilities: list = field(default_factory=list)
35- 
36- 
37-def _pairwise_compatible(prefill_instances, decode_instances):
38- """Reference O(P*D) definition the optimized helpers must stay equivalent to."""
39- decode_list = list(decode_instances)
40- return any(shared_dispatch_plans(p, d) for p in prefill_instances for d in decode_list)
41- 
42- 
43-def test_dispatch_plan_union_aggregates_and_ignores_unknown_values():
44- instances = [_Inst(dispatch_capabilities=[CONCURRENT]), _Inst(dispatch_capabilities=[HANDOFF, "bogus"])]
45- assert dispatch_plan_union(instances) == {
46- DispatchPlan.CONCURRENT_ENGINE_SYNC,
47- DispatchPlan.PREFILL_HANDOFF_DECODE,
48- }
49- assert dispatch_plan_union([]) == set()
50- assert dispatch_plan_union([_Inst(dispatch_capabilities=[])]) == set()
51- 
52- 
53-def test_has_compatible_dispatch_pair_matches_pairwise_definition():
54- cases = [
55- ([_Inst(dispatch_capabilities=[CONCURRENT])], [_Inst(dispatch_capabilities=[CONCURRENT])]),
56- ([_Inst(dispatch_capabilities=[CONCURRENT])], [_Inst(dispatch_capabilities=[HANDOFF])]),
57- (
58- [_Inst(dispatch_capabilities=[CONCURRENT]), _Inst(dispatch_capabilities=[HANDOFF])],
59- [_Inst(dispatch_capabilities=[HANDOFF])],
60- ),
61- ([_Inst(dispatch_capabilities=[])], [_Inst(dispatch_capabilities=[CONCURRENT])]),
62- ([], [_Inst(dispatch_capabilities=[CONCURRENT])]),
63- ]
64- for prefill, decode in cases:
65- assert has_compatible_dispatch_pair(prefill, decode) == _pairwise_compatible(prefill, decode)
66- 
67 20 
68class _EngineConfig:21class _EngineConfig:
69 def __init__(self, configs):22 def __init__(self, configs):
@@ -1,5 +1,3 @@
1-#!/usr/bin/env python3
2-# -*- coding: utf-8 -*-
3# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.1# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
4# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
5# You can use this software according to the terms and conditions of the Mulan PSL v2.3# You can use this software according to the terms and conditions of the Mulan PSL v2.
@@ -321,6 +319,68 @@ def test_endpoint_headless_defaults_to_false() -> None:
321 assert endpoint.headless is False319 assert endpoint.headless is False
322 320 
323 321 
322+def test_instance_readiness_requires_live_headless_workers_and_routable_master() -> None:
323+ instance = Instance(
324+ job_name="test_headless_readiness",
325+ model_name="test_model",
326+ id=1,
327+ role="prefill",
328+ parallel_config=ParallelConfig(dp_size=1, tp_size=4),
329+ enable_multi_endpoints=True,
330+ )
331+ master = Endpoint(
332+ id=0,
333+ ip="10.0.0.1",
334+ business_port="8000",
335+ mgmt_port="9000",
336+ status=EndpointStatus.NORMAL,
337+ )
338+ worker = Endpoint(
339+ id=1,
340+ ip="10.0.0.2",
341+ business_port="8000",
342+ mgmt_port="9000",
343+ status=EndpointStatus.INITIAL,
344+ headless=True,
345+ )
346+ instance.add_endpoints(master.ip, {master.id: master})
347+ instance.add_endpoints(worker.ip, {worker.id: worker})
348+ 
349+ assert instance.is_all_endpoints_ready() is False
350+ 
351+ worker.status = EndpointStatus.WAIT2START
352+ assert instance.is_all_endpoints_ready() is True
353+ 
354+ master.status = EndpointStatus.INITIAL
355+ assert instance.is_all_endpoints_ready() is False
356+ 
357+ master.status = EndpointStatus.NORMAL
358+ worker.status = EndpointStatus.ABNORMAL
359+ assert instance.is_all_endpoints_ready() is False
360+ 
361+ 
362+def test_instance_with_only_headless_workers_is_not_ready() -> None:
363+ instance = Instance(
364+ job_name="test_headless_only_readiness",
365+ model_name="test_model",
366+ id=1,
367+ role="prefill",
368+ parallel_config=ParallelConfig(dp_size=1, tp_size=4),
369+ enable_multi_endpoints=True,
370+ )
371+ worker = Endpoint(
372+ id=0,
373+ ip="10.0.0.2",
374+ business_port="8000",
375+ mgmt_port="9000",
376+ status=EndpointStatus.WAIT2START,
377+ headless=True,
378+ )
379+ instance.add_endpoints(worker.ip, {worker.id: worker})
380+ 
381+ assert instance.is_all_endpoints_ready() is False
382+ 
383+ 
324def test_is_endpoints_enough_counts_all_endpoints() -> None:384def test_is_endpoints_enough_counts_all_endpoints() -> None:
325 """is_endpoints_enough counts all endpoints regardless of headless flag.385 """is_endpoints_enough counts all endpoints regardless of headless flag.
326 This is safe because headless is only set when nnodes>1, and in that386 This is safe because headless is only set when nnodes>1, and in that
@@ -167,6 +167,21 @@ def create_reregister_msg(job_name: str, pod_ip: str, instance_id: int, config:
167 )167 )
168 168 
169 169 
170+def test_build_endpoints_preserves_bootstrap_port(instance_assembler, test_config):
171+ msg = create_register_msg(
172+ "native-transfer",
173+ test_config["pod_ip1"],
174+ test_config,
175+ bootstrap_port=9100,
176+ )
177+ 
178+ endpoints = instance_assembler._build_multi_endpoints(msg, 0)
179+ 
180+ assert endpoints
181+ for endpoint in endpoints.values():
182+ assert endpoint.bootstrap_port == 9100
183+ 
184+ 
170def register_instance_with_pods(assembler: InstanceAssembler, job_name: str, config: dict, pod_count: int = 2) -> bool:185def register_instance_with_pods(assembler: InstanceAssembler, job_name: str, config: dict, pod_count: int = 2) -> bool:
171 """Register pods for an instance and return whether assembly is complete"""186 """Register pods for an instance and return whether assembly is complete"""
172 pod_ips = [f"127.0.0.{i + 1}" for i in range(pod_count)]187 pod_ips = [f"127.0.0.{i + 1}" for i in range(pod_count)]
@@ -558,6 +558,22 @@ def test_state_transitions(instance_manager, test_config):
558 assert instance.status == InsStatus.INACTIVE558 assert instance.status == InsStatus.INACTIVE
559 559 
560 560 
561+def test_initial_instance_activates_with_ready_master_and_live_headless_worker():
562+ """A headless worker's process liveness is sufficient only when the routable master is ready."""
563+ manager = create_instance_manager_with_config()
564+ instance = create_test_instance(205, "test_headless_worker", ["192.168.1.1", "192.168.1.2"])
565+ worker = instance.endpoints["192.168.1.2"][0]
566+ worker.headless = True
567+ manager.add_instance(instance)
568+ instance.endpoints["192.168.1.1"][0].status = EndpointStatus.NORMAL
569+ worker.status = EndpointStatus.WAIT2START
570+ 
571+ result = manager._handle_state_transition(instance)
572+ 
573+ assert result is True
574+ assert instance.status == InsStatus.ACTIVE
575+ 
576+ 
561def test_inactive_with_mixed_paused_normal_goes_to_paused(instance_manager):577def test_inactive_with_mixed_paused_normal_goes_to_paused(instance_manager):
562 """INACTIVE + mixed PAUSED/NORMAL (no ABNORMAL) → PAUSED, NOT INITIAL"""578 """INACTIVE + mixed PAUSED/NORMAL (no ABNORMAL) → PAUSED, NOT INITIAL"""
563 manager = create_instance_manager_with_config()579 manager = create_instance_manager_with_config()
@@ -0,0 +1,46 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+from unittest.mock import MagicMock, patch
12+ 
13+from motor.config.tls_config import TLSConfig
14+from motor.coordinator.api_client.native_engine_api_client import NativeEngineApiClient
15+ 
16+ 
17+@patch("motor.coordinator.api_client.native_engine_api_client.SafeHTTPSClient")
18+def test_query_metrics_uses_inference_tls(mock_client):
19+ tls_config = TLSConfig(enable_tls=True)
20+ response = MagicMock(text="# TYPE ready gauge\nready 1", status_code=200)
21+ mock_client.return_value.__enter__.return_value.do_get.return_value = response
22+ 
23+ result = NativeEngineApiClient.query_metrics("10.0.0.8:8000", tls_config)
24+ 
25+ assert result == response.text
26+ mock_client.assert_called_once_with(
27+ address="10.0.0.8:8000",
28+ tls_config=tls_config,
29+ timeout=2,
30+ )
31+ mock_client.return_value.__enter__.return_value.do_get.assert_called_once_with("/metrics")
32+ 
33+ 
34+@patch("motor.coordinator.api_client.native_engine_api_client.SafeHTTPSClient")
35+def test_query_metrics_failure_returns_empty_text(mock_client):
36+ mock_client.side_effect = RuntimeError("connection refused")
37+ 
38+ assert NativeEngineApiClient.query_metrics("10.0.0.8:8000", None) == ""
39+ 
40+ 
41+@patch("motor.coordinator.api_client.native_engine_api_client.SafeHTTPSClient")
42+def test_query_metrics_non_success_status_returns_empty_text(mock_client):
43+ response = MagicMock(text="internal error", status_code=503)
44+ mock_client.return_value.__enter__.return_value.do_get.return_value = response
45+ 
46+ assert NativeEngineApiClient.query_metrics("10.0.0.8:8000", None) == ""
@@ -13,7 +13,6 @@ import threading
13 13 
14import pytest14import pytest
15 15 
16-from motor.common.resources.dispatch import DispatchPlan
17from motor.common.resources import Instance, PDRole, Workload, Endpoint, EventType16from motor.common.resources import Instance, PDRole, Workload, Endpoint, EventType
18from motor.config.coordinator import CoordinatorConfig17from motor.config.coordinator import CoordinatorConfig
19from motor.coordinator.domain.instance_manager import InstanceManager, UpdateInstanceMode18from motor.coordinator.domain.instance_manager import InstanceManager, UpdateInstanceMode
@@ -34,7 +33,6 @@ class TestInstanceManager:
34 model_name="test-model",33 model_name="test-model",
35 id=1,34 id=1,
36 role=PDRole.ROLE_P,35 role=PDRole.ROLE_P,
37- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
38 endpoints={},36 endpoints={},
39 )37 )
40 38 
@@ -43,7 +41,6 @@ class TestInstanceManager:
43 model_name="test-model",41 model_name="test-model",
44 id=2,42 id=2,
45 role=PDRole.ROLE_D,43 role=PDRole.ROLE_D,
46- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
47 endpoints={},44 endpoints={},
48 )45 )
49 46 
@@ -74,13 +71,6 @@ class TestInstanceManager:
74 self.instance_manager._add_instance_to_available_pool(self.decode_instance)71 self.instance_manager._add_instance_to_available_pool(self.decode_instance)
75 assert self.instance_manager.has_required_instances() is True72 assert self.instance_manager.has_required_instances() is True
76 73 
77- def test_has_required_instances_rejects_incompatible_pd_pair(self):
78- self.decode_instance.dispatch_capabilities = [DispatchPlan.PREFILL_HANDOFF_DECODE.value]
79- self.instance_manager._add_instance_to_available_pool(self.prefill_instance)
80- self.instance_manager._add_instance_to_available_pool(self.decode_instance)
81- 
82- assert self.instance_manager.has_required_instances() is False
83- 
84 def test_has_required_instances_hybrid(self):74 def test_has_required_instances_hybrid(self):
85 result = self.instance_manager._add_instance_to_available_pool(self.hybrid_instance)75 result = self.instance_manager._add_instance_to_available_pool(self.hybrid_instance)
86 assert result is True76 assert result is True
@@ -962,7 +952,6 @@ class TestInstanceManagerThreadSafety:
962 model_name="test-model",952 model_name="test-model",
963 id=1,953 id=1,
964 role=PDRole.ROLE_P,954 role=PDRole.ROLE_P,
965- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
966 endpoints={},955 endpoints={},
967 )956 )
968 957 
@@ -971,7 +960,6 @@ class TestInstanceManagerThreadSafety:
971 model_name="test-model",960 model_name="test-model",
972 id=2,961 id=2,
973 role=PDRole.ROLE_D,962 role=PDRole.ROLE_D,
974- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
975 endpoints={},963 endpoints={},
976 )964 )
977 965 
@@ -29,6 +29,7 @@ from motor.coordinator.metrics.metrics_collector import (
29 Metric,29 Metric,
30 _filter_kvstore_metrics,30 _filter_kvstore_metrics,
31)31)
32+from motor.coordinator.api_client.native_engine_api_client import NativeEngineApiClient
32from motor.config.coordinator import CoordinatorConfig33from motor.config.coordinator import CoordinatorConfig
33from motor.common.utils.singleton import ThreadSafeSingleton34from motor.common.utils.singleton import ThreadSafeSingleton
34from motor.coordinator.metrics.metric_computer import (35from motor.coordinator.metrics.metric_computer import (
@@ -873,6 +874,29 @@ http_request_duration_seconds_created{handler="/v1/chat/completions",method="POS
873 for port in [8004, 8005]:874 for port in [8004, 8005]:
874 assert requests.get(f"http://localhost:{port}/metrics").status_code == 404875 assert requests.get(f"http://localhost:{port}/metrics").status_code == 404
875 876 
877+ @_test_without_background_thread
878+ @patch.object(NativeEngineApiClient, "query_metrics", return_value="# TYPE ready gauge\nready 1")
879+ def test_fetch_endpoint_metrics_uses_native_business_port_and_infer_tls(self, query_metrics):
880+ endpoint = Endpoint(
881+ id=7,
882+ ip="2001:db8::7",
883+ business_port="8007",
884+ mgmt_port="9007",
885+ )
886+ instance = Instance(
887+ job_name="native-metrics",
888+ model_name="test-model",
889+ id=7,
890+ role=PDRole.ROLE_U,
891+ endpoints={"2001:db8::7": {7: endpoint}},
892+ )
893+ collector = MetricsCollector(self.config)
894+ 
895+ result = collector._fetch_endpoint_metrics(instance)
896+ 
897+ query_metrics.assert_called_once_with("[2001:db8::7]:8007", self.config.infer_tls_config)
898+ assert result["endpoints"][7]["metrics_str"] == "# TYPE ready gauge\nready 1"
899+ 
876 def test_prometheus_metrics_handler(self, mock_metrics_collector): # pylint: disable=redefined-outer-name900 def test_prometheus_metrics_handler(self, mock_metrics_collector): # pylint: disable=redefined-outer-name
877 mock_metrics_collector._last_metrics = None901 mock_metrics_collector._last_metrics = None
878 mock_metrics_collector.get_metrics.return_value = ""902 mock_metrics_collector.get_metrics.return_value = ""
@@ -16,7 +16,6 @@ Unit tests for semantic metrics modules:
16import math16import math
17from unittest.mock import patch, MagicMock17from unittest.mock import patch, MagicMock
18 18 
19-from motor.common.resources.dispatch import DispatchPlan
20from motor.coordinator.metrics.metric_types import (19from motor.coordinator.metrics.metric_types import (
21 AggregationContext,20 AggregationContext,
22 AggregationScope,21 AggregationScope,
@@ -582,8 +581,7 @@ def _apply_scope_filter(
582) -> list[tuple[int, Metric]]:581) -> list[tuple[int, Metric]]:
583 """Mirror MetricsCollector._aggregate_metrics SERVICE-scope filtering."""582 """Mirror MetricsCollector._aggregate_metrics SERVICE-scope filtering."""
584 if ctx is not None and ctx.scope == AggregationScope.SERVICE and ctx.instance_roles is not None:583 if ctx is not None and ctx.scope == AggregationScope.SERVICE and ctx.instance_roles is not None:
585- if name == "vllm:time_to_first_token_seconds" and ctx.instance_dispatch_capabilities is not None:584+ if name == "vllm:time_to_first_token_seconds" and ctx.instance_engine_types is not None:
586- handoff = DispatchPlan.PREFILL_HANDOFF_DECODE.value
587 entries = [585 entries = [
588 (ins_id, metric)586 (ins_id, metric)
589 for ins_id, metric in entries587 for ins_id, metric in entries
@@ -592,7 +590,7 @@ def _apply_scope_filter(
592 or ctx.instance_roles.get(ins_id) in {"decode", "union", "both", "hybrid"}590 or ctx.instance_roles.get(ins_id) in {"decode", "union", "both", "hybrid"}
593 or (591 or (
594 ctx.instance_roles.get(ins_id) == "prefill"592 ctx.instance_roles.get(ins_id) == "prefill"
595- and handoff in ctx.instance_dispatch_capabilities.get(ins_id, set())593+ and ctx.instance_engine_types.get(ins_id, "").strip().lower() == "vllm"
596 )594 )
597 )595 )
598 ]596 ]
@@ -619,30 +617,26 @@ class TestAggregationScope:
619 ctx = AggregationContext(617 ctx = AggregationContext(
620 scope=AggregationScope.SERVICE,618 scope=AggregationScope.SERVICE,
621 instance_roles={1: "prefill", 2: "decode"},619 instance_roles={1: "prefill", 2: "decode"},
622- instance_dispatch_capabilities={},620+ instance_engine_types={},
623 )621 )
624 entries = _apply_scope_filter("vllm:time_to_first_token_seconds", [(1, p_hist), (2, d_hist)], ctx)622 entries = _apply_scope_filter("vllm:time_to_first_token_seconds", [(1, p_hist), (2, d_hist)], ctx)
625 assert len(entries) == 1623 assert len(entries) == 1
626 ttft = self.engine.aggregate("vllm:time_to_first_token_seconds", [m for _, m in entries])624 ttft = self.engine.aggregate("vllm:time_to_first_token_seconds", [m for _, m in entries])
627 assert ttft.value[-1] == 8.0625 assert ttft.value[-1] == 8.0
628 626 
629- def test_service_scope_includes_only_handoff_prefill_ttft(self):627+ def test_service_scope_includes_only_vllm_prefill_ttft(self):
630- p_concurrent = _make_ttft_histogram(10.0, 5.0)628+ p_sglang = _make_ttft_histogram(10.0, 5.0)
631- p_handoff = _make_ttft_histogram(20.0, 15.0)629+ p_vllm = _make_ttft_histogram(20.0, 15.0)
632 d_hist = _make_ttft_histogram(30.0, 9.0)630 d_hist = _make_ttft_histogram(30.0, 9.0)
633 ctx = AggregationContext(631 ctx = AggregationContext(
634 scope=AggregationScope.SERVICE,632 scope=AggregationScope.SERVICE,
635 instance_roles={1: "prefill", 2: "prefill", 3: "decode"},633 instance_roles={1: "prefill", 2: "prefill", 3: "decode"},
636- instance_dispatch_capabilities={634+ instance_engine_types={1: "sglang", 2: "vllm", 3: "vllm"},
637- 1: {DispatchPlan.CONCURRENT_ENGINE_SYNC.value},
638- 2: {DispatchPlan.PREFILL_HANDOFF_DECODE.value},
639- 3: {DispatchPlan.PREFILL_HANDOFF_DECODE.value},
640- },
641 )635 )
642 636 
643 entries = _apply_scope_filter(637 entries = _apply_scope_filter(
644 "vllm:time_to_first_token_seconds",638 "vllm:time_to_first_token_seconds",
645- [(1, p_concurrent), (2, p_handoff), (3, d_hist)],639+ [(1, p_sglang), (2, p_vllm), (3, d_hist)],
646 ctx,640 ctx,
647 )641 )
648 642 
@@ -695,13 +689,6 @@ class TestRoleScope:
695 scope = MetricRegistry.get_effective_role_scope("vllm:time_to_first_token_seconds")689 scope = MetricRegistry.get_effective_role_scope("vllm:time_to_first_token_seconds")
696 assert scope == "decode"690 assert scope == "decode"
697 691 
698- def test_ttft_effective_role_scope_handoff_connector(self):
699- scope = MetricRegistry.get_effective_role_scope(
700- "vllm:time_to_first_token_seconds",
701- {DispatchPlan.PREFILL_HANDOFF_DECODE.value},
702- )
703- assert scope is None # No filtering
704- 
705 def test_effective_role_scope_unknown_metric(self):692 def test_effective_role_scope_unknown_metric(self):
706 scope = MetricRegistry.get_effective_role_scope("some_unknown_metric")693 scope = MetricRegistry.get_effective_role_scope("some_unknown_metric")
707 assert scope is None694 assert scope is None
@@ -1,101 +0,0 @@
1-# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# MindIE is licensed under Mulan PSL v2.
3-# You can use this software according to the terms and conditions of the Mulan PSL v2.
4-# You may obtain a copy of Mulan PSL v2 at:
5-# http://license.coscl.org.cn/MulanPSL2
6-# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7-# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8-# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9-# See the Mulan PSL v2 for more details.
10- 
11-from unittest.mock import AsyncMock, MagicMock, patch
12- 
13-import pytest
14- 
15-from motor.common.resources.dispatch import DispatchStopReason, DispatchStopState
16-from motor.common.resources.endpoint import Endpoint, EndpointStatus
17-from motor.common.resources.instance import Instance, InsStatus, PDRole, ParallelConfig
18-from motor.coordinator.domain import ScheduledResource
19-from motor.coordinator.router.dispatch_session import AttemptContext
20-from motor.coordinator.router.stop_client import DispatchStopClient
21- 
22- 
23-def _config():
24- cfg = MagicMock()
25- cfg.infer_tls_config = None
26- return cfg
27- 
28- 
29-def _resource(engine_type: str) -> ScheduledResource:
30- endpoint = Endpoint(
31- id=1,
32- ip="127.0.0.1",
33- business_port="8000",
34- mgmt_port="1026",
35- status=EndpointStatus.NORMAL,
36- )
37- instance = Instance(
38- job_name="job-1",
39- model_name="m",
40- engine_type=engine_type,
41- id=1,
42- role=PDRole.ROLE_D,
43- status=InsStatus.ACTIVE,
44- parallel_config=ParallelConfig(dp_size=1),
45- endpoints={endpoint.ip: {endpoint.id: endpoint}},
46- )
47- return ScheduledResource(instance=instance, endpoint=endpoint)
48- 
49- 
50-@pytest.mark.asyncio
51-async def test_dispatch_stop_uses_sglang_abort_request():
52- client = DispatchStopClient(_config())
53- attempt = AttemptContext(root_request_id="r1", attempt_seq=1, pair_id="p1")
54- mock_http = AsyncMock()
55- mock_response = MagicMock()
56- mock_response.raise_for_status = MagicMock()
57- mock_http.post = AsyncMock(return_value=mock_response)
58- 
59- with patch("motor.coordinator.router.stop_client.HTTPClientPool") as pool_cls:
60- pool_cls.return_value.get_client = AsyncMock(return_value=mock_http)
61- result = await client.stop(
62- _resource("sglang"),
63- attempt,
64- DispatchStopReason.PEER_FAILED,
65- )
66- 
67- assert result is not None
68- assert result.accepted is True
69- assert result.state == DispatchStopState.STOPPED
70- mock_http.post.assert_awaited_once()
71- assert mock_http.post.await_args.args[0] == "/abort_request"
72- assert mock_http.post.await_args.kwargs["json"] == {"rid": "r1#a1"}
73- 
74- 
75-@pytest.mark.asyncio
76-async def test_dispatch_stop_posts_for_non_sglang():
77- client = DispatchStopClient(_config())
78- attempt = AttemptContext(root_request_id="r1", attempt_seq=1, pair_id="p1")
79- mock_http = AsyncMock()
80- mock_response = MagicMock()
81- mock_response.raise_for_status = MagicMock()
82- mock_response.json.return_value = {
83- "root_request_id": "r1",
84- "attempt_seq": 1,
85- "accepted": True,
86- "state": DispatchStopState.STOPPED.value,
87- "message": "",
88- }
89- mock_http.post = AsyncMock(return_value=mock_response)
90- 
91- with patch("motor.coordinator.router.stop_client.HTTPClientPool") as pool_cls:
92- pool_cls.return_value.get_client = AsyncMock(return_value=mock_http)
93- result = await client.stop(
94- _resource("vllm"),
95- attempt,
96- DispatchStopReason.PEER_FAILED,
97- )
98- 
99- assert result is not None
100- mock_http.post.assert_awaited_once()
101- assert mock_http.post.await_args.args[0] == "/v1/dispatch/stop"
@@ -9,6 +9,7 @@
9# See the Mulan PSL v2 for more details.9# See the Mulan PSL v2 for more details.
10 10 
11import asyncio11import asyncio
12+import json
12 13 
13import pytest14import pytest
14from fastapi import FastAPI, HTTPException, Request15from fastapi import FastAPI, HTTPException, Request
@@ -17,7 +18,6 @@ from fastapi.testclient import TestClient
17 18 
18import motor.common.utils.error as cancel_error19import motor.common.utils.error as cancel_error
19from motor.common.logger.logger import _resolve_logger_name20from motor.common.logger.logger import _resolve_logger_name
20-from motor.common.resources.dispatch import DispatchPlan, has_compatible_dispatch_pair
21from motor.config.coordinator import CoordinatorConfig21from motor.config.coordinator import CoordinatorConfig
22from motor.common.resources.instance import Instance, PDRole22from motor.common.resources.instance import Instance, PDRole
23from motor.coordinator.domain import InstanceReadiness23from motor.coordinator.domain import InstanceReadiness
@@ -44,13 +44,6 @@ class _Scheduler:
44 return InstanceReadiness.ONLY_PREFILL44 return InstanceReadiness.ONLY_PREFILL
45 return InstanceReadiness.REQUIRED_MET45 return InstanceReadiness.REQUIRED_MET
46 46 
47- async def has_compatible_pd_pair(self):
48- if self._instances is None:
49- return False
50- prefill = [instance for instance in self._instances.values() if instance.role == PDRole.ROLE_P.value]
51- decode = [instance for instance in self._instances.values() if instance.role == PDRole.ROLE_D.value]
52- return has_compatible_dispatch_pair(prefill, decode)
53- 
54 47 
55def _app(config: CoordinatorConfig, scheduler: _Scheduler) -> FastAPI:48def _app(config: CoordinatorConfig, scheduler: _Scheduler) -> FastAPI:
56 app = FastAPI()49 app = FastAPI()
@@ -90,14 +83,12 @@ def test_dispatch_uses_unified_router_by_default(monkeypatch):
90 model_name="m",83 model_name="m",
91 id=1,84 id=1,
92 role=PDRole.ROLE_P.value,85 role=PDRole.ROLE_P.value,
93- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
94 ),86 ),
95 2: Instance(87 2: Instance(
96 job_name="d",88 job_name="d",
97 model_name="m",89 model_name="m",
98 id=2,90 id=2,
99 role=PDRole.ROLE_D.value,91 role=PDRole.ROLE_D.value,
100- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
101 ),92 ),
102 }93 }
103 client = TestClient(_app(_config(), _Scheduler(instances)))94 client = TestClient(_app(_config(), _Scheduler(instances)))
@@ -165,103 +156,43 @@ def test_dispatch_rejects_only_prefill_when_hybrid_fallback_disabled(monkeypatch
165 assert response.json()["detail"] == "PD separate service is unavailable and fallback to hybrid is disabled"156 assert response.json()["detail"] == "PD separate service is unavailable and fallback to hybrid is disabled"
166 157 
167 158 
168-def test_dispatch_rejects_incompatible_pd_topology():159+def test_dispatch_reuses_request_json_parsed_at_ingress(monkeypatch):
169- instances = {160+ """A body parsed by the API layer is not parsed again while building RequestInfo."""
170- 1: Instance(161+ calls = []
171- job_name="p",
172- model_name="m",
173- id=1,
174- role=PDRole.ROLE_P.value,
175- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
176- ),
177- 2: Instance(
178- job_name="d",
179- model_name="m",
180- id=2,
181- role=PDRole.ROLE_D.value,
182- dispatch_capabilities=[DispatchPlan.PREFILL_HANDOFF_DECODE.value],
183- ),
184- }
185 162 
186- response = TestClient(_app(_config(), _Scheduler(instances))).post(
187- "/v1/completions",
188- json={"model": "m", "prompt": "hi"},
189- )
190- 
191- assert response.status_code == 503
192- 
193- 
194-def test_dispatch_falls_back_to_union_for_incompatible_pd(monkeypatch):
195 class _FakeHybridRouter:163 class _FakeHybridRouter:
196 def __init__(self, req_info, config, scheduler=None, request_manager=None, sampling_manager=None):164 def __init__(self, req_info, config, scheduler=None, request_manager=None, sampling_manager=None):
197- pass165+ calls.append(req_info.req_data)
198 166 
199 async def handle_request(self):167 async def handle_request(self):
200 return JSONResponse({"router": "hybrid"})168 return JSONResponse({"router": "hybrid"})
201 169 
202 monkeypatch.setattr(dispatch, "PDHybridRouter", _FakeHybridRouter)170 monkeypatch.setattr(dispatch, "PDHybridRouter", _FakeHybridRouter)
203- instances = {171+ app = FastAPI()
204- 1: Instance(172+ config = CoordinatorConfig()
205- job_name="p",173+ request_manager = RequestManager(config)
206- model_name="m",174+ scheduler = _Scheduler({1: Instance(job_name="p", model_name="m", id=1, role=PDRole.ROLE_P.value)})
207- id=1,
208- role=PDRole.ROLE_P.value,
209- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
210- ),
211- 2: Instance(
212- job_name="d",
213- model_name="m",
214- id=2,
215- role=PDRole.ROLE_D.value,
216- dispatch_capabilities=[DispatchPlan.PREFILL_HANDOFF_DECODE.value],
217- ),
218- 3: Instance(job_name="u", model_name="m", id=3, role=PDRole.ROLE_U.value),
219- }
220 175 
221- response = TestClient(_app(_config(), _Scheduler(instances))).post(176+ @app.post("/v1/completions")
222- "/v1/completions",177+ async def completions(request: Request):
223- json={"model": "m", "prompt": "hi"},178+ request_json = json.loads((await request.body()).decode("utf-8"))
224- )179+ 
180+ async def unexpected_second_parse():
181+ raise AssertionError("request.json() must not be called after ingress parsing")
182+ 
183+ monkeypatch.setattr(request, "json", unexpected_second_parse)
184+ return await dispatch.handle_request(
185+ request,
186+ config,
187+ scheduler=scheduler,
188+ request_manager=request_manager,
189+ request_json=request_json,
190+ )
191+ 
192+ response = TestClient(app).post("/v1/completions", json={"model": "m", "prompt": "hi"})
225 193 
226 assert response.status_code == 200194 assert response.status_code == 200
227- assert response.json() == {"router": "hybrid"}195+ assert calls == [{"model": "m", "prompt": "hi"}]
228- 
229- 
230-def test_dispatch_rejects_union_fallback_when_hybrid_fallback_disabled(monkeypatch):
231- class _FakeHybridRouter:
232- def __init__(self, req_info, config, scheduler=None, request_manager=None, sampling_manager=None):
233- raise AssertionError("PDHybridRouter should not be used when fallback is disabled")
234- 
235- monkeypatch.setattr(dispatch, "PDHybridRouter", _FakeHybridRouter)
236- instances = {
237- 1: Instance(
238- job_name="p",
239- model_name="m",
240- id=1,
241- role=PDRole.ROLE_P.value,
242- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
243- ),
244- 2: Instance(
245- job_name="d",
246- model_name="m",
247- id=2,
248- role=PDRole.ROLE_D.value,
249- dispatch_capabilities=[DispatchPlan.PREFILL_HANDOFF_DECODE.value],
250- ),
251- 3: Instance(job_name="u", model_name="m", id=3, role=PDRole.ROLE_U.value),
252- }
253- config = CoordinatorConfig()
254- config.scheduler_config.enable_pd_separation_fallback_to_hybrid = False
255- 
256- response = TestClient(_app(config, _Scheduler(instances))).post(
257- "/v1/completions",
258- json={"model": "m", "prompt": "hi"},
259- )
260- 
261- assert response.status_code == 503
262- assert (
263- response.json()["detail"] == "PD separate service has no compatible P/D pair and fallback to hybrid is disabled"
264- )
265 196 
266 197 
267def test_dispatch_preserves_upstream_http_error(monkeypatch):198def test_dispatch_preserves_upstream_http_error(monkeypatch):
@@ -286,14 +217,12 @@ def test_dispatch_preserves_upstream_http_error(monkeypatch):
286 model_name="m",217 model_name="m",
287 id=1,218 id=1,
288 role=PDRole.ROLE_P.value,219 role=PDRole.ROLE_P.value,
289- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
290 ),220 ),
291 2: Instance(221 2: Instance(
292 job_name="d",222 job_name="d",
293 model_name="m",223 model_name="m",
294 id=2,224 id=2,
295 role=PDRole.ROLE_D.value,225 role=PDRole.ROLE_D.value,
296- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
297 ),226 ),
298 }227 }
299 228 
@@ -329,14 +258,12 @@ def test_dispatch_request_cancelled_does_not_log_error(monkeypatch, caplog):
329 model_name="m",258 model_name="m",
330 id=1,259 id=1,
331 role=PDRole.ROLE_P.value,260 role=PDRole.ROLE_P.value,
332- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
333 ),261 ),
334 2: Instance(262 2: Instance(
335 job_name="d",263 job_name="d",
336 model_name="m",264 model_name="m",
337 id=2,265 id=2,
338 role=PDRole.ROLE_D.value,266 role=PDRole.ROLE_D.value,
339- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
340 ),267 ),
341 }268 }
342 269 
@@ -373,14 +300,12 @@ def test_dispatch_dispatch_abort_still_returns_500(monkeypatch, caplog):
373 model_name="m",300 model_name="m",
374 id=1,301 id=1,
375 role=PDRole.ROLE_P.value,302 role=PDRole.ROLE_P.value,
376- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
377 ),303 ),
378 2: Instance(304 2: Instance(
379 job_name="d",305 job_name="d",
380 model_name="m",306 model_name="m",
381 id=2,307 id=2,
382 role=PDRole.ROLE_D.value,308 role=PDRole.ROLE_D.value,
383- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
384 ),309 ),
385 }310 }
386 311 
@@ -451,14 +376,12 @@ def test_dispatch_unexpected_exception_still_logs_error(monkeypatch, caplog):
451 model_name="m",376 model_name="m",
452 id=1,377 id=1,
453 role=PDRole.ROLE_P.value,378 role=PDRole.ROLE_P.value,
454- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
455 ),379 ),
456 2: Instance(380 2: Instance(
457 job_name="d",381 job_name="d",
458 model_name="m",382 model_name="m",
459 id=2,383 id=2,
460 role=PDRole.ROLE_D.value,384 role=PDRole.ROLE_D.value,
461- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
462 ),385 ),
463 }386 }
464 387 
@@ -0,0 +1,501 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""HTTP-contract tests for Coordinator-to-native-engine P/D traffic.
12+ 
13+These tests use real ``httpx`` request serialization and ASGI applications as
14+fake native engines. Only endpoint-to-transport binding is replaced; the
15+Coordinator request, response, SSE, error, and protocol-adapter paths run
16+unchanged.
17+"""
18+ 
19+from contextlib import asynccontextmanager
20+import json
21+from unittest.mock import AsyncMock
22+ 
23+from fastapi import FastAPI, Request
24+from fastapi.responses import JSONResponse, StreamingResponse
25+import httpx
26+import pytest
27+ 
28+from motor.common.http import HTTPClientPool
29+from motor.common.resources.endpoint import Endpoint, EndpointStatus, Workload
30+from motor.common.resources.instance import Instance, InsStatus, ParallelConfig, PDRole
31+from motor.config.coordinator import CoordinatorConfig, ExceptionConfig
32+from motor.config.tls_config import TLSConfig
33+from motor.coordinator.domain import ScheduledResource
34+from motor.coordinator.domain.request_manager import RequestManager
35+from motor.coordinator.models.request import RequestInfo, ReqState
36+from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter
37+from motor.coordinator.router.upstream_error import UpstreamHTTPError
38+ 
39+ 
40+def _instance(
41+ instance_id: int,
42+ role: PDRole,
43+ engine_type: str,
44+ *,
45+ bootstrap_port: int | None = None,
46+) -> Instance:
47+ endpoint = Endpoint(
48+ id=instance_id,
49+ ip="127.0.0.1",
50+ business_port=str(8300 + instance_id),
51+ mgmt_port=str(9300 + instance_id),
52+ bootstrap_port=bootstrap_port,
53+ status=EndpointStatus.NORMAL,
54+ )
55+ return Instance(
56+ job_name=f"native-{engine_type}-{role.value}-{instance_id}",
57+ model_name="test-model",
58+ engine_type=engine_type,
59+ id=instance_id,
60+ role=role,
61+ status=InsStatus.ACTIVE,
62+ parallel_config=ParallelConfig(dp_size=1, tp_size=1),
63+ endpoints={endpoint.ip: {endpoint.id: endpoint}},
64+ )
65+ 
66+ 
67+class _ContractScheduler:
68+ def __init__(self, engine_type: str):
69+ bootstrap_port = 21001 if engine_type == "sglang" else None
70+ self.p = _instance(1, PDRole.ROLE_P, engine_type, bootstrap_port=bootstrap_port)
71+ self.d = _instance(2, PDRole.ROLE_D, engine_type)
72+ self.cb_events: list[tuple[int, str]] = []
73+ self.workload_updates = []
74+ 
75+ async def select_and_allocate(self, role, _req_info, **_kwargs):
76+ instance = self.p if role == PDRole.ROLE_P else self.d
77+ endpoint = next(iter(next(iter(instance.endpoints.values())).values()))
78+ return instance, endpoint, Workload(active_tokens=1)
79+ 
80+ async def update_workload(self, params):
81+ self.workload_updates.append(params)
82+ return True
83+ 
84+ async def report_cb_event(self, instance_id: int, event: str) -> None:
85+ self.cb_events.append((instance_id, event))
86+ 
87+ async def get_unblocked_instances(self, role) -> list[int]:
88+ if role == PDRole.ROLE_P:
89+ return [self.p.id]
90+ if role == PDRole.ROLE_D:
91+ return [self.d.id]
92+ return []
93+ 
94+ 
95+def _config() -> CoordinatorConfig:
96+ config = CoordinatorConfig()
97+ config.exception_config = ExceptionConfig(max_retry=1, retry_delay=0)
98+ return config
99+ 
100+ 
101+def _router(req_info: RequestInfo, scheduler: _ContractScheduler) -> UnifiedPDRouter:
102+ config = _config()
103+ return UnifiedPDRouter(
104+ req_info,
105+ config,
106+ scheduler=scheduler,
107+ request_manager=RequestManager(config),
108+ )
109+ 
110+ 
111+@asynccontextmanager
112+async def _bind_native_apps(monkeypatch, router: UnifiedPDRouter, p_app: FastAPI, d_app: FastAPI):
113+ async with (
114+ httpx.AsyncClient(
115+ transport=httpx.ASGITransport(app=p_app),
116+ base_url="http://native-prefill",
117+ ) as p_client,
118+ httpx.AsyncClient(
119+ transport=httpx.ASGITransport(app=d_app),
120+ base_url="http://native-decode",
121+ ) as d_client,
122+ ):
123+ 
124+ @asynccontextmanager
125+ async def _client_for(resource: ScheduledResource):
126+ yield p_client if resource.instance.role == PDRole.ROLE_P else d_client
127+ 
128+ monkeypatch.setattr(router, "_client_for", _client_for)
129+ try:
130+ yield
131+ finally:
132+ await HTTPClientPool().close_all()
133+ 
134+ 
135+async def _collect_stream_body(response) -> bytes:
136+ chunks = []
137+ async for chunk in response.body_iterator:
138+ chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
139+ return b"".join(chunks)
140+ 
141+ 
142+def _sse(payload: dict) -> bytes:
143+ return b"data: " + json.dumps(payload, separators=(",", ":")).encode() + b"\n\n"
144+ 
145+ 
146+@pytest.mark.asyncio
147+async def test_vllm_handoff_preserves_chat_tools_logprobs_and_usage_over_http(monkeypatch):
148+ p_requests = []
149+ d_requests = []
150+ p_headers = []
151+ d_headers = []
152+ p_app = FastAPI()
153+ d_app = FastAPI()
154+ 
155+ @p_app.post("/v1/chat/completions")
156+ async def prefill(request: Request):
157+ body = await request.json()
158+ p_requests.append(body)
159+ p_headers.append(dict(request.headers))
160+ return {
161+ "kv_transfer_params": {
162+ "do_remote_prefill": True,
163+ "remote_request_id": body["request_id"],
164+ "remote_host": "10.0.0.8",
165+ "remote_port": 25000,
166+ "connector_private": {"ticket": "opaque"},
167+ },
168+ "usage": {
169+ "prompt_tokens": 9,
170+ "prompt_tokens_details": {"cached_tokens": 4},
171+ },
172+ }
173+ 
174+ @d_app.post("/v1/chat/completions")
175+ async def decode(request: Request):
176+ body = await request.json()
177+ d_requests.append(body)
178+ d_headers.append(dict(request.headers))
179+ return {
180+ "id": "chatcmpl-native",
181+ "object": "chat.completion",
182+ "choices": [
183+ {
184+ "index": 0,
185+ "message": {
186+ "role": "assistant",
187+ "content": None,
188+ "tool_calls": [
189+ {
190+ "id": "call_weather",
191+ "type": "function",
192+ "function": {
193+ "name": "weather",
194+ "arguments": '{"city":"Shenzhen"}',
195+ },
196+ }
197+ ],
198+ },
199+ "finish_reason": "tool_calls",
200+ "logprobs": {
201+ "content": [
202+ {
203+ "token": "weather",
204+ "logprob": -0.1,
205+ "top_logprobs": [],
206+ }
207+ ]
208+ },
209+ "token_ids": [42],
210+ }
211+ ],
212+ "usage": {
213+ "prompt_tokens": 9,
214+ "completion_tokens": 1,
215+ "total_tokens": 10,
216+ },
217+ "kv_transfer_params": {"must_not_leak": True},
218+ }
219+ 
220+ tools = [
221+ {
222+ "type": "function",
223+ "function": {
224+ "name": "weather",
225+ "description": "Get weather",
226+ "parameters": {
227+ "type": "object",
228+ "properties": {"city": {"type": "string"}},
229+ },
230+ },
231+ }
232+ ]
233+ req_info = RequestInfo(
234+ req_id="contract-vllm-chat",
235+ req_data={
236+ "model": "test-model",
237+ "messages": [{"role": "user", "content": "Weather?"}],
238+ "tools": tools,
239+ "tool_choice": "auto",
240+ "logprobs": True,
241+ "top_logprobs": 2,
242+ "max_completion_tokens": 6,
243+ "stream": False,
244+ },
245+ api="v1/chat/completions",
246+ entry_api="v1/chat/completions",
247+ req_len=9,
248+ )
249+ scheduler = _ContractScheduler("vllm")
250+ router = _router(req_info, scheduler)
251+ 
252+ async with _bind_native_apps(monkeypatch, router, p_app, d_app):
253+ response = await router.handle_request()
254+ 
255+ body = json.loads(response.body)
256+ assert p_requests[0]["stream"] is False
257+ assert p_requests[0]["max_tokens"] == 1
258+ assert p_requests[0]["max_completion_tokens"] == 1
259+ assert p_requests[0]["tools"] == tools
260+ assert d_requests[0]["max_completion_tokens"] == 6
261+ assert d_requests[0]["tools"] == tools
262+ assert d_requests[0]["kv_transfer_params"]["connector_private"] == {"ticket": "opaque"}
263+ assert p_headers[0]["x-request-id"] == d_headers[0]["x-request-id"] == "contract-vllm-chat#a1"
264+ assert "authorization" not in p_headers[0]
265+ assert "authorization" not in d_headers[0]
266+ assert body["choices"][0]["message"]["tool_calls"][0]["function"]["name"] == "weather"
267+ assert body["choices"][0]["logprobs"]["content"][0]["logprob"] == -0.1
268+ assert body["usage"]["prompt_tokens_details"] == {"cached_tokens": 4}
269+ assert "token_ids" not in body["choices"][0]
270+ assert "kv_transfer_params" not in body
271+ assert req_info.state == ReqState.DECODE_END
272+ assert scheduler.cb_events == [(1, "success"), (2, "success")]
273+ 
274+ 
275+@pytest.mark.asyncio
276+async def test_sglang_bootstrap_preserves_stream_sse_tools_logprobs_and_usage_over_http(monkeypatch):
277+ p_requests = []
278+ d_requests = []
279+ p_app = FastAPI()
280+ d_app = FastAPI()
281+ 
282+ @p_app.post("/v1/chat/completions")
283+ async def prefill(request: Request):
284+ p_requests.append(await request.json())
285+ 
286+ async def frames():
287+ yield _sse(
288+ {
289+ "choices": [],
290+ "usage": {
291+ "prompt_tokens": 7,
292+ "prompt_tokens_details": {"cached_tokens": 3},
293+ },
294+ }
295+ )
296+ yield b"data: [DONE]\n\n"
297+ 
298+ return StreamingResponse(frames(), media_type="text/event-stream")
299+ 
300+ @d_app.post("/v1/chat/completions")
301+ async def decode(request: Request):
302+ d_requests.append(await request.json())
303+ 
304+ async def frames():
305+ yield _sse(
306+ {
307+ "id": "chatcmpl-sglang",
308+ "choices": [
309+ {
310+ "index": 0,
311+ "delta": {
312+ "tool_calls": [
313+ {
314+ "index": 0,
315+ "id": "call_time",
316+ "type": "function",
317+ "function": {
318+ "name": "local_time",
319+ "arguments": '{"city":"Shanghai"}',
320+ },
321+ }
322+ ]
323+ },
324+ "logprobs": {
325+ "content": [
326+ {
327+ "token": "time",
328+ "logprob": -0.2,
329+ "top_logprobs": [],
330+ }
331+ ]
332+ },
333+ "token_ids": [51],
334+ "finish_reason": "tool_calls",
335+ }
336+ ],
337+ "bootstrap_host": "127.0.0.1",
338+ "bootstrap_port": 21001,
339+ "bootstrap_room": d_requests[0]["bootstrap_room"],
340+ }
341+ )
342+ yield _sse(
343+ {
344+ "choices": [],
345+ "usage": {
346+ "prompt_tokens": 7,
347+ "completion_tokens": 1,
348+ "total_tokens": 8,
349+ },
350+ "bootstrap_room": d_requests[0]["bootstrap_room"],
351+ }
352+ )
353+ yield b"data: [DONE]\n\n"
354+ 
355+ return StreamingResponse(frames(), media_type="text/event-stream")
356+ 
357+ tools = [
358+ {
359+ "type": "function",
360+ "function": {
361+ "name": "local_time",
362+ "parameters": {
363+ "type": "object",
364+ "properties": {"city": {"type": "string"}},
365+ },
366+ },
367+ }
368+ ]
369+ req_info = RequestInfo(
370+ req_id="contract-sglang-stream",
371+ req_data={
372+ "model": "test-model",
373+ "messages": [{"role": "user", "content": "Time?"}],
374+ "tools": tools,
375+ "logprobs": True,
376+ "top_logprobs": 2,
377+ "max_tokens": 5,
378+ "stream": True,
379+ "stream_options": {"include_usage": True},
380+ },
381+ api="v1/chat/completions",
382+ entry_api="v1/chat/completions",
383+ req_len=7,
384+ )
385+ scheduler = _ContractScheduler("sglang")
386+ router = _router(req_info, scheduler)
387+ 
388+ async with _bind_native_apps(monkeypatch, router, p_app, d_app):
389+ response = await router.handle_request()
390+ body = await _collect_stream_body(response)
391+ 
392+ assert p_requests[0]["rid"] == d_requests[0]["rid"] == "contract-sglang-stream#a1"
393+ assert p_requests[0]["bootstrap_room"] == d_requests[0]["bootstrap_room"]
394+ assert p_requests[0]["bootstrap_host"] == d_requests[0]["bootstrap_host"] == "127.0.0.1"
395+ assert p_requests[0]["bootstrap_port"] == d_requests[0]["bootstrap_port"] == 21001
396+ assert p_requests[0]["tools"] == d_requests[0]["tools"] == tools
397+ assert b'"tool_calls"' in body
398+ assert b'"logprobs"' in body
399+ assert b'"prompt_tokens_details":{"cached_tokens":3}' in body
400+ assert b"token_ids" not in body
401+ assert b"bootstrap_host" not in body
402+ assert b"bootstrap_port" not in body
403+ assert b"bootstrap_room" not in body
404+ assert body.count(b"data: [DONE]") == 1
405+ assert req_info.state == ReqState.DECODE_END
406+ assert scheduler.cb_events == [(1, "success"), (2, "success")]
407+ 
408+ 
409+@pytest.mark.parametrize(
410+ ("status_code", "expected_cb_event"),
411+ [
412+ (400, None),
413+ (503, (1, "failure")),
414+ ],
415+)
416+@pytest.mark.asyncio
417+async def test_vllm_prefill_http_error_preserves_status_and_circuit_breaker_semantics(
418+ monkeypatch,
419+ status_code,
420+ expected_cb_event,
421+):
422+ p_app = FastAPI()
423+ d_app = FastAPI()
424+ d_requests = []
425+ 
426+ @p_app.post("/v1/completions")
427+ async def prefill(_request: Request):
428+ return JSONResponse(
429+ status_code=status_code,
430+ content={"error": {"message": f"native prefill {status_code}"}},
431+ headers={"retry-after": "2"},
432+ )
433+ 
434+ @d_app.post("/v1/completions")
435+ async def decode(request: Request):
436+ d_requests.append(await request.json())
437+ return {"choices": [{"text": "must not run"}]}
438+ 
439+ req_info = RequestInfo(
440+ req_id=f"contract-vllm-error-{status_code}",
441+ req_data={
442+ "model": "test-model",
443+ "prompt": "hello",
444+ "max_tokens": 4,
445+ "stream": False,
446+ },
447+ api="v1/completions",
448+ entry_api="v1/completions",
449+ req_len=3,
450+ )
451+ scheduler = _ContractScheduler("vllm")
452+ router = _router(req_info, scheduler)
453+ 
454+ async with _bind_native_apps(monkeypatch, router, p_app, d_app):
455+ with pytest.raises(UpstreamHTTPError) as exc_info:
456+ await router.handle_request()
457+ 
458+ assert exc_info.value.status_code == status_code
459+ assert exc_info.value.headers["retry-after"] == "2"
460+ assert d_requests == []
461+ assert scheduler.cb_events == ([] if expected_cb_event is None else [expected_cb_event])
462+ 
463+ 
464+@pytest.mark.asyncio
465+async def test_native_pd_client_uses_coordinator_inference_tls_config(monkeypatch):
466+ req_info = RequestInfo(
467+ req_id="contract-native-tls",
468+ req_data={"model": "test-model", "prompt": "hello", "max_tokens": 1},
469+ api="v1/completions",
470+ entry_api="v1/completions",
471+ req_len=1,
472+ )
473+ scheduler = _ContractScheduler("vllm")
474+ config = _config()
475+ config.infer_tls_config = TLSConfig(
476+ enable_tls=True,
477+ ca_file="/certs/ca.pem",
478+ cert_file="/certs/client.pem",
479+ key_file="/certs/client.key",
480+ )
481+ router = UnifiedPDRouter(
482+ req_info,
483+ config,
484+ scheduler=scheduler,
485+ request_manager=RequestManager(config),
486+ )
487+ endpoint = next(iter(next(iter(scheduler.p.endpoints.values())).values()))
488+ resource = ScheduledResource(instance=scheduler.p, endpoint=endpoint)
489+ fake_client = object()
490+ pool = HTTPClientPool()
491+ get_client = AsyncMock(return_value=fake_client)
492+ monkeypatch.setattr(pool, "get_client", get_client)
493+ 
494+ async with router._client_for(resource) as client:
495+ assert client is fake_client
496+ 
497+ get_client.assert_awaited_once_with(
498+ ip=endpoint.ip,
499+ port=endpoint.business_port,
500+ tls_config=config.infer_tls_config,
501+ )
@@ -0,0 +1,271 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+from copy import deepcopy
12+ 
13+import pytest
14+ 
15+from motor.coordinator.router.adapters.pd_protocol import (
16+ ADAPTERS,
17+ CoordinationMode,
18+ EngineEndpointMetadata,
19+ EngineProtocolError,
20+ LegContext,
21+ PrefillMetadata,
22+ SglangProtocolAdapter,
23+ VllmProtocolAdapter,
24+)
25+ 
26+ 
27+def _endpoint(host: str, port: int | None = None) -> EngineEndpointMetadata:
28+ return EngineEndpointMetadata(
29+ host=host,
30+ bootstrap_port=port,
31+ )
32+ 
33+ 
34+def _context(
35+ *,
36+ endpoint: EngineEndpointMetadata | None = None,
37+ peer_endpoint: EngineEndpointMetadata | None = None,
38+ attempt_seq: int = 2,
39+) -> LegContext:
40+ return LegContext(
41+ engine_request_id="engine-1",
42+ pair_id="pair-1",
43+ attempt_seq=attempt_seq,
44+ api="/v1/chat/completions",
45+ endpoint=endpoint or _endpoint("prefill.local", 8998),
46+ peer_endpoint=peer_endpoint,
47+ )
48+ 
49+ 
50+def test_adapter_registry_is_static():
51+ assert ADAPTERS["vllm"].coordination_mode is CoordinationMode.HANDOFF
52+ assert ADAPTERS["sglang"].coordination_mode is CoordinationMode.BOOTSTRAP
53+ 
54+ with pytest.raises(TypeError):
55+ ADAPTERS["other"] = VllmProtocolAdapter()
56+ 
57+ 
58+def test_vllm_prefill_request_matches_native_handoff_contract_without_mutation():
59+ adapter = VllmProtocolAdapter()
60+ request = {
61+ "model": "glm",
62+ "messages": [{"role": "user", "content": "hello"}],
63+ "stream": True,
64+ "stream_options": {"include_usage": True},
65+ "max_tokens": 128,
66+ "max_completion_tokens": 96,
67+ "request_id": "client-request-id",
68+ "rid": "wrong-engine-id",
69+ "extra": {"nested": [1, 2]},
70+ }
71+ original = deepcopy(request)
72+ 
73+ engine_request = adapter.build_prefill_request(request, _context())
74+ 
75+ assert request == original
76+ assert engine_request.api == "/v1/chat/completions"
77+ assert engine_request.body["request_id"] == "engine-1"
78+ assert "rid" not in engine_request.body
79+ assert engine_request.body["stream"] is False
80+ assert engine_request.body["max_tokens"] == 1
81+ assert engine_request.body["max_completion_tokens"] == 1
82+ assert engine_request.body["min_tokens"] == 1
83+ assert "stream_options" not in engine_request.body
84+ assert engine_request.body["kv_transfer_params"] == {
85+ "do_remote_decode": True,
86+ "do_remote_prefill": False,
87+ "remote_engine_id": None,
88+ "remote_block_ids": None,
89+ "remote_host": None,
90+ "remote_port": None,
91+ }
92+ engine_request.body["extra"]["nested"].append(3)
93+ assert request == original
94+ 
95+ 
96+def test_vllm_prefill_does_not_add_max_completion_tokens():
97+ request = {"max_tokens": 32}
98+ 
99+ body = VllmProtocolAdapter().build_prefill_request(request, _context()).body
100+ 
101+ assert "max_completion_tokens" not in body
102+ 
103+ 
104+def test_vllm_prefill_response_returns_copied_ticket_and_usage():
105+ response = {
106+ "kv_transfer_params": {
107+ "do_remote_prefill": True,
108+ "remote_host": "10.0.0.1",
109+ "connector_private": {"blocks": [1, 2]},
110+ },
111+ "usage": {"prompt_tokens": 8, "details": {"cached_tokens": 4}},
112+ }
113+ 
114+ metadata = VllmProtocolAdapter().parse_prefill_response(response)
115+ 
116+ assert metadata.handoff_ticket == response["kv_transfer_params"]
117+ assert metadata.usage == response["usage"]
118+ response["kv_transfer_params"]["connector_private"]["blocks"].append(3)
119+ response["usage"]["details"]["cached_tokens"] = 0
120+ assert metadata.handoff_ticket["connector_private"]["blocks"] == [1, 2]
121+ assert metadata.usage["details"]["cached_tokens"] == 4
122+ 
123+ 
124+@pytest.mark.parametrize(
125+ ("response", "message"),
126+ [
127+ ({}, "Missing kv_transfer_params"),
128+ ({"kv_transfer_params": {}}, "Missing kv_transfer_params"),
129+ ({"kv_transfer_params": {"do_remote_prefill": False}}, "do_remote_prefill must be true"),
130+ ],
131+)
132+def test_vllm_prefill_response_rejects_invalid_handoff(response, message):
133+ with pytest.raises(EngineProtocolError, match=message) as exc_info:
134+ VllmProtocolAdapter().parse_prefill_response(response)
135+ 
136+ assert exc_info.value.engine_type == "vllm"
137+ assert exc_info.value.phase == "prefill"
138+ 
139+ 
140+def test_vllm_decode_restores_original_generation_budget_and_copies_ticket():
141+ adapter = VllmProtocolAdapter()
142+ request = {
143+ "stream": True,
144+ "max_tokens": 128,
145+ "max_completion_tokens": 96,
146+ "extra": {"nested": [1]},
147+ }
148+ original = deepcopy(request)
149+ ticket = {"do_remote_prefill": True, "remote_host": "10.0.0.1", "private": {"blocks": [7]}}
150+ 
151+ engine_request = adapter.build_decode_request(
152+ request,
153+ _context(),
154+ PrefillMetadata(handoff_ticket=ticket),
155+ )
156+ 
157+ assert request == original
158+ assert engine_request.body["request_id"] == "engine-1"
159+ assert engine_request.body["stream"] is True
160+ assert engine_request.body["max_tokens"] == 128
161+ assert engine_request.body["max_completion_tokens"] == 96
162+ assert engine_request.body["kv_transfer_params"] == ticket
163+ engine_request.body["kv_transfer_params"]["private"]["blocks"].append(8)
164+ assert ticket["private"]["blocks"] == [7]
165+ 
166+ 
167+@pytest.mark.parametrize("metadata", [None, PrefillMetadata(), PrefillMetadata(handoff_ticket={})])
168+def test_vllm_decode_requires_handoff_ticket(metadata):
169+ with pytest.raises(EngineProtocolError, match="Missing handoff ticket") as exc_info:
170+ VllmProtocolAdapter().build_decode_request({}, _context(), metadata)
171+ 
172+ assert exc_info.value.phase == "decode"
173+ 
174+ 
175+def test_vllm_declares_internal_response_field():
176+ assert VllmProtocolAdapter.internal_response_fields == frozenset({"kv_transfer_params"})
177+ 
178+ 
179+def test_sglang_prefill_and_decode_share_prefill_bootstrap_address_and_room():
180+ adapter = SglangProtocolAdapter()
181+ prefill_endpoint = _endpoint("prefill.local", 8998)
182+ decode_endpoint = _endpoint("decode.local", 8999)
183+ request = {
184+ "model": "glm",
185+ "messages": [{"role": "user", "content": "hello"}],
186+ "request_id": "wrong-engine-id",
187+ "rid": "client-request-id",
188+ }
189+ original = deepcopy(request)
190+ prefill_context = _context(endpoint=prefill_endpoint, peer_endpoint=decode_endpoint)
191+ decode_context = _context(endpoint=decode_endpoint, peer_endpoint=prefill_endpoint)
192+ 
193+ prefill = adapter.build_prefill_request(request, prefill_context)
194+ decode = adapter.build_decode_request(request, decode_context, None)
195+ 
196+ assert request == original
197+ assert prefill.body["rid"] == "engine-1"
198+ assert decode.body["rid"] == "engine-1"
199+ assert "request_id" not in prefill.body
200+ assert "request_id" not in decode.body
201+ assert prefill.body["stream"] is False
202+ for body in (prefill.body, decode.body):
203+ assert body["bootstrap_host"] == "prefill.local"
204+ assert body["bootstrap_port"] == 8998
205+ assert prefill.body["bootstrap_room"] == decode.body["bootstrap_room"]
206+ prefill.body["messages"][0]["content"] = "changed"
207+ assert request == original
208+ 
209+ 
210+def test_sglang_bootstrap_room_is_stable_per_attempt():
211+ adapter = SglangProtocolAdapter()
212+ 
213+ first = adapter.build_prefill_request({}, _context(attempt_seq=2)).body["bootstrap_room"]
214+ repeated = adapter.build_prefill_request({}, _context(attempt_seq=2)).body["bootstrap_room"]
215+ retried = adapter.build_prefill_request({}, _context(attempt_seq=3)).body["bootstrap_room"]
216+ 
217+ assert first == repeated
218+ assert first != retried
219+ assert 0 <= first < 1 << 63
220+ 
221+ 
222+@pytest.mark.parametrize(
223+ "endpoint",
224+ [
225+ EngineEndpointMetadata(host="", bootstrap_port=8998),
226+ _endpoint("prefill.local"),
227+ _endpoint("prefill.local", 0),
228+ _endpoint("prefill.local", 65536),
229+ ],
230+)
231+def test_sglang_prefill_rejects_invalid_bootstrap_endpoint(endpoint):
232+ with pytest.raises(EngineProtocolError) as exc_info:
233+ SglangProtocolAdapter().build_prefill_request({}, _context(endpoint=endpoint))
234+ 
235+ assert exc_info.value.engine_type == "sglang"
236+ assert exc_info.value.phase == "prefill"
237+ 
238+ 
239+def test_sglang_decode_requires_prefill_endpoint():
240+ with pytest.raises(EngineProtocolError, match="Missing prefill endpoint metadata") as exc_info:
241+ SglangProtocolAdapter().build_decode_request({}, _context(), None)
242+ 
243+ assert exc_info.value.phase == "decode"
244+ 
245+ 
246+def test_sglang_prefill_response_copies_usage_without_handoff_ticket():
247+ response = {"usage": {"prompt_tokens": 8, "details": {"cached_tokens": 4}}}
248+ 
249+ metadata = SglangProtocolAdapter().parse_prefill_response(response)
250+ 
251+ assert metadata.handoff_ticket is None
252+ assert metadata.usage == response["usage"]
253+ response["usage"]["details"]["cached_tokens"] = 0
254+ assert metadata.usage["details"]["cached_tokens"] == 4
255+ 
256+ 
257+def test_sglang_declares_internal_response_fields():
258+ assert SglangProtocolAdapter.internal_response_fields == frozenset(
259+ {"bootstrap_host", "bootstrap_port", "bootstrap_room"}
260+ )
261+ 
262+ 
263+def test_sglang_abort_uses_native_request_id():
264+ request = SglangProtocolAdapter().build_abort_request(_context())
265+ 
266+ assert request.api == "abort_request"
267+ assert request.body == {"rid": "engine-1"}
268+ 
269+ 
270+def test_vllm_does_not_claim_an_unverified_abort_endpoint():
271+ assert VllmProtocolAdapter().build_abort_request(_context()) is None
Rtests/coordinator/router/test_router_cdp_separation.pytests/coordinator/router/test_router_native_handoff.py+957-1119
@@ -1,1119 +1,957 @@
1-# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.1+# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
2-# MindIE is licensed under Mulan PSL v2.2+# MindIE is licensed under Mulan PSL v2.
3-# You can use this software according to the terms and conditions of the Mulan PSL v2.3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4-# You may obtain a copy of Mulan PSL v2 at:4+# You may obtain a copy of Mulan PSL v2 at:
5-# http://license.coscl.org.cn/MulanPSL25+# http://license.coscl.org.cn/MulanPSL2
6-# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7-# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8-# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9-# See the Mulan PSL v2 for more details.9+# See the Mulan PSL v2 for more details.
10- 10+ 
11-from pytest import MonkeyPatch11+from pytest import MonkeyPatch
12-from fastapi import FastAPI, status, Request12+from fastapi import FastAPI, status, Request
13-from fastapi.responses import JSONResponse13+from unittest.mock import patch, MagicMock, AsyncMock
14-from unittest.mock import patch, MagicMock, AsyncMock14+from fastapi.testclient import TestClient
15-from fastapi.testclient import TestClient15+import asyncio
16-import asyncio16+from contextlib import asynccontextmanager
17-from contextlib import asynccontextmanager17+import httpx
18-import httpx18+import json
19-import json19+import pytest
20-import pytest20+ 
21- 21+from motor.common.resources.endpoint import (
22-from motor.common.resources.dispatch import MOTOR_DISPATCH_KEY22+ Endpoint,
23-from motor.common.resources.endpoint import (23+ EndpointStatus,
24- Endpoint,24+ Workload,
25- EndpointStatus,25+ WorkloadAction,
26- Workload,26+)
27- WorkloadAction,27+from motor.common.resources.instance import PDRole, Instance, InsStatus, ParallelConfig
28-)28+from motor.config.coordinator import CoordinatorConfig, ExceptionConfig, SchedulerType
29-from motor.common.resources.dispatch import DispatchPlan29+from motor.coordinator.domain.instance_manager import InstanceManager
30-from motor.common.resources.instance import PDRole, Instance, InsStatus, ParallelConfig30+from motor.coordinator.domain import InstanceReadiness, ScheduledResource
31-from motor.config.coordinator import CoordinatorConfig, ExceptionConfig, SchedulerType31+from motor.coordinator.models.request import ReqState, RequestInfo
32-from motor.coordinator.domain.instance_manager import InstanceManager32+from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter
33-from motor.coordinator.domain import InstanceReadiness, ScheduledResource33+from motor.coordinator.tracer.tracing import TracerManager
34-from motor.coordinator.models.request import ReqState, RequestInfo34+from motor.coordinator.scheduler.scheduler import Scheduler
35-from motor.coordinator.router.strategies.unified_pd import (35+from motor.coordinator.domain.request_manager import RequestManager
36- UnifiedPDRouter as SeparateCDPRouter,36+from tests.coordinator.router.mock_openai_request import create_mock_request_info
37-)37+import motor.coordinator.router.dispatch as router
38-from motor.coordinator.tracer.tracing import TracerManager38+ 
39-from motor.coordinator.scheduler.scheduler import Scheduler39+TracerManager()
40-from motor.coordinator.domain.request_manager import RequestManager40+ 
41-from tests.coordinator.router.mock_openai_request import (41+ 
42- MockStreamResponse,42+def _native_prefill_body(request_body, *, usage=None):
43- create_mock_request_info,43+ body = {
44-)44+ "kv_transfer_params": {
45-import motor.coordinator.router.dispatch as router45+ "do_remote_prefill": True,
46- 46+ "remote_request_id": request_body["request_id"],
47-TracerManager()47+ "remote_host": "127.0.0.1",
48- 48+ "remote_port": 9000,
49- 49+ }
50-class _UnifiedPDPrefillClient:50+ }
51- def __init__(self, *, exc: Exception | None = None, post_fail_times: int = 0):51+ if usage is not None:
52- self.exc = exc52+ body["usage"] = usage
53- self.post_fail_times = post_fail_times53+ return body
54- self.post_fail_count = 054+ 
55- self.requests = []55+ 
56- self.base_url = "http://prefill"56+class _UnifiedPDPrefillClient:
57- self.timeout = 157+ def __init__(self, *, exc: Exception | None = None, post_fail_times: int = 0):
58- 58+ self.exc = exc
59- async def post(self, path, json=None, headers=None, timeout=None):59+ self.post_fail_times = post_fail_times
60- self.requests.append(json)60+ self.post_fail_count = 0
61- if self.exc is not None and (self.post_fail_times == 0 or self.post_fail_count < self.post_fail_times):61+ self.requests = []
62- self.post_fail_count += 162+ self.base_url = "http://prefill"
63- raise self.exc63+ self.timeout = 1
64- request = httpx.Request("POST", path, headers=headers or {}, json=json)64+ 
65- return httpx.Response(status_code=200, json={"status": "cached"}, request=request)65+ async def post(self, path, json=None, headers=None, timeout=None):
66- 66+ self.requests.append(json)
67- 67+ if self.exc is not None and (self.post_fail_times == 0 or self.post_fail_count < self.post_fail_times):
68-class _UnifiedPDStreamResponse:68+ self.post_fail_count += 1
69- def __init__(self, chunks, exc: Exception | None = None):69+ raise self.exc
70- self.chunks = list(chunks)70+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
71- self.exc = exc71+ return httpx.Response(
72- if isinstance(exc, httpx.HTTPStatusError):72+ status_code=200,
73- self.status_code = exc.response.status_code73+ json=_native_prefill_body(json),
74- self.is_success = False74+ request=request,
75- self.text = exc.response.text75+ )
76- self.headers = exc.response.headers76+ 
77- self._error_body = exc.response.content or str(exc).encode()77+ 
78- else:78+class _UnifiedPDStreamResponse:
79- self.status_code = 20079+ def __init__(self, chunks, exc: Exception | None = None):
80- self.is_success = True80+ self.chunks = list(chunks)
81- self.text = ""81+ self.exc = exc
82- self.headers = {}82+ if isinstance(exc, httpx.HTTPStatusError):
83- self._error_body = b""83+ self.status_code = exc.response.status_code
84- 84+ self.is_success = False
85- async def __aenter__(self):85+ self.text = exc.response.text
86- if isinstance(self.exc, httpx.RequestError):86+ self.headers = exc.response.headers
87- raise self.exc87+ self._error_body = exc.response.content or str(exc).encode()
88- return self88+ else:
89- 89+ self.status_code = 200
90- async def __aexit__(self, exc_type, exc_val, exc_tb):90+ self.is_success = True
91- return False91+ self.text = ""
92- 92+ self.headers = {}
93- async def aread(self):93+ self._error_body = b""
94- return self._error_body94+ 
95- 95+ async def __aenter__(self):
96- def raise_for_status(self):96+ if isinstance(self.exc, httpx.RequestError):
97- if self.exc is not None and not self.chunks:97+ raise self.exc
98- raise self.exc98+ return self
99- 99+ 
100- async def aiter_bytes(self):100+ async def __aexit__(self, exc_type, exc_val, exc_tb):
101- if not self.is_success:101+ return False
102- yield self._error_body102+ 
103- return103+ async def aread(self):
104- for chunk in self.chunks:104+ return self._error_body
105- yield chunk105+ 
106- if self.exc is not None and not isinstance(self.exc, (httpx.HTTPStatusError, httpx.RequestError)):106+ def raise_for_status(self):
107- raise self.exc107+ if self.exc is not None and not self.chunks:
108- 108+ raise self.exc
109- 109+ 
110-class _UnifiedPDDecodeClient:110+ async def aiter_bytes(self):
111- def __init__(111+ if not self.is_success:
112- self,112+ yield self._error_body
113- *,113+ return
114- stream_chunks=None,114+ for chunk in self.chunks:
115- stream_exc: Exception | None = None,115+ yield chunk
116- stream_fail_times: int = 0,116+ if self.exc is not None and not isinstance(self.exc, (httpx.HTTPStatusError, httpx.RequestError)):
117- post_exc: Exception | None = None,117+ raise self.exc
118- post_fail_times: int = 0,118+ 
119- ):119+ 
120- self.stream_chunks = stream_chunks or [120+class _UnifiedPDDecodeClient:
121- b'data: {"choices":[{"delta":{"content":"decoded chunk"},"index":0,"finish_reason":null}]}\n\n',121+ def __init__(
122- ]122+ self,
123- self.stream_exc = stream_exc123+ *,
124- self.stream_fail_times = stream_fail_times124+ stream_chunks=None,
125- self.post_exc = post_exc125+ stream_exc: Exception | None = None,
126- self.post_fail_times = post_fail_times126+ stream_fail_times: int = 0,
127- self.requests = []127+ post_exc: Exception | None = None,
128- self.stream_count = 0128+ post_fail_times: int = 0,
129- self.stream_fail_count = 0129+ ):
130- self.post_count = 0130+ self.stream_chunks = stream_chunks or [
131- self.post_fail_count = 0131+ b'data: {"choices":[{"delta":{"content":"decoded chunk"},"index":0,"finish_reason":null}]}\n\n',
132- self.base_url = "http://decode"132+ ]
133- self.timeout = 1133+ self.stream_exc = stream_exc
134- 134+ self.stream_fail_times = stream_fail_times
135- def stream(self, method, url, json=None, headers=None, timeout=None):135+ self.post_exc = post_exc
136- self.stream_count += 1136+ self.post_fail_times = post_fail_times
137- if json:137+ self.requests = []
138- self.requests.append(json)138+ self.stream_count = 0
139- if self.stream_exc is not None and (139+ self.stream_fail_count = 0
140- self.stream_fail_times == 0 or self.stream_fail_count < self.stream_fail_times140+ self.post_count = 0
141- ):141+ self.post_fail_count = 0
142- self.stream_fail_count += 1142+ self.base_url = "http://decode"
143- return _UnifiedPDStreamResponse([], exc=self.stream_exc)143+ self.timeout = 1
144- return _UnifiedPDStreamResponse(self.stream_chunks)144+ 
145- 145+ def stream(self, method, url, json=None, headers=None, timeout=None):
146- async def post(self, path, json=None, headers=None, timeout=None):146+ self.stream_count += 1
147- self.post_count += 1147+ if json:
148- if json:148+ self.requests.append(json)
149- self.requests.append(json)149+ if self.stream_exc is not None and (
150- if self.post_exc is not None and (self.post_fail_times == 0 or self.post_fail_count < self.post_fail_times):150+ self.stream_fail_times == 0 or self.stream_fail_count < self.stream_fail_times
151- self.post_fail_count += 1151+ ):
152- raise self.post_exc152+ self.stream_fail_count += 1
153- request = httpx.Request("POST", path, headers=headers or {}, json=json)153+ return _UnifiedPDStreamResponse([], exc=self.stream_exc)
154- return httpx.Response(154+ return _UnifiedPDStreamResponse(self.stream_chunks)
155- status_code=200,155+ 
156- json={"choices": [{"message": {"content": "test response"}}]},156+ async def post(self, path, json=None, headers=None, timeout=None):
157- request=request,157+ self.post_count += 1
158- )158+ if json:
159- 159+ self.requests.append(json)
160- 160+ if self.post_exc is not None and (self.post_fail_times == 0 or self.post_fail_count < self.post_fail_times):
161-def _patch_unified_pd_clients(monkeypatch, router_obj, p_client, d_client):161+ self.post_fail_count += 1
162- @asynccontextmanager162+ raise self.post_exc
163- async def _client_for(resource: ScheduledResource):163+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
164- if resource.instance.role == PDRole.ROLE_P:164+ return httpx.Response(
165- yield p_client165+ status_code=200,
166- else:166+ json={"choices": [{"message": {"content": "test response"}}]},
167- yield d_client167+ request=request,
168- 168+ )
169- monkeypatch.setattr(router_obj, "_client_for", _client_for)169+ 
170- 170+ 
171- 171+def _patch_unified_pd_clients(monkeypatch, router_obj, p_client, d_client):
172-def _patch_unified_pd_router_clients(monkeypatch, p_client, d_client):172+ @asynccontextmanager
173- """Patch UnifiedPDRouter._client_for at class level (for app-level integration tests)."""173+ async def _client_for(resource: ScheduledResource):
174- 174+ if resource.instance.role == PDRole.ROLE_P:
175- def _client_for(self, resource: ScheduledResource):175+ yield p_client
176- @asynccontextmanager176+ else:
177- async def _cm():177+ yield d_client
178- if resource.instance.role == PDRole.ROLE_P:178+ 
179- yield p_client179+ monkeypatch.setattr(router_obj, "_client_for", _client_for)
180- else:180+ 
181- yield d_client181+ 
182- 182+def _patch_unified_pd_router_clients(monkeypatch, p_client, d_client):
183- return _cm()183+ """Patch UnifiedPDRouter._client_for at class level (for app-level integration tests)."""
184- 184+ 
185- monkeypatch.setattr(SeparateCDPRouter, "_client_for", _client_for)185+ def _client_for(self, resource: ScheduledResource):
186- 186+ @asynccontextmanager
187- 187+ async def _cm():
188-async def _collect_stream_chunks(response) -> str:188+ if resource.instance.role == PDRole.ROLE_P:
189- chunks = []189+ yield p_client
190- async for chunk in response.body_iterator:190+ else:
191- if isinstance(chunk, bytes):191+ yield d_client
192- chunks.append(chunk.decode("utf-8", errors="replace"))192+ 
193- else:193+ return _cm()
194- chunks.append(chunk)194+ 
195- return "".join(chunks)195+ monkeypatch.setattr(UnifiedPDRouter, "_client_for", _client_for)
196- 196+ 
197- 197+ 
198-def _parse_stream_error_payload(chunk_str: str) -> dict:198+async def _collect_stream_chunks(response) -> str:
199- data_lines = [199+ chunks = []
200- line.removeprefix("data: ").strip()200+ async for chunk in response.body_iterator:
201- for line in chunk_str.splitlines()201+ if isinstance(chunk, bytes):
202- if line.startswith("data: ") and line.strip() != "data: [DONE]"202+ chunks.append(chunk.decode("utf-8", errors="replace"))
203- ]203+ else:
204- assert data_lines, f"expected streaming error chunk, got: {chunk_str!r}"204+ chunks.append(chunk)
205- return json.loads(data_lines[-1])205+ return "".join(chunks)
206- 206+ 
207- 207+ 
208-def _assert_stream_error_chunk(208+def _parse_stream_error_payload(chunk_str: str) -> dict:
209- chunk_str: str,209+ data_lines = [
210- *,210+ line.removeprefix("data: ").strip()
211- error_message: str,211+ for line in chunk_str.splitlines()
212- error_type: str | None = None,212+ if line.startswith("data: ") and line.strip() != "data: [DONE]"
213-) -> None:213+ ]
214- """Assert the SSE error payload propagates the expected message (and optional type)."""214+ assert data_lines, f"expected streaming error chunk, got: {chunk_str!r}"
215- payload = _parse_stream_error_payload(chunk_str)215+ return json.loads(data_lines[-1])
216- # Coordinator-synthesized stream errors use the {"error": {...}} envelope (matching the216+ 
217- # pre-commit / non-stream shape); unwrap it. Engine-verbatim bodies may already be flat.217+ 
218- if isinstance(payload.get("error"), dict):218+def _assert_stream_error_chunk(
219- payload = payload["error"]219+ chunk_str: str,
220- assert error_message in payload["message"], (220+ *,
221- f"expected {error_message!r} in error message, got {payload['message']!r}"221+ error_message: str,
222- )222+ error_type: str | None = None,
223- if error_type is not None:223+) -> None:
224- assert payload["type"] == error_type224+ """Assert the SSE error payload propagates the expected message (and optional type)."""
225- 225+ payload = _parse_stream_error_payload(chunk_str)
226- 226+ # Coordinator-synthesized stream errors use the {"error": {...}} envelope (matching the
227-app = FastAPI()227+ # pre-commit / non-stream shape); unwrap it. Engine-verbatim bodies may already be flat.
228-_config = CoordinatorConfig()228+ if isinstance(payload.get("error"), dict):
229-# CDP separate mode requires worker metaserver; set so app-based tests have a valid config229+ payload = payload["error"]
230-_config.worker_metaserver_port = getattr(_config, "worker_metaserver_port", None) or 12000230+ assert error_message in payload["message"], (
231-_scheduler = Scheduler(instance_provider=InstanceManager(_config), config=_config)231+ f"expected {error_message!r} in error message, got {payload['message']!r}"
232-_request_manager = RequestManager(_config)232+ )
233- 233+ if error_type is not None:
234- 234+ assert payload["type"] == error_type
235-@app.post("/v1/chat/completions")235+ 
236-async def handle_completions(request: Request):236+ 
237- return await router.handle_request(request, _config, scheduler=_scheduler, request_manager=_request_manager)237+app = FastAPI()
238- 238+_config = CoordinatorConfig()
239- 239+_scheduler = Scheduler(instance_provider=InstanceManager(_config), config=_config)
240-@app.post("/v1/metaserver")240+_request_manager = RequestManager(_config)
241-async def handle_metaserver(request: Request):241+ 
242- """Legacy metaserver stub kept for unused MockAsyncClient helpers."""242+ 
243- await request.json()243+@app.post("/v1/chat/completions")
244- return JSONResponse(content={"status": "ok"})244+async def handle_completions(request: Request):
245- 245+ return await router.handle_request(request, _config, scheduler=_scheduler, request_manager=_request_manager)
246- 246+ 
247-class MockAsyncClient:247+ 
248- def __init__(248+class TestRouterNativeHandoff:
249- self,249+ @pytest.fixture(autouse=True)
250- post_exc: Exception = None,250+ def fast_retry(self, monkeypatch: MonkeyPatch):
251- stream_exc: Exception = None,251+ """Skip real backoff in transport retry tests."""
252- post_fail_times: int = 1,252+ 
253- stream_fail_times: int = 1,253+ async def _instant_sleep(*_args, **_kwargs):
254- ):254+ return None
255- self.post_exc = post_exc255+ 
256- self.post_fail_times = post_fail_times256+ monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
257- self.post_count = 0257+ 
258- self.post_fail_count = 0258+ @pytest.fixture
259- 259+ def client(self):
260- self.stream_exc = stream_exc260+ return TestClient(app)
261- self.stream_fail_times = stream_fail_times261+ 
262- self.stream_count = 0262+ @classmethod
263- self.stream_fail_count = 0263+ def create_mock_instance(cls, instance_id, role):
264- 264+ """Create a proper mock Instance object"""
265- self.req_data_from_metaserver = {}265+ mock_instance = Instance(
266- self.req_data_d_request = {} # D request (with metaserver URL), not overwritten by inner post()266+ job_name=f"test-job-{instance_id}",
267- self.req_headers_from_router = {}267+ model_name=f"test-model-{instance_id}",
268- 268+ engine_type="vllm",
269- self.base_url = "test-base-url"269+ id=instance_id,
270- self.timeout = 1270+ role=role,
271- self.is_closed = True271+ status=InsStatus.ACTIVE,
272- 272+ parallel_config=ParallelConfig(dp_size=1, tp_size=1),
273- async def __aenter__(self):273+ endpoints={},
274- return self274+ )
275- 275+ return mock_instance
276- async def __aexit__(self, exc_type, exc_val, exc_tb):276+ 
277- pass277+ def _make_router(self, req_info, monkeypatch, p_client, d_client):
278- 278+ router_obj = UnifiedPDRouter(
279- async def aclose(self):279+ req_info,
280- pass280+ CoordinatorConfig(),
281- 281+ scheduler=Scheduler(
282- async def post(self, url, json=None, headers=None, **kwargs):282+ instance_provider=InstanceManager(CoordinatorConfig()),
283- self.post_count += 1283+ config=CoordinatorConfig(),
284- if self.post_exc and self.post_fail_count < self.post_fail_times:284+ ),
285- self.post_fail_count += 1285+ request_manager=_request_manager,
286- mock_response_fail = MagicMock()286+ )
287- mock_response_fail.raise_for_status = MagicMock(side_effect=self.post_exc)287+ _patch_unified_pd_clients(monkeypatch, router_obj, p_client, d_client)
288- return mock_response_fail288+ return router_obj
289- 289+ 
290- self.req_data_from_metaserver = json290+ @pytest.fixture
291- request = httpx.Request("POST", url, headers=headers or {}, json=json)291+ def setup_native_handoff(self, monkeypatch: MonkeyPatch):
292- 292+ host = "127.0.0.1"
293- return httpx.Response(293+ # Create proper instances for separate P/D flow
294- status_code=status.HTTP_200_OK,294+ mock_instance_p = self.create_mock_instance(0, PDRole.ROLE_P)
295- json={295+ mock_endpoint_p = Endpoint(
296- "choices": [296+ id=0,
297- {297+ ip=host,
298- "delta": {"content": "decoded chunk"},298+ business_port="8000",
299- "index": 0,299+ mgmt_port="8000",
300- "finish_reason": None,300+ status=EndpointStatus.NORMAL,
301- }301+ )
302- ],302+ mock_instance_p.endpoints = {host: {0: mock_endpoint_p}}
303- "id": "chatcmpl-123",303+ 
304- },304+ mock_instance_d = self.create_mock_instance(1, PDRole.ROLE_D)
305- request=request,305+ mock_endpoint_d = Endpoint(
306- )306+ id=1,
307- 307+ ip=host,
308- def stream(self, method, url, json=None, headers=None, **kwargs):308+ business_port="8001",
309- self.stream_count += 1309+ mgmt_port="8001",
310- if json:310+ status=EndpointStatus.NORMAL,
311- self.req_data_from_metaserver = json311+ )
312- self.req_data_d_request = json # keep D request; post() may overwrite req_data_from_metaserver312+ mock_instance_d.endpoints = {host: {1: mock_endpoint_d}}
313- # logger.info(f"----------req_data_from_coordinator:{json}")313+ 
314- if self.stream_exc and self.stream_fail_count < self.stream_fail_times:314+ # Mock functions (Scheduler uses get_required_instances_status for readiness)
315- self.stream_fail_count += 1315+ def mock_get_required_instances_status(self):
316- return MockStreamResponse(json or {}, recomputed=False, exc=self.stream_exc)316+ return InstanceReadiness.REQUIRED_MET
317- 317+ 
318- from urllib.parse import urlparse318+ def mock_has_required_instances(self):
319- 319+ return True
320- client = TestClient(app)320+ 
321- self.req_headers_from_router = headers321+ def mock_get_available_instances(self, role=None):
322- 322+ if role is None:
323- url = json["kv_transfer_params"]["metaserver"]323+ return {
324- parsed_url = urlparse(url)324+ mock_instance_p.id: mock_instance_p,
325- 325+ mock_instance_d.id: mock_instance_d,
326- # Forward request to metaserver326+ }
327- response = None327+ if role == PDRole.ROLE_U: # PD hybrid role
328- try:328+ return {} # No PD hybrid instances, will use separate P/D
329- response = client.post(329+ if role == PDRole.ROLE_P:
330- parsed_url.path,330+ return {mock_instance_p.id: mock_instance_p}
331- json={331+ if role == PDRole.ROLE_D:
332- "request_id": headers.get("X-Request-Id"),332+ return {mock_instance_d.id: mock_instance_d}
333- "do_remote_decode": False,333+ return {}
334- "do_remote_prefill": True,334+ 
335- "remote_engine_id": "test-engine",335+ async def mock_select_instance_and_endpoint(self, role):
336- "remote_host": parsed_url.hostname,336+ if role == PDRole.ROLE_P:
337- "remote_port": str(parsed_url.port),337+ return mock_instance_p, mock_endpoint_p
338- },338+ elif role == PDRole.ROLE_D:
339- )339+ return mock_instance_d, mock_endpoint_d
340- response.raise_for_status()340+ return None, None
341- except Exception as e:341+ 
342- err_text = getattr(response, "text", str(e)) if response is not None else str(e)342+ async def mock_select_and_allocate(
343- err_status = getattr(response, "status_code", 500) if response is not None else 500343+ self,
344- return MockStreamResponse(344+ role,
345- json or {},345+ req_info,
346- recomputed=False,346+ *,
347- exc=httpx.HTTPStatusError(347+ target_instance_id=None,
348- message=err_text,348+ required_engine_type=None,
349- request=MagicMock(),349+ ):
350- response=httpx.Response(status_code=err_status, text=err_text),350+ del required_engine_type
351- ),351+ if role == PDRole.ROLE_P:
352- )352+ return (
353- 353+ mock_instance_p,
354- # Return an async context manager354+ mock_endpoint_p,
355- return MockStreamResponse(json or {}, recomputed=False, exc=None)355+ Workload(active_tokens=1),
356- 356+ )
357- 357+ if role == PDRole.ROLE_D:
358-class MockAsyncClientFirstStreamRecompute(MockAsyncClient):358+ return (
359- """First decode stream simulates recompute after partial output; second completes."""359+ mock_instance_d,
360- 360+ mock_endpoint_d,
361- def stream(self, method, url, json=None, headers=None, **kwargs):361+ Workload(active_tokens=1),
362- self.stream_count += 1362+ )
363- if json:363+ return None
364- self.req_data_from_metaserver = json364+ 
365- self.req_data_d_request = json365+ async def mock_update_workload(self, params):
366- if self.stream_exc and self.stream_fail_count < self.stream_fail_times:366+ return True
367- self.stream_fail_count += 1367+ 
368- return MockStreamResponse(json or {}, recomputed=False, exc=self.stream_exc)368+ monkeypatch.setattr(
369- 369+ InstanceManager,
370- from urllib.parse import urlparse370+ "get_required_instances_status",
371- 371+ mock_get_required_instances_status,
372- client = TestClient(app)372+ )
373- self.req_headers_from_router = headers or {}373+ monkeypatch.setattr(InstanceManager, "has_required_instances", mock_has_required_instances)
374- 374+ monkeypatch.setattr(InstanceManager, "get_available_instances", mock_get_available_instances)
375- url_ms = json["kv_transfer_params"]["metaserver"]375+ monkeypatch.setattr(Scheduler, "select_instance_and_endpoint", mock_select_instance_and_endpoint)
376- parsed_url = urlparse(url_ms)376+ monkeypatch.setattr(Scheduler, "select_and_allocate", mock_select_and_allocate)
377- 377+ monkeypatch.setattr(Scheduler, "update_workload", mock_update_workload)
378- response = None378+ 
379- try:379+ mock_scheduler_config = MagicMock()
380- response = client.post(380+ mock_scheduler_config.scheduler_type = SchedulerType.LOAD_BALANCE
381- parsed_url.path,381+ # Real ExceptionConfig so transport_retry_limit and rescheduling settings work;
382- json={382+ # MagicMock lacks @property implementation and breaks decode transport loops (range / last-attempt check).
383- "request_id": headers.get("X-Request-Id"),383+ mock_exception_config = ExceptionConfig(max_retry=5, retry_delay=0.0001)
384- "do_remote_decode": False,384+ mock_api_config = MagicMock()
385- "do_remote_prefill": True,385+ mock_api_config.coordinator_api_host = "127.0.0.1"
386- "remote_engine_id": "test-engine",386+ mock_tls_config = MagicMock()
387- "remote_host": parsed_url.hostname,387+ mock_tls_config.enable_tls = False
388- "remote_port": str(parsed_url.port),388+ 
389- },389+ mock_config = MagicMock()
390- )390+ mock_config.scheduler_config = mock_scheduler_config
391- response.raise_for_status()391+ mock_config.exception_config = mock_exception_config
392- except Exception as e:392+ mock_config.api_config = mock_api_config
393- err_text = getattr(response, "text", str(e)) if response is not None else str(e)393+ mock_config.infer_tls_config = mock_tls_config
394- err_status = getattr(response, "status_code", 500) if response is not None else 500394+ mock_config.mgmt_tls_config = mock_tls_config
395- return MockStreamResponse(395+ monkeypatch.setattr(CoordinatorConfig, "__new__", lambda cls: mock_config)
396- json or {},396+ _config.exception_config = mock_exception_config
397- recomputed=False,397+ 
398- exc=httpx.HTTPStatusError(398+ @pytest.fixture
399- message=err_text,399+ def mock_raw_request(self):
400- request=MagicMock(),400+ # Mock Request
401- response=httpx.Response(status_code=err_status, text=err_text),401+ mock_req = MagicMock(spec=Request)
402- ),402+ mock_req.body = AsyncMock(return_value=b'{"model": "test"}')
403- )403+ mock_req.json = AsyncMock(return_value={"model": "test"})
404- 404+ mock_req.headers = {}
405- recomputed = self.stream_count == 1405+ mock_req.url.path = "/v1/chat/completions"
406- return MockStreamResponse(json or {}, recomputed=recomputed, exc=None)406+ 
407- 407+ # Must be awaitable so listen_for_disconnect() does not raise; never completes so handler wins.
408- 408+ async def _never_receive():
409-class TestRouterCDPSeparation:409+ await asyncio.Event().wait()
410- @pytest.fixture(autouse=True)410+ 
411- def fast_retry(self, monkeypatch: MonkeyPatch):411+ mock_req.receive = AsyncMock(side_effect=_never_receive)
412- """Skip real backoff and dispatch-stop HTTP in transport retry tests."""412+ return mock_req
413- 413+ 
414- async def _instant_sleep(*_args, **_kwargs):414+ @pytest.mark.asyncio
415- return None415+ async def test_successful_native_handoff_request(self, client, monkeypatch: MonkeyPatch, setup_native_handoff):
416- 416+ """Test case: native handoff request succeeds.
417- async def _noop_dispatch_stop(self, resource, attempt, reason, timeout=1.0):417+ Expected behavior:
418- return None418+ 1) Check request status is DecodeEnd
419- 419+ 2) Return normal response
420- monkeypatch.setattr(asyncio, "sleep", _instant_sleep)420+ """
421- monkeypatch.setattr(421+ p_client = _UnifiedPDPrefillClient()
422- "motor.coordinator.router.stop_client.DispatchStopClient.stop",422+ d_client = _UnifiedPDDecodeClient()
423- _noop_dispatch_stop,423+ 
424- )424+ req_info = await create_mock_request_info()
425- 425+ origin_req_id = req_info.req_id
426- @pytest.fixture426+ origin_req_len = req_info.req_len
427- def client(self):427+ origin_req_data = req_info.req_data
428- return TestClient(app)428+ 
429- 429+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
430- @classmethod430+ response = await handoff_router.handle_request()
431- def create_mock_instance(cls, instance_id, role):431+ chunks = []
432- """Create a proper mock Instance object"""432+ async for chunk in response.body_iterator:
433- mock_instance = Instance(433+ chunks.append(chunk)
434- job_name=f"test-job-{instance_id}",434+ 
435- model_name=f"test-model-{instance_id}",435+ assert response.status_code == status.HTTP_200_OK
436- engine_type="vllm",436+ assert "text/event-stream" in response.headers.get("content-type")
437- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],437+ 
438- id=instance_id,438+ assert len(p_client.requests) == 1
439- role=role,439+ assert len(d_client.requests) == 1
440- status=InsStatus.ACTIVE,440+ req_data_p = p_client.requests[0]
441- parallel_config=ParallelConfig(dp_size=1, tp_size=1),441+ assert req_data_p["stream"] is False
442- endpoints={},442+ assert req_data_p["max_tokens"] == 1
443- )443+ assert "_motor_dispatch" not in req_data_p
444- return mock_instance444+ assert "_motor_dispatch" not in d_client.requests[0]
445- 445+ assert d_client.requests[0]["request_id"] == req_data_p["request_id"]
446- def _make_router(self, req_info, monkeypatch, p_client, d_client):446+ assert d_client.requests[0]["kv_transfer_params"] == {
447- router_obj = SeparateCDPRouter(447+ "do_remote_prefill": True,
448- req_info,448+ "remote_request_id": req_data_p["request_id"],
449- CoordinatorConfig(),449+ "remote_host": "127.0.0.1",
450- scheduler=Scheduler(450+ "remote_port": 9000,
451- instance_provider=InstanceManager(CoordinatorConfig()),451+ }
452- config=CoordinatorConfig(),452+ 
453- ),453+ assert req_info.req_id == origin_req_id
454- request_manager=_request_manager,454+ assert req_info.req_len == origin_req_len
455- )455+ assert req_info.req_data == origin_req_data
456- _patch_unified_pd_clients(monkeypatch, router_obj, p_client, d_client)456+ 
457- return router_obj457+ assert req_info.state == ReqState.DECODE_END
458- 458+ assert req_info.status[ReqState.D_ALLOCATED] >= req_info.status[ReqState.ARRIVE]
459- @pytest.fixture459+ assert req_info.status[ReqState.P_ALLOCATED] >= req_info.status[ReqState.ARRIVE]
460- def setup_cdp_separation(self, monkeypatch: MonkeyPatch):460+ allocated_at = min(
461- host = "127.0.0.1"461+ req_info.status[ReqState.P_ALLOCATED],
462- # Create proper instances for separate P/D flow462+ req_info.status[ReqState.D_ALLOCATED],
463- mock_instance_p = self.create_mock_instance(0, PDRole.ROLE_P)463+ )
464- mock_endpoint_p = Endpoint(464+ assert req_info.status[ReqState.DECODE_END] >= allocated_at
465- id=0,465+ 
466- ip=host,466+ @pytest.mark.asyncio
467- business_port="8000",467+ async def test_native_handoff_stream_recompute_after_partial_output_continues(
468- mgmt_port="8000",468+ self, client, monkeypatch: MonkeyPatch, setup_native_handoff
469- status=EndpointStatus.NORMAL,469+ ):
470- )470+ """Decode stream with recomputed stop_reason still completes successfully."""
471- mock_instance_p.endpoints = {host: {0: mock_endpoint_p}}471+ d_client = _UnifiedPDDecodeClient(
472- 472+ stream_chunks=[
473- mock_instance_d = self.create_mock_instance(1, PDRole.ROLE_D)473+ b'data: {"choices":[{"delta":{"content":"partial "},"index":0,"stop_reason":"recomputed","token_ids":[1,2]}],"prompt_token_ids":[10,11]}\n\n',
474- mock_endpoint_d = Endpoint(474+ b'data: {"choices":[{"delta":{"content":"continuation"},"index":0,"finish_reason":"stop"}]}\n\n',
475- id=1,475+ ]
476- ip=host,476+ )
477- business_port="8001",477+ p_client = _UnifiedPDPrefillClient()
478- mgmt_port="8001",478+ req_info = await create_mock_request_info()
479- status=EndpointStatus.NORMAL,479+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
480- )480+ 
481- mock_instance_d.endpoints = {host: {1: mock_endpoint_d}}481+ response = await handoff_router.handle_request()
482- 482+ chunk_str = await _collect_stream_chunks(response)
483- # Mock functions (Scheduler uses get_required_instances_status for readiness)483+ 
484- def mock_get_required_instances_status(self):484+ assert req_info.state == ReqState.DECODE_END, chunk_str
485- return InstanceReadiness.REQUIRED_MET485+ assert d_client.stream_count == 1
486- 486+ assert "partial" in chunk_str
487- def mock_has_required_instances(self):487+ assert "recompute after first chunk" not in chunk_str
488- return True488+ 
489- 489+ @pytest.mark.asyncio
490- def mock_get_available_instances(self, role=None):490+ async def test_native_engine_decode_4xx_status_code(self, client, monkeypatch: MonkeyPatch, setup_native_handoff):
491- if role is None:491+ """Test case: native decode engine returns a 4XX status code.
492- return {492+ Expected behavior:
493- mock_instance_p.id: mock_instance_p,493+ 1) No request retry triggered
494- mock_instance_d.id: mock_instance_d,494+ 2) Directly return error message
495- }495+ """
496- if role == PDRole.ROLE_U: # PD hybrid role496+ error_message = "Test Bad Request"
497- return {} # No PD hybrid instances, will use separate P/D497+ d_client = _UnifiedPDDecodeClient(
498- if role == PDRole.ROLE_P:498+ stream_exc=httpx.HTTPStatusError(
499- return {mock_instance_p.id: mock_instance_p}499+ message=error_message,
500- if role == PDRole.ROLE_D:500+ request=MagicMock(),
501- return {mock_instance_d.id: mock_instance_d}501+ response=httpx.Response(status_code=status.HTTP_400_BAD_REQUEST, text=error_message),
502- return {}502+ )
503- 503+ )
504- async def mock_select_instance_and_endpoint(self, role):504+ p_client = _UnifiedPDPrefillClient()
505- if role == PDRole.ROLE_P:505+ req_info = await create_mock_request_info()
506- return mock_instance_p, mock_endpoint_p506+ 
507- elif role == PDRole.ROLE_D:507+ release_p_tokens = 0
508- return mock_instance_d, mock_endpoint_d508+ release_d_tokens = 0
509- return None, None509+ original_release = UnifiedPDRouter._release_attempt_resource
510- 510+ 
511- async def mock_select_and_allocate(self, role, req_info, *, target_instance_id=None):511+ async def mock_release_attempt_resource(self, resource, attempt_seq, action, attempt=None, **kwargs):
512- if role == PDRole.ROLE_P:512+ nonlocal release_p_tokens, release_d_tokens
513- return (513+ if resource.instance.role == PDRole.ROLE_P:
514- mock_instance_p,514+ if action == WorkloadAction.RELEASE_TOKENS:
515- mock_endpoint_p,515+ release_p_tokens += 1
516- Workload(active_tokens=1),516+ elif resource.instance.role == PDRole.ROLE_D:
517- )517+ if action == WorkloadAction.RELEASE_TOKENS:
518- if role == PDRole.ROLE_D:518+ release_d_tokens += 1
519- return (519+ await original_release(self, resource, attempt_seq, action, attempt, **kwargs)
520- mock_instance_d,520+ 
521- mock_endpoint_d,521+ monkeypatch.setattr(
522- Workload(active_tokens=1),522+ UnifiedPDRouter,
523- )523+ "_release_attempt_resource",
524- return None524+ mock_release_attempt_resource,
525- 525+ )
526- async def mock_update_workload(self, params):526+ 
527- return True527+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
528- 528+ response = await handoff_router.handle_request()
529- monkeypatch.setattr(529+ chunk_str = await _collect_stream_chunks(response)
530- InstanceManager,530+ 
531- "get_required_instances_status",531+ assert req_info.state == ReqState.EXCEPTION
532- mock_get_required_instances_status,532+ _assert_stream_error_chunk(chunk_str, error_message=error_message, error_type="UpstreamHTTPError")
533- )533+ assert str(status.HTTP_400_BAD_REQUEST) in chunk_str
534- monkeypatch.setattr(InstanceManager, "has_required_instances", mock_has_required_instances)534+ assert d_client.stream_count == 1
535- monkeypatch.setattr(InstanceManager, "get_available_instances", mock_get_available_instances)535+ assert release_d_tokens >= 1
536- monkeypatch.setattr(Scheduler, "select_instance_and_endpoint", mock_select_instance_and_endpoint)536+ assert release_p_tokens >= 1
537- monkeypatch.setattr(Scheduler, "select_and_allocate", mock_select_and_allocate)537+ 
538- monkeypatch.setattr(Scheduler, "update_workload", mock_update_workload)538+ @pytest.mark.asyncio
539- 539+ async def test_native_engine_decode_continuous_5xx_status_code(
540- mock_scheduler_config = MagicMock()540+ self,
541- mock_scheduler_config.scheduler_type = SchedulerType.LOAD_BALANCE541+ client,
542- # Real ExceptionConfig so transport_retry_limit and rescheduling settings work;542+ monkeypatch: MonkeyPatch,
543- # MagicMock lacks @property implementation and breaks decode transport loops (range / last-attempt check).543+ setup_native_handoff,
544- mock_exception_config = ExceptionConfig(max_retry=5, retry_delay=0.0001)544+ caplog: pytest.LogCaptureFixture,
545- mock_api_config = MagicMock()545+ ):
546- mock_api_config.coordinator_api_host = "127.0.0.1"546+ """Decode keeps getting 5XX with the same message: retries exhaust, error chunk returned;
547- mock_tls_config = MagicMock()547+ identical-error logs: one ERROR + (max_retry-1) WARNING dedup lines.
548- mock_tls_config.enable_tls = False548+ """
549- 549+ error_message = "Test Internal Server Error"
550- mock_config = MagicMock()550+ max_retry = CoordinatorConfig().exception_config.transport_retry_limit
551- mock_config.scheduler_config = mock_scheduler_config551+ d_client = _UnifiedPDDecodeClient(
552- mock_config.exception_config = mock_exception_config552+ stream_exc=httpx.HTTPStatusError(
553- mock_config.api_config = mock_api_config553+ message=error_message,
554- mock_config.infer_tls_config = mock_tls_config554+ request=MagicMock(),
555- mock_config.mgmt_tls_config = mock_tls_config555+ response=httpx.Response(
556- # CDP separate requires worker metaserver; use a fixed port for test556+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
557- mock_config.worker_metaserver_port = 12000557+ text=error_message,
558- 558+ ),
559- monkeypatch.setattr(CoordinatorConfig, "__new__", lambda cls: mock_config)559+ )
560- _config.exception_config = mock_exception_config560+ )
561- 561+ p_client = _UnifiedPDPrefillClient()
562- @pytest.fixture562+ req_info = await create_mock_request_info()
563- def mock_raw_request(self):563+ 
564- # Mock Request564+ exec_release = 0
565- mock_req = MagicMock(spec=Request)565+ original_release = UnifiedPDRouter._release_attempt_resource
566- mock_req.body = AsyncMock(return_value=b'{"model": "test"}')566+ 
567- mock_req.json = AsyncMock(return_value={"model": "test"})567+ async def mock_release_attempt_resource(self, resource, attempt_seq, action, attempt=None, **kwargs):
568- mock_req.headers = {}568+ nonlocal exec_release
569- mock_req.url.path = "/v1/chat/completions"569+ exec_release += 1
570- 570+ await original_release(self, resource, attempt_seq, action, attempt, **kwargs)
571- # Must be awaitable so listen_for_disconnect() does not raise; never completes so handler wins.571+ 
572- async def _never_receive():572+ monkeypatch.setattr(
573- await asyncio.Event().wait()573+ UnifiedPDRouter,
574- 574+ "_release_attempt_resource",
575- mock_req.receive = AsyncMock(side_effect=_never_receive)575+ mock_release_attempt_resource,
576- return mock_req576+ )
577- 577+ 
578- @pytest.mark.asyncio578+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
579- async def test_successful_request_with_separate_cdp(self, client, monkeypatch: MonkeyPatch, setup_cdp_separation):579+ response = await handoff_router.handle_request()
580- """Test case: CDP separation mode request success580+ chunk_str = await _collect_stream_chunks(response)
581- Expected behavior:581+ 
582- 1) Check request status is DecodeEnd582+ assert req_info.state == ReqState.EXCEPTION
583- 2) Return normal response583+ _assert_stream_error_chunk(chunk_str, error_message=error_message, error_type="UpstreamHTTPError")
584- """584+ assert str(status.HTTP_500_INTERNAL_SERVER_ERROR) in chunk_str
585- p_client = _UnifiedPDPrefillClient()585+ assert d_client.stream_count == max_retry
586- d_client = _UnifiedPDDecodeClient()586+ assert exec_release >= 1
587- 587+ 
588- req_info = await create_mock_request_info()588+ @pytest.mark.asyncio
589- origin_req_id = req_info.req_id589+ async def test_native_engine_decode_once_5xx_status_code(
590- origin_req_len = req_info.req_len590+ self, client, monkeypatch: MonkeyPatch, setup_native_handoff
591- origin_req_data = req_info.req_data591+ ):
592- 592+ """Test case: native decode request first returns 5XX, then 200.
593- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)593+ Expected behavior:
594- response = await cdp_router.handle_request()594+ 1) Check request status is DecodeEnd
595- chunks = []595+ 2) Trigger request retry
596- async for chunk in response.body_iterator:596+ 3) Request retry succeeds
597- chunks.append(chunk)597+ """
598- 598+ error_message = "Test Internal Server Error"
599- assert response.status_code == status.HTTP_200_OK599+ d_client = _UnifiedPDDecodeClient(
600- assert "text/event-stream" in response.headers.get("content-type")600+ stream_exc=httpx.HTTPStatusError(
601- 601+ message=error_message,
602- assert len(p_client.requests) == 1602+ request=MagicMock(),
603- assert len(d_client.requests) == 1603+ response=httpx.Response(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR),
604- req_data_p = p_client.requests[0]604+ ),
605- assert req_data_p["stream"] is False605+ stream_fail_times=1,
606- assert req_data_p["max_tokens"] == 1606+ )
607- assert req_data_p[MOTOR_DISPATCH_KEY]["role"] == "prefill"607+ p_client = _UnifiedPDPrefillClient()
608- assert d_client.requests[0][MOTOR_DISPATCH_KEY]["role"] == "decode"608+ req_info = await create_mock_request_info()
609- assert req_data_p[MOTOR_DISPATCH_KEY]["pair_id"] == d_client.requests[0][MOTOR_DISPATCH_KEY]["pair_id"]609+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
610- 610+ 
611- assert req_info.req_id == origin_req_id611+ response = await handoff_router.handle_request()
612- assert req_info.req_len == origin_req_len612+ chunk_str = await _collect_stream_chunks(response)
613- assert req_info.req_data == origin_req_data613+ 
614- 614+ assert response.status_code == status.HTTP_200_OK
615- assert req_info.state == ReqState.DECODE_END615+ assert d_client.stream_fail_count == 1
616- assert req_info.status[ReqState.D_ALLOCATED] >= req_info.status[ReqState.ARRIVE]616+ assert d_client.stream_count >= 2
617- assert req_info.status[ReqState.P_ALLOCATED] >= req_info.status[ReqState.ARRIVE]617+ assert req_info.state == ReqState.DECODE_END
618- allocated_at = min(618+ assert "decoded chunk" in chunk_str
619- req_info.status[ReqState.P_ALLOCATED],619+ 
620- req_info.status[ReqState.D_ALLOCATED],620+ @pytest.mark.asyncio
621- )621+ async def test_native_engine_decode_network_exception(self, client, monkeypatch: MonkeyPatch, setup_native_handoff):
622- assert req_info.status[ReqState.DECODE_END] >= allocated_at622+ """Test case: native decode engine raises a network exception.
623- 623+ Expected behavior:
624- @pytest.mark.asyncio624+ 1) Check request status is Exception
625- async def test_cdp_stream_recompute_after_partial_output_continues(625+ 2) Retries exhaust transport_retry_limit
626- self, client, monkeypatch: MonkeyPatch, setup_cdp_separation626+ 3) Directly return error message
627- ):627+ """
628- """Decode stream with recomputed stop_reason still completes successfully."""628+ error_message = "Connection error"
629- d_client = _UnifiedPDDecodeClient(629+ max_retry = CoordinatorConfig().exception_config.transport_retry_limit
630- stream_chunks=[630+ d_client = _UnifiedPDDecodeClient(
631- b'data: {"choices":[{"delta":{"content":"partial "},"index":0,"stop_reason":"recomputed","token_ids":[1,2]}],"prompt_token_ids":[10,11]}\n\n',631+ stream_exc=httpx.ConnectError(error_message, request=MagicMock()),
632- b'data: {"choices":[{"delta":{"content":"continuation"},"index":0,"finish_reason":"stop"}]}\n\n',632+ stream_fail_times=max_retry,
633- ]633+ )
634- )634+ p_client = _UnifiedPDPrefillClient()
635- p_client = _UnifiedPDPrefillClient()635+ req_info = await create_mock_request_info()
636- req_info = await create_mock_request_info()636+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
637- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)637+ 
638- 638+ response = await handoff_router.handle_request()
639- response = await cdp_router.handle_request()639+ chunk_str = await _collect_stream_chunks(response)
640- chunk_str = await _collect_stream_chunks(response)640+ 
641- 641+ _assert_stream_error_chunk(chunk_str, error_message=error_message, error_type="ConnectError")
642- assert req_info.state == ReqState.DECODE_END, chunk_str642+ assert d_client.stream_count == max_retry
643- assert d_client.stream_count == 1643+ assert d_client.stream_fail_count == max_retry
644- assert "partial" in chunk_str644+ assert req_info.state == ReqState.EXCEPTION
645- assert "recompute after first chunk" not in chunk_str645+ 
646- 646+ @pytest.mark.asyncio
647- @pytest.mark.asyncio647+ async def test_native_handoff_decode_non_stream_retry_exhausts_transport_limit(
648- async def test_cdp_requires_worker_metaserver_port(self, setup_cdp_separation, monkeypatch: MonkeyPatch):648+ self, client, monkeypatch: MonkeyPatch, setup_native_handoff
649- """UnifiedPD no longer requires worker_metaserver_port on the coordinator."""649+ ):
650- req_info = await create_mock_request_info()650+ """Non-stream decode failures retry whole transport attempts until limit."""
651- p_client = _UnifiedPDPrefillClient()651+ error_message = "Same post Decode error every retry"
652- d_client = _UnifiedPDDecodeClient()652+ max_retry = CoordinatorConfig().exception_config.transport_retry_limit
653- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)653+ d_client = _UnifiedPDDecodeClient(
654- response = await cdp_router.handle_request()654+ post_exc=httpx.HTTPStatusError(
655- assert response.status_code == status.HTTP_200_OK655+ message=error_message,
656- await _collect_stream_chunks(response)656+ request=MagicMock(),
657- assert req_info.state == ReqState.DECODE_END657+ response=httpx.Response(status_code=status.HTTP_502_BAD_GATEWAY, text=error_message),
658- 658+ ),
659- @pytest.mark.asyncio659+ post_fail_times=max_retry,
660- async def test_engine_server_decode_4xx_status_code(self, client, monkeypatch: MonkeyPatch, setup_cdp_separation):660+ )
661- """Test case: Decode EngineServer returns 4XX status code661+ p_client = _UnifiedPDPrefillClient()
662- Expected behavior:662+ req_info = await create_mock_request_info(stream=False)
663- 1) No request retry triggered663+ 
664- 2) Directly return error message664+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
665- """665+ 
666- error_message = "Test Bad Request"666+ with pytest.raises(httpx.HTTPStatusError):
667- d_client = _UnifiedPDDecodeClient(667+ await handoff_router.handle_request()
668- stream_exc=httpx.HTTPStatusError(668+ 
669- message=error_message,669+ assert d_client.post_count == max_retry
670- request=MagicMock(),670+ assert d_client.post_fail_count == max_retry
671- response=httpx.Response(status_code=status.HTTP_400_BAD_REQUEST, text=error_message),671+ assert req_info.state == ReqState.EXCEPTION
672- )672+ 
673- )673+ @pytest.mark.asyncio
674- p_client = _UnifiedPDPrefillClient()674+ async def test_native_engine_prefill_network_exception(
675- req_info = await create_mock_request_info()675+ self, client, monkeypatch: MonkeyPatch, setup_native_handoff
676- 676+ ):
677- release_p_tokens = 0677+ """Test case: native prefill engine raises a network exception.
678- release_d_tokens = 0678+ Expected behavior:
679- original_release = SeparateCDPRouter._release_attempt_resource679+ 1) Check request status is Exception
680- 680+ 2) Retries exhaust transport_retry_limit
681- async def mock_release_attempt_resource(self, resource, attempt_seq, action, attempt=None, **kwargs):681+ 3) Directly return error message
682- nonlocal release_p_tokens, release_d_tokens682+ """
683- if resource.instance.role == PDRole.ROLE_P:683+ error_message = "Connection error"
684- if action == WorkloadAction.RELEASE_TOKENS:684+ retry_times = CoordinatorConfig().exception_config.transport_retry_limit
685- release_p_tokens += 1685+ p_client = _UnifiedPDPrefillClient(
686- elif resource.instance.role == PDRole.ROLE_D:686+ exc=httpx.ConnectError(message=error_message, request=MagicMock()),
687- if action == WorkloadAction.RELEASE_TOKENS:687+ post_fail_times=retry_times,
688- release_d_tokens += 1688+ )
689- await original_release(self, resource, attempt_seq, action, attempt, **kwargs)689+ d_client = _UnifiedPDDecodeClient()
690- 690+ _patch_unified_pd_router_clients(monkeypatch, p_client, d_client)
691- monkeypatch.setattr(691+ 
692- SeparateCDPRouter,692+ state: ReqState = None
693- "_release_attempt_resource",693+ 
694- mock_release_attempt_resource,694+ def mock_update_state(self, new_state: ReqState):
695- )695+ nonlocal state
696- 696+ state = new_state
697- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)697+ 
698- response = await cdp_router.handle_request()698+ monkeypatch.setattr(RequestInfo, "update_state", mock_update_state)
699- chunk_str = await _collect_stream_chunks(response)699+ 
700- 700+ with client.stream(
701- assert req_info.state == ReqState.EXCEPTION701+ "POST",
702- _assert_stream_error_chunk(chunk_str, error_message=error_message, error_type="UpstreamHTTPError")702+ "/v1/chat/completions",
703- assert str(status.HTTP_400_BAD_REQUEST) in chunk_str703+ json={
704- assert d_client.stream_count == 1704+ "model": "test-model",
705- assert release_d_tokens >= 1705+ "messages": [{"role": "user", "content": "Hello"}],
706- assert release_p_tokens >= 1706+ },
707- 707+ ) as response:
708- @pytest.mark.asyncio708+ chunks = []
709- async def test_engine_server_decode_continuous_5xx_status_code(709+ for chunk in response.iter_lines():
710- self,710+ chunks.append(chunk)
711- client,711+ chunk_str = "".join(chunks)
712- monkeypatch: MonkeyPatch,712+ 
713- setup_cdp_separation,713+ assert error_message in chunk_str
714- caplog: pytest.LogCaptureFixture,714+ assert p_client.post_fail_count == retry_times
715- ):715+ assert state == ReqState.EXCEPTION
716- """Decode keeps getting 5XX with the same message: retries exhaust, error chunk returned;716+ 
717- identical-error logs: one ERROR + (max_retry-1) WARNING dedup lines.717+ @pytest.mark.asyncio
718- """718+ async def test_degradation_to_single_node(
719- error_message = "Test Internal Server Error"719+ self, monkeypatch: MonkeyPatch, setup_native_handoff, mock_raw_request, client
720- max_retry = CoordinatorConfig().exception_config.transport_retry_limit720+ ):
721- d_client = _UnifiedPDDecodeClient(721+ """
722- stream_exc=httpx.HTTPStatusError(722+ Test that when no ROLE_D instances are available, the router degrades to SINGLE_NODE mode
723- message=error_message,723+ and uses PDHybridRouter.
724- request=MagicMock(),724+ """
725- response=httpx.Response(725+ # Let listen_for_disconnect() exit immediately so the task does not hang (avoids WSL Terminated
726- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,726+ # when handler and disconnect run concurrently and disconnect awaits a never-completing receive).
727- text=error_message,727+ disconnect_msg = {"type": "http.disconnect"}
728- ),728+ mock_raw_request.receive = AsyncMock(return_value=disconnect_msg)
729- )729+ 
730- )730+ # Mock InstanceManager.get_available_instances
731- p_client = _UnifiedPDPrefillClient()731+ host = "127.0.0.1"
732- req_info = await create_mock_request_info()732+ mock_instance_p = self.create_mock_instance(0, PDRole.ROLE_P)
733- 733+ mock_endpoint_p = Endpoint(id=0, ip=host, business_port="8000", mgmt_port="8000")
734- exec_release = 0734+ mock_instance_p.endpoints = {host: {0: mock_endpoint_p}}
735- original_release = SeparateCDPRouter._release_attempt_resource735+ 
736- 736+ def mock_get_available_instances(self, role=None):
737- async def mock_release_attempt_resource(self, resource, attempt_seq, action, attempt=None, **kwargs):737+ if role is None:
738- nonlocal exec_release738+ return {mock_instance_p.id: mock_instance_p}
739- exec_release += 1739+ if role == PDRole.ROLE_U: # PD hybrid role
740- await original_release(self, resource, attempt_seq, action, attempt, **kwargs)740+ return {}
741- 741+ elif role == PDRole.ROLE_P:
742- monkeypatch.setattr(742+ return {mock_instance_p.id: mock_instance_p}
743- SeparateCDPRouter,743+ elif role == PDRole.ROLE_D:
744- "_release_attempt_resource",744+ return {}
745- mock_release_attempt_resource,745+ return {}
746- )746+ 
747- 747+ monkeypatch.setattr(InstanceManager, "get_available_instances", mock_get_available_instances)
748- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)748+ 
749- response = await cdp_router.handle_request()749+ # So router chooses SINGLE_NODE (PDHybridRouter) before creating the router
750- chunk_str = await _collect_stream_chunks(response)750+ def mock_get_required_instances_status(self):
751- 751+ return InstanceReadiness.ONLY_PREFILL # not ready -> fallback to SINGLE_NODE
752- assert req_info.state == ReqState.EXCEPTION752+ 
753- _assert_stream_error_chunk(chunk_str, error_message=error_message, error_type="UpstreamHTTPError")753+ monkeypatch.setattr(
754- assert str(status.HTTP_500_INTERNAL_SERVER_ERROR) in chunk_str754+ InstanceManager,
755- assert d_client.stream_count == max_retry755+ "get_required_instances_status",
756- assert exec_release >= 1756+ mock_get_required_instances_status,
757- 757+ )
758- @pytest.mark.asyncio758+ 
759- async def test_engine_server_decode_once_5xx_status_code(759+ def mock_has_required_instances(self):
760- self, client, monkeypatch: MonkeyPatch, setup_cdp_separation760+ return False
761- ):761+ 
762- """Test case: EngineServer Decode request first returns 5XX, then 200.762+ monkeypatch.setattr(InstanceManager, "has_required_instances", mock_has_required_instances)
763- Expected behavior:763+ 
764- 1) Check request status is DecodeEnd764+ def mock_select_instance_and_endpoint(self, role):
765- 2) Trigger request retry765+ if role == PDRole.ROLE_P:
766- 3) Request retry succeeds766+ return mock_instance_p, mock_endpoint_p
767- """767+ elif role == PDRole.ROLE_D:
768- error_message = "Test Internal Server Error"768+ return None, None
769- d_client = _UnifiedPDDecodeClient(769+ return None, None
770- stream_exc=httpx.HTTPStatusError(770+ 
771- message=error_message,771+ monkeypatch.setattr(Scheduler, "select_instance_and_endpoint", mock_select_instance_and_endpoint)
772- request=MagicMock(),772+ 
773- response=httpx.Response(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR),773+ # Mock PDHybridRouter response
774- ),774+ mock_response = "mock_message"
775- stream_fail_times=1,775+ with patch(
776- )776+ "motor.coordinator.router.dispatch.PDHybridRouter.handle_request",
777- p_client = _UnifiedPDPrefillClient()777+ new_callable=AsyncMock,
778- req_info = await create_mock_request_info()778+ return_value=mock_response,
779- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)779+ ) as mock_handle_request:
780- 780+ response = await router.handle_request(
781- response = await cdp_router.handle_request()781+ mock_raw_request,
782- chunk_str = await _collect_stream_chunks(response)782+ CoordinatorConfig(),
783- 783+ scheduler=_scheduler,
784- assert response.status_code == status.HTTP_200_OK784+ request_manager=_request_manager,
785- assert d_client.stream_fail_count == 1785+ )
786- assert d_client.stream_count >= 2786+ # Verify PDHybridRouter.handle_request was called
787- assert req_info.state == ReqState.DECODE_END787+ mock_handle_request.assert_called_once()
788- assert "decoded chunk" in chunk_str788+ # Verify response
789- 789+ assert response == mock_response
790- @pytest.mark.asyncio790+ 
791- async def test_engine_server_decode_network_exception(self, client, monkeypatch: MonkeyPatch, setup_cdp_separation):791+ @pytest.mark.asyncio
792- """Test case: EngineServer Decode network exception792+ async def test_no_degradation_when_d_instances_exist(self, monkeypatch, setup_native_handoff, mock_raw_request):
793- Expected behavior:793+ """
794- 1) Check request status is Exception794+ Test that when ROLE_D instances are available, the router uses the configured mode (PD_SEPARATE).
795- 2) Retries exhaust transport_retry_limit795+ """
796- 3) Directly return error message796+ # Let listen_for_disconnect() exit immediately (same as test_degradation_to_single_node).
797- """797+ disconnect_msg = {"type": "http.disconnect"}
798- error_message = "Connection error"798+ mock_raw_request.receive = AsyncMock(return_value=disconnect_msg)
799- max_retry = CoordinatorConfig().exception_config.transport_retry_limit799+ 
800- d_client = _UnifiedPDDecodeClient(800+ # Mock UnifiedPDRouter response
801- stream_exc=httpx.ConnectError(error_message, request=MagicMock()),801+ mock_response = "mock_message"
802- stream_fail_times=max_retry,802+ with patch(
803- )803+ "motor.coordinator.router.dispatch.UnifiedPDRouter.handle_request",
804- p_client = _UnifiedPDPrefillClient()804+ new_callable=AsyncMock,
805- req_info = await create_mock_request_info()805+ return_value=mock_response,
806- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)806+ ) as mock_handle_request:
807- 807+ response = await router.handle_request(
808- response = await cdp_router.handle_request()808+ mock_raw_request,
809- chunk_str = await _collect_stream_chunks(response)809+ CoordinatorConfig(),
810- 810+ scheduler=_scheduler,
811- _assert_stream_error_chunk(chunk_str, error_message=error_message, error_type="ConnectError")811+ request_manager=_request_manager,
812- assert d_client.stream_count == max_retry812+ )
813- assert d_client.stream_fail_count == max_retry813+ 
814- assert req_info.state == ReqState.EXCEPTION814+ # Verify UnifiedPDRouter.handle_request was called
815- 815+ mock_handle_request.assert_called_once()
816- @pytest.mark.asyncio816+ # Verify response
817- async def test_cdp_decode_non_stream_retry_exhausts_transport_limit(817+ assert response == mock_response
818- self, client, monkeypatch: MonkeyPatch, setup_cdp_separation818+ 
819- ):819+ @pytest.mark.parametrize(
820- """Non-stream decode failures retry whole transport attempts until limit."""820+ ("prefill_details", "expected_details"),
821- error_message = "Same post Decode error every retry"821+ [
822- max_retry = CoordinatorConfig().exception_config.transport_retry_limit822+ ({"cached_tokens": 10}, {"cached_tokens": 10}),
823- d_client = _UnifiedPDDecodeClient(823+ (None, {"cached_tokens": 0}),
824- post_exc=httpx.HTTPStatusError(824+ ],
825- message=error_message,825+ )
826- request=MagicMock(),826+ @pytest.mark.asyncio
827- response=httpx.Response(status_code=status.HTTP_502_BAD_GATEWAY, text=error_message),827+ async def test_prompt_tokens_details_propagation(
828- ),828+ self,
829- post_fail_times=max_retry,829+ client,
830- )830+ monkeypatch: MonkeyPatch,
831- p_client = _UnifiedPDPrefillClient()831+ setup_native_handoff,
832- req_info = await create_mock_request_info(stream=False)832+ prefill_details,
833- 833+ expected_details,
834- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)834+ ):
835- 835+ """UnifiedPD returns prompt cache details collected from the prefill response."""
836- with pytest.raises(httpx.HTTPStatusError):836+ req_info = await create_mock_request_info(stream=False)
837- await cdp_router.handle_request()837+ 
838- 838+ class _PrefillClient(_UnifiedPDPrefillClient):
839- assert d_client.post_count == max_retry839+ async def post(self, path, json=None, headers=None, timeout=None):
840- assert d_client.post_fail_count == max_retry840+ self.requests.append(json)
841- assert req_info.state == ReqState.EXCEPTION841+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
842- 842+ return httpx.Response(
843- @pytest.mark.asyncio843+ status_code=200,
844- async def test_engine_server_prefill_network_exception(844+ json=_native_prefill_body(
845- self, client, monkeypatch: MonkeyPatch, setup_cdp_separation845+ json,
846- ):846+ usage={"prompt_tokens_details": prefill_details},
847- """Test case: EngineServer prefill network exception847+ ),
848- Expected behavior:848+ request=request,
849- 1) Check request status is Exception849+ )
850- 2) Retries exhaust transport_retry_limit850+ 
851- 3) Directly return error message851+ class _DecodeClient(_UnifiedPDDecodeClient):
852- """852+ async def post(self, path, json=None, headers=None, timeout=None):
853- error_message = "Connection error"853+ self.post_count += 1
854- retry_times = CoordinatorConfig().exception_config.transport_retry_limit854+ if json:
855- p_client = _UnifiedPDPrefillClient(855+ self.requests.append(json)
856- exc=httpx.ConnectError(message=error_message, request=MagicMock()),856+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
857- post_fail_times=retry_times,857+ return httpx.Response(
858- )858+ status_code=200,
859- d_client = _UnifiedPDDecodeClient()859+ json={
860- _patch_unified_pd_router_clients(monkeypatch, p_client, d_client)860+ "choices": [{"message": {"content": "test response"}}],
861- 861+ "usage": {
862- state: ReqState = None862+ "prompt_tokens": 15,
863- 863+ "completion_tokens": 1,
864- def mock_update_state(self, new_state: ReqState):864+ "total_tokens": 16,
865- nonlocal state865+ },
866- state = new_state866+ },
867- 867+ request=request,
868- monkeypatch.setattr(RequestInfo, "update_state", mock_update_state)868+ )
869- 869+ 
870- with client.stream(870+ p_client = _PrefillClient()
871- "POST",871+ d_client = _DecodeClient()
872- "/v1/chat/completions",872+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
873- json={873+ 
874- "model": "test-model",874+ response = await handoff_router.handle_request()
875- "messages": [{"role": "user", "content": "Hello"}],875+ response_json = response.body.decode() if hasattr(response.body, "decode") else response.body
876- },876+ response_data = json.loads(response_json)
877- ) as response:877+ 
878- chunks = []878+ assert req_info.prompt_tokens_details == expected_details
879- for chunk in response.iter_lines():879+ assert response_data["choices"][0]["message"]["content"] == "test response"
880- chunks.append(chunk)880+ assert response_data["usage"]["prompt_tokens_details"] == expected_details
881- chunk_str = "".join(chunks)881+ 
882- 882+ @pytest.mark.asyncio
883- assert error_message in chunk_str883+ async def test_stream_prompt_tokens_details_when_recompute_disabled(
884- assert p_client.post_fail_count == retry_times884+ self, client, monkeypatch: MonkeyPatch, setup_native_handoff
885- assert state == ReqState.EXCEPTION885+ ):
886- 886+ """Prompt cache details are independent of the recompute feature switch."""
887- @pytest.mark.asyncio887+ prompt_tokens_details = {"cached_tokens": 10}
888- async def test_degradation_to_single_node(888+ req_info = await create_mock_request_info(stream=True)
889- self, monkeypatch: MonkeyPatch, setup_cdp_separation, mock_raw_request, client889+ 
890- ):890+ usage_chunk = b'data: {"choices":[],"usage":{"prompt_tokens":15,"completion_tokens":1,"total_tokens":16}}\n\n'
891- """891+ 
892- Test that when no ROLE_D instances are available, the router degrades to SINGLE_NODE mode892+ class _DelayedPrefillClient(_UnifiedPDPrefillClient):
893- and uses PDHybridRouter.893+ async def post(self, path, json=None, headers=None, timeout=None):
894- """894+ self.requests.append(json)
895- # Let listen_for_disconnect() exit immediately so the task does not hang (avoids WSL Terminated895+ await asyncio.sleep(0.01)
896- # when handler and disconnect run concurrently and disconnect awaits a never-completing receive).896+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
897- disconnect_msg = {"type": "http.disconnect"}897+ return httpx.Response(
898- mock_raw_request.receive = AsyncMock(return_value=disconnect_msg)898+ status_code=200,
899- 899+ json=_native_prefill_body(
900- # Mock InstanceManager.get_available_instances900+ json,
901- host = "127.0.0.1"901+ usage={"prompt_tokens_details": prompt_tokens_details},
902- mock_instance_p = self.create_mock_instance(0, PDRole.ROLE_P)902+ ),
903- mock_endpoint_p = Endpoint(id=0, ip=host, business_port="8000", mgmt_port="8000")903+ request=request,
904- mock_instance_p.endpoints = {host: {0: mock_endpoint_p}}904+ )
905- 905+ 
906- def mock_get_available_instances(self, role=None):906+ p_client = _DelayedPrefillClient()
907- if role is None:907+ d_client = _UnifiedPDDecodeClient(stream_chunks=[usage_chunk])
908- return {mock_instance_p.id: mock_instance_p}908+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
909- if role == PDRole.ROLE_U: # PD hybrid role909+ handoff_router.config.exception_config.reschedule_enabled = False
910- return {}910+ 
911- elif role == PDRole.ROLE_P:911+ response = await handoff_router.handle_request()
912- return {mock_instance_p.id: mock_instance_p}912+ chunks = [chunk async for chunk in response.body_iterator]
913- elif role == PDRole.ROLE_D:913+ response_data = json.loads(chunks[0].removeprefix(b"data: ").strip())
914- return {}914+ 
915- return {}915+ assert response_data["usage"]["prompt_tokens_details"] == prompt_tokens_details
916- 916+ 
917- monkeypatch.setattr(InstanceManager, "get_available_instances", mock_get_available_instances)917+ @pytest.mark.asyncio
918- 918+ async def test_native_handoff_nonstream_strips_token_ids_at_coordinator_exit(
919- # So router chooses SINGLE_NODE (PDHybridRouter) before creating the router919+ self, client, monkeypatch: MonkeyPatch, setup_native_handoff
920- def mock_get_required_instances_status(self):920+ ):
921- return InstanceReadiness.ONLY_PREFILL # not ready -> fallback to SINGLE_NODE921+ """UnifiedPD non-stream strips token ids and normalizes stop_reason before returning to client."""
922- 922+ req_info = await create_mock_request_info(stream=False)
923- monkeypatch.setattr(923+ req_info.entry_api = req_info.api
924- InstanceManager,924+ 
925- "get_required_instances_status",925+ recomputed_body = {
926- mock_get_required_instances_status,926+ "prompt_token_ids": [1, 2],
927- )927+ "choices": [
928- 928+ {
929- def mock_has_required_instances(self):929+ "message": {"role": "assistant", "content": "partial "},
930- return False930+ "stop_reason": "recomputed",
931- 931+ "token_ids": [3, 4],
932- monkeypatch.setattr(InstanceManager, "has_required_instances", mock_has_required_instances)932+ }
933- 933+ ],
934- def mock_select_instance_and_endpoint(self, role):934+ "usage": {"completion_tokens": 2},
935- if role == PDRole.ROLE_P:935+ }
936- return mock_instance_p, mock_endpoint_p936+ 
937- elif role == PDRole.ROLE_D:937+ class _RecomputedDecodeClient(_UnifiedPDDecodeClient):
938- return None, None938+ async def post(self, path, json=None, headers=None, timeout=None):
939- return None, None939+ self.post_count += 1
940- 940+ if json:
941- monkeypatch.setattr(Scheduler, "select_instance_and_endpoint", mock_select_instance_and_endpoint)941+ self.requests.append(json)
942- 942+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
943- # Mock PDHybridRouter response943+ return httpx.Response(status_code=200, json=recomputed_body, request=request)
944- mock_response = "mock_message"944+ 
945- with patch(945+ p_client = _UnifiedPDPrefillClient()
946- "motor.coordinator.router.dispatch.PDHybridRouter.handle_request",946+ d_client = _RecomputedDecodeClient()
947- new_callable=AsyncMock,947+ handoff_router = self._make_router(req_info, monkeypatch, p_client, d_client)
948- return_value=mock_response,948+ 
949- ) as mock_handle_request:949+ response = await handoff_router.handle_request()
950- response = await router.handle_request(950+ response_json = response.body.decode() if hasattr(response.body, "decode") else response.body
951- mock_raw_request,951+ response_data = json.loads(response_json)
952- CoordinatorConfig(),952+ 
953- scheduler=_scheduler,953+ assert response_data["choices"][0]["message"]["content"] == "partial "
954- request_manager=_request_manager,954+ assert response_data["choices"][0]["stop_reason"] == "stop"
955- )955+ assert "token_ids" not in response_data["choices"][0]
956- # Verify PDHybridRouter.handle_request was called956+ assert "prompt_token_ids" not in response_data
957- mock_handle_request.assert_called_once()957+ assert req_info.state == ReqState.DECODE_END
958- # Verify response
959- assert response == mock_response
960- 
961- @pytest.mark.asyncio
962- async def test_no_degradation_when_d_instances_exist(self, monkeypatch, setup_cdp_separation, mock_raw_request):
963- """
964- Test that when ROLE_D instances are available, the router uses the configured mode (PD_SEPARATE).
965- """
966- # Let listen_for_disconnect() exit immediately (same as test_degradation_to_single_node).
967- disconnect_msg = {"type": "http.disconnect"}
968- mock_raw_request.receive = AsyncMock(return_value=disconnect_msg)
969- 
970- # Mock UnifiedPDRouter response
971- mock_response = "mock_message"
972- with patch(
973- "motor.coordinator.router.dispatch.UnifiedPDRouter.handle_request",
974- new_callable=AsyncMock,
975- return_value=mock_response,
976- ) as mock_handle_request:
977- response = await router.handle_request(
978- mock_raw_request,
979- CoordinatorConfig(),
980- scheduler=_scheduler,
981- request_manager=_request_manager,
982- )
983- 
984- # Verify UnifiedPDRouter.handle_request was called
985- mock_handle_request.assert_called_once()
986- # Verify response
987- assert response == mock_response
988- 
989- @pytest.mark.parametrize(
990- ("prefill_details", "expected_details"),
991- [
992- ({"cached_tokens": 10}, {"cached_tokens": 10}),
993- (None, {"cached_tokens": 0}),
994- ],
995- )
996- @pytest.mark.asyncio
997- async def test_prompt_tokens_details_propagation(
998- self,
999- client,
1000- monkeypatch: MonkeyPatch,
1001- setup_cdp_separation,
1002- prefill_details,
1003- expected_details,
1004- ):
1005- """UnifiedPD returns prompt cache details collected from the prefill response."""
1006- req_info = await create_mock_request_info(stream=False)
1007- 
1008- class _PrefillClient(_UnifiedPDPrefillClient):
1009- async def post(self, path, json=None, headers=None, timeout=None):
1010- self.requests.append(json)
1011- request = httpx.Request("POST", path, headers=headers or {}, json=json)
1012- return httpx.Response(
1013- status_code=200,
1014- json={"usage": {"prompt_tokens_details": prefill_details}},
1015- request=request,
1016- )
1017- 
1018- class _DecodeClient(_UnifiedPDDecodeClient):
1019- async def post(self, path, json=None, headers=None, timeout=None):
1020- self.post_count += 1
1021- if json:
1022- self.requests.append(json)
1023- request = httpx.Request("POST", path, headers=headers or {}, json=json)
1024- return httpx.Response(
1025- status_code=200,
1026- json={
1027- "choices": [{"message": {"content": "test response"}}],
1028- "usage": {
1029- "prompt_tokens": 15,
1030- "completion_tokens": 1,
1031- "total_tokens": 16,
1032- },
1033- },
1034- request=request,
1035- )
1036- 
1037- p_client = _PrefillClient()
1038- d_client = _DecodeClient()
1039- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)
1040- 
1041- response = await cdp_router.handle_request()
1042- response_json = response.body.decode() if hasattr(response.body, "decode") else response.body
1043- response_data = json.loads(response_json)
1044- 
1045- assert req_info.prompt_tokens_details == expected_details
1046- assert response_data["choices"][0]["message"]["content"] == "test response"
1047- assert response_data["usage"]["prompt_tokens_details"] == expected_details
1048- 
1049- @pytest.mark.asyncio
1050- async def test_stream_prompt_tokens_details_when_recompute_disabled(
1051- self, client, monkeypatch: MonkeyPatch, setup_cdp_separation
1052- ):
1053- """Prompt cache details are independent of the recompute feature switch."""
1054- prompt_tokens_details = {"cached_tokens": 10}
1055- req_info = await create_mock_request_info(stream=True)
1056- 
1057- usage_chunk = b'data: {"choices":[],"usage":{"prompt_tokens":15,"completion_tokens":1,"total_tokens":16}}\n\n'
1058- 
1059- class _DelayedPrefillClient(_UnifiedPDPrefillClient):
1060- async def post(self, path, json=None, headers=None, timeout=None):
1061- self.requests.append(json)
1062- await asyncio.sleep(0.01)
1063- request = httpx.Request("POST", path, headers=headers or {}, json=json)
1064- return httpx.Response(
1065- status_code=200,
1066- json={"usage": {"prompt_tokens_details": prompt_tokens_details}},
1067- request=request,
1068- )
1069- 
1070- p_client = _DelayedPrefillClient()
1071- d_client = _UnifiedPDDecodeClient(stream_chunks=[usage_chunk])
1072- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)
1073- cdp_router.config.exception_config.reschedule_enabled = False
1074- 
1075- response = await cdp_router.handle_request()
1076- chunks = [chunk async for chunk in response.body_iterator]
1077- response_data = json.loads(chunks[0].removeprefix(b"data: ").strip())
1078- 
1079- assert response_data["usage"]["prompt_tokens_details"] == prompt_tokens_details
1080- 
1081- @pytest.mark.asyncio
1082- async def test_cdp_nonstream_recompute_returns_decode_body_as_is(
1083- self, client, monkeypatch: MonkeyPatch, setup_cdp_separation
1084- ):
1085- """UnifiedPD non-stream returns the decode engine body without coordinator-side recompute merge."""
1086- req_info = await create_mock_request_info(stream=False)
1087- req_info.entry_api = req_info.api
1088- 
1089- recomputed_body = {
1090- "prompt_token_ids": [1, 2],
1091- "choices": [
1092- {
1093- "message": {"role": "assistant", "content": "partial "},
1094- "stop_reason": "recomputed",
1095- "token_ids": [3, 4],
1096- }
1097- ],
1098- "usage": {"completion_tokens": 2},
1099- }
1100- 
1101- class _RecomputedDecodeClient(_UnifiedPDDecodeClient):
1102- async def post(self, path, json=None, headers=None, timeout=None):
1103- self.post_count += 1
1104- if json:
1105- self.requests.append(json)
1106- request = httpx.Request("POST", path, headers=headers or {}, json=json)
1107- return httpx.Response(status_code=200, json=recomputed_body, request=request)
1108- 
1109- p_client = _UnifiedPDPrefillClient()
1110- d_client = _RecomputedDecodeClient()
1111- cdp_router = self._make_router(req_info, monkeypatch, p_client, d_client)
1112- 
1113- response = await cdp_router.handle_request()
1114- response_json = response.body.decode() if hasattr(response.body, "decode") else response.body
1115- response_data = json.loads(response_json)
1116- 
1117- assert response_data["choices"][0]["message"]["content"] == "partial "
1118- assert response_data["choices"][0]["stop_reason"] == "recomputed"
1119- assert req_info.state == ReqState.DECODE_END
@@ -1,135 +0,0 @@
1-# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2-# MindIE is licensed under Mulan PSL v2.
3-# You can use this software according to the terms and conditions of the Mulan PSL v2.
4-# You may obtain a copy of Mulan PSL v2 at:
5-# http://license.coscl.org.cn/MulanPSL2
6-# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7-# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8-# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9-# See the Mulan PSL v2 for more details.
10- 
11-import pytest
12- 
13-from motor.common.resources.dispatch import MOTOR_DISPATCH_KEY
14-from motor.common.resources.endpoint import Endpoint, EndpointStatus
15-from motor.common.resources.instance import Instance, InsStatus, PDRole, ParallelConfig
16-from motor.coordinator.domain import ScheduledResource
17-from motor.coordinator.router.dispatch_session import AttemptContext
18-from motor.coordinator.router.sglang_native_dispatch import (
19- _bootstrap_port,
20- _stable_bootstrap_room,
21- ensure_sglang_pd_pair,
22- inject_sglang_pd_fields,
23- is_sglang_resource,
24-)
25- 
26- 
27-def _resource(engine_type: str | None, *, instance_id: int = 1) -> ScheduledResource:
28- endpoint = Endpoint(
29- id=instance_id,
30- ip="10.0.0.8",
31- business_port="8000",
32- mgmt_port="1026",
33- status=EndpointStatus.NORMAL,
34- )
35- instance = Instance(
36- job_name=f"job-{instance_id}",
37- model_name="m",
38- engine_type=engine_type,
39- id=instance_id,
40- role=PDRole.ROLE_P if instance_id == 1 else PDRole.ROLE_D,
41- status=InsStatus.ACTIVE,
42- parallel_config=ParallelConfig(dp_size=1),
43- endpoints={endpoint.ip: {endpoint.id: endpoint}},
44- )
45- return ScheduledResource(instance=instance, endpoint=endpoint)
46- 
47- 
48-def test_stable_bootstrap_room_is_stable_and_positive():
49- room_a = _stable_bootstrap_room("pair-1", 1)
50- room_b = _stable_bootstrap_room("pair-1", 1)
51- room_c = _stable_bootstrap_room("pair-1", 2)
52- assert room_a == room_b
53- assert room_a != room_c
54- assert room_a >= 0
55- assert room_a < (1 << 63)
56- 
57- 
58-def test_bootstrap_port(monkeypatch):
59- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "8998")
60- assert _bootstrap_port() == "8998"
61- 
62- monkeypatch.delenv("DISAGGREGATION_BOOTSTRAP_PORT", raising=False)
63- with pytest.raises(RuntimeError, match="must be an integer"):
64- _bootstrap_port()
65- 
66- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "abc")
67- with pytest.raises(RuntimeError, match="must be an integer"):
68- _bootstrap_port()
69- 
70- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "70000")
71- with pytest.raises(RuntimeError, match="1-65535"):
72- _bootstrap_port()
73- 
74- 
75-def test_is_sglang_resource():
76- sglang_p = _resource("sglang", instance_id=1)
77- vllm_d = _resource("vllm", instance_id=2)
78- assert is_sglang_resource(sglang_p) is True
79- assert is_sglang_resource(vllm_d) is False
80- assert is_sglang_resource(None) is False
81- 
82- 
83-def test_ensure_sglang_pd_pair_rejects_mixed_engines():
84- attempt = AttemptContext(
85- root_request_id="r1",
86- attempt_seq=1,
87- pair_id="pair",
88- prefill_resource=_resource("sglang", instance_id=1),
89- decode_resource=_resource("vllm", instance_id=2),
90- )
91- with pytest.raises(RuntimeError, match="both prefill and decode"):
92- ensure_sglang_pd_pair(attempt)
93- 
94- 
95-def test_inject_sglang_pd_fields(monkeypatch):
96- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "9100")
97- attempt = AttemptContext(
98- root_request_id="r1",
99- attempt_seq=3,
100- pair_id="pair-xyz",
101- prefill_resource=_resource("sglang", instance_id=1),
102- decode_resource=_resource("sglang", instance_id=2),
103- )
104- req = {"model": "m", "prompt": "hi", "request_id": "r1#a3"}
105- assert inject_sglang_pd_fields(req, attempt) is None
106- assert req["bootstrap_host"] == "10.0.0.8"
107- assert req["bootstrap_port"] == "9100"
108- assert req["bootstrap_room"] == _stable_bootstrap_room("pair-xyz", 3)
109- assert MOTOR_DISPATCH_KEY not in req
110- 
111- 
112-def test_inject_sglang_pd_fields_requires_bootstrap_port(monkeypatch):
113- monkeypatch.delenv("DISAGGREGATION_BOOTSTRAP_PORT", raising=False)
114- attempt = AttemptContext(
115- root_request_id="r1",
116- attempt_seq=1,
117- pair_id="pair",
118- prefill_resource=_resource("sglang", instance_id=1),
119- decode_resource=_resource("sglang", instance_id=2),
120- )
121- with pytest.raises(RuntimeError, match="DISAGGREGATION_BOOTSTRAP_PORT"):
122- inject_sglang_pd_fields({"model": "m"}, attempt)
123- 
124- 
125-def test_inject_sglang_pd_fields_rejects_mixed_engines(monkeypatch):
126- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "9100")
127- attempt = AttemptContext(
128- root_request_id="r1",
129- attempt_seq=1,
130- pair_id="pair",
131- prefill_resource=_resource("sglang", instance_id=1),
132- decode_resource=_resource("vllm", instance_id=2),
133- )
134- with pytest.raises(RuntimeError, match="both prefill and decode"):
135- inject_sglang_pd_fields({"model": "m"}, attempt)
@@ -12,23 +12,17 @@ import asyncio
12import json12import json
13import logging13import logging
14from contextlib import asynccontextmanager14from contextlib import asynccontextmanager
15-from types import SimpleNamespace
16from unittest.mock import AsyncMock, MagicMock15from unittest.mock import AsyncMock, MagicMock
17 16 
18import httpx17import httpx
19import pytest18import pytest
19+from fastapi import HTTPException
20+from fastapi.responses import JSONResponse
20from starlette.requests import ClientDisconnect21from starlette.requests import ClientDisconnect
21 22 
22import motor.common.utils.error as cancel_error23import motor.common.utils.error as cancel_error
23from motor.common.http import HTTPClientPool24from motor.common.http import HTTPClientPool
24from motor.common.logger.logger import _resolve_logger_name25from motor.common.logger.logger import _resolve_logger_name
25-from motor.common.resources.dispatch import (
26- DispatchPlan,
27- DispatchStopReason,
28- MOTOR_DISPATCH_KEY,
29- MOTOR_PREFILL_RESULT_KEY,
30- PrefillContextBudget,
31-)
32from motor.common.resources.endpoint import (26from motor.common.resources.endpoint import (
33 Endpoint,27 Endpoint,
34 EndpointStatus,28 EndpointStatus,
@@ -40,15 +34,13 @@ from motor.config.coordinator import CoordinatorConfig, ExceptionConfig, Schedul
40from motor.coordinator.domain import ScheduledResource34from motor.coordinator.domain import ScheduledResource
41from motor.coordinator.domain.request_manager import RequestManager35from motor.coordinator.domain.request_manager import RequestManager
42from motor.coordinator.models.request import RequestInfo, ReqState36from motor.coordinator.models.request import RequestInfo, ReqState
37+from motor.coordinator.router.adapters.pd_protocol import EngineProtocolError
43from motor.coordinator.router.dispatch_session import (38from motor.coordinator.router.dispatch_session import (
44 AttemptContext,39 AttemptContext,
45 AttemptState,40 AttemptState,
41+ AttemptStopReason,
46 PDDispatchSession,42 PDDispatchSession,
47)43)
48-from motor.coordinator.router.dispatch_capability import (
49- DispatchPlanNotSupported,
50- select_dispatch_plan_for_pair,
51-)
52from motor.common.utils.error import RequestCancelledError44from motor.common.utils.error import RequestCancelledError
53from motor.coordinator.router.rescheduler.rescheduler import Rescheduler, RetryRequestPlan45from motor.coordinator.router.rescheduler.rescheduler import Rescheduler, RetryRequestPlan
54from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter46from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter
@@ -61,21 +53,21 @@ def _instance(
61 instance_id: int,53 instance_id: int,
62 role: PDRole,54 role: PDRole,
63 *,55 *,
64- engine_type: str | None = None,56+ engine_type: str = "sglang",
65- dispatch_capabilities: list[str] | None = None,57+ bootstrap_port: int | None = None,
66) -> Instance:58) -> Instance:
67 endpoint = Endpoint(59 endpoint = Endpoint(
68 id=instance_id,60 id=instance_id,
69 ip="127.0.0.1",61 ip="127.0.0.1",
70 business_port=str(8100 + instance_id),62 business_port=str(8100 + instance_id),
71 mgmt_port=str(9100 + instance_id),63 mgmt_port=str(9100 + instance_id),
64+ bootstrap_port=bootstrap_port,
72 status=EndpointStatus.NORMAL,65 status=EndpointStatus.NORMAL,
73 )66 )
74 return Instance(67 return Instance(
75 job_name=f"job-{instance_id}",68 job_name=f"job-{instance_id}",
76- model_name=engine_type or "model",69+ model_name=engine_type,
77 engine_type=engine_type,70 engine_type=engine_type,
78- dispatch_capabilities=dispatch_capabilities or [],
79 id=instance_id,71 id=instance_id,
80 role=role,72 role=role,
81 status=InsStatus.ACTIVE,73 status=InsStatus.ACTIVE,
@@ -88,31 +80,28 @@ class _Scheduler:
88 def __init__(80 def __init__(
89 self,81 self,
90 *,82 *,
91- prefill_engine_type: str | None = None,83+ prefill_engine_type: str = "sglang",
92- decode_engine_type: str | None = None,84+ decode_engine_type: str = "sglang",
93- prefill_capabilities: list[str] | None = None,85+ prefill_bootstrap_port: int | None = 20001,
94- decode_capabilities: list[str] | None = None,
95 ):86 ):
96- if prefill_capabilities is None:
97- prefill_capabilities = [DispatchPlan.CONCURRENT_ENGINE_SYNC.value]
98- if decode_capabilities is None:
99- decode_capabilities = [DispatchPlan.CONCURRENT_ENGINE_SYNC.value]
100 self.p = _instance(87 self.p = _instance(
101 1,88 1,
102 PDRole.ROLE_P,89 PDRole.ROLE_P,
103 engine_type=prefill_engine_type,90 engine_type=prefill_engine_type,
104- dispatch_capabilities=prefill_capabilities,91+ bootstrap_port=prefill_bootstrap_port,
105 )92 )
106 self.d = _instance(93 self.d = _instance(
107 2,94 2,
108 PDRole.ROLE_D,95 PDRole.ROLE_D,
109 engine_type=decode_engine_type,96 engine_type=decode_engine_type,
110- dispatch_capabilities=decode_capabilities,
111 )97 )
112 self.update_workload = AsyncMock(return_value=True)98 self.update_workload = AsyncMock(return_value=True)
113 99 
114 async def select_and_allocate(self, role, req_info, **_kwargs):100 async def select_and_allocate(self, role, req_info, **_kwargs):
115 instance = self.p if role == PDRole.ROLE_P else self.d101 instance = self.p if role == PDRole.ROLE_P else self.d
102+ required_engine_type = _kwargs.get("required_engine_type")
103+ if required_engine_type and instance.engine_type != required_engine_type:
104+ return None
116 endpoint = next(iter(next(iter(instance.endpoints.values())).values()))105 endpoint = next(iter(next(iter(instance.endpoints.values())).values()))
117 return instance, endpoint, Workload(active_tokens=1)106 return instance, endpoint, Workload(active_tokens=1)
118 107 
@@ -129,11 +118,16 @@ class _Client:
129 self.name = name118 self.name = name
130 self.exc = exc119 self.exc = exc
131 self.requests = []120 self.requests = []
121+ self.abort_requests = []
132 self.headers = []122 self.headers = []
133 self.base_url = f"http://{name}"123 self.base_url = f"http://{name}"
134 self.timeout = 1124 self.timeout = 1
135 125 
136 async def post(self, path, json=None, headers=None, timeout=None):126 async def post(self, path, json=None, headers=None, timeout=None):
127+ if str(path).rstrip("/").endswith("abort_request"):
128+ self.abort_requests.append(json)
129+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
130+ return httpx.Response(status_code=200, json={}, request=request)
137 self.requests.append(json)131 self.requests.append(json)
138 self.headers.append(headers or {})132 self.headers.append(headers or {})
139 if self.exc is not None:133 if self.exc is not None:
@@ -142,7 +136,7 @@ class _Client:
142 if self.name == "prefill":136 if self.name == "prefill":
143 return httpx.Response(137 return httpx.Response(
144 status_code=200,138 status_code=200,
145- json={"status": "cached", "id": json["request_id"]},139+ json={"status": "cached", "id": json.get("request_id") or json.get("rid")},
146 request=request,140 request=request,
147 )141 )
148 return httpx.Response(142 return httpx.Response(
@@ -179,24 +173,54 @@ class _DelayedHTTPErrorClient(_HTTPErrorClient):
179 return await super().post(path, json=json, headers=headers, timeout=timeout)173 return await super().post(path, json=json, headers=headers, timeout=timeout)
180 174 
181 175 
182-class _PrefillResultClient(_Client):176+class _NativeHandoffPrefillClient(_Client):
183 async def post(self, path, json=None, headers=None, timeout=None):177 async def post(self, path, json=None, headers=None, timeout=None):
184 self.requests.append(json)178 self.requests.append(json)
185 self.headers.append(headers or {})179 self.headers.append(headers or {})
186 request = httpx.Request("POST", path, headers=headers or {}, json=json)180 request = httpx.Request("POST", path, headers=headers or {}, json=json)
187- dispatch = json[MOTOR_DISPATCH_KEY]
188 return httpx.Response(181 return httpx.Response(
189 status_code=200,182 status_code=200,
190 json={183 json={
191- "object": "motor.prefill_result",184+ "kv_transfer_params": {
192- "schema_version": "1.0",185+ "do_remote_prefill": True,
193- "root_request_id": dispatch["root_request_id"],186+ "remote_request_id": json["request_id"],
194- "engine_request_id": dispatch["engine_request_id"],187+ "remote_host": "10.0.0.1",
195- "pair_id": dispatch["pair_id"],188+ "remote_port": 9000,
196- "attempt_seq": dispatch["attempt_seq"],189+ "connector_private": {"opaque": "kv"},
197- "status": "completed",190+ },
198- "handoff_mode": "handoff",191+ "usage": {
199- "payload": {"opaque": "kv"},192+ "prompt_tokens": 5,
193+ "prompt_tokens_details": {"cached_tokens": 2},
194+ },
195+ },
196+ request=request,
197+ )
198+ 
199+ 
200+class _NativeSglangPrefillClient(_Client):
201+ async def post(self, path, json=None, headers=None, timeout=None):
202+ self.requests.append(json)
203+ self.headers.append(headers or {})
204+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
205+ if json.get("stream"):
206+ return httpx.Response(
207+ status_code=200,
208+ content=(
209+ b'data: {"choices":[],"usage":{"prompt_tokens":5,'
210+ b'"prompt_tokens_details":{"cached_tokens":2}}}\n\n'
211+ b"data: [DONE]\n\n"
212+ ),
213+ headers={"content-type": "text/event-stream"},
214+ request=request,
215+ )
216+ return httpx.Response(
217+ status_code=200,
218+ json={
219+ "id": json["rid"],
220+ "usage": {
221+ "prompt_tokens": 5,
222+ "prompt_tokens_details": {"cached_tokens": 2},
223+ },
200 },224 },
201 request=request,225 request=request,
202 )226 )
@@ -403,11 +427,11 @@ async def _invoke_asgi_response(response) -> list[dict]:
403 [427 [
404 (428 (
405 f"{cancel_error.NODE_FAULT}: http://127.0.0.1:8102",429 f"{cancel_error.NODE_FAULT}: http://127.0.0.1:8102",
406- DispatchStopReason.PEER_FAILED,430+ AttemptStopReason.PEER_FAILED,
407 ),431 ),
408- (cancel_error.CLIENT_DISCONNECT, DispatchStopReason.CLIENT_DISCONNECT),432+ (cancel_error.CLIENT_DISCONNECT, AttemptStopReason.CLIENT_DISCONNECT),
409- (cancel_error.DISPATCH_ABORT, DispatchStopReason.OTHER),433+ (cancel_error.DISPATCH_ABORT, AttemptStopReason.OTHER),
410- (cancel_error.SCOPE_ABORT, DispatchStopReason.OTHER),434+ (cancel_error.SCOPE_ABORT, AttemptStopReason.OTHER),
411 ],435 ],
412)436)
413def test_unified_pd_cancel_stop_reason_mapping(reason, expected):437def test_unified_pd_cancel_stop_reason_mapping(reason, expected):
@@ -454,236 +478,8 @@ async def test_unified_pd_process_response_error_wraps_cancelled_as_request_canc
454 assert cancel_error.CLIENT_DISCONNECT in caplog.text478 assert cancel_error.CLIENT_DISCONNECT in caplog.text
455 479 
456 480 
457-def test_dispatch_plan_prefers_explicit_capability_over_engine_fallback():
458- scheduler = _Scheduler(
459- prefill_engine_type="vllm",
460- decode_engine_type="sglang",
461- prefill_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
462- decode_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
463- )
464- p_endpoint = next(iter(next(iter(scheduler.p.endpoints.values())).values()))
465- d_endpoint = next(iter(next(iter(scheduler.d.endpoints.values())).values()))
466- 
467- plan = select_dispatch_plan_for_pair(
468- prefill=ScheduledResource(instance=scheduler.p, endpoint=p_endpoint),
469- decode=ScheduledResource(instance=scheduler.d, endpoint=d_endpoint),
470- )
471- 
472- assert plan == DispatchPlan.CONCURRENT_ENGINE_SYNC
473- 
474- 
475-def test_dispatch_plan_requires_connector_capability():
476- scheduler = _Scheduler(prefill_capabilities=[], decode_capabilities=[])
477- p_endpoint = next(iter(next(iter(scheduler.p.endpoints.values())).values()))
478- d_endpoint = next(iter(next(iter(scheduler.d.endpoints.values())).values()))
479- 
480- with pytest.raises(DispatchPlanNotSupported, match="do not advertise"):
481- select_dispatch_plan_for_pair(
482- prefill=ScheduledResource(instance=scheduler.p, endpoint=p_endpoint),
483- decode=ScheduledResource(instance=scheduler.d, endpoint=d_endpoint),
484- )
485- 
486- 
487-def test_dispatch_plan_requires_capability_from_both_instances():
488- scheduler = _Scheduler(
489- prefill_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
490- decode_capabilities=[],
491- )
492- p_endpoint = next(iter(next(iter(scheduler.p.endpoints.values())).values()))
493- d_endpoint = next(iter(next(iter(scheduler.d.endpoints.values())).values()))
494- 
495- with pytest.raises(DispatchPlanNotSupported, match="do not advertise"):
496- select_dispatch_plan_for_pair(
497- prefill=ScheduledResource(instance=scheduler.p, endpoint=p_endpoint),
498- decode=ScheduledResource(instance=scheduler.d, endpoint=d_endpoint),
499- )
500- 
501- 
502@pytest.mark.asyncio481@pytest.mark.asyncio
503-async def test_unified_pd_nonstream_dispatches_prefill_and_decode_with_same_attempt(482+async def test_unified_pd_vllm_uses_native_handoff_before_selecting_decode(monkeypatch):
504- monkeypatch,
505-):
506- req_info = RequestInfo(
507- req_id="root-1",
508- req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
509- api="v1/completions",
510- entry_api="v1/completions",
511- req_len=10,
512- )
513- scheduler = _Scheduler()
514- router = UnifiedPDRouter(
515- req_info,
516- _config(),
517- scheduler=scheduler,
518- request_manager=RequestManager(_config()),
519- )
520- p_client = _Client("prefill")
521- d_client = _Client("decode")
522- 
523- @asynccontextmanager
524- async def _client_for(resource: ScheduledResource):
525- if resource.instance.role == PDRole.ROLE_P:
526- yield p_client
527- else:
528- yield d_client
529- 
530- monkeypatch.setattr(router, "_client_for", _client_for)
531- 
532- response = await router.handle_request()
533- 
534- assert response.body == b'{"choices":[{"message":{"role":"assistant","content":"ok"}}]}'
535- assert len(p_client.requests) == 1
536- assert len(d_client.requests) == 1
537- 
538- p_dispatch = p_client.requests[0][MOTOR_DISPATCH_KEY]
539- d_dispatch = d_client.requests[0][MOTOR_DISPATCH_KEY]
540- assert p_dispatch["role"] == "prefill"
541- assert d_dispatch["role"] == "decode"
542- assert p_dispatch["root_request_id"] == "root-1"
543- assert d_dispatch["root_request_id"] == "root-1"
544- assert p_dispatch["attempt_seq"] == d_dispatch["attempt_seq"] == 1
545- assert p_dispatch["pair_id"] == d_dispatch["pair_id"]
546- assert p_client.requests[0]["request_id"] == "root-1#a1"
547- assert d_client.requests[0]["request_id"] == "root-1#a1"
548- assert p_client.headers[0]["X-Request-Id"] == "root-1#a1"
549- assert d_client.headers[0]["X-Request-Id"] == "root-1#a1"
550- assert scheduler.update_workload.await_count == 2
551- assert ReqState.PREFILL_END in req_info.status
552- assert req_info.status[ReqState.P_ALLOCATED] <= req_info.status[ReqState.PREFILL_END]
553- assert req_info.status[ReqState.PREFILL_END] <= req_info.status[ReqState.DECODE_END]
554- 
555- 
556-@pytest.mark.asyncio
557-async def test_unified_pd_decode_failure_stops_both_legs(monkeypatch):
558- req_info = RequestInfo(
559- req_id="root-stop",
560- req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
561- api="v1/completions",
562- entry_api="v1/completions",
563- req_len=10,
564- )
565- req_info.trace_obj.set_trace_prompt = MagicMock()
566- scheduler = _Scheduler()
567- router = UnifiedPDRouter(
568- req_info,
569- _config(),
570- scheduler=scheduler,
571- request_manager=RequestManager(_config()),
572- )
573- p_client = _Client("prefill")
574- d_client = _Client("decode", exc=httpx.ConnectError("decode down"))
575- stop_calls = []
576- 
577- @asynccontextmanager
578- async def _client_for(resource: ScheduledResource):
579- if resource.instance.role == PDRole.ROLE_P:
580- yield p_client
581- else:
582- yield d_client
583- 
584- async def _stop(self, resource, attempt, reason, timeout=1.0):
585- stop_calls.append((resource.instance.role, attempt.attempt_seq, reason.value))
586- return None
587- 
588- monkeypatch.setattr(router, "_client_for", _client_for)
589- monkeypatch.setattr(
590- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
591- _stop,
592- )
593- 
594- with pytest.raises(httpx.ConnectError):
595- await router.handle_request()
596- 
597- assert len(stop_calls) == 2
598- req_info.trace_obj.set_trace_prompt.assert_called_with(req_info.req_data)
599- assert {call[0] for call in stop_calls} == {PDRole.ROLE_P, PDRole.ROLE_D}
600- assert all(call[1] == 1 for call in stop_calls)
601- assert scheduler.update_workload.await_count == 2
602- 
603- 
604-@pytest.mark.asyncio
605-async def test_unified_pd_dual_dispatch_uses_dispatch_context_not_bootstrap_fields(
606- monkeypatch,
607-):
608- req_info = RequestInfo(
609- req_id="root-vllm-concurrent",
610- req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
611- api="v1/completions",
612- entry_api="v1/completions",
613- req_len=10,
614- )
615- scheduler = _Scheduler(prefill_engine_type="vllm", decode_engine_type="vllm")
616- router = UnifiedPDRouter(
617- req_info,
618- _config(),
619- scheduler=scheduler,
620- request_manager=RequestManager(_config()),
621- )
622- p_client = _Client("prefill")
623- d_client = _Client("decode")
624- 
625- @asynccontextmanager
626- async def _client_for(resource: ScheduledResource):
627- if resource.instance.role == PDRole.ROLE_P:
628- yield p_client
629- else:
630- yield d_client
631- 
632- monkeypatch.setattr(router, "_client_for", _client_for)
633- 
634- await router.handle_request()
635- 
636- for request_body in (p_client.requests[0], d_client.requests[0]):
637- assert "bootstrap_host" not in request_body
638- assert "bootstrap_port" not in request_body
639- assert "bootstrap_room" not in request_body
640- assert request_body[MOTOR_DISPATCH_KEY]["dispatch_mode"] == "pd_pair"
641- 
642- 
643-@pytest.mark.asyncio
644-async def test_unified_pd_sglang_injects_bootstrap_fields_not_motor_dispatch(
645- monkeypatch,
646-):
647- """SGLang pure-native PD: Coordinator injects stock bootstrap_* on both legs."""
648- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "9100")
649- req_info = RequestInfo(
650- req_id="root-sglang",
651- req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
652- api="v1/completions",
653- entry_api="v1/completions",
654- req_len=10,
655- )
656- scheduler = _Scheduler(prefill_engine_type="sglang", decode_engine_type="sglang")
657- router = UnifiedPDRouter(
658- req_info,
659- _config(),
660- scheduler=scheduler,
661- request_manager=RequestManager(_config()),
662- )
663- p_client = _Client("prefill")
664- d_client = _Client("decode")
665- 
666- @asynccontextmanager
667- async def _client_for(resource: ScheduledResource):
668- if resource.instance.role == PDRole.ROLE_P:
669- yield p_client
670- else:
671- yield d_client
672- 
673- monkeypatch.setattr(router, "_client_for", _client_for)
674- 
675- await router.handle_request()
676- 
677- for request_body in (p_client.requests[0], d_client.requests[0]):
678- assert MOTOR_DISPATCH_KEY not in request_body
679- assert request_body["bootstrap_host"] == "127.0.0.1"
680- assert request_body["bootstrap_port"] == "9100"
681- assert isinstance(request_body["bootstrap_room"], int)
682- assert p_client.requests[0]["bootstrap_room"] == d_client.requests[0]["bootstrap_room"]
683- 
684- 
685-@pytest.mark.asyncio
686-async def test_unified_pd_cpcd_waits_for_prefill_result_before_decode(monkeypatch):
687 req_info = RequestInfo(483 req_info = RequestInfo(
688 req_id="root-cpcd",484 req_id="root-cpcd",
689 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},485 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
@@ -691,8 +487,10 @@ async def test_unified_pd_cpcd_waits_for_prefill_result_before_decode(monkeypatc
691 entry_api="v1/completions",487 entry_api="v1/completions",
692 req_len=10,488 req_len=10,
693 )489 )
694- handoff = [DispatchPlan.PREFILL_HANDOFF_DECODE.value]490+ scheduler = _Scheduler(
695- scheduler = _Scheduler(prefill_capabilities=handoff, decode_capabilities=handoff)491+ prefill_engine_type="vllm",
492+ decode_engine_type="vllm",
493+ )
696 events = []494 events = []
697 select_and_allocate = scheduler.select_and_allocate495 select_and_allocate = scheduler.select_and_allocate
698 496 
@@ -713,10 +511,10 @@ async def test_unified_pd_cpcd_waits_for_prefill_result_before_decode(monkeypatc
713 request_manager=RequestManager(_config()),511 request_manager=RequestManager(_config()),
714 )512 )
715 513 
716- class _RecordingPrefillClient(_PrefillResultClient):514+ class _RecordingPrefillClient(_NativeHandoffPrefillClient):
717 async def post(self, path, json=None, headers=None, timeout=None):515 async def post(self, path, json=None, headers=None, timeout=None):
718 response = await super().post(path, json=json, headers=headers, timeout=timeout)516 response = await super().post(path, json=json, headers=headers, timeout=timeout)
719- events.append(("prefill_result",))517+ events.append(("native_prefill_result",))
720 return response518 return response
721 519 
722 p_client = _RecordingPrefillClient("prefill")520 p_client = _RecordingPrefillClient("prefill")
@@ -735,26 +533,254 @@ async def test_unified_pd_cpcd_waits_for_prefill_result_before_decode(monkeypatc
735 533 
736 assert len(p_client.requests) == 1534 assert len(p_client.requests) == 1
737 assert len(d_client.requests) == 1535 assert len(d_client.requests) == 1
738- p_dispatch = p_client.requests[0][MOTOR_DISPATCH_KEY]536+ p_request = p_client.requests[0]
739- d_dispatch = d_client.requests[0][MOTOR_DISPATCH_KEY]537+ d_request = d_client.requests[0]
740- assert p_dispatch["attempt_seq"] == d_dispatch["attempt_seq"] == 1538+ assert "_motor_dispatch" not in p_request
741- assert p_dispatch["pair_id"] == d_dispatch["pair_id"]539+ assert "_motor_dispatch" not in d_request
742- prefill_result = d_client.requests[0][MOTOR_PREFILL_RESULT_KEY]540+ assert "_motor_prefill_result" not in d_request
743- assert prefill_result["status"] == "completed"541+ assert p_request["request_id"] == d_request["request_id"] == "root-cpcd#a1"
744- assert prefill_result["handoff_mode"] == "handoff"542+ assert p_request["stream"] is False
745- assert prefill_result["payload"] == {"opaque": "kv"}543+ assert p_request["max_tokens"] == 1
544+ assert p_request["min_tokens"] == 1
545+ assert d_request["max_tokens"] == 8
546+ assert d_request["kv_transfer_params"] == {
547+ "do_remote_prefill": True,
548+ "remote_request_id": "root-cpcd#a1",
549+ "remote_host": "10.0.0.1",
550+ "remote_port": 9000,
551+ "connector_private": {"opaque": "kv"},
552+ }
553+ assert p_client.headers[0]["X-Request-Id"] == "root-cpcd#a1"
554+ assert d_client.headers[0]["X-Request-Id"] == "root-cpcd#a1"
746 assert scheduler.update_workload.await_count == 2555 assert scheduler.update_workload.await_count == 2
747 assert [event for event in events if event[0] == "select"] == [556 assert [event for event in events if event[0] == "select"] == [
748 ("select", PDRole.ROLE_P),557 ("select", PDRole.ROLE_P),
749 ("select", PDRole.ROLE_D),558 ("select", PDRole.ROLE_D),
750 ]559 ]
751- assert events.index(("prefill_result",)) < events.index(("select", PDRole.ROLE_D))560+ assert events.index(("native_prefill_result",)) < events.index(("select", PDRole.ROLE_D))
752 assert ("release", PDRole.ROLE_P, WorkloadAction.RELEASE_TOKENS) in events561 assert ("release", PDRole.ROLE_P, WorkloadAction.RELEASE_TOKENS) in events
562+ assert req_info.prompt_tokens_details == {"cached_tokens": 2}
753 assert ReqState.PREFILL_END in req_info.status563 assert ReqState.PREFILL_END in req_info.status
754 assert req_info.status[ReqState.P_ALLOCATED] <= req_info.status[ReqState.PREFILL_END]564 assert req_info.status[ReqState.P_ALLOCATED] <= req_info.status[ReqState.PREFILL_END]
755 assert req_info.status[ReqState.PREFILL_END] <= req_info.status[ReqState.DECODE_END]565 assert req_info.status[ReqState.PREFILL_END] <= req_info.status[ReqState.DECODE_END]
756 566 
757 567 
568+@pytest.mark.asyncio
569+async def test_unified_pd_vllm_rejects_missing_native_ticket_without_decode_or_peer_stop(
570+ monkeypatch,
571+):
572+ req_info = RequestInfo(
573+ req_id="root-missing-ticket",
574+ req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
575+ api="v1/completions",
576+ entry_api="v1/completions",
577+ req_len=10,
578+ )
579+ scheduler = _Scheduler(
580+ prefill_engine_type="vllm",
581+ decode_engine_type="vllm",
582+ )
583+ select_and_allocate = AsyncMock(side_effect=scheduler.select_and_allocate)
584+ scheduler.select_and_allocate = select_and_allocate
585+ router = UnifiedPDRouter(
586+ req_info,
587+ _config(),
588+ scheduler=scheduler,
589+ request_manager=RequestManager(_config()),
590+ )
591+ p_client = _Client("prefill")
592+ d_client = _Client("decode")
593+ 
594+ @asynccontextmanager
595+ async def _client_for(resource: ScheduledResource):
596+ if resource.instance.role == PDRole.ROLE_P:
597+ yield p_client
598+ else:
599+ yield d_client
600+ 
601+ monkeypatch.setattr(router, "_client_for", _client_for)
602+ 
603+ with pytest.raises(UpstreamHTTPError) as exc_info:
604+ await router.handle_request()
605+ 
606+ assert exc_info.value.status_code == 502
607+ assert b"Missing kv_transfer_params" in exc_info.value.body
608+ assert select_and_allocate.await_count == 1
609+ assert len(p_client.requests) == 1
610+ assert d_client.requests == []
611+ assert scheduler.update_workload.await_count == 1
612+ release = scheduler.update_workload.await_args.args[0]
613+ assert release.role == PDRole.ROLE_P
614+ assert release.workload_action == WorkloadAction.RELEASE_TOKENS
615+ 
616+ 
617+@pytest.mark.asyncio
618+async def test_unified_pd_vllm_strips_native_internal_fields_from_nonstream_response(
619+ monkeypatch,
620+):
621+ req_info = RequestInfo(
622+ req_id="root-native-strip",
623+ req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
624+ api="v1/completions",
625+ entry_api="v1/completions",
626+ req_len=10,
627+ )
628+ scheduler = _Scheduler(
629+ prefill_engine_type="vllm",
630+ decode_engine_type="vllm",
631+ )
632+ router = UnifiedPDRouter(
633+ req_info,
634+ _config(),
635+ scheduler=scheduler,
636+ request_manager=RequestManager(_config()),
637+ )
638+ p_client = _NativeHandoffPrefillClient("prefill")
639+ 
640+ class _DecodeClient(_Client):
641+ async def post(self, path, json=None, headers=None, timeout=None):
642+ self.requests.append(json)
643+ self.headers.append(headers or {})
644+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
645+ return httpx.Response(
646+ status_code=200,
647+ json={
648+ "choices": [{"text": "ok", "index": 0}],
649+ "kv_transfer_params": {"remote_host": "10.0.0.1"},
650+ },
651+ request=request,
652+ )
653+ 
654+ d_client = _DecodeClient("decode")
655+ 
656+ @asynccontextmanager
657+ async def _client_for(resource: ScheduledResource):
658+ if resource.instance.role == PDRole.ROLE_P:
659+ yield p_client
660+ else:
661+ yield d_client
662+ 
663+ monkeypatch.setattr(router, "_client_for", _client_for)
664+ 
665+ response = await router.handle_request()
666+ body = json.loads(response.body)
667+ 
668+ assert body["choices"][0]["text"] == "ok"
669+ assert "kv_transfer_params" not in body
670+ assert d_client.requests[0]["kv_transfer_params"]["remote_host"] == "10.0.0.1"
671+ 
672+ 
673+@pytest.mark.asyncio
674+async def test_unified_pd_vllm_native_handoff_preserves_chat_output_budget_and_shape(
675+ monkeypatch,
676+):
677+ req_info = RequestInfo(
678+ req_id="root-native-chat",
679+ req_data={
680+ "model": "m",
681+ "messages": [{"role": "user", "content": "hello"}],
682+ "stream": False,
683+ "max_completion_tokens": 6,
684+ },
685+ api="v1/chat/completions",
686+ entry_api="v1/chat/completions",
687+ req_len=10,
688+ )
689+ scheduler = _Scheduler(
690+ prefill_engine_type="vllm",
691+ decode_engine_type="vllm",
692+ )
693+ router = UnifiedPDRouter(
694+ req_info,
695+ _config(),
696+ scheduler=scheduler,
697+ request_manager=RequestManager(_config()),
698+ )
699+ p_client = _NativeHandoffPrefillClient("prefill")
700+ 
701+ class _CompletionDecodeClient(_Client):
702+ async def post(self, path, json=None, headers=None, timeout=None):
703+ self.requests.append(json)
704+ self.headers.append(headers or {})
705+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
706+ return httpx.Response(
707+ status_code=200,
708+ json={
709+ "object": "text_completion",
710+ "choices": [{"text": "ok", "index": 0, "finish_reason": "stop"}],
711+ },
712+ request=request,
713+ )
714+ 
715+ d_client = _CompletionDecodeClient("decode")
716+ 
717+ @asynccontextmanager
718+ async def _client_for(resource: ScheduledResource):
719+ if resource.instance.role == PDRole.ROLE_P:
720+ yield p_client
721+ else:
722+ yield d_client
723+ 
724+ monkeypatch.setattr(router, "_client_for", _client_for)
725+ 
726+ response = await router.handle_request()
727+ body = json.loads(response.body)
728+ 
729+ assert p_client.requests[0]["max_tokens"] == 1
730+ assert p_client.requests[0]["max_completion_tokens"] == 1
731+ assert d_client.requests[0]["max_completion_tokens"] == 6
732+ assert body["object"] == "chat.completion"
733+ assert body["choices"][0]["message"] == {"role": "assistant", "content": "ok"}
734+ 
735+ 
736+@pytest.mark.asyncio
737+async def test_unified_pd_vllm_strips_native_internal_fields_from_stream_response(
738+ monkeypatch,
739+):
740+ req_info = RequestInfo(
741+ req_id="root-native-stream-strip",
742+ req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},
743+ api="v1/completions",
744+ entry_api="v1/completions",
745+ req_len=10,
746+ )
747+ scheduler = _Scheduler(
748+ prefill_engine_type="vllm",
749+ decode_engine_type="vllm",
750+ )
751+ router = UnifiedPDRouter(
752+ req_info,
753+ _config(),
754+ scheduler=scheduler,
755+ request_manager=RequestManager(_config()),
756+ )
757+ p_client = _NativeHandoffPrefillClient("prefill")
758+ d_client = _SequenceStreamClient(
759+ "decode",
760+ [
761+ _StreamResponse(
762+ [(b'data: {"choices":[{"text":"A","index":0}],"kv_transfer_params":{"remote_host":"10.0.0.1"}}\n\n')]
763+ )
764+ ],
765+ )
766+ 
767+ @asynccontextmanager
768+ async def _client_for(resource: ScheduledResource):
769+ if resource.instance.role == PDRole.ROLE_P:
770+ yield p_client
771+ else:
772+ yield d_client
773+ 
774+ monkeypatch.setattr(router, "_client_for", _client_for)
775+ 
776+ response = await router.handle_request()
777+ chunks = [chunk async for chunk in response.body_iterator]
778+ 
779+ assert len(chunks) == 1
780+ assert b'"text":"A"' in chunks[0]
781+ assert b"kv_transfer_params" not in chunks[0]
782+ 
783+ 
758@pytest.mark.asyncio784@pytest.mark.asyncio
759async def test_unified_pd_handoff_registers_decode_canceller_after_client_open(785async def test_unified_pd_handoff_registers_decode_canceller_after_client_open(
760 monkeypatch,786 monkeypatch,
@@ -769,8 +795,10 @@ async def test_unified_pd_handoff_registers_decode_canceller_after_client_open(
769 entry_api="v1/completions",795 entry_api="v1/completions",
770 req_len=10,796 req_len=10,
771 )797 )
772- handoff = [DispatchPlan.PREFILL_HANDOFF_DECODE.value]798+ scheduler = _Scheduler(
773- scheduler = _Scheduler(prefill_capabilities=handoff, decode_capabilities=handoff)799+ prefill_engine_type="vllm",
800+ decode_engine_type="vllm",
801+ )
774 router = UnifiedPDRouter(802 router = UnifiedPDRouter(
775 req_info,803 req_info,
776 _config(),804 _config(),
@@ -787,7 +815,7 @@ async def test_unified_pd_handoff_registers_decode_canceller_after_client_open(
787 815 
788 monkeypatch.setattr(AttemptContext, "register_decode_canceller", _register_decode_canceller)816 monkeypatch.setattr(AttemptContext, "register_decode_canceller", _register_decode_canceller)
789 817 
790- p_client = _PrefillResultClient("prefill")818+ p_client = _NativeHandoffPrefillClient("prefill")
791 d_client = _Client("decode")819 d_client = _Client("decode")
792 820 
793 @asynccontextmanager821 @asynccontextmanager
@@ -838,14 +866,7 @@ async def test_unified_pd_nonretryable_upstream_error_is_not_retried(monkeypatch
838 else:866 else:
839 yield d_client867 yield d_client
840 868 
841- async def _stop(self, resource, attempt, reason, timeout=1.0):
842- return None
843- 
844 monkeypatch.setattr(router, "_client_for", _client_for)869 monkeypatch.setattr(router, "_client_for", _client_for)
845- monkeypatch.setattr(
846- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
847- _stop,
848- )
849 870 
850 with pytest.raises(UpstreamHTTPError) as exc_info:871 with pytest.raises(UpstreamHTTPError) as exc_info:
851 await router.handle_request()872 await router.handle_request()
@@ -888,14 +909,7 @@ async def test_unified_pd_stream_prefill_rejection_is_returned_before_first_deco
888 else:909 else:
889 yield d_client910 yield d_client
890 911 
891- async def _stop(self, resource, attempt, reason, timeout=1.0):
892- return None
893- 
894 monkeypatch.setattr(router, "_client_for", _client_for)912 monkeypatch.setattr(router, "_client_for", _client_for)
895- monkeypatch.setattr(
896- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
897- _stop,
898- )
899 monkeypatch.setattr(913 monkeypatch.setattr(
900 router.rescheduler,914 router.rescheduler,
901 "process_stream_chunk",915 "process_stream_chunk",
@@ -913,8 +927,7 @@ async def test_unified_pd_stream_prefill_rejection_is_returned_before_first_deco
913 927 
914 928 
915@pytest.mark.asyncio929@pytest.mark.asyncio
916-async def test_unified_pd_cpcd_sglang_uses_concurrent_plan(monkeypatch):930+async def test_unified_pd_sglang_uses_native_bootstrap_concurrently(monkeypatch):
917- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "9100")
918 req_info = RequestInfo(931 req_info = RequestInfo(
919 req_id="root-cpcd-sglang",932 req_id="root-cpcd-sglang",
920 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},933 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
@@ -922,12 +935,10 @@ async def test_unified_pd_cpcd_sglang_uses_concurrent_plan(monkeypatch):
922 entry_api="v1/completions",935 entry_api="v1/completions",
923 req_len=10,936 req_len=10,
924 )937 )
925- concurrent = [DispatchPlan.CONCURRENT_ENGINE_SYNC.value]
926 scheduler = _Scheduler(938 scheduler = _Scheduler(
927 prefill_engine_type="sglang",939 prefill_engine_type="sglang",
928 decode_engine_type="sglang",940 decode_engine_type="sglang",
929- prefill_capabilities=concurrent,941+ prefill_bootstrap_port=8998,
930- decode_capabilities=concurrent,
931 )942 )
932 router = UnifiedPDRouter(943 router = UnifiedPDRouter(
933 req_info,944 req_info,
@@ -935,7 +946,7 @@ async def test_unified_pd_cpcd_sglang_uses_concurrent_plan(monkeypatch):
935 scheduler=scheduler,946 scheduler=scheduler,
936 request_manager=RequestManager(_config()),947 request_manager=RequestManager(_config()),
937 )948 )
938- p_client = _Client("prefill")949+ p_client = _NativeSglangPrefillClient("prefill")
939 d_client = _Client("decode")950 d_client = _Client("decode")
940 951 
941 @asynccontextmanager952 @asynccontextmanager
@@ -951,24 +962,78 @@ async def test_unified_pd_cpcd_sglang_uses_concurrent_plan(monkeypatch):
951 962 
952 assert len(p_client.requests) == 1963 assert len(p_client.requests) == 1
953 assert len(d_client.requests) == 1964 assert len(d_client.requests) == 1
954- assert MOTOR_PREFILL_RESULT_KEY not in d_client.requests[0]965+ p_request = p_client.requests[0]
955- assert MOTOR_DISPATCH_KEY not in d_client.requests[0]966+ d_request = d_client.requests[0]
956- assert d_client.requests[0]["bootstrap_port"] == "9100"967+ assert "_motor_dispatch" not in p_request
968+ assert "_motor_dispatch" not in d_request
969+ assert "_motor_prefill_result" not in d_request
970+ assert p_request["rid"] == d_request["rid"] == "root-cpcd-sglang#a1"
971+ for request in (p_request, d_request):
972+ assert request["bootstrap_host"] == "127.0.0.1"
973+ assert request["bootstrap_port"] == 8998
974+ assert p_request["bootstrap_room"] == d_request["bootstrap_room"]
975+ assert p_client.headers[0]["X-Request-Id"] == "root-cpcd-sglang#a1"
976+ assert d_client.headers[0]["X-Request-Id"] == "root-cpcd-sglang#a1"
957 assert scheduler.update_workload.await_count == 2977 assert scheduler.update_workload.await_count == 2
958 978 
959 979 
960@pytest.mark.asyncio980@pytest.mark.asyncio
961-async def test_unified_pd_rejects_pair_without_shared_connector_capability(monkeypatch):981+async def test_unified_pd_sglang_rejects_missing_bootstrap_port_before_dispatch(
982+ monkeypatch,
983+):
962 req_info = RequestInfo(984 req_info = RequestInfo(
963- req_id="root-mixed",985+ req_id="root-sglang-no-port",
964 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},986 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
965 api="v1/completions",987 api="v1/completions",
966 entry_api="v1/completions",988 entry_api="v1/completions",
967 req_len=10,989 req_len=10,
968 )990 )
969 scheduler = _Scheduler(991 scheduler = _Scheduler(
970- prefill_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],992+ prefill_engine_type="sglang",
971- decode_capabilities=[DispatchPlan.PREFILL_HANDOFF_DECODE.value],993+ decode_engine_type="sglang",
994+ )
995+ next(iter(next(iter(scheduler.p.endpoints.values())).values())).bootstrap_port = None
996+ router = UnifiedPDRouter(
997+ req_info,
998+ _config(),
999+ scheduler=scheduler,
1000+ request_manager=RequestManager(_config()),
1001+ )
1002+ p_client = _NativeSglangPrefillClient("prefill")
1003+ d_client = _Client("decode")
1004+ 
1005+ @asynccontextmanager
1006+ async def _client_for(resource: ScheduledResource):
1007+ if resource.instance.role == PDRole.ROLE_P:
1008+ yield p_client
1009+ else:
1010+ yield d_client
1011+ 
1012+ monkeypatch.setattr(router, "_client_for", _client_for)
1013+ 
1014+ with pytest.raises(EngineProtocolError, match="bootstrap port"):
1015+ await router.handle_request()
1016+ 
1017+ assert p_client.requests == []
1018+ assert d_client.requests == []
1019+ assert scheduler.update_workload.await_count == 2
1020+ 
1021+ 
1022+@pytest.mark.asyncio
1023+async def test_unified_pd_sglang_rejects_mixed_engine_pair_without_dispatch(
1024+ monkeypatch,
1025+):
1026+ req_info = RequestInfo(
1027+ req_id="root-sglang-mixed",
1028+ req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
1029+ api="v1/completions",
1030+ entry_api="v1/completions",
1031+ req_len=10,
1032+ )
1033+ scheduler = _Scheduler(
1034+ prefill_engine_type="sglang",
1035+ decode_engine_type="vllm",
1036+ prefill_bootstrap_port=8998,
972 )1037 )
973 router = UnifiedPDRouter(1038 router = UnifiedPDRouter(
974 req_info,1039 req_info,
@@ -976,21 +1041,211 @@ async def test_unified_pd_rejects_pair_without_shared_connector_capability(monke
976 scheduler=scheduler,1041 scheduler=scheduler,
977 request_manager=RequestManager(_config()),1042 request_manager=RequestManager(_config()),
978 )1043 )
979- stop_calls = []1044+ with pytest.raises(HTTPException) as exc_info:
980- 
981- async def _stop(self, resource, attempt, reason, timeout=1.0):
982- stop_calls.append((resource.instance.role, attempt.attempt_seq, reason.value))
983- return None
984- 
985- monkeypatch.setattr(
986- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
987- _stop,
988- )
989- 
990- with pytest.raises(RuntimeError, match="no shared dispatch capability"):
991 await router.handle_request()1045 await router.handle_request()
992 1046 
993- assert {call[0] for call in stop_calls} == {PDRole.ROLE_P, PDRole.ROLE_D}1047+ assert exc_info.value.status_code == 503
1048+ assert "engine_type=sglang" in exc_info.value.detail
1049+ assert scheduler.update_workload.await_count == 1
1050+ 
1051+ 
1052+@pytest.mark.asyncio
1053+async def test_unified_pd_sglang_decode_failure_cancels_prefill_without_peer_stop(
1054+ monkeypatch,
1055+):
1056+ req_info = RequestInfo(
1057+ req_id="root-sglang-cancel",
1058+ req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
1059+ api="v1/completions",
1060+ entry_api="v1/completions",
1061+ req_len=10,
1062+ )
1063+ scheduler = _Scheduler(
1064+ prefill_engine_type="sglang",
1065+ decode_engine_type="sglang",
1066+ prefill_bootstrap_port=8998,
1067+ )
1068+ router = UnifiedPDRouter(
1069+ req_info,
1070+ _config(),
1071+ scheduler=scheduler,
1072+ request_manager=RequestManager(_config()),
1073+ )
1074+ prefill_started = asyncio.Event()
1075+ prefill_cancelled = asyncio.Event()
1076+ 
1077+ class _BlockingPrefillClient(_Client):
1078+ async def post(self, path, json=None, headers=None, timeout=None):
1079+ if str(path).rstrip("/").endswith("abort_request"):
1080+ return await super().post(path, json=json, headers=headers, timeout=timeout)
1081+ self.requests.append(json)
1082+ self.headers.append(headers or {})
1083+ prefill_started.set()
1084+ try:
1085+ await asyncio.Event().wait()
1086+ finally:
1087+ prefill_cancelled.set()
1088+ 
1089+ class _FailingDecodeClient(_Client):
1090+ async def post(self, path, json=None, headers=None, timeout=None):
1091+ if str(path).rstrip("/").endswith("abort_request"):
1092+ return await super().post(path, json=json, headers=headers, timeout=timeout)
1093+ self.requests.append(json)
1094+ self.headers.append(headers or {})
1095+ await prefill_started.wait()
1096+ raise httpx.ConnectError("decode down")
1097+ 
1098+ p_client = _BlockingPrefillClient("prefill")
1099+ d_client = _FailingDecodeClient("decode")
1100+ 
1101+ @asynccontextmanager
1102+ async def _client_for(resource: ScheduledResource):
1103+ if resource.instance.role == PDRole.ROLE_P:
1104+ yield p_client
1105+ else:
1106+ yield d_client
1107+ 
1108+ monkeypatch.setattr(router, "_client_for", _client_for)
1109+ 
1110+ with pytest.raises(httpx.ConnectError, match="decode down"):
1111+ await router.handle_request()
1112+ 
1113+ await asyncio.wait_for(prefill_cancelled.wait(), timeout=1)
1114+ assert p_client.requests[0]["bootstrap_room"] == d_client.requests[0]["bootstrap_room"]
1115+ assert p_client.abort_requests == [{"rid": "root-sglang-cancel#a1"}]
1116+ assert d_client.abort_requests == [{"rid": "root-sglang-cancel#a1"}]
1117+ assert scheduler.update_workload.await_count == 2
1118+ 
1119+ 
1120+@pytest.mark.asyncio
1121+async def test_unified_pd_sglang_retry_uses_new_matched_bootstrap_room(
1122+ monkeypatch,
1123+):
1124+ req_info = RequestInfo(
1125+ req_id="root-sglang-retry",
1126+ req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
1127+ api="v1/completions",
1128+ entry_api="v1/completions",
1129+ req_len=10,
1130+ )
1131+ scheduler = _Scheduler(
1132+ prefill_engine_type="sglang",
1133+ decode_engine_type="sglang",
1134+ prefill_bootstrap_port=8998,
1135+ )
1136+ config = _config()
1137+ config.exception_config.transport_max_retry = 2
1138+ router = UnifiedPDRouter(
1139+ req_info,
1140+ config,
1141+ scheduler=scheduler,
1142+ request_manager=RequestManager(config),
1143+ )
1144+ p_client = _NativeSglangPrefillClient("prefill")
1145+ 
1146+ class _FailOnceDecodeClient(_Client):
1147+ async def post(self, path, json=None, headers=None, timeout=None):
1148+ if str(path).rstrip("/").endswith("abort_request"):
1149+ return await super().post(path, json=json, headers=headers, timeout=timeout)
1150+ self.requests.append(json)
1151+ self.headers.append(headers or {})
1152+ if len(self.requests) == 1:
1153+ raise httpx.ConnectError("retry decode")
1154+ request = httpx.Request("POST", path, headers=headers or {}, json=json)
1155+ return httpx.Response(
1156+ status_code=200,
1157+ json={"choices": [{"text": "ok", "index": 0}]},
1158+ request=request,
1159+ )
1160+ 
1161+ d_client = _FailOnceDecodeClient("decode")
1162+ 
1163+ @asynccontextmanager
1164+ async def _client_for(resource: ScheduledResource):
1165+ if resource.instance.role == PDRole.ROLE_P:
1166+ yield p_client
1167+ else:
1168+ yield d_client
1169+ 
1170+ monkeypatch.setattr(router, "_client_for", _client_for)
1171+ 
1172+ response = await router.handle_request()
1173+ 
1174+ assert json.loads(response.body)["choices"][0]["text"] == "ok"
1175+ assert len(p_client.requests) == len(d_client.requests) == 2
1176+ for attempt_seq, (p_request, d_request) in enumerate(
1177+ zip(p_client.requests, d_client.requests, strict=True),
1178+ start=1,
1179+ ):
1180+ assert p_request["rid"] == d_request["rid"] == f"root-sglang-retry#a{attempt_seq}"
1181+ assert p_request["bootstrap_room"] == d_request["bootstrap_room"]
1182+ assert p_client.requests[0]["bootstrap_room"] != p_client.requests[1]["bootstrap_room"]
1183+ 
1184+ 
1185+@pytest.mark.asyncio
1186+async def test_unified_pd_sglang_chat_stream_uses_native_fields_and_strips_them(
1187+ monkeypatch,
1188+):
1189+ req_info = RequestInfo(
1190+ req_id="root-sglang-chat-stream",
1191+ req_data={
1192+ "model": "m",
1193+ "messages": [{"role": "user", "content": "hello"}],
1194+ "stream": True,
1195+ "max_completion_tokens": 6,
1196+ },
1197+ api="v1/chat/completions",
1198+ entry_api="v1/chat/completions",
1199+ req_len=10,
1200+ )
1201+ scheduler = _Scheduler(
1202+ prefill_engine_type="sglang",
1203+ decode_engine_type="sglang",
1204+ prefill_bootstrap_port=8998,
1205+ )
1206+ router = UnifiedPDRouter(
1207+ req_info,
1208+ _config(),
1209+ scheduler=scheduler,
1210+ request_manager=RequestManager(_config()),
1211+ )
1212+ p_client = _NativeSglangPrefillClient("prefill")
1213+ d_client = _SequenceStreamClient(
1214+ "decode",
1215+ [
1216+ _StreamResponse(
1217+ [
1218+ (
1219+ b'data: {"choices":[{"delta":{"content":"A"},"index":0}],'
1220+ b'"bootstrap_host":"127.0.0.1","bootstrap_port":8998,'
1221+ b'"bootstrap_room":123}\n\n'
1222+ )
1223+ ]
1224+ )
1225+ ],
1226+ )
1227+ 
1228+ @asynccontextmanager
1229+ async def _client_for(resource: ScheduledResource):
1230+ if resource.instance.role == PDRole.ROLE_P:
1231+ yield p_client
1232+ else:
1233+ yield d_client
1234+ 
1235+ monkeypatch.setattr(router, "_client_for", _client_for)
1236+ 
1237+ response = await router.handle_request()
1238+ chunks = [chunk async for chunk in response.body_iterator]
1239+ 
1240+ assert len(chunks) == 1
1241+ assert b'"content":"A"' in chunks[0]
1242+ assert b"bootstrap_" not in chunks[0]
1243+ assert p_client.requests[0]["stream"] is False
1244+ assert d_client.requests[0]["stream"] is True
1245+ for request in (p_client.requests[0], d_client.requests[0]):
1246+ assert request["max_completion_tokens"] == 6
1247+ assert "_motor_dispatch" not in request
1248+ assert p_client.requests[0]["bootstrap_room"] == d_client.requests[0]["bootstrap_room"]
994 1249 
995 1250 
996@pytest.mark.asyncio1251@pytest.mark.asyncio
@@ -1270,7 +1525,7 @@ async def test_unified_pd_background_release_uses_single_tracked_task():
1270 1525 
1271 1526 
1272@pytest.mark.asyncio1527@pytest.mark.asyncio
1273-async def test_unified_pd_release_failure_retains_local_ledger_and_is_drained(caplog):1528+async def test_unified_pd_release_failure_keeps_local_release_and_is_drained(caplog):
1274 req_info = RequestInfo(1529 req_info = RequestInfo(
1275 req_id="root-release-failure",1530 req_id="root-release-failure",
1276 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},1531 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},
@@ -1298,6 +1553,7 @@ async def test_unified_pd_release_failure_retains_local_ledger_and_is_drained(ca
1298 PDRole.ROLE_P,1553 PDRole.ROLE_P,
1299 )1554 )
1300 assert original is not None1555 assert original is not None
1556+ original_active_tokens = original.active_tokens
1301 1557 
1302 with caplog.at_level(logging.DEBUG, logger=_ROUTER_LOGGER):1558 with caplog.at_level(logging.DEBUG, logger=_ROUTER_LOGGER):
1303 submitted = await router._release_attempt_resource(1559 submitted = await router._release_attempt_resource(
@@ -1315,11 +1571,11 @@ async def test_unified_pd_release_failure_retains_local_ledger_and_is_drained(ca
1315 attempt.attempt_seq,1571 attempt.attempt_seq,
1316 PDRole.ROLE_P,1572 PDRole.ROLE_P,
1317 )1573 )
1318- # Delete-after-ACK: failed RPC keeps the local ledger so a later re-enqueue can1574+ assert current is not None
1319- # recompute the full negative delta; release_flags stay unmarked either way.1575+ assert current.active_tokens == original_active_tokens
1320- assert current == original
1321 assert not attempt.release_flags.prefill_tokens1576 assert not attempt.release_flags.prefill_tokens
1322- assert scheduler.update_workload.await_count == UnifiedPDRouter._RELEASE_RPC_ATTEMPTS1577+ assert scheduler.update_workload.await_count == 3
1578+ assert "Release workload background task failed" in caplog.text
1323 assert "Release workload rolled back locally" not in caplog.text1579 assert "Release workload rolled back locally" not in caplog.text
1324 finally:1580 finally:
1325 await request_manager.del_req_info(req_info.req_id)1581 await request_manager.del_req_info(req_info.req_id)
@@ -1486,7 +1742,7 @@ async def test_unified_pd_drain_double_cancel_keeps_release_cleanup():
1486 1742 
1487 1743 
1488@pytest.mark.asyncio1744@pytest.mark.asyncio
1489-async def test_unified_pd_concurrent_stream_tail_release_survives_iterator_cancellation(1745+async def test_unified_pd_bootstrap_stream_tail_release_survives_iterator_cancellation(
1490 monkeypatch,1746 monkeypatch,
1491):1747):
1492 req_info = RequestInfo(1748 req_info = RequestInfo(
@@ -1565,8 +1821,10 @@ async def test_unified_pd_handoff_stream_yields_before_prefill_token_release_fin
1565 entry_api="v1/completions",1821 entry_api="v1/completions",
1566 req_len=10,1822 req_len=10,
1567 )1823 )
1568- handoff = [DispatchPlan.PREFILL_HANDOFF_DECODE.value]1824+ scheduler = _Scheduler(
1569- scheduler = _Scheduler(prefill_capabilities=handoff, decode_capabilities=handoff)1825+ prefill_engine_type="vllm",
1826+ decode_engine_type="vllm",
1827+ )
1570 token_release_compute_started = asyncio.Event()1828 token_release_compute_started = asyncio.Event()
1571 allow_token_release_compute = asyncio.Event()1829 allow_token_release_compute = asyncio.Event()
1572 token_release_started = asyncio.Event()1830 token_release_started = asyncio.Event()
@@ -1605,7 +1863,7 @@ async def test_unified_pd_handoff_stream_yields_before_prefill_token_release_fin
1605 return await compute_and_update(resource, req_id, action, req_info_arg, **kwargs)1863 return await compute_and_update(resource, req_id, action, req_info_arg, **kwargs)
1606 1864 
1607 monkeypatch.setattr(router._workload_action_handler, "compute_and_update", _compute_and_update)1865 monkeypatch.setattr(router._workload_action_handler, "compute_and_update", _compute_and_update)
1608- p_client = _PrefillResultClient("prefill")1866+ p_client = _NativeHandoffPrefillClient("prefill")
1609 d_client = _StreamClient("decode")1867 d_client = _StreamClient("decode")
1610 1868 
1611 @asynccontextmanager1869 @asynccontextmanager
@@ -1671,18 +1929,18 @@ async def test_unified_pd_stream_dispatches_context_and_yields_visible_chunk(
1671 assert chunks == [b'data: {"choices":[{"delta":{"content":"A"},"index":0}]}\n\n']1929 assert chunks == [b'data: {"choices":[{"delta":{"content":"A"},"index":0}]}\n\n']
1672 assert len(p_client.requests) == 11930 assert len(p_client.requests) == 1
1673 assert len(d_client.requests) == 11931 assert len(d_client.requests) == 1
1674- assert d_client.requests[0][MOTOR_DISPATCH_KEY]["role"] == "decode"1932+ assert d_client.requests[0]["rid"] == p_client.requests[0]["rid"]
1675- assert p_client.requests[0][MOTOR_DISPATCH_KEY]["pair_id"] == d_client.requests[0][MOTOR_DISPATCH_KEY]["pair_id"]1933+ assert p_client.requests[0]["bootstrap_room"] == d_client.requests[0]["bootstrap_room"]
1676 assert scheduler.update_workload.await_count == 21934 assert scheduler.update_workload.await_count == 2
1677 assert ReqState.PREFILL_END in req_info.status1935 assert ReqState.PREFILL_END in req_info.status
1678 1936 
1679 1937 
1680@pytest.mark.asyncio1938@pytest.mark.asyncio
1681-async def test_unified_pd_concurrent_stream_prefill_release_does_not_block_first_chunk(1939+async def test_unified_pd_bootstrap_stream_prefill_release_does_not_block_first_chunk(
1682 monkeypatch,1940 monkeypatch,
1683):1941):
1684 req_info = RequestInfo(1942 req_info = RequestInfo(
1685- req_id="root-concurrent-prefill-release-background",1943+ req_id="root-bootstrap-prefill-release-background",
1686 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},1944 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},
1687 api="v1/completions",1945 api="v1/completions",
1688 entry_api="v1/completions",1946 entry_api="v1/completions",
@@ -1739,12 +1997,11 @@ async def test_unified_pd_concurrent_stream_prefill_release_does_not_block_first
1739 1997 
1740 1998 
1741@pytest.mark.asyncio1999@pytest.mark.asyncio
1742-async def test_unified_pd_concurrent_nonstream_holds_prefill_until_decode_returns(2000+async def test_unified_pd_bootstrap_nonstream_holds_prefill_tokens_until_decode_finishes(
1743 monkeypatch,2001 monkeypatch,
1744):2002):
1745- """Trigger/concurrent: P prepared ACK must not release load before decode completes."""
1746 req_info = RequestInfo(2003 req_info = RequestInfo(
1747- req_id="root-concurrent-nonstream-prefill-release",2004+ req_id="root-bootstrap-nonstream-prefill-release",
1748 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},2005 req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
1749 api="v1/chat/completions",2006 api="v1/chat/completions",
1750 entry_api="v1/chat/completions",2007 entry_api="v1/chat/completions",
@@ -1797,13 +2054,13 @@ async def test_unified_pd_concurrent_nonstream_holds_prefill_until_decode_return
1797 2054 
1798 response_task = asyncio.create_task(router.handle_request())2055 response_task = asyncio.create_task(router.handle_request())
1799 await asyncio.wait_for(decode_started.wait(), timeout=1)2056 await asyncio.wait_for(decode_started.wait(), timeout=1)
1800- # Prefill HTTP has returned (prepared), but P load must still be held.
1801- await asyncio.sleep(0.05)
1802 assert not p_token_compute_started.is_set()2057 assert not p_token_compute_started.is_set()
1803 assert not response_task.done()2058 assert not response_task.done()
1804 2059 
1805 allow_decode.set()2060 allow_decode.set()
1806 await asyncio.wait_for(p_token_compute_started.wait(), timeout=1)2061 await asyncio.wait_for(p_token_compute_started.wait(), timeout=1)
2062+ assert not response_task.done()
2063+ 
1807 allow_p_release_compute.set()2064 allow_p_release_compute.set()
1808 response = await asyncio.wait_for(response_task, timeout=1)2065 response = await asyncio.wait_for(response_task, timeout=1)
1809 2066 
@@ -1811,7 +2068,7 @@ async def test_unified_pd_concurrent_nonstream_holds_prefill_until_decode_return
1811 2068 
1812 2069 
1813@pytest.mark.asyncio2070@pytest.mark.asyncio
1814-async def test_unified_pd_client_disconnect_cancels_tasks_and_stops_engine(monkeypatch):2071+async def test_unified_pd_client_disconnect_cancels_tasks_and_releases_resources(monkeypatch):
1815 req_info = RequestInfo(2072 req_info = RequestInfo(
1816 req_id="root-stream-disconnect",2073 req_id="root-stream-disconnect",
1817 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},2074 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},
@@ -1832,7 +2089,6 @@ async def test_unified_pd_client_disconnect_cancels_tasks_and_stops_engine(monke
1832 d_response = _BlockingStreamResponse()2089 d_response = _BlockingStreamResponse()
1833 d_client = _BlockingStreamClient("decode", d_response)2090 d_client = _BlockingStreamClient("decode", d_response)
1834 attempts = []2091 attempts = []
1835- stop_calls = []
1836 original_create_attempt = router._create_attempt2092 original_create_attempt = router._create_attempt
1837 2093 
1838 @asynccontextmanager2094 @asynccontextmanager
@@ -1847,10 +2103,6 @@ async def test_unified_pd_client_disconnect_cancels_tasks_and_stops_engine(monke
1847 attempts.append(attempt)2103 attempts.append(attempt)
1848 return attempt2104 return attempt
1849 2105 
1850- async def _stop(self, resource, attempt, reason, timeout=1.0):
1851- stop_calls.append((resource.instance.role, attempt.attempt_seq, reason))
1852- return None
1853- 
1854 async def receive():2106 async def receive():
1855 await d_response.started.wait()2107 await d_response.started.wait()
1856 return {"type": "http.disconnect"}2108 return {"type": "http.disconnect"}
@@ -1860,10 +2112,6 @@ async def test_unified_pd_client_disconnect_cancels_tasks_and_stops_engine(monke
1860 2112 
1861 monkeypatch.setattr(router, "_client_for", _client_for)2113 monkeypatch.setattr(router, "_client_for", _client_for)
1862 monkeypatch.setattr(router, "_create_attempt", _create_attempt)2114 monkeypatch.setattr(router, "_create_attempt", _create_attempt)
1863- monkeypatch.setattr(
1864- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
1865- _stop,
1866- )
1867 2115 
1868 response = await router.handle_request()2116 response = await router.handle_request()
1869 await asyncio.wait_for(response(_asgi_scope(), receive, send), timeout=1)2117 await asyncio.wait_for(response(_asgi_scope(), receive, send), timeout=1)
@@ -1872,13 +2120,8 @@ async def test_unified_pd_client_disconnect_cancels_tasks_and_stops_engine(monke
1872 attempt = attempts[0]2120 attempt = attempts[0]
1873 await asyncio.wait_for(d_response.closed.wait(), timeout=1)2121 await asyncio.wait_for(d_response.closed.wait(), timeout=1)
1874 assert attempt.state == AttemptState.STOPPED2122 assert attempt.state == AttemptState.STOPPED
1875- assert attempt.stop_sent is True
1876 assert attempt.prefill_task.done()2123 assert attempt.prefill_task.done()
1877 assert attempt.decode_task.done()2124 assert attempt.decode_task.done()
1878- assert set(stop_calls) == {
1879- (PDRole.ROLE_P, 1, DispatchStopReason.CLIENT_DISCONNECT),
1880- (PDRole.ROLE_D, 1, DispatchStopReason.CLIENT_DISCONNECT),
1881- }
1882 assert scheduler.update_workload.await_count == 22125 assert scheduler.update_workload.await_count == 2
1883 assert not any(2126 assert not any(
1884 task.get_name() == "unified-pd-queue-root-stream-disconnect-a1" and not task.done()2127 task.get_name() == "unified-pd-queue-root-stream-disconnect-a1" and not task.done()
@@ -1906,20 +2149,12 @@ async def test_unified_pd_stop_attempt_drains_release_failures(monkeypatch, capl
1906 request_manager=request_manager,2149 request_manager=request_manager,
1907 )2150 )
1908 2151 
1909- async def _stop(self, resource, attempt, reason, timeout=1.0):
1910- return None
1911- 
1912- monkeypatch.setattr(
1913- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
1914- _stop,
1915- )
1916- 
1917 await request_manager.add_req_info(req_info)2152 await request_manager.add_req_info(req_info)
1918 try:2153 try:
1919 attempt = await router._create_attempt(PDDispatchSession(req_info.req_id))2154 attempt = await router._create_attempt(PDDispatchSession(req_info.req_id))
1920 2155 
1921 with caplog.at_level(logging.DEBUG, logger=_ROUTER_LOGGER):2156 with caplog.at_level(logging.DEBUG, logger=_ROUTER_LOGGER):
1922- await router._stop_attempt(attempt, DispatchStopReason.CLIENT_DISCONNECT)2157+ await router._stop_attempt(attempt, AttemptStopReason.CLIENT_DISCONNECT)
1923 2158 
1924 assert attempt.state == AttemptState.STOPPED2159 assert attempt.state == AttemptState.STOPPED
1925 assert scheduler.update_workload.await_count == 62160 assert scheduler.update_workload.await_count == 6
@@ -1932,7 +2167,7 @@ async def test_unified_pd_stop_attempt_drains_release_failures(monkeypatch, capl
1932 2167 
1933 2168 
1934@pytest.mark.asyncio2169@pytest.mark.asyncio
1935-async def test_unified_pd_send_failure_closes_decode_and_stops_engine(monkeypatch):2170+async def test_unified_pd_send_failure_closes_decode_and_releases_resources(monkeypatch):
1936 req_info = RequestInfo(2171 req_info = RequestInfo(
1937 req_id="root-stream-send-failure",2172 req_id="root-stream-send-failure",
1938 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},2173 req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},
@@ -1954,7 +2189,6 @@ async def test_unified_pd_send_failure_closes_decode_and_stops_engine(monkeypatc
1954 )2189 )
1955 d_client = _BlockingStreamClient("decode", d_response)2190 d_client = _BlockingStreamClient("decode", d_response)
1956 attempts = []2191 attempts = []
1957- stop_calls = []
1958 original_create_attempt = router._create_attempt2192 original_create_attempt = router._create_attempt
1959 2193 
1960 @asynccontextmanager2194 @asynccontextmanager
@@ -1969,20 +2203,12 @@ async def test_unified_pd_send_failure_closes_decode_and_stops_engine(monkeypatc
1969 attempts.append(attempt)2203 attempts.append(attempt)
1970 return attempt2204 return attempt
1971 2205 
1972- async def _stop(self, resource, attempt, reason, timeout=1.0):
1973- stop_calls.append((resource.instance.role, attempt.attempt_seq, reason))
1974- return None
1975- 
1976 async def send(message):2206 async def send(message):
1977 if message["type"] == "http.response.body" and message.get("body"):2207 if message["type"] == "http.response.body" and message.get("body"):
1978 raise OSError("client socket closed")2208 raise OSError("client socket closed")
1979 2209 
1980 monkeypatch.setattr(router, "_client_for", _client_for)2210 monkeypatch.setattr(router, "_client_for", _client_for)
1981 monkeypatch.setattr(router, "_create_attempt", _create_attempt)2211 monkeypatch.setattr(router, "_create_attempt", _create_attempt)
1982- monkeypatch.setattr(
1983- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
1984- _stop,
1985- )
1986 2212 
1987 response = await router.handle_request()2213 response = await router.handle_request()
1988 with pytest.raises(ClientDisconnect):2214 with pytest.raises(ClientDisconnect):
@@ -1992,13 +2218,8 @@ async def test_unified_pd_send_failure_closes_decode_and_stops_engine(monkeypatc
1992 attempt = attempts[0]2218 attempt = attempts[0]
1993 await asyncio.wait_for(d_response.closed.wait(), timeout=1)2219 await asyncio.wait_for(d_response.closed.wait(), timeout=1)
1994 assert attempt.state == AttemptState.STOPPED2220 assert attempt.state == AttemptState.STOPPED
1995- assert attempt.stop_sent is True
1996 assert attempt.prefill_task.done()2221 assert attempt.prefill_task.done()
1997 assert attempt.decode_task.done()2222 assert attempt.decode_task.done()
1998- assert set(stop_calls) == {
1999- (PDRole.ROLE_P, 1, DispatchStopReason.CLIENT_DISCONNECT),
2000- (PDRole.ROLE_D, 1, DispatchStopReason.CLIENT_DISCONNECT),
2001- }
2002 assert scheduler.update_workload.await_count == 22223 assert scheduler.update_workload.await_count == 2
2003 2224 
2004 2225 
@@ -2056,14 +2277,7 @@ async def test_unified_pd_stream_error_after_visible_chunk_reschedules_with_toke
2056 else:2277 else:
2057 yield d_client2278 yield d_client
2058 2279 
2059- async def _stop(self, resource, attempt, reason, timeout=1.0):
2060- return None
2061- 
2062 monkeypatch.setattr(router, "_client_for", _client_for)2280 monkeypatch.setattr(router, "_client_for", _client_for)
2063- monkeypatch.setattr(
2064- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
2065- _stop,
2066- )
2067 2281 
2068 response = await router.handle_request()2282 response = await router.handle_request()
2069 messages = await _invoke_asgi_response(response)2283 messages = await _invoke_asgi_response(response)
@@ -2075,7 +2289,7 @@ async def test_unified_pd_stream_error_after_visible_chunk_reschedules_with_toke
2075 assert len(d_client.requests) == 22289 assert len(d_client.requests) == 2
2076 assert d_client.requests[0]["return_token_ids"] is True2290 assert d_client.requests[0]["return_token_ids"] is True
2077 assert p_client.requests[1]["prompt"] == [1, 2, 10]2291 assert p_client.requests[1]["prompt"] == [1, 2, 10]
2078- assert p_client.requests[1]["max_tokens"] == 12292+ assert p_client.requests[1]["max_tokens"] == 8
2079 assert p_client.requests[1]["stream"] is False2293 assert p_client.requests[1]["stream"] is False
2080 assert d_client.requests[1]["prompt"] == [1, 2, 10]2294 assert d_client.requests[1]["prompt"] == [1, 2, 10]
2081 assert d_client.requests[1]["max_tokens"] == 72295 assert d_client.requests[1]["max_tokens"] == 7
@@ -2114,7 +2328,6 @@ async def test_unified_pd_retry_plan_validation_fails_before_new_attempt_allocat
2114 scheduler=scheduler,2328 scheduler=scheduler,
2115 request_manager=RequestManager(config),2329 request_manager=RequestManager(config),
2116 )2330 )
2117- stop_calls = []
2118 build_plan_calls = []2331 build_plan_calls = []
2119 original_build_retry_plan = router.rescheduler.build_retry_plan2332 original_build_retry_plan = router.rescheduler.build_retry_plan
2120 2333 
@@ -2124,19 +2337,11 @@ async def test_unified_pd_retry_plan_validation_fails_before_new_attempt_allocat
2124 raise httpx.ReadError("after token cache")2337 raise httpx.ReadError("after token cache")
2125 yield b"" # pylint: disable=unreachable2338 yield b"" # pylint: disable=unreachable
2126 2339 
2127- async def _stop(self, resource, attempt, reason, timeout=1.0):
2128- stop_calls.append((resource.instance.role, attempt.attempt_seq, reason))
2129- return None
2130- 
2131 def _build_retry_plan(req_data):2340 def _build_retry_plan(req_data):
2132 build_plan_calls.append(req_data)2341 build_plan_calls.append(req_data)
2133 return original_build_retry_plan(req_data)2342 return original_build_retry_plan(req_data)
2134 2343 
2135 monkeypatch.setattr(router, "_run_stream_attempt", _run_stream_attempt)2344 monkeypatch.setattr(router, "_run_stream_attempt", _run_stream_attempt)
2136- monkeypatch.setattr(
2137- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
2138- _stop,
2139- )
2140 monkeypatch.setattr(router.rescheduler, "build_retry_plan", _build_retry_plan)2345 monkeypatch.setattr(router.rescheduler, "build_retry_plan", _build_retry_plan)
2141 2346 
2142 response = await router.handle_request()2347 response = await router.handle_request()
@@ -2147,10 +2352,6 @@ async def test_unified_pd_retry_plan_validation_fails_before_new_attempt_allocat
2147 assert "parallel sampling" in body["detail"]2352 assert "parallel sampling" in body["detail"]
2148 assert len(build_plan_calls) == 12353 assert len(build_plan_calls) == 1
2149 assert scheduler.select_and_allocate.await_count == 22354 assert scheduler.select_and_allocate.await_count == 2
2150- assert set(stop_calls) == {
2151- (PDRole.ROLE_P, 1, DispatchStopReason.PEER_FAILED),
2152- (PDRole.ROLE_D, 1, DispatchStopReason.PEER_FAILED),
2153- }
2154 2355 
2155 2356 
2156@pytest.mark.asyncio2357@pytest.mark.asyncio
@@ -2164,8 +2365,10 @@ async def test_unified_pd_handoff_stream_retry_replays_same_prompt_through_prefi
2164 entry_api="v1/completions",2365 entry_api="v1/completions",
2165 req_len=10,2366 req_len=10,
2166 )2367 )
2167- handoff = [DispatchPlan.PREFILL_HANDOFF_DECODE.value]2368+ scheduler = _Scheduler(
2168- scheduler = _Scheduler(prefill_capabilities=handoff, decode_capabilities=handoff)2369+ prefill_engine_type="vllm",
2370+ decode_engine_type="vllm",
2371+ )
2169 config = _config()2372 config = _config()
2170 config.exception_config.transport_max_retry = 22373 config.exception_config.transport_max_retry = 2
2171 config.exception_config.reschedule_enabled = True2374 config.exception_config.reschedule_enabled = True
@@ -2175,7 +2378,7 @@ async def test_unified_pd_handoff_stream_retry_replays_same_prompt_through_prefi
2175 scheduler=scheduler,2378 scheduler=scheduler,
2176 request_manager=RequestManager(config),2379 request_manager=RequestManager(config),
2177 )2380 )
2178- p_client = _PrefillResultClient("prefill")2381+ p_client = _NativeHandoffPrefillClient("prefill")
2179 d_client = _SequenceStreamClient(2382 d_client = _SequenceStreamClient(
2180 "decode",2383 "decode",
2181 [2384 [
@@ -2200,14 +2403,7 @@ async def test_unified_pd_handoff_stream_retry_replays_same_prompt_through_prefi
2200 else:2403 else:
2201 yield d_client2404 yield d_client
2202 2405 
2203- async def _stop(self, resource, attempt, reason, timeout=1.0):
2204- return None
2205- 
2206 monkeypatch.setattr(router, "_client_for", _client_for)2406 monkeypatch.setattr(router, "_client_for", _client_for)
2207- monkeypatch.setattr(
2208- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
2209- _stop,
2210- )
2211 2407 
2212 response = await router.handle_request()2408 response = await router.handle_request()
2213 messages = await _invoke_asgi_response(response)2409 messages = await _invoke_asgi_response(response)
@@ -2218,26 +2414,21 @@ async def test_unified_pd_handoff_stream_retry_replays_same_prompt_through_prefi
2218 assert len(d_client.requests) == 22414 assert len(d_client.requests) == 2
2219 assert p_client.requests[1]["prompt"] == [1, 2, 10]2415 assert p_client.requests[1]["prompt"] == [1, 2, 10]
2220 assert p_client.requests[1]["max_tokens"] == 12416 assert p_client.requests[1]["max_tokens"] == 1
2221- assert p_client.requests[0][MOTOR_DISPATCH_KEY]["prefill_context_budget"] == {2417+ assert all("_motor_dispatch" not in request for request in p_client.requests)
2222- "max_output_tokens": 8,2418+ assert all("_motor_dispatch" not in request for request in d_client.requests)
2223- "parameter": "max_tokens",2419+ assert all("_motor_prefill_result" not in request for request in d_client.requests)
2224- }2420+ assert p_client.requests[0]["request_id"] == d_client.requests[0]["request_id"] == "root-handoff-reschedule#a1"
2225- assert p_client.requests[1][MOTOR_DISPATCH_KEY]["prefill_context_budget"] == {2421+ assert p_client.requests[1]["request_id"] == d_client.requests[1]["request_id"] == "root-handoff-reschedule#a2"
2226- "max_output_tokens": 7,
2227- "parameter": "max_tokens",
2228- }
2229 assert d_client.requests[1]["prompt"] == [1, 2, 10]2422 assert d_client.requests[1]["prompt"] == [1, 2, 10]
2230 assert d_client.requests[1]["max_tokens"] == 72423 assert d_client.requests[1]["max_tokens"] == 7
2231- retry_prefill_result = d_client.requests[1][MOTOR_PREFILL_RESULT_KEY]2424+ assert d_client.requests[1]["kv_transfer_params"]["remote_request_id"] == "root-handoff-reschedule#a2"
2232- assert retry_prefill_result["attempt_seq"] == 2
2233- assert retry_prefill_result["pair_id"] == d_client.requests[1][MOTOR_DISPATCH_KEY]["pair_id"]
2234 assert b'"text":"A"' in body2425 assert b'"text":"A"' in body
2235 assert b'"text":"B"' in body2426 assert b'"text":"B"' in body
2236 assert b"ReadError" not in body2427 assert b"ReadError" not in body
2237 2428 
2238 2429 
2239@pytest.mark.asyncio2430@pytest.mark.asyncio
2240-async def test_unified_pd_pool_node_fault_reschedules_and_stops_as_peer_failure(2431+async def test_unified_pd_pool_node_fault_reschedules_without_peer_stop(
2241 monkeypatch,2432 monkeypatch,
2242):2433):
2243 req_info = RequestInfo(2434 req_info = RequestInfo(
@@ -2260,14 +2451,13 @@ async def test_unified_pd_pool_node_fault_reschedules_and_stops_as_peer_failure(
2260 decode_started = asyncio.Event()2451 decode_started = asyncio.Event()
2261 decode_calls = []2452 decode_calls = []
2262 prefill_calls = []2453 prefill_calls = []
2263- stop_calls = []
2264 2454 
2265 async def _forward_prefill(self, api, req_data, client, timeout):2455 async def _forward_prefill(self, api, req_data, client, timeout):
2266 prefill_calls.append(req_data.copy())2456 prefill_calls.append(req_data.copy())
2267 request = httpx.Request("POST", f"/{api}", json=req_data)2457 request = httpx.Request("POST", f"/{api}", json=req_data)
2268 return httpx.Response(2458 return httpx.Response(
2269 status_code=200,2459 status_code=200,
2270- json={"status": "cached", "id": req_data["request_id"]},2460+ json={"status": "cached", "id": req_data.get("request_id") or req_data.get("rid")},
2271 request=request,2461 request=request,
2272 )2462 )
2273 2463 
@@ -2281,16 +2471,8 @@ async def test_unified_pd_pool_node_fault_reschedules_and_stops_as_peer_failure(
2281 await asyncio.Event().wait()2471 await asyncio.Event().wait()
2282 yield b'data: {"choices":[{"text":"B","index":0,"token_ids":[11],"finish_reason":"stop"}]}\n\n'2472 yield b'data: {"choices":[{"text":"B","index":0,"token_ids":[11],"finish_reason":"stop"}]}\n\n'
2283 2473 
2284- async def _stop(self, resource, attempt, reason, timeout=1.0):
2285- stop_calls.append((resource.instance.role, attempt.attempt_seq, reason))
2286- return None
2287- 
2288 monkeypatch.setattr(UnifiedPDRouter, "forward_request", _forward_prefill)2474 monkeypatch.setattr(UnifiedPDRouter, "forward_request", _forward_prefill)
2289 monkeypatch.setattr(UnifiedPDRouter, "forward_stream_request", _forward_decode)2475 monkeypatch.setattr(UnifiedPDRouter, "forward_stream_request", _forward_decode)
2290- monkeypatch.setattr(
2291- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
2292- _stop,
2293- )
2294 2476 
2295 pool = HTTPClientPool()2477 pool = HTTPClientPool()
2296 p_endpoint = next(iter(next(iter(scheduler.p.endpoints.values())).values()))2478 p_endpoint = next(iter(next(iter(scheduler.p.endpoints.values())).values()))
@@ -2325,10 +2507,6 @@ async def test_unified_pd_pool_node_fault_reschedules_and_stops_as_peer_failure(
2325 assert decode_calls[1]["prompt"] == [1, 2, 10]2507 assert decode_calls[1]["prompt"] == [1, 2, 10]
2326 assert b'"text":"A"' in body2508 assert b'"text":"A"' in body
2327 assert b'"text":"B"' in body2509 assert b'"text":"B"' in body
2328- assert len(stop_calls) == 2
2329- assert {call[0] for call in stop_calls} == {PDRole.ROLE_P, PDRole.ROLE_D}
2330- assert all(call[1] == 1 for call in stop_calls)
2331- assert all(call[2] == DispatchStopReason.PEER_FAILED for call in stop_calls)
2332 assert req_info.state == ReqState.DECODE_END2510 assert req_info.state == ReqState.DECODE_END
2333 finally:2511 finally:
2334 await pool.close_client(2512 await pool.close_client(
@@ -2365,7 +2543,6 @@ async def test_unified_pd_stream_error_after_visible_chunk_without_replay_does_n
2365 )2543 )
2366 p_client = _Client("prefill")2544 p_client = _Client("prefill")
2367 d_client = _StreamClient("decode", exc_after_chunks=httpx.ReadError("after chunk"))2545 d_client = _StreamClient("decode", exc_after_chunks=httpx.ReadError("after chunk"))
2368- stop_calls = []
2369 2546 
2370 @asynccontextmanager2547 @asynccontextmanager
2371 async def _client_for(resource: ScheduledResource):2548 async def _client_for(resource: ScheduledResource):
@@ -2374,15 +2551,7 @@ async def test_unified_pd_stream_error_after_visible_chunk_without_replay_does_n
2374 else:2551 else:
2375 yield d_client2552 yield d_client
2376 2553 
2377- async def _stop(self, resource, attempt, reason, timeout=1.0):
2378- stop_calls.append((resource.instance.role, attempt.attempt_seq, reason.value))
2379- return None
2380- 
2381 monkeypatch.setattr(router, "_client_for", _client_for)2554 monkeypatch.setattr(router, "_client_for", _client_for)
2382- monkeypatch.setattr(
2383- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
2384- _stop,
2385- )
2386 2555 
2387 response = await router.handle_request()2556 response = await router.handle_request()
2388 chunks = [chunk async for chunk in response.body_iterator]2557 chunks = [chunk async for chunk in response.body_iterator]
@@ -2391,7 +2560,6 @@ async def test_unified_pd_stream_error_after_visible_chunk_without_replay_does_n
2391 error_chunk = chunks[1].decode("utf-8") if isinstance(chunks[1], bytes) else chunks[1]2560 error_chunk = chunks[1].decode("utf-8") if isinstance(chunks[1], bytes) else chunks[1]
2392 assert "ReadError" in error_chunk2561 assert "ReadError" in error_chunk
2393 assert len(d_client.requests) == 12562 assert len(d_client.requests) == 1
2394- assert len(stop_calls) == 2
2395 await router._drain_release_tasks()2563 await router._drain_release_tasks()
2396 assert scheduler.update_workload.await_count == 22564 assert scheduler.update_workload.await_count == 2
2397 2565 
@@ -2437,14 +2605,7 @@ async def test_unified_pd_stream_error_before_first_body_retries_without_token_r
2437 else:2605 else:
2438 yield d_client2606 yield d_client
2439 2607 
2440- async def _stop(self, resource, attempt, reason, timeout=1.0):
2441- return None
2442- 
2443 monkeypatch.setattr(router, "_client_for", _client_for)2608 monkeypatch.setattr(router, "_client_for", _client_for)
2444- monkeypatch.setattr(
2445- "motor.coordinator.router.stop_client.DispatchStopClient.stop",
2446- _stop,
2447- )
2448 2609 
2449 response = await router.handle_request()2610 response = await router.handle_request()
2450 chunks = [chunk async for chunk in response.body_iterator]2611 chunks = [chunk async for chunk in response.body_iterator]
@@ -2453,31 +2614,255 @@ async def test_unified_pd_stream_error_before_first_body_retries_without_token_r
2453 assert chunks == [b'data: {"choices":[{"delta":{"content":"B"},"index":0,"finish_reason":"stop"}]}\n\n']2614 assert chunks == [b'data: {"choices":[{"delta":{"content":"B"},"index":0,"finish_reason":"stop"}]}\n\n']
2454 2615 
2455 2616 
2456-def test_dispatch_carries_effective_output_budget_to_prefill_leg():2617+@pytest.mark.asyncio
2457- session = PDDispatchSession(2618+async def test_unified_pd_nonstream_falls_back_to_hybrid_when_decode_pool_exhausted(
2458- "request-1",2619+ monkeypatch,
2459- prefill_context_budget=PrefillContextBudget(2620+):
2460- max_output_tokens=24,2621+ req_info = RequestInfo(
2461- parameter="max_completion_tokens",2622+ req_id="root-nonstream-fallback",
2462- ),2623+ req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
2624+ api="v1/completions",
2625+ entry_api="v1/completions",
2626+ req_len=10,
2463 )2627 )
2464- attempt = session.new_attempt(None, None, config=None, consumed_output_tokens=5)2628+ scheduler = _Scheduler(
2629+ prefill_engine_type="vllm",
2630+ decode_engine_type="vllm",
2631+ )
2632+ select_and_allocate = scheduler.select_and_allocate
2465 2633 
2466- dispatch = attempt.dispatch_for(PDRole.ROLE_P, "prefill_handoff_decode")2634+ async def _select_and_allocate(role, request_info, **kwargs):
2635+ if role == PDRole.ROLE_D:
2636+ return None
2637+ return await select_and_allocate(role, request_info, **kwargs)
2467 2638 
2468- assert dispatch.prefill_context_budget == PrefillContextBudget(2639+ async def _get_unblocked_instances(role):
2469- max_output_tokens=19,2640+ if role == PDRole.ROLE_D:
2470- parameter="max_completion_tokens",2641+ return []
2642+ return [scheduler.p.id]
2643+ 
2644+ scheduler.select_and_allocate = AsyncMock(side_effect=_select_and_allocate)
2645+ scheduler.get_unblocked_instances = _get_unblocked_instances
2646+ config = _config()
2647+ router = UnifiedPDRouter(
2648+ req_info,
2649+ config,
2650+ scheduler=scheduler,
2651+ request_manager=RequestManager(config),
2652+ )
2653+ p_client = _NativeHandoffPrefillClient("prefill")
2654+ fallback_calls = []
2655+ 
2656+ class _FallbackRouter:
2657+ async def handle_request(self, *, manage_request_context):
2658+ fallback_calls.append(manage_request_context)
2659+ return JSONResponse({"choices": [{"text": "hybrid"}]})
2660+ 
2661+ @asynccontextmanager
2662+ async def _client_for(resource: ScheduledResource):
2663+ assert resource.instance.role == PDRole.ROLE_P
2664+ yield p_client
2665+ 
2666+ monkeypatch.setattr(router, "_client_for", _client_for)
2667+ monkeypatch.setattr(router, "_build_hybrid_fallback_router", _FallbackRouter)
2668+ 
2669+ response = await router.handle_request()
2670+ 
2671+ assert json.loads(response.body)["choices"][0]["text"] == "hybrid"
2672+ assert fallback_calls == [False]
2673+ assert len(p_client.requests) == 1
2674+ assert scheduler.select_and_allocate.await_count == 2
2675+ await router._drain_release_tasks()
2676+ assert scheduler.update_workload.await_count == 1
2677+ release = scheduler.update_workload.await_args.args[0]
2678+ assert release.role == PDRole.ROLE_P
2679+ assert release.workload_action == WorkloadAction.RELEASE_TOKENS
2680+ 
2681+ 
2682+@pytest.mark.asyncio
2683+async def test_unified_pd_runtime_fallback_respects_disabled_switch(monkeypatch):
2684+ req_info = RequestInfo(
2685+ req_id="root-fallback-disabled",
2686+ req_data={"model": "m", "prompt": "hello", "stream": False, "max_tokens": 8},
2687+ api="v1/completions",
2688+ entry_api="v1/completions",
2689+ req_len=10,
2690+ )
2691+ scheduler = _Scheduler(
2692+ prefill_engine_type="vllm",
2693+ decode_engine_type="vllm",
2694+ )
2695+ select_and_allocate = scheduler.select_and_allocate
2696+ 
2697+ async def _select_and_allocate(role, request_info, **kwargs):
2698+ if role == PDRole.ROLE_D:
2699+ return None
2700+ return await select_and_allocate(role, request_info, **kwargs)
2701+ 
2702+ scheduler.select_and_allocate = AsyncMock(side_effect=_select_and_allocate)
2703+ scheduler.get_unblocked_instances = AsyncMock(return_value=[scheduler.p.id])
2704+ config = _config()
2705+ config.scheduler_config.enable_pd_separation_fallback_to_hybrid = False
2706+ router = UnifiedPDRouter(
2707+ req_info,
2708+ config,
2709+ scheduler=scheduler,
2710+ request_manager=RequestManager(config),
2711+ )
2712+ p_client = _NativeHandoffPrefillClient("prefill")
2713+ 
2714+ @asynccontextmanager
2715+ async def _client_for(resource: ScheduledResource):
2716+ assert resource.instance.role == PDRole.ROLE_P
2717+ yield p_client
2718+ 
2719+ monkeypatch.setattr(router, "_client_for", _client_for)
2720+ monkeypatch.setattr(
2721+ router,
2722+ "_build_hybrid_fallback_router",
2723+ lambda: pytest.fail("disabled runtime fallback must not build PDHybridRouter"),
2471 )2724 )
2472 2725 
2726+ with pytest.raises(HTTPException, match="No instance available for role"):
2727+ await router.handle_request()
2473 2728 
2474-def test_unified_pd_prefers_max_completion_tokens_and_preserves_parameter():2729+ scheduler.get_unblocked_instances.assert_not_awaited()
2475- router = SimpleNamespace(req_info=SimpleNamespace(req_data={"max_tokens": 32, "max_completion_tokens": 24}))
2476 2730 
2477- assert UnifiedPDRouter._prefill_context_budget(router) == PrefillContextBudget(2731+ 
2478- max_output_tokens=24,2732+@pytest.mark.asyncio
2479- parameter="max_completion_tokens",2733+async def test_unified_pd_stream_restarts_on_hybrid_before_commit_when_decode_pool_exhausted(
2734+ monkeypatch,
2735+):
2736+ req_info = RequestInfo(
2737+ req_id="root-stream-fallback-restart",
2738+ req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},
2739+ api="v1/completions",
2740+ entry_api="v1/completions",
2741+ req_len=10,
2480 )2742 )
2743+ scheduler = _Scheduler(
2744+ prefill_engine_type="vllm",
2745+ decode_engine_type="vllm",
2746+ )
2747+ select_and_allocate = scheduler.select_and_allocate
2748+ 
2749+ async def _select_and_allocate(role, request_info, **kwargs):
2750+ if role == PDRole.ROLE_D:
2751+ return None
2752+ return await select_and_allocate(role, request_info, **kwargs)
2753+ 
2754+ async def _get_unblocked_instances(role):
2755+ if role == PDRole.ROLE_D:
2756+ return []
2757+ return [scheduler.p.id]
2758+ 
2759+ scheduler.select_and_allocate = AsyncMock(side_effect=_select_and_allocate)
2760+ scheduler.get_unblocked_instances = _get_unblocked_instances
2761+ config = _config()
2762+ router = UnifiedPDRouter(
2763+ req_info,
2764+ config,
2765+ scheduler=scheduler,
2766+ request_manager=RequestManager(config),
2767+ )
2768+ p_client = _NativeHandoffPrefillClient("prefill")
2769+ fallback_calls = []
2770+ 
2771+ class _FallbackRouter:
2772+ async def stream_fallback_from_existing_context(self, **kwargs):
2773+ fallback_calls.append(kwargs)
2774+ kwargs["mark_unified_ready"]()
2775+ yield b'data: {"choices":[{"text":"hybrid","finish_reason":"stop"}]}\n\n'
2776+ 
2777+ @asynccontextmanager
2778+ async def _client_for(resource: ScheduledResource):
2779+ assert resource.instance.role == PDRole.ROLE_P
2780+ yield p_client
2781+ 
2782+ monkeypatch.setattr(router, "_client_for", _client_for)
2783+ monkeypatch.setattr(router, "_build_hybrid_fallback_router", _FallbackRouter)
2784+ 
2785+ response = await router.handle_request()
2786+ chunks = [chunk async for chunk in response.body_iterator]
2787+ 
2788+ assert chunks == [b'data: {"choices":[{"text":"hybrid","finish_reason":"stop"}]}\n\n']
2789+ assert len(fallback_calls) == 1
2790+ assert fallback_calls[0]["attempt_id"] == 2
2791+ assert fallback_calls[0]["req_data"]["prompt"] == "hello"
2792+ assert fallback_calls[0]["mark_unified_ready"] is not None
2793+ 
2794+ 
2795+@pytest.mark.asyncio
2796+async def test_unified_pd_stream_resumes_on_hybrid_with_token_replay_after_commit(
2797+ monkeypatch,
2798+):
2799+ req_info = RequestInfo(
2800+ req_id="root-stream-fallback-resume",
2801+ req_data={"model": "m", "prompt": "hello", "stream": True, "max_tokens": 8},
2802+ api="v1/completions",
2803+ entry_api="v1/completions",
2804+ req_len=10,
2805+ )
2806+ scheduler = _Scheduler(
2807+ prefill_engine_type="vllm",
2808+ decode_engine_type="vllm",
2809+ )
2810+ 
2811+ async def _get_unblocked_instances(role):
2812+ if role == PDRole.ROLE_D:
2813+ return []
2814+ return [scheduler.p.id]
2815+ 
2816+ scheduler.get_unblocked_instances = _get_unblocked_instances
2817+ config = _config()
2818+ config.exception_config.transport_max_retry = 2
2819+ config.exception_config.reschedule_enabled = True
2820+ router = UnifiedPDRouter(
2821+ req_info,
2822+ config,
2823+ scheduler=scheduler,
2824+ request_manager=RequestManager(config),
2825+ )
2826+ p_client = _NativeHandoffPrefillClient("prefill")
2827+ d_client = _SequenceStreamClient(
2828+ "decode",
2829+ [
2830+ _StreamResponse(
2831+ [
2832+ b'data: {"choices":[{"text":"A","index":0,"prompt_token_ids":[1,2],"token_ids":[10]}]}\n\n',
2833+ ],
2834+ exc_after_chunks=httpx.ReadError("decode disappeared"),
2835+ )
2836+ ],
2837+ )
2838+ fallback_calls = []
2839+ 
2840+ class _FallbackRouter:
2841+ async def stream_fallback_from_existing_context(self, **kwargs):
2842+ fallback_calls.append(kwargs)
2843+ yield b'data: {"choices":[{"text":"B","token_ids":[11],"finish_reason":"stop"}]}\n\n'
2844+ 
2845+ @asynccontextmanager
2846+ async def _client_for(resource: ScheduledResource):
2847+ if resource.instance.role == PDRole.ROLE_P:
2848+ yield p_client
2849+ else:
2850+ yield d_client
2851+ 
2852+ monkeypatch.setattr(router, "_client_for", _client_for)
2853+ monkeypatch.setattr(router, "_build_hybrid_fallback_router", _FallbackRouter)
2854+ 
2855+ response = await router.handle_request()
2856+ chunks = [chunk async for chunk in response.body_iterator]
2857+ body = b"".join(chunk if isinstance(chunk, bytes) else chunk.encode() for chunk in chunks)
2858+ 
2859+ assert b'"text":"A"' in body
2860+ assert b'"text":"B"' in body
2861+ assert len(fallback_calls) == 1
2862+ assert fallback_calls[0]["is_resume"] is True
2863+ assert fallback_calls[0]["api"] == "v1/completions"
2864+ assert fallback_calls[0]["req_data"]["prompt"] == [1, 2, 10]
2865+ assert fallback_calls[0]["req_data"]["max_tokens"] == 7
2481 2866 
2482 2867 
2483def test_retry_plan_preserves_max_completion_tokens_precedence_for_completion_replay():2868def test_retry_plan_preserves_max_completion_tokens_precedence_for_completion_replay():
@@ -250,8 +250,7 @@ def _make_error(status_code: int, body: bytes = b"") -> UpstreamHTTPError:
250 250 
251def test_cb_not_reportable_for_4xx():251def test_cb_not_reportable_for_4xx():
252 """4xx errors are client errors (e.g. input too long, bad params); the instance252 """4xx errors are client errors (e.g. input too long, bad params); the instance
253- must not be penalised. The engine_server is responsible for mapping all known253+ must not be penalised. Native engines return request-validation failures as 4xx.
254- request-validation exceptions to 4xx before they reach the coordinator.
255 """254 """
256 assert not is_cb_reportable_failure(_make_error(400))255 assert not is_cb_reportable_failure(_make_error(400))
257 assert not is_cb_reportable_failure(_make_error(422))256 assert not is_cb_reportable_failure(_make_error(422))
@@ -264,8 +263,8 @@ def test_cb_reportable_for_5xx():
264 263 
265 264 
266def test_cb_not_reportable_for_vllm_validation_error_returned_as_400():265def test_cb_not_reportable_for_vllm_validation_error_returned_as_400():
267- """VLLMValidationError (max_tokens > max_model_len) must be mapped to HTTP 400 by266+ """A native vLLM validation failure returned as HTTP 400 must not trip the
268- the engine_server so the coordinator never sees it as a 5xx fault.267+ coordinator circuit breaker.
269 """268 """
270 body = json.dumps({"detail": "max_tokens=1024 cannot be greater than max_model_len=50"}).encode()269 body = json.dumps({"detail": "max_tokens=1024 cannot be greater than max_model_len=50"}).encode()
271 error = _make_error(400, body)270 error = _make_error(400, body)
@@ -14,7 +14,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
14 14 
15import pytest15import pytest
16 16 
17-from motor.common.resources.dispatch import DispatchPlan
18from motor.common.resources.instance import Instance, PDRole17from motor.common.resources.instance import Instance, PDRole
19from motor.common.resources.endpoint import Endpoint, EndpointStatus, Workload18from motor.common.resources.endpoint import Endpoint, EndpointStatus, Workload
20from motor.coordinator.domain.scheduling import InstanceReadiness19from motor.coordinator.domain.scheduling import InstanceReadiness
@@ -192,8 +191,6 @@ async def test_has_required_instances_uses_cache_after_warmup():
192 ("10.0.0.3", "8003", EndpointStatus.NORMAL),191 ("10.0.0.3", "8003", EndpointStatus.NORMAL),
193 ],192 ],
194 )193 )
195- p_inst.dispatch_capabilities = [DispatchPlan.PREFILL_HANDOFF_DECODE.value]
196- d_inst.dispatch_capabilities = [DispatchPlan.PREFILL_HANDOFF_DECODE.value]
197 await client._cache.replace_all(PDRole.ROLE_E, [e_inst])194 await client._cache.replace_all(PDRole.ROLE_E, [e_inst])
198 await client._cache.replace_all(PDRole.ROLE_P, [p_inst])195 await client._cache.replace_all(PDRole.ROLE_P, [p_inst])
199 await client._cache.replace_all(PDRole.ROLE_D, [d_inst])196 await client._cache.replace_all(PDRole.ROLE_D, [d_inst])
@@ -15,7 +15,6 @@ from unittest.mock import AsyncMock, Mock, call, patch
15 15 
16import pytest16import pytest
17 17 
18-from motor.common.resources.dispatch import DispatchPlan
19from motor.common.resources.instance import Instance, PDRole18from motor.common.resources.instance import Instance, PDRole
20from motor.common.resources.endpoint import Endpoint, Workload, WorkloadAction, EndpointStatus19from motor.common.resources.endpoint import Endpoint, Workload, WorkloadAction, EndpointStatus
21from motor.coordinator.domain import InstanceReadiness, UpdateWorkloadParams20from motor.coordinator.domain import InstanceReadiness, UpdateWorkloadParams
@@ -67,7 +66,7 @@ def _make_instance(
67 instance_id: int = 1,66 instance_id: int = 1,
68 role: str = "prefill",67 role: str = "prefill",
69 endpoints: dict | None = None,68 endpoints: dict | None = None,
70- dispatch_capabilities: list[str] | None = None,69+ engine_type: str | None = None,
71) -> Instance:70) -> Instance:
72 """Create a real Instance (used by _SchedulerInstanceCache tests)."""71 """Create a real Instance (used by _SchedulerInstanceCache tests)."""
73 if endpoints is None:72 if endpoints is None:
@@ -76,10 +75,10 @@ def _make_instance(
76 return Instance(75 return Instance(
77 job_name="test-job",76 job_name="test-job",
78 model_name="test-model",77 model_name="test-model",
78+ engine_type=engine_type,
79 id=instance_id,79 id=instance_id,
80 role=role,80 role=role,
81 endpoints=endpoints,81 endpoints=endpoints,
82- dispatch_capabilities=dispatch_capabilities or [],
83 )82 )
84 83 
85 84 
@@ -419,6 +418,34 @@ class TestAsyncSchedulerClient:
419 )418 )
420 assert result == []419 assert result == []
421 420 
421+ @pytest.mark.asyncio
422+ async def test_select_endpoint_candidates_filters_required_engine_type(self):
423+ """Decode candidate selection must not mix native engine protocols."""
424+ vllm = _make_instance(
425+ instance_id=1,
426+ role="decode",
427+ endpoints={"pod1": {1: _make_endpoint(endpoint_id=1)}},
428+ engine_type="vllm",
429+ )
430+ sglang = _make_instance(
431+ instance_id=2,
432+ role="decode",
433+ endpoints={"pod2": {2: _make_endpoint(endpoint_id=2)}},
434+ engine_type="sglang",
435+ )
436+ self.mock_cache.get_instances.return_value = [vllm, sglang]
437+ req_info = Mock(spec=RequestInfo)
438+ req_info.req_id = "req-engine-filter"
439+ req_info.req_len = 10
440+ 
441+ candidates, _ = await self.client._select_endpoint_candidates_with_policy(
442+ req_info,
443+ PDRole.ROLE_D,
444+ required_engine_type=" SGLang ",
445+ )
446+ 
447+ assert [(instance.id, endpoint.id) for instance, endpoint, _ in candidates] == [(2, 2)]
448+ 
422 # -- test_select_and_allocate -------------------------------------------449 # -- test_select_and_allocate -------------------------------------------
423 450 
424 @pytest.mark.asyncio451 @pytest.mark.asyncio
@@ -666,9 +693,8 @@ class TestAsyncSchedulerClient:
666 @pytest.mark.asyncio693 @pytest.mark.asyncio
667 async def test_has_required_instances_met(self):694 async def test_has_required_instances_met(self):
668 """has_required_instances returns REQUIRED_MET when P and D present."""695 """has_required_instances returns REQUIRED_MET when P and D present."""
669- capability = [DispatchPlan.CONCURRENT_ENGINE_SYNC.value]696+ mock_p = _make_instance(1, PDRole.ROLE_P)
670- mock_p = _make_instance(1, PDRole.ROLE_P, dispatch_capabilities=capability)697+ mock_d = _make_instance(2, PDRole.ROLE_D)
671- mock_d = _make_instance(2, PDRole.ROLE_D, dispatch_capabilities=capability)
672 698 
673 def _get_instances_side_effect(role):699 def _get_instances_side_effect(role):
674 mapping = {PDRole.ROLE_P: [mock_p], PDRole.ROLE_D: [mock_d]}700 mapping = {PDRole.ROLE_P: [mock_p], PDRole.ROLE_D: [mock_d]}
@@ -696,31 +722,6 @@ class TestAsyncSchedulerClient:
696 assert result.is_ready() is False722 assert result.is_ready() is False
697 self.mock_transport.send_request.assert_not_awaited()723 self.mock_transport.send_request.assert_not_awaited()
698 724 
699- @pytest.mark.asyncio
700- async def test_has_required_instances_rejects_incompatible_pd_pair(self):
701- prefill = _make_instance(
702- 1,
703- PDRole.ROLE_P,
704- dispatch_capabilities=[DispatchPlan.CONCURRENT_ENGINE_SYNC.value],
705- )
706- decode = _make_instance(
707- 2,
708- PDRole.ROLE_D,
709- dispatch_capabilities=[DispatchPlan.PREFILL_HANDOFF_DECODE.value],
710- )
711- 
712- def _get_instances_side_effect(role):
713- mapping = {PDRole.ROLE_P: [prefill], PDRole.ROLE_D: [decode]}
714- return mapping.get(role, [])
715- 
716- self.mock_cache.get_instances.side_effect = _get_instances_side_effect
717- 
718- result = await self.client.has_required_instances()
719- 
720- assert result == InstanceReadiness.UNKNOWN
721- assert result.is_run() is False
722- self.mock_transport.send_request.assert_not_awaited()
723- 
724 @pytest.mark.asyncio725 @pytest.mark.asyncio
725 async def test_has_required_instances_union_wins_over_partial_roles(self):726 async def test_has_required_instances_union_wins_over_partial_roles(self):
726 decode = _make_instance(1, PDRole.ROLE_D)727 decode = _make_instance(1, PDRole.ROLE_D)
@@ -95,12 +95,14 @@ def _make_instance(
95 instance_id: int,95 instance_id: int,
96 endpoint_ids: tuple[int, ...],96 endpoint_ids: tuple[int, ...],
97 role: PDRole = PDRole.ROLE_P,97 role: PDRole = PDRole.ROLE_P,
98+ engine_type: str | None = None,
98) -> Instance:99) -> Instance:
99 inst = Instance(100 inst = Instance(
100 job_name=f"{role.value}-{instance_id}",101 job_name=f"{role.value}-{instance_id}",
101 model_name="test_model",102 model_name="test_model",
102 id=instance_id,103 id=instance_id,
103 role=role,104 role=role,
105+ engine_type=engine_type,
104 status=InsStatus.ACTIVE,106 status=InsStatus.ACTIVE,
105 parallel_config=ParallelConfig(dp_size=len(endpoint_ids)),107 parallel_config=ParallelConfig(dp_size=len(endpoint_ids)),
106 )108 )
@@ -197,8 +199,8 @@ class TestSerializeInstanceMinimal:
197 assert result["job_name"] == inst.job_name199 assert result["job_name"] == inst.job_name
198 assert result["model_name"] == "test_model"200 assert result["model_name"] == "test_model"
199 assert result["engine_type"] is None201 assert result["engine_type"] is None
200- assert result["dispatch_capabilities"] == []202+ assert "dispatch_capabilities" not in result
201- assert len(result) == 6203+ assert len(result) == 5
202 204 
203 205 
204class TestSerializeEndpointMinimal:206class TestSerializeEndpointMinimal:
@@ -224,6 +226,20 @@ class TestSerializeEndpointMinimal:
224 result = _serialize_endpoint_minimal(ep)226 result = _serialize_endpoint_minimal(ep)
225 assert result["status"] == EndpointStatus.NORMAL.value227 assert result["status"] == EndpointStatus.NORMAL.value
226 228 
229+ def test_endpoint_serializes_bootstrap_port(self):
230+ ep = Endpoint(
231+ id=14,
232+ ip="10.0.0.14",
233+ business_port="8014",
234+ mgmt_port="9014",
235+ bootstrap_port=9114,
236+ )
237+ 
238+ result = _serialize_endpoint_minimal(ep)
239+ restored = Endpoint.model_validate(result)
240+ 
241+ assert restored.bootstrap_port == 9114
242+ 
227 def test_endpoint_with_empty_mgmt_port_defaults_empty_string(self):243 def test_endpoint_with_empty_mgmt_port_defaults_empty_string(self):
228 """mgmt_port='' → serializer returns empty string (falsy → or '' branch)."""244 """mgmt_port='' → serializer returns empty string (falsy → or '' branch)."""
229 ep = Endpoint(id=13, ip="9.9.9.9", business_port="9000", mgmt_port="")245 ep = Endpoint(id=13, ip="9.9.9.9", business_port="9000", mgmt_port="")
@@ -772,6 +788,30 @@ class TestHandleAllocateOnlyEdgeCases:
772 assert response.data["instance"] is None788 assert response.data["instance"] is None
773 assert response.data["endpoint"] is None789 assert response.data["endpoint"] is None
774 790 
791+ @pytest.mark.asyncio
792+ async def test_required_engine_type_rejects_mismatched_candidate(self):
793+ dispatcher, instance_manager, *_ = _make_dispatcher(scheduler_type=SchedulerType.ROUND_ROBIN)
794+ instance = _make_instance(1, (10,), PDRole.ROLE_D, engine_type="vllm")
795+ await instance_manager.refresh_instances(EventType.ADD, [instance])
796+ 
797+ request = SchedulerRequest(
798+ request_type=SchedulerRequestType.ALLOCATE_ONLY,
799+ request_id="req-engine-filter",
800+ data={
801+ "instance_id": 1,
802+ "endpoint_id": 10,
803+ "role": PDRole.ROLE_D.value,
804+ "required_engine_type": "sglang",
805+ "workload_active_tokens": 1,
806+ },
807+ )
808+ 
809+ response = await dispatcher.dispatch(request)
810+ 
811+ assert response.response_type == SchedulerResponseType.SUCCESS
812+ assert response.data["instance"] is None
813+ assert response.data["endpoint"] is None
814+ 
775 815 
776class TestParseOptionalInt:816class TestParseOptionalInt:
777 def test_none_returns_none(self):817 def test_none_returns_none(self):
@@ -172,18 +172,21 @@ class TestCoordinatorServer:
172 im_mock_cls.return_value = im_instance172 im_mock_cls.return_value = im_instance
173 173 
174 # Mock handle_request to return appropriate JSON response174 # Mock handle_request to return appropriate JSON response
175- async def mock_handle_request(request, config, scheduler=None, request_manager=None):175+ async def mock_handle_request(request, config, scheduler=None, request_manager=None, request_json=None):
176 """Mock handle_request that returns JSON response matching test expectations"""176 """Mock handle_request that returns JSON response matching test expectations"""
177- try:177+ if request_json is not None:
178- # Try to get JSON from request (cached if already parsed)178+ body_json = request_json
179- body_json = await request.json()179+ else:
180- except Exception:
181- # Fallback: try to read body directly
182 try:180 try:
183- request_body = await request.body()181+ # Try to get JSON from request (cached if already parsed)
184- body_json = json.loads(request_body.decode("utf-8"))182+ body_json = await request.json()
185 except Exception:183 except Exception:
186- body_json = {}184+ # Fallback: try to read body directly
185+ try:
186+ request_body = await request.body()
187+ body_json = json.loads(request_body.decode("utf-8"))
188+ except Exception:
189+ body_json = {}
187 190 
188 # Extract input_data based on request type191 # Extract input_data based on request type
189 input_data = ""192 input_data = ""
@@ -800,18 +803,21 @@ class TestCoordinatorServerAdvanced:
800 im_mock_cls.return_value = im_instance803 im_mock_cls.return_value = im_instance
801 804 
802 # Mock handle_request to return appropriate JSON response805 # Mock handle_request to return appropriate JSON response
803- async def mock_handle_request(request, config, scheduler=None, request_manager=None):806+ async def mock_handle_request(request, config, scheduler=None, request_manager=None, request_json=None):
804 """Mock handle_request that returns JSON response matching test expectations"""807 """Mock handle_request that returns JSON response matching test expectations"""
805- try:808+ if request_json is not None:
806- # Try to get JSON from request (cached if already parsed)809+ body_json = request_json
807- body_json = await request.json()810+ else:
808- except Exception:
809- # Fallback: try to read body directly
810 try:811 try:
811- request_body = await request.body()812+ # Try to get JSON from request (cached if already parsed)
812- body_json = json.loads(request_body.decode("utf-8"))813+ body_json = await request.json()
813 except Exception:814 except Exception:
814- body_json = {}815+ # Fallback: try to read body directly
816+ try:
817+ request_body = await request.body()
818+ body_json = json.loads(request_body.decode("utf-8"))
819+ except Exception:
820+ body_json = {}
815 821 
816 # Extract input_data based on request type822 # Extract input_data based on request type
817 input_data = ""823 input_data = ""
@@ -1850,11 +1856,14 @@ class TestAnthropicEndpoints:
1850 im_mock_cls.return_value = im_instance1856 im_mock_cls.return_value = im_instance
1851 1857 
1852 # Mock handle_request to return appropriate response1858 # Mock handle_request to return appropriate response
1853- async def mock_handle_request(request, config, scheduler=None, request_manager=None):1859+ async def mock_handle_request(request, config, scheduler=None, request_manager=None, request_json=None):
1854- try:1860+ if request_json is not None:
1855- body_json = await request.json()1861+ body_json = request_json
1856- except Exception:1862+ else:
1857- body_json = {}1863+ try:
1864+ body_json = await request.json()
1865+ except Exception:
1866+ body_json = {}
1858 1867 
1859 if "messages" in body_json:1868 if "messages" in body_json:
1860 json.dumps(body_json["messages"], ensure_ascii=False)1869 json.dumps(body_json["messages"], ensure_ascii=False)
@@ -0,0 +1,230 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Opt-in smoke tests against a real native vLLM or SGLang P/D pair.
12+ 
13+Required environment:
14+ 
15+* ``MOTOR_RUN_NATIVE_PD_SMOKE=1``
16+* ``MOTOR_NATIVE_SMOKE_ENGINE_TYPE=vllm|sglang``
17+* ``MOTOR_NATIVE_SMOKE_PREFILL_URL=http[s]://host:port``
18+* ``MOTOR_NATIVE_SMOKE_DECODE_URL=http[s]://host:port``
19+* ``MOTOR_NATIVE_SMOKE_MODEL=<served model name>``
20+* ``MOTOR_NATIVE_SMOKE_BOOTSTRAP_PORT=<port>`` for SGLang
21+ 
22+HTTPS additionally requires ``MOTOR_NATIVE_SMOKE_CA_FILE``,
23+``MOTOR_NATIVE_SMOKE_CERT_FILE``, and ``MOTOR_NATIVE_SMOKE_KEY_FILE``.
24+"""
25+ 
26+import json
27+import os
28+from urllib.parse import urlparse
29+ 
30+import pytest
31+ 
32+from motor.common.http import HTTPClientPool
33+from motor.common.resources.endpoint import Endpoint, EndpointStatus, Workload
34+from motor.common.resources.instance import Instance, InsStatus, ParallelConfig, PDRole
35+from motor.config.coordinator import CoordinatorConfig, ExceptionConfig
36+from motor.config.tls_config import TLSConfig
37+from motor.coordinator.domain.request_manager import RequestManager
38+from motor.coordinator.models.request import RequestInfo, ReqState
39+from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter
40+ 
41+ 
42+pytestmark = pytest.mark.integration
43+ 
44+ 
45+def _required_env(name: str) -> str:
46+ value = os.getenv(name, "").strip()
47+ if not value:
48+ pytest.fail(f"{name} is required when MOTOR_RUN_NATIVE_PD_SMOKE=1")
49+ return value
50+ 
51+ 
52+def _parse_endpoint_url(name: str) -> tuple[str, int, str]:
53+ raw = _required_env(name)
54+ parsed = urlparse(raw)
55+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
56+ pytest.fail(f"{name} must be an http[s]://host:port URL")
57+ if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
58+ pytest.fail(f"{name} must not contain a path, query, or fragment")
59+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
60+ return parsed.hostname, port, parsed.scheme
61+ 
62+ 
63+def _instance(
64+ instance_id: int,
65+ role: PDRole,
66+ engine_type: str,
67+ host: str,
68+ port: int,
69+ *,
70+ bootstrap_port: int | None = None,
71+) -> Instance:
72+ endpoint = Endpoint(
73+ id=instance_id,
74+ ip=host,
75+ business_port=str(port),
76+ mgmt_port=str(port),
77+ bootstrap_port=bootstrap_port,
78+ status=EndpointStatus.NORMAL,
79+ )
80+ return Instance(
81+ job_name=f"smoke-{engine_type}-{role.value}",
82+ model_name=_required_env("MOTOR_NATIVE_SMOKE_MODEL"),
83+ engine_type=engine_type,
84+ id=instance_id,
85+ role=role,
86+ status=InsStatus.ACTIVE,
87+ parallel_config=ParallelConfig(dp_size=1, tp_size=1),
88+ endpoints={host: {instance_id: endpoint}},
89+ )
90+ 
91+ 
92+class _RealNativeScheduler:
93+ def __init__(
94+ self,
95+ engine_type: str,
96+ prefill: tuple[str, int],
97+ decode: tuple[str, int],
98+ bootstrap_port: int | None,
99+ ):
100+ self.p = _instance(
101+ 1,
102+ PDRole.ROLE_P,
103+ engine_type,
104+ *prefill,
105+ bootstrap_port=bootstrap_port,
106+ )
107+ self.d = _instance(2, PDRole.ROLE_D, engine_type, *decode)
108+ 
109+ async def select_and_allocate(self, role, _req_info, **_kwargs):
110+ instance = self.p if role == PDRole.ROLE_P else self.d
111+ endpoint = next(iter(next(iter(instance.endpoints.values())).values()))
112+ return instance, endpoint, Workload(active_tokens=1)
113+ 
114+ async def update_workload(self, _params):
115+ return True
116+ 
117+ async def report_cb_event(self, _instance_id: int, _event: str) -> None:
118+ return None
119+ 
120+ async def get_unblocked_instances(self, role) -> list[int]:
121+ if role == PDRole.ROLE_P:
122+ return [self.p.id]
123+ if role == PDRole.ROLE_D:
124+ return [self.d.id]
125+ return []
126+ 
127+ 
128+def _tls_config(scheme: str) -> TLSConfig:
129+ if scheme == "http":
130+ return TLSConfig(enable_tls=False)
131+ return TLSConfig(
132+ enable_tls=True,
133+ ca_file=_required_env("MOTOR_NATIVE_SMOKE_CA_FILE"),
134+ cert_file=_required_env("MOTOR_NATIVE_SMOKE_CERT_FILE"),
135+ key_file=_required_env("MOTOR_NATIVE_SMOKE_KEY_FILE"),
136+ passwd_file=os.getenv("MOTOR_NATIVE_SMOKE_PASSWD_FILE", "").strip(),
137+ crl_file=os.getenv("MOTOR_NATIVE_SMOKE_CRL_FILE", "").strip(),
138+ )
139+ 
140+ 
141+async def _stream_body(response) -> bytes:
142+ chunks = []
143+ async for chunk in response.body_iterator:
144+ chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
145+ return b"".join(chunks)
146+ 
147+ 
148+@pytest.mark.parametrize(
149+ ("api", "request_payload", "stream"),
150+ [
151+ ("v1/completions", {"prompt": "Reply with OK."}, False),
152+ ("v1/completions", {"prompt": "Reply with OK."}, True),
153+ (
154+ "v1/chat/completions",
155+ {"messages": [{"role": "user", "content": "Reply with OK."}]},
156+ False,
157+ ),
158+ (
159+ "v1/chat/completions",
160+ {"messages": [{"role": "user", "content": "Reply with OK."}]},
161+ True,
162+ ),
163+ ],
164+)
165+@pytest.mark.asyncio
166+async def test_real_native_pd_openai_smoke(api, request_payload, stream):
167+ if os.getenv("MOTOR_RUN_NATIVE_PD_SMOKE", "").strip() != "1":
168+ pytest.skip("set MOTOR_RUN_NATIVE_PD_SMOKE=1 to run real native P/D smoke tests")
169+ 
170+ engine_type = _required_env("MOTOR_NATIVE_SMOKE_ENGINE_TYPE").lower()
171+ if engine_type not in {"vllm", "sglang"}:
172+ pytest.fail("MOTOR_NATIVE_SMOKE_ENGINE_TYPE must be vllm or sglang")
173+ p_host, p_port, p_scheme = _parse_endpoint_url("MOTOR_NATIVE_SMOKE_PREFILL_URL")
174+ d_host, d_port, d_scheme = _parse_endpoint_url("MOTOR_NATIVE_SMOKE_DECODE_URL")
175+ if p_scheme != d_scheme:
176+ pytest.fail("Prefill and decode smoke endpoints must use the same HTTP scheme")
177+ bootstrap_port = None
178+ if engine_type == "sglang":
179+ bootstrap_port = int(_required_env("MOTOR_NATIVE_SMOKE_BOOTSTRAP_PORT"))
180+ if not 1 <= bootstrap_port <= 65535:
181+ pytest.fail("MOTOR_NATIVE_SMOKE_BOOTSTRAP_PORT must be in range 1..65535")
182+ 
183+ scheduler = _RealNativeScheduler(
184+ engine_type,
185+ (p_host, p_port),
186+ (d_host, d_port),
187+ bootstrap_port,
188+ )
189+ config = CoordinatorConfig()
190+ config.exception_config = ExceptionConfig(max_retry=1, retry_delay=0)
191+ config.infer_tls_config = _tls_config(p_scheme)
192+ req_data = {
193+ "model": _required_env("MOTOR_NATIVE_SMOKE_MODEL"),
194+ **request_payload,
195+ "max_tokens": 2,
196+ "stream": stream,
197+ }
198+ if stream:
199+ req_data["stream_options"] = {"include_usage": True}
200+ req_info = RequestInfo(
201+ req_id=f"native-smoke-{engine_type}-{api.replace('/', '-')}-{stream}",
202+ req_data=req_data,
203+ api=api,
204+ entry_api=api,
205+ req_len=8,
206+ )
207+ router = UnifiedPDRouter(
208+ req_info,
209+ config,
210+ scheduler=scheduler,
211+ request_manager=RequestManager(config),
212+ )
213+ 
214+ try:
215+ response = await router.handle_request()
216+ if stream:
217+ body = await _stream_body(response)
218+ assert b"data:" in body
219+ assert b'"error"' not in body
220+ else:
221+ body = response.body
222+ payload = json.loads(body)
223+ assert payload.get("choices")
224+ assert b"kv_transfer_params" not in body
225+ assert b"bootstrap_host" not in body
226+ assert b"bootstrap_port" not in body
227+ assert b"bootstrap_room" not in body
228+ assert req_info.state == ReqState.DECODE_END
229+ finally:
230+ await HTTPClientPool().close_all()
@@ -39,6 +39,13 @@ from motor.common.resources.instance import ( # noqa: E402
39 InsConditionEvent,39 InsConditionEvent,
40 PDRole,40 PDRole,
41)41)
42+from examples.deployer.prestop.prestop import ( # noqa: E402
43+ build_curl_tls_args,
44+ get_engine_metrics,
45+ get_engine_type,
46+ get_infer_tls_config,
47+ wait_for_engine_drain,
48+)
42 49 
43# ---------------------------------------------------------------------------50# ---------------------------------------------------------------------------
44# Helpers51# Helpers
@@ -149,6 +156,81 @@ class TestHeartbeatManagerPrestop:
149 heartbeat_mgr.pause_all_endpoints() # no exception156 heartbeat_mgr.pause_all_endpoints() # no exception
150 157 
151 158 
159+def test_prestop_uses_inference_tls_for_native_metrics():
160+ config = {
161+ "motor_deploy_config": {
162+ "tls_config": {
163+ "infer_tls_config": {
164+ "enable_tls": True,
165+ "ca_file": "/certs/ca.pem",
166+ "cert_file": "/certs/client.pem",
167+ "key_file": "/certs/client.key",
168+ "crl_file": "/certs/ca.crl",
169+ }
170+ }
171+ }
172+ }
173+ 
174+ tls_config = get_infer_tls_config(config)
175+ 
176+ assert build_curl_tls_args(tls_config) == [
177+ "--cacert",
178+ "/certs/ca.pem",
179+ "--cert",
180+ "/certs/client.pem",
181+ "--key",
182+ "/certs/client.key",
183+ "--crlfile",
184+ "/certs/ca.crl",
185+ ]
186+ 
187+ 
188+@pytest.mark.parametrize(
189+ ("engine_type", "metrics_text"),
190+ [
191+ ("vllm", "vllm:num_requests_waiting 2\nvllm:num_requests_running 3\n"),
192+ ("sglang", "sglang:num_waiting_reqs 4\nsglang:num_running_reqs 5\n"),
193+ ],
194+)
195+def test_prestop_parses_engine_specific_drain_metrics(engine_type, metrics_text):
196+ with patch("examples.deployer.prestop.prestop._http_get_text", return_value=metrics_text):
197+ assert get_engine_metrics("http://engine/metrics", {}, engine_type) == {
198+ "waiting": 2 if engine_type == "vllm" else 4,
199+ "running": 3 if engine_type == "vllm" else 5,
200+ }
201+ 
202+ 
203+def test_prestop_missing_required_metric_is_not_treated_as_drained():
204+ with patch(
205+ "examples.deployer.prestop.prestop._http_get_text",
206+ return_value="sglang:num_waiting_reqs 0\n",
207+ ):
208+ assert get_engine_metrics("http://engine/metrics", {}, "sglang") is None
209+ 
210+ 
211+def test_prestop_retries_when_metrics_are_temporarily_unavailable():
212+ with (
213+ patch(
214+ "examples.deployer.prestop.prestop.get_engine_metrics",
215+ side_effect=[None, {"waiting": 0, "running": 0}],
216+ ) as get_metrics,
217+ patch("examples.deployer.prestop.prestop.time.monotonic", side_effect=[0, 0, 1, 1]),
218+ patch("examples.deployer.prestop.prestop.time.sleep") as sleep,
219+ ):
220+ drained = wait_for_engine_drain(["http://engine/metrics"], {}, "sglang", 10, 3)
221+ 
222+ assert drained is True
223+ assert get_metrics.call_count == 2
224+ sleep.assert_called_once_with(3)
225+ 
226+ 
227+@patch.dict("os.environ", {"ROLE": "decode"}, clear=False)
228+def test_prestop_resolves_engine_type_from_active_role():
229+ config = {"motor_engine_decode_config": {"engine_type": "sglang"}}
230+ 
231+ assert get_engine_type(config) == "sglang"
232+ 
233+ 
152# ---------------------------------------------------------------------------234# ---------------------------------------------------------------------------
153# 2. Instance — is_all_endpoints_paused235# 2. Instance — is_all_endpoints_paused
154# ---------------------------------------------------------------------------236# ---------------------------------------------------------------------------
@@ -70,7 +70,7 @@ def _install_engine_fakes():
70 mock_endpoint_cfg_mod.EndpointConfig.init_endpoint_config.return_value = mock_ep70 mock_endpoint_cfg_mod.EndpointConfig.init_endpoint_config.return_value = mock_ep
71 71 
72 fakes = {72 fakes = {
73- "motor.engine_server.factory.config_factory": mock_cf_mod,73+ "motor.node_manager.core.services.native_engine.config_factory": mock_cf_mod,
74 "motor.engine_server.factory.endpoint_factory": mock_ef_mod,74 "motor.engine_server.factory.endpoint_factory": mock_ef_mod,
75 "motor.engine_server.core.infer_endpoint": mock_infer_mod,75 "motor.engine_server.core.infer_endpoint": mock_infer_mod,
76 "motor.engine_server.core.mgmt_endpoint": mock_mgmt_mod,76 "motor.engine_server.core.mgmt_endpoint": mock_mgmt_mod,
@@ -84,7 +84,7 @@ def _install_engine_fakes():
84def _uninstall_engine_fakes():84def _uninstall_engine_fakes():
85 """Remove engine-server module fakes that were added by _install_engine_fakes."""85 """Remove engine-server module fakes that were added by _install_engine_fakes."""
86 for mod_name in (86 for mod_name in (
87- "motor.engine_server.factory.config_factory",87+ "motor.node_manager.core.services.native_engine.config_factory",
88 "motor.engine_server.factory.endpoint_factory",88 "motor.engine_server.factory.endpoint_factory",
89 "motor.engine_server.core.infer_endpoint",89 "motor.engine_server.core.infer_endpoint",
90 "motor.engine_server.core.mgmt_endpoint",90 "motor.engine_server.core.mgmt_endpoint",
@@ -179,7 +179,7 @@ class TestMainNativeLaunchRouting:
179 "motor.engine_server.core.infer_endpoint",179 "motor.engine_server.core.infer_endpoint",
180 "motor.engine_server.core.mgmt_endpoint",180 "motor.engine_server.core.mgmt_endpoint",
181 "motor.engine_server.factory.endpoint_factory",181 "motor.engine_server.factory.endpoint_factory",
182- "motor.engine_server.factory.config_factory",182+ "motor.node_manager.core.services.native_engine.config_factory",
183 ):183 ):
184 sys.modules[mod_name].reset_mock()184 sys.modules[mod_name].reset_mock()
185 185 
@@ -26,7 +26,7 @@ from motor.config.endpoint import (
26 DECODE_PARALLEL_CONFIG_KEY,26 DECODE_PARALLEL_CONFIG_KEY,
27 ENCODE_PARALLEL_CONFIG_KEY,27 ENCODE_PARALLEL_CONFIG_KEY,
28)28)
29-from motor.engine_server.constants import constants29+from motor.common import engine_constants as constants
30 30 
31 31 
32# --- ParallelConfig tests ---32# --- ParallelConfig tests ---
@@ -126,6 +126,7 @@ def test_health_check_config_defaults():
126 config = HealthCheckConfig()126 config = HealthCheckConfig()
127 assert config.health_collector_timeout == 5127 assert config.health_collector_timeout == 5
128 assert config.health_collector_timeout_retry_attempts == 3128 assert config.health_collector_timeout_retry_attempts == 3
129+ assert config.startup_timeout == 1800
129 assert config.npu_usage_threshold == 3130 assert config.npu_usage_threshold == 3
130 assert config.enable_virtual_inference is False131 assert config.enable_virtual_inference is False
131 132 
@@ -135,12 +136,14 @@ def test_health_check_config_from_dict():
135 data = {136 data = {
136 "health_collector_timeout": 10,137 "health_collector_timeout": 10,
137 "health_collector_timeout_retry_attempts": 5,138 "health_collector_timeout_retry_attempts": 5,
139+ "startup_timeout": 600,
138 "npu_usage_threshold": 20,140 "npu_usage_threshold": 20,
139 "enable_virtual_inference": False,141 "enable_virtual_inference": False,
140 }142 }
141 config = HealthCheckConfig.from_dict(data)143 config = HealthCheckConfig.from_dict(data)
142 assert config.health_collector_timeout == 10144 assert config.health_collector_timeout == 10
143 assert config.health_collector_timeout_retry_attempts == 5145 assert config.health_collector_timeout_retry_attempts == 5
146+ assert config.startup_timeout == 600
144 assert config.npu_usage_threshold == 20147 assert config.npu_usage_threshold == 20
145 assert config.enable_virtual_inference is False148 assert config.enable_virtual_inference is False
146 149 
@@ -667,13 +670,13 @@ def test_load_deploy_config_rejects_malformed_multi_connectors(valid_config_file
667 {constants.CONNECTORS: [{}, "not-a-dict"]},670 {constants.CONNECTORS: [{}, "not-a-dict"]},
668 )671 )
669 for bad_extra in bad_extras:672 for bad_extra in bad_extras:
670- with open(valid_config_file_for_endpoint) as f:673+ with open(valid_config_file_for_endpoint, encoding="utf-8") as f:
671 data = json.load(f)674 data = json.load(f)
672 kv = {constants.KV_CONNECTOR: constants.MULTI_CONNECTOR}675 kv = {constants.KV_CONNECTOR: constants.MULTI_CONNECTOR}
673 if bad_extra is not None:676 if bad_extra is not None:
674 kv[constants.KV_CONNECTOR_EXTRA_CONFIG] = bad_extra677 kv[constants.KV_CONNECTOR_EXTRA_CONFIG] = bad_extra
675 data["engine_config"][constants.KV_TRANSFER_CONFIG] = kv678 data["engine_config"][constants.KV_TRANSFER_CONFIG] = kv
676- with open(valid_config_file_for_endpoint, "w") as f:679+ with open(valid_config_file_for_endpoint, "w", encoding="utf-8") as f:
677 json.dump(data, f)680 json.dump(data, f)
678 681 
679 config = EndpointConfig(682 config = EndpointConfig(
@@ -78,6 +78,7 @@ def _body(role="decode"):
78 "instance_id": 1,78 "instance_id": 1,
79 "endpoint_id": 0,79 "endpoint_id": 0,
80 "url": "http://127.0.0.1:8000",80 "url": "http://127.0.0.1:8000",
81+ "bootstrap_port": 31000,
81 },82 },
82 "decode": {83 "decode": {
83 "instance_id": 2,84 "instance_id": 2,
@@ -863,15 +864,14 @@ async def test_vllm_stream_normalization_preserves_done():
863 864 
864 865 
865@pytest.mark.asyncio866@pytest.mark.asyncio
866-async def test_sglang_adapter_generates_stable_bootstrap(monkeypatch):867+async def test_sglang_adapter_generates_stable_bootstrap():
867- monkeypatch.setenv("DISAGGREGATION_BOOTSTRAP_PORT", "31000")
868 adapter = SGLangDispatchAdapter(_Config(engine_type="sglang", role="decode"))868 adapter = SGLangDispatchAdapter(_Config(engine_type="sglang", role="decode"))
869 869 
870 first, _ = await adapter.adapt_request_body(_body("decode"))870 first, _ = await adapter.adapt_request_body(_body("decode"))
871 second, _ = await adapter.adapt_request_body(_body("decode"))871 second, _ = await adapter.adapt_request_body(_body("decode"))
872 872 
873 assert first["bootstrap_host"] == "127.0.0.1"873 assert first["bootstrap_host"] == "127.0.0.1"
874- assert first["bootstrap_port"] == "31000"874+ assert first["bootstrap_port"] == 31000
875 assert first["bootstrap_room"] == second["bootstrap_room"]875 assert first["bootstrap_room"] == second["bootstrap_room"]
876 876 
877 877 
@@ -883,13 +883,14 @@ async def test_sglang_adapter_requires_bootstrap_port(monkeypatch):
883 peer_stops.append(dispatch)883 peer_stops.append(dispatch)
884 return None884 return None
885 885 
886- monkeypatch.delenv("DISAGGREGATION_BOOTSTRAP_PORT", raising=False)
887 monkeypatch.setattr(SGLangDispatchAdapter, "stop_peer", _stop_peer)886 monkeypatch.setattr(SGLangDispatchAdapter, "stop_peer", _stop_peer)
888 adapter = SGLangDispatchAdapter(_Config(engine_type="sglang", role="decode"))887 adapter = SGLangDispatchAdapter(_Config(engine_type="sglang", role="decode"))
888+ body = _body("decode")
889+ body[MOTOR_DISPATCH_KEY]["endpoints"]["prefill"].pop("bootstrap_port")
889 890 
890 with pytest.raises(HTTPException) as exc_info:891 with pytest.raises(HTTPException) as exc_info:
891- await adapter.adapt_request_body(_body("decode"))892+ await adapter.adapt_request_body(body)
892 893 
893 assert exc_info.value.status_code == 500894 assert exc_info.value.status_code == 500
894- assert "DISAGGREGATION_BOOTSTRAP_PORT" in exc_info.value.detail895+ assert "bootstrap_port" in exc_info.value.detail
895 assert peer_stops[0].engine_request_id == "req#a1"896 assert peer_stops[0].engine_request_id == "req#a1"
@@ -13,9 +13,9 @@ from unittest.mock import MagicMock, patch
13import pytest13import pytest
14from fastapi.testclient import TestClient14from fastapi.testclient import TestClient
15 15 
16-from motor.engine_server.constants.constants import INIT_STATUS, STATUS_KEY16+from motor.common.engine_constants import INIT_STATUS, STATUS_KEY
17from motor.engine_server.core.mgmt_endpoint import MgmtEndpoint17from motor.engine_server.core.mgmt_endpoint import MgmtEndpoint
18-from tests.engine_server.core.test_vllm_config import _make_endpoint_config18+from tests.node_manager.core.services.native_engine.backends.vllm.test_config import _make_endpoint_config
19 19 
20 20 
21@pytest.fixture(autouse=True)21@pytest.fixture(autouse=True)
@@ -24,7 +24,7 @@ from motor.engine_server.core.sim_inference import (
24 _VIRTUAL_WARMUP_TIMEOUT_SEC,24 _VIRTUAL_WARMUP_TIMEOUT_SEC,
25 _is_virtual_metrics_request,25 _is_virtual_metrics_request,
26)26)
27-from motor.engine_server.constants import constants27+from motor.common import engine_constants as constants
28 28 
29# pylint: disable=redefined-outer-name29# pylint: disable=redefined-outer-name
30 30 
@@ -10,7 +10,7 @@
10 10 
11import pytest11import pytest
12 12 
13-from motor.engine_server.utils.ip import ip_valid_check, port_valid_check, is_valid_ipv6_address, build_endpoint13+from motor.common.utils.ip import build_endpoint, ip_valid_check, is_valid_ipv6_address, port_valid_check
14 14 
15 15 
16class TestIpUtils:16class TestIpUtils:
@@ -1,5 +1,3 @@
1-#!/usr/bin/env python3
2-# -*- coding: utf-8 -*-
3# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.1# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
4# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
5# You can use this software according to the terms and conditions of the Mulan PSL v2.3# You can use this software according to the terms and conditions of the Mulan PSL v2.
@@ -16,8 +14,16 @@ import tempfile
16import pytest14import pytest
17from pathlib import Path15from pathlib import Path
18 16 
19-from motor.engine_server.utils.validators import Validator, StringValidator, MapValidator, DirectoryValidator, \17+from motor.common.utils.validators import (
20- RankSizeValidator, FileValidator, IntValidator, ClassValidator18+ ClassValidator,
19+ DirectoryValidator,
20+ FileValidator,
21+ IntValidator,
22+ MapValidator,
23+ RankSizeValidator,
24+ StringValidator,
25+ Validator,
26+)
21 27 
22BIN_PATH = "/usr/bin"28BIN_PATH = "/usr/bin"
23DIRECTORY_BLACKLIST_PATH = "/abc/d/e"29DIRECTORY_BLACKLIST_PATH = "/abc/d/e"
@@ -59,8 +65,13 @@ def test_string_validator_can_be_transformed2int():
59 65 
60 66 
61def test_string_validator_contain_sensitive_words():67def test_string_validator_contain_sensitive_words():
62- assert not StringValidator("passwordme").check_not_contain_black_element("pass") \68+ assert (
63- .check_string_length().check().is_valid()69+ not StringValidator("passwordme")
70+ .check_not_contain_black_element("pass")
71+ .check_string_length()
72+ .check()
73+ .is_valid()
74+ )
64 75 
65 76 
66def test_map_validator_should_contain_inclusive_keys():77def test_map_validator_should_contain_inclusive_keys():
@@ -76,29 +87,37 @@ def test_directory_black_list():
76 os.makedirs(test_path, exist_ok=True)87 os.makedirs(test_path, exist_ok=True)
77 try:88 try:
78 # Test exact match89 # Test exact match
79- assert not DirectoryValidator(test_path).with_blacklist(90+ assert not DirectoryValidator(test_path).with_blacklist(lst=[test_path]).check().is_valid()
80- lst=[test_path]).check().is_valid()91+ assert DirectoryValidator(test_path).with_blacklist(lst=[""]).check().is_valid()
81- assert DirectoryValidator(test_path).with_blacklist(
82- lst=[""]).check().is_valid()
83 # Test parent path with exact_compare=True (should be valid)92 # Test parent path with exact_compare=True (should be valid)
84- assert DirectoryValidator(test_path).with_blacklist([temp_dir], exact_compare=True) \93+ assert DirectoryValidator(test_path).with_blacklist([temp_dir], exact_compare=True).check().is_valid()
85- .check().is_valid()
86 # Test parent path with exact_compare=False (should be invalid as test_path is child of temp_dir)94 # Test parent path with exact_compare=False (should be invalid as test_path is child of temp_dir)
87- assert not DirectoryValidator(test_path) \95+ assert not DirectoryValidator(test_path).with_blacklist([temp_dir], exact_compare=False).check().is_valid()
88- .with_blacklist([temp_dir], exact_compare=False).check().is_valid()
89 finally:96 finally:
90 import shutil97 import shutil
98+ 
91 shutil.rmtree(temp_dir, ignore_errors=True)99 shutil.rmtree(temp_dir, ignore_errors=True)
92 else:100 else:
93- assert not DirectoryValidator(DIRECTORY_BLACKLIST_PATH).with_blacklist(101+ assert (
94- lst=[DIRECTORY_BLACKLIST_PATH]).check().is_valid()102+ not DirectoryValidator(DIRECTORY_BLACKLIST_PATH)
95- assert DirectoryValidator(DIRECTORY_BLACKLIST_PATH).with_blacklist(103+ .with_blacklist(lst=[DIRECTORY_BLACKLIST_PATH])
96- lst=[""]).check().is_valid()104+ .check()
97- assert DirectoryValidator(DIRECTORY_BLACKLIST_PATH).with_blacklist(["/abc/d/"], exact_compare=True) \105+ .is_valid()
98- .check().is_valid()106+ )
107+ assert DirectoryValidator(DIRECTORY_BLACKLIST_PATH).with_blacklist(lst=[""]).check().is_valid()
108+ assert (
109+ DirectoryValidator(DIRECTORY_BLACKLIST_PATH)
110+ .with_blacklist(["/abc/d/"], exact_compare=True)
111+ .check()
112+ .is_valid()
113+ )
99 # if not exact compare, the /abc/d/e is chirldren path of /abc/d/, so it is invalid114 # if not exact compare, the /abc/d/e is chirldren path of /abc/d/, so it is invalid
100- assert not DirectoryValidator(DIRECTORY_BLACKLIST_PATH) \115+ assert (
101- .with_blacklist(["/abc/d/"], exact_compare=False).check().is_valid()116+ not DirectoryValidator(DIRECTORY_BLACKLIST_PATH)
117+ .with_blacklist(["/abc/d/"], exact_compare=False)
118+ .check()
119+ .is_valid()
120+ )
102 assert DirectoryValidator("/usr/bin/bash").with_blacklist().check().is_valid()121 assert DirectoryValidator("/usr/bin/bash").with_blacklist().check().is_valid()
103 assert not DirectoryValidator("/usr/bin/bash").with_blacklist(exact_compare=False).check().is_valid()122 assert not DirectoryValidator("/usr/bin/bash").with_blacklist(exact_compare=False).check().is_valid()
104 123 
@@ -121,21 +140,19 @@ def test_directory_soft_link():
121 # Skip on Windows as creating symlinks requires admin privileges or developer mode140 # Skip on Windows as creating symlinks requires admin privileges or developer mode
122 if IS_WINDOWS:141 if IS_WINDOWS:
123 pytest.skip("Symlink creation requires admin privileges on Windows")142 pytest.skip("Symlink creation requires admin privileges on Windows")
124- 143+ 
125- tmp = tempfile.NamedTemporaryFile(delete=True)
126 temp_dir = tempfile.mkdtemp()144 temp_dir = tempfile.mkdtemp()
127 path = os.path.join(temp_dir, "link.ink")145 path = os.path.join(temp_dir, "link.ink")
128- # make a soft link146+ with tempfile.NamedTemporaryFile(delete=True) as tmp:
129- os.symlink(tmp.name, path)147+ # make a soft link
130- 148+ os.symlink(tmp.name, path)
131- try:149+ try:
132- # do stuff with temp150+ # do stuff with temp
133- tmp.write(b"stuff")151+ tmp.write(b"stuff")
134- assert not DirectoryValidator(path).check_not_soft_link().check().is_valid()152+ assert not DirectoryValidator(path).check_not_soft_link().check().is_valid()
135- finally:153+ finally:
136- tmp.close()154+ os.remove(path)
137- os.remove(path)155+ os.removedirs(temp_dir)
138- os.removedirs(temp_dir)
139 156 
140 157 
141def test_directory_check():158def test_directory_check():
@@ -143,25 +160,36 @@ def test_directory_check():
143 assert not DirectoryValidator("").check_is_not_none().check_dir_name().check().is_valid()160 assert not DirectoryValidator("").check_is_not_none().check_dir_name().check().is_valid()
144 assert not DirectoryValidator(None).check_is_not_none().check_dir_name().check().is_valid()161 assert not DirectoryValidator(None).check_is_not_none().check_dir_name().check().is_valid()
145 assert DirectoryValidator("a/bc/d").check_is_not_none().check_dir_name().check().is_valid()162 assert DirectoryValidator("a/bc/d").check_is_not_none().check_dir_name().check().is_valid()
146- assert DirectoryValidator("/user/restore/fault/config", max_len=255). \163+ assert (
147- check_is_not_none().check_dir_name(). \164+ DirectoryValidator("/user/restore/fault/config", max_len=255)
148- path_should_exist(is_file=True, msg="can not find the fault ranks config file") \165+ .check_is_not_none()
149- .should_not_contains_sensitive_words().with_blacklist().check()166+ .check_dir_name()
150- assert DirectoryValidator(os.path.dirname(__file__), max_len=255). \167+ .path_should_exist(is_file=True, msg="can not find the fault ranks config file")
151- check_is_not_none().check_dir_name().check_dir_file_number() \168+ .should_not_contains_sensitive_words()
152- .path_should_exist(is_file=False, msg="can not find the fault ranks config file") \169+ .with_blacklist()
153- .should_not_contains_sensitive_words().with_blacklist().check()170+ .check()
171+ )
172+ assert (
173+ DirectoryValidator(os.path.dirname(__file__), max_len=255)
174+ .check_is_not_none()
175+ .check_dir_name()
176+ .check_dir_file_number()
177+ .path_should_exist(is_file=False, msg="can not find the fault ranks config file")
178+ .should_not_contains_sensitive_words()
179+ .with_blacklist()
180+ .check()
181+ )
154 assert not DirectoryValidator(os.path.dirname(__file__)).path_should_not_exist().check().is_valid()182 assert not DirectoryValidator(os.path.dirname(__file__)).path_should_not_exist().check().is_valid()
155 183 
156 184 
157def test_check_directory_permissions():185def test_check_directory_permissions():
158- temp_dir = tempfile.TemporaryDirectory()186+ with tempfile.TemporaryDirectory() as temp_dir:
159- temp_path = Path(temp_dir.name)187+ temp_path = Path(temp_dir)
160- test_dir = temp_path / "test_dir"188+ test_dir = temp_path / "test_dir"
161- test_dir.mkdir()189+ test_dir.mkdir()
162- os.chmod(test_dir, 0o777)190+ os.chmod(test_dir, 0o777)
163- target_mode = 0o750191+ target_mode = 0o750
164- assert not DirectoryValidator(test_dir).check_directory_permissions(target_mode).check().is_valid()192+ assert not DirectoryValidator(test_dir).check_directory_permissions(target_mode).check().is_valid()
165 193 
166 194 
167def test_rank_size_check():195def test_rank_size_check():
@@ -0,0 +1,9 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
@@ -0,0 +1,9 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
@@ -0,0 +1,9 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
Rtests/engine_server/core/test_sglang_config.pytests/node_manager/core/services/native_engine/backends/sglang/test_config.py+1-1
@@ -10,7 +10,7 @@
10 10 
11 11 
12from motor.config.endpoint import DeployConfig, EndpointConfig, EngineConfig, ModelConfig, ParallelConfig12from motor.config.endpoint import DeployConfig, EndpointConfig, EngineConfig, ModelConfig, ParallelConfig
13-from motor.engine_server.core.sglang.sglang_config import SGLangConfig13+from motor.node_manager.core.services.native_engine.backends.sglang.config import SGLangConfig
14 14 
15 15 
16def _make_endpoint_config(engine_cfg: dict) -> EndpointConfig:16def _make_endpoint_config(engine_cfg: dict) -> EndpointConfig:
@@ -0,0 +1,475 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Golden contracts for native-engine CLI configuration.
12+ 
13+These tests exercise the relocated runtime config builders and preserve the
14+pre-move CLI contract.
15+"""
16+ 
17+import copy
18+import json
19+import sys
20+ 
21+import pytest
22+ 
23+from motor.config.endpoint import DeployConfig, EndpointConfig, EngineConfig, ModelConfig, ParallelConfig
24+from motor.common import engine_constants as constants
25+from motor.node_manager.core.services.native_engine.config_factory import ConfigFactory
26+from motor.node_manager.core.services.native_engine.backends.sglang.config import SGLangConfig
27+from motor.node_manager.core.services.native_engine.backends.vllm.config import VLLMConfig
28+ 
29+ 
30+def _endpoint_config(
31+ *,
32+ engine_type: str,
33+ role: str = "union",
34+ engine_config: dict | None = None,
35+ host: str = "127.0.0.1",
36+ port: int = 8000,
37+ master_dp_ip: str = "10.0.0.1",
38+ dp_rank: int = 0,
39+ node_rank: int = 0,
40+ dp_size: int = 1,
41+ tp_size: int = 2,
42+ pp_size: int = 1,
43+ pcp_size: int = 1,
44+ dp_rpc_port: int = 9000,
45+ d2d_peer_ips: str | None = None,
46+) -> EndpointConfig:
47+ prefill_parallel = ParallelConfig(
48+ dp_size=dp_size,
49+ tp_size=tp_size,
50+ pp_size=pp_size,
51+ pcp_size=pcp_size,
52+ dp_rpc_port=dp_rpc_port,
53+ )
54+ decode_parallel = ParallelConfig(
55+ dp_size=dp_size,
56+ tp_size=tp_size,
57+ pp_size=pp_size,
58+ dp_rpc_port=dp_rpc_port,
59+ )
60+ deploy_config = DeployConfig(
61+ engine_type=engine_type,
62+ model_config=ModelConfig(
63+ model_name="glm-test",
64+ model_path="/models/glm-test",
65+ npu_mem_utils=0.85,
66+ encode_parallel_config=ParallelConfig(
67+ dp_size=dp_size,
68+ tp_size=tp_size,
69+ pp_size=pp_size,
70+ dp_rpc_port=dp_rpc_port,
71+ ),
72+ prefill_parallel_config=prefill_parallel,
73+ decode_parallel_config=decode_parallel,
74+ ),
75+ engine_config=EngineConfig.from_dict(engine_config or {}),
76+ mgmt_tls_config=None,
77+ infer_tls_config=None,
78+ )
79+ return EndpointConfig(
80+ engine_type=engine_type,
81+ role=role,
82+ host=host,
83+ port=port,
84+ mgmt_port=9001,
85+ master_dp_ip=master_dp_ip,
86+ dp_rank=dp_rank,
87+ node_rank=node_rank,
88+ d2d_peer_ips=d2d_peer_ips,
89+ deploy_config=deploy_config,
90+ )
91+ 
92+ 
93+def test_vllm_union_cli_golden_preserves_order_types_and_precedence():
94+ endpoint = _endpoint_config(
95+ engine_type="vllm",
96+ engine_config={
97+ "dtype": "bfloat16",
98+ "trust_remote_code": True,
99+ "allowed_local_media_path": ["/data/a", "/data/b"],
100+ "rope_scaling": {"rope_type": "yarn", "factor": 2.0},
101+ "tensor_parallel_size": 8,
102+ "enable_chunked_prefill": False,
103+ },
104+ )
105+ config = VLLMConfig(endpoint_config=endpoint)
106+ config.initialize()
107+ 
108+ assert config.get_cli_args() == [
109+ "--dtype",
110+ "bfloat16",
111+ "--trust-remote-code",
112+ "--allowed-local-media-path",
113+ "/data/a",
114+ "/data/b",
115+ "--rope-scaling",
116+ json.dumps({"rope_type": "yarn", "factor": 2.0}),
117+ "--tensor-parallel-size",
118+ "8",
119+ "--model",
120+ "/models/glm-test",
121+ "--served-model-name",
122+ "glm-test",
123+ "--gpu-memory-utilization",
124+ "0.85",
125+ "--data-parallel-size",
126+ "1",
127+ "--pipeline-parallel-size",
128+ "1",
129+ "--data-parallel-rpc-port",
130+ "9000",
131+ "--cp-kv-cache-interleave-size",
132+ "1",
133+ "--host",
134+ "127.0.0.1",
135+ "--port",
136+ "8000",
137+ ]
138+ 
139+ 
140+@pytest.mark.parametrize(
141+ ("role", "kv_role"),
142+ [
143+ ("prefill", "kv_producer"),
144+ ("decode", "kv_consumer"),
145+ ],
146+)
147+def test_vllm_pd_cli_golden_injects_handoff_metadata(role, kv_role):
148+ endpoint = _endpoint_config(
149+ engine_type="vllm",
150+ role=role,
151+ engine_config={
152+ "kv_transfer_config": {
153+ "kv_connector": "MooncakeConnectorV1",
154+ "kv_port": "36001",
155+ }
156+ },
157+ dp_size=2,
158+ dp_rank=1,
159+ )
160+ config = VLLMConfig(endpoint_config=endpoint)
161+ config.initialize()
162+ expected_kv = {
163+ "kv_connector": "MooncakeConnectorV1",
164+ "kv_port": "36001",
165+ "kv_role": kv_role,
166+ "engine_id": "0",
167+ "kv_connector_extra_config": {
168+ "prefill": {"dp_size": 2, "tp_size": 2, "pp_size": 1},
169+ "decode": {"dp_size": 2, "tp_size": 2, "pp_size": 1},
170+ },
171+ }
172+ 
173+ assert config.get_cli_args() == [
174+ "--kv-transfer-config",
175+ json.dumps(expected_kv),
176+ "--model",
177+ "/models/glm-test",
178+ "--served-model-name",
179+ "glm-test",
180+ "--gpu-memory-utilization",
181+ "0.85",
182+ "--data-parallel-size",
183+ "2",
184+ "--tensor-parallel-size",
185+ "2",
186+ "--pipeline-parallel-size",
187+ "1",
188+ "--data-parallel-rpc-port",
189+ "9000",
190+ "--cp-kv-cache-interleave-size",
191+ "1",
192+ "--host",
193+ "127.0.0.1",
194+ "--port",
195+ "8000",
196+ "--data-parallel-address",
197+ "10.0.0.1",
198+ "--data-parallel-rank",
199+ "1",
200+ ]
201+ 
202+ 
203+def test_vllm_cross_node_pcp_headless_cli_golden():
204+ endpoint = _endpoint_config(
205+ engine_type="vllm",
206+ engine_config={"nnodes": 2, "master_port": 7001},
207+ node_rank=1,
208+ pcp_size=2,
209+ )
210+ config = VLLMConfig(endpoint_config=endpoint)
211+ config.initialize()
212+ 
213+ assert config.get_cli_args()[-11:] == [
214+ "--prefill-context-parallel-size",
215+ "2",
216+ "--host",
217+ "127.0.0.1",
218+ "--port",
219+ "8000",
220+ "--node-rank",
221+ "1",
222+ "--master-addr",
223+ "10.0.0.1",
224+ "--headless",
225+ ]
226+ 
227+ 
228+def test_vllm_multi_connector_and_d2d_cli_contract():
229+ endpoint = _endpoint_config(
230+ engine_type="vllm",
231+ role="prefill",
232+ engine_config={
233+ "kv_transfer_config": {
234+ "kv_connector": "MultiConnector",
235+ "kv_connector_extra_config": {
236+ "connectors": [
237+ {"kv_connector": "NixlConnector"},
238+ {
239+ "kv_connector": "AscendStoreConnector",
240+ "kv_connector_extra_config": {},
241+ },
242+ ]
243+ },
244+ },
245+ "model_loader_extra_config": {
246+ "source": "auto",
247+ "listen_port": 5000,
248+ },
249+ },
250+ d2d_peer_ips="2001:db8::1,10.0.0.2",
251+ )
252+ config = VLLMConfig(endpoint_config=endpoint)
253+ config.initialize()
254+ cli_args = config.get_cli_args()
255+ 
256+ kv_index = cli_args.index("--kv-transfer-config")
257+ kv_config = json.loads(cli_args[kv_index + 1])
258+ assert kv_config == {
259+ "kv_connector": "MultiConnector",
260+ "kv_connector_extra_config": {
261+ "connectors": [
262+ {
263+ "kv_connector": "NixlConnector",
264+ "kv_role": "kv_producer",
265+ "kv_connector_extra_config": {
266+ "prefill": {"dp_size": 1, "tp_size": 2, "pp_size": 1},
267+ "decode": {"dp_size": 1, "tp_size": 2, "pp_size": 1},
268+ },
269+ },
270+ {
271+ "kv_connector": "AscendStoreConnector",
272+ "kv_connector_extra_config": {"lookup_rpc_port": "0"},
273+ "kv_role": "kv_producer",
274+ },
275+ ]
276+ },
277+ "engine_id": "0",
278+ "kv_role": "kv_producer",
279+ }
280+ loader_index = cli_args.index("--model-loader-extra-config")
281+ assert json.loads(cli_args[loader_index + 1]) == {
282+ "LISTEN_PORT": 5000,
283+ "SOURCE": [
284+ {
285+ "device_id": 0,
286+ "sources": ["[2001:db8::1]:5000", "10.0.0.2:5000"],
287+ },
288+ {
289+ "device_id": 1,
290+ "sources": ["[2001:db8::1]:5001", "10.0.0.2:5001"],
291+ },
292+ ],
293+ "MODEL": "glm-test",
294+ }
295+ assert cli_args[cli_args.index("--load-format") + 1] == "netloader"
296+ 
297+ 
298+def test_vllm_builder_output_is_stable_across_repeated_initialization():
299+ endpoint = _endpoint_config(
300+ engine_type="vllm",
301+ role="prefill",
302+ engine_config={
303+ "kv_transfer_config": {
304+ "kv_connector": "MooncakeHybridConnector",
305+ "kv_port": "36001",
306+ "kv_connector_extra_config": {},
307+ }
308+ },
309+ )
310+ config = VLLMConfig(endpoint_config=endpoint)
311+ 
312+ config.initialize()
313+ first_cli = config.get_cli_args()
314+ first_endpoint = copy.deepcopy(endpoint)
315+ config.initialize()
316+ 
317+ assert config.get_cli_args() == first_cli
318+ assert endpoint == first_endpoint
319+ 
320+ 
321+def test_vllm_convert_preserves_process_argv(monkeypatch):
322+ endpoint = _endpoint_config(engine_type="vllm")
323+ config = VLLMConfig(endpoint_config=endpoint)
324+ config.initialize()
325+ monkeypatch.setattr(sys, "argv", ["contract-test", "--original"])
326+ 
327+ config.convert()
328+ 
329+ assert sys.argv == ["contract-test", "--original"]
330+ 
331+ 
332+def test_sglang_union_cli_golden():
333+ endpoint = _endpoint_config(
334+ engine_type="sglang",
335+ engine_config={
336+ "dtype": "bfloat16",
337+ "trust_remote_code": True,
338+ "random_seed": 7,
339+ },
340+ )
341+ config = SGLangConfig(endpoint_config=endpoint)
342+ config.initialize()
343+ 
344+ assert config.get_cli_args() == [
345+ "--dtype",
346+ "bfloat16",
347+ "--trust-remote-code",
348+ "--random-seed",
349+ "7",
350+ "--enable-metrics",
351+ "--host",
352+ "127.0.0.1",
353+ "--port",
354+ "8000",
355+ "--disaggregation-mode",
356+ "null",
357+ ]
358+ 
359+ 
360+@pytest.mark.parametrize(
361+ ("role", "mode"),
362+ [
363+ ("prefill", "prefill"),
364+ ("decode", "decode"),
365+ ],
366+)
367+def test_sglang_pd_multinode_cli_golden(role, mode):
368+ endpoint = _endpoint_config(
369+ engine_type="sglang",
370+ role=role,
371+ engine_config={"nnodes": 2},
372+ host="::1",
373+ master_dp_ip="2001:db8::10",
374+ dp_rank=1,
375+ node_rank=7,
376+ dp_rpc_port=9100,
377+ )
378+ config = SGLangConfig(endpoint_config=endpoint)
379+ 
380+ assert config.get_cli_args() == [
381+ "--nnodes",
382+ "2",
383+ "--enable-metrics",
384+ "--host",
385+ "::1",
386+ "--port",
387+ "8000",
388+ "--dist-init-addr",
389+ "[2001:db8::10]:9100",
390+ "--node-rank",
391+ "7",
392+ "--disaggregation-mode",
393+ mode,
394+ ]
395+ 
396+ 
397+def test_sglang_multinode_accepts_string_nnodes_and_requires_master_address():
398+ endpoint = _endpoint_config(
399+ engine_type="sglang",
400+ role="prefill",
401+ engine_config={"nnodes": "2"},
402+ master_dp_ip="",
403+ )
404+ 
405+ with pytest.raises(ValueError, match="master_dp_ip is required"):
406+ SGLangConfig(endpoint_config=endpoint).get_cli_args()
407+ 
408+ 
409+@pytest.mark.parametrize("nnodes", ["invalid", 0])
410+def test_sglang_rejects_invalid_nnodes(nnodes):
411+ endpoint = _endpoint_config(engine_type="sglang", engine_config={"nnodes": nnodes})
412+ 
413+ with pytest.raises(ValueError, match="nnodes"):
414+ SGLangConfig(endpoint_config=endpoint).get_cli_args()
415+ 
416+ 
417+def test_native_cli_omits_none_values():
418+ endpoint = _endpoint_config(
419+ engine_type="sglang",
420+ engine_config={"download-dir": None},
421+ )
422+ 
423+ args = SGLangConfig(endpoint_config=endpoint).get_cli_args()
424+ 
425+ assert "--download-dir" not in args
426+ assert "None" not in args
427+ 
428+ 
429+def test_factory_rejects_unknown_engine_type_before_importing_builder():
430+ endpoint = _endpoint_config(engine_type="unknown")
431+ 
432+ with pytest.raises(ValueError, match="Unsupported engine type: unknown"):
433+ ConfigFactory(endpoint).parse()
434+ 
435+ 
436+@pytest.mark.parametrize(
437+ ("invalid_kv_config", "error_pattern"),
438+ [
439+ (None, "kv_transfer_config is None in engine_config"),
440+ (
441+ {
442+ "kv_connector": "MultiConnector",
443+ "kv_connector_extra_config": {"connectors": [{"kv_connector": "NixlConnector"}]},
444+ },
445+ "Failed to process kv_transfer_config",
446+ ),
447+ (
448+ {
449+ "kv_connector": "MultiConnector",
450+ "kv_connector_extra_config": {
451+ "connectors": [
452+ {"kv_connector": "NixlConnector"},
453+ {
454+ "kv_connector": "UnsupportedStoreConnector",
455+ "kv_connector_extra_config": {},
456+ },
457+ ]
458+ },
459+ },
460+ "Failed to process kv_transfer_config",
461+ ),
462+ ],
463+)
464+def test_vllm_invalid_pd_connector_contract(invalid_kv_config, error_pattern):
465+ engine_config = {}
466+ if invalid_kv_config is not None:
467+ engine_config[constants.KV_TRANSFER_CONFIG] = invalid_kv_config
468+ endpoint = _endpoint_config(
469+ engine_type="vllm",
470+ role="prefill",
471+ engine_config=engine_config,
472+ )
473+ 
474+ with pytest.raises(ValueError, match=error_pattern):
475+ VLLMConfig(endpoint_config=endpoint).initialize()
Rtests/engine_server/core/test_ipv6_address_formatting.pytests/node_manager/core/services/native_engine/backends/test_ipv6_address_formatting.py+3-3
@@ -10,9 +10,9 @@
10 10 
11from types import SimpleNamespace11from types import SimpleNamespace
12 12 
13-from motor.engine_server.constants import constants13+from motor.common import engine_constants as constants
14-from motor.engine_server.core.sglang.sglang_config import SGLangConfig14+from motor.node_manager.core.services.native_engine.backends.sglang.config import SGLangConfig
15-from motor.engine_server.core.vllm.vllm_config import VLLMConfig15+from motor.node_manager.core.services.native_engine.backends.vllm.config import VLLMConfig
16 16 
17 17 
18class _DeployConfig:18class _DeployConfig:
@@ -0,0 +1,9 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
Rtests/engine_server/core/test_vllm_config.pytests/node_manager/core/services/native_engine/backends/vllm/test_config.py+1-1
@@ -14,7 +14,7 @@ import json
14import pytest14import pytest
15 15 
16from motor.config.endpoint import DeployConfig, EndpointConfig, EngineConfig, ModelConfig, ParallelConfig16from motor.config.endpoint import DeployConfig, EndpointConfig, EngineConfig, ModelConfig, ParallelConfig
17-from motor.engine_server.core.vllm.vllm_config import VLLMConfig17+from motor.node_manager.core.services.native_engine.backends.vllm.config import VLLMConfig
18 18 
19 19 
20def _make_endpoint_config(20def _make_endpoint_config(
@@ -0,0 +1,295 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+from types import SimpleNamespace
12+from unittest.mock import MagicMock, patch
13+ 
14+import pytest
15+ 
16+from motor.common.resources.instance import PDRole
17+from motor.config.endpoint import EndpointConfig
18+from motor.config.tls_config import TLSConfig
19+from motor.node_manager.core.services.native_engine.backends.base import build_endpoint_config
20+from motor.node_manager.core.services.native_engine.backends.sglang.backend import SGLangBackend
21+from motor.node_manager.core.services.native_engine.backends.vllm.backend import VllmBackend
22+from motor.node_manager.core.services.native_engine.factory import get_backend
23+from motor.node_manager.core.services.native_engine.models import LaunchContext
24+ 
25+ 
26+def _context(
27+ *,
28+ role: PDRole = PDRole.ROLE_P,
29+ headless: bool = False,
30+) -> LaunchContext:
31+ return LaunchContext(
32+ role=role,
33+ instance_id=7,
34+ dp_rank=2,
35+ node_rank=1,
36+ host="10.0.0.2",
37+ business_port=8002,
38+ mgmt_port=9002,
39+ config_path="/config/user_config.json",
40+ master_dp_ip="10.0.0.1",
41+ kv_port=5002,
42+ lookup_rpc_port=6002,
43+ dp_rpc_port=7002,
44+ d2d_peer_ips=("10.0.0.3",),
45+ environment={"VLLM_HOST_IP": "10.0.0.2"},
46+ headless=headless,
47+ )
48+ 
49+ 
50+def _endpoint(
51+ *,
52+ engine_type: str,
53+ role: str,
54+ connector: str | None = None,
55+):
56+ engine_config = {}
57+ if connector is not None:
58+ engine_config["kv_transfer_config"] = {"kv_connector": connector}
59+ return SimpleNamespace(
60+ engine_type=engine_type,
61+ role=role,
62+ deploy_config=SimpleNamespace(
63+ engine_config=engine_config,
64+ dispatch_profile=None,
65+ health_check_config=SimpleNamespace(
66+ health_collector_timeout=5,
67+ health_collector_timeout_retry_attempts=3,
68+ startup_timeout=1800,
69+ ),
70+ infer_tls_config=None,
71+ ),
72+ )
73+ 
74+ 
75+def _prepare_with_config(backend, context, endpoint):
76+ native_config = MagicMock()
77+ native_config.get_cli_args.return_value = ["--model", "/models/glm-test", "--port", "8002"]
78+ with (
79+ patch(
80+ "motor.node_manager.core.services.native_engine.backends.base.build_endpoint_config", return_value=endpoint
81+ ),
82+ patch("motor.node_manager.core.services.native_engine.config_factory.ConfigFactory") as factory,
83+ ):
84+ factory.return_value.build_cli_config.return_value = native_config
85+ return backend.prepare(context)
86+ 
87+ 
88+def _build_with_config(backend, context, endpoint):
89+ return _prepare_with_config(backend, context, endpoint).command
90+ 
91+ 
92+def test_vllm_backend_builds_native_command_and_preserves_environment():
93+ context = _context()
94+ spec = _build_with_config(
95+ VllmBackend(),
96+ context,
97+ _endpoint(engine_type="vllm", role="prefill", connector="MooncakeConnectorV1"),
98+ )
99+ 
100+ assert spec.argv == ("vllm", "serve", "--model", "/models/glm-test", "--port", "8002")
101+ assert dict(spec.env) == {"VLLM_HOST_IP": "10.0.0.2"}
102+ assert "engine_server" not in spec.argv
103+ 
104+ 
105+def test_backend_prepares_command_and_probe_from_one_endpoint_config():
106+ context = _context()
107+ endpoint = _endpoint(engine_type="vllm", role="prefill", connector="MooncakeConnectorV1")
108+ native_config = MagicMock()
109+ native_config.get_cli_args.return_value = ["--model", "/models/glm-test"]
110+ 
111+ with (
112+ patch(
113+ "motor.node_manager.core.services.native_engine.backends.base.build_endpoint_config",
114+ return_value=endpoint,
115+ ) as build_endpoint,
116+ patch("motor.node_manager.core.services.native_engine.config_factory.ConfigFactory") as factory,
117+ ):
118+ factory.return_value.build_cli_config.return_value = native_config
119+ launch_spec = VllmBackend().prepare(context)
120+ 
121+ build_endpoint.assert_called_once_with(context, "vllm")
122+ assert launch_spec.command.argv == ("vllm", "serve", "--model", "/models/glm-test")
123+ assert launch_spec.probe.path == "/health"
124+ native_config.convert.assert_called_once_with()
125+ native_config.validate.assert_called_once_with()
126+ 
127+ 
128+@pytest.mark.parametrize(
129+ "connector",
130+ ["MooncakeConnectorV1", "MooncakeHybridConnector", "NixlConnector"],
131+)
132+def test_vllm_backend_accepts_frozen_handoff_connector_whitelist(connector):
133+ context = _context()
134+ 
135+ spec = _build_with_config(
136+ VllmBackend(),
137+ context,
138+ _endpoint(engine_type="vllm", role="prefill", connector=connector),
139+ )
140+ 
141+ assert spec.argv[:2] == ("vllm", "serve")
142+ 
143+ 
144+def test_vllm_backend_accepts_multi_connector_with_handoff_transport():
145+ context = _context()
146+ endpoint = _endpoint(engine_type="vllm", role="prefill", connector="MooncakeConnectorV1")
147+ endpoint.deploy_config.engine_config = {
148+ "kv_transfer_config": {
149+ "kv_connector": "MultiConnector",
150+ "kv_connector_extra_config": {
151+ "connectors": [
152+ {"kv_connector": "NixlConnector"},
153+ {"kv_connector": "AscendStoreConnector"},
154+ ]
155+ },
156+ }
157+ }
158+ 
159+ spec = _build_with_config(VllmBackend(), context, endpoint)
160+ 
161+ assert spec.argv[:2] == ("vllm", "serve")
162+ 
163+ 
164+@pytest.mark.parametrize(
165+ "connector,profile",
166+ [
167+ ("MooncakeLayerwiseConnector", "trigger"),
168+ ("UnknownConnector", "unknown"),
169+ ],
170+)
171+def test_vllm_backend_rejects_non_handoff_pd_connector(connector, profile):
172+ context = _context()
173+ endpoint = _endpoint(engine_type="vllm", role="prefill", connector=connector)
174+ 
175+ with (
176+ patch(
177+ "motor.node_manager.core.services.native_engine.backends.base.build_endpoint_config", return_value=endpoint
178+ ),
179+ pytest.raises(ValueError, match=rf"resolved dispatch profile is {profile}"),
180+ ):
181+ VllmBackend().prepare(context)
182+ 
183+ 
184+def test_vllm_backend_rejects_explicit_trigger_profile():
185+ context = _context()
186+ endpoint = _endpoint(
187+ engine_type="vllm",
188+ role="prefill",
189+ connector="MooncakeConnectorV1",
190+ )
191+ endpoint.deploy_config.dispatch_profile = "trigger"
192+ 
193+ with (
194+ patch(
195+ "motor.node_manager.core.services.native_engine.backends.base.build_endpoint_config", return_value=endpoint
196+ ),
197+ pytest.raises(ValueError, match="resolved dispatch profile is trigger"),
198+ ):
199+ VllmBackend().prepare(context)
200+ 
201+ 
202+def test_vllm_backend_allows_union_without_kv_connector():
203+ context = _context(role=PDRole.ROLE_U)
204+ 
205+ spec = _build_with_config(
206+ VllmBackend(),
207+ context,
208+ _endpoint(engine_type="vllm", role="union"),
209+ )
210+ 
211+ assert spec.argv[:2] == ("vllm", "serve")
212+ 
213+ 
214+def test_sglang_backend_builds_native_module_command():
215+ context = _context(role=PDRole.ROLE_D)
216+ 
217+ spec = _build_with_config(
218+ SGLangBackend(),
219+ context,
220+ _endpoint(engine_type="sglang", role="decode"),
221+ )
222+ 
223+ assert spec.argv[:3] == ("python3", "-m", "sglang.launch_server")
224+ assert "engine_server" not in spec.argv
225+ 
226+ 
227+def test_sglang_backend_rejects_encode_before_loading_config():
228+ context = _context(role=PDRole.ROLE_E)
229+ 
230+ with (
231+ patch("motor.node_manager.core.services.native_engine.backends.base.build_endpoint_config") as build_endpoint,
232+ pytest.raises(ValueError, match="SGLang encode role is not supported"),
233+ ):
234+ SGLangBackend().prepare(context)
235+ 
236+ build_endpoint.assert_not_called()
237+ 
238+ 
239+def test_get_backend_rejects_unknown_engine():
240+ with pytest.raises(ValueError, match="Unsupported engine type"):
241+ get_backend("unknown")
242+ 
243+ 
244+def testbuild_endpoint_config_maps_launch_context_without_cli_globals():
245+ context = _context()
246+ 
247+ with (
248+ patch.object(EndpointConfig, "validate") as validate,
249+ patch.object(EndpointConfig, "load_deploy_config") as load_deploy_config,
250+ ):
251+ endpoint_config = build_endpoint_config(context, "vllm")
252+ 
253+ validate.assert_called_once_with()
254+ load_deploy_config.assert_called_once_with()
255+ assert endpoint_config.role == "prefill"
256+ assert endpoint_config.dp_rank == 2
257+ assert endpoint_config.node_rank == 1
258+ assert endpoint_config.d2d_peer_ips == "10.0.0.3"
259+ assert endpoint_config.snapshot_metadata is None
260+ 
261+ 
262+def test_backend_builds_native_health_probe_from_engine_config():
263+ context = _context()
264+ tls_config = TLSConfig(enable_tls=True, ca_file="/certs/ca.crt")
265+ endpoint = _endpoint(engine_type="vllm", role="prefill", connector="MooncakeConnectorV1")
266+ endpoint.deploy_config.health_check_config = SimpleNamespace(
267+ health_collector_timeout=7,
268+ health_collector_timeout_retry_attempts=4,
269+ startup_timeout=900,
270+ )
271+ endpoint.deploy_config.infer_tls_config = tls_config
272+ 
273+ probe = _prepare_with_config(VllmBackend(), context, endpoint).probe
274+ 
275+ assert probe.path == "/health"
276+ assert probe.timeout_seconds == 7
277+ assert probe.max_attempts == 4
278+ assert probe.startup_timeout_seconds == 900
279+ assert probe.tls_config is tls_config
280+ assert probe.process_only is False
281+ 
282+ 
283+def test_headless_backend_uses_process_only_probe():
284+ context = _context(headless=True)
285+ endpoint = _endpoint(engine_type="vllm", role="prefill", connector="MooncakeConnectorV1")
286+ endpoint.deploy_config.health_check_config = SimpleNamespace(
287+ health_collector_timeout=5,
288+ health_collector_timeout_retry_attempts=3,
289+ startup_timeout=1800,
290+ )
291+ endpoint.deploy_config.infer_tls_config = None
292+ 
293+ probe = _prepare_with_config(VllmBackend(), context, endpoint).probe
294+ 
295+ assert probe.process_only is True
@@ -0,0 +1,62 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+from types import SimpleNamespace
12+from unittest.mock import patch
13+ 
14+from motor.common.resources.endpoint import Endpoint
15+from motor.common.resources.instance import PDRole
16+from motor.node_manager.core.services.native_engine.service import NativeEngineService
17+from motor.node_manager.core.services.native_engine.models import CommandSpec, LaunchSpec, ProbeSpec
18+ 
19+ 
20+def _native_engine_service() -> NativeEngineService:
21+ with (
22+ patch("motor.node_manager.core.services.native_engine.service.get_backend") as get_backend,
23+ patch("motor.node_manager.core.services.native_engine.service.ProcessSupervisor") as supervisor_class,
24+ ):
25+ service = NativeEngineService(
26+ engine_type="sglang",
27+ config_path="/tmp/config.json",
28+ device_num=1,
29+ parallel_config=SimpleNamespace(local_world_size=1),
30+ enable_multi_endpoints=False,
31+ )
32+ service.backend = get_backend.return_value
33+ service.supervisor = supervisor_class.return_value
34+ return service
35+ 
36+ 
37+def test_successful_pull_clears_recovery_latch():
38+ service = _native_engine_service()
39+ service._recovery_requested = True
40+ service.backend.prepare.return_value = LaunchSpec(
41+ command=CommandSpec(argv=("python", "-m", "sglang.launch_server"), env={}),
42+ probe=ProbeSpec(path="/health", timeout_seconds=1, startup_timeout_seconds=10),
43+ )
44+ service.supervisor.start.return_value = True
45+ endpoint = Endpoint(id=0, ip="127.0.0.1", business_port="8000", mgmt_port="8001")
46+ 
47+ service.pull(PDRole.ROLE_D, [endpoint], instance_id=1, master_dp_ip="127.0.0.1")
48+ 
49+ assert service._recovery_requested is False
50+ service.supervisor.start.assert_called_once()
51+ 
52+ 
53+@patch("motor.node_manager.core.services.native_engine.service.os.kill")
54+def test_health_check_requests_recovery_only_once(mock_kill):
55+ service = _native_engine_service()
56+ service.restart_on_failure = True
57+ service.supervisor.dead_pids.side_effect = [[101], [102]]
58+ 
59+ service.health_check()
60+ service.health_check()
61+ 
62+ mock_kill.assert_called_once()
@@ -0,0 +1,354 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+import os
12+import signal
13+import subprocess
14+import threading
15+from concurrent.futures import ThreadPoolExecutor
16+from unittest.mock import MagicMock, call, patch
17+ 
18+import pytest
19+import requests
20+ 
21+from motor.node_manager.core.services.native_engine.models import CommandSpec, ProbeSpec, RuntimeState
22+from motor.node_manager.core.services.native_engine.supervisor import ProcessSupervisor
23+ 
24+ 
25+def _command() -> CommandSpec:
26+ return CommandSpec(argv=("vllm", "serve"), env={"KEY": "value"})
27+ 
28+ 
29+def _probe(
30+ *,
31+ startup_timeout: float = 1800,
32+ max_attempts: int = 1,
33+ process_only: bool = False,
34+) -> ProbeSpec:
35+ return ProbeSpec(
36+ path="/health",
37+ timeout_seconds=5,
38+ startup_timeout_seconds=startup_timeout,
39+ max_attempts=max_attempts,
40+ process_only=process_only,
41+ )
42+ 
43+ 
44+def test_probe_rejects_non_positive_attempt_limit():
45+ with pytest.raises(ValueError, match="max_attempts must be a positive integer"):
46+ _probe(max_attempts=0)
47+ 
48+ 
49+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
50+def test_start_owns_native_process_group(mock_popen):
51+ process = MagicMock(pid=12345)
52+ process.poll.return_value = None
53+ mock_popen.return_value = process
54+ supervisor = ProcessSupervisor()
55+ 
56+ started = supervisor.start(3, _command(), _probe())
57+ 
58+ assert started is True
59+ assert supervisor.pid_list() == [12345]
60+ assert mock_popen.call_args.kwargs["start_new_session"] is True
61+ 
62+ 
63+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
64+def test_repeated_start_is_idempotent(mock_popen):
65+ process = MagicMock(pid=12345)
66+ process.poll.return_value = None
67+ mock_popen.return_value = process
68+ supervisor = ProcessSupervisor()
69+ 
70+ assert supervisor.start(3, _command(), _probe()) is True
71+ assert supervisor.start(3, _command(), _probe()) is False
72+ 
73+ mock_popen.assert_called_once()
74+ assert supervisor.pid_list() == [12345]
75+ 
76+ 
77+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
78+def test_repeated_start_rejects_different_launch_spec_without_stopping_existing(mock_popen):
79+ process = MagicMock(pid=12345)
80+ process.poll.return_value = None
81+ mock_popen.return_value = process
82+ supervisor = ProcessSupervisor()
83+ supervisor.start(3, _command(), _probe())
84+ 
85+ different_command = CommandSpec(argv=("vllm", "serve", "--port", "9000"), env={"KEY": "value"})
86+ with pytest.raises(RuntimeError, match="different launch spec"):
87+ supervisor.start(3, different_command, _probe())
88+ 
89+ mock_popen.assert_called_once()
90+ assert supervisor.pid_list() == [12345]
91+ 
92+ 
93+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
94+def test_concurrent_start_creates_only_one_process(mock_popen):
95+ process = MagicMock(pid=12345)
96+ process.poll.return_value = None
97+ second_popen_entered = threading.Event()
98+ popen_calls = 0
99+ calls_lock = threading.Lock()
100+ 
101+ def popen(*_args, **_kwargs):
102+ nonlocal popen_calls
103+ with calls_lock:
104+ popen_calls += 1
105+ call_number = popen_calls
106+ if call_number == 1:
107+ second_popen_entered.wait(timeout=0.1)
108+ else:
109+ second_popen_entered.set()
110+ return process
111+ 
112+ mock_popen.side_effect = popen
113+ supervisor = ProcessSupervisor()
114+ with ThreadPoolExecutor(max_workers=2) as executor:
115+ results = list(executor.map(lambda _: supervisor.start(3, _command(), _probe()), range(2)))
116+ 
117+ assert sorted(results) == [False, True]
118+ assert mock_popen.call_count == 1
119+ assert supervisor.pid_list() == [12345]
120+ 
121+ 
122+@patch("motor.node_manager.core.services.native_engine.supervisor.SafeHTTPSClient")
123+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
124+def test_failed_probe_stays_starting_until_startup_timeout(mock_popen, mock_client):
125+ process = MagicMock(pid=12345)
126+ process.poll.return_value = None
127+ mock_popen.return_value = process
128+ mock_client.return_value.__enter__.return_value.do_get.side_effect = RuntimeError("not listening")
129+ supervisor = ProcessSupervisor()
130+ supervisor.start(3, _command(), _probe(startup_timeout=600))
131+ 
132+ with patch("motor.node_manager.core.services.native_engine.supervisor.time.monotonic", return_value=100):
133+ supervisor._processes[3].started_at = 0
134+ state = supervisor.state(3, "10.0.0.1", 8000)
135+ 
136+ assert state == RuntimeState.STARTING
137+ 
138+ 
139+@patch("motor.node_manager.core.services.native_engine.supervisor.SafeHTTPSClient")
140+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
141+def test_failed_probe_after_startup_timeout_is_unhealthy(mock_popen, mock_client):
142+ process = MagicMock(pid=12345)
143+ process.poll.return_value = None
144+ mock_popen.return_value = process
145+ mock_client.return_value.__enter__.return_value.do_get.side_effect = RuntimeError("not listening")
146+ supervisor = ProcessSupervisor()
147+ supervisor.start(3, _command(), _probe(startup_timeout=10))
148+ 
149+ with patch("motor.node_manager.core.services.native_engine.supervisor.time.monotonic", return_value=11):
150+ supervisor._processes[3].started_at = 0
151+ state = supervisor.state(3, "10.0.0.1", 8000)
152+ 
153+ assert state == RuntimeState.UNHEALTHY
154+ 
155+ 
156+@patch("motor.node_manager.core.services.native_engine.supervisor.SafeHTTPSClient")
157+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
158+def test_successful_native_probe_marks_ready(mock_popen, mock_client):
159+ process = MagicMock(pid=12345)
160+ process.poll.return_value = None
161+ mock_popen.return_value = process
162+ supervisor = ProcessSupervisor()
163+ supervisor.start(3, _command(), _probe())
164+ 
165+ state = supervisor.state(3, "10.0.0.1", 8000)
166+ 
167+ assert state == RuntimeState.READY
168+ mock_client.return_value.__enter__.return_value.do_get.assert_called_once_with("/health")
169+ 
170+ 
171+@patch("motor.node_manager.core.services.native_engine.supervisor.SafeHTTPSClient")
172+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
173+def test_native_probe_retries_timeout_within_attempt_limit(mock_popen, mock_client):
174+ process = MagicMock(pid=12345)
175+ process.poll.return_value = None
176+ mock_popen.return_value = process
177+ timeout = requests.exceptions.ReadTimeout("timed out")
178+ wrapped_timeout = RuntimeError("send request failed")
179+ wrapped_timeout.__cause__ = timeout
180+ client = mock_client.return_value.__enter__.return_value
181+ client.do_get.side_effect = [wrapped_timeout, None]
182+ supervisor = ProcessSupervisor()
183+ supervisor.start(3, _command(), _probe(max_attempts=2))
184+ 
185+ assert supervisor.state(3, "10.0.0.1", 8000) == RuntimeState.READY
186+ assert client.do_get.call_count == 2
187+ 
188+ 
189+@patch("motor.node_manager.core.services.native_engine.supervisor.SafeHTTPSClient")
190+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
191+def test_native_probe_does_not_retry_non_timeout_error(mock_popen, mock_client):
192+ process = MagicMock(pid=12345)
193+ process.poll.return_value = None
194+ mock_popen.return_value = process
195+ client = mock_client.return_value.__enter__.return_value
196+ client.do_get.side_effect = RuntimeError("connection refused")
197+ supervisor = ProcessSupervisor()
198+ supervisor.start(3, _command(), _probe(startup_timeout=10, max_attempts=3))
199+ 
200+ with patch("motor.node_manager.core.services.native_engine.supervisor.time.monotonic", return_value=11):
201+ supervisor._processes[3].started_at = 0
202+ assert supervisor.state(3, "10.0.0.1", 8000) == RuntimeState.UNHEALTHY
203+ 
204+ client.do_get.assert_called_once_with("/health")
205+ 
206+ 
207+@patch("motor.node_manager.core.services.native_engine.supervisor.SafeHTTPSClient")
208+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
209+def test_native_probe_marks_unhealthy_after_exhausting_timeout_retries(mock_popen, mock_client):
210+ process = MagicMock(pid=12345)
211+ process.poll.return_value = None
212+ mock_popen.return_value = process
213+ mock_client.return_value.__enter__.return_value.do_get.side_effect = requests.exceptions.ReadTimeout("timed out")
214+ supervisor = ProcessSupervisor()
215+ supervisor.start(3, _command(), _probe(startup_timeout=10, max_attempts=2))
216+ 
217+ with patch("motor.node_manager.core.services.native_engine.supervisor.time.monotonic", return_value=11):
218+ supervisor._processes[3].started_at = 0
219+ assert supervisor.state(3, "10.0.0.1", 8000) == RuntimeState.UNHEALTHY
220+ 
221+ assert mock_client.return_value.__enter__.return_value.do_get.call_count == 2
222+ 
223+ 
224+@patch("motor.node_manager.core.services.native_engine.supervisor.SafeHTTPSClient")
225+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
226+def test_headless_probe_reports_running_without_claiming_readiness(mock_popen, mock_client):
227+ process = MagicMock(pid=12345)
228+ process.poll.return_value = None
229+ mock_popen.return_value = process
230+ supervisor = ProcessSupervisor()
231+ supervisor.start(3, _command(), _probe(process_only=True))
232+ 
233+ assert supervisor.state(3, "10.0.0.2", 8000) == RuntimeState.RUNNING
234+ assert supervisor._processes[3].ready_at is None
235+ mock_client.assert_not_called()
236+ 
237+ 
238+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
239+def test_dead_process_is_stopped_without_http_probe(mock_popen):
240+ process = MagicMock(pid=12345)
241+ process.poll.side_effect = [None, 1, 1]
242+ mock_popen.return_value = process
243+ supervisor = ProcessSupervisor()
244+ supervisor.start(3, _command(), _probe())
245+ 
246+ assert supervisor.state(3, "10.0.0.1", 8000) == RuntimeState.STOPPED
247+ with patch.object(supervisor, "_kill_group") as kill_group:
248+ assert supervisor.dead_pids() == [12345]
249+ kill_group.assert_called_once()
250+ assert supervisor.dead_pids() == []
251+ assert supervisor.pid_list() == []
252+ 
253+ 
254+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
255+def test_stop_uses_graceful_then_forced_process_group_cleanup(mock_popen):
256+ process = MagicMock(pid=12345)
257+ process.poll.return_value = None
258+ process.wait.side_effect = subprocess.TimeoutExpired(cmd="vllm", timeout=0)
259+ mock_popen.return_value = process
260+ supervisor = ProcessSupervisor(stop_grace_seconds=0)
261+ supervisor.start(3, _command(), _probe())
262+ 
263+ with (
264+ patch.object(supervisor, "_terminate_group") as terminate_group,
265+ patch.object(supervisor, "_kill_group") as kill_group,
266+ ):
267+ stopped = supervisor.stop_all()
268+ 
269+ assert stopped == [12345]
270+ runtime = terminate_group.call_args.args[0]
271+ assert runtime.process is process
272+ kill_group.assert_called_once_with(runtime)
273+ assert supervisor.pid_list() == []
274+ 
275+ 
276+@pytest.mark.skipif(os.name != "posix", reason="POSIX process groups only")
277+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
278+def test_stop_kills_remaining_group_after_leader_exits(mock_popen):
279+ process = MagicMock(pid=12345)
280+ process.poll.return_value = None
281+ process.wait.return_value = 0
282+ mock_popen.return_value = process
283+ supervisor = ProcessSupervisor()
284+ supervisor.start(3, _command(), _probe())
285+ 
286+ with patch("motor.node_manager.core.services.native_engine.supervisor.os.killpg") as killpg:
287+ assert supervisor.stop_all() == [12345]
288+ 
289+ assert killpg.call_args_list == [
290+ call(12345, signal.SIGTERM),
291+ call(12345, 0),
292+ call(12345, signal.SIGKILL),
293+ ]
294+ 
295+ 
296+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
297+def test_stop_one_endpoint_preserves_other_processes(mock_popen):
298+ first = MagicMock(pid=12345)
299+ second = MagicMock(pid=12346)
300+ first.poll.return_value = None
301+ second.poll.return_value = None
302+ mock_popen.side_effect = [first, second]
303+ supervisor = ProcessSupervisor()
304+ supervisor.start(3, _command(), _probe())
305+ supervisor.start(4, _command(), _probe())
306+ 
307+ with patch.object(supervisor, "_terminate_group"):
308+ assert supervisor.stop(4) == 12346
309+ 
310+ assert supervisor.pid_list() == [12345]
311+ 
312+ 
313+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
314+def test_stop_state_remains_visible_until_process_cleanup_finishes(mock_popen):
315+ process = MagicMock(pid=12345)
316+ process.poll.return_value = None
317+ entered_wait = threading.Event()
318+ release_wait = threading.Event()
319+ 
320+ def wait(*_args, **_kwargs):
321+ entered_wait.set()
322+ release_wait.wait(timeout=1)
323+ return 0
324+ 
325+ process.wait.side_effect = wait
326+ mock_popen.return_value = process
327+ supervisor = ProcessSupervisor()
328+ supervisor.start(3, _command(), _probe())
329+ 
330+ with patch.object(supervisor, "_terminate_group"):
331+ thread = threading.Thread(target=supervisor.stop_all)
332+ thread.start()
333+ assert entered_wait.wait(timeout=1)
334+ assert supervisor.state(3, "10.0.0.1", 8000) == RuntimeState.STOPPING
335+ release_wait.set()
336+ thread.join(timeout=1)
337+ 
338+ assert supervisor.state(3, "10.0.0.1", 8000) == RuntimeState.STOPPED
339+ 
340+ 
341+@pytest.mark.skipif(os.name != "posix", reason="POSIX process groups only")
342+@patch("motor.node_manager.core.services.native_engine.supervisor.subprocess.Popen")
343+def test_cleanup_uses_cached_process_group_when_leader_has_exited(mock_popen):
344+ process = MagicMock(pid=12345)
345+ process.poll.side_effect = [None, 1]
346+ mock_popen.return_value = process
347+ supervisor = ProcessSupervisor()
348+ supervisor.start(3, _command(), _probe())
349+ runtime = supervisor._processes[3]
350+ 
351+ with patch("motor.node_manager.core.services.native_engine.supervisor.os.killpg") as killpg:
352+ supervisor._terminate_group(runtime)
353+ 
354+ killpg.assert_called_once_with(12345, signal.SIGTERM)
@@ -10,6 +10,7 @@
10 10 
11import os11import os
12import json12import json
13+import signal
13import pytest14import pytest
14from unittest.mock import patch, MagicMock, mock_open15from unittest.mock import patch, MagicMock, mock_open
15 16 
@@ -18,6 +19,35 @@ from motor.node_manager.core.services.registry import SERVICE_ENGINE
18from motor.config.node_manager import NodeManagerConfig19from motor.config.node_manager import NodeManagerConfig
19from motor.common.resources.endpoint import Endpoint20from motor.common.resources.endpoint import Endpoint
20from motor.common.resources.instance import PDRole, ParallelConfig21from motor.common.resources.instance import PDRole, ParallelConfig
22+from motor.node_manager.core.services.native_engine.models import CommandSpec, LaunchSpec, ProbeSpec
23+ 
24+ 
25+def _recording_backend():
26+ backend = MagicMock()
27+ 
28+ def prepare(context):
29+ return LaunchSpec(
30+ command=CommandSpec(
31+ argv=(
32+ "vllm",
33+ "serve",
34+ "--host",
35+ context.host,
36+ "--port",
37+ str(context.business_port),
38+ ),
39+ env=context.environment,
40+ ),
41+ probe=ProbeSpec(path="/health", timeout_seconds=5, startup_timeout_seconds=1800),
42+ )
43+ 
44+ backend.prepare.side_effect = prepare
45+ return backend
46+ 
47+ 
48+def _last_launch_context(daemon):
49+ backend = daemon._services[SERVICE_ENGINE].backend
50+ return backend.prepare.call_args.args[0]
21 51 
22 52 
23def create_config_mock(config_data):53def create_config_mock(config_data):
@@ -57,14 +87,18 @@ def daemon(config_data):
57 tp_size=config_data["parallel_config"]["tp_size"], pp_size=config_data["parallel_config"]["pp_size"]87 tp_size=config_data["parallel_config"]["tp_size"], pp_size=config_data["parallel_config"]["pp_size"]
58 )88 )
59 config.basic_config.job_name = config_data.get("model_name", "test_job")89 config.basic_config.job_name = config_data.get("model_name", "test_job")
90+ config.basic_config.model_name = config_data.get("model_name", "test-model")
91+ config.basic_config.engine_type = "vllm"
60 config.basic_config.role = PDRole(config_data.get("role", "both"))92 config.basic_config.role = PDRole(config_data.get("role", "both"))
61 config.api_config.node_manager_port = config_data.get("node_manager_port", 8080)93 config.api_config.node_manager_port = config_data.get("node_manager_port", 8080)
62 94 
63 # Set device_num for testing (simulating visible devices)95 # Set device_num for testing (simulating visible devices)
64 config.basic_config.device_num = 8 # 8 devices for testing96 config.basic_config.device_num = 8 # 8 devices for testing
65 97 
66- daemon_instance = Daemon(config)98+ backend = _recording_backend()
67- yield daemon_instance99+ with patch("motor.node_manager.core.services.native_engine.service.get_backend", return_value=backend):
100+ daemon_instance = Daemon(config)
101+ yield daemon_instance
68 102 
69 103 
70@pytest.fixture104@pytest.fixture
@@ -76,6 +110,48 @@ def endpoints():
76 110 
77 111 
78class TestDaemon:112class TestDaemon:
113+ @pytest.mark.parametrize(
114+ ("launch_env", "expected_host_ip", "expected_mc_ipv6"),
115+ [
116+ (
117+ {"POD_IP": "10.0.0.8", "MOONCAKE_ASCEND_IPV6_EXPERIMENT": "1"},
118+ "10.0.0.8",
119+ "1",
120+ ),
121+ (
122+ {
123+ "POD_IP": "10.0.0.8",
124+ "VLLM_HOST_IP": "10.0.0.9",
125+ "MOONCAKE_ASCEND_IPV6_EXPERIMENT": "1",
126+ "MC_USE_IPV6": "0",
127+ },
128+ "10.0.0.9",
129+ "0",
130+ ),
131+ ],
132+ )
133+ @patch('subprocess.Popen')
134+ def test_engine_launch_environment_contract(
135+ self,
136+ mock_popen,
137+ daemon,
138+ launch_env,
139+ expected_host_ip,
140+ expected_mc_ipv6,
141+ ):
142+ mock_process = MagicMock(pid=12345)
143+ mock_process.poll.return_value = None
144+ mock_popen.return_value = mock_process
145+ endpoint = Endpoint(id=0, ip="10.0.0.1", business_port="9000", mgmt_port="9090")
146+ 
147+ with patch.dict(os.environ, launch_env, clear=True):
148+ daemon.pull_engine(PDRole.ROLE_U, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")
149+ 
150+ child_env = mock_popen.call_args.kwargs["env"]
151+ assert child_env["VLLM_HOST_IP"] == expected_host_ip
152+ assert child_env["MC_USE_IPV6"] == expected_mc_ipv6
153+ assert child_env["ASCEND_RT_VISIBLE_DEVICES"] == "0,1"
154+ 
79 @patch('subprocess.Popen')155 @patch('subprocess.Popen')
80 def test_pull_engine_success(self, mock_popen, daemon, endpoints):156 def test_pull_engine_success(self, mock_popen, daemon, endpoints):
81 mock_process = MagicMock(pid=12345)157 mock_process = MagicMock(pid=12345)
@@ -85,8 +161,19 @@ class TestDaemon:
85 master_dp_ip = "192.168.1.100"161 master_dp_ip = "192.168.1.100"
86 daemon.pull_engine(PDRole.ROLE_P, endpoints, instance_id, master_dp_ip)162 daemon.pull_engine(PDRole.ROLE_P, endpoints, instance_id, master_dp_ip)
87 # Verify that process was added to engine_pids163 # Verify that process was added to engine_pids
88- assert len(daemon._services[SERVICE_ENGINE].engine_pids) > 0164+ assert len(daemon.engine_pids) > 0
89- assert 12345 in daemon._services[SERVICE_ENGINE].engine_pids165+ assert 12345 in daemon.engine_pids
166+ 
167+ def test_pull_engine_failure_rolls_back_only_new_endpoints(self, daemon, endpoints):
168+ engine = daemon._services[SERVICE_ENGINE]
169+ with (
170+ patch.object(engine.supervisor, "start", side_effect=[False, True, RuntimeError("start failed")]),
171+ patch.object(engine.supervisor, "stop") as stop,
172+ ):
173+ with pytest.raises(RuntimeError, match="start failed"):
174+ daemon.pull_engine(PDRole.ROLE_P, endpoints, instance_id=1, master_dp_ip="192.168.1.100")
175+ 
176+ stop.assert_called_once_with(endpoints[1].id)
90 177 
91 @pytest.mark.parametrize(178 @pytest.mark.parametrize(
92 "invalid_endpoint,error_msg",179 "invalid_endpoint,error_msg",
@@ -99,41 +186,36 @@ class TestDaemon:
99 with pytest.raises(RuntimeError, match=error_msg):186 with pytest.raises(RuntimeError, match=error_msg):
100 daemon.pull_engine(PDRole.ROLE_U, [invalid_endpoint], instance_id=1, master_dp_ip="192.168.1.100")187 daemon.pull_engine(PDRole.ROLE_U, [invalid_endpoint], instance_id=1, master_dp_ip="192.168.1.100")
101 188 
102- @pytest.mark.parametrize(189+ def test_exit_daemon_delegates_to_process_supervisor(self, daemon):
103- "exception,should_not_raise",190+ supervisor = daemon._services[SERVICE_ENGINE].supervisor
104- [191+ with patch.object(supervisor, "stop_all", return_value=[1001, 1002]) as stop_all:
105- (None, True),192+ daemon.stop()
106- (ProcessLookupError("No such process"), True),193+ stop_all.assert_called_once_with()
107- (PermissionError("Permission denied"), True),
108- (Exception("Unexpected error"), True),
109- ],
110- )
111- @patch('os.kill')
112- def test_exit_daemon(self, mock_kill, daemon, exception, should_not_raise):
113- # Mock SIGKILL for Windows compatibility
114- with patch('motor.node_manager.core.services.engine.signal.SIGKILL', 9, create=True):
115- daemon._services[SERVICE_ENGINE].engine_pids = [1001, 1002]
116- if exception:
117- mock_kill.side_effect = exception
118- daemon.stop() # Method is called 'stop', not 'exit_daemon'
119- assert mock_kill.call_count == len([1001, 1002])
120 194 
121- @pytest.mark.parametrize(195+ def test_engine_exit_requests_pod_recovery_only_once(self, daemon):
122- "ip,port,expected",196+ engine = daemon._services[SERVICE_ENGINE]
123- [197+ engine.restart_on_failure = True
124- ("192.168.1.100", "8080", True),198+ with (
125- ("2001:db8::1", "8080", True),199+ patch.object(engine.supervisor, "dead_pids", return_value=[12345]),
126- ("invalid_ip", "8080", False),200+ patch("motor.node_manager.core.services.native_engine.service.os.kill") as kill,
127- ("192.168.1.100", "not_number", False),201+ ):
128- ("192.168.1.100", "0", False),202+ engine.health_check()
129- ("192.168.1.100", "99999", False),203+ engine.health_check()
130- ("192.168.1.100", "1", False),204+ 
131- ("192.168.1.100", "65535", True),205+ kill.assert_called_once_with(os.getpid(), signal.SIGTERM)
132- ],206+ 
133- )207+ @patch('subprocess.Popen')
134- def test_check_params(self, daemon, ip, port, expected):208+ def test_native_metrics_target_uses_business_port(self, mock_popen, daemon):
135- endpoint = Endpoint(id=1, ip=ip, business_port=port, mgmt_port="9090")209+ process = MagicMock(pid=12345)
136- assert daemon._services[SERVICE_ENGINE]._check_params(endpoint) == expected210+ process.poll.return_value = None
211+ mock_popen.return_value = process
212+ endpoint = Endpoint(id=0, ip="2001:db8::8", business_port="8000", mgmt_port="9000")
213+ daemon.pull_engine(PDRole.ROLE_U, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")
214+ 
215+ assert daemon.get_engine_metrics_target(endpoint) == "http://[2001:db8::8]:8000/metrics"
216+ 
217+ endpoint.headless = True
218+ assert daemon.get_engine_metrics_target(endpoint) is None
137 219 
138 @patch('subprocess.Popen')220 @patch('subprocess.Popen')
139 @patch('motor.node_manager.core.daemon.logger')221 @patch('motor.node_manager.core.daemon.logger')
@@ -148,10 +230,12 @@ class TestDaemon:
148 daemon.pull_engine(PDRole.ROLE_P, [endpoint], instance_id, master_dp_ip)230 daemon.pull_engine(PDRole.ROLE_P, [endpoint], instance_id, master_dp_ip)
149 231 
150 # Verify that process was added to engine_pids232 # Verify that process was added to engine_pids
151- assert len(daemon._services[SERVICE_ENGINE].engine_pids) > 0233+ assert len(daemon.engine_pids) > 0
152- assert 12345 in daemon._services[SERVICE_ENGINE].engine_pids234+ assert 12345 in daemon.engine_pids
153 # Verify Popen was called235 # Verify Popen was called
154 mock_popen.assert_called_once()236 mock_popen.assert_called_once()
237+ assert mock_popen.call_args.args[0][:2] == ["vllm", "serve"]
238+ assert "engine_server" not in mock_popen.call_args.args[0]
155 239 
156 @patch('subprocess.Popen')240 @patch('subprocess.Popen')
157 def test_hybrid_role_starts_union_engine(self, mock_popen, daemon):241 def test_hybrid_role_starts_union_engine(self, mock_popen, daemon):
@@ -162,9 +246,8 @@ class TestDaemon:
162 endpoint = Endpoint(id=0, ip="10.0.0.1", business_port="9000", mgmt_port="9090")246 endpoint = Endpoint(id=0, ip="10.0.0.1", business_port="9000", mgmt_port="9090")
163 daemon.pull_engine(PDRole.ROLE_U, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")247 daemon.pull_engine(PDRole.ROLE_U, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")
164 248 
165- cmd = mock_popen.call_args.args[0]249+ context = _last_launch_context(daemon)
166- role_arg_index = cmd.index("--role") + 1250+ assert context.role == PDRole.ROLE_U
167- assert cmd[role_arg_index] == "union"
168 251 
169 @patch('subprocess.Popen')252 @patch('subprocess.Popen')
170 def test_single_container_hybrid_omits_kv_port_when_unset(self, mock_popen, config_data):253 def test_single_container_hybrid_omits_kv_port_when_unset(self, mock_popen, config_data):
@@ -180,11 +263,14 @@ class TestDaemon:
180 )263 )
181 config.basic_config.device_num = 8264 config.basic_config.device_num = 8
182 config.basic_config.enable_multi_endpoints = False265 config.basic_config.enable_multi_endpoints = False
266+ config.basic_config.engine_type = "vllm"
183 config.single_container_config.single_container_flag = True267 config.single_container_config.single_container_flag = True
184 config.single_container_config.device_offset = 0268 config.single_container_config.device_offset = 0
185 config.single_container_config.kv_port = None269 config.single_container_config.kv_port = None
186 config.single_container_config.dp_rpc_port = 9000270 config.single_container_config.dp_rpc_port = 9000
187- daemon = Daemon(config)271+ backend = _recording_backend()
272+ with patch("motor.node_manager.core.services.native_engine.service.get_backend", return_value=backend):
273+ daemon = Daemon(config)
188 274 
189 mock_process = MagicMock(pid=12345)275 mock_process = MagicMock(pid=12345)
190 mock_process.poll.return_value = None276 mock_process.poll.return_value = None
@@ -193,9 +279,9 @@ class TestDaemon:
193 endpoint = Endpoint(id=0, ip="10.0.0.1", business_port="9000", mgmt_port="9090")279 endpoint = Endpoint(id=0, ip="10.0.0.1", business_port="9000", mgmt_port="9090")
194 daemon.pull_engine(PDRole.ROLE_U, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")280 daemon.pull_engine(PDRole.ROLE_U, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")
195 281 
196- cmd = mock_popen.call_args.args[0]282+ context = _last_launch_context(daemon)
197- assert "--kv-port" not in cmd283+ assert context.kv_port is None
198- assert cmd[cmd.index("--dp-rpc-port") + 1] == "9000"284+ assert context.dp_rpc_port == 9000
199 285 
200 # ===== D2D Weight Transfer Tests =====286 # ===== D2D Weight Transfer Tests =====
201 287 
@@ -218,10 +304,8 @@ class TestDaemon:
218 )304 )
219 305 
220 mock_popen.assert_called_once()306 mock_popen.assert_called_once()
221- cmd = mock_popen.call_args.args[0]307+ context = _last_launch_context(daemon)
222- assert '--d2d-peer-ips' in cmd308+ assert context.d2d_peer_ips == ("192.168.1.10", "192.168.1.11")
223- idx = cmd.index('--d2d-peer-ips')
224- assert cmd[idx + 1] == "192.168.1.10,192.168.1.11"
225 309 
226 @patch('subprocess.Popen')310 @patch('subprocess.Popen')
227 def test_pull_engine_without_d2d_peer_ips(self, mock_popen, daemon):311 def test_pull_engine_without_d2d_peer_ips(self, mock_popen, daemon):
@@ -240,8 +324,7 @@ class TestDaemon:
240 )324 )
241 325 
242 mock_popen.assert_called_once()326 mock_popen.assert_called_once()
243- cmd = mock_popen.call_args.args[0]327+ assert _last_launch_context(daemon).d2d_peer_ips == ()
244- assert '--d2d-peer-ips' not in cmd
245 328 
246 @patch('subprocess.Popen')329 @patch('subprocess.Popen')
247 def test_pull_engine_with_empty_d2d_peer_ips(self, mock_popen, daemon):330 def test_pull_engine_with_empty_d2d_peer_ips(self, mock_popen, daemon):
@@ -263,8 +346,7 @@ class TestDaemon:
263 )346 )
264 347 
265 mock_popen.assert_called_once()348 mock_popen.assert_called_once()
266- cmd = mock_popen.call_args.args[0]349+ assert _last_launch_context(daemon).d2d_peer_ips == ()
267- assert '--d2d-peer-ips' not in cmd
268 350 
269 @patch('subprocess.Popen')351 @patch('subprocess.Popen')
270 def test_pull_engine_with_d2d_peer_ips_rank_encoded(self, mock_popen, daemon):352 def test_pull_engine_with_d2d_peer_ips_rank_encoded(self, mock_popen, daemon):
@@ -288,10 +370,9 @@ class TestDaemon:
288 )370 )
289 371 
290 assert mock_popen.call_count == 2372 assert mock_popen.call_count == 2
291- first_cmd = mock_popen.call_args_list[0].args[0]373+ contexts = [call.args[0] for call in daemon._services[SERVICE_ENGINE].backend.prepare.call_args_list]
292- second_cmd = mock_popen.call_args_list[1].args[0]374+ assert contexts[0].d2d_peer_ips == ("192.168.1.10",)
293- assert first_cmd[first_cmd.index('--d2d-peer-ips') + 1] == "192.168.1.10"375+ assert contexts[1].d2d_peer_ips == ("192.168.1.11",)
294- assert second_cmd[second_cmd.index('--d2d-peer-ips') + 1] == "192.168.1.11"
295 376 
296 @patch('subprocess.Popen')377 @patch('subprocess.Popen')
297 def test_pull_engine_d2d_peer_ips_no_match_for_endpoint(self, mock_popen, daemon):378 def test_pull_engine_d2d_peer_ips_no_match_for_endpoint(self, mock_popen, daemon):
@@ -312,12 +393,11 @@ class TestDaemon:
312 )393 )
313 394 
314 mock_popen.assert_called_once()395 mock_popen.assert_called_once()
315- cmd = mock_popen.call_args.args[0]396+ assert _last_launch_context(daemon).d2d_peer_ips == ()
316- assert '--d2d-peer-ips' not in cmd
317 397 
318 @patch('subprocess.Popen')398 @patch('subprocess.Popen')
319 def test_pull_engine_includes_node_rank(self, mock_popen, daemon):399 def test_pull_engine_includes_node_rank(self, mock_popen, daemon):
320- """Test that --node-rank is included in the engine_server CLI with default value"""400+ """Test that the default node rank is passed to the backend."""
321 mock_process = MagicMock(pid=12345)401 mock_process = MagicMock(pid=12345)
322 mock_process.poll.return_value = None402 mock_process.poll.return_value = None
323 mock_popen.return_value = mock_process403 mock_popen.return_value = mock_process
@@ -325,14 +405,11 @@ class TestDaemon:
325 endpoint = Endpoint(id=0, ip="10.0.0.1", business_port="9000", mgmt_port="9090")405 endpoint = Endpoint(id=0, ip="10.0.0.1", business_port="9000", mgmt_port="9090")
326 daemon.pull_engine(PDRole.ROLE_P, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")406 daemon.pull_engine(PDRole.ROLE_P, [endpoint], instance_id=1, master_dp_ip="192.168.1.100")
327 407 
328- cmd = mock_popen.call_args.args[0]408+ assert _last_launch_context(daemon).node_rank == 0
329- assert "--node-rank" in cmd
330- node_rank_index = cmd.index("--node-rank")
331- assert cmd[node_rank_index + 1] == "0"
332 409 
333 @patch('subprocess.Popen')410 @patch('subprocess.Popen')
334 def test_pull_engine_custom_node_rank(self, mock_popen, daemon):411 def test_pull_engine_custom_node_rank(self, mock_popen, daemon):
335- """Test that --node-rank value matches the node_rank parameter"""412+ """Test that the configured node rank is passed to the backend."""
336 mock_process = MagicMock(pid=12345)413 mock_process = MagicMock(pid=12345)
337 mock_process.poll.return_value = None414 mock_process.poll.return_value = None
338 mock_popen.return_value = mock_process415 mock_popen.return_value = mock_process
@@ -340,6 +417,4 @@ class TestDaemon:
340 endpoint = Endpoint(id=1, ip="10.0.0.1", business_port="9000", mgmt_port="9090")417 endpoint = Endpoint(id=1, ip="10.0.0.1", business_port="9000", mgmt_port="9090")
341 daemon.pull_engine(PDRole.ROLE_P, [endpoint], instance_id=1, master_dp_ip="192.168.1.100", node_rank=2)418 daemon.pull_engine(PDRole.ROLE_P, [endpoint], instance_id=1, master_dp_ip="192.168.1.100", node_rank=2)
342 419 
343- cmd = mock_popen.call_args.args[0]420+ assert _last_launch_context(daemon).node_rank == 2
344- node_rank_index = cmd.index("--node-rank")
345- assert cmd[node_rank_index + 1] == "2"
@@ -175,6 +175,23 @@ class TestEngineManager:
175 assert msg is not None175 assert msg is not None
176 assert msg.is_master is True176 assert msg.is_master is True
177 177 
178+ def test_gen_register_msg_includes_sglang_bootstrap_port(self, engine_manager):
179+ engine_manager._config.basic_config.job_name = "test_job"
180+ engine_manager._config.basic_config.model_name = "test_model"
181+ engine_manager._config.basic_config.role = PDRole.ROLE_P
182+ engine_manager._config.api_config.pod_ip = "192.168.1.101"
183+ engine_manager._config.endpoint_config.service_ports = ["8080"]
184+ engine_manager._config.endpoint_config.mgmt_ports = ["8081"]
185+ engine_manager._config.endpoint_config.bootstrap_port = 9100
186+ engine_manager._config.api_config.node_manager_port = 8088
187+ engine_manager._config.basic_config.parallel_config = ParallelConfig(tp_size=2, pp_size=1)
188+ engine_manager._config.basic_config.device_num = 2
189+ 
190+ msg = engine_manager._gen_register_msg()
191+ 
192+ assert msg is not None
193+ assert msg.bootstrap_port == 9100
194+ 
178 def test_gen_register_msg_failure(self, engine_manager):195 def test_gen_register_msg_failure(self, engine_manager):
179 """Test _gen_register_msg with invalid config"""196 """Test _gen_register_msg with invalid config"""
180 engine_manager._config.basic_config.job_name = None197 engine_manager._config.basic_config.job_name = None
@@ -153,41 +153,95 @@ class TestHeartBeatManager:
153 assert heart_beat_manager._endpoints[0].id == 1153 assert heart_beat_manager._endpoints[0].id == 1
154 assert heart_beat_manager._endpoints[1].id == 2154 assert heart_beat_manager._endpoints[1].id == 2
155 155 
156- def test_update_endpoint_resets_grace_period(self, heart_beat_manager, sample_start_cmd_msg):156+ def test_update_endpoint_invalidates_inflight_native_probe(self, heart_beat_manager, sample_start_cmd_msg):
157- """update_endpoint should restart grace period for engine cold start"""157+ """update_endpoint advances the generation used to reject stale probe results."""
158- heart_beat_manager._thread_started = True158+ before = heart_beat_manager._endpoints_generation
159- heart_beat_manager._is_within_grace_period = False
160- heart_beat_manager._engine_status_thread_start_time = time.time() - 300
161- 
162- before = heart_beat_manager._engine_status_thread_start_time
163 heart_beat_manager.update_endpoint(sample_start_cmd_msg)159 heart_beat_manager.update_endpoint(sample_start_cmd_msg)
164 160 
165- assert heart_beat_manager._is_within_grace_period is True161+ assert heart_beat_manager._endpoints_generation == before + 1
166- assert heart_beat_manager._engine_status_thread_start_time > before
167 162 
168- @patch('motor.node_manager.core.heartbeat_manager.EngineServerApiClient.query_status')163+ @patch('motor.node_manager.core.heartbeat_manager.Daemon')
169- def test_get_engine_server_status_success(self, mock_query_status, heart_beat_manager, sample_endpoints):164+ def test_engine_metrics_targets_exclude_headless(self, mock_daemon, heart_beat_manager):
170- """test get engine server status success"""165+ routable = Endpoint(id=1, ip="10.0.0.1", business_port="8001", mgmt_port="9001")
171- # Mock query_status to return normal status for each endpoint166+ headless = Endpoint(
172- mock_query_status.return_value = {"status": "normal"}167+ id=2,
168+ ip="10.0.0.2",
169+ business_port="8002",
170+ mgmt_port="9002",
171+ headless=True,
172+ )
173+ mock_daemon.return_value.get_engine_metrics_target.return_value = "https://10.0.0.1:8001/metrics"
174+ with heart_beat_manager._endpoint_lock:
175+ heart_beat_manager._endpoints = [routable, headless]
176+ 
177+ targets = heart_beat_manager.get_engine_metrics_targets()
178+ 
179+ assert targets == ["https://10.0.0.1:8001/metrics"]
180+ mock_daemon.return_value.get_engine_metrics_target.assert_called_once_with(routable)
181+ 
182+ @patch('motor.node_manager.core.heartbeat_manager.Daemon')
183+ def test_refresh_native_engine_status_success(self, mock_daemon, heart_beat_manager, sample_endpoints):
184+ """READY native runtimes map to normal endpoint status."""
185+ from motor.node_manager.core.services.native_engine.models import RuntimeState
186+ 
187+ mock_daemon.return_value.get_engine_runtime_state.return_value = RuntimeState.READY
173 188 
174 with heart_beat_manager._endpoint_lock:189 with heart_beat_manager._endpoint_lock:
175 heart_beat_manager._endpoints = sample_endpoints.copy()190 heart_beat_manager._endpoints = sample_endpoints.copy()
176 191 
177- heart_beat_manager._get_engine_server_status()192+ heart_beat_manager._refresh_native_engine_status()
178 193 
179- # Verify that query_status was called for each endpoint194+ assert mock_daemon.return_value.get_engine_runtime_state.call_count == 2
180- assert mock_query_status.call_count == 2
181 195 
182- # Verify that status was updated correctly
183 assert heart_beat_manager._endpoints[0].status == EndpointStatus.NORMAL196 assert heart_beat_manager._endpoints[0].status == EndpointStatus.NORMAL
184 assert heart_beat_manager._endpoints[1].status == EndpointStatus.NORMAL197 assert heart_beat_manager._endpoints[1].status == EndpointStatus.NORMAL
185 198 
186- @patch('motor.node_manager.core.heartbeat_manager.EngineServerApiClient.query_status')199+ @patch('motor.node_manager.core.heartbeat_manager.Daemon')
187- def test_get_engine_server_status_discards_stale_probe_write_back(200+ def test_refresh_native_engine_status_keeps_initial_while_loading(self, mock_daemon, heart_beat_manager):
188- self, mock_query_status, heart_beat_manager, sample_start_cmd_msg201+ from motor.node_manager.core.services.native_engine.models import RuntimeState
202+ 
203+ mock_daemon.return_value.get_engine_runtime_state.return_value = RuntimeState.STARTING
204+ endpoint = Endpoint(
205+ id=1,
206+ ip="192.168.1.1",
207+ business_port="8080",
208+ mgmt_port="9090",
209+ status=EndpointStatus.INITIAL,
210+ )
211+ with heart_beat_manager._endpoint_lock:
212+ heart_beat_manager._endpoints = [endpoint]
213+ 
214+ heart_beat_manager._refresh_native_engine_status()
215+ 
216+ assert heart_beat_manager._endpoints[0].status == EndpointStatus.INITIAL
217+ 
218+ @patch('motor.node_manager.core.heartbeat_manager.Daemon')
219+ def test_refresh_headless_process_liveness_reports_wait2start(self, mock_daemon, heart_beat_manager):
220+ from motor.node_manager.core.services.native_engine.models import RuntimeState
221+ 
222+ mock_daemon.return_value.get_engine_runtime_state.return_value = RuntimeState.RUNNING
223+ endpoint = Endpoint(
224+ id=1,
225+ ip="192.168.1.2",
226+ business_port="8080",
227+ mgmt_port="9090",
228+ status=EndpointStatus.INITIAL,
229+ headless=True,
230+ )
231+ with heart_beat_manager._endpoint_lock:
232+ heart_beat_manager._endpoints = [endpoint]
233+ 
234+ heart_beat_manager._refresh_native_engine_status()
235+ 
236+ assert heart_beat_manager._endpoints[0].status == EndpointStatus.WAIT2START
237+ 
238+ @patch('motor.node_manager.core.heartbeat_manager.Daemon')
239+ def test_refresh_native_engine_status_discards_stale_probe_write_back(
240+ self, mock_daemon, heart_beat_manager, sample_start_cmd_msg
189 ):241 ):
190 """stale probe result must not overwrite endpoints refreshed by update_endpoint"""242 """stale probe result must not overwrite endpoints refreshed by update_endpoint"""
243+ from motor.node_manager.core.services.native_engine.models import RuntimeState
244+ 
191 stale_endpoint = Endpoint(245 stale_endpoint = Endpoint(
192 id=0,246 id=0,
193 ip="10.0.0.28",247 ip="10.0.0.28",
@@ -198,15 +252,15 @@ class TestHeartBeatManager:
198 252 
199 def probe_and_update_during_probe(*args, **kwargs):253 def probe_and_update_during_probe(*args, **kwargs):
200 heart_beat_manager.update_endpoint(sample_start_cmd_msg)254 heart_beat_manager.update_endpoint(sample_start_cmd_msg)
201- return {"status": "abnormal"}255+ return RuntimeState.UNHEALTHY
202 256 
203- mock_query_status.side_effect = probe_and_update_during_probe257+ mock_daemon.return_value.get_engine_runtime_state.side_effect = probe_and_update_during_probe
204 258 
205 with heart_beat_manager._endpoint_lock:259 with heart_beat_manager._endpoint_lock:
206 heart_beat_manager._endpoints = [stale_endpoint]260 heart_beat_manager._endpoints = [stale_endpoint]
207 heart_beat_manager._endpoints_generation = 0261 heart_beat_manager._endpoints_generation = 0
208 262 
209- heart_beat_manager._get_engine_server_status()263+ heart_beat_manager._refresh_native_engine_status()
210 264 
211 assert len(heart_beat_manager._endpoints) == 2265 assert len(heart_beat_manager._endpoints) == 2
212 assert heart_beat_manager._endpoints[0].ip == "192.168.1.1"266 assert heart_beat_manager._endpoints[0].ip == "192.168.1.1"
@@ -498,7 +552,6 @@ class TestHeartBeatManager:
498 # Set endpoint info with abnormal status552 # Set endpoint info with abnormal status
499 heart_beat_manager._job_name = "test_job"553 heart_beat_manager._job_name = "test_job"
500 heart_beat_manager._instance_id = 1554 heart_beat_manager._instance_id = 1
501- heart_beat_manager._is_within_grace_period = False # Ensure we're past grace period
502 heart_beat_manager.stop_event.clear()555 heart_beat_manager.stop_event.clear()
503 556 
504 with heart_beat_manager._endpoint_lock:557 with heart_beat_manager._endpoint_lock:
@@ -535,7 +588,6 @@ class TestHeartBeatManager:
535 588 
536 heart_beat_manager._job_name = "test_job"589 heart_beat_manager._job_name = "test_job"
537 heart_beat_manager._instance_id = 1590 heart_beat_manager._instance_id = 1
538- heart_beat_manager._is_within_grace_period = False
539 heart_beat_manager.stop_event.clear()591 heart_beat_manager.stop_event.clear()
540 592 
541 # Start with abnormal status593 # Start with abnormal status
@@ -583,7 +635,6 @@ class TestHeartBeatManager:
583 635 
584 heart_beat_manager._job_name = "test_job"636 heart_beat_manager._job_name = "test_job"
585 heart_beat_manager._instance_id = 1637 heart_beat_manager._instance_id = 1
586- heart_beat_manager._is_within_grace_period = False
587 heart_beat_manager.stop_event.clear()638 heart_beat_manager.stop_event.clear()
588 639 
589 with heart_beat_manager._endpoint_lock:640 with heart_beat_manager._endpoint_lock:
@@ -617,7 +668,6 @@ class TestHeartBeatManager:
617 668 
618 heart_beat_manager._job_name = "test_job"669 heart_beat_manager._job_name = "test_job"
619 heart_beat_manager._instance_id = 1670 heart_beat_manager._instance_id = 1
620- heart_beat_manager._is_within_grace_period = False
621 heart_beat_manager.stop_event.clear()671 heart_beat_manager.stop_event.clear()
622 672 
623 # Set multiple endpoints, one abnormal673 # Set multiple endpoints, one abnormal
@@ -651,7 +701,6 @@ class TestHeartBeatManager:
651 701 
652 heart_beat_manager._job_name = "test_job"702 heart_beat_manager._job_name = "test_job"
653 heart_beat_manager._instance_id = 1703 heart_beat_manager._instance_id = 1
654- heart_beat_manager._is_within_grace_period = False
655 heart_beat_manager.stop_event.clear()704 heart_beat_manager.stop_event.clear()
656 705 
657 with heart_beat_manager._endpoint_lock:706 with heart_beat_manager._endpoint_lock:
@@ -774,20 +823,22 @@ class TestHeartBeatManager:
774 mock_report_heartbeat.assert_called_once()823 mock_report_heartbeat.assert_called_once()
775 824 
776 @patch("motor.node_manager.core.heartbeat_manager.is_restored_from_host_side_snapshot", return_value=True)825 @patch("motor.node_manager.core.heartbeat_manager.is_restored_from_host_side_snapshot", return_value=True)
777- @patch("motor.node_manager.core.heartbeat_manager.EngineServerApiClient.query_status")826+ @patch("motor.node_manager.core.heartbeat_manager.Daemon")
778- def test_get_engine_server_status_keeps_status_before_start_after_restore(827+ def test_refresh_native_engine_status_keeps_status_before_start_after_restore(
779- self, mock_query_status, _mock_restored, heart_beat_manager, sample_endpoints828+ self, mock_daemon, _mock_restored, heart_beat_manager, sample_endpoints
780 ):829 ):
781- mock_query_status.return_value = {"status": "abnormal"}830+ from motor.node_manager.core.services.native_engine.models import RuntimeState
831+ 
832+ mock_daemon.return_value.get_engine_runtime_state.return_value = RuntimeState.UNHEALTHY
782 833 
783 with heart_beat_manager._endpoint_lock:834 with heart_beat_manager._endpoint_lock:
784 heart_beat_manager._endpoints = sample_endpoints.copy()835 heart_beat_manager._endpoints = sample_endpoints.copy()
785 836 
786- heart_beat_manager._get_engine_server_status()837+ heart_beat_manager._refresh_native_engine_status()
787 838 
788 assert heart_beat_manager._endpoints[0].status == EndpointStatus.NORMAL839 assert heart_beat_manager._endpoints[0].status == EndpointStatus.NORMAL
789 assert heart_beat_manager._endpoints[1].status == EndpointStatus.NORMAL840 assert heart_beat_manager._endpoints[1].status == EndpointStatus.NORMAL
790- assert mock_query_status.call_count == 2841+ assert mock_daemon.return_value.get_engine_runtime_state.call_count == 2
791 842 
792 @patch("motor.node_manager.core.heartbeat_manager.is_restored_from_host_side_snapshot", return_value=False)843 @patch("motor.node_manager.core.heartbeat_manager.is_restored_from_host_side_snapshot", return_value=False)
793 @patch("motor.node_manager.core.heartbeat_manager.time.sleep")844 @patch("motor.node_manager.core.heartbeat_manager.time.sleep")
@@ -169,6 +169,35 @@ def test_logging_config_defaults(nm_config_data):
169 assert config.logging_config.log_date_format == '%m-%d %H:%M:%S'169 assert config.logging_config.log_date_format == '%m-%d %H:%M:%S'
170 170 
171 171 
172+@patch.dict("os.environ", {"ROLE": "prefill"})
173+def test_native_sglang_bootstrap_port_from_engine_config():
174+ config = create_config_object()
175+ raw = {
176+ "motor_engine_prefill_config": {
177+ "engine_type": "sglang",
178+ "engine_config": {"disaggregation_bootstrap_port": 9100},
179+ }
180+ }
181+ 
182+ NodeManagerConfig._set_native_bootstrap_port(config, raw)
183+ 
184+ assert config.endpoint_config.bootstrap_port == 9100
185+ 
186+ 
187+@patch.dict("os.environ", {"ROLE": "prefill"})
188+def test_native_bootstrap_port_rejects_invalid_range():
189+ config = create_config_object()
190+ raw = {
191+ "motor_engine_prefill_config": {
192+ "engine_type": "sglang",
193+ "engine_config": {"disaggregation_bootstrap_port": 70000},
194+ }
195+ }
196+ 
197+ with pytest.raises(ValueError, match="disaggregation_bootstrap_port must be in range 1-65535"):
198+ NodeManagerConfig._set_native_bootstrap_port(config, raw)
199+ 
200+ 
172@pytest.mark.parametrize(201@pytest.mark.parametrize(
173 "invalid_config,expected_error",202 "invalid_config,expected_error",
174 [203 [
@@ -542,6 +571,14 @@ def test_from_json_loads_union_config_for_hybrid():
542 assert config.endpoint_config.endpoint_num == 2571 assert config.endpoint_config.endpoint_num == 2
543 572 
544 573 
574+def test_native_runtime_rejects_snapshot_configuration():
575+ config = NodeManagerConfig()
576+ config.snapshot_config.enable_snapshot = True
577+ 
578+ with pytest.raises(ValueError, match="Native engine runtime does not support snapshot yet"):
579+ config.validate_config()
580+ 
581+ 
545def _single_container_hybrid_user_config():582def _single_container_hybrid_user_config():
546 return {583 return {
547 "motor_deploy_config": {584 "motor_deploy_config": {
@@ -654,9 +691,7 @@ def test_vllm_multi_connector_infers_transport_connector_capability():
654 assert NodeManagerConfig._infer_dispatch_capabilities(_multi_connector("NixlConnector")) == [691 assert NodeManagerConfig._infer_dispatch_capabilities(_multi_connector("NixlConnector")) == [
655 DispatchPlan.PREFILL_HANDOFF_DECODE.value692 DispatchPlan.PREFILL_HANDOFF_DECODE.value
656 ]693 ]
657- assert NodeManagerConfig._infer_dispatch_capabilities(_multi_connector("MooncakeLayerwiseConnector")) == [694+ assert NodeManagerConfig._infer_dispatch_capabilities(_multi_connector("MooncakeLayerwiseConnector")) == []
658- DispatchPlan.CONCURRENT_ENGINE_SYNC.value
659- ]
660 695 
661 696 
662def test_vllm_multi_connector_ignores_non_transport_connector_profiles():697def test_vllm_multi_connector_ignores_non_transport_connector_profiles():
@@ -676,7 +711,7 @@ def test_vllm_multi_connector_ignores_non_transport_connector_profiles():
676 },711 },
677 }712 }
678 713 
679- assert NodeManagerConfig._infer_dispatch_capabilities(engine_config) == [DispatchPlan.CONCURRENT_ENGINE_SYNC.value]714+ assert NodeManagerConfig._infer_dispatch_capabilities(engine_config) == []
680 715 
681 716 
682def test_vllm_multi_connector_requires_transport_and_store_connectors():717def test_vllm_multi_connector_requires_transport_and_store_connectors():
@@ -742,7 +777,7 @@ def test_user_dispatch_capabilities_cannot_enable_unknown_connector():
742 assert config_data["basic_config"]["dispatch_capabilities"] == []777 assert config_data["basic_config"]["dispatch_capabilities"] == []
743 778 
744 779 
745-def test_vllm_layerwise_connector_infers_concurrent_capability():780+def test_vllm_layerwise_connector_does_not_advertise_unsupported_native_capability():
746 capabilities = NodeManagerConfig._infer_dispatch_capabilities(781 capabilities = NodeManagerConfig._infer_dispatch_capabilities(
747 {782 {
748 "engine_type": "vllm",783 "engine_type": "vllm",
@@ -754,7 +789,7 @@ def test_vllm_layerwise_connector_infers_concurrent_capability():
754 }789 }
755 )790 )
756 791 
757- assert capabilities == [DispatchPlan.CONCURRENT_ENGINE_SYNC.value]792+ assert capabilities == []
758 793 
759 794 
760def test_sglang_infers_concurrent_capability():795def test_sglang_infers_concurrent_capability():