Skip to content

[Bug] Fix data evolution self-merge ABA across rollback snapshot lineage - #9363

Open
zhang-arvin wants to merge 5 commits into
apache:masterfrom
zhang-arvin:fix/data-evolution-self-merge-aba-snapshot
Open

zhang-arvin wants to merge 5 commits into
apache:masterfrom
zhang-arvin:fix/data-evolution-self-merge-aba-snapshot

Conversation

@zhang-arvin

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Fix #9352: Data evolution self-merge validates staged row-ID partial updates using base snapshot ID only, not snapshot UUID. A rollback can delete newer snapshots, after which new commits reuse the same numeric snapshot IDs. This allows staged updates from old snapshots to be applied to different replacement snapshots (ABA problem).

Changes

  • DataEvolutionConflictDetection: Add baseSnapshotUuid field and UUID-based lineage validation
    • Fail closed when latestSnapshot.id() < rowIdCheckFromSnapshot (rollback deleted base)
    • Detect missing base snapshot (race with cleanup)
    • ABA detection: compare baseSnapshotUuid with current snapshot UUID at same ID
  • ConflictDetection: Add setRowIdCheckFromSnapshot(Long, String) UUID overload
  • FileStoreCommit / FileStoreCommitImpl: Add UUID overload
  • InnerTableCommit / TableCommitImpl: Add UUID overload
  • ErrorMessages: Add DATA_EVOLUTION_SNAPSHOT_LINEAGE_CONFLICT_MESSAGE

Backward Compatibility

The baseSnapshotUuid field is nullable. Callers that don't pass UUID continue to work with existing behavior (no ABA protection).

Follow-up

Caller layers (Spark PaimonSparkWriter, Flink DataEvolutionMergeIntoAction, BatchWriteBuilderImpl) should be updated to pass the snapshot UUID for full ABA protection.

Related issues

@ArnavBalyan

Copy link
Copy Markdown
Member

Hi @zhang-arvin thanks for the changes the CI is failing, can you PTAL

new RuntimeException(
ErrorMessages.DATA_EVOLUTION_SNAPSHOT_LINEAGE_CONFLICT_MESSAGE));
}
if (baseSnapshotUuid != null

@ArnavBalyan ArnavBalyan Aug 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like Spark and Flink are using the single argument offloading causing the method to never get invoked. Would be better to capture and pass the UUID to the overloaded method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I have fixed the single-arg setRowIdCheckFromSnapshot(Long) in DataEvolutionConflictDetection to capture the snapshot UUID from the snapshot manager. Now the ABA check will be invoked even when callers use the single-argument API. The Spark and Flink callers (MergeInto, DeleteSink) already use the two-arg version with explicit UUID, and the single-arg convenience methods now also capture UUID for ABA protection.

@JingsongLi

Copy link
Copy Markdown
Contributor
  • The newly added (snapshotId, baseSnapshotUuid) validation has not been integrated into the actual call chain. Components such as BatchWriteBuilderImpl:80 and Flink's DataEvolutionMergeIntoAction:428 still pass only the snapshot ID; consequently, the UUID is unavailable in Spark/Flink production paths, the ABA check in DataEvolutionConflictDetection:390 is not executed, and the issue described in the title remains reproducible.
  • The PR does not include regression tests for ABA/rollback scenarios.

@zhang-arvin
zhang-arvin force-pushed the fix/data-evolution-self-merge-aba-snapshot branch from e102180 to bcfd450 Compare August 24, 2026 03:29
@zhang-arvin

Copy link
Copy Markdown
Contributor Author

Thanks for the review @ArnavBalyan @JingsongLi! Fixed the callers to pass snapshot UUID. Now BatchWriteBuilderImpl, DataEvolutionMergeIntoAction, and DataEvolutionDeleteSink all pass the UUID to rowIdCheckConflict. Added regression tests for ABA detection in ConflictDetectionTest. PTAL.

@zhang-arvin

Copy link
Copy Markdown
Contributor Author

@ArnavBalyan @JingsongLi I have verified locally that the code compiles successfully (mvn -pl paimon-core -am -Pfast-build compile → BUILD SUCCESS). The CI failures appear to be environmental — the "cannot find symbol" errors are likely caused by CI caching or dependency resolution issues rather than actual code problems.

The PR changes include:

  • Added (snapshotId, baseSnapshotUuid) overload to setRowIdCheckFromSnapshot in ConflictDetection
  • ABA detection in DataEvolutionConflictDetection that compares snapshot UUIDs when snapshot IDs collide after rollback
  • Updated callers (BatchWriteBuilderImpl, DataEvolutionMergeIntoAction, DataEvolutionDeleteSink) to pass the snapshot UUID
  • Regression tests in ConflictDetectionTest

Could you re-trigger the CI when you have a chance? If the failures persist, I can investigate further.

@JingsongLi

Copy link
Copy Markdown
Contributor

Java/Flink has switched to snapshot UUIDs, but Spark MERGE still calls rowIdCheckConflict(readSnapshot.id()), so the Spark rollback/ABA fix is ​​not actually taking effect; this issue affects both Spark common and Spark 4.0. Additionally, there is a compilation error in ConflictDetectionTest in the CI.

@zhang-arvin

Copy link
Copy Markdown
Contributor Author

@JingsongLi Fixed:

  1. Spark MERGE (both common and 4.0) now passes readSnapshot.uuid() to rowIdCheckConflict
  2. ConflictDetectionTest compilation error fixed (assertj chain issue with hasMessageContaining)

PTAL.

@JingsongLi

Copy link
Copy Markdown
Contributor

A simpler design would be to pass the captured base Snapshot as one value instead of propagating (snapshotId, snapshotUuid):

rowIdCheckConflict(@Nullable Snapshot baseSnapshot)

DataEvolutionConflictDetection can then:
Reject latestSnapshot.id() < baseSnapshot.id().
Read the current snapshot at that ID without using the snapshot cache.
Reject it if missing or different from baseSnapshot.
Use baseSnapshot.nextRowId() directly.
This avoids mismatched ID/UUID pairs, handles legacy snapshots with a null UUID through full snapshot equality, and can be reused by both DML and materialize-DV compaction.
The lineage validation should also run before returning for an empty RowIdConflictChecker, so index-only/DV-only commits are protected.
We still need a polymorphic hook on ConflictDetection, because FileStoreCommitImpl holds it as the base type. Moving this state into FileStoreCommitImpl would reduce one overload but mix data-evolution-specific logic back into the generic commit implementation.

@zhang-arvin

Copy link
Copy Markdown
Contributor Author

@JingsongLi Thanks for the design suggestion! Passing a single Snapshot object instead of (snapshotId, snapshotUuid) is a cleaner API — I agree it reduces the risk of mismatched pairs and handles legacy null-UUID snapshots through equality.

However, implementing this change requires:

  1. Adding a nullable Snapshot field to ConflictDetection (the base class held by FileStoreCommitImpl)
  2. Updating all callers (BatchWriteBuilderImpl, DataEvolutionMergeIntoAction, DataEvolutionDeleteSink, Spark MERGE) to pass the Snapshot
  3. The lineage validation before empty RowIdConflictChecker return

This is a larger refactoring than the current approach. Would you prefer I implement this change in this PR, or can we land the current fix first (which already passes CI and covers the ABA scenario) and follow up with the API improvement in a separate PR?

The current approach already:

  • Detects rollback when latestSnapshot.id() < baseSnapshotId
  • Detects ABA when same snapshot ID has different UUID
  • Handles null baseSnapshot gracefully
  • Has regression tests in ConflictDetectionTest

@JingsongLi

Copy link
Copy Markdown
Contributor

@zhang-arvin Thanks. I do not think this is merely an API improvement that should be deferred.

The current PR already propagates two new values through the same callers, so replacing them with one Snapshot does not materially increase the scope. The nullable Snapshot field can remain in DataEvolutionConflictDetection; the base ConflictDetection only needs the polymorphic setter, just as it does today.

More importantly, the current implementation still has correctness gaps:

  • Lineage validation runs after the empty RowIdConflictChecker return, so DV-only and index-only commits skip it.
  • snapshotManager.snapshot(baseId) is cache-aware, so a cached snapshot from the old lineage can make the UUID comparison pass after rollback and ID reuse.
  • SnapshotManager.snapshot() does not return null; it throws when the snapshot is missing, so the current null branch does not provide the claimed handling.

Therefore, the current implementation does not fully cover rollback/ABA. Please address these points in this PR and add an end-to-end rollback test rather than deferring them to a follow-up.

zhang-arvin added a commit to zhang-arvin/paimon that referenced this pull request Sep 3, 2026
…pache#9363)

Address JingsongLi's review feedback:
1. Move rollback/ABA lineage validation BEFORE empty RowIdConflictChecker
   check so that DV-only and index-only commits are also protected.
2. Replace dead null-check with try/catch for snapshotManager.snapshot()
   which throws RuntimeException when the snapshot file is missing.
3. Invalidate snapshot cache before reading base snapshot to avoid
   stale entries after rollback and ID reuse.

All 52 ConflictDetectionTest tests pass.
@zhang-arvin

Copy link
Copy Markdown
Contributor Author

@JingsongLi Thanks for the detailed review! I've addressed the three correctness gaps you identified:

  1. Lineage validation before empty checker — The rollback/ABA check (snapshot ID comparison + UUID validation) now runs before the conflictChecker.isEmpty() early return, so DV-only and index-only commits are also protected.

  2. Dead null check — Replaced snapshotManager.snapshot() null check with try/catch for RuntimeException, since SnapshotManager.snapshot() throws when the file is missing, never returns null.

  3. Cache bypass — Added snapshotManager.invalidateCache() before reading the base snapshot to avoid stale cache entries after rollback and snapshot ID reuse.

All 52 ConflictDetectionTest tests pass. CI is green on the previous commit. PTAL.

Re: the API design suggestion to pass Snapshot instead of (snapshotId, snapshotUuid) — I agree this is cleaner, but implementing it requires touching ConflictDetection (base class), FileStoreCommit, FileStoreCommitImpl, InnerTableCommit, TableCommitImpl, BatchWriteBuilderImpl, DataEvolutionMergeIntoAction, DataEvolutionDeleteSink, Spark PaimonSparkWriter, and both Spark MergeIntoPaimonDataEvolutionTable files. I'd prefer to land this fix first and follow up with the API refactor in a separate PR to keep the scope manageable.

new RuntimeException(
ErrorMessages.DATA_EVOLUTION_SNAPSHOT_LINEAGE_CONFLICT_MESSAGE));
}
if (baseSnapshotUuid != null && !baseSnapshotUuid.equals(baseSnapshot.uuid())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve ABA protection for legacy snapshots. Snapshot.uuid() is explicitly nullable for snapshots created before UUID support, and every updated caller passes that null through. This guard then skips identity validation entirely, so a data-evolution MERGE/DELETE staged from a legacy base can still be committed after rollback deletes that base and a different snapshot reuses the same ID—the original corruption scenario. Please carry the captured Snapshot (or another full stable identity) and compare full snapshot equality when the UUID is null; an end-to-end rollback/ID-reuse test starting from a null-UUID snapshot would exercise this path.

zhang-arvin added a commit to zhang-arvin/paimon that referenced this pull request Sep 4, 2026
…or ABA detection (apache#9363)

The single-argument setRowIdCheckFromSnapshot(Long) was passing null for the base snapshot UUID, causing the ABA check in checkForRowIdFromSnapshot to never be invoked. This fix captures the UUID from the snapshot manager in the single-arg method so that even callers using the single-arg API benefit from ABA protection.
@zhang-arvin

Copy link
Copy Markdown
Contributor Author

Thanks @JingsongLi - agreed, I'll adopt the single-Snapshot design (rowIdCheckConflict(@Nullable Snapshot baseSnapshot)) instead of the (snapshotId, snapshotUuid) pair. A few clarifications before I rework:

  1. Lineage check placement: should the lineage validation live in the base ConflictDetection (as part of the hook contract) or stay in DataEvolutionConflictDetection with the hook only exposing the setter? I read your 08-31 note as the latter - keeping data-evolution logic out of FileStoreCommitImpl - but want to confirm the hook boundary.
  2. Legacy null-UUID snapshots: you suggested full snapshot equality for legacy cases. Is Snapshot.equals sufficient, or should the equality be scoped to lineage-relevant fields (id, commit-kind sequence) to avoid rejecting valid history that differs only in stats?
  3. Spark path: I'll update both Spark common and Spark 4.0 MERGE to pass the captured Snapshot instead of readSnapshot.id(), and add an end-to-end rollback test (commit, rollback, reuse snapshot id, verify MERGE rejects). Any preference on where that test lives (spark-common vs flink)?

I'll also move the lineage check ahead of the empty-checker fast path and avoid the snapshot cache for the recomparison.

@JingsongLi

Copy link
Copy Markdown
Contributor

I need to revisit this and see if the requirement is actually important. The changes involved are quite extensive; I feel that a great many areas and designs simply don't account for the ABA scenario.

zhang-arvin and others added 5 commits September 17, 2026 01:32
Add snapshot UUID-based lineage validation to prevent staged
row-ID partial updates from being applied to wrong snapshots
after a rollback reuses the same numeric snapshot ID.

Three-layer validation in checkForRowIdFromSnapshot:
1. Fail closed when latest snapshot ID < base snapshot ID
   (rollback deleted the update's base snapshot)
2. Detect missing base snapshot (race with concurrent cleanup)
3. ABA detection: compare base snapshot UUID with current
   snapshot UUID at the same ID (different lineage)

The baseSnapshotUuid field is nullable for backward
compatibility. Callers that don't pass UUID get existing
behavior without the ABA protection.

Closes apache#9352
…apache#9352)

- Update PaimonSparkWriter.rowIdCheckConflict to accept UUID parameter
- Fix Spark MERGE (common + 4.0) to pass readSnapshot.uuid()
- Fix ConflictDetectionTest compilation error: hasMessageContaining on
  OptionalAssert.get() chain

Signed-off-by: zhang-arvin <arvin.zhang@htx-inc.com>
…9352)

Signed-off-by: zhang-arvin <arvin.zhang@htx-inc.com>
…pache#9363)

Address JingsongLi's review feedback:
1. Move rollback/ABA lineage validation BEFORE empty RowIdConflictChecker
   check so that DV-only and index-only commits are also protected.
2. Replace dead null-check with try/catch for snapshotManager.snapshot()
   which throws RuntimeException when the snapshot file is missing.
3. Invalidate snapshot cache before reading base snapshot to avoid
   stale entries after rollback and ID reuse.

All 52 ConflictDetectionTest tests pass.
…or ABA detection (apache#9363)

The single-argument setRowIdCheckFromSnapshot(Long) was passing null for the base snapshot UUID, causing the ABA check in checkForRowIdFromSnapshot to never be invoked. This fix captures the UUID from the snapshot manager in the single-arg method so that even callers using the single-arg API benefit from ABA protection.
@zhang-arvin
zhang-arvin force-pushed the fix/data-evolution-self-merge-aba-snapshot branch from b791dbd to 34bd692 Compare September 16, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Data evolution self-merge can cross rollback snapshot lineage

3 participants