Skip to content

Commit 9e5078f

Browse files
authored
fix: handle ServiceConflictError when reusing Actor across sequential context (#804)
### Description - handle `ServiceConflictError` when reusing `Actor` across sequential context ### Issues - Closes: #678 ### Testing - Added new tests
1 parent 9e0aa56 commit 9e5078f

File tree

3 files changed

+76
-10
lines changed

3 files changed

+76
-10
lines changed

src/apify/_actor.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -350,18 +350,27 @@ def event_manager(self) -> EventManager:
350350
351351
It uses `ApifyEventManager` on the Apify platform and `LocalEventManager` otherwise.
352352
"""
353-
event_manager = (
354-
ApifyEventManager(
355-
configuration=self.configuration,
356-
persist_state_interval=self.configuration.persist_state_interval,
353+
try:
354+
event_manager = (
355+
ApifyEventManager(
356+
configuration=self.configuration,
357+
persist_state_interval=self.configuration.persist_state_interval,
358+
)
359+
if self.is_at_home()
360+
else LocalEventManager(
361+
system_info_interval=self.configuration.system_info_interval,
362+
persist_state_interval=self.configuration.persist_state_interval,
363+
)
357364
)
358-
if self.is_at_home()
359-
else LocalEventManager(
360-
system_info_interval=self.configuration.system_info_interval,
361-
persist_state_interval=self.configuration.persist_state_interval,
365+
service_locator.set_event_manager(event_manager)
366+
except ServiceConflictError:
367+
self.log.debug(
368+
'Event manager already exists in service locator (set by previous Actor context or explicitly by '
369+
'user). Using the existing event manager.'
362370
)
363-
)
364-
service_locator.set_event_manager(event_manager)
371+
# Use the event manager from the service locator
372+
event_manager = service_locator.get_event_manager()
373+
365374
return event_manager
366375

367376
@cached_property

tests/e2e/test_actor_lifecycle.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,34 @@ async def default_handler(context: BasicCrawlingContext) -> None:
110110
run_result = await run_actor(actor)
111111

112112
assert run_result.status == 'SUCCEEDED'
113+
114+
115+
async def test_actor_sequential_contexts(make_actor: MakeActorFunction, run_actor: RunActorFunction) -> None:
116+
"""Test that Actor and Actor() can be used in sequential async context manager blocks."""
117+
118+
async def main() -> None:
119+
async with Actor as actor:
120+
actor._exit_process = False
121+
assert actor._is_initialized is True
122+
123+
# Actor after Actor.
124+
async with Actor as actor:
125+
actor._exit_process = False
126+
assert actor._is_initialized is True
127+
128+
# Actor() after Actor.
129+
async with Actor(exit_process=False) as actor:
130+
assert actor._is_initialized is True
131+
132+
# Actor() after Actor().
133+
async with Actor(exit_process=False) as actor:
134+
assert actor._is_initialized is True
135+
136+
# Actor after Actor().
137+
async with Actor as actor:
138+
assert actor._is_initialized is True
139+
140+
actor = await make_actor(label='actor-sequential-contexts', main_func=main)
141+
run_result = await run_actor(actor)
142+
143+
assert run_result.status == 'SUCCEEDED'

tests/unit/actor/test_actor_lifecycle.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import contextlib
55
import logging
66
from typing import TYPE_CHECKING
7+
from unittest.mock import AsyncMock
78

89
import pytest
910

@@ -232,3 +233,28 @@ async def test_actor_fail_prevents_further_execution(caplog: pytest.LogCaptureFi
232233
status_records = [r for r in caplog.records if r.msg == '[Terminal status message]: cde']
233234
assert len(status_records) == 1
234235
assert status_records[0].levelno == logging.INFO
236+
237+
238+
@pytest.mark.parametrize(
239+
('first_with_call', 'second_with_call'),
240+
[
241+
pytest.param(False, False, id='both_without_call'),
242+
pytest.param(False, True, id='first_without_call'),
243+
pytest.param(True, False, id='second_without_call'),
244+
pytest.param(True, True, id='both_with_call'),
245+
],
246+
)
247+
async def test_actor_sequential_contexts(*, first_with_call: bool, second_with_call: bool) -> None:
248+
"""Test that Actor and Actor() can be used in two sequential async context manager blocks."""
249+
mock = AsyncMock()
250+
async with Actor(exit_process=False) if first_with_call else Actor as actor:
251+
await mock()
252+
assert actor._is_initialized is True
253+
254+
# After exiting the context, new Actor instance can be created without conflicts.
255+
async with Actor() if second_with_call else Actor as actor:
256+
await mock()
257+
assert actor._is_initialized is True
258+
259+
# The mock should have been called twice, once in each context.
260+
assert mock.call_count == 2

0 commit comments

Comments
 (0)