Fix use-after-free on TRUNCATE of an EmbeddedRocksDB table being read - #112915
Fix use-after-free on TRUNCATE of an EmbeddedRocksDB table being read#112915groeneai wants to merge 20 commits into
TRUNCATE of an EmbeddedRocksDB table being read#112915Conversation
TRUNCATE through an Alias never acquired the exclusive lock on the table whose data it destroys. InterpreterDropQuery locks the table named in the statement, which is the alias, and passes that holder down; StorageAlias forwarded to the target and reused that same holder. IStorage::lockForShare and lockExclusively are not virtual and each locks the storage's own drop_lock, so the target's exclusive lock was never taken while readers hold exactly that lock. For EmbeddedRocksDB this is memory-unsafe rather than merely racy: the handle is a unique_ptr<rocksdb::DB>, so truncate destroys the DBImpl while another query is still advancing an iterator obtained from it, which rocksdb forbids in db.h. Close does not help, since it refuses only on unreleased snapshots. AddressSanitizer reports a heap-use-after-free in the rocksdb merging iterator, with the memory released by DBImpl::Close under StorageAlias::truncate. Found by BuzzHouse (arm_asan_ubsan). The read side was already correct, holding the target's share lock for the whole pipeline lifetime, so reading and truncating both through the alias, or both directly, were already serialized. The missing edge was truncating through the alias while a reader reached the target another way. Acquire the target's exclusive lock in StorageAlias::truncate, replicating the MergeTreeData exemption InterpreterDropQuery already applies. TRUNCATE on an alias now waits for the target's readers, matching the direct path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix round on the Alias truncate lock, all inside StorageAlias::truncate and its test. Take the target's exclusive lock with RWLockImpl::NO_QUERY rather than the current query id. TRUNCATE TABLES ... LIKE truncates every matched table concurrently on a thread pool sharing one query context, so two tasks reaching the same target would hit RWLockImpl::getLock's same-query fast path, which throws a logical error instead of waiting. An Alias is matched by that loop because it does not override supportsTruncate. Two shapes are reachable with a single statement: two aliases over one target, and an alias next to its own target. Both abort a debug or sanitizer server before this change and both now succeed. DatabaseMySQL.cpp:419 uses NO_QUERY for the same reason. Forward the interpreter's holder on the exempt MergeTree path instead of passing an empty one. StorageReplicatedMergeTree::truncate consumes its holder via release() to keep the replicated truncate asynchronous, so an empty holder there would make that release a no-op and strand the alias's exclusive lock for the whole asynchronous phase, up to the replication wait timeout. The exempt branch is now byte-identical to the forwarding on master, so that engine sees exactly what it saw before. The parameter is used again, so its name is restored. The test no longer relies on a fixed sleep to establish the race. It waits via system.processes until a reader of the target is really running, then requires TRUNCATE through the alias to report DEADLOCK_AVOIDED and to succeed once the reader is gone. That oracle is independent of any sanitizer: with the lock removed it reads 0 instead of 1 on a plain debug build, and the server then aborts on the same free stack. Renumbered to 04664 because 04657 was taken on master after this branch diverged. Neither git nor check_gaps_in_tests_numbers reports a collision, only gaps of 100 or more. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TRUNCATE through an ENGINE = Alias took the exclusive lock on the alias only, never on the target whose data it destroys, so a reader that reached the target by another route kept using an EmbeddedRocksDB handle that TRUNCATE had already closed and freed. That is fixed by locking the target, with MergeTree exempt because it synchronizes truncation itself and the lock is heavyweight. The exemption tested the pointer the catalog returned. A database created with lazy_load_tables = 1 hands out a StorageTableProxy, which does not derive from MergeTreeData, so a proxied MergeTree or ReplicatedMergeTree fell into the non-exempt branch. For ReplicatedMergeTree that was a regression: it consumes its holder via table_lock.release() to keep the truncate asynchronous, so releasing a lock taken on the proxy instead left the alias's exclusive lock held across the whole asynchronous phase. Unwrap StorageProxy before the decision and use the unwrapped pointer only for it; truncate still goes through the target so the proxy materializes and forwards, and the exempt branch still forwards the interpreter's own holder unchanged. Split the TRUNCATE TABLES ... LIKE coverage into its own test. Forcing several pool tasks to want the same target lock at once needs the server-global PAUSEABLE failpoint truncate_database_tables_pause, so that test is no-parallel and the main one stays parallel-safe. Without it the tasks finish in microseconds and the assertion can pass without ever exercising the overlap the NO_QUERY argument exists to survive. Wait for read_rows > 0 rather than mere presence in system.processes: the ProcessList entry is published before the interpreter is built, hence before any table lock is taken. Use INSERT ... SELECT rather than INSERT ... VALUES, since the test runner redirects only stdout and stderr and a VALUES insert then blocks on the inherited stdin until the test times out. Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=109920&sha=9fc3b75335e01ca93d050a0336a98d9d85b98bf2&name_0=PR&name_1=BuzzHouse%20%28arm_asan_ubsan%29 Discovered on: ClickHouse#109920
Review round 4 found that both tests could pass without exercising what they name. 04674 proved only that the TRUNCATE TABLES ... LIKE tasks overlapped at the top of the per-table lambda, which is upstream of the target lock: if the first task acquires and releases before the second asks, RWLockImpl::getLock's same-query fast path is never reached and the test passes even with the query id restored. And 04673's lazy-proxy cell asserted only the absence of DEADLOCK_AVOIDED, which is also what a reader that never started, or a truncate that failed for an unrelated reason, produces. A PAUSEABLE_ONCE failpoint now parks one task while it owns the target's exclusive lock, so another provably requests a lock that is already held. That is the only state in which the query id matters, and both tests now assert the state they need from the server rather than assuming it: the held/requesting pair, the readiness of each reader, and that the exempt truncate actually succeeded. Each assertion was mutation-tested; with the query id restored the overlap test now fails 10 out of 10 runs. Renumbered to 04677/04678: 04673 was claimed on master and by other in-flight work while this branch was in review.
… target The MergeTree exemption in StorageAlias::truncate is decided on the storage the catalog entry really resolves to, but the walk followed only StorageProxy links. StorageAlias is not a StorageProxy, so a chain of two aliases stopped at the inner alias, the leaf was never reached, and the non-exempt branch ran. Such a chain is constructible. The constructor rejects an alias whose target is already an alias, but with lazy_load_tables the inner alias comes back from a reload as an unloaded StorageTableProxy, whose getName() is "TableProxy", so the guard does not fire. Two consequences: TRUNCATE over an exempt MergeTree leaf started reporting DEADLOCK_AVOIDED under a concurrent reader where before this branch it took no target lock at all, and with a ReplicatedMergeTree leaf the inner alias forwarded its incoming holder into a callee whose first statement releases it, so a lock this function owns would be released by a callee that believes it owns the interpreter's holder. The walk now follows both link kinds and is bounded by a visited set. Nothing else changes: the lock is still taken on the catalog entry a reader locks, and only the exemption decision uses the resolved leaf. No cycle is reachable today, because the catalog's dependency graph rejects one, so that bound is defensive. 04677_alias_truncate_locks_target gains a cell for that chain. It fails on the previous build with the truncate blocked and succeeds after this change, and deleting only the alias-following half of the walk reddens that cell and nothing else.
The exempt branch of StorageAlias::truncate forwards the interpreter's own TableExclusiveLockHolder rather than a fresh one. That is deliberate: StorageReplicatedMergeTree::truncate opens with table_lock.release() to keep the replicated truncate asynchronous, and it is the only consumer of that holder type in src/. Handing it an empty holder makes release() a no-op over a null drop_lock, so the alias stays write-locked for the whole asynchronous phase and readers of the alias get DEADLOCK_AVOIDED. Nothing pinned that line. Both exemption cells of 04677 use plain MergeTree, whose truncate leaves the holder parameter unnamed and therefore provably ignores it, so the regression an earlier round fixed could return silently. 04686_alias_truncate_replicated_holder covers it with a ReplicatedMergeTree leaf whose replication queue is stalled by SYSTEM STOP REPLICATION QUEUES: with no queue entry executed the truncate's alter_sync = 1 wait cannot complete, so it parks inside the asynchronous phase, and a read through the alias must still succeed. The precondition is asserted from the queued DROP_RANGE in system.replication_queue rather than from system.processes, because the ProcessList entry is published before the interpreter is built and so long before the lock is released; without that row the cell would also pass on a truncate that never started. Replacing the forwarded holder with an empty one flips exactly its two oracle rows while every row of 04677 and 04678 stays byte-identical. It needs zookeeper, no-shared-merge-tree and no-replicated-database, so it is a separate file: adding those tags to 04677 would cost its other cells their coverage. Cleanup restarts the queue from a trap, so an interrupted run cannot leave it stopped for a later test. Also note in the resolution comment that an alias chain does not need a lazy database: the constructor guard inspects only an already-existing target and RENAME does not re-validate, so renaming an alias onto a then-free target name forms one with no proxy involved.
Internal second-model review (round 6, all rounds summarized)An independent model reviewed this change cold against the final body, and I Round 6
❌ The body named two tests while the change ships three. Agreed and fixed 💡 Three notes recorded without changes: the Keeper test's 15-second precondition Standing outcomes from earlier rounds
Verdicts this round: 1 agreed, 4 disagreed with evidence, 2 dropped on analysis. |
Pre-PR validation gate (click to expand)
|
|
cc @nauu @tavplubix, could you review this? TRUNCATE through ENGINE = Alias locked only the alias, so the target lost the exclusive lock its readers hold on it, and for EmbeddedRocksDB that freed the rocksdb::DB under a live iterator. The exemption for MergeTree targets is decided on the storage the catalog entry really resolves to, so the walk follows both StorageProxy and StorageAlias links, and the exempt branch forwards the interpreter holder because StorageReplicatedMergeTree::truncate releases it to stay asynchronous. |
|
cc @nauu |
|
Workflow [PR], commit [df1d265] Summary: ❌
AI ReviewSummaryThis PR changes Findings
Final Verdict
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 52/70 (74.29%) · Uncovered code |
The Style check requires the literal text $CLICKHOUSE_TEST_ZOOKEEPER_PREFIX or
{database} to appear on the Replicated.*MergeTree( line itself. The rule lives in
ci/jobs/scripts/check_style/various_checks.sh and is a plain textual grep, so a path
reached through an intermediate shell variable cannot satisfy it no matter what the
variable expands to. Its own comment says as much: "it is not that accurate, but at
least something."
The test built the path as ZK_PATH="/clickhouse/tables/$CLICKHOUSE_DATABASE/rmt" and
passed $ZK_PATH to the engine, so the check failed and, because nearly every job in
the workflow needs Style check, cascaded the rest of the run to DROPPED. That in turn
made the Finish Workflow post hook fail: with the Bugfix validation jobs dropped,
new_tests_check.py found no per-arch job that had validated the bug.
Dropping the variable and writing the path inline also strengthens isolation rather
than weakening it. shell_config.sh sets
CLICKHOUSE_TEST_ZOOKEEPER_PREFIX="${CLICKHOUSE_TEST_NAME}_${CLICKHOUSE_DATABASE}",
so the path now carries the test name in addition to the unique database, and the
variable had no other use in the file.
Test behaviour is unchanged: the resolved path is still unique per test invocation,
so the DROP_RANGE precondition and the alias-read oracle observe exactly what they
did before.
Every existing cell either truncates a raw storage, where the catalog entry and the resolved leaf are the same object, or resolves to MergeTreeData and returns before the lock is acquired. So none of them can distinguish locking the catalog entry from locking the resolved leaf, and changing target_storage->lockExclusively to unwrapped->lockExclusively left all three test files green. That substitution is not harmless. A reader of a lazily loaded table locks the catalog entry, while StorageProxy::read forwards to the nested storage without locking it, so a lock on the leaf does not exclude that reader. Measured on the substituted binary, a direct reader of a lazy EmbeddedRocksDB no longer blocks TRUNCATE through its alias, and the truncate aborts inside rocksdb::DBImpl::Close while the reader's iterator is live, which is the same lifetime violation this change exists to prevent. The new cell wraps EmbeddedRocksDB in a lazy database, so the catalog hands out a proxy, and requires a reader holding only the proxy's share lock to block the truncate. It fails on the substituted binary with only the new rows flipping, and its companion row asserts the truncate still succeeds once the reader is gone, so the block is real rather than a permanently unavailable lock.
Pushed
|
The two rows that flipped on the flaky check are the two cells whose reader was LIMIT 25 x sleepEachRow(0.2), i.e. 5.09s measured. After the read_rows > 0 handshake the truncate then waits out its own lock_acquire_timeout = 3, so the reader only had to survive 5.09 - 0.20 - 3.00 = 1.89s of slack. Any latency beyond that, which a 50x sanitizer rerun supplies, let the reader finish first; nothing then held the share lock, the truncate succeeded at once and the probe read 0. Measured with a delay injected between the handshake and the truncate: the old form flips at 2.0s (predicted 1.89s), the new one still reports the block at 20s. The MergeTree cells never flipped because their readers are numbers(100), 20.11s measured, and their headline assertion is the insensitive direction. The reader is now killed instead of awaited, as the other cells already do, so the longer window costs no wall-clock. Kill-to-release measured at 0.15-0.23s, inside the following truncate's 3s timeout, and it writes nothing to stderr. All 18 reference rows are unchanged: the two block assertions are the only coverage of the behaviour this PR fixes, so they are pinned harder, not relaxed.
|
The flaky check reddened on my own new test Only two of the 18 reference rows flipped, and both are the "the TRUNCATE was blocked by a live I verified this by injecting a delay between the handshake and the TRUNCATE rather than by chasing The reader is now killed instead of awaited, as the sibling cells already do, so the longer window All 18 reference rows are byte-identical. Those two assertions are the only coverage of the blocking |
The flaky check failed this test three ways at the previous head, from two distinct defects plus one budget the test genuinely cannot meet. `kill %1` never named the reader it was meant to stop. The first cell runs three iterations of three background clients, so bash has already reached job nine and the reader is job ten; `%1` was reaped long before, the kill failed, and the bare `wait` that follows paid for the reader in full. Measured on an instrumented copy: kill returns 1 with the reader still listed as `[10]+ Running`, and the wait costs 26.8s. That was harmless while the reader was short, but the previous round widened it to about 30s precisely because it was believed to be killed rather than awaited, which put amd_msan at 185s against the 180s cap. Every reader is now killed by pid, so teardown is prompt regardless of how many jobs preceded it. The first cell's truncate was the only one inheriting its lock timeout from the server. Now that the truncate takes the target's lock it waits for that cell's three scans, and the CI config caps the wait at 60s (tests/config/users.d/limits.yaml, linked unconditionally by tests/config/install.sh), which a sanitizer build exceeds. The resulting Code 473 goes to stderr and the runner fails any test that writes there, which is the amd_tsan failure. It is pinned to 300s like every other truncate in the file. Neither of those brings amd_tsan near 180s, since it needs 575s for reasons that are inherent to the assertions: each reader has to outlast the truncate that must block on it. That is what the `long` tag is for. It costs the aggregate llvm-coverage job, which already excludes long tests deliberately, and it reduces the flaky-check repeat count to a tenth. No assertion was relaxed and the reference file is unchanged: all 18 rows still have to hold.
|
Pushed 1. That was harmless while the reader was short. The previous round widened it to about 30s because it 2. The first cell's truncate was the only one inheriting its lock timeout from the server. Now 3. Both directions, deterministic, no concurrency or load needed. Each defect gets an arm where the
For the second arm the server was configured with CI's Validation (buildId Mutation arms confirm the delta is load-bearing: restoring I have not squashed at ten commits: |
The three background scans in the first cell were ordered against the TRUNCATE by a bare `sleep 0.15`. On amd_msan under parallel load a cell-1 scan takes 88s at the median and 128s at the maximum (measured from the failing job's query log; the same scan is 0.43s locally), so 150ms is far too short and some scans reached the target lock only after the truncate had already queued. `RWLockImpl` grants writers priority: a reader may join the owning group only while no writer is queued, so such a scan waits for the truncate instead of being one of the readers the truncate waits for. The scans also inherited the CI cap of 60s (tests/config/users.d/limits.yaml) while the truncate was pinned to 300s, so a scan in that position could never win the wait. It then reported DEADLOCK_AVOIDED on stderr and the runner fails any test that writes there. Five of seven runs of `Stateless tests (amd_msan, WasmEdge, parallel, 2/2)` failed this way; the 281-402s durations were a consequence of five 60s lock waits, not a separate timeout problem. Wait for the scans to hold the lock by outcome instead of by a fixed delay, and pin their timeout to the truncate's so a scan that still lands behind it waits rather than failing. Verified with an injected cell that reproduces the ordering deterministically (holder holds the share lock, truncate queues, then the scan arrives): the old form fails with the exact CI signature (`Reason: having stderror`, `Code: 473 ... (60000ms)`) and the new form passes on identical state. 10/10 randomized runs and 8/8 concurrent copies pass, and the runtime drops to ~17s from 281-402s.
The readiness loop could fall through after its poll budget and truncate with no scan holding the target lock, which would leave the first cell racing nothing while still reporting green. Count the distinct scans observed reading and assert the count, so the prelude fails loudly instead of silently losing its coverage. Accumulated across polls rather than sampled at one instant, because a scan that already finished has also reached the target and requiring three at the same moment would be its own source of flakiness. Verified the assertion can fail: launching one scan instead of three reddens the test with `scans reached target 3` -> `1`.
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 24/26 (92.31%) · Uncovered code |
The test timed out at the runner's 600s cap on Stateless tests (amd_msan, WasmEdge, parallel, 2/2): 2 of 7 runs were killed at 600.16s and a passing run reached 520.82s. Measured from that job's own query_log, the nine loop scans account for 936s of the ~1000s per run, and the three truncates spend 301s waiting for them. Every scan read all 300000 rows, at p50 125.2s and max 205.3s, against 0.44s for the same query here. Per cell the truncate arrived 1.8-7.1s after the first scan started and then waited out the rest of the scan, so the cell's wall time equals the scan's duration and the loop is 85-93% of the runtime. The cost is the table scan, whose speed is a property of the build flavour, and not the lock contention the test is about. Sleep 0.1s per row over 200 rows instead. The window is then wall-clock and identical on every flavour, while max_block_size = 1 keeps the rocksdb iterator being pulled for its whole duration, which is what has to survive the truncate. The bounded scan is also a stronger reproducer than the full one. With the fix reverted the test now fails deterministically here, in StorageEmbeddedRocksDB::truncate closing the database under a live iterator, where the previous form finished its scan in 0.44s and left the truncate uncontended, so it only ever caught the bug on a slow enough runner. Runtime 281-402s to 76s, and flat: 8 concurrent copies finish in 76s and 16 in 78s, where before the per-scan cost grew with the flavour and the load.
CI finish ledger — bfeba99Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task 174 check-runs, 0 incomplete, All five Session id: cron:our-pr-ci-monitor:20260802-200000 |
tavplubix
left a comment
There was a problem hiding this comment.
@groeneai we should not rely on these locks. DatabaseOrdinary relies on them, but it's deprecated and we can disallow using Alias engine with Ordinary database. In other cases, we must hold StoragePtr while executing the query, see for example addStorageHolder
|
@tavplubix Thanks. Three measured points, then a question on scope. 1. 2. It cannot close this window, because the dangling object is not the storage. freeing the 3. It is not What that lock does for the rest of the tree: Taking your principle further points at a better fix for this particular bug: make the handle lifetime safe inside Your call on scope:
I plan on (a) unless you prefer otherwise. |
@groeneai so how does it get freed if we hold the StoragePtr? |
|
@tavplubix The void StorageEmbeddedRocksDB::truncate(...) // :297
{
std::lock_guard lock(rocksdb_ptr_mx);
if (rocksdb_ptr) { rocksdb_ptr->Close(); rocksdb_ptr = nullptr; } // :304-305, runs ~DBImpl
(void)fs::remove_all(rocksdb_dir);
fs::create_directories(rocksdb_dir);
initDB(); // :310, opens a NEW DBImpl
}
The freed 144-byte region is the An iterator holds it as a raw pointer. rocksdb states the requirement directly: "The returned iterator should be deleted before this db is deleted" (contrib/rocksdb/include/rocksdb/db.h:1066). So the storage hands out a handle-derived object under its lock and lets it outlive the lock. That is the bug I am fixing per (a): own the DB through a |
… freeing under them StorageEmbeddedRocksDB held its handle as unique_ptr<rocksdb::DB>. A full scan created its iterator under rocksdb_ptr_mx but received it after the guard was released, so the iterator outlived the guard for the whole pipeline. truncate() then took the mutex exclusively and destroyed the handle, which rocksdb forbids: "The returned iterator should be deleted before this db is deleted" (contrib/rocksdb/include/rocksdb/db.h:1066). AddressSanitizer reports a heap-use-after-free whose read is the iterator's own destructor recording a tick into the freed Statistics. The mutex protected the pointer, never the pointee's lifetime. The alias layer only removed the accident that hid it: TRUNCATE TABLE rdb with no alias is safe today only because InterpreterDropQuery takes lockExclusively for every non-MergeTreeData storage, and reaching the table through Buffer bypasses the alias entirely. Shared ownership alone is not sufficient, for two independent reasons. truncate() reopens the same directory, and rocksdb keeps a process-wide registry of held LOCK files (fs_posix.cc), so a surviving old handle makes that reopen fail with ENOLCK. And Close() tears down the column families a live iterator reads, so deferring the free to the reader's thread moves that teardown rather than avoiding it. So the handle becomes a shared_ptr, a full scan takes an RAII lease over handle and iterator, and truncate() moves the handle aside and waits under the held exclusive mutex until that lease is gone, bounded by lock_acquire_timeout, then closes, wipes and reopens. Holding the mutex throughout keeps a null handle unobservable, so a timed out TRUNCATE restores it and leaves the table populated rather than silently empty. Only the full-scan iterator escapes the guard: generateFullScan touches only the iterator, and the key-scan path re-takes the shared guard per multiGet, so no lease is needed there and no escapee re-acquires the mutex, which is why holding it across the wait cannot deadlock the readers being drained. The lease releases iterator then handle and notifies afterwards, under the mutex the waiter evaluates its predicate on. Notifying from a destructor body instead would fire while both members are still alive and lose the wakeup. drop() needs no drain: DatabaseCatalog splices a table into the drop set only once its StoragePtr refcount is 1 (isSharedPtrUnique, DatabaseCatalog.cpp), and the synchronous callers run after flushAndShutdown(true). The bulk sink's TTL clock escapes the guard but binds GetSystemClock() to a by-value shared_ptr, so it is refcount-owned across the call rather than a raw borrow. DeleteRange as an in-place clear was considered and not taken: it would change TRUNCATE's space-reclamation semantics for every user on every path.
The lifetime of the target's data no longer depends on any lock taken here: the preceding commit makes StorageEmbeddedRocksDB wait for its own readers, which covers the direct path and the Buffer path too, neither of which goes through the alias. Reverts these two files to their state before this branch, so StorageAlias::truncate forwards as it does on master and the alias_truncate_target_lock_acquired failpoint goes with the code that used it. The other non-MergeTree storages keep relying on the interpreter's exclusive lock exactly as they do on master; IStorage::truncate's documented contract is unchanged.
04677 previously asserted that TRUNCATE was BLOCKED by a live reader (DEADLOCK_AVOIDED), which was a property of the alias-side lock the preceding commit removes. It now asserts the stronger statement: with readers provably live (read_rows > 0), TRUNCATE succeeds, the table is emptied only after they finish, and no sanitizer report is produced. Both routes are covered, through the alias and directly on the table, since the defect never depended on an alias. The timeout cell asserts the original row count survives, not just the error code. A design that nulled the handle and released the lock would report TIMEOUT_EXCEEDED while silently leaving an empty table, so asserting the code alone would be a vacuous oracle. It routes its TRUNCATE through the alias so the only thing that can delay the statement is the drain rather than the interpreter's WRITE lock. Readers are paced by wall clock over a bounded row count rather than by scanning the whole table, which keeps the runtime independent of the build flavour. 04678 and 04686 are removed: 04678 existed only to cover RWLockImpl::NO_QUERY on the target lock in TRUNCATE TABLES ... LIKE, and 04686 only the holder forwarding for ReplicatedMergeTree targets. Both code paths are deleted in the preceding commit, so neither test has a subject any more. This removes no coverage of behaviour that still exists.
…a reader The wait added for full scan leases converted the setting straight into a duration and passed it to wait_for, so zero evaluated the predicate once and gave up. Zero is documented as no locking timeout, and RWLockImpl::getLock implements that by mapping it to time_point::max, so a TRUNCATE with the documented "wait as long as it takes" value failed immediately whenever a read was in progress. Since this change is what makes TRUNCATE on this engine read the setting at all, the meaning of zero is this change's own contract. The new test arm goes through the alias rather than the target. Truncating the target directly takes an exclusive table lock first, and that lock reads the same setting, so with zero it absorbs the whole wait and the branch under test is never reached - a direct-route arm agrees on both builds and proves nothing. Through the alias, only the target is left holding the reader, and the two builds separate cleanly: waits about 9-19s and succeeds, against TIMEOUT_EXCEEDED in under 600ms with the branch reverted. Also assert what the concurrent-scan helper never did: that a waited-for read actually finishes and returns every row. The readers' stdout went to /dev/null and a bare wait discarded their exit statuses, so a build that drained the lease correctly but broke the scan mid-iteration - the iterator's status turning bad and generateFullScan throwing ROCKSDB_ERROR - still reported a successful truncate and an empty table, and the test passed. sleepEachRow returns 0 per row, so summing sleepEachRow(0.1) + 1 keeps the pacing while making the result the row count. Verified with a mutant that throws mid-scan: only the new cells move, from 3 to 0 and 1 to 0, while "truncate succeeded 1" and "rows after truncate 0" stay put. The rocksdb::Iterator forward declaration was unused; the lease class it was added for is defined entirely in the .cpp.
TRUNCATE waited for the last full-scan lease while holding rocksdb_ptr_mx
exclusively. SharedMutex is writer-priority, so for the whole wait every other
user of the handle was fenced: both sinks, mutate, optimize, multiGet, the row
and byte estimators, and system.rocksdb.
That is worse than a stall. mutate re-takes the mutex shared inside its own
executor.pull loop, while its own read pipeline, and therefore its own lease,
is still alive. So
TRUNCATE rdb_alias SETTINGS lock_acquire_timeout = 0
concurrently with
ALTER TABLE rdb DELETE WHERE <non-key predicate matching rows>
wedged both queries permanently: mutate parked in SharedMutex::lock_shared
waiting for the lock TRUNCATE held, TRUNCATE parked in condition_variable::wait
waiting for the lease mutate held. Nothing serialised them, because
StorageAlias::truncate forwards to the target without locking it.
Which lock is held where, after this change:
- rocksdb_ptr_mx, exclusively: reading the lease count, Close(), nulling the
pointer, wiping the directory, reopening. Never across a wait.
- full_scan_leases_mx alone: the wait for the leases.
- Order is one-way, rocksdb_ptr_mx then full_scan_leases_mx.
Reading the count under the exclusive lock is also what makes the close safe: a
lease can only be taken while holding rocksdb_ptr_mx shared, so observing zero
while holding it exclusively means none exists and none can appear before the
close. If a lease is outstanding the mutex is released and the wait retries
against one deadline for the whole call, so a reader arriving mid-wait cannot
extend the budget. The count is now an explicit counter rather than
use_count() on the drained pointer, which the exclusive holder no longer keeps.
The pointer is no longer moved out and restored, so a timed out TRUNCATE leaves
the table exactly as found by construction, and there is still no window in
which another query can observe a null handle.
The test grows a cell that runs a lease-holding mutation beside a waiting
TRUNCATE and asserts both complete, since every reader it had takes no mutex
and so could not tell this apart. Its TRUNCATE uses a bounded timeout on
purpose: with an unlimited one a regressed build reports by hanging until the
runner gives up, instead of failing the cell. The exact count() assertions are
pinned against optimize_trivial_approximate_count_query, which the runner
randomizes and under which the engine answers from a key estimate that drifts
once the same keys are rewritten.
TRUNCATE of a table behind ENGINE = AliasTRUNCATE of an EmbeddedRocksDB table being read
Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model (engine: codex). This revision is a redesign after
Severity: ❌ blocker / Session id: cron:clickhouse-review-slot-9:20260804-153001 |
`Build (arm_tidy)` treats clang-tidy diagnostics as errors, and `cppcoreguidelines-init-variables` is one of the two `cppcoreguidelines` checks `.clang-tidy` deliberately leaves enabled. The declaration added by the lease-wait restructure had no initializer, which failed the build: `StorageEmbeddedRocksDB.cpp:346:18: error: variable 'leased' is not initialized [cppcoreguidelines-init-variables,-warnings-as-errors]` The value is always assigned under `full_scan_leases_mx` before it is read, so this is a tidy requirement rather than a behavior change. Verified with `clang-tidy-21` against the file's `compile_commands.json` entry: one error before, zero diagnostics after, and an injected uninitialized local still reddens the same check.
| /// Exclusive for the whole close and reopen, so no null rocksdb_ptr is observable | ||
| /// outside and no lease can be taken between the count read and the close, which needs | ||
| /// this mutex shared. Never held across the wait below. | ||
| std::lock_guard lock(rocksdb_ptr_mx); |
There was a problem hiding this comment.
lock_acquire_timeout stops applying as soon as the last full-scan lease drops. InterpreterOptimizeQuery does not take a table lock, and StorageEmbeddedRocksDB::optimize holds rocksdb_ptr_mx shared across CompactRange (StorageEmbeddedRocksDB.cpp:531-537), so TRUNCATE rdb_alias SETTINGS lock_acquire_timeout = 3 can wait on a reader here, have that reader finish within 3 seconds, and then still block arbitrarily longer on this exclusive rocksdb_ptr_mx acquisition behind a concurrent OPTIMIZE TABLE rdb (or another long shared holder). That breaks both the in-diff comment about using one budget for the whole call and the new TIMEOUT_EXCEEDED contract. The deadline needs to cover reacquiring rocksdb_ptr_mx as well, for example with a timed / try-lock loop against the same deadline.
There was a problem hiding this comment.
All three premises hold: InterpreterOptimizeQuery takes no table lock, optimize holds rocksdb_ptr_mx shared across CompactRange (:531-535), and DB::SharedMutex is writer-priority. I measured the overrun rather than reason about it, and it is real: with a concurrent OPTIMIZE TABLE rdb, TRUNCATE rdb_alias SETTINGS lock_acquire_timeout = 3 returned successfully after 4.41 s, and with lock_acquire_timeout = 1 after 7.31 s. The wall clock tracks the shared holder, not the setting.
Two measurements place it outside this change, though. The same arm on the direct path, TRUNCATE rdb with no Alias and no lease involved, took 7.20 s. And on a pristine master binary, which contains none of the lease code, it took 4.55 s. Master's truncate is a bare std::lock_guard lock(rocksdb_ptr_mx) with no timeout of any kind, so the untimed exclusive acquisition is long-standing engine behaviour on the path this PR does not touch.
I am also not able to implement the suggested remedy as described: DB::SharedMutex exposes only lock, try_lock, unlock and the shared equivalents, with no try_lock_for or try_lock_until to bound the acquisition against a deadline. Bounding it would mean a try-lock spin, which I would rather not add to pre-existing behaviour inside a use-after-free fix whose scope @ tavplubix already narrowed.
What you found that is genuinely wrong here is the comment. It claimed one budget for the whole call, when the deadline bounds only the lease waits across retries. Corrected in df1d26563d95b20 to say which waits it bounds and that acquiring rocksdb_ptr_mx stays untimed.
The TIMEOUT_EXCEEDED contract itself holds in both orderings: if the acquisition overruns the deadline while a lease is outstanding, wait_until returns false immediately and the exception is raised with nothing closed or nulled; if no lease is outstanding, the truncate proceeds exactly as it does on master.
The comment claimed one budget for the whole call. The deadline bounds the `full_scan_leases_released` waits across retries; acquiring `rocksdb_ptr_mx` is not bounded by it. Measured on this branch, with a concurrent `OPTIMIZE TABLE` holding `rocksdb_ptr_mx` shared across `CompactRange`: `TRUNCATE` with `lock_acquire_timeout = 3` returned successfully after 4.41 s, and with `lock_acquire_timeout = 1` after 7.31 s, so the wall clock tracks the shared holder rather than the setting. The same arm on the direct path, with no `Alias` and no lease involved, took 7.20 s, and a pristine master binary took 4.55 s, so this is the engine's existing untimed acquisition rather than anything this change introduces. `DB::SharedMutex` offers no timed acquire to bound it with. Comment only, no behavior change.
|
@tavplubix Option (a) is implemented and pushed. Ready for review whenever you have a moment. |
CI finish ledger — df1d265Every failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
Neither signature is caused by this PR's diff, which touches only Session id: cron:our-pr-ci-monitor:20260805-013000 |
Related: #112224
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixes a use-after-free when
TRUNCATEruns on anEmbeddedRocksDBtable that another query is reading.TRUNCATEnow waits for such a read to finish, bounded bylock_acquire_timeout, and reportsTIMEOUT_EXCEEDEDwithout changing the table if the read outlasts it.Description
StorageEmbeddedRocksDBheld its handle asunique_ptr<rocksdb::DB>. A full scan created its iterator underrocksdb_ptr_mxbut kept it after the guard was released, so the iterator outlived the guard for the whole pipeline andtruncatedestroyed the handle under it. rocksdb forbids that: "The returned iterator should be deleted before this db is deleted" (db.h:1066). AddressSanitizer reports aheap-use-after-freewhose read is the iterator's own destructor, ticking into the freedStatistics. The mutex protected the pointer, not the pointee's lifetime.The alias layer was not the cause: a plain
TRUNCATEwas safe only becauseInterpreterDropQuerytakeslockExclusivelyfor non-MergeTreeDatastorages, andBufferbypasses the alias entirely. Shared ownership alone is not a fix either, sinceClose()tears down the column families a live iterator reads.So
truncatewaits for the last reader: the handle becomes ashared_ptr, and a full scan takes an RAII lease over handle and iterator.truncatewaits on that lease under a separate mutex, holdingrocksdb_ptr_mxonly to read the count and, if no lease is left, to close, wipe and reopen in one exclusive section. It must not wait while holdingrocksdb_ptr_mx, which is writer-priority: that would fence every other user of the handle, including a mutation, which re-takes it shared from inside its own read pipeline while still holding the lease being waited for. Reading the count under the exclusive lock keeps the close safe, since a lease is only taken while holding that mutex shared.04677_rocksdb_truncate_waits_for_readercovers both routes, alias and direct: the table is emptied only after the readers finish, a timed-outTRUNCATEkeeps its row count, and a lease-holding mutation and a waitingTRUNCATEboth complete.Earlier revisions locked the target in
StorageAlias::truncateinstead. That is gone at @tavplubix's request, soStorageAlias.cppis untouched here.Found by
BuzzHouse (arm_asan_ubsan), STID 4594-36b1: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=109920&sha=9fc3b75335e01ca93d050a0336a98d9d85b98bf2&name_0=PR&name_1=BuzzHouse%20%28arm_asan_ubsan%29