Do not delete an already-published backup when BACKUP finalization fails - #112373
Do not delete an already-published backup when BACKUP finalization fails#112373groeneai wants to merge 5 commits into
Conversation
BackupImpl::finalizeWriting published the backup and only then armed writing_finalized, the flag that stops the failure-cleanup path from removing a completed backup. A throw in setCompressedSize() or removeLockFile(), which run after publication, therefore left the flag false, so setIsCorrupted() succeeded, remove_backup_files_after_failure (default true) let tryRemoveAllFiles() run, and it deleted `.backup` (or the archive) first. Its only bail-out, checkLockFile(), cannot fire in this scenario precisely because the lock file is still present, which is what happens when removing it is the operation that failed. The result was a complete, externally readable backup being destroyed, plus every incremental chained onto it: an incremental stores only a locator and per file (base_size, base_checksum) pairs, and nothing validates the base until a RESTORE reaches a file with base_size != 0, so those incrementals were reported as BACKUP_CREATED and only failed partway through recovery with Code 599. Arm the flag at the publication boundary instead, immediately after closeArchive(finalize=true). That is the only point correct for both writers: a directory backup is published once writeBackupMetadata finalizes its buffer, while an archive only becomes readable after IArchiveWriter::finalize() writes the central directory and flushes the object. Arming any earlier would make setIsCorrupted() return false for an archive that was never finalized, gating off its cleanup entirely. The failure is still reported to the client: the status comes from getBackupStatusFromCurrentException(), which is called unconditionally, and the synchronous path rethrows. setIsCorrupted() returning false gates only the file removal. One consequence is deliberate. The `.lock` object may be left behind after such a failure, since removing it is what failed. Lock files are consulted only in write mode, so RESTORE and use as a base_backup are unaffected, and a new BACKUP to the same destination is refused by the `.backup`/archive existence check first. A stray lock object is preferable to a destroyed backup. Two test-only failpoints are added for the injection points, one inside the publication window and one after it. Closes: ClickHouse#112271
…vacuous Review round follow-up on the test, no functional change. Keeping a published backup after a finalize failure means the lock file stays at the destination, because removing it is what failed. That is a deliberate, user-visible consequence of this change, so assert it: the archive lock object survives, and a new BACKUP to the same destination is still refused - by the published-backup existence check rather than by the stray lock, which checkBackupDoesntExist distinguishes through two different messages. The archive lock is a sibling object next to the archive rather than an entry inside it, so the test also removes it during cleanup instead of leaking one per run. The incremental chain arm could previously pass without exercising the chain: if a background merge replaced the part the base backup holds, the incremental became self-contained and restored to the same result while proving nothing. Stop merges on the table and assert that the incremental's manifest actually marks entries as reusing base data. Also backtick setIsCorrupted in the new comment, per the repo convention for literal function names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Internal second-model reviewTwo review rounds, both by a model that did not write the code. Round 1 reviewed ❌ The deliberately retained ❌ The incremental-chain arm could pass without exercising the chain (agreed, fixed).
💡 Noted, not changed: a comment now backticks Checks that were re-derived rather than accepted. Every countable claim in Session id: cron:clickhouse-review-slot-11:20260728-223900 |
Pre-PR validation gate
50 runs of the new test with CI randomization enabled: 50 passed, 0 failed. |
|
cc @SmitaRKulkarni @hanfei1991 - could you review this? |
|
Workflow [PR], commit [bc1732d] Summary: ❌
AI ReviewSummaryThis PR moves Findings❌ Blockers
Final VerdictRequest changes. |
|
@groeneai please fix the CI test failures. |
The style check rejects a stateless test whose filename contains the substring "fail" (ci/jobs/check_style.py, functional_tests_check), because it collides with the runner's own failure matching. The new test carried it in "..._finalize_failure_...", which failed Style check and, since that check gates the workflow, left every build and every functional, integration, fuzzer and bugfix-validation node dropped. "error" carries the same meaning and matches the vocabulary the test already uses for its own arm labels (bug_dir_error, bug_archive_error, control_early_error, control_mid_error). Both files are renamed together and their contents are unchanged; the test derives its own name at runtime from BASH_SOURCE, so nothing inside it refers to the old name.
`BACKUP` and `closeArchive` were left as plain text in two comments. The repository instructions ask for literal names from the ClickHouse SQL language, classes and functions to be wrapped in inline code blocks, and for a function to be written as `f` rather than as its application, so the second one also loses its argument list and names the argument instead.
|
Fixed in 85e0a41. There was one real failure: I verified the fix by driving The other two reds were downstream of that one. I also corrected the test name in the description, and wrapped The new head has no workflow run yet, so it needs your approval to start. |
| writeBackupMetadata(); | ||
| closeArchive(/* finalize= */ true); | ||
|
|
||
| /// The backup is published at this point: `.backup` is readable at the destination, or the archive |
There was a problem hiding this comment.
closeArchive/writeBackupMetadata are not the real publication boundary on remote writers, so this still leaves the old data-loss window open for archives and .backup objects stored on S3/Azure. ZipArchiveWriter::finalize and LibArchiveWriter::finalize both call the wrapped archive-file buffer's finalize after sealing the archive (src/IO/Archives/ZipArchiveWriter.cpp:362-373, src/IO/Archives/LibArchiveWriter.cpp:317-323), and those buffers explicitly treat preFinalize as a point where the file could already exist (src/IO/WriteBufferFromS3.cpp:159-223, src/Disks/IO/WriteBufferFromAzureBlobStorage.cpp:151-316).
If that lower-level finalize/commit path throws after publishing the object, writing_finalized is still false here, so setIsCorrupted + tryRemoveAllFiles will delete an already-visible archive exactly like before. The same issue also exists for directory backups, because writeBackupMetadata finalizes the .backup buffer before returning. I think this needs an internal split between “seal archive / finish .backup contents” and “final publish the wrapped object-storage buffer”, with writing_finalized flipped at that inner boundary rather than after these helpers return.
There was a problem hiding this comment.
You are right that the wrapped buffer's finalize runs after the archive is sealed, and right that a
throw inside it can leave a visible object. I measured where that is reachable, and it is narrower
than the finding states: at default settings there is no post-visibility throw site at all, so the
arming point is correct as written. The window you describe exists only with post-upload verification
turned on, and it is pre-existing.
Where the object becomes visible, per destination:
WriteBufferFromS3::finalizeImpl(src/IO/WriteBufferFromS3.cpp:206-238):preFinalizeat:218
only schedules the put / complete-multipart (task_tracker->addFinalat:198). The object is
visible oncetask_tracker->waitAll()at:223returns. A throw fromwaitAllmeans the upload
failed, so nothing is published.WriteBufferFromAzureBlobStorage::finalizeImpl(src/Disks/IO/WriteBufferFromAzureBlobStorage.cpp:306-397):
CommitBlockListat:342, or the inline single-blockUploadinsidepreFinalize.
Throw sites strictly after those points:
ZipArchiveWriter.cpp:373-377, after the wrapped buffer finalize at:372: zero.LibArchiveWriter.cpp:323-326, after:322: zero.- After
WriteBufferFromS3.cpp:223: only:229checkObjectExistsand the:233size mismatch,
both insideif (request_settings[S3RequestSetting::check_objects_after_upload]). - After Azure
:342: only:391and:395, both insideif (check_objects_after_upload).
Both settings default to false (s3_check_objects_after_upload and
azure_check_objects_after_upload, declared in src/Core/Settings.cpp), and the backup writers do
not force them on:
grep -rn check_objects_after_upload src/Backups/ returns one hit,
BackupIO_AzureBlobStorage.cpp:43, which serializes the value into a log map.
The second half is the same: for a directory backup writeBackupMetadata does finalize the .backup
buffer before returning, at BackupImpl.cpp:562, but between that and the arming at :1326 the only
statements are an arithmetic assignment, a LOG_TRACE, and closeArchive, whose body is a no-op when
archive_writer is null. Zero throw sites.
So with defaults, on both S3 and Azure and for both directory and archive backups, publication and
arming are adjacent and the fix holds. The changelog entry is scoped to that.
The residual you found is real, and worth stating precisely: with
s3_check_objects_after_upload=1 or azure_check_objects_after_upload=1, a throw at
WriteBufferFromS3.cpp:229/:233 or Azure :391/:395 does leave a visible object with
writing_finalized still false. WriteBuffer::finalize (src/IO/WriteBuffer.cpp:93-105) catches and
calls cancel(), but for S3 that reaches abortMultipartUpload (:459) for an upload whose
CompleteMultipartUpload already succeeded, so the abort fails and the object stays; the Azure buffer
has no cancelImpl override at all.
I am not folding that into this PR, for three reasons. It is pre-existing: all four carrier files are
byte-identical between this branch's merge-base, its head, and origin/master, so this PR neither
introduces nor widens it. It needs a design decision rather than a move, because a throw at :229/:233
means the object is visible but possibly the wrong size, and protecting a wrong-sized object from
cleanup may be worse than deleting it. And arming at the inner boundary would have to thread state out
of IArchiveWriter and WriteBuffer, which is wider than this one-line move and would redden this
PR's own control_mid_* arm, which asserts that a throw before archive_writer->finalize() still
removes the archive.
I filed it separately with the measurements and the constraints above, and it is reachable only with a
non-default setting enabled, on a failure that is itself an "it's a bug in S3 or S3 API" condition.
No source change in this round, so the approved change is untouched.
CI finish ledger - 85e0a41Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
Session id: cron:our-pr-ci-monitor:20260729-120000 |
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 32/33 (96.97%) · Uncovered code |
CI finish ledger - bc1732dEvery failure below has an owner: a fixing PR (mine or external), or a full-effort fix task CI is fully finished on this head: 174 check-runs, 0 queued or in progress, and on the latest
Session id: cron:our-pr-ci-monitor:20260803-053000 |
Closes: #112271
Related: #111394
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
A
BACKUPthat failed in the last steps of finalization, after its backup had already been written to the destination, deleted that complete backup during failure cleanup. Any incremental chained onto it was reported asBACKUP_CREATEDbut then failed duringRESTOREwithCode: 599 ... not found. A backup is now protected from the moment it is published, and the failure is still reported. Closes #112271.Description
BackupImpl::finalizeWritingpublished the backup and only then armed the guard protecting it from failure cleanup:If either of the last two steps throws,
writing_finalizedis still false, sosetIsCorruptedsucceeds,remove_backup_files_after_failure(default true) letstryRemoveAllFilesrun, and it removes.backup(or the archive) first. Its only bail-out,if (!checkLockFile(false)) return false;, cannot fire precisely because the lock file is still there, which is the case when removing it is what failed.The intent already existed and was correct; only the window was mis-sized. The arming moves to the publication boundary, right after
closeArchive(/* finalize= */ true)-- the only point correct for both writers, because an archive becomes readable only onceIArchiveWriter::finalizehas written its central directory. Arming earlier would gate off cleanup for a never-finalized archive, so a test arm covers that direction. The client still sees the error: the status comes fromgetBackupStatusFromCurrentException, called unconditionally.The
.lockobject may now be left behind, since removing it is what failed. That is deliberate: locks are consulted only in write mode, soRESTOREand use as abase_backupare unaffected, and a newBACKUPthere is still refused.04652_backup_finalize_error_keeps_published_backupasserts, for a directory and an archive backup, that the published backup survives, restores, and still reports the error; that an incremental chained onto it restores; that the retained.lockis present and the destination refused; plus three pre-publication controls.#111394 edits the same body, so whichever merges second needs a rebase.