Skip to content

Commit 1782ca6

Browse files
committed
fix: keep aborting child runs when all ABORTING listeners are removed
1 parent 3011f0a commit 1782ca6

4 files changed

Lines changed: 105 additions & 17 deletions

File tree

‎src/apify/_actor.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,17 @@ async def __aenter__(self) -> Self:
206206

207207
# Initialize the event manager and register it in the service locator.
208208
await self.event_manager.__aenter__()
209-
self.event_manager.on(event=Event.ABORTING, listener=self._abort_child_runs)
209+
# Only the platform emits `ABORTING`, and it does so through `ApifyEventManager`.
210+
if isinstance(self.event_manager, ApifyEventManager):
211+
self.event_manager._on_internal(event=Event.ABORTING, listener=self._abort_child_runs) # noqa: SLF001
210212
self.log.debug('Event manager initialized')
211213

212214
# Initialize the charging manager.
213215
try:
214216
await self._charging_manager_implementation.__aenter__()
215217
except BaseException:
216218
# Exit the already-entered event manager so its recurring tasks do not leak.
217-
self.event_manager.off(event=Event.ABORTING, listener=self._abort_child_runs)
219+
self._remove_internal_listeners()
218220
await self.event_manager.__aexit__(None, None, None)
219221
raise
220222
self.log.debug('Charging manager initialized')
@@ -306,7 +308,7 @@ async def finalize() -> None:
306308
except TimeoutError:
307309
self.log.exception('Actor cleanup timed out')
308310
finally:
309-
self.event_manager.off(event=Event.ABORTING, listener=self._abort_child_runs)
311+
self._remove_internal_listeners()
310312
self._active = False
311313

312314
if reraise_control_flow:
@@ -1226,6 +1228,10 @@ async def _find_or_start_child_run(
12261228
abort_with_parent=abort_with_parent,
12271229
)
12281230

1231+
def _remove_internal_listeners(self) -> None:
1232+
if isinstance(self.event_manager, ApifyEventManager):
1233+
self.event_manager._off_internal(event=Event.ABORTING, listener=self._abort_child_runs) # noqa: SLF001
1234+
12291235
async def _abort_child_runs(self) -> None:
12301236
await self._child_run_registry.abort_runs_with_parent(self.apify_client)
12311237

‎src/apify/events/_apify_event_manager.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import asyncio
44
import contextlib
55
import time
6+
from collections import defaultdict
67
from logging import getLogger
7-
from typing import TYPE_CHECKING, Annotated, Self, cast
8+
from typing import TYPE_CHECKING, Annotated, Any, Self, cast
89

910
import websockets.asyncio.client
1011
import websockets.client
@@ -24,6 +25,7 @@
2425
from types import TracebackType
2526

2627
from crawlee.events._event_manager import EventManagerOptions
28+
from crawlee.events._types import EventData, EventListener, WrappedListener
2729

2830
from apify._configuration import Configuration
2931

@@ -94,6 +96,11 @@ def __init__(self, configuration: Configuration, **kwargs: Unpack[EventManagerOp
9496
connection, so that `__aenter__` can report it.
9597
"""
9698

99+
self._internal_listeners: defaultdict[Event, dict[EventListener[Any], WrappedListener]] = defaultdict(dict)
100+
"""Listeners of the SDK itself, mapped as `event -> listener -> wrapper`. `off` doesn't remove them, so user
101+
code removing all listeners of an event keeps the SDK's own handling of it.
102+
"""
103+
97104
@override
98105
async def __aenter__(self) -> Self:
99106
"""Initialize the event manager upon entering the async context.
@@ -149,6 +156,24 @@ async def __aexit__(
149156
# emitting `PersistState` again, as re-entering the context would be a no-op.
150157
await super().__aexit__(exc_type, exc_value, exc_traceback)
151158

159+
@override
160+
def emit(self, *, event: Event, event_data: EventData) -> None:
161+
super().emit(event=event, event_data=event_data)
162+
163+
for listener, listener_wrapper in self._internal_listeners.get(event, {}).items():
164+
task_name = f'Task-{event.value}-{self._get_listener_name(listener)}'
165+
listener_task = asyncio.create_task(listener_wrapper(event_data), name=task_name)
166+
self._listener_tasks.add(listener_task)
167+
listener_task.add_done_callback(self._listener_tasks.discard)
168+
169+
def _on_internal(self, *, event: Event, listener: EventListener[Any]) -> None:
170+
"""Register a listener of the SDK itself, which `off` doesn't remove."""
171+
self._internal_listeners[event][listener] = self._wrap_listener(event, listener)
172+
173+
def _off_internal(self, *, event: Event, listener: EventListener[Any]) -> None:
174+
"""Remove a listener registered by `_on_internal`."""
175+
self._internal_listeners.get(event, {}).pop(listener, None)
176+
152177
async def _teardown_platform_websocket(self) -> None:
153178
"""Stop consuming the platform messages and close the websocket connection to the platform events."""
154179
try:

‎tests/unit/actor/test_actor_child_runs.py‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
import pytest
99

1010
from apify_client._models import Run
11+
from crawlee import service_locator
1112
from crawlee.events import Event, EventAbortingData
1213

13-
from apify import Actor
14+
from apify import Actor, Configuration
1415
from apify._actor import _ActorType
1516
from apify._child_runs import CHILD_RUNS_KEY, ChildRunRegistry
17+
from apify.events import ApifyEventManager
1618

1719
if TYPE_CHECKING:
1820
from ..conftest import ApifyClientAsyncPatcher
@@ -39,6 +41,14 @@ def make_run(run_id: str, status: str) -> Run:
3941
)
4042

4143

44+
@pytest.fixture
45+
def apify_event_manager() -> ApifyEventManager:
46+
"""Make the Actor use `ApifyEventManager`, which delivers `ABORTING` on the platform, without a websocket."""
47+
event_manager = ApifyEventManager(Configuration.get_global_configuration())
48+
service_locator.set_event_manager(event_manager)
49+
return event_manager
50+
51+
4252
async def record_child_run(name: str, run_id: str, *, actor_id: str = 'some-actor') -> None:
4353
"""Seed the registry the way an earlier attempt of this Actor run would have left it."""
4454
kvs = await Actor.open_key_value_store()
@@ -452,7 +462,7 @@ async def test_reattach_replaces_recorded_abort_with_parent(
452462

453463

454464
async def test_aborting_event_aborts_marked_active_child_runs(
455-
apify_client_async_patcher: ApifyClientAsyncPatcher,
465+
apify_client_async_patcher: ApifyClientAsyncPatcher, apify_event_manager: ApifyEventManager
456466
) -> None:
457467
"""On `ABORTING`, only child runs marked `abort_with_parent` that are still active are gracefully aborted."""
458468
runs = {
@@ -482,16 +492,18 @@ async def get_run(run_client: Any, *_args: Any, **_kwargs: Any) -> Run | None:
482492
]
483493
},
484494
)
485-
Actor.event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
486-
await Actor.event_manager.wait_for_all_listeners_to_complete()
495+
apify_event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
496+
await apify_event_manager.wait_for_all_listeners_to_complete()
487497

488498
aborts = apify_client_async_patcher.calls['run']['abort']
489499
assert sorted(args[0].resource_id for args, _ in aborts) == ['ready-run', 'running-run']
490500
assert all(kwargs == {'gracefully': True} for _, kwargs in aborts)
491501

492502

493503
async def test_failed_child_run_abort_does_not_stop_others(
494-
apify_client_async_patcher: ApifyClientAsyncPatcher, caplog: pytest.LogCaptureFixture
504+
apify_client_async_patcher: ApifyClientAsyncPatcher,
505+
caplog: pytest.LogCaptureFixture,
506+
apify_event_manager: ApifyEventManager,
495507
) -> None:
496508
"""A child run that fails to abort is logged, and the other marked child runs are still aborted."""
497509

@@ -513,8 +525,8 @@ async def abort_run(run_client: Any, *_args: Any, **_kwargs: Any) -> None:
513525
for name in ['broken', 'healthy']
514526
},
515527
)
516-
Actor.event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
517-
await Actor.event_manager.wait_for_all_listeners_to_complete()
528+
apify_event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
529+
await apify_event_manager.wait_for_all_listeners_to_complete()
518530

519531
aborts = apify_client_async_patcher.calls['run']['abort']
520532
assert sorted(args[0].resource_id for args, _ in aborts) == ['broken-run', 'healthy-run']
@@ -608,16 +620,35 @@ async def start_run() -> Run:
608620
client.run.return_value.abort.assert_awaited_once_with(gracefully=True)
609621

610622

611-
async def test_exit_removes_the_aborting_listener(apify_client_async_patcher: ApifyClientAsyncPatcher) -> None:
623+
async def test_exit_removes_the_aborting_listener(
624+
apify_client_async_patcher: ApifyClientAsyncPatcher, apify_event_manager: ApifyEventManager
625+
) -> None:
612626
"""After the Actor exits, an `ABORTING` event on a still-active event manager aborts no child run."""
613627
apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY'))
614628
apify_client_async_patcher.patch('run', 'get', return_value=make_run('new-run', 'RUNNING'))
615629
apify_client_async_patcher.patch('run', 'abort', return_value=None)
616630

617-
async with Actor.event_manager:
631+
async with apify_event_manager:
618632
async with Actor:
619633
await Actor.start('some-actor', name='scrape-eu', abort_with_parent=True)
620-
Actor.event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
621-
await Actor.event_manager.wait_for_all_listeners_to_complete()
634+
apify_event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
635+
await apify_event_manager.wait_for_all_listeners_to_complete()
622636

623637
assert apify_client_async_patcher.calls['run']['abort'] == []
638+
639+
640+
async def test_removing_all_aborting_listeners_keeps_aborting_child_runs(
641+
apify_client_async_patcher: ApifyClientAsyncPatcher, apify_event_manager: ApifyEventManager
642+
) -> None:
643+
"""Removing all `ABORTING` listeners from the event manager still aborts child runs marked `abort_with_parent`."""
644+
apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY'))
645+
apify_client_async_patcher.patch('run', 'get', return_value=make_run('new-run', 'RUNNING'))
646+
apify_client_async_patcher.patch('run', 'abort', return_value=None)
647+
648+
async with Actor:
649+
await Actor.start('some-actor', name='scrape-eu', abort_with_parent=True)
650+
apify_event_manager.off(event=Event.ABORTING)
651+
apify_event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
652+
await apify_event_manager.wait_for_all_listeners_to_complete()
653+
654+
assert len(apify_client_async_patcher.calls['run']['abort']) == 1

‎tests/unit/events/test_apify_event_manager.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
from collections import defaultdict
1010
from datetime import timedelta
1111
from typing import TYPE_CHECKING, Any
12-
from unittest.mock import Mock
12+
from unittest.mock import AsyncMock, Mock
1313

1414
import pytest
1515
import websockets
1616
import websockets.asyncio.client
1717
import websockets.asyncio.server
1818

19-
from crawlee.events._types import Event
19+
from crawlee.events._types import Event, EventAbortingData
2020

2121
from ..._utils import poll_until_condition
2222
from apify import Configuration
@@ -489,6 +489,32 @@ async def handler(_data: Any) -> None:
489489
assert persist_state_counter == 0
490490

491491

492+
async def test_internal_listener_is_kept_by_off() -> None:
493+
"""A listener registered by `_on_internal` still runs after `off` removes all listeners of its event."""
494+
listener = AsyncMock()
495+
496+
async with ApifyEventManager(Configuration.get_global_configuration()) as event_manager:
497+
event_manager._on_internal(event=Event.ABORTING, listener=listener)
498+
event_manager.off(event=Event.ABORTING)
499+
event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
500+
await event_manager.wait_for_all_listeners_to_complete()
501+
502+
listener.assert_awaited_once()
503+
504+
505+
async def test_off_internal_removes_the_listener() -> None:
506+
"""A listener removed by `_off_internal` no longer runs on its event."""
507+
listener = AsyncMock()
508+
509+
async with ApifyEventManager(Configuration.get_global_configuration()) as event_manager:
510+
event_manager._on_internal(event=Event.ABORTING, listener=listener)
511+
event_manager._off_internal(event=Event.ABORTING, listener=listener)
512+
event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
513+
await event_manager.wait_for_all_listeners_to_complete()
514+
515+
listener.assert_not_awaited()
516+
517+
492518
async def test_deprecated_event_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
493519
"""Test that deprecated events (like CPU_INFO) are silently skipped."""
494520
async with (

0 commit comments

Comments
 (0)