44
55import asyncio
66import logging
7+ from itertools import chain , repeat
78from typing import TYPE_CHECKING , TypeVar
89from unittest .mock import AsyncMock , Mock
910
@@ -93,7 +94,7 @@ async def run(_task_id: str) -> None:
9394 concurrency_system = concurrency_system ,
9495 consumer = CONSUMER ,
9596 run_task_function = run ,
96- is_task_ready_function = lambda : future (True ),
97+ is_task_ready_function = lambda : future (done_count < 1 ),
9798 is_finished_function = lambda : future (done_count >= 1 ),
9899 )
99100
@@ -122,7 +123,7 @@ async def run(_task_id: str) -> None:
122123 concurrency_system = concurrency_system ,
123124 consumer = CONSUMER ,
124125 run_task_function = run ,
125- is_task_ready_function = lambda : future (True ),
126+ is_task_ready_function = lambda : future (not release . is_set () ),
126127 is_finished_function = lambda : future (release .is_set ()),
127128 )
128129
@@ -137,6 +138,87 @@ async def run(_task_id: str) -> None:
137138 await asyncio .gather (pool_run_task , return_exceptions = True )
138139
139140
141+ async def test_finishes_only_when_idle (make_concurrency_system : type [ConcurrencySystem ]) -> None :
142+ """The pool asks whether it's finished only while none of its tasks are in flight."""
143+ started = 0
144+ own_concurrency_at_checks = list [int ]()
145+
146+ async def run (_task_id : str ) -> None :
147+ nonlocal started
148+ started += 1
149+ # The second task outlives the first, so the pool looks for new work while a task is still running.
150+ await asyncio .sleep (0.01 if started == 1 else 0.1 )
151+
152+ async def is_finished () -> bool :
153+ own_concurrency_at_checks .append (pool .own_concurrency )
154+ return True
155+
156+ async with make_concurrency_system () as concurrency_system :
157+ pool = AutoscaledPool (
158+ concurrency_system = concurrency_system ,
159+ consumer = CONSUMER ,
160+ run_task_function = run ,
161+ is_task_ready_function = AsyncMock (side_effect = chain ([True , True ], repeat (False ))),
162+ is_finished_function = is_finished ,
163+ )
164+
165+ await asyncio .wait_for (pool .run (), timeout = 5 )
166+
167+ assert started == 2
168+ assert own_concurrency_at_checks == [0 ]
169+
170+
171+ async def test_finishes_while_system_saturated (make_concurrency_system : type [ConcurrencySystem ]) -> None :
172+ """A pool out of work finishes even while another pool keeps a shared system saturated."""
173+ settings = ConcurrencySettings (min_concurrency = 1 , max_concurrency = 1 , desired_concurrency = 1 )
174+ is_task_ready = AsyncMock (return_value = False )
175+
176+ async with make_concurrency_system (settings ) as concurrency_system :
177+ # Another pool sharing the system holds the only slot.
178+ assert concurrency_system .try_register_task_start (ConcurrencyConsumer (id = 'other-pool' ), '0' )
179+
180+ pool = AutoscaledPool (
181+ concurrency_system = concurrency_system ,
182+ consumer = CONSUMER ,
183+ run_task_function = AsyncMock (),
184+ is_task_ready_function = is_task_ready ,
185+ is_finished_function = lambda : future (True ),
186+ )
187+
188+ await asyncio .wait_for (pool .run (), timeout = 5 )
189+
190+ # The pool never had capacity to ask for a task, so it finished from the saturated branch.
191+ is_task_ready .assert_not_awaited ()
192+
193+
194+ async def test_does_not_finish_while_paused (make_concurrency_system : type [ConcurrencySystem ]) -> None :
195+ """A paused pool with no work left finishes only once it's resumed."""
196+ is_finished = AsyncMock (return_value = True )
197+
198+ async with make_concurrency_system () as concurrency_system :
199+ pool = AutoscaledPool (
200+ concurrency_system = concurrency_system ,
201+ consumer = CONSUMER ,
202+ run_task_function = AsyncMock (),
203+ is_task_ready_function = lambda : future (False ),
204+ is_finished_function = is_finished ,
205+ )
206+ pool .pause ()
207+
208+ pool_run_task = asyncio .create_task (pool .run (), name = 'pool run task' )
209+ try :
210+ await asyncio .sleep (0.1 )
211+ is_finished .assert_not_awaited ()
212+
213+ pool .resume ()
214+ await asyncio .wait_for (pool_run_task , timeout = 5 )
215+ finally :
216+ pool_run_task .cancel ()
217+ await asyncio .gather (pool_run_task , return_exceptions = True )
218+
219+ is_finished .assert_awaited_once ()
220+
221+
140222@pytest .mark .run_alone
141223async def test_runs_concurrently (make_concurrency_system : type [ConcurrencySystem ]) -> None :
142224 """The pool fills the whole budget the system allows, rather than running the tasks one after another."""
@@ -159,7 +241,7 @@ async def run(_task_id: str) -> None:
159241 concurrency_system = concurrency_system ,
160242 consumer = CONSUMER ,
161243 run_task_function = run ,
162- is_task_ready_function = lambda : future (True ),
244+ is_task_ready_function = lambda : future (done_count < 10 ),
163245 is_finished_function = lambda : future (done_count >= 10 ),
164246 )
165247
@@ -270,7 +352,7 @@ async def run(_task_id: str) -> None:
270352 concurrency_system = concurrency_system ,
271353 consumer = CONSUMER ,
272354 run_task_function = run ,
273- is_task_ready_function = lambda : future (True ),
355+ is_task_ready_function = lambda : future (done_count < 20 ),
274356 is_finished_function = lambda : future (done_count >= 20 ),
275357 )
276358
@@ -297,22 +379,22 @@ async def run(_task_id: str) -> None:
297379 await release_worker .wait ()
298380 worker_finished .set ()
299381
300- async def is_finished () -> bool :
382+ async def is_task_ready () -> bool :
301383 nonlocal checks
302384 checks += 1
303385 if checks > 1 :
304386 await worker_started .wait ()
305387 check_failed .set ()
306388 raise RuntimeError ('Queue status unavailable' )
307- return False
389+ return True
308390
309391 async with make_concurrency_system () as concurrency_system :
310392 pool = AutoscaledPool (
311393 concurrency_system = concurrency_system ,
312394 consumer = CONSUMER ,
313395 run_task_function = run ,
314- is_task_ready_function = lambda : future ( True ) ,
315- is_finished_function = is_finished ,
396+ is_task_ready_function = is_task_ready ,
397+ is_finished_function = lambda : future ( False ) ,
316398 )
317399 pool_run_task = asyncio .create_task (pool .run ())
318400 try :
@@ -355,30 +437,27 @@ async def is_finished() -> bool:
355437
356438
357439async def test_propagates_exceptions_after_finished (make_concurrency_system : type [ConcurrencySystem ]) -> None :
358- """A task that fails after the pool is already finished still fails the run."""
359- started_count = 0
440+ """A task that fails once no work is left still fails the run, instead of the pool just finishing ."""
441+ is_finished = AsyncMock ( return_value = True )
360442
361443 async def run (_task_id : str ) -> None :
362- nonlocal started_count
363- started_count += 1
364-
365- await asyncio .sleep (1 )
366-
444+ await asyncio .sleep (0.1 )
367445 raise RuntimeError ('Scheduled crash' )
368446
369- async with make_concurrency_system (
370- ConcurrencySettings (min_concurrency = 1 , max_concurrency = 1 , desired_concurrency = 1 )
371- ) as concurrency_system :
447+ async with make_concurrency_system () as concurrency_system :
372448 pool = AutoscaledPool (
373449 concurrency_system = concurrency_system ,
374450 consumer = CONSUMER ,
375451 run_task_function = run ,
376- is_task_ready_function = lambda : future ( True ),
377- is_finished_function = lambda : future ( started_count > 0 ) ,
452+ is_task_ready_function = AsyncMock ( side_effect = chain ([ True ], repeat ( False )) ),
453+ is_finished_function = is_finished ,
378454 )
379455
380456 with pytest .raises (RuntimeError , match = r'Scheduled crash' ):
381- await pool .run ()
457+ await asyncio .wait_for (pool .run (), timeout = 5 )
458+
459+ # The pool never asked whether it was finished while the failing task was still running.
460+ is_finished .assert_not_awaited ()
382461
383462
384463async def test_propagates_task_timeout_error (
@@ -399,7 +478,7 @@ async def run(_task_id: str) -> None:
399478 concurrency_system = concurrency_system ,
400479 consumer = CONSUMER ,
401480 run_task_function = run ,
402- is_task_ready_function = lambda : future (True ),
481+ is_task_ready_function = lambda : future (started_count == 0 ),
403482 is_finished_function = lambda : future (started_count > 0 ),
404483 )
405484
0 commit comments