Skip to content

Commit d69d0ac

Browse files
committed
fix: allow Actor.reboot() to be retried after a failed or cancelled attempt
A failed reboot API call left the internal rebooting flag set, so every subsequent reboot() call was silently skipped for the rest of the run.
1 parent 99ea41f commit d69d0ac

2 files changed

Lines changed: 95 additions & 31 deletions

File tree

src/apify/_actor.py

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,44 +1206,49 @@ async def reboot(
12061206
self.log.debug('Actor is already rebooting, skipping the additional reboot call.')
12071207
return
12081208

1209-
self._is_rebooting = True
1209+
if not self.configuration.actor_run_id:
1210+
raise RuntimeError('actor_run_id cannot be None when running on the Apify platform.')
12101211

1211-
if not custom_after_sleep:
1212+
if custom_after_sleep is None:
12121213
custom_after_sleep = self.configuration.metamorph_after_sleep
12131214

1214-
# Call all the listeners for the PERSIST_STATE and MIGRATING events, and wait for them to finish.
1215-
# PERSIST_STATE listeners are called to allow the Actor to persist its state before the reboot.
1216-
# MIGRATING listeners are called to allow the Actor to gracefully stop in-progress tasks before the reboot.
1217-
# Typically, crawlers are listening for the MIIGRATING event to stop processing new requests.
1218-
# We can't just emit the events and wait for all listeners to finish,
1219-
# because this method might be called from an event listener itself, and we would deadlock.
1220-
persist_state_listeners = flatten(
1221-
(self.event_manager._listeners_to_wrappers[Event.PERSIST_STATE] or {}).values() # noqa: SLF001
1222-
)
1223-
migrating_listeners = flatten(
1224-
(self.event_manager._listeners_to_wrappers[Event.MIGRATING] or {}).values() # noqa: SLF001
1225-
)
1226-
1227-
async def safe_dispatch(listener: Any, data: Any) -> None:
1228-
try:
1229-
await listener(data)
1230-
except Exception:
1231-
self.log.exception('A pre-reboot event listener failed')
1215+
self._is_rebooting = True
12321216

1233-
timeout = event_listeners_timeout.total_seconds() if event_listeners_timeout else None
12341217
try:
1235-
async with asyncio.timeout(timeout), asyncio.TaskGroup() as tg:
1236-
for listener in persist_state_listeners:
1237-
tg.create_task(safe_dispatch(listener, EventPersistStateData(is_migrating=True)))
1238-
for listener in migrating_listeners:
1239-
tg.create_task(safe_dispatch(listener, EventMigratingData()))
1240-
except TimeoutError:
1241-
self.log.warning('Pre-reboot event listeners did not finish within timeout; proceeding with reboot')
1218+
# Call all the listeners for the PERSIST_STATE and MIGRATING events, and wait for them to finish.
1219+
# PERSIST_STATE listeners are called to allow the Actor to persist its state before the reboot.
1220+
# MIGRATING listeners are called to allow the Actor to gracefully stop in-progress tasks before
1221+
# the reboot. Typically, crawlers are listening for the MIIGRATING event to stop processing new requests.
1222+
# We can't just emit the events and wait for all listeners to finish,
1223+
# because this method might be called from an event listener itself, and we would deadlock.
1224+
persist_state_listeners = flatten(
1225+
(self.event_manager._listeners_to_wrappers[Event.PERSIST_STATE] or {}).values() # noqa: SLF001
1226+
)
1227+
migrating_listeners = flatten(
1228+
(self.event_manager._listeners_to_wrappers[Event.MIGRATING] or {}).values() # noqa: SLF001
1229+
)
12421230

1243-
if not self.configuration.actor_run_id:
1244-
raise RuntimeError('actor_run_id cannot be None when running on the Apify platform.')
1231+
async def safe_dispatch(listener: Any, data: Any) -> None:
1232+
try:
1233+
await listener(data)
1234+
except Exception:
1235+
self.log.exception('A pre-reboot event listener failed')
12451236

1246-
await self.apify_client.run(self.configuration.actor_run_id).reboot()
1237+
timeout = event_listeners_timeout.total_seconds() if event_listeners_timeout else None
1238+
try:
1239+
async with asyncio.timeout(timeout), asyncio.TaskGroup() as tg:
1240+
for listener in persist_state_listeners:
1241+
tg.create_task(safe_dispatch(listener, EventPersistStateData(is_migrating=True)))
1242+
for listener in migrating_listeners:
1243+
tg.create_task(safe_dispatch(listener, EventMigratingData()))
1244+
except TimeoutError:
1245+
self.log.warning('Pre-reboot event listeners did not finish within timeout; proceeding with reboot')
1246+
1247+
await self.apify_client.run(self.configuration.actor_run_id).reboot()
1248+
except BaseException:
1249+
# Reset the flag so that a failed or cancelled reboot can be retried.
1250+
self._is_rebooting = False
1251+
raise
12471252

12481253
if custom_after_sleep:
12491254
await asyncio.sleep(custom_after_sleep.total_seconds())

tests/unit/actor/test_actor_helpers.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,62 @@ async def hanging_listener(*_args: object) -> None:
420420

421421
# The reboot API call proceeded despite the hanging listener.
422422
assert len(apify_client_async_patcher.calls['run']['reboot']) == 1
423+
424+
425+
async def test_reboot_can_be_retried_after_failed_attempt(
426+
apify_client_async_patcher: ApifyClientAsyncPatcher,
427+
) -> None:
428+
"""Test that a failed reboot API call does not permanently disable subsequent reboot attempts."""
429+
attempts = 0
430+
431+
async def reboot_failing_once(*_args: object, **_kwargs: object) -> None:
432+
nonlocal attempts
433+
attempts += 1
434+
if attempts == 1:
435+
raise RuntimeError('Reboot API error')
436+
437+
apify_client_async_patcher.patch('run', 'reboot', replacement_method=reboot_failing_once)
438+
439+
async with Actor:
440+
Actor.configuration.is_at_home = True
441+
Actor.configuration.actor_run_id = 'some-run-id'
442+
443+
with pytest.raises(RuntimeError, match='Reboot API error'):
444+
await Actor.reboot(custom_after_sleep=timedelta(milliseconds=1))
445+
446+
# The retry must reach the API again instead of being silently skipped.
447+
await Actor.reboot(custom_after_sleep=timedelta(milliseconds=1))
448+
449+
assert len(apify_client_async_patcher.calls['run']['reboot']) == 2
450+
451+
452+
async def test_reboot_can_be_retried_after_cancelled_attempt(
453+
apify_client_async_patcher: ApifyClientAsyncPatcher,
454+
) -> None:
455+
"""Test that a cancelled reboot attempt does not permanently disable subsequent reboot attempts."""
456+
attempts = 0
457+
first_attempt_started = asyncio.Event()
458+
459+
async def reboot_hanging_once(*_args: object, **_kwargs: object) -> None:
460+
nonlocal attempts
461+
attempts += 1
462+
if attempts == 1:
463+
first_attempt_started.set()
464+
await asyncio.sleep(60)
465+
466+
apify_client_async_patcher.patch('run', 'reboot', replacement_method=reboot_hanging_once)
467+
468+
async with Actor:
469+
Actor.configuration.is_at_home = True
470+
Actor.configuration.actor_run_id = 'some-run-id'
471+
472+
reboot_task = asyncio.create_task(Actor.reboot(custom_after_sleep=timedelta(milliseconds=1)))
473+
await first_attempt_started.wait()
474+
reboot_task.cancel()
475+
with pytest.raises(asyncio.CancelledError):
476+
await reboot_task
477+
478+
# The retry must reach the API again instead of being silently skipped.
479+
await Actor.reboot(custom_after_sleep=timedelta(milliseconds=1))
480+
481+
assert len(apify_client_async_patcher.calls['run']['reboot']) == 2

0 commit comments

Comments
 (0)