Skip to content

feat: fail loudly, recover automatically#143

Open
bmorton wants to merge 28 commits into
mainfrom
feat/fail-loudly-recover-automatically
Open

feat: fail loudly, recover automatically#143
bmorton wants to merge 28 commits into
mainfrom
feat/fail-loudly-recover-automatically

Conversation

@bmorton

@bmorton bmorton commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Implements docs/superpowers/specs/2026-07-25-fail-loudly-recover-automatically-design.md.

Principle: every abnormal state is (1) named by a condition, (2) either auto-recovered on a bounded schedule or terminated with evidence, and (3) observable without reading operator logs. No stall is silent; no recovery is unbounded.

The four silent failures this fixes

Before After
A stalled upgrade hung forever with no condition — a crashlooping service left the cluster on mixed Temporal versions indefinitely UpgradeBlocked + Degraded past --upgrade-phase-timeout, naming the stalled service and the Deployment's own unready reason. Self-clears when the rollout completes — a report, not a latch
A permanently failed schema Job wedged the cluster until a human deleted it Recreated at 1m/5m/15m, then terminal with SchemaReady=False + Degraded. The failed Job is retained so its pod logs stay readable. Safe to re-run: the schema tools run without --overwrite
Cleanup was abandoned on any error, orphaning Temporal-side objects when a frontend was merely restarting An absent cluster still forgets immediately (preserving the #58 guarantee that deletion always terminates); an unreachable one retries to cleanupDeadline, then abandons with a CleanupAbandoned warning and a counter
409 Conflicts on status updates were swallowed, losing Ready transitions Retried in a shared helper — fixed once instead of seven times

Also

  • Every condition on all 8 CRDs is exported as temporal_operator_resource_condition, collected on scrape so deleted resources stop appearing and future conditions need no new plumbing
  • Four domain metrics, four PrometheusRule alerts, Events in all 8 controllers (was 2)
  • Degraded and Progressing — declared but previously unused — are now set
  • Bounded gRPC deadlines: grpc.NewClient is lazy, so an unreachable frontend previously hung a controller on first RPC
  • Webhook guards mid-upgrade version changes, allowing a revert to fromVersion as the documented escape hatch (with a warning once the schema has migrated)
  • New upgrade-stall e2e suite; -race enabled in make test
  • Troubleshooting, operations and architecture docs for each new failure state

Verification

make build ✓ · make lint 0 issues ✓ · make test passes with -race, no data races ✓ · make generate manifests helm-chart zero drift ✓ · coverage 48.1% → 49.3%

Issue #58's regression tests pass unmodified.

Notes for review

Three bugs were caught during implementation that would otherwise have shipped:

  1. A permanently-silent severity: critical alert. The original expr matched status="False" and compared == 1, but that gauge is 1 only when a condition is True — the alert for an unstartable cluster could never fire. Fixed, with TestConditionAlertComparisons now guarding the whole class.
  2. A test that would have stayed green if the guard it tested were deleted.
  3. Connection cleanup bounded post-dial RPC errors but not resolve/dial errors — the exact case the design was written for.

Known follow-up: a local peer that resolves but is ready=false is skipped during connection deletion. Pre-existing behavior, not introduced here, but a gap relative to the spec's wording.

API changes are status-only; no spec surface added.

bmorton and others added 28 commits July 25, 2026 06:29
Audits the operator's silent failure modes (stalled upgrades, wedged
schema Jobs, abandoned cleanup, dropped status conflicts) and designs
bounded recovery plus condition-derived observability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Twenty TDD tasks across four phases: shared foundations, recovery
semantics, condition-derived observability, and verification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Add Policy type for managing retry delays and Decision type for
authorization decisions. Implement DeadlineExceeded and Remaining
helper functions for bounded deadline checking.

Include SchemaJobPolicy with 1m/5m/15m delays for database schema
Job recreation. All functions are pure with time-injection to enable
unit testing without clock dependencies.

Coverage: 92.9%
Signed-off-by: Brian Morton <brian@mmmhm.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Finding 1: Add 'since' column to TestDeadlineExceeded table, replacing
the name-equality override. Use plausible 'now' (start+4min) for the
zero-since row to properly guard the invariant that zero 'since' never
expires regardless of current time. This catches regressions where the
'if since.IsZero() { return false }' guard is removed.

Finding 2: Add 'wantAttempt' column to TestPolicyNext table and assert
on Decision.Attempt in both TestPolicyNext and
TestPolicyNextNegativeAttemptsTreatedAsZero.

Finding 3: Add test case for Remaining with zero 'since', covering the
'if since.IsZero() { return deadline }' branch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Wraps k8s.io/client-go/tools/events.EventRecorder with per-object,
per-eventtype, per-reason deduplication so states re-evaluated on every
reconcile (stalled upgrade, unreachable target) emit one Event per state
change rather than one per requeue.

- New(nil) returns nil; nil *Recorder drops all events safely
- Forget(obj) clears an object's dedupe state for re-entry reporting
- sync.Mutex protects the shared dedupe map for concurrent reconciles
- Reuses reason as the action verb per existing convention

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
- Extend TestNilRecorderIsSafe to test Warning() and Forget() in addition to Normal()
- Add TestForgetIsolationPreventsPrefixCollisions to verify Forget() does not
  delete entries for different objects when one UID is a substring of another
- Add comment in emit() explaining fallback behavior for objects without usable key

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Replaces seven near-identical setReady/statusUpdate pairs with the shared
helper. Fixes the swallowed 409 Conflict in the TemporalClusterClient and
TemporalDevServer reconcilers, and fixes the missing observedGeneration
stamp on dev.Status that TemporalDevServer's setReady previously omitted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Remove unused 'want' parameter from assertStamped by hoisting the
generation constant to file scope as testGeneration, allowing the helper
to reference it directly. This fixes the unparam lint failure.

Also correct the comment attribution: TemporalDevServer (not
TemporalClusterClient) was the controller that did not stamp
Status.ObservedGeneration before the migration to internal/status.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
A rolling phase that exceeds upgradePhaseTimeout now sets UpgradeBlocked
and Degraded with the Deployment's own failure reason, emits a warning
event, and requeues. The condition clears automatically when the rollout
completes. Also moves Rollbackable=false to the schema-migrating phase,
matching the field's documented contract.

The existing upgrade test's Rollbackable assertion (BeFalse during
SchemaMigrating) encoded the bug: with the move, Rollbackable is still
true when the phase is first observed at SchemaMigrating (the operator
has not yet processed a schema step). Updated the assertion to BeTrue
and revised the By description to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Rejects retargeting a running upgrade to a third version, allows a revert
to status.upgrade.fromVersion as the documented escape hatch, and warns
when that revert happens after the schema has already migrated.

Also fixes validateUpgradePath, which validated from spec.version (already
the target mid-upgrade) instead of the true baseline in fromVersion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
A schema Job that exhausts its BackoffLimit is now deleted and recreated
after 1m, 5m, then 15m before the operator gives up with a terminal
SchemaReady=False and Degraded=True. The failed Job is retained on
give-up so its pod logs stay inspectable. Recreation is safe: the schema
tools run without --overwrite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
A target that is absent still forgets immediately, preserving the issue
#58 guarantee that deletion always terminates. A target that exists but
is unreachable now retries for up to cleanupDeadline before abandoning
with a CleanupAbandoned warning event and a counter increment, rather
than orphaning the Temporal object on the first transient error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
grpc.NewClient is lazy, so a half-open connection hangs on the first RPC
rather than at dial. Every client method now derives a bounded context,
so an unreachable frontend can no longer stall a controller indefinitely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Tasks 7 and 9 added status fields (upgrade.phaseStartedAt, stalledService,
message; persistence.schemaAttempts) but did not regenerate dist/chart.
The Verify generated chart CI job would have failed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
- Create internal/metrics/domain.go with CleanupAbandoned, TargetUnreachable,
  UpgradePhaseSeconds, and SchemaJobAttempts metrics registered idempotently
  into controller-runtime's registry
- Wire CleanupAbandoned and TargetUnreachable increments into all four
  cleanupUnreachable/reconcileDelete branches (TemporalNamespace,
  TemporalSchedule, TemporalSearchAttribute, TemporalClusterConnection)
- Wire UpgradePhaseSeconds gauge in reconcileUpgrade with DeletePartialMatch
  on completion to clear stale series
- Wire SchemaJobAttempts gauge in handleFailedSchemaJob and
  DeleteLabelValues in resetSchemaAttempts for stale-series hygiene
- Fix missing Progressing=True condition set on TemporalClusterConnection
  cleanupRetry branch to match the other three controllers
- Promote github.com/prometheus/client_golang to a direct dependency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Adds ConditionCollector, a prometheus.Collector that reads every
condition on every Temporal CRD from the manager's cache on scrape.
This avoids stale series for deleted objects and makes all conditions
added in Phases 1-2 queryable with no per-condition plumbing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
…controller recorders

- Call metrics.Register() and register ConditionCollector with
  controller-runtime's metrics registry after manager construction
- Add Events *events.Recorder field to TemporalClusterReconciler,
  TemporalClusterClientReconciler, TemporalDevServerReconciler, and
  TemporalWorkflowRunReconciler (four reconcilers already had it)
- Populate Events on all eight reconciler constructions in cmd/main.go
  with distinct per-controller recorder names

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Add four PrometheusRule alerts covering the operator failure modes surfaced
in Tasks 13-15:

- TemporalClusterUpgradeStalled: fires after 5m when UpgradeBlocked=True,
  indicating mixed server versions due to a stuck rolling upgrade.
- TemporalSchemaMigrationFailed: fires after 1m when SchemaReady=False with
  reason SchemaMigrationFailed, meaning the retry budget is exhausted.
- TemporalResourceDegraded: fires after 15m when any resource has Degraded=True.
- TemporalCleanupAbandoned: fires after 1m when cleanup_abandoned_total rises,
  meaning a Temporal-side object was orphaned.

All alerts have a 'for:' duration to prevent single-scrape flapping.

The chart generator (helmgen) does not pick up config/prometheus/ because it
is commented out in config/default/kustomization.yaml. A hand-maintained
override at hack/helm/overrides/templates/monitoring/alerts.yaml is used with
the chart's standard {{- if .Values.prometheus.enable }} gate and
{{- include "chart.labels" . | nindent 4 }} helper, matching the existing
servicemonitor.yaml convention.

The test in internal/metrics/alerts_test.go uses a relative path
../../config/prometheus/alerts.yaml. This couples the test to repo layout but
provides a meaningful drift guard (an alert referencing a nonexistent metric
or missing 'for:' fails the test). A future improvement could embed the file
via go:embed or accept the path via a test flag to reduce fragility.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
The temporal_operator_resource_condition gauge emits one series per condition
with the condition status as a label. The gauge value is 1 only when the
condition is True, so a False condition always carries value 0.

The TemporalSchemaMigrationFailed alert matched status="False" but compared
== 1, which can never be true. The alert was permanently silent for the
critical path where a schema migration has failed and the cluster cannot start.

Fix: change the comparison to == 0. Add an explanatory comment so the next
reader understands why this alert compares to 0 while the others compare to 1.

Add TestConditionAlertComparisons in alerts_test.go to enforce the invariant:
exprs matching status="True" must compare == 1, and exprs matching
status="False" must compare == 0. Regenerate dist/chart via make helm-chart.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Covers the failure mode with no prior e2e coverage: an upgrade that
cannot roll out must report UpgradeBlocked, reject retargeting, and
recover when spec.version is reverted.

Also adds --upgrade-phase-timeout flag (default 15m) to cmd/main.go
so e2e can shorten it via --upgrade-phase-timeout=1m.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
…on check

- Use a bash array for extra_args in e2e.yml to prevent glob expansion
  of [0]/[1] subscripts and word-splitting of multi-word strings
- Capture stderr in reject-retarget-while-stalled and require the failure
  to contain an admission-rejection keyword; transient API errors no
  longer cause a false pass
- Add status.version assertion to 01-assert.yaml to match the reference
  upgrade suite baseline

Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Brian Morton <brian@mmmhm.com>
Add the -race flag to the go test invocation in the test target to enable
the race detector for all unit and integration tests. The race detector has
been verified to pass without detecting any data races in the full suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
…anup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
… connection deletion

During deletion, local peers that cannot be resolved (non-NotFound error,
e.g. TLS secret missing) or that resolve successfully but cannot be dialed
were silently skipped: they never entered the locals map, removeErr stayed
nil, and the finalizer was removed immediately. This orphaned remote-cluster
registrations on surviving peers with no trace.

Introduce localPeerErrors, which collects errors for local peers expected
to participate in cleanup but unreachable. The result is joined with
removeErr and passed through decideCleanup, giving those failures the same
bounded-retry / CleanupAbandoned treatment as RemoveRemoteCluster failures
already had.

ErrTargetNotFound peers are excluded from the new path: resolvePeers never
sets resolveErr for them and their empty address fails the ready+address
gate, preserving the issue #58 immediate-forget guarantee.

Update the connection controller test to verify the corrected behaviour:
- within the cleanup deadline the finalizer is retained and a retry is
  scheduled (RequeueAfter > 0)
- with cleanupDeadline collapsed to 0 the abandon path fires and the object
  is eventually garbage-collected

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
IsTrue has zero callers anywhere in the repository. Removing it shrinks
the public API surface and eliminates dead code with no test coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
Tasks adding UpgradeStatus.phaseStartedAt/stalledService/message and
PersistenceStatus.schemaAttempts ran `make generate manifests` and
`make helm-chart` but not `make api-docs docs-crd-reference`, leaving
the generated CRD reference stale and failing the Verify generated docs
CI job.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2fd4be6c-6d19-4724-a9ec-211a7a6daffb
Signed-off-by: Brian Morton <brian@mmmhm.com>
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.

1 participant