Skip to content

feat(cloud-backups): keep-tail seed for audit-rotate (preserve a live reader's lookback across rotation) - #17

Merged
logicflakes merged 3 commits into
mainfrom
2026-08-metrics-audit-keep-tail-seed
Aug 25, 2026
Merged

logicflakes merged 3 commits into
mainfrom
2026-08-metrics-audit-keep-tail-seed

Conversation

@logicflakes

Copy link
Copy Markdown
Collaborator

Problem

pg audit-rotate empties the live table at each rotation (renames it aside as an immutable archive, stands up a fresh empty one). That is correct for a write-only table, but a table with a live reader that queries the live table over a rolling date window loses that window across the boundary.

Concretely: the finding-change v3 daily repair sweep reads its target table with WHERE <ts> >= now() - lookback (~2 days, daily cron). After a rotation the recent rows are in the archive, not the live table, so for up to lookback the sweep sees an empty/partial window and silently stops self-healing dropped rows — which then become permanent holes.

Fix

Add --keep-tail-days N (env KEEP_TAIL_DAYS, default 0) + --keep-tail-column (default revision_created_date). When N > 0, the rotate transaction seeds the fresh table with the most recent N days of rows from the just-sealed archive, filtered on the same column the reader queries:

INSERT INTO <live> SELECT * FROM <archive> WHERE <col> >= now() - make_interval(days => N);
  • Atomic with the rename (fresh table not yet visible → a plain INSERT can't conflict; a failure rolls back the whole rotation to retry cleanly).
  • N = 0 is byte-for-byte the old behaviour (no seed), so write-only rotation is unchanged.
  • Cost: the ACCESS EXCLUSIVE window becomes O(rows copied) not catalog-only, so keep N small; size it ≥ reader lookback + reader cron period + margin.
  • Validation bounds N to [0, retentionDays]; preflight refuses if the column is absent, or if the table has a STORED generated column (the SELECT * seed can't write one).
  • Consecutive archives then overlap by ~N (the tail is re-copied), so an archive-union restore must dedupe by PK; idempotent consumers are unaffected. Summary logs keep_tail_days.

Testing

  • Unit: SQL shape (seed present/positioned/absent-at-0), all validation branches, the new generated-column helper.
  • Live (PG17 + MinIO): keep-tail=4 → fresh table holds exactly the recent tail while the archive keeps everything; keep-tail=0 → fresh table empty; missing column and STORED generated column both refuse before any DDL (table untouched).

Rollout

Build the cloud-backup image from this PR before the consuming chart sets KEEP_TAIL_DAYS (default 0 = old image ignores it / no-op).

🤖 Generated with Claude Code

Rhythm Garg and others added 2 commits August 25, 2026 12:36
…kback loss)

Rotation empties the live table, which is correct for a write-only table
(audit) but breaks a table with a LIVE reader that queries the live table
over a rolling date window. metrics_audit is read by the finding-change v3
daily repair sweep (revision_created_date >= now()-lookback, ~2d, daily
cron); after each rotation the sweep's window would fall entirely into the
archive for up to that lookback, silently losing its self-heal and leaving
permanent v3 holes.

Add --keep-tail-days (env KEEP_TAIL_DAYS, default 0) + --keep-tail-column
(default revision_created_date): when > 0, the rotate transaction seeds the
fresh table with the most recent N days of rows from the just-sealed archive,
filtered on the same column the reader queries. Atomic with the rename (fresh
table not yet visible, so a plain INSERT cannot conflict; a failure rolls back
the whole rotation to retry cleanly). Cost: the ACCESS EXCLUSIVE window
becomes O(rows copied), so keep N small. 0 = byte-for-byte the old pure
rotation (audit unchanged). Preflight refuses if the column is absent;
validation bounds N to [0, retentionDays]; summary logs keep_tail_days.

Validated live (PG17 + MinIO): keep-tail=4 leaves the fresh table holding
exactly the recent tail while the archive keeps everything; keep-tail=0
leaves it empty; a missing column refuses before any DDL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReARM-Agent: 7a8e924d-653b-4c4b-83db-10a88f2be941
ReARM-Agentic-Session: 30c3f60f-f5fc-4f14-a44f-92401f2222de
…chive overlap

Review follow-ups on the keep-tail seed:
- Refuse keep-tail on a table with a STORED generated column: the INSERT ... SELECT *
  seed cannot write one and would roll back the rotate txn and wedge every run with an
  opaque error. New preflight (only when seeding) counts is_generated='ALWAYS' and
  refuses; audit/metrics_audit have none, so it is a no-op there. Live-validated.
- Document that consecutive archives overlap by ~keepTailDays (the tail is re-copied),
  so an archive-union restore must dedupe by PK; live table never duplicates and
  idempotent consumers are unaffected. Noted in rotateSQL + the flag help.
- Fix a copied-over comment that wrongly cited int64 overflow (the keep-tail cutoff is
  computed in Postgres via make_interval; the bound is purely the degeneracy cap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReARM-Agent: 7a8e924d-653b-4c4b-83db-10a88f2be941
ReARM-Agentic-Session: 30c3f60f-f5fc-4f14-a44f-92401f2222de
@logicflakes

Copy link
Copy Markdown
Collaborator Author

Review + sandbox validation done.

  • Two-layer review (correctness + conventions, skeptical-senior pass): clean bar three findings, all fixed in the follow-up commit — refuse keep-tail on a STORED generated column (the SELECT * seed can't write one), document the ~keepTailDays archive overlap (union-restore must dedupe by PK), and correct a copied overflow-rationale comment. ASCII guard clean (my lines add no non-ASCII).
  • Sandbox-validated with the CI-built image (not a local build): ran the image as an in-cluster Job against an isolated scratch DB seeded with a metrics_audit-shaped table (10 old + 6 recent rows), KEEP_TAIL_DAYS=4. Result: rotated_this_run:true, keep_tail_days:4, archives_retained:1; the freshly-rotated live table held exactly the 6 recent rows while the archive kept all 16. Real data untouched (isolated DB, dropped afterwards).

… inside it

Stress test (concurrent writer + 500k-row keep-tail window) showed the in-
transaction seed holds ACCESS EXCLUSIVE for the whole copy -- measured ~3.15s
at 500k rows, unbounded (statement_timeout=0 in the rotate txn) -- blocking
every metrics writer for that time. A re-scan storm concentrating rows in the
window could stall writes for seconds. That regressed the original catalog-only
(~ms) rename.

Move the seed to a separate statement AFTER rotateSQL commits: an
INSERT ... SELECT ... ON CONFLICT DO NOTHING that takes only ROW EXCLUSIVE and
does not block writers. rotateSQL is catalog-only again. The seed is now
best-effort (rotation already succeeded = disk relief done): a failure logs at
ERROR (keep_tail_seed_failed) for alerting and leaves the fresh table without
its tail until the next rotation, rather than rolling back the rotation. Runs
before the --drain-backlog drop so the source archive still exists. Summary adds
keep_tail_seeded; a keep_tail_seeded log carries the copy duration.

Re-stress-tested: same 500k-row seed took 7.0s running CONCURRENTLY while a live
writer saw an 84ms max stall (the catalog rename) and zero failed writes;
archive + seeded fresh table both correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReARM-Agent: 7a8e924d-653b-4c4b-83db-10a88f2be941
ReARM-Agentic-Session: 30c3f60f-f5fc-4f14-a44f-92401f2222de
@logicflakes

Copy link
Copy Markdown
Collaborator Author

Stress test → design change (perf). Measured the keep-tail seed's lock impact and it revealed a real regression in the original in-transaction placement:

  • The seed's INSERT ... SELECT * held ACCESS EXCLUSIVE for the whole copy — ~3.15 s at 500k rows (avg 724-byte jsonb, real 3-index shape), and unbounded (statement_timeout=0 in the rotate txn). A re-scan storm concentrating rows in the window would block every metrics writer for seconds. The original pure rotation was catalog-only (~ms).
  • Fix (pushed): the seed now runs after the rename commits — a separate INSERT ... ON CONFLICT DO NOTHING that takes only ROW EXCLUSIVE and does not block writers. rotateSQL is catalog-only again. Seed is best-effort (rotation already = disk relief); a failure logs keep_tail_seed_failed at ERROR and leaves the tail unseeded until next rotation rather than rolling back.
  • Re-stress-tested (concurrent writer + 500k-row window): the seed copy took 7.0 s running concurrently while a live writer saw an 84 ms max stall (just the catalog rename) and zero failed writes; archive (500,092) and seeded fresh table (500,000 + concurrent writes) both correct.

Seed copy cost is O(rows) (~72 ms/10k → ~7 s/500k) but off the writer-blocking path. Re-validating on the sandbox with the rebuilt image.

@logicflakes
logicflakes merged commit c9b4a86 into main Aug 25, 2026
19 checks passed
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.

1 participant