Skip to content

feat: manage backup retention in Klio instead of Kopia - #238

Open
gabriele-wolfox wants to merge 36 commits into
mainfrom
dev/168
Open

gabriele-wolfox wants to merge 36 commits into
mainfrom
dev/168

Conversation

@gabriele-wolfox

@gabriele-wolfox gabriele-wolfox commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Replace Kopia's snapshot-expire retention with a retention policy manager that Klio owns: Klio evaluates its own backup catalog and deletes the expired backups, rather than delegating to kopia snapshot expire.

Highlights

  • New retentionPolicy in the PluginConfiguration with a single latest criterion (keep the N most recent backups), configurable per tier and validated to be at least 1. Omitting it keeps every backup.
  • The tier2 snapshot pins are removed. The invariant that a tier1 backup is never deleted before it reaches tier2 now lives in the retention selection, read from our own catalog, so snapshot manifest IDs stay stable.
  • Retention travels to the server on the CloseBackup request and is applied per tier by the backup consumer after every backup.
  • New klio retention apply command (and ApplyRetention gRPC call) to apply the configured retention on demand (eg. without waiting for the next backup, to reclaim space quickly after tightening the policy).
  • The old klio retention set/get CLI and the Kopia retention-policy plumbing are removed.

Testing

  • Unit tests cover the pure Evaluate selection and the consumer's applyRetention (against a fake client).
  • A tier1 retention e2e keeps two of three backups, verifies the retention manager deletes the oldest, then tightens the policy to one and verifies klio retention apply leaves only the newest.
  • The tier2 retention e2e runs the same shared flow (automatic retention plus the on-demand klio retention apply) and adds tier2-only WAL retention and recovery-gate checks.

Closes #168.

gabriele-wolfox and others added 16 commits September 21, 2026 17:54
Replace Kopia's snapshot-expire retention with a Klio-managed policy
manager that evaluates the backup catalog and deletes the expired
backups itself. The only criterion for now is latest, which keeps the N
most recent backups; it is configured per tier in the PluginConfiguration
and must be at least 1.

Because retention no longer runs through Kopia, the tier2 snapshot pins
are gone. The invariant that a tier1 backup is never deleted before it
reaches tier2 now lives in the retention selection, read from our own
catalog, which keeps snapshot manifest IDs stable.

The tier1 and tier2 policies travel to the server on the CloseBackup
request, and the consumer applies them per tier after every backup. The
klio retention CLI and the Kopia policy plumbing are removed.

Assisted-by: Claude

Signed-off-by: Gabriele Quaresima <gabriele.quaresima@enterprisedb.com>
Add a klio retention apply command and an ApplyRetention gRPC call that
apply the configured retention immediately, without waiting for the next
backup. This frees space on demand, for example after shrinking the
policy on a nearly-full disk.

The command reads the client configuration and sends the tier1 and tier2
policies to the server, which enqueues a maintenance-only task. The
backup consumer then runs the same per-tier retention path used after a
backup, so there is a single retention implementation.

Assisted-by: Claude

Signed-off-by: Gabriele Quaresima <gabriele.quaresima@enterprisedb.com>
The operator writes the client config with the `tier1_retention` key while
the mapstructure tag said `retention`, so viper left the field nil and the
tier1 retention policy never reached the server: tier1 retention was a
no-op. Both synced copies of the struct are fixed and a decode test covers
the retention blocks.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
The `kopia repository create` command stores Kopia's default retention
counters in the global policy and every `kopia snapshot create` applies
the effective policy, so with the pins gone Kopia kept expiring tier1
snapshots behind Klio's back, per source, capped by its own buckets.

The server now disables Kopia retention at start, before the Kopia servers
come up: the global policy of each tier is set to keep everything, and the
per-source policies left by earlier Klio versions are reset to inherit it.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
The deletion glue (catalog listing, policy evaluation, keep guard, error
joining) had no unit test. Narrow the client parameter to the two methods
it uses so a fake can drive it.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
…tion

An empty cluster name is a wildcard for the backup listing, so a
maintenance task carrying one evaluated the policy over every cluster's
catalog and then failed on each delete, blocking the single-ack consumer
through its retry budget. The RPC now rejects an empty or invalid name and
the consumer refuses to apply retention without one.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
…fails

Best-effort tier1 maintenance is right after a backup, which must not fail
on it. A maintenance-only task has no backup to protect: the retention run
is the whole task, so a tier1 failure was logged once and acked as done
while the client had been told it was scheduled. It now fails the task so
the queue retries it.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
…tasks

The purge after a successful task exists because a completed backup makes
the earlier failures moot. A maintenance-only task reuses the same handler,
so an on-demand retention run erased dead-lettered backup tasks without any
backup having succeeded.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
The snapshots of a backup were deleted in listing order. A deletion
interrupted after the metadata snapshot left the data snapshots orphaned
and invisible to the catalog, so retention could never finish the job.
Deleting the metadata last keeps a partially deleted backup listed until
the next run completes it.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
A PluginConfiguration stored before the `latest` field existed reads back
with `latest: 0` once the old keys are pruned. The operator emitted a
retention block for it, the client rejected the config and every backup of
the cluster failed until the resource was edited. Such a block now
converts to "no policy", keeping every backup; the CRD validation still
rejects `latest` below 1 on write.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
…ture

The helper read every backup known to the Klio server, so the exact-count
assertions would fail as soon as another cluster shared the server.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
Remove the consumer options only read by the deleted tier1 server refresh,
fix a comment naming a method that no longer exists, correct the upgrade
note (only `klio retention get` and `set` went away, `apply` is new) and
update AGENTS.md, which still described the pins and the tier1 unpin.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
…backup

The tier1 retention guard considered a backup safe to delete as soon as its
metadata snapshot appeared on tier2. The relay is a single snapshot
migration with no ordering between a backup's parts, so a relay that dies
midway can leave the metadata on tier2 without the data, and the next
maintenance run would then delete the only complete copy on tier1.

The guard now lists the snapshots of both tiers and keeps a backup until
every tier1 snapshot of it (pgdata, metadata, control data, tablespaces)
has a counterpart on tier2.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
…nreadable

ListBackups skipped a backup whose metadata could not be read and returned
the rest as if the catalog were complete. The catalog now drives base
backup retention and WAL cleanup, so a transient read error made them
delete one backup, or the WALs of one backup, too many. A metadata read
error now fails the listing, and the callers fail closed.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
The WAL server requires a client certificate but never bound its Common
Name to the cluster named in the request, so any certificate signed by the
CA could apply a retention policy to another cluster's backups. The host
part of the userName@hostName Common Name must now match the cluster in
the ApplyRetention request; the other calls are unchanged.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
With tier2 enabled, the tier1 retention guard protected every backup that
had not reached tier2, including backups taken with tier2 backup disabled,
which never will. Those backups were never deleted from tier1.

The client now records in the backup metadata whether it asked for the
tier2 relay, and the guard lets tier1 retention delete a backup that was
never meant to reach tier2. Backups taken by older clients carry no
annotation and keep the protective behaviour.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
armru and others added 20 commits September 21, 2026 17:56
Retention orders backups by the start time taken from the instance clock,
so a clock skew after a switchover can misorder them. Spell that out, and
describe when tier 1 retention waits for tier 2.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
Add a tier1-only e2e that mirrors the tier2 retention test: configure
a tier1 retention policy of latest:2, take three backups, and assert
the retention manager keeps exactly two and deletes the oldest, then
tighten the policy to one and verify `klio retention apply` leaves
only the newest.

Both retention features run the same tier-parameterized flow
(verifyRetentionAndOnDemandApply): the tier1 and tier2 Run methods
differ only in the tier annotation, the policy the on-demand step
edits, and the tier2-only WAL and recovery-gate checks layered on
top. Generalize the tier2 retention helpers accordingly, add a
Tier1RetentionPolicy option to the Klio plugin-configuration
template, register the new Tier1Retention feature, and update the
e2e test-structure docs.

Assisted-by: Claude

Signed-off-by: Gabriele Quaresima <gabriele.quaresima@enterprisedb.com>
The count check already decoded the cluster name of each backup but
counted every cluster's backups, while the name listing filtered by
cluster. The exact-count assertions of the shared retention flow now
look at the cluster under test only.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
The tier1 retention feature checked that base backups are deleted but not
that the WAL horizon moved with them, which is the part of the WAL cleanup
this change touches: the first required WAL is now recomputed from Klio's
catalog after retention. After the on-demand apply leaves the newest
backup, the test waits until no tier1 WAL older than that backup's begin
WAL survives.

The tier1 WAL listing helpers move from the e2e package to the Klio
features package so both tests can use them.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
… removed

The tier1 guard kept every backup whose snapshots were missing on tier2,
which conflates "not relayed yet" with "relayed and deleted since". With a
tier2 policy tighter than tier1's, tier2 retention runs first and removes
the older backups; tier1 then kept those same backups, and their WALs,
forever, with only an info log that reads as transient.

The relay migrates every tier1 snapshot of the cluster at once, so a newer
backup complete on tier2 proves the relay ran after the older one existed.
A backup absent from tier2 is now deletable when such a newer backup
exists; one that is only partially on tier2 is still kept, since a relay
may be in flight or have failed midway.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
A required `latest` makes PluginConfigurations stored by earlier versions,
which carry the pruned Kopia-style keys and no `latest`, fail schema
validation on their next write, including the controller's own status
update, unless the cluster ratchets CRD validation. The field is optional
with a plain minimum of 1; an empty block converts to "no policy" as
before.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
… retry

In mergePolicyField a pointer field was required to detect "unset"
via nil, but BackupTask.Tier2RetentionPolicy is now a value-typed
retention.Policy whose own zero value means "no policy" (see
core/pkg/retention/retention.go). Generalize the constraint from any
to comparable and use each type's zero value as the sentinel instead
of nil, so the same helper folds both the pointer-typed
Tier2CompressionPolicy and the value-typed Tier2RetentionPolicy.

mergeBackupTasks also dropped Tier1RetentionPolicy and MaintenanceOnly
when folding failed backup tasks for the same cluster into one retry:
Tier1RetentionPolicy was never copied at all, and MaintenanceOnly
always reset to false. A retried maintenance-only task would silently
run as a full backup task, and a retried backup would lose its tier1
retention policy for that cycle. Fold Tier1RetentionPolicy through the
same agree-or-blank mergePolicyField used for Tier2RetentionPolicy, and
AND MaintenanceOnly across entries: any genuine failed backup in the
batch forces the merged task through full processing, since that
already applies retention too.

Also update retry_test.go, still written against the deleted
kopia.RetentionPolicy pointer type removed together with Kopia's own
retention policies; it no longer compiled.

golangci-lint's ireturn flagged mergePolicyField returning T directly,
introduced when its constraint changed from *T to comparable. T is a
concrete type at every call site (retention.Policy,
*kopia.CompressionPolicy), not a real interface return; ireturn can't
tell the two apart for a generic type parameter.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
CloseBackup drives tier1/tier2 retention deletion (via the retention
policies on the request) and reads the cluster's WAL directory, keyed
by the client-supplied cluster name, but unlike ApplyRetention never
checked that the caller's certificate was issued for that cluster. A
client authenticated for one cluster could request CloseBackup for
another cluster's name and trigger retention deletes against it.

Add the same checkPeerCluster check ApplyRetention already uses, at
the top of CloseBackup before any of its cluster-scoped work runs.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
RetentionPolicy.Validate returned an inline errors.New(...) instead of
a package-level error variable.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
The runTier1Retention had tier1RetentionGuard and applyRetention each
call ListBackups on tier1 independently. Between the two calls, a
concurrent change (relay, delete) could make them see different
snapshots of the same cluster: the guard's "keep" decisions would then
be evaluated against a catalog from a different point in time, the
same race class fixed for tier1-vs-tier2 in aa7f5879.

List tier1 backups once in runTier1Retention and pass that single
listing into both the guard and applyRetention. Applied the same
change to maintainTier2, whose applyRetention call listed tier2
backups internally; that listing now happens in the caller too, ahead
of the call.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
The policyTarget duplicated SourceInfo (same fields, same JSON tags,
and a String() producing the same output) in the same package, plus an
isGlobal() that reimplemented SourceInfo.String()'s own emptiness
check. Removed policyTarget and its methods; listPolicyTargets and
parsePolicyTargets now return []SourceInfo. Added SourceInfo.IsGlobal()
(also used by its own String()) as the one place that check lives.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
A backup that dies between uploading its parts and closing (Close
uploads the metadata snapshot last) leaves snapshot parts with no
metadata snapshot behind. ListBackups only sees metadata-tagged
snapshots, so these orphans were invisible to the catalog and never
retention-managed: they stayed in the repository forever, and this
was also the failure mode of a since-reverted fix that let a failed
delete strip a backup's metadata while leaving another part behind.

Klio processes one backup at a time per cluster, so a newer backup
reaching a real, catalogued completion proves an older metadata-less
one is not still running: it is a permanently abandoned attempt, the
same newer-backup-proves-it trick keepUntilOnTier2 already uses for
tier2 relay. deleteAbandonedOrphans applies that rule during tier1
maintenance, judging each orphan snapshot part independently on its
own StartTime against the newest catalogued backup's StartedAt: a
part that isn't old enough yet simply stays an orphan candidate and
gets swept on a later cycle, so there is no need to decide a backup's
parts as a group. When no catalogued backup exists yet nothing is
deletable, since the orphan could still be the cluster's first backup,
genuinely in progress. The same blind spot exists on tier2 for a
different reason: relayTier2's MigrateSnapshots call can migrate some
of a backup's parts and not others before failing, and if that task's
retries are exhausted, those parts never get their tier2 metadata
snapshot either; deleteAbandonedOrphans runs during tier2 maintenance
too, against tier2's own client, snapshot listing and backup catalog.
Orphans are deleted directly by manifest ID via a new
Connection.DeleteSnapshot: they have no metadata, so calling them a
"backup" and deleting them through the name-based DeleteBackup, which
re-lists from scratch to rediscover manifests already in hand, was the
wrong fit.

Reworked runTier1Retention to list tier1 snapshots once and share them
with tier1RetentionGuard, alongside the existing shared tier1Backups
listing, rather than adding a third redundant listing. Manifest.StartTime
changes from a raw JSON string to UTCTimestamp, matching EndTime's
existing, already-tested parsing on the same struct; it was otherwise
unused.

Root cause found live on a cluster showing a real orphan: Kopia's CLI
stores every user-defined snapshot tag key with a literal "tag:"
prefix (see the vendored cli/command_snapshot_create.go's getTags),
but ListSnapshots never stripped it before returning manifests. Every
direct Tags[klioclient.SomeConstant] lookup was a silent no-op, since
the actual key was "tag:klio.io/tag" and so on. Filter-based --tags
usage was unaffected because Kopia applies the same prefix when
matching, which is why this went unnoticed. This broke
isMetadataSnapshot (the delete-ordering safety net below),
deleteAbandonedOrphans's own orphan detection (always saw zero
candidates), and keepUntilOnTier2 (the tier1-must-wait-for-tier2 guard,
unconditionally returning "deletable" for every backup). Confirmed
against real "snapshot list --json" output pulled from the live
cluster, which is why the fix is verified with a JSON fixture test
rather than only synthetic Manifest structs.

newRelayState (the tier2 relay guard) and the orphan detection above
each rolled their own loop over Tags[BackupNameTagName] to partition
raw snapshot manifests by backup name; extracted into one shared
groupSnapshotsByBackup helper, so this grouping, and any future
tag-related fix to it, has one call site instead of two.

deleteSnapshots (backup deletion) used to keep a backup's metadata
snapshot for last, so a partial delete failure left the backup listed
for the next retention run to finish. But the loop never stops on a
failure, so an isolated failure on one part still let metadata delete
cleanly afterward, leaving the backup listed as valid while some of
its data was already gone. Delete metadata first instead: a partially
deleted backup is never listed, and its leftover parts become orphans
for the sweep above to catch, once a newer backup proves them
abandoned. On the retention path this is immediate, since a backup is
only ever deleted for falling outside the "keep the newest N" policy,
which means a newer, known-good backup already exists by construction.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
Evaluate sorted by StartedAt to pick the N most recent backups for the
"latest" policy. StartedAt only orders closed, catalogued backups
here (no in-progress ones are ever in this catalog), so the
false-positive risk StartedAt exists to avoid elsewhere (see
deleteAbandonedOrphans) doesn't apply; StoppedAt is the more
meaningful "how recent is this backup" signal, since it reflects when
the backup actually finished and its data became a real, complete
restore point, rather than merely when the attempt began.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
The only return path ever producing a result set scheduled: true;
every failure path returns an error instead, never reaching it. The
field was therefore always true whenever a result came back at all,
making it dead API surface: the client's own scheduled check could
never be false.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
Added info-level logging across the retention lifecycle, to make the
behavior traceable end to end without a debugger.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
Sample PluginConfiguration manifests still used the old Kopia-style
keepLatest/keepHourly/keepDaily/keepWeekly/keepMonthly/keepAnnual
retention keys, which the CRD no longer has (RetentionPolicy is now a
single latest field). The API server prunes unknown fields, so these
samples silently configured no retention at all.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
A feature is a standard way a test is set up and executed
(Name/Setup/Run/Teardown). This PR had accumulated code in
operator/test/klio/features/ that wasn't one.

No functional change throughout: same types, same helpers, same
behavior, just relocated and reordered.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>
Tier1 retention runs as a direct filesystem write, bypassing the
persistent tier1 kopia server. Without a refresh, that server could
keep listing an already-deleted backup for up to its staleness window,
observed live as backups piling up past the configured latest:N policy
before a delayed batch catch-up. Add refreshTier1KopiaServer, mirroring
the existing refreshTier2KopiaServer, called right after tier1
retention succeeds.

Collapse the per-tier server certificate fingerprint fields into one
shared ServerCertificateFingerprint, since tier1 and tier2 share the
same TLS certificate.

Update the AGENTS.md note on direct-write refreshes: retention deletes
don't corrupt the consumer's own decisions (those always read its own
direct ListBackups), but server-routed callers (get-metadata, verify,
restore, list/delete) can observe stale post-delete state without the
refresh, which is the real justification for keeping it.

Assisted-by: Claude
Signed-off-by: Francesco Canovai <francesco.canovai@enterprisedb.com>

This branch has not been deployed

No deployments
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.

Retention policies are not atomic across a backup's snapshots

3 participants