Skip to content

feat: Add async event processor - #472

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

feat: Add async event processor#472
jsonbailey wants to merge 10 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
New async path delivers analytics to LaunchDarkly and refactors shared event-handling used by sync; behavior is intended to match sync and is heavily tested, but delivery and disable-on-error semantics remain business-critical.

Overview
Introduces async analytics event delivery for the async SDK: DefaultAsyncEventProcessor and an asyncio-based EventDispatcher (inbox loop, bounded concurrent flushes, diagnostics, gzip/retry HTTP) mirror the sync processor while exposing sync send_event/flush and async stop/flush_and_wait.

Shared dispatch logic moves into event_processor_common as EventDispatcherBase (_process_event, index dedup, debug sampling, _handle_response disable-on-unrecoverable-error) plus a single CURRENT_EVENT_SCHEMA; the sync EventDispatcher now subclasses that base instead of duplicating ~100 lines.

Async concurrency for flush workers switches from AsyncWorkerPool to BoundedTaskSet (try_run rejects when full); async flush_and_wait retries flush until a worker accepts the batch. A large test suite parallels the sync DefaultEventProcessor tests over a mock aiohttp session.

Reviewed by Cursor Bugbot for commit 623faa0. 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
@jsonbailey
jsonbailey force-pushed the jb/sdk-2769/async-event-processor branch 2 times, most recently from 2230024 to 5ed9484 Compare July 30, 2026 16:30
"""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.

@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
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.)

@jsonbailey
jsonbailey force-pushed the jb/sdk-2769/async-event-processor branch from 016e434 to 8d0005a Compare August 4, 2026 16:36
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.
@jsonbailey
jsonbailey force-pushed the jb/sdk-2769/async-event-processor branch from 2a10924 to 623faa0 Compare August 4, 2026 20:27

@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.

There are 2 total unresolved issues (including 1 from previous review).

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 623faa0. Configure here.

message.param.set()
return
except Exception:
log.error('Unhandled exception in event processor', exc_info=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shutdown exception hangs stop forever

High Severity

If _do_shutdown raises, the outer except logs it and continues the main loop without setting the stop reply or returning. stop then waits forever on that reply, so shutdown never completes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 623faa0. Configure here.

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