retention piece 1: immediate rotations for chat-history / job-log / followups (b67084660385)
- chat-history.jsonl rotates at 10MB or 7d -> logs/archive/*.jsonl.gz + .sha256 - job-log.jsonl rotates at 2MB or 14d -> same archive layout - followups.json archives resolved>7d / escalated>30d -> followups.archive.jsonl (append-only) - verify wrapper: sha256 -c, gzip -t, clean listing, spot-extract JSON + ts bounds - driver + hourly systemd user timer (retention-rotations.timer) - archive-only: nothing is ever deleted; thread backfill excluded (needs human-reviewed preview) First run 2026-10-05 16:35Z: chat-history 29.7MB + job-log 4.7MB rotated and verified; followups 0 eligible of 163.
This commit is contained in:
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""retention-archive-followups.py — retention piece 1 (board bug b67084660385).
|
||||
|
||||
Archives resolved follow-ups older than 7 days and escalated follow-ups older
|
||||
than 30 days from followups.json into followups.archive.jsonl (append-only).
|
||||
|
||||
ARCHIVE-ONLY: records are moved from the live file to the archive file; the
|
||||
archive itself is never pruned or hard-deleted by this script.
|
||||
|
||||
Concurrency: the live file is written atomically (tmp + os.replace) by both
|
||||
this script and bin/followup-sweeper.py. This script snapshots the file's
|
||||
(mtime_ns, size) before deciding the archival set and aborts (rc=2) if the
|
||||
file changed in the meantime — the next run picks it up.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
ROOT = os.environ.get("NETVM_ROOT", "/home/super/Projects/NetVM")
|
||||
LIVE = os.path.join(ROOT, "followups.json")
|
||||
ARCHIVE = os.path.join(ROOT, "followups.archive.jsonl")
|
||||
RESOLVED_AFTER_DAYS = 7
|
||||
ESCALATED_AFTER_DAYS = 30
|
||||
|
||||
|
||||
def parse_ts(ts):
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(LIVE):
|
||||
print("SKIP followups: no live file at %s" % LIVE)
|
||||
return 0
|
||||
|
||||
with open(LIVE, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
st_before = os.stat(LIVE)
|
||||
data = json.loads(raw)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
archive, keep = [], {}
|
||||
for key, rec in data.items():
|
||||
status = rec.get("status")
|
||||
eligible = False
|
||||
reason = ""
|
||||
if status == "resolved":
|
||||
dt = parse_ts(rec.get("resolved_at")) or parse_ts(rec.get("sent_at"))
|
||||
if dt and (now - dt) > timedelta(days=RESOLVED_AFTER_DAYS):
|
||||
eligible, reason = True, "resolved>7d"
|
||||
elif status == "escalated":
|
||||
dt = parse_ts(rec.get("escalated_at")) or parse_ts(rec.get("sent_at"))
|
||||
if dt and (now - dt) > timedelta(days=ESCALATED_AFTER_DAYS):
|
||||
eligible, reason = True, "escalated>30d"
|
||||
if eligible:
|
||||
out = dict(rec)
|
||||
out["_archived_at"] = now.isoformat()
|
||||
out["_archive_reason"] = reason
|
||||
archive.append(out)
|
||||
else:
|
||||
keep[key] = rec
|
||||
|
||||
if not archive:
|
||||
print("SKIP followups: 0 eligible of %d records (resolved>7d / escalated>30d)" % len(data))
|
||||
return 0
|
||||
|
||||
st_after = os.stat(LIVE)
|
||||
if (st_after.st_mtime_ns, st_after.st_size) != (st_before.st_mtime_ns, st_before.st_size):
|
||||
print("ABORT followups: live file changed during archival decision; retry next run",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
with open(ARCHIVE, "a", encoding="utf-8") as f:
|
||||
for rec in archive:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
tmp = "%s.tmp.retention.%d" % (LIVE, os.getpid())
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(keep, f, indent=2)
|
||||
os.replace(tmp, LIVE)
|
||||
|
||||
print("OK followups: archived %d records -> %s (live now %d)" % (len(archive), ARCHIVE, len(keep)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user