laskoviymishka commented on code in PR #1637:
URL: https://github.com/apache/iceberg-go/pull/1637#discussion_r3719187125
##########
table/metadata.go:
##########
@@ -390,6 +390,42 @@ func (b *MetadataBuilder) currentSnapshot() *Snapshot {
return s
}
+// currentSnapshotForRef returns the head snapshot for ref.
+// Empty or main refs return the current snapshot.
+// Unknown refs also fall back to the current snapshot.
+func (b *MetadataBuilder) currentSnapshotForRef(ref string) *Snapshot {
+ if ref == "" || ref == MainBranch {
+ return b.currentSnapshot()
+ }
+
+ r, ok := b.refs[ref]
+ if !ok {
+ return b.currentSnapshot()
Review Comment:
This fallback isn't mirrored on the OCC retry path, and the gap is silent.
`rebuildSnapshotUpdates` in `table/table.go:694` (outside this diff)
resolves the retry parent as `freshMeta.SnapshotByName(branch)` and stops
there, no `CurrentSnapshot()` fallback. On a first write to a not-yet-existing
branch, attempt 0 gets main's head through this helper and inherits main's
manifests, but a retry gets `freshHead == nil`, so `rebuildFn` calls
`assembleManifests` with a nil parent, `existingManifests(nil)` returns
nothing, and the rebuilt list holds only `addedContent`. The attempt-0 list
that did inherit main's manifests is then deleted as orphaned. The commit
succeeds with the branch missing all of main's data.
The asymmetry predates this PR, but pre-fix the main-head assertion failed
`Validate` on every attempt so no retry could ever commit. This change is what
makes it reachable. A couple of ways to handle, wdyt?
- give `rebuildSnapshotUpdates` the same `CurrentSnapshot()` fallback,
matching Java's `SnapshotUtil.latestSnapshot(freshBase, branch)`
- drop the fallback here and make fork-from-main an explicit choice in
`createSnapshotProducer`, so one place owns the policy
##########
table/metadata.go:
##########
@@ -390,6 +390,42 @@ func (b *MetadataBuilder) currentSnapshot() *Snapshot {
return s
}
+// currentSnapshotForRef returns the head snapshot for ref.
+// Empty or main refs return the current snapshot.
+// Unknown refs also fall back to the current snapshot.
+func (b *MetadataBuilder) currentSnapshotForRef(ref string) *Snapshot {
+ if ref == "" || ref == MainBranch {
+ return b.currentSnapshot()
+ }
+
+ r, ok := b.refs[ref]
+ if !ok {
+ return b.currentSnapshot()
+ }
+
+ s, _ := b.SnapshotByID(r.SnapshotID)
+
+ return s
+}
+
+// currentSnapshotIDForRef returns the head snapshot ID for ref.
+// Empty or main refs return currentSnapshotID.
+// Unknown refs return nil so AssertRefSnapshotID asserts that the
Review Comment:
I think this works right now and it isn't a blocker, but I'd really like
both doc comments to say *why* they diverge, not just that they do.
The split is load-bearing: the parent lookup wants main's head so a new
branch forks from main, while the assertion wants nil so `AssertRefSnapshotID`
proves the branch is absent. Read cold, the two comments look like an
inconsistency. The failure mode for anyone deriving one from the other
(`currentSnapshotForRef(ref).SnapshotID` as the assertion id) is a spurious OCC
rejection on every new-branch create. One sentence in each comment naming the
caller it serves would head that off.
##########
table/transaction_internal_test.go:
##########
@@ -44,6 +45,140 @@ func
TestTransactionApplyKeepsDistinctRequirementsOfSameType(t *testing.T) {
requireContainsRefSnapshotRequirement(t, txn.reqs, "feature",
&featureSnapshotID)
}
+func TestCurrentSnapshotForRefResolvesBranchHead(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+
+ main := txn.meta.currentSnapshotForRef(MainBranch)
+ require.NotNil(t, main)
+ require.Equal(t, int64(10), main.SnapshotID)
+
+ empty := txn.meta.currentSnapshotForRef("")
+ require.NotNil(t, empty)
+ require.Equal(t, int64(10), empty.SnapshotID, "empty ref must resolve
like main")
+
+ feature := txn.meta.currentSnapshotForRef("feature")
+ require.NotNil(t, feature)
+ require.Equal(t, int64(20), feature.SnapshotID, "feature branch must
resolve to its own head (20), not main (10)")
+
+ missing := txn.meta.currentSnapshotForRef("does-not-exist")
+ require.NotNil(t, missing)
+ require.Equal(t, int64(10), missing.SnapshotID, "a not-yet-created
branch falls back to main's head")
+}
+
+func TestCurrentSnapshotIDForRefResolvesBranchHead(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+
+ require.NotNil(t, txn.meta.currentSnapshotIDForRef(MainBranch))
+ require.Equal(t, int64(10),
*txn.meta.currentSnapshotIDForRef(MainBranch))
+
+ require.NotNil(t, txn.meta.currentSnapshotIDForRef("feature"))
+ require.Equal(t, int64(20),
*txn.meta.currentSnapshotIDForRef("feature"),
+ "feature branch assertion id must be the branch head (20), not
main (10)")
+
+ require.Nil(t, txn.meta.currentSnapshotIDForRef("does-not-exist"),
+ "a not-yet-created branch must assert non-existence (nil), not
main's head")
+}
+
+func TestCreateSnapshotProducerParentsOnBranchHead(t *testing.T) {
+ t.Run("feature branch parents on feature head", func(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+ txn.branch = "feature"
+ sp := createSnapshotProducer(OpAppend, txn, nil, nil, nil)
+ require.Equal(t, int64(20), sp.parentSnapshotID,
+ "append on feature must layer on the feature head (20),
not main head (10)")
+ })
+
+ t.Run("main branch still parents on main head", func(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+ txn.branch = ""
+ sp := createSnapshotProducer(OpAppend, txn, nil, nil, nil)
+ require.Equal(t, int64(10), sp.parentSnapshotID)
+ })
+}
+
+func TestBranchWriteCommitsThroughCatalogPath(t *testing.T) {
+ ctx := context.Background()
+ spec := iceberg.NewPartitionSpec()
+ ident := Identifier{"db", "tbl"}
+
+ producers := []struct {
+ name string
+ op Operation
+ newProd func(Operation, *Transaction, iceio.WriteFileIO,
*uuid.UUID, iceberg.Properties) *snapshotProducer
+ }{
+ {"fast append", OpAppend, newFastAppendFilesProducer},
+ {"merge append", OpAppend, newMergeAppendFilesProducer},
+ {"overwrite", OpOverwrite, newOverwriteFilesProducer},
+ }
+
+ for _, tc := range producers {
+ t.Run(tc.name, func(t *testing.T) {
+ txn, memIO := createTestTransactionWithMemIO(t, spec)
+
+ // 1. Create the "feature" branch on a fresh table. The
branch does
+ // not exist yet, so the snapshot has no parent and the
requirement
+ // asserts the branch is absent (nil).
+ txn.branch = "feature"
+ sp1 := newFastAppendFilesProducer(OpAppend, txn, memIO,
nil, nil)
+ sp1.appendDataFile(newTestDataFile(t, spec,
"file://feature-1.parquet", nil))
+ up1, rq1, err := sp1.commit(ctx)
+ require.NoError(t, err)
+ addSnap1, ok := up1[0].(*addSnapshotUpdate)
+ require.True(t, ok)
+ require.Nil(t, addSnap1.Snapshot.ParentSnapshotID,
"first feature snapshot has no parent")
Review Comment:
This assertion is vacuous as written.
Step 1 builds "feature" on a fresh table, so
`currentSnapshotForRef("feature")` falls through to `currentSnapshot()`, which
is nil, and `ParentSnapshotID` comes back nil regardless of what the helper
does. It'd still pass if we deleted the `!ok` fallback entirely.
The case that actually exercises the fallback is a first write to a new
branch when main already has data: `ParentSnapshotID` should be main's head and
the snapshot's manifests should include main's files. That's the same scenario
the retry path gets wrong, so pinning it here earns its keep twice.
##########
table/transaction.go:
##########
@@ -60,7 +60,7 @@ func (s snapshotUpdate) fastAppend() *snapshotProducer {
// checks.
func (s snapshotUpdate) mergeOverwrite(commitUUID *uuid.UUID, filter
iceberg.BooleanExpression) *snapshotProducer {
op := s.operation
- if s.operation == OpOverwrite && s.txn.meta.currentSnapshot() == nil {
+ if s.operation == OpOverwrite &&
s.txn.meta.currentSnapshotForRef(s.txn.branch) == nil {
Review Comment:
Three sibling call sites in this same file still read main's head, so branch
support is only half wired.
`ReplaceDataFiles` at line 613 and `ReplaceDataFilesWithDataFiles` at line
999 both do `s := meta.currentSnapshot()`, bail with `ErrInvalidOperation` when
it's nil, then scan `s.dataFiles(fs, nil)` to build the delete set.
`AddDataFiles` does the same at line 906 for the duplicate check. On a branch
transaction that means a branch with data fails as "cannot replace files in a
table without an existing snapshot" when main is empty, and the duplicate check
misses files that live only on the branch.
I'd swap those to `currentSnapshotForRef(t.branch)` in this pass, since it's
the same one-line change you've already made here. If you'd rather keep the
diff narrow, a follow-up issue works, but then the PR description shouldn't
read as though branch writes are fixed end to end.
##########
table/metadata.go:
##########
@@ -390,6 +390,42 @@ func (b *MetadataBuilder) currentSnapshot() *Snapshot {
return s
}
+// currentSnapshotForRef returns the head snapshot for ref.
+// Empty or main refs return the current snapshot.
+// Unknown refs also fall back to the current snapshot.
Review Comment:
Small thing: "unknown refs" here means "not in `b.refs`", but a ref that
*is* present and points at a snapshot missing from `b.snapshotList` returns nil
rather than falling back.
`RemoveSnapshots` prunes dangling refs so this shouldn't happen today, and
`currentSnapshot()` at line 388 has the same shape, so I'm not asking for a
code change. Worth a line saying the fallback assumes every entry in `b.refs`
resolves, since `createSnapshotProducer` and `mergeOverwrite` both read nil as
"no snapshot exists".
##########
table/transaction_internal_test.go:
##########
@@ -44,6 +45,140 @@ func
TestTransactionApplyKeepsDistinctRequirementsOfSameType(t *testing.T) {
requireContainsRefSnapshotRequirement(t, txn.reqs, "feature",
&featureSnapshotID)
}
+func TestCurrentSnapshotForRefResolvesBranchHead(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+
+ main := txn.meta.currentSnapshotForRef(MainBranch)
+ require.NotNil(t, main)
+ require.Equal(t, int64(10), main.SnapshotID)
+
+ empty := txn.meta.currentSnapshotForRef("")
+ require.NotNil(t, empty)
+ require.Equal(t, int64(10), empty.SnapshotID, "empty ref must resolve
like main")
+
+ feature := txn.meta.currentSnapshotForRef("feature")
+ require.NotNil(t, feature)
+ require.Equal(t, int64(20), feature.SnapshotID, "feature branch must
resolve to its own head (20), not main (10)")
+
+ missing := txn.meta.currentSnapshotForRef("does-not-exist")
+ require.NotNil(t, missing)
+ require.Equal(t, int64(10), missing.SnapshotID, "a not-yet-created
branch falls back to main's head")
+}
+
+func TestCurrentSnapshotIDForRefResolvesBranchHead(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+
+ require.NotNil(t, txn.meta.currentSnapshotIDForRef(MainBranch))
+ require.Equal(t, int64(10),
*txn.meta.currentSnapshotIDForRef(MainBranch))
+
+ require.NotNil(t, txn.meta.currentSnapshotIDForRef("feature"))
+ require.Equal(t, int64(20),
*txn.meta.currentSnapshotIDForRef("feature"),
+ "feature branch assertion id must be the branch head (20), not
main (10)")
+
+ require.Nil(t, txn.meta.currentSnapshotIDForRef("does-not-exist"),
+ "a not-yet-created branch must assert non-existence (nil), not
main's head")
+}
+
+func TestCreateSnapshotProducerParentsOnBranchHead(t *testing.T) {
+ t.Run("feature branch parents on feature head", func(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+ txn.branch = "feature"
+ sp := createSnapshotProducer(OpAppend, txn, nil, nil, nil)
+ require.Equal(t, int64(20), sp.parentSnapshotID,
+ "append on feature must layer on the feature head (20),
not main head (10)")
+ })
+
+ t.Run("main branch still parents on main head", func(t *testing.T) {
+ txn := newTransactionWithSnapshotRefs(t)
+ txn.branch = ""
+ sp := createSnapshotProducer(OpAppend, txn, nil, nil, nil)
+ require.Equal(t, int64(10), sp.parentSnapshotID)
+ })
+}
+
+func TestBranchWriteCommitsThroughCatalogPath(t *testing.T) {
+ ctx := context.Background()
+ spec := iceberg.NewPartitionSpec()
+ ident := Identifier{"db", "tbl"}
+
+ producers := []struct {
+ name string
+ op Operation
+ newProd func(Operation, *Transaction, iceio.WriteFileIO,
*uuid.UUID, iceberg.Properties) *snapshotProducer
+ }{
+ {"fast append", OpAppend, newFastAppendFilesProducer},
+ {"merge append", OpAppend, newMergeAppendFilesProducer},
+ {"overwrite", OpOverwrite, newOverwriteFilesProducer},
+ }
+
+ for _, tc := range producers {
+ t.Run(tc.name, func(t *testing.T) {
+ txn, memIO := createTestTransactionWithMemIO(t, spec)
+
+ // 1. Create the "feature" branch on a fresh table. The
branch does
+ // not exist yet, so the snapshot has no parent and the
requirement
+ // asserts the branch is absent (nil).
+ txn.branch = "feature"
+ sp1 := newFastAppendFilesProducer(OpAppend, txn, memIO,
nil, nil)
+ sp1.appendDataFile(newTestDataFile(t, spec,
"file://feature-1.parquet", nil))
+ up1, rq1, err := sp1.commit(ctx)
+ require.NoError(t, err)
+ addSnap1, ok := up1[0].(*addSnapshotUpdate)
+ require.True(t, ok)
+ require.Nil(t, addSnap1.Snapshot.ParentSnapshotID,
"first feature snapshot has no parent")
+ requireContainsRefSnapshotRequirement(t, rq1,
"feature", nil)
+ featureHead := addSnap1.Snapshot.SnapshotID
+ require.NoError(t, txn.apply(up1, rq1))
+ meta1, err := txn.meta.Build()
+ require.NoError(t, err)
+
+ // 2. Advance main independently so feature and main
diverge.
+ tblMain := New(ident, meta1, "metadata.json",
func(context.Context) (iceio.IO, error) { return memIO, nil }, nil)
+ txnMain := tblMain.NewTransaction()
+ spMain := newFastAppendFilesProducer(OpAppend, txnMain,
memIO, nil, nil)
+ spMain.appendDataFile(newTestDataFile(t, spec,
"file://main-1.parquet", nil))
+ upM, rqM, err := spMain.commit(ctx)
+ require.NoError(t, err)
+ mainHead :=
upM[0].(*addSnapshotUpdate).Snapshot.SnapshotID
Review Comment:
Worth the checked form here, for consistency with `addSnap1` fourteen lines
up. If `upM[0]` ever comes back as a different update type this panics into a
goroutine dump instead of a clean test failure.
--
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]