main
py 1,510 lines 57 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for TieringOffloadingManager and ExampleSecondaryTierManager.
4
5 These tests verify:
6 1. Basic tiered offloading operations (store, load, lookup)
7 2. Cascade behavior (chunks stored to all secondary tiers)
8 3. Promotion behavior (chunks loaded from secondary to primary to GPU)
9 4. ref_cnt management (chunks protected during async transfers)
10 5. Eviction coordination between tiers
11 """
12
13 from collections.abc import Iterable
14 from unittest.mock import MagicMock
15
16 import numpy as np
17 import pytest
18 import torch
19
20 from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
21 OffloadingConnectorStats,
22 )
23 from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
24 _parse_tier_filter,
25 )
26 from vllm.v1.kv_offload.base import (
27 Locality,
28 LookupResult,
29 Medium,
30 OffloadingCounterMetadata,
31 OffloadingEvent,
32 OffloadingGaugeMetadata,
33 OffloadKey,
34 OffloadPolicy,
35 ReqContext,
36 RequestOffloadingContext,
37 ScheduleEndContext,
38 TierFilter,
39 TierMatcher,
40 make_offload_key,
41 )
42 from vllm.v1.kv_offload.cpu.policies.lru import LRUCachePolicy
43 from vllm.v1.kv_offload.tiering.base import (
44 JobResult,
45 SecondaryTierManager,
46 TieringOffloadingMetrics,
47 TransferJob,
48 )
49 from vllm.v1.kv_offload.tiering.example.manager import ExampleSecondaryTierManager
50 from vllm.v1.kv_offload.tiering.factory import SecondaryTierFactory
51 from vllm.v1.kv_offload.tiering.manager import (
52 CPUPrimaryTierOffloadingManager,
53 TieringOffloadingManager,
54 )
55 from vllm.v1.kv_offload.tiering.spec import TieringOffloadingSpec
56
57 _CTX = ReqContext(req_id="test")
58 _MOCK_OFFLOADING_SPEC = MagicMock()
59
60
61 def _mock_mmap_region(num_chunks: int, row_bytes: int = 16):
62 """Create a mock SharedOffloadRegion for testing."""
63 mock = MagicMock()
64 view = memoryview(torch.zeros((num_chunks, row_bytes), dtype=torch.int8).numpy())
65 mock.create_kv_memoryview.return_value = view
66 return mock
67
68
69 def to_keys(int_ids: Iterable[int]) -> list[OffloadKey]:
70 return [make_offload_key(str(i).encode(), 0) for i in int_ids]
71
72
73 def count_hits(manager, keys: list[OffloadKey]) -> int | None:
74 """Count consecutive lookup hits from the start of keys.
75
76 Returns the count of leading HIT results, or None if any lookup
77 returns HIT_PENDING or RETRY.
78 """
79 count = 0
80 for key in keys:
81 result = manager.lookup(key, _CTX)
82 if result in (LookupResult.HIT_PENDING, LookupResult.RETRY):
83 return None
84 if result is not LookupResult.HIT:
85 break
86 count += 1
87 return count
88
89
90 def histogram_count_key(metric_name: str, labelvalues: tuple[str, ...]) -> str:
91 return f"{metric_name}:{labelvalues}_count"
92
93
94 def has_histogram_count(reduced: dict[str, int | float], metric_name: str) -> bool:
95 return any(
96 key.startswith(f"{metric_name}:") and key.endswith("_count") for key in reduced
97 )
98
99
100 class MetricsSecondaryTierManager(SecondaryTierManager):
101 """Test-only secondary tier that declares and emits one labeled metric."""
102
103 MY_TIER_METRIC = "my_tier_metric"
104
105 @classmethod
106 def build_metric_definitions(cls, extra_config):
107 return {
108 cls.MY_TIER_METRIC: OffloadingCounterMetadata(
109 documentation="Number of bytes served by the test tier.",
110 labelnames=("tier",),
111 )
112 }
113
114 def __init__(self, *args, **kwargs):
115 super().__init__(*args, **kwargs)
116 self.stats: OffloadingConnectorStats | None = None
117 self.lookup_result = LookupResult.MISS
118 self.finished_jobs: list[JobResult] = []
119 self.submitted_loads: list[TransferJob] = []
120 self.submitted_stores: list[TransferJob] = []
121
122 def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
123 return self.lookup_result
124
125 def submit_store(self, job_metadata: TransferJob) -> None:
126 self.submitted_stores.append(job_metadata)
127 return
128
129 def submit_load(self, job_metadata: TransferJob) -> None:
130 self.submitted_loads.append(job_metadata)
131 return
132
133 def get_finished_jobs(self) -> Iterable[JobResult]:
134 results = self.finished_jobs
135 self.finished_jobs = []
136 return results
137
138 def drain_jobs(self) -> None:
139 return
140
141 def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
142 return RequestOffloadingContext()
143
144 def get_stats(self) -> OffloadingConnectorStats | None:
145 stats = self.stats
146 self.stats = None
147 return stats
148
149
150 def test_tiering_spec_collects_secondary_metric_definitions(monkeypatch):
151 monkeypatch.setitem(
152 SecondaryTierFactory._registry,
153 "test_metrics",
154 lambda: MetricsSecondaryTierManager,
155 )
156
157 metrics = TieringOffloadingSpec.build_metric_definitions(
158 {"secondary_tiers": [{"type": "test_metrics"}]}
159 )
160
161 metadata = metrics[MetricsSecondaryTierManager.MY_TIER_METRIC]
162 assert metadata.documentation == "Number of bytes served by the test tier."
163 assert metadata.labelnames == ("tier",)
164 assert metrics[TieringOffloadingMetrics.CHUNK_QUERIES].labelnames == ("tier",)
165 assert metrics[TieringOffloadingMetrics.LOOKUP_SYNC_DELAY].labelnames == ("tier",)
166 assert metrics[TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY].labelnames == ("tier",)
167 assert metrics[TieringOffloadingMetrics.PROMOTION_JOB_FAILURES].labelnames == (
168 "tier",
169 )
170 assert isinstance(
171 metrics[TieringOffloadingMetrics.PROMOTION_ALLOCATION_FAILURES],
172 OffloadingCounterMetadata,
173 )
174 for metric_name in (
175 TieringOffloadingMetrics.PRIMARY_WRITE_USAGE_PERC,
176 TieringOffloadingMetrics.PRIMARY_READ_USAGE_PERC,
177 TieringOffloadingMetrics.ACTIVE_PROMOTION_JOBS,
178 TieringOffloadingMetrics.ACTIVE_CASCADE_JOBS,
179 ):
180 assert isinstance(metrics[metric_name], OffloadingGaugeMetadata)
181 assert metrics[metric_name].labelnames == ("tier",)
182
183
184 def test_tiering_manager_aggregates_secondary_stats():
185 mock_region = _mock_mmap_region(5)
186 primary_tier = CPUPrimaryTierOffloadingManager(
187 num_chunks=5, mmap_region=mock_region
188 )
189 secondary_tier = MetricsSecondaryTierManager(
190 offloading_spec=_MOCK_OFFLOADING_SPEC,
191 primary_kv_view=mock_region.create_kv_memoryview(),
192 tier_type="test_metrics",
193 )
194 secondary_stats = OffloadingConnectorStats()
195 secondary_stats.increase_counter(
196 MetricsSecondaryTierManager.MY_TIER_METRIC, 7, ("test_metrics",)
197 )
198 secondary_tier.stats = secondary_stats
199 manager = TieringOffloadingManager(
200 primary_tier=primary_tier,
201 secondary_tiers=[secondary_tier],
202 )
203
204 stats = manager.get_stats()
205
206 assert stats is not None
207 assert (
208 stats.data["data"][MetricsSecondaryTierManager.MY_TIER_METRIC][
209 ("test_metrics",)
210 ]
211 == 7
212 )
213
214 # The primary tier's cache-usage gauge is always reported, so get_stats()
215 # never returns None, but the secondary tier has nothing new to report
216 # once its stats have been consumed.
217 second_stats = manager.get_stats()
218 assert second_stats is not None
219 assert MetricsSecondaryTierManager.MY_TIER_METRIC not in second_stats.data["data"]
220
221
222 class TestExampleSecondaryTierManager:
223 """Tests for ExampleSecondaryTierManager implementation."""
224
225 def test_basic_store_and_lookup(self):
226 """Test basic store and lookup operations."""
227 mock_view = memoryview(torch.zeros((10, 16), dtype=torch.int8).numpy())
228 tier = ExampleSecondaryTierManager(
229 offloading_spec=_MOCK_OFFLOADING_SPEC,
230 primary_kv_view=mock_view,
231 tier_type="example",
232 custom_param=67,
233 )
234
235 # Initially empty
236 chunks = to_keys(range(3))
237 assert tier.lookup(chunks[0], _CTX) is LookupResult.MISS
238
239 # Store chunks (simulate with direct insertion for testing)
240 tier.chunks[chunks[0]] = True
241 tier.chunks[chunks[1]] = True
242
243 # Lookup should find first two chunks
244 assert tier.lookup(chunks[0], _CTX) is LookupResult.HIT
245 assert tier.lookup(chunks[1], _CTX) is LookupResult.HIT
246
247 # Third chunk not present
248 assert tier.lookup(chunks[2], _CTX) is LookupResult.MISS
249
250
251 # What a request-level cascade does with a key already present in the primary
252 # tier, keyed by what primary lookup says about it. SUPPLY reaches the peer now,
253 # DEFER is retried next step, DROP is never offered again, permanently so since
254 # prepare_store counts the key as stored and the scheduler advances past its
255 # chunk. A new LookupResult has to declare which it is, and the test named
256 # beside it is where that behavior is actually exercised.
257 _CASCADE_SUPPLY_BEHAVIOR = {
258 # test_cascade_defers_keys_whose_primary_write_is_in_flight, after the
259 # writer's complete_store lands.
260 LookupResult.HIT: "supply",
261 # The same test, before it lands.
262 LookupResult.HIT_PENDING: "defer",
263 # test_cascade_drops_deferred_keys_whose_primary_write_failed, where the
264 # failed write frees the chunk.
265 LookupResult.MISS: "drop",
266 # test_cascade_rejects_retry_from_primary. The primary tier resolves every
267 # key it holds, so RETRY cannot reach the cascade, and parking on it would
268 # have no guarantee of draining, which is what makes parking HIT_PENDING
269 # safe. It is the one case no real primary tier can produce.
270 LookupResult.RETRY: "unreachable",
271 }
272
273
274 def test_every_lookup_result_has_a_declared_cascade_behavior():
275 """A new LookupResult must state what the cascade does with it, rather
276 than falling into whichever branch happens to catch it."""
277 assert set(_CASCADE_SUPPLY_BEHAVIOR) == set(LookupResult)
278
279
280 class TestTieringOffloadingManager:
281 """Tests for TieringOffloadingManager."""
282
283 @pytest.fixture
284 def manager_setup(self):
285 # Create primary tier (CPU-based)
286 mock_region = _mock_mmap_region(5)
287 self.primary_tier = CPUPrimaryTierOffloadingManager(
288 num_chunks=5, mmap_region=mock_region
289 )
290
291 mock_view = mock_region.create_kv_memoryview()
292
293 # Create secondary tiers with the primary view
294 self.secondary_tier1 = ExampleSecondaryTierManager(
295 offloading_spec=_MOCK_OFFLOADING_SPEC,
296 primary_kv_view=mock_view,
297 tier_type="example",
298 )
299 self.secondary_tier2 = ExampleSecondaryTierManager(
300 offloading_spec=_MOCK_OFFLOADING_SPEC,
301 primary_kv_view=mock_view,
302 tier_type="example",
303 )
304
305 # Create tiered manager
306 self.manager = TieringOffloadingManager(
307 primary_tier=self.primary_tier,
308 secondary_tiers=[self.secondary_tier1, self.secondary_tier2],
309 )
310
311 def _simulate_on_schedule_end(self, new_req_ids: list[str] | None = None):
312 """Simulate end of scheduler step: lifecycle flush + drain events."""
313 ctx = ScheduleEndContext(new_req_ids=new_req_ids or [], preempted_req_ids=())
314 self.manager.on_schedule_end(ctx)
315 list(self.manager.take_events())
316
317 def _start_request(self, req_context: ReqContext = _CTX):
318 if req_context.req_id not in self.manager._req_state:
319 self.manager.on_new_request(req_context)
320
321 def test_failed_promotion_finalizes_primary_with_failure(self, manager_setup):
322 """A failed promotion still finalizes the primary slots with
323 success=False; the tier corrects its own verdict."""
324 from unittest.mock import patch
325
326 from vllm.v1.kv_offload.tiering.base import JobResult
327
328 self._start_request()
329 # Register an in-flight promotion job for tier1 by hand.
330 job_id = self.manager._next_job_id()
331 self.manager._register_job(
332 TransferJob(
333 job_id=job_id,
334 keys=to_keys([1, 2]),
335 chunk_ids=np.array([0, 1], dtype=np.int64),
336 is_promotion=True,
337 req_context=_CTX,
338 ),
339 0,
340 )
341 failed = JobResult(job_id=job_id, success=False)
342 with (
343 patch.object(
344 self.secondary_tier1, "get_finished_jobs", return_value=[failed]
345 ),
346 patch.object(self.primary_tier, "complete_write") as completed,
347 ):
348 self.manager._process_finished_jobs()
349 completed.assert_called_once_with(to_keys([1, 2]), _CTX, False)
350
351 def test_successful_promotion_finalizes_primary_with_success(self, manager_setup):
352 from unittest.mock import patch
353
354 from vllm.v1.kv_offload.tiering.base import JobResult
355
356 self._start_request()
357 job_id = self.manager._next_job_id()
358 self.manager._register_job(
359 TransferJob(
360 job_id=job_id,
361 keys=to_keys([1]),
362 chunk_ids=np.array([0], dtype=np.int64),
363 is_promotion=True,
364 req_context=_CTX,
365 ),
366 0,
367 )
368 ok = JobResult(job_id=job_id, success=True)
369 with (
370 patch.object(self.secondary_tier1, "get_finished_jobs", return_value=[ok]),
371 patch.object(self.primary_tier, "complete_write") as completed,
372 ):
373 self.manager._process_finished_jobs()
374 completed.assert_called_once_with(to_keys([1]), _CTX, True)
375
376 def test_take_events_aggregates_tier_owned_events(self, manager_setup):
377 primary_event = OffloadingEvent(to_keys([1]), Medium.CPU, removed=False)
378 secondary_event1 = OffloadingEvent(to_keys([2]), Medium.STORAGE, removed=False)
379 secondary_event2 = OffloadingEvent(to_keys([3]), Medium.STORAGE, removed=True)
380
381 self.primary_tier.take_events = MagicMock(return_value=[primary_event])
382 self.secondary_tier1.take_events = MagicMock(return_value=[secondary_event1])
383 self.secondary_tier2.take_events = MagicMock(return_value=[secondary_event2])
384
385 assert list(self.manager.take_events()) == [
386 primary_event,
387 secondary_event1,
388 secondary_event2,
389 ]
390 self.primary_tier.take_events.assert_called_once_with()
391 self.secondary_tier1.take_events.assert_called_once_with()
392 self.secondary_tier2.take_events.assert_called_once_with()
393
394 def test_basic_store_to_primary(self, manager_setup):
395 """Test basic store operation to primary tier."""
396 chunks = to_keys(range(3))
397
398 # Prepare store
399 self._start_request()
400 result = self.manager.prepare_store(chunks, _CTX)
401 assert result is not None
402 assert len(result.keys_to_store) == 3
403
404 # Complete store
405 self.manager.complete_store(chunks, _CTX, success=True)
406
407 # Chunks should be in primary tier
408 assert count_hits(self.primary_tier, chunks) == 3
409
410 def test_cascade_to_all_secondary_tiers(self, manager_setup):
411 """Test that chunks are cascaded to ALL secondary tiers."""
412 chunks = to_keys(range(3))
413
414 self.secondary_tier1.submit_store = MagicMock(
415 wraps=self.secondary_tier1.submit_store
416 )
417 self.secondary_tier2.submit_store = MagicMock(
418 wraps=self.secondary_tier2.submit_store
419 )
420
421 # Store to primary
422 self._start_request()
423 result = self.manager.prepare_store(chunks, _CTX)
424 assert result is not None
425
426 # Complete store (triggers cascade via submit_store on each tier)
427 self.manager.complete_store(chunks, _CTX, success=True)
428
429 # submit_store was called once per secondary tier
430 self.secondary_tier1.submit_store.assert_called_once()
431 self.secondary_tier2.submit_store.assert_called_once()
432
433 # Chunks should be in both secondary tiers
434 assert self.secondary_tier1.get_num_chunks() == 3
435 assert self.secondary_tier2.get_num_chunks() == 3
436
437 # Verify chunks are present
438 assert all(
439 self.secondary_tier1.lookup(c, _CTX) is LookupResult.HIT for c in chunks
440 )
441 assert all(
442 self.secondary_tier2.lookup(c, _CTX) is LookupResult.HIT for c in chunks
443 )
444
445 def test_ref_cnt_protection_during_cascade(self, manager_setup):
446 """Test that ref_cnt protects chunks during cascade."""
447 chunks = to_keys(range(3))
448
449 # Store to primary
450 self._start_request()
451 result = self.manager.prepare_store(chunks, _CTX)
452 assert result is not None
453 self.manager.complete_store(chunks, _CTX, success=True)
454
455 # After complete_store, chunks should have ref_cnt > 0
456 # (one for each secondary tier)
457 for key in chunks:
458 chunk = self.primary_tier._policy.get(key)
459 # ref_cnt should be 2 (one for each secondary tier)
460 assert chunk.ref_cnt == 2
461
462 # End of step 1: _maybe_process_finished_jobs() was already called by
463 # prepare_store() above (setting the per-step flag), so on_schedule_end()
464 # does NOT poll get_finished_jobs() again — cascade completions remain
465 # unprocessed until the next step.
466 self._simulate_on_schedule_end()
467
468 # ref_cnt still held: cascade jobs finished (sync tier) but haven't
469 # been polled yet because the per-step guard skipped the second call.
470 for key in chunks:
471 chunk = self.primary_tier._policy.get(key)
472 assert chunk.ref_cnt == 2
473
474 # Secondary tiers have completed jobs waiting to be drained
475 assert len(self.secondary_tier1.completed_jobs) > 0
476 assert len(self.secondary_tier2.completed_jobs) > 0
477
478 # End of step 2: flag was reset, so _maybe_process_finished_jobs()
479 # runs and processes the cascade completions (complete_read → ref_cnt--)
480 self._simulate_on_schedule_end()
481
482 # After cascade completes, ref_cnt should be 0
483 for key in chunks:
484 chunk = self.primary_tier._policy.get(key)
485 assert chunk.ref_cnt == 0
486
487 # All completed jobs have been drained
488 assert len(self.secondary_tier1.completed_jobs) == 0
489 assert len(self.secondary_tier2.completed_jobs) == 0
490
491 def test_lookup_from_primary(self, manager_setup):
492 """Test lookup when chunks are in primary tier."""
493 chunks = to_keys(range(3))
494
495 # Store chunks
496 self._start_request()
497 self.manager.prepare_store(chunks, _CTX)
498 self.manager.complete_store(chunks, _CTX, success=True)
499
500 # Lookup should find all chunks in primary
501 assert count_hits(self.manager, chunks) == 3
502
503 def test_promotion_from_secondary(self, manager_setup):
504 """Test promotion of chunks from secondary to primary tier."""
505 chunks = to_keys(range(3))
506
507 # Manually add chunks to secondary tier (simulate previous cascade)
508 for chunk in chunks:
509 self.secondary_tier1.chunks[chunk] = True
510
511 # Lookup each chunk to initiate promotion for all of them
512 for chunk in chunks:
513 result = self.manager.lookup(chunk, _CTX)
514 assert result is LookupResult.HIT_PENDING # promotion initiated
515
516 # End of step 1: flushes deferred submit_load() calls
517 self._simulate_on_schedule_end()
518
519 # End of step 2: processes the completed promotion jobs
520 self._simulate_on_schedule_end()
521
522 # Now chunks should be in primary tier
523 assert count_hits(self.primary_tier, chunks) == 3
524
525 # Next lookup should succeed
526 assert count_hits(self.manager, chunks) == 3
527
528 @pytest.mark.parametrize(
529 ("successful_indices", "expected_results"),
530 [
531 (
532 (0, 2),
533 [LookupResult.HIT, LookupResult.MISS, LookupResult.HIT],
534 ),
535 (
536 None,
537 [LookupResult.MISS, LookupResult.MISS, LookupResult.MISS],
538 ),
539 ],
540 ids=["partial", "legacy-full-failure"],
541 )
542 def test_failed_promotion_keeps_only_successful_chunks(
543 self, manager_setup, successful_indices, expected_results
544 ):
545 chunks = to_keys(range(3))
546 for chunk in chunks:
547 self.secondary_tier1.chunks[chunk] = True
548
549 def submit_partial(job_metadata: TransferJob) -> None:
550 successful_keys = (
551 None
552 if successful_indices is None
553 else tuple(chunks[i] for i in successful_indices)
554 )
555 self.secondary_tier1.completed_jobs.append(
556 JobResult(
557 job_id=job_metadata.job_id,
558 success=False,
559 successful_keys=successful_keys,
560 )
561 )
562
563 self.secondary_tier1.submit_load = submit_partial
564
565 for chunk in chunks:
566 assert self.manager.lookup(chunk, _CTX) is LookupResult.HIT_PENDING
567
568 self._simulate_on_schedule_end()
569 self._simulate_on_schedule_end()
570
571 assert [
572 self.primary_tier.lookup(chunk, _CTX) for chunk in chunks
573 ] == expected_results
574
575 def test_lookup_reports_sync_delay_for_resolved_lookups(self, manager_setup):
576 """Resolved lookups report one sync delay sample per tier and chunk."""
577 self._start_request()
578 chunks = to_keys(range(2))
579
580 # No tier has these chunks: they resolve immediately as misses.
581 for chunk in chunks:
582 assert self.manager.lookup(chunk, _CTX) is LookupResult.MISS
583
584 stats = self.manager.get_stats()
585 assert stats is not None
586 reduced = stats.reduce()
587 assert (
588 reduced[
589 histogram_count_key(
590 TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("0:primary",)
591 )
592 ]
593 == 2
594 )
595 assert (
596 reduced[
597 histogram_count_key(
598 TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("1:example",)
599 )
600 ]
601 == 2
602 )
603 assert (
604 reduced[
605 histogram_count_key(
606 TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("2:example",)
607 )
608 ]
609 == 2
610 )
611 assert not has_histogram_count(
612 reduced, TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
613 )
614
615 def test_lookup_does_not_report_async_delay_for_promotion(self, manager_setup):
616 """Promotion time is not included in lookup async delay."""
617 self._start_request()
618 chunk = to_keys(range(1))[0]
619 self.secondary_tier1.chunks[chunk] = True
620
621 # First lookup finds the chunk in a secondary tier and defers.
622 assert self.manager.lookup(chunk, _CTX) is LookupResult.HIT_PENDING
623
624 # Promotion is not treated as unresolved secondary lookup time.
625 self._simulate_on_schedule_end(new_req_ids=[_CTX.req_id])
626 stats = self.manager.get_stats()
627 assert stats is not None
628 reduced = stats.reduce()
629 assert (
630 reduced[
631 histogram_count_key(
632 TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("0:primary",)
633 )
634 ]
635 == 1
636 )
637 assert (
638 reduced[
639 histogram_count_key(
640 TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("1:example",)
641 )
642 ]
643 == 1
644 )
645 assert not has_histogram_count(
646 reduced, TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
647 )
648
649 # Promotion completes on the next scheduler step.
650 self._simulate_on_schedule_end()
651
652 # Next lookup resolves via the now-promoted primary-tier chunk.
653 assert self.manager.lookup(chunk, _CTX) is LookupResult.HIT
654
655 stats = self.manager.get_stats()
656 if stats is not None:
657 assert not has_histogram_count(
658 stats.reduce(), TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
659 )
660
661 def test_lookup_reports_async_delay_when_deferred_lookup_resolves(
662 self, manager_setup
663 ):
664 """Async delay is observed when a RETRY lookup later resolves."""
665 ctx = ReqContext(req_id="req_lookup_finish")
666 self._start_request(ctx)
667 chunk = to_keys(range(1))[0]
668 self.secondary_tier1.lookup = MagicMock(
669 side_effect=[LookupResult.RETRY, LookupResult.HIT]
670 )
671
672 # First lookup is deferred by the secondary tier.
673 assert self.manager.lookup(chunk, ctx) is LookupResult.RETRY
674
675 stats = self.manager.get_stats()
676 if stats is not None:
677 assert not has_histogram_count(
678 stats.reduce(), TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY
679 )
680
681 assert self.manager.lookup(chunk, ctx) is LookupResult.HIT_PENDING
682
683 stats = self.manager.get_stats()
684 assert stats is not None
685 reduced = stats.reduce()
686 assert (
687 reduced[
688 histogram_count_key(
689 TieringOffloadingMetrics.LOOKUP_SYNC_DELAY, ("1:example",)
690 )
691 ]
692 == 1
693 )
694 assert (
695 reduced[
696 histogram_count_key(
697 TieringOffloadingMetrics.LOOKUP_ASYNC_DELAY, ("1:example",)
698 )
699 ]
700 == 1
701 )
702
703 def test_partial_lookup(self, manager_setup):
704 """Test lookup with partial hits."""
705 chunks = to_keys(range(5))
706
707 # Store first 3 chunks to primary
708 self._start_request()
709 self.manager.prepare_store(chunks[:3], _CTX)
710 self.manager.complete_store(chunks[:3], _CTX, success=True)
711
712 # Lookup all 5 chunks should return 3 (first 3 found)
713 assert count_hits(self.manager, chunks) == 3
714
715 def test_eviction_in_primary_tier(self, manager_setup):
716 """Test eviction in primary tier when capacity is exceeded."""
717 # Primary tier has capacity of 5 chunks
718 # First, fill the primary tier
719 chunks = to_keys(range(5))
720 self._start_request()
721 result = self.manager.prepare_store(chunks, _CTX)
722 assert result is not None
723 assert len(result.keys_to_store) == 5
724 self.manager.complete_store(chunks, _CTX, success=True)
725
726 # End of step: release ref_cnt from cascade
727 self._simulate_on_schedule_end()
728
729 # Now try to store 2 more chunks (should trigger eviction)
730 more_chunks = to_keys(range(5, 7))
731 result = self.manager.prepare_store(more_chunks, _CTX)
732
733 # Should evict 2 chunks from primary tier
734 assert result is not None
735 assert len(result.evicted_keys) == 2
736 assert len(result.keys_to_store) == 2
737
738 def test_touch_propagates_to_all_tiers(self, manager_setup):
739 """Test that touch() propagates to all tiers."""
740 chunks = to_keys(range(3))
741
742 # Store chunks
743 self._start_request()
744 self.manager.prepare_store(chunks, _CTX)
745 self.manager.complete_store(chunks, _CTX, success=True)
746 self._simulate_on_schedule_end()
747 # for secondary tiers to drain jobs, so primary tier's chunks are evictable.
748 self._simulate_on_schedule_end()
749
750 self.secondary_tier1.touch = MagicMock(wraps=self.secondary_tier1.touch)
751 self.secondary_tier2.touch = MagicMock(wraps=self.secondary_tier2.touch)
752
753 # Touch chunks
754 self.manager.touch(chunks, _CTX)
755
756 # Verify touch was called on the primary tier (head is most recent).
757 policy = self.primary_tier._policy
758 assert isinstance(policy, LRUCachePolicy)
759 ranks = policy._ranks
760 assert ranks[chunks[2]] < ranks[chunks[1]] < ranks[chunks[0]]
761
762 # Verify touch was propagated to all secondary tiers
763 self.secondary_tier1.touch.assert_called_once_with(chunks, _CTX)
764 self.secondary_tier2.touch.assert_called_once_with(chunks, _CTX)
765
766 def test_request_order_survives_late_cascade_completion(self, manager_setup):
767 """Cascade completion must not replace request order with I/O order."""
768 chunks = to_keys(range(5))
769 self._start_request()
770 output = self.manager.prepare_store(chunks, _CTX)
771 assert output is not None
772 self.manager.complete_store(chunks, _CTX, success=True)
773
774 # Finalize while both secondary-tier cascades still pin primary chunks.
775 self.manager.on_request_finished(_CTX)
776 self._simulate_on_schedule_end()
777 self._simulate_on_schedule_end()
778
779 evict_ctx = ReqContext(req_id="evict")
780 self._start_request(evict_ctx)
781 output = self.manager.prepare_store(to_keys([5]), evict_ctx)
782 assert output is not None
783 assert output.evicted_keys == [chunks[-1]]
784
785 def test_late_old_store_submission_does_not_override_newer_request_order(
786 self, manager_setup
787 ):
788 """Cascade submission after finish is not a new primary access."""
789 old_blocks = to_keys(range(2))
790 old_ctx = ReqContext(req_id="old")
791 self._start_request(old_ctx)
792 assert self.manager.prepare_store(old_blocks, old_ctx) is not None
793 self.manager.on_request_finished(old_ctx)
794
795 new_blocks = to_keys(range(2, 5))
796 new_ctx = ReqContext(req_id="new")
797 self._start_request(new_ctx)
798 assert self.manager.prepare_store(new_blocks, new_ctx) is not None
799 self.manager.complete_store(new_blocks, new_ctx, success=True)
800 self.manager.on_request_finished(new_ctx)
801
802 # The old GPU->primary write lands after the newer request finished.
803 # Its cascade pins must not turn it into the more recent request.
804 self.manager.complete_store(old_blocks, old_ctx, success=True)
805 self._simulate_on_schedule_end()
806 self._simulate_on_schedule_end()
807
808 evict_ctx = ReqContext(req_id="evict")
809 self._start_request(evict_ctx)
810 output = self.manager.prepare_store(to_keys([5]), evict_ctx)
811 assert output is not None
812 assert output.evicted_keys == [old_blocks[-1]]
813
814 def test_failed_store_no_cascade(self, manager_setup):
815 """Test that failed GPU→primary store doesn't cascade."""
816 chunks = to_keys(range(3))
817
818 self.secondary_tier1.submit_store = MagicMock(
819 wraps=self.secondary_tier1.submit_store
820 )
821 self.secondary_tier2.submit_store = MagicMock(
822 wraps=self.secondary_tier2.submit_store
823 )
824
825 # Prepare store
826 self._start_request()
827 result = self.manager.prepare_store(chunks, _CTX)
828 assert result is not None
829
830 # Complete store with failure — cascade must not happen
831 self.manager.complete_store(chunks, _CTX, success=False)
832
833 # submit_store was never called on either secondary tier
834 self.secondary_tier1.submit_store.assert_not_called()
835 self.secondary_tier2.submit_store.assert_not_called()
836
837 def test_lookup_batches_submit_load_per_request(self, manager_setup):
838 """lookup() defers submit_load until on_schedule_end(), one per request.
839
840 Chunks from different requests each get their own submit_load call, each
841 carrying the correct req_context.
842 """
843 chunks = to_keys(range(4))
844 for chunk in chunks:
845 self.secondary_tier1.chunks[chunk] = True
846
847 self.secondary_tier1.submit_load = MagicMock(
848 wraps=self.secondary_tier1.submit_load
849 )
850
851 ctx_a = ReqContext(req_id="req_a")
852 ctx_b = ReqContext(req_id="req_b")
853
854 # All lookups return HIT_PENDING: secondary hit triggers promotion
855 assert self.manager.lookup(chunks[0], ctx_a) is LookupResult.HIT_PENDING
856 assert self.manager.lookup(chunks[1], ctx_a) is LookupResult.HIT_PENDING
857 assert self.manager.lookup(chunks[2], ctx_b) is LookupResult.HIT_PENDING
858 assert self.manager.lookup(chunks[3], ctx_b) is LookupResult.HIT_PENDING
859
860 # submit_load must not fire during lookup - only at end of step
861 self.secondary_tier1.submit_load.assert_not_called()
862
863 # simulate end of step
864 self._simulate_on_schedule_end()
865
866 assert self.secondary_tier1.submit_load.call_count == 2
867 calls = self.secondary_tier1.submit_load.call_args_list
868 jm_a = calls[0].args[0]
869 jm_b = calls[1].args[0]
870 assert set(jm_a.keys) == {chunks[0], chunks[1]}
871 assert jm_a.req_context is ctx_a
872 assert set(jm_b.keys) == {chunks[2], chunks[3]}
873 assert jm_b.req_context is ctx_b
874
875 def test_lookup_shared_chunk_no_duplicate_promotion(self, manager_setup):
876 """A chunk looked up by two requests in the same step is promoted once.
877
878 The first lookup initiates promotion (returns None via secondary hit).
879 The second lookup sees ref_cnt=-1 on the primary slot and returns None
880 via the primary in-flight path — without triggering a second promotion.
881 """
882 shared_chunk = to_keys([0])[0]
883 self.secondary_tier1.chunks[shared_chunk] = True
884
885 self.secondary_tier1.submit_load = MagicMock(
886 wraps=self.secondary_tier1.submit_load
887 )
888
889 ctx_a = ReqContext(req_id="req_a")
890 ctx_b = ReqContext(req_id="req_b")
891
892 result_a = self.manager.lookup(shared_chunk, ctx_a)
893 result_b = self.manager.lookup(shared_chunk, ctx_b)
894
895 # Both lookups find the shared_chunk and trigger promotion
896 # returning HIT_PENDING.
897 assert result_a is LookupResult.HIT_PENDING
898 assert result_b is LookupResult.HIT_PENDING
899
900 self._simulate_on_schedule_end()
901
902 # Only one submit_load call despite two lookups
903 self.secondary_tier1.submit_load.assert_called_once()
904 job_metadata = self.secondary_tier1.submit_load.call_args.args[0]
905 assert list(job_metadata.keys) == [shared_chunk]
906 assert job_metadata.req_context is ctx_a
907
908 def test_complete_store_forwards_req_context_to_submit_store(self, manager_setup):
909 """complete_store cascades to secondary tiers with the correct req_context."""
910 chunks = to_keys(range(2))
911
912 self.secondary_tier1.submit_store = MagicMock(
913 wraps=self.secondary_tier1.submit_store
914 )
915
916 ctx = ReqContext(req_id="req_ctx", kv_transfer_params={"key": "value"})
917
918 self._start_request(ctx)
919 self.manager.prepare_store(chunks, ctx)
920 self.manager.complete_store(chunks, ctx, success=True)
921
922 assert self.secondary_tier1.submit_store.call_count == 1
923 job_metadata = self.secondary_tier1.submit_store.call_args.args[0]
924 assert job_metadata.req_context is ctx
925
926 def test_on_request_finished_delays_only_secondary_until_store_submitted(
927 self, manager_setup
928 ):
929 """Primary order commits immediately; secondary cleanup waits."""
930 chunks = to_keys(range(2))
931 ctx = ReqContext(req_id="req_delayed_secondary")
932 calls: list[tuple[str, str]] = []
933
934 self.primary_tier.on_request_finished = MagicMock(
935 side_effect=lambda req_context: calls.append(
936 ("primary_finish", req_context.req_id)
937 )
938 )
939
940 original_submit_store1 = self.secondary_tier1.submit_store
941 original_submit_store2 = self.secondary_tier2.submit_store
942
943 def submit_store1(job_metadata):
944 calls.append(("submit_store_1", job_metadata.req_context.req_id))
945 return original_submit_store1(job_metadata)
946
947 def submit_store2(job_metadata):
948 calls.append(("submit_store_2", job_metadata.req_context.req_id))
949 return original_submit_store2(job_metadata)
950
951 self.secondary_tier1.submit_store = MagicMock(side_effect=submit_store1)
952 self.secondary_tier2.submit_store = MagicMock(side_effect=submit_store2)
953 self.secondary_tier1.on_request_finished = MagicMock(
954 side_effect=lambda req_context: calls.append(
955 ("secondary_finish_1", req_context.req_id)
956 )
957 )
958 self.secondary_tier2.on_request_finished = MagicMock(
959 side_effect=lambda req_context: calls.append(
960 ("secondary_finish_2", req_context.req_id)
961 )
962 )
963
964 self._start_request(ctx)
965 self.manager.prepare_store(chunks, ctx)
966 self.manager.on_request_finished(ctx)
967
968 assert calls == [("primary_finish", ctx.req_id)]
969 self.secondary_tier1.on_request_finished.assert_not_called()
970 self.secondary_tier2.on_request_finished.assert_not_called()
971
972 self.manager.complete_store(chunks, ctx, success=True)
973
974 assert calls == [
975 ("primary_finish", ctx.req_id),
976 ("submit_store_1", ctx.req_id),
977 ("submit_store_2", ctx.req_id),
978 ("secondary_finish_1", ctx.req_id),
979 ("secondary_finish_2", ctx.req_id),
980 ]
981
982 def test_failed_store_finalizes_finished_request(self, manager_setup):
983 """Failed primary stores still unblock secondary finalization."""
984 chunks = to_keys(range(2))
985 ctx = ReqContext(req_id="req_failed_store_finalize")
986
987 self.secondary_tier1.submit_store = MagicMock(
988 wraps=self.secondary_tier1.submit_store
989 )
990 self.secondary_tier2.submit_store = MagicMock(
991 wraps=self.secondary_tier2.submit_store
992 )
993 self.secondary_tier1.on_request_finished = MagicMock(
994 wraps=self.secondary_tier1.on_request_finished
995 )
996 self.secondary_tier2.on_request_finished = MagicMock(
997 wraps=self.secondary_tier2.on_request_finished
998 )
999
1000 self._start_request(ctx)
1001 self.manager.prepare_store(chunks, ctx)
1002 self.manager.on_request_finished(ctx)
1003
1004 self.secondary_tier1.on_request_finished.assert_not_called()
1005 self.secondary_tier2.on_request_finished.assert_not_called()
1006
1007 self.manager.complete_store(chunks, ctx, success=False)
1008
1009 self.secondary_tier1.submit_store.assert_not_called()
1010 self.secondary_tier2.submit_store.assert_not_called()
1011 self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
1012 self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
1013 assert ctx.req_id not in self.manager._req_state
1014
1015 def test_zero_store_request_finalizes_immediately(self, manager_setup):
1016 """Requests with no pending stores finalize secondary tiers immediately."""
1017 ctx = ReqContext(req_id="req_zero_store_finalize")
1018
1019 self.secondary_tier1.on_request_finished = MagicMock(
1020 wraps=self.secondary_tier1.on_request_finished
1021 )
1022 self.secondary_tier2.on_request_finished = MagicMock(
1023 wraps=self.secondary_tier2.on_request_finished
1024 )
1025
1026 self._start_request(ctx)
1027 self.manager.on_request_finished(ctx)
1028
1029 self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
1030 self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
1031 assert ctx.req_id not in self.manager._req_state
1032
1033 def test_reset_cache_finalizes_delayed_secondary_request(self, manager_setup):
1034 """reset_cache abandons pending primary stores and finalizes secondaries."""
1035 chunks = to_keys(range(2))
1036 ctx = ReqContext(req_id="req_reset_finalize_secondary")
1037
1038 self.secondary_tier1.on_request_finished = MagicMock(
1039 wraps=self.secondary_tier1.on_request_finished
1040 )
1041 self.secondary_tier2.on_request_finished = MagicMock(
1042 wraps=self.secondary_tier2.on_request_finished
1043 )
1044
1045 self._start_request(ctx)
1046 self.manager.prepare_store(chunks, ctx)
1047 self.manager.on_request_finished(ctx)
1048
1049 self.secondary_tier1.on_request_finished.assert_not_called()
1050 self.secondary_tier2.on_request_finished.assert_not_called()
1051
1052 self.manager.reset_cache()
1053
1054 self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
1055 self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
1056 assert self.manager._req_state == {}
1057
1058 def test_reset_cache_clears_pending_primary_stores_for_active_request(
1059 self, manager_setup
1060 ):
1061 """reset_cache drops active pending stores so resumed requests finalize."""
1062 initial_chunks = to_keys(range(2))
1063 resumed_chunks = to_keys(range(2, 4))
1064 ctx = ReqContext(req_id="req_reset_resume")
1065
1066 self.secondary_tier1.on_request_finished = MagicMock(
1067 wraps=self.secondary_tier1.on_request_finished
1068 )
1069 self.secondary_tier2.on_request_finished = MagicMock(
1070 wraps=self.secondary_tier2.on_request_finished
1071 )
1072
1073 self._start_request(ctx)
1074 self.manager.prepare_store(initial_chunks, ctx)
1075 assert self.manager._req_state[ctx.req_id].pending_primary_stores == 1
1076
1077 self.manager.reset_cache()
1078
1079 assert ctx.req_id in self.manager._req_state
1080 assert self.manager._req_state[ctx.req_id].pending_primary_stores == 0
1081 self.secondary_tier1.on_request_finished.assert_not_called()
1082 self.secondary_tier2.on_request_finished.assert_not_called()
1083
1084 self.manager.prepare_store(resumed_chunks, ctx)
1085 self.manager.complete_store(resumed_chunks, ctx, success=True)
1086 self.manager.on_request_finished(ctx)
1087
1088 self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
1089 self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
1090 assert ctx.req_id not in self.manager._req_state
1091
1092 def test_on_new_request_lifecycle(self, manager_setup):
1093 """Policy defaults to CHUNK_LEVEL, escalates when a tier requests it,
1094 and is cleaned up on on_request_finished."""
1095 # Default: all tiers return CHUNK_LEVEL
1096 ctx = ReqContext(req_id="req_policy_lifecycle")
1097 result = self.manager.on_new_request(ctx)
1098 assert result.policy == OffloadPolicy.CHUNK_LEVEL
1099 assert self.manager._req_state[ctx.req_id].request_level_tiers is None
1100 self.manager.on_request_finished(ctx)
1101 assert ctx.req_id not in self.manager._req_state
1102
1103 # Escalate: tier1 requests REQUEST_LEVEL
1104 self.secondary_tier1.on_new_request = lambda req_context: (
1105 RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
1106 )
1107
1108 ctx = ReqContext(req_id="req_policy_lifecycle_2")
1109 result = self.manager.on_new_request(ctx)
1110 assert result.policy == OffloadPolicy.REQUEST_LEVEL
1111 assert self.manager._req_state[ctx.req_id].request_level_tiers == {0}
1112
1113 # Cleanup
1114 self.manager.on_request_finished(ctx)
1115 assert ctx.req_id not in self.manager._req_state
1116
1117 @pytest.mark.parametrize("new_ids", [(), (3, 4)], ids=["fully_warm", "mixed"])
1118 def test_prepare_store_cascades_existing_chunks_to_request_level_tiers(
1119 self, manager_setup, new_ids
1120 ):
1121 """prepare_store cascades hit chunks to request-level tiers only.
1122
1123 The fully-warm case is the producer shape from issue #52808: nothing is
1124 new, so primary returns an empty keys_to_store and no GPU->primary
1125 transfer runs. A request-level tier must still be handed the chunks,
1126 otherwise a peer waiting on them has nothing to fetch.
1127 """
1128 # Store some chunks to primary first
1129 existing_chunks = to_keys(range(3))
1130 self._start_request()
1131 result = self.manager.prepare_store(existing_chunks, _CTX)
1132 assert result is not None
1133 self.manager.complete_store(existing_chunks, _CTX, success=True)
1134 # Drain cascade completions
1135 self._simulate_on_schedule_end()
1136
1137 # Make tier1 request-level, tier2 stays chunk-level
1138 self.secondary_tier1.on_new_request = lambda req_context: (
1139 RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
1140 )
1141
1142 ctx = ReqContext(req_id="req_cascade")
1143 self.manager.on_new_request(ctx)
1144
1145 # Spy on submit_store
1146 self.secondary_tier1.submit_store = MagicMock(
1147 wraps=self.secondary_tier1.submit_store
1148 )
1149 self.secondary_tier2.submit_store = MagicMock(
1150 wraps=self.secondary_tier2.submit_store
1151 )
1152
1153 # Call prepare_store with existing + new chunks
1154 new_chunks = to_keys(new_ids)
1155 all_chunks = existing_chunks + new_chunks
1156 result = self.manager.prepare_store(all_chunks, ctx)
1157 assert result is not None
1158 assert set(result.keys_to_store) == set(new_chunks)
1159
1160 # Only tier1 (request-level) should get existing chunks cascaded now.
1161 # New chunks are cascaded to ALL tiers later via complete_store().
1162 self.secondary_tier1.submit_store.assert_called_once()
1163 job_metadata = self.secondary_tier1.submit_store.call_args.args[0]
1164 assert set(job_metadata.keys) == set(existing_chunks)
1165
1166 # tier2 (chunk-level) does not get existing chunks here.
1167 self.secondary_tier2.submit_store.assert_not_called()
1168
1169 def _make_request_level_request(self, req_id: str) -> ReqContext:
1170 """Start a request for which tier1 asks for request-level offloading,
1171 with tier1's submit_store wrapped so the cascade can be observed."""
1172 self.secondary_tier1.on_new_request = lambda req_context: (
1173 RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
1174 )
1175 ctx = ReqContext(req_id=req_id)
1176 self.manager.on_new_request(ctx)
1177 self.secondary_tier1.submit_store = MagicMock(
1178 wraps=self.secondary_tier1.submit_store
1179 )
1180 return ctx
1181
1182 def _cascaded_keys_for(self, req_id: str) -> list[set[OffloadKey]]:
1183 """Key sets tier1 was asked to store on behalf of `req_id`."""
1184 return [
1185 set(call.args[0].keys)
1186 for call in self.secondary_tier1.submit_store.call_args_list
1187 if call.args[0].req_context.req_id == req_id
1188 ]
1189
1190 def test_cascade_rejects_retry_from_primary(self, manager_setup):
1191 """RETRY would be parked with no guarantee of ever draining, so the
1192 cascade refuses it. No real primary tier returns it, so this is the one
1193 disposition that has to be forced."""
1194 keys = to_keys(range(3))
1195 self._start_request()
1196 assert self.manager.prepare_store(keys, _CTX) is not None
1197 self.manager.complete_store(keys, _CTX, success=True)
1198 self._simulate_on_schedule_end()
1199
1200 ctx = self._make_request_level_request("req_cascade")
1201 self.manager.primary_tier.lookup = lambda key, req_context: (LookupResult.RETRY)
1202
1203 with pytest.raises(AssertionError):
1204 self.manager._cascade_existing_chunks_to_request_level_tiers(keys, ctx, {0})
1205
1206 def _start_in_flight_primary_write(self, keys: list[OffloadKey]) -> ReqContext:
1207 """Leave a GPU->primary write for `keys` open, so they look present to
1208 prepare_store but are not yet readable."""
1209 writer_ctx = ReqContext(req_id="req_writer")
1210 self._start_request(writer_ctx)
1211 assert self.manager.prepare_store(keys, writer_ctx) is not None
1212 return writer_ctx
1213
1214 def test_cascade_defers_keys_whose_primary_write_is_in_flight(self, manager_setup):
1215 """A key another request is still writing must reach the peer once the
1216 write lands, not be dropped: prepare_store already counts it as stored
1217 and the scheduler advances past its chunk, so nothing re-offers it."""
1218 keys = to_keys(range(3))
1219 writer_ctx = self._start_in_flight_primary_write(keys)
1220
1221 ctx = self._make_request_level_request("req_cascade")
1222 result = self.manager.prepare_store(keys, ctx)
1223 assert result is not None
1224 assert not result.keys_to_store
1225 assert not self._cascaded_keys_for(ctx.req_id)
1226
1227 self.manager.complete_store(keys, writer_ctx, success=True)
1228 self._simulate_on_schedule_end()
1229
1230 assert self._cascaded_keys_for(ctx.req_id) == [set(keys)]
1231
1232 def test_deferred_cascade_holds_request_from_finalization(self, manager_setup):
1233 """A request that finishes with keys still deferred must stay alive, or
1234 its tiers are torn down before the peer is ever served."""
1235 keys = to_keys(range(3))
1236 writer_ctx = self._start_in_flight_primary_write(keys)
1237
1238 ctx = self._make_request_level_request("req_cascade")
1239 result = self.manager.prepare_store(keys, ctx)
1240 assert result is not None
1241 assert not result.keys_to_store
1242 self.manager.on_request_finished(ctx)
1243
1244 assert ctx.req_id in self.manager._req_state
1245 assert self.manager.has_pending_work()
1246
1247 self.manager.complete_store(keys, writer_ctx, success=True)
1248 self._simulate_on_schedule_end()
1249
1250 assert self._cascaded_keys_for(ctx.req_id) == [set(keys)]
1251 assert ctx.req_id not in self.manager._req_state
1252
1253 def test_cascade_drops_deferred_keys_whose_primary_write_failed(
1254 self, manager_setup
1255 ):
1256 """A failed write frees the chunk, so the deferred key resolves to MISS
1257 and the request finalizes instead of parking forever."""
1258 keys = to_keys(range(3))
1259 writer_ctx = self._start_in_flight_primary_write(keys)
1260
1261 ctx = self._make_request_level_request("req_cascade")
1262 result = self.manager.prepare_store(keys, ctx)
1263 assert result is not None
1264 assert not result.keys_to_store
1265 self.manager.on_request_finished(ctx)
1266
1267 self.manager.complete_store(keys, writer_ctx, success=False)
1268 self._simulate_on_schedule_end()
1269
1270 assert not self._cascaded_keys_for(ctx.req_id)
1271 assert ctx.req_id not in self.manager._req_state
1272 assert not self.manager.has_pending_work()
1273
1274 def test_reset_cache_clears_orchestrator_state(self, manager_setup):
1275 """reset_cache wipes every kind of orchestrator state and resets
1276 primary tier; pending submissions are dropped without being sent
1277 to the secondary tier. Active request state is retained."""
1278 # Cascade — populates primary chunks and leaves cascade jobs
1279 # in _jobs (the synchronous example tier has already
1280 # queued completions); reset_cache's drain loop will pick them up.
1281 chunks = to_keys(range(3))
1282 self._start_request()
1283 self.manager.prepare_store(chunks, _CTX)
1284 self.manager.complete_store(chunks, _CTX, success=True)
1285 assert self.manager._jobs
1286
1287 # Pending promotion submission (deferred — no on_schedule_end after
1288 # the lookup that staged it).
1289 promo_chunk = to_keys([99])[0]
1290 self.secondary_tier1.chunks[promo_chunk] = True
1291 assert (
1292 self.manager.lookup(promo_chunk, ReqContext(req_id="pending"))
1293 is LookupResult.HIT_PENDING
1294 )
1295 assert self.manager._pending_load_submissions
1296
1297 # Request-level tier registration.
1298 self.secondary_tier1.on_new_request = lambda req_context: (
1299 RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
1300 )
1301 rl_ctx = ReqContext(req_id="rl")
1302 self.manager.on_new_request(rl_ctx)
1303 assert self.manager._req_state[rl_ctx.req_id].request_level_tiers == {0}
1304
1305 # Mark this step as already polled (reset_cache must clear it).
1306 self.manager._processed_jobs_this_step = True
1307
1308 # Spy: pending submission must NOT reach the tier.
1309 self.secondary_tier1.submit_load = MagicMock(
1310 wraps=self.secondary_tier1.submit_load
1311 )
1312
1313 self.manager.reset_cache()
1314
1315 # Orchestrator state cleared.
1316 assert self.manager._jobs == {}
1317 assert self.manager._pending_load_submissions == {}
1318 assert set(self.manager._req_state) == {_CTX.req_id, rl_ctx.req_id}
1319 assert self.manager._processed_jobs_this_step is False
1320
1321 # Primary tier reset to a fresh state.
1322 assert self.primary_tier._num_allocated_chunks == 0
1323 assert self.primary_tier._free_list == []
1324 for chunk in chunks:
1325 assert self.primary_tier.lookup(chunk, _CTX) is LookupResult.MISS
1326
1327 # Pending submission was dropped, not submitted.
1328 self.secondary_tier1.submit_load.assert_not_called()
1329
1330 def test_reset_cache_drains_all_tiers(self, manager_setup):
1331 """reset_cache must drain each secondary tier before resetting
1332 the primary tier so no tier I/O is touching primary memory.
1333 Without the drain, an in-flight transfer could write into, or
1334 read junk from, a primary slot that the post-reset path has
1335 reallocated.
1336 """
1337 self.secondary_tier1.drain_jobs = MagicMock(
1338 wraps=self.secondary_tier1.drain_jobs
1339 )
1340 self.secondary_tier2.drain_jobs = MagicMock(
1341 wraps=self.secondary_tier2.drain_jobs
1342 )
1343
1344 # Drive a cascade so a job lands in _jobs.
1345 chunks = to_keys(range(3))
1346 self._start_request()
1347 self.manager.prepare_store(chunks, _CTX)
1348 self.manager.complete_store(chunks, _CTX, success=True)
1349 assert self.manager._jobs
1350
1351 self.manager.reset_cache()
1352
1353 self.secondary_tier1.drain_jobs.assert_called_once()
1354 self.secondary_tier2.drain_jobs.assert_called_once()
1355 assert self.manager._jobs == {}
1356
1357 @pytest.mark.parametrize(
1358 "load_tier_filter",
1359 [
1360 TierFilter(matchers=(TierMatcher(medium=Medium.STORAGE),)),
1361 TierFilter(matchers=()),
1362 ],
1363 ids=["non_matching_medium", "empty_no_load"],
1364 )
1365 def test_tier_filter_skips_filtered_secondary(
1366 self, manager_setup, load_tier_filter
1367 ):
1368 """Filter excluding secondary medium returns MISS from secondaries
1369 even when they hold the chunk; primary is unaffected."""
1370 chunks = to_keys(range(2))
1371 # Put one chunk in primary, one only in secondary
1372 self._start_request()
1373 self.manager.prepare_store(chunks[:1], _CTX)
1374 self.manager.complete_store(chunks[:1], _CTX, success=True)
1375 self.secondary_tier1.chunks[chunks[1]] = True
1376
1377 # Secondaries have medium=CPU, so load_tier_filter skips them.
1378 self.secondary_tier1.lookup = MagicMock(wraps=self.secondary_tier1.lookup)
1379
1380 ctx = ReqContext(req_id="r1", load_tier_filter=load_tier_filter)
1381 assert self.manager.lookup(chunks[0], ctx) is LookupResult.HIT
1382 assert self.manager.lookup(chunks[1], ctx) is LookupResult.MISS
1383 self.secondary_tier1.lookup.assert_not_called()
1384
1385 @pytest.mark.parametrize(
1386 "load_tier_filter",
1387 [
1388 TierFilter.ALL,
1389 TierFilter(matchers=(TierMatcher(medium=Medium.CPU),)),
1390 TierFilter(matchers=(TierMatcher(),)),
1391 ],
1392 ids=["all", "explicit_cpu", "unconstrained_matcher"],
1393 )
1394 def test_tier_filter_allows_matching_secondary(
1395 self, manager_setup, load_tier_filter
1396 ):
1397 """Filter that matches the secondary's medium allows lookup."""
1398 chunks = to_keys(range(1))
1399 self.secondary_tier1.chunks[chunks[0]] = True
1400
1401 self.secondary_tier1.lookup = MagicMock(wraps=self.secondary_tier1.lookup)
1402
1403 ctx = ReqContext(req_id="r2", load_tier_filter=load_tier_filter)
1404 assert self.manager.lookup(chunks[0], ctx) is LookupResult.HIT_PENDING
1405 self.secondary_tier1.lookup.assert_called()
1406
1407
1408 class TestTieringOffloadingWithoutSecondaryTiers:
1409 """Test TieringOffloadingManager with no secondary tiers (backward compat)."""
1410
1411 def test_works_without_secondary_tiers(self):
1412 """Test that manager works with empty secondary_tiers list."""
1413 primary_tier = CPUPrimaryTierOffloadingManager(
1414 num_chunks=5, mmap_region=_mock_mmap_region(5)
1415 )
1416
1417 # Create manager with no secondary tiers
1418 manager = TieringOffloadingManager(
1419 primary_tier=primary_tier, secondary_tiers=[]
1420 )
1421
1422 chunks = to_keys(range(3))
1423
1424 # Should work like a regular OffloadingManager
1425 manager.on_new_request(_CTX)
1426 result = manager.prepare_store(chunks, _CTX)
1427 assert result is not None
1428 manager.complete_store(chunks, _CTX, success=True)
1429
1430 assert count_hits(manager, chunks) == 3
1431
1432
1433 @pytest.mark.parametrize(
1434 "raw,expected",
1435 [
1436 (
1437 [{"medium": "storage"}],
1438 TierFilter(matchers=(TierMatcher(medium=Medium.STORAGE),)),
1439 ),
1440 (
1441 [{"medium": "CPU"}],
1442 TierFilter(matchers=(TierMatcher(medium=Medium.CPU),)),
1443 ),
1444 (
1445 [{}],
1446 TierFilter(matchers=(TierMatcher(),)),
1447 ),
1448 (
1449 [{"medium": "storage", "locality": "local"}],
1450 TierFilter(
1451 matchers=(TierMatcher(medium=Medium.STORAGE, locality=Locality.LOCAL),)
1452 ),
1453 ),
1454 (
1455 [{"medium": "cpu"}, {"medium": "storage"}],
1456 TierFilter(
1457 matchers=(
1458 TierMatcher(medium=Medium.CPU),
1459 TierMatcher(medium=Medium.STORAGE),
1460 )
1461 ),
1462 ),
1463 (
1464 [],
1465 TierFilter(matchers=()),
1466 ),
1467 ],
1468 ids=[
1469 "medium_storage",
1470 "medium_cpu_uppercase",
1471 "unconstrained",
1472 "with_locality",
1473 "multiple_matchers",
1474 "empty_list_deny_all",
1475 ],
1476 )
1477 def test_parse_tier_filter_valid(raw, expected):
1478 assert _parse_tier_filter(raw) == expected
1479
1480
1481 @pytest.mark.parametrize(
1482 "raw",
1483 [
1484 "not a list",
1485 [{"medium": "unknown"}],
1486 [{"locality": "nowhere"}],
1487 ],
1488 ids=["non_list", "invalid_medium", "invalid_locality"],
1489 )
1490 def test_parse_tier_filter_invalid_returns_all(raw):
1491 assert _parse_tier_filter(raw) is TierFilter.ALL
1492
1493
1494 def test_parse_tier_filter_skips_bad_entries():
1495 result = _parse_tier_filter(
1496 [
1497 {"medium": "storage"},
1498 "not a dict",
1499 {"medium": "bogus"},
1500 {"medium": "cpu"},
1501 ]
1502 )
1503 assert result.matchers == (
1504 TierMatcher(medium=Medium.STORAGE),
1505 TierMatcher(medium=Medium.CPU),
1506 )
1507
1508
1509 if __name__ == "__main__":
1510 pytest.main([__file__, "-v"])