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 AsyncIterator , 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 ,
@@ -130,6 +149,8 @@ async def find_or_start(
130149 An `ABORTED` or `TIMED-OUT` run is resurrected, since Actors are expected to resume from their state.
131150 A `FAILED` run, or one the API no longer knows, is replaced by a new run under the same name.
132151
152+ Starting or resurrecting a run waits while the concurrency limit is reached. Reattaching never waits.
153+
133154 Args:
134155 name: Name of the child run, unique within the parent run.
135156 actor_id: The Actor to start.
@@ -157,9 +178,10 @@ async def find_or_start(
157178 )
158179
159180 if record is None :
160- run = await self ._start (
161- name , checksum = checksum , start_run = start_run , history = [], abort_with_parent = abort_with_parent
162- )
181+ async with self ._slot (name , client ):
182+ run = await self ._start (
183+ name , checksum = checksum , start_run = start_run , history = [], abort_with_parent = abort_with_parent
184+ )
163185 self ._clients [name ] = client
164186 return run , True
165187
@@ -176,21 +198,26 @@ async def find_or_start(
176198 status = run .status if run is not None else 'LOST' ,
177199 started_at = record .started_at ,
178200 )
179- run = await self ._start (
180- name ,
181- checksum = checksum ,
182- start_run = start_run ,
183- history = [* record .history , replaced ],
184- abort_with_parent = abort_with_parent ,
185- )
201+ async with self ._slot (name , client ):
202+ run = await self ._start (
203+ name ,
204+ checksum = checksum ,
205+ start_run = start_run ,
206+ history = [* record .history , replaced ],
207+ abort_with_parent = abort_with_parent ,
208+ )
186209 return run , True
187210
211+ self ._observe (name , run )
212+
188213 if record .abort_with_parent != abort_with_parent :
189214 await self ._save (name , record .model_copy (update = {'abort_with_parent' : abort_with_parent }))
190215
191216 if run .status in _RESURRECTABLE_STATUSES :
192- logger .info (f'Resurrecting child run "{ name } "' , extra = {'run_id' : run .id , 'status' : run .status })
193- run = await resurrect_run (run .id )
217+ async with self ._slot (name , client ):
218+ logger .info (f'Resurrecting child run "{ name } "' , extra = {'run_id' : run .id , 'status' : run .status })
219+ run = await resurrect_run (run .id )
220+ self ._observe (name , run )
194221 else :
195222 logger .info (f'Reattaching to child run "{ name } "' , extra = {'run_id' : run .id , 'status' : run .status })
196223
@@ -215,14 +242,20 @@ def run_clients(self, default_client: ApifyClientAsync) -> dict[str, RunClientAs
215242 async def update (self , name : str , run : Run ) -> None :
216243 """Record the latest observed status of the run recorded under `name`.
217244
245+ A run that is no longer active releases its slot under the concurrency limit.
246+
218247 Args:
219248 name: Name of the child run.
220249 run: The run as just returned by the API. Nothing is recorded unless it is the current run under `name`.
221250 """
222251 record = (await self ._load ()).get (name )
223- if record is None or record .run_id != run .id or record . status == run . status :
252+ if record is None or record .run_id != run .id :
224253 return
225- await self ._save (name , record .model_copy (update = {'status' : run .status }))
254+ self ._observe (name , run )
255+ if record .status != run .status :
256+ await self ._save (name , record .model_copy (update = {'status' : run .status }))
257+ async with self ._slots :
258+ self ._slots .notify_all ()
226259
227260 async def abort_runs_with_parent (self , client : ApifyClientAsync ) -> None :
228261 """Gracefully abort every recorded run marked `abort_with_parent` that is still `READY` or `RUNNING`.
@@ -232,6 +265,10 @@ async def abort_runs_with_parent(self, client: ApifyClientAsync) -> None:
232265 Args:
233266 client: Client used for a name not started or looked up in this process, e.g. after a migration.
234267 """
268+ self ._parent_aborting = True
269+ async with self ._slots :
270+ self ._slots .notify_all ()
271+
235272 records = await self ._load ()
236273 # Names with a start in flight are not recorded yet, so their locks are awaited too.
237274 await asyncio .gather (* (self ._abort (name , client ) for name in {* records , * self ._name_locks }))
@@ -271,8 +308,75 @@ async def _start(
271308 abort_with_parent = abort_with_parent ,
272309 )
273310 await self ._save (name , record )
311+ self ._observe (name , run )
274312 return run
275313
314+ @asynccontextmanager
315+ async def _slot (self , name : str , client : ApifyClientAsync ) -> AsyncIterator [None ]:
316+ """Hold a slot for starting or resurrecting the run under `name`, waiting while the limit is reached."""
317+ if self ._max_concurrent_runs is None :
318+ yield
319+ return
320+
321+ async with self ._slots :
322+ while (
323+ self ._max_concurrent_runs is not None
324+ and await self ._count_active (client , exclude = name ) >= self ._max_concurrent_runs
325+ ):
326+ if self ._parent_aborting :
327+ raise RuntimeError (
328+ f'Child run "{ name } " was not started, since this Actor run is being aborted and the limit '
329+ f'of { self ._max_concurrent_runs } concurrent child runs is reached.'
330+ )
331+ logger .debug (f'Child run "{ name } " is waiting for a free slot' )
332+ with suppress (TimeoutError ):
333+ await asyncio .wait_for (self ._slots .wait (), timeout = _STATUS_MAX_AGE .total_seconds ())
334+ self ._starting .add (name )
335+
336+ try :
337+ yield
338+ finally :
339+ self ._starting .discard (name )
340+ async with self ._slots :
341+ self ._slots .notify_all ()
342+
343+ async def _count_active (self , client : ApifyClientAsync , * , exclude : str ) -> int :
344+ """Count active recorded runs and slots held by others, fetching runs whose active status is not fresh."""
345+ records = await self ._load ()
346+ names = [name for name in records if name != exclude and name not in self ._starting ]
347+ now = asyncio .get_running_loop ().time ()
348+ stale = [
349+ name
350+ for name in names
351+ if name not in self ._observed
352+ or (
353+ self ._observed [name ][0 ] in _ACTIVE_STATUSES
354+ and now - self ._observed [name ][1 ] >= _STATUS_MAX_AGE .total_seconds ()
355+ )
356+ ]
357+ runs = await asyncio .gather (
358+ * (self ._clients .get (name , client ).run (records [name ].run_id ).get () for name in stale ),
359+ return_exceptions = True ,
360+ )
361+ for name , run in zip (stale , runs , strict = True ):
362+ if isinstance (run , BaseException ):
363+ logger .warning (
364+ f'Failed to fetch child run "{ name } " to count it toward the concurrency limit' ,
365+ extra = {'run_id' : records [name ].run_id },
366+ exc_info = run ,
367+ )
368+ elif run is None :
369+ self ._observed [name ] = ('MISSING' , now )
370+ else :
371+ self ._observe (name , run )
372+
373+ # A run that could not be fetched counts only when an earlier observation saw it active.
374+ active = sum (1 for name in names if name in self ._observed and self ._observed [name ][0 ] in _ACTIVE_STATUSES )
375+ return active + len (self ._starting )
376+
377+ def _observe (self , name : str , run : Run ) -> None :
378+ self ._observed [name ] = (run .status , asyncio .get_running_loop ().time ())
379+
276380 async def load (self ) -> dict [str , ChildRunRecord ]:
277381 """Read the records from the default key-value store, replacing any read before."""
278382 async with self ._lock :
0 commit comments