Skip to content

[experimental] CAS (Content-addressed storage) over shared object storage for antalya-26.6 - #2159

Merged
mkmkme merged 60 commits into
antalya-26.6from
feature/antalya-26.6/CAS
Aug 26, 2026
Merged

[experimental] CAS (Content-addressed storage) over shared object storage for antalya-26.6#2159
mkmkme merged 60 commits into
antalya-26.6from
feature/antalya-26.6/CAS

Conversation

@filimonov

@filimonov filimonov commented Aug 4, 2026

Copy link
Copy Markdown
Member

What this is

A new opt-in metadata storage type cas for object-storage disks: MergeTree parts are stored
as content-addressed blobs in a shared S3/GCS bucket pool, deduplicated by content hash, with
part manifests and named refs on top — "git for MergeTree". Multiple servers mount the same
pool and share identical data blocks without re-uploading them; same-pool replication fetches
publish a local ref over the shared blobs instead of copying bytes (fetch-by-relink). Garbage
collection runs as a lease-coordinated background round with full audit trail; integrity is
checkable online with SYSTEM CAS FSCK and the clickhouse-disks cas-* applets.

User-facing surface: the cas disk/metadata-storage type, SYSTEM CAS GC RUN / GC REBUILD / FSCK / DROP POOL MEMBER, system tables system.cas_log, system.cas_gc_log,
system.cas_mounts, and the clickhouse-disks commands cas-inspect, cas-fsck,
cas-gc-dryrun, cas-gc-rebuild, cas-drop-member. Full documentation ships in this PR under
docs/en/antalya/cas/ (architecture and operations), plus the system-table pages.

How the series is structured

29 commits, ordered for review; the subsystem lands dark and is switched on by one registration
line.

  1. Upstream fixes (11 commits) — standalone correctness fixes to shared code, each
    reviewable on its own: ReadBufferFromFileView position tracking, ReadBufferFromS3
    retry-after-cancel, ThreadGroup parent lifetime, MergeTreeDeduplicationLog fail-closed
    null-writer, S3 conditional writes + 412 no-retry policy, Expect: 100-continue for large
    conditional uploads, GCS conditional-write dialect and GOOG4 signer, LocalObjectStorage
    snapshot listing semantics, the disk-transaction contract (one logical part = one
    transaction, read-your-writes), SYSTEM-on-proxy unwrap, and clickhouse-disks non-zero exit
    code for failed non-interactive commands.
  2. The CAS subsystem (8 commits) — a layered library under
    src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/, bottom-up:
    Primitives → Formats → Backend → Pool → Parts → Gc → Tools. No layer is reachable until
    the wiring lands.
  3. Integration (4 commits) — the metadata storage and disk transaction, capability
    predicates, the registration line, system logs and tables, SYSTEM commands, registries and
    entry points, fetch-by-relink in DataPartsExchange.
  4. Tests (3 commits) — the gtest battery (134 files), stateless and integration suites.
  5. CI wiring and docs (3 commits) — CAS-default test lanes, tags for tests that cannot run
    on CAS, and the documentation set.

Behavior changes to shared code (reviewers, look here)

Everything CAS-specific is gated behind the cas disk type; a server without a cas disk gets
identical behavior except for these deliberate fixes:

  • clickhouse-disks --query now exits non-zero when a command fails (previously always 0);
    interactive sessions unaffected. This exposed and fixes two latent test defects
    (test_disks_app_func path typo, test_replicated_database reading metadata_path after
    DETACH).
  • HTTP 412 on conditional S3 requests is never retried (deterministic failure; also benefits
    pre-existing Iceberg conditional writes).
  • The disk-transaction contract commit routes projection sub-parts through the parent
    whole-part transaction and makes staged state readable before commit — behavior-preserving on
    local disks, stricter on object-storage metadata.
  • ReadBufferFromFileView position fix also affects the pre-existing packed skip-index reader.

Verification

  • Per-layer gtest battery (src/Disks/tests/, 134 files) plus stateless and integration
    suites; CI runs a CAS-default-disk lane over the general stateless set.
  • Long-running soak/chaos harness (multi-node shared pool, fault injection, fsck-verified
    invariants) has been green on this tree; the harness itself is intentionally not part of this
    PR.
  • The branch is a clean reconstruction of the development tree: the diff between this series
    and the development branch, restricted to shipped paths, is empty by construction.

Developed with AI assistance (Claude); every commit carries Co-Authored-By and
Signed-off-by.

Changelog category (leave one):

  • Experimental Feature

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

Added experimental content-addressed storage (CAS) for MergeTree: a new cas metadata storage
type for object-storage disks that stores parts as content-hash-deduplicated blobs in a shared
S3/GCS pool with manifests and refs, allowing multiple servers to share identical data without
re-uploading it, replicate same-pool parts without transferring bytes, and reclaim unreferenced
data with lease-coordinated garbage collection. Includes SYSTEM CAS GC RUN / GC REBUILD / FSCK / DROP POOL MEMBER, system tables system.cas_log, system.cas_gc_log,
system.cas_mounts, and clickhouse-disks commands cas-inspect, cas-fsck, cas-gc-dryrun,
cas-gc-rebuild, cas-drop-member.

Documentation entry for user-facing changes

Documentation is included in this pull request: docs/en/antalya/cas/ (architecture and
operations), docs/en/operations/storing-data.md, docs/en/sql-reference/statements/system.md,
and the three system-table pages.

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful tests
  • Performance tests
  • Aarch64 tests
  • All with ASAN
  • All with TSAN
  • All with MSAN
  • All with UBSAN
  • All with Coverage
  • All Regression
  • Disable CI Cache

Regression jobs to run:

  • Fast suites (mostly <1h)
  • Aggregate Functions (2h)
  • Alter (1.5h)
  • Benchmark (30m)
  • ClickHouse Keeper (1h)
  • Iceberg (2h)
  • LDAP (1h)
  • OAuth (5m)
  • Parquet (1.5h)
  • RBAC (1.5h)
  • SSL Server (1h)
  • S3 (2h)
  • S3 Export (2h)
  • Swarms (30m)
  • Tiered Storage (2h)

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Workflow [PR], commit [ceee42c]

filimonov and others added 25 commits August 5, 2026 00:07
ReadBufferFromFileView assumed the inner buffer keeps its working buffer
across setReadUntilPosition; ReadBufferFromS3 resets it, so getPosition
lied and seek logic could re-read a stale decompressed block. Recompute
the offset from the inner buffer after every right-bound change.
Includes the ReadBufferFromMemory counterpart and gtest batteries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
processException kept retrying transient errors after KILL QUERY; check
CurrentThread::get().isQueryCanceled() in the outer retry loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
A borrowed child ThreadGroup parents its trackers at the parent group via
raw pointers; background work outliving the query produced a use-after-free
in parent counters. The child now holds a shared_ptr to the parent group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
addPart/dropPart guarded current_writer only with chassert (a release
no-op), so a missing writer meant a null dereference; throw LOGICAL_ERROR
instead. Also treats a missing logs_dir as normal for storages that do
not materialize empty directories (carries a CAS-related hunk; wired later).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
HTTP 412 on a conditional request is deterministic: never retry it
(S3Exception::isPreconditionFailed, RetryStrategy). Adds conditional
PUT/COPY (If-Match / If-None-Match) through the client, WriteBufferFromS3
and copyS3File, and the token-conditional operations on S3ObjectStorage.
Also carries the copyS3File message_format_string fix (PreformattedMessage
instead of a preformatted string) and CAS-facing write-settings plumbing
(wired later).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
A conditional PUT that is doomed to 412 should not stream its whole body;
send Expect: 100-continue above a size threshold and peek the response.
Prevents mid-upload connection resets and retry storms on S3-compatible
stores. Carries the GCS dialect integration points (wired later).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
GCS XML API needs native GOOG4 signing and x-goog-if-generation-match for
generation-safe conditional writes; AWS SigV4 If-Match semantics are not
enough, and conditional multipart complete is silently ignored. Adds the
signer, the header dialect, and fixed-vector tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Emulate object-store behavior under concurrent removal: a file vanishing
between listing and stat is skipped, not a filesystem_error; non-recursive
explicit-stack walk with error_code overloads and a symlink guard; fail
closed on an embedded-NUL path (AST fuzzer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Projection sub-parts ride the parent whole-part transaction instead of
committing early; read-your-writes (in-flight resolve) becomes part of the
IDiskTransaction contract so staged state is visible before commit;
clone/freeze/restore wrap the whole part in one transaction; staged
operation order is explicit. Mixed-file note: these files also carry the
content-addressed capability surface and eager-dispatch branches that are
wired by the later CAS integration commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Single-table SYSTEM commands (SYNC/RESTORE/RESTART/DROP REPLICA, WAIT
LOADING PARTS, PREWARM, ...) cast the storage directly and missed tables
behind a proxy; unwrap the proxy before the cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
clickhouse-disks --query always exited 0 and reported failures only on
stderr, so scripts, cron jobs and CI could not gate on it at all. Record
each command's error code in processQueryText and return it as the
process exit code for non-interactive runs; interactive REPL sessions
keep exiting 0. Within one semicolon-separated batch a later success does
not clear an earlier failure.

Carries the two test fixes the contract exposed: a 2024 typo in
test_disks_app_func ("d/a" instead of "a/d/a" always failed inside the
tool and was swallowed), and test_replicated_table_structure_alter
reading metadata_path from system.tables after DETACH DATABASE (empty
path, the remove never ran, the recovery scenario was never exercised).
Mixed-file note: DisksApp.cpp also carries the cas-* command registration
and CA pool initialization, wired by the later CAS integration commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Core value types of the content-addressed storage subsystem: identifiers,
hashes, tokens, namespaces. No dependencies on other CAS layers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
On-wire/on-disk encodings: manifests, ref-log records, GC state, seals,
codecs. Depends only on Primitives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Object-storage access layer: the backend interface, the object-storage
adapter, the in-memory backend for tests, capability probing and request
control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Pool identity and runtime: server root, mount lifecycle, pool metadata,
the ref ledger and ref protocol (publish/confirm, recovery, snapshots),
the part-write transaction (dedup gate, conditional create, promote),
staging and plain objects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Part-path parsing and the part-folder access facade over manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The garbage-collection round: fold, in-degree settlement, lease and
heartbeat, prune, baseline rebuild, ack-floor fencing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
fsck (integrity checking), inspect, and pool-member decommission.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The content-addressed metadata storage, its disk transaction and part
staging (the top-level subsystem glue), registration of the
content_addressed metadata storage type, capability predicates on
IDisk/DiskObjectStorage/IMetadataStorage, the conditional-object-storage
API on IObjectStorage, the FileView read-pipeline stage, write-ETag
surfacing, cache-over-CA, and the atomic-file-write short-circuit for
txn_version.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The CAS system logs (definitions, SystemLog registration, Context
getters), the CAS mounts system table, and the per-disk GC-health
asynchronous metrics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
SYSTEM CONTENT ADDRESSED GARBAGE COLLECTION / GC REBUILD / DROP POOL
MEMBER: grammar, AST, interpreter handlers, access checks, and the parser
round-trip test. Includes the magic_enum range widening required once
ASTSystemQuery::Type outgrew the default range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
ProfileEvents/CurrentMetrics/AccessType/FailPoint/ServerSettings entries,
build wiring, the fetch-by-relink replication protocol extension in
DataPartsExchange (same-pool fetch publishes a local ref over shared
blobs; gated by pool_uuid, non-CA fetches unchanged), the clickhouse-disks
CA commands (inspect, fsck, gc-dryrun, gc-rebuild, drop-member), and
server/local entry-point wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The full CAS gtest battery: per-layer unit tests, protocol state-machine
tests, wiring/assembly tests, and the shared test helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
CarlosFelipeOR and others added 16 commits August 25, 2026 15:10
…ection stays cheaper

The test did not pin `index_granularity`, so the projection was not reliably
cheaper than the base table and the test flapped in CI.

CI: https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2073
PR: #2073

Squashed from: 95ba719

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The "proven absent" presence probe and the cold-reader admission both required
two whole-catalog reads to be byte-identical before trusting an observation.
The catalog is pool-global, so under a parallel workload the condition almost
never holds and both paths starve: in CI, `01069_database_memory` failed 193 of
194 retries with "catalog changed while probing table-root cleanup
completeness" on its `deduplication_logs` namespace.

Each catalog read is one token-CAS'd full value, so this namespace's row is what
the proof actually needs:
- presence probe: row absent in BOTH reads => proven absent, linearized at the
  second read; a row that appears in between answers present;
- cold reader: revalidate THIS row by value; unrelated churn admits, an own-row
  change still forces a fresh observation. The second cut now runs the ambiguity
  validation explicitly -- an aliasing incarnation admitted between the reads
  left the target row byte-identical and was only refused by the whole-catalog
  comparison it replaces.

Two tests pinning whole-catalog stillness updated to the per-row contract;
regression tests added for unrelated-churn starvation and the aliasing
incarnation.

PR: #2073

Squashed from: 684161d

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The `encrypted` wrapper over a content-addressed disk fails at the first
`INSERT`, not at `CREATE`, so it is recorded as a known limitation rather than
as an unsupported configuration. The `cas_log` note mirrors the existing
`cas_gc_log` config-enablement wording.

Closes: #2213

Squashed from: e1af0b0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
…uthentication

GCS generation handling was wired to authentication rather than to the caller:
`ClientFactory::create` forced the GCS conditional dialect for both `gcp_oauth`
and `gcs_hmac`, `PocoHTTPClientGCPOAuth::makeRequestInternal` applied the
adapter to every OAuth request, and the shared response path substituted
`x-goog-generation` into the AWS SDK ETag field whenever the header was present.

That is a concrete regression for existing non-CAS `gcp_oauth` users, not a
hypothetical risk. `HEAD` and `GET` responses yielded a numeric generation
through the SDK ETag field, while `LIST` parses its ETag from the XML response
body and kept an ordinary ETag. ClickHouse exposes that value through the
`_etag` virtual column and uses it in the filesystem-cache, page-cache and
Parquet-metadata-cache keys, so one object acquired inconsistent user-visible
`_etag` values and duplicate cache entries depending on whether its metadata
came from `LIST` or from `HEAD`.

Generation semantics become a property of the request, not of the client. A
typed `NativeConditional` field travels on the AWS request wrapper and the Poco
HTTP request, re-derived on every SDK attempt, and CAS marks only its own
token-producing and token-consuming calls: create-if-absent metadata and control
artifacts, conditional replacement, native-token `HEAD`, exact-token `DELETE`
and conditional copy. The field cannot become a header, enter a signature or
appear on the network, so there is no generation client matrix, no additional
OAuth token cache or refresh timeline, and no storage-wide setting.

All three authentication paths keep their contracts and their names: ordinary S3
interoperability with `access_key_id`/`secret_access_key` and AWS SigV4,
`gcp_oauth`, and `http_client=gcs_hmac`. GOOG4 signing gets an explicit
per-operation header allowlist in which every allowed header has a documented
disposition -- translate, preserve, or consume -- instead of a generic prefix
rename; an unsupported remaining `x-amz-*` header raises an exception rather
than being guessed or sent in a mixed-prefix request. Unsupported combinations
fail closed at CAS mount or before the mutation, and GCS CAS never falls back
from generations to ordinary ETags. The conditional single-`PUT` cap is renamed
to `gcs_max_conditional_put_bytes`.

Also: transport quoting is stripped when minting a generation token; a token a
successful `HEAD` did not carry is refused; a token-dialect flip on disk reload
is refused, checked against the effective settings; and S3-native staging is
refused on a generation-token backend.

Coverage: `test_storage_gcp_auth` proves that a `LIST`-derived and a
`HEAD`-derived read of the same object produce the same `_etag` and the same
filesystem-cache, page-cache and Parquet-metadata-cache inputs, with the
generation in none of them -- written to fail against the old blanket override.
`gtest_goog4_signer` and `gtest_aws_s3_client` pin signer and client behavior,
`tests/integration/test_cas_gcs` is a deterministic GCS mock that refuses what
it does not model, and `tests/integration/test_gcs_live` is an opt-in release
gate for the wire shapes unit tests cannot prove.

Squashed from: 9601ac3 378472f 5d7f262 9b887ac cf06f81
faab667 b4f34cf 7524e13 4c1916e 1c91a16 8562e4c
10e97f9 c5a0672 66cf66d b85461a debf1d2 b4b27a0
c0517ff 80977a7 b579691 975fe26 07cc447 dabf9f5
af801c3 55f9d5f 576e551 81b7682 2ca5677 1c21fca
ea0a051 c375be6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The `FREEZE` shadow namespace was pool-global, so two servers sharing a pool
wrote into one another's snapshots and `UNFREEZE` on one could reach the other's.
Shadow content is now ordinary server-relative content, scoped under
`server_root_id`, at all six sites that construct it.

`05024_cas_freeze_two_roots` pins cross-root `UNFREEZE` isolation on a
content-addressed disk.

Closes: #2212

Squashed from: 8e5ee61 11f5397 7c4d412 080a1a6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
… the cross-disk path

`freezeRemote` had no content-addressed transaction branch, so
`ATTACH PARTITION FROM` a local disk into a CAS disk failed. The cross-disk path
now clones a part into a content-addressed disk in one transaction.

`05025_cas_attach_partition_cross_disk` pins the previously-failing case.

Closes: #2173

Squashed from: c5467b8 cfe9a6a f26f322 3a1cb53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
…le pool

A newline in a part-file path made the manifest record line unparseable, and the
orphan sweep then refused to make progress -- so a single undecodable manifest
wedged reclamation pool-wide. Reproduced.

Two halves. The manifest entry path now goes through one escaper in both the
record line and the banner, so encoding and decoding are symmetric: the defect
was the asymmetry, not the path. And the orphan sweep skips and records an
undecodable manifest instead of aborting the round, so one bad object cannot
stop the pool.

`05026_cas_manifest_path_newline`, plus `gtest_cas_part_manifest_format`,
`gtest_cas_orphan_nomination` and `gtest_cas_sweep_deletion_premise`. The
documented orphan-sweep safety protocol is corrected to match.

Related: #2031

Squashed from: 6333a98 f738450 dbb4e9c
(`a628cf09ae3` is the same patch as `6333a986af6` committed on the
`cas-unconditional-blob-publication` line and unified by merge `2d39604d584`;
it carries no additional content and is deliberately not replayed.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Two motivations: a hard size limit on GCS, and the removal of a conditional lane
that turned out to be unnecessary.

A blob larger than 1 GiB could not be created on GCS at all. Conditional create
was required to return an incarnation token; token-producing writes are marked
`NativeConditional`, forced into a single `PUT`, and bounded by the conditional
`PUT` cap, because GCS does not enforce preconditions on multipart completion --
so the code failed before multipart rather than silently lose the condition. The
cap is not a blob-format limit: raising it only increases the amount each
concurrent single-part upload buffers and moves the failure. The same
token-producing restriction also reached the otherwise-unconditional resurrect
path.

The result token was an accident. `BlobDepRecord::token.has_value` only
distinguishes an uploaded or observed blob from manifest-trusted evidence; the
token value was never a promotion precondition. Dropping it removes, after an
exhaustive caller audit, `Backend::putIfAbsentStream`, `Backend::promoteStaged`,
`Backend::resurrect`, `CasRequestController::conditionalCreateControlled`,
`CasRefLedger::stagingConditionalCreate`, `Pool::stagingConditionalCreate`,
`IObjectStorage::copyObjectConditional` with the S3 copy plumbing added solely
for it, and the conditional-copy mount probe with its `conditional_copy_supported`
state -- shrinking the fork surface in generic object-storage and S3 code.

Every CAS writer now uses one protocol, independent of provider and source: make
the precommit durable, `HEAD` the blob, adopt a present non-condemned body,
otherwise publish the writer's own payload unconditionally under a fresh
envelope, reconcile the per-blob freshness metadata to `Clean`, and record an
explicit materialization proof. Concurrent writers may both publish the same key
after racing `HEAD` misses; this is accepted, because the key fixes the logical
payload, durable references name the content hash rather than an incarnation
token, and a fresh envelope protects the winner from exact-token deletes already
queued for a condemned predecessor. Large blobs stay streaming and
multipart-capable. The request-count and latency cost of the mandatory `HEAD` was
measured and explicitly accepted.

Conditional semantics are unchanged for manifests, ref-log and control objects,
leases, checkpoints, the registry, GC state, freshness metadata and exact-token
deletion; the cap survives for writes that actually carry a condition. No
durable format changes: blob key, envelope, manifest, ref-log and pool formats
are untouched.

Squashed from: bfb2916 cd4e835 fe80d15 1dcc0f8 7852e64
5147dc4 907c3b5 e6bd0b5 7559364 2f65aaa 57e4a13
12079ee c062fca 02a67bb f8b6e8a ee1d68a ba072e6
2c5a07f 2551ec2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
…ive GC meta-jobs ownership

Mount-lease renewal and remount retried asymmetrically: a transient failure
could lose the lease with no bounded recovery, and a renewal racing a worker
start or a keeper replacement had no single owner.

Mount leases gain a required durable `write_attempt_id`. This is a recreate-only
format change: the pool format moves to generation 10 and generation-9 pools are
rejected at the reader floor, because a missing attempt identity makes ambiguous
writes unresolvable. Zero attempt identifiers are rejected. Overwrite retries are
bounded by absolute deadlines rather than attempt counts.

Renewal ownership is centralized: ownership races are closed, workers terminate
on lifecycle loss, terminal publication is serialized and exception-safe, and a
refused snapshot publication backs off. Recovery is exposed through
`ProfileEvents` and `cas_log`, non-interfering and correctly accounted, with
diagnostic failures isolated from the operation they observe.

Two further defects, both first-class: `~Gc` did not drain `meta_pool`, so a
meta-pool job could outlive the state it referenced. Every meta-pool job now
takes shared ownership of what it touches, the pool is drained on every throwing
round exit, and that cleanup is nonthrowing while still propagating genuine
pool and framework exceptions on the success path. And
`MountLeaseKeeper::claim` raised `LOGICAL_ERROR` for ordinary environment
changes: claim-time slot races and foreign-owner conflicts now raise `ABORTED`,
`MountFencedException` is preserved for GC fencing, and every adoption window is
covered.

Adds `tests/integration/test_cas_mount_renewal_retry`.

Closes: #2244

Squashed from: e8f7ce9 159e1ab bde036b 6323532 b6a0baf
ecf3d5d ea81034 8fcd289 948eed7 9b4c4ca 4ee9b69
d27ce8c 2f05d24 71a93fa 90bcbc6 cf1db18 f158974
80601d3 28b3887 9abc55b be5e92b 1ffe590 c1c66cb
7a376f1 145389b e430917 98e4968 57241b3 a77434d
683b686

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Detached CAS background work outlived `Context`, so a shutdown could dereference
a released `Context` or leave work running past the release of the pool that
owned it.

All detached CAS work now goes through one tracked dispatcher. Shutdown stops
admission, interrupts ref-table recovery, stops recovery before follow-up
requests, and drains before the pool is released. Every teardown phase is
fail-soft, so no phase can terminate the process. CAS event sinks resolve a weak
`Context` per event and are null-safe. A diagnostic-dispatch failure can no
longer replace the caller's fail-closed exception.

Verified on the final revision: Debug `CAS*` 2170/2170 and ASan `CAS*` 2169/2169
with zero sanitizer reports, plus a real CAS-backed server shutdown after an
approximately 18 MiB insert -- `SIGTERM=0`, wait status 0, no remaining process
and no detached background-task timeout or fatal teardown diagnostics.

Squashed from: e69b4d3 b0f66ff b565a5d 55d4e39 5bb93cb
6b0db04 4b04b2c c35c400 d7a02b4 e51affc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The RustFS test backend was pinned to a beta image. It moves to rc3.

Squashed from: 3c18dcc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
A CAS disk block is shared with the object storage, the generic disk layer and
the proxy resolver, and the CAS settings were the only consumer in it that
scanned every key and rejected whatever was not in a hand-written skip-list. Any
legal key nobody had enumerated failed server startup: all `s3_*` request
settings, most S3 client settings, repeated `<header>` elements, `<proxy>`, and
every Azure key, so a CAS disk over Azure could not be configured at all. This
is also what blocked the `http_keep_alive_timeout` mitigation suggested in
#2243.

The CAS settings now live under a `cas_` config-key prefix and nothing else in
the block is read, so the skip-list is gone with nothing in its place. A
mis-spelled CAS setting is still rejected, because that check is now made over a
namespace CAS actually owns.

The unprefixed spelling is accepted for a bounded period, since configurations
using it already exist in external CI/CD scripts; each disk reports its
superseded keys once. A key written in both spellings, or twice in one spelling,
is rejected rather than resolved silently. `skip_access_check` keeps its bare
spelling and stops being a CAS setting: the generic disk layer reads the same
key, and one key must not become two. Settings are validated before being
applied and are loaded transactionally.

`gcs_max_conditional_put_bytes` moves out of CAS into `S3AuthSettings`: it is a
property of the GCS conditional-write dialect, not a CAS policy, and what CAS
owns -- that such a write must not go multipart -- is already a per-write flag.
`WriteSettings::s3_single_part_upload_max_bytes_override` had exactly one
producer and disappears with it.

Externally configured CAS settings are reported with their `cas_*` names in
validation, parser failures, staging capability checks and mount-recovery
guidance; the original prefixed spelling is preserved for unknown keys while
internal setting names and persisted fields stay unchanged.

The integration CAS disk now carries `http_keep_alive_timeout` and five more
keys of the same class, so the lane fails if the disk block ever stops accepting
them.

Closes: #2243
Related: #2031

Squashed from: 1dfd7f6 4810645 3ed7f5f aae83c1 9fad804
71401fc 2671092 07d7416 d1f8fe9 3522179 a923b98
65fcfce 5ac95b8 7eb7a7e 33aa718

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Upstream `7b89d9e0786` (ClickHouse ClickHouse#111483, "Restrict local object access only
for user files path") reworked blocks that content-addressed work had commented,
and two branches merged it independently four days apart. Both resolutions kept
the same code; only the comment text and one `#include` position came out
different.

Reconciled here so that comparing the two branches stops reporting differences
that are not differences. No code changes: `MergeTask.cpp` and
`IMergeTreeDataPart.cpp` differ only in the wording of the projection
whole-part-transaction comment, `DataPartStorageOnDiskBase.cpp` in one sentence
about the arena scope, and `MergeTreeData.cpp` only in where `IO/copyData.h`
sits in the include list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Four independent defects, all of them in test or harness code, none in the
product.

**The mount-renewal integration test could not start at all.** Its appended
compose file bind-mounted the fault-injecting proxy from `utils/ca-soak/proxy/`,
a soak-harness path that is deliberately not part of this branch. The mount
resolved to nothing, so `python3 /proxy/s3_fault_proxy.py` failed immediately,
the `s3proxy` healthcheck never passed, `node` never started behind its
`condition: service_healthy`, and `cluster.start()` threw in the module fixture.
The test is now self-contained: the proxy script lives beside it and the bind is
relative to the test directory. `utils/ca-soak/proxy/` keeps its own copy for the
soak harness.

**The `FREEZE` and cross-disk `ATTACH PARTITION FROM` tests were not
re-runnable.** `05024_cas_freeze_two_roots` and
`05025_cas_attach_partition_cross_disk` left state that made a second run in the
same server fail, so a retry could not distinguish a real regression from
leftovers.

**CAS gtest fixtures leaked a parked logger channel.** The fixtures parked a
Poco logger channel without holding a real reference to it, so the channel could
be released while still installed -- an AutoPtr steal that ASan reports as a
use-after-free. Five fixtures now hold a genuine reference.

**The exit tests forked a multithreaded sanitizer-instrumented runner.** The tsan
unit-test job failed `CASShutdownExitTest.EmitAfterResetSharedContextExitsCleanly`:
the default "fast" death-test style forks the test runner, and the exit test then
builds a real `LocalObjectStorage`-backed storage inside that child, where a
filesystem call failed spuriously (`weakly_canonical: Invalid argument` from the
bootstrap's residual LIST) and the fail-closed refusal escaped the death
statement. These tests now use the "threadsafe" style, which re-executes the
binary so the child never inherits the runner's threads -- the same approach as
`gtest_backup_info.cpp`. This removes the whole hazard class rather than one
symptom: a forked child of a threaded process can also hang outright if it
inherits a held lock, which is what a `std::exit` in such a child did before
`d7a02b45f23` replaced it with `std::_Exit`.

`makeLocalObjectStorageForTest` also stops discarding the error code when it
cannot clear or create its scratch root, so a broken fixture says so instead of
letting the storage layer report something bewildering later.

Verified under ASan: the `CAS*` battery passes (2186 tests, exit 0) with the exit
tests going through the re-exec path. The tsan lane was not reproduced locally
(no fresh tsan build); the next CI push measures it.

CI: https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2073&sha=5828352eddfa33a42915e26314999c358b84db95&name_0=PR
PR: #2073

Squashed from: 0e623d2 d2a0599 81642c8
(plus the compose bind-mount repair, authored here: the soak-harness path it
referenced does not exist on this branch.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
The renewal worker's admission gate checked only the driver state
(explicit park, keeper in `RenewalTerminal`), never that the keeper it
was about to drive was `Active`. After an external lease loss the
remount path replaces the keeper, and a worker that re-admitted around
that window called `renew` on a keeper outside `Active`.
`MountLeaseKeeper::renew` classifies that as `LOGICAL_ERROR`, the worker
call path had no handler, and an exception escaping a thread terminates
the process: `CASPoolRemount.ExternalLossDuringRenewalUsesOneRecoveryGeneration`
dumped core twice in a row in the Debug gate with exactly this stack
(`renewalLoop` -> `renewKeeperOnce` -> `renew`, "renew is allowed only
in Active state").

The worker now admits a renewal only when `renewalWorkerMayRenew` holds
under `driver_mutex`: it exclusively owns the driver (`WorkerIdle`), a
keeper is installed, and that keeper is `Active`. The park test and the
wake predicate share this one definition, so they cannot drift apart.
The check is sound against concurrent transitions because every keeper
transition is mutually exclusive with an admitted worker call:
`installKeeper`, `keeperReset` and `startKeeper` require `Dormant` or
`Parked` driver ownership under the same mutex, and `release` runs only
after `stopBackgroundWorkers` has joined the worker. Parking instead of
the old fall-through also removes a latent null dereference on
`mount_keeper` when no keeper is installed. Wake-up is guaranteed: the
only path from `New` to `Active` is the startup renewal, whose
`DriverLease::finish` notifies `driver_cv`.

If `renew` still reports a state violation, the worker no longer takes
the process down: it trips the mount fence (write admission is bounded
by the fence deadline, not by this thread's existence), logs, asserts in
debug builds, and exits the loop.

Both `LOGICAL_ERROR` guards in `MountLeaseKeeper` now print the observed
keeper state.

Verification: Debug gate `CAS*` 2187/2187 with the fix, including the
previously crashing test; the `CASPoolRemount` suite repeated 207
times in one process with no failure. The only observable side effect is
one extra idle global-pool thread in the unit-test binary (a CAS worker
lifetime now overlaps one more concurrent thread before its join), which
shifts the pre-existing gtest fork-with-threads warning from 20 to 21
threads; thread names in /proc confirm no thread stays parked in
`renewalLoop`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 6d9709ea3f6d5ad6fc706c8589fc714b72fd0267)
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
@mkmkme

mkmkme commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

@blau-ai

@blau-ai

blau-ai commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

CI triage for ceee42c (feature/antalya-26.6/CAS)

Reviewed the praktika reports + TestFlows regression reports for the latest head. There is a lot of red, but almost all of it is noise — I found one clearly PR-caused product bug, plus the CAS-specific regression lanes that need your eyes. Everything else is pre-existing on the (also-red) base, infra/Spark, an image CVE, or CI-confirmed flakiness.

Verdict

  • PR-caused (real): 02265_column_ttl — reproducible CAS relink failure. ← the one to fix.
  • PR-related (CAS lanes, need your review): the cas_* / cas_s3_cache_* TestFlows regression suites.
  • Not PR-caused: all Iceberg integration failures, the non-CAS regression suites, the Grype scan, the arm_tsan stress "Unknown error", and ~6 one-off stateless failures (CI's own reruns pass).

1. PR-caused — 02265_column_ttl (Stateless, cas s3 storage lanes)

Fails on two independent lanes (amd_asan_ubsan, cas s3 storage 1/2 and arm_binary, cas s3 storage), and CI's randomized-settings rerun makes it reproducible, not flaky: 6/7 and 13/14 reruns fail. The exception:

Code: 210. DB::Exception: Source localhost did not prove it still holds the manifest
it offered for part 20100101_0_0_1 by relink; the relink is abandoned and the fetch
will be retried later. (NETWORK_ERROR)
  5. DataPartsExchange::Fetcher::relinkPartToDisk (DataPartsExchange.cpp:1549)
  6. DataPartsExchange::Fetcher::fetchSelectedPart
  7. StorageReplicatedMergeTree::fetchPart
  9. StorageReplicatedMergeTree::executeFetch

This is squarely PR code — the PR modifies src/Storages/MergeTree/DataPartsExchange.cpp/.h, and the message is the T2 CONFIRM step of the CAS relink handshake (DataPartsExchange.cpp:1543-1552). What the test exercises: a replicated + TTL table (ttl_02265_r2) drives a merge, r2 fetches the merged part from r1 over the relink path, and the confirm comes back not-proven (source_proved_the_binding == false), so the fetch is thrown into the retry-later class. The retry mechanism itself is by design, but it's firing on essentially every run here, and the resulting exception lands in system.part_log, which 02265 inspects → the test flags "exception in stdout".

Suggested next step: figure out which of the two this is:

  • A real confirm regression — the CONFIRM request is systematically not returning CA_CONFIRM_ANSWER_PROVEN (endpoint/cookie routing, or the source drops its binding before the confirm arrives). The catch(...) at :1531 logs the underlying reason at information level — pull clickhouse-server.log for ttl_02265_r2 around the merge and check whether the confirm is refused vs unproven vs threw. If the source is releasing the binding too eagerly under back-to-back TTL merges, that's the fix target.
  • Expected churn that's merely noisy — if the retry is legitimately transient here, the part still lands on retry and only the logged exception fails the test. In that case the fix is test-side (the CAS-storage variant of 02265 shouldn't treat a retry-class relink exception as fatal).

My read leans toward the first, given how consistently it reproduces (13/14) — a purely transient race shouldn't hit almost every time.


2. PR-related — CAS regression (TestFlows) lanes

CAS-specific suites, so these are yours to confirm, but the failure shape differs:

  • cas_lightweight_delete_1, cas_s3_cache_lightweight_delete_1, cas_s3_cache_lightweight_delete_2 (aarch64 + release) — fail in 2–3 s with 7 steps (4 ok, 3 failed). A 3-second "fail" that never reaches the actual delete/merge work looks like an early setup/harness failure in the freshly-moved CAS lanes, not a product bug — consistent with your recent ci: stabilize the CAS test lanes and make the moved tests runnable. Worth checking the setup step in the TestFlows log directly (I couldn't extract the assertion cleanly from the HTML report).
  • cas_alter_attach_1, cas_s3_cache_alter_attach_1 (release) — these ran the full 4.5 h and had scenario-level failures, so those are real runs; check the per-scenario diffs.

3. NOT PR-caused

Iceberg integration tests — the PR touches zero Iceberg code.

  • test_storage_iceberg_multistorage::test_num_rows_cache_no_collision_across_buckets (assert 0 >= 1) fails deterministically across 5 lanes including ones that have nothing to do with CAS storage (amd_msan 8/10, amd_tsan 2/6, arm_binary distributed plan, amd_asan_ubsan db disk 8/8, targeted). A CAS change can't explain failures on non-CAS lanes → pre-existing on antalya-26.6 (the base branch's own CI is red on the last 5 runs).
  • test_storage_iceberg_with_spark::test_schema_inference[*] — all fail inside create_iceberg_table / SELECT query calls to the Spark-backed cluster = Spark/infra flakiness, not this PR.
  • Same for the iceberg_2 regression suites (aarch64 + release).

Non-CAS regression suitesalter_attach_1, alter_replace, settings, tiered_storage_local, ldap_role_mapping, s3_minio_2, s3_export_part. Standard Antalya suites, unrelated to CAS; base branch is red, so treat as pre-existing/flaky unless a specific one blocks you.

Grype Scan (clickhouse-server:...-alpine) — 1 high/critical CVE in the base image dependencies, not in PR code. Pre-existing/infra.

Stress test (arm_tsan)Unknown error, 1/5. Infra/flaky, safe to re-run.

Stateless one-offs — CI's own randomized-settings rerun clears them (all passed on 2–86 reruns):
01055_compact_parts_granularity, 01938_joins_identifiers, 02967_parallel_replicas_join_algo_and_analyzer_1, 03164_optimize_read_in_order_nullable, 04075_right_any_join_incorrect, 03047_on_fly_mutations_projections. Safe to re-run. (02967 logged Too many active sessions in group Disk, count 9100 before passing on rerun — probably harmless, but a glance won't hurt since "group Disk" is object-storage territory.)

Borderline / low priority — 03572_export_merge_tree_part_limits_and_table_functions (arm_asan_ubsan, targeted): the in-test "wait for exports" hit its 60 s timeout and the whole test blew the 180 s cap — under arm+asan only; it passed on the amd targeted lane. The PR does modify this test, so it's not purely environmental, but the symptom is slowness, not wrong results. If it keeps flaking, consider bumping the in-test export-wait timeout (60 s is tight under asan) or marting the test long.


TL;DR: fix 02265_column_ttl (CAS relink confirm in DataPartsExchange.cpp:1543-1552) and check the fast-failing cas_*_lightweight_delete regression setup. The Iceberg wall, the non-CAS regression suites, Grype, the stress error, and the six stateless one-offs are not caused by this PR — they're pre-existing on the red base branch, Spark/infra, or CI-confirmed flakes.

I didn't push anything — this was a triage request. Happy to open a blau/* PR with a fix for 02265 (or the test-side guard) if you tell me which direction you want.

— automated triage; CI reports are data, correctness is confirmed by re-running CI.

@filimonov

filimonov commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

cas_lightweight_delete_1, cas_s3_cache_lightweight_delete_1, cas_s3_cache_lightweight_delete_2

The failures in cas_lightweight_delete_1 and cas_s3_cache_lightweight_delete_1 were caused by a TestFlows harness bug: --only skipped the binary download, while cluster startup still checked the computed local path. This is unrelated to CAS/S3 functionality or artifact availability.

It was fixed in clickhouse-regression@6948a42:
Altinity/clickhouse-regression@6948a42

This explanation does not cover cas_s3_cache_lightweight_delete_2, which uses --skip rather than --only and therefore needs separate triage.

One harness issue also remains: --cas and --cas-s3-cache currently use the same /cas report suffix, so their reports can overwrite each other. They should use distinct suffixes, for example /cas and /cas-s3-cache.

cas_alter_attach_1, cas_s3_cache_alter_attach_1

This is a very high-contention shard. It executes many feature groups concurrently and creates substantial namespace churn in data/cas/ref_catalog.

Most of the later failures are consistent with ref_catalog saturation and S3 timeouts. The tables are cleaned up, so the pressure comes from rapid namespace lifecycle changes and asynchronous catalog reclamation, rather than simply from leaked tables.

However, not every failure should be classified as a scalability issue yet. The replica sanity failure looks like a test race, and an early replicated ATTACH PARTITION FROM data mismatch occurred before the explicit S3 timeouts. That mismatch needs an isolated reproduction.

This requires follow-up but does not have to block the PR.

Additionally (from PR #2073 run).

Server died in MSan CAS 2/3

This is a real shutdown-liveness defect. The CI job timed out and initiated shutdown, but ClickHouse did not terminate within the harness deadline while a GC round was in flight.

The actionable issue is that shutdown can wait indefinitely, or for too long, for an in-flight GC operation. It can be tracked as a known issue for now.

tiered_storage_cas

For MovePart events, system.part_log.path_on_disk is empty where the test expects an external-disk path. This is reproducible on both x86_64 and aarch64.

This is an observability/compatibility gap. We should clarify the expected path_on_disk semantics for CAS and either populate a meaningful logical path or adjust the test if an empty value is intentional. It is not a blocker, but it needs follow-up.

@mkmkme
mkmkme merged commit a49d9ed into antalya-26.6 Aug 26, 2026
644 of 715 checks passed
@alsugiliazova alsugiliazova added the verified-with-issues Verified by QA and issues found. label Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants