Skip to content

fix(arrow): resolve eq-delete waiters when a delete file fails to load - #3275

Open
dhruvarya-db wants to merge 6 commits into
apache:mainfrom
dhruvarya-db:fix-eqdel-recverror-stuck-loading
Open

dhruvarya-db wants to merge 6 commits into
apache:mainfrom
dhruvarya-db:fix-eqdel-recverror-stuck-loading

Conversation

@dhruvarya-db

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

This case was raised during review of #3268.

What changes are included in this PR?

An equality-delete file that fails to load (missing, unreadable, or unparseable) can leave an entry permanently stuck in
the Loading state inside DeleteFilter. Any task that later waits on that entry blocks
forever on a notification that will never fire. This PR makes a failed load clean up
after itself and wake its waiters, so the failure surfaces as an error instead of a
hang.

Background: how equality-delete loading works

Equality-delete predicates are produced by one task and consumed by others through a small
state machine keyed by delete-file path:

  • EqDelState::Loading(Notify) — a task is parsing this file; waiters hold the Notify.
  • EqDelState::Loaded(Predicate) — the predicate is ready.

The producer side (insert_equality_delete) marks the entry Loading, then spawns a task
that waits on a oneshot channel for the parsed predicate, stores it as Loaded, and calls
notify_waiters(). The parsed predicate is sent from the delete-file loader
(caching_delete_file_loader.rs, the FreshEqDel branch).

The consumer side (get_equality_delete_predicate_for_delete_file_path) reads the entry; if
it is Loading, it waits on the Notify until the producer signals completion.

The bug

The spawned producer task did:

let eq_del = eq_del.await.unwrap();   // <-- panics if the sender was dropped
// ... insert Loaded ...
notify.notify_waiters();

The predicate is sent here (caching_delete_file_loader.rs):

let predicate =
    Self::parse_equality_deletes_record_batch_stream(batch_stream, equality_ids)
        .await?;               // <-- early return on parse error DROPS `sender`
sender.send(predicate) ...

If parsing the delete file fails, the ? returns early before sender.send(...),
dropping the sender. On the producer task, eq_del.await then resolves to Err(RecvError),
.unwrap() panics, and the two lines that follow — the Loaded insert and
notify_waiters() — never run. The entry is stranded in Loading, and its Notify is
never signalled.

Exact sequence of events

sequenceDiagram
    participant P as Producer task (insert_equality_delete)
    participant L as Loader (FreshEqDel branch)
    participant S as DeleteFilter state
    participant W as Waiter (get_equality_delete_predicate...)

    L->>S: insert(path, Loading(notify))
    L->>P: spawn task, awaiting oneshot rx
    W->>S: read(path) -> Loading(notify)
    W->>W: notify.notified().await   (parked)
    Note over L: parse_equality_deletes_...().await?  ❌ parse error
    L--xP: sender dropped (early return, never sent)
    P->>P: eq_del.await -> Err(RecvError)
    Note over P: .unwrap() PANICS
    Note over S: entry stays Loading forever,<br/>notify_waiters() never called
    Note over W: parked forever — HANG 🔒
Loading

State of the map entry:

                 success path                     failure path (this bug)
   Loading  ──────────────────► Loaded      Loading ──► (parse error) ──► Loading  🔒
              send + notify                            sender dropped,     forever
                                                       task panics

When it actually hangs

Within a single load_deletes call the parse error also propagates up the results stream
via ?, which tears down that call and cancels its sibling tasks — so the stuck entry is
usually not observed there. The hang bites when a DeleteFilter is shared across
concurrent or sequential loads
(an ArrowReader is Clone and its clones share one
loader/filter, and many data files typically reference the same equality-delete file):

  1. Loader task A claims delete file E, marks it Loading, and fails to parse it.
  2. A panics; E is never signalled and never leaves Loading.
  3. A later task B references E, sees it already claimed, and waits on its Notify.
  4. B blocks forever.

Secondary effects: the runtime task panics on an ordinary I/O/parse error, and a reused
ArrowReader stays poisoned for that file across subsequent scans.

The fix

Handle the dropped sender instead of unwrapping it:

  • On Ok(predicate): unchanged — store Loaded and notify_waiters().
  • On Err(_): remove the stale Loading marker, then notify_waiters().

Waiters that wake to a missing entry now return None (the post-await match no longer
treats that as unreachable!). build_equality_delete_predicate turns that None into a
clear "Missing predicate for equality delete file '…'" error, so the failure is reported
rather than deadlocked. Removing the entry (rather than caching a permanent failure) also
lets a later, independent load of the same file try again.

sequenceDiagram
    participant P as Producer task
    participant S as DeleteFilter state
    participant W as Waiter

    Note over P: eq_del.await -> Err(RecvError)
    P->>S: remove(path)          (drop stale Loading)
    P->>W: notify.notify_waiters()
    W->>S: re-read(path) -> None
    W-->>W: return None  ➜  surfaced as "missing predicate" error ✅
Loading

Are these changes tested?

Each test is bounded by tokio::time::timeout. With the fix reverted, all three fail after 5s with a "hung" assertion instead of hanging the test suite.

  • test_get_equality_delete_predicate_resolves_when_load_fails: a unit test on DeleteFilter. It drops the predicate sender without sending, which is what a failed parse does, and checks that the waiter gets None.
  • test_cloned_readers_error_on_failed_equality_delete_load: builds one ArrowReader, clones it, and reads a task whose equality-delete file can't be read. Both the original and the clone must return an error. The clones share one DeleteFilter, so this tests the shared-reader case from the description directly.
  • test_equality_delete_load_retries_after_failure: after a failed read, it restores the delete file and reads again with the same reader. The deletes must be applied ([1, 2, 3, 4, 5] minus 3), which shows that removing the stale entry lets the reader recover.

AI Disclosure

The code was written using Claude but was manually reviewed by me.

When parsing an equality-delete file fails, the loader drops the predicate
sender without sending. The task spawned by `insert_equality_delete` was
calling `eq_del.await.unwrap()`, so it panicked on `RecvError` and never ran
the `Loaded` insert or `notify_waiters()`. The map entry stayed `Loading`
forever, and any task that later waited on that entry blocked indefinitely.

Handle the dropped sender: remove the stale `Loading` marker and wake waiters.
Waiters that wake to a missing entry now return `None`, which surfaces as a
"missing predicate" error instead of a hang. Adds a deterministic regression
test.

Signed-off-by: Dhruv Arya <[email protected]>
@JeonDaehong

JeonDaehong commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Thanks, handling the dropped sender looks right to me. While checking whether a waiter can still hang, I found two more scenarios on main (not introduced in this PR) where a waiter can get stuck

  1. Overwritten Notifier in try_start_eq_del_load & insert_equality_delete

    • Issue: try_start_eq_del_load stores Loading(notify_a), and then insert_equality_delete replaces it with Loading(notify_b). A waiter that reads the entry in between ends up waiting on notify_a, which is never notified (the returned notify at caching_delete_file_loader.rs:297 is currently unused).
    • Fix: Reuse the existing notifier from try_start_eq_del_load inside insert_equality_delete.
  2. Race Condition in Waiter Wakeup (notified() vs notified_owned())

    • Issue: The waiter clones the Notify, drops the read lock, and only then calls notified(). Since notify_waiters() doesn't store a permit, if the load finishes in that gap, the wakeup is lost.
    • Fix: Call notifier.clone().notified_owned() while still holding the lock (as try_start_pos_del_load does).

I reproduced both on this branch (for 2, I added a small sleep between the read lock and notified() to make the gap deterministic). Both waiters hang as expected, and with the fix applied (reusing the notifier and using notified_owned() inside the lock), both wake up correctly.

Happy to send this as a follow-up PR if you'd rather keep this PR focused!

Repro for 1 (multi_thread runtime)
let filter = DeleteFilter::new(Runtime::current());
let _claimed = filter.try_start_eq_del_load("eq.parquet").unwrap();
let f2 = filter.clone();
let waiter = tokio::spawn(async move {
    f2.get_equality_delete_predicate_for_delete_file_path("eq.parquet").await
});
tokio::time::sleep(Duration::from_millis(200)).await;
let (tx, rx) = tokio::sync::oneshot::channel::<Predicate>();
filter.insert_equality_delete("eq.parquet", rx);
tx.send(AlwaysTrue).unwrap();
// times out: the waiter is parked on the first notifier
assert!(tokio::time::timeout(Duration::from_secs(3), waiter).await.is_err());

@dhruvarya-db
dhruvarya-db marked this pull request as draft October 20, 2026 20:50
@dhruvarya-db dhruvarya-db reopened this Oct 20, 2026
c-thiel added a commit to lakekeeper/iceberg-rust that referenced this pull request Oct 26, 2026
…iled

Tasks that share a delete filter load each equality delete file once; the others wait for that load. When the load failed, the spawned task that stores the set panicked on the dropped sender and left the file marked as loading. Every task that waited for, or later needed, that file then waited forever, and a reader that shares the filter across tasks hung after the first error instead of failing.

A failed equality delete load now removes the file's state and wakes the waiters, which return the existing "Missing equality delete set" error. A later task loads the file again. The spawned task and the drop path of a positional delete load also take a poisoned state lock: a panic there would leave the waiters waiting forever, and in the drop path a panic during unwinding would abort the process.

Upstream issue apache#3274 reports this hang. PR apache#3275 fixed it the same way and was closed without merging on 2026-10-20. The positional counterpart is issue apache#3434 and PR apache#3435, cherry-picked before this commit. One more test here checks that a task waiting for a positional delete load that a failing sibling cancelled loads the file itself, with all its deletes.

Review: `insert_equality_delete` and the end of `get_equality_delete_set_for_delete_file_path` in delete_filter.rs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
JanKaul pushed a commit to lakekeeper/iceberg-rust that referenced this pull request Oct 26, 2026
…iled

Tasks that share a delete filter load each equality delete file once; the others wait for that load. When the load failed, the spawned task that stores the set panicked on the dropped sender and left the file marked as loading. Every task that waited for, or later needed, that file then waited forever, and a reader that shares the filter across tasks hung after the first error instead of failing.

A failed equality delete load now removes the file's state and wakes the waiters, which return the existing "Missing equality delete set" error. A later task loads the file again. The spawned task and the drop path of a positional delete load also take a poisoned state lock: a panic there would leave the waiters waiting forever, and in the drop path a panic during unwinding would abort the process.

Upstream issue apache#3274 reports this hang. PR apache#3275 fixed it the same way and was closed without merging on 2026-10-20. The positional counterpart is issue apache#3434 and PR apache#3435, cherry-picked before this commit. One more test here checks that a task waiting for a positional delete load that a failing sibling cancelled loads the file itself, with all its deletes.

Review: `insert_equality_delete` and the end of `get_equality_delete_set_for_delete_file_path` in delete_filter.rs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@dhruvarya-db dhruvarya-db reopened this Oct 28, 2026
@dhruvarya-db

Copy link
Copy Markdown
Contributor Author

Hi @JeonDaehong , thanks for identifying the other gaps! Since this PR is pretty self-contained, it seems reasonable to address them in a follow up. Feel free to create fix PRs.

@dhruvarya-db
dhruvarya-db marked this pull request as ready for review October 28, 2026 17:51
@dhruvarya-db

Copy link
Copy Markdown
Contributor Author

Hi @mbutrovich / @laskoviymishka , can either of you please take a look at this small PR when you get a chance?

…helper

FileScanTaskDeleteFile::builder().build() returns Result since apache#3145.
Signed-off-by: Dhruv Arya <[email protected]>
@JeonDaehong

Copy link
Copy Markdown
Contributor

thanks! I'll send fix PRs for those two once my open ones land, I'm at the open PR limit right now.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

First pass, thanks @dhruvarya-db!

Comment on lines 190 to 195
match self.state.read().unwrap().equality_deletes.get(file_path) {
Some(EqDelState::Loaded(predicate)) => Some(predicate.clone()),
_ => unreachable!("Cannot be any other state than loaded"),
// Woken without a `Loaded` entry: the load we waited on failed (a retry may have
// re-added the entry as `Loading`).
_ => None,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With this change, None after the wakeup means the load failed, and build_equality_delete_predicate turns it into Unexpected => Missing predicate for equality delete file '...'. Only the read that owned the load sees the real cause (Failed to open file ...). A concurrent read through a clone that was waiting on the same file gets the "missing predicate" message, which reads like an internal bug rather than an I/O failure.

Should the message say that the load failed? For example "Equality delete file '{}' failed to load in a concurrent read". As I read it, after load_deletes returns Ok, every equality-delete file of the task was either claimed by that call or already present, so a failed load is the only way to reach None here.

Comment on lines +283 to +287
// The loader dropped the sender without delivering a predicate, which happens
// when the equality-delete file fails to parse and the load returns early. Drop
// the `Loading` marker so the entry is not stuck forever, then wake any waiters:
// on wakeup they find the entry gone and surface an error instead of blocking on
// a notification that would otherwise never fire.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could the comment also cover cancellation? The sender is dropped whenever a FreshEqDel context is dropped before send. That includes a valid file whose load is cancelled because a sibling delete file failed: load_deletes returns on the first item? and drops the try_buffer_unordered stream with the other loads still in flight. Removing the entry rather than caching a failure matters for that case, so it's worth saying.

Suggested change
// The loader dropped the sender without delivering a predicate, which happens
// when the equality-delete file fails to parse and the load returns early. Drop
// the `Loading` marker so the entry is not stuck forever, then wake any waiters:
// on wakeup they find the entry gone and surface an error instead of blocking on
// a notification that would otherwise never fire.
// The loader dropped the sender without sending: the load failed, or was
// cancelled because a sibling delete file failed. Remove the marker so a later
// read retries, and wake waiters so they return `None` instead of hanging.

Comment on lines +722 to +731
#[tokio::test]
async fn test_cloned_readers_error_on_failed_equality_delete_load() {
let tmp_dir = TempDir::new().unwrap();
let (task, _, _) = setup_unreadable_eq_del(tmp_dir.path());
// Clones share one `DeleteFilter`, so the second read sees whatever the first left behind.
let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
let clone = reader.clone();

assert!(read_ids(&reader, &task).await.is_err());
assert!(read_ids(&clone, &task).await.is_err());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does this test reach the waiter path from the description? When I print both errors at the head commit, the clone gets the same Failed to open file .../eq-del.parquet: No such file or directory as the original. As I read it, the failed entry is already removed when the clone reads, so the clone claims the file again and fails on its own open. That's the same path as the second read in test_equality_delete_load_retries_after_failure.

Could we drop this test and do the second read in test_equality_delete_load_retries_after_failure through reader.clone()? That covers the shared DeleteFilter and the retry in one test. test_get_equality_delete_predicate_resolves_when_load_fails already covers a waiter waking to None.

This branch has not been deployed

No deployments
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.

Failed equality-delete load leaves the entry stuck in Loading, hanging later reads

4 participants