[KV Offload] Fix failed-load livelock by marking the lookup verdict as a miss (#49328)

Signed-off-by: Robbie J <RobbieJ@users.noreply.github.com>

Robbie J committed Aug 9, 2026 at 08:25 UTC 1b0ce31f3265dce4f0c638cd7e2cf27f5d8c0fbc
9 files changed +488 -45
csrc/fs_io.cpp
+32 -14
@@ -78,32 +78,37 @@ inline int _store_block(const char* tmp_path, const char* dest_path,
78 }
79
80 // Core single-block load: dst/size are raw pointer + byte count. Returns 0
81 -// on success, or the errno of the failing step on failure. On failure,
82 -// the source file is removed since a partially-read block should not be reused.
81 +// on success, or the errno of the failing step on failure. Removes the source
82 +// file ONLY on a provable short read (the read completed but returned fewer
83 +// bytes than requested): stores are atomic, so a too-short file is genuine
84 +// corruption. Open failures and read errors (bytes_read < 0) are
85 +// transient/ambiguous and leave the file untouched; a close failure after a
86 +// full read is harmless and does not fail the load.
87 inline int _load_block(const char* source_path, char* dst, size_t size,
88 bool use_o_direct) {
89 const int o_direct_flag = use_o_direct ? kODirectFlag : 0;
90 const int fd = open(source_path, O_RDONLY | o_direct_flag, 0);
91 if (fd < 0) {
88 - const int err = errno;
89 - unlink(source_path);
90 - return err;
92 + return errno;
93 }
94
95 const ssize_t bytes_read = read(fd, dst, size);
94 - if (bytes_read < 0 || static_cast<size_t>(bytes_read) != size) {
95 - const int err = bytes_read < 0 ? errno : EIO;
96 + if (bytes_read < 0) {
97 + // Transient read error: leave the file untouched.
98 + const int err = errno;
99 close(fd);
97 - unlink(source_path);
100 return err;
101 }
100 -
101 - if (close(fd) != 0) {
102 - const int err = errno;
102 + if (static_cast<size_t>(bytes_read) < size) {
103 + // Provable short read: the block is genuinely corrupt, so remove it.
104 + close(fd);
105 unlink(source_path);
104 - return err;
106 + return EIO;
107 }
108
109 + // A close error after a successful full read is harmless: the data is
110 + // already in the destination buffer, so the load succeeds.
111 + close(fd);
112 return 0;
113 }
114
@@ -312,8 +317,21 @@ static PyObject* batch_load_block(PyObject* /*self*/, PyObject* args) {
317 if (failed_index >= 0) {
318 // PyErr_SetFromErrnoWithFilename() reads the errno to format exception.
319 errno = failure_errno;
315 - return PyErr_SetFromErrnoWithFilename(PyExc_OSError,
316 - source_paths[failed_index]);
320 + PyErr_SetFromErrnoWithFilename(PyExc_OSError, source_paths[failed_index]);
321 + // Attach the number of blocks that loaded before the failure so the tier
322 + // can keep them (partial success). failed_index == count of blocks read OK.
323 + PyObject *etype, *evalue, *etb;
324 + PyErr_Fetch(&etype, &evalue, &etb);
325 + PyErr_NormalizeException(&etype, &evalue, &etb);
326 + if (evalue != nullptr) {
327 + PyObject* num = PyLong_FromSsize_t(failed_index);
328 + if (num != nullptr) {
329 + PyObject_SetAttrString(evalue, "num_succeeded", num);
330 + Py_DECREF(num);
331 + }
332 + }
333 + PyErr_Restore(etype, evalue, etb);
334 + return nullptr;
335 }
336
337 Py_RETURN_NONE;
tests/v1/kv_offload/tiering/test_async_lookup.py
+68
@@ -5,6 +5,8 @@
5 import threading
6 from collections.abc import Iterable
7
8 +import pytest
9 +
10 from vllm.v1.kv_offload.base import OffloadKey, ReqContext, make_offload_key
11 from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
12
@@ -156,3 +158,69 @@ class TestAsyncLookupManager:
158 mgr = InMemoryLookupManager()
159 mgr.shutdown()
160 assert not mgr._thread.is_alive()
161 +
162 + def test_mark_miss_flips_cached_verdict_without_reprobing(self):
163 + """Failed-load livelock regression (#49176). After a failed load the
164 + tier calls mark_miss(), flipping the cached True to False; every
165 + subsequent lookup then returns False (MISS) directly, WITHOUT enqueuing
166 + a fresh batch_lookup — that is what makes the request unable to loop.
167 + The entry is retained (as False) not dropped, so cleanup()'s reverse
168 + index stays consistent; an unknown key is a no-op."""
169 + mgr = InMemoryLookupManager(existing_keys={_key(1), _key(2)})
170 + ctx = _ctx("reqA")
171 + assert mgr.lookup(_key(1), ctx) is None
172 + mgr.lookup(_key(2), ctx)
173 + mgr.flush()
174 + mgr._results_ready.wait()
175 + mgr._results_ready.clear()
176 + assert mgr.lookup(_key(1), ctx) is True
177 +
178 + # An unknown key must not raise or plant an entry.
179 + mgr.mark_miss([_key(99)])
180 + assert _key(99) not in mgr._lookup_state
181 +
182 + # The block is still "present" per the backing set, so a re-probe would
183 + # wrongly return True again — the verdict must be served from cache as
184 + # False, with no re-probe enqueued, and stay False across steps.
185 + mgr.mark_miss([_key(1)])
186 + assert mgr.lookup(_key(1), ctx) is False
187 + assert mgr._lookup_batch == [] # no fresh probe enqueued
188 + mgr.flush()
189 + assert mgr._lookup_queue.empty() # nothing posted to the worker
190 + assert mgr.lookup(_key(1), ctx) is False
191 +
192 + # Entry retained (now False) with reverse index intact, so cleanup()
193 + # (which direct-indexes _lookup_state per reverse-index key) tears down
194 + # both structures without raising.
195 + assert mgr._lookup_state[_key(1)].result is False
196 + assert _key(1) in mgr._req_keys["reqA"]
197 + mgr.cleanup("reqA")
198 + assert _key(1) not in mgr._lookup_state
199 + assert _key(2) not in mgr._lookup_state
200 + assert "reqA" not in mgr._req_keys
201 + mgr.shutdown()
202 +
203 + def test_enqueue_once_invariant_enforced(self):
204 + """A key is enqueued for probing exactly once, so drain_results() may
205 + receive at most one result per key. Normal operation resolves a key a
206 + single time; a second result for an already-decided key trips the assert
207 + that guards the invariant (a silent overwrite could flip a corrected
208 + miss back to True and reopen the failed-load livelock)."""
209 + mgr = InMemoryLookupManager(existing_keys={_key(1)})
210 + ctx = _ctx("reqA")
211 +
212 + # (a) Normal operation: the key is enqueued once and resolved once, with
213 + # no second result left pending.
214 + assert mgr.lookup(_key(1), ctx) is None
215 + mgr.flush()
216 + mgr._results_ready.wait()
217 + mgr._results_ready.clear()
218 + assert mgr.lookup(_key(1), ctx) is True # decided exactly once
219 + assert mgr._pending_results.empty()
220 +
221 + # (b) A stray/duplicate result for the now-decided key violates the
222 + # enqueue-once invariant and must trip the assert.
223 + mgr._pending_results.put([(_key(1), True)])
224 + with pytest.raises(AssertionError):
225 + mgr.drain_results()
226 + mgr.shutdown()
tests/v1/kv_offload/tiering/test_fs_tier.py
+186
@@ -245,6 +245,11 @@ def test_store_then_load_roundtrip(fs_tier):
245 tier.submit_load(job_l)
246 load_results = drain(tier)
247 assert all(r.success for r in load_results)
248 + # A successful load must NOT touch the file: the delete path fires only on
249 + # a provable short read, so a good block stays on disk (guards against an
250 + # over-eager delete regressing to upstream's delete-on-any-error).
251 + for k in (key(1), key(2)):
252 + assert os.path.exists(tier.file_mapper.get_file_name(k))
253 # Blocks stay on disk after load
254 assert lookup_and_wait(tier, [key(1), key(2)]) == [
255 LookupResult.HIT,
@@ -545,6 +550,187 @@ def test_out_of_bounds_block_id_smoke(fs_tier, monkeypatch, use_c_ext):
550 assert not load_results[0].success
551
552
553 +@pytest.mark.parametrize("use_c_ext", [True, False])
554 +def test_failed_load_corrects_verdict_and_removes_corrupt_file(
555 + fs_tier, monkeypatch, use_c_ext
556 +):
557 + """Failed-load livelock regression, covering the whole contract.
558 +
559 + A successful promotion leaves the cached HIT and the on-disk block intact.
560 + A promotion that short-reads a truncated (corrupt) block fails, and in
561 + get_finished_jobs() the tier removes the corrupt file (stores are atomic,
562 + so a too-short file is genuine corruption) and marks the cached verdict
563 + False. The SAME request's next lookup is then a MISS served from cache with
564 + NO re-probe, so the scheduler cannot re-issue the doomed promotion.
565 + """
566 + import vllm.v1.kv_offload.tiering.fs.io as io_mod
567 +
568 + if use_c_ext and not io_mod._HAS_FSIO_C:
569 + pytest.skip("fs_io_C extension not built")
570 + monkeypatch.setattr(io_mod, "_HAS_FSIO_C", use_c_ext)
571 +
572 + tier, _ = fs_tier
573 + tier.submit_store(make_job(1, [key(1)], [0]))
574 + assert all(r.success for r in drain(tier))
575 + path = tier.file_mapper.get_file_name(key(1))
576 +
577 + ctx = ReqContext(req_id="livelock-req")
578 + assert lookup_and_wait(tier, [key(1)], ctx=ctx) == [LookupResult.HIT]
579 +
580 + # A successful promotion must NOT touch the verdict or the file.
581 + tier.submit_load(make_job(2, [key(1)], [0], is_promotion=True))
582 + results = drain(tier)
583 + assert len(results) == 1 and results[0].success
584 + assert tier.lookup(key(1), ctx) == LookupResult.HIT
585 + assert os.path.exists(path)
586 +
587 + # Truncate below block_size so the next promotion short-reads.
588 + with open(path, "wb") as f:
589 + f.write(b"x" * 10)
590 + tier.submit_load(make_job(3, [key(1)], [0], is_promotion=True))
591 + results = drain(tier) # get_finished_jobs() marks the verdict False here
592 + assert len(results) == 1 and not results[0].success
593 +
594 + # Corrupt file removed; the SAME request now misses from cache, no re-probe.
595 + assert not os.path.exists(path)
596 + lm = tier._lookup_manager
597 + assert tier.lookup(key(1), ctx) == LookupResult.MISS
598 + assert lm._lookup_batch == []
599 +
600 + # A FRESH request re-probes the tier (no cached verdict) and misses too,
601 + # since the corrupt file is gone -- the real batch_lookup re-probe path.
602 + fresh = ReqContext(req_id="fresh-after-short-read")
603 + assert lookup_and_wait(tier, [key(1)], ctx=fresh) == [LookupResult.MISS]
604 +
605 +
606 +@pytest.mark.parametrize("use_c_ext", [True, False])
607 +def test_batched_partial_load_failure_keeps_loaded_blocks(
608 + fs_tier, monkeypatch, use_c_ext
609 +):
610 + """A batched promotion stops at the first bad block and reports how many
611 + loaded before it (#50321). Corrupt the LAST block: the earlier blocks load
612 + fine, so the job reports successful_keys for them and marks only the failed
613 + tail a miss. The earlier keys stay HIT — including for the same request —
614 + while the corrupt block stays a MISS (its file was removed)."""
615 + import vllm.v1.kv_offload.tiering.fs.io as io_mod
616 +
617 + if use_c_ext and not io_mod._HAS_FSIO_C:
618 + pytest.skip("fs_io_C extension not built")
619 + monkeypatch.setattr(io_mod, "_HAS_FSIO_C", use_c_ext)
620 +
621 + tier, _ = fs_tier
622 + keys = [key(1), key(2), key(3)] # last one is the "bad" block
623 + tier.submit_store(make_job(1, keys, [0, 1, 2]))
624 + assert all(r.success for r in drain(tier))
625 + bad_path = tier.file_mapper.get_file_name(key(3))
626 +
627 + ctx = ReqContext(req_id="batch-req")
628 + assert lookup_and_wait(tier, keys, ctx=ctx) == [LookupResult.HIT] * 3
629 +
630 + # Corrupt only the last block, then load the whole batch as one job.
631 + with open(bad_path, "wb") as f:
632 + f.write(b"x" * 10)
633 + tier.submit_load(make_job(2, keys, [0, 1, 2], is_promotion=True))
634 + results = drain(tier)
635 + # (a) the job fails but reports the two blocks that loaded before the bad one.
636 + assert len(results) == 1 and not results[0].success
637 + assert tuple(results[0].successful_keys) == (key(1), key(2))
638 +
639 + # (b) Only the failed tail is a miss; the loaded blocks stay HIT on the same
640 + # request, and nothing was re-probed.
641 + lm = tier._lookup_manager
642 + assert [tier.lookup(k, ctx) for k in keys] == [
643 + LookupResult.HIT,
644 + LookupResult.HIT,
645 + LookupResult.MISS,
646 + ]
647 + assert lm._lookup_batch == []
648 +
649 + # (c) A fresh request re-probes: the loaded blocks are still on disk (HIT),
650 + # only the corrupt block was removed (MISS).
651 + tier.on_request_finished(ctx)
652 + fresh = ReqContext(req_id="fresh-batch-req")
653 + assert lookup_and_wait(tier, keys, ctx=fresh) == [
654 + LookupResult.HIT,
655 + LookupResult.HIT,
656 + LookupResult.MISS,
657 + ]
658 +
659 +
660 +@pytest.mark.parametrize("use_c_ext", [True, False])
661 +def test_batched_load_first_block_fails_marks_whole_batch(
662 + fs_tier, monkeypatch, use_c_ext
663 +):
664 + """When the FIRST block fails, nothing loaded before it: the job reports no
665 + successful_keys (None) and the whole batch is marked a miss for the
666 + request."""
667 + import vllm.v1.kv_offload.tiering.fs.io as io_mod
668 +
669 + if use_c_ext and not io_mod._HAS_FSIO_C:
670 + pytest.skip("fs_io_C extension not built")
671 + monkeypatch.setattr(io_mod, "_HAS_FSIO_C", use_c_ext)
672 +
673 + tier, _ = fs_tier
674 + keys = [key(1), key(2), key(3)] # first one is the "bad" block
675 + tier.submit_store(make_job(1, keys, [0, 1, 2]))
676 + assert all(r.success for r in drain(tier))
677 +
678 + ctx = ReqContext(req_id="batch-first-fail")
679 + assert lookup_and_wait(tier, keys, ctx=ctx) == [LookupResult.HIT] * 3
680 +
681 + with open(tier.file_mapper.get_file_name(key(1)), "wb") as f:
682 + f.write(b"x" * 10)
683 + tier.submit_load(make_job(2, keys, [0, 1, 2], is_promotion=True))
684 + results = drain(tier)
685 + assert len(results) == 1 and not results[0].success
686 + # Nothing loaded before the failure -> no partial success reported.
687 + assert results[0].successful_keys is None
688 + # The whole batch is a miss for this request.
689 + assert [tier.lookup(k, ctx) for k in keys] == [LookupResult.MISS] * 3
690 +
691 +
692 +@pytest.mark.parametrize("use_c_ext", [True, False])
693 +def test_transient_load_failure_leaves_file(fs_tier, monkeypatch, use_c_ext):
694 + """A transient host error (here ELOOP on open) is NOT a short read: the job
695 + fails but the block file must survive untouched, on both the C and Python
696 + paths. Deleting on a transient error would turn a passing hiccup into
697 + permanent data loss."""
698 + import vllm.v1.kv_offload.tiering.fs.io as io_mod
699 +
700 + if use_c_ext and not io_mod._HAS_FSIO_C:
701 + pytest.skip("fs_io_C extension not built")
702 + monkeypatch.setattr(io_mod, "_HAS_FSIO_C", use_c_ext)
703 +
704 + tier, _ = fs_tier
705 + tier.submit_store(make_job(1, [key(1)], [0]))
706 + assert all(r.success for r in drain(tier))
707 + path = tier.file_mapper.get_file_name(key(1))
708 + with open(path, "rb") as f:
709 + original = f.read()
710 +
711 + # Make open() fail with ELOOP (fd < 0) without truncating the block. Not
712 + # chmod 000: CI runs as root, which bypasses permission bits, so open()
713 + # would succeed and the load would not fail at all.
714 + saved = path + ".saved"
715 + loop = path + ".loop"
716 + os.rename(path, saved)
717 + os.symlink(loop, path)
718 + os.symlink(path, loop)
719 +
720 + tier.submit_load(make_job(2, [key(1)], [0], is_promotion=True))
721 + results = drain(tier)
722 + assert len(results) == 1 and not results[0].success
723 +
724 + # The path is left alone: a non-short-read error must not unlink.
725 + assert os.path.lexists(path)
726 +
727 + os.unlink(path)
728 + os.unlink(loop)
729 + os.rename(saved, path)
730 + with open(path, "rb") as f:
731 + assert f.read() == original
732 +
733 +
734 # ---------------------------------------------------------------------------
735 # KV events
736 # ---------------------------------------------------------------------------
tests/v1/kv_offload/tiering/test_obj_tier.py
+26
@@ -334,6 +334,32 @@ class TestMockObjTierBasic:
334 assert len(results) == 1
335 assert not results[0].success
336
337 + def test_failed_load_marks_verdict_negative(self):
338 + """Regression for the failed-load livelock on the obj tier: a
339 + cached HIT must not survive a failed load of the same key. On the
340 + failed promotion the tier marks the verdict False from
341 + get_finished_jobs() (drained here) on the scheduler thread; otherwise
342 + the scheduler would re-issue the same doomed promotion every step for
343 + the life of the request. The mark is served from cache with no
344 + re-probe, so even though the mock object is still 'present' the SAME
345 + request now resolves to MISS."""
346 + ctx = ReqContext(req_id="obj-livelock")
347 + self.tier.submit_store(make_job(1, [key(1)], [0]))
348 + assert all(r.success for r in drain(self.tier))
349 + # Cache a positive verdict: the object is present, so lookup is a HIT.
350 + assert lookup_and_wait(self.tier, [key(1)], ctx=ctx) == [LookupResult.HIT]
351 +
352 + # The promotion the HIT triggered fails.
353 + self.agent.check_xfer_state = lambda h: "ERR"
354 + self.tier.submit_load(make_job(2, [key(1)], [0]))
355 + results = drain(self.tier)
356 + assert len(results) == 1 and not results[0].success
357 +
358 + # After the failed promotion the SAME request's lookup must resolve to
359 + # MISS (verdict marked False) instead of serving the stale HIT — even
360 + # though the object itself is still present in the mock store.
361 + assert lookup_and_wait(self.tier, [key(1)], ctx=ctx) == [LookupResult.MISS]
362 +
363 def test_pending_transfer_not_returned_until_done(self):
364 # First poll returns PROC; second poll returns DONE.
365 call_count = [0]
tests/v1/kv_offload/tiering/test_tiering_offloading.py
+49
@@ -251,6 +251,55 @@ class TestTieringOffloadingManager:
251 if req_context.req_id not in self.manager._req_state:
252 self.manager.on_new_request(req_context)
253
254 + def test_failed_promotion_finalizes_primary_with_failure(self, manager_setup):
255 + """A failed promotion still finalizes the primary slots with
256 + success=False; the tier corrects its own verdict."""
257 + from unittest.mock import patch
258 +
259 + from vllm.v1.kv_offload.tiering.base import JobResult
260 +
261 + self._start_request()
262 + # Register an in-flight promotion job for tier1 by hand.
263 + job_id = self.manager._next_job_id()
264 + self.manager._transfer_jobs[job_id] = JobMetadata(
265 + job_id=job_id,
266 + keys=to_keys([1, 2]),
267 + block_ids=[0, 1],
268 + is_promotion=True,
269 + req_context=_CTX,
270 + )
271 + failed = JobResult(job_id=job_id, success=False)
272 + with (
273 + patch.object(
274 + self.secondary_tier1, "get_finished_jobs", return_value=[failed]
275 + ),
276 + patch.object(self.primary_tier, "complete_write") as completed,
277 + ):
278 + self.manager._process_finished_jobs()
279 + completed.assert_called_once_with(to_keys([1, 2]), _CTX, False)
280 +
281 + def test_successful_promotion_finalizes_primary_with_success(self, manager_setup):
282 + from unittest.mock import patch
283 +
284 + from vllm.v1.kv_offload.tiering.base import JobResult
285 +
286 + self._start_request()
287 + job_id = self.manager._next_job_id()
288 + self.manager._transfer_jobs[job_id] = JobMetadata(
289 + job_id=job_id,
290 + keys=to_keys([1]),
291 + block_ids=[0],
292 + is_promotion=True,
293 + req_context=_CTX,
294 + )
295 + ok = JobResult(job_id=job_id, success=True)
296 + with (
297 + patch.object(self.secondary_tier1, "get_finished_jobs", return_value=[ok]),
298 + patch.object(self.primary_tier, "complete_write") as completed,
299 + ):
300 + self.manager._process_finished_jobs()
301 + completed.assert_called_once_with(to_keys([1]), _CTX, True)
302 +
303 def test_take_events_aggregates_tier_owned_events(self, manager_setup):
304 primary_event = OffloadingEvent(to_keys([1]), Medium.CPU, removed=False)
305 secondary_event1 = OffloadingEvent(to_keys([2]), Medium.STORAGE, removed=False)
vllm/v1/kv_offload/tiering/async_lookup.py
+19 -1
@@ -34,7 +34,7 @@ lookup() is a pure OrderedDict operation.
34 import queue
35 import threading
36 from abc import ABC, abstractmethod
37 -from collections.abc import Iterable
37 +from collections.abc import Collection, Iterable
38 from dataclasses import dataclass, field
39
40 from vllm.logger import init_logger
@@ -170,8 +170,26 @@ class AsyncLookupManager(ABC):
170 for key, result in batch:
171 state = self._lookup_state.get(key)
172 if state is not None:
173 + # A key is enqueued for probing exactly once, so a decided
174 + # verdict must never receive a second result. Enforcing it
175 + # keeps a late/duplicate result from resurrecting a stale
176 + # True and reopening the failed-load livelock.
177 + assert state.result is None, (
178 + "cached key received a second lookup result; the "
179 + "enqueue-once invariant is broken and could reopen the "
180 + "failed-load livelock"
181 + )
182 state.result = result
183
184 + def mark_miss(self, keys: Collection[OffloadKey]) -> None:
185 + """Force the cached verdict for ``keys`` to False after a failed load, so
186 + the scheduler stops re-issuing the doomed promotion (livelock, #49176).
187 + Keys with no cached entry are skipped."""
188 + for key in keys:
189 + state = self._lookup_state.get(key)
190 + if state is not None:
191 + state.result = False
192 +
193 def cleanup(self, req_id: str) -> None:
194 """Remove entries no longer needed by any active request.
195
vllm/v1/kv_offload/tiering/fs/io.py
+23 -14
@@ -139,9 +139,9 @@ def _load_block(
139 block_size: int,
140 use_o_direct: bool = True,
141 ) -> None:
142 - """
143 - Load callback: read one KV block from disk. Remove the file on failure.
144 - """
142 + """Read one KV block from disk; remove the file only on a provable short
143 + read (a too-short file is genuine corruption) and leave it untouched on any
144 + other error."""
145 fd: int | None = None
146 view_slice = view.cast("B")[offset : offset + block_size]
147 o_direct = O_DIRECT if use_o_direct else 0
@@ -150,15 +150,16 @@ def _load_block(
150 fd = os.open(source_path, os.O_RDONLY | o_direct)
151 bytes_read = os.readv(fd, [view_slice])
152 if bytes_read < block_size:
153 + # A failure to remove must not mask the short-read error below.
154 + try:
155 + os.remove(source_path)
156 + except OSError as cleanup_exc:
157 + logger.warning(
158 + "Failed to remove short-read file %s: %s",
159 + source_path,
160 + cleanup_exc,
161 + )
162 raise OSError(f"Short read: expected {block_size} bytes, read {bytes_read}")
154 - except Exception:
155 - try:
156 - os.remove(source_path)
157 - except OSError as cleanup_exc:
158 - logger.warning(
159 - "Failed to remove unreadable file %s: %s", source_path, cleanup_exc
160 - )
161 - raise
163 finally:
164 if fd is not None:
165 os.close(fd)
@@ -200,7 +201,9 @@ def batch_load_block(
201 Load a batch of KV blocks from disk into a shared buffer in one call.
202
203 Block i is read from source_paths[i] into view[offsets[i] : offsets[i]+block_size].
203 - Raises on first error and removes the offending file.
204 + Raises on first error (see _load_block for the delete-on-short-read policy).
205 + On failure the raised OSError carries ``num_succeeded`` = the number of
206 + blocks loaded before the failing one, so the tier can keep them.
207 """
208 _validate_offsets(view, offsets, block_size)
209
@@ -209,5 +212,11 @@ def batch_load_block(
212 view_slices = [view_B[x : x + block_size] for x in offsets]
213 return batch_load_block_C(paths, view_slices, use_o_direct)
214 else:
212 - for path, offset in zip(paths, offsets):
213 - _load_block(path, view, offset, block_size, use_o_direct)
215 + for i, (path, offset) in enumerate(zip(paths, offsets)):
216 + try:
217 + _load_block(path, view, offset, block_size, use_o_direct)
218 + except OSError as exc:
219 + # Blocks 0..i-1 loaded fine; record the count for partial keep.
220 + # The C path sets the same attribute via PyObject_SetAttrString.
221 + exc.num_succeeded = i # type: ignore[attr-defined]
222 + raise
vllm/v1/kv_offload/tiering/fs/manager.py
+66 -13
@@ -148,6 +148,16 @@ class FileSystemTierManager(SecondaryTierManager):
148 )
149 # Keys of in-flight store jobs, tracked only when events are enabled.
150 self._store_job_keys: dict[JobId, list[OffloadKey]] = {}
151 + # Keys of in-flight load (promotion) jobs, so a failed load can mark
152 + # its own cached lookup verdicts False (see get_finished_jobs).
153 + self._load_job_keys: dict[JobId, list[OffloadKey]] = {}
154 + # Per load job: how many blocks loaded before a failure (partial keep).
155 + # Written by the pool worker inside the load task before it raises (so
156 + # before task_done publishes the job); read on the scheduler thread in
157 + # get_finished_jobs only for job ids the finished queue returned. Under
158 + # the GIL that read cannot observe the finished job without the prior
159 + # write, so no extra lock is needed (get_finished is itself lock-free).
160 + self._load_progress: dict[JobId, int] = {}
161
162 # Extract block size from primary view
163 assert primary_kv_view.strides is not None, (
@@ -219,22 +229,47 @@ class FileSystemTierManager(SecondaryTierManager):
229
230 @override
231 def submit_load(self, job_metadata: JobMetadata) -> None:
222 - task = functools.partial(
223 - batch_load_block,
224 - [self.file_mapper.get_file_name(key) for key in job_metadata.keys],
225 - self._primary_kv_view,
226 - [int(bid) * self._block_size for bid in job_metadata.block_ids],
227 - self._block_size,
228 - self._use_o_direct,
229 - )
232 + job_id = job_metadata.job_id
233 + # Track this load's keys so a failed promotion can mark only its failed
234 + # keys as a miss (see get_finished_jobs).
235 + keys = list(job_metadata.keys)
236 + self._load_job_keys[job_id] = keys
237 + paths = [self.file_mapper.get_file_name(key) for key in keys]
238 + offsets = [int(bid) * self._block_size for bid in job_metadata.block_ids]
239 +
240 + def load_task() -> None:
241 + try:
242 + batch_load_block(
243 + paths,
244 + self._primary_kv_view,
245 + offsets,
246 + self._block_size,
247 + self._use_o_direct,
248 + )
249 + except OSError as exc:
250 + # Runs on the pool worker thread. Record how many blocks loaded
251 + # before the failure so get_finished_jobs can keep them; this
252 + # write precedes task_done, so the scheduler reads it safely
253 + # under the GIL once the finished queue hands back this job.
254 + num_succeeded = getattr(exc, "num_succeeded", 0)
255 + self._load_progress[job_id] = num_succeeded
256 + # Surfaces errno (e.g. EMFILE "Too many open files") for both
257 + # the C and Python load paths.
258 + logger.debug(
259 + "Load of %d blocks for job %s failed at block %d: %s",
260 + len(paths),
261 + job_id,
262 + num_succeeded,
263 + exc,
264 + )
265 + raise
266
231 - self._pool.enqueue_load(job_metadata.job_id, 1, [task])
267 + self._pool.enqueue_load(job_id, 1, [load_task])
268
269 @override
270 def get_finished_jobs(self) -> Iterable[JobResult]:
235 - """
236 - Collect completed jobs from the finished-jobs queue.
237 - """
271 + """Collect finished jobs; a failed promotion marks only its failed keys
272 + as a miss here (scheduler thread)."""
273 results = []
274 for job_id, success in self._pool.get_finished():
275 if self.events is not None:
@@ -248,7 +283,25 @@ class FileSystemTierManager(SecondaryTierManager):
283 locality=self.locality,
284 )
285 )
251 - results.append(JobResult(job_id=job_id, success=success))
286 + load_keys = self._load_job_keys.pop(job_id, None)
287 + num_succeeded = self._load_progress.pop(job_id, 0)
288 + if load_keys is not None and not success:
289 + # A batched load stops at the first bad block and reports how
290 + # many loaded before it. Those earlier blocks are kept in the
291 + # primary tier (reported via successful_keys); only this block
292 + # and the ones after it are marked a miss and recomputed.
293 + successful = load_keys[:num_succeeded]
294 + failed = load_keys[num_succeeded:]
295 + self._lookup_manager.mark_miss(failed)
296 + results.append(
297 + JobResult(
298 + job_id=job_id,
299 + success=False,
300 + successful_keys=tuple(successful) if successful else None,
301 + )
302 + )
303 + else:
304 + results.append(JobResult(job_id=job_id, success=success))
305 return results
306
307 @override
vllm/v1/kv_offload/tiering/obj/manager.py
+19 -3
@@ -141,6 +141,9 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
141 )
142 # Keys of in-flight store jobs, tracked only when events are enabled.
143 self._store_job_keys: dict[JobId, list[OffloadKey]] = {}
144 + # Keys of in-flight load (promotion) jobs, so a failed download can
145 + # mark its own cached lookup verdicts False (see get_finished_jobs).
146 + self._load_job_keys: dict[JobId, list[OffloadKey]] = {}
147
148 agent_config = nixl_agent_config(backends=[])
149 self._agent = nixl_agent("ObjAgent", agent_config)
@@ -281,6 +284,7 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
284 )
285
286 def submit_load(self, job_metadata: JobMetadata) -> None:
287 + self._load_job_keys[job_metadata.job_id] = list(job_metadata.keys)
288 obj_keys = (self._file_mapper.get_file_name(k) for k in job_metadata.keys)
289 self._submit_transfer(
290 job_metadata.job_id, job_metadata.block_ids, obj_keys, NIXL_READ
@@ -340,12 +344,13 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
344 self._pending_results.append(JobResult(job_id=job_id, success=success))
345
346 def get_finished_jobs(self) -> Iterable[JobResult]:
343 - """Poll in-flight transfers; return completed (job_id, success) pairs."""
347 + """Poll transfers; a failed promotion marks its cached verdicts False
348 + here (scheduler thread)."""
349 self._poll_active_transfers()
350 results = self._pending_results
351 self._pending_results = []
347 - if self.events is not None:
348 - for result in results:
352 + for result in results:
353 + if self.events is not None:
354 keys = self._store_job_keys.pop(result.job_id, None)
355 if result.success and keys:
356 self.events.append(
@@ -356,6 +361,17 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
361 locality=self.locality,
362 )
363 )
364 + # Mark only the keys that did not load as a miss; the request
365 + # recomputes them. The miss is per-request (cleared when the request
366 + # finishes), so other requests still HIT the blocks that loaded
367 + # fine. nixl reports the batch as a whole (successful_keys is None),
368 + # so today this marks all keys; the subtraction keeps it correct if
369 + # partial results are ever reported.
370 + load_keys = self._load_job_keys.pop(result.job_id, None)
371 + if load_keys is not None and not result.success:
372 + successful = set(result.successful_keys or ())
373 + failed = [k for k in load_keys if k not in successful]
374 + self._lookup_manager.mark_miss(failed)
375 return results
376
377 def take_events(self) -> Iterable[OffloadingEvent]: