Skip to content

recover compaction after a partially committed identifier - #11102

Open
mvanhorn wants to merge 4 commits into
apache:masterfrom
mvanhorn:fix/11071-paimon-recover-half-committed-compaction
Open

mvanhorn wants to merge 4 commits into
apache:masterfrom
mvanhorn:fix/11071-paimon-recover-half-committed-compaction

Conversation

@mvanhorn

Copy link
Copy Markdown

Purpose

If a job stops after one commit identifier has published its first snapshot and before the compaction snapshot for that same identifier, restore drops the compaction. The next compaction then deletes an output file that is absent from the table, conflict detection aborts the commit, and the job restarts. Each table left in that window costs another restart, and the changelog the dropped compaction would have published is missing. One commit identifier is published as two snapshots, first APPEND or OVERWRITE and then COMPACT.

On restore a committable is kept only when its identifier is greater than the latest snapshot identifier for this commit user, so an identifier whose first snapshot already exists is dropped together with its still unpublished compact increments. The writer of the next identifier already had those compaction outputs in memory and emits deletes for them, which conflict detection rejects because the files were never added.

When the committable identifier equals that latest snapshot and the snapshot kind is APPEND or OVERWRITE, filterCommitted adds a separate committable that copies the watermark and properties and keeps only the non-empty compact increments, each with an empty data increment. CompactRecoveryCommittable marks that copy so commit writes no further append snapshot and skips overwrite, and the replay publishes one COMPACT snapshot even when an empty append would otherwise be written.

Before replay, every file from the compact-after and changelog metas and every new index file is resolved through CompactedChangelogPathResolver and checked for existence; a missing output skips the whole identifier, and an I/O error from the existence check fails recovery as UncheckedIOException.

Fixes #11071

Tests

mvn -B -ntp -pl paimon-core -am test -Dtest=FileStoreCommitTest,TableCommitTest -Dsurefire.failIfNoSpecifiedTests=false finished TableCommitTest with 30 tests, 0 failures, 0 errors, and 0 skipped, then timed out at 300s.
No test was run with the paimon FileStoreCommitImpl change reverted, because no test was named as covering the reported bug, so none is shown to catch this bug.
The changed test paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java did not run here: no test runner for it was available.
The changed test paimon-core/src/test/java/org/apache/paimon/table/sink/TableCommitTest.java did not run here: no test runner for it was available.

When the committable identifier equals that latest snapshot and the
snapshot kind is APPEND or OVERWRITE, filterCommitted adds a separate
committable that copies the watermark and properties and keeps only the
non-empty compact increments, each with an empty data increment.
CompactRecoveryCommittable marks that copy so commit writes no further
append snapshot and skips overwrite, and the replay publishes one
COMPACT snapshot even when an empty append would otherwise be written.
Before replay, every file from the compact-after and changelog metas and
every new index file is resolved through CompactedChangelogPathResolver
and checked for existence; a missing output skips the whole identifier,
and an I/O error from the existence check fails recovery as
UncheckedIOException.

Fixes apache#11071
commitCallbacks.forEach(callback -> callback.retry(committable));
Snapshot snapshot = latestSnapshot.get();
if (committable.identifier() == snapshot.commitIdentifier()
&& (snapshot.commitKind() == CommitKind.APPEND

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.

Do not replay a compact increment whose inputs have been concurrently removed

This branch recovers the COMPACT whenever its output files exist, but that is not sufficient to make the increment replayable. A concurrent compaction may have already deleted one of its compactBefore files. In that case conflict detection rejects this stale delete; after restart filterCommitted reconstructs the same recovery committable again, so the job can never progress. The existing PrimaryKeyFileStoreTableITCase.testBatchJobWithConflictAndRestart demonstrates this in the failed Flink 2 CI job and in the local run: all 10 restart attempts end in File deletion conflicts detected.

Please validate the compact inputs against the current snapshot and discard/rebase an invalid recovery (with coverage for this concurrent-compaction case) instead of unconditionally replaying it based only on output existence + adding a test case might help.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, thanks. 49ef4c4 adds a check in recoverCompact: before replaying a recovered COMPACT, compactInputsStillLive verifies that every compactBefore file is still an ADD entry in the latest snapshot, and filterCommitted drops the recovery when one was removed by a concurrent compaction. FileStoreCommitTest gets testFilterHalfCommittedIdentifierWithConcurrentlyCompactedInput for that case.

A concurrent compaction may delete a compact-before file of the recovered
increment. Replaying it would fail conflict detection on every restart, so
filterCommitted now discards the recovery unless all its inputs are still
live in the latest snapshot.

@JingsongLi JingsongLi 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.

Requirement fit: SUPPORTED. Implementation: FINDINGS.

The partial-commit recovery problem is real. On this head, 43 focused existing core tests passed; two added deterministic recovery regressions failed. The existing Flink 2 / JDK 17 PrimaryKeyFileStoreTableITCase#testBatchJobWithConflictAndRestart passed. Two stale-input cases below still prevent recovery.

Verification limit: the full FileStoreCommitTest run was stopped while its pre-existing random multi-writer stress test was still running; no full-suite pass is claimed. The current CI run was cancelled and its aggregate CI-result failed, so that status alone is not attributed to the patch.

for (CommitMessage message : committable.fileCommittables()) {
for (DataFileMeta file :
((CommitMessageImpl) message).compactIncrement().compactBefore()) {
inputs.add(Arrays.asList(message.partition(), message.bucket(), file.fileName()));

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] Compare complete manifest identities when checking live compact inputs

A concurrent compaction can upgrade a file from level 0 to level 1 without changing its physical filename (MergeTreeCompactTask.upgrade / AbstractCompactRewriter.upgrade). This tuple still considers the old input live, but conflict detection uses FileEntry.Identifier, including level and auxiliary metadata. Replaying the old level-0 deletion then fails with File deletion conflicts detected. The latest snapshot still contains the same filename, so every restart reconstructs the same invalid recovery instead of skipping it.

I reproduced this on 49ef4c4: publish the APPEND phase of identifier 2, commit DELETE(old level 0)/ADD(same file upgraded to level 1) as another user, then restore identifier 2. The new regression testFilterHalfCommittedIdentifierWithConcurrentlyUpgradedInput confirms the replay conflicts and fails the expected-empty recovery assertion. Please compare the full manifest identity (or explicitly rebase), rather than just partition/bucket/name.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, matching on partition/bucket/name misses the in-place level upgrade. I'll change compactInputsStillLive to compare the full FileEntry.Identifier (level and extra metadata included) and add your testFilterHalfCommittedIdentifierWithConcurrentlyUpgradedInput regression to the PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 4820c4a. compactInputsStillLive now builds the full FileEntry.Identifier for each compact-before file and checks it against the identifiers of the live ADD entries, so a file that a concurrent compaction upgraded in place no longer counts as live and the stale recovery is skipped. Your testFilterHalfCommittedIdentifierWithConcurrentlyUpgradedInput regression is in FileStoreCommitTest and passes along with the other testFilterHalfCommitted cases.

partitions.add(message.partition());
}
}
if (inputs.isEmpty()) {

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] Validate pending index deletions before accepting recovery

Compact increments can also contain deletedIndexFiles, including an index-only message produced by DataEvolutionCompactGlobalIndexDropper. If a concurrent DROP GLOBAL INDEX removes that index after the APPEND phase, this check still succeeds: the index-only message has no compactBefore, and index liveness is never checked. GlobalIndexCombiner then rejects the stale deletion, while each subsequent restart reconstructs it again.

The added testRecoveryAfterConcurrentGlobalIndexDrop reproduces two fresh writer recoveries on this head; both explicitly assert the root cause IllegalStateException: Trying to delete global index file recovery-index which does not exist. Spark/Flink DROP GLOBAL INDEX perform the same metadata-only removal. Please validate the compact increment's deleted index identities against the current index manifest as well, and discard/rebase an invalid replay.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. In 6cd7bd9, recovery now also checks every deleted index file in the compact increment against the latest index manifest (same partition, bucket and file meta) and discards the replay when one is missing or changed, so a concurrent DROP GLOBAL INDEX no longer makes each restart fail in the combiner. The new FileStoreCommitTest case replays an index-only message after the index was dropped by another commit.

A concurrent compaction can upgrade a compact-before file to a higher
level without renaming it. Matching inputs only on partition, bucket and
file name treated such a file as still live, but conflict detection uses
the full FileEntry.Identifier, so the replayed level 0 deletion failed on
every restart. compactInputsStillLive now compares the full identifier,
and a regression test covers the in-place upgrade.
A compact increment can delete index files without any compact-before
file, for example the index-only message written when dropping a global
index. If a concurrent DROP GLOBAL INDEX removed that index after the
first phase, the replayed deletion failed in the index manifest combiner
on every restart. Recovery now also checks each deleted index file
against the latest index manifest and discards the increment when one
is missing or changed.
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] Lost compaction on restore causes "not previously added" conflict

3 participants