Skip to content

feat: Add async FDv2 data sources - #485

Open
jsonbailey wants to merge 3 commits into
mainfrom
jb/sdk-60/async-fdv2-sources
Open

feat: Add async FDv2 data sources#485
jsonbailey wants to merge 3 commits into
mainfrom
jb/sdk-60/async-fdv2-sources

Conversation

@jsonbailey

@jsonbailey jsonbailey commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Overview

Part of the async Python SDK work (epic SDK-60). This is the first of two stacked PRs
that add async FDv2 support. It adds the async FDv2 data sources and the shared
support they need. The follow-up PR adds the async FDv2 data system (coordinator)
and wires it into the async client.

This is experimental and not yet wired into a public code path on its own; the async
client only builds an AsyncFDv2 data system in the stacked follow-up PR.

What this PR adds

  • impl/datasourcev2/async_polling.py — async polling data source and its builders.
  • impl/datasourcev2/async_streaming.py — async streaming data source and its builder.
  • impl/integrations/test_datav2/async_test_data_sourcev2.py plus a new
    TestDataV2.async_builder property for use with the async FDv2 data system.

Shared refactors

To avoid duplicating parsing logic between the sync and async sources, the payload
parsing and message handling are extracted into new shared modules that both consume:

  • polling_common.pypolling_payload_to_changeset / fdv1_polling_payload_to_changeset,
    moved out of polling.py. Sync polling.py now imports them (and re-exports them).
  • streaming_common.pyprocess_message, moved out of StreamingDataSource._process_message.
    Sync streaming.py now calls the shared function.

Two small fixes to existing async infrastructure that the async sources depend on:

  • impl/datasource/async_status.py — drop the read/write lock from
    AsyncDataSourceUpdateSinkImpl; the single-threaded event loop does not need it.
  • impl/aio/concurrency.py — stop swallowing/re-raising CancelledError separately in
    AsyncRepeatingTask; let cancellation propagate normally.

Testing

  • LD_SKIP_DATABASE_TESTS=1 uv run pytest ldclient/testing/impl/datasourcev2/ — 115 passed.
  • ldclient/testing/integrations/test_test_data_sourcev2.py — 34 passed.
  • make lint (mypy, isort, pycodestyle) — clean.

Tracked internally: SDK-2869


Note

Overview
Adds async Flag Delivery v2 polling and streaming data sources (AsyncPollingDataSource, AsyncStreamingDataSource) with aiohttp-based requesters, builders, and FDv1 fallback polling—intended for the stacked async FDv2 data system (not yet on a public client path alone).

Shared logic is moved into polling_common.py and streaming_common.py (payload parsing, poll-loop decisions, stream message handling, error classification, FDv1 fallback signaling); sync polling.py and streaming.py now call those helpers instead of duplicating code.

Also adds an async TestDataV2 source (TestDataV2.async_builder) with thread-safe queue delivery, drops the read/write lock from AsyncDataSourceUpdateSinkImpl, and adjusts AsyncRepeatingTask so CancelledError is not re-raised from the action callback loop.

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

@jsonbailey jsonbailey changed the title feat: Add async FDv2 data sources chore: Add async FDv2 data sources Aug 12, 2026
@jsonbailey jsonbailey changed the title chore: Add async FDv2 data sources feat: Add async FDv2 data sources Aug 12, 2026
result = self.__action()
if inspect.isawaitable(result):
await result
except asyncio.CancelledError:

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.

There is no need to catch and raise here, it isn't caught by Exception so this was just extra code.


from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey
from ldclient.impl.listeners import Listeners
from ldclient.impl.rwlock import ReadWriteLock

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 locks are not always needed in async python unless you are doing multiple awaits between the values you want to keep consistent which we are not doing here. It was over eager and trying to match sync too much.

@jsonbailey
jsonbailey force-pushed the jb/sdk-60/async-fdv2-sources branch from a07dac5 to c33ab27 Compare August 13, 2026 16:46
@jsonbailey
jsonbailey marked this pull request as ready for review August 13, 2026 19:05
@jsonbailey
jsonbailey requested a review from a team as a code owner August 13, 2026 19:05
Comment thread ldclient/impl/integrations/test_datav2/async_test_data_sourcev2.py
Comment thread ldclient/impl/datasourcev2/async_polling.py

@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 aecca5e. Configure here.

log.info("Stopping PollingDataSourceV2 synchronizer")
self._interrupt_event.set()
self._stop.set()
await self._requester.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 closes transport mid-request

Medium Severity

stop now closes the requester immediately, which can tear down the aiohttp session while sync is still awaiting fetch. That request then raises, and sync does not catch it, so shutdown can surface as an unexpected exception instead of a clean halt. The existing FDv1 async poller waits for the current poll to finish before closing the transport for this reason.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aecca5e. Configure here.

@jsonbailey
jsonbailey force-pushed the jb/sdk-60/async-fdv2-sources branch from aecca5e to 6a237d8 Compare August 13, 2026 22:29

@kinyoklion kinyoklion left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note

This comment is from Claude (a multi-agent review with general, security, and adversarial agents). @rlamb ran the review and posted the results.

Problem: the async tests do not examine the async loop logic

Short description (ASD-STE100):

  • The sync sources and the async sources contain the same loop logic in two copies.
  • The shared modules (polling_common.py, streaming_common.py) have good test coverage. The two async loop copies do not.
  • We did a mutation test. We changed the async loop code in four different ways. All the async tests stayed green each time.
  • Thus, a defect in the async loop logic can ship, and no test will show it.

The four mutations that shipped green:

  1. async_polling.py — change the recoverable-error branch (WAIT_CONTINUE) to break. One HTTP 500 then stops the poll loop permanently. 15/15 async polling tests pass.
  2. async_polling.py — remove await self._requester.close() from stop(). The owned HTTP session leaks. 15/15 tests pass.
  3. async_streaming.py — remove the fallback_requested or condition on the Fault path. A latched FDv1 fallback directive no longer stops the stream on a recoverable fault. 21/21 async streaming tests pass.
  4. async_streaming.py — remove the envid default from the Start handler. A reconnect Start without the header clears the environment ID. 21/21 tests pass.

Solution

The two suggestions below add four tests, one for each mutation. We made sure of this behavior:

  • Each test passes on this branch (head aecca5e; full suites: 40/40 with the additions).
  • Each test fails fast (< 1 s, no hang) when we apply its mutation.

The polling tests use asyncio.wait_for, so a regression cannot hang CI. Related: several existing tests in these files wait with no time limit (for example, bare await gen.__anext__() with poll_interval=60). We recommend asyncio.wait_for or pytest-timeout for those as well — a hung test stops CI and does not show a failure.

await asyncio.wait_for(task, timeout=2)

assert len(updates) == 1
assert updates[0].state == DataSourceState.VALID

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Claude — suggested tests]

These two tests pin the async-only loop behavior:

  • test_sync_recoverable_error_continues_polling — makes sure one recoverable error (HTTP 500) does not stop the poll loop. The sync suite has this test; the async suite does not. Kills mutation 1.
  • test_requester_is_closed_after_stop — makes sure the requester closes after stop(). The test asserts after the generator completes. Thus the test stays correct if stop() later becomes signal-only with a finally in sync(). Kills mutation 2.

Both tests use asyncio.wait_for. A regression fails in less than 2 seconds. It does not hang CI.

Suggested change
assert updates[0].state == DataSourceState.VALID
assert updates[0].state == DataSourceState.VALID
@pytest.mark.asyncio
async def test_sync_recoverable_error_continues_polling():
change_set = _valid_change_set()
src = _make_source(
[
_Fail(error="500", exception=UnsuccessfulResponseException(500)),
_Success(value=(change_set, {})),
],
poll_interval=0.01,
)
gen = src.sync(_ss())
first = await asyncio.wait_for(gen.__anext__(), timeout=2)
second = await asyncio.wait_for(gen.__anext__(), timeout=2)
await gen.aclose()
assert first.state == DataSourceState.INTERRUPTED
assert first.error is not None
assert first.error.status_code == 500
assert second.state == DataSourceState.VALID
assert second.change_set is change_set
@pytest.mark.asyncio
async def test_requester_is_closed_after_stop():
class CloseTrackingRequester(MockPollingRequester):
def __init__(self, results):
super().__init__(results)
self.closed = False
async def close(self) -> None:
self.closed = True
requester = CloseTrackingRequester([_Success(value=(_valid_change_set(), {}))])
src = AsyncPollingDataSource(poll_interval=60, requester=requester)
first_update_received = asyncio.Event()
async def consume():
async for _ in src.sync(_ss()):
first_update_received.set()
task = asyncio.create_task(consume())
await asyncio.wait_for(first_update_received.wait(), timeout=2)
await src.stop()
await asyncio.wait_for(task, timeout=2)
assert requester.closed is True

make_session.assert_not_called()
assert factory_cls.call_args.kwargs["session"] is supplied
assert supplied.closed is False
assert src._owned_session is None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Claude — suggested tests]

These two tests pin the async-only stream-loop behavior:

  • test_fallback_latched_on_start_carries_through_recoverable_fault — makes sure a latched fallback directive stops the stream when a recoverable fault occurs. The sync suite has three tests for the latch; the async suite has none. Kills mutation 3.
  • test_envid_preserved_across_headerless_reconnect_start — makes sure a reconnect Start without the X-LD-EnvID header does not clear the environment ID. Kills mutation 4.

The mock action lists are finite, so these tests cannot hang.

Suggested change
assert src._owned_session is None
assert src._owned_session is None
@pytest.mark.asyncio
async def test_fallback_latched_on_start_carries_through_recoverable_fault():
"""A directive latched on Start must halt the stream even on a recoverable fault."""
src = make_streaming_data_source()
actions = [
Start(headers={_LD_FD_FALLBACK_HEADER: 'true'}),
Fault(error=HTTPStatusError(503)),
# Must never be reached — the latched directive halts the stream.
server_intent_event(IntentCode.TRANSFER_FULL),
put_object_event("flag-1"),
payload_transferred_event(),
]
updates = await collect_updates(src, actions)
assert len(updates) == 1
assert updates[0].state == DataSourceState.INTERRUPTED
assert updates[0].error.status_code == 503
assert updates[0].fallback_to_fdv1 is True
@pytest.mark.asyncio
async def test_envid_preserved_across_headerless_reconnect_start():
"""A reconnect Start without X-LD-EnvID must not clear the latched value."""
src = make_streaming_data_source()
actions = [
Start(headers={_LD_ENVID_HEADER: 'my-env'}),
server_intent_event(IntentCode.TRANSFER_FULL),
put_object_event("flag-1"),
payload_transferred_event(1),
Start(headers={}),
server_intent_event(IntentCode.TRANSFER_FULL),
put_object_event("flag-2"),
payload_transferred_event(2),
]
updates = await collect_updates(src, actions)
assert len(updates) == 2
assert updates[0].environment_id == 'my-env'
assert updates[1].environment_id == 'my-env'

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