Skip to content

Skip uncommitted parts in non-transactional TRUNCATE#109598

Merged
alexey-milovidov merged 6 commits into
ClickHouse:masterfrom
tuanpach:truncate-skip-uncommitted-parts
Jul 20, 2026
Merged

Skip uncommitted parts in non-transactional TRUNCATE#109598
alexey-milovidov merged 6 commits into
ClickHouse:masterfrom
tuanpach:truncate-skip-uncommitted-parts

Conversation

@tuanpach

@tuanpach tuanpach commented Jul 7, 2026

Copy link
Copy Markdown
Member

A non-transactional TRUNCATE covers all active parts, including a part whose creating transaction has not committed yet (for a non-transactional observer isVisible only checks removal_tid.isEmpty(), not creation_csn). Covering such a part set its removal_csn to NonTransactionalCSN while creation_csn was still unknown, which discarded a write that may still commit and tripped a LOGICAL_ERROR in VersionMetadata::validateInfo:

Logical error: 'Object ...|all_12_25_7, creation_csn is not set while removal_csn is set to 1'.

This changes the non-transactional TRUNCATE to only remove parts whose creation is already committed (visible at Tx::MaxCommittedCSN), leaving in-flight parts for their own transaction to commit or roll back. This is an alternative to #109537, which instead relaxes validateInfo to accept the state.

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

Fixed a LOGICAL_ERROR (creation_csn is not set while removal_csn is set to 1) that could be thrown when a non-transactional TRUNCATE ran concurrently with an uncommitted transaction that had inserted into the same table. Such a TRUNCATE no longer removes parts created by not-yet-committed transactions.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

A non-transactional TRUNCATE covers all active parts, including a part
whose creating transaction has not committed yet (for a non-transactional
observer `isVisible` only checks `removal_tid.isEmpty()`, not
`creation_csn`). Covering such a part set its `removal_csn` to
`NonTransactionalCSN` while `creation_csn` was still unknown, discarding a
write that may still commit and tripping a `LOGICAL_ERROR` in
`VersionMetadata::validateInfo`.

Filter the covered parts to only those already committed at
`Tx::MaxCommittedCSN`, leaving in-flight parts for their own transaction
to commit or roll back.
@clickhouse-gh

clickhouse-gh Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [2fddd21]

Summary:


AI Review

Summary

This PR makes non-transactional TRUNCATE skip parts whose creating transaction has not committed yet, and the strengthened regression test now proves that committed rows are still removed while the in-flight row survives. I do not think it is safe to approve as-is, because the same creation_csn == 0 / non-transactional removal invariant break is still reachable from the sibling destructive-DDL paths that share the same removal machinery.

Findings

❌ Blockers

  • [src/Storages/StorageMergeTree.cpp:2702-2716, src/Storages/StorageMergeTree.cpp:2812-2834, src/Storages/StorageMergeTree.cpp:3133-3135, src/Storages/StorageMergeTree.cpp:3300-3309, src/Storages/MergeTree/MergeTreeData.cpp:6193-6291, src/Storages/MergeTree/MergeTreeData.cpp:6113-6123, src/Interpreters/MergeTreeTransaction.cpp:105-141] [dismissed by author -- https://github.com/Skip uncommitted parts in non-transactional TRUNCATE #109598#discussion_r3534067439] The new filter only hardens TRUNCATE. DROP PART, DROP PARTITION, the non-transactional REPLACE PARTITION cleanup, and MOVE PARTITION TO TABLE source cleanup still gather the same unfiltered Active parts and feed them into the same non-transactional removal path, which immediately stores removal_csn = 1. An in-flight insert can therefore still reach creation_csn == 0 && removal_csn == 1, so those entry points can still discard a write that later commits or throw the same LOGICAL_ERROR. I still consider this real because the current code leaves every sibling caller and the shared removal helper unchanged. Suggested fix: apply the committed-only filter at the shared non-transactional destructive-DDL layer, or cover all sibling non-transactional removal entry points in this PR.
Final Verdict
  • Status: ❌ Block

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jul 7, 2026
Comment thread src/Storages/StorageMergeTree.cpp Outdated

/// Do not remove parts whose creating transaction has not committed yet:
/// removing such a part would discard a write that may still commit.
std::erase_if(parts, [](const auto & part) { return !part->version->isVisible(Tx::MaxCommittedCSN, Tx::EmptyTID); });

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.

This only hardens TRUNCATE, but the same invalid state is still reachable from the other non-transactional removal paths. dropPart (src/Storages/StorageMergeTree.cpp:2686-2707), dropPartition (src/Storages/StorageMergeTree.cpp:2796-2827), movePartitionToTable source cleanup (src/Storages/StorageMergeTree.cpp:3265-3286), and the non-transactional REPLACE PARTITION cleanup (src/Storages/StorageMergeTree.cpp:3118-3119 via src/Storages/MergeTree/MergeTreeData.cpp:5812-5910) still feed unfiltered Active parts into the same removal machinery. That machinery writes removal_csn = NonTransactionalCSN immediately for txn == nullptr (src/Interpreters/MergeTreeTransaction.cpp:137-142), so an in-flight insert can still end up as creation_csn == 0 && removal_csn == 1 and trip VersionMetadata::validateInfo.

I think this needs to be fixed at a shared "committed parts only for non-transactional destructive DDL" layer rather than patching only this TRUNCATE call site.

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.

The finding is correct — dropPart, dropPartition, the REPLACE PARTITION cleanup via removePartsInRangeFromWorkingSet, and the MOVE PARTITION TO TABLE source cleanup can reach the same creation_csn == 0 && removal_csn == 1 state.

The narrow scope here is intentional: this PR is an alternative to #109537. #109537 relaxes validateInfo to accept that state, fixing all non-transactional destructive-DDL paths at the shared layer but weakening the invariant. This PR takes the opposite trade-off — keep the invariant and stop feeding uncommitted parts into non-transactional destructive DDL, starting with the TRUNCATE reproducer. Whether to extend the call-site filter to the sibling paths or adopt the shared-layer approach of #109537 is a design choice between the two PRs, so mixing both into one change is out of scope here.

When transactions are not in use, every part is already committed and the
filter is a no-op that still locks each part's version mutex. Skip it using
the per-table `transactions_enabled` flag, mirroring the existing gate for
beginning a transaction on merges.
Comment thread tests/queries/0_stateless/04408_truncate_skips_uncommitted_part.sh
Comment thread src/Storages/StorageMergeTree.cpp
@alexey-milovidov alexey-milovidov self-assigned this Jul 7, 2026
alexey-milovidov and others added 3 commits July 7, 2026 23:50
…moved

Address review feedback on the regression test: the previous version only
exercised `TRUNCATE` on an otherwise empty table, so it would also pass if the
new `transactions_enabled` branch accidentally turned `TRUNCATE` into a no-op.

Each case now seeds an already-committed row before `TRUNCATE` and asserts that
it disappears while the in-flight (uncommitted) part is skipped. This proves the
advertised contract ("skip uncommitted parts") without weakening ordinary
`TRUNCATE` semantics: if `TRUNCATE` removed nothing, the committed row would
survive and the assertions would fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member

The only CI failure on the latest run, Stateless tests (amd_tsan, flaky check) → "Start ClickHouse Server", is unrelated to this PR. The server aborted at startup on a pre-existing TSan data race in DB::DynamicDelay::rotateToMin (src/Common/DynamicDelay.cpp:69), triggered from DiskLocalCheckThread::startup during disk initialization — this PR only touches StorageMergeTree::truncate and a stateless test. The race is already fixed by #110783 (approved, pending merge); once that lands, a rerun or master merge will clear this red.

@clickhouse-gh

clickhouse-gh Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.20% 86.20% +0.00%
Functions 92.10% 92.10% +0.00%
Branches 78.30% 78.20% -0.10%

Changed lines: Changed C/C++ lines covered: 5/5 (100.00%) · Uncovered code

Full report · Diff report

@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Jul 20, 2026
Merged via the queue into ClickHouse:master with commit 538d53c Jul 20, 2026
178 checks passed
@robot-ch-test-poll3 robot-ch-test-poll3 added the pr-synced-to-cloud The PR is synced to the cloud repo label Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-bugfix Pull request with bugfix, not backported by default pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants