This is an automated email from the ASF dual-hosted git repository.

CTTY pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 94ceaf912 feat(writer): Support ObjectStorageLocationGenerator (#2871)
94ceaf912 is described below

commit 94ceaf9127fbb11dc85eb661c061b59b5f31c7ae
Author: hsiang-c <[email protected]>
AuthorDate: Tue Aug 4 14:54:20 2026 -0700

    feat(writer): Support ObjectStorageLocationGenerator (#2871)
    
    ## Which issue does this PR close?
    
    <!--
    We generally require a GitHub issue to be filed for all bug fixes and
    enhancements and this helps us generate change logs for our releases.
    You can link an issue to this PR using the GitHub syntax. For example
    `Closes #123` indicates that this PR will close issue #123.
    -->
    
    - Closes #2870
    
    ## What changes are included in this PR?
    
    <!--
    Provide a summary of the modifications in this PR. List the main changes
    such as new features, bug fixes, refactoring, or any other updates.
    -->
    - Introduce `ObjectStorageLocationGenerator`, another implementation to
    `LocationGenerator` that generates a deterministic hash for each stored
    file and appends the hash directly after the `write.data.path`. The
    equivalent class in Iceberg Java is [ObjectStoreLocationProvider
    
](https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/core/src/main/java/org/apache/iceberg/LocationProviders.java#L128)
    - I didn't make `ObjectStorageLocationGenerator` configurable yet. Plan
    to do it in the follow-up PR.
    
    ## Are these changes tested?
    
    <!--
    Specify what test covers (unit test, integration test, etc.).
    
    If tests are not included in your PR, please explain why (for example,
    are they covered by existing tests)?
    -->
    
    Unit tests
    
    ---------
    
    Co-authored-by: Daniel Carl Jones <[email protected]>
---
 crates/iceberg/public-api.txt                      |  21 ++
 crates/iceberg/src/spec/table_properties.rs        | 116 +++++--
 crates/iceberg/src/util/location.rs                |  44 +++
 crates/iceberg/src/util/mod.rs                     |   2 +
 .../src/writer/file_writer/location_generator.rs   | 384 ++++++++++++++++++---
 5 files changed, 491 insertions(+), 76 deletions(-)

diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index d88ffa1b9..1f2829878 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -2764,9 +2764,13 @@ pub iceberg::spec::TableProperties::max_ref_age_ms: i64
 pub iceberg::spec::TableProperties::max_snapshot_age_ms: i64
 pub iceberg::spec::TableProperties::metadata_compression_codec: 
iceberg::compression::CompressionCodec
 pub iceberg::spec::TableProperties::min_snapshots_to_keep: usize
+pub iceberg::spec::TableProperties::write_data_location: 
core::option::Option<alloc::string::String>
 pub iceberg::spec::TableProperties::write_datafusion_fanout_enabled: bool
+pub iceberg::spec::TableProperties::write_folder_storage_location: 
core::option::Option<alloc::string::String>
 pub iceberg::spec::TableProperties::write_format_default: alloc::string::String
 pub iceberg::spec::TableProperties::write_metadata_path: 
core::option::Option<alloc::string::String>
+pub iceberg::spec::TableProperties::write_object_storage_location: 
core::option::Option<alloc::string::String>
+pub iceberg::spec::TableProperties::write_object_storage_partitioned_paths: 
bool
 pub iceberg::spec::TableProperties::write_target_file_size_bytes: usize
 impl iceberg::spec::TableProperties
 pub const iceberg::spec::TableProperties::PROPERTY_COMMIT_MAX_RETRY_WAIT_MS: 
&str
@@ -2814,7 +2818,12 @@ pub const 
iceberg::spec::TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL: &str
 pub const 
iceberg::spec::TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL_DEFAULT: i32
 pub const iceberg::spec::TableProperties::PROPERTY_SNAPSHOT_COUNT: &str
 pub const iceberg::spec::TableProperties::PROPERTY_UUID: &str
+pub const iceberg::spec::TableProperties::PROPERTY_WRITE_DATA_LOCATION: &str
+pub const 
iceberg::spec::TableProperties::PROPERTY_WRITE_FOLDER_STORAGE_LOCATION: &str
 pub const iceberg::spec::TableProperties::PROPERTY_WRITE_METADATA_PATH: &str
+pub const 
iceberg::spec::TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_LOCATION: &str
+pub const 
iceberg::spec::TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS:
 &str
+pub const 
iceberg::spec::TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS_DEFAULT:
 bool
 pub const 
iceberg::spec::TableProperties::PROPERTY_WRITE_PARTITION_SUMMARY_LIMIT: &str
 pub const 
iceberg::spec::TableProperties::PROPERTY_WRITE_PARTITION_SUMMARY_LIMIT_DEFAULT: 
u64
 pub const 
iceberg::spec::TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES: &str
@@ -3181,6 +3190,7 @@ pub fn 
iceberg::transform::TransformFunction::transform_literal_result(&self, in
 pub fn iceberg::transform::create_transform_function(transform: 
&iceberg::spec::Transform) -> 
iceberg::Result<iceberg::transform::BoxedTransformFunction>
 pub type iceberg::transform::BoxedTransformFunction = alloc::boxed::Box<dyn 
iceberg::transform::TransformFunction>
 pub mod iceberg::util
+pub mod iceberg::util::location
 pub mod iceberg::util::snapshot
 pub fn iceberg::util::snapshot::ancestors_between(table_metadata: 
&iceberg::spec::TableMetadataRef, latest_snapshot_id: i64, oldest_snapshot_id: 
core::option::Option<i64>) -> impl core::iter::traits::iterator::Iterator<Item 
= iceberg::spec::SnapshotRef> + core::marker::Send
 pub fn iceberg::util::snapshot::ancestors_of(table_metadata: 
&iceberg::spec::TableMetadataRef, snapshot_id: i64) -> impl 
core::iter::traits::iterator::Iterator<Item = iceberg::spec::SnapshotRef> + 
core::marker::Send
@@ -3247,6 +3257,15 @@ impl core::fmt::Debug for 
iceberg::writer::file_writer::location_generator::Defa
 pub fn 
iceberg::writer::file_writer::location_generator::DefaultLocationGenerator::fmt(&self,
 f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result
 impl iceberg::writer::file_writer::location_generator::LocationGenerator for 
iceberg::writer::file_writer::location_generator::DefaultLocationGenerator
 pub fn 
iceberg::writer::file_writer::location_generator::DefaultLocationGenerator::generate_location(&self,
 partition_key: core::option::Option<&iceberg::spec::PartitionKey>, file_name: 
&str) -> alloc::string::String
+pub struct 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator
+impl 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator
+pub fn 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator::new(table_metadata:
 &iceberg::spec::TableMetadata) -> iceberg::Result<Self>
+impl core::clone::Clone for 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator
+pub fn 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator::clone(&self)
 -> 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator
+impl core::fmt::Debug for 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator
+pub fn 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator::fmt(&self,
 f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl iceberg::writer::file_writer::location_generator::LocationGenerator for 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator
+pub fn 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator::generate_location(&self,
 partition_key: core::option::Option<&iceberg::spec::PartitionKey>, file_name: 
&str) -> alloc::string::String
 pub trait iceberg::writer::file_writer::location_generator::FileNameGenerator: 
core::clone::Clone + core::marker::Send + core::marker::Sync + 'static
 pub fn 
iceberg::writer::file_writer::location_generator::FileNameGenerator::generate_file_name(&self)
 -> alloc::string::String
 impl iceberg::writer::file_writer::location_generator::FileNameGenerator for 
iceberg::writer::file_writer::location_generator::DefaultFileNameGenerator
@@ -3255,6 +3274,8 @@ pub trait 
iceberg::writer::file_writer::location_generator::LocationGenerator: c
 pub fn 
iceberg::writer::file_writer::location_generator::LocationGenerator::generate_location(&self,
 partition_key: core::option::Option<&iceberg::spec::PartitionKey>, file_name: 
&str) -> alloc::string::String
 impl iceberg::writer::file_writer::location_generator::LocationGenerator for 
iceberg::writer::file_writer::location_generator::DefaultLocationGenerator
 pub fn 
iceberg::writer::file_writer::location_generator::DefaultLocationGenerator::generate_location(&self,
 partition_key: core::option::Option<&iceberg::spec::PartitionKey>, file_name: 
&str) -> alloc::string::String
+impl iceberg::writer::file_writer::location_generator::LocationGenerator for 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator
+pub fn 
iceberg::writer::file_writer::location_generator::ObjectStorageLocationGenerator::generate_location(&self,
 partition_key: core::option::Option<&iceberg::spec::PartitionKey>, file_name: 
&str) -> alloc::string::String
 pub mod iceberg::writer::file_writer::rolling_writer
 pub struct iceberg::writer::file_writer::rolling_writer::RollingFileWriter<B: 
iceberg::writer::file_writer::FileWriterBuilder, L: 
iceberg::writer::file_writer::location_generator::LocationGenerator, F: 
iceberg::writer::file_writer::location_generator::FileNameGenerator>
 impl<B, L, F> 
iceberg::writer::file_writer::rolling_writer::RollingFileWriter<B, L, F> where 
B: iceberg::writer::file_writer::FileWriterBuilder, L: 
iceberg::writer::file_writer::location_generator::LocationGenerator, F: 
iceberg::writer::file_writer::location_generator::FileNameGenerator
diff --git a/crates/iceberg/src/spec/table_properties.rs 
b/crates/iceberg/src/spec/table_properties.rs
index 379feee5c..a35f1427a 100644
--- a/crates/iceberg/src/spec/table_properties.rs
+++ b/crates/iceberg/src/spec/table_properties.rs
@@ -21,6 +21,7 @@ use std::str::FromStr;
 
 use crate::compression::CompressionCodec;
 use crate::error::{Error, ErrorKind, Result};
+use crate::util::location::strip_trailing_slash;
 
 fn parse_property<T: FromStr>(
     properties: &HashMap<String, String>,
@@ -40,18 +41,6 @@ where
     })
 }
 
-/// Strips trailing slashes from a location, preserving a bare URI scheme root
-fn strip_trailing_slash(path: &str) -> &str {
-    let mut path = path;
-    while !path.ends_with("://") {
-        let Some(stripped) = path.strip_suffix('/') else {
-            break;
-        };
-        path = stripped;
-    }
-    path
-}
-
 fn parse_location_property(
     properties: &HashMap<String, String>,
     key: &str,
@@ -128,6 +117,24 @@ pub(crate) fn parse_metadata_file_compression(
     }
 }
 
+/// Parse boolean property case insensitively
+/// Rust standard library only accepts "true" and "false", see 
https://doc.rust-lang.org/std/primitive.bool.html#method.from_str
+/// Users might accidentally trigger fallback with valid configuration values 
such as "False" or "True"
+fn parse_property_bool(
+    properties: &HashMap<String, String>,
+    key: &str,
+    default: bool,
+) -> Result<bool> {
+    properties.get(key).map_or(Ok(default), |value| {
+        value.to_lowercase().parse::<bool>().map_err(|e| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!("Invalid value for {key}: {e}"),
+            )
+        })
+    })
+}
+
 /// TableProperties that contains the properties of a table.
 #[derive(Debug)]
 pub struct TableProperties {
@@ -173,6 +180,20 @@ pub struct TableProperties {
     pub encryption_key_id: Option<String>,
     /// The encryption data encryption key length in bytes.
     pub encryption_data_key_length: usize,
+    /// Base directory for data files
+    pub write_data_location: Option<String>,
+    /// Deprecated table property for data file write location.
+    ///
+    /// Property will be removed at a later date.
+    /// Superseded by [write_data_location].
+    pub write_folder_storage_location: Option<String>,
+    /// Deprecated table property for data file write location for object 
storage location generator.
+    ///
+    /// Property will be removed at a later date.
+    /// Superseded by [write_data_location].
+    pub write_object_storage_location: Option<String>,
+    /// Whether partition values are included in object storage paths.
+    pub write_object_storage_partitioned_paths: bool,
 }
 
 impl TableProperties {
@@ -325,6 +346,17 @@ impl TableProperties {
     pub const PROPERTY_ENCRYPTION_DATA_KEY_LENGTH: &str = 
"encryption.data-key-length";
     /// Default value for the encryption DEK length (16 bytes = AES-128).
     pub const PROPERTY_ENCRYPTION_DATA_KEY_LENGTH_DEFAULT: usize = 16;
+    /// Property key for the base directory for data files
+    pub const PROPERTY_WRITE_DATA_LOCATION: &str = "write.data.path";
+    /// Property key for deprecated [write_folder_storage_location]
+    pub const PROPERTY_WRITE_FOLDER_STORAGE_LOCATION: &str = 
"write.folder-storage.path";
+    /// Property key for deprecated object storage path, kept as a fallback 
for compatibility.
+    pub const PROPERTY_WRITE_OBJECT_STORAGE_LOCATION: &str = 
"write.object-storage.path";
+    /// Property key for controlling whether partition values are included in 
object storage paths.
+    pub const PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS: &str =
+        "write.object-storage.partitioned-paths";
+    /// Default value for [PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS]
+    pub const PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS_DEFAULT: bool = 
true;
 }
 
 impl TryFrom<&HashMap<String, String>> for TableProperties {
@@ -368,12 +400,12 @@ impl TryFrom<&HashMap<String, String>> for 
TableProperties {
                 TableProperties::PROPERTY_WRITE_METADATA_PATH,
             )?,
             metadata_compression_codec: 
parse_metadata_file_compression(props)?,
-            write_datafusion_fanout_enabled: parse_property(
+            write_datafusion_fanout_enabled: parse_property_bool(
                 props,
                 TableProperties::PROPERTY_DATAFUSION_WRITE_FANOUT_ENABLED,
                 
TableProperties::PROPERTY_DATAFUSION_WRITE_FANOUT_ENABLED_DEFAULT,
             )?,
-            gc_enabled: parse_property(
+            gc_enabled: parse_property_bool(
                 props,
                 TableProperties::PROPERTY_GC_ENABLED,
                 TableProperties::PROPERTY_GC_ENABLED_DEFAULT,
@@ -393,7 +425,7 @@ impl TryFrom<&HashMap<String, String>> for TableProperties {
                 TableProperties::PROPERTY_MAX_REF_AGE_MS,
                 TableProperties::PROPERTY_MAX_REF_AGE_MS_DEFAULT,
             )?,
-            cdc_enabled: parse_property(
+            cdc_enabled: parse_property_bool(
                 props,
                 TableProperties::PROPERTY_PARQUET_CDC_ENABLED,
                 TableProperties::PROPERTY_PARQUET_CDC_ENABLED_DEFAULT,
@@ -421,6 +453,20 @@ impl TryFrom<&HashMap<String, String>> for TableProperties 
{
                 TableProperties::PROPERTY_ENCRYPTION_DATA_KEY_LENGTH,
                 TableProperties::PROPERTY_ENCRYPTION_DATA_KEY_LENGTH_DEFAULT,
             )?,
+            write_data_location: props
+                .get(TableProperties::PROPERTY_WRITE_DATA_LOCATION)
+                .cloned(),
+            write_folder_storage_location: props
+                .get(TableProperties::PROPERTY_WRITE_FOLDER_STORAGE_LOCATION)
+                .cloned(),
+            write_object_storage_location: props
+                .get(TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_LOCATION)
+                .cloned(),
+            write_object_storage_partitioned_paths: parse_property_bool(
+                props,
+                
TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS,
+                
TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS_DEFAULT,
+            )?,
         })
     }
 }
@@ -529,22 +575,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_strip_trailing_slash() {
-        for (path, expected) in [
-            ("s3://bucket/db/tbl", "s3://bucket/db/tbl"),
-            ("s3://bucket/db/tbl/", "s3://bucket/db/tbl"),
-            ("s3://bucket/db/tbl////", "s3://bucket/db/tbl"),
-            ("blobstore://", "blobstore://"),
-            ("blobstore:///", "blobstore://"),
-            ("file:///", "file://"),
-            ("////", ""),
-            ("", ""),
-        ] {
-            assert_eq!(strip_trailing_slash(path), expected);
-        }
-    }
-
     #[test]
     fn test_table_properties_compression() {
         let props = HashMap::from([(
@@ -947,4 +977,28 @@ mod tests {
         let tp = TableProperties::try_from(&props).unwrap();
         assert!(!tp.cdc_enabled);
     }
+
+    #[test]
+    fn test_parse_boolean_property_case_insensitive() {
+        let false_variants = ["False", "FALSE"];
+        let true_variants = ["True", "TRUE"];
+
+        for f in false_variants {
+            let props = HashMap::from([(
+                
TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS.to_string(),
+                f.to_string(),
+            )]);
+            let tp = TableProperties::try_from(&props).unwrap();
+            assert!(!tp.write_object_storage_partitioned_paths);
+        }
+
+        for t in true_variants {
+            let props = HashMap::from([(
+                
TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS.to_string(),
+                t.to_string(),
+            )]);
+            let tp = TableProperties::try_from(&props).unwrap();
+            assert!(tp.write_object_storage_partitioned_paths);
+        }
+    }
 }
diff --git a/crates/iceberg/src/util/location.rs 
b/crates/iceberg/src/util/location.rs
new file mode 100644
index 000000000..00681a12a
--- /dev/null
+++ b/crates/iceberg/src/util/location.rs
@@ -0,0 +1,44 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/// Strips trailing slashes from a location, preserving a bare URI scheme root
+pub(crate) fn strip_trailing_slash(path: &str) -> &str {
+    let mut path = path;
+    while !path.ends_with("://") {
+        let Some(stripped) = path.strip_suffix('/') else {
+            break;
+        };
+        path = stripped;
+    }
+    path
+}
+
+#[test]
+fn test_strip_trailing_slash() {
+    for (path, expected) in [
+        ("s3://bucket/db/tbl", "s3://bucket/db/tbl"),
+        ("s3://bucket/db/tbl/", "s3://bucket/db/tbl"),
+        ("s3://bucket/db/tbl////", "s3://bucket/db/tbl"),
+        ("blobstore://", "blobstore://"),
+        ("blobstore:///", "blobstore://"),
+        ("file:///", "file://"),
+        ("////", ""),
+        ("", ""),
+    ] {
+        assert_eq!(strip_trailing_slash(path), expected);
+    }
+}
diff --git a/crates/iceberg/src/util/mod.rs b/crates/iceberg/src/util/mod.rs
index 3cf2eef9b..94da0130c 100644
--- a/crates/iceberg/src/util/mod.rs
+++ b/crates/iceberg/src/util/mod.rs
@@ -17,6 +17,8 @@
 
 use std::num::NonZeroUsize;
 
+/// Utilities for working with data, metadata, database, table ...etc. 
locations
+pub mod location;
 /// Utilities for working with snapshots.
 pub mod snapshot;
 
diff --git a/crates/iceberg/src/writer/file_writer/location_generator.rs 
b/crates/iceberg/src/writer/file_writer/location_generator.rs
index d37d00c93..caaeb7b68 100644
--- a/crates/iceberg/src/writer/file_writer/location_generator.rs
+++ b/crates/iceberg/src/writer/file_writer/location_generator.rs
@@ -21,7 +21,8 @@ use std::sync::Arc;
 use std::sync::atomic::AtomicU64;
 
 use crate::Result;
-use crate::spec::{DataFileFormat, PartitionKey, TableMetadata};
+use crate::spec::{DataFileFormat, PartitionKey, TableMetadata, 
TableProperties};
+use crate::util::location::strip_trailing_slash;
 
 /// `LocationGenerator` used to generate the location of data file.
 pub trait LocationGenerator: Clone + Send + Sync + 'static {
@@ -41,9 +42,14 @@ pub trait LocationGenerator: Clone + Send + Sync + 'static {
     fn generate_location(&self, partition_key: Option<&PartitionKey>, 
file_name: &str) -> String;
 }
 
-const WRITE_DATA_LOCATION: &str = "write.data.path";
-const WRITE_FOLDER_STORAGE_LOCATION: &str = "write.folder-storage.path";
+/// Default directory for data files
 const DEFAULT_DATA_DIR: &str = "/data";
+/// Number of trailing hash bits used to build the entropy directories.
+const HASH_BINARY_STRING_BITS: usize = 20;
+/// Length of each entropy directory.
+const ENTROPY_DIR_LENGTH: usize = 4;
+/// Number of entropy directories generated from the hash.
+const ENTROPY_DIR_DEPTH: usize = 3;
 
 #[derive(Clone, Debug)]
 /// `DefaultLocationGenerator` used to generate the data dir location of data 
file.
@@ -54,17 +60,22 @@ pub struct DefaultLocationGenerator {
 
 impl DefaultLocationGenerator {
     /// Create a new `DefaultLocationGenerator`.
+    /// Resolve the base data location from table properties, falling back to 
`{table_location}/data`.
+    ///
+    /// Precedence follows Java Iceberg: `write.data.path` first, then the 
deprecated
+    /// `write.folder-storage.path` properties, and finally the default
+    /// `{table_location}/data`.
     pub fn new(table_metadata: &TableMetadata) -> Result<Self> {
-        let table_location = table_metadata.location();
-        let prop = table_metadata.properties();
-        let configured_data_location = prop
-            .get(WRITE_DATA_LOCATION)
-            .or(prop.get(WRITE_FOLDER_STORAGE_LOCATION));
-        let data_location = if let Some(data_location) = 
configured_data_location {
-            data_location.clone()
-        } else {
-            format!("{table_location}{DEFAULT_DATA_DIR}")
-        };
+        let table_location = strip_trailing_slash(table_metadata.location());
+        let prop = TableProperties::try_from(table_metadata.properties())?;
+        let data_location = strip_trailing_slash(
+            prop.write_data_location
+                .or(prop.write_folder_storage_location)
+                .unwrap_or(format!("{table_location}{DEFAULT_DATA_DIR}"))
+                .as_ref(),
+        )
+        .to_string();
+
         Ok(Self { data_location })
     }
 
@@ -93,6 +104,136 @@ impl LocationGenerator for DefaultLocationGenerator {
     }
 }
 
+/// `ObjectStorageLocationGenerator` injects hash entropy into generated file 
locations so that
+/// files are spread across many object-store prefixes.
+///
+/// Object stores such as S3 shard request throughput by key prefix, so 
writing every file under a
+/// common `.../data/` prefix creates a throughput hotspot. This generator 
prepends a
+/// deterministic, hashed directory tree (derived from the file name) to each 
location, mirroring
+/// Java Iceberg's `ObjectStoreLocationProvider`.
+///
+/// The behavior is controlled by these table properties:
+/// * `write.data.path` / `write.object-storage.path` / 
`write.folder-storage.path` - the base data
+///   location (checked in that order), defaulting to `{table_location}/data`.
+/// * `write.object-storage.partitioned-paths` - whether partition values are 
included in the path
+///   (defaults to `true`).
+#[derive(Clone, Debug)]
+pub struct ObjectStorageLocationGenerator {
+    storage_location: String,
+    /// Database/table context, only set when the storage location is outside 
the table location.
+    context: Option<String>,
+    include_partition_paths: bool,
+}
+
+impl ObjectStorageLocationGenerator {
+    /// Create a new `ObjectStorageLocationGenerator` from table metadata.
+    /// Resolve the base data location from table properties, falling back to 
`{table_location}/data`.
+    ///
+    /// Precedence follows Java Iceberg: `write.data.path` first, then the 
deprecated
+    /// `write.object-storage.path` and `write.folder-storage.path` 
properties, and finally the default
+    /// `{table_location}/data`.
+    pub fn new(table_metadata: &TableMetadata) -> Result<Self> {
+        let table_location = strip_trailing_slash(table_metadata.location());
+        let prop = TableProperties::try_from(table_metadata.properties())?;
+        let storage_location = strip_trailing_slash(
+            prop.write_data_location
+                .or(prop.write_object_storage_location)
+                .or(prop.write_folder_storage_location)
+                .unwrap_or(format!("{table_location}{DEFAULT_DATA_DIR}"))
+                .as_ref(),
+        )
+        .to_string();
+
+        // If the storage location is within the table prefix, files are 
already scoped to this
+        // table so there is no need to add database/table context to avoid 
collisions.
+        let context = if storage_location.starts_with(table_location) {
+            None
+        } else {
+            Some(path_context(table_location))
+        };
+
+        let include_partition_paths = 
prop.write_object_storage_partitioned_paths;
+
+        Ok(Self {
+            storage_location,
+            context,
+            include_partition_paths,
+        })
+    }
+
+    /// Build the final location for a fully-formed data file name (which may 
already include a
+    /// partition path).
+    fn new_data_location(&self, name: &str) -> String {
+        let hash = self.compute_hash(name);
+        if let Some(context) = &self.context {
+            format!("{}/{}/{}/{}", self.storage_location, hash, context, name)
+        } else if self.include_partition_paths {
+            format!("{}/{}/{}", self.storage_location, hash, name)
+        } else {
+            // When partition paths are excluded, join the entropy to the file 
name with `-` so the
+            // file still lives directly under the storage location.
+            format!("{}/{}-{}", self.storage_location, hash, name)
+        }
+    }
+
+    /// Compute the entropy directory tree for the given name, e.g. 
`0101/0110/1001/10110010`.
+    fn compute_hash(&self, name: &str) -> String {
+        let mut bytes = name.as_bytes();
+        let hash_code = murmur3::murmur3_32(&mut bytes, 0).unwrap();
+        let binary = format!("{:032b}", hash_code);
+        let hash = &binary[binary.len() - HASH_BINARY_STRING_BITS..];
+        dirs_from_hash(hash)
+    }
+}
+
+impl LocationGenerator for ObjectStorageLocationGenerator {
+    fn generate_location(&self, partition_key: Option<&PartitionKey>, 
file_name: &str) -> String {
+        let name =
+            if self.include_partition_paths && 
!PartitionKey::is_effectively_none(partition_key) {
+                format!("{}/{}", partition_key.unwrap().to_path(), file_name)
+            } else {
+                file_name.to_string()
+            };
+        self.new_data_location(&name)
+    }
+}
+
+/// Derive the `{parent}/{name}` context from a table location, mirroring 
Hadoop's
+/// `Path.getParent().getName()` / `Path.getName()`.
+fn path_context(table_location: &str) -> String {
+    let mut segments = table_location.rsplit('/').filter(|s| !s.is_empty());
+    let name = segments.next().unwrap_or("");
+    match segments.next() {
+        Some(parent) => format!("{parent}/{name}"),
+        None => name.to_string(),
+    }
+}
+
+/// Divide a binary hash string into directories for optimized listing/orphan 
removal.
+///
+/// With `ENTROPY_DIR_DEPTH = 3` and `ENTROPY_DIR_LENGTH = 4`, the 20-bit hash
+/// `10011001100110011001` becomes `1001/1001/1001/10011001`.
+fn dirs_from_hash(hash: &str) -> String {
+    let mut result = String::new();
+
+    let mut i = 0;
+    while i < ENTROPY_DIR_DEPTH * ENTROPY_DIR_LENGTH {
+        if i > 0 {
+            result.push('/');
+        }
+        let end = (i + ENTROPY_DIR_LENGTH).min(hash.len());
+        result.push_str(&hash[i..end]);
+        i += ENTROPY_DIR_LENGTH;
+    }
+
+    if hash.len() > ENTROPY_DIR_DEPTH * ENTROPY_DIR_LENGTH {
+        result.push('/');
+        result.push_str(&hash[ENTROPY_DIR_DEPTH * ENTROPY_DIR_LENGTH..]);
+    }
+
+    result
+}
+
 /// `FileNameGeneratorTrait` used to generate file name for data file. The 
file name can be passed to `LocationGenerator` to generate the location of the 
file.
 pub trait FileNameGenerator: Clone + Send + Sync + 'static {
     /// Generate a file name.
@@ -150,41 +291,15 @@ pub(crate) mod test {
     use super::LocationGenerator;
     use crate::spec::{
         FormatVersion, Literal, NestedField, PartitionKey, PartitionSpec, 
PrimitiveType, Schema,
-        Struct, StructType, TableMetadata, Transform, Type,
+        Struct, StructType, TableMetadata, TableProperties, Transform, Type,
     };
     use crate::writer::file_writer::location_generator::{
-        DefaultLocationGenerator, FileNameGenerator, WRITE_DATA_LOCATION,
-        WRITE_FOLDER_STORAGE_LOCATION,
+        DefaultLocationGenerator, FileNameGenerator, 
ObjectStorageLocationGenerator,
     };
 
     #[test]
     fn test_default_location_generate() {
-        let mut table_metadata = TableMetadata {
-            format_version: FormatVersion::V2,
-            table_uuid: 
Uuid::parse_str("fb072c92-a02b-11e9-ae9c-1bb7bc9eca94").unwrap(),
-            location: "s3://data.db/table".to_string(),
-            last_updated_ms: 1515100955770,
-            last_column_id: 1,
-            schemas: HashMap::new(),
-            current_schema_id: 1,
-            partition_specs: HashMap::new(),
-            default_spec: PartitionSpec::unpartition_spec().into(),
-            default_partition_type: StructType::new(vec![]),
-            last_partition_id: 1000,
-            default_sort_order_id: 0,
-            sort_orders: HashMap::from_iter(vec![]),
-            snapshots: HashMap::default(),
-            current_snapshot_id: None,
-            last_sequence_number: 1,
-            properties: HashMap::new(),
-            snapshot_log: Vec::new(),
-            metadata_log: vec![],
-            refs: HashMap::new(),
-            statistics: HashMap::new(),
-            partition_statistics: HashMap::new(),
-            encryption_keys: HashMap::new(),
-            next_row_id: 0,
-        };
+        let mut table_metadata = table_metadata_with("s3://data.db/table", 
HashMap::new());
 
         let file_name_generator = super::DefaultFileNameGenerator::new(
             "part".to_string(),
@@ -200,7 +315,7 @@ pub(crate) mod test {
 
         // test custom data location
         table_metadata.properties.insert(
-            WRITE_FOLDER_STORAGE_LOCATION.to_string(),
+            
TableProperties::PROPERTY_WRITE_FOLDER_STORAGE_LOCATION.to_string(),
             "s3://data.db/table/data_1".to_string(),
         );
         let location_generator = 
DefaultLocationGenerator::new(&table_metadata).unwrap();
@@ -212,7 +327,7 @@ pub(crate) mod test {
         );
 
         table_metadata.properties.insert(
-            WRITE_DATA_LOCATION.to_string(),
+            TableProperties::PROPERTY_WRITE_DATA_LOCATION.to_string(),
             "s3://data.db/table/data_2".to_string(),
         );
         let location_generator = 
DefaultLocationGenerator::new(&table_metadata).unwrap();
@@ -224,7 +339,7 @@ pub(crate) mod test {
         );
 
         table_metadata.properties.insert(
-            WRITE_DATA_LOCATION.to_string(),
+            TableProperties::PROPERTY_WRITE_DATA_LOCATION.to_string(),
             // invalid table location
             "s3://data.db/data_3".to_string(),
         );
@@ -310,6 +425,185 @@ pub(crate) mod test {
         );
     }
 
+    // This test is ported from 
https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/core/src/test/java/org/apache/iceberg/TestLocationProvider.java#L303
+    #[test]
+    fn test_object_storage_hash_injection() {
+        // Golden vectors ported from Java's 
TestLocationProvider#testHashInjection, verifying that
+        // the murmur3 entropy directories match Java Iceberg exactly.
+        let table_metadata = table_metadata_with("s3://data.db/table", 
HashMap::new());
+        let location_gen = 
ObjectStorageLocationGenerator::new(&table_metadata).unwrap();
+
+        for (file_name, expected) in [
+            ("a", "s3://data.db/table/data/0101/0110/1001/10110010/a"),
+            ("b", "s3://data.db/table/data/1110/0111/1110/00000011/b"),
+            ("c", "s3://data.db/table/data/0010/1101/0110/01011111/c"),
+            ("d", "s3://data.db/table/data/1001/0001/0100/01110011/d"),
+        ] {
+            assert_eq!(location_gen.generate_location(None, file_name), 
expected);
+        }
+    }
+
+    #[test]
+    fn test_object_storage_include_partition_paths() {
+        // With partitioned paths enabled (the default), the partition path is 
part of the hashed
+        // name and appears after the entropy directories.
+        let schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(1)
+                .with_fields(vec![
+                    NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                    NestedField::required(2, "a", 
Type::Primitive(PrimitiveType::String)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+        let partition_spec = PartitionSpec::builder(schema.clone())
+            .add_partition_field("id", "id", Transform::Identity)
+            .unwrap()
+            .add_partition_field("a", "a_trunc_3", Transform::Truncate(3))
+            .unwrap()
+            .build()
+            .unwrap();
+        let partition_data =
+            Struct::from_iter([Some(Literal::int(0)), 
Some(Literal::string("apa"))]);
+        let partition_key = PartitionKey::new(partition_spec, schema, 
partition_data);
+
+        let table_metadata = table_metadata_with("s3://data.db/table", 
HashMap::new());
+        let location_gen = 
ObjectStorageLocationGenerator::new(&table_metadata).unwrap();
+        let location = location_gen.generate_location(Some(&partition_key), 
"test.parquet");
+
+        assert!(
+            location.starts_with("s3://data.db/table/data/"),
+            "unexpected location: {location}"
+        );
+        // The partition path is included
+        assert!(
+            location.ends_with("/id=0/a_trunc_3=apa/test.parquet"),
+            "unexpected location: {location}"
+        );
+    }
+
+    // This test is ported from 
https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/core/src/test/java/org/apache/iceberg/TestLocationProvider.java#L267
+    #[test]
+    fn test_object_storage_include_partition_paths_with_special_characters() {
+        let schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(1)
+                .with_fields(vec![
+                    NestedField::required(1, "data#1", 
Type::Primitive(PrimitiveType::Int)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+        let partition_spec = PartitionSpec::builder(schema.clone())
+            .add_partition_field("data#1", "data#1", Transform::Identity)
+            .unwrap()
+            .build()
+            .unwrap();
+        let partition_data = 
Struct::from_iter([Some(Literal::string("val#1"))]);
+        let partition_key = PartitionKey::new(partition_spec, schema, 
partition_data);
+
+        let table_metadata = table_metadata_with(
+            "s3://data.db/table",
+            HashMap::from([(
+                
TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS.to_string(),
+                "true".to_string(),
+            )]),
+        );
+        let location_gen = 
ObjectStorageLocationGenerator::new(&table_metadata).unwrap();
+        let location = location_gen.generate_location(Some(&partition_key), 
"test.parquet");
+
+        assert_eq!(
+            location,
+            
"s3://data.db/table/data/0000/1011/0110/00001000/data%231=val%231/test.parquet"
+        );
+    }
+
+    // This test is ported from 
https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/core/src/test/java/org/apache/iceberg/TestLocationProvider.java#L285
+    #[test]
+    fn test_object_storage_exclude_partition_paths() {
+        // Golden vector ported from Java's 
TestLocationProvider#testExcludePartitionInPath. With
+        // partitioned paths disabled, the partition value is dropped and the 
last entropy dir is
+        // joined to the file name with `-`.
+        let schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(1)
+                .with_fields(vec![
+                    NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+        let partition_spec = PartitionSpec::builder(schema.clone())
+            .add_partition_field("id", "id", Transform::Identity)
+            .unwrap()
+            .build()
+            .unwrap();
+        let partition_data = Struct::from_iter([Some(Literal::int(0))]);
+        let partition_key = PartitionKey::new(partition_spec, schema, 
partition_data);
+
+        let table_metadata = table_metadata_with(
+            "s3://data.db/table",
+            HashMap::from([(
+                
TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS.to_string(),
+                "false".to_string(),
+            )]),
+        );
+        let location_gen = 
ObjectStorageLocationGenerator::new(&table_metadata).unwrap();
+        let location = location_gen.generate_location(Some(&partition_key), 
"test.parquet");
+
+        assert_eq!(
+            location,
+            "s3://data.db/table/data/0110/1010/0011/11101000-test.parquet"
+        );
+    }
+
+    #[test]
+    fn test_object_storage_context_when_data_location_outside_table() {
+        // When the data location is outside the table location, the 
database/table context is
+        // injected after the entropy directories to avoid cross-table 
collisions.
+        let table_metadata = table_metadata_with(
+            "s3://data.db/table",
+            HashMap::from([(
+                TableProperties::PROPERTY_WRITE_DATA_LOCATION.to_string(),
+                "s3://custom-bucket/objects".to_string(),
+            )]),
+        );
+        let location_gen = 
ObjectStorageLocationGenerator::new(&table_metadata).unwrap();
+        let location = location_gen.generate_location(None, "a");
+
+        // Entropy for "a" is 0101/0110/1001/10110010, then the 
"data.db/table" context, then file.
+        assert_eq!(
+            location,
+            
"s3://custom-bucket/objects/0101/0110/1001/10110010/data.db/table/a"
+        );
+    }
+
+    #[test]
+    fn test_object_storage_data_location_precedence() {
+        // write.data.path takes precedence over the deprecated folder-storage 
fallback.
+        let table_metadata = table_metadata_with(
+            "s3://data.db/table",
+            HashMap::from([
+                (
+                    TableProperties::PROPERTY_WRITE_DATA_LOCATION.to_string(),
+                    "s3://data.db/table/data_primary".to_string(),
+                ),
+                (
+                    
TableProperties::PROPERTY_WRITE_FOLDER_STORAGE_LOCATION.to_string(),
+                    "s3://data.db/table/data_legacy".to_string(),
+                ),
+            ]),
+        );
+        let location_gen = 
ObjectStorageLocationGenerator::new(&table_metadata).unwrap();
+        let location = location_gen.generate_location(None, "a");
+
+        assert_eq!(
+            location,
+            "s3://data.db/table/data_primary/0101/0110/1001/10110010/a"
+        );
+    }
+
     /// Build a minimal `TableMetadata` for location generator tests.
     fn table_metadata_with(location: &str, properties: HashMap<String, 
String>) -> TableMetadata {
         TableMetadata {

Reply via email to