This is an automated email from the ASF dual-hosted git repository.
laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new a7592ed0f fix(table)!: honor object storage partitioned-paths for
partitioned writes (#2368)
a7592ed0f is described below
commit a7592ed0f311a2b5248e0a14f1f40fcd3bb80ca2
Author: Ish <[email protected]>
AuthorDate: Wed Nov 4 16:50:13 2026 +0100
fix(table)!: honor object storage partitioned-paths for partitioned writes
(#2368)
---
table/locations.go | 31 ++++++-----
table/locations_test.go | 43 +++++++--------
table/rolling_data_writer.go | 56 +++----------------
table/rolling_data_writer_test.go | 111 ++++++++++++++++++++++++--------------
table/writer.go | 2 +-
website/src/releases.md | 2 +
6 files changed, 123 insertions(+), 122 deletions(-)
diff --git a/table/locations.go b/table/locations.go
index a051c291f..f782bf2f5 100644
--- a/table/locations.go
+++ b/table/locations.go
@@ -19,7 +19,6 @@ package table
import (
"fmt"
- "path"
"strings"
"github.com/apache/iceberg-go"
@@ -38,7 +37,9 @@ const (
)
type LocationProvider interface {
- NewDataLocation(dataFileName string) string
+ // NewDataLocation returns the location for a data file. partitionPath
is the
+ // file's partition path, such as "a=1/b=2", or empty for an
unpartitioned file.
+ NewDataLocation(dataFileName, partitionPath string) string
NewTableMetadataFileLocation(newVersion int) (string, error)
NewMetadataLocation(metadataFileName string) string
}
@@ -79,8 +80,12 @@ func joinLocationPath(location string, elem ...string)
string {
return b.String()
}
-func (slp *simpleLocationProvider) NewDataLocation(dataFileName string) string
{
- return joinLocationPath(slp.dataPath, dataFileName)
+func (slp *simpleLocationProvider) NewDataLocation(dataFileName, partitionPath
string) string {
+ if partitionPath == "" {
+ return joinLocationPath(slp.dataPath, dataFileName)
+ }
+
+ return joinLocationPath(slp.dataPath, partitionPath, dataFileName)
}
func (slp *simpleLocationProvider) NewTableMetadataFileLocation(newVersion
int) (string, error) {
@@ -151,17 +156,19 @@ func computeHash(dataFileName string) string {
return string(hashPath[:])
}
-func (p *objectStoreLocationProvider) NewDataLocation(dataFileName string)
string {
- if path.Dir(dataFileName) != "." {
- return p.simpleLocationProvider.NewDataLocation(dataFileName)
+// NewDataLocation places the entropy hash right under the data path, like the
+// Java implementation. The partition directories are kept, and hashed with the
+// file name, only when partitioned paths are enabled.
+func (p *objectStoreLocationProvider) NewDataLocation(dataFileName,
partitionPath string) string {
+ if !p.includePartitionPaths {
+ return joinLocationPath(p.dataPath,
computeHash(dataFileName)+"-"+dataFileName)
}
- hashedPath := computeHash(dataFileName)
- if p.includePartitionPaths {
- return joinLocationPath(p.dataPath, hashedPath, dataFileName)
- } else {
- return joinLocationPath(p.dataPath, hashedPath+"-"+dataFileName)
+ if partitionPath != "" {
+ dataFileName = partitionPath + "/" + dataFileName
}
+
+ return joinLocationPath(p.dataPath, computeHash(dataFileName),
dataFileName)
}
func newObjectStoreLocationProvider(tableLoc string, tableProps
iceberg.Properties) *objectStoreLocationProvider {
diff --git a/table/locations_test.go b/table/locations_test.go
index 5bdd4d1c3..e72e0a42a 100644
--- a/table/locations_test.go
+++ b/table/locations_test.go
@@ -57,8 +57,8 @@ func TestLocationProviderLocalPaths(t *testing.T) {
metadataFile, err :=
provider.NewTableMetadataFileLocation(1)
require.NoError(t, err)
for _, file := range []struct{
location, dir string }{
-
{provider.NewDataLocation("data.parquet"), dataDir},
-
{provider.NewDataLocation("partition=value/data.parquet"), dataDir},
+
{provider.NewDataLocation("data.parquet", ""), dataDir},
+
{provider.NewDataLocation("data.parquet", "partition=value"), dataDir},
{provider.NewMetadataLocation("manifest.avro"), metadataDir},
{metadataFile,
metadataDir},
} {
@@ -103,18 +103,18 @@ func TestLocationProviderLiteralPaths(t *testing.T) {
t.Run(tt.location, func(t *testing.T) {
provider, err :=
table.LoadLocationProvider(tt.location, nil)
require.NoError(t, err)
- assert.Equal(t, tt.data, provider.NewDataLocation("a"))
+ assert.Equal(t, tt.data, provider.NewDataLocation("a",
""))
assert.Equal(t, tt.metadata,
provider.NewMetadataLocation("manifest.avro"))
})
}
}
func TestLocationProviderLiteralCustomPaths(t *testing.T) {
- for _, tt := range []struct{ objectStore, partitionPaths, data string }{
- {"false", "false", "s3://bucket/my data%20#?/a"},
- {"false", "true", "s3://bucket/my data%20#?/a"},
- {"true", "false", "s3://bucket/my
data%20#?/0101/0110/1001/10110010-a"},
- {"true", "true", "s3://bucket/my
data%20#?/0101/0110/1001/10110010/a"},
+ for _, tt := range []struct{ objectStore, partitionPaths, data,
partitioned, multiLevel string }{
+ {"false", "false", "s3://bucket/my data%20#?/a",
"s3://bucket/my data%20#?/partition=a b%20#?/file.parquet", "s3://bucket/my
data%20#?/a=1/b=2/file.parquet"},
+ {"false", "true", "s3://bucket/my data%20#?/a", "s3://bucket/my
data%20#?/partition=a b%20#?/file.parquet", "s3://bucket/my
data%20#?/a=1/b=2/file.parquet"},
+ {"true", "false", "s3://bucket/my
data%20#?/0101/0110/1001/10110010-a", "s3://bucket/my
data%20#?/0010/1111/0101/11011101-file.parquet", "s3://bucket/my
data%20#?/0010/1111/0101/11011101-file.parquet"},
+ {"true", "true", "s3://bucket/my
data%20#?/0101/0110/1001/10110010/a", "s3://bucket/my
data%20#?/1011/0010/0000/11011010/partition=a b%20#?/file.parquet",
"s3://bucket/my data%20#?/1110/0111/1101/10000010/a=1/b=2/file.parquet"},
} {
t.Run(tt.objectStore+"/"+tt.partitionPaths, func(t *testing.T) {
provider, err :=
table.LoadLocationProvider("s3://bucket/my table", iceberg.Properties{
@@ -124,9 +124,10 @@ func TestLocationProviderLiteralCustomPaths(t *testing.T) {
table.WriteMetadataPathKey:
"gs://bucket/100% metadata/./../here/",
})
require.NoError(t, err)
- assert.Equal(t, tt.data, provider.NewDataLocation("a"))
- assert.Equal(t, "s3://bucket/my data%20#?/partition=a
b%20#?/file.parquet",
- provider.NewDataLocation("partition=a
b%20#?/file.parquet"))
+ assert.Equal(t, tt.data, provider.NewDataLocation("a",
""))
+ assert.Equal(t, tt.partitioned,
+ provider.NewDataLocation("file.parquet",
"partition=a b%20#?"))
+ assert.Equal(t, tt.multiLevel,
provider.NewDataLocation("file.parquet", "a=1/b=2"))
assert.Equal(t, "gs://bucket/100%
metadata/./../here/manifest #?%.avro",
provider.NewMetadataLocation("manifest
#?%.avro"))
})
@@ -185,12 +186,12 @@ func TestObjectStoreLocationProvider(t *testing.T) {
iceberg.Properties{table.ObjectStoreEnabledKey: "true"})
require.NoError(t, err)
- assert.Equal(t, "table_location/data/0101/0110/1001/10110010/a",
provider.NewDataLocation("a"))
- assert.Equal(t, "table_location/data/1110/0111/1110/00000011/b",
provider.NewDataLocation("b"))
- assert.Equal(t, "table_location/data/0010/1101/0110/01011111/c",
provider.NewDataLocation("c"))
- assert.Equal(t, "table_location/data/1001/0001/0100/01110011/d",
provider.NewDataLocation("d"))
+ assert.Equal(t, "table_location/data/0101/0110/1001/10110010/a",
provider.NewDataLocation("a", ""))
+ assert.Equal(t, "table_location/data/1110/0111/1110/00000011/b",
provider.NewDataLocation("b", ""))
+ assert.Equal(t, "table_location/data/0010/1101/0110/01011111/c",
provider.NewDataLocation("c", ""))
+ assert.Equal(t, "table_location/data/1001/0001/0100/01110011/d",
provider.NewDataLocation("d", ""))
assert.Equal(t,
"table_location/data/0000/0000/0000/00000000/part-00164929.parquet",
- provider.NewDataLocation("part-00164929.parquet"))
+ provider.NewDataLocation("part-00164929.parquet", ""))
}
// TestObjectStoreLocationProviderPartitionedPathsDisabled tests that when
partitioned paths are disabled,
@@ -201,9 +202,9 @@ func
TestObjectStoreLocationProviderPartitionedPathsDisabled(t *testing.T) {
iceberg.Properties{table.ObjectStoreEnabledKey: "true",
table.WriteObjectStorePartitionedPathsKey: "false"})
require.NoError(t, err)
- assert.Equal(t, "table_location/data/0101/0110/1001/10110010-a",
provider.NewDataLocation("a"))
- assert.Equal(t, "table_location/data/1110/0111/1110/00000011-b",
provider.NewDataLocation("b"))
- assert.Equal(t, "table_location/data/0010/1101/0110/01011111-c",
provider.NewDataLocation("c"))
- assert.Equal(t, "table_location/data/1001/0001/0100/01110011-d",
provider.NewDataLocation("d"))
- assert.Equal(t,
"table_location/data/0110/1010/0011/11101000-test.parquet",
provider.NewDataLocation("test.parquet"))
+ assert.Equal(t, "table_location/data/0101/0110/1001/10110010-a",
provider.NewDataLocation("a", ""))
+ assert.Equal(t, "table_location/data/1110/0111/1110/00000011-b",
provider.NewDataLocation("b", ""))
+ assert.Equal(t, "table_location/data/0010/1101/0110/01011111-c",
provider.NewDataLocation("c", ""))
+ assert.Equal(t, "table_location/data/1001/0001/0100/01110011-d",
provider.NewDataLocation("d", ""))
+ assert.Equal(t,
"table_location/data/0110/1010/0011/11101000-test.parquet",
provider.NewDataLocation("test.parquet", ""))
}
diff --git a/table/rolling_data_writer.go b/table/rolling_data_writer.go
index 507fde2f3..5c1801cb5 100644
--- a/table/rolling_data_writer.go
+++ b/table/rolling_data_writer.go
@@ -23,8 +23,6 @@ import (
"fmt"
"iter"
"log/slog"
- "maps"
- "net/url"
"strings"
"sync"
"sync/atomic"
@@ -54,8 +52,6 @@ const rollingDataWriterQueueCapacity = 64
// for different partitions, providing shared configuration and coordination
// across all writers in a partitioned write operation.
type writerFactory struct {
- rootLocation string
- rootURL *url.URL
fs iceio.WriteFileIO
writeUUID *uuid.UUID
taskSchema *iceberg.Schema
@@ -90,13 +86,12 @@ type writerFactory struct {
// capacity; non-positive uses rollingDataWriterQueueCapacity.
recordBufferSize int
- writers sync.Map
- partitionLocProviders sync.Map
- nextCount func() (int, bool)
- stopCount func()
- countMu sync.Mutex
- partitionIDCounter atomic.Int64
- mu sync.Mutex
+ writers sync.Map
+ nextCount func() (int, bool)
+ stopCount func()
+ countMu sync.Mutex
+ partitionIDCounter atomic.Int64
+ mu sync.Mutex
}
type writerFactoryOption func(*writerFactory) error
@@ -150,13 +145,6 @@ func withFactoryFileSchema(schema *iceberg.Schema)
writerFactoryOption {
func newWriterFactory(rootLocation string, args recordWritingArgs, meta
*MetadataBuilder, taskSchema *iceberg.Schema, targetFileSize int64, opts
...writerFactoryOption) (*writerFactory, error) {
nextCount, stopCount := iter.Pull(args.counter)
- rootURL, err := url.Parse(rootLocation)
- if err != nil {
- stopCount()
-
- return nil, err
- }
-
locProvider, err := LoadLocationProvider(rootLocation, meta.props)
if err != nil {
stopCount()
@@ -220,8 +208,6 @@ func newWriterFactory(rootLocation string, args
recordWritingArgs, meta *Metadat
}
f := &writerFactory{
- rootLocation: rootLocation,
- rootURL: rootURL,
fs: args.fs,
writeUUID: args.writeUUID,
taskSchema: taskSchema,
@@ -309,16 +295,7 @@ func (w *writerFactory) openFileWriter(ctx
context.Context, partitionPath string
fileName := dataFileName(*w.writeUUID, cnt, partitionID, fileCount,
strings.ToLower(string(w.fileFormat)))
- var filePath string
- if partitionPath != "" {
- partitionLoc, err := w.partitionLocProvider(partitionPath)
- if err != nil {
- return nil, err
- }
- filePath = partitionLoc.NewDataLocation(fileName)
- } else {
- filePath = w.locProvider.NewDataLocation(fileName)
- }
+ filePath := w.locProvider.NewDataLocation(fileName, partitionPath)
// SortOrderID is set only for presorted input: otherwise batches are
sorted
// individually (see resolveSortKeys), which does not sort the whole
file.
@@ -338,25 +315,6 @@ func (w *writerFactory) openFileWriter(ctx
context.Context, partitionPath string
}, arrowSchema)
}
-func (w *writerFactory) partitionLocProvider(partitionPath string)
(LocationProvider, error) {
- if cached, ok := w.partitionLocProviders.Load(partitionPath); ok {
- return cached.(LocationProvider), nil
- }
-
- partitionDataPath := w.rootURL.JoinPath("data", partitionPath).String()
- partitionProps := make(iceberg.Properties, len(w.tableProps)+1)
- maps.Copy(partitionProps, w.tableProps)
- partitionProps[WriteDataPathKey] = partitionDataPath
- loc, err := LoadLocationProvider(w.rootLocation, partitionProps)
- if err != nil {
- return nil, err
- }
-
- w.partitionLocProviders.Store(partitionPath, loc)
-
- return loc, nil
-}
-
// RollingDataWriter writes Arrow records for a specific partition, rolling to
// new data files when the actual compressed file size reaches the target.
type RollingDataWriter struct {
diff --git a/table/rolling_data_writer_test.go
b/table/rolling_data_writer_test.go
index 717f6e5a2..15f5e0aec 100644
--- a/table/rolling_data_writer_test.go
+++ b/table/rolling_data_writer_test.go
@@ -20,8 +20,10 @@ package table
import (
"context"
"errors"
+ "maps"
"os"
"path/filepath"
+ "regexp"
"runtime"
"strconv"
"sync"
@@ -655,12 +657,11 @@ func (s *RollingDataWriterTestSuite)
TestBytesWrittenReflectsCompressedSize() {
s.Equal(int64(100), df.Count())
}
-// TestPartitionLocProviderPreservesObjectStorageProps reproduces #1155: when
-// write.object-storage.enabled is true on a partitioned table, partition data
-// file paths must include entropy hash dirs from objectStoreLocationProvider.
-// Previously partitionLocProvider rebuilt props with only WriteDataPathKey, so
-// LoadLocationProvider fell back to simpleLocationProvider for partitions.
-func (s *RollingDataWriterTestSuite)
TestPartitionLocProviderPreservesObjectStorageProps() {
+// TestPartitionedDataLocations checks partitioned data file paths against the
+// Java layout: with object storage the entropy hash comes right after the
+// data path, and write.object-storage.partitioned-paths=false drops the
+// partition directories (#2367). write.data.path is honored as well.
+func (s *RollingDataWriterTestSuite) TestPartitionedDataLocations() {
arrSchema := arrow.NewSchema([]arrow.Field{
{Name: "id", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
{Name: "name", Type: arrow.BinaryTypes.String, Nullable: true},
@@ -669,42 +670,74 @@ func (s *RollingDataWriterTestSuite)
TestPartitionLocProviderPreservesObjectStor
icebergSchema, err := ArrowSchemaToIcebergWithFreshIDs(arrSchema, false)
s.Require().NoError(err)
- spec := iceberg.NewPartitionSpec()
- loc := "table_location"
- meta, err := NewMetadata(icebergSchema, &spec, UnsortedSortOrder, loc,
iceberg.Properties{
- ObjectStoreEnabledKey: "true",
- })
- s.Require().NoError(err)
- metaBuilder, err := MetadataBuilderFromBase(meta, "")
- s.Require().NoError(err)
+ const (
+ partition = "dt=2026-06-04"
+ hash = `[01]{4}/[01]{4}/[01]{4}/[01]{8}`
+ file = `[^/]+\.parquet`
+ )
- writeUUID := uuid.New()
- args := recordWritingArgs{
- sc: arrSchema,
- fs: iceio.LocalFS{},
- writeUUID: &writeUUID,
- counter: func(yield func(int) bool) {
- for i := 0; ; i++ {
- if !yield(i) {
- break
- }
+ for _, tt := range []struct {
+ name string
+ props iceberg.Properties
+ want string
+ }{
+ {"simple", iceberg.Properties{}, "data/" + partition + "/" +
file},
+ {"simple custom path", iceberg.Properties{WriteDataPathKey:
"custom"}, "custom/" + partition + "/" + file},
+ {"object storage", iceberg.Properties{ObjectStoreEnabledKey:
"true"}, "data/" + hash + "/" + partition + "/" + file},
+ {"object storage without partition paths", iceberg.Properties{
+ ObjectStoreEnabledKey: "true",
+ WriteObjectStorePartitionedPathsKey: "false",
+ }, "data/" + hash + "-" + file},
+ {"object storage custom path", iceberg.Properties{
+ ObjectStoreEnabledKey: "true",
+ WriteDataPathKey: "custom",
+ }, "custom/" + hash + "/" + partition + "/" + file},
+ } {
+ s.Run(tt.name, func() {
+ loc := filepath.ToSlash(s.T().TempDir())
+ props := maps.Clone(tt.props)
+ if dataPath, ok := props[WriteDataPathKey]; ok {
+ props[WriteDataPathKey] = loc + "/" + dataPath
}
- },
- }
- factory, err := newWriterFactory(loc, args, metaBuilder, icebergSchema,
1024*1024)
- s.Require().NoError(err)
- defer factory.closeAll()
- provider, err := factory.partitionLocProvider("dt=2026-06-04")
- s.Require().NoError(err)
- _, ok := provider.(*objectStoreLocationProvider)
- s.True(ok, "expected objectStoreLocationProvider for partitioned writes
when write.object-storage.enabled=true")
-
- // Hashed prefix should appear before the file name for object storage
layout.
- dataLoc := provider.NewDataLocation("a")
- s.Contains(dataLoc, "table_location/data/dt=2026-06-04/")
- s.NotEqual("table_location/data/dt=2026-06-04/a", dataLoc,
- "object storage layout should inject an entropy hash prefix
between partition dir and file")
+ spec := iceberg.NewPartitionSpec()
+ meta, err := NewMetadata(icebergSchema, &spec,
UnsortedSortOrder, loc, props)
+ s.Require().NoError(err)
+ metaBuilder, err := MetadataBuilderFromBase(meta, "")
+ s.Require().NoError(err)
+
+ writeUUID := uuid.New()
+ args := recordWritingArgs{
+ sc: arrSchema,
+ fs: iceio.LocalFS{},
+ writeUUID: &writeUUID,
+ counter: func(yield func(int) bool) {
+ for i := 0; ; i++ {
+ if !yield(i) {
+ break
+ }
+ }
+ },
+ }
+ factory, err := newWriterFactory(loc, args,
metaBuilder, icebergSchema, 1024*1024)
+ s.Require().NoError(err)
+ defer factory.closeAll()
+
+ outputCh := make(chan iceberg.DataFile, 1)
+ writer := factory.newRollingDataWriter(s.ctx,
partition, nil, outputCh)
+
+ record := s.buildRecord(arrSchema, 5)
+ defer record.Release()
+
+ s.Require().NoError(writer.Add(record))
+ s.Require().NoError(writer.closeAndWait())
+ close(outputCh)
+
+ df := <-outputCh
+ s.Require().NotNil(df)
+ s.Regexp("^"+regexp.QuoteMeta(loc)+"/"+tt.want+"$",
df.FilePath())
+ })
+ }
}
// TestSortsRowsBeforeWriting exercises the sort-on-write path: the rolling
diff --git a/table/writer.go b/table/writer.go
index 16b32cefa..98405b9c9 100644
--- a/table/writer.go
+++ b/table/writer.go
@@ -218,7 +218,7 @@ func (w *defaultDataFileWriter) writeFile(ctx
context.Context, partitionValues m
}
filePath := w.loc.NewDataLocation(
- task.GenerateDataFileName(w.invariants.extension))
+ task.GenerateDataFileName(w.invariants.extension), "")
return w.format.WriteDataFile(ctx, w.fs, partitionValues,
tblutils.WriteFileInfo{
FileSchema: w.fileSchema,
diff --git a/website/src/releases.md b/website/src/releases.md
index dd9e3eda6..0dc5a7c51 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -42,6 +42,8 @@ Per-release notes (highlights, breaking changes,
contributors) are published on
### Unreleased compatibility notes
+- **`LocationProvider.NewDataLocation` takes the partition path.** The method
is now `NewDataLocation(dataFileName, partitionPath string)`, matching Iceberg
Java and PyIceberg, so custom `LocationProvider` implementations must add the
argument. Callers pass the partition path there, or `""` for an unpartitioned
file, instead of prefixing the file name.
+- **Partitioned data files follow the table's location provider.** With
`write.object-storage.enabled=true` and the default
`write.object-storage.partitioned-paths=true`, new partitioned files land under
`data/<hash>/<partition>/` instead of `data/<partition>/<hash>/`, which can
affect prefix-based lifecycle rules and bucket policies.
`write.object-storage.partitioned-paths=false` and `write.data.path` now apply
to partitioned writes. Partitioned paths also no longer percent-encode the t
[...]
- **Catalog capabilities split into narrower interfaces.** `catalog.Catalog`
now embeds `TableCatalog` and `NamespaceCatalog` with unchanged table and
namespace methods. `Name() string` is now required, breaking third-party
catalogs and test fakes. `ViewCatalog` is optional (REST and Hive), while REST
also implements `UpdatableViewCatalog` for `UpdateView`. Use
`catalog.As[T](cat)` and the `Unwrap() Catalog` convention to discover optional
capabilities through decorators. Future capabili [...]
- **`ReplaceFilesWithDeleteFiles` removed.** For delete-file replacements, use
`tx.NewRewrite(snapshotProps)`, call `DeleteFile` for each source file and
`AddDeleteFileWithDataSequenceNumber` for each replacement, then commit the
builder. Rewrites record a `replace` snapshot instead of `overwrite`. The
five-argument `ReplaceFiles` API is unchanged and remains available for
compaction and delete-file removals.
- **Path-based data-file addition renamed.** Replace `Transaction.AddFiles`
with `AddDataFilesByPath`, `AddFilesOption` with `AddDataFilesByPathOption`,
and `WithAddFilesConcurrency` with `WithAddDataFilesByPathConcurrency`.
Behavior is unchanged.