@@ -68,15 +68,17 @@ def test_scheduler_reports_allocation_failure(request_runner):
6868 runner .run (decoded_tokens = [EOS_TOKEN_ID ])
6969
7070 reduced = _reduce_kv_connector_stats (runner )
71- assert reduced [_ConnectorMetricName .ALLOCATION_FAILURE ] == 1
71+ # Two attempts: once while running (block becomes full during prefill),
72+ # once from finished_req_ids on the next step.
73+ assert reduced [_ConnectorMetricName .ALLOCATION_FAILURE ] == 2
7274
7375
7476@pytest .mark .parametrize ("async_scheduling" , [True , False ])
7577@pytest .mark .parametrize ("prompt_offset" , [- 1 , - 2 ])
7678def test_last_block_offloaded_at_request_finish (
7779 request_runner , async_scheduling : bool , prompt_offset : int
7880):
79- """EOS fills the last block at request finish — verify req_status is kept alive .
81+ """EOS fills the last block at request finish - verify the final block is stored .
8082
8183 prompt = block_size + prompt_offset tokens → not a full block at schedule time,
8284 so _build_store_jobs creates no store job. After EOS, request_finished
@@ -98,18 +100,16 @@ def test_last_block_offloaded_at_request_finish(
98100 generate_store_output (list (keys ))
99101 )
100102
101- # Run with one step (EOS)
102- runner .run (
103- decoded_tokens = [EOS_TOKEN_ID ],
104- )
103+ if prompt_offset == - 1 :
104+ # EOS fills the block, so a store job is created for block 0.
105+ runner .run (decoded_tokens = [EOS_TOKEN_ID ], expected_stored = (0 ,))
106+ else :
107+ # Block remains partial, so no store job is created.
108+ runner .run (decoded_tokens = [EOS_TOKEN_ID ])
105109
106110 cs = runner .connector_scheduler
107- # Verify req_status is kept alive for _build_store_jobs to process
108- # regardless of whether there are storable blocks
109- assert "0" in cs ._req_status , (
110- "req_status was deleted but should be kept alive "
111- "for _build_store_jobs to process finished_req_ids."
112- )
111+ # After the full run completes, req_status is cleaned up.
112+ assert "0" not in cs ._req_status
113113
114114
115115@pytest .mark .parametrize ("async_scheduling" , [True , False ])
@@ -569,14 +569,14 @@ def test_request_preemption(request_runner, async_scheduling: bool):
569569
570570
571571@pytest .mark .parametrize ("async_scheduling" , [True , False ])
572- def test_on_request_finished_is_not_deferred_until_store_completion (
572+ def test_on_request_finished_not_deferred_until_store_completion (
573573 request_runner , async_scheduling : bool
574574):
575- """on_request_finished fires when no more stores will be submitted.
575+ """on_request_finished fires after the last prepare_store is submitted.
576576
577- A request can finish while its GPU->primary store is still in flight. The
578- manager-level hook should not wait for that completion; complete_store may
579- still arrive afterward for already-submitted transfer jobs .
577+ The manager contract guarantees no more submit-side calls (prepare_store)
578+ after on_request_finished. However, complete_store callbacks for
579+ already-submitted transfers may still arrive afterward .
580580 """
581581 block_size = 4
582582 blocks_per_chunk = 3
@@ -613,8 +613,9 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
613613 complete_transfers = False ,
614614 )
615615
616- # Finish the request while its stores are still in flight. The hook should
617- # fire immediately even though no complete_store has arrived yet.
616+ # Finish the request while its stores are still in flight. The hook fires
617+ # once the last prepare_store is issued (on the next schedule step), even
618+ # though complete_store has not yet been called.
618619 runner .run (
619620 decoded_tokens = [EOS_TOKEN_ID ],
620621 complete_transfers = False ,
@@ -624,8 +625,7 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
624625
625626 assert calls == [("on_request_finished" , req_id )], calls
626627
627- # Drain the stores afterward. The already-submitted complete_store calls
628- # are allowed to arrive after on_request_finished.
628+ # Drain the stores afterward. complete_store is allowed after the hook.
629629 runner .run (
630630 decoded_tokens = [],
631631 complete_transfers = True ,
@@ -638,11 +638,50 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
638638 finished_idx = calls .index (("on_request_finished" , req_id ))
639639 store_indices = [i for i , c in enumerate (calls ) if c == ("complete_store" , req_id )]
640640
641- # The request-level hook no longer waits for already-submitted transfers .
641+ # complete_store arrives after on_request_finished, as allowed by the contract .
642642 assert store_indices , calls
643643 assert finished_idx < min (store_indices ), calls
644644
645645
646+ @pytest .mark .parametrize ("async_scheduling" , [True , False ])
647+ def test_on_request_finished_fires_after_final_block_store (
648+ request_runner , async_scheduling : bool
649+ ):
650+ """on_request_finished fires after the final-block prepare_store at EOS.
651+
652+ When EOS fills a partial block, request_finished() keeps req_status alive
653+ so _build_store_jobs can create a store job for it on the next step.
654+ """
655+ block_size = 4
656+ runner = request_runner (
657+ block_size = block_size ,
658+ num_gpu_blocks = 10 ,
659+ async_scheduling = async_scheduling ,
660+ )
661+
662+ calls : list [tuple [str , str ]] = []
663+ runner .manager .on_request_finished .side_effect = lambda req_context : calls .append (
664+ ("on_request_finished" , req_context .req_id )
665+ )
666+
667+ def prepare_store (keys , req_context ):
668+ calls .append (("prepare_store" , req_context .req_id ))
669+ return generate_store_output (keys )
670+
671+ runner .manager .prepare_store .side_effect = prepare_store
672+
673+ runner .new_request (token_ids = [0 ] * (block_size - 1 ))
674+ runner .run (decoded_tokens = [EOS_TOKEN_ID ], expected_stored = (0 ,))
675+
676+ req_id = str (runner .req_id )
677+ assert calls .count (("on_request_finished" , req_id )) == 1 , calls
678+
679+ finished_idx = calls .index (("on_request_finished" , req_id ))
680+ prepare_indices = [i for i , c in enumerate (calls ) if c == ("prepare_store" , req_id )]
681+ assert prepare_indices , calls
682+ assert finished_idx > max (prepare_indices ), calls
683+
684+
646685@pytest .mark .parametrize ("async_scheduling" , [True , False ])
647686def test_concurrent_lookups_of_the_same_prefix (request_runner , async_scheduling : bool ):
648687 block_size = 4
@@ -831,7 +870,10 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
831870 touch_calls = runner .manager .touch .call_args_list
832871 assert len (touch_calls ) == 6
833872
834- runner .run (decoded_tokens = [EOS_TOKEN_ID ])
873+ # EOS fills the 7th block (offset 6). The extra schedule step processes
874+ # finished_req_ids and stores block 6 for both groups before the request's
875+ # GPU blocks are freed.
876+ runner .run (decoded_tokens = [EOS_TOKEN_ID ], expected_stored = (6 ,))
835877
836878 runner .scheduler .reset_prefix_cache ()
837879
@@ -844,19 +886,11 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
844886 # Group 1 (sliding window, window=2): only the last 2 blocks
845887 # are within the window → loads blocks 1,2
846888 expected_loaded = ((0 , 0 ), (0 , 1 ), (0 , 2 ), (1 , 1 ), (1 , 2 )),
847- # The deferred store from the previous request's last block
848- # completes during this step, and its blocks are flushed because
849- # they were reallocated to the new request.
850- # Only block 1 (sliding window group) is stored — block 0's
851- # deferred store is flushed because it was reallocated.
852- expected_stored = ((0 , 1 ),),
853- expected_flushed = ((0 , 1 ),),
854889 )
855890
856- # 4 touch calls: 2 from get_num_new_matched_tokens (2 groups)
857- # + 2 from _get_reqs_to_store (2 groups)
891+ # 2 touch calls from get_num_new_matched_tokens (2 groups)
858892 touch_calls = runner .manager .touch .call_args_list
859- assert len (touch_calls ) == 4
893+ assert len (touch_calls ) == 2
860894 # full attention group touched all 3 blocks
861895 assert len (touch_calls [0 ].args [0 ]) == 3
862896 # sliding window group touched just the last 2 blocks
@@ -1733,8 +1767,8 @@ def test_reset_cache(request_runner, async_scheduling: bool):
17331767def test_reset_cache_finalizes_finished_request_with_pending_store (
17341768 request_runner , async_scheduling : bool
17351769):
1736- """reset_cache drops a finished request whose in-flight stores it discards
1737- without calling on_request_finished twice .
1770+ """reset_cache fires on_request_finished for a finished request whose
1771+ in-flight stores it discards, exactly once .
17381772 """
17391773 block_size = 4
17401774 blocks_per_chunk = 3
@@ -1770,16 +1804,15 @@ def test_reset_cache_finalizes_finished_request_with_pending_store(
17701804 assert req_status .transfer_jobs , "expected an in-flight store before finish"
17711805 assert any (job .is_store for job in cs ._jobs .values ())
17721806
1773- # Finish the request while its store is still in flight. request_finished
1774- # fires the hook eagerly, but the entry stays tracked so later completions
1775- # can still call complete_store().
1807+ # Finish the request while its store is still in flight. The manager hook
1808+ # is deferred because the final store decision has not happened yet.
17761809 req_status .req .status = RequestStatus .FINISHED_STOPPED
17771810 cs .request_finished (req_status .req )
1778- assert finalized == [req_id ]
1811+ assert finalized == []
17791812 assert req_id in cs ._req_status
17801813
1781- # reset_cache discards the in-flight store and drops the state without a
1782- # duplicate on_request_finished call .
1814+ # reset_cache discards both the in-flight and not-yet-prepared final stores,
1815+ # so it issues the deferred notification before dropping the state .
17831816 cs .reset_cache ()
17841817 assert finalized == [req_id ]
17851818 assert req_id not in cs ._req_status
0 commit comments