mbutrovich commented on code in PR #3433:
URL: https://github.com/apache/iceberg-rust/pull/3433#discussion_r4259496649


##########
crates/iceberg/src/arrow/caching_delete_file_loader.rs:
##########
@@ -1129,6 +1129,330 @@ mod tests {
         assert!(result.is_none()); // no pos dels for file 3
     }
 
+    /// Local filesystem storage that counts metadata requests.
+    #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+    struct MetadataCountingStorage {
+        #[serde(skip)]
+        calls: Arc<std::sync::atomic::AtomicUsize>,
+        /// Fail every metadata request with a retryable error.
+        #[serde(skip)]
+        fail_metadata: bool,
+    }
+
+    #[async_trait::async_trait]
+    #[typetag::serde(name = "test_metadata_counting_storage")]
+    impl crate::io::Storage for MetadataCountingStorage {
+        async fn exists(&self, path: &str) -> Result<bool> {
+            crate::io::LocalFsStorage::new().exists(path).await
+        }
+
+        async fn metadata(&self, path: &str) -> 
Result<crate::io::FileMetadata> {
+            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+            if self.fail_metadata {
+                return Err(
+                    Error::new(ErrorKind::Unexpected, "metadata temporarily 
unavailable")
+                        .with_retryable(true),
+                );
+            }
+            crate::io::LocalFsStorage::new().metadata(path).await
+        }
+
+        async fn read(&self, path: &str) -> Result<Bytes> {
+            crate::io::LocalFsStorage::new().read(path).await
+        }
+
+        async fn reader(&self, path: &str) -> Result<Box<dyn 
crate::io::FileRead>> {
+            crate::io::LocalFsStorage::new().reader(path).await
+        }
+
+        async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
+            crate::io::LocalFsStorage::new().write(path, bs).await
+        }
+
+        async fn writer(&self, path: &str) -> Result<Box<dyn 
crate::io::FileWrite>> {
+            crate::io::LocalFsStorage::new().writer(path).await
+        }
+
+        async fn delete(&self, path: &str) -> Result<()> {
+            crate::io::LocalFsStorage::new().delete(path).await
+        }
+
+        async fn delete_prefix(&self, path: &str) -> Result<()> {
+            crate::io::LocalFsStorage::new().delete_prefix(path).await
+        }
+
+        async fn delete_stream(
+            &self,
+            paths: futures::stream::BoxStream<'static, String>,
+        ) -> Result<()> {
+            crate::io::LocalFsStorage::new().delete_stream(paths).await
+        }
+
+        fn new_input(&self, path: &str) -> Result<crate::io::InputFile> {
+            Ok(crate::io::InputFile::new(
+                Arc::new(self.clone()),
+                path.to_string(),
+            ))
+        }
+
+        fn new_output(&self, path: &str) -> Result<crate::io::OutputFile> {
+            Ok(crate::io::OutputFile::new(
+                Arc::new(self.clone()),
+                path.to_string(),
+            ))
+        }
+    }
+
+    #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+    struct MetadataCountingStorageFactory {
+        #[serde(skip)]
+        calls: Arc<std::sync::atomic::AtomicUsize>,
+        #[serde(skip)]
+        fail_metadata: bool,
+    }
+
+    #[typetag::serde(name = "test_metadata_counting_storage_factory")]
+    impl crate::io::StorageFactory for MetadataCountingStorageFactory {
+        fn build(&self, _config: &crate::io::StorageConfig) -> Result<Arc<dyn 
crate::io::Storage>> {
+            Ok(Arc::new(MetadataCountingStorage {
+                calls: Arc::clone(&self.calls),
+                fail_metadata: self.fail_metadata,
+            }))
+        }
+    }

Review Comment:
   Could the test storage import `LocalFsStorage`, `AtomicUsize`, `Ordering` 
and the other `crate::io` types at the top of the test module, and hold one 
`LocalFsStorage` in a field? Each method currently builds a new 
`crate::io::LocalFsStorage::new()` behind a fully qualified path, which makes 
the 90 lines harder to scan for the two methods that differ (`metadata` and the 
constructors).



##########
crates/iceberg/src/scan/task.rs:
##########
@@ -612,7 +618,8 @@ mod _serde {
     #[derive(Deserialize)]
     pub(super) struct FileScanTaskDeleteFileSerde {
         file_path: String,
-        file_size_in_bytes: u64,
+        #[serde(default)]
+        file_size_in_bytes: Option<u64>,

Review Comment:
   Could we add a serde test for the unknown size? A serialized task without 
`file_size_in_bytes` used to fail to deserialize and now produces `None`, and 
`skip_serializing_if` drops the field on the way out. A round trip of a task 
with `None`, next to 
`test_deserialized_deletion_vector_rejects_missing_fields`, would cover both 
directions.



##########
crates/iceberg/src/arrow/delete_file_loader.rs:
##########
@@ -66,13 +66,45 @@ impl BasicDeleteFileLoader {
     pub(crate) async fn parquet_to_batch_stream(
         &self,
         data_file_path: &str,
-        file_size_in_bytes: u64,
+        file_size_in_bytes: Option<u64>,
         key_metadata: Option<&[u8]>,
     ) -> Result<ArrowRecordBatchStream> {
         /*
            Essentially a super-cut-down ArrowReader. We can't use ArrowReader 
directly
            as that introduces a circular dependency.
         */
+        // `None` means the task did not carry a size (see 
`FileScanTaskDeleteFile`).
+        // Resolve it when the file is first loaded.
+        let file_size_in_bytes = match file_size_in_bytes {
+            Some(file_size_in_bytes) => file_size_in_bytes,
+            None => {
+                self.file_io
+                    .new_input(data_file_path)?
+                    .metadata()
+                    .await
+                    .map_err(|error| {
+                        // Keep the storage error's kind and retryability.
+                        Error::new(
+                            error.kind(),
+                            format!("Failed to stat delete file 
'{data_file_path}'"),
+                        )
+                        .with_retryable(error.retryable())
+                        .with_source(error)
+                    })?
+                    .size
+            }
+        };
+        // Reading a Parquet footer needs at least its last eight bytes. Reject
+        // a smaller size rather than read the file as if it had no deletes.
+        if file_size_in_bytes < 8 {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "Delete file '{data_file_path}' has a size of 
{file_size_in_bytes} bytes, \
+                     below the Parquet footer minimum"
+                ),
+            ));
+        }

Review Comment:
   > A size below the Parquet footer minimum, including `Some(0)`, is rejected 
instead of dropping the file's deletes.
   
   Which path reads a sub-footer size as a file with no deletes? Parquet 
already rejects a size below its footer in 
[`ParquetMetaDataReader`](https://github.com/apache/arrow-rs/blob/f90e061326bd821a7af09281d9e92de6f3b603d9/parquet/src/file/metadata/reader.rs#L588-L590).
 With this check disabled on the head commit, `None` on an empty file, 
`Some(0)` and `Some(4)` each fail with `Failed to load Parquet metadata, 
source: EOF: file size of N is less than footer`. A 10-byte file passes this 
check and then fails in Parquet with `Corrupt footer`, so the check doesn't 
define the minimum either.
   
   What do you think about removing it and relying on Parquet's error? The 
`footer minimum` assertions in 
`unknown_delete_size_still_reports_missing_and_empty_objects` would then check 
for Parquet's error instead. If the `DataInvalid` kind matters to callers, 
could we compare against `parquet::file::FOOTER_SIZE` rather than the literal 
`8`?



##########
crates/iceberg/src/scan/task.rs:
##########
@@ -366,8 +366,14 @@ pub struct FileScanTaskDeleteFile {
     /// The delete file path
     file_path: String,
 
-    /// The total size of the delete file in bytes, from the manifest entry.
-    file_size_in_bytes: u64,
+    /// The total size of the delete file in bytes. Tasks planned from 
manifest entries carry
+    /// the recorded size. An externally constructed task may leave it unset 
when the size is
+    /// unavailable; the Parquet delete-file loader then resolves it with one 
metadata request
+    /// when the file is first loaded. Deletion vectors do not use it.

Review Comment:
   Should this doc also cover a recorded size that can't be trusted? A task 
planned on the JVM always has `fileSizeInBytes()`, since 
[`file_size_in_bytes`](https://github.com/apache/iceberg/blob/db43b88a6c0d470bd4f8acf08a18328750aeed20/format/spec.md?plain=1#L725)
 is required in every format version. The case I know of is 
apache/iceberg#12554: `rewrite_table_path` before Iceberg Java 1.12.0 left the 
old size in the manifest after rewriting position delete files, and an engine 
that wants to read those tables has to ignore the recorded size. Saying so here 
would tell engine authors when to leave it unset.



##########
crates/iceberg/src/arrow/delete_file_loader.rs:
##########
@@ -66,13 +66,45 @@ impl BasicDeleteFileLoader {
     pub(crate) async fn parquet_to_batch_stream(
         &self,
         data_file_path: &str,
-        file_size_in_bytes: u64,
+        file_size_in_bytes: Option<u64>,
         key_metadata: Option<&[u8]>,
     ) -> Result<ArrowRecordBatchStream> {
         /*
            Essentially a super-cut-down ArrowReader. We can't use ArrowReader 
directly
            as that introduces a circular dependency.
         */
+        // `None` means the task did not carry a size (see 
`FileScanTaskDeleteFile`).
+        // Resolve it when the file is first loaded.
+        let file_size_in_bytes = match file_size_in_bytes {
+            Some(file_size_in_bytes) => file_size_in_bytes,
+            None => {
+                self.file_io
+                    .new_input(data_file_path)?
+                    .metadata()
+                    .await
+                    .map_err(|error| {
+                        // Keep the storage error's kind and retryability.
+                        Error::new(
+                            error.kind(),
+                            format!("Failed to stat delete file 
'{data_file_path}'"),
+                        )
+                        .with_retryable(error.retryable())

Review Comment:
   What should a caller do with a retryable error here? On the head commit, I 
loaded a position delete file of unknown size whose path didn't exist yet, so 
`load_deletes` failed with `Failed to stat delete file`. After creating the 
file, a second `load_deletes` for the same delete file on the same 
`CachingDeleteFileLoader` never completed (I stopped it after 3 seconds). A new 
loader read it fine.
   
   As I read it, this predates the PR, and #3345's description listed it under 
"Not in this PR". 
[`try_start_pos_del_load`](https://github.com/apache/iceberg-rust/blob/b154603a710d730d2f43f8af4db257cc137237ef/crates/iceberg/src/arrow/delete_filter.rs#L127-L145)
 marks the file `Loading`, and only the success path calls 
`finish_pos_del_load`, so a failed load leaves it `Loading` and later callers 
wait on a notification that never comes. I see the same hang with a known size 
when the open fails. Since 
[`ArrowReader`](https://github.com/apache/iceberg-rust/blob/b154603a710d730d2f43f8af4db257cc137237ef/crates/iceberg/src/arrow/reader/mod.rs#L168-L172)
 clones share this state, retrying `read` on a clone of the same reader would 
hang too. #3274 tracks the equality-delete half, and #3275 fixes only that 
half. This PR adds a new retryable failure on the positional path, so could we 
file an issue for the positional half and link it here?



-- 
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]

Reply via email to