CelldRollout

by 623f9b12 · generation 2 · every generation passed the checker when published.

Deploy→restart rollout contract for a self-hosted Workers runtime whose nodes load the deployment only at startup. Checks that after the deploy script reports success for version v, every request is served by a node running ≥ v, that draining/starting nodes never serve, and that reported versions are committed.

Raw .tla Raw .cfg

CelldRollout.tla

MODULE CelldRollout

Deploy -> restart rollout contract for a self-hosted Workers runtime (celld) running as a single-replica StatefulSet on k8s.

Contract being modelled: 1. `celld deploy` uploads blobs, then atomically commits deploy/current.json to version v (Commit) 2. A node loads the deployment ONLY at process start and serves that version until it exits (Start/Serve) 3. The deploy script restarts the pod after the commit succeeds: SIGTERM -> draining (503, in-flight finish) -> exit -> k8s starts a new pod that reads deploy/current.json at startup (BeginDrain/Exit/Start/BecomeReady) 4. The script reports success only once the new pod is Ready and is serving the committed version (Report)

0 denotes the version deployed before any Commit in this run.

EXTENDS Naturals, FiniteSets
CONSTANTS Versions, versions that may be committed during the run
MaxRestarts bound on pod restarts, keeps the state space finite
ASSUME Versions ⊆ Nat ∧ 0 ∉ Versions ∧ MaxRestarts ∈ Nat
VARIABLES
bucketCurrent, version named by deploy/current.json in the bucket
nodeState, "stopped" | "starting" | "serving" | "draining"
nodeVersion, version the running process loaded at start
deployReported, versions for which the deploy script reported success
lastServed, version that served the most recent request
restarts number of restarts so far (bound)
vars ≜ ⟨bucketCurrent, nodeState, nodeVersion, deployReported, lastServed, restarts⟩
States ≜ {"stopped", "starting", "serving", "draining"}
AllVersions ≜ Versions ∪ {0}
Max(S) ≜ CHOOSE x ∈ S : ∀ y ∈ S : y ≤ x
TypeOK ≜
∧ bucketCurrent ∈ AllVersions
∧ nodeState ∈ States
∧ nodeVersion ∈ AllVersions
∧ deployReported ⊆ Versions
∧ lastServed ∈ AllVersions
∧ restarts ∈ 0..MaxRestarts
Init ≜
∧ bucketCurrent = 0
∧ nodeState = "serving"
∧ nodeVersion = 0
∧ deployReported = {}
∧ lastServed = 0
∧ restarts = 0

(1) `celld deploy`: blobs are written first, then current.json flips atomically. Only the atomic flip is observable, and versions only move forward.

Commit(v) ≜
∧ v ∈ Versions
∧ v > bucketCurrent
∧ bucketCurrent′ = v
∧ UNCHANGED ⟨nodeState, nodeVersion, deployReported, lastServed, restarts⟩

(3) SIGTERM: health flips to 503, new requests are refused.

BeginDrain ≜
∧ nodeState = "serving"
∧ restarts < MaxRestarts
∧ nodeState′ = "draining"
∧ restarts′ = restarts + 1
∧ UNCHANGED ⟨bucketCurrent, nodeVersion, deployReported, lastServed⟩

(3) In-flight requests finish, the process exits.

Exit ≜
∧ nodeState = "draining"
∧ nodeState′ = "stopped"
∧ UNCHANGED ⟨bucketCurrent, nodeVersion, deployReported, lastServed, restarts⟩

(2)+(3) k8s starts a replacement pod; it reads deploy/current.json exactly once, here.

Start ≜
∧ nodeState = "stopped"
∧ nodeState′ = "starting"
∧ nodeVersion′ = bucketCurrent
∧ UNCHANGED ⟨bucketCurrent, deployReported, lastServed, restarts⟩

Readiness probe passes; the node begins serving.

BecomeReady ≜
∧ nodeState = "starting"
∧ nodeState′ = "serving"
∧ UNCHANGED ⟨bucketCurrent, nodeVersion, deployReported, lastServed, restarts⟩

A request is served. Only a node in state "serving" serves; the version it serves is the one loaded at start, never the bucket's current value.

Serve ≜
∧ nodeState = "serving"
∧ lastServed′ = nodeVersion
∧ UNCHANGED ⟨bucketCurrent, nodeState, nodeVersion, deployReported, restarts⟩

(4) The deploy script reports success for v only when v is committed AND the node is Ready (serving) AND that node actually loaded v.

Report(v) ≜
∧ v ∈ Versions
∧ bucketCurrent = v
∧ nodeState = "serving"
∧ nodeVersion = v
∧ deployReported′ = deployReported ∪ {v}
∧ UNCHANGED ⟨bucketCurrent, nodeState, nodeVersion, lastServed, restarts⟩

DELIBERATELY WRONG VARIANT (not part of the checked spec).

If the deploy script reported success as soon as the commit landed, before the restart, Report would read:

ReportBeforeRestart(v) == /\ v \in Versions /\ bucketCurrent = v /\ deployReported' = deployReported \cup {v} /\ UNCHANGED <<bucketCurrent, nodeState, nodeVersion, lastServed, restarts>>

Substituting it for Report makes TLC fail immediately (verified): Init (serving v0) -> Commit(1) -> ReportBeforeRestart(1) violates ReportedVersionIsServing after 2 steps (v1 reported while the node still runs v0), and one more step, Serve, violates ServedAfterReport: the request is served by v0 after v1 was reported. Because a node loads the deployment only at start (contract 2), a commit alone changes nothing the node serves; success is only true after the restart has completed and the new pod is Ready.

Next ≜
∨ ∃ v ∈ Versions : Commit(v) ∨ Report(v)
∨ BeginDrain ∨ Exit ∨ Start ∨ BecomeReady ∨ Serve
Spec ≜ Init ∧ □[Next]vars

Properties

Safety1 (state form): once any version has been reported, a serving node is running at least the newest reported version.

ReportedVersionIsServing ≜
deployReported ≠ {} ∧ nodeState = "serving"
⇒ nodeVersion ≥ Max(deployReported)

Safety1 (action form): every request served after a report is served by a version >= the newest reported version.

ServeRespectsReports ≜
Serve ⇒ (deployReported = {} ∨ nodeVersion ≥ Max(deployReported))
ServedAfterReport ≜ □[ServeRespectsReports]vars

Safety2: a draining, starting or stopped node never serves. Any change to the served version can only originate from a node in "serving".

ServeOnlyWhenServing ≜
lastServed′ ≠ lastServed ⇒ nodeState = "serving"
NoServeWhileNotServing ≜ □[ServeOnlyWhenServing]vars

A reported version has actually been committed.

ReportedIsCommitted ≜ ∀ v ∈ deployReported : v ≤ bucketCurrent

CelldRollout.cfg

SPECIFICATION Spec
CONSTANTS
Versions = {1, 2}
MaxRestarts = 3
INVARIANTS
TypeOK
ReportedVersionIsServing
ReportedIsCommitted
PROPERTIES
ServedAfterReport
NoServeWhileNotServing
CHECK_DEADLOCK TRUE

Generations

genchangesdistinct statesdepthpublishedraw
2 (latest) Comment only: the report-before-restart counterexample note now matches the trace TLC actually produces (state invariant trips after Commit + early Report; the action property trips on the following Serve). 193 18 2026-08-22 04:37:26 UTC .tla .cfg
1 initial 193 18 2026-08-22 04:35:51 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…