main
py 2,700 lines 102 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for the unified bidirectional P2PSession.
4
5 A P2PSession owns a single ControlConnection and dispatches every message
6 type — both client-role (FetchMsg / TransferDoneMsg / AbortAck) and
7 server-role (FetchMsg / TransferDoneMsg / AbortAck from the peer's
8 perspective). These tests exercise both flows independently and the
9 bidirectional case where one session simultaneously serves a fetch and
10 completes its own load.
11 """
12
13 from __future__ import annotations
14
15 import time
16 from collections.abc import Sequence
17
18 import numpy as np
19 import pytest
20
21 from vllm.v1.kv_offload.base import (
22 LookupResult,
23 OffloadKey,
24 ReqContext,
25 RequestOffloadingContext,
26 )
27 from vllm.v1.kv_offload.tiering.base import TransferJob
28 from vllm.v1.kv_offload.tiering.p2p.session import (
29 LoadResult,
30 P2PSession,
31 StoreResult,
32 )
33 from vllm.v1.kv_offload.tiering.p2p.session.client import (
34 _ABORT_ACK_TIMEOUT_S,
35 _LOAD_TIMEOUT_S,
36 )
37 from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
38 TYPE_KEY,
39 AbortAckMsg,
40 AbortFetchMsg,
41 ConnectAckMsg,
42 ConnectMsg,
43 DisconnectMsg,
44 FetchMsg,
45 LookupMsg,
46 LookupRespMsg,
47 TransferDoneMsg,
48 )
49 from vllm.v1.kv_offload.tiering.p2p.session.server import (
50 _CANCEL_DRAIN_TIMEOUT_S,
51 _InflightXfer,
52 _OutboundRequestState,
53 )
54 from vllm.v1.kv_offload.tiering.p2p.session.session import (
55 _MAX_CONSECUTIVE_DISPATCH_ERRORS,
56 )
57
58 # ---------------------------------------------------------------------------
59 # Helpers
60 # ---------------------------------------------------------------------------
61
62
63 # Shared PYTHONHASHSEED used by the session under test and the fake peer's
64 # ConnectMsg so the handshake succeeds unless a test overrides one side.
65 _DEFAULT_HASH_SEED = "0"
66
67
68 class FakeDataTransport:
69 """Minimal fake DataTransport for testing sessions."""
70
71 def __init__(
72 self,
73 base_addr: int = 0x1000,
74 num_blocks: int = 16,
75 block_len: int = 4096,
76 config_fingerprint: str = "",
77 ) -> None:
78 self._base_addr = base_addr
79 self._num_blocks = num_blocks
80 self._block_len = block_len
81 self._config_fingerprint = config_fingerprint
82 self._remote_peers: dict[str, dict] = {}
83 self._transfers: dict[int, tuple] = {}
84 self._next_id = 0
85 self._poll_done: list[int] = []
86 self._poll_failed: list[int] = []
87 self._cancel_still_inflight: set[int] = set()
88 self._cancel_calls: list[tuple[list[int], str]] = []
89
90 @property
91 def base_addr(self) -> int:
92 return self._base_addr
93
94 @property
95 def num_blocks(self) -> int:
96 return self._num_blocks
97
98 @property
99 def block_len(self) -> int:
100 return self._block_len
101
102 @property
103 def config_fingerprint(self) -> str:
104 return self._config_fingerprint
105
106 def get_agent_metadata(self) -> bytes:
107 return b"fake-metadata"
108
109 def add_remote_peer(
110 self, peer_id, agent_metadata, base_addr, num_blocks, block_len
111 ) -> None:
112 self._remote_peers[peer_id] = {
113 ConnectMsg.AGENT_METADATA: agent_metadata,
114 ConnectMsg.BASE_ADDR: base_addr,
115 "num_blocks": num_blocks,
116 "block_len": block_len,
117 }
118
119 def remove_remote_peer(self, peer_id: str) -> None:
120 self._remote_peers.pop(peer_id, None)
121
122 def write_blocks(self, peer_id, local_idxs, remote_idxs) -> int | None:
123 if peer_id not in self._remote_peers:
124 return None
125 tid = self._next_id
126 self._next_id += 1
127 self._transfers[tid] = (peer_id, local_idxs, remote_idxs)
128 return tid
129
130 def poll(self, peer_id=None):
131 from vllm.v1.kv_offload.tiering.p2p.data.base import PollResult
132
133 result = PollResult(done=list(self._poll_done), failed=list(self._poll_failed))
134 self._poll_done.clear()
135 self._poll_failed.clear()
136 return result
137
138 def cancel(self, transfer_ids, mode: str = "immediate") -> list[int]:
139 ids = list(transfer_ids)
140 self._cancel_calls.append((ids, mode))
141 if mode == "wait":
142 still: list[int] = []
143 for tid in ids:
144 if tid in self._cancel_still_inflight:
145 still.append(tid)
146 else:
147 self._transfers.pop(tid, None)
148 return still
149 for tid in ids:
150 self._transfers.pop(tid, None)
151 self._cancel_still_inflight.discard(tid)
152 return []
153
154 def close(self) -> None:
155 pass
156
157
158 class FakeConnection:
159 """Fake ControlConnection that captures sent messages."""
160
161 def __init__(self, peer_id: str = "peer:8000") -> None:
162 self.peer_id = peer_id
163 self._inbox: list[dict] = []
164 self._sent: list[dict] = []
165 self._closed = False
166 # When True, send() raises to simulate a broken/dead connection.
167 self.fail_send = False
168
169 @property
170 def alive(self) -> bool:
171 return not self._closed
172
173 def send(self, msg: dict) -> None:
174 if self.fail_send:
175 raise ConnectionError("simulated dead connection")
176 self._sent.append(msg)
177
178 def recv(self) -> list[dict]:
179 msgs = self._inbox
180 self._inbox = []
181 return msgs
182
183 def enqueue(self, msg: dict) -> None:
184 self._inbox.append(msg)
185
186 def mark_dead(self) -> None:
187 self._closed = True
188
189 def close(self) -> None:
190 self._closed = True
191
192
193 def _peer_connect_msg(
194 peer_id: str = "peer:8000",
195 block_len: int = 4096,
196 fingerprint: str | None = None,
197 hash_seed: str = _DEFAULT_HASH_SEED,
198 ) -> dict:
199 """Build a ConnectMsg as if the peer sent it."""
200 msg = {
201 TYPE_KEY: ConnectMsg.TYPE,
202 ConnectMsg.PEER_ID: peer_id,
203 ConnectMsg.AGENT_METADATA: b"peer-metadata",
204 ConnectMsg.BASE_ADDR: 0x2000,
205 ConnectMsg.NUM_BLOCKS: 16,
206 ConnectMsg.BLOCK_LEN: block_len,
207 ConnectMsg.HASH_SEED: hash_seed,
208 }
209 if fingerprint is not None:
210 msg[ConnectMsg.CONFIG_FINGERPRINT] = fingerprint
211 return msg
212
213
214 class FakeParent:
215 """Configurable :class:`ParentManager` for server-role tests.
216
217 ``stored`` is the dict of ready blocks (key → primary block_id).
218 ``pending`` and ``retry`` script the first lookup() result for those
219 keys; subsequent lookups behave normally (a key that promised
220 HIT_PENDING / RETRY can later be promoted to HIT by adding it to
221 ``stored`` and removing it from ``pending``/``retry``). ``calls``
222 captures every parent invocation in order for assertions.
223
224 Injected per-step via ``session.serve_external_requests(parent)`` —
225 not held by the session, matching how ``TieringOffloadingManager``
226 hands the tier a handle valid only for that call.
227 """
228
229 def __init__(
230 self,
231 stored: dict[OffloadKey, int] | None = None,
232 pending: set[OffloadKey] | None = None,
233 retry: set[OffloadKey] | None = None,
234 ) -> None:
235 self.stored: dict[OffloadKey, int] = dict(stored or {})
236 self.pending: set[OffloadKey] = set(pending or ())
237 self.retry: set[OffloadKey] = set(retry or ())
238 self._next_job_id: int = 1000
239 self.calls: list[tuple] = []
240
241 def on_new_request(self, ctx: ReqContext) -> RequestOffloadingContext:
242 self.calls.append(("on_new_request", ctx.req_id))
243 return RequestOffloadingContext()
244
245 def lookup(self, key: OffloadKey, ctx: ReqContext) -> LookupResult:
246 self.calls.append(("lookup", key, ctx.req_id))
247 if key in self.pending:
248 return LookupResult.HIT_PENDING
249 if key in self.retry:
250 return LookupResult.RETRY
251 if key in self.stored:
252 return LookupResult.HIT
253 return LookupResult.MISS
254
255 def create_store_job(
256 self,
257 keys: Sequence[OffloadKey],
258 ctx: ReqContext,
259 ) -> TransferJob:
260 keys_list = list(keys)
261 self.calls.append(("create_store_job", tuple(keys_list), ctx.req_id))
262 chunk_ids = np.array([self.stored[k] for k in keys_list], dtype=np.int32)
263 job_id = self._next_job_id
264 self._next_job_id += 1
265 return TransferJob(
266 job_id=job_id,
267 keys=keys_list,
268 chunk_ids=chunk_ids,
269 is_promotion=False,
270 req_context=ctx,
271 )
272
273 def on_request_finished(self, ctx: ReqContext) -> None:
274 self.calls.append(("on_request_finished", ctx.req_id))
275
276
277 def _make_session(
278 conn: FakeConnection | None = None,
279 transport: FakeDataTransport | None = None,
280 peer_id: str = "peer:8000",
281 local_id: str = "local:9000",
282 local_hash_seed: str = _DEFAULT_HASH_SEED,
283 ) -> tuple[P2PSession, FakeConnection, FakeDataTransport]:
284 if conn is None:
285 conn = FakeConnection(peer_id=peer_id)
286 if transport is None:
287 transport = FakeDataTransport()
288 session = P2PSession(
289 peer_id=peer_id,
290 local_id=local_id,
291 transport=transport,
292 local_block_len=transport.block_len,
293 local_hash_seed=local_hash_seed,
294 conn=conn,
295 )
296 return session, conn, transport
297
298
299 def _serve(session: P2PSession, parent: FakeParent) -> None:
300 """Resolve enqueued inbound lookups, as the manager does each step."""
301 session.serve_external_requests(parent)
302
303
304 def _activate(
305 session: P2PSession, conn: FakeConnection, peer_id: str = "peer:8000"
306 ) -> None:
307 """Drive the handshake: peer sends ConnectMsg + ConnectAckMsg."""
308 conn.enqueue(_peer_connect_msg(peer_id=peer_id))
309 conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: peer_id})
310 session.poll()
311
312
313 # --- Accessors for the server role's consolidated per-request state.
314 # ServerRole keeps one _ServerRequestState per kv_request_id; entries are
315 # garbage-collected once fully idle, so "no outbound"/"no abort" reads as
316 # either a missing entry or a None field. These helpers paper over that.
317
318
319 def _client_load(session: P2PSession, kv_request_id: str):
320 """The single in-flight load of a kv_request_id (loads are per-round)."""
321 loads = session._client._requests[kv_request_id].loads
322 assert len(loads) == 1
323 return next(iter(loads.values()))
324
325
326 def _srv_outbound(session: P2PSession, kv_request_id: str):
327 """Serve-side round for a kv_request_id, or None (idle / GC'd).
328
329 Rounds are keyed by wire round_seq; surfaces the demanded round when
330 a fetch has bound one, else any parked supply round.
331 """
332 st = session._server._requests.get(kv_request_id)
333 if st is None or not st.outbound:
334 return None
335 for rnd in st.outbound.values():
336 if rnd.demand_received:
337 return rnd
338 return next(iter(st.outbound.values()))
339
340
341 def _srv_lookups(session: P2PSession) -> list:
342 """Every parked inbound _ActiveLookup across all requests."""
343 return [
344 lu for st in session._server._requests.values() for lu in st.lookups.values()
345 ]
346
347
348 def _srv_abort_started(session: P2PSession, kv_request_id: str) -> float | None:
349 """Pending-abort start time for a kv_request_id, or None."""
350 for (kv, _), started in session._server._pending_aborts.items():
351 if kv == kv_request_id:
352 return started
353 return None
354
355
356 def _srv_inflight_count(session: P2PSession, kv_request_id: str) -> int:
357 """Inflight-transfer count tracked for a kv_request_id (0 if idle)."""
358 st = session._server._requests.get(kv_request_id)
359 return len(st.inflight_tids) if st is not None else 0
360
361
362 def _srv_total_inflight(session: P2PSession) -> int:
363 """Sum of per-request inflight counts across all requests."""
364 return sum(len(st.inflight_tids) for st in session._server._requests.values())
365
366
367 # ---------------------------------------------------------------------------
368 # Connect / handshake
369 # ---------------------------------------------------------------------------
370
371
372 class TestConnectHandshake:
373 def test_connect_msg_sent_on_creation(self):
374 """Session sends its own ConnectMsg on connection."""
375 session, conn, _ = _make_session()
376 assert len(conn._sent) == 1
377 msg = conn._sent[0]
378 assert msg[TYPE_KEY] == ConnectMsg.TYPE
379 assert msg[ConnectMsg.PEER_ID] == "local:9000"
380 assert msg[ConnectMsg.NUM_BLOCKS] == 16
381 assert msg[ConnectMsg.BLOCK_LEN] == 4096
382 assert ConnectMsg.AGENT_METADATA in msg
383
384 def test_peer_connect_triggers_add_remote_and_ack(self):
385 """Receiving ConnectMsg registers peer and replies with ConnectAck."""
386 session, conn, transport = _make_session()
387 conn.enqueue(_peer_connect_msg())
388 session.poll()
389 assert "peer:8000" in transport._remote_peers
390 ack = next(m for m in conn._sent if m[TYPE_KEY] == ConnectAckMsg.TYPE)
391 assert ack[ConnectAckMsg.PEER_ID] == "local:9000"
392
393 def test_connect_ack_makes_session_ready(self):
394 """Session.ready becomes True after ConnectAckMsg."""
395 session, conn, _ = _make_session()
396 assert not session.ready
397 conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: "peer:8000"})
398 session.poll()
399 assert session.ready
400
401 def test_messages_queued_before_ack_flush_on_ack(self):
402 """Outgoing messages sent before ConnectAck are flushed after."""
403 session, conn, _ = _make_session()
404 session.request_blocks(
405 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
406 )
407 # Before ack: only our ConnectMsg was sent.
408 assert len(conn._sent) == 1
409 assert conn._sent[0][TYPE_KEY] == ConnectMsg.TYPE
410 # Ack arrives.
411 conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: "peer:8000"})
412 session.poll()
413 # Queued fetch is now sent.
414 assert any(m[TYPE_KEY] == FetchMsg.TYPE for m in conn._sent)
415
416 def test_block_len_mismatch_marks_dead(self):
417 """Mismatched block_len rejects peer and marks connection dead."""
418 session, conn, transport = _make_session()
419 conn.enqueue(_peer_connect_msg(block_len=8192)) # mismatch
420 session.poll()
421 assert "peer:8000" not in transport._remote_peers
422 assert not session.alive
423
424 def test_config_fingerprint_mismatch_marks_dead(self):
425 """Mismatched config fingerprint rejects peer."""
426 transport = FakeDataTransport(config_fingerprint="abc123")
427 session, conn, _ = _make_session(transport=transport)
428 conn.enqueue(_peer_connect_msg(fingerprint="different"))
429 session.poll()
430 assert "peer:8000" not in transport._remote_peers
431 assert not session.alive
432
433 def test_config_fingerprint_match_succeeds(self):
434 """Matching fingerprints register the peer."""
435 transport = FakeDataTransport(config_fingerprint="same_fp")
436 session, conn, _ = _make_session(transport=transport)
437 conn.enqueue(_peer_connect_msg(fingerprint="same_fp"))
438 session.poll()
439 assert "peer:8000" in transport._remote_peers
440 assert session.alive
441
442 def test_missing_fingerprint_allowed(self):
443 """Missing fingerprint on either side is allowed."""
444 transport = FakeDataTransport(config_fingerprint="abc123")
445 session, conn, _ = _make_session(transport=transport)
446 conn.enqueue(_peer_connect_msg()) # no fingerprint
447 session.poll()
448 assert "peer:8000" in transport._remote_peers
449
450 def test_hash_seed_mismatch_marks_dead(self):
451 """Mismatched PYTHONHASHSEED rejects peer and marks connection dead."""
452 session, conn, transport = _make_session(local_hash_seed="0")
453 conn.enqueue(_peer_connect_msg(hash_seed="12345")) # mismatch
454 session.poll()
455 assert "peer:8000" not in transport._remote_peers
456 assert not session.alive
457 assert not any(m[TYPE_KEY] == ConnectAckMsg.TYPE for m in conn._sent)
458
459 def test_hash_seed_match_succeeds(self):
460 """Matching PYTHONHASHSEED registers the peer and acks."""
461 session, conn, transport = _make_session(local_hash_seed="12345")
462 conn.enqueue(_peer_connect_msg(hash_seed="12345"))
463 session.poll()
464 assert "peer:8000" in transport._remote_peers
465 assert session.alive
466 assert any(m[TYPE_KEY] == ConnectAckMsg.TYPE for m in conn._sent)
467
468 def test_hash_seed_advertised_in_connect_msg(self):
469 """Session advertises its own PYTHONHASHSEED in the ConnectMsg."""
470 _, conn, _ = _make_session(local_hash_seed="777")
471 assert conn._sent[0][ConnectMsg.HASH_SEED] == "777"
472
473
474 # ---------------------------------------------------------------------------
475 # Client-role flows
476 # ---------------------------------------------------------------------------
477
478
479 class TestClientFlows:
480 def test_request_blocks_sends_fetch(self):
481 session, conn, _ = _make_session()
482 _activate(session, conn)
483 session.request_blocks(
484 job_id=1, kv_request_id="req-1", keys=[b"k1", b"k2"], block_ids=[0, 1]
485 )
486 lookup = conn._sent[-1]
487 assert lookup[TYPE_KEY] == FetchMsg.TYPE
488 assert lookup[FetchMsg.KV_REQUEST_ID] == "req-1"
489 assert lookup[FetchMsg.KEYS] == [b"k1", b"k2"]
490 assert lookup[FetchMsg.BLOCK_INDEXES] == [0, 1]
491
492 def test_transfer_done_success(self):
493 session, conn, _ = _make_session()
494 _activate(session, conn)
495 session.request_blocks(
496 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
497 )
498 conn.enqueue(
499 {
500 TYPE_KEY: TransferDoneMsg.TYPE,
501 TransferDoneMsg.ROUND_SEQ: 0,
502 TransferDoneMsg.KV_REQUEST_ID: "req-1",
503 TransferDoneMsg.SUCCESS: True,
504 }
505 )
506 loads = session.poll().loads
507 assert loads == [LoadResult(job_id=1, kv_request_id="req-1", success=True)]
508
509 def test_transfer_done_failure(self):
510 session, conn, _ = _make_session()
511 _activate(session, conn)
512 session.request_blocks(
513 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
514 )
515 conn.enqueue(
516 {
517 TYPE_KEY: TransferDoneMsg.TYPE,
518 TransferDoneMsg.ROUND_SEQ: 0,
519 TransferDoneMsg.KV_REQUEST_ID: "req-1",
520 TransferDoneMsg.SUCCESS: False,
521 }
522 )
523 loads = session.poll().loads
524 assert loads == [LoadResult(job_id=1, kv_request_id="req-1", success=False)]
525
526 def test_finish_request_sends_abort(self):
527 session, conn, _ = _make_session()
528 _activate(session, conn)
529 session.request_blocks(
530 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
531 )
532 session.finish_request("req-1")
533 abort = conn._sent[-1]
534 assert abort[TYPE_KEY] == AbortFetchMsg.TYPE
535 assert abort[AbortFetchMsg.KV_REQUEST_ID] == "req-1"
536
537 def test_finish_preserves_active_load_until_terminal_result(self):
538 session, conn, _ = _make_session()
539 _activate(session, conn)
540 session.request_blocks(
541 job_id=7, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
542 )
543
544 session.finish_request("req-1")
545
546 load = _client_load(session, "req-1")
547 assert load.job_id == 7
548 assert load.aborted_at is not None
549 aborted_at = load.aborted_at
550 assert session._client.has_active_loads is True
551 session.finish_request("req-1")
552 assert _client_load(session, "req-1").aborted_at == aborted_at
553 aborts = [m for m in conn._sent if m[TYPE_KEY] == AbortFetchMsg.TYPE]
554 assert len(aborts) == 1
555 assert aborts[0][AbortFetchMsg.ROUND_SEQ] == 0
556
557 def test_finish_abort_ack_emits_one_failure(self):
558 session, conn, _ = _make_session()
559 _activate(session, conn)
560 session.request_blocks(
561 job_id=8, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
562 )
563 session.finish_request("req-1")
564
565 conn.enqueue(
566 {
567 TYPE_KEY: AbortAckMsg.TYPE,
568 AbortAckMsg.ROUND_SEQ: 0,
569 AbortAckMsg.KV_REQUEST_ID: "req-1",
570 }
571 )
572 loads = session.poll().loads
573 assert loads == [LoadResult(job_id=8, kv_request_id="req-1", success=False)]
574 assert session._client.has_active_loads is False
575 assert "req-1" not in session._client._requests
576 assert session.poll().loads == []
577
578 def test_finish_abort_timeout_emits_one_failure(self):
579 session, conn, _ = _make_session()
580 _activate(session, conn)
581 session.request_blocks(
582 job_id=9, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
583 )
584 session.finish_request("req-1")
585 _client_load(session, "req-1").aborted_at = (
586 time.monotonic() - _ABORT_ACK_TIMEOUT_S - 1.0
587 )
588
589 loads = session.poll().loads
590 assert loads == [LoadResult(job_id=9, kv_request_id="req-1", success=False)]
591 assert session._client.has_active_loads is False
592 assert "req-1" not in session._client._requests
593 assert session.poll().loads == []
594
595 def test_late_transfer_done_after_abort_ack_is_ignored(self):
596 session, conn, _ = _make_session()
597 _activate(session, conn)
598 session.request_blocks(
599 job_id=10, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
600 )
601 session.finish_request("req-1")
602
603 conn.enqueue(
604 {
605 TYPE_KEY: AbortAckMsg.TYPE,
606 AbortAckMsg.ROUND_SEQ: 0,
607 AbortAckMsg.KV_REQUEST_ID: "req-1",
608 }
609 )
610 assert session.poll().loads == [
611 LoadResult(job_id=10, kv_request_id="req-1", success=False)
612 ]
613
614 conn.enqueue(
615 {
616 TYPE_KEY: TransferDoneMsg.TYPE,
617 TransferDoneMsg.ROUND_SEQ: 0,
618 TransferDoneMsg.KV_REQUEST_ID: "req-1",
619 TransferDoneMsg.SUCCESS: True,
620 }
621 )
622 assert session.poll().loads == []
623 assert session._client.has_active_loads is False
624
625 def test_active_loads_work_list_tracks_in_flight(self):
626 """collect_results / has_active_loads use the _active_loads work-list,
627 armed when a fetch is issued and discarded exactly when its load
628 clears — a probe-only request never enters it, and completion empties
629 it while the entry may briefly linger for GC."""
630 session, conn, _ = _make_session()
631 _activate(session, conn)
632 client = session._client
633
634 # A probe-only request has no in-flight load: not in _active_loads.
635 session.register_lookup("req-probe", b"hp")
636 assert client._active_loads == set()
637 assert client.has_active_loads is False
638
639 # Issuing a fetch arms the work-list.
640 session.request_blocks(
641 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
642 )
643 assert client._active_loads == {"req-1"}
644 assert client.has_active_loads is True
645
646 # Completion clears the load and discards it from the work-list.
647 conn.enqueue(
648 {
649 TYPE_KEY: TransferDoneMsg.TYPE,
650 TransferDoneMsg.ROUND_SEQ: 0,
651 TransferDoneMsg.KV_REQUEST_ID: "req-1",
652 TransferDoneMsg.SUCCESS: True,
653 }
654 )
655 session.poll()
656 assert client._active_loads == set()
657 assert client.has_active_loads is False
658
659 def test_load_timeout_sends_abort(self):
660 session, conn, _ = _make_session()
661 _activate(session, conn)
662 session.request_blocks(
663 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
664 )
665 _client_load(session, "req-1").submitted_at = time.monotonic() - 60.0
666 session.poll()
667 abort = conn._sent[-1]
668 assert abort[TYPE_KEY] == AbortFetchMsg.TYPE
669
670 def test_load_abort_ack_timeout_surfaces_failure(self):
671 """After load timeout sends AbortFetch, if no AbortAck arrives within
672 _ABORT_ACK_TIMEOUT_S the request is surfaced as failed and removed
673 from _requests — the engine cannot wait forever on a peer that won't
674 ack.
675 """
676 session, conn, _ = _make_session()
677 _activate(session, conn)
678 session.request_blocks(
679 job_id=7, kv_request_id="req-7", keys=[b"k"], block_ids=[0]
680 )
681 # 1) Trip the load timeout to send AbortFetch and stamp aborted_at.
682 _client_load(session, "req-7").submitted_at = (
683 time.monotonic() - _LOAD_TIMEOUT_S - 1.0
684 )
685 loads = session.poll().loads
686 assert loads == []
687 assert any(
688 m.get(TYPE_KEY) == AbortFetchMsg.TYPE
689 and m[AbortFetchMsg.KV_REQUEST_ID] == "req-7"
690 for m in conn._sent
691 )
692 assert _client_load(session, "req-7").aborted_at is not None
693
694 # 2) Now backdate aborted_at past the abort-ack timeout. No ack ever
695 # arrived from the peer.
696 _client_load(session, "req-7").aborted_at = (
697 time.monotonic() - _ABORT_ACK_TIMEOUT_S - 1.0
698 )
699 loads = session.poll().loads
700 assert loads == [LoadResult(job_id=7, kv_request_id="req-7", success=False)]
701 assert "req-7" not in session._client._requests
702
703 def test_load_abort_ack_clears_request(self):
704 """After load timeout sends AbortFetch, an arriving AbortAckMsg from
705 the peer surfaces the failure cleanly and removes the request from
706 _requests — covers the on_abort_ack arrival path."""
707 session, conn, _ = _make_session()
708 _activate(session, conn)
709 session.request_blocks(
710 job_id=8, kv_request_id="req-8", keys=[b"k"], block_ids=[0]
711 )
712 _client_load(session, "req-8").submitted_at = (
713 time.monotonic() - _LOAD_TIMEOUT_S - 1.0
714 )
715 # First poll: AbortFetch goes out.
716 session.poll()
717 assert _client_load(session, "req-8").aborted_at is not None
718
719 # Peer acks the abort.
720 conn.enqueue(
721 {
722 TYPE_KEY: AbortAckMsg.TYPE,
723 AbortAckMsg.ROUND_SEQ: 0,
724 AbortAckMsg.KV_REQUEST_ID: "req-8",
725 }
726 )
727 loads = session.poll().loads
728 assert loads == [LoadResult(job_id=8, kv_request_id="req-8", success=False)]
729 assert "req-8" not in session._client._requests
730
731
732 # ---------------------------------------------------------------------------
733 # Symmetric-P2P lookup flow (do_p2p_fetch)
734 # ---------------------------------------------------------------------------
735
736
737 class TestLookupFlow:
738 """Consumer-side state machine for do_p2p_fetch lookups."""
739
740 def test_aggregate_flush_resolve_round_trip(self):
741 """register_lookup → flush sends one LookupMsg → response
742 resolves entries → register_lookup returns the cached bool on
743 every call, and repeat probes never re-issue a LookupMsg."""
744 session, conn, _ = _make_session()
745 _activate(session, conn)
746
747 # Aggregate two keys for the same kv_request_id; both return None.
748 assert session.register_lookup("req-1", b"hA") is None
749 assert session.register_lookup("req-1", b"hB") is None
750
751 # Flush sends one LookupMsg with both keys.
752 sent_before = len(conn._sent)
753 session.flush_pending_lookups()
754 new = conn._sent[sent_before:]
755 assert len(new) == 1
756 msg = new[0]
757 assert msg[TYPE_KEY] == LookupMsg.TYPE
758 assert msg[LookupMsg.KV_REQUEST_ID] == "req-1"
759 assert sorted(msg[LookupMsg.KEYS]) == [b"hA", b"hB"]
760
761 # Idempotent re-flush sends nothing — the entries are now in-flight.
762 sent_before = len(conn._sent)
763 session.flush_pending_lookups()
764 assert conn._sent[sent_before:] == []
765
766 # While in-flight, register_lookup keeps returning None.
767 assert session.register_lookup("req-1", b"hA") is None
768
769 # Peer answers: hA hit, hB miss.
770 conn.enqueue(
771 {
772 TYPE_KEY: LookupRespMsg.TYPE,
773 LookupRespMsg.KV_REQUEST_ID: "req-1",
774 LookupRespMsg.KEYS: [b"hA", b"hB"],
775 LookupRespMsg.HITS: [True, False],
776 }
777 )
778 session.poll()
779
780 # register_lookup returns the resolved bool.
781 assert session.register_lookup("req-1", b"hA") is True
782 assert session.register_lookup("req-1", b"hB") is False
783 # The entry is cached, not popped: repeat probes keep returning the
784 # same result and never re-queue the key, so a flush sends nothing.
785 assert session.register_lookup("req-1", b"hA") is True
786 assert session.register_lookup("req-1", b"hB") is False
787 sent_before = len(conn._sent)
788 session.flush_pending_lookups()
789 assert [
790 m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE
791 ] == []
792
793 def test_request_blocks_clears_probe_cache(self):
794 """A resolved HIT probe is popped when its fetch is issued, so a
795 re-scheduled request re-probes instead of trusting the stale True
796 (the served block is unpinned and may have been evicted)."""
797 session, conn, _ = _make_session()
798 _activate(session, conn)
799
800 # Probe hA, flush, and let the peer resolve it to a HIT.
801 assert session.register_lookup("req-1", b"hA") is None
802 session.flush_pending_lookups()
803 conn.enqueue(
804 {
805 TYPE_KEY: LookupRespMsg.TYPE,
806 LookupRespMsg.KV_REQUEST_ID: "req-1",
807 LookupRespMsg.KEYS: [b"hA"],
808 LookupRespMsg.HITS: [True],
809 }
810 )
811 session.poll()
812 assert session.register_lookup("req-1", b"hA") is True
813
814 # Fetch consumes the probe.
815 session.request_blocks(
816 job_id=1, kv_request_id="req-1", keys=[b"hA"], block_ids=[0]
817 )
818 assert b"hA" not in session._client._requests["req-1"].probes
819
820 # Re-scheduled probe of the same key is treated as brand-new: it
821 # returns None and re-queues, so a flush emits a fresh LookupMsg.
822 assert session.register_lookup("req-1", b"hA") is None
823 sent_before = len(conn._sent)
824 session.flush_pending_lookups()
825 fresh = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
826 assert len(fresh) == 1
827 assert fresh[0][LookupMsg.KEYS] == [b"hA"]
828
829 def test_flush_uses_work_list_not_full_scan(self):
830 """Flush drains a work-list rather than scanning every live request.
831
832 A request with no newly-registered keys is not revisited: after a
833 flush the work-list is empty, an idle re-flush sends nothing, and a
834 subsequent register re-arms exactly the one affected id — even while
835 an unrelated request stays live in ``_requests``.
836 """
837 session, conn, _ = _make_session()
838 _activate(session, conn)
839 client = session._client
840
841 # Two requests register keys; both are queued for flush.
842 session.register_lookup("req-A", b"hA")
843 session.register_lookup("req-B", b"hB")
844 assert client._flush_pending == {"req-A", "req-B"}
845
846 # Flush drains the work-list even though both requests stay live.
847 session.flush_pending_lookups()
848 assert client._flush_pending == set()
849 assert set(client._requests) == {"req-A", "req-B"}
850
851 # An idle re-flush visits nothing and sends no LookupMsg.
852 sent_before = len(conn._sent)
853 session.flush_pending_lookups()
854 assert [
855 m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE
856 ] == []
857
858 # A new register re-arms only that id.
859 session.register_lookup("req-A", b"hA2")
860 assert client._flush_pending == {"req-A"}
861
862 def test_separate_lookup_msg_per_kv_request_id(self):
863 """Hashes for different kv_request_ids flush as separate LookupMsgs."""
864 session, conn, _ = _make_session()
865 _activate(session, conn)
866
867 session.register_lookup("req-A", b"h1")
868 session.register_lookup("req-B", b"h2")
869 session.register_lookup("req-A", b"h3")
870
871 sent_before = len(conn._sent)
872 session.flush_pending_lookups()
873 sent = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
874 assert len(sent) == 2
875 by_req = {m[LookupMsg.KV_REQUEST_ID]: m[LookupMsg.KEYS] for m in sent}
876 assert sorted(by_req["req-A"]) == [b"h1", b"h3"]
877 assert by_req["req-B"] == [b"h2"]
878
879 def test_multiple_lookup_msgs_across_steps(self):
880 """A request's block set may be discovered across scheduler steps:
881 each step that registers new keys flushes its own LookupMsg for
882 the same kv_request_id, carrying only the newly-probed keys."""
883 session, conn, _ = _make_session()
884 _activate(session, conn)
885
886 # Step 1: probe hA, hB.
887 session.register_lookup("req-1", b"hA")
888 session.register_lookup("req-1", b"hB")
889 sent_before = len(conn._sent)
890 session.flush_pending_lookups()
891 first = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
892 assert len(first) == 1
893 assert first[0][LookupMsg.KV_REQUEST_ID] == "req-1"
894 assert sorted(first[0][LookupMsg.KEYS]) == [b"hA", b"hB"]
895
896 # Step 2: a new key is discovered for the same request. The
897 # in-flight keys from step 1 are not re-sent; a second LookupMsg
898 # goes out carrying only the newly-probed key.
899 assert session.register_lookup("req-1", b"hA") is None # in-flight no-op
900 session.register_lookup("req-1", b"hC")
901 sent_before = len(conn._sent)
902 session.flush_pending_lookups()
903 second = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
904 assert len(second) == 1
905 assert second[0][LookupMsg.KV_REQUEST_ID] == "req-1"
906 assert second[0][LookupMsg.KEYS] == [b"hC"]
907
908 def test_split_response_resolves_across_messages(self):
909 """Producer may answer one LookupMsg's keys across multiple
910 LookupRespMsgs — pairs are self-describing so each lands."""
911 session, conn, _ = _make_session()
912 _activate(session, conn)
913
914 session.register_lookup("req-1", b"hA")
915 session.register_lookup("req-1", b"hB")
916 session.flush_pending_lookups()
917
918 # Two responses, each carrying one of the two keys.
919 conn.enqueue(
920 {
921 TYPE_KEY: LookupRespMsg.TYPE,
922 LookupRespMsg.KV_REQUEST_ID: "req-1",
923 LookupRespMsg.KEYS: [b"hA"],
924 LookupRespMsg.HITS: [True],
925 }
926 )
927 conn.enqueue(
928 {
929 TYPE_KEY: LookupRespMsg.TYPE,
930 LookupRespMsg.KV_REQUEST_ID: "req-1",
931 LookupRespMsg.KEYS: [b"hB"],
932 LookupRespMsg.HITS: [False],
933 }
934 )
935 session.poll()
936
937 assert session.register_lookup("req-1", b"hA") is True
938 assert session.register_lookup("req-1", b"hB") is False
939
940 def test_finish_request_cancels_pending_lookups(self):
941 """finish_request drops every pending lookup for the kv_request_id."""
942 session, conn, _ = _make_session()
943 _activate(session, conn)
944
945 session.register_lookup("req-1", b"hA")
946 session.register_lookup("req-1", b"hB")
947 session.register_lookup("req-2", b"hC")
948 session.finish_request("req-1")
949
950 # req-1 entries gone, req-2 untouched.
951 assert "req-1" not in session._client._requests
952 assert b"hC" in session._client._requests["req-2"].probes
953
954 def test_finish_after_flushed_lookup_sends_empty_fetch(self):
955 """LookupMsg flushed but no FetchMsg sent (all-miss case) →
956 finish_request emits an empty FetchMsg so the peer can drop its
957 lookup state and call parent.on_request_finished."""
958 session, conn, _ = _make_session()
959 _activate(session, conn)
960
961 session.register_lookup("req-1", b"hA")
962 session.flush_pending_lookups()
963 sent_before = len(conn._sent)
964
965 session.finish_request("req-1")
966
967 fetches = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == FetchMsg.TYPE]
968 assert len(fetches) == 1
969 assert fetches[0][FetchMsg.KV_REQUEST_ID] == "req-1"
970 assert fetches[0][FetchMsg.KEYS] == []
971 assert fetches[0][FetchMsg.BLOCK_INDEXES] == []
972
973 def test_finish_without_flushed_lookup_sends_no_fetch(self):
974 """No LookupMsg was ever sent → finish_request must not emit an
975 empty FetchMsg (the peer has no state to release)."""
976 session, conn, _ = _make_session()
977 _activate(session, conn)
978
979 # Register but never flush.
980 session.register_lookup("req-1", b"hA")
981 sent_before = len(conn._sent)
982
983 session.finish_request("req-1")
984
985 fetches = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == FetchMsg.TYPE]
986 assert fetches == []
987
988 def test_finish_after_real_fetch_sends_no_second_fetch(self):
989 """A real FetchMsg was already sent for the id → finish_request
990 must not emit a second (empty) FetchMsg."""
991 session, conn, _ = _make_session()
992 _activate(session, conn)
993
994 session.register_lookup("req-1", b"hA")
995 session.flush_pending_lookups()
996 # Resolve the probe to a HIT before fetching, as the manager only
997 # loads confirmed hits (an unresolved probe yields RETRY).
998 conn.enqueue(
999 {
1000 TYPE_KEY: LookupRespMsg.TYPE,
1001 LookupRespMsg.KV_REQUEST_ID: "req-1",
1002 LookupRespMsg.KEYS: [b"hA"],
1003 LookupRespMsg.HITS: [True],
1004 }
1005 )
1006 session.poll()
1007 session.request_blocks(
1008 job_id=1, kv_request_id="req-1", keys=[b"hA"], block_ids=[7]
1009 )
1010 sent_before = len(conn._sent)
1011
1012 session.finish_request("req-1")
1013
1014 fetches = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == FetchMsg.TYPE]
1015 assert fetches == []
1016
1017 def test_server_lookup_deferred_until_serve_then_all_misses(self):
1018 """``poll()`` only enqueues an inbound LookupMsg — no response is
1019 sent until ``serve_external_requests``. With an all-miss parent
1020 the aggregated LookupRespMsg carries the same keys and
1021 ``hits=[False, ...]``."""
1022 session, conn, _ = _make_session()
1023 _activate(session, conn)
1024
1025 sent_before = len(conn._sent)
1026 conn.enqueue(
1027 {
1028 TYPE_KEY: LookupMsg.TYPE,
1029 LookupMsg.ROUND_SEQ: 0,
1030 LookupMsg.KV_REQUEST_ID: "req-1",
1031 LookupMsg.KEYS: [b"hX", b"hY", b"hZ"],
1032 }
1033 )
1034 session.poll()
1035
1036 # Dispatch alone must not answer — the parent handle is only valid
1037 # during serve_external_requests.
1038 assert [
1039 m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupRespMsg.TYPE
1040 ] == []
1041
1042 _serve(session, FakeParent())
1043
1044 resps = [
1045 m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupRespMsg.TYPE
1046 ]
1047 assert len(resps) == 1
1048 resp = resps[0]
1049 assert resp[LookupRespMsg.KV_REQUEST_ID] == "req-1"
1050 assert resp[LookupRespMsg.KEYS] == [b"hX", b"hY", b"hZ"]
1051 assert resp[LookupRespMsg.HITS] == [False, False, False]
1052
1053
1054 # ---------------------------------------------------------------------------
1055 # Server-side handling of inbound LookupMsg (ParentManager-driven)
1056 #
1057 # poll() only enqueues the LookupMsg; serve_external_requests(parent)
1058 # resolves it. Tests follow the poll() → _serve() pattern.
1059 # ---------------------------------------------------------------------------
1060
1061
1062 def _send_lookup(conn: FakeConnection, kv_request_id: str, keys: list[bytes]):
1063 conn.enqueue(
1064 {
1065 TYPE_KEY: LookupMsg.TYPE,
1066 LookupMsg.ROUND_SEQ: 0,
1067 LookupMsg.KV_REQUEST_ID: kv_request_id,
1068 LookupMsg.KEYS: list(keys),
1069 }
1070 )
1071
1072
1073 def _lookup_resps(conn: FakeConnection, since: int = 0) -> list[dict]:
1074 return [m for m in conn._sent[since:] if m[TYPE_KEY] == LookupRespMsg.TYPE]
1075
1076
1077 class TestServerLookupHandling:
1078 def test_immediate_hits_create_one_store_job(self):
1079 """All-HIT batch: one create_store_job call with all keys, one
1080 LookupRespMsg with hits=[True]*N, on_request_finished fires at the
1081 end of serve, and `available` is populated for the eventual fetch."""
1082 cb = FakeParent(stored={b"hA": 1, b"hB": 2, b"hC": 3})
1083 session, conn, _ = _make_session()
1084 _activate(session, conn)
1085
1086 sent_before = len(conn._sent)
1087 _send_lookup(conn, "req-1", [b"hA", b"hB", b"hC"])
1088 session.poll()
1089 _serve(session, cb)
1090
1091 resps = _lookup_resps(conn, sent_before)
1092 assert len(resps) == 1
1093 assert resps[0][LookupRespMsg.KEYS] == [b"hA", b"hB", b"hC"]
1094 assert resps[0][LookupRespMsg.HITS] == [True, True, True]
1095
1096 kinds = [c[0] for c in cb.calls]
1097 assert kinds.count("create_store_job") == 1
1098 cs = next(c for c in cb.calls if c[0] == "create_store_job")
1099 assert cs[1] == (b"hA", b"hB", b"hC")
1100 assert cb.calls[-1][0] == "on_request_finished"
1101
1102 # Hits are pinned in outbound state for the upcoming FetchMsg match.
1103 assert set(_srv_outbound(session, "req-1").available) == {
1104 b"hA",
1105 b"hB",
1106 b"hC",
1107 }
1108
1109 def test_all_misses_no_store_job_finish_fires(self):
1110 """All-MISS batch: no create_store_job call; one LookupRespMsg
1111 with hits=[False]*N; on_request_finished fires at end of serve."""
1112 cb = FakeParent()
1113 session, conn, _ = _make_session()
1114 _activate(session, conn)
1115
1116 sent_before = len(conn._sent)
1117 _send_lookup(conn, "req-1", [b"hA", b"hB"])
1118 session.poll()
1119 _serve(session, cb)
1120
1121 resps = _lookup_resps(conn, sent_before)
1122 assert len(resps) == 1
1123 assert resps[0][LookupRespMsg.HITS] == [False, False]
1124 assert all(c[0] != "create_store_job" for c in cb.calls)
1125 assert cb.calls[-1][0] == "on_request_finished"
1126
1127 def test_mixed_hit_miss_pending_defers_response_until_aggregate(self):
1128 """HIT/MISS resolutions do not go out on first sight when any
1129 key is still HIT_PENDING / RETRY. The lookup parks until every
1130 key has settled (or the deadline fires), then one
1131 LookupRespMsg carries all keys in wire order."""
1132 cb = FakeParent(
1133 stored={b"hA": 1},
1134 pending={b"hB"},
1135 retry={b"hD"},
1136 )
1137 session, conn, _ = _make_session()
1138 _activate(session, conn)
1139
1140 sent_before = len(conn._sent)
1141 _send_lookup(conn, "req-1", [b"hA", b"hB", b"hC", b"hD"])
1142 session.poll()
1143 _serve(session, cb)
1144
1145 # No LookupRespMsg yet — hB and hD are still pending.
1146 assert _lookup_resps(conn, sent_before) == []
1147 # HIT is still pinned immediately so the eventual FetchMsg matches.
1148 cs_calls = [c for c in cb.calls if c[0] == "create_store_job"]
1149 assert len(cs_calls) == 1
1150 assert cs_calls[0][1] == (b"hA",)
1151 # Lookup is parked; on_request_finished not yet called.
1152 assert all(c[0] != "on_request_finished" for c in cb.calls)
1153 assert len(_srv_lookups(session)) == 1
1154
1155 def test_pending_resolves_then_aggregate_response_fires(self):
1156 """A HIT_PENDING key that becomes HIT on a later poll releases
1157 the deferred aggregate response: one LookupRespMsg carrying
1158 both keys in wire order, and one create_store_job call per
1159 HIT (the second HIT is pinned when it resolves, not when the
1160 response goes out)."""
1161 cb = FakeParent(stored={b"hA": 1}, pending={b"hB"})
1162 session, conn, _ = _make_session()
1163 _activate(session, conn)
1164
1165 sent_before = len(conn._sent)
1166 _send_lookup(conn, "req-1", [b"hA", b"hB"])
1167 session.poll()
1168 _serve(session, cb)
1169 # No response yet — hB still pending.
1170 assert _lookup_resps(conn, sent_before) == []
1171
1172 # Promote hB.
1173 cb.pending.discard(b"hB")
1174 cb.stored[b"hB"] = 2
1175
1176 # Drive resolver via a second serve_external_requests.
1177 _serve(session, cb)
1178
1179 resps = _lookup_resps(conn, sent_before)
1180 assert len(resps) == 1
1181 assert resps[0][LookupRespMsg.KEYS] == [b"hA", b"hB"]
1182 assert resps[0][LookupRespMsg.HITS] == [True, True]
1183
1184 cs_calls = [c for c in cb.calls if c[0] == "create_store_job"]
1185 assert len(cs_calls) == 2
1186 assert cs_calls[0][1] == (b"hA",)
1187 assert cs_calls[1][1] == (b"hB",)
1188 # on_request_finished fires once after the aggregate resolve.
1189 assert sum(1 for c in cb.calls if c[0] == "on_request_finished") == 1
1190 assert b"hA" in _srv_outbound(session, "req-1").available
1191 assert b"hB" in _srv_outbound(session, "req-1").available
1192
1193 def test_pending_timeout_replies_miss_no_store_job(self):
1194 """A HIT_PENDING key that stays pending past the batch
1195 ``deadline`` is force-MISS and never pinned; the deferred
1196 aggregate response fires with hits=[False]."""
1197 cb = FakeParent(pending={b"hA"})
1198 session, conn, _ = _make_session()
1199 _activate(session, conn)
1200
1201 sent_before = len(conn._sent)
1202 _send_lookup(conn, "req-1", [b"hA"])
1203 session.poll()
1204 _serve(session, cb)
1205 # Initial serve: nothing immediate, lookup parked, no LookupRespMsg.
1206 assert _lookup_resps(conn, sent_before) == []
1207
1208 # Forge the deadline into the past to trigger the timeout branch.
1209 lookup = _srv_lookups(session)[0]
1210 lookup.deadline = time.monotonic() - 0.1
1211
1212 _serve(session, cb)
1213
1214 resps = _lookup_resps(conn, sent_before)
1215 assert len(resps) == 1
1216 assert resps[0][LookupRespMsg.KEYS] == [b"hA"]
1217 assert resps[0][LookupRespMsg.HITS] == [False]
1218 assert all(c[0] != "create_store_job" for c in cb.calls)
1219 assert sum(1 for c in cb.calls if c[0] == "on_request_finished") == 1
1220
1221 def test_finish_request_called_per_lookup_msg_not_per_kv_request_id(self):
1222 """Two LookupMsgs for the same kv_request_id get distinct ctxs
1223 and two on_request_finished calls (one per batch)."""
1224 cb = FakeParent(stored={b"hA": 1, b"hB": 2})
1225 session, conn, _ = _make_session()
1226 _activate(session, conn)
1227
1228 _send_lookup(conn, "req-1", [b"hA"])
1229 session.poll()
1230 _serve(session, cb)
1231 _send_lookup(conn, "req-1", [b"hB"])
1232 session.poll()
1233 _serve(session, cb)
1234
1235 finish_calls = [c for c in cb.calls if c[0] == "on_request_finished"]
1236 assert len(finish_calls) == 2
1237 # Distinct synthetic req_ids
1238 assert finish_calls[0][1] != finish_calls[1][1]
1239 # Both namespaced under the same kv_request_id
1240 assert ":req-1:" in finish_calls[0][1]
1241 assert ":req-1:" in finish_calls[1][1]
1242
1243 def test_close_returns_open_batch_ctxs_as_failed_serves(self):
1244 """Tearing the session down with a parked batch returns the
1245 synthetic ctx as a failed serve (no parent handle at teardown) so
1246 the manager can release the TieringManager's state on its next
1247 serve."""
1248 cb = FakeParent(pending={b"hA"})
1249 session, conn, _ = _make_session()
1250 _activate(session, conn)
1251
1252 _send_lookup(conn, "req-1", [b"hA"])
1253 session.poll()
1254 _serve(session, cb)
1255 assert len(_srv_lookups(session)) == 1
1256 assert all(c[0] != "on_request_finished" for c in cb.calls)
1257
1258 result = session.close()
1259
1260 assert len(result.failed_serves) == 1
1261 assert ":req-1:" in result.failed_serves[0].req_id
1262 # close() itself must not call the parent.
1263 assert all(c[0] != "on_request_finished" for c in cb.calls)
1264
1265 def test_wire_finish_drops_pending_batches_for_kv_request_id(self):
1266 """``ServerRole.finish(kv_request_id)`` drops every parked batch
1267 whose kv_request_id matches and queues its ctx for the next
1268 serve's on_request_finished."""
1269 cb = FakeParent(pending={b"hA", b"hB"})
1270 session, conn, _ = _make_session()
1271 _activate(session, conn)
1272
1273 _send_lookup(conn, "req-1", [b"hA"])
1274 session.poll()
1275 _serve(session, cb)
1276 _send_lookup(conn, "req-2", [b"hB"])
1277 session.poll()
1278 _serve(session, cb)
1279 assert len(_srv_lookups(session)) == 2
1280
1281 session._server.finish("req-1")
1282
1283 # req-1 batch dropped from parked lookups; its ctx queued for release.
1284 remaining_kv_request_ids = {b.kv_request_id for b in _srv_lookups(session)}
1285 assert remaining_kv_request_ids == {"req-2"}
1286 queued = session._server._finished_lookup_ctxs
1287 assert len(queued) == 1
1288 assert ":req-1:" in queued[0].req_id
1289
1290 # The next serve fires on_request_finished exactly once for req-1.
1291 _serve(session, cb)
1292 finish_calls = [c for c in cb.calls if c[0] == "on_request_finished"]
1293 assert len(finish_calls) == 1
1294 assert ":req-1:" in finish_calls[0][1]
1295
1296 def test_incoming_fetch_drops_pending_lookups_for_kv_request_id(self):
1297 """A peer FetchMsg terminates the lookup phase for its id: parked
1298 lookups with matching kv_request_id are dropped and their
1299 ctx queued for on_request_finished; other kv_request_ids untouched."""
1300 cb = FakeParent(pending={b"hA", b"hB"})
1301 session, conn, _ = _make_session()
1302 _activate(session, conn)
1303
1304 _send_lookup(conn, "req-1", [b"hA"])
1305 session.poll()
1306 _serve(session, cb)
1307 _send_lookup(conn, "req-2", [b"hB"])
1308 session.poll()
1309 _serve(session, cb)
1310 assert len(_srv_lookups(session)) == 2
1311
1312 # Empty FetchMsg: peer signals "lookup phase done" without asking
1313 # for any blocks (the all-miss case).
1314 conn.enqueue(
1315 {
1316 TYPE_KEY: FetchMsg.TYPE,
1317 FetchMsg.ROUND_SEQ: 0,
1318 FetchMsg.KV_REQUEST_ID: "req-1",
1319 FetchMsg.KEYS: [],
1320 FetchMsg.BLOCK_INDEXES: [],
1321 }
1322 )
1323 session.poll()
1324
1325 remaining_kv_request_ids = {lu.kv_request_id for lu in _srv_lookups(session)}
1326 assert remaining_kv_request_ids == {"req-2"}
1327 # Dispatch queues the ctx but does not call the parent yet.
1328 queued = session._server._finished_lookup_ctxs
1329 assert len(queued) == 1
1330 assert ":req-1:" in queued[0].req_id
1331
1332 # The next serve fires on_request_finished exactly once for req-1.
1333 _serve(session, cb)
1334 finish_calls = [c for c in cb.calls if c[0] == "on_request_finished"]
1335 assert len(finish_calls) == 1
1336 assert ":req-1:" in finish_calls[0][1]
1337
1338 def test_lookup_then_fetch_round_trip_emits_store_result(self):
1339 """End-to-end: lookup pins primary slots → fetch matches them →
1340 NIXL transfer completes → StoreResult surfaces with the
1341 create_store_job's job_id (the engine releases the pin)."""
1342 cb = FakeParent(stored={b"hA": 7, b"hB": 8})
1343 session, conn, transport = _make_session()
1344 _activate(session, conn)
1345
1346 _send_lookup(conn, "req-1", [b"hA", b"hB"])
1347 session.poll()
1348 _serve(session, cb)
1349 cs = next(c for c in cb.calls if c[0] == "create_store_job")
1350 # FakeParent issues monotonic job_ids starting at 1000.
1351 expected_job_id = 1000
1352
1353 # Consumer issues FetchMsg on the resolved hits.
1354 conn.enqueue(
1355 {
1356 TYPE_KEY: FetchMsg.TYPE,
1357 FetchMsg.ROUND_SEQ: 0,
1358 FetchMsg.KV_REQUEST_ID: "req-1",
1359 FetchMsg.KEYS: [b"hA", b"hB"],
1360 FetchMsg.BLOCK_INDEXES: [20, 21],
1361 }
1362 )
1363 session.poll()
1364
1365 # NIXL write_blocks called with our pinned local block_ids.
1366 assert len(transport._transfers) == 1
1367 _, (_peer, local, remote) = next(iter(transport._transfers.items()))
1368 assert local == [7, 8]
1369 assert remote == [20, 21]
1370
1371 # Drive the transport completion.
1372 transport._poll_done.append(0)
1373 result = session.poll()
1374
1375 store_results = [s for s in result.stores if s.success]
1376 assert any(s.job_id == expected_job_id for s in store_results)
1377 # Sanity: kv mention in synthetic ctx.
1378 assert cs[2].startswith("p2p:")
1379
1380
1381 # ---------------------------------------------------------------------------
1382 # Server-role flows
1383 # ---------------------------------------------------------------------------
1384
1385
1386 class TestServerFlows:
1387 def test_store_then_fetch_matches(self):
1388 """Blocks stored before fetch demand are matched on demand arrival."""
1389 session, conn, transport = _make_session()
1390 _activate(session, conn)
1391 session.add_stored_blocks("req-1", [b"k1", b"k2"], [0, 1], job_id=1)
1392 conn.enqueue(
1393 {
1394 TYPE_KEY: FetchMsg.TYPE,
1395 FetchMsg.ROUND_SEQ: 0,
1396 FetchMsg.KV_REQUEST_ID: "req-1",
1397 FetchMsg.KEYS: [b"k1", b"k2"],
1398 FetchMsg.BLOCK_INDEXES: [10, 11],
1399 }
1400 )
1401 session.poll()
1402 assert len(transport._transfers) == 1
1403 _, (peer, local, remote) = next(iter(transport._transfers.items()))
1404 assert local == [0, 1]
1405 assert remote == [10, 11]
1406
1407 def test_fetch_then_store_matches(self):
1408 """Fetch demand registered before store; store fulfills it."""
1409 session, conn, transport = _make_session()
1410 _activate(session, conn)
1411 conn.enqueue(
1412 {
1413 TYPE_KEY: FetchMsg.TYPE,
1414 FetchMsg.ROUND_SEQ: 0,
1415 FetchMsg.KV_REQUEST_ID: "req-1",
1416 FetchMsg.KEYS: [b"k1"],
1417 FetchMsg.BLOCK_INDEXES: [5],
1418 }
1419 )
1420 session.poll()
1421 assert len(transport._transfers) == 0
1422 session.add_stored_blocks("req-1", [b"k1"], [3], job_id=1)
1423 assert len(transport._transfers) == 1
1424
1425 def test_transfer_completion_emits_store_result_and_done(self):
1426 """Completed transfer reports StoreResult and sends TransferDoneMsg."""
1427 session, conn, transport = _make_session()
1428 _activate(session, conn)
1429 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1430 conn.enqueue(
1431 {
1432 TYPE_KEY: FetchMsg.TYPE,
1433 FetchMsg.ROUND_SEQ: 0,
1434 FetchMsg.KV_REQUEST_ID: "req-1",
1435 FetchMsg.KEYS: [b"k1"],
1436 FetchMsg.BLOCK_INDEXES: [5],
1437 }
1438 )
1439 session.poll()
1440 tid = next(iter(transport._transfers))
1441 transport._poll_done.append(tid)
1442 stores = session.poll().stores
1443 assert StoreResult(job_id=1, success=True) in stores
1444 assert any(m[TYPE_KEY] == TransferDoneMsg.TYPE for m in conn._sent)
1445
1446 def test_abort_fetch_replies_with_ack(self):
1447 session, conn, _ = _make_session()
1448 _activate(session, conn)
1449 conn.enqueue(
1450 {
1451 TYPE_KEY: AbortFetchMsg.TYPE,
1452 AbortFetchMsg.ROUND_SEQ: 0,
1453 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1454 }
1455 )
1456 session.poll()
1457 ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
1458 assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
1459 assert _srv_abort_started(session, "req-1") is None
1460
1461 def test_abort_fetch_defers_ack_when_cancel_pending(self):
1462 """If cancel(mode='wait') reports still-inflight tids, the ack is
1463 deferred and the abort is parked (abort_started_at set)."""
1464 session, conn, transport = _make_session()
1465 _activate(session, conn)
1466 # Seed an inflight transfer for req-1 that the transport pretends
1467 # cannot be canceled yet.
1468 tid = 42
1469 session._server._inflight_add(
1470 tid,
1471 _InflightXfer(
1472 kv_request_id="req-1",
1473 block_count=1,
1474 job_ids={1},
1475 round=_OutboundRequestState(inflight=1),
1476 ),
1477 )
1478 transport._cancel_still_inflight.add(tid)
1479
1480 conn.enqueue(
1481 {
1482 TYPE_KEY: AbortFetchMsg.TYPE,
1483 AbortFetchMsg.ROUND_SEQ: 0,
1484 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1485 }
1486 )
1487 session.poll()
1488
1489 assert not any(m[TYPE_KEY] == AbortAckMsg.TYPE for m in conn._sent)
1490 assert _srv_abort_started(session, "req-1") is not None
1491 # First attempt happens inside _on_abort_fetch; the per-tick
1492 # drain runs again at the end of poll() — both are wait-mode.
1493 assert all(mode == "wait" for _, mode in transport._cancel_calls)
1494 assert tid in session._server._inflight # still tracked
1495
1496 def test_abort_fetch_acks_after_drain(self):
1497 """Once the transport reports the tid as DONE the parked abort
1498 completes and AbortAckMsg is sent."""
1499 session, conn, transport = _make_session()
1500 _activate(session, conn)
1501 tid = 42
1502 session._server._inflight_add(
1503 tid,
1504 _InflightXfer(
1505 kv_request_id="req-1",
1506 block_count=1,
1507 job_ids={1},
1508 round=_OutboundRequestState(inflight=1),
1509 ),
1510 )
1511 transport._cancel_still_inflight.add(tid)
1512
1513 conn.enqueue(
1514 {
1515 TYPE_KEY: AbortFetchMsg.TYPE,
1516 AbortFetchMsg.ROUND_SEQ: 0,
1517 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1518 }
1519 )
1520 session.poll()
1521 assert _srv_abort_started(session, "req-1") is not None
1522
1523 # Backend finishes draining: transport.poll() will return tid as
1524 # DONE, and the next cancel(mode='wait') call sees it's gone.
1525 transport._cancel_still_inflight.discard(tid)
1526 transport._poll_done.append(tid)
1527
1528 session.poll()
1529
1530 ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
1531 assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
1532 assert _srv_abort_started(session, "req-1") is None
1533 assert tid not in session._server._inflight
1534
1535 def test_abort_fetch_force_cancels_after_timeout(self):
1536 """If wait-mode never drains, the deadline forces immediate
1537 cancel and an ack is still sent."""
1538 session, conn, transport = _make_session()
1539 _activate(session, conn)
1540 tid = 42
1541 session._server._inflight_add(
1542 tid,
1543 _InflightXfer(
1544 kv_request_id="req-1",
1545 block_count=1,
1546 job_ids={1},
1547 round=_OutboundRequestState(inflight=1),
1548 ),
1549 )
1550 transport._cancel_still_inflight.add(tid)
1551
1552 conn.enqueue(
1553 {
1554 TYPE_KEY: AbortFetchMsg.TYPE,
1555 AbortFetchMsg.ROUND_SEQ: 0,
1556 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1557 }
1558 )
1559 session.poll()
1560 assert _srv_abort_started(session, "req-1") is not None
1561 # Backdate past the drain deadline.
1562 session._server._pending_aborts[("req-1", 0)] = (
1563 time.monotonic() - _CANCEL_DRAIN_TIMEOUT_S - 1.0
1564 )
1565 # Even if the transport still claims it can't cancel, the
1566 # session must force-pop and ack.
1567 transport._cancel_calls.clear()
1568
1569 session.poll()
1570
1571 ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
1572 assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
1573 assert _srv_abort_started(session, "req-1") is None
1574 assert tid not in session._server._inflight
1575 assert ([tid], "immediate") in transport._cancel_calls
1576
1577 def test_abort_fetch_idempotent_while_draining(self):
1578 """Receiving AbortFetchMsg twice for the same kv_request_id
1579 keeps a single pending entry and produces a single ack."""
1580 session, conn, transport = _make_session()
1581 _activate(session, conn)
1582 tid = 42
1583 session._server._inflight_add(
1584 tid,
1585 _InflightXfer(
1586 kv_request_id="req-1",
1587 block_count=1,
1588 job_ids={1},
1589 round=_OutboundRequestState(inflight=1),
1590 ),
1591 )
1592 transport._cancel_still_inflight.add(tid)
1593
1594 conn.enqueue(
1595 {
1596 TYPE_KEY: AbortFetchMsg.TYPE,
1597 AbortFetchMsg.ROUND_SEQ: 0,
1598 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1599 }
1600 )
1601 session.poll()
1602 first_started_at = _srv_abort_started(session, "req-1")
1603
1604 # Second AbortFetchMsg for the same kv_request_id while still
1605 # draining must not reset the deadline.
1606 conn.enqueue(
1607 {
1608 TYPE_KEY: AbortFetchMsg.TYPE,
1609 AbortFetchMsg.ROUND_SEQ: 0,
1610 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1611 }
1612 )
1613 session.poll()
1614 assert _srv_abort_started(session, "req-1") == first_started_at
1615
1616 # Now let the drain succeed and confirm exactly one ack ever.
1617 transport._cancel_still_inflight.discard(tid)
1618 transport._poll_done.append(tid)
1619 session.poll()
1620
1621 acks = [m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE]
1622 assert len(acks) == 1
1623 assert acks[0][AbortAckMsg.KV_REQUEST_ID] == "req-1"
1624
1625 def test_store_timeout(self):
1626 session, conn, _ = _make_session()
1627 _activate(session, conn)
1628 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1629 # Backdate.
1630 session._server._store_jobs[1] = time.monotonic() - 60.0
1631 stores = session.poll().stores
1632 assert StoreResult(job_id=1, success=False) in stores
1633
1634 def test_store_timeout_then_late_completion_no_duplicate(self):
1635 """A job timed out by _timeout_pending_store_jobs must not also
1636 emit a contradictory StoreResult(success=True) when the transport
1637 later reports the same transfer as done."""
1638 session, conn, transport = _make_session()
1639 _activate(session, conn)
1640 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1641 conn.enqueue(
1642 {
1643 TYPE_KEY: FetchMsg.TYPE,
1644 FetchMsg.ROUND_SEQ: 0,
1645 FetchMsg.KV_REQUEST_ID: "req-1",
1646 FetchMsg.KEYS: [b"k1"],
1647 FetchMsg.BLOCK_INDEXES: [5],
1648 }
1649 )
1650 session.poll()
1651 tid = next(iter(transport._transfers))
1652
1653 # Backdate the store job so the next poll times it out.
1654 session._server._store_jobs[1] = time.monotonic() - 60.0
1655 stores = session.poll().stores
1656 assert StoreResult(job_id=1, success=False) in stores
1657 assert StoreResult(job_id=1, success=True) not in stores
1658
1659 # Transport later reports the same transfer as done — must not
1660 # emit a second (contradictory) StoreResult for job_id=1.
1661 transport._poll_done.append(tid)
1662 stores = session.poll().stores
1663 assert all(s.job_id != 1 for s in stores), (
1664 f"unexpected duplicate StoreResult after timeout: {stores}"
1665 )
1666
1667 def test_store_timeout_then_late_failure_no_duplicate(self):
1668 """Symmetric guard: a timed-out job must not also emit a second
1669 StoreResult(success=False) when the transport later reports the
1670 same transfer as failed."""
1671 session, conn, transport = _make_session()
1672 _activate(session, conn)
1673 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1674 conn.enqueue(
1675 {
1676 TYPE_KEY: FetchMsg.TYPE,
1677 FetchMsg.ROUND_SEQ: 0,
1678 FetchMsg.KV_REQUEST_ID: "req-1",
1679 FetchMsg.KEYS: [b"k1"],
1680 FetchMsg.BLOCK_INDEXES: [5],
1681 }
1682 )
1683 session.poll()
1684 tid = next(iter(transport._transfers))
1685
1686 session._server._store_jobs[1] = time.monotonic() - 60.0
1687 stores = session.poll().stores
1688 assert [s for s in stores if s.job_id == 1] == [
1689 StoreResult(job_id=1, success=False)
1690 ]
1691
1692 transport._poll_failed.append(tid)
1693 stores = session.poll().stores
1694 assert all(s.job_id != 1 for s in stores), (
1695 f"unexpected duplicate StoreResult after timeout: {stores}"
1696 )
1697
1698
1699 # ---------------------------------------------------------------------------
1700 # finish_request server-role early-fail flow
1701 # ---------------------------------------------------------------------------
1702
1703
1704 class TestFinishRequestServerSide:
1705 def _last_transfer_done(self, conn: FakeConnection) -> dict | None:
1706 for msg in reversed(conn._sent):
1707 if msg[TYPE_KEY] == TransferDoneMsg.TYPE:
1708 return msg
1709 return None
1710
1711 def test_no_inflight_unmatched_demand_sends_failure(self):
1712 """finish_request with unmatched demand and no inflight ->
1713 immediate TransferDoneMsg(success=False); _outbound cleared."""
1714 session, conn, _ = _make_session()
1715 _activate(session, conn)
1716 # Decoder demanded a block we never stored.
1717 conn.enqueue(
1718 {
1719 TYPE_KEY: FetchMsg.TYPE,
1720 FetchMsg.ROUND_SEQ: 0,
1721 FetchMsg.KV_REQUEST_ID: "req-1",
1722 FetchMsg.KEYS: [b"k1"],
1723 FetchMsg.BLOCK_INDEXES: [5],
1724 }
1725 )
1726 session.poll()
1727 assert _srv_outbound(session, "req-1") is not None
1728
1729 session.finish_request("req-1")
1730
1731 msg = self._last_transfer_done(conn)
1732 assert msg is not None
1733 assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1734 assert msg[TransferDoneMsg.SUCCESS] is False
1735 assert _srv_outbound(session, "req-1") is None
1736
1737 def test_with_inflight_defers_then_fires_on_last_transfer(self):
1738 """finish_request with inflight defers; last transfer fires the
1739 early-fail message and clears _outbound."""
1740 session, conn, transport = _make_session()
1741 _activate(session, conn)
1742 # Demand 2 blocks; we store 1 (kicks one inflight transfer).
1743 conn.enqueue(
1744 {
1745 TYPE_KEY: FetchMsg.TYPE,
1746 FetchMsg.ROUND_SEQ: 0,
1747 FetchMsg.KV_REQUEST_ID: "req-1",
1748 FetchMsg.KEYS: [b"k1", b"k2"],
1749 FetchMsg.BLOCK_INDEXES: [10, 11],
1750 }
1751 )
1752 session.poll()
1753 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1754 assert len(transport._transfers) == 1
1755
1756 # finish_request while inflight: no early-fail yet.
1757 before = len(conn._sent)
1758 session.finish_request("req-1")
1759 assert len(conn._sent) == before
1760 assert _srv_outbound(session, "req-1") is not None
1761 assert _srv_outbound(session, "req-1").finishing
1762
1763 # Last inflight settles -> early-fail fires.
1764 tid = next(iter(transport._transfers))
1765 transport._poll_done.append(tid)
1766 session.poll()
1767
1768 msg = self._last_transfer_done(conn)
1769 assert msg is not None
1770 assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1771 assert msg[TransferDoneMsg.SUCCESS] is False
1772 assert _srv_outbound(session, "req-1") is None
1773
1774 def test_full_demand_satisfied_still_sends_success(self):
1775 """finish_request must not override a fully-satisfied transfer:
1776 when remaining hits 0, success=True still fires."""
1777 session, conn, transport = _make_session()
1778 _activate(session, conn)
1779 conn.enqueue(
1780 {
1781 TYPE_KEY: FetchMsg.TYPE,
1782 FetchMsg.ROUND_SEQ: 0,
1783 FetchMsg.KV_REQUEST_ID: "req-1",
1784 FetchMsg.KEYS: [b"k1"],
1785 FetchMsg.BLOCK_INDEXES: [10],
1786 }
1787 )
1788 session.poll()
1789 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1790 # Mark finishing (e.g., on_request_finished racing with the last
1791 # store) — but all demand is satisfied.
1792 session.finish_request("req-1")
1793
1794 tid = next(iter(transport._transfers))
1795 transport._poll_done.append(tid)
1796 session.poll()
1797
1798 msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
1799 assert msg[TransferDoneMsg.SUCCESS] is True
1800
1801 def test_prefiller_first_finish_before_fetch(self):
1802 """Prefiller-first: finish_request runs before the decoder's
1803 fetch arrives. State is held until fetch, then
1804 finalized — success=True if all demand was matched against
1805 available blocks, else success=False."""
1806 # Case A: all demand satisfied by available blocks.
1807 session, conn, transport = _make_session()
1808 _activate(session, conn)
1809 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1810 # finish_request first — no demand received yet -> defer.
1811 session.finish_request("req-1")
1812 assert _srv_outbound(session, "req-1") is not None
1813 # Fetch arrives now: demand fully satisfied by available.
1814 conn.enqueue(
1815 {
1816 TYPE_KEY: FetchMsg.TYPE,
1817 FetchMsg.ROUND_SEQ: 0,
1818 FetchMsg.KV_REQUEST_ID: "req-1",
1819 FetchMsg.KEYS: [b"k1"],
1820 FetchMsg.BLOCK_INDEXES: [10],
1821 }
1822 )
1823 session.poll()
1824 # The transfer was inflight; finalize it.
1825 tid = next(iter(transport._transfers))
1826 transport._poll_done.append(tid)
1827 session.poll()
1828 msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
1829 assert msg[TransferDoneMsg.SUCCESS] is True
1830
1831 # Case B: demand exceeds available -> early-fail fires from fetch.
1832 session, conn, transport = _make_session()
1833 _activate(session, conn)
1834 session.add_stored_blocks("req-2", [b"k1"], [0], job_id=2)
1835 session.finish_request("req-2")
1836 conn.enqueue(
1837 {
1838 TYPE_KEY: FetchMsg.TYPE,
1839 FetchMsg.ROUND_SEQ: 0,
1840 FetchMsg.KV_REQUEST_ID: "req-2",
1841 FetchMsg.KEYS: [b"k1", b"k2"],
1842 FetchMsg.BLOCK_INDEXES: [10, 11],
1843 }
1844 )
1845 session.poll()
1846 # Inflight for k1 still in flight; nothing yet for the early-fail
1847 # — the same code path will fire from _collect_store_results.
1848 tid = next(iter(transport._transfers))
1849 transport._poll_done.append(tid)
1850 session.poll()
1851 msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
1852 assert msg[TransferDoneMsg.SUCCESS] is False
1853 assert _srv_outbound(session, "req-2") is None
1854
1855 def test_unknown_request_is_noop(self):
1856 session, conn, _ = _make_session()
1857 _activate(session, conn)
1858 before = len(conn._sent)
1859 session.finish_request("never-existed")
1860 assert len(conn._sent) == before
1861
1862 def test_finish_request_no_inflight_emits_store_failure(self):
1863 """finish_request with a stored-but-unmatched job and no inflight ->
1864 TransferDoneMsg(success=False) AND deferred
1865 StoreResult(success=False) for the submit_store'd job, instead of
1866 the 30s _STORE_TIMEOUT_S path."""
1867 session, conn, _ = _make_session()
1868 _activate(session, conn)
1869 # Decoder demanded b"demand"; we never stored it.
1870 conn.enqueue(
1871 {
1872 TYPE_KEY: FetchMsg.TYPE,
1873 FetchMsg.ROUND_SEQ: 0,
1874 FetchMsg.KV_REQUEST_ID: "req-1",
1875 FetchMsg.KEYS: [b"demand"],
1876 FetchMsg.BLOCK_INDEXES: [5],
1877 }
1878 )
1879 session.poll()
1880 # We did submit_store a different block — goes to available, never
1881 # matches demand. Without the shortcut, job 42 sits in _store_jobs
1882 # for _STORE_TIMEOUT_S.
1883 session.add_stored_blocks("req-1", [b"unrelated"], [0], job_id=42)
1884 assert _srv_outbound(session, "req-1").pending_job_ids == {42}
1885
1886 session.finish_request("req-1")
1887
1888 # Peer notified immediately with success=False (remaining > 0).
1889 msg = self._last_transfer_done(conn)
1890 assert msg is not None
1891 assert msg[TransferDoneMsg.SUCCESS] is False
1892 assert _srv_outbound(session, "req-1") is None
1893
1894 # Local store job surfaces on the next poll, success=False.
1895 stores = session.poll().stores
1896 assert StoreResult(job_id=42, success=False) in stores
1897 assert 42 not in session._server._store_jobs
1898
1899 def test_finish_request_remaining_zero_emits_success_via_inflight(self):
1900 """Deferred-via-inflight path: finish_request with inflight, last
1901 transfer drains remaining to 0 -> TransferDoneMsg(success=True)
1902 AND StoreResult(success=True)."""
1903 session, conn, transport = _make_session()
1904 _activate(session, conn)
1905 conn.enqueue(
1906 {
1907 TYPE_KEY: FetchMsg.TYPE,
1908 FetchMsg.ROUND_SEQ: 0,
1909 FetchMsg.KV_REQUEST_ID: "req-1",
1910 FetchMsg.KEYS: [b"k1"],
1911 FetchMsg.BLOCK_INDEXES: [10],
1912 }
1913 )
1914 session.poll()
1915 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=7)
1916 # finish_request races with the inflight transfer.
1917 session.finish_request("req-1")
1918 assert _srv_outbound(session, "req-1") is not None # deferred
1919
1920 # Last inflight completes -> _finalize_outbound(success=True) fires.
1921 tid = next(iter(transport._transfers))
1922 transport._poll_done.append(tid)
1923 stores = session.poll().stores
1924
1925 msg = self._last_transfer_done(conn)
1926 assert msg is not None
1927 assert msg[TransferDoneMsg.SUCCESS] is True
1928 assert StoreResult(job_id=7, success=True) in stores
1929 assert _srv_outbound(session, "req-1") is None
1930
1931 def test_write_blocks_failure_finalizes_with_failure(self):
1932 """write_blocks returning None must not leave the request hanging.
1933
1934 The matched blocks are gone from req.demanded but no inflight
1935 will satisfy them, so remaining > 0 forever. Setting finishing
1936 and calling _finalize_outbound(success=False) immediately (no
1937 other inflight) tells the peer + emits StoreResult(success=False)
1938 without waiting on _STORE_TIMEOUT_S or _LOAD_TIMEOUT_S.
1939 """
1940 session, conn, transport = _make_session()
1941 _activate(session, conn)
1942 # Decoder demands b"k1".
1943 conn.enqueue(
1944 {
1945 TYPE_KEY: FetchMsg.TYPE,
1946 FetchMsg.ROUND_SEQ: 0,
1947 FetchMsg.KV_REQUEST_ID: "req-1",
1948 FetchMsg.KEYS: [b"k1"],
1949 FetchMsg.BLOCK_INDEXES: [10],
1950 }
1951 )
1952 session.poll()
1953 # Force write_blocks to fail on the next call.
1954 transport.write_blocks = lambda *a, **kw: None # type: ignore[method-assign]
1955
1956 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=42)
1957
1958 # Outbound was finalized immediately (no other inflight).
1959 assert _srv_outbound(session, "req-1") is None
1960 # Peer notified with success=False.
1961 msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
1962 assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1963 assert msg[TransferDoneMsg.SUCCESS] is False
1964 # Local store job surfaces on the next poll.
1965 stores = session.poll().stores
1966 assert StoreResult(job_id=42, success=False) in stores
1967 assert 42 not in session._server._store_jobs
1968
1969 def test_partial_match_completes_in_two_rounds(self):
1970 """Peer demand for [k1, k2, k3]; first round only k1 is available,
1971 second round adds k2 and k3. Each round transfers what's matched
1972 and the request finalizes with success once remaining hits zero.
1973 """
1974 session, conn, transport = _make_session()
1975 _activate(session, conn)
1976
1977 # Peer fetches three blocks.
1978 conn.enqueue(
1979 {
1980 TYPE_KEY: FetchMsg.TYPE,
1981 FetchMsg.ROUND_SEQ: 0,
1982 FetchMsg.KV_REQUEST_ID: "req-1",
1983 FetchMsg.KEYS: [b"k1", b"k2", b"k3"],
1984 FetchMsg.BLOCK_INDEXES: [10, 11, 12],
1985 }
1986 )
1987 session.poll()
1988 # Demand registered, no matches yet.
1989 assert session._server._inflight == {}
1990 outbound = _srv_outbound(session, "req-1")
1991 assert outbound.remaining == 3
1992 assert set(outbound.demanded.keys()) == {b"k1", b"k2", b"k3"}
1993
1994 # Round 1: only k1 is stored locally.
1995 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=100)
1996 # One inflight transfer for k1.
1997 assert len(session._server._inflight) == 1
1998 tid_1 = next(iter(session._server._inflight))
1999 assert transport._transfers[tid_1][1] == [0]
2000 assert transport._transfers[tid_1][2] == [10]
2001 # k2 and k3 still demanded.
2002 assert set(outbound.demanded.keys()) == {b"k2", b"k3"}
2003
2004 # Transfer 1 completes.
2005 transport._poll_done.append(tid_1)
2006 stores = session.poll().stores
2007 assert StoreResult(job_id=100, success=True) in stores
2008 assert session._server._inflight == {}
2009 assert outbound.remaining == 2
2010 # Not yet finalized — still 2 blocks demanded.
2011 assert _srv_outbound(session, "req-1") is not None
2012
2013 # Round 2: k2 and k3 arrive together.
2014 session.add_stored_blocks("req-1", [b"k2", b"k3"], [1, 2], job_id=200)
2015 assert len(session._server._inflight) == 1
2016 tid_2 = next(iter(session._server._inflight))
2017 assert tid_2 != tid_1
2018 assert sorted(transport._transfers[tid_2][1]) == [1, 2]
2019
2020 # Transfer 2 completes — request now fully satisfied.
2021 transport._poll_done.append(tid_2)
2022 stores = session.poll().stores
2023 assert StoreResult(job_id=200, success=True) in stores
2024 # _finalize_outbound fired — request gone, peer notified with success.
2025 assert _srv_outbound(session, "req-1") is None
2026 done = next(m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE)
2027 assert done[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
2028 assert done[TransferDoneMsg.SUCCESS] is True
2029
2030 def test_write_blocks_failure_finalizes_after_last_inflight_completes(self):
2031 """write_blocks returns None on a SECOND match while a first transfer
2032 is still inflight. The request should NOT finalize until the inflight
2033 completes, then the elif branch in collect_results
2034 (``finishing and not _has_inflight_for(...)``) finalizes it as failure.
2035 """
2036 session, conn, transport = _make_session()
2037 _activate(session, conn)
2038
2039 # Peer demands two blocks.
2040 conn.enqueue(
2041 {
2042 TYPE_KEY: FetchMsg.TYPE,
2043 FetchMsg.ROUND_SEQ: 0,
2044 FetchMsg.KV_REQUEST_ID: "req-1",
2045 FetchMsg.KEYS: [b"k1", b"k2"],
2046 FetchMsg.BLOCK_INDEXES: [10, 11],
2047 }
2048 )
2049 session.poll()
2050
2051 # Round 1: k1 transfers cleanly.
2052 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=100)
2053 assert len(session._server._inflight) == 1
2054 tid_1 = next(iter(session._server._inflight))
2055 outbound = _srv_outbound(session, "req-1")
2056 assert outbound.remaining == 2 # decrement happens on completion
2057 assert outbound.finishing is False
2058
2059 # Round 2: write_blocks fails for k2 while transfer_1 is still inflight.
2060 transport.write_blocks = lambda *a, **kw: None # type: ignore[method-assign]
2061 session.add_stored_blocks("req-1", [b"k2"], [1], job_id=200)
2062 # No new transfer was registered.
2063 assert list(session._server._inflight.keys()) == [tid_1]
2064 # Marked finishing, but NOT finalized yet (transfer_1 still inflight).
2065 assert outbound.finishing is True
2066 assert _srv_outbound(session, "req-1") is not None
2067 done_msgs = [m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE]
2068 assert done_msgs == []
2069
2070 # Transfer 1 completes — now ``_has_inflight_for("req-1")`` is False
2071 # and the elif branch in collect_results fires _finalize(success=False).
2072 # The k1 success result is direct; the k2 failure result is queued
2073 # in _pending_store_results and surfaces on the NEXT poll.
2074 transport._poll_done.append(tid_1)
2075 stores_first = session.poll().stores
2076 assert StoreResult(job_id=100, success=True) in stores_first
2077 # Outbound state cleaned up; peer notified with success=False.
2078 assert _srv_outbound(session, "req-1") is None
2079 done = next(m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE)
2080 assert done[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
2081 assert done[TransferDoneMsg.SUCCESS] is False
2082
2083 # Next poll drains the queued failure.
2084 stores_second = session.poll().stores
2085 assert StoreResult(job_id=200, success=False) in stores_second
2086
2087
2088 # ---------------------------------------------------------------------------
2089 # Bidirectional — the case the unification is meant to fix
2090 # ---------------------------------------------------------------------------
2091
2092
2093 class TestBidirectional:
2094 def test_session_handles_both_roles_concurrently(self):
2095 """Single session simultaneously serves a fetch and completes a load.
2096
2097 This is the regression test for the unification: with the old
2098 split design the inbound FetchMsg would be dispatched to a
2099 client-only session and dropped (or a server-only session would
2100 miss the TransferDoneMsg). One unified session handles both.
2101 """
2102 session, conn, transport = _make_session()
2103 _activate(session, conn)
2104
2105 # Server role: the peer fetches a block from us.
2106 session.add_stored_blocks("req-srv", [b"served"], [0], job_id=100)
2107 conn.enqueue(
2108 {
2109 TYPE_KEY: FetchMsg.TYPE,
2110 FetchMsg.ROUND_SEQ: 0,
2111 FetchMsg.KV_REQUEST_ID: "req-srv",
2112 FetchMsg.KEYS: [b"served"],
2113 FetchMsg.BLOCK_INDEXES: [7],
2114 }
2115 )
2116
2117 # Client role: we ask the peer for a different block.
2118 session.request_blocks(
2119 job_id=200, kv_request_id="req-cli", keys=[b"loaded"], block_ids=[3]
2120 )
2121
2122 # Both flows progress in the same poll.
2123 session.poll()
2124
2125 # Server side: write_blocks was submitted.
2126 assert len(transport._transfers) == 1
2127 # Client side: the lookup was sent.
2128 assert any(
2129 m[TYPE_KEY] == FetchMsg.TYPE and m[FetchMsg.KV_REQUEST_ID] == "req-cli"
2130 for m in conn._sent
2131 )
2132
2133 # Peer now signals: server-side transfer completes AND a
2134 # TransferDoneMsg arrives for our client-side request, all in
2135 # one batch on the same connection.
2136 tid = next(iter(transport._transfers))
2137 transport._poll_done.append(tid)
2138 conn.enqueue(
2139 {
2140 TYPE_KEY: TransferDoneMsg.TYPE,
2141 TransferDoneMsg.ROUND_SEQ: 0,
2142 TransferDoneMsg.KV_REQUEST_ID: "req-cli",
2143 TransferDoneMsg.SUCCESS: True,
2144 }
2145 )
2146 result_ = session.poll()
2147 loads = result_.loads
2148 stores = result_.stores
2149
2150 assert LoadResult(job_id=200, kv_request_id="req-cli", success=True) in loads
2151 assert StoreResult(job_id=100, success=True) in stores
2152
2153
2154 # ---------------------------------------------------------------------------
2155 # Pending sessions (no connection yet)
2156 # ---------------------------------------------------------------------------
2157
2158
2159 class TestPendingSession:
2160 def test_pending_session_buffers_stored_blocks(self):
2161 """Pending session accepts add_stored_blocks but cannot send."""
2162 transport = FakeDataTransport()
2163 session = P2PSession(
2164 peer_id="peer:8000",
2165 local_id="local:9000",
2166 transport=transport,
2167 local_block_len=4096,
2168 local_hash_seed=_DEFAULT_HASH_SEED,
2169 conn=None,
2170 )
2171 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
2172 result_ = session.poll()
2173 loads = result_.loads
2174 stores = result_.stores
2175 assert loads == []
2176 assert stores == []
2177 assert not session.connected
2178 assert session.alive
2179
2180 def test_attach_connection_sends_connect(self):
2181 """attach_connection triggers our ConnectMsg send."""
2182 transport = FakeDataTransport()
2183 session = P2PSession(
2184 peer_id="peer:8000",
2185 local_id="local:9000",
2186 transport=transport,
2187 local_block_len=4096,
2188 local_hash_seed=_DEFAULT_HASH_SEED,
2189 conn=None,
2190 )
2191 conn = FakeConnection(peer_id="peer:8000")
2192 session.attach_connection(conn)
2193 assert conn._sent
2194 assert conn._sent[0][TYPE_KEY] == ConnectMsg.TYPE
2195
2196 def test_attach_connection_twice_raises(self):
2197 """attach_connection on an already-connected session raises."""
2198 session, conn, _ = _make_session()
2199 with pytest.raises(ValueError, match="already connected"):
2200 session.attach_connection(FakeConnection())
2201
2202 def test_pending_close_returns_pending_stores(self):
2203 """Closing a pending session reports buffered stores as failed."""
2204 transport = FakeDataTransport()
2205 session = P2PSession(
2206 peer_id="peer:8000",
2207 local_id="local:9000",
2208 transport=transport,
2209 local_block_len=4096,
2210 local_hash_seed=_DEFAULT_HASH_SEED,
2211 conn=None,
2212 )
2213 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
2214 session.add_stored_blocks("req-2", [b"k2"], [1], job_id=2)
2215 result = session.close()
2216 assert result.failed_jobs == []
2217 assert result.failed_req_ids == []
2218 assert set(result.failed_stores) == {1, 2}
2219 assert result.failed_serves == []
2220
2221
2222 # ---------------------------------------------------------------------------
2223 # Disconnect
2224 # ---------------------------------------------------------------------------
2225
2226
2227 class TestDisconnect:
2228 def test_disconnect_marks_session_dead(self):
2229 session, conn, _ = _make_session()
2230 _activate(session, conn)
2231 conn.enqueue({TYPE_KEY: DisconnectMsg.TYPE})
2232 session.poll()
2233 assert not session.alive
2234
2235 def test_close_returns_pending_loads_and_stores(self):
2236 session, conn, _ = _make_session()
2237 _activate(session, conn)
2238 session.request_blocks(1, "req-1", [b"k"], [0])
2239 session.request_blocks(2, "req-2", [b"k"], [0])
2240 session.add_stored_blocks("req-srv", [b"k"], [0], job_id=10)
2241 result = session.close()
2242 assert set(result.failed_jobs) == {1, 2}
2243 assert set(result.failed_req_ids) == {"req-1", "req-2"}
2244 assert set(result.failed_stores) == {10}
2245 assert result.failed_serves == []
2246
2247 def test_send_failure_marks_connection_dead(self):
2248 """A raising send must mark the connection dead, not silently drop
2249 the message — otherwise the session lingers alive, is never reaped,
2250 and in-flight lookups/loads toward the dead peer hang forever."""
2251 session, conn, _ = _make_session()
2252 _activate(session, conn)
2253 assert session.alive
2254
2255 conn.fail_send = True
2256 # request_blocks flushes a FetchMsg synchronously via _do_send.
2257 session.request_blocks(1, "req-1", [b"k"], [0])
2258
2259 assert not session.alive
2260
2261 def test_close_surfaces_inflight_lookups(self):
2262 """close() reports kv_request_ids whose symmetric-P2P probe is still
2263 unresolved; resolved probes are not reported (their answer is in)."""
2264 session, conn, _ = _make_session()
2265 _activate(session, conn)
2266
2267 session.register_lookup("req-hit", b"hA")
2268 session.register_lookup("req-inflight", b"hB")
2269 session.flush_pending_lookups()
2270
2271 # Only req-hit is answered; req-inflight stays in flight.
2272 conn.enqueue(
2273 {
2274 TYPE_KEY: LookupRespMsg.TYPE,
2275 LookupRespMsg.KV_REQUEST_ID: "req-hit",
2276 LookupRespMsg.KEYS: [b"hA"],
2277 LookupRespMsg.HITS: [True],
2278 }
2279 )
2280 session.poll()
2281
2282 result = session.close()
2283 assert result.failed_jobs == []
2284 assert result.failed_req_ids == ["req-inflight"]
2285
2286
2287 # ---------------------------------------------------------------------------
2288 # Adversarial / malformed messages
2289 # ---------------------------------------------------------------------------
2290
2291
2292 class TestAdversarial:
2293 def test_unknown_message_type_logged(self):
2294 session, conn, _ = _make_session()
2295 _activate(session, conn)
2296 conn.enqueue({TYPE_KEY: "evil_command"})
2297 result_ = session.poll()
2298 loads = result_.loads
2299 stores = result_.stores
2300 assert loads == []
2301 assert stores == []
2302
2303 def test_empty_message(self):
2304 session, conn, _ = _make_session()
2305 _activate(session, conn)
2306 conn.enqueue({})
2307 result_ = session.poll()
2308 loads = result_.loads
2309 stores = result_.stores
2310 assert loads == []
2311 assert stores == []
2312
2313 def test_non_dict_message(self):
2314 session, conn, _ = _make_session()
2315 _activate(session, conn)
2316 conn._inbox.append(42)
2317 result_ = session.poll()
2318 loads = result_.loads
2319 stores = result_.stores
2320 assert loads == []
2321 assert stores == []
2322
2323 def test_fetch_mismatched_lengths(self):
2324 session, conn, transport = _make_session()
2325 _activate(session, conn)
2326 conn.enqueue(
2327 {
2328 TYPE_KEY: FetchMsg.TYPE,
2329 FetchMsg.ROUND_SEQ: 0,
2330 FetchMsg.KV_REQUEST_ID: "req-bad",
2331 FetchMsg.KEYS: [b"k1", b"k2"],
2332 FetchMsg.BLOCK_INDEXES: [1],
2333 }
2334 )
2335 session.poll()
2336 assert len(transport._transfers) == 0
2337 # Protocol violation: session disconnects immediately so the peer
2338 # can't keep wedging us with malformed traffic.
2339 assert not session.alive
2340 assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
2341
2342 def test_transfer_done_missing_kv_request_id(self):
2343 session, conn, _ = _make_session()
2344 _activate(session, conn)
2345 conn.enqueue({TYPE_KEY: TransferDoneMsg.TYPE, TransferDoneMsg.SUCCESS: True})
2346 loads = session.poll().loads
2347 assert loads == []
2348 assert not session.alive
2349 assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
2350
2351 def test_duplicate_connect_ack(self):
2352 session, conn, _ = _make_session()
2353 _activate(session, conn)
2354 conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: "peer:8000"})
2355 session.poll()
2356 assert session.ready
2357
2358
2359 class TestDispatchErrorHandling:
2360 """Errors raised by message handlers split into two classes:
2361
2362 - Protocol-contract violations from the peer (ValueError) → disconnect
2363 on the first occurrence; retrying won't help and may corrupt state.
2364 - Anything else is treated as an internal bug: log loudly, count, and
2365 only disconnect once errors arrive in a tight burst. A successful
2366 dispatch in between resets the counter.
2367 """
2368
2369 def test_value_error_disconnects_on_first_occurrence(self):
2370 """A FetchMsg that fails validate() raises ValueError and must
2371 terminate the session immediately, with a DisconnectMsg sent."""
2372 session, conn, _ = _make_session()
2373 _activate(session, conn)
2374 # length mismatch → FetchMsg.validate raises ValueError
2375 conn.enqueue(
2376 {
2377 TYPE_KEY: FetchMsg.TYPE,
2378 FetchMsg.ROUND_SEQ: 0,
2379 FetchMsg.KV_REQUEST_ID: "req-bad",
2380 FetchMsg.KEYS: [b"k1", b"k2"],
2381 FetchMsg.BLOCK_INDEXES: [1],
2382 }
2383 )
2384 session.poll()
2385 assert not session.alive
2386 assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
2387
2388 def test_transfer_done_missing_field_disconnects(self):
2389 """Same contract for a malformed TransferDoneMsg from the peer."""
2390 session, conn, _ = _make_session()
2391 _activate(session, conn)
2392 conn.enqueue({TYPE_KEY: TransferDoneMsg.TYPE, TransferDoneMsg.SUCCESS: True})
2393 session.poll()
2394 assert not session.alive
2395
2396 def test_internal_error_does_not_disconnect_once(self):
2397 """A non-ValueError raised by a handler is treated as an internal
2398 bug: counter increments, session stays alive on a single hit."""
2399 session, conn, _ = _make_session()
2400 _activate(session, conn)
2401
2402 def _boom(*args, **kwargs):
2403 raise RuntimeError("simulated internal bug")
2404
2405 session._server.on_fetch = _boom
2406 conn.enqueue(
2407 {
2408 TYPE_KEY: FetchMsg.TYPE,
2409 FetchMsg.ROUND_SEQ: 0,
2410 FetchMsg.KV_REQUEST_ID: "req-1",
2411 FetchMsg.KEYS: [b"k1"],
2412 FetchMsg.BLOCK_INDEXES: [0],
2413 }
2414 )
2415 session.poll()
2416 assert session.alive
2417 assert session._dispatch_error_count == 1
2418
2419 def test_internal_error_threshold_disconnects(self):
2420 """Once consecutive non-protocol errors hit the threshold, the
2421 session tears down via _protocol_error."""
2422 session, conn, _ = _make_session()
2423 _activate(session, conn)
2424
2425 def _boom(*args, **kwargs):
2426 raise RuntimeError("simulated internal bug")
2427
2428 session._server.on_fetch = _boom
2429 for _ in range(_MAX_CONSECUTIVE_DISPATCH_ERRORS):
2430 conn.enqueue(
2431 {
2432 TYPE_KEY: FetchMsg.TYPE,
2433 FetchMsg.ROUND_SEQ: 0,
2434 FetchMsg.KV_REQUEST_ID: "req-1",
2435 FetchMsg.KEYS: [b"k1"],
2436 FetchMsg.BLOCK_INDEXES: [0],
2437 }
2438 )
2439 session.poll()
2440 assert not session.alive
2441 assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
2442
2443 def test_internal_error_counter_resets_on_success(self):
2444 """A successful dispatch between errors prevents the threshold
2445 from being reached."""
2446 session, conn, _ = _make_session()
2447 _activate(session, conn)
2448
2449 original_on_fetch = session._server.on_fetch
2450
2451 def _boom(*args, **kwargs):
2452 raise RuntimeError("simulated internal bug")
2453
2454 # Alternate (boom, success) (_MAX-1) times: counter rises to 1
2455 # then resets to 0 each cycle, never reaching the threshold.
2456 for _ in range(_MAX_CONSECUTIVE_DISPATCH_ERRORS - 1):
2457 session._server.on_fetch = _boom
2458 conn.enqueue(
2459 {
2460 TYPE_KEY: FetchMsg.TYPE,
2461 FetchMsg.ROUND_SEQ: 0,
2462 FetchMsg.KV_REQUEST_ID: "req-1",
2463 FetchMsg.KEYS: [b"k1"],
2464 FetchMsg.BLOCK_INDEXES: [0],
2465 }
2466 )
2467 session.poll()
2468 session._server.on_fetch = original_on_fetch
2469 # A benign no-op message (unknown type) dispatches cleanly
2470 # and resets the consecutive-error counter.
2471 conn.enqueue({TYPE_KEY: "unknown_for_test"})
2472 session.poll()
2473
2474 assert session.alive
2475 assert session._dispatch_error_count == 0
2476
2477
2478 class TestInflightPerReqInvariant:
2479 """Per-request `inflight_tids` is the O(1) replacement for the
2480 previous O(N) scan in `_has_inflight_for`. These tests check that
2481 every mutation site keeps the set in sync with `_inflight` and
2482 that the lookup is correct under high fan-out.
2483 """
2484
2485 def test_invariant_holds_through_lifecycle(self):
2486 """Run a full submit→complete sequence for two concurrent
2487 kv_request_ids and assert the counter matches `_inflight` at
2488 every observable step, including the empty-after-finish case."""
2489 session, conn, transport = _make_session()
2490 _activate(session, conn)
2491
2492 def _invariant_holds() -> bool:
2493 return _srv_total_inflight(session) == len(session._server._inflight)
2494
2495 assert _invariant_holds()
2496
2497 # Two requests, two blocks each, all dispatched in one batch.
2498 session.add_stored_blocks("req-A", [b"a1", b"a2"], [0, 1], job_id=10)
2499 session.add_stored_blocks("req-B", [b"b1", b"b2"], [2, 3], job_id=11)
2500 for kv_id, keys, indexes in (
2501 ("req-A", [b"a1", b"a2"], [100, 101]),
2502 ("req-B", [b"b1", b"b2"], [102, 103]),
2503 ):
2504 conn.enqueue(
2505 {
2506 TYPE_KEY: FetchMsg.TYPE,
2507 FetchMsg.ROUND_SEQ: 0,
2508 FetchMsg.KV_REQUEST_ID: kv_id,
2509 FetchMsg.KEYS: keys,
2510 FetchMsg.BLOCK_INDEXES: indexes,
2511 }
2512 )
2513 session.poll()
2514
2515 assert _invariant_holds()
2516 assert session._server._has_inflight_for("req-A")
2517 assert session._server._has_inflight_for("req-B")
2518 assert not session._server._has_inflight_for("req-C")
2519
2520 # Complete req-A's transfer first; req-B should still be inflight.
2521 a_tids = [
2522 tid
2523 for tid, x in session._server._inflight.items()
2524 if x.kv_request_id == "req-A"
2525 ]
2526 for tid in a_tids:
2527 transport._poll_done.append(tid)
2528 session.poll()
2529
2530 assert _invariant_holds()
2531 assert not session._server._has_inflight_for("req-A")
2532 assert _srv_inflight_count(session, "req-A") == 0 # entry drained
2533 assert session._server._has_inflight_for("req-B")
2534
2535 # Complete req-B; counter must drain to empty.
2536 b_tids = [
2537 tid
2538 for tid, x in session._server._inflight.items()
2539 if x.kv_request_id == "req-B"
2540 ]
2541 for tid in b_tids:
2542 transport._poll_done.append(tid)
2543 session.poll()
2544
2545 assert _invariant_holds()
2546 assert session._server._inflight == {}
2547 assert _srv_total_inflight(session) == 0
2548
2549 def test_has_inflight_for_correct_with_many_requests(self):
2550 """Populate many inflight xfers across many ids; lookup must
2551 match the actual presence in `_inflight` for both hits and
2552 misses. The whole point of the counter is that this lookup is
2553 constant-time, but we assert correctness, not timing."""
2554 session, _, _ = _make_session()
2555 for kv_id_idx in range(100):
2556 kv_id = f"req-{kv_id_idx}"
2557 for j in range(10):
2558 tid = kv_id_idx * 10 + j
2559 session._server._inflight_add(
2560 tid,
2561 _InflightXfer(
2562 kv_request_id=kv_id,
2563 block_count=1,
2564 job_ids={tid},
2565 round=_OutboundRequestState(inflight=1),
2566 ),
2567 )
2568 assert _srv_total_inflight(session) == len(session._server._inflight)
2569 assert session._server._has_inflight_for("req-0")
2570 assert session._server._has_inflight_for("req-99")
2571 assert not session._server._has_inflight_for("req-missing")
2572
2573 # Drain all entries for req-50 and confirm the entry disappears.
2574 tids_50 = [
2575 tid
2576 for tid, x in session._server._inflight.items()
2577 if x.kv_request_id == "req-50"
2578 ]
2579 for tid in tids_50:
2580 session._server._inflight_pop(tid)
2581 assert _srv_inflight_count(session, "req-50") == 0
2582 assert not session._server._has_inflight_for("req-50")
2583 # Other ids unaffected.
2584 assert session._server._has_inflight_for("req-49")
2585
2586
2587 # ---------------------------------------------------------------------------
2588 # Protocol validation tests (unchanged from the prior file)
2589 # ---------------------------------------------------------------------------
2590
2591
2592 class TestConnectMsgValidation:
2593 def _valid_msg(self) -> dict:
2594 return {
2595 TYPE_KEY: ConnectMsg.TYPE,
2596 ConnectMsg.PEER_ID: "peer:1",
2597 ConnectMsg.AGENT_METADATA: b"meta",
2598 ConnectMsg.BASE_ADDR: 0x1000,
2599 ConnectMsg.NUM_BLOCKS: 8,
2600 ConnectMsg.BLOCK_LEN: 4096,
2601 ConnectMsg.HASH_SEED: "0",
2602 }
2603
2604 def test_valid_message_passes(self):
2605 ConnectMsg.validate(self._valid_msg())
2606
2607 def test_missing_peer_id(self):
2608 msg = self._valid_msg()
2609 del msg[ConnectMsg.PEER_ID]
2610 with pytest.raises(ValueError, match="peer_id"):
2611 ConnectMsg.validate(msg)
2612
2613 def test_missing_agent_metadata(self):
2614 msg = self._valid_msg()
2615 del msg[ConnectMsg.AGENT_METADATA]
2616 with pytest.raises(ValueError, match="agent_metadata"):
2617 ConnectMsg.validate(msg)
2618
2619 def test_agent_metadata_wrong_type(self):
2620 msg = self._valid_msg()
2621 msg[ConnectMsg.AGENT_METADATA] = "not bytes"
2622 with pytest.raises(ValueError, match="agent_metadata"):
2623 ConnectMsg.validate(msg)
2624
2625 def test_base_addr_negative(self):
2626 msg = self._valid_msg()
2627 msg[ConnectMsg.BASE_ADDR] = -1
2628 with pytest.raises(ValueError, match="base_addr"):
2629 ConnectMsg.validate(msg)
2630
2631 def test_num_blocks_zero(self):
2632 msg = self._valid_msg()
2633 msg[ConnectMsg.NUM_BLOCKS] = 0
2634 with pytest.raises(ValueError, match="num_blocks"):
2635 ConnectMsg.validate(msg)
2636
2637 def test_block_len_zero(self):
2638 msg = self._valid_msg()
2639 msg[ConnectMsg.BLOCK_LEN] = 0
2640 with pytest.raises(ValueError, match="block_len"):
2641 ConnectMsg.validate(msg)
2642
2643 def test_missing_hash_seed(self):
2644 msg = self._valid_msg()
2645 del msg[ConnectMsg.HASH_SEED]
2646 with pytest.raises(ValueError, match="hash_seed"):
2647 ConnectMsg.validate(msg)
2648
2649 def test_hash_seed_wrong_type(self):
2650 msg = self._valid_msg()
2651 msg[ConnectMsg.HASH_SEED] = 12345 # int, not str
2652 with pytest.raises(ValueError, match="hash_seed"):
2653 ConnectMsg.validate(msg)
2654
2655
2656 class TestFetchMsgValidation:
2657 def _valid_msg(self) -> dict:
2658 return {
2659 TYPE_KEY: FetchMsg.TYPE,
2660 FetchMsg.ROUND_SEQ: 0,
2661 FetchMsg.KV_REQUEST_ID: "req-1",
2662 FetchMsg.KEYS: [b"k1", b"k2"],
2663 FetchMsg.BLOCK_INDEXES: [0, 1],
2664 }
2665
2666 def test_valid_message_passes(self):
2667 FetchMsg.validate(self._valid_msg())
2668
2669 def test_length_mismatch(self):
2670 msg = self._valid_msg()
2671 msg[FetchMsg.BLOCK_INDEXES] = [0]
2672 with pytest.raises(ValueError, match="length mismatch"):
2673 FetchMsg.validate(msg)
2674
2675 def test_negative_index(self):
2676 msg = self._valid_msg()
2677 msg[FetchMsg.BLOCK_INDEXES] = [0, -1]
2678 with pytest.raises(ValueError, match="invalid index"):
2679 FetchMsg.validate(msg)
2680
2681
2682 class TestTransferDoneMsgValidation:
2683 def test_valid_message_passes(self):
2684 msg = {
2685 TYPE_KEY: TransferDoneMsg.TYPE,
2686 TransferDoneMsg.ROUND_SEQ: 0,
2687 TransferDoneMsg.KV_REQUEST_ID: "req-1",
2688 TransferDoneMsg.SUCCESS: True,
2689 }
2690 TransferDoneMsg.validate(msg)
2691
2692 def test_success_wrong_type(self):
2693 msg = {
2694 TYPE_KEY: TransferDoneMsg.TYPE,
2695 TransferDoneMsg.ROUND_SEQ: 0,
2696 TransferDoneMsg.KV_REQUEST_ID: "req-1",
2697 TransferDoneMsg.SUCCESS: 1,
2698 }
2699 with pytest.raises(ValueError, match="success"):
2700 TransferDoneMsg.validate(msg)