@@ -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 → 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 → no store job.
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,56 @@ 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 — allowed by 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 a request finishes with a partial block that becomes full at EOS,
653+ request_finished() keeps req_status alive so _build_store_jobs can create
654+ a store job for the last block on the next step. on_request_finished must
655+ fire after that prepare_store — not before it.
656+ """
657+ block_size = 4
658+ runner = request_runner (
659+ block_size = block_size ,
660+ num_gpu_blocks = 10 ,
661+ async_scheduling = async_scheduling ,
662+ )
663+
664+ calls : list [tuple [str , str ]] = []
665+ runner .manager .on_request_finished .side_effect = lambda req_context : calls .append (
666+ ("on_request_finished" , req_context .req_id )
667+ )
668+
669+ def _prepare_store_side_effect (keys , req_context ):
670+ calls .append (("prepare_store" , req_context .req_id ))
671+ return generate_store_output (keys )
672+
673+ runner .manager .prepare_store .side_effect = _prepare_store_side_effect
674+
675+ # Prompt is one token short of a full block — no store until EOS fills it.
676+ runner .new_request (token_ids = [0 ] * (block_size - 1 ))
677+ runner .run (decoded_tokens = [EOS_TOKEN_ID ], expected_stored = (0 ,))
678+
679+ req_id = str (runner .req_id )
680+
681+ # on_request_finished fires exactly once, after the final prepare_store.
682+ assert calls .count (("on_request_finished" , req_id )) == 1 , calls
683+
684+ finished_idx = calls .index (("on_request_finished" , req_id ))
685+ prepare_indices = [i for i , c in enumerate (calls ) if c == ("prepare_store" , req_id )]
686+
687+ assert prepare_indices , calls
688+ assert finished_idx > max (prepare_indices ), calls
689+
690+
646691@pytest .mark .parametrize ("async_scheduling" , [True , False ])
647692def test_concurrent_lookups_of_the_same_prefix (request_runner , async_scheduling : bool ):
648693 block_size = 4
@@ -831,7 +876,10 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
831876 touch_calls = runner .manager .touch .call_args_list
832877 assert len (touch_calls ) == 6
833878
834- runner .run (decoded_tokens = [EOS_TOKEN_ID ])
879+ # EOS fills the 7th block (offset 6). The extra schedule step processes
880+ # finished_req_ids and stores block 6 for both groups before the request's
881+ # GPU blocks are freed.
882+ runner .run (decoded_tokens = [EOS_TOKEN_ID ], expected_stored = (6 ,))
835883
836884 runner .scheduler .reset_prefix_cache ()
837885
@@ -844,19 +892,11 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
844892 # Group 1 (sliding window, window=2): only the last 2 blocks
845893 # are within the window → loads blocks 1,2
846894 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 ),),
854895 )
855896
856- # 4 touch calls: 2 from get_num_new_matched_tokens (2 groups)
857- # + 2 from _get_reqs_to_store (2 groups)
897+ # 2 touch calls from get_num_new_matched_tokens (2 groups)
858898 touch_calls = runner .manager .touch .call_args_list
859- assert len (touch_calls ) == 4
899+ assert len (touch_calls ) == 2
860900 # full attention group touched all 3 blocks
861901 assert len (touch_calls [0 ].args [0 ]) == 3
862902 # sliding window group touched just the last 2 blocks
@@ -1733,8 +1773,8 @@ def test_reset_cache(request_runner, async_scheduling: bool):
17331773def test_reset_cache_finalizes_finished_request_with_pending_store (
17341774 request_runner , async_scheduling : bool
17351775):
1736- """reset_cache drops a finished request whose in-flight stores it discards
1737- without calling on_request_finished twice .
1776+ """reset_cache fires on_request_finished for a finished request whose
1777+ in-flight stores it discards, exactly once .
17381778 """
17391779 block_size = 4
17401780 blocks_per_chunk = 3
@@ -1770,16 +1810,15 @@ def test_reset_cache_finalizes_finished_request_with_pending_store(
17701810 assert req_status .transfer_jobs , "expected an in-flight store before finish"
17711811 assert any (job .is_store for job in cs ._jobs .values ())
17721812
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().
1813+ # Finish the request while its store is still in flight. on_request_finished
1814+ # is deferred — it does NOT fire here because transfer_jobs are pending.
17761815 req_status .req .status = RequestStatus .FINISHED_STOPPED
17771816 cs .request_finished (req_status .req )
1778- assert finalized == [req_id ]
1817+ assert finalized == []
17791818 assert req_id in cs ._req_status
17801819
1781- # reset_cache discards the in-flight store and drops the state without a
1782- # duplicate on_request_finished call .
1820+ # reset_cache discards the in-flight stores and fires on_request_finished
1821+ # for the finished request before resetting the manager .
17831822 cs .reset_cache ()
17841823 assert finalized == [req_id ]
17851824 assert req_id not in cs ._req_status
0 commit comments