Skip to content

Commit da89431

Browse files
authored
test: fix flaky test_event_listener_can_be_removed_successfully (#1013)
`test_event_listener_can_be_removed_successfully` is flaky on CI ([example run](https://github.com/apify/apify-sdk-python/actions/runs/28158933735/job/83394293634)). The deployed Actor asserts `counter == last_count` after `Actor.off()`, but the count occasionally moved on, so the run reports `FAILED` and the test fails. The cause is a timing race, not a bug in `Actor.off()`. `EventManager.emit()` dispatches each listener through an asyncio task, so there's latency between a `PERSIST_STATE` event being emitted and the counter being incremented. `off()` only stops future dispatch. It can't cancel an invocation already in flight from an event emitted just before it. Under load that increment lands after the snapshot, so the equality check fails even though `off()` behaves correctly. The fix drops the fixed-duration `asyncio.sleep` calls the test relied on. A second reference listener stays subscribed the whole time and acts as a heartbeat, so every step waits until N more `PERSIST_STATE` events have actually been observed rather than for a guessed wall-clock duration. After `off()` the test waits one event cycle (which flushes any invocation dispatched just before it), snapshots the removed listener's count, then waits several more event cycles and asserts the count did not move. That still fails if `off()` ever genuinely breaks. Both listeners are async so they run on the event loop and can drive the heartbeat safely. Verified locally against the real `EventManager` (no platform needed): the previous fixed-sleep version fails 40/40 under load, while this event-driven version holds 0 failures across every condition tried, including the exact stress that broke the old version, listeners slower than the emit interval, and 150-run batches.
1 parent 18009a1 commit da89431

1 file changed

Lines changed: 41 additions & 14 deletions

File tree

tests/e2e/test_actor_events.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,29 +75,56 @@ async def test_event_listener_can_be_removed_successfully(
7575
) -> None:
7676
async def main() -> None:
7777
import os
78-
from typing import Any
7978

8079
from crawlee.events._types import Event
8180

8281
from apify._consts import ApifyEnvVars
8382

8483
os.environ[ApifyEnvVars.PERSIST_STATE_INTERVAL_MILLIS] = '100'
8584

86-
counter = 0
87-
88-
def count_event(data: Any) -> None:
89-
nonlocal counter
90-
print(data)
91-
counter += 1
85+
# `removed_count` is bumped by the listener we later remove. `total_count` is bumped by a
86+
# reference listener that stays subscribed the whole time and acts as a heartbeat, letting the
87+
# test wait for real events instead of guessing wall-clock durations. Both listeners are async
88+
# so they run on the event loop and can drive `heartbeat` safely.
89+
removed_count = 0
90+
total_count = 0
91+
heartbeat = asyncio.Event()
92+
93+
async def removed_listener() -> None:
94+
nonlocal removed_count
95+
removed_count += 1
96+
97+
async def reference_listener() -> None:
98+
nonlocal total_count
99+
total_count += 1
100+
heartbeat.set()
101+
102+
async def wait_for_events(n: int) -> None:
103+
"""Block until the reference listener has observed at least `n` more events."""
104+
target = total_count + n
105+
while total_count < target:
106+
heartbeat.clear()
107+
if total_count >= target:
108+
return
109+
await heartbeat.wait()
92110

93111
async with Actor:
94-
Actor.on(Event.PERSIST_STATE, count_event)
95-
await asyncio.sleep(0.5)
96-
assert counter > 1
97-
last_count = counter
98-
Actor.off(Event.PERSIST_STATE, count_event)
99-
await asyncio.sleep(0.5)
100-
assert counter == last_count
112+
Actor.on(Event.PERSIST_STATE, removed_listener)
113+
Actor.on(Event.PERSIST_STATE, reference_listener)
114+
115+
# Both listeners are live: wait until the one we will remove has demonstrably received events.
116+
while removed_count < 2:
117+
await wait_for_events(1)
118+
119+
Actor.off(Event.PERSIST_STATE, removed_listener)
120+
121+
# One more event cycle flushes any invocation dispatched just before `off`, then snapshot.
122+
await wait_for_events(1)
123+
count_after_off = removed_count
124+
125+
# Over several further event cycles, the removed listener must not be called again.
126+
await wait_for_events(5)
127+
assert removed_count == count_after_off
101128

102129
actor = await make_actor(label='actor-off-event', main_func=main)
103130
run_result = await run_actor(actor)

0 commit comments

Comments
 (0)