77import warnings
88from dataclasses import asdict
99from datetime import UTC , datetime , timedelta
10- from functools import cached_property
10+ from functools import cached_property , partial
1111from pathlib import Path
1212from typing import TYPE_CHECKING , Any , Literal , TypeVar , cast , overload
1313
3535 ChargingManagerImplementation ,
3636 charge_lock_if_charging ,
3737)
38+ from apify ._child_runs import ChildRunRegistry
3839from apify ._configuration import Configuration
3940from apify ._consts import EVENT_LISTENERS_TIMEOUT , EXIT_CODE_ERROR_USER_FUNCTION_THREW , ActorEnvVars , ApifyEnvVars
4041from apify ._crypto import decrypt_input_secrets , load_private_key
4950
5051if 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