mbutrovich commented on code in PR #3435:
URL: https://github.com/apache/iceberg-rust/pull/3435#discussion_r4444225222
##########
crates/iceberg/src/arrow/delete_filter.rs:
##########
@@ -61,18 +61,86 @@ pub(crate) struct DeleteFilter {
runtime: Runtime,
}
+/// One attempt to load a positional delete file, shared with the tasks
waiting on it.
+#[derive(Debug, Default)]
+pub(crate) struct PosDelAttempt {
+ notify: Arc<Notify>,
+ /// Set when the load fails, so the tasks waiting on it get the error
instead of
+ /// each opening the file again.
+ failure: OnceLock<(ErrorKind, String)>,
+}
+
+/// A wait on another task's positional delete load, from
[`PosDelLoadAction::WaitFor`].
+pub(crate) struct PosDelWait {
+ notified: OwnedNotified,
+ attempt: Arc<PosDelAttempt>,
+}
+
+impl PosDelWait {
+ /// Waits for the load to end. Returns its error if it failed. `Ok` means
it finished
+ /// or was dropped without an error, so the caller must call
+ /// [`DeleteFilter::try_start_pos_del_load`] again rather than assume the
file is loaded.
+ pub(crate) async fn wait(self) -> Result<()> {
+ self.notified.await;
+ match self.attempt.failure.get() {
+ Some((kind, message)) => Err(Error::new(*kind, message.clone())),
+ None => Ok(()),
+ }
+ }
+}
+
/// Action to take when trying to start loading a positional delete file
pub(crate) enum PosDelLoadAction {
- /// The file is not loaded, the caller should load it.
- Load,
+ /// The file is not loaded, the caller should load it and call
+ /// [`PosDelLoadGuard::finish`] once its delete vectors are merged.
+ Load(PosDelLoadGuard),
/// The file is already loaded, nothing to do.
AlreadyLoaded,
/// The file is currently being loaded by another task.
- /// The caller *must* await this future to ensure data availability before
+ /// The caller *must* await [`PosDelWait::wait`] 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
+ /// wait is created under the state lock so it cannot miss the loader's
/// `notify_waiters()` (which stores no permit).
- WaitFor(OwnedNotified),
+ WaitFor(PosDelWait),
+}
+
+/// Ownership of an in-progress positional delete load, returned by
+/// [`DeleteFilter::try_start_pos_del_load`].
+///
+/// [`finish`](Self::finish) marks the file loaded. [`fail`](Self::fail) hands
the error to
+/// the tasks waiting on this attempt. Dropping the guard without finishing
removes the
+/// `Loading` marker and wakes the waiters, so a later attempt can load the
file instead of
+/// waiting forever.
+pub(crate) struct PosDelLoadGuard {
+ filter: DeleteFilter,
+ file_path: String,
+ attempt: Arc<PosDelAttempt>,
+ finished: bool,
+}
+
+impl PosDelLoadGuard {
+ /// Marks the file as loaded and wakes any waiters.
+ pub(crate) fn finish(mut self) {
+ self.finished = true;
+ self.filter.finish_pos_del_load(&self.file_path);
+ }
+
+ /// Ends the load with `error`, which the tasks waiting on it return.
Later calls to
+ /// [`DeleteFilter::try_start_pos_del_load`] start a new attempt.
+ pub(crate) fn fail(self, error: &Error) {
+ let _ = self
+ .attempt
+ .failure
+ .set((error.kind(), error.message().to_string()));
+ }
Review Comment:
Should the waiters get the whole error, including its context and source?
`fail` keeps only `kind` and `message`, so a waiter's error drops both. With
the OpenDAL storage, the cause of an I/O failure is in the source:
[`from_opendal_error`](https://github.com/apache/iceberg-rust/blob/896f9d6c50ff52fa9e43aec170d3f14bfb40b512/crates/storage/opendal/src/utils.rs#L23-L29)
sets the message to "Failure in doing io operation" and puts the OpenDAL error
in `with_source`. I failed a guard with an error built that way and printed
what the leader and a waiter return:
```
leader: Unexpected, context: { path: s3://bucket/pos-delete.parquet } =>
Failure in doing io operation, source: 403 Forbidden
waiter: Unexpected => Failure in doing io operation
```
In the 200-task example from the earlier thread, 199 tasks return the second
line. With more than one data file in flight, the scan [buffers tasks
unordered](https://github.com/apache/iceberg-rust/blob/896f9d6c50ff52fa9e43aec170d3f14bfb40b512/crates/iceberg/src/arrow/reader/pipeline.rs#L97-L104),
so as I read it the error a user sees can be a waiter's, with no path and no
cause.
Keeping the `Display` output as the message carries both. The waiter's error
then starts with the kind twice (`DataInvalid => DataInvalid => bad file`), and
the two tests that compare `err.message()` exactly would check with `contains`
instead. Could we also add a source to the error in
`test_failed_pos_del_load_returns_its_error_to_waiters` and check that the
waiter's error mentions it?
```suggestion
pub(crate) fn fail(self, error: &Error) {
let _ = self
.attempt
.failure
.set((error.kind(), error.to_string()));
}
```
##########
crates/iceberg/src/arrow/caching_delete_file_loader.rs:
##########
@@ -1791,4 +1808,394 @@ 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() {
+ 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));
+ }
+
+ /// The load of a positional delete file taken by the test, and tasks
polled until
+ /// they wait on it.
+ struct PendingPosDelLoad {
+ guard: PosDelLoadGuard,
+ waiters: Vec<futures::future::BoxFuture<'static,
Result<DeleteFileContext>>>,
+ path: String,
+ }
+
+ /// Takes the load of the first test task's positional delete file, then
polls
+ /// `load_file_for_task` for the same file from `waiters` tasks until each
waits on it.
+ async fn wait_on_pos_del_load(tmp_dir: &TempDir, waiters: usize) ->
PendingPosDelLoad {
+ 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 mut pending = Vec::with_capacity(waiters);
+ for _ in 0..waiters {
+ let (pos_del, schema) = (pos_del.clone(), task.schema_ref());
+ let (basic, filter) = (
+ loader.basic_delete_file_loader.clone(),
+ loader.delete_filter.clone(),
+ );
+ let mut waiter: futures::future::BoxFuture<'static, _> =
Box::pin(async move {
+ CachingDeleteFileLoader::load_file_for_task(&pos_del, basic,
filter, schema).await
+ });
+ assert!(futures::poll!(&mut waiter).is_pending());
+ pending.push(waiter);
+ }
+ PendingPosDelLoad {
+ guard,
+ waiters: pending,
+ path: pos_del.file_path().to_string(),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_pos_del_waiter_loads_file_after_cancelled_load() {
+ let tmp_dir = TempDir::new().unwrap();
+ let PendingPosDelLoad {
+ guard, mut waiters, ..
+ } = wait_on_pos_del_load(&tmp_dir, 1).await;
+
+ // Dropped without an error, as when a sibling's error stops
`load_deletes`.
+ drop(guard);
+
+ let ctx = tokio::time::timeout(std::time::Duration::from_secs(5),
waiters.remove(0))
+ .await
+ .expect("waiter did not wake after the load was cancelled")
+ .unwrap();
+ assert!(matches!(ctx, DeleteFileContext::PosDels { .. }));
+ }
+
+ #[tokio::test]
+ async fn test_pos_del_waiters_return_the_error_of_a_failed_load() {
+ let tmp_dir = TempDir::new().unwrap();
+ let PendingPosDelLoad { guard, waiters, .. } =
wait_on_pos_del_load(&tmp_dir, 2).await;
+
+ // The file is still there, so a waiter that opened it again would
succeed.
+ guard.fail(&Error::new(ErrorKind::Unexpected, "load failed"));
+
+ for waiter in waiters {
+ let err = tokio::time::timeout(std::time::Duration::from_secs(5),
waiter)
+ .await
+ .expect("waiter did not wake after the load failed")
+ .err()
+ .unwrap();
+ assert_eq!(err.kind(), ErrorKind::Unexpected);
+ assert_eq!(err.message(), "load failed");
+ }
+ }
+
+ #[tokio::test]
+ async fn test_pos_del_waiters_retry_after_cancelled_load() {
+ let tmp_dir = TempDir::new().unwrap();
+ let PendingPosDelLoad {
+ guard,
+ waiters,
+ path,
+ } = wait_on_pos_del_load(&tmp_dir, 2).await;
+
+ // Each waiter loads the file itself after a cancelled load, and gets
its own error.
+ std::fs::remove_file(&path).unwrap();
+ drop(guard);
+
+ for waiter in waiters {
+ let err = tokio::time::timeout(std::time::Duration::from_secs(5),
waiter)
+ .await
+ .expect("waiter did not wake after the load was cancelled")
+ .err()
+ .unwrap();
+ assert!(err.to_string().contains("Failed to open file"), "{err}");
+ }
+ }
+
+ /// Local filesystem storage that yields before every read, so the loads
of several
+ /// delete files in one `load_deletes` call start together, and holds
reads of
+ /// `gated.parquet` until the gate is opened, so one of them is still in
flight when
+ /// another fails. `LocalFsStorage` never yields, so on the current-thread
test
+ /// runtime each load would otherwise finish before the next one starts.
+ mod gated_storage {
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicBool, Ordering};
+
+ use bytes::Bytes;
+ use futures::stream::BoxStream;
+
+ use crate::Result;
+ use crate::io::{
+ FileMetadata, FileRead, FileWrite, InputFile, LocalFsStorage,
OutputFile, Storage,
+ StorageConfig, StorageFactory,
+ };
+
+ #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+ pub(super) struct GatedStorage {
+ #[serde(skip)]
+ inner: LocalFsStorage,
+ #[serde(skip)]
+ open: Arc<AtomicBool>,
+ }
+
+ impl GatedStorage {
+ async fn wait_for_gate(&self, path: &str) {
+ tokio::task::yield_now().await;
+ while path.ends_with("gated.parquet") &&
!self.open.load(Ordering::SeqCst) {
+ tokio::task::yield_now().await;
+ }
+ }
+ }
+
+ #[async_trait::async_trait]
+ #[typetag::serde(name = "test_gated_storage")]
+ impl Storage for GatedStorage {
+ async fn metadata(&self, path: &str) -> Result<FileMetadata> {
+ self.inner.metadata(path).await
+ }
+
+ fn new_input(&self, path: &str) -> Result<InputFile> {
+ Ok(InputFile::new(Arc::new(self.clone()), path.to_string()))
+ }
+
+ fn new_output(&self, path: &str) -> Result<OutputFile> {
+ Ok(OutputFile::new(Arc::new(self.clone()), path.to_string()))
+ }
+
+ async fn exists(&self, path: &str) -> Result<bool> {
+ self.inner.exists(path).await
+ }
+
+ async fn read(&self, path: &str) -> Result<Bytes> {
+ self.wait_for_gate(path).await;
+ self.inner.read(path).await
+ }
+
+ async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
+ self.wait_for_gate(path).await;
+ self.inner.reader(path).await
+ }
+
+ async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
+ self.inner.write(path, bs).await
+ }
+
+ async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
+ self.inner.writer(path).await
+ }
+
+ async fn delete(&self, path: &str) -> Result<()> {
+ self.inner.delete(path).await
+ }
+
+ async fn delete_prefix(&self, path: &str) -> Result<()> {
+ self.inner.delete_prefix(path).await
+ }
+
+ async fn delete_stream(&self, paths: BoxStream<'static, String>)
-> Result<()> {
+ self.inner.delete_stream(paths).await
+ }
+ }
+
+ #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+ pub(super) struct GatedStorageFactory {
+ #[serde(skip)]
+ pub(super) open: Arc<AtomicBool>,
+ }
+
+ #[typetag::serde(name = "test_gated_storage_factory")]
+ impl StorageFactory for GatedStorageFactory {
+ fn build(&self, _config: &StorageConfig) -> Result<Arc<dyn
Storage>> {
+ Ok(Arc::new(GatedStorage {
+ inner: LocalFsStorage,
+ open: Arc::clone(&self.open),
+ }))
+ }
+ }
+ }
+
+ /// A failing positional delete file ends `load_deletes` early while the
load of the
+ /// other file in the same call is still in flight, dropping its guard.
That file
+ /// still loads later.
+ #[tokio::test]
+ async fn
test_pos_del_load_dropped_by_a_failing_sibling_can_be_loaded_later() {
+ let tmp_dir = TempDir::new().unwrap();
+ let schema = Arc::new(Schema::builder().build().unwrap());
+ let write = |name: &str, positions: Vec<i64>| {
+ let path = tmp_dir.path().join(name);
+ let path = path.to_str().unwrap().to_string();
+ 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.clone())
+
.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()
+ };
Review Comment:
Could this closure and the one in
`test_pos_del_load_can_be_retried_after_parse_failure` (lines 2145-2169) become
one test helper, such as `fn write_pos_del_file(path: &str, positions:
Vec<i64>) -> FileScanTaskDeleteFile`? They write the same batch and build the
same `FileScanTaskDeleteFile`, and differ only in how the path is formed. One
helper keeps the two tests' fixtures from drifting apart.
--
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]