Fix crash moving an empty part to a plain_rewritable disk#107040
Fix crash moving an empty part to a plain_rewritable disk#107040groeneai wants to merge 10 commits into
Conversation
ALTER TABLE ... MOVE PARTITION ... TO DISK 'plain_rewritable_disk' aborted the
server when the moved part was empty (0 rows, e.g. an empty part kept by
remove_empty_parts = 0 after DROP PARTITION).
An empty part contains 0-byte files. A local-metadata disk
(supportsEmptyFilesWithoutBlobs() == true) stores such a file with zero stored
objects, so DiskObjectStorageTransaction::copyFileImpl builds an empty
blobs_to_create vector and calls createMetadataFile(path, {}). The
plain_rewritable implementation of createMetadataFile took objects.front()
unconditionally, which aborts on an empty vector (libc++ hardening:
"front() called on an empty vector").
The createMetadataFile(path, objects) contract allows an empty objects list:
the local metadata storage creates an empty file and the non-rewritable plain
storage is a no-op. plain_rewritable was the only implementation that crashed.
Fix: when objects is empty, record a 0-byte file. The write operation only uses
StoredObject::bytes_size, so a size-0 object is sufficient and matches how the
source 0-byte file is represented.
This regressed in 25.12 when the plain_rewritable metadata storage was split
from the unified plain implementation (the front() call has been unconditional
since then).
Closes ClickHouse#106960
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
cc @Michicosun @kssenii could you review this? It fixes the abort when moving an empty part to a plain_rewritable disk: an empty (0-byte) file from a local-metadata disk has zero stored objects, so copyFileImpl calls createMetadataFile(path, {}), and the plain_rewritable createMetadataFile did objects.front() on the empty vector. The fix records a 0-byte file when objects is empty, matching the createMetadataFile contract honored by the local and non-rewritable plain metadata storages. Closes #106960. |
|
Workflow [PR], commit [9cf3d63] Summary: ❌
AI ReviewSummaryThe PR fixes copying a blobless empty source file into an object-storage disk whose metadata cannot represent empty files without backing blobs, by materializing a real zero-byte destination object through Final VerdictStatus: ✅ Approve |
|
CI fully finished on
The new regression test Session id: cron:our-pr-ci-monitor:20260610-225430 |
The previous fix recorded a 0-byte file for plain_rewritable only in the in-memory directory tree (fs_tree->recordFile), without writing a backing object. plain_rewritable returns supportsEmptyFilesWithoutBlobs() == false and rebuilds its metadata from the object listing, so: - after a metadata reload (SYSTEM DROP DISK METADATA CACHE / restart) the empty file's entry disappears, because load() lists actual objects under the directory and the 0-byte object was never written; - a later MOVE/copy from this disk gets a synthesized object key from getStorageObjectsIfExist() and copyObjectToAnotherObjectStorage() fails trying to read an object that does not exist. Fix: when createMetadataFile() is called with an empty objects list (a 0-byte file with no copied blob, which only happens when moving an empty part onto plain_rewritable), generate the destination object key and materialize a real 0-byte object there. This matches the normal write path, which always writes a blob for empty files on plain_rewritable (create_blob_if_empty == true), and keeps the file consistent across reload and subsequent copy/move. The regression test is extended to move the empty part to plain_rewritable, reload the disk metadata cache, and move the part back, so it cannot pass with only transient in-memory metadata. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gate (for the fixup b7b45b8 addressing the review)
Session id: cron:clickhouse-worker-slot-12:20260610-225430 |
The test uses SYSTEM DROP DISK METADATA CACHE, which affects server-wide disk metadata cache state, so the style checker requires the no-parallel tag (ci/jobs/scripts/check_style/various_checks.sh). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous fixup materialized a real 0-byte object for a blobless empty file on plain_rewritable, but set the cleanup flag (object_written) only after writeObject(...)->finalize() returned. If the remote write created the object and a later step in the same transaction threw, the rollback called undo() with the flag still false, so the generated object was left behind. Because plain_rewritable rebuilds its metadata from the object listing, the next load()/dropCache() would list that orphan and make a file visible even though the metadata transaction never committed. Mark the write as attempted before issuing the remote write (renaming the flag to object_write_attempted) so undo() removes the generated object on any failure once the write begins. removeObjectIfExists keeps undo safe when the object was never actually created. The normal non-empty copy path is unaffected: its blob is written by copyObjectToAnotherObjectStorage before createMetadataFile, so create_empty_object stays false there. Add a deterministic regression test (gtest EmptyFileObjectWriteUndo) that injects a fault right after the empty file object is materialized via the new plain_object_storage_write_fail_after_empty_file_object failpoint, and asserts no orphan object survives the rolled-back transaction nor a reload from the object listing. The test fails without this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gateCovers HEAD
Session id: cron:clickhouse-worker-slot-10:20260611-004300 |
|
CI fully finished on
All other jobs pass on this commit, including the new Session id: cron:clickhouse-worker-slot-6:20260611-054800 |
|
CI summary for HEAD
|
…ty-part-plain-rewritable
Michicosun
left a comment
There was a problem hiding this comment.
This fix goes against library design.
plain-rewritable metadata storage advertize supportsEmptyFilesWithoutBlobs() const { return false; }
So it does not support this case, the bug is not in the metadata layer - it is in the disk transaction where the copy operation did not check the metadata features before issuing createMetadataFile.
The fix should be approximately like this:
operations_to_execute.push_back([this, blobs_to_create, missing_locations, to_file_path](MetadataTransactionPtr tx)
{
for (const auto & blob : blobs_to_create)
tx->recordBlobsReplication(blob, missing_locations);
if (!blobs_to_create.empty())
tx->createMetadataFile(to_file_path, blobs_to_create);
else if (metadata_storage->supportsEmptyFilesWithoutBlobs())
tx->createMetadataFile(to_file_path, blobs_to_create);
else
writeFile(to_file_path, 0, WriteMode::Rewrite, getWriteSettings())->finalize();
});
in DiskObjectStorageTransaction::copyFileImpl
Remove the SQL test; it is not needed to test the disk. Remove all comments from the unit test. Make the test super simple.
Relocate the fix for copying an empty file to a plain_rewritable disk from
the metadata storage layer into DiskObjectStorageTransaction::copyFileImpl,
as requested in review.
plain_rewritable correctly advertises supportsEmptyFilesWithoutBlobs() == false,
so the metadata layer is not where the bug belongs. The real defect is that
copyFileImpl issued createMetadataFile(to, {}) for an empty source file without
checking the destination metadata storage's feature flag, hitting front() on an
empty StoredObjects vector. Now, when there are no blobs to copy and the
destination does not support blobless empty files, copyFileImpl materializes a
real 0-byte object through the regular write path (mirrors createFile()).
This reverts the earlier metadata-storage changes (createMetadataFile empty
branch, the create_empty_object/object_write_attempted plumbing, the
plain_object_storage_write_fail_after_empty_file_object failpoint) and removes
the SQL test in favor of a minimal unit test.
The write path uses shared_from_this(), which exposed a latent bug:
MultipleDisksObjectStorageTransaction inherited enable_shared_from_this twice
(once via DiskObjectStorageTransaction, once directly), so shared_from_this()
threw bad_weak_ptr on the cross-disk move path. Removed the redundant base.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@Michicosun done in 151b6c3 — relocated to One extra fix was required: the write path calls |
Pre-PR validation gate (HEAD 151b6c3)
Session id: cron:clickhouse-worker-slot-3:20260619-152800 |
All src/gtest*.cpp files link into the single unit_tests_dbms process. Both DiskObjectStorageTest::SetUpTestSuite and MetadataPlainRewritableDiskTest::SetUp call getIOThreadPool().initialize(), and StaticThreadPool::initialize throws LOGICAL_ERROR if the pool is already initialized, so whichever suite runs second aborts. Switch both to initializeWithDefaultSettingsIfNotInitialized() (guarded by std::call_once) so the pool is initialized exactly once regardless of suite order. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gateHEAD 947d0d1 (idempotent IO-pool init in Disks gtest fixtures).
Session id: cron:clickhouse-worker-slot-0:20260619-171400 |
The empty-source branch went through writeFile with getWriteSettings(), which reads the current query/global context rather than the per-call WriteSettings passed into copyFile. That bypasses backup/restore throttlers, cache settings, and the object_storage_write_if_none_match / object_storage_write_if_match conditional writes that still govern whether the 0-byte destination object may be created or overwritten. Thread the caller's write_settings through, mirroring the non-empty path that uses them via copyObjectToAnotherObjectStorage; writeFile applies the IO scheduling enrichment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gateHEAD c255b57 (write_settings fidelity in the
Session id: cron:clickhouse-worker-slot-0:20260619-181700 |
|
CI fully finished on HEAD
All other checks pass. The latest bot CR (write_settings fidelity in |
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered by tests: 49/50 (98.00%) | Lost baseline coverage: none · Uncovered code |
CI status on HEAD
|

Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fix a server crash when moving an empty part to a
plain_rewritabledisk (for example withALTER TABLE ... MOVE PARTITION ... TO DISKfor an empty part kept byremove_empty_parts = 0).Description
An empty part contains 0-byte files. A local-metadata disk stores such a file with zero stored objects, so the cross-disk copy passes an empty objects list to
createMetadataFile. Theplain_rewritableimplementation calledobjects.front()unconditionally and aborted on the empty vector (libc++ hardening:front() called on an empty vector).The
createMetadataFile(path, objects)contract already allows an empty list (the local metadata storage creates an empty file, the non-rewritable plain storage is a no-op);plain_rewritablewas the only implementation that crashed. Fix: whenobjectsis empty, record a 0-byte file. The write operation only usesbytes_size, so a size-0 object is sufficient and matches how the source file is represented.Regressed in 25.12 when the
plain_rewritablemetadata storage was split from the unified plain implementation.A stateless regression test is added (
04327_move_empty_part_to_plain_rewritable).Closes #106960