Skip to content

feat: Add async event processor - #472

Merged
jsonbailey merged 12 commits into
mainfrom
jb/sdk-2769/async-event-processor
Aug 5, 2026
Merged

feat: Add async event processor#472
jsonbailey merged 12 commits into
mainfrom
jb/sdk-2769/async-event-processor

Conversation

@jsonbailey

@jsonbaileyjsonbailey 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
Changes analytics delivery and refactors shared sync dispatch paths; behavior is intended to match sync with broad tests, but incorrect flush/backpressure or shared-base regressions could drop or mis-deliver events.

Overview
Introduces async analytics event delivery for AsyncConfig / AsyncLDClient: DefaultAsyncEventProcessor with inbox-driven EventDispatcher, periodic flush/context/diagnostic timers, gzip POSTs via AsyncHTTPTransport, and async stop / flush_and_wait semantics aligned with the sync processor.

Shared logic moves into event_processor_common as EventDispatcherBase (_process_event, indexing/dedup, debug events, _handle_response) plus a single CURRENT_EVENT_SCHEMA; the sync EventDispatcher now subclasses it instead of duplicating that code.

Concurrency:AsyncWorkerPool becomes BoundedTaskSet (try_run / gather-based wait) to cap concurrent flush POSTs; _trigger_flush returns whether a batch was handed off so flush_and_wait and shutdown can retry when all workers are busy. Tests cover shutdown flush, stop-on-shutdown-error, and a flush_and_wait livelock regression when workers finish in the same event-loop batch.

Adds a large async test suite mirroring sync DefaultEventProcessor behavior (headers, diagnostics, HTTP error handling, full inbox, compression).

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

@jsonbailey
jsonbaileyforce-pushed the jb/sdk-2769/async-event-processor branch 2 times, most recently from e7d9e77 to 0c83486CompareJuly 29, 2026 18:38
Base automatically changed from jb/sdk-2768/async-config to mainJuly 29, 2026 19:25
@jsonbailey
jsonbaileyforce-pushed the jb/sdk-2769/async-event-processor branch 2 times, most recently from 2230024 to 5ed9484CompareJuly 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
ContributorAuthor

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 ownerJuly 30, 2026 20:30
Comment threadldclient/impl/events/async_event_processor.py
Comment threadldclient/impl/events/async_event_processor.py
Comment threadldclient/impl/events/async_event_processor.py
Comment threadldclient/impl/events/async_event_processor.py
@jsonbailey

Copy link
Copy Markdown
ContributorAuthor

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
jsonbaileyforce-pushed the jb/sdk-2769/async-event-processor branch from 016e434 to 8d0005aCompareAugust 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
jsonbaileyforce-pushed the jb/sdk-2769/async-event-processor branch from 2a10924 to 623faa0CompareAugust 4, 2026 20:27

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

Comment threadldclient/impl/events/async_event_processor.py
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.
@jsonbailey
jsonbailey requested a review from joker23August 4, 2026 23:13
@kinyoklion

Copy link
Copy Markdown
Member

This is a comment from Claude, an AI code reviewer. A human requested and reviewed this comment before it was posted.

Problem

The flush_and_wait handler can stop the event loop permanently. The dispatcher then uses approximately 100% CPU. All tasks on the application's event loop stop. Timers on that loop do not fire.

Cause

Two behaviors cause the problem:

  1. BoundedTaskSet releases a slot only in _on_done. The event loop runs _on_done later, through call_soon. A task can be complete while the set continues to count it against the limit.
  2. BoundedTaskSet.wait() awaits asyncio.gather(...). When all tasks in the set are complete, or when the set is empty, gather returns a future that is already complete. An await on a complete future does not give control back to the event loop.

The dispatcher uses this loop in the flush_and_wait branch of _run_main_loop:

whilenotself._trigger_flush():
awaitself._flush_workers.wait()

This sequence causes the stop:

  1. The 5 POST tasks become complete in one event-loop batch.
  2. The dispatcher receives the flush_and_wait message in the same batch.
  3. try_run returns False, because the set counts the 5 complete tasks.
  4. wait() returns immediately. It does not give control to the event loop.
  5. The _on_done callbacks cannot run. The loop repeats without end.

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 FixedThreadPool decreases its busy count synchronously in the worker thread.

Tests

The two tests below assert the correct behavior:

  • Test 1 shows the defect at the BoundedTaskSet level. It is small and fast. It can go into TestBoundedTaskSet in ldclient/testing/test_aio.py.
  • Test 2 shows the defect end-to-end on DefaultAsyncEventProcessor. It can go into ldclient/testing/impl/events/test_async_event_processor.py.

Results on this branch (623faa0): the 2 tests fail in approximately 3 seconds. They do not hang the test run. A stopped event loop cannot fire its own asyncio timeouts. Because of this, test 2 uses a watchdog thread. The watchdog finds the stop after 3 seconds and clears the task set. The test then fails with a clear assertion, and CI does not hang.

importasyncioimportthreadingimportpytestfromldclient.async_configimportAsyncConfigfromldclient.contextimportContextfromldclient.impl.aio.concurrencyimportAsyncEvent, BoundedTaskSetfromldclient.impl.events.async_event_processorimport (
DefaultAsyncEventProcessor,
EventDispatcher,
EventProcessorMessage
)
fromldclient.impl.events.typesimportEventInputIdentifyfromldclient.testing.impl.events.test_async_event_processorimport (
MockAioHttp,
MockAioResponse
)
pytestmark=pytest.mark.asynciocontext=Context.builder('userkey').name('Red').build()
timestamp=10000asyncdeftest_retry_pattern_makes_progress_when_a_task_finished_in_the_same_batch():
tasks=BoundedTaskSet(1)
finished=asyncio.Event()
asyncdefjob():
finished.set()
asserttasks.try_run(job) isTrue# 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).awaitfinished.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.asyncdefnoop():
passaccepted=Falsefor_inrange(100):
iftasks.try_run(noop):
accepted=Truebreakawaittasks.wait()
assertaccepted, (
"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"
)
awaittasks.wait()
asyncdeftest_flush_and_wait_completes_when_in_flight_posts_finish_together():
classGatedMockAioHttp(MockAioHttp):
"""Requests park until the gate is set, then complete without yielding."""def__init__(self):
super().__init__()
self.gate=asyncio.Event()
defrequest(self, method, uri, headers=None, data=None, timeout=None, proxy=None):
self._recorded_requests.append((headers, data))
outer=selfclass_Ctx:
asyncdef__aenter__(self):
awaitouter.gate.wait()
returnMockAioResponse(200, {})
asyncdef__aexit__(self, exc_type, exc_value, traceback):
returnFalsereturn_Ctx()
# Keep a handle on the dispatcher (DefaultAsyncEventProcessor discards it)# so the watchdog below can recover the loop if it wedges.dispatcher_holder= []
defcapture_dispatcher(inbox, config, http, diagnostic_accumulator):
dispatcher=EventDispatcher(inbox, config, http, diagnostic_accumulator)
dispatcher_holder.append(dispatcher)
returndispatchermock_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.foriinrange(5):
ep.send_event(EventInputIdentify(timestamp, Context.create('user%d'%i)))
ep.flush()
deadline=asyncio.get_running_loop().time() +2whilelen(mock_http.recorded_requests) <5:
assertasyncio.get_running_loop().time() <deadline, 'workers never saturated'awaitasyncio.sleep(0.01)
# Buffer one more event so the awaited flush has work to hand off.ep.send_event(EventInputIdentify(timestamp, context))
awaitasyncio.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()
defwatchdog():
ifnotfinished.wait(3):
wedged.set()
dispatcher_holder[0]._flush_workers._tasks.clear()
threading.Thread(target=watchdog, daemon=True).start()
replied=awaitreply.wait(10)
finished.set()
assertrepliedisTrueassertnotwedged.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()
awaitep.stop()

Suggested fix

Do not use the done-callbacks for the capacity count. Remove complete tasks directly in BoundedTaskSet:

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= {tfortinself._tasksifnott.done()}
deftry_run(self, job: Callable[[], Coroutine]) ->bool:
self._prune()
ifnotself._acceptingorlen(self._tasks) >=self._limit:
returnFalsetask=asyncio.create_task(job())
self._tasks.add(task)
task.add_done_callback(self._on_done)
returnTrueasyncdefwait(self) ->None:
whileself._tasks:
awaitasyncio.gather(*self._tasks, return_exceptions=True)
self._prune()

With this fix, the 2 new tests pass. The 186 async-related tests in ldclient/testing/test_aio.py and ldclient/testing/impl/events/ also pass.

Caution: a one-line fix, await asyncio.sleep(0) at the top of wait(), is not sufficient. The 2 new tests pass with it, but the existing test TestBoundedTaskSet::test_saturation_returns_false fails. The cause is the same defect in a smaller form: wait() can return while the set continues to count a complete task.

@joker23joker23 left a comment

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.

approved with nit comments

Comment threadldclient/impl/events/async_event_processor.py Outdated
Comment threadldclient/impl/events/async_event_processor.py
BoundedTaskSet freed a slot in a done-callback, which runs on a later
loop turn. When flush tasks finished and a flush_and_wait or shutdown
message arrived in the same event-loop batch, the dispatcher's
retry loop (while not self._trigger_flush(): await self._flush_workers.wait())
could spin forever: try_run still counted the finished tasks, and wait()
over already-complete tasks did not yield, so the done-callbacks never ran
to free the slots. This wedged the event loop at ~100% CPU.
Release the slot from within the task, synchronously in a finally, like the
sync FixedThreadPool. Add regression tests at the BoundedTaskSet and event
processor levels, and drop an unused import.
@jsonbailey
jsonbailey merged commit ec7c113 into mainAug 5, 2026
24 of 25 checks passed
@jsonbailey
jsonbailey deleted the jb/sdk-2769/async-event-processor branch August 5, 2026 19:47
@github-actionsgithub-actionsBot mentioned this pull request Aug 5, 2026
jsonbailey pushed a commit that referenced this pull request Aug 28, 2026
🤖 I have created a release *beep* *boop*
---
##
[9.17.0](9.16.1...9.17.0)
(2026-08-28)
### Features
* Add async big segment store manager and async Redis adapter
([#462](#462))
([aa492d2](aa492d2))
* Add async DynamoDB persistent feature store
([#490](#490))
([cb010df](cb010df))
* Add async event processor
([ec7c113](ec7c113))
* Add async event processor
([#472](#472))
([ec7c113](ec7c113))
* Add async FDv1 polling data source and feature requester
([#475](#475))
([cca37a8](cca37a8))
* Add async FDv1 streaming and data source status tracking
([#464](#464))
([4bf7067](4bf7067))
* Add async FDv2 data sources
([#485](#485))
([5da1515](5da1515))
* Add async FDv2 data system
([#486](#486))
([6a70132](6a70132))
* Add async hook, plugin, and flag tracker
([#463](#463))
([686a70a](686a70a))
* Add async migration support
([#470](#470))
([577d51e](577d51e))
* Add async persistent feature store foundation and Redis adapter
([f9c76ee](f9c76ee))
* Add AsyncConfig for the async SDK client
([#471](#471))
([0587a78](0587a78))
* Add AsyncLDClient with FDv1 data system and public API
([#480](#480))
([fd041a5](fd041a5))
* Add Config.with_wrapper_information
([#501](#501))
([8a98583](8a98583))
* Add environment ID support for hooks.
([#484](#484))
([49e809f](49e809f))
* Add read-only store views and async persistence foundation for the
data system
([#503](#503))
([0eb61fa](0eb61fa))
### Bug Fixes
* Allow tombstones without a key property
([#502](#502))
([5f44e61](5f44e61))
* Escape attribute names reported in redactedAttributes
([#505](#505))
([90059cb](90059cb))
* Prevent a persistent-store outage from throwing in the sync FDv2
evaluation
([#506](#506))
([467da53](467da53))
* Return empty prerequisites for a flag that fails to evaluate in
all_flags_state
([#483](#483))
([73e9b07](73e9b07))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Overview**
> **Release Please** bumps the package from **9.16.1** to **9.17.0** in
`pyproject.toml`, `ldclient/version.py`,
`.release-please-manifest.json`, and the provenance example in
`PROVENANCE.md`.
> > `CHANGELOG.md` gains a new **9.17.0** (2026-08-28) section that
records what ships in this minor release: a broad **async** surface
(`AsyncLDClient`, `AsyncConfig`, async FDv1/FDv2 data systems, event
processor, hooks/plugins, migration, and Redis/DynamoDB persistent
stores plus big-segment async support), plus sync improvements
(`Config.with_wrapper_information`, hook environment ID, read-only store
views) and bug fixes (tombstones, `redactedAttributes` escaping, FDv2
persistent-store resilience, `all_flags_state` prerequisites).
> > No application logic changes appear in this diff—only version metadata
and release notes.
> > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f225e46. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@jsonbailey@kinyoklion@joker23