Skip to content

fix(backups/postgres): purge stale recovery cluster on repeat in-place restore - #3318

Merged
IvanHunters merged 2 commits into
mainfrom
fix/cnpg-repeat-restore-purge
Jul 16, 2026
Merged

IvanHunters merged 2 commits into
mainfrom
fix/cnpg-repeat-restore-purge

Conversation

@IvanHunters

@IvanHunters IvanHunters commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Fixes a silent data-integrity failure in the CNPG backup driver: a repeat in-place PostgreSQL RestoreJob into a target that was already restored once reported Succeeded but did nothing. The PVC was untouched (same uid), no *-full-recovery pods appeared, and the database kept its pre-restore contents.

Root cause: the restore patch sets spec.bootstrap.enabled=true on the Postgres app and never resets it, so the chart keeps rendering spec.bootstrap.recovery on the live cnpg.io Cluster. On a second RestoreJob the re-purge guard (cnpgPurgeNeeded) saw that leftover bootstrap.recovery and treated it as "an earlier reconcile of this restore already purged", skipping the destructive Cluster + PVC purge. That guard was meant only to avoid re-purging a freshly-recovered Cluster when the post-purge status write races, but it could not tell that case apart from a Cluster left over by a previous completed restore.

The fix distinguishes the two by the live Cluster's creationTimestamp relative to the RestoreJob's StartedAt:

  • created after StartedAt: freshly recovered by this restore's own purge, so it is skipped (unchanged behaviour for the status-write-race path);
  • created at or before StartedAt: leftover from an earlier completed restore, so it is purged and the restore re-bootstraps from the backup.

Design note: an identity-based alternative (record the purged Cluster's UID on the TargetPurged condition and compare UIDs on later reconciles) was considered and rejected. It is immune to clock skew but leans on the same status write that the status-write-race path assumes can fail, so it cannot cover the exact case the skip exists for. The timestamp comparison needs no extra persisted state, and the fresh-vs-stale gap (a full purge plus chart re-render cycle) always dwarfs any plausible control-plane clock skew. The comparison is strict, so an exact-second tie resolves to purge (the conservative direction).

Tests

  • New cnpgClusterFreshlyRecovered unit test: fresh / stale / boundary / missing-timestamp cases.
  • TestCNPGPurgeNeeded extended with the repeat-restore (stale recovery cluster) case that reproduced the bug.
  • recoveryBootstrapClusterState test updated to assert the returned creationTimestamp.
  • New reconcile-level regression test driving reconcileCNPGRestore end to end: a stale leftover recovery Cluster is purged, a freshly-recovered Cluster is not re-purged.

Bug report: #3311

Screenshots

Not applicable (no UI changes).

Downstream repositories

The diff touches only internal/backupcontroller (operator reconcile logic); no API types, CRDs, chart values, or docs change, so no downstream repository is affected.

Release note

fix(postgres): a repeat in-place restore no longer silently succeeds without restoring; a recovery cluster left over from an earlier completed restore is now purged so the restore re-bootstraps from the backup

Summary by CodeRabbit

  • Bug Fixes
    • Improved CNPG restore handling for repeat in-place restores.
    • Stale recovery clusters are now correctly purged instead of being mistaken for newly recovered clusters.
    • Newly recovered clusters are protected from unnecessary destructive cleanup.
    • Restore behavior is more reliable when recovery clusters are left over from earlier attempts.

…e restore

A repeat in-place PostgreSQL RestoreJob into an already-restored target
silently no-opped: the destructive purge was skipped because the live
Cluster still carried spec.bootstrap.recovery left over from the previous
restore, and the re-purge guard mistook that for a freshly-recovered
Cluster produced by this restore's own purge. The job then reported
Succeeded while the PVC, disk, and data were never touched.

Tell the two apart by the Cluster's creationTimestamp relative to the
RestoreJob's StartedAt: only a recovery Cluster created at/after StartedAt
is treated as this restore's own and skipped; one that predates StartedAt
is leftover from an earlier completed restore and is purged so the restore
re-bootstraps from the backup.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…onservative freshness tie

Address review feedback on the repeat in-place restore fix:

- Make the freshness discriminator strict (creationTimestamp > StartedAt) so
  an exact tie resolves to "not fresh" and the caller purges, matching the
  function's stated conservative default; a fresh Cluster is always created
  well after StartedAt so this never misclassifies the status-write-race path.
- Document why a wall-clock timestamp comparison was chosen over recording the
  purged Cluster's UID on the TargetPurged condition (the UID approach leans on
  the same status write the race path assumes can fail).
- Add a reconcile-level regression test that drives reconcileCNPGRestore end to
  end with a fake client: a stale leftover recovery Cluster from a prior restore
  is purged, and a Cluster this restore just re-created is not re-purged. This
  covers the actual call site where #3311 lived, not just the pure helpers.

Signed-off-by: Ivan Okhotnikov <[email protected]>
@IvanHunters IvanHunters added kind/bug Categorizes issue or PR as related to a bug area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) kind/backport Categorizes issue or PR as requiring a backport to the current release line labels Jul 16, 2026
@github-actions github-actions Bot added area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review size/L This PR changes 100-499 lines, ignoring generated files labels Jul 16, 2026
@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

CNPG restore purge logic now distinguishes freshly recovered clusters from stale recovery clusters by comparing cluster creation time with the RestoreJob start time. Recovery state detection returns creation timestamps, reconciliation uses the freshness result, and unit and reconciliation tests cover repeat in-place restores.

Changes

CNPG recovery purge handling

Layer / File(s) Summary
Freshness-aware purge decision
internal/backupcontroller/cnpgstrategy_controller.go, internal/backupcontroller/cnpgstrategy_controller_test.go
cnpgPurgeNeeded now accepts a freshness discriminator, while tests cover stale, fresh, equal, and missing timestamp cases.
Recovery state and reconcile wiring
internal/backupcontroller/cnpgstrategy_controller.go, internal/backupcontroller/cnpgstrategy_controller_test.go
recoveryBootstrapClusterState returns recovery presence and creation time, and reconciliation uses cnpgClusterFreshlyRecovered before deciding whether to purge.
Repeat-restore regression coverage
internal/backupcontroller/cnpgstrategy_controller_test.go
Reconciliation tests verify stale recovery clusters are purged and freshly recovered clusters are not marked for deletion.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • cozystack/cozystack#3312 — Implements the same timestamp-based recovery freshness logic and corresponding CNPG tests.

Suggested reviewers: myasnikovdaniil, androndo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: purging stale recovery clusters during repeat in-place restores.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cnpg-repeat-restore-purge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a data-integrity bug in the CNPG backup driver where repeat in-place restore operations would silently fail to re-bootstrap the database. By refining the logic that determines whether a destructive purge of the existing cluster is required, the system can now accurately identify stale recovery clusters left over from previous operations. This ensures that subsequent restore attempts correctly clear old state and perform a fresh bootstrap from the backup, while still protecting against unnecessary purges caused by race conditions during the initial recovery phase.

Highlights

  • Fixing silent restore failure: Resolved an issue where repeat in-place restores were incorrectly skipping the necessary destructive purge of the existing cluster, leading to successful job reports without actual data restoration.
  • Timestamp-based recovery detection: Introduced a creation timestamp comparison between the live Cluster and the RestoreJob's start time to distinguish between a freshly-recovered cluster (which should be preserved) and a stale leftover from a previous restore (which must be purged).
  • Enhanced testing suite: Added comprehensive unit tests for the new logic and a full reconcile-level regression test to ensure repeat restores correctly trigger purges while maintaining protection against status-write race conditions.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the CNPG backup controller strategy to fix a bug where repeat in-place restores were silent no-ops. It introduces cnpgClusterFreshlyRecovered to distinguish between a freshly-recovered CNPG Cluster produced by the current restore job (which should skip purging) and a stale leftover recovery Cluster from a previous restore (which must be purged). It also updates the helper method recoveryBootstrapClusterState to return the cluster's creation timestamp and adds comprehensive unit and integration tests to verify this behavior. The review feedback suggests two minor optimizations: comparing the underlying time.Time fields directly using .Time in cnpgClusterFreshlyRecovered, and returning a pointer to cluster.CreationTimestamp directly in recoveryBootstrapClusterState to avoid an unnecessary copy.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

if !hasRecovery || clusterCreatedAt == nil || restoreStartedAt == nil {
return false
}
return clusterCreatedAt.After(restoreStartedAt.Time)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

To be more explicit and avoid relying on implicit method promotion or pointer dereferencing, we can compare the underlying time.Time fields directly using .Time.

Suggested change
return clusterCreatedAt.After(restoreStartedAt.Time)
return clusterCreatedAt.Time.After(restoreStartedAt.Time)

Comment on lines +1216 to +1217
created := cluster.CreationTimestamp
return true, &created, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Instead of copying the metav1.Time struct to a local variable created and then taking its address, we can directly return a pointer to the CreationTimestamp field of the heap-allocated cluster struct. This is more concise and avoids an unnecessary copy.

Suggested change
created := cluster.CreationTimestamp
return true, &created, nil
return true, &cluster.CreationTimestamp, nil

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/backupcontroller/cnpgstrategy_controller_test.go`:
- Around line 1715-1740: Add a test case alongside “freshly-recovered cluster
from this restore is not re-purged” that recreates a recovery Cluster with the
prior restore configuration and a creation timestamp after StartedAt, without
this restore creating it. Run reconcileCNPGRestore and assert the recreated
Cluster is marked for deletion, proving timestamp alone does not establish
ownership. Reuse the existing mkRecoveryCluster, RestoreJobReconciler, and test
client setup.

In `@internal/backupcontroller/cnpgstrategy_controller.go`:
- Around line 368-372: Replace the timestamp-only logic in
cnpgClusterFreshlyRecovered with a durable restore-specific marker or persisted
pre-purge Cluster identity so recovery is confirmed by provenance, not merely
creation time; update internal/backupcontroller/cnpgstrategy_controller.go lines
368-372 accordingly. Add a test in
internal/backupcontroller/cnpgstrategy_controller_test.go lines 1715-1740
covering a Cluster recreated after StartedAt that retains prior restore settings
and is still patched and purged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 016949f3-4158-4726-a513-178cdd7924f4

📥 Commits

Reviewing files that changed from the base of the PR and between f6a03e9 and a8494d8.

📒 Files selected for processing (2)
  • internal/backupcontroller/cnpgstrategy_controller.go
  • internal/backupcontroller/cnpgstrategy_controller_test.go

Comment on lines +1715 to +1740
t.Run("freshly-recovered cluster from this restore is not re-purged", func(t *testing.T) {
backup := mkBackupArtifact(t)
// creationTimestamp a minute after StartedAt: this restore's own re-render.
fresh := metav1.NewTime(startedAt.Add(time.Minute))
c := newCNPGStrategyTestClient(t, backup, mkRestoreJob(), strategy, cnpgBackup,
newPostgresApp(appName, ns), mkRecoveryCluster(fresh))
r := &RestoreJobReconciler{Client: c, Interface: dynamicfake.NewSimpleDynamicClient(testCNPGScheme(t))}

rj := &backupsv1alpha1.RestoreJob{}
if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: "rj"}, rj); err != nil {
t.Fatalf("get seeded RestoreJob: %v", err)
}
if _, err := r.reconcileCNPGRestore(ctx, rj, backup); err != nil {
t.Fatalf("reconcileCNPGRestore: %v", err)
}

// The freshly-recovered Cluster must survive - re-purging it would
// destroy the recovery this restore just started (the status-write-race
// protection the guard was built for).
got := &cnpgtypes.Cluster{}
if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: clusterName}, got); err != nil {
t.Fatalf("expected freshly-recovered Cluster to survive, got err=%v", err)
}
if !got.DeletionTimestamp.IsZero() {
t.Fatalf("freshly-recovered Cluster must not be marked for deletion")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Cover recreation after StartedAt that was not caused by this restore.

Add a case where a recovery Cluster using the prior restore configuration is recreated after StartedAt, before the current restore purges it. It must still be purged; otherwise the test preserves the timestamp-equals-ownership flaw.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backupcontroller/cnpgstrategy_controller_test.go` around lines 1715
- 1740, Add a test case alongside “freshly-recovered cluster from this restore
is not re-purged” that recreates a recovery Cluster with the prior restore
configuration and a creation timestamp after StartedAt, without this restore
creating it. Run reconcileCNPGRestore and assert the recreated Cluster is marked
for deletion, proving timestamp alone does not establish ownership. Reuse the
existing mkRecoveryCluster, RestoreJobReconciler, and test client setup.

Comment on lines +368 to +372
func cnpgClusterFreshlyRecovered(hasRecovery bool, clusterCreatedAt, restoreStartedAt *metav1.Time) bool {
if !hasRecovery || clusterCreatedAt == nil || restoreStartedAt == nil {
return false
}
return clusterCreatedAt.After(restoreStartedAt.Time)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Creation time does not prove that this restore produced the Cluster.

StartedAt is persisted before purge processing, while the HelmRelease remains active. A Cluster recreated from the previous recovery configuration during that window postdates StartedAt, causing the controller to skip the current patch and purge and potentially report success with stale data.

  • internal/backupcontroller/cnpgstrategy_controller.go#L368-L372: use a durable restore-specific marker or persisted pre-purge Cluster identity instead of timestamp ordering alone.
  • internal/backupcontroller/cnpgstrategy_controller_test.go#L1715-L1740: add a post-StartedAt recreation case that retains prior restore settings and must still be purged.
📍 Affects 2 files
  • internal/backupcontroller/cnpgstrategy_controller.go#L368-L372 (this comment)
  • internal/backupcontroller/cnpgstrategy_controller_test.go#L1715-L1740
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backupcontroller/cnpgstrategy_controller.go` around lines 368 - 372,
Replace the timestamp-only logic in cnpgClusterFreshlyRecovered with a durable
restore-specific marker or persisted pre-purge Cluster identity so recovery is
confirmed by provenance, not merely creation time; update
internal/backupcontroller/cnpgstrategy_controller.go lines 368-372 accordingly.
Add a test in internal/backupcontroller/cnpgstrategy_controller_test.go lines
1715-1740 covering a Cluster recreated after StartedAt that retains prior
restore settings and is still patched and purged.

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — correctly fixes the silent repeat-in-place-restore no-op (#3311) by distinguishing a recovery Cluster left over from a prior completed restore (must purge) from one this restore just re-created (must not re-purge), with tests covering the full contract and no behavioral regression.

Business context: A repeat in-place PostgreSQL restore reported Succeeded while skipping the destructive purge, leaving the database with its pre-restore contents (#3311). This change gates the purge-skip on the live Cluster's creationTimestamp relative to the RestoreJob's StartedAt.

Review notes

  • Root cause and fix check out. StartedAt is stamped on the first reconcile, which returns early, so the purge and chart re-render always happen on later reconciles — a genuinely fresh Cluster is created well after StartedAt, while a prior-restore leftover predates it. The reconcile-level regression exercises both directions.
  • No regression: the new skip condition (hasRecovery && createdAt > StartedAt) is a strict subset of the previous one (hasRecovery). The change never skips a purge the old logic would have run; it only adds purges. The exact-second tie resolves conservatively to purge.
  • Tests cover the full contract — truth table (fresh / stale / exact-second tie / missing timestamp) plus a reconcile-level test asserting the stale Cluster and its labelled PVC are purged while the freshly-recovered one survives.
  • Scope is internal reconcile logic only; no API, CRD, chart-values, or documentation surface changes.

Non-blocking follow-ups

  1. Concurrent RestoreJobs targeting the same app are not serialized (the RestoreJob controller uses a plain reconciler with no per-target exclusion). If a second restore starts before the first's replacement Cluster is rendered, the timestamp heuristic cannot prove which job produced that Cluster, so the second could skip its purge and succeed against the wrong backup. This is pre-existing — the previous hasRecovery-only logic had the same exposure — and is not worsened here, but it is worth a separate hardening issue (e.g. record the purged Cluster's UID on the TargetPurged condition, or enforce target-level mutual exclusion).
  2. The two low-priority inline suggestions already on the diff (explicit .Time.After, returning the timestamp pointer directly) are optional — author's discretion.

@IvanHunters
IvanHunters merged commit 7212e9b into main Jul 16, 2026
70 of 98 checks passed
@IvanHunters
IvanHunters deleted the fix/cnpg-repeat-restore-purge branch July 16, 2026 11:35
@github-actions

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/backport Categorizes issue or PR as requiring a backport to the current release line kind/bug Categorizes issue or PR as related to a bug size/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants