feat: Add async event processor - #472
Conversation
e7d9e77 to
0c83486
Compare
2230024 to
5ed9484
Compare
| """A fixed-size pool of concurrent tasks that rejects jobs when its limit | ||
| is reached. Matches the contract of | ||
| ``ldclient.impl.fixed_thread_pool.FixedThreadPool``.""" | ||
| class BoundedTaskSet: |
There was a problem hiding this comment.
The AsyncWorkerPool was previously created to mirror the FixedThreadPool but some of the naming and params didn't seem to align with the async code.
| log.debug('Sending events payload: ' + json_body) | ||
| payload_id = str(uuid.uuid4()) |
There was a problem hiding this comment.
would this leak redacted properties?
There was a problem hiding this comment.
No — redaction happens in the shared EventOutputFormatter.make_output_events (in event_processor_common, the same code the sync event processor uses) before serialization. It strips private-attribute values and includes only their names under _meta.redactedAttributes. So the serialized payload — and the debug log of it — carries redacted attribute names, not values, so no redacted values leak. This is byte-identical to the sync event processor's behavior.
|
Shutdown event-delivery parity (sync vs. async) — decision needed While addressing the The proposed async fix is to have The catch: this gap exists identically in the sync Question: should we fix the sync side too, and if so —
(Leaning toward keeping this PR async-scoped and handling sync separately, but flagging for a call before applying.) |
016e434 to
8d0005a
Compare
Implement the new AsyncEventProcessor interface with a concrete DefaultAsyncEventProcessor, matching the sync DefaultEventProcessor naming convention.
Triggers a flush and awaits delivery via a new inbox message, returning whether it completed within the timeout.
The async event delivery concurrency limiter no longer mirrors the sync FixedThreadPool's shape. BoundedTaskSet drops the unused name parameter and the thread vocabulary, reserves a slot synchronously at spawn (so a full set rejects rather than queues), and uses a done-callback plus asyncio.gather for cleanup and draining.
Remove the banner-style section headings; where they carried a useful note, capture it as a short class docstring instead.
…ction comments Rename drain() back to wait() (it awaits the in-flight tasks; it does not halt intake), simplify the job param to Callable[[], Coroutine], and remove banner-style section-heading comments from the async event/concurrency tests.
…cessors Move the event schema version constant into event_processor_common so both processors advertise the same X-LaunchDarkly-Event-Schema and can't drift.
_trigger_flush now returns whether it handed the batch to a worker; the flush_and_wait handler retries (waiting for a free worker) until the batch is handed off, so a saturated worker pool no longer causes a false success.
2a10924 to
623faa0
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 623faa0. Configure here.
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.
|
This is a comment from Claude, an AI code reviewer. A human requested and reviewed this comment before it was posted. ProblemThe CauseTwo behaviors cause the problem:
The dispatcher uses this loop in the while not self._trigger_flush():
await self._flush_workers.wait()This sequence causes the stop:
With a real transport, this interleaving is a race. Socket-read completions and the inbox put only have to land in one batch. The sync SDK does not have this problem, because TestsThe two tests below assert the correct behavior:
Results on this branch ( import asyncio
import threading
import pytest
from ldclient.async_config import AsyncConfig
from ldclient.context import Context
from ldclient.impl.aio.concurrency import AsyncEvent, BoundedTaskSet
from ldclient.impl.events.async_event_processor import (
DefaultAsyncEventProcessor,
EventDispatcher,
EventProcessorMessage
)
from ldclient.impl.events.types import EventInputIdentify
from ldclient.testing.impl.events.test_async_event_processor import (
MockAioHttp,
MockAioResponse
)
pytestmark = pytest.mark.asyncio
context = Context.builder('userkey').name('Red').build()
timestamp = 10000
async def test_retry_pattern_makes_progress_when_a_task_finished_in_the_same_batch():
tasks = BoundedTaskSet(1)
finished = asyncio.Event()
async def job():
finished.set()
assert tasks.try_run(job) is True
# A raw asyncio.Event wakes this coroutine in the same loop batch in which
# the job's task finished, *before* the task's done-callback has run.
# This is exactly the window in which the dispatcher's flush_and_wait
# handler can observe the set (a task completion and an inbox put landing
# in one batch).
await finished.wait()
# Desired behavior: the retry pattern used by EventDispatcher._run_main_loop
# while not try_run(...): await wait()
# must make progress here. Bounded to 100 iterations so the defect shows
# up as a clean assertion failure rather than a wedged test run.
async def noop():
pass
accepted = False
for _ in range(100):
if tasks.try_run(noop):
accepted = True
break
await tasks.wait()
assert accepted, (
"try_run never freed capacity: wait() returned without yielding to the "
"event loop, so the done-callback that reaps the finished task can "
"never run; the equivalent unbounded loop in the event dispatcher "
"spins forever"
)
await tasks.wait()
async def test_flush_and_wait_completes_when_in_flight_posts_finish_together():
class GatedMockAioHttp(MockAioHttp):
"""Requests park until the gate is set, then complete without yielding."""
def __init__(self):
super().__init__()
self.gate = asyncio.Event()
def request(self, method, uri, headers=None, data=None, timeout=None, proxy=None):
self._recorded_requests.append((headers, data))
outer = self
class _Ctx:
async def __aenter__(self):
await outer.gate.wait()
return MockAioResponse(200, {})
async def __aexit__(self, exc_type, exc_value, traceback):
return False
return _Ctx()
# Keep a handle on the dispatcher (DefaultAsyncEventProcessor discards it)
# so the watchdog below can recover the loop if it wedges.
dispatcher_holder = []
def capture_dispatcher(inbox, config, http, diagnostic_accumulator):
dispatcher = EventDispatcher(inbox, config, http, diagnostic_accumulator)
dispatcher_holder.append(dispatcher)
return dispatcher
mock_http = GatedMockAioHttp()
config = AsyncConfig(sdk_key='SDK_KEY', diagnostic_opt_out=True)
ep = DefaultAsyncEventProcessor(config, mock_http, dispatcher_class=capture_dispatcher)
try:
# Saturate all 5 flush workers with parked POSTs.
for i in range(5):
ep.send_event(EventInputIdentify(timestamp, Context.create('user%d' % i)))
ep.flush()
deadline = asyncio.get_running_loop().time() + 2
while len(mock_http.recorded_requests) < 5:
assert asyncio.get_running_loop().time() < deadline, 'workers never saturated'
await asyncio.sleep(0.01)
# Buffer one more event so the awaited flush has work to hand off.
ep.send_event(EventInputIdentify(timestamp, context))
await asyncio.sleep(0.05)
# The race, made deterministic: complete all in-flight POSTs and
# enqueue the flush_and_wait message inside one loop callback, so the
# dispatcher dequeues it in the same batch in which the workers
# finished -- before BoundedTaskSet's done-callbacks have reaped them.
# (With a real transport this interleaving needs no help: socket-read
# completions and the inbox put just have to land in one batch.)
reply = AsyncEvent()
mock_http.gate.set()
ep._inbox.put_nowait(EventProcessorMessage('flush_and_wait', reply))
# While the loop is wedged, nothing scheduled on it -- including
# asyncio timeouts -- can fire, so a plain wait_for would hang the
# whole test run. A watchdog thread detects the wedge and forcibly
# frees the task set so the test fails with an assertion instead.
finished = threading.Event()
wedged = threading.Event()
def watchdog():
if not finished.wait(3):
wedged.set()
dispatcher_holder[0]._flush_workers._tasks.clear()
threading.Thread(target=watchdog, daemon=True).start()
replied = await reply.wait(10)
finished.set()
assert replied is True
assert not wedged.is_set(), (
"flush_and_wait wedged the event loop: the dispatcher spun in "
"'while not self._trigger_flush(): await self._flush_workers.wait()' "
"without yielding, and only the watchdog thread's forcible clearing "
"of the task set un-stuck it"
)
finally:
mock_http.gate.set()
await ep.stop()Suggested fixDo not use the done-callbacks for the capacity count. Remove complete tasks directly in def _prune(self) -> None:
# Do not rely on the done-callbacks (which run via call_soon) to free up
# capacity: a task can be finished but still tracked.
self._tasks = {t for t in self._tasks if not t.done()}
def try_run(self, job: Callable[[], Coroutine]) -> bool:
self._prune()
if not self._accepting or len(self._tasks) >= self._limit:
return False
task = asyncio.create_task(job())
self._tasks.add(task)
task.add_done_callback(self._on_done)
return True
async def wait(self) -> None:
while self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
self._prune()With this fix, the 2 new tests pass. The 186 async-related tests in Caution: a one-line fix, |

Adds the async analytics event delivery component for the async SDK client, extracted from the async SDK implementation branch (SDK-60).
Stacked on #471 (AsyncConfig) — review that first. Until #471 merges this PR shows its commits too; after merge a rebase drops them out.
What's here
AsyncEventProcessorinterface +DefaultAsyncEventProcessorconcrete implementation, mirroring the syncEventProcessor/DefaultEventProcessorsplit.send_event/flushare sync;stopis a coroutine.event_processor_common.pyso the sync and async processors share buffering, output formatting, and dispatch logic.DefaultEventProcessorsuite over an injected mock aiohttp session.SDK-2769
feat: Add async event processor
Note
Medium Risk
Touches analytics delivery and shared dispatch behavior used by the sync processor, but logic is largely moved rather than rewritten and is covered by parallel async tests.
Overview
Adds
DefaultAsyncEventProcessorand an asyncEventDispatcherso the experimental async SDK can buffer and POST analytics events (including diagnostics) viaAsyncHTTPTransport, with syncsend_event/flush, asyncflush_and_wait/stop, and bounded concurrent flushes throughBoundedTaskSet.Shared non-I/O dispatch logic moves into
EventDispatcherBaseinevent_processor_common.py(event handling, index deduplication, debug flags, HTTP response disabling); the sync dispatcher now subclasses it andCURRENT_EVENT_SCHEMAis centralized there.AsyncWorkerPoolis replaced byBoundedTaskSet(try_run/wait/stop) for flush backpressure.Adds
test_async_event_processor.pymirroring the sync processor tests (payload shape, gzip, auth headers, recoverable vs unrecoverable errors, shutdown/flush edge cases).Reviewed by Cursor Bugbot for commit 1dfcdfd. Bugbot is set up for automated code reviews on this repo. Configure here.