Files

94 lines
3.2 KiB
Python
Raw Permalink Normal View History

#!/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())