Repository navigation
fix(arrow): release a positional delete file whose load fails #3435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
deff41b
6532513
6757eb2
faa71d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}; | ||
|
|
@@ -57,7 +57,7 @@ enum DeleteFileContext { | |
| ExistingEqDel, | ||
| ExistingPosDel, | ||
| PosDels { | ||
| file_path: String, | ||
| guard: PosDelLoadGuard, | ||
| stream: ArrowRecordBatchStream, | ||
| }, | ||
| FreshEqDel { | ||
|
|
@@ -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. | ||
|
|
@@ -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, | ||
|
|
@@ -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; | ||
| } | ||
| 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?, | ||
| }), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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, | ||
| }) | ||
| } | ||
|
|
@@ -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() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we add tests that go through the new loop in On #2859, A second case is a positional delete file that opens but fails to parse (a row with a negative
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 6757eb2 : |
||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: could |
||
| ) { | ||
| 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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could the description's case of two concurrent |
||
| 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]); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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_deletesruns on a spawned task, so those attempts continue afterArrowReaderhas 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, becauseNotifycarries no payload andErrorisn'tClone. Which behavior do we want here?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
Errorisn't Clone. I'd prefer to handle this as a follow-up. Happy to open an issue if you agree.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.