Skip to content

Commit ce22097

Browse files
committed
feat(retention): chain-aware retention and pruning
- Add a pure retention engine in internal/dumps (GroupChains + Plan, no I/O or clock): the catalog is grouped into restorable chains (a base plus its incrementals) and kept or pruned as a unit, so an incremental is never orphaned from its base. A chain's age is its newest member, so an actively-appended chain is never pruned mid-life. - Support three composable rules with union semantics (a chain is kept if it satisfies any active rule, so a rule can only ever protect): keep_last N, max_age duration, and GFS daily/weekly/monthly. An all-zero policy keeps everything — prune is a no-op until configured. - Add app.Prune: scopes to a profile, plans over chains, and on --apply deletes leaf-inward (incrementals before base) so an interrupted prune leaves at worst a complete shorter chain. Per-dump failures are collected, not fatal; the run reports them and exits non-zero. - Rewire siphon prune to the engine: --profile, --keep-last, --max-age, --gfs-daily/weekly/monthly, dry-run by default, --apply to delete. - Add a retention config block (defaults + per-profile override that replaces wholesale) with fail-fast validation; precedence is CLI flags > profile > defaults > keep-everything. - Document in docs/RETENTION.md; update README and CHANGELOG. Unit-test the engine (GFS bucketing, union, chain grouping, keep-everything), the executor (leaf-inward order, scoping, collected failures), and the config (validation, override precedence).
1 parent 01d23f2 commit ce22097

11 files changed

Lines changed: 1054 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Phase G (ops) — **retention & lifecycle** (second G cycle):
13+
- Chain-aware retention engine in `internal/dumps` (`GroupChains` + `Plan`, pure and DB/clock-free): the catalog is grouped into restorable **chains** (a base plus its incrementals), and retention keeps or prunes each chain as a unit, so an incremental is never orphaned from its base. A chain's age is its newest member, so an actively-appended chain is never pruned mid-life.
14+
- Three composable policy rules with **union** semantics (a chain is kept if it satisfies any active rule, so adding a rule only ever protects more): `keep_last: N`, `max_age: <duration>`, and grandfather-father-son `gfs: {daily, weekly, monthly}`. An all-zero/omitted policy keeps everything — prune is a no-op unless a rule is explicitly configured.
15+
- `siphon prune` rewired to the engine: `--profile` scoping, `--keep-last` / `--max-age` / `--gfs-daily|weekly|monthly` flags, dry-run by default with `--apply` to delete. Deletion is leaf-inward (incrementals before base), so an interrupted prune leaves at worst a complete shorter chain. Per-dump failures are collected and reported; the run exits non-zero on any failure but still prunes the rest. Works over any storage backend via the existing `Store.Delete`.
16+
- New `retention:` config block (`defaults` + per-profile override that replaces the default wholesale; `keep_last`, `max_age`, `gfs`) with fail-fast validation. Resolution precedence: CLI flags > profile > defaults > keep-everything. See [docs/RETENTION.md](docs/RETENTION.md).
17+
1218
- Phase G (ops) — **cloud storage backends** (first G cycle):
1319
- New `internal/storage` leaf defining a backend-neutral `Store` interface (`Put`/`Get`/`Delete`/`List`/`Stat`, all context-aware) with two implementations: **local** (the previous filesystem layout, now behind the interface — temp-write+rename for atomic publish, keys map verbatim to file names so existing catalogs need no migration) and **s3** (S3 and S3-compatible services like MinIO/R2 via AWS SDK v2; streaming transfer-manager upload that is atomic on completion, path-style for custom endpoints).
1420
- `internal/dumps.Catalog` refactored to hold a `Store` and address dumps by key (`<id>.dump`, `<id>.meta.json`) instead of filesystem paths; `Path`/`MetaPath`/`Root` removed. Catalog methods (`PutDump`/`OpenDump`/`ReadMeta`/`WriteMeta`/`List`/`Delete`/`ResolveChain`/`PruneDryRun`) are now context-aware. `backup`, `restore`, `verify`, and `dumps` stream through the store; SHA-256 integrity is computed app-side over `envelope ++ body`, so a dump verifies identically whether it lives locally or in S3.

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_
5555
| **D** — Driver hardening | Shared cross-driver test harness (`RunDriverSuite`), capability-gating helper (`RequireCapability`), Postgres connection-probe retry, and a `docs/DRIVERS.md` contributor guide | ✅ Complete |
5656
| **E** — MySQL + MariaDB | Both drivers via a shared `_mysqlcommon` package (shared `Conn`, `mysqldump`/`mariadb-dump` backup, client-pipe restore), exercised by the Phase D `RunDriverSuite` harness | ✅ Complete |
5757
| **F** — Advanced transfer | All four advanced-transfer modes work end-to-end: bounded-buffer streaming sync; **incremental** backup/restore (`backup --incremental --base <id>` captures a bounded change set via Postgres logical decoding / MySQL-MariaDB binlog, `restore` replays the base→incremental chain, Postgres orphan-slot sweep); **cross-engine** sync (`sync --cross-engine` — typed `SchemaInspector` introspection → canonical type-mapping, e.g. Postgres → MySQL); and **CDC** (`siphon cdc` / `sync --continuous` — unbounded change streaming with snapshot→stream handoff, resumable, same- and cross-engine). Live DB paths are integration-tested in CI — see [docs/INCREMENTAL.md](docs/INCREMENTAL.md), [docs/CROSS_ENGINE.md](docs/CROSS_ENGINE.md), [docs/CDC.md](docs/CDC.md) | ✅ Complete |
58-
| **G** — Ops features | **Cloud storage** ships first: the dump catalog can live in an S3 / S3-compatible bucket via a pluggable `storage.Store` backend (local + S3 today; GCS/Azure are a fast-follow), selected by a `storage:` config block, with SHA-256 integrity preserved end-to-end — see [docs/STORAGE.md](docs/STORAGE.md). Secret backends, profile groups + 2FA, team mode, audit log, retention, and telemetry follow in later G cycles. | 🟡 In progress |
58+
| **G** — Ops features | **Cloud storage** (✅): the dump catalog can live in an S3 / S3-compatible bucket via a pluggable `storage.Store` backend (local + S3; GCS/Azure are a fast-follow), `storage:` config block, SHA-256 integrity end-to-end — see [docs/STORAGE.md](docs/STORAGE.md). **Retention** (✅): chain-aware pruning (`siphon prune`) with keep-last-N / max-age / GFS rules, per-profile `retention:` config, dry-run by default — never orphans an incremental's base; see [docs/RETENTION.md](docs/RETENTION.md). Secret backends, profile groups + 2FA, team mode, audit log, and telemetry follow in later G cycles. | 🟡 In progress |
5959
| **H** — Distribution | GoReleaser, Homebrew tap, Scoop bucket, install script, docs site | ⏳ Planned |
6060

6161
## Requirements
@@ -181,6 +181,18 @@ storage:
181181

182182
Credentials are resolved from the standard AWS chain (env vars, `~/.aws`, instance role) — never stored in the config file. See [docs/STORAGE.md](docs/STORAGE.md) for details.
183183

184+
A `retention:` block (default + optional per-profile override) drives `siphon prune`, which deletes old backups as whole chains so an incremental is never orphaned:
185+
186+
```yaml
187+
defaults:
188+
retention:
189+
keep_last: 7
190+
max_age: 720h # 30 days
191+
gfs: { daily: 7, weekly: 4, monthly: 6 }
192+
```
193+
194+
`siphon prune` is dry-run by default; pass `--apply` to delete. Flags override the configured policy per run. See [docs/RETENTION.md](docs/RETENTION.md) for details.
195+
184196
## Architecture
185197

186198
siphon is a strictly layered Go application; imports flow **downward only**, enforced at lint time by `golangci-lint`'s `depguard` (an upward import fails CI):

docs/RETENTION.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Retention & pruning
2+
3+
`siphon prune` applies a retention policy to the dump catalog, deleting old
4+
backups while guaranteeing it never orphans an incremental from its base. It
5+
works against any storage backend (local or S3), since it prunes through the
6+
same `Store.Delete` the catalog already uses.
7+
8+
## Table of contents
9+
10+
- [The chain is the unit](#the-chain-is-the-unit)
11+
- [Policy rules](#policy-rules)
12+
- [Configuration](#configuration)
13+
- [The CLI](#the-cli)
14+
- [Safety properties](#safety-properties)
15+
16+
## The chain is the unit
17+
18+
An incremental dump depends on its base (and any intermediate incrementals) to
19+
restore. Retention therefore operates on **chains**, not individual dumps: a base
20+
plus its incrementals is kept or deleted as a whole. A full backup is a
21+
single-member chain. Counting and bucketing are done over chains, so "keep the
22+
last 7" means seven restorable backups, not seven files.
23+
24+
A chain's age is the timestamp of its **newest** member, so a chain that is
25+
still being appended to (an old base with a fresh incremental) is treated as
26+
young and is never pruned mid-life.
27+
28+
## Policy rules
29+
30+
Three rules, each independently enableable. A chain is kept if it satisfies
31+
**any** active rule (union) — adding a rule can only protect more chains, never
32+
fewer, so a policy change can't silently delete data a rule meant to keep.
33+
34+
| Rule | Meaning |
35+
| --- | --- |
36+
| `keep_last: N` | keep the N newest chains |
37+
| `max_age: <duration>` | keep chains younger than the duration (Go duration string, e.g. `720h`) |
38+
| `gfs: {daily, weekly, monthly}` | grandfather-father-son: keep the newest chain in each of the most-recent N days / ISO weeks / months |
39+
40+
An **all-zero / omitted policy keeps everything** — prune becomes a no-op. The
41+
destructive direction always requires explicit configuration.
42+
43+
## Configuration
44+
45+
Retention lives in a `retention:` block. Set a default for all profiles, and
46+
optionally override per profile (the profile block **replaces** the default
47+
block wholesale — it is not field-merged).
48+
49+
```yaml
50+
defaults:
51+
retention:
52+
keep_last: 7
53+
max_age: 720h # 30 days
54+
gfs: { daily: 7, weekly: 4, monthly: 6 }
55+
56+
profiles:
57+
prod:
58+
driver: postgres
59+
# ...
60+
retention: # prod keeps more, independent of the default
61+
keep_last: 30
62+
gfs: { daily: 14, weekly: 8, monthly: 12 }
63+
```
64+
65+
Precedence, highest first: **CLI flags → profile block → defaults block →
66+
built-in (keep everything)**. An invalid block (negative count, unparseable
67+
duration) fails fast at config load.
68+
69+
## The CLI
70+
71+
```bash
72+
# Dry-run (default): show which chains the policy would prune, for one profile.
73+
siphon prune --profile prod
74+
75+
# Override the configured policy for this run, then actually delete.
76+
siphon prune --profile prod --keep-last 14 --apply
77+
78+
# Pure flag-driven policy (no config), GFS only.
79+
siphon prune --gfs-daily 7 --gfs-weekly 4 --gfs-monthly 6 --apply
80+
```
81+
82+
`--apply` performs deletions; without it, prune only prints the plan. Flags
83+
(`--keep-last`, `--max-age`, `--gfs-daily/-weekly/-monthly`) override the
84+
config-derived policy, but only when explicitly set — an unset flag never zeroes
85+
out a configured rule.
86+
87+
## Safety properties
88+
89+
- **Dry-run by default** — deletion requires an explicit `--apply`.
90+
- **Keep-everything on empty** — a missing or all-zero policy prunes nothing.
91+
- **Union semantics** — every rule can only protect; none can cause a surprise deletion.
92+
- **Chain-atomic** — a base is only deleted when its whole chain is pruned, so an incremental is never left unrestorable.
93+
- **Leaf-inward deletion** — within a pruned chain, incrementals are deleted before the base, so an interrupted prune (Ctrl-C, network blip) leaves at worst a complete shorter chain, never a base missing under a surviving incremental.
94+
- **Collected failures** — a single dump that fails to delete is reported and the run exits non-zero, but the remaining chains are still pruned.

internal/app/prune.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/nixrajput/siphon/internal/dumps"
8+
)
9+
10+
// PruneOpts configures a prune run. Policy is resolved by the caller (the CLI
11+
// maps config + flags into it), so this layer stays config-agnostic. Profile
12+
// scopes the catalog to one profile's dumps ("" = all profiles). Apply performs
13+
// deletions; otherwise the run is a dry-run that only computes the plan.
14+
type PruneOpts struct {
15+
Profile string
16+
Policy dumps.RetentionPolicy
17+
Apply bool
18+
}
19+
20+
// ChainOutcome is one chain's place in the plan, flattened for reporting.
21+
type ChainOutcome struct {
22+
Root string
23+
DumpIDs []string
24+
SizeBytes int64
25+
Pruned bool // true = scheduled for (or performed) deletion
26+
Deleted []string // dump IDs actually deleted (Apply only)
27+
Errors []string // per-dump deletion failures (Apply only)
28+
}
29+
30+
// PruneResult is the structured outcome the CLI renders.
31+
type PruneResult struct {
32+
Profile string
33+
Apply bool
34+
Outcomes []ChainOutcome
35+
Reclaimed int64 // bytes freed (sum of successfully deleted dumps; Apply only)
36+
Failed int // count of dumps that failed to delete
37+
}
38+
39+
// Prune groups the catalog into chains, plans retention over them, and (when
40+
// Apply) deletes the pruned chains. It is synchronous (like Verify): prune is a
41+
// list + a few deletes, not a long stream, so it returns a structured result
42+
// directly rather than over the job channel.
43+
//
44+
// Deletion is chain-aware and leaf-inward: within a pruned chain, incrementals
45+
// are removed before the base, so an interrupted prune leaves at worst a
46+
// complete shorter chain — never a base missing under a surviving incremental.
47+
func Prune(ctx context.Context, d Deps, opt PruneOpts) (*PruneResult, error) {
48+
all, err := d.Dumps.List(ctx)
49+
if err != nil {
50+
return nil, err
51+
}
52+
// Scope to the requested profile before grouping, so chains and the plan
53+
// reflect only this profile's backups.
54+
if opt.Profile != "" {
55+
filtered := make([]dumps.Meta, 0, len(all))
56+
for _, m := range all {
57+
if m.Profile == opt.Profile {
58+
filtered = append(filtered, m)
59+
}
60+
}
61+
all = filtered
62+
}
63+
64+
plan := dumps.Plan(dumps.GroupChains(all), opt.Policy, time.Now())
65+
66+
result := &PruneResult{Profile: opt.Profile, Apply: opt.Apply}
67+
for _, ch := range plan.Keep {
68+
result.Outcomes = append(result.Outcomes, chainOutcome(ch, false))
69+
}
70+
for _, ch := range plan.Prune {
71+
oc := chainOutcome(ch, true)
72+
if opt.Apply {
73+
applyChainDeletion(ctx, d, ch, &oc, result)
74+
}
75+
result.Outcomes = append(result.Outcomes, oc)
76+
}
77+
return result, nil
78+
}
79+
80+
// applyChainDeletion deletes a pruned chain's members leaf-inward (incrementals
81+
// before the base). A per-dump failure is collected and does not abort the rest
82+
// of the chain or the run — a single stuck object should not block reclaiming
83+
// the others.
84+
func applyChainDeletion(ctx context.Context, d Deps, ch dumps.Chain, oc *ChainOutcome, result *PruneResult) {
85+
for i := len(ch.Members) - 1; i >= 0; i-- {
86+
m := ch.Members[i]
87+
if err := d.Dumps.Delete(ctx, m.ID); err != nil {
88+
oc.Errors = append(oc.Errors, m.ID+": "+err.Error())
89+
result.Failed++
90+
continue
91+
}
92+
oc.Deleted = append(oc.Deleted, m.ID)
93+
result.Reclaimed += m.SizeBytes
94+
}
95+
}
96+
97+
func chainOutcome(ch dumps.Chain, pruned bool) ChainOutcome {
98+
oc := ChainOutcome{Root: ch.Root, Pruned: pruned}
99+
for _, m := range ch.Members {
100+
oc.DumpIDs = append(oc.DumpIDs, m.ID)
101+
oc.SizeBytes += m.SizeBytes
102+
}
103+
return oc
104+
}

0 commit comments

Comments
 (0)