@@ -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- """Prepare the final store before notifying the manager of request finish .
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
@@ -94,85 +96,20 @@ def test_last_block_offloaded_at_request_finish(
9496 )
9597 # prompt = block_size + prompt_offset tokens
9698 runner .new_request (token_ids = [0 ] * (block_size + prompt_offset ))
97- manager_events : list [str ] = []
98-
99- def prepare_store (keys , req_context ):
100- manager_events .append ("prepare_store" )
101- return generate_store_output (list (keys ))
102-
103- runner .manager .prepare_store .side_effect = prepare_store
104- runner .manager .on_request_finished .side_effect = lambda req_context : (
105- manager_events .append ("on_request_finished" )
106- )
107-
108- # Run with one step (EOS)
109- runner .run (
110- decoded_tokens = [EOS_TOKEN_ID ],
111- )
112-
113- cs = runner .connector_scheduler
114- # Verify req_status is kept alive for _build_store_jobs to process
115- # regardless of whether there are storable blocks
116- assert "0" in cs ._req_status , (
117- "req_status was deleted but should be kept alive "
118- "for _build_store_jobs to process finished_req_ids."
119- )
120- assert manager_events == []
121-
122- scheduler_output = runner .scheduler .schedule ()
123- metadata = scheduler_output .kv_connector_metadata
124- assert isinstance (metadata , OffloadingConnectorMetadata )
125-
126- if prompt_offset == - 1 :
127- assert metadata .store_jobs
128- assert manager_events == ["prepare_store" , "on_request_finished" ]
129- assert "0" in cs ._req_status
130- else :
131- assert not metadata .store_jobs
132- assert manager_events == ["on_request_finished" ]
133- assert "0" not in cs ._req_status
134-
135-
136- @pytest .mark .parametrize ("async_scheduling" , [True , False ])
137- def test_store_completion_at_finish_keeps_pending_final_store (
138- request_runner , async_scheduling : bool
139- ):
140- """A completed older store must not discard the pending final store."""
141- block_size = 4
142- blocks_per_chunk = 3
143- tokens_per_chunk = block_size * blocks_per_chunk
144- runner = request_runner (
145- block_size = block_size ,
146- num_gpu_blocks = 10 ,
147- async_scheduling = async_scheduling ,
148- blocks_per_chunk = blocks_per_chunk ,
149- )
150- runner .new_request (token_ids = [0 ] * (2 * tokens_per_chunk - 1 ))
15199 runner .manager .prepare_store .side_effect = lambda keys , req_context : (
152100 generate_store_output (list (keys ))
153101 )
154102
155- # The first chunk's store completes in the same output that finishes the
156- # request. The EOS-filled second chunk still needs the next schedule step.
157- runner .run (
158- decoded_tokens = [EOS_TOKEN_ID ],
159- complete_transfers = True ,
160- expected_stored = tuple (range (blocks_per_chunk )),
161- )
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 ])
162109
163110 cs = runner .connector_scheduler
164- req_id = str (runner .req_id )
165- assert req_id in cs ._req_status
166- assert not cs ._req_status [req_id ].transfer_jobs
167- runner .manager .on_request_finished .assert_not_called ()
168-
169- scheduler_output = runner .scheduler .schedule ()
170- metadata = scheduler_output .kv_connector_metadata
171- assert isinstance (metadata , OffloadingConnectorMetadata )
172- assert metadata .store_jobs
173- runner .manager .on_request_finished .assert_called_once_with (
174- cs ._req_status [req_id ].req_context
175- )
111+ # After the full run completes, req_status is cleaned up.
112+ assert "0" not in cs ._req_status
176113
177114
178115@pytest .mark .parametrize ("async_scheduling" , [True , False ])
@@ -632,14 +569,14 @@ def test_request_preemption(request_runner, async_scheduling: bool):
632569
633570
634571@pytest .mark .parametrize ("async_scheduling" , [True , False ])
635- def test_on_request_finished_is_not_deferred_until_store_completion (
572+ def test_on_request_finished_not_deferred_until_store_completion (
636573 request_runner , async_scheduling : bool
637574):
638- """on_request_finished fires when no more stores will be submitted.
575+ """on_request_finished fires after the last prepare_store is submitted.
639576
640- A request can finish while its GPU->primary store is still in flight. The
641- manager-level hook should not wait for that completion; complete_store may
642- 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 .
643580 """
644581 block_size = 4
645582 blocks_per_chunk = 3
@@ -676,9 +613,9 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
676613 complete_transfers = False ,
677614 )
678615
679- # Finish the request while its stores are still in flight. The following
680- # scheduler step prepares any final store and fires the hook before any
681- # complete_store has arrived .
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 .
682619 runner .run (
683620 decoded_tokens = [EOS_TOKEN_ID ],
684621 complete_transfers = False ,
@@ -688,8 +625,7 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
688625
689626 assert calls == [("on_request_finished" , req_id )], calls
690627
691- # Drain the stores afterward. The already-submitted complete_store calls
692- # are allowed to arrive after on_request_finished.
628+ # Drain the stores afterward. complete_store is allowed after the hook.
693629 runner .run (
694630 decoded_tokens = [],
695631 complete_transfers = True ,
@@ -702,11 +638,50 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
702638 finished_idx = calls .index (("on_request_finished" , req_id ))
703639 store_indices = [i for i , c in enumerate (calls ) if c == ("complete_store" , req_id )]
704640
705- # The request-level hook no longer waits for already-submitted transfers .
641+ # complete_store arrives after on_request_finished, as allowed by the contract .
706642 assert store_indices , calls
707643 assert finished_idx < min (store_indices ), calls
708644
709645
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+
710685@pytest .mark .parametrize ("async_scheduling" , [True , False ])
711686def test_concurrent_lookups_of_the_same_prefix (request_runner , async_scheduling : bool ):
712687 block_size = 4
@@ -895,7 +870,10 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
895870 touch_calls = runner .manager .touch .call_args_list
896871 assert len (touch_calls ) == 6
897872
898- 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 ,))
899877
900878 runner .scheduler .reset_prefix_cache ()
901879
@@ -908,19 +886,11 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
908886 # Group 1 (sliding window, window=2): only the last 2 blocks
909887 # are within the window → loads blocks 1,2
910888 expected_loaded = ((0 , 0 ), (0 , 1 ), (0 , 2 ), (1 , 1 ), (1 , 2 )),
911- # The deferred store from the previous request's last block
912- # completes during this step, and its blocks are flushed because
913- # they were reallocated to the new request.
914- # Only block 1 (sliding window group) is stored — block 0's
915- # deferred store is flushed because it was reallocated.
916- expected_stored = ((0 , 1 ),),
917- expected_flushed = ((0 , 1 ),),
918889 )
919890
920- # 4 touch calls: 2 from get_num_new_matched_tokens (2 groups)
921- # + 2 from _get_reqs_to_store (2 groups)
891+ # 2 touch calls from get_num_new_matched_tokens (2 groups)
922892 touch_calls = runner .manager .touch .call_args_list
923- assert len (touch_calls ) == 4
893+ assert len (touch_calls ) == 2
924894 # full attention group touched all 3 blocks
925895 assert len (touch_calls [0 ].args [0 ]) == 3
926896 # sliding window group touched just the last 2 blocks
@@ -1797,8 +1767,8 @@ def test_reset_cache(request_runner, async_scheduling: bool):
17971767def test_reset_cache_finalizes_finished_request_with_pending_store (
17981768 request_runner , async_scheduling : bool
17991769):
1800- """reset_cache drops a finished request whose in-flight stores it discards
1801- 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 .
18021772 """
18031773 block_size = 4
18041774 blocks_per_chunk = 3
@@ -1835,8 +1805,7 @@ def test_reset_cache_finalizes_finished_request_with_pending_store(
18351805 assert any (job .is_store for job in cs ._jobs .values ())
18361806
18371807 # Finish the request while its store is still in flight. The manager hook
1838- # is deferred until the final store decision, and the entry stays tracked
1839- # so later completions can still call complete_store().
1808+ # is deferred because the final store decision has not happened yet.
18401809 req_status .req .status = RequestStatus .FINISHED_STOPPED
18411810 cs .request_finished (req_status .req )
18421811 assert finalized == []
0 commit comments