Skip to content

Commit 518d535

Browse files
committed
feat(canopy): tear down ephemeral (verify) replicas after verification
A `verify` replica's job is to prove a snapshot restores, not to serve queries. It was booting postgres and then idling forever. Add `spec.ephemeral` (default false; set true by the verify intent): once a restore reaches Active (postgres came up healthy, and for canopy replicas the RestoreVerification was reported in the switchover block), the reconciler records `status.verifiedSnapshotId` and deletes the restore, reclaiming the Deployment + PVC. The replica CR and namespace stay so canopy's worklist stays satisfied. Re-restore is gated on the verified marker: with no active restore after teardown, the reconciler compares the desired snapshot against `verifiedSnapshotId` instead of a (now absent) active restore, so it only restores again when canopy offers a newer snapshot (canopy path) or the schedule fires (legacy path). Without the marker the active-restore-deleted / desired-changed triggers would loop. The analytics intents keep ephemeral=false (long-lived query replicas). Gated entirely behind spec.ephemeral, so non-ephemeral replicas are unchanged. Adds an integration test (tests/ephemeral.rs, new CI matrix entry) that does not need stub-canopy: it drives a legacy ephemeral replica through restore -> Active -> teardown and asserts no re-restore loop. Does NOT include the requested health_details on RestoreVerification: bestool-canopy 0.4.3 has no such field yet. Deferred until the crate ships it.
1 parent 1cd63b0 commit 518d535

12 files changed

Lines changed: 271 additions & 2 deletions

File tree

.github/workflows/integration.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ jobs:
5656
test-ps-all-missing
5757
needs_non_pg_snapshot: false
5858

59+
- name: ephemeral
60+
namespaces: >-
61+
test-ephemeral
62+
needs_non_pg_snapshot: false
63+
5964
# canopy_integration is a scaffold in tests/canopy_integration.rs
6065
# but no stub-canopy HTTP server exists yet, so the happy-path
6166
# test times out. Re-add this matrix entry when the stub lands:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Defines a continuously-refreshed replica of a PostgreSQL database restored from
103103
| `affinity` | `Affinity` | No | — | Pod scheduling affinity rules. |
104104
| `tolerations` | `[]Toleration` | No | `[]` | Pod tolerations. |
105105
| `readOnly` | `bool` | No | `true` | Set the restored database to read-only mode. |
106+
| `ephemeral` | `bool` | No | `false` | Tear the restore down once it reaches `Active` (postgres came up healthy) instead of keeping it running. The replica only restores again when a newer snapshot is offered (canopy path) or the schedule next fires (legacy path). Used by the `verify` intent, whose job is just to prove the snapshot restores. |
106107
| `postgresExtraConfig` | `string` | No | — | Extra lines appended to `postgresql.conf` (e.g. `shared_preload_libraries`). |
107108
| `notifications` | `[]NotificationConfig` | No | `[]` | Notification targets called on restore events. |
108109
| `persistentSchemas` | `[]string` | No | — | List of schema names to migrate from the previous restore to the new restore on each switchover. See [Persistent schemas](#persistent-schemas) below for the migration time budget and what happens on timeout. |
@@ -180,6 +181,7 @@ Additional fields for `target: graphQL`:
180181
| `nextScheduledRestore` | `Time` | When the next scheduled restore will occur. |
181182
| `latestAvailableSnapshot` | `string` | Snapshot ID of the latest available snapshot matching the filter. |
182183
| `canopyDesiredSnapshotId` | `string` | For canopy-sourced replicas: the snapshot the canopy worklist syncer wants restored. The reconciler triggers a new restore when this differs from the current one. |
184+
| `verifiedSnapshotId` | `string` | For `ephemeral` replicas: the last snapshot that was verified and then torn down. Gates re-restore — the reconciler only restores again when the desired snapshot differs from this. |
183185
| `connectionInfo` | `ConnectionInfo` | Connection details (host, port, database, username, password secret). |
184186
| `queuePosition` | `uint32` | Position in the global restore queue. |
185187
| `notifications` | `[]NotificationStatus` | Status of each configured notification target. |

crds.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,17 @@ spec:
571571
- group
572572
- type
573573
type: object
574+
ephemeral:
575+
default: false
576+
description: |-
577+
Ephemeral replica: once a restore reaches `Active` (postgres came up
578+
healthy and, for canopy replicas, the verification was reported),
579+
tear the restore down instead of keeping it running. The replica CR
580+
stays; it only restores again when a new snapshot is offered (canopy
581+
path) or the schedule next fires (legacy path). Used by the `verify`
582+
intent, whose whole job is "prove the snapshot restores" — keeping
583+
the database idling afterward just wastes cluster resources.
584+
type: boolean
574585
kopiaSecretRef:
575586
description: |-
576587
Reference to a Secret containing kopia repository credentials.
@@ -934,6 +945,15 @@ spec:
934945
serviceName:
935946
nullable: true
936947
type: string
948+
verifiedSnapshotId:
949+
description: |-
950+
Last snapshot id an ephemeral replica (`spec.ephemeral`) verified
951+
and then tore down. After teardown there is no `currentRestore` to
952+
compare against, so this marker is what stops the reconciler from
953+
immediately re-restoring the same snapshot; a restore is only
954+
re-triggered when the desired snapshot differs from this.
955+
nullable: true
956+
type: string
937957
type: object
938958
required:
939959
- spec

src/controllers/canopy/intent.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ pub struct IntentConfig {
4141
pub service_annotations: Option<BTreeMap<String, String>>,
4242
pub switchover_grace_period: TimeSpan,
4343
pub storage_size_override: Quantity,
44+
/// Tear the restore down once it's verified healthy rather than keeping
45+
/// it running. Materialised into `PostgresPhysicalReplicaSpec.ephemeral`.
46+
/// True for `verify` (throwaway snapshot check), false for the
47+
/// analytics intents (long-lived query replicas).
48+
pub ephemeral: bool,
4449
/// Floor on the postgres pod's `/dev/shm` sizing. Materialised into
4550
/// `PostgresPhysicalReplicaSpec.shm_size_floor` so the shared
4651
/// Deployment builder picks `max(computed_from_resources, floor)`.
@@ -79,6 +84,7 @@ pub fn config_for(intent: &str) -> Option<IntentConfig> {
7984
service_annotations: None,
8085
switchover_grace_period: TimeSpan(Span::new().minutes(5)),
8186
storage_size_override: Quantity("20Gi".to_string()),
87+
ephemeral: true,
8288
shm_size_floor: Quantity("512Mi".to_string()),
8389
}),
8490
"analytics-dev" => Some(IntentConfig {
@@ -89,6 +95,7 @@ pub fn config_for(intent: &str) -> Option<IntentConfig> {
8995
service_annotations: None,
9096
switchover_grace_period: TimeSpan(Span::new().minutes(5)),
9197
storage_size_override: Quantity("50Gi".to_string()),
98+
ephemeral: false,
9299
shm_size_floor: Quantity("2Gi".to_string()),
93100
}),
94101
"analytics-dbt" => Some(IntentConfig {
@@ -105,6 +112,7 @@ pub fn config_for(intent: &str) -> Option<IntentConfig> {
105112
])),
106113
switchover_grace_period: TimeSpan(Span::new().minutes(2)),
107114
storage_size_override: Quantity("50Gi".to_string()),
115+
ephemeral: false,
108116
shm_size_floor: Quantity("2Gi".to_string()),
109117
}),
110118
_ => None,
@@ -160,6 +168,7 @@ impl IntentConfig {
160168
affinity: None,
161169
tolerations: Vec::new(),
162170
read_only: self.read_only,
171+
ephemeral: self.ephemeral,
163172
postgres_extra_config: None,
164173
notifications,
165174
persistent_schemas: self.persistent_schemas.clone(),
@@ -213,6 +222,38 @@ mod tests {
213222
assert!(cfg.read_only);
214223
assert!(cfg.minimum_ttl.is_none());
215224
assert!(cfg.persistent_schemas.is_none());
225+
assert!(cfg.ephemeral, "verify replicas are torn down after verify");
226+
}
227+
228+
#[test]
229+
fn only_verify_is_ephemeral() {
230+
assert!(config_for("verify").unwrap().ephemeral);
231+
assert!(
232+
!config_for("analytics-dev").unwrap().ephemeral,
233+
"analytics-dev is a long-lived query replica"
234+
);
235+
assert!(
236+
!config_for("analytics-dbt").unwrap().ephemeral,
237+
"analytics-dbt is a long-lived query replica"
238+
);
239+
}
240+
241+
#[test]
242+
fn to_replica_spec_carries_ephemeral() {
243+
let e = entry("verify", "test");
244+
assert!(
245+
config_for("verify")
246+
.unwrap()
247+
.to_replica_spec(&e, vec![])
248+
.ephemeral
249+
);
250+
let e = entry("analytics-dev", "test");
251+
assert!(
252+
!config_for("analytics-dev")
253+
.unwrap()
254+
.to_replica_spec(&e, vec![])
255+
.ephemeral
256+
);
216257
}
217258

218259
#[test]

src/controllers/replica.rs

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,60 @@ pub async fn reconcile(replica: Arc<PostgresPhysicalReplica>, ctx: Arc<Context>)
357357
return Ok(Action::requeue(Duration::from_secs(10)));
358358
}
359359

360+
// Ephemeral teardown. For a `spec.ephemeral` replica (e.g. the `verify`
361+
// intent) the point of the restore is to prove the snapshot comes up —
362+
// once it's Active (the switchover block above ran and, for canopy
363+
// replicas, reported the verification), keeping the database running
364+
// just wastes resources. Record the verified snapshot and delete the
365+
// restore. The replica CR stays; the should_restore logic below only
366+
// re-triggers when a newer snapshot is offered (canopy) or the schedule
367+
// fires (legacy), gated by `verifiedSnapshotId`.
368+
if replica.spec.ephemeral
369+
&& let Some(active) = active_restore
370+
&& active.metadata.deletion_timestamp.is_none()
371+
{
372+
let active_name = active.name_any();
373+
let verified_snapshot = active.spec.snapshot.clone();
374+
info!(
375+
replica = name,
376+
restore = active_name,
377+
snapshot = verified_snapshot,
378+
"ephemeral replica: snapshot verified, tearing down restore"
379+
);
380+
381+
// Record the marker BEFORE deleting so that even if the delete or a
382+
// crash interleaves, the reconciler won't treat the vanished
383+
// restore as an accidental deletion and immediately re-restore.
384+
let replicas: Api<PostgresPhysicalReplica> = Api::namespaced(client.clone(), &namespace);
385+
let patch = serde_json::json!({
386+
"status": {
387+
"verifiedSnapshotId": verified_snapshot,
388+
"currentRestore": null,
389+
"previousRestore": null,
390+
}
391+
});
392+
replicas
393+
.patch_status(
394+
&name,
395+
&PatchParams::apply("postgres-restore-operator"),
396+
&Patch::Merge(&patch),
397+
)
398+
.await?;
399+
400+
if let Err(e) = restores.delete(&active_name, &Default::default()).await {
401+
warn!(
402+
replica = name,
403+
restore = %active_name,
404+
error = %e,
405+
"failed to delete ephemeral restore; will retry"
406+
);
407+
}
408+
if let Some(promoted) = ctx.release_restore_slot(&name).await {
409+
info!(promoted = %promoted, "promoted queued restore after ephemeral teardown");
410+
}
411+
return Ok(Action::requeue(Duration::from_secs(30)));
412+
}
413+
360414
// Sweep stale Active restores after grace period.
361415
//
362416
// Any Active restore for this replica that isn't the current one is a
@@ -909,17 +963,30 @@ pub async fn reconcile(replica: Arc<PostgresPhysicalReplica>, ctx: Arc<Context>)
909963
// On the canopy path, the worklist syncer updates
910964
// `status.canopyDesiredSnapshotId` when canopy offers a newer snapshot.
911965
// Trigger a restore whenever the desired snapshot differs from what the
912-
// active restore already carries, in addition to the usual schedule /
966+
// replica already carries, in addition to the usual schedule /
913967
// never-restored / active-deleted triggers. minimum_ttl still gates:
914968
// the intent may declare an explicit lower bound on restore frequency
915969
// even in the face of a newer canopy snapshot.
970+
//
971+
// "What the replica already carries" is normally the active restore's
972+
// snapshot, but an ephemeral replica has no active restore after it
973+
// tears one down — there we fall back to `verifiedSnapshotId`, the
974+
// marker recording the last snapshot we verified. Without that
975+
// fallback the `(Some, None)` arm would re-fire forever.
976+
let effective_current_snapshot =
977+
active_restore.map(|r| r.spec.snapshot.clone()).or_else(|| {
978+
replica
979+
.status
980+
.as_ref()
981+
.and_then(|s| s.verified_snapshot_id.clone())
982+
});
916983
let canopy_desired_changed = is_canopy
917984
&& match (
918985
replica
919986
.status
920987
.as_ref()
921988
.and_then(|s| s.canopy_desired_snapshot_id.as_ref()),
922-
active_restore.map(|r| r.spec.snapshot.as_str()),
989+
effective_current_snapshot.as_deref(),
923990
) {
924991
(Some(desired), Some(current)) => desired != current,
925992
(Some(_), None) => true,

src/controllers/replica/scheduling.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ mod tests {
273273
affinity: None,
274274
tolerations: Vec::new(),
275275
read_only: true,
276+
ephemeral: false,
276277
postgres_extra_config: None,
277278
notifications: Vec::new(),
278279
storage_size_maximum: k8s_openapi::apimachinery::pkg::api::resource::Quantity(

src/controllers/replica/schema_migration.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ mod tests {
302302
affinity: None,
303303
tolerations: vec![],
304304
read_only: true,
305+
ephemeral: false,
305306
postgres_extra_config: None,
306307
notifications: vec![],
307308
storage_size_maximum: k8s_openapi::apimachinery::pkg::api::resource::Quantity(

src/controllers/replica/tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ fn make_replica(
3838
affinity: None,
3939
tolerations: vec![],
4040
read_only: true,
41+
ephemeral: false,
4142
postgres_extra_config: None,
4243
notifications: vec![],
4344
persistent_schemas,
@@ -198,6 +199,7 @@ fn snapshot_list_job_rotates_kopia_logs() {
198199
affinity: None,
199200
tolerations: vec![],
200201
read_only: true,
202+
ephemeral: false,
201203
postgres_extra_config: None,
202204
notifications: vec![],
203205
persistent_schemas: None,

src/controllers/restore/tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ fn deployment_uses_affinity_not_node_selector() {
3737
affinity: None,
3838
tolerations: vec![],
3939
read_only: true,
40+
ephemeral: false,
4041
postgres_extra_config: None,
4142
notifications: vec![],
4243

@@ -123,6 +124,7 @@ fn test_restore_and_replica() -> (PostgresPhysicalRestore, PostgresPhysicalRepli
123124
affinity: None,
124125
tolerations: vec![],
125126
read_only: true,
127+
ephemeral: false,
126128
postgres_extra_config: None,
127129
notifications: vec![],
128130

src/types/replica.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ pub struct PostgresPhysicalReplicaSpec {
118118
#[serde(default = "default_read_only")]
119119
pub read_only: bool,
120120

121+
/// Ephemeral replica: once a restore reaches `Active` (postgres came up
122+
/// healthy and, for canopy replicas, the verification was reported),
123+
/// tear the restore down instead of keeping it running. The replica CR
124+
/// stays; it only restores again when a new snapshot is offered (canopy
125+
/// path) or the schedule next fires (legacy path). Used by the `verify`
126+
/// intent, whose whole job is "prove the snapshot restores" — keeping
127+
/// the database idling afterward just wastes cluster resources.
128+
#[serde(default)]
129+
pub ephemeral: bool,
130+
121131
/// Extra lines appended to postgresql.conf (e.g. shared_preload_libraries)
122132
#[serde(default, skip_serializing_if = "Option::is_none")]
123133
pub postgres_extra_config: Option<String>,
@@ -313,6 +323,14 @@ pub struct PostgresPhysicalReplicaStatus {
313323
#[serde(default, skip_serializing_if = "Option::is_none")]
314324
pub canopy_desired_snapshot_id: Option<String>,
315325

326+
/// Last snapshot id an ephemeral replica (`spec.ephemeral`) verified
327+
/// and then tore down. After teardown there is no `currentRestore` to
328+
/// compare against, so this marker is what stops the reconciler from
329+
/// immediately re-restoring the same snapshot; a restore is only
330+
/// re-triggered when the desired snapshot differs from this.
331+
#[serde(default, skip_serializing_if = "Option::is_none")]
332+
pub verified_snapshot_id: Option<String>,
333+
316334
#[serde(default, skip_serializing_if = "Option::is_none")]
317335
pub connection_info: Option<ConnectionInfo>,
318336

0 commit comments

Comments
 (0)