Skip to content

feat: Add async event processor - #472

Open
jsonbailey wants to merge 8 commits into
mainfrom
jb/sdk-2769/async-event-processor
Open

feat: Add async event processor#472
jsonbailey wants to merge 8 commits into
mainfrom
jb/sdk-2769/async-event-processor

Conversation

@jsonbailey

@jsonbailey jsonbailey commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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

  • AsyncEventProcessor interface + DefaultAsyncEventProcessor concrete implementation, mirroring the sync EventProcessor/DefaultEventProcessor split. send_event/flush are sync; stop is a coroutine.
  • Shared sans-I/O helpers extracted into event_processor_common.py so the sync and async processors share buffering, output formatting, and dispatch logic.
  • Tests mirroring the sync DefaultEventProcessor suite over an injected mock aiohttp session.

SDK-2769

feat: Add async event processor


Note

Medium Risk
Touches analytics delivery and shared dispatch logic used by both sync and async paths; behavior is intended to mirror the existing sync processor but regressions could affect event loss, flush semantics, or processor disablement on HTTP errors.

Overview
Adds DefaultAsyncEventProcessor and an asyncio EventDispatcher so the async SDK can buffer, batch, and POST analytics/diagnostic events over AsyncHTTPTransport, with the same inbox-driven model as the sync processor (timers, non-blocking send_event/flush, async stop, and flush_and_wait that retries flush until a batch is actually handed off under worker saturation).

Refactors shared logic into EventDispatcherBase in event_processor_common.py (_process_event, indexing/dedup, debug handling, _handle_response) and moves CURRENT_EVENT_SCHEMA there; the sync EventDispatcher now subclasses the base instead of duplicating that code.

Renames AsyncWorkerPool to BoundedTaskSet (try_run / wait / stop) for bounded concurrent flush tasks, with matching test updates in test_aio.py. Adds a large test_async_event_processor suite mirroring sync behavior (headers, gzip, diagnostics, HTTP error disablement, full inbox drops).

Reviewed by Cursor Bugbot for commit 016e434. Bugbot is set up for automated code reviews on this repo. Configure here.

@jsonbailey
jsonbailey force-pushed the jb/sdk-2769/async-event-processor branch 2 times, most recently from e7d9e77 to 0c83486 Compare July 29, 2026 18:38
Base automatically changed from jb/sdk-2768/async-config to main July 29, 2026 19:25
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.
@jsonbailey
jsonbailey force-pushed the jb/sdk-2769/async-event-processor branch from 0c83486 to ccb9c58 Compare July 29, 2026 19:31
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.
@jsonbailey
jsonbailey force-pushed the jb/sdk-2769/async-event-processor branch from 2230024 to 5ed9484 Compare July 30, 2026 16:30
Remove the banner-style section headings; where they carried a useful
note, capture it as a short class docstring instead.
"""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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

…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.
@jsonbailey
jsonbailey marked this pull request as ready for review July 30, 2026 20:30
@jsonbailey
jsonbailey requested a review from a team as a code owner July 30, 2026 20:30
Comment thread ldclient/impl/events/async_event_processor.py
_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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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 016e434. Configure here.

self._diagnostic_flush_workers.stop()
await self._diagnostic_flush_workers.wait()

await self._http.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop can drop pending events

Medium Severity

stop promises to deliver all pending events, but _do_shutdown never flushes the outbox. The pre-stop flush() can be dropped when the inbox is full, or leave events buffered when flush workers are saturated—the same case flush_and_wait already retries—so those events are abandoned on shutdown.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 016e434. Configure here.

Comment on lines +67 to +68
log.debug('Sending events payload: ' + json_body)
payload_id = str(uuid.uuid4())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this leak redacted properties?

elif message.type == 'stop':
await self._do_shutdown()
message.param.set()
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If _doShutdown throws then it will go into the catch block, but that would not break out of the loop.

@jsonbailey

Copy link
Copy Markdown
Contributor Author

Shutdown event-delivery parity (sync vs. async) — decision needed

While addressing the flush_and_wait saturation finding, Bugbot flagged a related gap: stop() promises to deliver all pending events, but _do_shutdown never re-flushes the outbox. The pre-stop flush() can be dropped when the inbox is full, or left buffered when the flush workers are saturated — so those events can be abandoned on shutdown.

The proposed async fix is to have _do_shutdown guarantee-flush the outbox before draining workers (reusing the same retry-until-handed-off logic that now backs flush_and_wait).

The catch: this gap exists identically in the sync EventProcessor — its stop() / _do_shutdown have the same structure and can drop events the same way. Fixing only the async side would make async's shutdown more robust than sync's (a behavioral divergence), whereas today they match.

Question: should we fix the sync side too, and if so —

  • (a) bundle the sync + async fix together (here or a combined change), keeping the two in parity, or
  • (b) land the async fix here and do sync in a separate PR/follow-up?

(Leaning toward keeping this PR async-scoped and handling sync separately, but flagging for a call before applying.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants