Repository navigation
bug(backups/postgres): CNPG in-place restore purge-skip relies on creationTimestamp, not true provenance #3322
Description
Summary
The CNPG in-place restore reconciler decides whether to skip the destructive Cluster + PVC purge using the live Cluster's creationTimestamp relative to the RestoreJob's StartedAt (internal/backupcontroller/cnpgstrategy_controller.go, cnpgClusterFreshlyRecovered + the cnpgPurgeNeeded call site). The timestamp is a heuristic for "this restore produced the live recovery Cluster", not true provenance. Two edge cases can defeat it and let a restore report Succeeded against the wrong/stale data.
Exposure
-
Concurrent RestoreJobs to the same app. The RestoreJob controller is a plain reconciler with no per-target mutual exclusion. If a second restore starts before the first restore's replacement Cluster is rendered, the timestamp cannot prove which job produced that Cluster; the second job may skip its purge and succeed against the first job's backup.
-
External / chart re-creation of the old Cluster during the pre-purge window.
StartedAtis stamped on the first reconcile, but the purge happens on a later reconcile and the window can span the WAL-archive gate (up tocnpgWALArchiveDeadline, ~3m). If the old recovery Cluster is deleted during that window (operator action, a competing controller) the HelmRelease is still active and re-renders it from the previous restore's config, giving it acreationTimestampafterStartedAt. The reconciler then treats it as freshly-recovered, skips patch + purge, and can report success on stale data.
Not a regression
This is pre-existing. The guard before #3318 keyed only on hasRecovery and had the same (larger) exposure. #3318 narrowed it to a strict subset (hasRecovery && createdAt > StartedAt), which removes the common repeat-restore no-op (#3311) but does not establish true provenance. This issue tracks closing the residual gap, not fixing a regression introduced by #3318.
Impact
Data integrity: in the two edge cases above a restore can report Succeeded while the database holds data from the wrong backup or the pre-restore state. Requires concurrency or external Cluster deletion, so it is not the common path, but the failure is silent.
Proposed hardening (any of, in rough order of robustness)
- Record the purged Cluster's UID (or a restore-specific marker) on the
TargetPurgedcondition at purge time, and only skip re-purge when the live Cluster is confirmed to be the object this restore produced. - Additionally gate the skip on the live Cluster's
spec.bootstrap.recovery.source(and recovery target time) matching this restore's intended source, so a Cluster re-created from a different prior-restore config is still purged. - Enforce target-level mutual exclusion for RestoreJobs against the same Postgres app so concurrent restores cannot interleave.
References
- Surfaced during review of fix(backups/postgres): purge stale recovery cluster on repeat in-place restore #3318.
- Original silent-no-op bug fixed by fix(backups/postgres): purge stale recovery cluster on repeat in-place restore #3318: bug(backups/postgres): repeat in-place RestoreJob silently succeeds without restoring (destructive purge skipped when target still carries bootstrap.recovery) #3311.
- Anchor:
internal/backupcontroller/cnpgstrategy_controller.go—cnpgClusterFreshlyRecoveredand thecnpgPurgeNeededcall site.
Activity
fix(backups): refuse a restore into an application another restore holds
fix(backups): skip the CNPG restore purge only for this restore's Clu…
fix(backups): refuse a restore into an application another restore holds
fix(backups): skip the CNPG restore purge only for this restore's Clu…
fix(backups): refuse a restore into an application another restore holds
fix(backups): skip the CNPG restore purge only for this restore's Clu…
fix(backups): refuse a restore into an application another restore holds
fix(backups): skip the CNPG restore purge only for this restore's Clu…
fix(backups): refuse concurrent restores and skip the CNPG purge only…