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 c5661c0ca feat(table): write and register v1 partition statistics 
(#2396)
c5661c0ca is described below

commit c5661c0caab434d72861424b5239c62708baaf13
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 19:03:48 2026 +0100

    feat(table): write and register v1 partition statistics (#2396)
    
    * feat(table): compute and write v1 partition statistics
    
    * test(table): cover v1 partition statistics schema, sorting and metadata 
updates
    
    * fix(table): order binary and UUID partition tuples without reflection 
copy panic
    
    * test(table): use schema field slice in partition statistics assertions
    
    * test(table): verify partition statistics from live manifests and deleted 
entries
    
    * fix(table): guard partition statistics for unsupported layouts
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): refuse duplicate files in partition statistics
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): sort decimal partition statistics by numeric value
    
    * test(table): cover decimal partition statistics ordering
    
    * feat(table): support embedded partition statistics manifests
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): infer omitted v1 embedded manifest spec IDs
    
    * fix(table): accept legacy v1 fields without explicit IDs
    
    * test(table): cover optional v1 partition spec IDs
    
    * fix(table): match Java partition statistics ordering and API
    
    * test(table): cover float and UUID partition statistics sorting
    
    * style(table): separate early returns in partition statistics comparator
    
    * refactor(table): share embedded v1 manifest spec parsing with audit
    
    * fix(table): normalize decoded decimal partition statistics
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/embedded_manifest_spec.go          |  77 ++++
 table/embedded_manifest_spec_test.go     |  89 +++++
 table/partition_statistics_write.go      | 652 +++++++++++++++++++++++++++++++
 table/partition_statistics_write_test.go | 614 +++++++++++++++++++++++++++++
 4 files changed, 1432 insertions(+)

diff --git a/table/embedded_manifest_spec.go b/table/embedded_manifest_spec.go
new file mode 100644
index 000000000..503b6ed6f
--- /dev/null
+++ b/table/embedded_manifest_spec.go
@@ -0,0 +1,77 @@
+// 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 (
+       "encoding/json"
+       "fmt"
+       "strconv"
+
+       "github.com/apache/iceberg-go"
+)
+
+// embeddedManifestSpecHeader is the partition spec from a v1 Avro manifest
+// header. Legacy writers may omit the spec ID or individual field IDs.
+type embeddedManifestSpecHeader struct {
+       id     int32
+       hasID  bool
+       fields []iceberg.PartitionField
+}
+
+func parseEmbeddedManifestSpecHeader(metadata map[string][]byte) 
(embeddedManifestSpecHeader, error) {
+       var header embeddedManifestSpecHeader
+       if raw, present := metadata["partition-spec-id"]; present {
+               id, err := strconv.ParseInt(string(raw), 10, 32)
+               if err != nil || id < 0 {
+                       return header, fmt.Errorf("invalid partition-spec-id 
%q", raw)
+               }
+               header.id, header.hasID = int32(id), true
+       }
+       if err := json.Unmarshal(metadata["partition-spec"], &header.fields); 
err != nil {
+               return header, fmt.Errorf("invalid v1 partition-spec: %w", err)
+       }
+       for i := range header.fields {
+               if header.fields[i].FieldID == 0 {
+                       header.fields[i].FieldID = iceberg.PartitionDataIDStart 
+ i
+               }
+       }
+
+       return header, nil
+}
+
+// resolve uses a header ID if present, including unknown IDs that the caller
+// may want to report. Without one, it only infers an ID from exactly one
+// matching historical spec. The caller decides whether an absent or ambiguous
+// match is inconclusive (audit) or an error (statistics writer).
+func (h embeddedManifestSpecHeader) resolve(specs 
map[int32]iceberg.PartitionSpec) (id int32, known, ambiguous bool) {
+       if h.hasID {
+               return h.id, true, false
+       }
+       for candidateID, candidate := range specs {
+               actual := iceberg.NewPartitionSpecID(int(candidateID), 
h.fields...)
+               if !actual.Equals(candidate) {
+                       continue
+               }
+               if known {
+                       return 0, false, true
+               }
+               id, known = candidateID, true
+       }
+
+       return id, known, false
+}
diff --git a/table/embedded_manifest_spec_test.go 
b/table/embedded_manifest_spec_test.go
new file mode 100644
index 000000000..794bdb130
--- /dev/null
+++ b/table/embedded_manifest_spec_test.go
@@ -0,0 +1,89 @@
+// 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 (
+       "encoding/json"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/require"
+)
+
+func TestEmbeddedManifestSpecHeaderResolution(t *testing.T) {
+       field := iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id", Transform: 
iceberg.IdentityTransform{},
+       }
+       spec := iceberg.NewPartitionSpecID(7, field)
+       sameFields := iceberg.NewPartitionSpecID(8, field)
+       fields, err := json.Marshal([]iceberg.PartitionField{field})
+       require.NoError(t, err)
+
+       header, err := 
parseEmbeddedManifestSpecHeader(map[string][]byte{"partition-spec": fields})
+       require.NoError(t, err)
+       id, known, ambiguous := 
header.resolve(map[int32]iceberg.PartitionSpec{7: spec})
+       require.Equal(t, int32(7), id)
+       require.True(t, known)
+       require.False(t, ambiguous)
+
+       _, known, ambiguous = header.resolve(map[int32]iceberg.PartitionSpec{7: 
spec, 8: sameFields})
+       require.False(t, known)
+       require.True(t, ambiguous)
+
+       _, known, ambiguous = header.resolve(map[int32]iceberg.PartitionSpec{})
+       require.False(t, known)
+       require.False(t, ambiguous)
+
+       // Old v1 headers can omit each field-id, which starts at 1000.
+       var rawFields []map[string]json.RawMessage
+       require.NoError(t, json.Unmarshal(fields, &rawFields))
+       delete(rawFields[0], "field-id")
+       fields, err = json.Marshal(rawFields)
+       require.NoError(t, err)
+       header, err = 
parseEmbeddedManifestSpecHeader(map[string][]byte{"partition-spec": fields})
+       require.NoError(t, err)
+       require.Equal(t, 1000, header.fields[0].FieldID)
+       id, known, ambiguous = 
header.resolve(map[int32]iceberg.PartitionSpec{7: spec})
+       require.Equal(t, int32(7), id)
+       require.True(t, known)
+       require.False(t, ambiguous)
+
+       // Explicit IDs are returned even when the table has no such spec, so
+       // validation can report the unknown ID rather than silently skipping 
it.
+       header, err = parseEmbeddedManifestSpecHeader(map[string][]byte{
+               "partition-spec": fields, "partition-spec-id": []byte("42"),
+       })
+       require.NoError(t, err)
+       id, known, ambiguous = 
header.resolve(map[int32]iceberg.PartitionSpec{7: spec})
+       require.Equal(t, int32(42), id)
+       require.True(t, known)
+       require.False(t, ambiguous)
+}
+
+func TestEmbeddedManifestSpecHeaderRejectsInvalidMetadata(t *testing.T) {
+       for _, value := range []string{"", "-1", "abc", "2147483648"} {
+               t.Run(value, func(t *testing.T) {
+                       _, err := 
parseEmbeddedManifestSpecHeader(map[string][]byte{
+                               "partition-spec": []byte("[]"), 
"partition-spec-id": []byte(value),
+                       })
+                       require.ErrorContains(t, err, "invalid 
partition-spec-id")
+               })
+       }
+       _, err := 
parseEmbeddedManifestSpecHeader(map[string][]byte{"partition-spec": 
[]byte("{bad")})
+       require.ErrorContains(t, err, "invalid v1 partition-spec")
+}
diff --git a/table/partition_statistics_write.go 
b/table/partition_statistics_write.go
new file mode 100644
index 000000000..0d6bfe854
--- /dev/null
+++ b/table/partition_statistics_write.go
@@ -0,0 +1,652 @@
+// 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"
+       "encoding/binary"
+       "fmt"
+       "math"
+       "reflect"
+       "slices"
+
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/parquet"
+       "github.com/apache/arrow-go/v18/parquet/pqarrow"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/google/uuid"
+       "github.com/twmb/avro/ocf"
+)
+
+// PartitionStatisticsSchemaV1 is the v1 partition statistics file schema.
+// It uses the unified partition type across all partition specs, not the
+// current spec's partition type. Optional delete metrics are omitted in v1.
+//
+// https://iceberg.apache.org/spec/#partition-statistics-file
+func PartitionStatisticsSchemaV1(partitionType *iceberg.StructType) 
*iceberg.Schema {
+       return iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "partition", Type: 
partitionType, Required: true},
+               iceberg.NestedField{ID: 2, Name: "spec_id", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+               iceberg.NestedField{ID: 3, Name: "data_record_count", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+               iceberg.NestedField{ID: 4, Name: "data_file_count", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+               iceberg.NestedField{ID: 5, Name: 
"total_data_file_size_in_bytes", Type: iceberg.PrimitiveTypes.Int64, Required: 
true},
+               iceberg.NestedField{ID: 10, Name: "total_record_count", Type: 
iceberg.PrimitiveTypes.Int64, Required: false},
+       )
+}
+
+// SetPartitionStatistics registers a previously written partition statistics
+// file with a snapshot. Files must follow the Iceberg partition statistics
+// file spec. This stages a metadata update; Commit publishes the reference.
+// Re-registering a snapshot replaces its existing statistics file reference.
+func (t *Transaction) SetPartitionStatistics(stats PartitionStatisticsFile) 
error {
+       meta, err := t.txnMeta()
+       if err != nil {
+               return err
+       }
+       if stats.StatisticsPath == "" || stats.FileSizeInBytes <= 0 {
+               return fmt.Errorf("%w: partition statistics path must be 
nonempty and file size must be positive",
+                       iceberg.ErrInvalidArgument)
+       }
+       if _, err := meta.SnapshotByID(stats.SnapshotID); err != nil {
+               return fmt.Errorf("set partition statistics: %w", err)
+       }
+
+       return t.apply([]Update{NewSetPartitionStatisticsUpdate(stats)}, nil)
+}
+
+// RemovePartitionStatistics removes the partition statistics file reference
+// associated with snapshotID. It does not delete the physical statistics file.
+func (t *Transaction) RemovePartitionStatistics(snapshotID int64) error {
+       if _, err := t.txnMeta(); err != nil {
+               return err
+       }
+
+       return 
t.apply([]Update{NewRemovePartitionStatisticsUpdate(snapshotID)}, nil)
+}
+
+// ComputePartitionStatistics computes and writes a Parquet partition 
statistics
+// file for the transaction's current v1 branch head, and stages its metadata
+// reference. The caller must Commit to publish the reference. It does not
+// rewrite any data files or create a new table snapshot.
+//
+// The statistics are derived from the live entries in the head's manifests,
+// grouped by both partition spec ID and partition tuple. The file is sorted by
+// the unified partition tuple (ascending, null first), then by spec ID for
+// deterministic ties. Only tables with at least one partition field are
+// currently supported.
+// Partition fields whose source was dropped or whose result type changed are
+// not yet supported. Embedded manifests may omit the v1 partition spec ID 
when the header
+// fields identify exactly one historical spec.
+//
+// Use a fresh transaction: a staged schema, spec, or snapshot change would
+// make the original head's partition type ambiguous. The branch head is pinned
+// to prevent an OCC retry from publishing statistics for a different snapshot.
+// The returned file may become orphaned if Commit fails; orphan-file cleanup
+// can remove it once it is no longer needed.
+func (t *Transaction) ComputePartitionStatistics(ctx context.Context) 
(*PartitionStatisticsFile, error) {
+       meta, err := t.txnMeta()
+       if err != nil {
+               return nil, err
+       }
+       if t.tbl == nil || t.tbl.Metadata() == nil {
+               return nil, fmt.Errorf("%w: partition statistics require a 
table", iceberg.ErrInvalidArgument)
+       }
+       if meta.formatVersion != 1 {
+               return nil, fmt.Errorf("%w: partition statistics computation 
currently supports format v1 only",
+                       iceberg.ErrNotImplemented)
+       }
+       if meta.HasChanges() {
+               return nil, fmt.Errorf("%w: compute partition statistics using 
a fresh transaction",
+                       iceberg.ErrInvalidArgument)
+       }
+
+       snapshot := t.planningSnapshot(meta)
+       if snapshot == nil {
+               return nil, fmt.Errorf("%w: cannot compute partition statistics 
without a snapshot",
+                       iceberg.ErrInvalidArgument)
+       }
+       if err := validatePartitionStatisticsSourceTypes(t.tbl.Metadata()); err 
!= nil {
+               return nil, err
+       }
+       partitionType, err := inspectPartitionType(t.tbl.Metadata())
+       if err != nil {
+               return nil, fmt.Errorf("partition statistics: %w", err)
+       }
+       if partitionType == nil || len(partitionType.FieldList) == 0 {
+               return nil, fmt.Errorf("%w: partition statistics for 
unpartitioned tables",
+                       iceberg.ErrNotImplemented)
+       }
+       // This fences the snapshot whose manifests are about to be read. A
+       // concurrent append/overwrite on this branch must not silently rebase 
the
+       // stats registration to that newer head.
+       if err := t.AssertRefSnapshotID(""); err != nil {
+               return nil, err
+       }
+       if err := t.apply(nil, []Requirement{
+               AssertCurrentSchemaID(t.tbl.Metadata().CurrentSchema().ID),
+               AssertDefaultSpecID(t.tbl.Metadata().DefaultPartitionSpec()),
+               
AssertLastAssignedPartitionID(lastAssignedPartitionID(t.tbl.Metadata())),
+       }); err != nil {
+               return nil, err
+       }
+
+       fs, err := t.tbl.FS(ctx)
+       if err != nil {
+               return nil, err
+       }
+       wfs, err := requireWriteFileIO(fs)
+       if err != nil {
+               return nil, err
+       }
+       rows, err := collectPartitionStatistics(ctx, fs, *snapshot, 
partitionType, t.tbl.Metadata().PartitionSpecs())
+       if err != nil {
+               return nil, fmt.Errorf("partition statistics: %w", err)
+       }
+       if err := sortPartitionStatistics(rows, partitionType); err != nil {
+               return nil, fmt.Errorf("partition statistics: %w", err)
+       }
+
+       locationProvider, err := t.tbl.LocationProvider()
+       if err != nil {
+               return nil, err
+       }
+       path := locationProvider.NewMetadataLocation(uuid.NewString() + 
"-partition-stats.parquet")
+       size, err := writePartitionStatisticsParquet(ctx, wfs, path, 
partitionType, rows)
+       if err != nil {
+               return nil, err
+       }
+
+       stats := PartitionStatisticsFile{
+               SnapshotID:      snapshot.SnapshotID,
+               StatisticsPath:  path,
+               FileSizeInBytes: size,
+       }
+       if err := t.SetPartitionStatistics(stats); err != nil {
+               _ = wfs.Remove(context.WithoutCancel(ctx), path)
+
+               return nil, err
+       }
+
+       return &stats, nil
+}
+
+// validatePartitionStatisticsSourceTypes rejects historical partition fields
+// whose source is missing or whose result type changed. The unified partition
+// type would otherwise omit historical values or misrepresent their types.
+func validatePartitionStatisticsSourceTypes(meta Metadata) error {
+       current := meta.CurrentSchema()
+       for _, spec := range meta.PartitionSpecs() {
+               for _, field := range spec.Fields() {
+                       for _, id := range field.SourceIDs {
+                               typ, ok := current.FindTypeByID(id)
+                               if !ok {
+                                       return fmt.Errorf("%w: partition source 
field %d was dropped", iceberg.ErrNotImplemented, id)
+                               }
+                               for _, schema := range meta.Schemas() {
+                                       if old, exists := 
schema.FindTypeByID(id); exists &&
+                                               
!field.Transform.ResultType(typ).Equals(field.Transform.ResultType(old)) {
+                                               return fmt.Errorf("%w: 
partition source field %d changed result type", iceberg.ErrNotImplemented, id)
+                                       }
+                               }
+                       }
+               }
+       }
+
+       return nil
+}
+
+// Embedded v1 paths lack manifest-list descriptors. Read and validate the
+// header spec before constructing a descriptor with the real spec ID.
+func partitionStatisticsEmbeddedManifest(ctx context.Context, fs iceio.IO, 
manifest iceberg.ManifestFile, specs map[int32]iceberg.PartitionSpec) 
(iceberg.ManifestFile, error) {
+       f, err := fs.Open(ctx, manifest.FilePath())
+       if err != nil {
+               return nil, err
+       }
+       defer f.Close()
+       ocfReader, err := ocf.NewReader(f)
+       if err != nil {
+               return nil, fmt.Errorf("read embedded manifest %q: %w", 
manifest.FilePath(), err)
+       }
+       header, parseErr := 
parseEmbeddedManifestSpecHeader(ocfReader.Metadata())
+       if err := ocfReader.Close(); err != nil {
+               return nil, err
+       }
+       if parseErr != nil {
+               return nil, fmt.Errorf("%w: embedded manifest %q: %v",
+                       iceberg.ErrInvalidArgument, manifest.FilePath(), 
parseErr)
+       }
+
+       id, found, ambiguous := header.resolve(specs)
+       if !found {
+               if ambiguous {
+                       return nil, fmt.Errorf("%w: embedded manifest %q has an 
ambiguous partition spec without an ID",
+                               iceberg.ErrNotImplemented, manifest.FilePath())
+               }
+
+               return nil, fmt.Errorf("%w: embedded manifest %q has no 
matching partition spec",
+                       iceberg.ErrInvalidArgument, manifest.FilePath())
+       }
+       expected, ok := specs[id]
+       if !ok {
+               return nil, fmt.Errorf("%w: embedded manifest %q references 
unknown partition spec %d",
+                       iceberg.ErrInvalidArgument, manifest.FilePath(), id)
+       }
+       actual := iceberg.NewPartitionSpecID(int(id), header.fields...)
+       if !actual.Equals(expected) {
+               return nil, fmt.Errorf("%w: embedded manifest %q partition spec 
differs from table metadata",
+                       iceberg.ErrInvalidArgument, manifest.FilePath())
+       }
+       // Entries will open and validate the descriptor through 
ManifestFile.Entries.
+       // Constructing and closing another reader here only repeats that work.
+       return iceberg.NewManifestBuilder(1, manifest.FilePath(), 
manifest.Length(), id, manifest.SnapshotID()).
+               
AddedFiles(-1).ExistingFiles(-1).DeletedFiles(-1).AddedRows(-1).ExistingRows(-1).DeletedRows(-1).Build(),
 nil
+}
+
+// collectPartitionStatistics reads only manifest metadata, not Parquet data.
+// Each partition spec gets a separate lookup tree: partitions that have the
+// same tuple under two spec IDs are distinct statistics rows.
+func collectPartitionStatistics(
+       ctx context.Context,
+       fs iceio.IO,
+       snapshot Snapshot,
+       partitionType *iceberg.StructType,
+       specs []iceberg.PartitionSpec,
+) ([]*inspectPartitionAggregate, error) {
+       specByID := make(map[int32]iceberg.PartitionSpec, len(specs))
+       for _, spec := range specs {
+               specByID[int32(spec.ID())] = spec
+       }
+       manifests, err := snapshot.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+
+       trees := make(map[int32]*inspectPartitionAggregateTree)
+       seenPaths := make(map[string]string)
+       rows := make([]*inspectPartitionAggregate, 0)
+       for _, manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               if snapshot.ManifestList == "" && snapshot.ManifestLocations != 
nil {
+                       manifest, err = 
partitionStatisticsEmbeddedManifest(ctx, fs, manifest, specByID)
+                       if err != nil {
+                               return nil, err
+                       }
+               }
+               specID := manifest.PartitionSpecID()
+               spec, ok := specByID[specID]
+               if !ok {
+                       return nil, fmt.Errorf("%w: manifest %q references 
unknown partition spec %d", iceberg.ErrInvalidArgument, manifest.FilePath(), 
specID)
+               }
+               tree := trees[specID]
+               if tree == nil {
+                       tree = newInspectPartitionAggregateTree()
+                       trees[specID] = tree
+               }
+
+               for entry, err := range manifest.Entries(fs, true) {
+                       if err != nil {
+                               return nil, fmt.Errorf("read manifest %s: %w", 
manifest.FilePath(), err)
+                       }
+                       if err := ctx.Err(); err != nil {
+                               return nil, err
+                       }
+
+                       if entry == nil || entry.DataFile() == nil {
+                               return nil, fmt.Errorf("%w: manifest %q 
contains a nil data file",
+                                       iceberg.ErrInvalidArgument, 
manifest.FilePath())
+                       }
+                       file := entry.DataFile()
+                       path := file.FilePath()
+                       if path == "" {
+                               return nil, fmt.Errorf("%w: manifest %q 
contains an empty data file path",
+                                       iceberg.ErrInvalidArgument, 
manifest.FilePath())
+                       }
+                       if previous, duplicate := seenPaths[path]; duplicate {
+                               return nil, fmt.Errorf("%w: duplicate live data 
file %q in manifests %q and %q",
+                                       iceberg.ErrInvalidArgument, path, 
previous, manifest.FilePath())
+                       }
+                       seenPaths[path] = manifest.FilePath()
+                       if file.ContentType() != iceberg.EntryContentData {
+                               return nil, fmt.Errorf("%w: v1 manifest %s 
contains a delete file",
+                                       iceberg.ErrInvalidArgument, 
manifest.FilePath())
+                       }
+                       if file.Count() < 0 || file.FileSizeBytes() < 0 {
+                               return nil, fmt.Errorf("%w: file %s has a 
negative count or size",
+                                       iceberg.ErrInvalidArgument, 
file.FilePath())
+                       }
+                       values := dataFilePartition(file)
+                       if err := validatePartitionStatisticsTuple(spec, 
file.FilePath(), values); err != nil {
+                               return nil, err
+                       }
+                       partition := inspectCoercePartition(values, 
partitionType)
+                       for _, field := range partitionType.FieldList {
+                               value := partition[field.ID]
+                               // Manifest decoding uses DecimalLiteral, while 
callers can
+                               // supply Decimal. Normalize before building 
aggregate keys
+                               // so both representations count toward the 
same partition.
+                               if literal, ok := 
value.(iceberg.DecimalLiteral); ok {
+                                       value = iceberg.Decimal(literal)
+                                       partition[field.ID] = value
+                               }
+                               if decimal, ok := value.(iceberg.Decimal); ok {
+                                       if typ, ok := 
field.Type.(iceberg.DecimalType); ok && decimal.Scale != typ.Scale() {
+                                               return nil, fmt.Errorf("%w: 
partition field %q has decimal scale %d, expected %d",
+                                                       
iceberg.ErrInvalidArgument, field.Name, decimal.Scale, typ.Scale())
+                                       }
+                               }
+                               if _, err := 
comparePartitionStatisticValues(value, value); err != nil {
+                                       return nil, fmt.Errorf("partition field 
%q: %w", field.Name, err)
+                               }
+                       }
+                       row := tree.lookupPartition(partition, partitionType)
+                       if row == nil {
+                               row = &inspectPartitionAggregate{
+                                       specID:    specID,
+                                       partition: 
cloneInspectPartition(partition),
+                               }
+                               rows = append(rows, row)
+                               tree.insert(newPartitionRecord(partition, 
partitionType), row)
+                       }
+                       if row.dataFileCount == math.MaxInt32 ||
+                               row.dataRecordCount > 
math.MaxInt64-file.Count() ||
+                               row.dataFileSize > 
math.MaxInt64-file.FileSizeBytes() {
+                               return nil, fmt.Errorf("%w: partition 
statistics count overflow",
+                                       iceberg.ErrInvalidArgument)
+                       }
+                       row.dataFileCount++
+                       row.dataRecordCount += file.Count()
+                       row.dataFileSize += file.FileSizeBytes()
+               }
+       }
+
+       return rows, nil
+}
+
+func validatePartitionStatisticsTuple(spec iceberg.PartitionSpec, path string, 
values map[int]any) error {
+       for _, field := range spec.Fields() {
+               if _, exists := values[field.FieldID]; !exists {
+                       return fmt.Errorf("%w: file %q is missing partition 
field %d", iceberg.ErrInvalidArgument, path, field.FieldID)
+               }
+       }
+
+       return nil
+}
+
+// sortPartitionStatistics obeys the spec's NULL FIRST tuple ordering, not
+// inspectPartitionKey's text ordering (where "10" sorts before "2").
+// A spec ID only breaks equal tuple ties; it is not the primary sort key.
+func sortPartitionStatistics(rows []*inspectPartitionAggregate, partitionType 
*iceberg.StructType) error {
+       // Collection already validates each partition value before using it as
+       // an aggregate key. Do not repeat that validation for every output row.
+       var sortErr error
+       slices.SortStableFunc(rows, func(a, b *inspectPartitionAggregate) int {
+               for _, field := range partitionType.FieldList {
+                       comp, err := 
comparePartitionStatisticValues(a.partition[field.ID], b.partition[field.ID])
+                       if err != nil {
+                               sortErr = fmt.Errorf("partition field %q: %w", 
field.Name, err)
+
+                               return 0
+                       }
+                       if comp != 0 {
+                               return comp
+                       }
+               }
+               switch {
+               case a.specID < b.specID:
+                       return -1
+               case a.specID > b.specID:
+                       return 1
+               default:
+                       return 0
+               }
+       })
+
+       return sortErr
+}
+
+// comparePartitionStatisticValues compares primitive partition values by
+// their underlying types, including named date/time integer types and binary
+// UUIDs. Unsupported types are rejected instead of emitting a file whose
+// partition ordering violates the Iceberg spec.
+func comparePartitionStatisticValues(a, b any) (int, error) {
+       if a == nil || b == nil {
+               if a == nil && b == nil {
+                       return 0, nil
+               }
+               if a == nil {
+                       return -1, nil
+               }
+
+               return 1, nil
+       }
+       // Decimal partitions sort by their numeric value, not by the underlying
+       // Decimal struct or the lexicographic encoding of the unscaled integer.
+       if left, ok := a.(iceberg.Decimal); ok {
+               right, valid := b.(iceberg.Decimal)
+               if !valid || left.Scale != right.Scale {
+                       return 0, fmt.Errorf("%w: incompatible decimal 
partition values %v and %v",
+                               iceberg.ErrInvalidArgument, a, b)
+               }
+
+               return left.Val.Cmp(right.Val), nil
+       }
+       // Java's UUID.compareTo orders each 64-bit half as a signed integer.
+       // Binary and fixed partitions retain unsigned lexicographic ordering.
+       if left, ok := a.(uuid.UUID); ok {
+               right, valid := b.(uuid.UUID)
+               if !valid {
+                       return 0, fmt.Errorf("%w: incompatible partition values 
%T and %T",
+                               iceberg.ErrInvalidArgument, a, b)
+               }
+               if cmp := 
compareOrderedStats(int64(binary.BigEndian.Uint64(left[:8])),
+                       int64(binary.BigEndian.Uint64(right[:8]))); cmp != 0 {
+                       return cmp, nil
+               }
+
+               return 
compareOrderedStats(int64(binary.BigEndian.Uint64(left[8:])),
+                       int64(binary.BigEndian.Uint64(right[8:]))), nil
+       }
+       if _, ok := b.(uuid.UUID); ok {
+               return 0, fmt.Errorf("%w: incompatible partition values %T and 
%T",
+                       iceberg.ErrInvalidArgument, a, b)
+       }
+       left, right := reflect.ValueOf(a), reflect.ValueOf(b)
+       if left.Kind() != right.Kind() {
+               return 0, fmt.Errorf("%w: incompatible partition values %T and 
%T",
+                       iceberg.ErrInvalidArgument, a, b)
+       }
+
+       switch left.Kind() {
+       case reflect.Bool:
+               if left.Bool() == right.Bool() {
+                       return 0, nil
+               }
+               if !left.Bool() {
+                       return -1, nil
+               }
+
+               return 1, nil
+       case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, 
reflect.Int64:
+               return compareOrderedStats(left.Int(), right.Int()), nil
+       case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, 
reflect.Uint64:
+               return compareOrderedStats(left.Uint(), right.Uint()), nil
+       case reflect.Float32, reflect.Float64:
+               // Match Java Float.compare/Double.compare: all NaNs compare 
equal
+               // and sort after +Inf; -0 sorts before +0.
+               aFloat, bFloat := left.Float(), right.Float()
+               switch {
+               case math.IsNaN(aFloat):
+                       if math.IsNaN(bFloat) {
+                               return 0, nil
+                       }
+
+                       return 1, nil
+               case math.IsNaN(bFloat):
+                       return -1, nil
+               case aFloat == 0 && bFloat == 0:
+                       switch {
+                       case math.Signbit(aFloat) == math.Signbit(bFloat):
+                               return 0, nil
+                       case math.Signbit(aFloat):
+                               return -1, nil
+                       default:
+                               return 1, nil
+                       }
+               default:
+                       return compareOrderedStats(aFloat, bFloat), nil
+               }
+       case reflect.String:
+               return bytes.Compare([]byte(left.String()), 
[]byte(right.String())), nil
+       case reflect.Slice, reflect.Array:
+               if left.Type().Elem().Kind() != reflect.Uint8 || 
right.Type().Elem().Kind() != reflect.Uint8 {
+                       break
+               }
+               limit := min(left.Len(), right.Len())
+               for index := range limit {
+                       aByte, bByte := left.Index(index).Uint(), 
right.Index(index).Uint()
+                       if aByte != bByte {
+                               return compareOrderedStats(aByte, bByte), nil
+                       }
+               }
+               switch {
+               case left.Len() < right.Len():
+                       return -1, nil
+               case left.Len() > right.Len():
+                       return 1, nil
+               default:
+                       return 0, nil
+               }
+       }
+
+       return 0, fmt.Errorf("%w: cannot order partition values of type %T",
+               iceberg.ErrNotImplemented, a)
+}
+
+func compareOrderedStats[T interface{ ~int64 | ~uint64 | ~float64 }](a, b T) 
int {
+       switch {
+       case a < b:
+               return -1
+       case a > b:
+               return 1
+       default:
+               return 0
+       }
+}
+
+type partitionStatisticsByteCounter struct {
+       writer iceio.FileWriter
+       size   int64
+}
+
+func (w *partitionStatisticsByteCounter) Write(data []byte) (int, error) {
+       n, err := w.writer.Write(data)
+       w.size += int64(n)
+
+       return n, err
+}
+
+// writePartitionStatisticsParquet streams bounded Arrow batches to the table
+// metadata directory and removes incomplete files on failure.
+func writePartitionStatisticsParquet(
+       ctx context.Context,
+       fs iceio.WriteFileIO,
+       path string,
+       partitionType *iceberg.StructType,
+       rows []*inspectPartitionAggregate,
+) (_ int64, err error) {
+       sc, err := 
SchemaToArrowSchema(PartitionStatisticsSchemaV1(partitionType), nil, true, 
false)
+       if err != nil {
+               return 0, err
+       }
+       fw, err := fs.Create(ctx, path)
+       if err != nil {
+               return 0, err
+       }
+       defer func() {
+               if err != nil {
+                       _ = fs.Remove(context.WithoutCancel(ctx), path)
+               }
+       }()
+
+       counter := &partitionStatisticsByteCounter{writer: fw}
+       writer, err := pqarrow.NewFileWriter(sc, counter, 
parquet.NewWriterProperties(), pqarrow.DefaultWriterProps())
+       if err != nil {
+               _ = fw.Close()
+
+               return 0, err
+       }
+       bldr := array.NewRecordBuilder(memory.DefaultAllocator, sc)
+       defer bldr.Release()
+
+       part, err := 
newInspectPartitionBuilder(bldr.Field(0).(*array.StructBuilder), partitionType)
+       if err != nil {
+               _ = writer.Close()
+               _ = fw.Close()
+
+               return 0, err
+       }
+       for start := 0; start < len(rows); start += inspectRecordBatchSize {
+               if err := ctx.Err(); err != nil {
+                       _ = writer.Close()
+                       _ = fw.Close()
+
+                       return 0, err
+               }
+               end := min(start+inspectRecordBatchSize, len(rows))
+               for _, row := range rows[start:end] {
+                       if err := part.append(row.partition); err != nil {
+                               _ = writer.Close()
+                               _ = fw.Close()
+
+                               return 0, err
+                       }
+                       bldr.Field(1).(*array.Int32Builder).Append(row.specID)
+                       
bldr.Field(2).(*array.Int64Builder).Append(row.dataRecordCount)
+                       
bldr.Field(3).(*array.Int32Builder).Append(row.dataFileCount)
+                       
bldr.Field(4).(*array.Int64Builder).Append(row.dataFileSize)
+                       // v1 has no delete files, so the exact live record 
count equals
+                       // the sum of data-file record counts.
+                       
bldr.Field(5).(*array.Int64Builder).Append(row.dataRecordCount)
+               }
+
+               rec := bldr.NewRecordBatch()
+               writeErr := writer.Write(rec)
+               rec.Release()
+               if writeErr != nil {
+                       _ = writer.Close()
+                       _ = fw.Close()
+
+                       return 0, writeErr
+               }
+       }
+       if err := writer.Close(); err != nil {
+               _ = fw.Close()
+
+               return 0, err
+       }
+       if err := fw.Close(); err != nil {
+               return 0, err
+       }
+
+       return counter.size, nil
+}
diff --git a/table/partition_statistics_write_test.go 
b/table/partition_statistics_write_test.go
new file mode 100644
index 000000000..35fe8bdba
--- /dev/null
+++ b/table/partition_statistics_write_test.go
@@ -0,0 +1,614 @@
+// 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"
+       "encoding/json"
+       "errors"
+       "io"
+       "math"
+       "slices"
+       "strconv"
+       "testing"
+       "time"
+
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/decimal128"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/parquet/file"
+       "github.com/apache/arrow-go/v18/parquet/pqarrow"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/google/uuid"
+       "github.com/stretchr/testify/require"
+       "github.com/twmb/avro/ocf"
+)
+
+func statsTestPartitionType() *iceberg.StructType {
+       return &iceberg.StructType{FieldList: []iceberg.NestedField{
+               {ID: 1000, Name: "day", Type: iceberg.PrimitiveTypes.Int32},
+               {ID: 1001, Name: "region", Type: iceberg.PrimitiveTypes.String},
+       }}
+}
+
+func TestPartitionStatisticsSchemaV1(t *testing.T) {
+       sc := PartitionStatisticsSchemaV1(statsTestPartitionType())
+       require.Equal(t, []string{
+               "partition", "spec_id", "data_record_count", "data_file_count",
+               "total_data_file_size_in_bytes", "total_record_count",
+       }, testFieldNames(sc))
+       fields := sc.Fields()
+       require.Equal(t, []int{1, 2, 3, 4, 5, 10}, []int{
+               fields[0].ID, fields[1].ID, fields[2].ID,
+               fields[3].ID, fields[4].ID, fields[5].ID,
+       })
+       for index := range 5 {
+               require.True(t, fields[index].Required)
+       }
+       require.False(t, fields[5].Required)
+       require.Equal(t, 1000, 
fields[0].Type.(*iceberg.StructType).FieldList[0].ID)
+}
+
+func TestPartitionStatisticValuesNullFirstNumericAndBinary(t *testing.T) {
+       for _, tc := range []struct {
+               a, b any
+               want int
+       }{
+               {nil, int32(2), -1},
+               {int32(2), nil, 1},
+               {nil, nil, 0},
+               {int32(-2), int32(10), -1},
+               {int64(10), int64(-2), 1},
+               {uint64(1), uint64(2), -1},
+               {float64(-1.5), float64(0), -1},
+               {float64(math.Copysign(0, -1)), float64(0), -1},
+               {float64(0), float64(math.Copysign(0, -1)), 1},
+               {float32(math.Copysign(0, -1)), float32(0), -1},
+               {math.Inf(-1), -math.MaxFloat64, -1},
+               {math.NaN(), math.Inf(1), 1},
+               {math.Inf(1), math.NaN(), -1},
+               {math.NaN(), math.NaN(), 0},
+               {float32(math.NaN()), float32(math.NaN()), 0},
+               {float32(math.NaN()), float32(math.Inf(1)), 1},
+               {false, true, -1},
+               {"10", "2", -1},
+               {[]byte{0, 255}, []byte{1}, -1},
+               {[2]byte{0, 255}, [2]byte{1, 0}, -1},
+               {[]byte{1, 2}, []byte{1, 2}, 0},
+               {uuid.MustParse("80000000-0000-0000-0000-000000000000"), 
uuid.MustParse("7fffffff-ffff-ffff-ffff-ffffffffffff"), -1},
+               {uuid.MustParse("00000000-0000-0000-8000-000000000000"), 
uuid.MustParse("00000000-0000-0000-7fff-ffffffffffff"), -1},
+               {iceberg.Decimal{Val: decimal128.FromI64(-120), Scale: 2}, 
iceberg.Decimal{Val: decimal128.FromI64(30), Scale: 2}, -1},
+               {iceberg.Decimal{Val: decimal128.FromI64(30), Scale: 2}, 
iceberg.Decimal{Val: decimal128.FromI64(30), Scale: 2}, 0},
+               {iceberg.Decimal{Val: decimal128.FromI64(120), Scale: 2}, 
iceberg.Decimal{Val: decimal128.FromI64(30), Scale: 2}, 1},
+       } {
+               got, err := comparePartitionStatisticValues(tc.a, tc.b)
+               require.NoError(t, err, "compare %v to %v", tc.a, tc.b)
+               require.Equal(t, tc.want, got)
+       }
+       _, err := comparePartitionStatisticValues(struct{ X int }{}, struct{ X 
int }{})
+       require.ErrorIs(t, err, iceberg.ErrNotImplemented)
+       _, err = comparePartitionStatisticValues(
+               iceberg.Decimal{Val: decimal128.FromI64(10), Scale: 1},
+               iceberg.Decimal{Val: decimal128.FromI64(10), Scale: 2})
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+}
+
+func TestSortPartitionStatisticsByTupleThenSpecID(t *testing.T) {
+       pt := statsTestPartitionType()
+       rows := []*inspectPartitionAggregate{
+               {partition: map[int]any{1000: int32(10), 1001: "z"}, specID: 3},
+               {partition: map[int]any{1000: int32(2), 1001: "z"}, specID: 4},
+               {partition: map[int]any{1000: int32(-1), 1001: "a"}, specID: 1},
+               {partition: map[int]any{1000: nil, 1001: "z"}, specID: 2},
+               {partition: map[int]any{1000: int32(2), 1001: "z"}, specID: 1},
+               {partition: map[int]any{1000: int32(2), 1001: "a"}, specID: 5},
+       }
+       require.NoError(t, sortPartitionStatistics(rows, pt))
+       require.Equal(t, []int32{2, 1, 5, 1, 4, 3}, []int32{
+               rows[0].specID, rows[1].specID, rows[2].specID,
+               rows[3].specID, rows[4].specID, rows[5].specID,
+       })
+       require.Nil(t, rows[0].partition[1000])
+       require.EqualValues(t, -1, rows[1].partition[1000])
+       require.EqualValues(t, 10, rows[5].partition[1000])
+}
+
+func TestSortPartitionStatisticsFloatAndUUID(t *testing.T) {
+       t.Run("float", func(t *testing.T) {
+               pt := &iceberg.StructType{FieldList: []iceberg.NestedField{
+                       {ID: 1000, Name: "value", Type: 
iceberg.PrimitiveTypes.Float64},
+               }}
+               rows := []*inspectPartitionAggregate{
+                       {partition: map[int]any{1000: math.NaN()}, specID: 6},
+                       {partition: map[int]any{1000: float64(0)}, specID: 3},
+                       {partition: map[int]any{1000: math.Inf(1)}, specID: 5},
+                       {partition: map[int]any{1000: math.Copysign(0, -1)}, 
specID: 2},
+                       {partition: map[int]any{1000: math.Inf(-1)}, specID: 1},
+                       {partition: map[int]any{1000: 
math.Float64frombits(0xfff8000000000001)}, specID: 7},
+               }
+               require.NoError(t, sortPartitionStatistics(rows, pt))
+               require.Equal(t, []int32{1, 2, 3, 5, 6, 7}, []int32{
+                       rows[0].specID, rows[1].specID, rows[2].specID,
+                       rows[3].specID, rows[4].specID, rows[5].specID,
+               })
+               require.True(t, math.Signbit(rows[1].partition[1000].(float64)))
+               require.False(t, 
math.Signbit(rows[2].partition[1000].(float64)))
+               require.True(t, math.IsNaN(rows[4].partition[1000].(float64)))
+       })
+       t.Run("uuid", func(t *testing.T) {
+               pt := &iceberg.StructType{FieldList: []iceberg.NestedField{
+                       {ID: 1000, Name: "value", Type: 
iceberg.PrimitiveTypes.UUID},
+               }}
+               rows := []*inspectPartitionAggregate{
+                       {partition: map[int]any{1000: 
uuid.MustParse("7fffffff-ffff-ffff-0000-000000000000")}, specID: 4},
+                       {partition: map[int]any{1000: 
uuid.MustParse("00000000-0000-0000-7fff-ffffffffffff")}, specID: 3},
+                       {partition: map[int]any{1000: 
uuid.MustParse("80000000-0000-0000-0000-000000000000")}, specID: 1},
+                       {partition: map[int]any{1000: 
uuid.MustParse("00000000-0000-0000-8000-000000000000")}, specID: 2},
+               }
+               require.NoError(t, sortPartitionStatistics(rows, pt))
+               require.Equal(t, []int32{1, 2, 3, 4}, []int32{
+                       rows[0].specID, rows[1].specID, rows[2].specID, 
rows[3].specID,
+               })
+       })
+}
+
+func TestPartitionStatisticsParquetWrite(t *testing.T) {
+       ctx := t.Context()
+       fs := iceio.NewMemFS()
+       path := "mem://bucket/table/metadata/v1-stats.parquet"
+       rows := []*inspectPartitionAggregate{
+               {
+                       specID: 0, partition: map[int]any{1000: int32(-3), 
1001: "a"},
+                       dataRecordCount: 17, dataFileCount: 2, dataFileSize: 
2048,
+               },
+               {
+                       specID: 1, partition: map[int]any{1000: int32(10), 
1001: nil},
+                       dataRecordCount: 3, dataFileCount: 1, dataFileSize: 512,
+               },
+       }
+       size, err := writePartitionStatisticsParquet(ctx, fs, path, 
statsTestPartitionType(), rows)
+       require.NoError(t, err)
+       require.Positive(t, size)
+
+       f, err := fs.Open(ctx, path)
+       require.NoError(t, err)
+       data, err := io.ReadAll(f)
+       require.NoError(t, err)
+       require.NoError(t, f.Close())
+       require.EqualValues(t, len(data), size)
+       require.True(t, bytes.HasPrefix(data, []byte("PAR1")))
+       require.True(t, bytes.HasSuffix(data, []byte("PAR1")))
+       reader, err := file.NewParquetReader(bytes.NewReader(data))
+       require.NoError(t, err)
+       defer reader.Close()
+       arrowReader, err := pqarrow.NewFileReader(reader, 
pqarrow.ArrowReadProperties{}, memory.DefaultAllocator)
+       require.NoError(t, err)
+       table, err := arrowReader.ReadTable(ctx)
+       require.NoError(t, err)
+       defer table.Release()
+       require.EqualValues(t, 2, table.NumRows())
+       partitions := table.Column(0).Data().Chunk(0).(*array.Struct)
+       require.Equal(t, int32(-3), partitions.Field(0).(*array.Int32).Value(0))
+       require.Equal(t, int32(10), partitions.Field(0).(*array.Int32).Value(1))
+       require.True(t, partitions.Field(1).IsNull(1))
+       require.Equal(t, int64(17), 
table.Column(2).Data().Chunk(0).(*array.Int64).Value(0))
+       require.Equal(t, int64(3), 
table.Column(5).Data().Chunk(0).(*array.Int64).Value(1))
+
+       // Cancellation must not leave an incomplete metadata file.
+       cancelled, cancel := context.WithCancel(ctx)
+       cancel()
+       failedPath := "mem://bucket/table/metadata/cancelled.parquet"
+       _, err = writePartitionStatisticsParquet(cancelled, fs, failedPath, 
statsTestPartitionType(), rows)
+       require.ErrorIs(t, err, context.Canceled)
+       _, err = fs.Open(ctx, failedPath)
+       require.Error(t, err)
+}
+
+func TestCollectPartitionStatisticsFromLiveManifests(t *testing.T) {
+       spec := partitionedSpec()
+       const snapshotID = int64(1)
+       sequenceNumber := int64(1)
+       entry := func(name string, partition int32, count int64, status 
iceberg.ManifestEntryStatus) iceberg.ManifestEntry {
+               file := newTestDataFileWithCount(
+                       t, spec, 
"mem://default/table-location/data/"+name+".parquet",
+                       map[int]any{1000: partition}, count)
+
+               return iceberg.NewManifestEntry(status, new(snapshotID),
+                       &sequenceNumber, &sequenceNumber, file)
+       }
+       tbl := inspectFilesTableWithManifests(t, spec, inspectManifestSpec{
+               content: iceberg.ManifestContentData,
+               entries: []iceberg.ManifestEntry{
+                       entry("first", 2, 5, iceberg.EntryStatusADDED),
+                       entry("second", 2, 7, iceberg.EntryStatusADDED),
+                       entry("third", 10, 3, iceberg.EntryStatusADDED),
+                       entry("removed", 10, 100, iceberg.EntryStatusDELETED),
+               },
+       })
+       ctx := t.Context()
+       fs, err := tbl.FS(ctx)
+       require.NoError(t, err)
+       pt, err := inspectPartitionType(tbl.Metadata())
+       require.NoError(t, err)
+
+       rows, err := collectPartitionStatistics(ctx, fs, 
*tbl.CurrentSnapshot(), pt, tbl.Metadata().PartitionSpecs())
+       require.NoError(t, err)
+       require.Len(t, rows, 2)
+       require.NoError(t, sortPartitionStatistics(rows, pt))
+       require.Equal(t, int32(2), rows[0].partition[1000])
+       require.Equal(t, int64(12), rows[0].dataRecordCount)
+       require.Equal(t, int32(2), rows[0].dataFileCount)
+       require.Equal(t, int32(10), rows[1].partition[1000])
+       require.Equal(t, int64(3), rows[1].dataRecordCount)
+       require.Equal(t, int32(1), rows[1].dataFileCount)
+}
+
+func TestCollectPartitionStatisticsDecimalFromManifest(t *testing.T) {
+       decimalType := iceberg.MustDecimalTypeOf(10, 2)
+       spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "amount", Transform: 
iceberg.IdentityTransform{},
+       })
+       schema := iceberg.NewSchema(0, iceberg.NestedField{
+               ID: 1, Name: "amount", Type: decimalType, Required: true,
+       })
+       partitionType := &iceberg.StructType{FieldList: []iceberg.NestedField{
+               {ID: 1000, Name: "amount", Type: decimalType},
+       }}
+       amount := iceberg.Decimal{Val: decimal128.FromI64(1234), Scale: 2}
+       const snapshotID = int64(7)
+       files := []iceberg.DataFile{
+               newTestDataFileWithCount(t, spec, 
"mem://stats/decimal-a.parquet", map[int]any{1000: amount}, 4),
+               newTestDataFileWithCount(t, spec, 
"mem://stats/decimal-b.parquet", map[int]any{1000: 
iceberg.DecimalLiteral(amount)}, 6),
+       }
+       entries := make([]iceberg.ManifestEntry, 0, len(files))
+       for _, df := range files {
+               entries = append(entries, 
iceberg.NewManifestEntry(iceberg.EntryStatusADDED, new(snapshotID), nil, nil, 
df))
+       }
+
+       const manifestPath = "mem://stats/decimal-manifest.avro"
+       var encoded bytes.Buffer
+       _, err := iceberg.WriteManifest(manifestPath, &encoded, 1, spec, 
schema, snapshotID, entries)
+       require.NoError(t, err)
+       fs := iceio.NewMemFS()
+       require.NoError(t, fs.WriteFile(t.Context(), manifestPath, 
encoded.Bytes()))
+
+       snapshot := Snapshot{SnapshotID: snapshotID, ManifestLocations: 
[]string{manifestPath}}
+       rows, err := collectPartitionStatistics(t.Context(), fs, snapshot, 
partitionType, []iceberg.PartitionSpec{spec})
+       require.NoError(t, err)
+       require.Len(t, rows, 1)
+       require.Equal(t, amount, rows[0].partition[1000])
+       require.Equal(t, int64(10), rows[0].dataRecordCount)
+       require.Equal(t, int32(2), rows[0].dataFileCount)
+       require.NoError(t, sortPartitionStatistics(rows, partitionType))
+
+       const statsPath = "mem://stats/decimal-stats.parquet"
+       _, err = writePartitionStatisticsParquet(t.Context(), fs, statsPath, 
partitionType, rows)
+       require.NoError(t, err)
+       stored, err := fs.Open(t.Context(), statsPath)
+       require.NoError(t, err)
+       content, err := io.ReadAll(stored)
+       require.NoError(t, err)
+       require.NoError(t, stored.Close())
+       reader, err := file.NewParquetReader(bytes.NewReader(content))
+       require.NoError(t, err)
+       defer reader.Close()
+       arrowReader, err := pqarrow.NewFileReader(reader, 
pqarrow.ArrowReadProperties{}, memory.DefaultAllocator)
+       require.NoError(t, err)
+       result, err := arrowReader.ReadTable(t.Context())
+       require.NoError(t, err)
+       defer result.Release()
+       require.EqualValues(t, 1, result.NumRows())
+       partition := result.Column(0).Data().Chunk(0).(*array.Struct)
+       require.Equal(t, amount.Val, 
partition.Field(0).(*array.Decimal128).Value(0))
+}
+
+func TestTransactionPartitionStatisticsRegistration(t *testing.T) {
+       tbl := inspectTestTable(Identifier{"db", "stats"}, 
map[string]SnapshotRef{
+               MainBranch: {SnapshotID: 103, Type: BranchRef},
+       })
+       tx := tbl.NewTransaction()
+       stats := PartitionStatisticsFile{
+               SnapshotID:      103,
+               StatisticsPath:  
"s3://warehouse/db/stats/metadata/stats.parquet",
+               FileSizeInBytes: 512,
+       }
+
+       require.NoError(t, tx.SetPartitionStatistics(stats))
+       meta, err := tx.meta.Build()
+       require.NoError(t, err)
+       require.Equal(t, []PartitionStatisticsFile{stats}, 
slices.Collect(meta.PartitionStatistics()))
+
+       replacement := stats
+       replacement.FileSizeInBytes = 1024
+       require.NoError(t, tx.SetPartitionStatistics(replacement))
+       meta, err = tx.meta.Build()
+       require.NoError(t, err)
+       require.Equal(t, []PartitionStatisticsFile{replacement}, 
slices.Collect(meta.PartitionStatistics()))
+
+       require.NoError(t, tx.RemovePartitionStatistics(stats.SnapshotID))
+       meta, err = tx.meta.Build()
+       require.NoError(t, err)
+       require.Empty(t, slices.Collect(meta.PartitionStatistics()))
+
+       require.ErrorIs(t, tx.SetPartitionStatistics(PartitionStatisticsFile{
+               SnapshotID: 999, StatisticsPath: stats.StatisticsPath, 
FileSizeInBytes: 512,
+       }), ErrSnapshotNotFound)
+       require.ErrorIs(t, tx.SetPartitionStatistics(PartitionStatisticsFile{
+               SnapshotID: 103, FileSizeInBytes: 512,
+       }), iceberg.ErrInvalidArgument)
+       require.ErrorIs(t, tx.SetPartitionStatistics(PartitionStatisticsFile{
+               SnapshotID: 103, StatisticsPath: stats.StatisticsPath, 
FileSizeInBytes: -1,
+       }), iceberg.ErrInvalidArgument)
+       var nilTx *Transaction
+       require.Error(t, nilTx.RemovePartitionStatistics(103))
+       require.Error(t, nilTx.SetPartitionStatistics(stats))
+}
+
+func TestComputePartitionStatisticsRejectsNonV1AndMissingSnapshot(t 
*testing.T) {
+       tbl := inspectTestTable(Identifier{"db", "stats"}, 
map[string]SnapshotRef{
+               MainBranch: {SnapshotID: 103, Type: BranchRef},
+       })
+       tx := tbl.NewTransaction()
+       _, err := tx.ComputePartitionStatistics(t.Context())
+       require.True(t, errors.Is(err, iceberg.ErrNotImplemented))
+
+       tx.meta.formatVersion = 1
+       _, err = tx.ComputePartitionStatistics(t.Context())
+       // The fixture's schema is unpartitioned. Fail before any filesystem IO.
+       require.True(t, errors.Is(err, iceberg.ErrNotImplemented))
+}
+
+type statsSchemaHistory struct {
+       Metadata
+       current *iceberg.Schema
+       schemas []*iceberg.Schema
+       specs   []iceberg.PartitionSpec
+}
+
+func (m statsSchemaHistory) CurrentSchema() *iceberg.Schema          { return 
m.current }
+func (m statsSchemaHistory) Schemas() []*iceberg.Schema              { return 
m.schemas }
+func (m statsSchemaHistory) PartitionSpecs() []iceberg.PartitionSpec { return 
m.specs }
+
+func TestPartitionStatisticsRejectsDroppedAndPromotedSources(t *testing.T) {
+       old := iceberg.NewSchema(1, iceberg.NestedField{ID: 1, Name: "id", 
Type: iceberg.PrimitiveTypes.Int32})
+       promoted := iceberg.NewSchema(2, iceberg.NestedField{ID: 1, Name: "id", 
Type: iceberg.PrimitiveTypes.Int64})
+       dropped := iceberg.NewSchema(3, iceberg.NestedField{ID: 2, Name: 
"other", Type: iceberg.PrimitiveTypes.String})
+       meta := statsSchemaHistory{current: old, schemas: 
[]*iceberg.Schema{old}, specs: []iceberg.PartitionSpec{partitionedSpec()}}
+       require.NoError(t, validatePartitionStatisticsSourceTypes(meta))
+       meta.current, meta.schemas = promoted, []*iceberg.Schema{old, promoted}
+       require.ErrorIs(t, validatePartitionStatisticsSourceTypes(meta), 
iceberg.ErrNotImplemented)
+       meta.specs = 
[]iceberg.PartitionSpec{iceberg.NewPartitionSpec(iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "bucket", Transform: 
iceberg.BucketTransform{NumBuckets: 4},
+       })}
+       require.NoError(t, validatePartitionStatisticsSourceTypes(meta))
+       meta.current, meta.schemas = dropped, []*iceberg.Schema{old, dropped}
+       require.ErrorIs(t, validatePartitionStatisticsSourceTypes(meta), 
iceberg.ErrNotImplemented)
+}
+
+func TestComputePartitionStatisticsWritesAndStages(t *testing.T) {
+       spec := partitionedSpec()
+       const snapshotID = int64(1)
+       sequenceNumber := int64(1)
+       data := newTestDataFileWithCount(t, spec, 
"mem://default/table-location/data/a.parquet", map[int]any{1000: int32(7)}, 12)
+       entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
new(snapshotID), &sequenceNumber, &sequenceNumber, data)
+       tbl := inspectFilesTableWithManifests(t, spec, 
inspectManifestSpec{content: iceberg.ManifestContentData, entries: 
[]iceberg.ManifestEntry{entry}})
+       base := tbl.Metadata().(*metadataV2).commonMetadata
+       base.FormatVersion = 1
+       fs, err := tbl.FS(t.Context())
+       require.NoError(t, err)
+       tbl = New(tbl.Identifier(), &metadataV1{commonMetadata: base}, 
"metadata.json", func(context.Context) (iceio.IO, error) { return fs, nil }, 
nil)
+       tx := tbl.NewTransaction()
+       stats, err := tx.ComputePartitionStatistics(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, snapshotID, stats.SnapshotID)
+       metadata, err := tx.meta.Build()
+       require.NoError(t, err)
+       require.Equal(t, []PartitionStatisticsFile{*stats}, 
slices.Collect(metadata.PartitionStatistics()))
+       f, err := fs.Open(t.Context(), stats.StatisticsPath)
+       require.NoError(t, err)
+       require.NoError(t, f.Close())
+}
+
+func TestCollectPartitionStatisticsRejectsDuplicateLiveFiles(t *testing.T) {
+       spec := partitionedSpec()
+       const snapshotID = int64(1)
+       seq := int64(1)
+       file := newTestDataFileWithCount(t, spec,
+               "mem://default/table-location/data/duplicate.parquet",
+               map[int]any{1000: int32(2)}, 5)
+       entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
new(snapshotID),
+               &seq, &seq, file)
+       tbl := inspectFilesTableWithManifests(t, spec, inspectManifestSpec{
+               content: iceberg.ManifestContentData,
+               entries: []iceberg.ManifestEntry{entry, entry},
+       })
+       fs, err := tbl.FS(t.Context())
+       require.NoError(t, err)
+       pt, err := inspectPartitionType(tbl.Metadata())
+       require.NoError(t, err)
+
+       _, err = collectPartitionStatistics(t.Context(), fs, 
*tbl.CurrentSnapshot(), pt, tbl.Metadata().PartitionSpecs())
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       require.ErrorContains(t, err, "duplicate live data file")
+}
+
+func TestPartitionStatisticsEmbeddedManifests(t *testing.T) {
+       fs := iceio.NewMemFS()
+       spec0 := partitionedSpec()
+       spec7 := iceberg.NewPartitionSpecID(7, 
iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1000, Name: "id", 
Transform: iceberg.IdentityTransform{}})
+       write := func(path string, spec iceberg.PartitionSpec, name string, 
value any, count int64) {
+               file := newTestDataFileWithCount(t, spec, 
"mem://stats/"+name+".parquet", map[int]any{1000: value}, count)
+               entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
new(int64(1)), nil, nil, file)
+               var out bytes.Buffer
+               _, err := iceberg.WriteManifest(path, &out, 1, spec, 
simpleSchema(), 1, []iceberg.ManifestEntry{entry})
+               require.NoError(t, err)
+               require.NoError(t, fs.WriteFile(t.Context(), path, out.Bytes()))
+       }
+       write("mem://stats/spec0.avro", spec0, "a", int32(3), 10)
+       write("mem://stats/spec7.avro", spec7, "b", int32(3), 20)
+       write("mem://stats/null.avro", spec7, "null", nil, 1)
+       snapshot := Snapshot{SnapshotID: 1, ManifestLocations: 
[]string{"mem://stats/spec0.avro", "mem://stats/spec7.avro", 
"mem://stats/null.avro"}}
+       pt := &iceberg.StructType{FieldList: []iceberg.NestedField{{ID: 1000, 
Name: "id", Type: iceberg.Int32Type{}}}}
+       rows, err := collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{spec0, spec7})
+       require.NoError(t, err)
+       require.Len(t, rows, 3)
+       require.NoError(t, sortPartitionStatistics(rows, pt))
+       require.Nil(t, rows[0].partition[1000])
+       require.Equal(t, int32(7), rows[0].specID)
+       require.Equal(t, int32(0), rows[1].specID)
+       require.Equal(t, int64(10), rows[1].dataRecordCount)
+       require.Equal(t, int32(7), rows[2].specID)
+       require.Equal(t, int64(20), rows[2].dataRecordCount)
+       statsPath := "mem://stats/partition-stats.parquet"
+       _, err = writePartitionStatisticsParquet(t.Context(), fs, statsPath, 
pt, rows)
+       require.NoError(t, err)
+       statsFile, err := fs.Open(t.Context(), statsPath)
+       require.NoError(t, err)
+       encoded, err := io.ReadAll(statsFile)
+       require.NoError(t, err)
+       require.NoError(t, statsFile.Close())
+       parquetReader, err := file.NewParquetReader(bytes.NewReader(encoded))
+       require.NoError(t, err)
+       defer parquetReader.Close()
+       arrowReader, err := pqarrow.NewFileReader(parquetReader, 
pqarrow.ArrowReadProperties{}, memory.DefaultAllocator)
+       require.NoError(t, err)
+       result, err := arrowReader.ReadTable(t.Context())
+       require.NoError(t, err)
+       defer result.Release()
+       require.EqualValues(t, 3, result.NumRows())
+       require.Equal(t, []int32{7, 0, 7}, 
result.Column(1).Data().Chunk(0).(*array.Int32).Int32Values())
+       _, err = collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{spec0})
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       wrong := iceberg.NewPartitionSpecID(7, 
iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1000, Name: "id", 
Transform: iceberg.BucketTransform{NumBuckets: 4}})
+       _, err = collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{spec0, wrong})
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+}
+
+func TestPartitionStatisticsEmbeddedManifestWithoutSpecID(t *testing.T) {
+       fs := iceio.NewMemFS()
+       spec := partitionedSpec()
+       file := newTestDataFileWithCount(t, spec, "mem://stats/a.parquet", 
map[int]any{1000: int32(3)}, 10)
+       entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
new(int64(1)), nil, nil, file)
+       var original bytes.Buffer
+       _, err := iceberg.WriteManifest("mem://stats/a.avro", &original, 1, 
spec, simpleSchema(), 1, []iceberg.ManifestEntry{entry})
+       require.NoError(t, err)
+       reader, err := ocf.NewReader(bytes.NewReader(original.Bytes()))
+       require.NoError(t, err)
+       metadata := make(map[string][]byte)
+       for k, v := range reader.Metadata() {
+               if k != "partition-spec-id" && k != "avro.schema" && k != 
"avro.codec" {
+                       metadata[k] = v
+               }
+       }
+       schema := reader.Schema()
+       require.NoError(t, reader.Close())
+       writeEmpty := func(path string) {
+               var rewritten bytes.Buffer
+               writer, err := ocf.NewWriter(&rewritten, schema, 
ocf.WithMetadata(metadata))
+               require.NoError(t, err)
+               require.NoError(t, writer.Close())
+               require.NoError(t, fs.WriteFile(t.Context(), path, 
rewritten.Bytes()))
+       }
+       const missingID = "mem://stats/missing.avro"
+       writeEmpty(missingID)
+       pt := &iceberg.StructType{FieldList: []iceberg.NestedField{{ID: 1000, 
Name: "id", Type: iceberg.Int32Type{}}}}
+       snapshot := Snapshot{SnapshotID: 1, ManifestLocations: 
[]string{missingID}}
+       rows, err := collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{spec})
+       require.NoError(t, err)
+       require.Empty(t, rows)
+
+       fields := make([]iceberg.PartitionField, 0, spec.NumFields())
+       for _, field := range spec.Fields() {
+               fields = append(fields, field)
+       }
+       duplicateSpec := iceberg.NewPartitionSpecID(spec.ID()+7, fields...)
+       _, err = collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{spec, duplicateSpec})
+       require.ErrorIs(t, err, iceberg.ErrNotImplemented)
+
+       wrongFields := slices.Clone(fields)
+       wrongFields[0].Transform = iceberg.BucketTransform{NumBuckets: 4}
+       wrongSpec := iceberg.NewPartitionSpecID(spec.ID(), wrongFields...)
+       _, err = collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{wrongSpec})
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+
+       // Historical v1 partition fields may also omit their field IDs.
+       var rawFields []map[string]json.RawMessage
+       require.NoError(t, json.Unmarshal(metadata["partition-spec"], 
&rawFields))
+       for _, field := range rawFields {
+               delete(field, "field-id")
+       }
+       metadata["partition-spec"], err = json.Marshal(rawFields)
+       require.NoError(t, err)
+       const missingBoth = "mem://stats/missing-both.avro"
+       writeEmpty(missingBoth)
+       snapshot.ManifestLocations = []string{missingBoth}
+       rows, err = collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{spec})
+       require.NoError(t, err)
+       require.Empty(t, rows)
+
+       // The generic manifest reader requires an ID, but v1 field IDs are
+       // still optional even if partition-spec-id is present.
+       metadata["partition-spec-id"] = []byte(strconv.Itoa(spec.ID()))
+       const missingFieldIDs = "mem://stats/missing-field-ids.avro"
+       writeEmpty(missingFieldIDs)
+       snapshot.ManifestLocations = []string{missingFieldIDs}
+       rows, err = collectPartitionStatistics(t.Context(), fs, snapshot, pt, 
[]iceberg.PartitionSpec{spec})
+       require.NoError(t, err)
+       require.Empty(t, rows)
+}
+
+func TestPartitionStatisticsRequiresPartitionFieldIDs(t *testing.T) {
+       spec := partitionedSpec()
+       require.NoError(t, validatePartitionStatisticsTuple(spec, "file", 
map[int]any{1000: nil}))
+       require.NoError(t, validatePartitionStatisticsTuple(spec, "file", 
map[int]any{1000: int32(3)}))
+       require.ErrorIs(t, validatePartitionStatisticsTuple(spec, "file", nil), 
iceberg.ErrInvalidArgument)
+       require.ErrorIs(t, validatePartitionStatisticsTuple(spec, "file", 
map[int]any{1001: int32(3)}), iceberg.ErrInvalidArgument)
+}
+
+func TestComputePartitionStatisticsEmbeddedManifest(t *testing.T) {
+       fs := iceio.NewMemFS()
+       spec := partitionedSpec()
+       f := newTestDataFileWithCount(t, spec, "mem://table/data/a.parquet", 
map[int]any{1000: int32(7)}, 12)
+       entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
new(int64(1)), nil, nil, f)
+       var out bytes.Buffer
+       const path = "mem://table/metadata/manifest.avro"
+       _, err := iceberg.WriteManifest(path, &out, 1, spec, simpleSchema(), 1, 
[]iceberg.ManifestEntry{entry})
+       require.NoError(t, err)
+       require.NoError(t, fs.WriteFile(t.Context(), path, out.Bytes()))
+       metadata, err := NewMetadata(simpleSchema(), &spec, UnsortedSortOrder, 
"mem://table", iceberg.Properties{PropertyFormatVersion: "1"})
+       require.NoError(t, err)
+       builder, err := MetadataBuilderFromBase(metadata, "")
+       require.NoError(t, err)
+       require.NoError(t, builder.AddSnapshot(&Snapshot{SnapshotID: 1, 
TimestampMs: time.Now().UnixMilli() + 1000, ManifestLocations: []string{path}}))
+       require.NoError(t, builder.SetSnapshotRef(MainBranch, 1, BranchRef))
+       metadata, err = builder.Build()
+       require.NoError(t, err)
+       tbl := New(Identifier{"db", "table"}, metadata, 
"mem://table/metadata.json", func(context.Context) (iceio.IO, error) { return 
fs, nil }, nil)
+       tx := tbl.NewTransaction()
+       stats, err := tx.ComputePartitionStatistics(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, int64(1), stats.SnapshotID)
+       result, err := tx.meta.Build()
+       require.NoError(t, err)
+       require.Equal(t, []PartitionStatisticsFile{*stats}, 
slices.Collect(result.PartitionStatistics()))
+       stored, err := fs.Open(t.Context(), stats.StatisticsPath)
+       require.NoError(t, err)
+       require.NoError(t, stored.Close())
+}

Reply via email to