Zero-downtime lifecycle
The goal of this page: upgrade probectl at scale without an outage, and be able to roll back at every step. Four things make that possible, and the sections below are each one of them — the control plane is stateless replicas behind a load balancer (the traffic distributor in front of them), migrations are additive (expand/contract), the agent fleet rolls out version by version, and nothing is irreversible until you choose to make it so.
If you remember one rule, remember this one: during any rolling upgrade — replacing replicas one at a time while the rest keep serving — the old release (N) and the new release (N+1) are running side by side, so everything they share — the database schema, the agent protocol — must work for both at once. It is resurfacing a bridge one lane at a time: traffic in both the old and new lanes keeps flowing throughout, so every intermediate state of the bridge must carry both. Every constraint below falls out of that rule.
Rolling control-plane upgrade
The control plane holds no durable state of its own — all state lives in Postgres, ClickHouse, and the TSDB. That's what makes replicas interchangeable, and it's why they upgrade by replace, not restart-in-place:
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
LB[load balancer] --> A[replica N]
LB --> B[replica N]
subgraph upgrade
direction LR
B -. SIGTERM .-> D[draining: /readyz 503]
D -. in-flight drains .-> X[exit]
N1[replica N+1] -- /readyz 200 --> LB
end
- Apply migrations first (they are additive, so they're safe for the still-running release N — see the next section).
- Roll replicas one at a time. On
SIGTERM/SIGINT(the polite please-shut-down signals) a replica flips/readyzto503 drainingimmediately — so the load balancer stops sending it new requests — then drains its in-flight requests (finishes what it already accepted, accepts nothing new) withinPROBECTL_SHUTDOWN_TIMEOUTbefore exiting. (/healthzstays200the whole time: the process is still serving, it's just no longer accepting new traffic. The two probes mean different things on purpose — the lights are on, but the door sign says Closed.) - Bring up the replacement; it starts serving once
/readyzis200— meaning the database is reachable and it's not draining.
Because N and N+1 run side by side during the roll, both must work against the same schema, which the migration policy below guarantees.
Migrations: expand/contract (the rollback contract)
This is the heart of zero-downtime. A migration is a versioned SQL script
that moves the database schema from one shape to the next; probectl's are
sequential numbered files in migrations/. A safe upgrade and a safe rollback both
require one thing: release N's schema has to work with both N's code and N−1's
code. You get that by never making a destructive change in a single step.
Instead you split it across two releases:
- Expand (release N): add the new column/table/index (nullable, or with a default), backfill it, and start writing both the old and new shapes.
- Contract (release N+1, once nothing reads the old shape anymore): drop the old column/constraint.
Anything that would break N−1 mid-roll is forbidden in a single migration, and the
migration gate rejects it automatically (make migration-gate, a standing CI
job: the checker in internal/store/migrate walks every embedded *.sql in
migrations/):
| Rejected | Why | Do instead |
|---|---|---|
DROP TABLE / DROP COLUMN |
N-1 code still uses it | drop in the next release |
ALTER COLUMN … TYPE |
table rewrite + lock; breaks N-1 | add a new column, backfill, switch |
RENAME COLUMN/TABLE |
N-1 code references the old name | add the new name, backfill, drop later |
ADD COLUMN … NOT NULL without DEFAULT |
rewrite fails on existing rows; N-1 inserts break | add nullable or give a DEFAULT |
ALTER COLUMN … SET NOT NULL |
locks/fails | add a NOT VALID check, VALIDATE later |
TRUNCATE |
destroys data | — |
Allowed (and used throughout): CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS … (nullable or defaulted), DROP POLICY/DROP INDEX, ADD CONSTRAINT … NOT VALID, RLS enable/force. Migrations remain idempotent (safe re-run) and
each applies in its own transaction under an advisory lock — a named,
application-level Postgres lock, used here to elect exactly one migration
applier when several replicas start at once (one applier wins; the
rest find the schema already current).
Rollback is then trivial: roll the replicas back to N. N's code already works against N+1's additive schema, because the schema only added — it didn't remove anything N depends on. (The contract migration that finally removes the old shape is deliberately deferred to a later release precisely so this stays true.)
Executed v0.5.0 rollback boundary
The supported upgrade boundary for this release is v0.5.0 → the current
0.6.0 release. make upgrade-rollback-drill-isolated is the executable proof,
not a mock: it extracts and builds the exact v0.5.0 tag, starts a disposable
real PostgreSQL service, and then runs this sequence:
- apply the 45 historical migrations and start the v0.5.0 HTTPS server;
- plant two tenants with overlapping targets plus a stable test/data digest, wrapped-key bytes, and audit-chain sentinels;
- prove a failed pre-migration connection changes nothing;
- apply the current additive migrations and start the current HTTPS server;
- put the v0.5.0 binary back on the current schema and start it over HTTPS;
- roll forward to current again; and
- after every boundary, require the same digest, forced-RLS one-tenant view, wrapped-key bytes, and audit-head continuity.
The command refuses a dirty worktree for release evidence. Developers may set
PROBECTL_UPGRADE_ALLOW_DIRTY=1 only for an explicitly non-release rehearsal.
It is destructive only to its unique disposable Compose project. Backup/restore
remains a separate mechanism and receipt (make backup-restore-drill-isolated),
so a successful binary rollback is never misrepresented as disaster recovery.
This proves binary/schema compatibility on real PostgreSQL. It does not by
itself prove Kubernetes rolling-scheduler behavior, regional WAN/DNS timing, or
a non-author's runbook usability; those belong in the selected two-region live
receipt described in docs/ops/two-region-receipt.md.
Agent version skew
Skew is the version distance between two sides that must interoperate —
here, the control plane and an agent, which inevitably differ mid-rollout. The
control plane is the authority on whether an agent is compatible. At
registration it compares the agent's version to its own using an N/N−1 window
(internal/lifecycle):
- Same major version, with a minor skew ≤
PROBECTL_AGENT_SKEW_WINDOW(default 1) → compatible, in both directions: an N agent talking to an N+1 control plane works, and so does an N+1 agent talking to an N control plane. - A wider skew, a major-version mismatch, or an agent below
PROBECTL_AGENT_MIN_VERSION→ rejected with gRPCFailedPreconditionand an "upgrade required" message. (That status is deliberately distinct from a transient error, so the agent surfaces it to an operator instead of hot-looping forever trying to reconnect.) - A dev/unpinned build (
0.0.0-dev) on either side skips the check entirely.
The payoff: a fleet can run a mix of N and N+1 agents during a rollout, and upgrading the control plane never strands an agent that's one version behind.
| control \ agent | N−1 | N | N+1 | N±2 |
|---|---|---|---|---|
| N | yes | yes | yes | no (upgrade) |
Upgrading into central tests
Central test distribution (PROBECTL_TESTSYNC_SIGNING_KEY_FILE or
PROBECTL_TESTSYNC_SIGNING_KEY, ADR) needs
agents of this release or later: an older agent never calls FetchTestBundle
and keeps running its local canaries, and the fleet view shows its tests as
"none applied". Agents that were enrolled before the key existed receive it at
their next identity rotation (written beside cert.pem as testsync.pub) and
start pulling within 30 seconds; run probectl-agent rotate to do it now, or,
for an agent that never rotates, copy the output of
probectl-control testsync public-key into its identity directory.
Staged fleet rollout (cohorts + pace)
You don't push a new agent version to the whole fleet at once — you promote it
ring by ring, watching health between rings. internal/lifecycle assigns each
agent to a cohort by a stable hash of its id (stable so an agent never flaps
between rings from one evaluation to the next — the way students assigned to
homerooms by surname always land in the same room): a small canary ring
first (named for the coal-mine canary: it meets the new version first, so a bad
release hurts a few percent of the fleet, not all of it),
then early, then the main fleet. A Rollout carries the target version
and a Stage that the operator advances one ring at a time:
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
S0[Stage 0: none] --> S1[Stage 1: canary] --> S2[Stage 2: + early] --> S3[Stage 3: all]
DesiredVersion(agentID, current) returns the target version once that agent's
cohort has
been released, and the agent's current version otherwise. The cohort split is
configurable (default 5% canary / 20% early, leaving the remaining ~75% as the
main fleet). Note the boundary: this is the cohort + pace model plus the
version-skew enforcement that makes a staged roll safe to attempt. Actually
delivering a self-update to the agent (advertising the desired version down to
it) is a separate follow-up.
Release upgrade notes
Most releases upgrade by the rules above and nothing else. A release that needs an operator step before its pods can start is listed here, newest first.
Historical audit records during upgrade
The verifier preserves hashes written by v0.6.5 rather than rewriting audit history. Records without durable canonical bytes can verify using the exact historical format, provided their headers contain neither newlines nor backslashes. Ambiguous historical headers fail closed and require operator investigation; the upgrade does not silently reinterpret them. Current records always use the timestamp-bound, escaped-header format, including in mixed old/new chains. Removing a current record's canonical-data marker does not make a changed timestamp valid.
Historical timestamps were not hash-bound by v0.6.5. Compatibility does not retroactively attest them. Payload, stream identity, sequence and chain links still verify against the original hashes, with the existing signed head/WORM anchors checked independently. Preserve the original database and signed artifacts when rehearsing an upgrade.
0.6.6: the ClickHouse reader credential (DPR-285)
Who this affects: any install where a ClickHouse lane has
PROBECTL_*STORE_TENANT_SCOPING=true — the default under
PROBECTL_DEPLOYMENT_PROFILE=multi-tenant or regulated, and therefore under
the Helm values-multitenant.yaml and values-strict.yaml profiles. A single
profile install without tenant scoping is unaffected.
What changed: before 0.6.6 every ClickHouse read authenticated as the
control login, which no row policy filtered, so only the application's
WHERE tenant_id = … kept tenants apart. From 0.6.6 every tenant-scoped read
runs as the SELECT-only reader login the row policy binds, so the database
itself refuses another tenant's rows. Think of it as the difference between a
librarian promising to fetch only your books and a reading room whose door
only opens onto your shelf.
What happens if you skip it: the 0.6.6 control plane fails config validation at start and never becomes ready; it does not fall back to reading as the control login. Under the Helm chart's default rolling update the 0.6.5 pods keep serving until the credential is supplied.
Before upgrading:
- Create the reader in ClickHouse and grant it
SELECTon the control login's default database only:CREATE USER probectl_reader IDENTIFIED WITH sha256_password BY '…', thenGRANT SELECT ON <database>.* TO probectl_reader. - Stage its owner-only (
0600) credential file,{"username":"probectl_reader","password":"…"}— in Helm, add it to thecontrol.credentialFilesSecret. - Set
PROBECTL_CLICKHOUSE_READER_BASIC_AUTH_FILEto that file's path. Its username must equal every scoped lane'sPROBECTL_*STORE_READER_USER.
At boot the control plane refuses to start unless that login is not the
control login, resolves the same default database, is denied INSERT and
CREATE, holds no grant beyond SELECT on that database and on individual
tables (no SELECT ON *.*, no role, no grant option; DPR-328), and sees no row
outside the tenant it is scoped to. The Helm commands
are in deploy/helm/README.md ("Upgrading a
tenant-scoped install"); the key is documented in
configuration.md. The durable Compose profile
(deploy/compose/probectl.prod.yml, new in 0.6.6) provisions the reader from
CLICKHOUSE_READER_PASSWORD in .env, so a fresh Compose install needs no
extra step.
0.6.6: per-tenant ClickHouse databases are read as the reader too (DPR-323)
Who this affects: an install with the siloed_isolation license, tenant
scoping on (as above) and at least one siloed or hybrid tenant, or that will
provision one. A pooled-only install is unaffected.
What changed: a siloed or hybrid tenant's own ClickHouse database used to be read as the control login, so the database name the router chose was the only boundary. From 0.6.6 those reads run as the reader login with the tenant's setting, and every per-tenant table carries the same row policy as the pooled tables, so a read the router sends to the wrong tenant's database finds nothing. Back to the reading room: each shelf now checks your card too, not just the room's door.
What happens if you skip it:
- If the control login cannot grant the reader its per-tenant tables, startup
refuses at the silo reader-scope catch-up (the error names
SELECT ON *.* WITH GRANT OPTION), and new siloed/hybrid provisioning is refused the same way. It never falls back to reading as the control login. - If a siloed or hybrid tenant lives on a residency plane off the pooled
ClickHouse origin and
PROBECTL_DATAPLANE_READER_BASIC_AUTH_FILEShas no entry for that plane, startup refuses at the reader-scope catch-up, naming the entry, and provisioning onto that plane is refused the same way. A plane that hosts no siloed or hybrid tenant needs nothing new.
Under the Helm chart's default rolling update the 0.6.5 pods keep serving until the steps below are done.
Before upgrading:
- On every ClickHouse server that hosts per-tenant databases (the default
server and each residency plane), let the control login grant
SELECT:GRANT SELECT ON *.* TO <control login> WITH GRANT OPTION. The durable Compose profile'sclickhouse-initdoes this on the nextup. - On each residency plane, create the same reader user as on the default
server (same name;
SQL_probectl_tenantdefaulting to'', as indeploy/compose/prod/clickhouse/users.xml). It needs no grant of its own: the control plane grants it each per-tenant table. The reader must be a SQL-created user (CREATE USER), not one defined inusers.xml, whose read-only storage refuses grants. - For each plane that hosts a siloed or hybrid tenant, stage that plane
reader's owner-only credential file and list it in
PROBECTL_DATAPLANE_READER_BASIC_AUTH_FILES(eu=/etc/probectl/credentials/ch-eu-reader.json). A plane on the pooled ClickHouse origin, or one with no such tenant, needs no entry.
At boot, before the silo router is published, the control plane installs the
reader scope on every existing siloed and hybrid tenant's database and proves
it per plane: the reader authenticates as the configured reader user, is not
the control login, is denied INSERT and CREATE, and sees no row of a
per-tenant table under another tenant's setting. A scope that fails the proof
is withdrawn and startup is refused. The key is documented in
configuration.md; the mechanism in
security/tenant-isolation.md.
Unreleased: per-tenant quarantine instead of stop-everything (D-62)
Who this affects: an install with the siloed_isolation license and at
least one siloed or hybrid tenant. Nothing changes until you opt in.
What changed: the startup preconditions of the notes above (residency
plane configured, reader credential, reader-scope proof, Postgres silo
catch-up) refuse startup for every tenant when one tenant fails them. That is
still the default (PROBECTL_SILO_FAILURE_MODE=stop). Setting
PROBECTL_SILO_FAILURE_MODE=quarantine makes a failure quarantine only the
affected tenant: its reads and writes are refused with HTTP 503
tenant_quarantined, every other tenant starts and runs, the quarantine is
audited, alerted (ProbectlTenantQuarantined) and listed in the provider
console, and the tenant is released by itself once the check passes. Migration
0130_tenant_quarantine adds the provider-domain table behind that list
(expand-only; release N-1 never reads it). The intended beta default is
quarantine; the switch is the one DefaultSiloFailureMode constant in
internal/config.
Before opting in:
- Enable
metrics.prometheusRule(or add theProbectlTenantQuarantinedrule to your own Prometheus): in quarantine mode a broken plane no longer stops the rollout, so the alert is how you learn of it. - Keep Kafka's
offsets.retention.minutes(7 days by default) well above how long a quarantine may last: a quarantined tenant's lanes wait unconsumed, and their committed offsets expire after that window. - A configuration cause (a plane removed from
PROBECTL_DATAPLANES, a missingPROBECTL_DATAPLANE_READER_BASIC_AUTH_FILESentry) clears when the fixed configuration is rolled out; a runtime cause (an unreachable plane, a missingGRANT, a schema catch-up precondition) clears without a restart within onePROBECTL_SILO_QUARANTINE_RECHECK_INTERVALof the fix.
Configuration
See configuration.md for the PROBECTL_AGENT_SKEW_WINDOW
and PROBECTL_AGENT_MIN_VERSION keys (agent-transport section) and
PROBECTL_SHUTDOWN_TIMEOUT (the drain window).
Out of scope
This page is about single-region rolling upgrades: migration safety, version
skew, and the staged-rollout model. Two related-but-separate concerns live
elsewhere: multi-region active-active HA and disaster-recovery failover (RPO/RTO)
are in multi-region.md, and per-tenant backup/restore plus
verifiable deletion are a separate compliance capability.