diff --git a/python/sglang/srt/mem_cache/hiradix_cache.py b/python/sglang/srt/mem_cache/hiradix_cache.py index 93b9ee5a1..6d223e8c3 100644 --- a/python/sglang/srt/mem_cache/hiradix_cache.py +++ b/python/sglang/srt/mem_cache/hiradix_cache.py @@ -1559,8 +1559,15 @@ class HiRadixCache(RadixCache): continue # could not fit even after evict-to-fit -> recompute owned_pages = self._cp_l3_build_reload_owned_pages(ranges, shashes[:c], store) op_id = store.submit_reload(reload_key, owned_pages) + # Slice the candidate key to the C L3-durable pages (matching suffix_hashes[:c] and the C-page + # reservation) so the reloaded node is WELL-FORMED: + # len(key)//page == host_len//page == object.num_pages == len(hash_value) == C. + # The un-durable suffix [C:n_full] is simply not present (a correct cache miss / recompute); + # carrying the full key while only C pages are backed would make the node claim a prefix it + # cannot serve and crash the radix split (split_pages from the long key vs the C-page object). self.ongoing_l3_reload[op_id] = { - "reload_key": reload_key, "last_host_node": lhn, "suffix_key": skey, + "reload_key": reload_key, "last_host_node": lhn, + "suffix_key": skey[: c * self.page_size], "suffix_hashes": list(shashes[:c]), "n_pages": c, "waiters": [rid], "ttl": self.cp_l3_reload_ttl_ticks, } @@ -1622,6 +1629,15 @@ class HiRadixCache(RadixCache): n = len(page_hashes) owned = {} for pk, rng in ranges.items(): + if n > rng.num_pages: + # Symmetric with the spill builder _cp_l3_build_owned_pages: base + i + # past the object would address the neighbour object's pages in the same + # slab. Safe by construction (n == c == ranges), fail loud on any desync. + raise RuntimeError( + f"[CP_L3_FAILFAST] reload page count {n} exceeds object pages " + f"{rng.num_pages} for payload {pk!r} (node key/host_len desync); " + f"base_page={rng.base_page} slab_id={rng.slab_id}" + ) base = rng.base_page owned[pk] = [(base + i, page_hashes[i]) for i in range(n) if i % cp_size == cp_rank] return owned @@ -1805,6 +1821,16 @@ class HiRadixCache(RadixCache): rng = meta.object_ranges.get(pk) if rng is None: continue + if n > rng.num_pages: + # base + i for i in range(n) would address PAST this object into the + # neighbour object's pages in the same slab -> silent L3 corruption + # (wrong content under a valid hash). After the reload-key slice this + # cannot happen; fail loud on any future key/host_len desync. + raise RuntimeError( + f"[CP_L3_FAILFAST] spill page count {n} exceeds object pages " + f"{rng.num_pages} for payload {pk!r} (node key/host_len desync); " + f"base_page={rng.base_page} slab_id={rng.slab_id}" + ) base = rng.base_page owned[pk] = [ (base + i, page_keys[i]) for i in range(n) if i % cp_size == cp_rank @@ -6045,6 +6071,18 @@ class HiRadixCache(RadixCache): if self._uses_cp_hicache: if self._node_backuped(child): _old_cp_meta = child.cp_hicache + # Invariant: a backed node's host backup spans its full key, so the + # radix split point never exceeds host_len. Fail loud if a malformed + # partial-backed node (key longer than its host backup) ever reaches + # here -- it would otherwise feed split_pages>num_pages to the L2 split + # and drive child.host_len negative. + if split_len > child.host_len: + raise RuntimeError( + "[CP_HICACHE_FAILFAST][split_exceeds_host_backup] radix split at " + f"split_len={split_len} exceeds the node's backed host_len=" + f"{child.host_len} (a partial-backed node must not exist); " + f"node_id={getattr(child, 'id', None)}" + ) # Leave committed for the pre-split metadata BEFORE reassigning # child.cp_hicache, so a first-touch lazy rebuild of the running # counts still sees the intact, in-tree child as committed. diff --git a/test/registered/unit/mem_cache/test_cp_shared_l2_pool.py b/test/registered/unit/mem_cache/test_cp_shared_l2_pool.py index ab8aadcf1..b447fcf78 100644 --- a/test/registered/unit/mem_cache/test_cp_shared_l2_pool.py +++ b/test/registered/unit/mem_cache/test_cp_shared_l2_pool.py @@ -656,6 +656,32 @@ class TestCpSharedL2Metadata(unittest.TestCase): with self.assertRaisesRegex(ValueError, "multiple of page_size"): metadata.split(32) + def test_shared_metadata_split_rejects_object_range_pages_mismatch(self): + # The malformed L3-reloaded-node state the reload-key slice prevents: + # padded_len claims 2 pages but the backing object holds only 1 (the node's + # key was longer than its host backup). split() must fail loud rather than + # mis-partition the object. + metadata = CpSharedL2NodeMetadata( + logical_len=128, + padded_len=128, # 2 pages + page_size=64, + object_ranges={ + PAYLOAD_TARGET_KV: CpSharedL2ObjectRange( + object_key="malformed", + payload_kind=PAYLOAD_TARGET_KV, + slab_id=0, + base_page=0, + num_pages=1, # only 1 page backed -> mismatch + generation=1, + ) + }, + required_payloads=(PAYLOAD_TARGET_KV,), + object_key="malformed", + ) + + with self.assertRaisesRegex(ValueError, "page count must match"): + metadata.split(64) + class TestCpSharedHostSlabPrimitives(unittest.TestCase): def test_create_attach_visibility_and_lifecycle_owner_unlinks_only_on_creator_close(self): @@ -1102,6 +1128,29 @@ class TestCpSharedL2PageAllocator(unittest.TestCase): self.assertTrue(allocator.release("node-12")) self.assertEqual(allocator.free_pages(PAYLOAD_TARGET_KV), 8) + def test_split_committed_object_rejects_split_pages_outside_open_interval(self): + # The CP HiCache prefill crash: a partially-host-backed L3-reloaded node + # (object num_pages=C) was split at a KEY-derived split_pages >= C. The + # allocator must reject any split_pages not in (0, num_pages) loudly and + # leave the object untouched (the page-count invariant the reload-key + # slice restores). C=1 here mirrors the reported split_pages=172 vs + # range_pages=1. + allocator = self.make_allocator(pages=8) + allocator.reserve("obj", PAYLOAD_TARGET_KV, 1) # C = 1 page + allocator.mark_object_committed("obj") + for bad in (0, 1, 2, 172): # 0 and >=num_pages(1) are all invalid + with self.assertRaises(ValueError): + allocator.split_committed_object( + "obj", + split_pages_by_payload={PAYLOAD_TARGET_KV: bad}, + parent_object_key="obj-parent", + child_object_key="obj", + ) + # object untouched after every rejected split (validated before mutation) + self.assertTrue(allocator.is_committed("obj")) + self.assertEqual(allocator.object_ranges("obj")[PAYLOAD_TARGET_KV].num_pages, 1) + self.assertEqual(allocator.object_ranges("obj-parent"), {}) + def test_duplicate_live_object_payload_reservations_are_rejected(self): allocator = self.make_allocator(pages=5) allocator.reserve("dup", PAYLOAD_TARGET_KV, 1)