Skip to content

Commit bca5f38

Browse files
passcodclaude
andcommitted
fix(replica): don't re-restore an already-verified or in-progress snapshot
The snapshot-list result handler only compared the picked snapshot against the *active* restore. For an ephemeral verify replica the active restore is torn down after verification, so a later snapshot-list job resolving to the same snapshot passed the guard, created a second restore, and reported a duplicate restore-verification to canopy. Skip creation when the snapshot is already recorded in status.verifiedSnapshotId or when any non-failed restore is already working on it (Pending/Restoring/Ready/Switching/Active). Failed restores still allow a retry via the failure backoff path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9fa64a5 commit bca5f38

2 files changed

Lines changed: 108 additions & 4 deletions

File tree

src/controllers/replica.rs

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,36 @@ pub fn persistent_schemas_migration_settled(replica: &PostgresPhysicalReplica) -
6767
.is_none_or(SchemaMigrationPhase::is_settled)
6868
}
6969

70+
/// True when a snapshot is already covered by an existing restore and must
71+
/// not be restored again.
72+
///
73+
/// A snapshot is covered when we've already recorded it as verified
74+
/// (`status.verifiedSnapshotId`, the ephemeral marker that outlives the torn
75+
/// down restore) or when any non-failed restore for this replica is already
76+
/// working on it (Pending/Restoring/Ready/Switching/Active). Failed restores
77+
/// don't count — the failure backoff path is allowed to retry the snapshot.
78+
///
79+
/// Without this an ephemeral `verify` replica whose restore has been torn
80+
/// down would re-create a restore for the same snapshot — the snapshot-list
81+
/// handler otherwise only checks the *active* restore, which is gone after
82+
/// teardown — and double-report the verification to canopy.
83+
fn snapshot_already_covered(
84+
snapshot_id: &str,
85+
verified_snapshot_id: Option<&str>,
86+
restores: &[PostgresPhysicalRestore],
87+
) -> bool {
88+
if verified_snapshot_id == Some(snapshot_id) {
89+
return true;
90+
}
91+
restores.iter().any(|r| {
92+
r.spec.snapshot == snapshot_id
93+
&& !matches!(
94+
r.status.as_ref().and_then(|s| s.phase.as_ref()),
95+
Some(&RestorePhase::Failed)
96+
)
97+
})
98+
}
99+
70100
/// Generate a random password for analytics credentials.
71101
pub(crate) fn generate_password() -> String {
72102
let mut rng = rand::rng();
@@ -691,14 +721,20 @@ pub async fn reconcile(replica: Arc<PostgresPhysicalReplica>, ctx: Arc<Context>)
691721
)
692722
.await?;
693723

694-
let current_snapshot_id =
695-
active_restore.map(|r| r.spec.snapshot.as_str());
724+
let verified_snapshot_id = replica
725+
.status
726+
.as_ref()
727+
.and_then(|s| s.verified_snapshot_id.as_deref());
696728

697-
if current_snapshot_id == Some(&snap.id) {
729+
if snapshot_already_covered(
730+
&snap.id,
731+
verified_snapshot_id,
732+
&restore_list.items,
733+
) {
698734
debug!(
699735
replica = name,
700736
snapshot = snap.id,
701-
"latest snapshot already active, skipping"
737+
"snapshot already covered by an existing or verified restore, skipping"
702738
);
703739
} else {
704740
info!(

src/controllers/replica/tests.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::{kopia::Snapshot, types::*, util::TimeSpan};
55

66
use super::{
77
generate_password, persistent_schemas_migration_settled, resources::build_snapshot_list_job,
8+
snapshot_already_covered,
89
};
910

1011
fn make_replica(
@@ -233,3 +234,70 @@ fn snapshot_list_job_rotates_kopia_logs() {
233234
"snapshot-list script must rotate kopia logs by age"
234235
);
235236
}
237+
238+
fn make_restore(snapshot: &str, phase: Option<RestorePhase>) -> PostgresPhysicalRestore {
239+
PostgresPhysicalRestore {
240+
metadata: ObjectMeta {
241+
name: Some(format!("test-{snapshot}")),
242+
namespace: Some("default".into()),
243+
..Default::default()
244+
},
245+
spec: PostgresPhysicalRestoreSpec {
246+
replica: k8s_openapi::api::core::v1::LocalObjectReference {
247+
name: "test".into(),
248+
},
249+
snapshot: snapshot.into(),
250+
snapshot_size: k8s_openapi::apimachinery::pkg::api::resource::Quantity("1Gi".into()),
251+
snapshot_time: None,
252+
storage_size: k8s_openapi::apimachinery::pkg::api::resource::Quantity("2Gi".into()),
253+
},
254+
status: phase.map(|p| PostgresPhysicalRestoreStatus {
255+
phase: Some(p),
256+
..Default::default()
257+
}),
258+
}
259+
}
260+
261+
#[test]
262+
fn snapshot_covered_by_verified_marker() {
263+
// Ephemeral `verify` replica: the restore has been torn down (no live
264+
// restores) but the marker records that we already verified the
265+
// snapshot, so we must not restore it again and double-report.
266+
assert!(snapshot_already_covered("snapA", Some("snapA"), &[]));
267+
assert!(!snapshot_already_covered("snapB", Some("snapA"), &[]));
268+
}
269+
270+
#[test]
271+
fn snapshot_covered_by_live_restore() {
272+
for phase in [
273+
RestorePhase::Pending,
274+
RestorePhase::Restoring,
275+
RestorePhase::Ready,
276+
RestorePhase::Switching,
277+
RestorePhase::Active,
278+
] {
279+
let restores = [make_restore("snapA", Some(phase.clone()))];
280+
assert!(
281+
snapshot_already_covered("snapA", None, &restores),
282+
"a {phase:?} restore on the snapshot must count as covered"
283+
);
284+
}
285+
}
286+
287+
#[test]
288+
fn failed_restore_does_not_cover_snapshot() {
289+
// A Failed restore is allowed to be retried via the failure backoff
290+
// path, so it must not block a fresh restore of the same snapshot.
291+
let restores = [make_restore("snapA", Some(RestorePhase::Failed))];
292+
assert!(!snapshot_already_covered("snapA", None, &restores));
293+
}
294+
295+
#[test]
296+
fn uncovered_snapshot_is_created() {
297+
// No marker, and the only live restore is for a different snapshot.
298+
let restores = [make_restore("snapOld", Some(RestorePhase::Active))];
299+
assert!(!snapshot_already_covered("snapNew", None, &restores));
300+
// A restore with no status yet (phase None) still counts as live.
301+
let pending = [make_restore("snapNew", None)];
302+
assert!(snapshot_already_covered("snapNew", None, &pending));
303+
}

0 commit comments

Comments
 (0)