fix(table): resolve write parent snapshot from target branch head instead of main - #1637
Conversation
Signed-off-by: badalprasadsingh <badal@datazip.io>
laskoviymishka
left a comment
There was a problem hiding this comment.
I'd hold this before merge, but the diagnosis is right: currentSnapshot() only ever tracking main was the actual root cause, and splitting the parent lookup from the assertion lookup is the correct shape.
What I'd want fixed first is that rebuildSnapshotUpdates in table/table.go doesn't mirror the new fallback. It resolves the retry parent with freshMeta.SnapshotByName(branch) and stops, so on a first write to a branch that doesn't exist yet it gets nil where currentSnapshotForRef gave attempt 0 main's head. rebuildFn then calls assembleManifests with a nil parent, existingManifests(nil) returns nothing, and the rebuilt manifest list carries only the new files while the attempt-0 list that did inherit main's manifests gets deleted as orphaned. The commit succeeds and the branch quietly comes up missing all of main's data. The asymmetry predates this PR, but the old main-head assertion always failed before any retry could commit, so this is the change that makes it reachable.
Things I'd want settled before merge:
- add the
CurrentSnapshot()fallback torebuildSnapshotUpdates, or move the fork-from-main choice up intocreateSnapshotProducerso one place owns it - cover the first write to a new branch when main already has data, since every branch test here starts from an empty table and the parentless assertion passes vacuously
- either extend
currentSnapshotForReftoReplaceDataFiles,ReplaceDataFilesWithDataFilesandAddDataFiles, or file a follow-up so branch support isn't advertised as complete
Once those are addressed, happy to take another pass.
|
|
||
| r, ok := b.refs[ref] | ||
| if !ok { | ||
| return b.currentSnapshot() |
There was a problem hiding this 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
rebuildSnapshotUpdatesthe sameCurrentSnapshot()fallback, matching Java'sSnapshotUtil.latestSnapshot(freshBase, branch) - drop the fallback here and make fork-from-main an explicit choice in
createSnapshotProducer, so one place owns the policy
There was a problem hiding this comment.
Fixed.
You are absolutely right. It was just as you described, moving with option one: latestSnapshotForBranch matches Java's SnapshotUtil.latestSnapshot(freshBase, branch). Added TestBranchCreateForksFromMainHeadAcrossRetry, which reproduces the data loss on the pre-fix code.
| 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 { |
There was a problem hiding this 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.
There was a problem hiding this comment.
Yes. For now, I am keeping his PR scoped to the snapshot-parent/lineage + retry fix and handling the read-side planning in a dedicated follow-up.
I'll make the PR description explicit that read-side planning is not yet branch-aware so nothing reads as end-to-end.
| 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") |
There was a problem hiding this 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.
There was a problem hiding this comment.
Done.
It was vacuous. Kept the fresh-table case as said but added TestBranchCreateForksFromMainHead, which starts from a table where main already has data, so ParentSnapshotID must equal main's head and the new branch's manifests must include main's files - it now fails if the !ok fallback is removed. As you noted, this is the same scenario the retry path got wrong, so the retry variant pins it a second time.
|
|
||
| // currentSnapshotIDForRef returns the head snapshot ID for ref. | ||
| // Empty or main refs return currentSnapshotID. | ||
| // Unknown refs return nil so AssertRefSnapshotID asserts that the |
There was a problem hiding this 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.
|
|
||
| // currentSnapshotForRef returns the head snapshot for ref. | ||
| // Empty or main refs return the current snapshot. | ||
| // Unknown refs also fall back to the current snapshot. |
There was a problem hiding this 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".
There was a problem hiding this comment.
Done, as well.
| 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 |
There was a problem hiding this 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.
There was a problem hiding this comment.
Done. Now, consistent with addSnap1.
Signed-off-by: badalprasadsingh <badal@datazip.io>
Signed-off-by: badalprasadsingh <badal@datazip.io>
zeroshade
left a comment
There was a problem hiding this comment.
Thanks for taking this on — the parent-selection change itself is correct, a single-snapshot retry correctly re-resolves the branch head on every attempt, missing branches correctly fork from main, and default/explicit-main behavior is unchanged. Those pieces are sound.
Branch write planning still reads main
The blocking issue outside the changed lines is that snapshot production now parents on the target branch, while high-level destructive-write planning and validation still inspect meta.currentSnapshot() (main). The resulting snapshot can therefore be parented on the branch while its deletion/replacement plan describes main. A full branch overwrite can retain branch-only files, and replace operations can reject or target the wrong files.
The verified head locations are:
table/transaction.go:613-643—ReplaceDataFilesmembership and duplicate validation.table/transaction.go:905-921— add-file duplicate validation.table/transaction.go:999-1029—ReplaceDataFilesWithDataFilesmembership and duplicate validation.table/transaction.go:1133-1185—ReplaceFilesdata/delete/DV validation.table/transaction.go:1271-1286— add-file duplicate validation.table/transaction.go:1681-1697— copy-on-write/delete file classification.table/transaction.go:1756-1763— filtered deletion manifest selection.table/transaction.go:2173-2190— merge-on-read deletion-vector collection.table/rewrite_manifests.go:360-367— branch rewrite no-op detection.
Suggested fix: resolve every planning and validation snapshot through currentSnapshotForRef(t.branch) (or a shared fallible branch-aware lookup), then add semantic tests using branch-only files.
Write-path coverage
| Path | Branch HEAD as parent | End-to-end |
|---|---|---|
| Append | Correct (snapshot_producers.go:576-605) |
Correct except multi-snapshot retry |
| Fast append | Correct (snapshot_producers.go:92-96) |
Correct except multi-snapshot retry |
| Merge append | Correct (snapshot_producers.go:516-527) |
Correct except multi-snapshot retry |
| Overwrite | Correct (snapshot_producers.go:153-157) |
Incorrect: deletion planning reads main |
| Delete copy-on-write | Correct (transaction.go:61-71,1477-1505) |
Incorrect: file classification reads main |
| Delete merge-on-read | Correct (transaction.go:1524-1552) |
Incorrect: classification and DV lookup read main |
| ReplaceDataFiles | Correct (snapshot_producers.go:658-684) |
Incorrect: validation/planning reads main |
| ReplaceFiles / ReplaceDataFilesWithDataFiles | Correct (snapshot_producers.go:1038-1058,1193-1218) |
Incorrect: validation/planning reads main |
| RewriteFiles / RewriteDataFiles | Correct via ReplaceFiles | Incorrect: inherits main-based validation |
| RewriteManifests | Correct (rewrite_manifests.go:133-137) |
Incomplete: no-op check at :360-367 reads main |
| RowDelta | Correct (row_delta.go:159-181) |
No branch-specific coverage |
| Multiple staged snapshots | Correctly chained initially | Incorrect on retry: all get the same fresh parent |
The two other blockers are inline: retrying multiple staged snapshots has a silent-data-loss path, and a tag-targeted write can advance and convert the tag into a branch. Missing coverage includes public Append/Overwrite/Delete/Replace/Rewrite/AddFiles/RowDelta on divergent branches; actual branch-only overwrite/delete semantics; copy-on-write and merge-on-read deletion; multiple staged snapshots with a forced retry; branch advancement between attempts; a missing branch concurrently created as a tag; existing-tag rejection; full public explicit-main commit; and malformed/dangling refs.
| } else if freshMeta != nil { | ||
| freshHead = freshMeta.CurrentSnapshot() | ||
| if freshMeta != nil { | ||
| freshHead = latestSnapshotForBranch(freshMeta, branch) |
There was a problem hiding this comment.
freshHead is calculated once and then supplied to every staged addSnapshotUpdate. If a transaction stages snapshots A and B and retries, both are rebuilt as children of the same branch head; they become siblings, and the final ref to B omits A. That is a silent-data-loss path.
Suggested fix: replay staged snapshot updates sequentially, making each rebuilt snapshot the logical parent of the next one, and add a forced-retry test with at least two staged snapshots.
| return b.currentSnapshot() | ||
| } | ||
|
|
||
| r, ok := b.refs[ref] |
There was a problem hiding this comment.
This accepts any named ref without checking its type. A transaction targeting a tag will resolve the tag as its parent, then commitManifests emits a BranchRef replacement: the write advances the tag and converts it into a branch. The same bug exists if an initially absent branch name becomes a tag during retry.
Suggested fix: reject non-branch refs both when constructing the transaction and after every metadata refresh before replay/commit, with tests for an existing tag and the absent-name→tag race.
| return b.currentSnapshot() | ||
| } | ||
|
|
||
| s, _ := b.SnapshotByID(r.SnapshotID) |
There was a problem hiding this comment.
A present ref whose snapshot cannot be resolved is silently treated as parentless here. Although loaded metadata should already reject this state, this helper should fail closed rather than turning a dangling ref into a first-snapshot write.
Suggested fix: make the lookup fallible (or validate before reaching it) and propagate ErrInvalidMetadata when a named ref points to a missing snapshot.
|
Sure @zeroshade. I'll address Issue #1638 in this PR itself. Will also revert the PR title and description. |
laskoviymishka
left a comment
There was a problem hiding this comment.
Almost there. The retry path is fixed and the new test bites, which is what I wanted from last round.
One thing before merge, and it's new with the assertion change rather than something I raised before. Switching AssertRefSnapshotID to the ref's own head removed the accident that was blocking tag writes: pre-fix the requirement asserted main's head against a tag and failed Validate, so tag targeting was unreachable. Now it asserts the tag's own id, passes, and commitManifests advances the ref with BranchRef, so a tag named v1.0 becomes a branch and stops being immutable. Java guards this in SnapshotProducer.targetBranch(). A ref-type check in NewTransactionOnBranchWithError would fail it at construction and cover all three helpers at once.
Everything else I asked for is in:
latestSnapshotForBranch, taking the option-one route matchingSnapshotUtil.latestSnapshot; crediting your call, mirroring it was the right choice over pushing the policy up intocreateSnapshotProducerTestBranchCreateForksFromMainHeadstarting from a table where main already has data, so it fails if the!okfallback goes away, andTestBranchCreateForksFromMainHeadAcrossRetryreaching the rebuild path- the doc comments on both helpers, and the checked assertion on
addSnapM - #1638 for the read-side callers, which settles the scope question
Fix the tag guard and this is good to land. The SnapshotByName simplification and the peer-advances-branch test case are both optional.
| // the two must not be conflated or new-branch creates get a false OCC rejection. | ||
| func (b *MetadataBuilder) currentSnapshotIDForRef(ref string) *int64 { | ||
| if ref == "" || ref == MainBranch { | ||
| return b.currentSnapshotID |
There was a problem hiding this comment.
This is new with the assertion change, not something I raised last round. None of the three helpers check SnapshotRefType, so a tag name passed as txn.branch resolves like a branch. Pre-fix that was harmless by accident: the requirement asserted main's head against the tag, so Validate rejected it. Now it asserts the tag's own id, the check passes, and commitManifests calls NewRetainingSnapshotRefUpdate(branch, ..., BranchRef) then b.refs[name] = ref, so the tag advances to a new child snapshot and its immutability is gone. Java rejects this up front in SnapshotProducer.targetBranch(): checkArgument(!refExists || base.ref(branch).isBranch(), "%s is a tag, not a branch...").
I'd put the guard in NewTransactionOnBranchWithError so it fails at construction rather than at commit, which also keeps all three helpers safe without touching them.
| return meta.CurrentSnapshot() | ||
| } | ||
|
|
||
| for name, ref := range meta.Refs() { |
There was a problem hiding this comment.
Metadata already exposes SnapshotByName, and its body is exactly this lookup (SnapshotRefs[name] then SnapshotByID) as a direct map index. Refs() yields through cloneSnapshotRef per entry, so this allocates across the whole ref set on every retry attempt to find one name. rewriteRefSnapshotRequirements a few lines up already uses SnapshotByName for the same thing.
if s := meta.SnapshotByName(branch); s != nil {
return s
}
return meta.CurrentSnapshot()Same semantics, and it makes the missing ref-type check easier to see.
| mainHead := mainMeta.CurrentSnapshot().SnapshotID | ||
|
|
||
| // Fail attempt 0 (forcing a retry through rebuildSnapshotUpdates), apply on 1. | ||
| cat := &flakyCatalog{metadata: mainMeta, failUntilAttempt: 1, failWith: fmt.Errorf("REST: %w", ErrCommitFailed)} |
There was a problem hiding this comment.
flakyCatalog returns the same mainMeta on the retry, so freshMeta still has no feature ref and latestSnapshotForBranch falls back to main's head, same as attempt 0. That pins the nil-parent regression, which is what I asked for. The case it doesn't reach is a peer creating the branch between attempts, where the fallback should be skipped and the peer's head becomes the parent. Not a blocker, but it's the one retry path with no coverage.
Description
Fixes #1636
Writes on a non-main branch built snapshots parented on main's head because
createSnapshotProducerusedMetadataBuilder.currentSnapshot()(only synced with main), ignoringtxn.branch. This corrupted branch lineage and could replace branch data with main's on the first commit.Added
currentSnapshotForRef/currentSnapshotIDForRef, and used them for the parent snapshot, theAssertRefSnapshotIDrequirement, and theoverwritetoappenddowngrade check.main/empty-refbehavior is unchanged; a not-yet-existing branch falls back to the current snapshot.This PR is intentionally limited to fixing snapshot parent selection, lineage, and OCC behavior for branch writes. Read-side planning paths (such as duplicate checks and replace planning) are not yet branch-aware and will be addressed in a follow-up change.
Testing
Added the necessary tests for it.