Repository navigation
fix(arrow): resolve eq-delete waiters when a delete file fails to load - #3275
dhruvarya-db wants to merge 6 commits into
Conversation
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]>
Signed-off-by: Dhruv Arya <[email protected]>
Signed-off-by: Dhruv Arya <[email protected]>
…retry Signed-off-by: Dhruv Arya <[email protected]>
|
Thanks, handling the dropped sender looks right to me. While checking whether a waiter can still hang, I found two more scenarios on
I reproduced both on this branch (for 2, I added a small sleep between the read lock and 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()); |
…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]>
…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]>
|
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. |
|
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]>
|
thanks! I'll send fix PRs for those two once my open ones land, I'm at the open PR limit right now. |
mbutrovich
left a comment
There was a problem hiding this comment.
First pass, thanks @dhruvarya-db!
| 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, | ||
| } |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.
| // 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. |
| #[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()); |
There was a problem hiding this comment.
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.
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
Loadingstate insideDeleteFilter. Any task that later waits on that entry blocksforever 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 theNotify.EqDelState::Loaded(Predicate)— the predicate is ready.The producer side (
insert_equality_delete) marks the entryLoading, then spawns a taskthat waits on a
oneshotchannel for the parsed predicate, stores it asLoaded, and callsnotify_waiters(). The parsed predicate is sent from the delete-file loader(
caching_delete_file_loader.rs, theFreshEqDelbranch).The consumer side (
get_equality_delete_predicate_for_delete_file_path) reads the entry; ifit is
Loading, it waits on theNotifyuntil the producer signals completion.The bug
The spawned producer task did:
The predicate is sent here (
caching_delete_file_loader.rs):If parsing the delete file fails, the
?returns early beforesender.send(...),dropping the sender. On the producer task,
eq_del.awaitthen resolves toErr(RecvError),.unwrap()panics, and the two lines that follow — theLoadedinsert andnotify_waiters()— never run. The entry is stranded inLoading, and itsNotifyisnever 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 🔒State of the map entry:
When it actually hangs
Within a single
load_deletescall the parse error also propagates up the results streamvia
?, which tears down that call and cancels its sibling tasks — so the stuck entry isusually not observed there. The hang bites when a
DeleteFilteris shared acrossconcurrent or sequential loads (an
ArrowReaderisCloneand its clones share oneloader/filter, and many data files typically reference the same equality-delete file):
E, marks itLoading, and fails to parse it.Eis never signalled and never leavesLoading.E, sees it already claimed, and waits on itsNotify.Secondary effects: the runtime task panics on an ordinary I/O/parse error, and a reused
ArrowReaderstays poisoned for that file across subsequent scans.The fix
Handle the dropped sender instead of unwrapping it:
Ok(predicate): unchanged — storeLoadedandnotify_waiters().Err(_): remove the staleLoadingmarker, thennotify_waiters().Waiters that wake to a missing entry now return
None(the post-awaitmatch no longertreats that as
unreachable!).build_equality_delete_predicateturns thatNoneinto aclear
"Missing predicate for equality delete file '…'"error, so the failure is reportedrather 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 ✅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 onDeleteFilter. It drops the predicate sender without sending, which is what a failed parse does, and checks that the waiter getsNone.test_cloned_readers_error_on_failed_equality_delete_load: builds oneArrowReader, 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 oneDeleteFilter, 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]minus3), 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.