From 361e14d58bdf82a72799536512c12474205231f4 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 15 Oct 2026 11:11:50 +0200 Subject: [PATCH 1/4] feat: allow a different input when a named child run is replaced --- src/apify/_actor.py | 16 +++++----- src/apify/_child_runs.py | 26 ++++++++++------ tests/unit/actor/test_actor_child_runs.py | 38 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index f846b090..6ed695a3 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -1021,8 +1021,8 @@ async def start( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. The name is bound to the Actor and input it was first used with, - so reusing it for a different Actor, task or input raises a `ValueError`. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises + a `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run @@ -1212,8 +1212,8 @@ async def call( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. The name is bound to the Actor and input it was first used with, - so reusing it for a different Actor, task or input raises a `ValueError`. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises + a `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run. @@ -1384,8 +1384,8 @@ async def start_task( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. The name is bound to the task and input it was first used with, - so reusing it for a different Actor, task or input raises a `ValueError`. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises + a `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run. @@ -1474,8 +1474,8 @@ async def call_task( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. The name is bound to the task and input it was first used with, - so reusing it for a different Actor, task or input raises a `ValueError`. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises + a `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run. diff --git a/src/apify/_child_runs.py b/src/apify/_child_runs.py index 8b5c7bc6..d2534391 100644 --- a/src/apify/_child_runs.py +++ b/src/apify/_child_runs.py @@ -55,7 +55,7 @@ class ChildRunRecord(ChildRunSnapshot): """The current run tracked under a name in the child run registry, with the runs it replaced.""" checksum: str - """Hash of the Actor or task and the input the name was first used with.""" + """Hash of the Actor or task and the input the current run was started with.""" history: list[ChildRunSnapshot] = Field(default_factory=list) """Earlier runs under this name that failed or went missing and were replaced by a new run, oldest first.""" @@ -122,19 +122,24 @@ async def find_or_start( A recorded run that is `READY` or `RUNNING` is reattached and one that `SUCCEEDED` is returned as is. An `ABORTED` or `TIMED-OUT` run is resurrected, since Actors are expected to resume from their state. - A `FAILED` run, or one the API no longer knows, is replaced by a new run under the same name. + A `FAILED` run, or one the API no longer knows, is replaced by a new run under the same name, for any Actor, + task or input. Args: name: Name of the child run, unique within the parent run. actor_id: The Actor to start. task_id: The task to start, in place of `actor_id`. - run_input: Input of the run. With the Actor or task, it must match what `name` was first used with. + run_input: Input of the run. The recorded run is reused only if it was started with the same Actor or task + and input. client: Client used to look up the recorded run. start_run: Starts a new run of the Actor or task. resurrect_run: Resurrects the recorded run, given its ID. Returns: The run, and whether it was newly started. + + Raises: + ValueError: If the recorded run would be reused, but was started for a different Actor, task or input. """ checksum = checksum_request(actor_id=actor_id, task_id=task_id, run_input=run_input) @@ -142,12 +147,6 @@ async def find_or_start( records = await self._load() record = records.get(name) - if record is not None and record.checksum != checksum: - raise ValueError( - f'The run name "{name}" was already used for a different Actor, task or input. ' - 'Use a unique `run_name` for each child run.' - ) - if record is None: run = await self._start(name, checksum=checksum, start_run=start_run, history=[]) self._clients[name] = client @@ -155,7 +154,6 @@ async def find_or_start( run_client = client.run(record.run_id) run = await _get_recorded_run(run_client) - self._clients[name] = client if run is not None and run.status in _SETTLING_STATUSES: run = await run_client.wait_for_finish() @@ -169,8 +167,15 @@ async def find_or_start( run = await self._start( name, checksum=checksum, start_run=start_run, history=[*record.history, replaced] ) + self._clients[name] = client return run, True + if record.checksum != checksum: + raise ValueError( + f'The run name "{name}" was already used for a different Actor, task or input. ' + 'Use a unique `run_name` for each child run.' + ) + if run.status in _RESURRECTABLE_STATUSES: logger.info(f'Resurrecting child run "{name}"', extra={'run_id': run.id, 'status': run.status}) run = await resurrect_run(run.id) @@ -178,6 +183,7 @@ async def find_or_start( logger.info(f'Reattaching to child run "{name}"', extra={'run_id': run.id, 'status': run.status}) await self.update(name, run) + self._clients[name] = client return run, False def run_clients(self, default_client: ApifyClientAsync) -> dict[str, RunClientAsync]: diff --git a/tests/unit/actor/test_actor_child_runs.py b/tests/unit/actor/test_actor_child_runs.py index cce23466..cd8a220a 100644 --- a/tests/unit/actor/test_actor_child_runs.py +++ b/tests/unit/actor/test_actor_child_runs.py @@ -215,6 +215,7 @@ async def test_named_start_rejects_name_recorded_for_another_actor( ) -> None: """Reusing a name for a different Actor raises instead of attaching to the other Actor's run.""" apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY')) + apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', 'RUNNING')) async with Actor: await record_child_run('scrape-eu', 'old-run', actor_id='other-actor') @@ -229,6 +230,7 @@ async def test_named_start_rejects_name_recorded_for_task( ) -> None: """Reusing a task run's name for an Actor raises instead of attaching to the task's run.""" apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY')) + apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', 'RUNNING')) async with Actor: await record_child_run('scrape-eu', 'old-run', actor_id=None, task_id='some-task') @@ -439,6 +441,7 @@ async def test_named_call_task_rejects_name_recorded_for_actor( ) -> None: """Reusing an Actor run's name for a task raises instead of attaching to the Actor's run.""" apify_client_async_patcher.patch('task', 'start', return_value=make_run('new-run', 'READY')) + apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', 'RUNNING')) async with Actor: await record_child_run('scrape-eu', 'old-run') @@ -539,6 +542,7 @@ async def test_named_start_rejects_name_recorded_with_other_input( ) -> None: """Reusing a name for the same Actor with a different input raises instead of attaching to the earlier run.""" apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY')) + apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', 'RUNNING')) async with Actor: await record_child_run('scrape-eu', 'old-run', run_input={'since': '2025-01-01'}) @@ -548,6 +552,39 @@ async def test_named_start_rejects_name_recorded_with_other_input( assert apify_client_async_patcher.calls['actor']['start'] == [] +@pytest.mark.parametrize( + ('recorded_actor_id', 'recorded_input'), + [ + pytest.param('other-actor', None, id='other actor'), + pytest.param('some-actor', {'since': '2025-01-01'}, id='other input'), + ], +) +async def test_named_start_replaces_run_recorded_for_other_request( + apify_client_async_patcher: ApifyClientAsyncPatcher, + recorded_actor_id: str, + recorded_input: Any, +) -> None: + """A failed run recorded for another Actor or input is replaced, and the name takes the new run's checksum.""" + apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY')) + apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', 'FAILED')) + + async with Actor: + await record_child_run('scrape-eu', 'old-run', actor_id=recorded_actor_id, run_input=recorded_input) + run = await Actor.start('some-actor', {'since': '2026-01-01'}, run_name='scrape-eu') + kvs = await Actor.open_key_value_store() + stored = await kvs.get_value(CHILD_RUNS_KEY) + + assert run.id == 'new-run' + assert stored == { + 'scrape-eu': stored_record( + 'new-run', + 'READY', + run_input={'since': '2026-01-01'}, + history=[{'runId': 'old-run', 'status': 'FAILED', 'startedAt': STARTED_AT}], + ) + } + + async def test_named_start_ignores_input_key_order(apify_client_async_patcher: ApifyClientAsyncPatcher) -> None: """An input equal to the recorded one up to key order reattaches to the recorded run.""" apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', 'RUNNING')) @@ -696,6 +733,7 @@ async def test_child_runs_keeps_client_of_name_after_rejected_reuse( ) -> None: """A name reuse rejected for a different input leaves `Actor.child_runs` with the original client.""" apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY')) + apify_client_async_patcher.patch('run', 'get', return_value=make_run('new-run', 'RUNNING')) async with Actor: await Actor.start('some-actor', {'since': '2025-01-01'}, run_name='scrape-eu') From b994dfdceb1c776566a2525c6613e5b3114f63d5 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 15 Oct 2026 12:41:13 +0200 Subject: [PATCH 2/4] test: check that a rejected named start never resurrects the recorded run --- tests/unit/actor/test_actor_child_runs.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/unit/actor/test_actor_child_runs.py b/tests/unit/actor/test_actor_child_runs.py index cd8a220a..18cbcef4 100644 --- a/tests/unit/actor/test_actor_child_runs.py +++ b/tests/unit/actor/test_actor_child_runs.py @@ -537,12 +537,22 @@ async def test_named_start_retries_recorded_run_not_found_yet( assert apify_client_async_patcher.calls['actor']['start'] == [] +@pytest.mark.parametrize( + 'status', + [ + pytest.param('RUNNING', id='running'), + pytest.param('SUCCEEDED', id='succeeded'), + pytest.param('ABORTED', id='aborted'), + ], +) async def test_named_start_rejects_name_recorded_with_other_input( apify_client_async_patcher: ApifyClientAsyncPatcher, + status: str, ) -> None: - """Reusing a name for the same Actor with a different input raises instead of attaching to the earlier run.""" + """Reusing a name with a different input raises instead of reattaching to, or resurrecting, the earlier run.""" apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY')) - apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', 'RUNNING')) + apify_client_async_patcher.patch('run', 'get', return_value=make_run('old-run', status)) + apify_client_async_patcher.patch('run', 'resurrect', return_value=make_run('old-run', 'RUNNING')) async with Actor: await record_child_run('scrape-eu', 'old-run', run_input={'since': '2025-01-01'}) @@ -550,6 +560,7 @@ async def test_named_start_rejects_name_recorded_with_other_input( await Actor.start('some-actor', {'since': '2026-01-01'}, run_name='scrape-eu') assert apify_client_async_patcher.calls['actor']['start'] == [] + assert apify_client_async_patcher.calls['run']['resurrect'] == [] @pytest.mark.parametrize( From ad5713ead685d07e3023b58743f28b49ef85a397 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 15 Oct 2026 12:41:14 +0200 Subject: [PATCH 3/4] docs: rewrap the run_name docstrings --- src/apify/_actor.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 6ed695a3..5b553eb1 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -1021,8 +1021,8 @@ async def start( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises - a `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a + `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run @@ -1212,8 +1212,8 @@ async def call( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises - a `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a + `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run. @@ -1384,8 +1384,8 @@ async def start_task( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises - a `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a + `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run. @@ -1474,8 +1474,8 @@ async def call_task( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises - a `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a + `ValueError`, unless the recorded run is replaced. Returns: Info about the started Actor run. From a0f4ecea5b68eb9c77067e884ff606a21e6f5be3 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 15 Oct 2026 14:16:55 +0200 Subject: [PATCH 4/4] docs: reword run_name ValueError docstring --- src/apify/_actor.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 5b553eb1..150cf924 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -1021,8 +1021,8 @@ async def start( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a - `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Using `run_name` to attach to a run of a different Actor, task or + with a different input raises a `ValueError`. Returns: Info about the started Actor run @@ -1212,8 +1212,8 @@ async def call( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a - `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Using `run_name` to attach to a run of a different Actor, task or + with a different input raises a `ValueError`. Returns: Info about the started Actor run. @@ -1384,8 +1384,8 @@ async def start_task( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a - `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Using `run_name` to attach to a run of a different Actor, task or + with a different input raises a `ValueError`. Returns: Info about the started Actor run. @@ -1474,8 +1474,8 @@ async def call_task( default key-value store, so after a migration or resurrection of this Actor the same call reattaches to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is resurrected, and a new run is started only when nothing is recorded under the name, or the recorded - run `FAILED` or no longer exists. Reusing the name for a different Actor, task or input raises a - `ValueError`, unless the recorded run is replaced. + run `FAILED` or no longer exists. Using `run_name` to attach to a run of a different Actor, task or + with a different input raises a `ValueError`. Returns: Info about the started Actor run.