laskoviymishka commented on code in PR #1464:
URL: https://github.com/apache/iceberg-go/pull/1464#discussion_r3577778777


##########
manifest.go:
##########
@@ -306,6 +306,27 @@ func (m *manifestFile) toV1(v1file *manifestFileV1) {
        }
 }
 
+// ensurePartitionList normalizes the narrow case behind #1309: an inherited
+// manifest whose partition-field summary list is present but empty. Only an
+// empty-spec manifest has zero summaries, so in practice this is an
+// unpartitioned table. On decode that present-empty array becomes a non-nil
+// pointer over a nil slice, which the manifest-list writer would otherwise
+// re-encode as Avro null for the optional partitions union (field 507) — a
+// value strict readers such as Redshift Spectrum reject. Only that
+// present-but-nil slice is normalized back to an empty array. A genuinely
+// absent value (nil pointer) is left null, which is spec-legal because
+// partitions is optional; populated (partitioned) summaries are non-empty and
+// pass through untouched.
+func ensurePartitionList(partitions **[]FieldSummary) {
+       if *partitions == nil {
+               return
+       }
+       if **partitions == nil {
+               empty := []FieldSummary{}
+               *partitions = &empty

Review Comment:
   I'd add a one-liner that the pointer swap here (rather than `**partitions = 
empty`) is deliberate. Both `toV1` and the `wrapped := *file` copy share the 
underlying slice with the source manifest, so swapping the pointer leaves the 
original untouched — but writing through it would mutate the inherited manifest 
and break the next commit that reads it. That's subtle enough that someone 
could "simplify" it and reintroduce aliasing. wdyt?



##########
manifest.go:
##########
@@ -306,6 +306,27 @@ func (m *manifestFile) toV1(v1file *manifestFileV1) {
        }
 }
 
+// ensurePartitionList normalizes the narrow case behind #1309: an inherited
+// manifest whose partition-field summary list is present but empty. Only an
+// empty-spec manifest has zero summaries, so in practice this is an
+// unpartitioned table. On decode that present-empty array becomes a non-nil
+// pointer over a nil slice, which the manifest-list writer would otherwise
+// re-encode as Avro null for the optional partitions union (field 507) — a
+// value strict readers such as Redshift Spectrum reject. Only that
+// present-but-nil slice is normalized back to an empty array. A genuinely
+// absent value (nil pointer) is left null, which is spec-legal because
+// partitions is optional; populated (partitioned) summaries are non-empty and
+// pass through untouched.
+func ensurePartitionList(partitions **[]FieldSummary) {

Review Comment:
   The `**[]FieldSummary` works and it's well-commented, but a 
value-in/value-out form reads cleaner at both call sites — `tmp.PartitionList = 
ensurePartitionList(tmp.PartitionList)`. Not blocking, just a preference. 
Thoughts?



##########
table/manifest_list_partitions_regression_test.go:
##########
@@ -0,0 +1,213 @@
+// 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_test
+
+// End-to-end regression test for issue #1309: AddFiles() after a prior 
Delete()
+// produces a manifest list whose `partitions` field serializes as Avro null,
+// which Redshift Spectrum rejects ("Wrong type in Avro file. Field: 
partitions.
+// Expected: 12 (union). Got: 7 (null)").
+//
+// The trigger is the inheritance path: Delete() uses the mergeOverwrite 
producer
+// and the following AddFiles() uses fastAppend, which inherits the delete
+// snapshot's manifests unchanged. When the manifest list is rewritten, an
+// inherited manifest whose `partitions` summaries are present-but-empty (an
+// unpartitioned table has zero field summaries) round-trips as a non-nil 
pointer
+// to a nil slice, which the writer previously encoded as null instead of a
+// present empty array.
+//
+// The fix (ensurePartitionList in manifest.go) normalizes a present-but-nil
+// summary list back to an empty array so the union resolves to the array 
branch.
+// This test asserts every record in the final manifest list encodes 
`partitions`
+// as a present array, never null.
+
+import (
+       "context"
+       "errors"
+       "io"
+       "path/filepath"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/parquet/pqarrow"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+       "github.com/twmb/avro/ocf"
+)
+
+// manifestListRecordV2 mirrors the version-2 manifest-list entry schema
+// (internal/avro_schemas.go, "manifest_list_file_v2"). Only PartitionList is
+// inspected, but the full record is declared so the reader schema derived from
+// these struct tags matches the writer schema field-for-field. PartitionList 
is
+// decoded as `any` so a null union member surfaces as a nil interface and a
+// present array surfaces as a non-nil []any.
+type manifestListRecordV2 struct {

Review Comment:
   This is a field-for-field duplicate of `manifestListPartitionsRecord` in 
`manifest_test.go` (different package, so it can't just be shared). Not a 
blocker, but a schema change now has to touch both or they silently drift — I'd 
at least put a comment on each pointing at the other. wdyt?



##########
table/manifest_list_partitions_regression_test.go:
##########
@@ -0,0 +1,213 @@
+// 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_test
+
+// End-to-end regression test for issue #1309: AddFiles() after a prior 
Delete()
+// produces a manifest list whose `partitions` field serializes as Avro null,
+// which Redshift Spectrum rejects ("Wrong type in Avro file. Field: 
partitions.
+// Expected: 12 (union). Got: 7 (null)").
+//
+// The trigger is the inheritance path: Delete() uses the mergeOverwrite 
producer
+// and the following AddFiles() uses fastAppend, which inherits the delete
+// snapshot's manifests unchanged. When the manifest list is rewritten, an
+// inherited manifest whose `partitions` summaries are present-but-empty (an
+// unpartitioned table has zero field summaries) round-trips as a non-nil 
pointer
+// to a nil slice, which the writer previously encoded as null instead of a
+// present empty array.
+//
+// The fix (ensurePartitionList in manifest.go) normalizes a present-but-nil
+// summary list back to an empty array so the union resolves to the array 
branch.
+// This test asserts every record in the final manifest list encodes 
`partitions`
+// as a present array, never null.
+
+import (
+       "context"
+       "errors"
+       "io"
+       "path/filepath"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/parquet/pqarrow"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+       "github.com/twmb/avro/ocf"
+)
+
+// manifestListRecordV2 mirrors the version-2 manifest-list entry schema
+// (internal/avro_schemas.go, "manifest_list_file_v2"). Only PartitionList is
+// inspected, but the full record is declared so the reader schema derived from
+// these struct tags matches the writer schema field-for-field. PartitionList 
is
+// decoded as `any` so a null union member surfaces as a nil interface and a
+// present array surfaces as a non-nil []any.
+type manifestListRecordV2 struct {
+       Path               string `avro:"manifest_path"`
+       Len                int64  `avro:"manifest_length"`
+       SpecID             int32  `avro:"partition_spec_id"`
+       Content            int32  `avro:"content"`
+       SeqNumber          int64  `avro:"sequence_number"`
+       MinSeqNumber       int64  `avro:"min_sequence_number"`
+       AddedSnapshotID    int64  `avro:"added_snapshot_id"`
+       AddedFilesCount    int32  `avro:"added_files_count"`
+       ExistingFilesCount int32  `avro:"existing_files_count"`
+       DeletedFilesCount  int32  `avro:"deleted_files_count"`
+       PartitionList      any    `avro:"partitions"`
+       AddedRowsCount     int64  `avro:"added_rows_count"`
+       ExistingRowsCount  int64  `avro:"existing_rows_count"`
+       DeletedRowsCount   int64  `avro:"deleted_rows_count"`
+       Key                []byte `avro:"key_metadata"`
+}
+
+// readManifestListPartitionFields raw-decodes the manifest-list Avro file at
+// manifestListPath and returns the `partitions` union value for every record, 
in
+// file order. Reading the raw union (rather than the normalized ManifestFile) 
is
+// what lets the test distinguish a present empty array from null.
+func readManifestListPartitionFields(t *testing.T, fs iceio.IO, 
manifestListPath string) []any {
+       t.Helper()
+
+       f, err := fs.Open(manifestListPath)
+       require.NoError(t, err)
+       defer func() { require.NoError(t, f.Close()) }()
+
+       rd, err := ocf.NewReader(f)
+       require.NoError(t, err)
+       defer func() { require.NoError(t, rd.Close()) }()
+
+       var partitions []any
+       for {
+               var rec manifestListRecordV2
+               err := rd.Decode(&rec)
+               if errors.Is(err, io.EOF) {
+                       break
+               }
+               require.NoError(t, err)
+               partitions = append(partitions, rec.PartitionList)
+       }
+
+       return partitions
+}
+
+// TestManifestListPartitionsPresentAfterDeleteThenAddFiles reproduces the 
#1309
+// sequence (append -> delete -> add-files) on an unpartitioned v2 table and
+// asserts the final manifest list never encodes `partitions` as null.
+func TestManifestListPartitionsPresentAfterDeleteThenAddFiles(t *testing.T) {
+       ctx := context.Background()
+       location := filepath.ToSlash(t.TempDir())
+       fs := iceio.LocalFS{}
+
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+       )
+
+       // Default (copy-on-write) delete mode is what routes Delete() through 
the
+       // mergeOverwrite producer, so leave WriteDeleteMode unset.
+       meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, 
table.UnsortedSortOrder, location,
+               iceberg.Properties{table.PropertyFormatVersion: "2"})
+       require.NoError(t, err)
+
+       metaLoc := location + "/metadata/v1.metadata.json"
+       fsF := func(context.Context) (iceio.IO, error) { return fs, nil }
+       cat := &concurrentTestCatalog{metadata: meta, location: metaLoc, fsF: 
fsF}
+       tbl := table.New(table.Identifier{"db", "list_partitions_1309"}, meta, 
metaLoc, fsF, cat)
+
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+       }, nil)
+
+       // Step 1 — append initial data so the later Delete has a file to 
rewrite.
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id":1},{"id":2},{"id":3}]`,
+       })
+       require.NoError(t, err)
+       defer data.Release()
+
+       tbl, err = tbl.Append(ctx, array.NewTableReader(data, -1), nil)
+       require.NoError(t, err)
+
+       // Step 2 — Delete (copy-on-write / mergeOverwrite). id==2 matches a 
subset of
+       // the file's rows, forcing a rewrite and an overwrite snapshot whose 
manifest
+       // is inherited by the next commit.
+       tbl, err = tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"), 
int64(2)), nil)
+       require.NoError(t, err)
+
+       // Step 3 — AddFiles (fastAppend) inherits the delete snapshot's 
manifests.
+       // This is the commit whose manifest list #1309 reports as unreadable.
+       extraPath := location + "/data-extra.parquet"
+       writeInt64Parquet(t, fs, extraPath, arrowSchema, []int64{4, 5})
+
+       txn := tbl.NewTransaction()
+       require.NoError(t, txn.AddFiles(ctx, []string{extraPath}, nil, false))
+       tbl, err = txn.Commit(ctx)
+       require.NoError(t, err)
+
+       // The final manifest list references the inherited (delete) manifest 
plus the
+       // newly added one. Every record's `partitions` must be a present 
array; a nil
+       // (null) value is the #1309 regression.
+       snap := tbl.CurrentSnapshot()
+       require.NotNil(t, snap)
+       require.NotEmpty(t, snap.ManifestList, "final snapshot must have a 
manifest list")
+
+       partitionFields := readManifestListPartitionFields(t, fs, 
snap.ManifestList)
+       require.GreaterOrEqual(t, len(partitionFields), 2,
+               "final manifest list should reference the inherited delete 
manifest and the added manifest")
+       for i, p := range partitionFields {
+               require.NotNilf(t, p,
+                       "manifest-list record %d encoded `partitions` as Avro 
null; Redshift Spectrum "+
+                               "rejects this (issue #1309) — it must be a 
present (empty) array", i)
+               require.IsTypef(t, []any{}, p,
+                       "manifest-list record %d `partitions` should decode as 
an array", i)
+       }
+
+       // Local data-integrity smoke test (independent of the manifest-list fix
+       // above): iceberg-go's own reader round-trips the appended values — 1 
and 3
+       // survive the delete of id==2, and 4 and 5 are added. This only 
exercises
+       // this library's read path; it does not reproduce or explain the 
issue's
+       // secondary Athena "NULL columns" symptom, which involves an external 
engine
+       // and is out of scope here.
+       require.Equal(t, []int64{1, 3, 4, 5}, idsInTable(t, tbl),
+               "iceberg-go must read the appended rows back with their real 
values")
+}
+
+// writeInt64Parquet writes a single-column ("id") Parquet file so AddFiles 
has a
+// real file to ingest.
+func writeInt64Parquet(t *testing.T, fs iceio.LocalFS, path string, 
arrowSchema *arrow.Schema, ids []int64) {
+       t.Helper()
+
+       bldr := array.NewInt64Builder(memory.DefaultAllocator)
+       defer bldr.Release()
+
+       bldr.AppendValues(ids, nil)
+       col := bldr.NewArray()
+       defer col.Release()
+
+       rec := array.NewRecordBatch(arrowSchema, []arrow.Array{col}, 
int64(len(ids)))
+       defer rec.Release()
+
+       arrTbl := array.NewTableFromRecords(arrowSchema, 
[]arrow.RecordBatch{rec})
+       defer arrTbl.Release()
+
+       fo, err := fs.Create(path)

Review Comment:
   `fo` never gets closed — `pqarrow.WriteTable` closes its own parquet writer 
but not the underlying `FileWriter`. It's test-only and `t.TempDir()` cleans 
the dir, so the leak is hidden, but on Windows the subsequent `AddFiles` could 
read a partially-flushed file. I'd add `defer func() { require.NoError(t, 
fo.Close()) }()` right after `Create`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to