Skip to content

Commit 95dbc7c

Browse files
authored
feat: reattach named child runs after migration or resurrection (#1149)
`Actor.start`, `Actor.call`, `Actor.start_task` and `Actor.call_task` accept an optional `run_name`. Right after the child starts, the SDK records it in the default key-value store under `__ACTOR_CHILD_RUNS`. After a migration or resurrection of the parent, the same call finds the recorded run and reuses it: - `READY` / `RUNNING`: reattaches to it - `SUCCEEDED`: returns it as is - `ABORTED` / `TIMED-OUT`: resurrects it with the call's build, memory, timeout, `max_items` and max charge (an `ABORTING` / `TIMING-OUT` run is waited for first) - `FAILED`, or the run no longer exists: starts a new run and moves the old one to `history` The record has the same shape as in the JS SDK (apify/apify-sdk-js#759): `runId`, `status`, `startedAt`, `checksum` and `history`. The checksum covers the Actor or task ID and the input, computed the same way as in JS. Reusing a name for a different Actor, task or input raises `ValueError`. Concurrent calls under one name in the same process share a single run. A named `call` on a reattached or resurrected run streams only new log lines. If the API reports a recorded run as missing, the SDK retries for 3 seconds before replacing it, because a run started moments ago may not be on every API replica yet. A hard kill between the platform starting the child and the registry write can still orphan the child. Closing that gap needs an idempotency key on the run-start endpoint. Closes: #1127 *✍️ Drafted by Claude Code*
1 parent 4851f28 commit 95dbc7c

4 files changed

Lines changed: 1149 additions & 29 deletions

File tree

‎src/apify/_actor.py‎

Lines changed: 227 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import warnings
88
from dataclasses import asdict
99
from datetime import UTC, datetime, timedelta
10-
from functools import cached_property
10+
from functools import cached_property, partial
1111
from pathlib import Path
1212
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload
1313

@@ -35,6 +35,7 @@
3535
ChargingManagerImplementation,
3636
charge_lock_if_charging,
3737
)
38+
from apify._child_runs import ChildRunRegistry
3839
from apify._configuration import Configuration
3940
from apify._consts import EVENT_LISTENERS_TIMEOUT, EXIT_CODE_ERROR_USER_FUNCTION_THREW, ActorEnvVars, ApifyEnvVars
4041
from apify._crypto import decrypt_input_secrets, load_private_key
@@ -49,13 +50,14 @@
4950

5051
if TYPE_CHECKING:
5152
import logging
52-
from collections.abc import Callable, MutableMapping
53+
from collections.abc import Awaitable, Callable, MutableMapping
5354
from decimal import Decimal
5455
from types import TracebackType
5556
from typing import Self
5657

5758
from apify_client._literals import ActorPermissionLevel
5859
from apify_client._models import Run
60+
from apify_client._resource_clients import RunClientAsync
5961
from crawlee._types import JsonSerializable
6062
from crawlee.proxy_configuration import _NewUrlFunction
6163

@@ -161,6 +163,8 @@ def __init__(
161163
# Keep track of all used state stores to persist their values on exit
162164
self._use_state_stores: set[str | None] = set()
163165

166+
self._child_run_registry = ChildRunRegistry(self.open_key_value_store)
167+
164168
self._active = False
165169
"""Whether the Actor instance is currently active (initialized and within context)."""
166170

@@ -953,6 +957,7 @@ async def start(
953957
timeout: timedelta | Literal['inherit'] | None = None,
954958
force_permission_level: ActorPermissionLevel | None = None,
955959
webhooks: list[Webhook] | None = None,
960+
run_name: str | None = None,
956961
) -> Run:
957962
"""Run an Actor on the Apify platform.
958963
@@ -982,6 +987,12 @@ async def start(
982987
webhooks: Optional ad-hoc webhooks (https://docs.apify.com/webhooks/ad-hoc-webhooks) associated with
983988
the Actor run which can be used to receive a notification, e.g. when the Actor finished or failed.
984989
If you already have a webhook set up for the Actor or task, you do not have to add it again here.
990+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
991+
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
992+
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
993+
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
994+
run `FAILED` or no longer exists. The name is bound to the Actor and input it was first used with,
995+
so reusing it for a different Actor, task or input raises a `ValueError`.
985996
986997
Returns:
987998
Info about the started Actor run
@@ -992,7 +1003,8 @@ async def start(
9921003
client = self.new_client(token=token) if token else self.apify_client
9931004

9941005
actor_client = client.actor(actor_id)
995-
return await actor_client.start(
1006+
start_run = partial(
1007+
actor_client.start,
9961008
run_input=run_input,
9971009
content_type=content_type,
9981010
build=build,
@@ -1005,6 +1017,25 @@ async def start(
10051017
webhooks=to_client_representations(webhooks),
10061018
)
10071019

1020+
if run_name is None:
1021+
return await start_run()
1022+
1023+
run, _ = await self._find_or_start_child_run(
1024+
run_name,
1025+
actor_id=actor_id,
1026+
run_input=run_input,
1027+
client=client,
1028+
start_run=start_run,
1029+
token=token,
1030+
build=build,
1031+
max_items=max_items,
1032+
max_total_charge_usd=max_total_charge_usd,
1033+
restart_on_error=restart_on_error,
1034+
memory_mbytes=memory_mbytes,
1035+
timeout=timeout,
1036+
)
1037+
return run
1038+
10081039
@_ensure_context
10091040
async def abort(
10101041
self,
@@ -1115,6 +1146,7 @@ async def call(
11151146
webhooks: list[Webhook] | None = None,
11161147
wait: timedelta | None = None,
11171148
logger: logging.Logger | Literal['default'] | None = 'default',
1149+
run_name: str | None = None,
11181150
) -> Run:
11191151
"""Start an Actor on the Apify Platform and wait for it to finish before returning.
11201152
@@ -1147,6 +1179,12 @@ async def call(
11471179
logger: Logger used to redirect logs from the Actor run. Using "default" literal means that a predefined
11481180
default logger will be used. Setting `None` will disable any log propagation. Passing custom logger
11491181
will redirect logs to the provided logger.
1182+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
1183+
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
1184+
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
1185+
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
1186+
run `FAILED` or no longer exists. The name is bound to the Actor and input it was first used with,
1187+
so reusing it for a different Actor, task or input raises a `ValueError`.
11501188
11511189
Returns:
11521190
Info about the started Actor run.
@@ -1157,26 +1195,122 @@ async def call(
11571195
client = self.new_client(token=token) if token else self.apify_client
11581196

11591197
actor_client = client.actor(actor_id)
1160-
run = await actor_client.call(
1161-
run_input=run_input,
1162-
content_type=content_type,
1163-
build=build,
1164-
max_items=max_items,
1165-
max_total_charge_usd=max_total_charge_usd,
1166-
restart_on_error=restart_on_error,
1167-
memory_mbytes=memory_mbytes,
1168-
run_timeout=self._resolve_run_timeout(timeout),
1169-
force_permission_level=force_permission_level,
1170-
webhooks=to_client_representations(webhooks),
1171-
wait_duration=wait,
1172-
logger=logger,
1173-
)
1198+
1199+
if run_name is None:
1200+
run = await actor_client.call(
1201+
run_input=run_input,
1202+
content_type=content_type,
1203+
build=build,
1204+
max_items=max_items,
1205+
max_total_charge_usd=max_total_charge_usd,
1206+
restart_on_error=restart_on_error,
1207+
memory_mbytes=memory_mbytes,
1208+
run_timeout=self._resolve_run_timeout(timeout),
1209+
force_permission_level=force_permission_level,
1210+
webhooks=to_client_representations(webhooks),
1211+
wait_duration=wait,
1212+
logger=logger,
1213+
)
1214+
else:
1215+
started_run, is_new = await self._find_or_start_child_run(
1216+
run_name,
1217+
actor_id=actor_id,
1218+
run_input=run_input,
1219+
client=client,
1220+
start_run=partial(
1221+
actor_client.start,
1222+
run_input=run_input,
1223+
content_type=content_type,
1224+
build=build,
1225+
max_items=max_items,
1226+
max_total_charge_usd=max_total_charge_usd,
1227+
restart_on_error=restart_on_error,
1228+
memory_mbytes=memory_mbytes,
1229+
run_timeout=self._resolve_run_timeout(timeout),
1230+
force_permission_level=force_permission_level,
1231+
webhooks=to_client_representations(webhooks),
1232+
),
1233+
token=token,
1234+
build=build,
1235+
max_items=max_items,
1236+
max_total_charge_usd=max_total_charge_usd,
1237+
restart_on_error=restart_on_error,
1238+
memory_mbytes=memory_mbytes,
1239+
timeout=timeout,
1240+
)
1241+
# The earlier attempt of this call already streamed the log of a reattached or resurrected run.
1242+
run = await self._wait_for_child_run(
1243+
run_name, client.run(started_run.id), started_run, wait=wait, logger=logger, from_start=is_new
1244+
)
11741245

11751246
if run is None:
11761247
raise RuntimeError(f'Failed to call Actor with ID "{actor_id}".')
11771248

11781249
return run
11791250

1251+
async def _find_or_start_child_run(
1252+
self,
1253+
name: str,
1254+
*,
1255+
actor_id: str | None = None,
1256+
task_id: str | None = None,
1257+
run_input: Any,
1258+
client: ApifyClientAsync,
1259+
start_run: Callable[[], Awaitable[Run]],
1260+
token: str | None,
1261+
build: str | None,
1262+
max_items: int | None,
1263+
max_total_charge_usd: Decimal | None,
1264+
restart_on_error: bool | None,
1265+
memory_mbytes: int | None,
1266+
timeout: timedelta | Literal['inherit'] | None,
1267+
) -> tuple[Run, bool]:
1268+
return await self._child_run_registry.find_or_start(
1269+
name,
1270+
actor_id=actor_id,
1271+
task_id=task_id,
1272+
run_input=run_input,
1273+
client=client,
1274+
start_run=start_run,
1275+
resurrect_run=partial(
1276+
self.resurrect,
1277+
token=token,
1278+
build=build,
1279+
max_items=max_items,
1280+
max_total_charge_usd=max_total_charge_usd,
1281+
restart_on_error=restart_on_error,
1282+
memory_mbytes=memory_mbytes,
1283+
timeout=timeout,
1284+
),
1285+
)
1286+
1287+
async def _wait_for_child_run(
1288+
self,
1289+
name: str,
1290+
run_client: RunClientAsync,
1291+
run: Run,
1292+
*,
1293+
wait: timedelta | None,
1294+
logger: logging.Logger | Literal['default'] | None,
1295+
from_start: bool,
1296+
) -> Run | None:
1297+
if run.status == 'SUCCEEDED':
1298+
return run
1299+
1300+
if not logger:
1301+
finished_run = await run_client.wait_for_finish(wait_duration=wait)
1302+
else:
1303+
to_logger = None if logger == 'default' else logger
1304+
status_redirector = await run_client.get_status_message_watcher(to_logger=to_logger)
1305+
streamed_log = await run_client.get_streamed_log(to_logger=to_logger, from_start=from_start)
1306+
1307+
async with status_redirector, streamed_log:
1308+
finished_run = await run_client.wait_for_finish(wait_duration=wait)
1309+
1310+
if finished_run is not None:
1311+
await self._child_run_registry.update(name, finished_run)
1312+
return finished_run
1313+
11801314
@_ensure_context
11811315
async def start_task(
11821316
self,
@@ -1191,6 +1325,7 @@ async def start_task(
11911325
timeout: timedelta | Literal['inherit'] | None = None,
11921326
webhooks: list[Webhook] | None = None,
11931327
token: str | None = None,
1328+
run_name: str | None = None,
11941329
) -> Run:
11951330
"""Start an Actor task on the Apify Platform.
11961331
@@ -1220,6 +1355,12 @@ async def start_task(
12201355
webhooks: Optional webhooks (https://docs.apify.com/webhooks) associated with the Actor run, which can
12211356
be used to receive a notification, e.g. when the Actor finished or failed. If you already have
12221357
a webhook set up for the Actor, you do not have to add it again here.
1358+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
1359+
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
1360+
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
1361+
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
1362+
run `FAILED` or no longer exists. The name is bound to the task and input it was first used with,
1363+
so reusing it for a different Actor, task or input raises a `ValueError`.
12231364
12241365
Returns:
12251366
Info about the started Actor run.
@@ -1229,7 +1370,8 @@ async def start_task(
12291370

12301371
client = self.new_client(token=token) if token else self.apify_client
12311372
task_client = client.task(task_id)
1232-
return await task_client.start(
1373+
start_run = partial(
1374+
task_client.start,
12331375
task_input=task_input,
12341376
build=build,
12351377
max_items=max_items,
@@ -1240,6 +1382,25 @@ async def start_task(
12401382
webhooks=to_client_representations(webhooks),
12411383
)
12421384

1385+
if run_name is None:
1386+
return await start_run()
1387+
1388+
run, _ = await self._find_or_start_child_run(
1389+
run_name,
1390+
task_id=task_id,
1391+
run_input=task_input,
1392+
client=client,
1393+
start_run=start_run,
1394+
token=token,
1395+
build=build,
1396+
max_items=max_items,
1397+
max_total_charge_usd=max_total_charge_usd,
1398+
restart_on_error=restart_on_error,
1399+
memory_mbytes=memory_mbytes,
1400+
timeout=timeout,
1401+
)
1402+
return run
1403+
12431404
@_ensure_context
12441405
async def call_task(
12451406
self,
@@ -1255,6 +1416,7 @@ async def call_task(
12551416
webhooks: list[Webhook] | None = None,
12561417
wait: timedelta | None = None,
12571418
token: str | None = None,
1419+
run_name: str | None = None,
12581420
) -> Run:
12591421
"""Start an Actor task on the Apify Platform and wait for it to finish before returning.
12601422
@@ -1284,6 +1446,12 @@ async def call_task(
12841446
be used to receive a notification, e.g. when the Actor finished or failed. If you already have
12851447
a webhook set up for the Actor, you do not have to add it again here.
12861448
wait: The maximum time the server waits for the run to finish. If not provided, waits indefinitely.
1449+
run_name: Optional name of the child run, unique within this Actor run. A named run is recorded in the
1450+
default key-value store, so after a migration or resurrection of this Actor the same call reattaches
1451+
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
1452+
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
1453+
run `FAILED` or no longer exists. The name is bound to the task and input it was first used with,
1454+
so reusing it for a different Actor, task or input raises a `ValueError`.
12871455
12881456
Returns:
12891457
Info about the started Actor run.
@@ -1294,17 +1462,47 @@ async def call_task(
12941462
client = self.new_client(token=token) if token else self.apify_client
12951463

12961464
task_client = client.task(task_id)
1297-
run = await task_client.call(
1298-
task_input=task_input,
1299-
build=build,
1300-
max_items=max_items,
1301-
max_total_charge_usd=max_total_charge_usd,
1302-
restart_on_error=restart_on_error,
1303-
memory_mbytes=memory_mbytes,
1304-
run_timeout=self._resolve_run_timeout(timeout),
1305-
webhooks=to_client_representations(webhooks),
1306-
wait_duration=wait,
1307-
)
1465+
1466+
if run_name is None:
1467+
run = await task_client.call(
1468+
task_input=task_input,
1469+
build=build,
1470+
max_items=max_items,
1471+
max_total_charge_usd=max_total_charge_usd,
1472+
restart_on_error=restart_on_error,
1473+
memory_mbytes=memory_mbytes,
1474+
run_timeout=self._resolve_run_timeout(timeout),
1475+
webhooks=to_client_representations(webhooks),
1476+
wait_duration=wait,
1477+
)
1478+
else:
1479+
started_run, _ = await self._find_or_start_child_run(
1480+
run_name,
1481+
task_id=task_id,
1482+
run_input=task_input,
1483+
client=client,
1484+
start_run=partial(
1485+
task_client.start,
1486+
task_input=task_input,
1487+
build=build,
1488+
max_items=max_items,
1489+
max_total_charge_usd=max_total_charge_usd,
1490+
restart_on_error=restart_on_error,
1491+
memory_mbytes=memory_mbytes,
1492+
run_timeout=self._resolve_run_timeout(timeout),
1493+
webhooks=to_client_representations(webhooks),
1494+
),
1495+
token=token,
1496+
build=build,
1497+
max_items=max_items,
1498+
max_total_charge_usd=max_total_charge_usd,
1499+
restart_on_error=restart_on_error,
1500+
memory_mbytes=memory_mbytes,
1501+
timeout=timeout,
1502+
)
1503+
run = await self._wait_for_child_run(
1504+
run_name, client.run(started_run.id), started_run, wait=wait, logger=None, from_start=False
1505+
)
13081506

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

0 commit comments

Comments
 (0)