Skip to content

Renew the CAS mount lease until a definitive answer; an expired lease is a pause - #2517

Merged
filimonov merged 9 commits into
antalya-26.6-20261008from
fix/antalya-26.6/cas-mount-renewal-until-definitive
Oct 19, 2026
Merged

filimonov merged 9 commits into
antalya-26.6-20261008from
fix/antalya-26.6/cas-mount-renewal-until-definitive

Conversation

@filimonov

@filimonov filimonov commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

The background CAS mount lease renewal used to give up when it got no definitive answer from the object store inside its budget. It then tripped the fence and started a remount under a fresh epoch. A short object-store outage (tens of seconds) cost about 40 s without writes, restarted merges and counted a CASMountLeaseLost, although nothing had touched the slot and the holder still had its lease on its own clock. Now the renewal retries until the store gives a definitive answer. While the lease cannot cover a write, writes pause and resume under the same epoch; the mount is no longer lost.

The branch is rebased on antalya-26.6-20261008, which already contains #2509 and #2513. It is not stacked any more. Read it commit by commit.

Rollout

How it works

  • Every cas_mount_renew_period_ms the renewal thread builds one tuple and keeps it for the whole series of attempts: key, body bytes, the expected ETag of the last confirmed body, write_attempt_id, and start, taken before the first request.
  • The conditional PUT runs on the lease plane under Retry::untilDefinitive, which has no deadline. After an unclear attempt, the next one waits a uniform draw in [0.8, 1.2] of 1000 ms minus what the attempt took (Retry::pauseAfterAttempt). There is no growing backoff, so the first good second after an outage ends the series.
  • Any store refusal (credentials, AccessDenied, MalformedXML, …) waits one renewal period, then a new engine call sends the same tuple. A refused read waits the same period. A clean 412 whose read failed repeats only the read. A refusal after an unclear attempt is settled inside the call.
  • Four things end the series:
    • A commit. DriverLease::finish publishes the deadline start + lease under driver_mutex, before the driver leaves the call, so a remount cannot arm a new epoch in between. The store is unconditional; it is monotonic because a renewal starts only after the previous one finished, and the renewer cannot be replaced during a call.
    • A slot that moved: another owner, our own body gc_fenced, a deleted slot, or our token over other bytes. This goes to the existing loss handling.
    • A local failure.
    • A stop or a remount request. Every pause on the lease plane wakes on both.
  • Stop semantics.
    • A stop between engine calls, after a refusal or after a read that found the slot unchanged, is NotAttempted: nothing of ours is in flight, and the farewell still hands the slot back.
    • A stop during a call that sent an attempt, or while the reads after a refused precondition repeat, is terminal with classification cancelled: the request may still land, or the slot may have moved. No farewell runs.
    • A remount request (park) always follows a fence trip and stays a loss.
  • The pause.
    • Writes stop being admitted once the lease has less than 2 × envelope + cas_lease_safety_margin_ms left. This rule is unchanged.
    • From the same moment, computed by the same predicate, system.cas_mounts shows lifecycle = 'not_live' and lifecycle_reason = 'lease_expired', with lifecycle_since = when writes paused.
    • At the next renewal attempt the server logs one WARNING per pause. It names the writer_epoch and the last renewal error: CAS mount lease of '<srid>' under writer_epoch <E> has too little time left for a write … Last renewal error: ….
    • A confirmed renewal re-admits writes under the same epoch only if it leaves more than that reservation. A renewal confirmed later than that leaves writes paused, and the next one starts at once.
    • A refused write's error says writes are paused and resume once the renewal is confirmed.
    • A clean shutdown during a pause skips the farewell, which has no lease room left, logs one INFO, and leaves the slot for GC to observe.
  • The bounded renewals stay bounded by the last confirmed lease minus the safety margin:
    • the startup re-anchor;
    • the remount re-anchor;
    • the background renewal of a pool opened for decommission (bounded_background_renewal).
  • Late commit. A renewal confirmed after an outage longer than the lease stores an expires_at_ms already in the past. GC does not fence the slot for it, because GC fences only a token it watched unchanged for its whole threshold.

Settings (no new ones)

  • cas_mount_lease_ttl_ms: the length of the right to write from each confirmed renewal.
  • cas_mount_renew_period_ms: the interval between renewal starts, and the wait after a refusal.
  • cas_attempt_timeout_ms: the budget of one attempt, hence the admission envelope.
  • cas_lease_safety_margin_ms: unchanged.
  • The 1000 ms spacing is a constant.

Risks / notes

  • Nothing changes on the store or in the observer protocol. The remount machinery stays as the reaction to a definitive loss.
  • CASMountRenewalDeadlineExceeded now counts only bounded renewals: the startup and remount re-anchors, and a decommission's background renewal. An outage of a normal mount no longer moves it or CASMountLeaseLost; alert on system.cas_mounts.lifecycle_reason = 'lease_expired' instead.
  • During a pause, background merges restart about once a second and fail at commit (the pool stays Live while admission is closed). Tracked as backlog task CAS-256.
  • The S39 soak card still expects a remount after the long fault and needs CAS-344.
  • Docs: mounts-and-leases.md, debugging.md, monitoring.md, troubleshooting.md, metrics_and_events.md, cas_mounts.md.

Testing

  • Unit, 30 new or rewritten tests on two shared fixtures:
    • CASRetrySpacing.* and CASRequestsSpacing.*: no deadline, spaced attempts, the standard backoff unchanged.
    • CASHeartbeatUntilDefinitive.*, one tuple past the lease and the refusal pause: RetriesOneTuplePastTheLease, ARefusedRenewalIsSentAgainAfterTheRefusalPause, AfterACleanPreconditionRefusalOnlyTheReadRepeats.
    • CASHeartbeatUntilDefinitive.*, every way a series ends after a refused precondition: AReadThatFindsAForeignHolderEndsTheSeries, AReadThatFindsOurSlotFencedEndsTheSeries, AReadThatFindsTheSlotDeletedEndsTheSeries, OurTokenOverOtherBytesEndsTheSeries.
    • CASHeartbeatUntilDefinitive.*, stops and liveness: AStopBetweenCallsEndsTheSeriesAsCancelled, AStopDuringTheReadsEndsTheSeriesWithoutAFarewell, ALostLivenessDuringTheReadsEndsTheSeriesAsALoss.
    • CASHeartbeatUntilDefinitive.ALateCommitRestartsTheGcObservation: a late commit against GC.
    • CASMountRuntime.*: a long outage is a pause under the same epoch; an old epoch's renewal does not move the new deadline; a late commit reports a true reason; a remount request ends a series.
    • CASMountRuntime.*, stop and park: they end the refusal wait at once, and the farewell survives a stop.
    • CASMountRuntime.*, the rest: one refusal waits one period; the decommission renewal ends inside its lease; the pause is logged once with the last error; a clean shutdown during a pause leaves the slot.
    • CASLifecycleSnapshot.*: the row is not_live from the admission edge.
    • CASHeartbeat.CancellationAfterSendIsTerminalAndForbidsRelease now also asserts classification cancelled.
    • Each new behaviour was shown red by reverting it on the final tree.
  • Integration: test_long_mount_put_outage_pauses_under_the_same_epoch; test_cas_mount_renewal_retry 6 passed.
  • CAS* gate: 2670 passed (release), 2674 passed (ASan, no error).
  • TLA+ model run models/mount-lease-series-v2 @1e4f3222015, all batteries green. Pause safety ResumedEpochNeverWritesAfterGcActed holds in CaRenewalDriver.

Closes: #2431
Related: #2509
Related: #2513
Related: #2516

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 to CHANGELOG.md):

A CAS mount lease renewal retries until the object store gives a definitive answer instead of giving up at a deadline. While the lease is too short to admit a write, writes pause and resume under the same epoch once a renewal is confirmed, instead of losing the mount and remounting.

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)

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Workflow [PR], commit [54d1258]

@k-morozov
k-morozov self-requested a review October 13, 2026 09:34
@filimonov
filimonov marked this pull request as draft October 15, 2026 09:47
filimonov and others added 7 commits October 16, 2026 02:35
The plane already carries the claim and the remount re-anchor, and the
background renewal moves onto it next; "farewell" names one of its users.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Retry::untilDefinitive` has no window and no lease bound. Its reissues
wait a draw from [0.8, 1.2] of 1000 ms minus what the failed attempt
took (`Retry::pauseAfterAttempt`) instead of the growing backoff, so the
first good second after an outage ends a retry series. Existing policies
are unchanged.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The background renewal gave up at the lease deadline, fenced the mount
and remounted under a fresh epoch, although the store had not answered
and nothing had touched the slot. It now keeps one tuple and retries it
on the lease plane under `Retry::untilDefinitive`:

- a refusal waits one renewal period and sends the same body again;
- a refused precondition whose read failed repeats only the read;
- a commit, a slot that moved, a local failure, a stop or a park ends
  the series.

A stop between engine calls, after a refusal or a read that found the
slot unchanged, leaves the renewer active so the farewell still runs; a
stop during a call that sent an attempt, or while the reads repeat, is
terminal and classified as cancelled. A committed deadline is published
in `DriverLease::finish`, under the driver mutex, so a remount cannot arm
a new epoch between a commit and its deadline. The lease plane sleeps
interruptibly on a stop or a park, like the mount plane. The startup and
remount re-anchors stay bounded by the lease, and so does a runtime with
`bounded_background_renewal`.

Closes: #2431

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The decommission tool opens its pool with the background renewal. An
admin claim that cannot renew should end at its lease rather than pause,
so `Pool::openForDecommission` sets `bounded_background_renewal`.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Writes stop being admitted once the lease has less than two attempt
envelopes plus the safety margin left. `system.cas_mounts` now shows
`not_live` / `lease_expired` from that same moment, computed by the
predicate admission uses, and one WARNING per pause names the writer
epoch and the last renewal error. The refusal texts a write meets during
a pause say that writes resume once the renewal is confirmed. A clean
shutdown during a pause skips the farewell, which has no lease room left,
and leaves the slot for GC to observe.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shared fixtures for the renewal series (`CASHeartbeatUntilDefinitive`,
`BackgroundRenewal`) and tests for every way a series ends, the refusal
pause, stop and park, the late commit against GC, the pause report and
the teardown during a pause. Three runtime tests that relied on a
transient fault ending the background renewal fence the slot instead.
The integration test checks that a mount PUT outage longer than the
lease is a pause under the same `writer_epoch`.

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>
@filimonov
filimonov force-pushed the fix/antalya-26.6/cas-mount-renewal-until-definitive branch from 2f650d9 to e3588e3 Compare October 16, 2026 04:34
@filimonov
filimonov marked this pull request as ready for review October 16, 2026 05:37
Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
k-morozov
k-morozov previously approved these changes Oct 18, 2026

@k-morozov k-morozov left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM. My comments are mostly related to refactoring and don't affect the resolution of the issue.

/// The background renewal, on the lease plane, retried until a definitive answer: a commit, a
/// conflict the read observed, a local failure, or `environment.live` refusing. A refusal sends the
/// same body again, and a refused precondition whose read failed repeats only the read.
MountRenewResult renewUntilDefinitive(const MountRenewOperationEnvironment & environment);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggest renewUntilResolved.

Comment on lines +1684 to +1685
? Retry::untilDefinitive()
: Retry::untilLeaseSafe(confirmed_deadline_boot_ms, static_cast<uint64_t>(lease_safety_margin.count()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

untilLeaseSafe use deadline. Maybe could specify this info?


MountRenewResult MountLeaseRenewer::renewOn(
CasRequests & plane, const MountRenewOperationEnvironment & environment)
CasRequests & plane, const MountRenewOperationEnvironment & environment, bool until_definitive)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggest to use particular policy instead bool flag. Like policies in CasOperation::writeLoop.

Comment on lines +1720 to +1721
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am wondering that we could forget to process new value in std::variants.

written = op.replace(key, body, precondition(),
Retry::untilLeaseSafe(confirmed_deadline_boot_ms, static_cast<uint64_t>(lease_safety_margin.count())));
written = op.replace(key, body, expected, policy);
while (until_definitive)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Maybe we could update this place because it depends on the specific policy.

{
public:
MountLeaseRenewer(
CasRequests & mount_requests_, CasRequests & open_requests_, const Layout & layout_,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It is not related with current PR.
I suggest rename open_requests_ to `lease_requests' becasee in the next it is confusing.

/// When set, a reissue in `CasOperation`'s write and read loops waits until a draw from [0.8, 1.2]
/// of this has passed since the failed attempt started, instead of the growing backoff. A
/// first-attempt fuse timeout is still reissued at once.
std::optional<uint64_t> attempt_spacing_ms = std::nullopt;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggest change name. For example reissue_interval_ms?

result = active == RenewalDriverState::RemountCall
? call.renewer->renewForRemount(renewalEnvironment(worker_call))
: call.renewer->renew(renewalEnvironment(worker_call));
if (active == RenewalDriverState::RemountCall)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Different cas requests are being used here inside the methods, though it’s not immediately obvious. I can’t suggest any simple refactoring for this. Need to think.

The base changed `mountObservationThresholdMs` and `computeHeartbeatFloor` to take the
observer and declared leases; the test kept the old signatures and broke the build:
https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2517&sha=d410298e2c4717738d9c3c8adc055ab99a240b01&name_0=PR&name_1=Build%20%28amd_debug%29

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

Copy link
Copy Markdown
Member Author

CI triage for 54d1258. No red is caused by this PR.

  • Build (amd_release), Build (arm_release): Docker Hub pull of the builder image failed (TLS handshake timeout, connection reset by peer). Infra.
  • Integration tests (amd_asan_ubsan, db disk, old analyzer, 2/8): UBSan in utils/self-extracting-executable/decompressor.cpp:209 while unpacking the binary, before any test ran. Infra.
  • iceberg_1, parquet_aws_s3: could not bring up docker-compose cluster. Infra.
  • Unit tests (asan_ubsan): only IcebergMetadataGenerator.ModifyColumnAfterItselfWithoutTypeIsNoop, the UBSan failure seen on every PR of this base. All CAS* tests pass.
  • cas_selects: 1 scenario, against 8 on siblings (CAS: refuse new nested server_root_id, delete S3 staging on abort, actionable member refusals #2566). clickhouse1 hit 8894 Cannot assign requested address (errno 99, client port exhaustion) to the store, and one part-manifest PUT ran out of its retry budget, giving one client Code: 210. The lease-loss form is gone: 0 mount lease not held; renewal paused writes twice and recovered after 12 attempts / 10.7 s and 9 attempts / 7.9 s.
  • cas_lightweight_delete_4: a lost lightweight DELETE in the base, not a flake. Mutation 23 registered 7 ms before mutation 22 (prepareMutationEntry writes the file outside the lock), the part was mutated straight to version 23 (Part 5_6_6_0_21 doesn't change up to mutation version 23), and mutation 22 was never applied: 50000 rows left. Fixed upstream in Fix a concurrent ALTER UPDATE being skipped and reported done in MergeTree ClickHouse/ClickHouse#124167, not in antalya-26.6 yet.

@filimonov
filimonov merged commit 9e54034 into antalya-26.6-20261008 Oct 19, 2026
344 of 361 checks passed
filimonov added a commit that referenced this pull request Oct 19, 2026
Brings in the base merge of PR B: #2566 (member lifecycle PR A, final
review) and #2517 (renewal until a definitive answer).

- The foreign-owner refusal takes the #2566 text, which names
  `SYSTEM CAS DROP POOL MEMBER` for a dead owner.
- Docs keep this branch's rejoin rules and add the #2566 rows for the
  terminal-slot claim and ids with `/`.
- The unverified-tombstone test expects this branch's
  `decommission is not finished` refusal.

Signed-off-by: Mikhail Filimonov <mfilimonov@altinity.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@k-morozov k-morozov added the verified Approved for release label Oct 20, 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.

3 participants