main
py 1,138 lines 46.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Server-role state machine for a single peer session.
4
5 Owns block matching (supply vs. demand), inflight RDMA transfers, store-job
6 timeouts, abort-drain, and produces ``StoreResult`` for completed stores.
7 The session coordinator parses wire messages and dispatches typed
8 arguments here; this module never touches ``ControlConnection`` directly
9 — it emits via the ``send`` callback injected by the coordinator (which
10 gates on ConnectAck).
11
12 Protocol violations the role can detect (today: duplicate ``FetchMsg``
13 for the same ``kv_request_id``) are surfaced as ``ValueError`` so the
14 coordinator's ``_dispatch_message`` can reuse its existing
15 ``_protocol_error`` path.
16 """
17
18 from __future__ import annotations
19
20 import time
21 from collections.abc import Callable, Iterable, Sequence
22 from dataclasses import dataclass, field
23 from typing import TYPE_CHECKING, NamedTuple
24
25 from vllm.logger import init_logger
26 from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
27 from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
28 TYPE_KEY,
29 AbortAckMsg,
30 LookupRespMsg,
31 TransferDoneMsg,
32 )
33
34 if TYPE_CHECKING:
35 from vllm.v1.kv_offload.tiering.base import JobId, ParentManager
36 from vllm.v1.kv_offload.tiering.p2p.data import DataTransport
37
38 logger = init_logger(__name__)
39
40 _STORE_TIMEOUT_S = 30.0
41 _CANCEL_DRAIN_TIMEOUT_S = 10.0
42 # Cap on time the server holds a HIT_PENDING / RETRY key from an inbound
43 # LookupMsg before falling back to MISS. Long enough that an in-flight
44 # primary write or a just-started promotion typically completes; short
45 # enough that the consumer doesn't sit idle on a stuck producer.
46 _LOOKUP_PENDING_TIMEOUT_S = 5.0
47
48
49 class StoreResult(NamedTuple):
50 """Result from a session poll, server side."""
51
52 job_id: int
53 success: bool
54
55
56 class _MatchResult(NamedTuple):
57 """Result of block matching: pairs ready for transfer."""
58
59 local_idxs: list[int]
60 remote_idxs: list[int]
61 # The set of store job IDs that contributed blocks
62 job_ids: set[int]
63
64
65 @dataclass
66 class _OutboundRequestState:
67 """Server-role state for a single fetch round of a peer request.
68
69 A kv_request_id may run several lookup→fetch rounds; rounds live in
70 ``_ServerRequestState.outbound`` keyed by the wire ``round_seq``, so
71 terminals touch only their own round.
72 """
73
74 # Supply came from inbound lookup pins (symmetric): no late
75 # submit_store can arrive, so unmatched fetch demand fails fast. PD
76 # rounds park demand for stores instead.
77 lookup_supplied: bool = False
78 demand_received: bool = False
79 available: dict[OffloadKey, tuple[int, int]] = field(
80 default_factory=dict
81 ) # key → (job_id, local_block_idx): blocks we have, awaiting demand
82 demanded: dict[OffloadKey, int] = field(
83 default_factory=dict
84 ) # key → remote_block_idx: blocks peer wants, awaiting supply
85 remaining: int = 0 # blocks that need to be transferred to client
86 finishing: bool = False # Signal finish request ASAP
87 inflight: int = 0 # transfers submitted for this round, not yet polled
88 # Job IDs that submit_store'd blocks for this round and have not
89 # yet emitted a StoreResult. The terminal-finalize helper drains
90 # this set; poll-done and poll-failed discard entries as their
91 # StoreResults fire.
92 pending_job_ids: set[int] = field(default_factory=set)
93
94 def add_stored_blocks(
95 self,
96 keys: Sequence[OffloadKey],
97 block_ids: Sequence[int],
98 job_id: int,
99 ) -> _MatchResult:
100 """Add locally-stored blocks. Returns matched pairs."""
101 self.pending_job_ids.add(job_id)
102 local_idxs: list[int] = []
103 remote_idxs: list[int] = []
104 for key, local_idx in zip(keys, block_ids):
105 remote_idx = self.demanded.pop(key, None)
106 if remote_idx is not None:
107 local_idxs.append(local_idx)
108 remote_idxs.append(remote_idx)
109 else:
110 self.available[key] = (job_id, local_idx)
111 return _MatchResult(
112 local_idxs=local_idxs,
113 remote_idxs=remote_idxs,
114 job_ids={job_id} if local_idxs else set(),
115 )
116
117 def add_fetch_demand(
118 self,
119 keys: Sequence[OffloadKey],
120 block_indexes: Sequence[int],
121 ) -> _MatchResult:
122 """Register the peer's fetch demand. Returns matched pairs."""
123 self.demand_received = True
124 self.remaining = len(keys)
125
126 local_idxs: list[int] = []
127 remote_idxs: list[int] = []
128 job_ids: set[int] = set()
129 for key, remote_idx in zip(keys, block_indexes):
130 stored_entry = self.available.pop(key, None)
131 if stored_entry is not None:
132 stored_job_id, local_idx = stored_entry
133 local_idxs.append(local_idx)
134 remote_idxs.append(remote_idx)
135 job_ids.add(stored_job_id)
136 else:
137 self.demanded[key] = remote_idx
138 return _MatchResult(
139 local_idxs=local_idxs,
140 remote_idxs=remote_idxs,
141 job_ids=job_ids,
142 )
143
144
145 @dataclass
146 class _InflightXfer:
147 """Metadata for a single inflight RDMA transfer, keyed by transfer_id."""
148
149 kv_request_id: str
150 block_count: int
151 # The set of store job IDs that contributed blocks to this transfer.
152 job_ids: set[int]
153 # Round this transfer serves and its key in ``st.outbound``;
154 # remaining/finalize apply only while the round is still registered.
155 # Dummy default for test-seeded entries.
156 round: _OutboundRequestState = field(default_factory=_OutboundRequestState)
157 round_key: int = 0
158
159
160 @dataclass
161 class _ActiveLookup:
162 """In-flight state for one inbound LookupMsg.
163
164 Aggregates per-key HIT/MISS resolutions and defers the single
165 outbound LookupRespMsg until every key has been resolved or the
166 ``deadline`` fires (remaining ``pending`` keys then force-MISS).
167 Exactly one LookupRespMsg is emitted per LookupMsg, carrying every
168 key in the original wire order.
169 """
170
171 lookup_id: int
172 kv_request_id: str
173 ctx: ReqContext
174 # Wire round these probes belong to; pins park under it.
175 round_seq: int = 0
176 # Keys from the inbound LookupMsg, preserved in wire order so
177 # the aggregated response goes back in the same order.
178 keys: list[OffloadKey] = field(default_factory=list)
179 # Per-key resolution: True = HIT, False = MISS. A key is present
180 # here once definitively resolved; still-pending keys are only
181 # in ``pending``.
182 resolved: dict[OffloadKey, bool] = field(default_factory=dict)
183 # Keys still awaiting resolution (HIT_PENDING / RETRY from
184 # ``parent.lookup``); re-polled by ``_resolve_pending_lookups``.
185 pending: set[OffloadKey] = field(default_factory=set)
186 # Absolute deadline (``time.monotonic``). Once reached, remaining
187 # ``pending`` keys are force-resolved to MISS so the consumer
188 # can fall back instead of waiting on a stuck producer.
189 deadline: float = 0.0
190
191
192 class _PendingLookup(NamedTuple):
193 """A raw inbound LookupMsg awaiting resolution.
194
195 Enqueued by ``on_lookup`` during dispatch and drained by the next
196 ``serve_external_requests``. The deadline for any resulting
197 HIT_PENDING / RETRY key is measured from ``enqueued_at``.
198 """
199
200 keys: list[OffloadKey]
201 enqueued_at: float
202 round_seq: int = 0
203
204
205 @dataclass
206 class _ServerRequestState:
207 """Per-kv_request_id server-side state.
208
209 Consolidates the outbound serve/transfer state, the symmetric-P2P
210 inbound-lookup state, abort bookkeeping, and the inflight-transfer
211 count for one kv_request_id. The owning id is the ``_requests`` dict
212 key and is not duplicated here. An entry is dropped once every field
213 is idle — see ``ServerRole._maybe_prune``.
214 """
215
216 # Fetch rounds keyed by wire round_seq. A round is created by its
217 # first supply or its fetch and removed at its terminal (finalize /
218 # failure / abort).
219 outbound: dict[int, _OutboundRequestState] = field(default_factory=dict)
220 # Raw inbound LookupMsgs not yet processed against the ParentManager.
221 pending_lookups: list[_PendingLookup] = field(default_factory=list)
222 # Per-LookupMsg state parked with HIT_PENDING / RETRY keys, keyed by
223 # the (globally unique) lookup_id and re-polled each serve.
224 lookups: dict[int, _ActiveLookup] = field(default_factory=dict)
225 # Transfer ids in ``ServerRole._inflight`` for this id. Kept in sync
226 # via _inflight_add / _inflight_pop so a non-empty set is an exact
227 # "has any inflight transfer" predicate and the abort drain can
228 # enumerate this request's transfers without scanning all of
229 # ``_inflight``.
230 inflight_tids: set[int] = field(default_factory=set)
231
232
233 class ServerRole:
234 """Server-side store/serve state machine for one peer session.
235
236 The coordinator owns the connection and the send-gating; this role
237 is given a ``send`` callback, the ``DataTransport``, and the
238 ``peer_id`` for transport calls and log messages.
239 """
240
241 def __init__(
242 self,
243 peer_id: str,
244 transport: DataTransport,
245 send: Callable[[dict], None],
246 ) -> None:
247 self._peer_id = peer_id
248 self._transport = transport
249 self._send = send
250
251 # All per-kv_request_id state lives here. Entries are created
252 # lazily and dropped by _maybe_prune once every field is idle.
253 self._requests: dict[str, _ServerRequestState] = {}
254 # kv_request_ids with lookup work (unprocessed pending_lookups or
255 # parked lookups) for the next serve to visit — the work-list that
256 # keeps serve_external_requests from scanning every request.
257 self._serve_pending: set[str] = set()
258 # transfer_id → xfer. Mutate ONLY via _inflight_add / _inflight_pop
259 # so the per-request inflight_tids stays in sync.
260 self._inflight: dict[int, _InflightXfer] = {}
261 self._store_jobs: dict[int, float] = {} # job_id → submitted_at
262 # StoreResults queued by _finalize_outbound for the next poll
263 # tick to surface. Mirrors the deferred-result pattern used for
264 # load timeouts.
265 self._pending_store_results: list[StoreResult] = []
266 # Synthetic lookup ctxs whose ``on_request_finished`` still needs to
267 # fire but which were closed outside a serve window (FetchMsg / local
268 # finish popped their parked lookup). Drained via
269 # ``parent.on_request_finished`` in ``serve_external_requests``.
270 self._finished_lookup_ctxs: list[ReqContext] = []
271 self._lookup_id_counter: int = 0
272 # Parked aborts awaiting drain, keyed by (kv_request_id, round)
273 # with the abort start time.
274 self._pending_aborts: dict[tuple[str, int], float] = {}
275
276 # ------------------------------------------------------------------
277 # State helpers
278 # ------------------------------------------------------------------
279
280 def _get_or_create_request(self, kv_request_id: str) -> _ServerRequestState:
281 """Get or create the state entry for a kv_request_id."""
282 st = self._requests.get(kv_request_id)
283 if st is None:
284 st = _ServerRequestState()
285 self._requests[kv_request_id] = st
286 return st
287
288 def _maybe_prune(self, kv_request_id: str) -> None:
289 """Drop the entry once it holds no live state."""
290 st = self._requests.get(kv_request_id)
291 if (
292 st is not None
293 and not st.outbound
294 and not st.inflight_tids
295 and not st.lookups
296 and not st.pending_lookups
297 and not any(kv == kv_request_id for kv, _ in self._pending_aborts)
298 ):
299 del self._requests[kv_request_id]
300
301 # ------------------------------------------------------------------
302 # Public API
303 # ------------------------------------------------------------------
304
305 def add_stored_blocks(
306 self,
307 kv_request_id: str,
308 keys: Sequence[OffloadKey],
309 block_ids: Sequence[int],
310 job_id: JobId,
311 round_seq: int = 0,
312 *,
313 from_lookup: bool = False,
314 ) -> None:
315 """New blocks stored locally — match within their fetch round.
316
317 Lookup pins carry the round they were probed under; PD
318 submit_store batches share PD's single round 0.
319 """
320 self._store_jobs[job_id] = time.monotonic()
321 st = self._get_or_create_request(kv_request_id)
322 rnd = st.outbound.get(round_seq)
323 if rnd is None:
324 rnd = st.outbound[round_seq] = _OutboundRequestState()
325 if from_lookup:
326 rnd.lookup_supplied = True
327 result = rnd.add_stored_blocks(keys, block_ids, job_id)
328 if result.local_idxs and rnd.demand_received:
329 self._submit_transfer(kv_request_id, result, rnd, round_seq)
330
331 def on_fetch(
332 self,
333 kv_request_id: str,
334 keys: Sequence[OffloadKey],
335 block_indexes: Sequence[int],
336 round_seq: int = 0,
337 ) -> None:
338 """Handle a FetchMsg from the peer.
339
340 A non-empty fetch binds and closes its round, leaving lookup
341 state alone (the next round's LookupMsg may already be in
342 flight). The terminal empty fetch closes the id: parked lookups
343 are popped and every remaining round drained. A second fetch for
344 a round already holding demand raises ValueError
345 (protocol-error disconnect).
346 """
347 logger.debug(
348 "P2PSession %s: fetch RECEIVED kv_request_id=%s round=%s blocks=%d",
349 self._peer_id,
350 kv_request_id,
351 round_seq,
352 len(keys),
353 )
354 st = self._requests.get(kv_request_id)
355 existing = st.outbound.get(round_seq) if st is not None else None
356 if existing is not None and existing.demand_received:
357 raise ValueError(
358 f"duplicate fetch for kv_request_id={kv_request_id} round={round_seq}"
359 )
360 st = self._get_or_create_request(kv_request_id)
361 req = st.outbound.get(round_seq)
362 if req is None:
363 req = st.outbound[round_seq] = _OutboundRequestState()
364 result = req.add_fetch_demand(keys, block_indexes)
365 if not keys:
366 # Terminal empty fetch: close the lookup phase and drain
367 # every round with no TransferDoneMsg (nothing waits on it).
368 self._finish_inbound_lookups(kv_request_id)
369 for key in list(st.outbound):
370 self._finalize_outbound(kv_request_id, key, send_done=False)
371 return
372 if req.lookup_supplied and req.demanded:
373 # A symmetric round's supply always precedes its fetch, so
374 # unmatched demand is unservable — fail now, not at the load
375 # timeout. PD rounds keep parking demand for stores that
376 # arrive later.
377 logger.warning(
378 "P2PSession %s: fetch kv_request_id=%s round=%s demanded %d "
379 "blocks but %d have no pinned supply; failing fetch "
380 "immediately",
381 self._peer_id,
382 kv_request_id,
383 round_seq,
384 len(keys),
385 len(req.demanded),
386 )
387 self._finalize_outbound(kv_request_id, round_seq, success=False)
388 return
389 if result.local_idxs:
390 self._submit_transfer(kv_request_id, result, req, round_seq)
391 # Prefiller-first mode: finish_request may have run before
392 # fetch arrived. If so, finalize once we know what was
393 # demanded — fully satisfied → success, else early-fail.
394 if req.finishing and req.inflight == 0:
395 self._finalize_outbound(kv_request_id, round_seq)
396
397 def on_abort_fetch(self, kv_request_id: str, round_seq: int = 0) -> None:
398 """Handle an AbortFetchMsg from the peer, cancelling one round."""
399 # Abort for an unknown id may be a benign race/duplicate or a
400 # real protocol violation; we don't track completed ids, so warn.
401 st = self._requests.get(kv_request_id)
402 if (st is None or not st.outbound) and not self._has_inflight_for(
403 kv_request_id
404 ):
405 logger.warning(
406 "P2PSession %s: abort_fetch for unknown kv_request_id=%s "
407 "(no outbound or inflight state); benign race or stale",
408 self._peer_id,
409 kv_request_id,
410 )
411 # Idempotent: receiving AbortFetchMsg again before we've sent the
412 # ack just triggers another drain attempt without resetting the
413 # deadline.
414 self._get_or_create_request(kv_request_id)
415 self._pending_aborts.setdefault((kv_request_id, round_seq), time.monotonic())
416 self._drain_abort(kv_request_id, round_seq)
417
418 def on_lookup(
419 self,
420 kv_request_id: str,
421 keys: Sequence[OffloadKey],
422 round_seq: int = 0,
423 ) -> None:
424 """Enqueue a LookupMsg from a symmetric-P2P consumer.
425
426 Dispatch runs during ``session.poll()`` where the
427 :class:`ParentManager` handle is not available, so this only
428 records the raw request. It is resolved — querying the tiering
429 manager and emitting the aggregated ``LookupRespMsg`` — by the
430 next :meth:`serve_external_requests`, the sole window in which
431 parent calls are valid.
432 """
433 logger.debug(
434 "P2P LOOKUP server %s: RECV LookupMsg kv_request_id=%s round=%s keys=%d",
435 self._peer_id,
436 kv_request_id,
437 round_seq,
438 len(keys),
439 )
440 self._get_or_create_request(kv_request_id).pending_lookups.append(
441 _PendingLookup(
442 keys=list(keys),
443 enqueued_at=time.monotonic(),
444 round_seq=round_seq,
445 )
446 )
447 self._serve_pending.add(kv_request_id)
448
449 def serve_external_requests(self, parent: ParentManager) -> None:
450 """Resolve inbound peer lookups against the tiering manager.
451
452 Called once per scheduler step with a ``parent`` handle valid
453 only for this call. Drains newly-enqueued LookupMsgs, re-polls
454 any parked HIT_PENDING / RETRY keys, and releases the
455 bookkeeping for lookups closed since the last serve.
456 """
457 for kv_request_id in list(self._serve_pending):
458 st = self._requests.get(kv_request_id)
459 if st is None:
460 self._serve_pending.discard(kv_request_id)
461 continue
462 if st.pending_lookups:
463 pending = st.pending_lookups
464 st.pending_lookups = []
465 for pl in pending:
466 self._process_inbound_lookup(
467 kv_request_id, pl.keys, pl.enqueued_at, pl.round_seq, parent
468 )
469 self._resolve_pending_lookups(kv_request_id, parent)
470 st = self._requests.get(kv_request_id)
471 if st is None or (not st.pending_lookups and not st.lookups):
472 self._serve_pending.discard(kv_request_id)
473 self._maybe_prune(kv_request_id)
474
475 if self._finished_lookup_ctxs:
476 for ctx in self._finished_lookup_ctxs:
477 parent.on_request_finished(ctx)
478 self._finished_lookup_ctxs = []
479
480 def _poll_lookup_keys(
481 self,
482 lookup: _ActiveLookup,
483 keys: Iterable[OffloadKey],
484 parent: ParentManager,
485 ) -> list[OffloadKey]:
486 """Poll ``keys`` against the tiering manager and pin any HITs.
487
488 For each key not already definitively resolved, query
489 ``parent.lookup`` and record the outcome on ``lookup``: HIT / MISS
490 land in ``resolved`` and clear ``pending``; HIT_PENDING / RETRY park
491 in ``pending`` for a later serve. Newly-HIT keys are pinned in one
492 batch via :meth:`_pin_and_register_hits` and also returned (for
493 caller logging).
494
495 Shared by the first-sighting pass (:meth:`_process_inbound_lookup`)
496 and the re-poll pass (:meth:`_resolve_pending_lookups`); callers pass
497 a de-duplicated ``keys`` collection.
498 """
499 new_hits: list[OffloadKey] = []
500 for h in keys:
501 if h in lookup.resolved:
502 continue
503 result = parent.lookup(h, lookup.ctx)
504 if result is LookupResult.HIT:
505 new_hits.append(h)
506 lookup.resolved[h] = True
507 lookup.pending.discard(h)
508 elif result is LookupResult.MISS:
509 lookup.resolved[h] = False
510 lookup.pending.discard(h)
511 else:
512 lookup.pending.add(h)
513 if new_hits:
514 self._pin_and_register_hits(lookup, new_hits, parent)
515 return new_hits
516
517 def _process_inbound_lookup(
518 self,
519 kv_request_id: str,
520 keys: list[OffloadKey],
521 enqueued_at: float,
522 round_seq: int,
523 parent: ParentManager,
524 ) -> None:
525 """Resolve one enqueued LookupMsg against ``parent``.
526
527 For each key, query the tiering manager via ``parent.lookup``
528 and pin any HITs immediately via ``parent.create_store_job``
529 (plumbed into the existing ``add_stored_blocks`` matching path so
530 the eventual FetchMsg finds them). HIT_PENDING / RETRY keys park
531 in :class:`_ActiveLookup` for re-polling by
532 :meth:`_resolve_pending_lookups`.
533
534 The outbound ``LookupRespMsg`` is deferred until every key has
535 settled to HIT or MISS (or the batch-level ``deadline`` fires,
536 forcing any stragglers to MISS). One LookupRespMsg goes out per
537 LookupMsg — carrying every key in wire order — after which
538 ``parent.on_request_finished`` fires and the entry is dropped.
539 """
540 self._lookup_id_counter += 1
541 lookup_id = self._lookup_id_counter
542 ctx = ReqContext(req_id=f"p2p:{self._peer_id}:{kv_request_id}:lu{lookup_id}")
543 lookup = _ActiveLookup(
544 lookup_id=lookup_id,
545 kv_request_id=kv_request_id,
546 ctx=ctx,
547 round_seq=round_seq,
548 keys=list(keys),
549 deadline=enqueued_at + _LOOKUP_PENDING_TIMEOUT_S,
550 )
551
552 # Open per-request bookkeeping for this synthetic ctx before the
553 # first lookup; released by ``on_request_finished`` once every
554 # key has settled.
555 parent.on_new_request(ctx)
556
557 # dict.fromkeys de-duplicates keys within the LookupMsg while
558 # preserving wire order — each unique key is polled once.
559 hit_keys = self._poll_lookup_keys(lookup, dict.fromkeys(lookup.keys), parent)
560
561 logger.debug(
562 "P2P LOOKUP server %s: RESOLVED kv_request_id=%s hits=%d misses=%d "
563 "pending=%d",
564 self._peer_id,
565 kv_request_id,
566 len(hit_keys),
567 sum(1 for v in lookup.resolved.values() if not v),
568 len(lookup.pending),
569 )
570
571 if lookup.pending:
572 self._get_or_create_request(kv_request_id).lookups[lookup_id] = lookup
573 else:
574 # Every key resolved on first sight — emit the aggregated
575 # response now and close the synthetic request.
576 self._finalize_lookup(lookup, parent)
577
578 def _pin_and_register_hits(
579 self,
580 lookup: _ActiveLookup,
581 keys: list[OffloadKey],
582 parent: ParentManager,
583 ) -> None:
584 """Pin primary slots for HIT keys and park them as the lookup's
585 round supply via ``add_stored_blocks``.
586
587 Caller has already confirmed every key is HIT (single-threaded
588 scheduler ⇒ no eviction race), so the JobMetadata returned by
589 ``parent.create_store_job`` carries parallel ``keys``/``block_ids``
590 of length ``len(keys)``.
591 """
592 meta = parent.create_store_job(keys, lookup.ctx)
593 self.add_stored_blocks(
594 lookup.kv_request_id,
595 list(meta.keys),
596 meta.chunk_ids.tolist(),
597 meta.job_id,
598 round_seq=lookup.round_seq,
599 from_lookup=True,
600 )
601
602 def _resolve_pending_lookups(
603 self, kv_request_id: str, parent: ParentManager
604 ) -> None:
605 """Re-poll a request's deferred LookupMsg keys; finalize when ready.
606
607 Walks every parked :class:`_ActiveLookup` for ``kv_request_id`` and
608 re-calls ``parent.lookup`` per still-pending key, moving HIT/MISS
609 results into ``resolved``. If ``deadline`` has passed, remaining
610 ``pending`` keys are force-resolved to MISS so the consumer
611 can fall back instead of waiting on a stuck producer. Newly-HIT
612 keys are pinned via ``parent.create_store_job`` in one call per
613 affected lookup. Lookups whose ``pending`` empties out get their
614 aggregated LookupRespMsg sent by :meth:`_finalize_lookup`.
615 """
616 st = self._requests.get(kv_request_id)
617 if st is None or not st.lookups:
618 return
619 now = time.monotonic()
620 finished_lookups: list[int] = []
621 for lookup_id, lookup in st.lookups.items():
622 self._poll_lookup_keys(lookup, list(lookup.pending), parent)
623
624 if lookup.pending and now >= lookup.deadline:
625 for h in lookup.pending:
626 lookup.resolved[h] = False
627 lookup.pending.clear()
628
629 if not lookup.pending:
630 finished_lookups.append(lookup_id)
631
632 for lookup_id in finished_lookups:
633 lookup = st.lookups.pop(lookup_id)
634 self._finalize_lookup(lookup, parent)
635
636 def _finalize_lookup(self, lookup: _ActiveLookup, parent: ParentManager) -> None:
637 """Emit the aggregated LookupRespMsg and close the synthetic request.
638
639 Called once per lookup when ``pending`` is empty — either every
640 key resolved to HIT or MISS, or the deadline forced remaining
641 stragglers to MISS. Preserves the wire order of the inbound
642 LookupMsg so the client can zip keys and hits positionally.
643 """
644 if lookup.keys:
645 hits = [lookup.resolved[h] for h in lookup.keys]
646 n_hit = sum(1 for v in hits if v)
647 logger.debug(
648 "P2P LOOKUP server %s: SEND LookupRespMsg kv_request_id=%s "
649 "keys=%d hits=%d misses=%d",
650 self._peer_id,
651 lookup.kv_request_id,
652 len(lookup.keys),
653 n_hit,
654 len(hits) - n_hit,
655 )
656 self._send(
657 {
658 TYPE_KEY: LookupRespMsg.TYPE,
659 LookupRespMsg.KV_REQUEST_ID: lookup.kv_request_id,
660 LookupRespMsg.KEYS: list(lookup.keys),
661 LookupRespMsg.HITS: hits,
662 }
663 )
664 parent.on_request_finished(lookup.ctx)
665
666 def _finish_inbound_lookups(self, kv_request_id: str) -> None:
667 """Close the server-side lookup phase for ``kv_request_id``.
668
669 Pops every parked ``_ActiveLookup`` for this id (so
670 ``_resolve_pending_lookups`` cannot promote a HIT_PENDING /
671 RETRY key into a fresh ``parent.create_store_job`` after this
672 point) and queues each ``lookup.ctx`` for
673 ``parent.on_request_finished`` (fired by the next
674 ``serve_external_requests``, since no parent handle is available
675 during dispatch) so the TieringManager can release per-lookup
676 bookkeeping. Any still-unprocessed raw LookupMsg for this id is
677 dropped — it never got ``on_new_request``, so nothing is owed.
678 The aggregated LookupRespMsg is skipped — the client already
679 knows the request is over (it just sent a terminal FetchMsg, or
680 is finishing locally).
681
682 Called on the two events that mean "no more lookup traffic for
683 ``kv_request_id`` is expected on this session": the terminal
684 empty FetchMsg from the peer and a local ``finish``. Whichever
685 fires second is a no-op.
686 """
687 st = self._requests.get(kv_request_id)
688 if st is None:
689 return
690 st.pending_lookups.clear()
691 for lookup in st.lookups.values():
692 self._finished_lookup_ctxs.append(lookup.ctx)
693 st.lookups.clear()
694 self._serve_pending.discard(kv_request_id)
695 self._maybe_prune(kv_request_id)
696
697 def finish(self, kv_request_id: str) -> None:
698 """Mark an outbound request finishing.
699
700 No more submit_store calls will arrive for this id. Any blocks
701 the peer demanded but we never stored will never come; tell the
702 peer to stop waiting (TransferDoneMsg success=False) instead of
703 letting it hit _LOAD_TIMEOUT_S.
704
705 Also drops any in-flight lookups for this kv_request_id and
706 queues their ctxs for ``parent.on_request_finished`` so the
707 TieringManager can release per-request bookkeeping. (For
708 symmetric P2P this path is rarely hit since the producer has no
709 local request lifecycle for the consumer's id; this cleanup is
710 mostly active on the PD side.)
711
712 If the decoder hasn't sent fetch yet (no demand received),
713 defer — on_fetch will finalize once demand arrives.
714
715 If inflight transfers exist for this id, defer — the last
716 completing transfer in collect_results will fire the message.
717 """
718 self._finish_inbound_lookups(kv_request_id)
719
720 st = self._requests.get(kv_request_id)
721 if st is None:
722 return
723 for key, req in list(st.outbound.items()):
724 req.finishing = True
725 if not req.demand_received or req.inflight:
726 # No demand yet (prefiller-first): on_fetch finalizes via
727 # `finishing`. Inflight: the last completion finalizes.
728 continue
729 self._finalize_outbound(kv_request_id, key)
730
731 def collect_results(self) -> list[StoreResult]:
732 """Drain timeouts, deferred results, and transport completions.
733
734 Inbound LookupMsg resolution (including re-polling HIT_PENDING /
735 RETRY keys) is NOT done here — it runs in
736 ``serve_external_requests`` where the ParentManager is available.
737 """
738 results: list[StoreResult] = self._timeout_pending_store_jobs()
739
740 if self._pending_store_results:
741 results.extend(self._pending_store_results)
742 self._pending_store_results.clear()
743
744 # Scope the poll to this peer: the transport is shared across all peer
745 # sessions of the engine, and poll() drains completed handles. An
746 # unscoped poll here would consume sibling sessions' completions and
747 # report them as "unknown transfer_id", starving those sessions.
748 poll_result = self._transport.poll(self._peer_id)
749
750 for tid in poll_result.done:
751 xfer = self._inflight_pop(tid)
752 if xfer is None:
753 # Bug signal: transport reported a transfer we have no
754 # bookkeeping for. Likely a double-completion in the
755 # transport or a stale removal in the session. The
756 # attached job(s) still live in _store_jobs and will be
757 # surfaced as failures by _timeout_pending_store_jobs
758 # after _STORE_TIMEOUT_S, but log loudly so the
759 # underlying bug is findable.
760 logger.error(
761 "P2PSession %s: transport reported done for unknown "
762 "transfer_id=%d; attached job(s) will fail via "
763 "store-timeout instead of completing now",
764 self._peer_id,
765 tid,
766 )
767 continue
768 results.extend(self._settle_xfer_jobs(xfer, success=True))
769 rnd = xfer.round
770 st = self._requests.get(xfer.kv_request_id)
771 if st is not None and st.outbound.get(xfer.round_key) is rnd:
772 rnd.remaining -= xfer.block_count
773 assert rnd.remaining >= 0, (
774 f"remaining went negative for kv_request_id={xfer.kv_request_id}"
775 )
776 if rnd.remaining == 0:
777 self._finalize_outbound(
778 xfer.kv_request_id, xfer.round_key, success=True
779 )
780 elif rnd.finishing and rnd.inflight == 0:
781 self._finalize_outbound(
782 xfer.kv_request_id, xfer.round_key, success=False
783 )
784 self._maybe_prune(xfer.kv_request_id)
785
786 failed_rounds: list[tuple[str, _OutboundRequestState]] | None = None
787 for tid in poll_result.failed:
788 xfer = self._inflight_pop(tid)
789 if xfer is None:
790 # See the matching error log in the done branch above.
791 logger.error(
792 "P2PSession %s: transport reported failed for unknown "
793 "transfer_id=%d; attached job(s) will fail via "
794 "store-timeout instead of completing now",
795 self._peer_id,
796 tid,
797 )
798 continue
799 results.extend(self._settle_xfer_jobs(xfer, success=False))
800 rnd = xfer.round
801 st = self._requests.get(xfer.kv_request_id)
802 if st is not None and st.outbound.get(xfer.round_key) is rnd:
803 del st.outbound[xfer.round_key]
804 if failed_rounds is None:
805 failed_rounds = []
806 failed_rounds.append((xfer.kv_request_id, rnd))
807 self._send(
808 {
809 TYPE_KEY: TransferDoneMsg.TYPE,
810 TransferDoneMsg.KV_REQUEST_ID: xfer.kv_request_id,
811 TransferDoneMsg.SUCCESS: False,
812 TransferDoneMsg.ROUND_SEQ: xfer.round_key,
813 }
814 )
815 self._maybe_prune(xfer.kv_request_id)
816
817 # Cancel each failed round's other inflight and fail its
818 # remaining store jobs — nothing else will settle them.
819 if failed_rounds:
820 for kv_request_id, rnd in failed_rounds:
821 ids_to_cancel = [
822 tid for tid, x in self._inflight.items() if x.round is rnd
823 ]
824 for tid in ids_to_cancel:
825 self._inflight_pop(tid)
826 if ids_to_cancel:
827 self._transport.cancel(ids_to_cancel)
828 results.extend(self._fail_round_jobs(rnd))
829 self._maybe_prune(kv_request_id)
830
831 return results
832
833 def collect_idle_timeouts(self) -> list[StoreResult]:
834 """Run only the store-job timeout sweep.
835
836 Used by the coordinator's no-conn poll path: a pending session
837 cannot have inflight transfers (no peer registered yet), so we
838 skip the transport poll and the deferred-result drain.
839 """
840 return self._timeout_pending_store_jobs()
841
842 def drain_pending_aborts(self) -> None:
843 """Re-attempt every parked abort once per poll tick."""
844 for kv_request_id, round_seq in list(self._pending_aborts):
845 self._drain_abort(kv_request_id, round_seq)
846
847 def close(self) -> tuple[list[int], list[ReqContext]]:
848 """Tear down. Cancels inflight.
849
850 Returns ``(failed_store_job_ids, failed_serves)`` where
851 ``failed_serves`` are synthetic lookup ctxs still owing a
852 ``parent.on_request_finished``. The session is going away with no
853 parent handle in hand, so the manager flushes these in its next
854 ``serve_external_requests``.
855 """
856 failed_stores = list(self._store_jobs.keys())
857 self._store_jobs.clear()
858 if self._inflight:
859 self._transport.cancel(list(self._inflight.keys()))
860 self._inflight.clear()
861 self._pending_store_results.clear()
862 # Surface every synthetic ctx still owing on_request_finished so
863 # the manager can release the TieringManager's per-request
864 # bookkeeping: parked lookups plus any already queued from a
865 # FetchMsg / finish that closed them before this teardown.
866 failed_serves = [
867 lu.ctx for st in self._requests.values() for lu in st.lookups.values()
868 ]
869 failed_serves.extend(self._finished_lookup_ctxs)
870 self._requests.clear()
871 self._serve_pending.clear()
872 self._pending_aborts.clear()
873 self._finished_lookup_ctxs.clear()
874 return failed_stores, failed_serves
875
876 @property
877 def has_inflight_transfers(self) -> bool:
878 """True if any outbound store transfer is still in flight."""
879 return bool(self._inflight)
880
881 # ------------------------------------------------------------------
882 # Internal — inflight bookkeeping
883 # ------------------------------------------------------------------
884
885 def _has_inflight_for(self, kv_request_id: str) -> bool:
886 st = self._requests.get(kv_request_id)
887 return st is not None and bool(st.inflight_tids)
888
889 def _inflight_add(self, tid: int, xfer: _InflightXfer) -> None:
890 """Insert an inflight transfer and record it on the request."""
891 self._inflight[tid] = xfer
892 self._get_or_create_request(xfer.kv_request_id).inflight_tids.add(tid)
893
894 def _inflight_pop(self, tid: int) -> _InflightXfer | None:
895 """Pop an inflight transfer and drop it from the request's set.
896
897 Callers are responsible for the ``_maybe_prune`` that may follow once
898 the request's other state has also cleared.
899 """
900 xfer = self._inflight.pop(tid, None)
901 if xfer is None:
902 return None
903 xfer.round.inflight -= 1
904 assert xfer.round.inflight >= 0
905 st = self._requests.get(xfer.kv_request_id)
906 if st is not None:
907 st.inflight_tids.discard(tid)
908 return xfer
909
910 def _settle_xfer_jobs(
911 self, xfer: _InflightXfer, success: bool
912 ) -> list[StoreResult]:
913 """Emit StoreResults for a completed transfer's store jobs.
914
915 Pops each attached job from ``_store_jobs`` and clears it from
916 its round's pending set. A job already popped (via timeout,
917 cancel, etc.) is skipped so we never double-emit a contradictory
918 result.
919 """
920 results: list[StoreResult] = []
921 for job_id in xfer.job_ids:
922 if self._store_jobs.pop(job_id, None) is None:
923 continue
924 results.append(StoreResult(job_id=job_id, success=success))
925 xfer.round.pending_job_ids.discard(job_id)
926 return results
927
928 # ------------------------------------------------------------------
929 # Internal — finalize / abort drain
930 # ------------------------------------------------------------------
931
932 def _fail_round_jobs(self, rnd: _OutboundRequestState) -> list[StoreResult]:
933 """Fail a terminated round's still-pending store jobs (idempotent)."""
934 results: list[StoreResult] = []
935 for job_id in rnd.pending_job_ids:
936 if self._store_jobs.pop(job_id, None) is None:
937 continue
938 results.append(StoreResult(job_id=job_id, success=False))
939 rnd.pending_job_ids.clear()
940 return results
941
942 def _finalize_outbound(
943 self,
944 kv_request_id: str,
945 round_key: int,
946 success: bool | None = None,
947 send_done: bool = True,
948 ) -> None:
949 """Pop one round and emit its terminal results.
950
951 If ``success`` is None, derive it from ``req.remaining == 0``.
952 ``send_done=False`` skips the TransferDoneMsg (terminal empty
953 fetch). Other rounds of the id are untouched.
954 """
955 st = self._requests[kv_request_id]
956 req = st.outbound.pop(round_key)
957 if success is None:
958 success = req.demand_received and req.remaining == 0
959 settled = self._fail_round_jobs(req) if not success else None
960 if settled is not None:
961 self._pending_store_results.extend(settled)
962 else:
963 for job_id in req.pending_job_ids:
964 if self._store_jobs.pop(job_id, None) is None:
965 continue
966 self._pending_store_results.append(
967 StoreResult(job_id=job_id, success=True)
968 )
969 req.pending_job_ids.clear()
970 logger.debug(
971 "P2PSession %s: finalize kv_request_id=%s round=%s success=%s "
972 "remaining=%d leftover_available=%d send_done=%s",
973 self._peer_id,
974 kv_request_id,
975 round_key,
976 success,
977 req.remaining,
978 len(req.available),
979 send_done,
980 )
981 if send_done and req.demand_received:
982 self._send(
983 {
984 TYPE_KEY: TransferDoneMsg.TYPE,
985 TransferDoneMsg.KV_REQUEST_ID: kv_request_id,
986 TransferDoneMsg.SUCCESS: success,
987 TransferDoneMsg.ROUND_SEQ: round_key,
988 }
989 )
990 self._maybe_prune(kv_request_id)
991
992 def _drain_abort(self, kv_request_id: str, round_seq: int) -> None:
993 """One drain attempt for a pending abort.
994
995 Detaches the aborted round, then asks the transport to cancel its
996 inflight transfers in ``mode="wait"``. Sends ``AbortAckMsg`` once
997 nothing remains inflight, or after ``_CANCEL_DRAIN_TIMEOUT_S``
998 falls back to ``mode="immediate"`` and acks anyway.
999 """
1000 st = self._requests[kv_request_id]
1001 rnd = st.outbound.pop(round_seq, None)
1002 if rnd is not None:
1003 # Its transfers are being cancelled; fail its jobs now
1004 # instead of leaking them to the store timeout.
1005 self._pending_store_results.extend(self._fail_round_jobs(rnd))
1006 ids = [
1007 tid
1008 for tid, x in self._inflight.items()
1009 if x.kv_request_id == kv_request_id and x.round_key == round_seq
1010 ]
1011 if not ids:
1012 self._finalize_abort(kv_request_id, round_seq)
1013 return
1014
1015 started_at = self._pending_aborts[(kv_request_id, round_seq)]
1016 if time.monotonic() - started_at >= _CANCEL_DRAIN_TIMEOUT_S:
1017 for tid in ids:
1018 self._inflight_pop(tid)
1019 self._transport.cancel(ids, mode="immediate")
1020 logger.warning(
1021 "P2PSession %s: cancel drain timed out for kv_request_id=%s,"
1022 " force-canceled %d transfers",
1023 self._peer_id,
1024 kv_request_id,
1025 len(ids),
1026 )
1027 self._finalize_abort(kv_request_id, round_seq)
1028 return
1029
1030 still = self._transport.cancel(ids, mode="wait")
1031 # Tids the transport successfully released are gone from its
1032 # _inflight; mirror that in session bookkeeping so they don't
1033 # block the drain forever waiting for a poll() event that will
1034 # never come.
1035 still_set = set(still)
1036 for tid in ids:
1037 if tid not in still_set:
1038 self._inflight_pop(tid)
1039 if not still:
1040 self._finalize_abort(kv_request_id, round_seq)
1041
1042 def _finalize_abort(self, kv_request_id: str, round_seq: int) -> None:
1043 self._pending_aborts.pop((kv_request_id, round_seq), None)
1044 self._send(
1045 {
1046 TYPE_KEY: AbortAckMsg.TYPE,
1047 AbortAckMsg.KV_REQUEST_ID: kv_request_id,
1048 AbortAckMsg.ROUND_SEQ: round_seq,
1049 }
1050 )
1051 self._maybe_prune(kv_request_id)
1052
1053 # ------------------------------------------------------------------
1054 # Internal — transfers and store-job timeouts
1055 # ------------------------------------------------------------------
1056
1057 def _submit_transfer(
1058 self,
1059 kv_request_id: str,
1060 result: _MatchResult,
1061 rnd: _OutboundRequestState,
1062 round_key: int,
1063 ) -> None:
1064 logger.debug(
1065 "P2PSession %s: NIXL write_blocks CALL kv_request_id=%s "
1066 "local_idxs=%d remote_idxs=%d",
1067 self._peer_id,
1068 kv_request_id,
1069 len(result.local_idxs),
1070 len(result.remote_idxs),
1071 )
1072 transfer_id = self._transport.write_blocks(
1073 self._peer_id, result.local_idxs, result.remote_idxs
1074 )
1075 if transfer_id is not None:
1076 logger.debug(
1077 "P2PSession %s: NIXL write_blocks SUBMITTED kv_request_id=%s "
1078 "transfer_id=%d blocks=%d",
1079 self._peer_id,
1080 kv_request_id,
1081 transfer_id,
1082 len(result.local_idxs),
1083 )
1084 rnd.inflight += 1
1085 self._inflight_add(
1086 transfer_id,
1087 _InflightXfer(
1088 kv_request_id=kv_request_id,
1089 block_count=len(result.local_idxs),
1090 job_ids=result.job_ids,
1091 round=rnd,
1092 round_key=round_key,
1093 ),
1094 )
1095 else:
1096 logger.warning(
1097 "P2PSession %s: write_blocks failed for %s (%d blocks)",
1098 self._peer_id,
1099 kv_request_id,
1100 len(result.local_idxs),
1101 )
1102 # The matched blocks were popped from rnd.demanded /
1103 # rnd.available, but no inflight will satisfy them, so
1104 # remaining will never reach 0 on its own. Mark the round
1105 # as finishing so the existing terminal paths clean up: if
1106 # other transfers of this round are in flight, the last one
1107 # to drain will fire _finalize_outbound(success=False) via
1108 # the elif branch in collect_results. If nothing else is in
1109 # flight, finalize now so the peer and the local store jobs
1110 # don't wait for finish_request or for _STORE_TIMEOUT_S /
1111 # _LOAD_TIMEOUT_S.
1112 rnd.finishing = True
1113 st = self._requests.get(kv_request_id)
1114 if (
1115 st is not None
1116 and st.outbound.get(round_key) is rnd
1117 and rnd.inflight == 0
1118 ):
1119 self._finalize_outbound(kv_request_id, round_key, success=False)
1120
1121 def _timeout_pending_store_jobs(self) -> list[StoreResult]:
1122 if not self._store_jobs:
1123 return []
1124 deadline = time.monotonic() - _STORE_TIMEOUT_S
1125 timed_out: list[int] | None = None
1126 for jid, submitted_at in self._store_jobs.items():
1127 if submitted_at <= deadline:
1128 if timed_out is None:
1129 timed_out = []
1130 timed_out.append(jid)
1131 if timed_out is None:
1132 return []
1133 results: list[StoreResult] = []
1134 for jid in timed_out:
1135 del self._store_jobs[jid]
1136 results.append(StoreResult(job_id=jid, success=False))
1137 logger.warning("P2PSession %s: store job %d timed out", self._peer_id, jid)
1138 return results