feat(cloud-backups): keep-tail seed for audit-rotate (preserve a live reader's lookback across rotation) - #17
Merged
Conversation
…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
Collaborator
Author
|
Review + sandbox validation done.
|
… 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
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:
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
pg audit-rotateempties 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 tolookbackthe sweep sees an empty/partial window and silently stops self-healing dropped rows — which then become permanent holes.Fix
Add
--keep-tail-days N(envKEEP_TAIL_DAYS, default 0) +--keep-tail-column(defaultrevision_created_date). WhenN > 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:N = 0is byte-for-byte the old behaviour (no seed), so write-only rotation is unchanged.ACCESS EXCLUSIVEwindow becomesO(rows copied)not catalog-only, so keep N small; size it≥ reader lookback + reader cron period + margin.[0, retentionDays]; preflight refuses if the column is absent, or if the table has a STORED generated column (theSELECT *seed can't write one).keep_tail_days.Testing
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