Skip to content

Commit 7c3d462

Browse files
committed
feat: add a concurrency limit for named child runs
1 parent c1f6b59 commit 7c3d462

4 files changed

Lines changed: 450 additions & 16 deletions

File tree

‎src/apify/_actor.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,22 @@ def child_runs(self) -> dict[str, RunClientAsync]:
420420
"""
421421
return self._child_run_registry.run_clients(self.apify_client)
422422

423+
def set_child_run_limits(self, *, max_concurrent_runs: int | None) -> None:
424+
"""Limit the named child runs of this Actor run.
425+
426+
While `max_concurrent_runs` named child runs are `READY`, `RUNNING`, `ABORTING` or `TIMING-OUT`, a named
427+
`Actor.start`, `Actor.call`, `Actor.start_task` or `Actor.call_task` that would start or resurrect a run waits
428+
until one of them finishes. Reattaching to a recorded run never waits. Runs started without a `run_name` are not
429+
counted and never wait. A child run not awaited by `Actor.call` or `Actor.call_task` is fetched again before it
430+
is counted, if its status is more than 10 seconds old.
431+
432+
The limit is kept in memory, so call this method again after a migration or resurrection of this Actor run.
433+
434+
Args:
435+
max_concurrent_runs: How many named child runs may be active at once, or `None` for no limit.
436+
"""
437+
self._child_run_registry.set_max_concurrent_runs(max_concurrent_runs)
438+
423439
@cached_property
424440
def configuration(self) -> Configuration:
425441
"""Actor configuration, uses the default instance if not explicitly set."""

‎src/apify/_child_runs.py‎

Lines changed: 120 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import asyncio
44
import hashlib
55
import json
6-
from datetime import datetime
6+
from contextlib import asynccontextmanager, suppress
7+
from datetime import datetime, timedelta
78
from logging import getLogger
89
from typing import TYPE_CHECKING, Any
910
from weakref import WeakValueDictionary
@@ -12,7 +13,7 @@
1213
from pydantic.alias_generators import to_camel
1314

1415
if 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
@@ -32,6 +33,11 @@
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:

‎tests/e2e/test_actor_child_runs.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,36 @@ async def main() -> None:
131131
child_run = await apify_client_async.run(child_run_id).wait_for_finish(wait_duration=timedelta(seconds=120))
132132
assert child_run is not None
133133
assert child_run.status == 'ABORTED'
134+
135+
136+
async def test_named_child_runs_respect_the_concurrency_limit(
137+
make_actor: MakeActorFunction,
138+
run_actor: RunActorFunction,
139+
) -> None:
140+
"""Named child runs started concurrently under a limit of one run one after another."""
141+
142+
async def main() -> None:
143+
async with Actor:
144+
actor_input = (await Actor.get_input()) or {}
145+
if actor_input.get('is_child') is True:
146+
await asyncio.sleep(10)
147+
return
148+
149+
actor_id = Actor.configuration.actor_id or ''
150+
Actor.set_child_run_limits(max_concurrent_runs=1)
151+
runs = await asyncio.gather(
152+
*(
153+
Actor.call(actor_id=actor_id, run_input={'is_child': True}, run_name=f'child-{index}')
154+
for index in range(2)
155+
)
156+
)
157+
first, second = sorted(runs, key=lambda run: run.started_at)
158+
assert first.finished_at is not None, 'first.finished_at is None'
159+
assert second.started_at >= first.finished_at, f'first={first}, second={second}'
160+
161+
actor = await make_actor(label='child-run-limit', main_func=main)
162+
run_result = await run_actor(actor)
163+
164+
assert run_result.status == 'SUCCEEDED'
165+
# The parent run and its two child runs.
166+
assert (await actor.runs().list()).total == 3

0 commit comments

Comments
 (0)