33import asyncio
44import hashlib
55import json
6- from datetime import datetime
6+ from contextlib import asynccontextmanager , suppress
7+ from datetime import datetime , timedelta
78from logging import getLogger
89from typing import TYPE_CHECKING , Any
910from weakref import WeakValueDictionary
1213from pydantic .alias_generators import to_camel
1314
1415if TYPE_CHECKING :
15- from collections .abc import Awaitable , Callable
16+ from collections .abc import AsyncGenerator , Awaitable , Callable
1617
1718 from apify_client import ApifyClientAsync
1819 from apify_client ._models import Run
3233
3334_ABORTABLE_STATUSES = frozenset ({'READY' , 'RUNNING' })
3435
36+ _ACTIVE_STATUSES = frozenset ({'READY' , 'RUNNING' , 'ABORTING' , 'TIMING-OUT' })
37+
38+ _STATUS_MAX_AGE = timedelta (seconds = 10 )
39+ """How long an observed active status counts toward the concurrency limit before the run is fetched again."""
40+
3541_NOT_FOUND_GRACE_SECS = 3
3642"""How long a recorded run that the API reports as missing is looked up again before it counts as gone."""
3743
@@ -111,6 +117,19 @@ def __init__(self, open_key_value_store: Callable[[], Awaitable[KeyValueStore]])
111117 """Serializes `find_or_start` per name. A lock is dropped once no call under its name holds it."""
112118 self ._clients : dict [str , ApifyClientAsync ] = {}
113119 """Client the run under each name was last started or looked up with. Lost on a migration."""
120+ self ._max_concurrent_runs : int | None = None
121+ self ._slots = asyncio .Condition ()
122+ self ._starting : set [str ] = set ()
123+ """Names holding a slot for a start or resurrection in flight, not counted from the records yet."""
124+ self ._observed : dict [str , tuple [str , float ]] = {}
125+ """Last observed status of the run recorded under each name, with the event loop time it was observed at."""
126+ self ._parent_aborting = False
127+
128+ def set_max_concurrent_runs (self , max_concurrent_runs : int | None ) -> None :
129+ """Set how many recorded runs may be active at once, or remove the limit with `None`."""
130+ if max_concurrent_runs is not None and max_concurrent_runs < 1 :
131+ raise ValueError (f'`max_concurrent_runs` must be at least 1, got { max_concurrent_runs } .' )
132+ self ._max_concurrent_runs = max_concurrent_runs
114133
115134 async def find_or_start (
116135 self ,
@@ -131,6 +150,8 @@ async def find_or_start(
131150 A `FAILED` run, or one the API no longer knows, is replaced by a new run under the same name, for any Actor,
132151 task or input.
133152
153+ Starting or resurrecting a run waits while the concurrency limit is reached. Reattaching never waits.
154+
134155 Args:
135156 name: Name of the child run, unique within the parent run.
136157 actor_id: The Actor to start.
@@ -156,9 +177,10 @@ async def find_or_start(
156177 record = records .get (name )
157178
158179 if record is None :
159- run = await self ._start (
160- name , checksum = checksum , start_run = start_run , history = [], abort_with_parent = abort_with_parent
161- )
180+ async with self ._slot (name , client ):
181+ run = await self ._start (
182+ name , checksum = checksum , start_run = start_run , history = [], abort_with_parent = abort_with_parent
183+ )
162184 self ._clients [name ] = client
163185 return run , True
164186
@@ -174,13 +196,14 @@ async def find_or_start(
174196 status = run .status if run is not None else 'LOST' ,
175197 started_at = record .started_at ,
176198 )
177- run = await self ._start (
178- name ,
179- checksum = checksum ,
180- start_run = start_run ,
181- history = [* record .history , replaced ],
182- abort_with_parent = abort_with_parent ,
183- )
199+ async with self ._slot (name , client ):
200+ run = await self ._start (
201+ name ,
202+ checksum = checksum ,
203+ start_run = start_run ,
204+ history = [* record .history , replaced ],
205+ abort_with_parent = abort_with_parent ,
206+ )
184207 self ._clients [name ] = client
185208 return run , True
186209
@@ -190,12 +213,16 @@ async def find_or_start(
190213 'Use a unique `run_name` for each child run.'
191214 )
192215
216+ self ._observe (name , run )
217+
193218 if record .abort_with_parent != abort_with_parent :
194219 await self ._save (name , record .model_copy (update = {'abort_with_parent' : abort_with_parent }))
195220
196221 if run .status in _RESURRECTABLE_STATUSES :
197- logger .info (f'Resurrecting child run "{ name } "' , extra = {'run_id' : run .id , 'status' : run .status })
198- run = await resurrect_run (run .id )
222+ async with self ._slot (name , client ):
223+ logger .info (f'Resurrecting child run "{ name } "' , extra = {'run_id' : run .id , 'status' : run .status })
224+ run = await resurrect_run (run .id )
225+ self ._observe (name , run )
199226 else :
200227 logger .info (f'Reattaching to child run "{ name } "' , extra = {'run_id' : run .id , 'status' : run .status })
201228
@@ -221,14 +248,20 @@ def run_clients(self, default_client: ApifyClientAsync) -> dict[str, RunClientAs
221248 async def update (self , name : str , run : Run ) -> None :
222249 """Record the latest observed status of the run recorded under `name`.
223250
251+ A run that is no longer active releases its slot under the concurrency limit.
252+
224253 Args:
225254 name: Name of the child run.
226255 run: The run as just returned by the API. Nothing is recorded unless it is the current run under `name`.
227256 """
228257 record = (await self ._load ()).get (name )
229- if record is None or record .run_id != run .id or record . status == run . status :
258+ if record is None or record .run_id != run .id :
230259 return
231- await self ._save (name , record .model_copy (update = {'status' : run .status }))
260+ self ._observe (name , run )
261+ if record .status != run .status :
262+ await self ._save (name , record .model_copy (update = {'status' : run .status }))
263+ async with self ._slots :
264+ self ._slots .notify_all ()
232265
233266 async def abort_runs_with_parent (self , client : ApifyClientAsync ) -> None :
234267 """Gracefully abort every recorded run marked `abort_with_parent` that is still `READY` or `RUNNING`.
@@ -238,6 +271,10 @@ async def abort_runs_with_parent(self, client: ApifyClientAsync) -> None:
238271 Args:
239272 client: Client used for a name not started or looked up in this process, e.g. after a migration.
240273 """
274+ self ._parent_aborting = True
275+ async with self ._slots :
276+ self ._slots .notify_all ()
277+
241278 records = await self ._load ()
242279 # Names with a start in flight are not recorded yet, so their locks are awaited too.
243280 await asyncio .gather (* (self ._abort (name , client ) for name in {* records , * self ._name_locks }))
@@ -277,8 +314,75 @@ async def _start(
277314 abort_with_parent = abort_with_parent ,
278315 )
279316 await self ._save (name , record )
317+ self ._observe (name , run )
280318 return run
281319
320+ @asynccontextmanager
321+ async def _slot (self , name : str , client : ApifyClientAsync ) -> AsyncGenerator [None ]:
322+ """Hold a slot for starting or resurrecting the run under `name`, waiting while the limit is reached."""
323+ if self ._max_concurrent_runs is None :
324+ yield
325+ return
326+
327+ async with self ._slots :
328+ while (
329+ self ._max_concurrent_runs is not None
330+ and await self ._count_active (client , exclude = name ) >= self ._max_concurrent_runs
331+ ):
332+ if self ._parent_aborting :
333+ raise RuntimeError (
334+ f'Child run "{ name } " was not started, since this Actor run is being aborted and the limit '
335+ f'of { self ._max_concurrent_runs } concurrent child runs is reached.'
336+ )
337+ logger .debug (f'Child run "{ name } " is waiting for a free slot' )
338+ with suppress (TimeoutError ):
339+ await asyncio .wait_for (self ._slots .wait (), timeout = _STATUS_MAX_AGE .total_seconds ())
340+ self ._starting .add (name )
341+
342+ try :
343+ yield
344+ finally :
345+ self ._starting .discard (name )
346+ async with self ._slots :
347+ self ._slots .notify_all ()
348+
349+ async def _count_active (self , client : ApifyClientAsync , * , exclude : str ) -> int :
350+ """Count active recorded runs and slots held by others, fetching runs whose active status is not fresh."""
351+ records = await self ._load ()
352+ names = [name for name in records if name != exclude and name not in self ._starting ]
353+ now = asyncio .get_running_loop ().time ()
354+ stale = [
355+ name
356+ for name in names
357+ if name not in self ._observed
358+ or (
359+ self ._observed [name ][0 ] in _ACTIVE_STATUSES
360+ and now - self ._observed [name ][1 ] >= _STATUS_MAX_AGE .total_seconds ()
361+ )
362+ ]
363+ runs = await asyncio .gather (
364+ * (self ._clients .get (name , client ).run (records [name ].run_id ).get () for name in stale ),
365+ return_exceptions = True ,
366+ )
367+ for name , run in zip (stale , runs , strict = True ):
368+ if isinstance (run , BaseException ):
369+ logger .warning (
370+ f'Failed to fetch child run "{ name } " to count it toward the concurrency limit' ,
371+ extra = {'run_id' : records [name ].run_id },
372+ exc_info = run ,
373+ )
374+ elif run is None :
375+ self ._observed [name ] = ('MISSING' , now )
376+ else :
377+ self ._observe (name , run )
378+
379+ # A run that could not be fetched counts only when an earlier observation saw it active.
380+ active = sum (1 for name in names if name in self ._observed and self ._observed [name ][0 ] in _ACTIVE_STATUSES )
381+ return active + len (self ._starting )
382+
383+ def _observe (self , name : str , run : Run ) -> None :
384+ self ._observed [name ] = (run .status , asyncio .get_running_loop ().time ())
385+
282386 async def load (self ) -> dict [str , ChildRunRecord ]:
283387 """Read the records from the default key-value store, replacing any read before."""
284388 async with self ._lock :
0 commit comments