feat: Add async FDv2 data system - #486
Conversation
027941e to
b911aa7
Compare
b911aa7 to
35dd8be
Compare
35dd8be to
c88b6aa
Compare
c88b6aa to
1c996d6
Compare
1c996d6 to
9f6ce38
Compare
cde1b0b to
ef7c261
Compare
22a3bab to
4b20c9a
Compare
4b20c9a to
0db5afa
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ 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 0db5afa. Configure here.
| # Apply the basis to the store | ||
| await self._store.apply(basis.change_set, basis.persist) | ||
|
|
||
| # Set ready event if and only if a selector is defined for the changeset |
There was a problem hiding this comment.
We are changing the spec and the logic around this. I think it's fine to leave for now, but just making sure it's on your radar.
Addresses review feedback on the interim warm-start refresh. The async readiness path is now awaitable at every layer instead of a private two-step refresh feeding a synchronous gate: - AsyncDataSystem.data_availability and AsyncLDClient.is_initialized() become coroutines; the eval path awaits data_availability() directly. - AsyncFeatureStore gains an abstract async is_initialized(); the getattr duck-type is dropped, so a custom store that omits it fails at construction instead of silently serving DEFAULTS forever. - The warm-start store error is caught inside data_availability and degrades to DEFAULTS, so evaluation never propagates a store error. - Removes the interim refresh_availability, cold-path gating, and start-time refresh, which are subsumed by the awaitable gate. - Adds a per-iteration stop-event check in the synchronizer loop so a perpetually-ready queue cannot starve the stop signal. The sync data system is unchanged.
AsyncDataSystem.data_availability and AsyncLDClient.is_initialized() become coroutines; AsyncFeatureStore gains an abstract async is_initialized() and the getattr duck-type is dropped; the warm-start store error is caught inside data_availability and degrades to DEFAULTS; the interim refresh_availability, cold-path gating, and start-time refresh are removed. Extracted to PR #486. Sync unchanged.
…n close() - is_monitoring_enabled now delegates to the store's own opt-in, matching the sync wrapper. A store that cannot report availability is no longer polled, so it is never marked unavailable with no path back to recovery. - close() does nothing on a later call, bounds the poller stop with a timeout so a wedged poller cannot hang shutdown, and logs an error from the inner store's close instead of letting it propagate.
…g them AsyncStore.close() previously returned the close error as Optional[Exception], which the only caller (AsyncFDv2.stop) discarded, so a failed close was silently lost. It now logs a warning and returns None. Closing happens at shutdown, where there is no caller left to react to the error, so logging is the useful outcome.
| availability is not polled, so it is never marked unavailable with no | ||
| path back to recovery. | ||
| """ | ||
| return callable(getattr(self._store, "is_available", None)) |
There was a problem hiding this comment.
Are we checking for the store is_available elsewhere now?
There was a problem hiding this comment.
Yes. The recovery poller still calls is_available, in _check_availability. This method now decides only whether to monitor. It uses the store's own opt-in (is_monitoring_enabled), which is true when the core provides is_available.
|
|
||
| Does nothing on a later call, so closing more than once is safe. | ||
| """ | ||
| if self._closed: |
There was a problem hiding this comment.
I think this could get us into another situation where we never close since the boolean is set before any of the actual closing happens... this might be fine if we think that calling the stop/close functions are our best effort.
There was a problem hiding this comment.
Correct, and this is best-effort by design. We set the flag first so a second close() call does nothing. Both teardown steps have their own guards, so neither one raises. Only task cancellation can stop the close early, which means an abnormal shutdown. If you want a retry after a failed close, we would need a lock so callers wait for the first close — more than a shutdown path needs, but I can add it if you prefer.
| return self._config.offline | ||
|
|
||
| def is_initialized(self) -> bool: | ||
| async def is_initialized(self) -> bool: |
There was a problem hiding this comment.
not sure if this is a problem, but does python do truthy so when is_initialized() returns a future then if client.is_initialized() will always test true?
There was a problem hiding this comment.
is_initialized() is now a coroutine, so if client.is_initialized() is always true unless you await it. This applies to every async method here, such as variation and all_flags_state. The fix is to await the call. The docstring now states that it is a coroutine, and all internal calls await it.

Overview
Part of the async Python SDK work (epic SDK-60). Adds the async FDv2 data system (coordinator) and wires it into the async client. Targets
main(its predecessor, #485 async FDv2 data sources, has merged).This is experimental and should not be considered production-ready.
What this PR adds
impl/datasystem/async_fdv2.py—AsyncFDv2, the async data system that coordinates the async initializers and synchronizers, mirrors the syncFDv2fallback/recovery behavior, and exposes the async data source status and flag tracking. IncludesAsyncFeatureStoreClientWrapperfor persistent-store availability polling.async_client.pywiring:_make_data_systembuildsAsyncFDv2;_wire_data_source_sessionsshares the client's aiohttp session into the async data source builders so they reuse the connection pool.Async readiness gate (awaitable end-to-end)
Readiness and availability are now awaitable so a persistent store populated by another process (warm start / daemon) is recognized:
AsyncDataSystem.data_availabilityandAsyncLDClient.is_initialized()are coroutines; the eval path awaitsdata_availability()directly.AsyncFeatureStoregains an abstract asyncis_initialized()(with$initedcaching + monotonic latch inAsyncCachingStoreWrapper, surfaced viaAsyncStore.is_ready()). Thegetattrduck-type is dropped, so a custom store that omits it fails at construction rather than silently serving defaults.DEFAULTSrather than raising, sovariation()never throws on a store outage. (Sync counterpart: fix: Do not propagate persistent-store errors from the sync FDv2 warm-start check #506.)Wrapper hardening
is_monitoring_enabled()delegates to the store's own opt-in (matching sync), so a store that cannot report availability is not polled and left stuck unavailable.close()is idempotent, bounds the availability-poller stop with a timeout, and logs errors from the inner store close.AsyncStore.close()logs and swallows close errors instead of returning them.Shared refactor
impl/datasystem/fdv2_common.pygains module-levelfallback_condition/recovery_condition, shared by the sync and async data systems.Testing
LD_SKIP_DATABASE_TESTS=1 uv run pytest ldclient/testing/impl/datasystem/andldclient/testing/test_async_client.py— green.make lint(mypy, isort, pycodestyle) — clean.Tracked internally: SDK-2870
Note
Overview
Adds async Flag Delivery v2 support by introducing
AsyncFDv2, wiring it intoAsyncLDClientwhendatasystem_configis set (replacing the previousNotImplementedError), and sharing the client's aiohttp session with async polling/streaming data source builders via_wire_data_source_sessions.Readiness and availability are now async so persistent stores populated by another process can be detected:
AsyncLDClient.is_initialized(),AsyncDataSystem.data_availability(), and a newAsyncFeatureStore.is_initialized()(with caching/latching inAsyncCachingStoreWrapperandAsyncStore.is_ready()). Contract-test harnesses await these checks accordingly.Sync FDv2 is refactored to share wiring through
_FDv2Baseand module-levelfallback_condition/recovery_conditioninfdv2_common.py.AsyncFDv2mirrors initializer/synchronizer coordination, FDv1 fallback, persistent-store outage recovery, and addsAsyncFeatureStoreClientWrapperfor availability polling.AsyncStore.close()logs and swallows close errors instead of returning them. Large new test suites coverAsyncFDv2, async persistence, and readiness gating.Reviewed by Cursor Bugbot for commit 111bb37. Bugbot is set up for automated code reviews on this repo. Configure here.