Skip to content

Flink: Fix deleted rows reappearing after a failed eq-delete conversion cycle - #17630

Open
vishnuprakaz wants to merge 4 commits into
apache:mainfrom
vishnuprakaz:flink-eqdelete-replan-reindex
Open

Flink: Fix deleted rows reappearing after a failed eq-delete conversion cycle#17630
vishnuprakaz wants to merge 4 commits into
apache:mainfrom
vishnuprakaz:flink-eqdelete-replan-reindex

Conversation

@vishnuprakaz

Copy link
Copy Markdown
Contributor

If a conversion cycle fails after it has resolved an equality delete, the rows that delete was hiding come back and they stay back. The task reports success, so nothing looks wrong.

To reproduce it the table has two rows. I commit an equality delete for each of them in one snapshot, so both rows should be invisible, then remove row two's delete file from disk. The cycle resolves row one's delete and aborts when it cannot read row two's, so nothing is committed. I put the missing file back and trigger again. The cycle succeeds this time, but row one is visible again:

TestConvertEqualityDeletes > testDeleteResolvedBeforeFailureIsRetained() FAILED
    org.opentest4j.AssertionFailedError:
    expected: []
     but was: [Record(1, a)]

Row one's delete had already resolved during the failed cycle, and that is the one that gets lost. Row two's delete never got that far, and it converts correctly on the retry. That asymmetry is the bug. Resolving an equality delete consumes the index entries it matched, and a failed cycle leaves them consumed. Nothing puts them back, because the target branch never moved, and both the per-key clear and the planner's reindex only trigger when it does. So the retry resolves the same
delete against an index that no longer holds those rows, writes no deletion vector, and the committer then removes the equality delete file and marks the snapshot converted.

The planner already rebuilds the index when an external commit has moved the target branch. This is that situation without the move, so the planner now remembers which staging snapshot its last plan covered. If it picks the same one again, that cycle never committed, and the index is rebuilt before the delete phase.
The snapshot id is checkpointed so a restore taken mid-cycle is covered too, and it is recorded only once the staging inputs have validated.

Two planner tests ran several triggers without simulating the converter's own commit, so their no-re-emission assertions no longer hold once the index is rebuilton a replan. Both now simulate that commit, the way noMainReEmitAfterOwnCommit does, which made one of them identical to it, so I folded it in happy to restore it if you would rather keep both.

Also updates two statements in the maintenance docs that this change makes out of date. Needs a backport to 2.0 and 1.20.

The alternative I considered was making resolution non-destructive in the index, which changes what three of the PKIndex tests assert.

This is the smallest fix I found that leaves the index semantics as they are.

Happy to rework on this if there is a better way.

@vishnuprakaz

Copy link
Copy Markdown
Contributor Author

cc: @mxm @pvary

@mxm
mxm self-requested a review August 13, 2026 12:17

@mxm mxm left a comment

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.

Thanks for the PR @vishnuprakaz!

The core issue is that the planner does not get notified about downstream failures and that the index already gets modified before the cycle completes. While your fix looks correct, it also comes at the cost of a full re-index on failures. It would be better to buffer index modifications until the planner reads the committed snapshot. Only then, we can safely apply the index modifications without a full re-index.

How did you discover this issue? If the transient read failures are more common, it definitely makes sense to only modify the index after everything has been committed, to avoid frequent rebuilds of the index.

long rebuildSequenceNumber =
indexedSequenceNumber == null
? mainSnapshot.sequenceNumber()
: Math.max(mainSnapshot.sequenceNumber(), indexedSequenceNumber + 1);

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.

Let's rename sequenceNumber to generation and remove the Max.max logic, i.e. indexSequenceNumber = mainSnapshot.generation() + 1. The sequence number we used initially but the logic has diverted from it.

@@ -127,6 +131,9 @@ public class EqualityConvertPlanner extends AbstractStreamOperator<ReadCommand>
private transient Long lastStagingSnapshotId;
private transient Long indexSnapshotId;
private transient Long indexedSequenceNumber;

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.

Suggested change
private transient Long indexedSequenceNumber;
private transient Long indexGeneration;

private transient Long indexedSequenceNumber;
// Staging snapshot the last emitted plan covered, checkpointed so it survives a restore taken
// mid-cycle. Selecting it again means that cycle never committed.
private transient Long plannedStagingSnapshotId;

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.

Should this be:

Suggested change
private transient Long plannedStagingSnapshotId;
private transient Long pendingStagingSnapshotId;

private transient Long indexedSequenceNumber;
// Staging snapshot the last emitted plan covered, checkpointed so it survives a restore taken
// mid-cycle. Selecting it again means that cycle never committed.
private transient Long plannedStagingSnapshotId;

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.

Should this be:

Suggested change
private transient Long plannedStagingSnapshotId;
private transient Long pendingStagingSnapshotId;

@vishnuprakaz

Copy link
Copy Markdown
Contributor Author

Thanks for taking a look @mxm!

Actually no incident behind it,

I was following the conversion work and was reading through the operators to understand how a cycle works. I ended up wondering what happens when a cycle fails partway through. There's a retry test for that, but the failure comes before the deletes resolve, so I couldn't tell what happens when it comes after.

So I went back through the series to see whether it had come up before, and found #17038. Two cycles ended up running on the same uncommitted snapshot there, and one committed its marker without the DV. Same result as what I was worried about, just a different cause so I wanted to see whether one failing cycle could do it alone. That's what the test in the PR checks.

so I don't have numbers on how often cycles fail. My guess is occasional rather than frequent a transient read error, or a checkpoint landing mid cycle on a restart.

Agreed on the buffering,I think the planner needs the pending snapshot either way to tell the index whether to apply or discard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants