fix(polling_source): replace _accumulated_stream with optimistic-lock batch list (ITL-617) - #260
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… batch list Fixes ITL-617: concurrent sync callers (iter_data, as_table) could advance _cursor while async_iter_data was running, causing the async loop to skip already-fetched rows — permanent silent data loss. Replace the single _accumulated_stream with an append-only _batches list and a threading.Lock. All callers use the optimistic lock protocol: snapshot cursor (brief lock) → I/O with no lock held → commit only if cursor unchanged (brief lock). The async loop tracks its position with a per-iterator local_batch_idx and drains _batches at the top of each iteration to pick up sync-committed rows. The lock is never held across await. Closes ITL-617 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
| # ── 1. Drain: yield any batches not yet emitted ────────────── | ||
| # Covers pre-existing rows on first iteration AND rows committed | ||
| # by concurrent sync callers while this iterator was polling. | ||
| while local_batch_idx < len(self._batches): |
There was a problem hiding this comment.
The last committed batch is never yielded when the loop terminates.
Rows are now emitted only at this drain step, at the top of the next iteration. But three return paths sit after the commit in the same iteration — the duration check (~L906), the overrun threshold (~L894), and max_consecutive_errors (~L870). So the final fetch of any duration-limited run is committed to _batches and then dropped when the generator exits.
Repro:
fake = FakeDynamicSource(batches=[_batch(1, 10)], fetch_delay=0.05)
src = PollingSource(
fake, tag_columns="id",
polling_config=PollingConfig(interval=0.01, duration=0.01, max_missed_intervals=1000),
)
rows = [item async for item in src.async_iter_data()]
assert len(rows) == 1 # PR: 0, while len(src._batches) == 1main yields 1 row here; this branch yields 0.
Why the new tests don't catch it
Triggering the bug needs a terminating return to fire in the same iteration that committed a batch. No test in the suite sets that up:
- The two new concurrency tests (
test_iter_data_and_as_table_concurrent_with_async_run_lose_no_rows,test_output_schema_and_keys_concurrent_with_async_run_lose_no_rows) are the only new tests asserting a row count, and they useinterval=0.02, duration=1.0with 3 instant batches. The last batch commits at roughly t=0.06s; the duration check doesn't fire until t=1.0s. That leaves ~47 further iterations, the first of which drains batch 3. Commit and termination land in different iterations, so the gap never opens. - The two tests that do hit a terminating
returnnever assert on rows.test_duration_limit_terminates_sourceruns withbatches=[]and only assertsfake.close_called.test_overrun_terminates_after_thresholdcollects intoitemsbut asserts onlyfake.close_called— it discards the row count, which is exactly the value the bug corrupts.
My repro closes the gap deliberately: fetch_delay=0.05 against duration=0.01 means the single fetch outlives the duration budget, so the commit and the duration-check return happen in one iteration and the drain never runs again.
Suggest hoisting the drain into a helper and running it once more before every return (and after the while True body), plus a row-count assertion on the two termination tests.
There was a problem hiding this comment.
Fixed in commit 90c5b59 (rebased on the pull, so check the branch tip).
The three normal-termination return statements (duration check, overrun threshold, max_consecutive_errors) are now break. A single final drain block sits after the while True loop — before except asyncio.CancelledError — so it runs on every clean exit regardless of which condition fired. CancelledError and the fatal-raise paths (CursorInvalidatedError, InputValidationError) skip the final drain intentionally: a cancelled or fatally-errored consumer will not read further items.
Tests:
- Added
test_last_batch_yielded_before_duration_exit— your exact repro (fetch_delay=0.05, duration=0.01), assertinglen(rows) == 1. - Strengthened
test_duration_limit_terminates_sourcewithassert len(items) == 0(no batches in that fixture, so it acts as a negative sanity check). - Strengthened
test_overrun_terminates_after_thresholdwithassert len(items) > 0(rows committed before the overrun limit must be yielded).
64/64 pass.
…ction-during-an-async-run
The drain step at the top of the async loop was not reached when a batch was committed in the same iteration that triggered a duration/overrun/ max-errors exit — those return paths skipped the next drain permanently. Convert the three normal-termination `return` statements to `break` and add a single final drain block after the `while True` loop. CancelledError and fatal raises (CursorInvalidatedError, InputValidationError) skip the final drain intentionally — their consumers will not read further items. Also strengthen two existing tests with row-count assertions, and add test_last_batch_yielded_before_duration_exit (Brian's repro: fetch_delay > duration so commit and duration-check land in the same iteration). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@brian-arnold thanks for catching this! I have now addressed the regression and also added tests to catch this in the future. |
brian-arnold
left a comment
There was a problem hiding this comment.
Thank you; Looks correct on my end!
Review round summaryBrian caught a real regression: any batch committed in the final iteration of Changes made in response to this review:
64/64 tests pass. |
Summary
_accumulated_stream: ArrowTableStream | Nonewith an append-only_batches: list[ArrowTableStream]and_state_lock: threading.Lockiter_data/as_tableand asyncasync_iter_data) now use the optimistic lock protocol: snapshot cursor (brief lock) → I/O with no lock held → commit only if cursor unchanged (brief lock)local_batch_idxand drains_batchesat the top of each iteration, so rows committed by a concurrent sync caller are never lost regardless of who wins the commit raceawaitCloses ITL-617
Root Cause
_get_latest_stream(), called byiter_data()oras_table()whileasync_iter_data()was running, could advance_cursorand merge new rows into_accumulated_streamwithout the async loop ever emitting them — permanent silent data loss.ITL-615(PR #255) partially patchedoutput_schema()andkeys()by bypassing_get_latest_stream()when the cached stream was available. ITL-617 fixes the root by making all mutation of_cursorand_batchesrace-free.Test Plan
TestPollingSourceSyncAccessDuringAsyncRunwith two regression tests:test_iter_data_and_as_table_concurrent_with_async_run_lose_no_rows: hammersiter_data()andas_table()viaasyncio.to_threadwhile the async loop runs; asserts all 3 rows are delivered to the async iteratortest_output_schema_and_keys_concurrent_with_async_run_lose_no_rows: hammersoutput_schema()andkeys()concurrently; asserts all 3 rows delivered AND exactly 3 fetches (schema calls never trigger a fetch)test_channels/test_polling_source.pytests pass🤖 Generated with Claude Code