Skip to content

Commit 27a6ac7

Browse files
committed
Merge branch 'feat/child-runs-api' into feat/abort-child-runs-with-parent
2 parents 1782ca6 + b938289 commit 27a6ac7

4 files changed

Lines changed: 231 additions & 68 deletions

File tree

‎src/apify/_actor.py‎

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -951,7 +951,7 @@ async def start(
951951
timeout: timedelta | Literal['inherit'] | None = None,
952952
force_permission_level: ActorPermissionLevel | None = None,
953953
webhooks: list[Webhook] | None = None,
954-
name: str | None = None,
954+
run_name: str | None = None,
955955
abort_with_parent: bool = False,
956956
) -> Run:
957957
"""Run an Actor on the Apify platform.
@@ -979,21 +979,21 @@ async def start(
979979
webhooks: Optional ad-hoc webhooks (https://docs.apify.com/webhooks/ad-hoc-webhooks) associated with
980980
the Actor run which can be used to receive a notification, e.g. when the Actor finished or failed.
981981
If you already have a webhook set up for the Actor or task, you do not have to add it again here.
982-
name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
982+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
983983
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
984984
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
985985
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
986986
run `FAILED` or no longer exists. The name is bound to `actor_id` exactly as passed, so reusing it with
987-
any other value raises a `ValueError`.
987+
any other value, or for a task, raises a `ValueError`.
988988
abort_with_parent: If true, the child run is gracefully aborted when this Actor run is gracefully
989-
aborted. It requires `name`, and the value is recorded under it, replacing the one from an earlier
989+
aborted. It requires `run_name`, and the value is recorded under it, replacing the one from an earlier
990990
call. A hard abort, a timeout or a crash of this Actor run leaves the child running.
991991
992992
Returns:
993993
Info about the started Actor run
994994
"""
995-
if abort_with_parent and name is None:
996-
raise ValueError('`abort_with_parent` requires `name`, since only named child runs are tracked.')
995+
if abort_with_parent and run_name is None:
996+
raise ValueError('`abort_with_parent` requires `run_name`, since only named child runs are tracked.')
997997

998998
client = self.new_client(token=token) if token else self.apify_client
999999

@@ -1020,11 +1020,11 @@ async def start(
10201020
webhooks=to_client_representations(webhooks),
10211021
)
10221022

1023-
if name is None:
1023+
if run_name is None:
10241024
return await start_run()
10251025

10261026
run, _ = await self._find_or_start_child_run(
1027-
name,
1027+
run_name,
10281028
actor_id=actor_id,
10291029
client=client,
10301030
start_run=start_run,
@@ -1091,7 +1091,7 @@ async def call(
10911091
webhooks: list[Webhook] | None = None,
10921092
wait: timedelta | None = None,
10931093
logger: logging.Logger | Literal['default'] | None = 'default',
1094-
name: str | None = None,
1094+
run_name: str | None = None,
10951095
abort_with_parent: bool = False,
10961096
) -> Run:
10971097
"""Start an Actor on the Apify Platform and wait for it to finish before returning.
@@ -1122,21 +1122,21 @@ async def call(
11221122
logger: Logger used to redirect logs from the Actor run. Using "default" literal means that a predefined
11231123
default logger will be used. Setting `None` will disable any log propagation. Passing custom logger
11241124
will redirect logs to the provided logger.
1125-
name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
1125+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
11261126
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
11271127
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
11281128
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
11291129
run `FAILED` or no longer exists. The name is bound to `actor_id` exactly as passed, so reusing it with
1130-
any other value raises a `ValueError`.
1130+
any other value, or for a task, raises a `ValueError`.
11311131
abort_with_parent: If true, the child run is gracefully aborted when this Actor run is gracefully
1132-
aborted. It requires `name`, and the value is recorded under it, replacing the one from an earlier
1132+
aborted. It requires `run_name`, and the value is recorded under it, replacing the one from an earlier
11331133
call. A hard abort, a timeout or a crash of this Actor run leaves the child running.
11341134
11351135
Returns:
11361136
Info about the started Actor run.
11371137
"""
1138-
if abort_with_parent and name is None:
1139-
raise ValueError('`abort_with_parent` requires `name`, since only named child runs are tracked.')
1138+
if abort_with_parent and run_name is None:
1139+
raise ValueError('`abort_with_parent` requires `run_name`, since only named child runs are tracked.')
11401140

11411141
client = self.new_client(token=token) if token else self.apify_client
11421142

@@ -1151,7 +1151,7 @@ async def call(
11511151

11521152
actor_client = client.actor(actor_id)
11531153

1154-
if name is None:
1154+
if run_name is None:
11551155
run = await actor_client.call(
11561156
run_input=run_input,
11571157
content_type=content_type,
@@ -1167,7 +1167,7 @@ async def call(
11671167
)
11681168
else:
11691169
started_run, is_new = await self._find_or_start_child_run(
1170-
name,
1170+
run_name,
11711171
actor_id=actor_id,
11721172
client=client,
11731173
start_run=partial(
@@ -1203,7 +1203,8 @@ async def _find_or_start_child_run(
12031203
self,
12041204
name: str,
12051205
*,
1206-
actor_id: str,
1206+
actor_id: str | None = None,
1207+
task_id: str | None = None,
12071208
client: ApifyClientAsync,
12081209
start_run: Callable[[], Awaitable[Run]],
12091210
build: str | None,
@@ -1216,6 +1217,7 @@ async def _find_or_start_child_run(
12161217
return await self._child_run_registry.find_or_start(
12171218
name,
12181219
actor_id=actor_id,
1220+
task_id=task_id,
12191221
client=client,
12201222
start_run=start_run,
12211223
resurrect_run=lambda run_client: run_client.resurrect(
@@ -1261,9 +1263,9 @@ async def _wait_for_child_run(
12611263
async def child_runs(self) -> dict[str, ChildRunInfo]:
12621264
"""Get the named child runs of this Actor run, with their current state.
12631265
1264-
Every run started by `Actor.start` or `Actor.call` with a `name` is included, even one started before a
1265-
migration or resurrection of this Actor run. Runs started without a `name` are not tracked. Each run is fetched
1266-
from the API when this method is called, so the result is a snapshot.
1266+
Every run started by `Actor.start`, `Actor.call` or `Actor.call_task` with a `run_name` is included, even one
1267+
started before a migration or resurrection of this Actor run. Runs started without a `run_name` are not
1268+
tracked. Each run is fetched from the API when this method is called, so the result is a snapshot.
12671269
12681270
Returns:
12691271
The child runs by name.
@@ -1283,6 +1285,7 @@ async def call_task(
12831285
webhooks: list[Webhook] | None = None,
12841286
wait: timedelta | None = None,
12851287
token: str | None = None,
1288+
run_name: str | None = None,
12861289
) -> Run:
12871290
"""Start an Actor task on the Apify Platform and wait for it to finish before returning.
12881291
@@ -1308,6 +1311,12 @@ async def call_task(
13081311
be used to receive a notification, e.g. when the Actor finished or failed. If you already have
13091312
a webhook set up for the Actor, you do not have to add it again here.
13101313
wait: The maximum time the server waits for the run to finish. If not provided, waits indefinitely.
1314+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
1315+
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
1316+
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
1317+
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
1318+
run `FAILED` or no longer exists. The name is bound to `task_id` exactly as passed, so reusing it with
1319+
any other value, or for an Actor, raises a `ValueError`.
13111320
13121321
Returns:
13131322
Info about the started Actor run.
@@ -1324,15 +1333,41 @@ async def call_task(
13241333
raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.')
13251334

13261335
task_client = client.task(task_id)
1327-
run = await task_client.call(
1328-
task_input=task_input,
1329-
build=build,
1330-
restart_on_error=restart_on_error,
1331-
memory_mbytes=memory_mbytes,
1332-
run_timeout=task_call_timeout,
1333-
webhooks=to_client_representations(webhooks),
1334-
wait_duration=wait,
1335-
)
1336+
1337+
if run_name is None:
1338+
run = await task_client.call(
1339+
task_input=task_input,
1340+
build=build,
1341+
restart_on_error=restart_on_error,
1342+
memory_mbytes=memory_mbytes,
1343+
run_timeout=task_call_timeout,
1344+
webhooks=to_client_representations(webhooks),
1345+
wait_duration=wait,
1346+
)
1347+
else:
1348+
started_run, _ = await self._find_or_start_child_run(
1349+
run_name,
1350+
task_id=task_id,
1351+
client=client,
1352+
start_run=partial(
1353+
task_client.start,
1354+
task_input=task_input,
1355+
build=build,
1356+
restart_on_error=restart_on_error,
1357+
memory_mbytes=memory_mbytes,
1358+
run_timeout=task_call_timeout,
1359+
webhooks=to_client_representations(webhooks),
1360+
),
1361+
build=build,
1362+
max_total_charge_usd=None,
1363+
restart_on_error=restart_on_error,
1364+
memory_mbytes=memory_mbytes,
1365+
run_timeout=task_call_timeout,
1366+
abort_with_parent=False,
1367+
)
1368+
run = await self._wait_for_child_run(
1369+
client.run(started_run.id), started_run, wait=wait, logger=None, from_start=False
1370+
)
13361371

13371372
if run is None:
13381373
raise RuntimeError(f'Failed to call Task with ID "{task_id}".')

‎src/apify/_child_runs.py‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
from collections import defaultdict
55
from dataclasses import dataclass
66
from logging import getLogger
7-
from typing import TYPE_CHECKING
7+
from typing import TYPE_CHECKING, Self
88

9-
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
9+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, model_validator
1010
from pydantic.alias_generators import to_camel
1111

1212
from apify._utils import docs_group
@@ -38,8 +38,11 @@ class ChildRunRecord(BaseModel):
3838

3939
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
4040

41-
actor_id: str
42-
"""The Actor ID or name the child was started with, as the caller passed it."""
41+
actor_id: str | None = None
42+
"""The Actor ID or name the child was started with, as the caller passed it, or `None` for a task run."""
43+
44+
task_id: str | None = None
45+
"""The task ID or name the child was started with, as the caller passed it, or `None` for an Actor run."""
4346

4447
run_id: str
4548
"""ID of the current run under this name."""
@@ -50,14 +53,27 @@ class ChildRunRecord(BaseModel):
5053
abort_with_parent: bool = False
5154
"""Whether the current run is aborted when this Actor run is gracefully aborted."""
5255

56+
@model_validator(mode='after')
57+
def _check_started_from(self) -> Self:
58+
if (self.actor_id is None) == (self.task_id is None):
59+
raise ValueError('Exactly one of `actor_id` and `task_id` must be set.')
60+
return self
61+
62+
63+
def _describe_started_from(actor_id: str | None, task_id: str | None) -> str:
64+
return f'Actor "{actor_id}"' if actor_id is not None else f'task "{task_id}"'
65+
5366

5467
@docs_group('Actor')
5568
@dataclass(frozen=True)
5669
class ChildRunInfo:
5770
"""A named child run of this Actor run, as returned by `Actor.child_runs`."""
5871

59-
actor_id: str
60-
"""The Actor ID or name the child was started with, as the caller passed it."""
72+
actor_id: str | None
73+
"""The Actor ID or name the child was started with, as the caller passed it, or `None` for a task run."""
74+
75+
task_id: str | None
76+
"""The task ID or name the child was started with, as the caller passed it, or `None` for an Actor run."""
6177

6278
run_id: str
6379
"""ID of the current run under this name."""
@@ -95,7 +111,8 @@ async def find_or_start(
95111
self,
96112
name: str,
97113
*,
98-
actor_id: str,
114+
actor_id: str | None = None,
115+
task_id: str | None = None,
99116
client: ApifyClientAsync,
100117
start_run: Callable[[], Awaitable[Run]],
101118
resurrect_run: Callable[[RunClientAsync], Awaitable[Run]],
@@ -110,8 +127,9 @@ async def find_or_start(
110127
Args:
111128
name: Name of the child run, unique within the parent run.
112129
actor_id: The Actor to start. It must match the Actor already recorded under `name`.
130+
task_id: The task to start, in place of `actor_id`. It must match the task already recorded under `name`.
113131
client: Client used to look up, resurrect and abort the recorded run.
114-
start_run: Starts a new run of the Actor.
132+
start_run: Starts a new run of the Actor or task.
115133
resurrect_run: Resurrects the recorded run, given its run client.
116134
abort_with_parent: Whether to abort the run when this Actor run is gracefully aborted. It replaces
117135
the value recorded under `name`.
@@ -123,10 +141,11 @@ async def find_or_start(
123141
records = await self._load()
124142
record = records.get(name)
125143

126-
if record is not None and record.actor_id != actor_id:
144+
if record is not None and (record.actor_id, record.task_id) != (actor_id, task_id):
127145
raise ValueError(
128-
f'Child run "{name}" is already recorded for Actor "{record.actor_id}", '
129-
f'it cannot be reused for Actor "{actor_id}".'
146+
f'Child run "{name}" is already recorded for '
147+
f'{_describe_started_from(record.actor_id, record.task_id)}, '
148+
f'it cannot be reused for {_describe_started_from(actor_id, task_id)}.'
130149
)
131150

132151
self._clients[name] = client
@@ -135,6 +154,7 @@ async def find_or_start(
135154
run = await self._start(
136155
name,
137156
actor_id=actor_id,
157+
task_id=task_id,
138158
start_run=start_run,
139159
previous_run_ids=[],
140160
abort_with_parent=abort_with_parent,
@@ -151,6 +171,7 @@ async def find_or_start(
151171
run = await self._start(
152172
name,
153173
actor_id=actor_id,
174+
task_id=task_id,
154175
start_run=start_run,
155176
previous_run_ids=[*record.previous_run_ids, record.run_id],
156177
abort_with_parent=abort_with_parent,
@@ -179,6 +200,7 @@ async def list_runs(self, client: ApifyClientAsync) -> dict[str, ChildRunInfo]:
179200
return {
180201
name: ChildRunInfo(
181202
actor_id=record.actor_id,
203+
task_id=record.task_id,
182204
run_id=record.run_id,
183205
run=run,
184206
previous_run_ids=list(record.previous_run_ids),
@@ -219,14 +241,16 @@ async def _start(
219241
self,
220242
name: str,
221243
*,
222-
actor_id: str,
244+
actor_id: str | None,
245+
task_id: str | None,
223246
start_run: Callable[[], Awaitable[Run]],
224247
previous_run_ids: list[str],
225248
abort_with_parent: bool,
226249
) -> Run:
227250
run = await start_run()
228251
record = ChildRunRecord(
229252
actor_id=actor_id,
253+
task_id=task_id,
230254
run_id=run.id,
231255
previous_run_ids=previous_run_ids,
232256
abort_with_parent=abort_with_parent,

‎tests/e2e/test_actor_child_runs.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ async def main() -> None:
3030
child_run_id = await Actor.get_value('child_run_id')
3131

3232
if child_run_id is None:
33-
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, name='child')
33+
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
3434
await Actor.set_value('child_run_id', run.id)
3535
await Actor.reboot()
3636
return
3737

38-
run = await Actor.call(actor_id=actor_id, run_input={'is_child': True}, name='child')
38+
run = await Actor.call(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
3939
assert run is not None, 'run is None'
4040
assert run.id == child_run_id, f'run.id={run.id}, child_run_id={child_run_id}'
4141
assert run.status == 'SUCCEEDED', f'run.status={run.status}'
@@ -72,7 +72,7 @@ async def main() -> None:
7272
child_run_id = await Actor.get_value('child_run_id')
7373

7474
if child_run_id is None:
75-
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, name='child')
75+
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
7676
await Actor.set_value('child_run_id', run.id)
7777
run_client = Actor.apify_client.run(run.id)
7878
await run_client.abort()
@@ -82,7 +82,7 @@ async def main() -> None:
8282
await Actor.reboot()
8383
return
8484

85-
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, name='child')
85+
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
8686
try:
8787
assert run.id == child_run_id, f'run.id={run.id}, child_run_id={child_run_id}'
8888
assert run.status in {'READY', 'RUNNING'}, f'run.status={run.status}'

0 commit comments

Comments
 (0)