laskoviymishka commented on code in PR #2859:
URL: https://github.com/apache/iceberg-rust/pull/2859#discussion_r3629462968
##########
crates/iceberg/src/arrow/caching_delete_file_loader.rs:
##########
@@ -241,11 +241,11 @@ impl CachingDeleteFileLoader {
DataContentType::PositionDeletes => {
match del_filter.try_start_pos_del_load(&task.file_path) {
PosDelLoadAction::AlreadyLoaded =>
Ok(DeleteFileContext::ExistingPosDel),
- PosDelLoadAction::WaitFor(notify) => {
+ PosDelLoadAction::WaitFor(notified) => {
// Positional deletes are accessed synchronously by
ArrowReader.
// We must wait here to ensure the data is ready
before returning,
// otherwise ArrowReader might get an empty/partial
result.
- notify.notified().await;
+ notified.await;
Review Comment:
One thing this fix makes easier to hit: waiters now reliably park on this
await, but if the load errors before `finish_pos_del_load` runs (e.g.
`parse_positional_deletes_record_batch_stream` yields an `Err` and `item?`
propagates), the state stays `Loading` and everyone parked here hangs forever.
Pre-existing, so not a blocker — but I'd want a `fail_pos_del_load` (or an
RAII guard) that flips to a failed state and still calls `notify_waiters()`,
with waiters re-checking state and surfacing the error. wdyt?
##########
crates/iceberg/src/arrow/delete_filter.rs:
##########
@@ -67,9 +68,11 @@ pub(crate) enum PosDelLoadAction {
/// The file is already loaded, nothing to do.
AlreadyLoaded,
/// The file is currently being loaded by another task.
- /// The caller *must* wait for this notifier to ensure data availability
- /// before returning, as subsequent access (get_delete_vector) is
synchronous.
- WaitFor(Arc<Notify>),
+ /// The caller *must* await this future to ensure data availability before
+ /// returning, as subsequent access (get_delete_vector) is synchronous. The
+ /// future is created under the state lock so it cannot miss the loader's
Review Comment:
I'd tweak this to name the actual load-bearing invariant. What makes it safe
isn't "created under the lock" in the abstract — `notified_owned()` snapshots
the `notify_waiters_calls` counter at construction and resolves on first poll
if that counter advanced. The safety is that the snapshot and the state
transition are guarded by the same lock.
Worth spelling out, because a future refactor that moves `notified_owned()`
off the lock (or switches `try_start_pos_del_load` to a read lock) reads as
harmless but silently reintroduces the race. wdyt?
##########
crates/iceberg/src/arrow/delete_filter.rs:
##########
@@ -127,7 +130,9 @@ impl DeleteFilter {
if let Some(state) = state.positional_deletes.get(file_path) {
match state {
PosDelState::Loaded => return PosDelLoadAction::AlreadyLoaded,
- PosDelState::Loading(notify) => return
PosDelLoadAction::WaitFor(notify.clone()),
+ PosDelState::Loading(notify) => {
+ return
PosDelLoadAction::WaitFor(notify.clone().notified_owned());
Review Comment:
While we're here — the equality-delete path has this exact bug unfixed.
`get_equality_delete_predicate_for_delete_file_path` clones the `Arc<Notify>`
under the read lock, drops it, then does `notifier.notified().await` outside,
so a `notify_waiters()` from `insert_equality_delete` in that window is lost —
same hang.
There's also a second race there: `insert_equality_delete` creates a fresh
`Notify` and overwrites the entry from `try_start_eq_del_load`, so a waiter can
end up parked on a different notifier than the one that gets signalled. Out of
scope for this PR (looks tied to #2696), but the same
`notified_owned()`-under-lock fix would close it. Thoughts?
##########
crates/iceberg/src/arrow/delete_filter.rs:
##########
@@ -296,6 +301,38 @@ pub(crate) mod tests {
const FIELD_ID_POSITIONAL_DELETE_FILE_PATH: u64 = 2147483546;
const FIELD_ID_POSITIONAL_DELETE_POS: u64 = 2147483545;
+ // Regression test for the positional-delete lost-wakeup hang.
+ //
+ // Drives the real API through the losing interleaving: the loader fires
+ // `notify_waiters()` (via `finish_pos_del_load`) *before* the waiter
awaits the notifier
+ // handed back by `WaitFor`. `notify_waiters()` stores no permit, so this
only completes if
+ // the waiter's `Notified` was created before the signal. Because
`WaitFor` now carries an
+ // `OwnedNotified` created under the lock in `try_start_pos_del_load`, it
is; on the old
+ // `WaitFor(Arc<Notify>)` contract the waiter created its `Notified` too
late and hung.
+ #[tokio::test]
+ async fn test_wait_for_completes_when_load_finishes_before_await() {
Review Comment:
This sequences finish-before-await, which is exactly the losing order, so
it's a valid check of the counter-snapshot semantics — good.
It doesn't exercise real concurrency though (single-threaded, serial, no
`spawn`), so it won't catch a future break that only shows up under concurrent
scheduling. I'd either add a second test that spawns the waiter and forces the
interleaving with a barrier / `yield_now`, or drop a line in the comment noting
this validates the mechanism rather than scheduling. Either's fine.
##########
crates/iceberg/src/arrow/delete_filter.rs:
##########
@@ -296,6 +301,38 @@ pub(crate) mod tests {
const FIELD_ID_POSITIONAL_DELETE_FILE_PATH: u64 = 2147483546;
const FIELD_ID_POSITIONAL_DELETE_POS: u64 = 2147483545;
+ // Regression test for the positional-delete lost-wakeup hang.
+ //
+ // Drives the real API through the losing interleaving: the loader fires
+ // `notify_waiters()` (via `finish_pos_del_load`) *before* the waiter
awaits the notifier
+ // handed back by `WaitFor`. `notify_waiters()` stores no permit, so this
only completes if
+ // the waiter's `Notified` was created before the signal. Because
`WaitFor` now carries an
+ // `OwnedNotified` created under the lock in `try_start_pos_del_load`, it
is; on the old
+ // `WaitFor(Arc<Notify>)` contract the waiter created its `Notified` too
late and hung.
+ #[tokio::test]
+ async fn test_wait_for_completes_when_load_finishes_before_await() {
+ let filter = DeleteFilter::new(Runtime::current());
+ let path = "s3://bucket/pos-delete.parquet";
+
+ assert!(matches!(
+ filter.try_start_pos_del_load(path),
+ PosDelLoadAction::Load
+ ));
+
+ let PosDelLoadAction::WaitFor(notified) =
filter.try_start_pos_del_load(path) else {
+ panic!("expected WaitFor for an in-progress load");
+ };
+
+ // Loader completes and signals before the waiter awaits.
+ filter.finish_pos_del_load(path);
+
+ let waited = tokio::time::timeout(std::time::Duration::from_secs(5),
notified).await;
Review Comment:
Two small things on this line. This test needs tokio's `time` (and `macros`
for `#[tokio::test]`), but the workspace pin only declares `sync` +
`rt-multi-thread` — it builds today purely via feature unification from
transitive deps, so if the dep tree ever gets slimmed this fails to compile
with a fairly opaque error. I'd declare them explicitly (workspace tokio
features, or a crate-level `tokio = { workspace = true, features = ["time"] }`).
Also, 5s is a lot here — on the fixed path the first poll resolves
synchronously and a real regression hangs indefinitely, so this only ever burns
CI wall-clock when something's broken. I'd drop it to 100–500ms.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]