ADR-0007 — Repository Circuit Breaker¶
- Status: Accepted / Implemented
- Date: 2026-08-04
- Deciders: kopiur maintainers
- Builds on: ADR-0005 §4 (the opt-in backend health probe), ADR-0006 (the mass-deletion breaker precedent: a single fault must not fan out into unbounded per-CR work), ADR-0003 §4.5 (repository as a first-class resource every consumer gates on)
- Scope:
kopiur.home-operations.com/v1alpha1— additive fields (Repository/ClusterRepository.spec.health.probe.onFailure) + two schema default flips on existing fields (probe.enablednow defaultstrue,onFailuredefaultsDegrade) + controller behavior changes (phase-aware probe failure, strict-retry recovery loop, aRestorereadiness gate, missed-slot re-pinning)
Context¶
Issue #345: a homelab's Garage (S3) backend went down for several hours. Every
Repository pointing at it had bootstrapped long ago and sat Ready; the
health probe existed (ADR-0005 §4) but was opt-in and alert-only, and this
fleet — like almost every real fleet — had never enabled it. So the readiness
gate (repository_ready) stayed open the whole outage: every schedule tick
minted a Snapshot, every Snapshot launched a mover Job, every Job burned its
backoffLimit against a dead endpoint and died. The user came back to 53
Failed Snapshot CRs and 23 dead mover Jobs against one repository — the
exact "storm of pods that each only fail on kopia repository connect" the
fail-fast gate was designed to prevent, defeated because nothing ever flipped
the phase the gate reads.
The pre-existing design had documented this as a "one-Job detection window":
a failed backup stamps a reverify-requested-at nudge, the repository
re-probes, and a terminal verdict flips it Failed. Two gaps made the
incident worse than one Job:
- A retryable outage never flipped the phase. The reverify classified the
connect failure as transient and left the repository
Ready, so the gate never closed — one doomed Job per schedule tick, not one per outage. - The nudge itself was asymmetric and the schedule's slot pin could wedge
(fixed as this feature's M1 groundwork; see
crates/controller/src/snapshot_schedule.rs::SlotDispositionfor the missed-deadline re-pin).
ADR-0006 already establishes the shape of the answer: when a fault would fan out into unbounded per-CR work, put a breaker between the trigger and the fan-out. Here the fault is "the backend is down", the fan-out is "one Failed CR plus one dead Job per schedule tick per consumer kind".
Decision¶
1. The probe is ON by default, and its default action is to pause¶
spec.health.probe.enabled now schema-defaults to true
(crates/api/src/repository.rs::default_health_probe_enabled): every
Repository/ClusterRepository re-connects its backend on the probe cadence
(default interval: 30m, min 30s; failureThreshold default 3) unless
explicitly opted out with enabled: false. A new field,
probe.onFailure: Degrade | Alert (default Degrade), decides what sustained
failure does:
Degrade(default) — pastfailureThresholdconsecutive failed connects the repository moves to phaseDegradedwithBackendReachable=FalseandReady=False: the circuit breaker opens.Alert— the pre-#345 contract, now the opt-out: the repository staysReady; only theBackendReachablecondition, a Warning Event, andkopiur_repository_health_probe_failuresfire.
The cost of default-on is one short connect Job per repository per 30 minutes
(object-store/server/volume-backed backends; bare-path filesystem repos connect
in-process). To avoid a thundering first probe after upgrade, a successful
bootstrap/strict connect seeds the probe clock
(crates/controller/src/health.rs::success_fold): a repository that has never
probed gets its lastProbeAt stamped by the connect that just succeeded, so the
first real probe lands one full interval later, not immediately.
2. One sensor: every bootstrap-connect verdict¶
The breaker has exactly one sensor — the outcome of a bootstrap-shaped
kopia repository connect, whether launched as a periodic probe or as a strict
(re-)bootstrap. Consumers (a failed backup, replication, maintenance) only
nudge the repository to re-probe sooner (the existing
reverify-requested-at mechanism, now symmetric); their own failures never
increment the streak directly. This is deliberate: a mover can fail for reasons
that say nothing about the backend — a broken source PVC, a full scratch
volume, an app-level hook error — and a breaker fed by consumer failures would
pause a healthy repository over a sick workload. Only a failed connect,
observed by the repository's own probe/bootstrap machinery
(crates/controller/src/health.rs::reconcile_probe_failure), counts.
status.health.consecutiveProbeFailures accumulates across probe and
strict-retry failures, so the streak is also the outage-duration signal.
3. Open, half-open, close¶
- Open (
crates/controller/src/health.rs::breaker_verdict/probe_failure_phase): atfailureThresholdunderDegrade, phase →Degraded,BackendReachable=False,Ready=False, akopiur_repository_breaker_trips_totalincrement and a Warning Event — on the transition only. Every consumer gate closes:Snapshot,SnapshotPolicy,Maintenance,RepositoryReplicationalready gated onReady;Restorenow does too (crates/controller/src/restore/mod.rs), closing the one write path that previously launched movers against a dead backend. Gated work parks (Pending,ReadyreasonRepositoryNotReady) — deferred, never refused — and repository→pending-consumer watch mappers (crates/controller/src/watch.rs) re-reconcile parked CRs promptly on recovery instead of waiting out a requeue. - Phase stability while open. A
Degradedrepository holdsDegradedthrough its retry cycles (crates/controller/src/health.rs::launch_phase): noDegraded→Initializingflapping, sofor:-clause alerting and the parked-work gates see one stable open state. - Half-open — the strict retry loop: the repository recycles its bootstrap
Job and re-connects on an exponential launch-side holdoff, 120s doubling to a
600s cap (
health::strict_retry_backoff/strict_retry_holdoff, stamped in status so a restart cannot reset the clock). Worst case ~144 connect Jobs per day of outage — bounded, and each is a short connect, not a backup. - Close — any successful connect (probe or strict) heals: streak
cleared,
BackendReachable=True, phaseReady(health::success_fold). Recovery is fully automatic; there is no ack.
4. The strict-verdict reroute is narrowed to RepositoryUnavailable only¶
Previously any failed strict bootstrap on a once-bootstrapped repository parked
terminal Failed (kstatus Stalled). Now exactly the
RepositoryUnavailable verdict class — connection refused/timeout, DNS, the
transport-shaped failures an outage produces — reroutes into the Degraded
retry loop (crates/controller/src/repository.rs::recycle_bootstrap_outage;
verdict classification in crates/kopia/src/error.rs). Locked,
SourceError, auth/password failures keep their terminal Failed: those need
a human (a wrong password will not fix itself, and hammering a locked
repository is harmful), and blurring them into "retrying" would hide an
actionable error behind a spinner.
5. Vanished escalates through the breaker to terminal — never a recreate¶
A probe that finds the backend reachable but the repository absent
(RepositoryVanished) opens the breaker like any other failure (pausing is
equally right either way), and the strict re-check then confirms:
RepositoryNotInitialized on a repository with a pinned uniqueId is terminal
Failed. The ADR-0005 invariant is untouched: kopiur never auto-recreates
a once-Ready repository — create.enabled governs the first bootstrap only,
and a wipe always ends in a loud terminal state for a human, not a silent
fresh-empty-repo.
Amended by #435. The invariant above stands unchanged; what it lacked was a door. Two things were wrong in practice:
- The terminal state reused
RepositoryNotInitialized, whose message says "setspec.create.enabled: true" — false advice for the reporter, whosecreate.enabledwas alreadytrueand whose bucket had been deleted. That case now has its own reason,RepositoryReinitializeBlocked, produced from a typedhealth::CreateGate(Allowed { via_reinit_ack }/DisabledBySpec { also_pinned }/PinnedOnceReady) that is carried to the mover asBootstrapRepositoryOp.create_block. - Nothing ever cleared
status.uniqueId, so "a deliberate human action" had no documented form and the reporter deleted the whole namespace. The action is now the annotationkopiur.home-operations.com/allow-reinitialize: <status.uniqueId>, honored only while it equals the pin — self-expiring, because a successful re-init mints a new id.
The ack'd pass is a strict (non-probe) bootstrap, so the existing
success_fold(probe_run = false, ..) → Heal path resets the breaker; no second
reset path was added.
6. kstatus: Degraded is Reconciling, not Stalled¶
crates/controller/src/io/finalizer.rs::ready_outcome_for_phase maps
Degraded → Reconciling. For Flux/kstatus tooling that means flux wait /
health checks wait on a breaker-open repository rather than failing the
Kustomization: the state is self-healing by construction, which is exactly what
Reconciling means. Failed remains Stalled.
7. Missed-slot semantics per concurrencyPolicy¶
While the breaker is open, a SnapshotSchedule keeps ticking:
Forbid(default): the first slot'sSnapshotparksPendingand counts as the active run, so later slotsWait— parked work is bounded at one, and on recovery that pinned stale slot fires exactly once (the catch-up), after which the cadence is normal again.Allow: onePendingper slot, by that policy's declared overlap contract — choosingAllowis choosing unbounded overlap.startingDeadlineSecondsnow actually works for a due-but-expired slot: it is skipped and re-pinned forward (CronJob semantics,snapshot_schedule.rs::SlotDisposition::SkipExpired) instead of wedging the reconciler in a 1-second requeue loop on a past slot — the M1 fix that makes "bounded parked work" true even for schedules that set a deadline.
8. Observability¶
New metrics (crates/controller/src/metrics.rs):
kopiur_repository_breaker_trips_total{kind,namespace,name,probe_kind}
(transition-only counter),
kopiur_repository_consecutive_backend_failures{kind,namespace,name}
(store-backed; emitted whenever health status exists, so the post-recovery 0
is visible), kopiur_repository_breaker_open_since_timestamp_seconds (series
exists only while open — time() - metric is the open duration), and
kopiur_snapshot_gated{namespace,policy} (parked-Pending population; drains to
absence on recovery). Helm ships two rules
(deploy/helm/kopiur/templates/prometheusrule.tpl):
KopiurRepositoryBreakerOpen (warning, 15m) and KopiurSnapshotsGated (info,
30m), plus dashboard panels (deploy/dashboards/kopiur.json).
Consequences¶
Positive. The #345 incident cannot repeat: an outage now costs at most the
in-flight backup plus the one doomed Job launched inside the detection window —
then the breaker opens, work parks Pending (visible, bounded, never lost),
kubectl get repository says Degraded, alerts name the breaker, and when the
backend returns everything heals and catches up with zero human actions.
Detection is proactive (default-on probe) instead of next-backup-reactive, so
an outage between backups is seen within one probe interval.
Costs.
- One connect Job per repository per probe interval (default 30m), for every
object-store/server/volume-backed repository in the fleet, forever — the
price of watching. Tune
intervalup for metered backends, orenabled: falseto opt a repository out entirely (which also disables the breaker: the probe is its only sensor). - Repositories can now leave
Readywithout any spec change — a behavior change GitOps/status tooling must expect (mitigated by theDegraded→Reconcilingkstatus mapping). - A backup window can be missed deliberately: while open, nothing even tries.
Better one missed window than a stream of dead Jobs, but users who truly
prefer try-anyway set
onFailure: Alert.
Residual/accepted.
- The detection window is small, not zero. A backup launched between the outage's start and the threshold-crossing probe still fails — one doomed Job per outage per repository, same as before; the breaker removes the per-tick pile-up, not the first casualty.
- Alert-mode +
spec.preflightcomposes sharply. ASnapshotPolicypreflight usingrepository.backendReachablewill hold backupsPendingand thenFailedoncepreflight.timeoutelapses — so an Alert-mode fleet with that preflight still accumulates boundedFailedCRs during an outage. Accepted: both knobs are explicit user configuration, and the preflight timeout is the user's own bound. Allowschedules park onePendingper slot during an outage, by contract (see §7).
Upgrade notes¶
- The CRD default materializes into stored objects.
probe.enabledandonFailureare schema defaults: the apiserver stampsenabled: true(andonFailure: Degradewhere a probe block exists) into stored objects on their next write. A GitOps repo that had writtenspec.health.probe: { interval: … }withoutenabledwill see a server-side diff (enabled: trueappearing) — expected, one-time, and semantically what was already happening. - Behavior change: repositories can leave
Readywithout a spec change. Anything that treatedphase == Readyas a permanent post-bootstrap state (custom dashboards, scripts) must now handleDegraded. - First probe shortly after upgrade for never-probed repositories — the probe-clock seeding (§1) only helps repositories whose upgrade-era reconcile performs a successful connect/finalize first; a long-idle repository probes within its first interval after the new operator starts. Fleet-wide that is one wave of short connect Jobs.
- Restoring the old behavior is two lines per repository:
health: { probe: { onFailure: Alert } }(alert-only, never pauses) orhealth: { probe: { enabled: false } }(no probe, no breaker — back to next-backup-reactive detection). - New metrics/alerts — see §8; the Helm PrometheusRule and dashboard ship updated in the same release.
Alternatives considered¶
- A second counter/probe dedicated to the breaker (keep the alert probe
as-was, add a breaker-specific health check). Rejected — two sensors over
the same backend drift, double the Job cost, and invite the question of
which one the gate believes. One sensor, one streak, one verdict
(
health.rskeeps the whole state machine in one place, exhaustively matched). - Feeding the breaker from consumer (mover) failures. Rejected — a broken source PVC or hook would trip a repository-wide pause over a single sick workload. Consumers nudge the sensor; only connect verdicts count (§2).
- A manual ack to close, à la ADR-0006's mass-deletion breaker. Rejected — that breaker guards an irreversible destructive fan-out, so a human in the loop is the point. This breaker guards a pause: non-destructive, self-healing, and an ack requirement would turn every transient NAS reboot into a paging event with a manual step.
- Gating at the schedule tick instead of the repository (don't mint
SnapshotCRs while the repository is down). Rejected — aSnapshotSchedule'spolicySelectorcan span policies on different repositories, so the tick has no single repository to gate on; and a parkedPendingCR is honest, visible state (kopiur_snapshot_gated) that launches itself on recovery, whereas an un-minted slot is invisible. TheForbidbound already keeps parked volume at one. - Terminal
Failedon outage (the old strict-verdict behavior) with better docs. Rejected —Failed/Stalledneeds a spec/Secret change to retry, which turns every outage into a manual recovery; and it is a lie: nothing is wrong with the spec.Degraded/Reconcilingwith automatic retry states the truth.