Skip to content

Commit ccf93c5

Browse files
committed
Merge branch 'feat/named-child-runs' into feat/child-runs-api
2 parents f93b3d9 + ed8160c commit ccf93c5

4 files changed

Lines changed: 207 additions & 55 deletions

File tree

‎src/apify/_actor.py‎

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -946,7 +946,7 @@ async def start(
946946
timeout: timedelta | Literal['inherit'] | None = None,
947947
force_permission_level: ActorPermissionLevel | None = None,
948948
webhooks: list[Webhook] | None = None,
949-
name: str | None = None,
949+
run_name: str | None = None,
950950
) -> Run:
951951
"""Run an Actor on the Apify platform.
952952
@@ -973,12 +973,12 @@ async def start(
973973
webhooks: Optional ad-hoc webhooks (https://docs.apify.com/webhooks/ad-hoc-webhooks) associated with
974974
the Actor run which can be used to receive a notification, e.g. when the Actor finished or failed.
975975
If you already have a webhook set up for the Actor or task, you do not have to add it again here.
976-
name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
976+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
977977
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
978978
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
979979
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
980980
run `FAILED` or no longer exists. The name is bound to `actor_id` exactly as passed, so reusing it with
981-
any other value raises a `ValueError`.
981+
any other value, or for a task, raises a `ValueError`.
982982
983983
Returns:
984984
Info about the started Actor run
@@ -1008,11 +1008,11 @@ async def start(
10081008
webhooks=to_client_representations(webhooks),
10091009
)
10101010

1011-
if name is None:
1011+
if run_name is None:
10121012
return await start_run()
10131013

10141014
run, _ = await self._find_or_start_child_run(
1015-
name,
1015+
run_name,
10161016
actor_id=actor_id,
10171017
client=client,
10181018
start_run=start_run,
@@ -1078,7 +1078,7 @@ async def call(
10781078
webhooks: list[Webhook] | None = None,
10791079
wait: timedelta | None = None,
10801080
logger: logging.Logger | Literal['default'] | None = 'default',
1081-
name: str | None = None,
1081+
run_name: str | None = None,
10821082
) -> Run:
10831083
"""Start an Actor on the Apify Platform and wait for it to finish before returning.
10841084
@@ -1108,12 +1108,12 @@ async def call(
11081108
logger: Logger used to redirect logs from the Actor run. Using "default" literal means that a predefined
11091109
default logger will be used. Setting `None` will disable any log propagation. Passing custom logger
11101110
will redirect logs to the provided logger.
1111-
name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
1111+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
11121112
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
11131113
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
11141114
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
11151115
run `FAILED` or no longer exists. The name is bound to `actor_id` exactly as passed, so reusing it with
1116-
any other value raises a `ValueError`.
1116+
any other value, or for a task, raises a `ValueError`.
11171117
11181118
Returns:
11191119
Info about the started Actor run.
@@ -1131,7 +1131,7 @@ async def call(
11311131

11321132
actor_client = client.actor(actor_id)
11331133

1134-
if name is None:
1134+
if run_name is None:
11351135
run = await actor_client.call(
11361136
run_input=run_input,
11371137
content_type=content_type,
@@ -1147,7 +1147,7 @@ async def call(
11471147
)
11481148
else:
11491149
started_run, is_new = await self._find_or_start_child_run(
1150-
name,
1150+
run_name,
11511151
actor_id=actor_id,
11521152
client=client,
11531153
start_run=partial(
@@ -1182,7 +1182,8 @@ async def _find_or_start_child_run(
11821182
self,
11831183
name: str,
11841184
*,
1185-
actor_id: str,
1185+
actor_id: str | None = None,
1186+
task_id: str | None = None,
11861187
client: ApifyClientAsync,
11871188
start_run: Callable[[], Awaitable[Run]],
11881189
build: str | None,
@@ -1194,6 +1195,7 @@ async def _find_or_start_child_run(
11941195
return await self._child_run_registry.find_or_start(
11951196
name,
11961197
actor_id=actor_id,
1198+
task_id=task_id,
11971199
client=client,
11981200
start_run=start_run,
11991201
resurrect_run=lambda run_client: run_client.resurrect(
@@ -1253,6 +1255,7 @@ async def call_task(
12531255
webhooks: list[Webhook] | None = None,
12541256
wait: timedelta | None = None,
12551257
token: str | None = None,
1258+
run_name: str | None = None,
12561259
) -> Run:
12571260
"""Start an Actor task on the Apify Platform and wait for it to finish before returning.
12581261
@@ -1278,6 +1281,12 @@ async def call_task(
12781281
be used to receive a notification, e.g. when the Actor finished or failed. If you already have
12791282
a webhook set up for the Actor, you do not have to add it again here.
12801283
wait: The maximum time the server waits for the run to finish. If not provided, waits indefinitely.
1284+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
1285+
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
1286+
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
1287+
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
1288+
run `FAILED` or no longer exists. The name is bound to `task_id` exactly as passed, so reusing it with
1289+
any other value, or for an Actor, raises a `ValueError`.
12811290
12821291
Returns:
12831292
Info about the started Actor run.
@@ -1294,15 +1303,40 @@ async def call_task(
12941303
raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.')
12951304

12961305
task_client = client.task(task_id)
1297-
run = await task_client.call(
1298-
task_input=task_input,
1299-
build=build,
1300-
restart_on_error=restart_on_error,
1301-
memory_mbytes=memory_mbytes,
1302-
run_timeout=task_call_timeout,
1303-
webhooks=to_client_representations(webhooks),
1304-
wait_duration=wait,
1305-
)
1306+
1307+
if run_name is None:
1308+
run = await task_client.call(
1309+
task_input=task_input,
1310+
build=build,
1311+
restart_on_error=restart_on_error,
1312+
memory_mbytes=memory_mbytes,
1313+
run_timeout=task_call_timeout,
1314+
webhooks=to_client_representations(webhooks),
1315+
wait_duration=wait,
1316+
)
1317+
else:
1318+
started_run, _ = await self._find_or_start_child_run(
1319+
run_name,
1320+
task_id=task_id,
1321+
client=client,
1322+
start_run=partial(
1323+
task_client.start,
1324+
task_input=task_input,
1325+
build=build,
1326+
restart_on_error=restart_on_error,
1327+
memory_mbytes=memory_mbytes,
1328+
run_timeout=task_call_timeout,
1329+
webhooks=to_client_representations(webhooks),
1330+
),
1331+
build=build,
1332+
max_total_charge_usd=None,
1333+
restart_on_error=restart_on_error,
1334+
memory_mbytes=memory_mbytes,
1335+
run_timeout=task_call_timeout,
1336+
)
1337+
run = await self._wait_for_child_run(
1338+
client.run(started_run.id), started_run, wait=wait, logger=None, from_start=False
1339+
)
13061340

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

‎src/apify/_child_runs.py‎

Lines changed: 36 additions & 13 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
@@ -36,15 +36,28 @@ class ChildRunRecord(BaseModel):
3636

3737
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
3838

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

4245
run_id: str
4346
"""ID of the current run under this name."""
4447

4548
previous_run_ids: list[str] = Field(default_factory=list)
4649
"""IDs of earlier runs under this name that failed or went missing and were replaced by a new run, oldest first."""
4750

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

4962
@docs_group('Actor')
5063
@dataclass(frozen=True)
@@ -85,7 +98,8 @@ async def find_or_start(
8598
self,
8699
name: str,
87100
*,
88-
actor_id: str,
101+
actor_id: str | None = None,
102+
task_id: str | None = None,
89103
client: ApifyClientAsync,
90104
start_run: Callable[[], Awaitable[Run]],
91105
resurrect_run: Callable[[RunClientAsync], Awaitable[Run]],
@@ -99,8 +113,9 @@ async def find_or_start(
99113
Args:
100114
name: Name of the child run, unique within the parent run.
101115
actor_id: The Actor to start. It must match the Actor already recorded under `name`.
116+
task_id: The task to start, in place of `actor_id`. It must match the task already recorded under `name`.
102117
client: Client used to look up and resurrect the recorded run.
103-
start_run: Starts a new run of the Actor.
118+
start_run: Starts a new run of the Actor or task.
104119
resurrect_run: Resurrects the recorded run, given its run client.
105120
106121
Returns:
@@ -111,12 +126,16 @@ async def find_or_start(
111126
record = records.get(name)
112127

113128
if record is None:
114-
return await self._start(name, actor_id=actor_id, start_run=start_run, previous_run_ids=[]), True
129+
run = await self._start(
130+
name, actor_id=actor_id, task_id=task_id, start_run=start_run, previous_run_ids=[]
131+
)
132+
return run, True
115133

116-
if record.actor_id != actor_id:
134+
if (record.actor_id, record.task_id) != (actor_id, task_id):
117135
raise ValueError(
118-
f'Child run "{name}" is already recorded for Actor "{record.actor_id}", '
119-
f'it cannot be reused for Actor "{actor_id}".'
136+
f'Child run "{name}" is already recorded for '
137+
f'{_describe_started_from(record.actor_id, record.task_id)}, '
138+
f'it cannot be reused for {_describe_started_from(actor_id, task_id)}.'
120139
)
121140

122141
run_client = client.run(record.run_id)
@@ -127,7 +146,9 @@ async def find_or_start(
127146

128147
if run is None or run.status == 'FAILED':
129148
previous_run_ids = [*record.previous_run_ids, record.run_id]
130-
run = await self._start(name, actor_id=actor_id, start_run=start_run, previous_run_ids=previous_run_ids)
149+
run = await self._start(
150+
name, actor_id=actor_id, task_id=task_id, start_run=start_run, previous_run_ids=previous_run_ids
151+
)
131152
return run, True
132153

133154
if run.status in _RESURRECTABLE_STATUSES:
@@ -160,12 +181,14 @@ async def _start(
160181
self,
161182
name: str,
162183
*,
163-
actor_id: str,
184+
actor_id: str | None,
185+
task_id: str | None,
164186
start_run: Callable[[], Awaitable[Run]],
165187
previous_run_ids: list[str],
166188
) -> Run:
167189
run = await start_run()
168-
await self._save(name, ChildRunRecord(actor_id=actor_id, run_id=run.id, previous_run_ids=previous_run_ids))
190+
record = ChildRunRecord(actor_id=actor_id, task_id=task_id, run_id=run.id, previous_run_ids=previous_run_ids)
191+
await self._save(name, record)
169192
return run
170193

171194
async def _load(self) -> dict[str, ChildRunRecord]:

‎tests/e2e/test_actor_child_runs.py‎

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

2828
if child_run_id is None:
29-
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, name='child')
29+
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
3030
await Actor.set_value('child_run_id', run.id)
3131
await Actor.reboot()
3232
return
3333

34-
run = await Actor.call(actor_id=actor_id, run_input={'is_child': True}, name='child')
34+
run = await Actor.call(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
3535
assert run is not None, 'run is None'
3636
assert run.id == child_run_id, f'run.id={run.id}, child_run_id={child_run_id}'
3737
assert run.status == 'SUCCEEDED', f'run.status={run.status}'
@@ -68,7 +68,7 @@ async def main() -> None:
6868
child_run_id = await Actor.get_value('child_run_id')
6969

7070
if child_run_id is None:
71-
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, name='child')
71+
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
7272
await Actor.set_value('child_run_id', run.id)
7373
run_client = Actor.apify_client.run(run.id)
7474
await run_client.abort()
@@ -78,7 +78,7 @@ async def main() -> None:
7878
await Actor.reboot()
7979
return
8080

81-
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, name='child')
81+
run = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, run_name='child')
8282
try:
8383
assert run.id == child_run_id, f'run.id={run.id}, child_run_id={child_run_id}'
8484
assert run.status in {'READY', 'RUNNING'}, f'run.status={run.status}'

0 commit comments

Comments
 (0)