Skip to content

Add TLA+, Z3 and CBMC specs for the lease, allocator and ring span arithmetic - #153

Closed
claude[bot] wants to merge 6 commits into
mainfrom
claude/formal-specs
Closed

claude[bot] wants to merge 6 commits into
mainfrom
claude/formal-specs

Conversation

@claude

@claude claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Alan Liu · Slack thread

Before: The reasons the publisher lease, the version allocator and the start wait are safe were written down only as prose in docs/catalog-descriptor-key.md. Nothing checked that prose against the code, and it has already been wrong once (the correction at :304-314).

After: Five parts of the design are now checked by a machine against the code at 2b74d14: the lease/fence protocol, the version allocator, the storage service's lease lifecycle, the clock-skew and start-wait arithmetic, and the payload ring's span arithmetic. specs/README.md gives the exact command for every model and the result each one should reach.

This PR adds files under specs/ and nothing else (85 files). DMI and CI do not build, import or run anything in specs/.

How. tla/VersionAllocator.tla, tla/PublisherLease.tla and tla/LeaseLifecycle.tla are checked with TLC. z3/clock_skew.py covers the fence margin and the default start wait. cbmc/payload_ring_span.cpp checks payload_compute_spans against its stated precondition. LeaseLifecycle.tla refutes three things the service currently relies on:

  • Self-latch with no rival (O3_selflatch). With clock skew, a writer can be refused by its own dropped row at the moment its quarantine ends. The 2 x TTL latch then fires "held by another publisher" when no other publisher exists.
  • Concurrent spool sweep (O5_cosweep). A quarantined incumbent lets its row lapse, so a second process can take the lease and sweep the spool while the first is still writing.
  • skipped_packs counted as a renewal (O1_skip). A cycle that only skips packs that were already committed advances last_renew_ns_ without renewing anything, so the lease can lapse while the service still believes it holds it.

A fix for the lease-request timing gap comes in a follow-up PR on top of #150 (fix/bound-lease-requests). The spool sweep needs an owner lock on the spool, and this PR series does not fix it.

Note: alan/blissful-brown-hiq82y has an earlier copy of specs/, plus a RingCapacity.tla that is not in this PR. The two branches will conflict if both land.

Checked locally with TLC 2.19 on Java 21. nocap_distinct run with -deadlock completes with no error (1,117 generated / 836 distinct, as the README says). LeaseLifecycle_O3_selflatch is refuted on NoFalsePositiveLatch, as expected. python3 specs/z3/clock_skew.py prints ALL CHECKS AS EXPECTED.


After independent review (a7e2858)

The reviewer ran all 85 configurations, Z3 and CBMC, and every verdict matched. Each of four deliberate protocol breaks produced a counterexample. The fixes address what the models claimed beyond that:

  • Ring scope: the ring coverage is span arithmetic only, not the ring's publish/consume protocol. A new CBMC property, P5, checks that no span byte lands in the unconsumed region [tail, head).
  • Lease request time: the lease model treats every ClickHouse request as instantaneous. That is now documented prominently, and the O1 verdicts are qualified. New O1_slowreq3 (holds) and O1_slowreq (refuted: a stall of about 2/3 of a TTL lets the lease lapse) show the boundary.
  • Late-landing writes: the justification for dismissing a late-landing lease INSERT cited the publish-timeout cap, which applies only to publish statements. That is corrected, and O2_quar/O3_false are marked as holding only under the stated assumption.
  • Self-refusal check: NoSelfRefusal now counts only a real self-refusal.
  • Non-vacuity guards for NoOverlappingAdmit.
  • Replication assumption: linearizability needs insert_quorum as well as select_sequential_consistency.
  • Line references updated to Recover the publisher lease after ClickHouse errors and wait for a crashed predecessor at start #150's head.
  • Further limits documented: skew is modelled in one direction only, Stop is never enabled, and there is no cross-call allocator monotonicity check.
  • New specs/check.sh checks every configuration against its expected verdict: 80 passed, 0 failed on the fast set, and the 7 manual configurations pass with --all. No CI job was added.

Rebased onto main after #150 merged (73e6cad, cdc9c47)

#150 was squash-merged, so this PR's own commits were replayed onto main (71be2af) without conflicts, and the PR now targets main.

  • Line references: every file:line reference in specs/ now points at main's code, about 100 references checked by printing the code each one names. About 20 references that were already wrong before the rebase were corrected too.
  • Retry wording: with Connect to a secured ClickHouse catalog: header credentials, verified TLS, bounded retries #151 on main, the client repeats a read after a transient failure, and a write only when it never connected; it never repeats after a timeout. The models' retry text and caveats now say exactly that.
  • Verdicts: specs/check.sh gives 80 passed and 0 failed on the fast set. The diff touches only specs/, and only comments or prose in the model files.
  • An independent verifier found three more stale citations and one retry-wording error; all are fixed in cdc9c47.

@claude
claude Bot force-pushed the claude/formal-specs branch 2 times, most recently from d3fad41 to fe1f2e5 Compare September 24, 2026 18:49
@zaoxing
zaoxing requested a lite review from Copilot September 24, 2026 22:51

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zaoxing
zaoxing marked this pull request as ready for review September 24, 2026 22:52
@zaoxing
zaoxing force-pushed the claude/formal-specs branch from fe1f2e5 to 9f60e8e Compare September 24, 2026 23:02
@zaoxing zaoxing changed the title Add TLA+, Z3 and CBMC specs for the lease, allocator and ring protocols Add TLA+, Z3 and CBMC specs for the lease, allocator and ring span arithmetic Sep 25, 2026
The safety arguments for the publisher lease, the version allocator and the
start wait lived only in prose in docs/catalog-descriptor-key.md, and that
prose has already been wrong once (the correction at :304-314). specs/ now
machine-checks them against the code as of 2b74d14:

- tla/VersionAllocator.tla: the sole-claimant allocation loop.
- tla/PublisherLease.tla: claim/renew/release and the fenced publish.
- tla/LeaseLifecycle.tla: the lease thread, quarantine, the 2 x TTL latch,
  the start wait and the spool sweep in CaptureStorageService.
- z3/clock_skew.py: the fence margin and the default start wait.
- cbmc/payload_ring_span.cpp: payload_compute_spans and its precondition.

LeaseLifecycle.tla refutes three things the code currently relies on: the
latch can fire with no rival when clock skew outlasts the quarantine window,
two processes can sweep one spool at once, and a publish that only skips
already-committed packs is counted as a renewal. The README gives the exact
command for every config and the verdict each one should reach. Nothing in
specs/ is built or run by DMI or CI.
The O1 caveat said the renewal retry budget applies only to refusals. It
applies to no failure: a refusal drops the lease in the coordinator
(reject_live, or the failed claim read-back), and a transport error,
timeout or parse error is a ClickHouseError that quarantines the writer on
the first one. The four lease-thread wakes are room for a renewal that
runs late, not for one that fails. The same paragraph now calls the
arithmetic, rather than the comment, correct and conservative.
…sults

The O1_tries5 header was copied from O1_tries and said EXPECT: HOLDS, but
FiveTriesFit is refuted at the constant level, as the README table and
the results table in LeaseLifecycle.tla already say; TLC confirms it.
The README introduced the two O1 caveats as neither a model result,
though O1_absorb confirms the first and O1_skip is the second.
…k.sh

An independent review found the specs README claiming more than the models
show. This corrects the claims, adds the configs that back or bound them,
and adds a script that re-runs the checks and compares every verdict.

- Ring: only payload_compute_spans's span arithmetic is checked, not a ring
  protocol. The CBMC harness gains P5, that no span byte lands in the
  unconsumed region [tail, head); it holds with the precondition and fails
  without it. P5 takes head's offset from off1 rather than recomputing
  head % cap, which does not finish.
- LeaseLifecycle settles every ClickHouse call in zero time, while
  keep_lease() holds lease_mutex_ across up to three requests bounded only
  by request_s (60 s against a 15 s TTL). O1_slowreq3 (MaxLate 3, no
  cycle) holds and O1_slowreq (MaxLate 4) is refuted, so the O1 HOLDS
  verdicts are qualified: they need each lease request to finish in about
  half the TTL. A follow-up PR will bound lease request time.
- LIMITS 5 cited catalog_writer.cpp:519's publish-timeout cap to dismiss a
  late-landing unknown outcome, but that cap covers only publish_snapshot;
  the lease INSERT has no max_execution_time. The model lands such a row
  at once, so O2_quar and O3_false hold only under that assumption, and of
  the two suggested self-latch fixes only resetting
  held_elsewhere_since_ns_ is robust.
- NoSelfRefusal flagged ticks on which the service was quarantined and
  took no claim. It now counts only a claim actually taken and refused by
  the service's own row; O2_selfref (Skew 1) is still refuted, and it
  holds at Skew 0. The history variable changed, so the LeaseLifecycle
  state counts are re-recorded; no verdict changed.
- PublisherLease.cfg's AllSafety holds vacuously for NoOverlappingAdmit at
  the base constants. base5_ovr (refuted) proves base5 non-vacuous, and
  noovr1 / ovr1 add the one-chunk pair beside noovr0 / ovr0.
- Limitations now also say: Linearizable needs insert_quorum as well as
  select_sequential_consistency on a replicated catalog; skew runs one
  way; Stop is never enabled and its tombstone differs from the code's;
  each allocator allocates once, with no cross-call monotonicity check.
- Line references move to #150's head, c0361d7 (storage_service.cpp +6
  from run_cycle on, storage_service.h sweep comment at :104-108), and
  commit references from the orphaned 2b74d14 to 204a8d2, the same tree.
- Nits: seven Z3 checks, not six; O5_cosweep's config allows one cut
  (the trace takes none, and MaxCuts 0 is refuted too); the .tla no longer
  points at an LLruns/ directory or a vac_cosweep config; apt's cbmc on
  Ubuntu 20.04 is too old.

specs/check.sh runs the fast set (every config but PublisherLease.cfg,
believers, holderssafe, overrun, nonlin_fence, stalepid and noovr1, which
--all adds), z3/clock_skew.py and both CBMC builds, compares each result
with a table of expected verdicts, and exits non-zero on a mismatch or on
a .cfg the table does not list. Tools come from TLA2TOOLS_JAR, CBMC and
PYTHON. TLC runs in a scratch copy, so nothing lands in the tree. It is not
wired into CI.
#150 was squash-merged as 7419fd0, and main then took #151, #152 and
#139. The specs cited #150's head c0361d7; they now cite main at
71be2af.

- storage_service.cpp moved up two lines (#151 builds the ClickHouse
  client from one ClickHouseConnection), native_capture.py moved with
  #149/#151/#152, and deciding_read() is now clickhouse_client.cpp:374.
- #151 also made execute() retry a read after a transient failure, up to
  max_attempts (3 by default), and never a write that may have reached
  the server. The README's O1 caveat, its Limitations entry and LIMITS 3
  in LeaseLifecycle.tla now say a lease request's reads can take up to
  three request timeouts, and RenewIfDue says the quarantining exception
  is the first to outlast those retries. No modelled outcome changes.
- Refs that missed the code they describe, in files main did not change:
  the O1a quote is storage_service.h:233-234, not storage_service.cpp;
  the renewal in publish_snapshot is catalog_writer.cpp:490 and :579;
  publish_snapshot ends at :669; the config check with the quorum rule is
  :148-169; the chunk loop is :531; the watermark read-back is :608-628
  (:611-627 for its refusal); the version allocator's statement lines;
  reject_live's comparison is lease_coordinator.cpp:222; and the start
  wait's knob checks are native_capture.py:351-354.
- The README says what 204a8d2 is now that #150's branch is squashed.

Comment and prose changes only; every verdict is unchanged.
The RenewIfDue comment said the exception that quarantines a renewal is
the first to outlast the client's read retries. The claim INSERT is a
write: execute() repeats it only when the connection was never made and
never after a timeout, so its first failure after connecting quarantines
at once. Say that, as the README already does.

The "DURABLY CLAIMED, not solely claimed" quote in VersionAllocator.tla
is at tests/test_native_catalog_lease_live.py:653-662, not :598-607
(that is the test's def and docstring; cite the docstring's point at
:605-609 separately).

Close three ranges that stopped partway through the code they cite on
main: the fence-margin precondition is catalog_writer.cpp:148-160, the
watermark publish with its read-back is :583-628, and the visibility
write statement is :583-606.
@zaoxing
zaoxing force-pushed the claude/formal-specs branch from a7e2858 to cdc9c47 Compare September 28, 2026 01:04
@zaoxing
zaoxing changed the base branch from fix/lease-recovery to main September 28, 2026 01:05
@zaoxing zaoxing closed this Sep 28, 2026
@zaoxing zaoxing reopened this Sep 28, 2026
@zaoxing

zaoxing commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Superseded by #157 (same commits, updated for #154), which was opened from zaoxing's account so the squash commit is authored by Alan Liu. Merged as 71e6663.

@zaoxing zaoxing closed this Sep 28, 2026
@zaoxing
zaoxing deleted the claude/formal-specs branch September 28, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants