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


##########
table/row_delta_test.go:
##########
@@ -1427,3 +1427,164 @@ func assertRowCount(t *testing.T, tbl *table.Table, 
expected int64) {
 
        assert.Equal(t, expected, total, "unexpected row count")
 }
+
+func unregisteredSpec() iceberg.PartitionSpec {
+       return iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id_identity", 
Transform: iceberg.IdentityTransform{},
+       })
+}
+
+func buildPosDeleteFileForSpec(t *testing.T, spec iceberg.PartitionSpec, path 
string, partition map[int]any) iceberg.DataFile {
+       t.Helper()
+
+       b, err := iceberg.NewDataFileBuilder(
+               spec, iceberg.EntryContentPosDeletes,
+               path, iceberg.ParquetFile, partition, nil, nil, 5, 512)
+       require.NoError(t, err)
+
+       return b.Build()
+}
+
+func buildDataFileForSpec(t *testing.T, spec iceberg.PartitionSpec, path 
string, partition map[int]any) iceberg.DataFile {
+       t.Helper()
+
+       b, err := iceberg.NewDataFileBuilder(
+               spec, iceberg.EntryContentData,
+               path, iceberg.ParquetFile, partition, nil, nil, 10, 1024)
+       require.NoError(t, err)
+
+       return b.Build()
+}
+
+func TestRowDeltaRejectsDeleteFileWithUnregisteredSpec(t *testing.T) {
+       tbl := newRowDeltaCommitTestTable(t)
+
+       tx := tbl.NewTransaction()
+       rd := tx.NewRowDelta(nil)
+       rd.AddDeletes(buildPosDeleteFileForSpec(t, unregisteredSpec(),
+               "s3://bucket/data/pos-del.parquet", map[int]any{1000: 
int64(7)}))
+
+       err := rd.Commit(t.Context())
+       require.Error(t, err)
+       assert.ErrorIs(t, err, table.ErrPartitionSpecNotFound)
+       assert.ErrorContains(t, err, "99")
+
+       result, err := tx.Commit(t.Context())
+       require.NoError(t, err)
+       assert.Nil(t, result.CurrentSnapshot(), "a rejected row delta must not 
produce a snapshot")
+}
+
+func TestRowDeltaRejectsDataFileWithUnregisteredSpec(t *testing.T) {
+       tbl := newRowDeltaCommitTestTable(t)
+
+       tx := tbl.NewTransaction()
+       rd := tx.NewRowDelta(nil)
+       rd.AddRows(buildDataFileForSpec(t, unregisteredSpec(),
+               "s3://bucket/data/insert.parquet", map[int]any{1000: int64(7)}))
+
+       err := rd.Commit(t.Context())
+       require.Error(t, err)
+       assert.ErrorIs(t, err, table.ErrPartitionSpecNotFound)
+       assert.ErrorContains(t, err, "99")
+}
+
+func TestRowDeltaWritesRegisteredNonDefaultSpec(t *testing.T) {
+       tbl := newRowDeltaCommitTestTable(t)
+
+       evolveTx := tbl.NewTransaction()
+       require.NoError(t, evolveTx.UpdateSpec(false).
+               AddField("id", iceberg.IdentityTransform{}, "id_identity").
+               Commit())
+       evolved, err := evolveTx.Commit(t.Context())
+       require.NoError(t, err)
+
+       spec := evolved.Metadata().PartitionSpec()
+       require.NotEqual(t, 0, spec.ID(), "spec evolution must register a new 
spec id")
+       require.Equal(t, 1, spec.NumFields())
+       fieldID := spec.Field(0).FieldID
+       partition := map[int]any{fieldID: int64(7)}
+
+       tx := evolved.NewTransaction()
+       rd := tx.NewRowDelta(nil)
+       rd.AddRows(buildDataFileForSpec(t, spec, 
"s3://bucket/data/insert.parquet", partition))
+       rd.AddDeletes(buildPosDeleteFileForSpec(t, spec, 
"s3://bucket/data/pos-del.parquet", partition))
+       require.NoError(t, rd.Commit(t.Context()))
+
+       result, err := tx.Commit(t.Context())
+       require.NoError(t, err)
+
+       snap := result.CurrentSnapshot()
+       require.NotNil(t, snap)
+
+       fs := iceio.LocalFS{}
+       manifests, err := snap.Manifests(fs)
+       require.NoError(t, err)
+       require.Len(t, manifests, 2)
+
+       for _, m := range manifests {
+               assert.Equal(t, int32(spec.ID()), m.PartitionSpecID(),
+                       "manifest must declare the spec its entries were 
written under")
+               entries := 0
+               for e, err := range m.Entries(fs, true) {
+                       require.NoError(t, err)
+                       assert.Equal(t, int32(spec.ID()), e.DataFile().SpecID())
+                       assert.Equal(t, int64(7), 
e.DataFile().Partition()[fieldID])
+                       entries++
+               }
+               assert.Equal(t, 1, entries)
+       }
+}
+
+func TestRowDeltaMixedSpecCommitLeavesNoManifests(t *testing.T) {
+       validData := func() iceberg.DataFile { return buildDataFile(t, 
"s3://bucket/data/insert.parquet") }
+       validDelete := func() iceberg.DataFile { return buildPosDeleteFile(t, 
"s3://bucket/data/pos-del.parquet") }
+       orphanData := func() iceberg.DataFile {
+               return buildDataFileForSpec(t, unregisteredSpec(), 
"s3://bucket/data/orphan.parquet", map[int]any{1000: int64(7)})
+       }
+       orphanDelete := func() iceberg.DataFile {
+               return buildPosDeleteFileForSpec(t, unregisteredSpec(), 
"s3://bucket/data/orphan-del.parquet", map[int]any{1000: int64(7)})
+       }
+
+       tests := []struct {
+               name    string
+               rows    []iceberg.DataFile
+               deletes []iceberg.DataFile
+       }{
+               {"valid data with unregistered delete", 
[]iceberg.DataFile{validData()}, []iceberg.DataFile{orphanDelete()}},
+               {"valid delete with unregistered data", 
[]iceberg.DataFile{orphanData()}, []iceberg.DataFile{validDelete()}},
+               {"valid data group before unregistered one", 
[]iceberg.DataFile{validData(), orphanData()}, nil},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       tbl := newRowDeltaCommitTestTable(t)

Review Comment:
   This is the test that would catch the ordering gap I flagged in 
`snapshot_producers.go`, but it can't as written — `newRowDeltaCommitTestTable` 
gives a fresh table, so `deletedEntries()` is empty and no tombstones get 
written before `validateAddedSpecs` fires. Every subtest here rejects during 
the added-file phase with nothing on disk yet.
   
   `TestParentDependentManifestsMixedSpecWritesNoManifests` has the same 
limitation — it drives `parentDependentManifests` in isolation, never the full 
`buildManifests` where the parent-dependent phase succeeds and the added-spec 
phase then fails.
   
   Once the validation moves up, I'd add a case that commits a file first, then 
runs an overwrite with a valid deletion plus an unregistered added-file spec, 
and asserts no new manifests. That's the path that's currently unguarded.



##########
table/snapshot_producers.go:
##########
@@ -626,12 +632,16 @@ func createSnapshotProducer(op Operation, txn 
*Transaction, fs iceio.WriteFileIO
        }
 }
 
-func (sp *snapshotProducer) spec(id int) iceberg.PartitionSpec {
-       if spec, _ := sp.txn.meta.GetSpecByID(id); spec != nil {
-               return *spec
+func (sp *snapshotProducer) spec(id int) (iceberg.PartitionSpec, error) {
+       spec, err := sp.txn.meta.GetSpecByID(id)
+       if err == nil && spec == nil {
+               err = ErrPartitionSpecNotFound
+       }
+       if err != nil {

Review Comment:
   I'd reserve the "unregistered" wording for the actual not-found case. As 
written, any error out of `GetSpecByID` (a future decode/IO failure, say) comes 
back to the caller labeled `"unregistered partition spec id N"`, which wouldn't 
be true.
   
   The `if err == nil && spec == nil` guard also looks dead — `GetSpecByID` 
returns either `(*spec, nil)` or `(nil, err)`, never `(nil, nil)` — so the 
not-found signal is already the error it hands back. Something like:
   ```go
   spec, err := sp.txn.meta.GetSpecByID(id)
   if errors.Is(err, ErrPartitionSpecNotFound) {
       return iceberg.PartitionSpec{}, fmt.Errorf("unregistered partition spec 
id %d: %w", id, err)
   }
   if err != nil {
       return iceberg.PartitionSpec{}, err
   }
   return *spec, nil
   ```
   Minor while we're here: the id lands in the message twice (`"unregistered 
partition spec id 99: partition spec not found: id 99"`) — worth trimming one.



##########
table/row_delta_test.go:
##########
@@ -1427,3 +1427,164 @@ func assertRowCount(t *testing.T, tbl *table.Table, 
expected int64) {
 
        assert.Equal(t, expected, total, "unexpected row count")
 }
+
+func unregisteredSpec() iceberg.PartitionSpec {
+       return iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "id_identity", 
Transform: iceberg.IdentityTransform{},

Review Comment:
   Small one: this and `unregisteredSpec()` in `snapshot_producers_test.go` 
(around line 2033) are near-duplicates both hard-coding id 99, but with 
different `Name` values — `"id_identity"` here, `"id"` there. Different 
packages so they can't be shared, but the mismatch reads as accidental, and 
anyone who later registers spec 99 or evolves past it breaks both files 
confusingly. I'd at least align the `Name`.



##########
table/snapshot_producers.go:
##########
@@ -788,6 +798,10 @@ func (sp *snapshotProducer) buildManifests(ctx 
context.Context, parent *Snapshot
 // written once and reused verbatim across OCC retries (rewriting them would
 // churn manifest paths and orphan object-store files on every attempt).
 func (sp *snapshotProducer) addedContentManifests() ([]iceberg.ManifestFile, 
error) {
+       if err := sp.validateAddedSpecs(); err != nil {

Review Comment:
   Looking at this validation path on its own, I think it closes the 
sibling-goroutine race but not the cross-phase one. `buildManifests` calls 
`parentDependentManifests` first, and that can write tombstone manifests for 
deleted entries before we ever reach `addedContentManifests`, where 
`validateAddedSpecs` actually runs.
   
   So an overwrite with valid parent-dependent deletions plus an added file on 
an unregistered spec writes the tombstones, then rejects, and those manifests 
are orphaned. Same shape on the summary side: the new `removeFile` closure in 
`accumulateSummaryDelta` errors on an unregistered deleted-file spec, but that 
runs after `buildManifests` has already written everything.
   
   I'd lift a single validation to the very top of `buildManifests`, before 
either phase, and widen it to cover the to-be-deleted spec ids too 
(`deletedFiles` / `deletedDeleteFiles` / `deletedDVsByRef`), not just 
`addedFiles` — so nothing touches object storage until every spec id in the 
commit is known-good. I left a note on 
`TestRowDeltaMixedSpecCommitLeavesNoManifests` about the test that would guard 
this.



-- 
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