Skip to content

fix(cloud-backups): stop paging on self-healing backup retries - #14

Merged
logicflakes merged 2 commits into
mainfrom
2026-08-oci-backup-alert-noise
Aug 15, 2026
Merged

fix(cloud-backups): stop paging on self-healing backup retries#14
logicflakes merged 2 commits into
mainfrom
2026-08-oci-backup-alert-noise

Conversation

@logicflakes

Copy link
Copy Markdown
Collaborator

What happened

The oci-artifacts-backup CronJob alerted on a customer prod instance on 2026-08-08:

error = upload failed: reading first block of "rebom-artifacts-2026-08.tar.gz.age": backup failed:
  oras backup failed: exit status 1 | Logs: Error: failed to find tags: Get
  "https://<acr>.azurecr.io/v2/rearm-artifacts/rebom-artifacts-2026-08/tags/list?last=rebom-02cb38e2-...&n=100&orderby=":
  dial tcp 10.0.5.5:443: connect: connection refused

No backup was lost. The alert carried attempt = 1, and no backup_exhausted or
pipeline_completed_with_failures followed it. Attempt 2 completed the upload. The alert fired only
because backup_attempt_failed was logged at ERROR and the operator's rule pages on any
level=ERROR. Since only ERROR-level logs leave the pod, the successful outcome was invisible and
every transient blip read as an outage. There was no fix on 2026-08-01 either: the only change was
an image roll whose cloud-backups delta is the audit-rotate refactor, so the repeat was expected.

Root cause underneath

oras backup aborts an entire repo on a single un-retried ECONNREFUSED during tag enumeration.
oras-go's retry predicate (registry/remote/retry/policy.go) retries dial timeouts only:
ECONNREFUSED has Timeout() == false, so it gives up after one dial. Reproduced against a fake
ACR paginator emitting the same Link: rel="next" header, fault-injecting on page 2:

injected on a tags/list page result
connection refused exit 1, aborts, 0-byte tar (identical error string to prod)
connection reset retried, succeeded
HTTP 429 retried, succeeded
HTTP 500 retried, succeeded

The tool's own 3x retry already absorbs this, so this PR leaves the registry behaviour alone and
fixes the reporting instead.

Changes

Alerting levels

  • backup_attempt_failed drops to WARN while a retry remains. backup_exhausted becomes the single
    ERROR for a target that really failed, and now carries attempts_used plus every attempt's cause
    (it previously had no error field, so a genuine give-up alerted with a blank cause).
  • New WARN backup_recovered_after_retry, so a repeatedly flaky registry stays visible without paging.
  • pipeline_completed_with_failures / pipeline_failed_all_repos_missing gained a flat error
    field naming the targets; the failing paths lived only inside a nested summary map.
  • Both sites used "msg" as an attr key. slog's JSON handler already emits the event name as
    msg, so the record carried a duplicate key and every JSON parser keeps the last one -- the event
    name never survived parsing, which defeated the point of adding a flat field. Renamed to detail.
  • Causes are truncated per attempt before being joined (bounding a ~24KB single field), and
    PreflightCheck stderr now uses a tailBuffer like its Backup/Restore siblings rather than an
    unbounded builder.

Behaviour

  • PreflightCheck ran outside the retry loop, so the same transient failure landing on the probe
    aborted the whole job for every target with zero retries. Now shares the backup retry/backoff via
    RunPreflightWithRetry, auth fast-fail preserved.
  • Under explicit registry paths, a skipped (absent) repository is now an ERROR. PrintSummary only
    escalates when every target is missing, so 49 of 50 could vanish at INFO with exit 0. Rolling
    months stays exempt: it deliberately fabricates a previous-month path that may legitimately not
    exist yet, and alerting on that would recreate the noise this PR removes.

Verification

End-to-end against a local rig reproducing the exact production failure (fake ACR Link-header
pagination + oras 1.3.3 + registry:2 + minio, real cloud-backup binary):

scenario ERROR lines reaching the operator
A: refusal during backup (the Aug-8 case) 0 (WARN attempt, WARN recovered, backup succeeds)
B: refusal during preflight (previously killed the whole job) 0 (retries, job completes)
C: sustained refusal (genuine data loss) 2, both naming the target and the causes

New tests pin the level of all five terminal outcomes (table-driven) and guard against reintroducing
the duplicate-msg key. All are mutation-checked: reverting each guarantee fails its test.
go build / go vet / go test ./... green, gofmt clean, added lines pure ASCII.

Review

Two-layer self-review gate run before this PR. Layer 2 (conventions) found the duplicate-msg bug,
the duplicated auth predicate, and that truncation had been applied to the WARN path while the ERROR
path stayed raw -- all fixed here. Not run: /security-review, since this touches no auth, secrets,
input handling or crypto (the aggregated error field carries the same tool output already logged
per attempt, so it is no new exposure).

Deliberately out of scope

  • cmd/pg_backup.go:113 and cmd/pg_audit_rotate.go:137 call PreflightCheck with the identical
    no-retry structure. Not touched here: pg audit-rotate is the rotate-backup-drop path and
    widening this PR into a destructive lane I have not validated is not worth it. Worth a follow-up.
  • Making oras's tag phase resumable or batched, which would make an ECONNREFUSED survivable within
    a single attempt rather than costing a full restart. That is a real redesign.
  • The underlying network question -- what closed the keep-alive to the private endpoint and then
    refused the redial. It is environmental and currently self-heals within ~20s.

🤖 Generated with Claude Code

Rhythm Garg and others added 2 commits August 14, 2026 15:26
The oci-artifacts-backup CronJob alerted on a customer prod instance on
2026-08-08. Investigation showed the backup never actually failed: attempt
1 died on a refused TCP dial to the ACR private endpoint during tag
pagination, and attempt 2 completed the upload. The alert fired only
because backup_attempt_failed was logged at ERROR, and the operator's rule
pages on any level=ERROR. Only ERROR-level logs leave the pod, so the
successful outcome was invisible and every blip read as an outage.

Underneath, `oras backup` aborts a whole repo on one ECONNREFUSED:
oras-go's retry predicate retries dial TIMEOUTS only, so a refused
connection is never retried (reproduced against a fake ACR paginator; a
reset, a 429 and a 500 injected at the same point all recover). The tool's
own 3x retry already absorbs that, so the registry behaviour is left
alone and the reporting is fixed instead.

Alerting levels:
- backup_attempt_failed drops to WARN while a retry remains. The single
  ERROR for a target that really failed is backup_exhausted, which now
  carries attempts_used and every attempt's cause -- it previously had no
  error field at all, so a genuine give-up alerted with a blank cause.
- New WARN backup_recovered_after_retry, so a repeatedly flaky registry is
  still visible without paging.
- pipeline_completed_with_failures and pipeline_failed_all_repos_missing
  gained a flat error field naming the targets; the failing paths were
  only inside a nested summary map, which alerting renders opaquely.
- Both sites used "msg" as an attr key. slog's JSON handler already emits
  the event name as "msg", so the record carried a duplicate key and every
  JSON parser kept the last one -- the event name never survived parsing.
  Renamed to "detail".
- Causes are truncated per attempt (MaxCauseLength) before being joined,
  bounding what was a ~24KB single field, and PreflightCheck's stderr now
  uses a tailBuffer like its Backup/Restore siblings instead of an
  unbounded builder.

Behaviour:
- PreflightCheck ran outside the retry loop, so the same transient failure
  landing on the probe aborted the entire job for every target with no
  retry at all. It now shares the backup retry/backoff via
  RunPreflightWithRetry, with the auth fast-fail preserved.
- Under explicit registry paths, a skipped (absent) repository is now an
  ERROR. PrintSummary only escalates when EVERY target is missing, so 49
  of 50 could vanish at INFO with exit 0. Rolling months stays exempt,
  since it deliberately fabricates a previous-month path that legitimately
  may not exist yet.

Verified end to end against a local rig reproducing the exact production
error (fake ACR Link-header pagination + oras 1.3.3 + minio): the Aug-8
scenario now emits zero ERROR lines, a preflight refusal survives, and a
sustained refusal emits exactly two ERRORs that name the target and the
causes. New tests pin the level of all five terminal outcomes and are
mutation-checked -- reverting each guarantee fails its test.

Co-Authored-By: Claude <noreply@anthropic.com>
ReARM-Agent: 1420896f-adf5-4843-896f-d863cfcc6528
ReARM-Agentic-Session: 94afdb9d-05d3-44ad-abcd-92e753ddf2df
…review

Four independent review lenses were run against the previous commit, each
blind to the others. One REFUTED its central safety claim with a
reproducible counterexample, and the others found defects the first
commit introduced. All of the following are proven by execution, not
inspection.

The counterexample: a repository can be silently skipped, producing no
backup, no ERROR and exit 0. `repositoryAbsent` classified any log tail
containing bare "404" as "repository does not exist". Content digests are
hex and contain "404" in ~1.5% of cases, so a refused dial on a repo with
enough blobs in the 8KB tail was misread as an absence -- the same
ECONNREFUSED that started this investigation. The skip then set
jobHandled, bypassed the remaining retries, and under the shipped
appendRollingMonths=true default produced pipeline_completed_successfully.

- repositoryAbsent now vetoes the classification on any transport marker
  (connection refused/reset, dial tcp, i/o timeout, no such host, TLS
  handshake, unexpected EOF, deadline exceeded), matches
  case-insensitively, and recognises the canonical distribution error
  ("name unknown"). The case-sensitivity gap was also making preflight
  abort entire runs against distribution-compatible registries, verified
  live -- the previous commit's preflight retry merely made that failure
  60s slower.
- A skip that lands AFTER an attempt already failed for another reason is
  now an ERROR carrying the earlier causes, since the absence is
  unconfirmed.
- The rolling-months exemption for missing targets was too broad: it
  exempted the whole run, but only the PREVIOUS-month target may
  legitimately be absent. The current month is being actively written.
  orchestrator.SkipIsExpected now decides this, sharing the date
  arithmetic with resolveTargets rather than re-deriving it.

Regressions the previous commit introduced, now fixed:

- TruncateCause kept the HEAD of what is already a TAIL buffer. These
  tools print their diagnostic last, so the surviving ERROR contained
  upload progress noise and the line naming the fault was dropped -
  exactly inverting the intent. It now keeps the tail.
- PreflightCheck was switched to a tailBuffer, but its output is what the
  absence/auth predicates match on, so bounding it changed CLASSIFICATION,
  not just message size. Reverted; the surfaced string is bounded at the
  call site instead, where it is display-only.
- The missing-targets ERROR counted len(skipped), a slice capped at
  MaxPathsTracked=100, so 150 skips of 200 reported "100 of 200".
- PrintSummary joined those same capped lists with no marker, and pg
  audit-rotate records the database name once per archive, so the field
  read "3 of 3 target(s) failed: rearm, rearm, rearm". formatPaths now
  dedupes and discloses truncation.
- Abandoning mid-retry (context cancelled during backoff) returned
  silently; the causes were WARN-only and therefore invisible. Now emits
  backup_abandoned at ERROR.
- pipeline_failed_all_repos_missing and backup_targets_missing_from_registry
  both fired for the all-skipped case. Deduplicated.

Everything added outside internal/pipeline previously had no tests, which
is why the cap bug got through. Added coverage for repositoryAbsent (both
directions), SkipIsExpected, PreviousMonthSuffix across a year boundary,
formatPaths, GetSkipped aliasing, and the three new ERROR paths.

Verified end to end on the rig: the Aug-8 transient refusal still emits
zero ERRORs; an absent CURRENT-month repo now emits one ERROR naming it;
an absent previous-month repo stays silent; a sustained refusal emits two
ERRORs carrying the causes. go test -race clean.

Reviewed but deliberately NOT fixed here, filed separately: encryption
silently disabled when the optional secret key is absent (writes a
plaintext backup, exit 0); no dead man's switch; a panic in the upload
goroutine escapes the recover(); ~36h worst-case time-to-first-ERROR.

Co-Authored-By: Claude <noreply@anthropic.com>
ReARM-Agent: 1420896f-adf5-4843-896f-d863cfcc6528
ReARM-Agentic-Session: 94afdb9d-05d3-44ad-abcd-92e753ddf2df
@logicflakes

Copy link
Copy Markdown
Collaborator Author

Adversarial validation: four independent lenses, one refutation

Ran four blind review lenses against 2714bb3 to check the central risk of this PR -- that demoting per-attempt failures to WARN hides real errors. One refuted the safety claim with a reproducible counterexample. 8c4f644 fixes everything found.

The counterexample (now closed)

A repository could be silently skipped: no backup, no ERROR, exit 0. repositoryAbsent treated any log tail containing bare "404" as "repository does not exist". Content digests are hex and contain 404 in ~1.5% of cases, so a refused dial on a repo with enough blobs in the 8KB tail was misread as an absence -- the same ECONNREFUSED that started this investigation. The skip set jobHandled, bypassed the remaining retries, and under the shipped appendRollingMonths: true default produced pipeline_completed_successfully.

Measured collision probability: 26% for a repo with ~130 blobs in the tail, 69% at 500.

Fixed by vetoing the absence classification on any transport marker, matching case-insensitively, and recognising the canonical distribution error (name unknown). That last gap was separately making preflight abort entire runs against distribution-compatible registries -- verified live; this PR's preflight retry had only made that failure 60s slower.

Regressions this PR had introduced

  • TruncateCause kept the HEAD of what is already a TAIL buffer. These tools print their diagnostic last, so the surviving ERROR held upload progress noise and the line naming the fault was dropped -- exactly inverting the intent. Proven: baseline ERROR contained unexpected status code 500, this branch's did not.
  • The PreflightCheck tailBuffer changed classification, not just size -- its output is what the absence/auth predicates match on. Reverted; bounded at the call site instead, where it is display-only.
  • len(skipped) is capped at MaxPathsTracked=100, so 150 skips of 200 reported "100 of 200".
  • PrintSummary joined those capped lists with no marker, and pg audit-rotate records the database name once per archive -> 3 of 3 target(s) failed: rearm, rearm, rearm.
  • Abandoning mid-retry returned silently, with the causes WARN-only.
  • Two ERRORs both fired for the all-skipped case.
  • The rolling-months exemption was too broad -- it exempted the whole run, but only the previous-month target may legitimately be absent.

Blast radius: PG lane verified safe

RunWithRetry and PrintSummary are shared with pg backup and the destructive pg audit-rotate. Verified end-to-end against a real Postgres 16 + MinIO, with a positive control: the table drop is gated on the backup succeeding, not on any log level. A failed upload leaves the archive intact, emits three ERRORs and exits 1.

Rig results after the fixes

scenario ERROR lines to operator
Aug-8 transient refusal 0 (correct -- backup succeeds)
CURRENT-month repo absent, rolling months 1, naming the target (was 0)
previous-month repo absent 0 (correct -- legitimate)
sustained refusal 2, carrying the causes

go test -race clean. Everything added outside internal/pipeline previously had no tests -- which is why the cap bug got through; coverage added for all of it.

Filed separately, NOT in this PR

  • [HIGH/security] Encryption silently disabled. The chart mounts encryption-password optional: true; a missing key resolves to "" and buildWriterModifiers treats that as "don't encrypt", with no validation. Proven: removing the key wrote a plaintext .tar.gz that was opened with no key at all, 0 ERROR, exit 0 -- and the .age object is orphaned at its last-good content, so a freshness check keyed on that name still looks healthy. ValidatePGAuditRotate has exactly this guard behind --allow-unencrypted; ValidateBackup has none.
  • No dead man's switch (a healthy run and a job that never ran are both silence), compounded by concurrencyPolicy: Forbid with no activeDeadlineSeconds.
  • A panic in the upload goroutine escapes the recover().
  • ~36h worst-case time-to-first-ERROR (3 attempts x 12h default timeout).

@logicflakes
logicflakes merged commit b5e60ae into main Aug 15, 2026
19 checks passed
@logicflakes

Copy link
Copy Markdown
Collaborator Author

Follow-up: yesterday's alert was a false positive from this branch, now fixed (ae37f86)

The guard added here fired on prod for rearm-artifacts/downloadable-artifacts-2026-08. That was noise of exactly the kind this branch exists to remove.

Why. Under rolling months the tool does no discovery -- it fabricates <base>-<current month> and <base>-<previous month> from the clock and attempts each, discovering absence by failing. And a monthly repository is never created explicitly: registries create one implicitly on first push, with both writers deriving the same name from the UTC clock at push time (rebom-backend ociService.getMonthlyRepositoryName, rearm-core ArtifactService.getMonthlyRepositoryName). So a base path with no artifacts in a month has no repository for that month. SBOMs land constantly, so rebom-artifacts always exists; downloadable artifacts only land when a release publishes one. The guard assumed a current-month target must exist -- true for the first path, false for the second -- and would have paged every run until August's first downloadable, then again every new month.

Fix. Alert per base path, not per target: did this base path produce any backup? One month absent is indistinguishable from a quiet month; a base path producing nothing is unambiguous and is what a renamed/deleted/mistyped path looks like. Explicit paths expand to one target each, so the strict behaviour there is preserved by construction rather than by a special case. SkipIsExpected and the current-vs-previous month distinction are gone.

On the existence check. Rejected as unreachable, not merely undesirable. Telling "never created" from "deleted" needs state the credentials don't contain: the registry can't enumerate repositories without catalogue scope, and the destination can't be read back under the write-only credential storage.Provider's contract deliberately preserves. Conditional writes do leak existence with write permission alone (S3 If-None-Match: *, Azure 409 BlobAlreadyExists) -- but the probe that answers "absent" succeeds, leaving an undeleteable 0-byte object at the backup key, destroying the answer it returns.

Worth recording for whoever revisits this: on Azure the write-only ideal in the Provider comment isn't actually achievable with RBAC anyway -- there's no built-in write-only blob role, so the SP must hold Storage Blob Data Contributor, which already grants read. The residual gap (current-month repo deleted while the previous survives) needs either s3:ListBucket/blob read or a per-path expectation flag in the chart. Both remain open; neither is needed for the reported failure.

Verified on the rig against the production shape (rebom both months, downloadable previous month only):

build ERROR lines
shipped code 1 -- reproduces the customer alert verbatim
this commit 0
base path absent in both months 1, naming the path

go test -race clean.

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