Skip to content

Commit 1dfcdfd

Browse files
committed
fix: Complete async event processor shutdown and flush buffered events
Two shutdown bugs on the async event processor's stop path: - If _do_shutdown raised, the dispatcher logged it and continued the loop without setting the stop reply or returning, so stop() (which waits on that reply with no timeout) hung forever. Guard the stop branch so the reply is always set and the loop exits. - _do_shutdown never drained the outbox, so buffered events were lost on shutdown if the pre-stop flush was dropped (inbox full) or left buffered (workers saturated). Hand off the outbox with retry before stopping the workers. Adds regression tests for both. The sync event processor has the same two issues; tracked as a follow-up.
1 parent 623faa0 commit 1dfcdfd

2 files changed

Lines changed: 48 additions & 3 deletions

File tree

ldclient/impl/events/async_event_processor.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ async def _run_main_loop(self):
146146
await self._diagnostic_flush_workers.wait()
147147
message.param.set()
148148
elif message.type == 'stop':
149-
await self._do_shutdown()
149+
try:
150+
await self._do_shutdown()
151+
except Exception:
152+
log.error('Error during event processor shutdown', exc_info=True)
150153
message.param.set()
151154
return
152155
except Exception:
@@ -180,6 +183,11 @@ def _send_and_reset_diagnostics(self):
180183
self._diagnostic_flush_workers.try_run(task.run)
181184

182185
async def _do_shutdown(self):
186+
# Deliver any still-buffered events before shutting down. Retry the
187+
# hand-off while all flush workers are busy (like flush_and_wait), then
188+
# stop the pool and wait for the in-flight flushes to finish.
189+
while not self._trigger_flush():
190+
await self._flush_workers.wait()
183191
self._flush_workers.stop()
184192
await self._flush_workers.wait()
185193

ldclient/testing/impl/events/test_async_event_processor.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020

2121
from ldclient.async_config import AsyncConfig
2222
from ldclient.context import Context
23-
from ldclient.impl.aio.concurrency import AsyncQueue
23+
from ldclient.impl.aio.concurrency import AsyncEvent, AsyncQueue
2424
from ldclient.impl.events.async_event_processor import (
2525
DefaultAsyncEventProcessor,
26-
EventDispatcher
26+
EventDispatcher,
27+
EventProcessorMessage
2728
)
2829
from ldclient.impl.events.diagnostics import (
2930
_DiagnosticAccumulator,
@@ -218,6 +219,42 @@ async def test_trigger_flush_reports_whether_batch_handed_off():
218219
await dispatcher._runner.stop_all()
219220

220221

222+
@pytest.mark.asyncio
223+
async def test_stop_completes_even_if_shutdown_raises():
224+
# If _do_shutdown raises, the dispatcher must still set the stop reply and
225+
# exit the loop; otherwise stop() would wait on that reply forever.
226+
mock_http = MockAioHttp()
227+
config = AsyncConfig(sdk_key='SDK_KEY', diagnostic_opt_out=True)
228+
inbox = AsyncQueue(config.events_max_pending)
229+
dispatcher = EventDispatcher(inbox, config, mock_http)
230+
231+
async def boom():
232+
raise RuntimeError("shutdown failed")
233+
dispatcher._do_shutdown = boom
234+
235+
reply = AsyncEvent()
236+
await inbox.put(EventProcessorMessage('stop', reply))
237+
# Would hang without the fix; wait_for turns a hang into a test failure.
238+
await asyncio.wait_for(reply.wait(), 2)
239+
240+
241+
@pytest.mark.asyncio
242+
async def test_shutdown_flushes_buffered_events():
243+
# _do_shutdown must drain the outbox so buffered events are delivered on a
244+
# clean shutdown, even when no flush reached the dispatcher beforehand.
245+
mock_http = MockAioHttp()
246+
config = AsyncConfig(sdk_key='SDK_KEY', diagnostic_opt_out=True)
247+
inbox = AsyncQueue(config.events_max_pending)
248+
dispatcher = EventDispatcher(inbox, config, mock_http)
249+
dispatcher._outbox.add_event(EventInputIdentify(timestamp, context))
250+
251+
reply = AsyncEvent()
252+
await inbox.put(EventProcessorMessage('stop', reply))
253+
await asyncio.wait_for(reply.wait(), 2)
254+
255+
assert mock_http.request_data is not None
256+
257+
221258
async def test_two_events_for_same_context_only_produce_one_index_event():
222259
mock_http = MockAioHttp()
223260
async with make_processor(mock_http) as ep:

0 commit comments

Comments
 (0)