Repository navigation
test: reproduce lost-wakeup hang in DeleteFileIndex (no fix) - #9
Open
dhruvarya-db wants to merge 1 commit into
Open
dhruvarya-db wants to merge 1 commit into
dhruvarya-db wants to merge 1 commit into
Conversation
This was referenced Jun 23, 2026
…data_file The populating task signals completion with Notify::notify_waiters() (no stored permit), and the query creates its Notified future only after releasing the read lock, so a notification fired in that window is lost and the query hangs. A test-only seam (FORCE_LOST_WAKEUP) widens the otherwise nanosecond window so the race is hit deterministically. This commit is the reproduction only: the test FAILS (hangs, then trips a 5s timeout) on the current code. Signed-off-by: Dhruv Arya <[email protected]>
dhruvarya-db
force-pushed
the
repro-delete-file-index-lost-wakeup
branch
from
June 23, 2026 01:08
d89be97 to
0f58185
Compare
laskoviymishka
pushed a commit
to apache/iceberg-rust
that referenced
this pull request
Jun 26, 2026
…file (#2696) ## Which issue does this PR close? N/A — concurrency bug found while reviewing #2590. ## What changes are included in this PR? Fixes a lost-wakeup hang in `DeleteFileIndex::get_deletes_for_data_file`. ## The bug `DeleteFileIndex` populates asynchronously on a background task that signals completion with `tokio::sync::Notify::notify_waiters()`. Per the tokio docs, `notify_waiters()` stores no permit and only wakes `Notified` futures that have already been **created**. `get_deletes_for_data_file` observes the `Populating` state, **releases the read lock**, and only *then* creates the `Notified` future. If the populating task flips the state to `Populated` and calls `notify_waiters()` in that window, the wakeup is lost and the query awaits forever. The window is normally only a few instructions wide and is effectively never hit, because the querier usually registers before the populator is even scheduled. This is reachable from the `SnapshotValidator` commit path (#2590), which drops the channel sender immediately before querying — making the populator finish right as the query starts. ## The fix Create the `Notified` future (via `notified_owned`) **while still holding the read lock**. The populating task cannot call `notify_waiters()` until it acquires the write lock, which is blocked while the read lock is held — so the notification is guaranteed to happen *after* the future has been created, and tokio guarantees such notifications are delivered. No re-check needed. The same latent pattern exists in `arrow/delete_filter.rs` and `arrow/caching_delete_file_loader.rs`; this PR fixes only the index site, and I'm happy to follow up on the others. ## Are these changes tested? This production change is intentionally minimal (no test), because a deterministic regression test requires a small test-only seam to make the otherwise nanosecond-wide race window reliable. To keep that out of this PR, the seam + test live in companion PRs on my fork: - Reproduction only — the test **fails** (hangs → timeout), demonstrating the bug: dhruvarya-db#9 - The same fix **with** the regression test — the test **passes**: dhruvarya-db#10 I'm happy to fold the regression test into this PR if maintainers prefer. ## How the race happens (before this fix) `get_deletes_for_data_file` releases the read lock at **(B)** and only creates its `Notified` future at **(C)**. The populating task can flip the state to `Populated` and call `notify_waiters()` in the window between (B) and (C). Because `notify_waiters()` stores no permit (unlike `notify_one()`) and only wakes `Notified` futures created *before* the call, a notification fired before the future exists is lost — and the consumer then awaits forever: ``` CONSUMER get_deletes_for_data_file PRODUCER populating task ───────────────────────────────── ────────────────────────── read lock: state == Populating (A) clone notifier (Arc<Notify>) drop read lock (B) │ │ ◄═══════ WINDOW ═══════► write lock: state = Populated │ drop write lock │ notify_waiters() ✗ no waiter registered yet │ → wakeup lost (no permit stored) ▼ notifier.notified().await (C) (task has exited) │ ▼ ░░░ awaits forever ░░░ ← never woken → commit/query hangs ``` This is reachable in practice because observing `Populating` at (A) is the common case: the consumer does only synchronous work between dropping the sender and the read at (A), while the populator must first be woken from its parked `collect()`, scheduled, and acquire the write lock — so the consumer typically reaches (A) before the state flips. **The fix** creates the `Notified` future at **(A)**, while the read lock is still held. The populator cannot call `notify_waiters()` until it takes the write lock (blocked while the read lock is held), so the future always precedes any `notify_waiters()` and the notification is guaranteed to be delivered. Signed-off-by: Dhruv Arya <[email protected]> Co-authored-by: Dhruv Arya <[email protected]>
Li0k
pushed a commit
to risingwavelabs/iceberg-rust
that referenced
this pull request
Aug 26, 2026
…file (apache#2696) N/A — concurrency bug found while reviewing apache#2590. Fixes a lost-wakeup hang in `DeleteFileIndex::get_deletes_for_data_file`. `DeleteFileIndex` populates asynchronously on a background task that signals completion with `tokio::sync::Notify::notify_waiters()`. Per the tokio docs, `notify_waiters()` stores no permit and only wakes `Notified` futures that have already been **created**. `get_deletes_for_data_file` observes the `Populating` state, **releases the read lock**, and only *then* creates the `Notified` future. If the populating task flips the state to `Populated` and calls `notify_waiters()` in that window, the wakeup is lost and the query awaits forever. The window is normally only a few instructions wide and is effectively never hit, because the querier usually registers before the populator is even scheduled. This is reachable from the `SnapshotValidator` commit path (apache#2590), which drops the channel sender immediately before querying — making the populator finish right as the query starts. Create the `Notified` future (via `notified_owned`) **while still holding the read lock**. The populating task cannot call `notify_waiters()` until it acquires the write lock, which is blocked while the read lock is held — so the notification is guaranteed to happen *after* the future has been created, and tokio guarantees such notifications are delivered. No re-check needed. The same latent pattern exists in `arrow/delete_filter.rs` and `arrow/caching_delete_file_loader.rs`; this PR fixes only the index site, and I'm happy to follow up on the others. This production change is intentionally minimal (no test), because a deterministic regression test requires a small test-only seam to make the otherwise nanosecond-wide race window reliable. To keep that out of this PR, the seam + test live in companion PRs on my fork: - Reproduction only — the test **fails** (hangs → timeout), demonstrating the bug: dhruvarya-db#9 - The same fix **with** the regression test — the test **passes**: dhruvarya-db#10 I'm happy to fold the regression test into this PR if maintainers prefer. `get_deletes_for_data_file` releases the read lock at **(B)** and only creates its `Notified` future at **(C)**. The populating task can flip the state to `Populated` and call `notify_waiters()` in the window between (B) and (C). Because `notify_waiters()` stores no permit (unlike `notify_one()`) and only wakes `Notified` futures created *before* the call, a notification fired before the future exists is lost — and the consumer then awaits forever: ``` CONSUMER get_deletes_for_data_file PRODUCER populating task ───────────────────────────────── ────────────────────────── read lock: state == Populating (A) clone notifier (Arc<Notify>) drop read lock (B) │ │ ◄═══════ WINDOW ═══════► write lock: state = Populated │ drop write lock │ notify_waiters() ✗ no waiter registered yet │ → wakeup lost (no permit stored) ▼ notifier.notified().await (C) (task has exited) │ ▼ ░░░ awaits forever ░░░ ← never woken → commit/query hangs ``` This is reachable in practice because observing `Populating` at (A) is the common case: the consumer does only synchronous work between dropping the sender and the read at (A), while the populator must first be woken from its parked `collect()`, scheduled, and acquire the write lock — so the consumer typically reaches (A) before the state flips. **The fix** creates the `Notified` future at **(A)**, while the read lock is still held. The populator cannot call `notify_waiters()` until it takes the write lock (blocked while the read lock is held), so the future always precedes any `notify_waiters()` and the notification is guaranteed to be delivered. Signed-off-by: Dhruv Arya <[email protected]> Co-authored-by: Dhruv Arya <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a deterministic regression test reproducing a lost-wakeup hang in
DeleteFileIndex::get_deletes_for_data_file. This PR intentionally contains no fix — the new test FAILS (hangs, then trips a 5s timeout). It exists to demonstrate the bug in isolation, so CI failure here is expected.The bug
DeleteFileIndexpopulates asynchronously on a background task that signals completion withtokio::sync::Notify::notify_waiters(). Per the tokio docs,notify_waiters()stores no permit and only wakesNotifiedfutures that have already been created.get_deletes_for_data_fileobserves thePopulatingstate, releases the read lock, and only then creates theNotifiedfuture. If the populating task flips the state toPopulatedand callsnotify_waiters()in that window, the wakeup is lost and the query awaits forever.The window is normally only a few instructions wide and is effectively never hit, because the querier usually registers before the populator is even scheduled. This is reachable from the
SnapshotValidatorcommit path (apache#2590), which drops the channel sender immediately before querying — making the populator finish right as the query starts.A test-only
FORCE_LOST_WAKEUPseam widens the window deterministically (pausing the querier inside it) so the hang is reproducible on demand.Companion PRs