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

zeroshade 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 07f52fb3e perf(table): read partition manifests concurrently (#2228)
07f52fb3e is described below

commit 07f52fb3e01421c75a2f163beda31f5ae4ba687a
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 20 19:25:40 2026 +0200

    perf(table): read partition manifests concurrently (#2228)
    
    * perf(table): aggregate partition manifests concurrently
    
    * test(table): cover parallel partition manifest aggregation
    
    * test(perf): benchmark partition metadata manifest reads
    
    * fix(table): repair parallel partition inspection
    
    * fix(table): honor cancellation while merging parallel manifest results
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): cover canceled scans and deterministic partition timestamp 
ties
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): avoid canceled manifest opens
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * style(table): fix lint findings
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/inspect_partitions.go                     | 184 +++++++++++++++++-----
 table/inspect_partitions_parallel_bench_test.go | 112 ++++++++++++++
 table/inspect_partitions_parallel_test.go       | 193 ++++++++++++++++++++++++
 3 files changed, 454 insertions(+), 35 deletions(-)

diff --git a/table/inspect_partitions.go b/table/inspect_partitions.go
index c63dc4877..62f73efba 100644
--- a/table/inspect_partitions.go
+++ b/table/inspect_partitions.go
@@ -28,6 +28,8 @@ import (
        "github.com/apache/arrow-go/v18/arrow/array"
        "github.com/apache/arrow-go/v18/arrow/scalar"
        "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "golang.org/x/sync/errgroup"
 )
 
 type inspectPartitionAggregate struct {
@@ -166,6 +168,10 @@ func (i InspectTable) Partitions(ctx context.Context) 
(array.RecordReader, error
        return rr, nil
 }
 
+// inspectPartitionManifestConcurrency bounds concurrent manifest reads and
+// per-manifest partition aggregation state.
+const inspectPartitionManifestConcurrency = 8
+
 func (i InspectTable) partitionAggregates(ctx context.Context, partitionType 
*iceberg.StructType) ([]inspectPartitionAggregate, error) {
        snapshot := i.tbl.Metadata().CurrentSnapshot()
        if snapshot == nil {
@@ -189,46 +195,72 @@ func (i InspectTable) partitionAggregates(ctx 
context.Context, partitionType *ic
 
        aggregates := make([]*inspectPartitionAggregate, 0)
        aggregateTree := newInspectPartitionAggregateTree()
-       for manifest := range manifests {
-               if err := ctx.Err(); err != nil {
-                       return nil, err
+
+       // Buffer only a bounded number of manifests. Workers group their 
entries
+       // independently, then their results are merged in manifest order.
+       // This keeps equal-timestamp tie-breaking deterministic.
+       processBatch := func(batch []iceberg.ManifestFile) error {
+               if len(batch) == 1 {
+                       return i.foldInspectPartitionManifest(ctx, fs, 
batch[0], partitionType,
+                               snapshotTimes, aggregateTree, &aggregates)
                }
-               for entry, err := range manifest.Entries(fs, true) {
-                       if err != nil {
-                               return nil, err
-                       }
-                       file := entry.DataFile()
-                       partitionValues := dataFilePartition(file)
-                       aggregate := 
aggregateTree.lookupPartition(partitionValues, partitionType)
-                       if aggregate == nil {
-                               partition := 
inspectCoercePartition(partitionValues, partitionType)
-                               record := newPartitionRecord(partition, 
partitionType)
-                               aggregate = &inspectPartitionAggregate{
-                                       partition:   
cloneInspectPartition(partition),
-                                       orderingKey: 
inspectPartitionKey(partition),
+
+               partial := make([][]*inspectPartitionAggregate, len(batch))
+               g, gctx := errgroup.WithContext(ctx)
+               for index, manifest := range batch {
+                       g.Go(func() error {
+                               localTree := newInspectPartitionAggregateTree()
+
+                               return i.foldInspectPartitionManifest(gctx, fs, 
manifest, partitionType,
+                                       snapshotTimes, localTree, 
&partial[index])
+                       })
+               }
+               if err := g.Wait(); err != nil {
+                       return err
+               }
+
+               merged := 0
+               for _, manifestAggregates := range partial {
+                       for _, local := range manifestAggregates {
+                               // A manifest can contain many distinct 
partitions. Remain
+                               // responsive to cancellation during the 
ordered merge, too.
+                               if merged&1023 == 0 {
+                                       if err := ctx.Err(); err != nil {
+                                               return err
+                                       }
                                }
-                               aggregates = append(aggregates, aggregate)
-                               aggregateTree.insert(record, aggregate)
-                       }
+                               merged++
+                               aggregate := 
aggregateTree.lookupPartition(local.partition, partitionType)
+                               if aggregate == nil {
+                                       aggregates = append(aggregates, local)
+                                       
aggregateTree.insert(newPartitionRecord(local.partition, partitionType), local)
 
-                       switch file.ContentType() {
-                       case iceberg.EntryContentData:
-                               aggregate.dataRecordCount += file.Count()
-                               aggregate.dataFileCount++
-                               aggregate.dataFileSize += file.FileSizeBytes()
-                       case iceberg.EntryContentPosDeletes:
-                               aggregate.positionDeleteCount += file.Count()
-                               aggregate.positionDeleteFiles++
-                       case iceberg.EntryContentEqDeletes:
-                               aggregate.equalityDeleteCount += file.Count()
-                               aggregate.equalityDeleteFiles++
+                                       continue
+                               }
+                               mergeInspectPartitionAggregate(aggregate, local)
                        }
+               }
 
-                       if file.ContentType() == iceberg.EntryContentData ||
-                               file.ContentType() == 
iceberg.EntryContentPosDeletes ||
-                               file.ContentType() == 
iceberg.EntryContentEqDeletes {
-                               i.updatePartitionTimestamp(aggregate, 
snapshotTimes, entry.SnapshotID(), file.SpecID())
-                       }
+               return nil
+       }
+
+       batch := make([]iceberg.ManifestFile, 0, 
inspectPartitionManifestConcurrency)
+       for manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               batch = append(batch, manifest)
+               if len(batch) < inspectPartitionManifestConcurrency {
+                       continue
+               }
+               if err := processBatch(batch); err != nil {
+                       return nil, err
+               }
+               batch = batch[:0]
+       }
+       if len(batch) > 0 {
+               if err := processBatch(batch); err != nil {
+                       return nil, err
                }
        }
 
@@ -241,6 +273,88 @@ func (i InspectTable) partitionAggregates(ctx 
context.Context, partitionType *ic
        return result, nil
 }
 
+// foldInspectPartitionManifest performs the original partition accounting 
using
+// a caller-owned tree. Each concurrent worker owns its own tree and result 
slice.
+func (i InspectTable) foldInspectPartitionManifest(
+       ctx context.Context,
+       fs iceio.IO,
+       manifest iceberg.ManifestFile,
+       partitionType *iceberg.StructType,
+       snapshotTimes map[int64]int64,
+       aggregateTree *inspectPartitionAggregateTree,
+       aggregates *[]*inspectPartitionAggregate,
+) error {
+       // Avoid opening remote manifest files after the caller has canceled.
+       if err := ctx.Err(); err != nil {
+               return err
+       }
+
+       entryIndex := 0
+       for entry, err := range manifest.Entries(fs, true) {
+               if err != nil {
+                       return err
+               }
+               if entryIndex&1023 == 0 {
+                       if err := ctx.Err(); err != nil {
+                               return err
+                       }
+               }
+               entryIndex++
+
+               file := entry.DataFile()
+               partitionValues := dataFilePartition(file)
+               aggregate := aggregateTree.lookupPartition(partitionValues, 
partitionType)
+               if aggregate == nil {
+                       partition := inspectCoercePartition(partitionValues, 
partitionType)
+                       record := newPartitionRecord(partition, partitionType)
+                       aggregate = &inspectPartitionAggregate{
+                               partition:   cloneInspectPartition(partition),
+                               orderingKey: inspectPartitionKey(partition),
+                       }
+                       *aggregates = append(*aggregates, aggregate)
+                       aggregateTree.insert(record, aggregate)
+               }
+
+               switch file.ContentType() {
+               case iceberg.EntryContentData:
+                       aggregate.dataRecordCount += file.Count()
+                       aggregate.dataFileCount++
+                       aggregate.dataFileSize += file.FileSizeBytes()
+               case iceberg.EntryContentPosDeletes:
+                       aggregate.positionDeleteCount += file.Count()
+                       aggregate.positionDeleteFiles++
+               case iceberg.EntryContentEqDeletes:
+                       aggregate.equalityDeleteCount += file.Count()
+                       aggregate.equalityDeleteFiles++
+               }
+
+               if file.ContentType() == iceberg.EntryContentData ||
+                       file.ContentType() == iceberg.EntryContentPosDeletes ||
+                       file.ContentType() == iceberg.EntryContentEqDeletes {
+                       i.updatePartitionTimestamp(aggregate, snapshotTimes, 
entry.SnapshotID(), file.SpecID())
+               }
+       }
+
+       return ctx.Err()
+}
+
+func mergeInspectPartitionAggregate(dst, src *inspectPartitionAggregate) {
+       dst.dataRecordCount += src.dataRecordCount
+       dst.dataFileCount += src.dataFileCount
+       dst.dataFileSize += src.dataFileSize
+       dst.positionDeleteCount += src.positionDeleteCount
+       dst.positionDeleteFiles += src.positionDeleteFiles
+       dst.equalityDeleteCount += src.equalityDeleteCount
+       dst.equalityDeleteFiles += src.equalityDeleteFiles
+
+       if src.lastUpdatedAt != nil &&
+               (dst.lastUpdatedAt == nil || *src.lastUpdatedAt > 
*dst.lastUpdatedAt) {
+               dst.lastUpdatedAt = src.lastUpdatedAt
+               dst.lastUpdatedSnapshot = src.lastUpdatedSnapshot
+               dst.specID = src.specID
+       }
+}
+
 func (i InspectTable) updatePartitionTimestamp(
        aggregate *inspectPartitionAggregate,
        snapshotTimes map[int64]int64,
diff --git a/table/inspect_partitions_parallel_bench_test.go 
b/table/inspect_partitions_parallel_bench_test.go
new file mode 100644
index 000000000..fe15160d4
--- /dev/null
+++ b/table/inspect_partitions_parallel_bench_test.go
@@ -0,0 +1,112 @@
+// 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.
+
+package table
+
+import (
+       "bytes"
+       "context"
+       "fmt"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/require"
+)
+
+// BenchmarkInspectPartitionsParallelManifests exercises the full metadata
+// table read, including manifest decoding and partition aggregation.
+// The tracked FileIO adds 10ms latency per manifest open to model remote I/O.
+func BenchmarkInspectPartitionsParallelManifests(b *testing.B) {
+       for _, count := range []int{1, 8, 32} {
+               b.Run(fmt.Sprintf("manifests=%d", count), func(b *testing.B) {
+                       tbl := benchmarkInspectPartitionTable(b, count)
+                       b.ReportAllocs()
+                       b.ResetTimer()
+                       for range b.N {
+                               reader, err := 
tbl.Inspect().Partitions(b.Context())
+                               if err != nil {
+                                       b.Fatal(err)
+                               }
+                               reader.Release()
+                       }
+               })
+       }
+}
+
+func benchmarkInspectPartitionTable(b *testing.B, manifestCount int) *Table {
+       b.Helper()
+
+       const snapshotID = int64(1)
+       sequenceNumber := int64(1)
+       spec := *iceberg.UnpartitionedSpec
+       schema := iceberg.NewSchema(0)
+       meta, err := NewMetadata(schema, &spec, UnsortedSortOrder, 
"mem://inspect-bench/table", nil)
+       require.NoError(b, err)
+       builder, err := MetadataBuilderFromBase(meta, "metadata.json")
+       require.NoError(b, err)
+
+       fs := iceio.NewMemFS()
+       manifests := make([]iceberg.ManifestFile, 0, manifestCount)
+       for index := range manifestCount {
+               dataPath := 
fmt.Sprintf("mem://inspect-bench/table/data/file-%d.parquet", index)
+               dataFileBuilder, err := iceberg.NewDataFileBuilder(
+                       spec, iceberg.EntryContentData, dataPath, 
iceberg.ParquetFile,
+                       nil, nil, nil, 100, 1_024,
+               )
+               require.NoError(b, err)
+
+               manifestPath := 
fmt.Sprintf("mem://inspect-bench/table/metadata/inspect-%d.avro", index)
+               var manifestBuf bytes.Buffer
+               manifest, err := iceberg.WriteManifest(
+                       manifestPath, &manifestBuf, 2, spec, schema, snapshotID,
+                       []iceberg.ManifestEntry{
+                               
iceberg.NewManifestEntry(iceberg.EntryStatusADDED,
+                                       new(snapshotID), &sequenceNumber, 
&sequenceNumber, dataFileBuilder.Build()),
+                       },
+               )
+               require.NoError(b, err)
+               require.NoError(b, fs.WriteFile(manifestPath, 
manifestBuf.Bytes()))
+               manifests = append(manifests, manifest)
+       }
+
+       manifestListPath := 
"mem://inspect-bench/table/metadata/manifest-list.avro"
+       var listBuf bytes.Buffer
+       require.NoError(b, iceberg.WriteManifestList(
+               2, &listBuf, snapshotID, nil, &sequenceNumber, 0, manifests,
+       ))
+       require.NoError(b, fs.WriteFile(manifestListPath, listBuf.Bytes()))
+
+       schemaID := schema.ID
+       require.NoError(b, builder.AddSnapshot(&Snapshot{
+               SnapshotID:     snapshotID,
+               SequenceNumber: sequenceNumber,
+               TimestampMs:    meta.LastUpdatedMillis() + 1,
+               ManifestList:   manifestListPath,
+               SchemaID:       &schemaID,
+               Summary:        &Summary{Operation: OpAppend},
+       }))
+       require.NoError(b, builder.SetSnapshotRef(MainBranch, snapshotID, 
BranchRef))
+
+       built, err := builder.Build()
+       require.NoError(b, err)
+
+       delayed := &inspectPartitionManifestTrackingIO{IO: fs}
+
+       return New(Identifier{"db", "inspect-bench"}, built, "metadata.json",
+               func(context.Context) (iceio.IO, error) { return delayed, nil 
}, nil)
+}
diff --git a/table/inspect_partitions_parallel_test.go 
b/table/inspect_partitions_parallel_test.go
new file mode 100644
index 000000000..d03e9c6c6
--- /dev/null
+++ b/table/inspect_partitions_parallel_test.go
@@ -0,0 +1,193 @@
+// 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.
+
+package table
+
+import (
+       "context"
+       "fmt"
+       "strings"
+       "sync/atomic"
+       "testing"
+       "time"
+
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/require"
+)
+
+type inspectPartitionManifestTrackingIO struct {
+       iceio.IO
+       active    atomic.Int32
+       maxActive atomic.Int32
+       failPath  string
+}
+
+func (f *inspectPartitionManifestTrackingIO) Open(path string) (iceio.File, 
error) {
+       if strings.Contains(path, "/metadata/inspect-") && 
strings.HasSuffix(path, ".avro") {
+               if f.failPath != "" && strings.Contains(path, f.failPath) {
+                       return nil, fmt.Errorf("injected manifest read error 
for %s", path)
+               }
+
+               active := f.active.Add(1)
+               for {
+                       maxActive := f.maxActive.Load()
+                       if active <= maxActive || 
f.maxActive.CompareAndSwap(maxActive, active) {
+                               break
+                       }
+               }
+               // Deliberately delay only manifest opens to expose concurrent 
I/O
+               // without depending on timing assertions.
+               time.Sleep(10 * time.Millisecond)
+               f.active.Add(-1)
+       }
+
+       return f.IO.Open(path)
+}
+
+func inspectParallelPartitionManifestTable(t *testing.T, count int) *Table {
+       t.Helper()
+
+       spec := partitionedSpec()
+       const snapshotID = int64(1)
+       sequenceNumber := int64(1)
+       manifests := make([]inspectManifestSpec, 0, count)
+       for index := range count {
+               files := []struct {
+                       partition int32
+                       count     int64
+               }{
+                       {partition: 7, count: 3},
+                       {partition: 8, count: 5},
+               }
+               entries := make([]iceberg.ManifestEntry, 0, len(files))
+               for _, f := range files {
+                       path := 
fmt.Sprintf("mem://default/table-location/data/parallel-%d-%d.parquet", index, 
f.partition)
+                       dataFile := newTestDataFileWithCount(t, spec, path, 
map[int]any{1000: f.partition}, f.count)
+                       entries = append(entries, iceberg.NewManifestEntry(
+                               iceberg.EntryStatusADDED, new(snapshotID),
+                               &sequenceNumber, &sequenceNumber, dataFile,
+                       ))
+               }
+               manifests = append(manifests, inspectManifestSpec{
+                       content: iceberg.ManifestContentData,
+                       entries: entries,
+               })
+       }
+
+       return inspectFilesTableWithManifests(t, spec, manifests...)
+}
+
+func inspectWithTrackingIO(t *testing.T, tbl *Table) (*Table, 
*inspectPartitionManifestTrackingIO) {
+       t.Helper()
+
+       fs, err := tbl.FS(t.Context())
+       require.NoError(t, err)
+
+       tracked := &inspectPartitionManifestTrackingIO{IO: fs}
+       wrapped := New(Identifier{"db", "parallel-inspect"}, tbl.Metadata(), 
tbl.MetadataLocation(),
+               func(context.Context) (iceio.IO, error) { return tracked, nil 
}, nil)
+
+       return wrapped, tracked
+}
+
+func TestInspectPartitionsParallelManifestAggregation(t *testing.T) {
+       for _, count := range []int{1, 8, 17} {
+               t.Run(fmt.Sprintf("manifests=%d", count), func(t *testing.T) {
+                       tbl, tracked := inspectWithTrackingIO(t, 
inspectParallelPartitionManifestTable(t, count))
+
+                       reader, err := tbl.Inspect().Partitions(t.Context())
+                       require.NoError(t, err)
+                       defer reader.Release()
+
+                       record := collectRecord(t, reader)
+                       defer record.Release()
+                       require.EqualValues(t, 2, record.NumRows())
+
+                       partitions := 
record.Column(0).(*array.Struct).Field(0).(*array.Int32)
+                       recordCounts := record.Column(2).(*array.Int64)
+                       fileCounts := record.Column(3).(*array.Int32)
+                       lastSnapshot := record.Column(10).(*array.Int64)
+                       for index, partition := range []int32{7, 8} {
+                               require.Equal(t, partition, 
partitions.Value(index))
+                               wantRows := int64(count) * 3
+                               if partition == 8 {
+                                       wantRows = int64(count) * 5
+                               }
+                               require.Equal(t, wantRows, 
recordCounts.Value(index))
+                               require.EqualValues(t, count, 
fileCounts.Value(index))
+                               require.EqualValues(t, 1, 
lastSnapshot.Value(index))
+                       }
+                       require.LessOrEqual(t, tracked.maxActive.Load(), 
int32(inspectPartitionManifestConcurrency))
+                       if count > 1 {
+                               require.GreaterOrEqual(t, 
tracked.maxActive.Load(), int32(2))
+                       }
+               })
+       }
+}
+
+func TestInspectPartitionsParallelManifestReadFailure(t *testing.T) {
+       tbl, tracked := inspectWithTrackingIO(t, 
inspectParallelPartitionManifestTable(t, 8))
+       tracked.failPath = "inspect-3.avro"
+
+       reader, err := tbl.Inspect().Partitions(t.Context())
+       require.ErrorContains(t, err, "injected manifest read error")
+       require.Nil(t, reader)
+       require.EqualValues(t, 0, tracked.active.Load())
+}
+
+func TestInspectPartitionsParallelCancelledContext(t *testing.T) {
+       tbl, tracked := inspectWithTrackingIO(t, 
inspectParallelPartitionManifestTable(t, 8))
+       ctx, cancel := context.WithCancel(t.Context())
+       cancel()
+
+       reader, err := tbl.Inspect().Partitions(ctx)
+       require.ErrorIs(t, err, context.Canceled)
+       require.Nil(t, reader)
+       require.Zero(t, tracked.maxActive.Load())
+}
+
+func TestMergeInspectPartitionAggregateTimestampTies(t *testing.T) {
+       const timestamp = int64(123000)
+       firstSnapshot := int64(10)
+       secondSnapshot := int64(20)
+       dst := &inspectPartitionAggregate{
+               dataRecordCount: 3, dataFileCount: 1, specID: 1,
+               lastUpdatedAt: new(timestamp), lastUpdatedSnapshot: 
&firstSnapshot,
+       }
+       sameTime := &inspectPartitionAggregate{
+               dataRecordCount: 5, dataFileCount: 2, specID: 2,
+               lastUpdatedAt: new(timestamp), lastUpdatedSnapshot: 
&secondSnapshot,
+       }
+       mergeInspectPartitionAggregate(dst, sameTime)
+
+       // The earlier manifest wins a timestamp tie, but counts still add up.
+       require.EqualValues(t, 8, dst.dataRecordCount)
+       require.EqualValues(t, 3, dst.dataFileCount)
+       require.EqualValues(t, 1, dst.specID)
+       require.Equal(t, firstSnapshot, *dst.lastUpdatedSnapshot)
+
+       newerTime := timestamp + 1
+       newer := &inspectPartitionAggregate{
+               specID: 3, lastUpdatedAt: &newerTime, lastUpdatedSnapshot: 
&secondSnapshot,
+       }
+       mergeInspectPartitionAggregate(dst, newer)
+       require.EqualValues(t, 3, dst.specID)
+       require.Equal(t, newerTime, *dst.lastUpdatedAt)
+       require.Equal(t, secondSnapshot, *dst.lastUpdatedSnapshot)
+}

Reply via email to