Skip to content

Fix use-after-free on TRUNCATE of an EmbeddedRocksDB table being read - #112915

Open
groeneai wants to merge 20 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-rocksdb-truncate-iterator-uaf
Open

Fix use-after-free on TRUNCATE of an EmbeddedRocksDB table being read#112915
groeneai wants to merge 20 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-rocksdb-truncate-iterator-uaf

Conversation

@groeneai

@groeneai groeneai commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Related: #112224

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fixes a use-after-free when TRUNCATE runs on an EmbeddedRocksDB table that another query is reading. TRUNCATE now waits for such a read to finish, bounded by lock_acquire_timeout, and reports TIMEOUT_EXCEEDED without changing the table if the read outlasts it.

Description

StorageEmbeddedRocksDB held its handle as unique_ptr<rocksdb::DB>. A full scan created its iterator under rocksdb_ptr_mx but kept it after the guard was released, so the iterator outlived the guard for the whole pipeline and truncate destroyed the handle under it. rocksdb forbids that: "The returned iterator should be deleted before this db is deleted" (db.h:1066). AddressSanitizer reports a heap-use-after-free whose read is the iterator's own destructor, ticking into the freed Statistics. The mutex protected the pointer, not the pointee's lifetime.

The alias layer was not the cause: a plain TRUNCATE was safe only because InterpreterDropQuery takes lockExclusively for non-MergeTreeData storages, and Buffer bypasses the alias entirely. Shared ownership alone is not a fix either, since Close() tears down the column families a live iterator reads.

So truncate waits for the last reader: the handle becomes a shared_ptr, and a full scan takes an RAII lease over handle and iterator. truncate waits on that lease under a separate mutex, holding rocksdb_ptr_mx only to read the count and, if no lease is left, to close, wipe and reopen in one exclusive section. It must not wait while holding rocksdb_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_reader covers both routes, alias and direct: the table is emptied only after the readers finish, a timed-out TRUNCATE keeps its row count, and a lease-holding mutation and a waiting TRUNCATE both complete.

Earlier revisions locked the target in StorageAlias::truncate instead. That is gone at @tavplubix's request, so StorageAlias.cpp is 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

groeneai and others added 6 commits July 30, 2026 23:10
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.
@groeneai

groeneai commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Internal second-model review (round 6, all rounds summarized)

An independent model reviewed this change cold against the final body, and I
reviewed it separately before reading the implementation notes. Six rounds of
findings were adjudicated; this is the last round plus the standing outcomes.

Round 6

⚠️ Alias-chain exemption re-resolves between classification and delegation
(StorageAlias.cpp:296-335). Claim: if EXCHANGE TABLES swaps a non-MergeTree
leaf for a ReplicatedMergeTree one between the walk and the delegation, the
outer call hands the inner alias's lock to a callee that releases it.
Disagreed, with the mechanism traced. The harm needs the live leaf to be
truncated unprotected, and it cannot be, because the hop directly above the leaf
decides its protection from a fresh resolution. A1::truncate performs its own
getTargetTable and its own walk against the post-exchange catalog, so its
branch is never stale: if it classifies MergeTreeData the leaf really is
self-synchronizing at that moment, which is the exact condition
InterpreterDropQuery.cpp:330-333 says makes the lock unnecessary; if it
classifies anything else it takes an exclusive lock on the leaf itself. All four
combinations of stale-versus-live classification were enumerated and each is
safe. Proxy hops cannot participate at all: StorageTableProxy::getNested
(StorageTableProxy.h:55-69) materializes once under a mutex and caches the
pointer. The one real residual is that the interpreter's lock on the outer alias
stays held across the leaf's asynchronous wait rather than being released early,
which is strictly more conservative than the previous behaviour and cannot
deadlock, since that wait touches only the leaf's own locks.

The body named two tests while the change ships three. Agreed and fixed
before the final review pass: 04686_alias_truncate_replicated_holder is now
named along with the reason it needs Keeper, and four sentences were condensed to
keep the description inside its length budget.

💡 Three notes recorded without changes: the Keeper test's 15-second precondition
poll fails loudly rather than silently measuring an uncontended truncate; its
ZooKeeper path uses $CLICKHOUSE_DATABASE, which is already the unique component
of the conventional prefix; and the new test is green on unfixed master by
design, since it pins the holder-forwarding line rather than reproducing the
original fault, and is pinned instead by substituting an empty holder.

Standing outcomes from earlier rounds

  • The exemption decides on the storage the catalog entry really resolves to, and
    the walk follows both proxy and alias links. Two earlier rounds found this: the
    first that deciding on the raw pointer locks an exempt MergeTree leaf, the
    second that following proxy links alone stops at a nested alias.
  • The lock is deliberately taken on the immediate target rather than the resolved
    leaf, because readers lock the catalog entry and a proxy forwards reads without
    locking what it wraps. Recorded so this is not later simplified.
  • RWLockImpl::NO_QUERY is required: TRUNCATE TABLES ... LIKE shares one query
    id across its pool tasks, where the same-query fast path raises an error
    instead of waiting.
  • Two rounds were spent making the tests assert their own preconditions, so a
    reader that never started cannot make an uncontended truncate look serialized.
  • One plan premise was measured false and reported rather than worked around: an
    alias cycle is not constructible, because the catalog's dependency check
    rejects one, so the walk's cycle guard is documented defensive code.

Verdicts this round: 1 agreed, 4 disagreed with evidence, 2 dropped on analysis.

@groeneai

groeneai commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. On an ASAN build: an EmbeddedRocksDB table with 2,000,000 rows, a Buffer over it and an Alias to it; three concurrent SELECT count() FROM <buffer> SETTINGS max_threads=1, max_block_size=100, then TRUNCATE TABLE <alias>. The server dies at the first iteration with heap-use-after-free, reproduced twice. Not a low-rate flake.
b Root cause explained? Yes. InterpreterDropQuery locks the table named in the statement, the alias, and passes that holder down; StorageAlias::truncate forwarded to the target reusing that holder. 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. rocksdb_ptr is a unique_ptr<rocksdb::DB>, so truncate destroys the DBImpl while another query is still advancing an iterator obtained from it.
c Fix matches root cause? Yes. It acquires the missing lock on the target inside StorageAlias::truncate, at the layer that owns the proxying decision. No widened bound, no no-random-* tag, no reduced dataset, no guard at the faulting site. Read-side alternatives were rejected: a shared_ptr handle would only convert the use-after-free into iterating a closed database, since Close still runs; holding the rocksdb mutex across the scan treats one consumer and leaves every other engine behind an Alias exposed.
d Test intent preserved / new tests added? Yes. No existing test weakened or removed. Two new tests. 04677_alias_truncate_locks_target.sh asserts the lock deterministically rather than by timing: while a reader provably holds the target's share lock, TRUNCATE through the alias must report DEADLOCK_AVOIDED and must succeed once that reader is gone. The reader witness is read_rows > 0, not mere presence in system.processes, because the ProcessList entry is published before the interpreter is built and therefore before any table lock exists. It also covers an Alias over a lazily-proxied MergeTree, and an Alias over a chain of both link kinds, which must stay exempt in both cases. 04678_alias_truncate_tables_like_overlap.sh covers TRUNCATE TABLES ... LIKE, where several pool tasks can want the same target lock at once; it needs two server-global failpoints to force that overlap, so it is no-parallel and lives in its own file to keep 04677 parallel-safe. One of those is a new PAUSEABLE_ONCE failpoint parked immediately after the target lock is taken, which is what makes the overlap deterministic; it is inert unless explicitly enabled, so no production path changes. 04686_alias_truncate_replicated_holder.sh pins the remaining branch, the holder forwarding on the exempt path: with a ReplicatedMergeTree leaf and its replication queue stalled by SYSTEM STOP REPLICATION QUEUES, the truncate parks inside its asynchronous phase, and a read through the alias must still succeed, which it can only do if release ran on a real forwarded holder. Its precondition is asserted from system.replication_queue, not from system.processes, because only the queued DROP_RANGE proves the truncate got past table_lock.release. It needs zookeeper, no-shared-merge-tree and no-replicated-database, so it is a separate file rather than a cell in 04677, whose other cells would lose coverage under those tags.
e Both directions demonstrated? Yes, and per hunk. Pre-fix binary: dies at iteration 1, twice, free stack StorageAlias::truncate to StorageEmbeddedRocksDB::truncate. Fixed binary: 30/30 iterations clean, 0 sanitizer reports. Without a sanitizer, on a plain debug build, the fix-reverted arm aborts on TRUNCATE TABLE rdb_alias with the same free stack. Every guard is separately mutation-tested one at a time, with the source md5 restored after each and the served buildId() asserted equal to that arm's ELF Build ID before every measurement: reverting the proxy unwrap flips exactly one row; reverting the alias-following half of the same walk flips exactly the two chained-alias rows and nothing else; reverting NO_QUERY to getCurrentQueryId makes 04678 fail 10 out of 10 runs with Logical error: 'RWLockImpl::getLock(): RWLock is already locked in exclusive mode'; leaving the new failpoint out of the sequence flips only the two held/requesting rows; replacing the forwarded holder on the exempt path with a fresh empty one flips exactly the two oracle rows of 04686 (succeeded 1 to 0, blocked 0 to 1, i.e. DEADLOCK_AVOIDED) while its precondition row stays 1 and all 14 rows of 04677 and all 10 of 04678 stay byte-identical, so 04686 is the only test that pins that line. That mutation also makes the parameter unused and -Werror reject the file, which independently confirms the forwarding is its only use. 50 of 50 randomized runs pass for each test, 0 fatals, 0 sanitizer reports, no failpoint left enabled.
f Fix is general across code paths? Yes. Invariant: a statement that destroys a table's data handle must hold the exclusive lock on the storage object readers lock. truncate is the only IStorage method taking a TableExclusiveLockHolder &, so this cannot leak into DROP, DETACH or ALTER. Only two files forward truncate (StorageAlias.cpp, StorageProxy.h) and only StorageAlias resolves its target through DatabaseCatalog; StorageProxy's three subclasses own their nested storage privately, so it is never another query's catalog table. StorageAlias::drop is a deliberate no-op. The EmbeddedRocksDBBulkSink use outside the lock is safe because GetSystemClock returns a shared_ptr the caller copies by value.
g Fix generalizes across inputs (params/datatypes/wrappers)? Yes, including every storage wrapper the catalog can interpose. Truncating through an alias verified correct for MergeTree, EmbeddedRocksDB, Memory, Log, StripeLog and Join targets, plus target still writable and readable afterwards both directly and through the alias. The MergeTree exemption is decided on the storage the catalog entry really resolves to, following both link kinds: a database with lazy_load_tables = 1 hands out a StorageTableProxy, and because an unloaded proxy reports its name as TableProxy an alias can also be created on top of one, so proxy links and alias links both have to be followed. Measured under a concurrent reader of the target: eager MergeTree succeeds, lazily-proxied MergeTree succeeds, an alias over a proxied alias succeeds (each of the last two returned DEADLOCK_AVOIDED before the round that fixed it), and both eager and proxied EmbeddedRocksDB correctly return DEADLOCK_AVOIDED. Those both-directions pairs prove the exemption is live rather than dead code. The walk is bounded by a visited set; I could not construct a cycle on this build, because the catalog's dependency graph rejects one through all five routes I tried, so that bound is documented defensive code. ReplicatedMergeTree is now covered for real on a Keeper-enabled server rather than argued from source: with its replication queue stalled the truncate parks in its asynchronous phase, and a read through the alias succeeds only because the exempt branch forwards the interpreter's own holder for that engine's truncate to release.
h Backward compatible? (maintainer-approved exception only) Yes. No setting, no serialization or format change, no SettingsChangesHistory.cpp entry. There is an intended behaviour change: TRUNCATE on an alias now waits for the target's readers and can time out. For a non-MergeTree target it makes the alias path behave like the long-standing direct path, measured as both forms returning DEADLOCK_AVOIDED under the same contention where previously only the direct form did. A MergeTree target is exempt, takes no new lock and is unchanged: re-measured, its alias truncate still succeeds under the same concurrent read. DDL only, nothing added to read or insert paths.
i Invariants and contracts preserved? Yes. Two locks on two distinct objects, so no re-entrancy on one lock, and with no contention the truncate succeeds, so there is no self-deadlock. Each callee receives the holder it can act on: the exempt MergeTreeData path forwards the alias's own holder, the other path passes the newly acquired target lock. That distinction matters because StorageReplicatedMergeTree::truncate consumes its holder via release() to keep the replicated truncate asynchronous, so handing it a lock this code owns would let a callee release a lock it does not own. Resolving the leaf across both link kinds is what keeps that mapping correct for a chained alias. The lock itself is still taken on the catalog entry a reader locks, not on the resolved leaf; only the exemption decision uses the leaf. No accessor was added to TableExclusiveLockHolder.

@groeneai

groeneai commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@PedroTadim

Copy link
Copy Markdown
Member

cc @nauu

@PedroTadim PedroTadim added the can be tested Allows running workflows for external contributors label Aug 1, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [df1d265]

Summary:

job_name test_name status info comment
Stateless tests (arm_binary, parallel) FAIL
Server died FAIL cidb
Logical error: Bad cast from type A to B (STID: 4350-5ca2) FAIL cidb

AI Review

Summary

This PR changes StorageEmbeddedRocksDB full scans to lease the underlying rocksdb::DB across iterator lifetime and makes TRUNCATE wait for outstanding full-scan leases before Close/reopen. That should eliminate the reported use-after-free and the new regression coverage is strong, but one timeout contract remains broken, so I would still request changes.

Findings
  • ⚠️ Majors
    • [src/Storages/RocksDB/StorageEmbeddedRocksDB.cpp:333-378, 532-536] [dismissed by author -- https://github.com/Fix use-after-free on TRUNCATE of an EmbeddedRocksDB table being read #112915#discussion_r3715214059] lock_acquire_timeout stops applying once the last full-scan lease drops. truncate waits with a deadline on full_scan_leases_released, but then reacquires rocksdb_ptr_mx with an untimed std::lock_guard, while optimize still holds that same mutex shared across CompactRange. That means TRUNCATE ... SETTINGS lock_acquire_timeout = N can still block past N and succeed instead of raising TIMEOUT_EXCEEDED, which violates the new user-facing timeout contract. I still consider it real because the current head keeps both the untimed exclusive acquisition and the long shared CompactRange holder exactly as in the thread.
    • Suggested fix: make the final rocksdb_ptr_mx acquisition honor the same deadline, or narrow the changelog/body contract so it no longer promises a bounded whole-call wait.
Final Verdict
  • Status: ⚠️ Request changes
  • Minimum required actions:
    • Either bound the final exclusive rocksdb_ptr_mx acquisition by lock_acquire_timeout, or stop claiming that TRUNCATE as a whole is bounded by that setting and returns TIMEOUT_EXCEEDED whenever the wait exceeds it.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.70% +0.00%

Changed lines: Changed C/C++ lines covered: 52/70 (74.29%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 1, 2026
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.
@groeneai

groeneai commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 19805734f81f01: style fix plus a test that pins the lock target

Style check was PR-caused and is fixed here. 04686 built its zookeeper path through a
shell variable, and the rule in ci/jobs/scripts/check_style/various_checks.sh is a literal grep
for $CLICKHOUSE_TEST_ZOOKEEPER_PREFIX or {database} on the Replicated.*MergeTree( line
itself, so no expansion of that variable could satisfy it. The path is now inline. Since
shell_config.sh sets CLICKHOUSE_TEST_ZOOKEEPER_PREFIX="${CLICKHOUSE_TEST_NAME}_${CLICKHOUSE_DATABASE}",
the path now carries the test name as well as the unique database, so isolation is stronger than
before. Finish Workflow / Post Hooks failed only as a consequence: Style check is a needs root
for nearly the whole workflow, so its failure dropped the Bugfix validation jobs and
new_tests_check.py then found none that had validated the bug. Both should clear on this head.

While verifying that, I found and closed a real gap in my own tests. Every cell either
truncated a raw storage, where the catalog entry and the resolved leaf are the same object, or
resolved to MergeTreeData and returned before the lock was taken. So nothing distinguished
locking the catalog entry from locking the resolved leaf, and substituting
unwrapped->lockExclusively for target_storage->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 with the reader's iterator still live, which is the same lifetime
violation this PR exists to prevent.

The new 04677 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. On the substituted
binary it fails with only the new rows flipping; rows 1-13 stay byte-identical. A companion row
asserts the truncate still succeeds once the reader is gone, so the block is real and not a
permanently unavailable lock.

Verification, all with the served buildId asserted equal to the ELF Build ID: 04677 20/20 and
6/6 with six concurrent copies, 04686 20/20 and 8/8 with eight concurrent copies, 04678 OK,
stderr empty on every run, slowest run 17s against the 180s cap. Both new cells derive their
database names from $CLICKHOUSE_DATABASE, so no no-parallel tag is needed. The restore after
the mutation relinked to a bit-identical binary, so no mutation residue is in this push.

Session id: cron:clickhouse-maint-slot-18:20260801-160800

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

groeneai commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

The flaky check reddened on my own new test 04677_alias_truncate_locks_target, on all four
sanitizers, while the ordinary parallel runs stayed green. Fixed in fa4d222.

Only two of the 18 reference rows flipped, and both are the "the TRUNCATE was blocked by a live
reader" assertions. Those two cells were the only ones whose reader was
LIMIT 25 x sleepEachRow(0.2), which I measured at 5.09s. After the read_rows > 0 handshake the
TRUNCATE 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 past that, which a 50x sanitizer rerun readily
supplies, let the reader finish first; nothing then held the share lock, the TRUNCATE succeeded
immediately and the probe read 0. The MergeTree cells never flipped because their readers are
numbers(100), 20.11s measured, and their headline assertion is the insensitive direction.

I verified this by injecting a delay between the handshake and the TRUNCATE rather than by chasing
the race: the old form reports the block at 0/1.0/1.5s and stops reporting it from 2.0s on, against
the 1.89s the arithmetic predicts. The new form still reports it with 20s injected, and the
follow-up TRUNCATE still succeeds, so the row remains a real block rather than a dead lock.

The reader is now killed instead of awaited, as the sibling cells already do, so the longer window
costs no wall-clock (slowest run 59.8s against the 600s cap). Kill-to-release measured 0.15-0.23s,
inside the following TRUNCATE's 3s timeout, and it writes nothing to stderr. I confirmed the runner
randomizes no query-duration cap, so a longer reader is not exposed to one.

All 18 reference rows are byte-identical. Those two assertions are the only coverage of the blocking
behaviour this PR fixes, so they are pinned harder, not relaxed.

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

groeneai commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c3b18dc5f6d6bff. The flaky check failed 04677 three ways at the previous head, from two
distinct defects in the test plus one budget the test cannot meet. No assertion was relaxed and the
reference file is unchanged, so all 18 rows still have to hold.

1. kill %1 never named the reader. 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,
so the kill failed and the bare wait after it paid for the reader in full. Measured on an
instrumented copy of the test: kill returns 1 with the reader still listed as [10]+ ... Running,
and the wait costs 26.8s of the 30s reader.

That was harmless while the reader was short. The previous round widened it to about 30s because it
was believed to be killed rather than awaited, which is what put amd_msan at 185s against the 180s
cap. Every reader is now killed by pid.

2. 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
tests/config/users.d/limits.yaml caps the wait at 60s (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. It is pinned to 300s like every other truncate in
the file. This is the amd_tsan failure, and it was present before the previous round too.

3. long tag. Neither fix brings amd_tsan near 180s, since it needs 575s for reasons inherent
to the assertions: each reader has to outlast the truncate that must block on it. long is the
mechanism the runner provides (tests/clickhouse-test, the cap applies only when long is absent).
It costs the aggregate llvm-coverage job, which already excludes long tests deliberately, and reduces
the flaky-check repeat count to a tenth.

Both directions, deterministic, no concurrency or load needed. Each defect gets an arm where the
old form must fail and the new form must pass on identical state:

arm old form new form
reader teardown after nine consumed job numbers kill rc=1, teardown 30.05s rc=0, teardown 0.01s
cell-1 truncate vs a reader outlasting the 60s cap 365 stderr bytes, Code: 473 0 stderr bytes

For the second arm the server was configured with CI's lock_acquire_timeout = 60 and cell 1's
readers slowed to model a sanitizer build. The old form then reproduced the CI signature exactly:
Reason: having stderror, Code: 473 ... has timed out! (60000ms), three owner query ids. The new
form passed on the same state.

Validation (buildId eee850900a54f93bcac20643e03486110a900007, asserted equal to the ELF Build ID
before every measurement): 04677 3/3 with --no-random-settings, 10/10 with CI's random settings,
8/8 with eight concurrent copies. Runtime 41s -> 15.6s sequential (max 18.8s randomized). Siblings
unregressed: 04678 2/2, 04686 2/2. Leaked failpoints 0 and leftover test databases 0 after every run.

Mutation arms confirm the delta is load-bearing: restoring kill %1 puts the runtime back to 43.4s,
and restoring the inherited timeout reddens with the CI signature above.

I have not squashed at ten commits: .claude/CLAUDE.md says to add new commits rather than rebase or
amend on a branch.

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`.
Comment thread src/Storages/StorageAlias.cpp Outdated
@clickhouse-gh

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.70% +0.00%

Changed lines: Changed C/C++ lines covered: 24/26 (92.31%) · Uncovered code

Full report · Diff report

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

groeneai commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI finish ledger — bfeba99

Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

174 check-runs, 0 incomplete, Config Workflow and Finish Workflow both success, past the
20-minute CIDB ingestion buffer. No failing check on this head (18 skipped, rest green).

All five Bugfix validation jobs are accounted for: functional (amd64, aarch64) and unit tests
success, integration tests skipped (no integration-test changes in the diff).

Session id: cron:our-pr-ci-monitor:20260802-200000

@tavplubix tavplubix left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@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

@groeneai

groeneai commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

@tavplubix Thanks. Three measured points, then a question on scope.

1. addStorageHolder is already on this path, and the use-after-free happened with it in place. StorageAlias::read holds both the target's StoragePtr and its share lock for the whole pipeline (addStorageHolder / addTableLock, StorageAlias.cpp:220-221 on master). So the holder is not the missing piece here.

2. It cannot close this window, because the dangling object is not the storage. StorageEmbeddedRocksDB::truncate destroys and recreates the inner std::unique_ptr<rocksdb::DB> while the StorageEmbeddedRocksDB object itself survives. Free stack from the reproduced report:

rocksdb::DeleteScheduler::~DeleteScheduler <- ~SstFileManagerImpl <- ~DBOptions <- rocksdb::DBImpl::~DBImpl
DB::StorageEmbeddedRocksDB::truncate            (rocksdb_ptr->Close(); rocksdb_ptr = nullptr)
DB::StorageAlias::truncate
DB::InterpreterDropQuery::executeToTableImpl

freeing the Statistics object that the initDB() on the next line re-allocates. The reader's rocksdb::Iterator is created in ReadFromEmbeddedRocksDB::initializePipeline under SharedLockGuard(storage.rocksdb_ptr_mx), but that guard is released at the end of that inner block and the iterator then lives for the rest of the pipeline. Holding a StoragePtr keeps the wrapper alive; the iterator still dangles.

3. It is not Ordinary-specific. On the TRUNCATE branch the exclusive lock is not gated on the database engine: InterpreterDropQuery::executeToTableImpl takes it for every non-MergeTree table (if (!std::dynamic_pointer_cast<MergeTreeData>(table)), line 332). The getUUID() == UUIDHelpers::Nil gate is only on the DETACH and DROP branches (lines 302 and 360). My reproducer uses ENGINE = Atomic databases and the test is tagged no-ordinary-database, so disallowing Alias over Ordinary would leave this open.

What that lock does for the rest of the tree: IStorage::truncate is documented "Must be called under exclusive lock (lockExclusively)" (IStorage.h:470) and implementations are written to it, for example StorageSet::truncate does removeRecursive(path) plus recreate with no internal synchronization. Today the direct path is safe only because the reader and the truncater take the same drop_lock; this patch gives the alias path that same mutual exclusion.

Taking your principle further points at a better fix for this particular bug: make the handle lifetime safe inside StorageEmbeddedRocksDB, by holding the DB through a shared_ptr and having the iterator source keep a reference, so swapping the handle in truncate cannot free it under a live iterator. That removes the reliance on drop_lock for rocksdb on both the direct and the alias path.

Your call on scope:

  • (a) I do that rocksdb handle-lifetime fix here and drop the target lock, leaving the alias path for the other non-MergeTree storages (Set, Log, File, Memory) exactly as it is on master, to be discussed separately.
  • (b) the same fix, plus keep the target lock for the storages whose truncate still relies on the documented exclusive-lock contract.

I plan on (a) unless you prefer otherwise.

@tavplubix

Copy link
Copy Markdown
Member

StorageAlias::read holds both the target's StoragePtr and its share lock for the whole pipeline (addStorageHolder / addTableLock, StorageAlias.cpp:220-221 on master). So the holder is not the missing piece here.

@groeneai so how does it get freed if we hold the StoragePtr?

@groeneai

groeneai commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

@tavplubix The StoragePtr keeps the StorageEmbeddedRocksDB object alive. It does not keep the rocksdb handle alive, and truncate destroys that handle in place while the storage object survives:

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
}

rocksdb_ptr is a std::unique_ptr<rocksdb::DB> (StorageEmbeddedRocksDB.h:137-138), so the assignment at :305 runs ~DBImpl synchronously, no matter how many StoragePtr references exist.

The freed 144-byte region is the Statistics object that the old handle owned. initDB creates one per open (base.statistics = rocksdb::CreateDBStatistics(), :537) and passes it to DB::Open, so the only owning shared_ptr<Statistics> lives inside that DBImpl. Nothing on the ClickHouse side holds a reference: getRocksDBStatistics (:898) reads it back out of rocksdb_ptr->GetOptions().

An iterator holds it as a raw pointer. DBIter::statistics_ is a Statistics* (contrib/rocksdb/db/db_iter.h:474) initialized from ioptions.stats (db/db_iter.cc:58), which is statistics.get() (options/db_options.cc:811). ~DBIter dereferences it: RecordTick(statistics_, NO_ITERATOR_DELETED) at db_iter.h:157 and local_stats_.BumpGlobalStatistics(statistics_) at :159. That is the READ in the report, and the free side is ~DBImpl -> ~DBOptions -> ~SstFileManagerImpl -> ~DeleteScheduler dropping the last owning reference (delete_scheduler.h:195, set at db_impl_open.cc:2625).

rocksdb states the requirement directly: "The returned iterator should be deleted before this db is deleted" (contrib/rocksdb/include/rocksdb/db.h:1066). EmbeddedRocksDBSource owns the iterator (std::unique_ptr<rocksdb::Iterator>, StorageEmbeddedRocksDB.cpp:216) and destroys it with the pipeline, long after truncate replaced the handle. The lock that would have ordered the two, SharedLockGuard(storage.rocksdb_ptr_mx) in ReadFromEmbeddedRocksDB::initializePipeline (:744), is released at :751 while the iterator it produced lives on.

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 shared_ptr and have the source keep a reference, so replacing the handle in truncate cannot free it under a live iterator. The target exclusive lock then comes out of StorageAlias::truncate as you asked.

… 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.
@groeneai groeneai changed the title Fix use-after-free on TRUNCATE of a table behind ENGINE = Alias Fix use-after-free on TRUNCATE of an EmbeddedRocksDB table being read Aug 4, 2026
@groeneai

groeneai commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author
Internal second-model review: adjudication log (click to expand)

Pre-publication review by an independent model (engine: codex). This revision is a redesign after
@tavplubix's review, so it went through a fresh cold review plus a full re-gate rather than a delta
recheck: the production diff shares no lines with the alias-locking approach the earlier rounds
verified.

# Sev Finding Verdict Evidence / action
1 truncate waits for the last full-scan lease while holding rocksdb_ptr_mx exclusively; SharedMutex is writer-priority, so the wait fences every other user of the handle, and mutate re-takes that mutex shared from inside its own read pipeline while still holding the lease being waited for AGREE, fixed @ 7139cd7 Reproduced at stack level on the pre-fix build: TRUNCATE ... lock_acquire_timeout = 0 beside a full-scan ALTER ... DELETE wedged both permanently, mutate parked in SharedMutex::lock_shared and truncate in condition_variable::wait. The wait moved out of the exclusive section; the exclusive section now covers only the lease-count read, the Close, the wipe and the reopen, and the lock order is one-way
2 ⚠️ An in-diff comment stated as fact that holding the mutex across the wait is safe because the iterator never re-acquires it AGREE, fixed @ 7139cd7 True of the iterator and irrelevant: what deadlocks is the query that owns it. Comment replaced with the invariant that now holds, and the refuted wording is kept only in the internal record so the correction is auditable
3 ⚠️ No test covered a concurrent handle user during the wait, so nothing would redden for finding 1 AGREE, fixed @ 7139cd7 Every reader the test had takes no mutex at all. Added a cell that runs a lease-holding mutation beside a waiting TRUNCATE and asserts both complete; it uses a bounded timeout on purpose, so a regressed build fails the cell instead of hanging until the runner gives up
4 💡 The supplied CI report URL and the corresponding PR link are absent from the branch's commit messages DISAGREE Already present. Commit e1f9676 carries both verbatim (Report: with the full report URL, Discovered on: with the pull request), and it is an ancestor of the head being published. Confirmed with a positive and a negative control over the same grep
5 💡 truncate can be starved indefinitely with lock_acquire_timeout = 0 under a steady stream of overlapping scans DISAGREE That is what the setting asks for, and it matches the setting's meaning everywhere else it is read: RWLock maps a zero timeout to an unlimited deadline for lockForShare and lockExclusively too. Every bounded value terminates, and both outcomes are asserted
6 💡 The test costs about a minute per serial run DISAGREE The runtime is a floor set by the assertions: the readers have to outlast the truncates that wait for them, and the timeout cells have to reach their deadlines. The test is tagged long, which is what the runner's runtime budget checks

Severity: ❌ blocker / ⚠️ major / 💡 nit. DISAGREE verdicts carry recorded evidence and are
terminal per finding.

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);

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

groeneai commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

@tavplubix Option (a) is implemented and pushed. rocksdb_ptr is now a shared_ptr that the scan source keeps a reference to through a lease, so replacing the handle in truncate cannot free it under a live iterator, and StorageAlias::truncate no longer locks the target. TRUNCATE waits for outstanding scans within lock_acquire_timeout and raises TIMEOUT_EXCEEDED without touching the table if the budget runs out.

Ready for review whenever you have a moment.

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI finish ledger — df1d265

Every failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

Check / test Reason Owner / fixing PR
Stateless tests (arm_binary, parallel) / Bad cast from type DB::ColumnNullable to DB::ColumnString (STID 4350-5ca2) logical error in MergeTreeDataPartWriterOnDisk::initColumnsSubstreamsIfNeeded on the mutation path; trunk family (86 rows / 71 unrelated PRs / 12 master runs in 30d, first seen 2026-06-08) #112501 (external, open)
Stateless tests (arm_binary, parallel) / Server died collateral of the abort above — one event, two rows #112501 (external, open)
Build (arm_release) / Post Hooks build_profile_hook.py telemetry INSERT fails with LogCluster HTTP 500; non-gating, the enclosing build succeeded a fix task owns it (investigating at full effort — fixing-PR link to follow here)

Neither signature is caused by this PR's diff, which touches only
StorageEmbeddedRocksDB and one new stateless test: the abort is on the MergeTree
mutation write path and is reproduced on master and across 71 unrelated PRs.

Session id: cron:our-pr-ci-monitor:20260805-013000

@groeneai groeneai added the groeneai-origin-ci-master PR origin: master/nightly CI monitoring finding label Aug 19, 2026
@clickhouse-gh clickhouse-gh Bot added the comp-foreign-db Connectivity to external databases (ODBC/JDBC, MySQL, PostgreSQL, etc.). label Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors comp-foreign-db Connectivity to external databases (ODBC/JDBC, MySQL, PostgreSQL, etc.). groeneai-origin-ci-master PR origin: master/nightly CI monitoring finding pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants