Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c2f5a87
feat: add a concurrency limit for named child runs
vdusek Oct 9, 2026
a20b3c7
refactor: keep the child run status max age in seconds and mark missi…
vdusek Oct 29, 2026
fc48db7
docs: document that a start waiting for a slot raises once the parent…
vdusek Oct 29, 2026
0e84cde
test: cover a recorded child run the API no longer knows under the co…
vdusek Oct 29, 2026
70fefc9
fix: count a named start already in flight when the concurrency limit…
vdusek Oct 29, 2026
5d03404
fix: count a named start that finishes while the active child runs ar…
vdusek Oct 29, 2026
158c3e6
perf: skip fetching finished child runs when counting toward the conc…
vdusek Oct 29, 2026
ca902ab
docs: add docstrings to the child run module constants
vdusek Oct 29, 2026
3447fab
docs: add docstrings to the child run registry attributes
vdusek Oct 29, 2026
a4569a7
refactor: rename the child run concurrency limit internals and method
vdusek Oct 30, 2026
403f118
Merge remote-tracking branch 'origin/master' into feat/child-run-conc…
vdusek Oct 30, 2026
206db98
refactor: use asyncio.timeout when waiting under the child run concur…
vdusek Oct 30, 2026
56f2cfe
refactor: cache child run statuses in a named tuple
vdusek Oct 30, 2026
10fc99c
fix: don't start a waiting abort_with_parent child run once the paren…
vdusek Oct 30, 2026
c81ae14
test: rename the child run limit test and document its helper
vdusek Oct 30, 2026
6d957ec
docs: clarify when the child run concurrency limit refetches statuses…
vdusek Oct 30, 2026
c5a23ab
refactor: type child run statuses as a Literal
vdusek Nov 4, 2026
37a6e87
docs: reword when waiting child run starts are woken
vdusek Nov 4, 2026
c834b21
Merge remote-tracking branch 'origin/master' into feat/child-run-conc…
vdusek Nov 4, 2026
5c6780a
refactor: persist the child run registry with RecoverableState
vdusek Nov 4, 2026
8cab4ee
refactor: extract waking the starts waiting under the child run limit
vdusek Nov 4, 2026
41614ac
fix: don't delay aborting child runs behind a start waiting under the…
vdusek Nov 4, 2026
2f0ac3c
refactor: replace set_named_child_run_limits with an Actor option
vdusek Nov 9, 2026
578b722
fix: resolve the inherited child run timeout once a waiting start starts
vdusek Nov 9, 2026
0154449
fix: don't fetch child runs while holding the concurrency limit condi…
vdusek Nov 9, 2026
7cc79b7
fix: keep the last seen status of a child run that fails to be fetched
vdusek Nov 9, 2026
d3c56cd
Merge remote-tracking branch 'origin/master' into feat/child-run-conc…
vdusek Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 61 additions & 42 deletions src/apify/_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from contextlib import AsyncExitStack, ExitStack
from dataclasses import asdict
from datetime import UTC, datetime, timedelta
from functools import cached_property, partial
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload

Expand Down Expand Up @@ -140,6 +140,7 @@ def __init__(
status_message: str | None = None,
event_listeners_timeout: timedelta | None = EVENT_LISTENERS_TIMEOUT,
cleanup_timeout: timedelta = timedelta(seconds=30),
max_concurrent_child_runs: int | None = None,
) -> None:
"""Initialize a new instance.

Expand All @@ -152,6 +153,12 @@ def __init__(
status_message: Final status message to display upon Actor termination.
event_listeners_timeout: Maximum time to wait for Actor event listeners to complete before exiting.
cleanup_timeout: Maximum time to wait for cleanup tasks to finish.
max_concurrent_child_runs: How many named child runs may be `READY`, `RUNNING`, `ABORTING` or `TIMING-OUT`
at once, or `None` for no limit. At the limit, a named `Actor.start`, `Actor.call`, `Actor.start_task`
or `Actor.call_task` waits to start or resurrect a run until another one finishes. Reattaching to a
recorded run never waits. Runs started without a `run_name` are not counted and never wait. A child
run's status is fetched again before it is counted, if it is more than 10 seconds old. Once this Actor
run is being gracefully aborted, a call that would wait raises a `RuntimeError`.
"""
self._configuration = configuration
self._configure_logging = configure_logging
Expand All @@ -171,7 +178,9 @@ def __init__(
# Keep track of all used state stores to persist their values on exit
self._use_state_stores: set[str | None] = set()

self._child_run_registry = ChildRunRegistry(self.open_key_value_store)
self._child_run_registry = ChildRunRegistry(
self.open_key_value_store, max_concurrent_runs=max_concurrent_child_runs
)

self._internal_listeners = ExitStack()
"""Removes the listeners the Actor registered on the event manager, when the Actor leaves its context."""
Expand Down Expand Up @@ -385,6 +394,7 @@ def __call__(
event_listeners_timeout: timedelta | None = EVENT_LISTENERS_TIMEOUT,
status_message: str | None = None,
cleanup_timeout: timedelta = timedelta(seconds=30),
max_concurrent_child_runs: int | None = None,
) -> Self:
"""Make a new Actor instance with a non-default configuration.

Expand All @@ -398,6 +408,7 @@ def __call__(
event_listeners_timeout=event_listeners_timeout,
status_message=status_message,
cleanup_timeout=cleanup_timeout,
max_concurrent_child_runs=max_concurrent_child_runs,
)

@property
Expand Down Expand Up @@ -1122,19 +1133,20 @@ async def start(
client = self.new_client(token=token) if token else self.apify_client

actor_client = client.actor(actor_id)
start_run = partial(
actor_client.start,
run_input=run_input,
content_type=content_type,
build=build,
max_items=max_items,
max_total_charge_usd=max_total_charge_usd,
restart_on_error=restart_on_error,
memory_mbytes=memory_mbytes,
run_timeout=self._resolve_run_timeout(timeout),
force_permission_level=force_permission_level,
webhooks=to_client_representations(webhooks),
)

async def start_run() -> Run:
return await actor_client.start(
run_input=run_input,
content_type=content_type,
build=build,
max_items=max_items,
max_total_charge_usd=max_total_charge_usd,
restart_on_error=restart_on_error,
memory_mbytes=memory_mbytes,
run_timeout=self._resolve_run_timeout(timeout),
force_permission_level=force_permission_level,
webhooks=to_client_representations(webhooks),
)

if run_name is None:
return await start_run()
Expand Down Expand Up @@ -1340,13 +1352,9 @@ async def call(
logger=logger,
)
else:
started_run, is_new = await self._find_or_start_child_run(
run_name,
actor_id=actor_id,
run_input=run_input,
client=client,
start_run=partial(
actor_client.start,

async def start_run() -> Run:
return await actor_client.start(
run_input=run_input,
content_type=content_type,
build=build,
Expand All @@ -1357,7 +1365,14 @@ async def call(
run_timeout=self._resolve_run_timeout(timeout),
force_permission_level=force_permission_level,
webhooks=to_client_representations(webhooks),
),
)

started_run, is_new = await self._find_or_start_child_run(
run_name,
actor_id=actor_id,
run_input=run_input,
client=client,
start_run=start_run,
build=build,
max_items=max_items,
max_total_charge_usd=max_total_charge_usd,
Expand Down Expand Up @@ -1509,17 +1524,18 @@ async def start_task(

client = self.new_client(token=token) if token else self.apify_client
task_client = client.task(task_id)
start_run = partial(
task_client.start,
task_input=task_input,
build=build,
max_items=max_items,
max_total_charge_usd=max_total_charge_usd,
restart_on_error=restart_on_error,
memory_mbytes=memory_mbytes,
run_timeout=self._resolve_run_timeout(timeout),
webhooks=to_client_representations(webhooks),
)

async def start_run() -> Run:
return await task_client.start(
task_input=task_input,
build=build,
max_items=max_items,
max_total_charge_usd=max_total_charge_usd,
restart_on_error=restart_on_error,
memory_mbytes=memory_mbytes,
run_timeout=self._resolve_run_timeout(timeout),
webhooks=to_client_representations(webhooks),
)

if run_name is None:
return await start_run()
Expand Down Expand Up @@ -1624,13 +1640,9 @@ async def call_task(
wait_duration=wait,
)
else:
started_run, _ = await self._find_or_start_child_run(
run_name,
task_id=task_id,
run_input=task_input,
client=client,
start_run=partial(
task_client.start,

async def start_run() -> Run:
return await task_client.start(
task_input=task_input,
build=build,
max_items=max_items,
Expand All @@ -1639,7 +1651,14 @@ async def call_task(
memory_mbytes=memory_mbytes,
run_timeout=self._resolve_run_timeout(timeout),
webhooks=to_client_representations(webhooks),
),
)

started_run, _ = await self._find_or_start_child_run(
run_name,
task_id=task_id,
run_input=task_input,
client=client,
start_run=start_run,
build=build,
max_items=max_items,
max_total_charge_usd=max_total_charge_usd,
Expand Down
Loading
Loading