Search the whole storage policy when picking a detached/ directory name - #112416
Search the whole storage policy when picking a detached/ directory name#112416groeneai wants to merge 5 commits into
Conversation
detached/ is a table-wide namespace: every reader resolves a detached directory by scanning all disks of the storage policy. The allocator that every detach goes through, DataPartStorageOnDiskBase::getRelativePathForPrefix, probed only the part's own disk, because its volume is a per-part SingleDiskVolume. So on a multi-disk policy, if detached/P already exists on disk A and the live part P sits on disk B, the allocator did not see the collision, skipped the _tryN suffix that exists for exactly this case, and created a second logical detached/P. The damage lands on the next statement. tryLoadPartsToAttach feeds both directories into one ActiveDataPartSet; because the two dir_names are identical strings the second tryAdd returns HasCovering, and the loop only handles HasIntersectingPart, so neither copy is demoted to ignored_. Both become attach candidates and each commits under a fresh block number, so ATTACH PARTITION silently attaches both copies. The single-part form was affected too: it resolves the disk through getDiskForDetachedPart, which returns the first policy disk carrying the name, so which copy got attached was decided by disk order. MergeTreeData owns the storage policy and already exposes the canonical table-wide answer, so the fix injects it into the allocator as a predicate rather than teaching the single-disk part storage about other disks. The allocator is the single choke point for all 17 renameToDetached / makeCloneInDetached producers, so no per-site checks are needed and no producer can drift. Two details: The broken-part content check stays own-disk. looksLikeBrokenDetachedPartHasTheSameContent builds its comparison storage on the part's own volume, so a twin on another disk cannot be inspected. The loop keeps the own-disk result as a separate, narrower question and only advances to _tryN in that case, never dropping a broken part on the strength of a directory it could not read. Exhaustion of the 10 attempts now fails closed. It used to return the last candidate even though it knew the name was taken, relying on the caller's own guard: Backup() throws DIRECTORY_ALREADY_EXISTS and rename(..., remove_new_dir_if_exists = true) deletes the local twin. Both guards are own-disk, so a name taken only on another disk at _try9 would have recreated exactly the duplicate this change prevents, leaving the fix complete for _try0..8 and silently broken at _try9. The throw applies only when a table-wide predicate was supplied, so non-detached callers are untouched, and no catch was widened to swallow it. Single-disk policies are a no-op: tryGetDiskForDetachedPart iterates the one disk and checks the same path. Verified byte-identical system.detached_parts output before and after, and pinned by a control case in the new test. The collision cannot be built from one table's own detaches, since a DETACH covers the active part and ATTACH PART re-activates the copy on the disk it already sits on. The new integration test uses FETCH to publish the twin, MOVE PART ... TO DISK to split live from detached, then DETACH, and asserts mid-way that the split actually happened so it cannot degenerate into a single-disk run. Related: ClickHouse#112215 fixes the fetch-side half of the same hole.
…ustion Review round 1 follow-ups to the table-wide detached/ name search. The new trailing parameter of getRelativePathForPrefix carried a default argument on a virtual method, which clang-tidy's google-default-arguments check rejects and Build (arm_tidy) turns into an error. The sole caller repo-wide already passes it explicitly, so the parameter is now required rather than suppressed: a future detach path cannot silently omit the table-wide predicate. The fail-closed DIRECTORY_ALREADY_EXISTS thrown when all ten candidate names are taken was raised outside renameToDetached's try block, and its catch list covers ErrnoException and fs::filesystem_error but not DB::Exception, so callers that pass ignore_error could not skip it. Two broken-on-start sites run inside function-try-blocks whose handler calls std::terminate, so such an escape would take the server down instead of skipping one part. The name resolution now happens inside that try and one narrow catch honours the same ignore_error policy as the arms already there; callers that did not ask for errors to be ignored still see the throw, so the hole the throw closes stays closed. The own-disk existsDirectory probe is now evaluated lazily, only for the broken-part content comparison that needs it, so a detach no longer pays for it in addition to the table-wide check. Adds coverage for both directions of the exhaustion branch, which had none. The survival case asserts the exhaustion was reached and logged, so it cannot pass without exercising the branch.
Moving the detached-name resolution inside renameToDetached's existing try block, so an ignore_error caller can skip a detach that cannot pick a name, also put the rename itself under the newly added handler. An unqualified catch therefore swallowed every DB::Exception the block can raise, including storage failures from the rename and LOGICAL_ERROR. That matters because the caller erases the part from data_parts_indexes on the statement right after renameToDetached returns, so a swallowed rename failure forgets a part that is still sitting in its original directory, and LOGICAL_ERROR would be skipped in release while aborting a debug build. Qualify the handler on DIRECTORY_ALREADY_EXISTS, the code the allocator raises when all ten candidate names are taken. Everything else propagates as before. The arm stays after the ErrnoException one, which derives from Exception and would otherwise be shadowed. The regression test drives the surviving hole through SYSTEM RESTORE REPLICA, which detaches every part with ignore_error = true, on a disk whose metadata is rewritable so that a directory move can fail with a DB::Exception at all; a local disk only ever raises fs::filesystem_error there, which a pre-existing handler already owns. Without the qualification the injected fault is swallowed and the part is then reported missing. Also scope the startup test's anti-vacuity log check to the current server run and attribute it to the frame that swallows. The same sentence is emitted by a user query in the sibling exhaustion test and by AsyncLoader::worker when the exception escapes instead of being swallowed, so an unscoped search proved only that exhaustion had been reached. A pre-restart line offset cannot be used for the window: the integration logger runs with rotateOnOpen, so a restart opens a fresh log file.
Internal second-model review (4 rounds, 12 findings, all resolved)Every change in this PR was reviewed by a second model independently of the model that wrote it, Round 4 (final)
💡 Residual noted, not blocking: because name resolution and the rename share one Round 3❌ The ❌ The anti-vacuity log assertion was itself vacuous, two ways. AGREED and fixed. It was 💡 Round 2❌ ❌ The new fail-closed throw would have killed the server at startup. AGREED and fixed. Two 💡 Two nits folded in: a redundant own-disk probe per attempt (now lazy), and no coverage of the new Confirmed soundCarrier enumeration was re-derived independently each round and matched: Session id: cron:clickhouse-review-slot-50:20260729-093800 |
Pre-PR validation gate (click to expand)
|
|
cc @tiandiwonder @alesapin — could you review this? The detached part name allocator probed only the part's own disk (a per-part |
|
Workflow [PR], commit [1f78c07] Summary: ❌
AI ReviewSummaryThis PR fixes the cross-disk detached-name allocation bug for the detach flows that go through Findings❌ Blockers
Final Verdict❌ Changes needed before merge. |
The table-wide search introduced here probed every disk of the storage policy, but detached/ is not enumerated across all of them: getDetachedParts() deliberately skips read-only and write-once disks, and ATTACH PARTITION additionally drops every '_tryN' directory from its candidate set. So a name occupied only on a skipped disk was treated as taken. The live part was then renamed to '<part>_try1', which nothing can attach, while the directory that caused the collision stayed invisible: the partition became unattachable even though both copies were on disk. Reproduced on a policy holding one writable and one read-only disk, where ATTACH PARTITION recovered the rows before this change and none after it. The allocator now asks whether the name is taken on the disks that enumeration actually covers. The part's own disk is still probed unconditionally, so the answer stays a superset of the original own-disk probe and the duplicate detached/<part> this branch fixes is still prevented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI finish ledger - e2198c8Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
The two Session id: cron:our-pr-ci-monitor:20260729-220000 |
| /// so the name must be free on those too, not just on the disk this part happens to live on. | ||
| IDataPartStorage::NameTakenChecker name_taken_anywhere; | ||
| if (detached) | ||
| name_taken_anywhere = [this](const String & dir_name) { return storage.isDetachedNameTakenOnEnumerableDisk(dir_name); }; |
There was a problem hiding this comment.
detached/ is still only table-wide on the IMergeTreeDataPart paths. Replicated fetch-to-detached flows bypass this helper entirely: StorageReplicatedMergeTree::executeClonePartFromShard still does part->renameTo(detached/<part>, true) at StorageReplicatedMergeTree.cpp:3527, and fetchPart(..., to_detached = true) does the same at StorageReplicatedMergeTree.cpp:5720 after only a best-effort precheck.
If detached/<part> already exists on another disk, those paths can still create the second logical detached/<part> that tryLoadPartsToAttach later attaches twice. These writers need the same whole-policy collision check, or they need to reject the operation once the name is already taken anywhere in the enumerable detached namespace.
There was a problem hiding this comment.
Correct on the mechanism, and both line numbers still land on the right code after the master merge. git grep -nE 'renameTo\(\s*(fs::path\()?(DETACHED_DIR_NAME|"detached")' -- src/ returns exactly those two hits, and both reach DataPartStorageOnDiskBase::rename, whose probe is volume->getDisk()->existsDirectory(to) at DataPartStorageOnDiskBase.cpp:709. Neither goes through getRelativePathForPrefix, so the new predicate does not apply to them. The PR body's "all 17 detach producers" was wrong in both directions and I have corrected it: 15 call sites use the allocator, 2 write directly.
I am not extending the predicate to those two here, for a reason beyond scope.
The allocator's remedy for a taken name is a _tryN suffix, and both ATTACH readers erase _tryN entries before choosing candidates: MergeTreeData.cpp:9300 and StorageReplicatedMergeTree.cpp:2424 filter on part_info.has_try_suffix. For a detach that is right, since the part is leaving the working set and the copy is recoverable residue. For a fetch-to-detached the output exists so the user can ATTACH it, so detached/P_try1 would be silently unattachable. These two need your second remedy, refusal, not the allocator. This PR hit the mirror image of that from the other side: an earlier revision treated a name on a read-only disk as taken, pushed the live part to a _tryN directory the same enumeration filters out, and left the partition unattachable.
So they are owned separately:
fetchPart(..., to_detached = true) at :5720 is already fixed in #112215, which takes lockParts, rejects when tryGetDiskForDetachedPart(part_name) finds a copy anywhere in the policy (MergeTreeData.cpp:10696 iterates getStoragePolicy()->getDisks()), and drops remove_new_dir_if_exists. That is your refusal remedy verbatim. Adding it here too would put the same change in two open PRs.
executeClonePartFromShard at :3527 is genuinely unfixed, and it is the one you also raised on #112215 as discussion_r3663716371. It cannot take a name check as it stands: the CLONE_PART_FROM_SHARD entry is re-executed indefinitely, executeLogEntry's already-exists short circuit is gated on is_get_or_attach || MERGE_PARTS || MUTATE_PART so this type falls through, and a refusing rename therefore fails forever on the entry's own leftover while PartMovesBetweenShardsOrchestrator.cpp:383 waits before DESTINATION_ATTACH. A wedged MOVE PART TO SHARD is worse than the duplicate. Making it safe needs an ownership marker, either a staging name or a checksum on the entry so re-execution recognises its own output, which is a design change rather than a parameter. It is tracked, and the feature is gated behind part_moves_between_shards_enable, default 0.
One writer your list does not name, for completeness rather than as an action item: ReplicatedMergeTreeSink.cpp:641 renames a deduplicated ATTACH PART candidate back from detached/attaching_<part>. It passes remove_new_dir_if_exists = false, so a collision throws, and the name it restores is one ATTACH had just taken out of that namespace.
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 32/41 (78.05%) · Uncovered code |
CI finish ledger - 1f78c07Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
Session id: cron:our-pr-ci-monitor:20260801-230000 |
Related: #112215
Related: #58957
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fix
ALTER TABLE ... DETACH PARTcreating a seconddetached/directory under a name already in use on another disk of a multi-disk storage policy. The duplicate silently duplicated rows on a followingATTACH PARTITIONand madeATTACH PARTpick an arbitrary one of the copies. The name is now checked against the disks that directory is enumerated from, so the existing_tryNsuffix is applied as intended.Description
What breaks.
detached/is a table-wide namespace, enumerated bygetDetachedPartsacross the policy's disks. Ifdetached/Pexists on disk A and the live partPsits on disk B,DETACH PART 'P'creates a second logicaldetached/P.tryLoadPartsToAttachfeeds both into oneActiveDataPartSet; the identicaldir_namemakes the secondtryAddreturnHasCovering, unhandled by the loop, so both attach under fresh block numbers.Root cause. The allocator detaches funnel through,
DataPartStorageOnDiskBase::getRelativePathForPrefix, probed only its ownSingleDiskVolume, blind to the other disks, so it skipped the_tryNsuffix meant for this case.The change.
IDataPartStorage::getRelativePathForPrefixnow takes a required table-wide name predicate, andIMergeTreeDataPartsuppliesMergeTreeData::isDetachedNameTakenOnEnumerableDisk, applying the same read-only and write-once skip asgetDetachedParts, so a name taken only on a diskATTACHnever offers is ignored. The own disk is still probed, so the answer is a superset of the old one. Two consequences: the broken-part content comparison stays own-disk, since a twin elsewhere cannot be read; and exhausting the 10 attempts now throwsDIRECTORY_ALREADY_EXISTSinstead of returning a taken name, tolerated underignore_errorfor that code only. Single-disk policies are a no-op.Scope. The predicate covers the 15 detach call sites using that allocator. Two
Replicatedpaths rename intodetached/directly and stay out of scope: a_tryNname is unattachable there, so they must refuse instead. ThefetchPartpublish is fixed in #112215;executeClonePartFromShardneeds an ownership marker, since its entry is re-executed and a refusing rename would stall the shard-move orchestrator.Validation. New integration test on a two-disk JBOD policy (functional tests may not fabricate parts). Reverting only the predicate reproduces the
2 rows / 1 distinct namefailure; both exhaustion directions, a non-exhaustion failure and a read-only disk are covered.test_partitionand the stateless detached tests stay green.