Skip to content

CAS: refuse new nested server_root_id, delete S3 staging on abort, actionable member refusals - #2566

Open
filimonov wants to merge 13 commits into
antalya-26.6-20261008from
fix/antalya-26.6/cas-member-lifecycle-pr-a
Open

filimonov wants to merge 13 commits into
antalya-26.6-20261008from
fix/antalya-26.6/cas-member-lifecycle-pr-a

Conversation

@filimonov

@filimonov filimonov commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

A cas_server_root_id may no longer contain /, for new and existing members alike. Decommission, staging and the GC floor lookup select a member's objects by path prefix, so decommissioning a also removes the namespaces, staging and mountpoint objects of a member a/b. A server whose id contains / refuses to open the disk and must be recreated under a new id.

Changes

  • validateServerRootId refuses / with BAD_ARGUMENTS. It runs at disk config read, in Pool::open and in SYSTEM CAS DROP POOL MEMBER / clickhouse-disks cas-drop-member, so a nested id can neither mount nor be decommissioned. The message tells the operator to recreate the server under a new <cas_server_root_id> without /.
  • An aborted transaction deletes its S3 staging object, best-effort, as a commit does. Before, aborted staging stayed until the next mount of the same server_root_id.
  • The bucket requirements document the AbortIncompleteMultipartUpload lifecycle rule: an upload killed mid-way is invisible to object LIST and is never swept.
  • The owner and decommission refusals name the recovery that works:
    • retired owner: configure a new cas_server_root_id; do not clear the tombstone;
    • foreign owner: restore the uuid file or start under a new id; decommission the old id once its slot is terminated or fenced;
    • SYSTEM CAS DROP POOL MEMBER on a hard-killed member: wait until the disk's GC leader fences it, about one lease TTL plus one renew period after it first saw the lease; SYSTEM CAS GC RUN on the leader helps, but two back-to-back runs do not fence.

Risks / notes

  • Backward incompatible: a server whose cas_server_root_id contains / (for example from a {shard}/{replica} template) refuses to open the disk with BAD_ARGUMENTS. Recreate it under a new id without /, such as {shard}-{replica}. Data stored under the old id is not visible under the new id; the old id's objects stay in the pool and keep their blobs referenced, and SYSTEM CAS DROP POOL MEMBER refuses the old id, so it cannot reclaim them.
  • Decommissioning a still selects the objects of a legacy a/b left in the pool by an earlier build. Such a member can no longer mount.

Cleaning up a legacy id with / (example id a/b)

Before upgrading, on 26.6.4 (preferred):

  1. Stop the server whose id is a/b. If it must stay in the cluster, first start a replacement under an id without / and let it replicate.
  2. From another member, run SYSTEM CAS DROP POOL MEMBER 'a/b' FROM DISK '<disk>'. 26.6.4 accepts / in the id. A live lease is refused; a hard-killed member must be fenced first, as described above.
  3. Rerun until it returns slot_removed = true and empty warnings. The warning namespace(s) are still owned by this member means GC rounds must run first.
  4. Upgrade.

Dropping a/b selects keys under a/b/ only, never a/bc or a, but it does select a deeper a/b/c. Never drop a parent a while a/b must survive: both versions select a/b's objects for a.

After upgrading, if the steps above were missed:

  1. Delete nothing by hand.
    • cas/ref_catalog is one object for the whole pool, updated by compare-and-swap by writers and GC. Its a/b/... rows cannot be removed by hand.
    • cas/ns/stream/<life>/ and cas/ns/state/<life>/ are keyed by life id, not by server_root_id. Only the catalog maps them to a/b.
    • gc/server-roots/a/b/ (owner, epoch, mount) is the slot that decommission claims. Without owner and mount it refuses with unknown pool member. The manifest sweep reads its floor from mount.
    • staging/a/b/, roots/a/b/ and eligible cas/manifests/a/b/ debris are drained by cas-drop-member itself.
    • blobs/: GC reclaims a blob once no namespace references it.
  2. Keep the a/b server stopped. Run the 26.6.4 offline tool against the pool from a config whose CAS disk points at the same pool with <readonly>true</readonly>:
    clickhouse-disks -C config.xml --disk cas cas-drop-member 'a/b'
  3. Rerun it after GC rounds until it prints slot_removed=true and no warning= lines. The upgraded servers' GC deletes the removed catalog rows and then the unreferenced blobs.

Not verified: that a 26.6.4 binary reads every object this build writes. No test runs a 26.6.4 cas-drop-member against a pool written by this build.

Testing

  • New gtests (fail on the base): an existing member with a nested id refuses to open, without claiming the mount or minting an epoch; decommission of a nested id is refused and leaves its owner object untouched; abort deletes S3 staging; the refusal texts. Guards: system.cas_mounts still names a legacy nested slot whole; abort after storage shutdown does not throw; the decommission staging drain removes what the writer staged under the local and native key prefixes; the next mount sweeps a staging object whose abort delete was lost.
  • CAS* unit tests green in release and ASan; test_cas_drop_pool_member passes, including a new case: SYSTEM CAS DROP POOL MEMBER 'node2/nested' is refused with BAD_ARGUMENTS.

Changelog category (leave one):

  • Backward Incompatible Change

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

CAS: cas_server_root_id may not contain /. A server whose id contains / (for example from a {shard}/{replica} template) refuses to start the disk and must be recreated under a new id without /; data stored under the old id is not visible under the new one. An aborted transaction deletes its S3 staging object. SYSTEM CAS DROP POOL MEMBER and the owner refusals name the state or command that resolves them.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful tests
  • Unit 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)
  • CAS (content-addressed storage; Antalya only)
  • 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)

filimonov and others added 8 commits October 14, 2026 01:23
`SYSTEM CAS DROP POOL MEMBER 'a'` and the staging sweep select objects by
key prefix, so a member `a/b` was drained together with `a`. The first
claim of an id (owner absent) now refuses an id containing `/`;
`validateServerRootId` keeps accepting one so members that already own a
nested id keep mounting. The adopt-from-lease decommission fallback goes
through the same claim and is refused for a nested id before any object
is touched.

Tests: `CASServerRootId.NewIdWithSlashIsRefused`,
`CASDecommission.LeaseAdoptFallbackRefusesANestedIdWithoutOwner` (red on
the base: the owner was created), guard
`CASServerRootId.ExistingIdWithSlashStillMounts`.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
An aborted transaction left its S3 staging object until the next mount
of the same `server_root_id`; the comment calling it a re-readable
publication source was wrong, nothing reads `pending_blobs` after the
cleanup. Commit and abort now delete it alike, best-effort.

Incomplete multipart uploads are invisible to object LIST, so the docs
state the bucket lifecycle rule `AbortIncompleteMultipartUpload`.

Tests: `CASS3Staging.AbortDeletesS3Staging` (red on the base: one object
remains), guard `CASS3Staging.AbortAfterStorageShutdownDoesNotThrow`.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drain lists `<pool_prefix>/staging/<srid>/` as a logical key and the
backend applies the local root itself, while the writer mints keys
through `physicalKey`. Two guard tests stage a blob through a real
transaction, model a lost abort delete, and decommission the member
over an EmulatedSingleProcess (root-prefixed) and a Native (verbatim)
backend. A third pins that the next mount's sweep reclaims the same
object.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The foreign-owner refusal advised a hand delete that the next start
refuses over existing data; it now names `SYSTEM CAS DROP POOL MEMBER`
for a dead owner and the hand delete last. The retired-owner refusal
advised clearing the tombstone, which would let writer epochs repeat; it
now says to configure a new `cas_server_root_id`. The `NoWait` refusal
said "wait for its lease to lapse", but the claim trusts no wall clock:
it now names the terminal states (`terminated`, `fenced`) and how GC
reaches `fenced` for a hard-killed member.

Tests: `CASServerRootClaim.RefusalsNameTheSupportedRecovery`,
`CASDecommission.NoWaitRefusalNamesTheTerminalStates` (red on the base:
substrings absent). The substrings the integration test matches on
(`alive or contended`, `explicitly decommissioned`) are kept.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fencing happens only in a round run by the disk's GC leader, after it has
seen the mount token unchanged for about one lease TTL plus one renew
period. The refusal text and the docs said any live member's next round.
They now name the leader and `is_leader` in `system.cas_mounts`. The test
also pins `alive or contended` and `state = 'terminated'`.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three CASServerRootSafety owner-claim expectations now assert code and
message, so they pass only through the emptiness check that runs before the
nesting rule. The post-shutdown abort test makes the delete throw so it
reaches the catch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
…ifecycle PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Workflow [PR], commit [596fced]

The GC leader fences an expired mount lease only at the start of a GC
round, and under load a round took 183-245 s. The decommission refusal
and the docs now say so.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CasOperation & op, const Layout & l, const String & srid, const RefCatalog & catalog_observation)
{
const String owned_prefix = srid + "/";
for (const CatalogEntry & entry : catalog_observation.entries)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems there is a corner case here.

As I can see above here we decided support old server_root with '`/'.

We have entry in ref_catalog only after inserting data (precommit stage). So we could have server_rrot_id a/b without entry in ref_catalog and serverRootSubtreeEmpty returns true instead false.

This repoduces the same problem.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. / is now refused for every cas_server_root_id, including ids that already own their owner object, so a nested id can no longer mount or be decommissioned and the subtree check does not have to reason about one. An operator recreates such a member under a new id without /. Fixed in 52cb4b5.

filimonov and others added 4 commits October 17, 2026 19:36
Decommission and the staging sweep select a member's objects by key
prefix, so a member `a/b` would be drained by a decommission of `a`.
An owner-claim check covered only new ids; a nested id with no ref
catalog entry yet still let `a` claim over it. `validateServerRootId`
now refuses '/' for every id, so the disk config, `Pool::open` and
`SYSTEM CAS DROP POOL MEMBER` all refuse it, and the claim-time check
is gone.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants