Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 212 additions & 25 deletions crates/iceberg/src/arrow/caching_delete_file_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use bytes::Bytes;
use futures::{StreamExt, TryStreamExt};
use tokio::sync::oneshot::{Receiver, channel};

use super::delete_filter::{DeleteFilter, PosDelLoadAction};
use super::delete_filter::{DeleteFilter, PosDelLoadAction, PosDelLoadGuard};
use crate::arrow::delete_file_loader::BasicDeleteFileLoader;
use crate::arrow::scan_metrics::ScanMetrics;
use crate::arrow::{arrow_primitive_to_literal, arrow_schema_to_schema};
Expand Down Expand Up @@ -57,7 +57,7 @@ enum DeleteFileContext {
ExistingEqDel,
ExistingPosDel,
PosDels {
file_path: String,
guard: PosDelLoadGuard,
stream: ArrowRecordBatchStream,
},
FreshEqDel {
Expand All @@ -80,7 +80,7 @@ enum DeleteFileContext {
// results are fully merged into the DeleteFileManager's state
enum ParsedDeleteFileContext {
DelVecs {
file_path: String,
guard: PosDelLoadGuard,
results: HashMap<String, DeleteVector>,
},
// A single deletion vector decoded from a Puffin blob, keyed by the data file it applies to.
Expand Down Expand Up @@ -229,12 +229,12 @@ impl CachingDeleteFileLoader {

while let Some(item) = results_stream.next().await {
match item? {
ParsedDeleteFileContext::DelVecs { file_path, results } => {
ParsedDeleteFileContext::DelVecs { guard, results } => {
for (data_file_path, delete_vector) in results.into_iter() {
del_filter.upsert_delete_vector(data_file_path, delete_vector);
}
// Mark the positional delete file as fully loaded so waiters can proceed
del_filter.finish_pos_del_load(&file_path);
guard.finish();
}
ParsedDeleteFileContext::DelVec {
data_file_path,
Expand Down Expand Up @@ -271,25 +271,31 @@ impl CachingDeleteFileLoader {
return Self::load_deletion_vector(task, basic_delete_file_loader).await;
}

match del_filter.try_start_pos_del_load(task.file_path()) {
PosDelLoadAction::AlreadyLoaded => Ok(DeleteFileContext::ExistingPosDel),
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.
notified.await;
Ok(DeleteFileContext::ExistingPosDel)
loop {
match del_filter.try_start_pos_del_load(task.file_path()) {
PosDelLoadAction::AlreadyLoaded => {
return Ok(DeleteFileContext::ExistingPosDel);
}
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.
// The wait also ends if that load fails, so check again.
notified.await;
}
Comment on lines +279 to +285

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.

Should a woken waiter retry the load, or return the failed load's error? On #2859, @laskoviymishka suggested an RAII guard with waiters that "re-check state and surface the error" (thread). This PR adds the guard and the re-check, but a waiter that finds the file released loads it itself.

As I read it, if K tasks wait on a delete file that keeps failing (corrupt, or a permission error), each one takes a turn at loading it, one after another. Each turn opens the file again, and for a parse error it also reads and parses the file again. load_deletes runs on a spawned task, so those attempts continue after ArrowReader has returned the first error. Retrying does help with the transient failure that #3433 adds. Surfacing the error would need the error stored in the state, because Notify carries no payload and Error isn't Clone. Which behavior do we want here?

@JeonDaehong JeonDaehong Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. I'd keep the retry. Given that #3433 involves potential transient failures (e.g., size stats), surfacing the first error would cause every waiter to fail on something a second attempt could fix.

You're right about the overhead for a file that keeps failing, as K waiters would retry sequentially. However, storing the error in the state would require a larger change since Error isn't Clone. I'd prefer to handle this as a follow-up. Happy to open an issue if you agree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't feel strongly either way, but I'd prefer surfacing the error, having more insights in problem always helpful, as I suggested on #2859.

But If there's a good reason to keep the retry, I can be convinced.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For example, say one delete file is shared by 200 scan tasks and that file is missing or corrupt. With retry, we can end up reopening the same object ~200 times serially before the last task finally fails. If we surface the error, it’s one open and all waiting tasks fail fast.

Maybe a middle ground is to return the error only to the tasks waiting on that attempt, but still clear the entry so later calls can retry, similar to Go’s singleflight.

PosDelLoadAction::Load(guard) => {
// On error the guard is dropped, which releases the file for a
// later attempt.
let stream = basic_delete_file_loader
.parquet_to_batch_stream(
task.file_path(),
task.file_size_in_bytes(),
task.key_metadata(),
)
.await?;
return Ok(DeleteFileContext::PosDels { guard, stream });
}
}
PosDelLoadAction::Load => Ok(DeleteFileContext::PosDels {
file_path: task.file_path().to_string(),
stream: basic_delete_file_loader
.parquet_to_batch_stream(
task.file_path(),
task.file_size_in_bytes(),
task.key_metadata(),
)
.await?,
}),
}
}

Expand Down Expand Up @@ -435,10 +441,10 @@ impl CachingDeleteFileLoader {
match ctx {
DeleteFileContext::ExistingEqDel => Ok(ParsedDeleteFileContext::EqDel),
DeleteFileContext::ExistingPosDel => Ok(ParsedDeleteFileContext::ExistingPosDel),
DeleteFileContext::PosDels { file_path, stream } => {
DeleteFileContext::PosDels { guard, stream } => {
let del_vecs = Self::parse_positional_deletes_record_batch_stream(stream).await?;
Ok(ParsedDeleteFileContext::DelVecs {
file_path,
guard,
results: del_vecs,
})
}
Expand Down Expand Up @@ -1791,4 +1797,185 @@ mod tests {
assert_eq!(err.kind(), ErrorKind::DataInvalid);
assert!(err.message().contains("expected 2 from record_count"));
}

#[tokio::test]
async fn test_pos_del_load_can_be_retried_after_failure() {

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 we add tests that go through the new loop in load_file_for_task? This test takes the Load branch twice in sequence, and test_abandoned_pos_del_load_wakes_waiters_and_can_be_retried stops at try_start_pos_del_load. As I read it, no test takes the WaitFor branch and then comes back around to Load, which is the new behavior here.

On #2859, load_file_for_task was left untested because a deterministic test seemed to need a test-only hook (thread). The guard removes that need. A test can take the Load guard from loader.delete_filter, poll load_file_for_task for the same file to Pending with futures::poll!, drop the guard, and then check that the waiter returns DeleteFileContext::PosDels. The test in #2630 uses the same polling pattern. A variant with the file missing should return the open error.

A second case is a positional delete file that opens but fails to parse (a row with a negative pos), then is rewritten with valid rows and loaded again on the same loader. Here the guard is dropped in parse_file_content_for_task instead of in load_file_for_task, so the open-error test doesn't cover it. On main the second load hangs (I stopped it after 5 seconds).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 6757eb2 :
test_pos_del_waiter_loads_file_after_abandoned_load, test_pos_del_waiter_returns_open_error_after_abandoned_load (both poll the waiter to Pending while holding the guard), and test_pos_del_load_can_be_retried_after_parse_failure. All three time out with the guard's Drop disabled.

let tmp_dir = TempDir::new().unwrap();
let file_scan_tasks = setup(tmp_dir.path());
let task = &file_scan_tasks[0];
let pos_del = task
.deletes()
.iter()
.find(|delete| delete.file_type() == DataContentType::PositionDeletes)
.unwrap()
.clone();
let path = pos_del.file_path().to_string();
let hidden = format!("{path}.hidden");
let load = |loader: &CachingDeleteFileLoader| {
loader.load_deletes(std::slice::from_ref(&pos_del), task.schema_ref())
};

// The first load fails because the file is not there yet.
std::fs::rename(&path, &hidden).unwrap();
let loader = CachingDeleteFileLoader::new(FileIO::new_with_fs(), 10, Runtime::current());
assert!(load(&loader).await.unwrap().is_err());

// Once it is, the same loader loads it instead of waiting on the failed load.
std::fs::rename(&hidden, &path).unwrap();
let retried = tokio::time::timeout(std::time::Duration::from_secs(5), load(&loader))
.await
.expect("retry waited on the failed load")
.unwrap()
.unwrap();
let fresh = load(&CachingDeleteFileLoader::new(
FileIO::new_with_fs(),
10,
Runtime::current(),
))
.await
.unwrap()
.unwrap();

let positions = |filter: &DeleteFilter| -> Vec<u64> {
filter
.get_delete_vector(task)
.unwrap()
.lock()
.unwrap()
.iter()
.collect()
};
assert!(!positions(&fresh).is_empty());
assert_eq!(positions(&retried), positions(&fresh));
}

/// Takes the load of the first test task's positional delete file, then polls
/// `load_file_for_task` for the same file until it waits on that load.
async fn wait_on_pos_del_load(
tmp_dir: &TempDir,
) -> (
PosDelLoadGuard,
std::pin::Pin<Box<impl Future<Output = Result<DeleteFileContext>>>>,
String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: could wait_on_pos_del_load return a small struct instead of the 3-tuple? Only the open-error test uses the path. With named fields, the other caller could skip it with .. instead of _.

) {
let task = setup(tmp_dir.path()).remove(0);
let pos_del = task
.deletes()
.iter()
.find(|delete| delete.file_type() == DataContentType::PositionDeletes)
.unwrap()
.clone();
let loader = CachingDeleteFileLoader::new(FileIO::new_with_fs(), 10, Runtime::current());

let PosDelLoadAction::Load(guard) = loader
.delete_filter
.try_start_pos_del_load(pos_del.file_path())
else {
panic!("expected Load for a new file");
};
let path = pos_del.file_path().to_string();
let mut waiter = Box::pin(async move {
CachingDeleteFileLoader::load_file_for_task(
&pos_del,
loader.basic_delete_file_loader.clone(),
loader.delete_filter.clone(),
task.schema_ref(),
)
.await
});
assert!(futures::poll!(&mut waiter).is_pending());
(guard, waiter, path)
}

#[tokio::test]
async fn test_pos_del_waiter_loads_file_after_abandoned_load() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could the description's case of two concurrent load_deletes calls go in as a test? These tests have one waiter per abandoned load. Nothing covers several waiters waking on one abandon, or a sibling's guard dropped by the item? early return in load_deletes. One way: add a second polled waiter to wait_on_pos_del_load, remove the file before the drop, and both waiters should return the open error.

let tmp_dir = TempDir::new().unwrap();
let (guard, waiter, _) = wait_on_pos_del_load(&tmp_dir).await;

drop(guard);

let ctx = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("waiter did not wake after the load was abandoned")
.unwrap();
assert!(matches!(ctx, DeleteFileContext::PosDels { .. }));
}

#[tokio::test]
async fn test_pos_del_waiter_returns_open_error_after_abandoned_load() {
let tmp_dir = TempDir::new().unwrap();
let (guard, waiter, path) = wait_on_pos_del_load(&tmp_dir).await;

std::fs::remove_file(&path).unwrap();
drop(guard);

let result = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("waiter did not wake after the load was abandoned");
let err = result.err().unwrap();
assert!(err.to_string().contains("Failed to open file"), "{err}");
}

#[tokio::test]
async fn test_pos_del_load_can_be_retried_after_parse_failure() {
let tmp_dir = TempDir::new().unwrap();
let path = tmp_dir.path().join("pos-del.parquet");
let path = path.to_str().unwrap();
let schema = Arc::new(Schema::builder().build().unwrap());
let write = |positions: Vec<i64>| {
let batch = RecordBatch::try_new(
crate::arrow::delete_filter::tests::create_pos_del_schema(),
vec![
Arc::new(StringArray::from_iter_values(vec![
"data.parquet";
positions.len()
])),
Arc::new(Int64Array::from_iter_values(positions)),
],
)
.unwrap();
let mut writer =
ArrowWriter::try_new(File::create(path).unwrap(), batch.schema(), None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
FileScanTaskDeleteFile::builder()
.with_file_path(path.to_string())
.with_file_size_in_bytes(std::fs::metadata(path).unwrap().len())
.with_file_type(DataContentType::PositionDeletes)
.with_file_format(DataFileFormat::Parquet)
.with_partition_spec_id(0)
.build()
.unwrap()
};
let loader = CachingDeleteFileLoader::new(FileIO::new_with_fs(), 10, Runtime::current());

// The file opens but fails to parse, so the load is abandoned after the open.
let invalid = write(vec![-1]);
let err = loader
.load_deletes(&[invalid], Arc::clone(&schema))
.await
.unwrap()
.err()
.unwrap();
assert!(err.message().contains("negative position"), "{err}");

// Once the file is valid, the same loader loads it instead of waiting.
let valid = write(vec![1, 3]);
let filter = tokio::time::timeout(
std::time::Duration::from_secs(5),
loader.load_deletes(&[valid], schema),
)
.await
.expect("retry waited on the failed load")
.unwrap()
.unwrap();
let positions: Vec<u64> = filter
.get_delete_vector_for_path("data.parquet")
.unwrap()
.lock()
.unwrap()
.iter()
.collect();
assert_eq!(positions, vec![1, 3]);
}
}
Loading
Loading