Skip to content

Commit e3518a7

Browse files
committed
fix(events): open the platform websocket only once per context
1 parent 7d23ecd commit e3518a7

3 files changed

Lines changed: 213 additions & 35 deletions

File tree

src/apify/_actor.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,12 +1249,10 @@ async def reboot(
12491249
# the reboot. Typically, crawlers are listening for the MIGRATING event to stop processing new requests.
12501250
# We can't just emit the events and wait for all listeners to finish,
12511251
# because this method might be called from an event listener itself, and we would deadlock.
1252-
persist_state_listeners = flatten(
1253-
(self.event_manager._listeners_to_wrappers[Event.PERSIST_STATE] or {}).values() # noqa: SLF001
1254-
)
1255-
migrating_listeners = flatten(
1256-
(self.event_manager._listeners_to_wrappers[Event.MIGRATING] or {}).values() # noqa: SLF001
1257-
)
1252+
# Read the mapping with `get` - subscripting it would insert entries for events nobody listens to.
1253+
listeners_to_wrappers = self.event_manager._listeners_to_wrappers # noqa: SLF001
1254+
persist_state_listeners = flatten(listeners_to_wrappers.get(Event.PERSIST_STATE, {}).values())
1255+
migrating_listeners = flatten(listeners_to_wrappers.get(Event.MIGRATING, {}).values())
12581256

12591257
async def safe_dispatch(listener: Any, data: Any) -> None:
12601258
try:

src/apify/events/_apify_event_manager.py

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -86,29 +86,47 @@ def __init__(self, configuration: Configuration, **kwargs: Unpack[EventManagerOp
8686
self._platform_events_websocket: websockets.asyncio.client.ClientConnection | None = None
8787
"""WebSocket connection to the platform events."""
8888

89-
self._process_platform_messages_task: asyncio.Task | None = None
89+
self._process_platform_messages_task: asyncio.Task[None] | None = None
9090
"""Task for processing messages from the platform websocket."""
9191

92-
self._connected_to_platform_websocket: asyncio.Future[bool] | None = None
93-
"""Future that resolves when the connection to the platform websocket is established."""
92+
self._connected_to_platform_websocket: asyncio.Future[None] | None = None
93+
"""Resolves once the platform websocket is connected, or fails with the error that prevented the first
94+
connection, so that `__aenter__` can report it.
95+
"""
9496

9597
@override
9698
async def __aenter__(self) -> Self:
99+
"""Initialize the event manager upon entering the async context.
100+
101+
On the outermost entry, it connects to the platform events websocket and starts consuming its messages.
102+
"""
97103
await super().__aenter__()
98-
self._connected_to_platform_websocket = asyncio.Future()
99104

100-
# Run tasks but don't await them
101-
if self._configuration.actor_events_ws_url:
102-
self._process_platform_messages_task = asyncio.create_task(
103-
self._process_platform_messages(self._configuration.actor_events_ws_url)
104-
)
105-
is_connected = await self._connected_to_platform_websocket
106-
if not is_connected:
107-
# Exit the already-entered parent so the recurring persist state task does not leak.
108-
await self.__aexit__(None, None, None)
109-
raise RuntimeError('Error connecting to platform events websocket!')
110-
else:
105+
# Only the outermost context owns the websocket, so a nested one must not open a second connection.
106+
if self._active_ref_count > 1:
107+
return self
108+
109+
if not self._configuration.actor_events_ws_url:
111110
logger.debug('APIFY_ACTOR_EVENTS_WS_URL env var not set, no events from Apify platform will be emitted.')
111+
return self
112+
113+
# The future has to exist before the task that resolves it starts running.
114+
self._connected_to_platform_websocket = asyncio.Future()
115+
self._process_platform_messages_task = asyncio.create_task(
116+
self._process_platform_messages(self._configuration.actor_events_ws_url)
117+
)
118+
119+
try:
120+
await self._connected_to_platform_websocket
121+
except Exception as exc:
122+
# Exit the already-entered parent so the recurring persist state task does not leak.
123+
await self.__aexit__(None, None, None)
124+
raise RuntimeError('Error connecting to platform events websocket!') from exc
125+
except BaseException:
126+
# Cancellation has to clean up as well. A stale task left behind would make the next entry look nested,
127+
# returning a manager that silently receives no platform events at all.
128+
await self.__aexit__(None, None, None)
129+
raise
112130

113131
return self
114132

@@ -119,17 +137,35 @@ async def __aexit__(
119137
exc_value: BaseException | None,
120138
exc_traceback: TracebackType | None,
121139
) -> None:
122-
# Cancel the task before closing the websocket so that the closed connection is not treated as a drop
123-
# and followed by a reconnect attempt.
124-
if self._process_platform_messages_task and not self._process_platform_messages_task.done():
125-
self._process_platform_messages_task.cancel()
126-
with contextlib.suppress(asyncio.CancelledError):
127-
await self._process_platform_messages_task
140+
"""Close the event manager upon exiting the async context.
128141
129-
if self._platform_events_websocket:
130-
await self._platform_events_websocket.close()
131-
132-
await super().__aexit__(exc_type, exc_value, exc_traceback)
142+
On the outermost exit, it stops consuming the platform messages and closes the websocket connection.
143+
"""
144+
try:
145+
if self._active_ref_count == 1:
146+
await self._teardown_platform_websocket()
147+
finally:
148+
# The parent context has to be left even if the shutdown above fails. Staying active would mean never
149+
# emitting `PersistState` again, as re-entering the context would be a no-op.
150+
await super().__aexit__(exc_type, exc_value, exc_traceback)
151+
152+
async def _teardown_platform_websocket(self) -> None:
153+
"""Stop consuming the platform messages and close the websocket connection to the platform events."""
154+
try:
155+
# Cancel the task before closing the websocket so that the closed connection is not treated as a drop
156+
# and followed by a reconnect attempt.
157+
if self._process_platform_messages_task and not self._process_platform_messages_task.done():
158+
self._process_platform_messages_task.cancel()
159+
with contextlib.suppress(asyncio.CancelledError):
160+
await self._process_platform_messages_task
161+
162+
if self._platform_events_websocket:
163+
await self._platform_events_websocket.close()
164+
finally:
165+
# Leave no closed connection or resolved future behind, so that the context can be entered again.
166+
self._process_platform_messages_task = None
167+
self._platform_events_websocket = None
168+
self._connected_to_platform_websocket = None
133169

134170
def _process_connection_exception(self, exc: Exception) -> Exception | None:
135171
"""Decide whether a failed connection attempt to the platform websocket should be retried.
@@ -159,7 +195,7 @@ async def _process_platform_messages(self, ws_url: str) -> None:
159195
async for websocket in connections:
160196
self._platform_events_websocket = websocket
161197
if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done():
162-
self._connected_to_platform_websocket.set_result(True)
198+
self._connected_to_platform_websocket.set_result(None)
163199
else:
164200
logger.info('Reconnected to the platform events websocket.')
165201

@@ -178,10 +214,12 @@ async def _process_platform_messages(self, ws_url: str) -> None:
178214
backoff_delays = websockets.client.backoff()
179215
else:
180216
await asyncio.sleep(next(backoff_delays))
181-
except Exception:
217+
except Exception as exc:
182218
logger.exception('Error in websocket connection')
219+
183220
if self._connected_to_platform_websocket is not None and not self._connected_to_platform_websocket.done():
184-
self._connected_to_platform_websocket.set_result(False)
221+
# `__aenter__` is still waiting for the first connection, so let it fail with this as the cause.
222+
self._connected_to_platform_websocket.set_exception(exc)
185223

186224
async def _consume_messages(self, websocket: websockets.asyncio.client.ClientConnection) -> bool:
187225
"""Handle platform messages until the connection closes; return whether it was lost vs. closed cleanly."""

tests/unit/events/test_apify_event_manager.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,33 @@ async def start() -> None:
151151
await stop()
152152

153153

154+
@contextlib.asynccontextmanager
155+
async def _unresponsive_ws_server(monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[None]:
156+
"""A `127.0.0.1` server that accepts connections but never completes the WebSocket handshake.
157+
158+
It keeps `__aenter__` waiting for its first connection, which is what lets a test cancel it mid-connect.
159+
"""
160+
shutdown = asyncio.Event()
161+
162+
async def handler(_reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
163+
try:
164+
await shutdown.wait()
165+
finally:
166+
writer.close()
167+
168+
server = await asyncio.start_server(handler, host='127.0.0.1')
169+
port: int = server.sockets[0].getsockname()[1]
170+
monkeypatch.setenv(ActorEnvVars.EVENTS_WEBSOCKET_URL, f'ws://127.0.0.1:{port}')
171+
172+
try:
173+
yield
174+
finally:
175+
# Release the handlers first, `wait_closed` would block on them otherwise.
176+
shutdown.set()
177+
server.close()
178+
await server.wait_closed()
179+
180+
154181
async def test_lifecycle_local(caplog: pytest.LogCaptureFixture) -> None:
155182
caplog.set_level(logging.DEBUG, logger='apify')
156183

@@ -260,10 +287,12 @@ async def test_lifecycle_on_platform_without_websocket(monkeypatch: pytest.Monke
260287
monkeypatch.setenv(ActorEnvVars.EVENTS_WEBSOCKET_URL, 'ws://localhost:56565')
261288
event_manager = ApifyEventManager(Configuration.get_global_configuration())
262289

263-
with pytest.raises(RuntimeError, match=r'Error connecting to platform events websocket!'):
290+
with pytest.raises(RuntimeError, match=r'Error connecting to platform events websocket!') as exc_info:
264291
async with event_manager:
265292
pass
266293

294+
# The error that prevented the connection is reported as the cause, not only logged.
295+
assert isinstance(exc_info.value.__cause__, OSError)
267296
assert event_manager.active is False
268297
persist_state_task = event_manager._emit_persist_state_event_rec_task.task
269298
assert persist_state_task is None or persist_state_task.done()
@@ -278,6 +307,119 @@ async def test_lifecycle_on_platform(monkeypatch: pytest.MonkeyPatch) -> None:
278307
assert len(connected_ws_clients) == 1
279308

280309

310+
async def test_nested_context_keeps_a_single_websocket(monkeypatch: pytest.MonkeyPatch) -> None:
311+
"""A nested context reuses the single platform connection, and only the outermost exit tears it down."""
312+
async with _platform_ws_server(monkeypatch) as (connected_ws_clients, client_connected):
313+
event_manager = ApifyEventManager(Configuration.get_global_configuration())
314+
315+
async with event_manager:
316+
await client_connected.wait()
317+
assert len(connected_ws_clients) == 1
318+
task = event_manager._process_platform_messages_task
319+
320+
# A crawler running under an Actor enters the already-entered event manager again.
321+
async with event_manager:
322+
await asyncio.sleep(0.2)
323+
assert len(connected_ws_clients) == 1
324+
assert event_manager._process_platform_messages_task is task
325+
326+
# The inner exit must leave the connection alone, the Actor still needs the platform events.
327+
await asyncio.sleep(0.2)
328+
assert len(connected_ws_clients) == 1
329+
assert task is not None
330+
assert not task.done()
331+
332+
# A single connection also means every event is delivered exactly once.
333+
event_calls: list[Any] = []
334+
event_manager.on(event=Event.SYSTEM_INFO, listener=event_calls.append)
335+
websockets.broadcast(connected_ws_clients, json.dumps({'name': 'systemInfo', 'data': DUMMY_SYSTEM_INFO}))
336+
await poll_until_condition(lambda: bool(event_calls), poll_interval=0.05)
337+
await asyncio.sleep(0.2)
338+
assert len(event_calls) == 1
339+
340+
# Poll because the server-side handler may not have deregistered its connection yet.
341+
await poll_until_condition(lambda: not connected_ws_clients, poll_interval=0.05)
342+
assert not connected_ws_clients
343+
assert task.done()
344+
345+
346+
async def test_context_can_be_reentered_after_full_exit(monkeypatch: pytest.MonkeyPatch) -> None:
347+
"""Entering a fully exited event manager again opens a fresh platform connection."""
348+
async with _platform_ws_server(monkeypatch) as (connected_ws_clients, client_connected):
349+
event_manager = ApifyEventManager(Configuration.get_global_configuration())
350+
351+
async with event_manager:
352+
await client_connected.wait()
353+
assert len(connected_ws_clients) == 1
354+
355+
await poll_until_condition(lambda: not connected_ws_clients, poll_interval=0.05)
356+
assert event_manager._process_platform_messages_task is None
357+
assert event_manager._platform_events_websocket is None
358+
359+
client_connected.clear()
360+
async with event_manager:
361+
await asyncio.wait_for(client_connected.wait(), timeout=10)
362+
assert len(connected_ws_clients) == 1
363+
364+
365+
async def test_cancelled_entry_leaves_no_stale_state(monkeypatch: pytest.MonkeyPatch) -> None:
366+
"""A cancelled entry releases the context, so the next entry connects again instead of looking like a nested one."""
367+
async with _unresponsive_ws_server(monkeypatch):
368+
event_manager = ApifyEventManager(Configuration.get_global_configuration())
369+
370+
first_entry = asyncio.create_task(event_manager.__aenter__())
371+
await asyncio.sleep(0.2)
372+
assert not first_entry.done()
373+
374+
first_entry.cancel()
375+
with contextlib.suppress(asyncio.CancelledError):
376+
await first_entry
377+
378+
assert event_manager.active is False
379+
assert event_manager._process_platform_messages_task is None
380+
assert event_manager._platform_events_websocket is None
381+
persist_state_task = event_manager._emit_persist_state_event_rec_task.task
382+
assert persist_state_task is None or persist_state_task.done()
383+
384+
# The next entry has to attempt a connection of its own, rather than return a manager receiving no events.
385+
second_entry = asyncio.create_task(event_manager.__aenter__())
386+
await asyncio.sleep(0.2)
387+
assert not second_entry.done()
388+
assert event_manager._active_ref_count == 1
389+
assert event_manager._process_platform_messages_task is not None
390+
391+
second_entry.cancel()
392+
with contextlib.suppress(asyncio.CancelledError):
393+
await second_entry
394+
assert event_manager.active is False
395+
396+
397+
async def test_exit_releases_context_when_the_websocket_shutdown_fails(monkeypatch: pytest.MonkeyPatch) -> None:
398+
"""A failing websocket shutdown still releases the context, so the manager cannot stay active for good."""
399+
async with _platform_ws_server(monkeypatch) as (_, client_connected):
400+
event_manager = ApifyEventManager(Configuration.get_global_configuration())
401+
await event_manager.__aenter__()
402+
await client_connected.wait()
403+
404+
monkeypatch.setattr(
405+
event_manager, '_teardown_platform_websocket', Mock(side_effect=RuntimeError('close failed'))
406+
)
407+
408+
with pytest.raises(RuntimeError, match='close failed'):
409+
await event_manager.__aexit__(None, None, None)
410+
411+
assert event_manager.active is False
412+
persist_state_task = event_manager._emit_persist_state_event_rec_task.task
413+
assert persist_state_task is None or persist_state_task.done()
414+
415+
# The mocked shutdown left the message-processing task running.
416+
task = event_manager._process_platform_messages_task
417+
assert task is not None
418+
task.cancel()
419+
with contextlib.suppress(asyncio.CancelledError):
420+
await task
421+
422+
281423
async def test_event_handling_on_platform(monkeypatch: pytest.MonkeyPatch) -> None:
282424
async with _platform_ws_server(monkeypatch) as (connected_ws_clients, client_connected):
283425

0 commit comments

Comments
 (0)