Files
philips-tv-speicher-voll/patches/epg-stale-cleanup/tools/epg_retention.py

182 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Reference implementation and offline verifier for bounded EPG retention.
The Philips schema stores EPG timestamps as Unix epoch *seconds*. This tool is
read-only unless both --apply and --ack-working-copy are supplied. Mutating
mode is restricted to the sibling work/ directory so the golden snapshot
cannot be opened for writing by accident.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
from pathlib import Path
import sqlite3
import time
ROOT = Path(__file__).resolve().parents[1]
WORK_ROOT = (ROOT / "work").resolve()
GOLDEN_SHA256 = "71403743206d5082aae56c4a947f3429f5c3d54b908c4abe2eb0ca66e06694ba"
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for block in iter(lambda: f.read(8 * 1024 * 1024), b""):
h.update(block)
return h.hexdigest()
def parse_now(value: str | None) -> int:
if value is None:
return int(time.time())
if value.isdecimal():
n = int(value)
if n > 10_000_000_000:
raise ValueError("--now must be epoch seconds, not milliseconds")
return n
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("ISO --now requires an explicit timezone")
return int(parsed.timestamp())
def pragma_int(db: sqlite3.Connection, name: str) -> int:
return int(db.execute(f"PRAGMA {name}").fetchone()[0])
def table_exists(db: sqlite3.Connection, table: str) -> bool:
return db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone() is not None
def table_stats(db: sqlite3.Connection, table: str, cutoff: int) -> dict:
if not table_exists(db, table):
return {"present": False}
row = db.execute(
f"SELECT count(*), min(endtime), max(endtime), "
f"sum(CASE WHEN endtime < ? THEN 1 ELSE 0 END), "
f"sum(CASE WHEN endtime >= ? THEN 1 ELSE 0 END) FROM {table}",
(cutoff, cutoff),
).fetchone()
return {
"present": True,
"rows": row[0],
"min_end_seconds": row[1],
"max_end_seconds": row[2],
"eligible": row[3] or 0,
"retained": row[4] or 0,
}
def snapshot(db: sqlite3.Connection, path: Path, cutoff: int) -> dict:
return {
"path": str(path),
"bytes": path.stat().st_size,
"page_size": pragma_int(db, "page_size"),
"page_count": pragma_int(db, "page_count"),
"freelist_count": pragma_int(db, "freelist_count"),
"auto_vacuum": pragma_int(db, "auto_vacuum"),
"scheduletable": table_stats(db, "scheduletable", cutoff),
}
def delete_batches(db: sqlite3.Connection, cutoff: int, batch_size: int) -> dict:
sql = (
"DELETE FROM scheduletable WHERE _id IN ("
"SELECT _id FROM scheduletable WHERE endtime < ? "
"ORDER BY endtime, _id LIMIT ?)"
)
total = batches = 0
max_batch_ms = 0.0
started = time.monotonic()
while True:
one = time.monotonic()
db.execute("BEGIN IMMEDIATE")
try:
cur = db.execute(sql, (cutoff, batch_size))
deleted = cur.rowcount
db.commit()
except BaseException:
db.rollback()
raise
elapsed_ms = (time.monotonic() - one) * 1000
max_batch_ms = max(max_batch_ms, elapsed_ms)
batches += 1
total += deleted
if deleted < batch_size:
break
return {
"deleted": total,
"batches_including_final": batches,
"batch_size": batch_size,
"max_batch_ms": round(max_batch_ms, 3),
"duration_seconds": round(time.monotonic() - started, 3),
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("database", type=Path)
ap.add_argument("--now", help="epoch seconds or timezone-qualified ISO-8601")
ap.add_argument("--retention-days", type=int, default=7)
ap.add_argument("--batch-size", type=int, default=2000)
ap.add_argument("--apply", action="store_true")
ap.add_argument("--ack-working-copy", action="store_true")
ap.add_argument("--hash", action="store_true", help="include file SHA-256")
args = ap.parse_args()
path = args.database.resolve()
if not path.is_file():
ap.error(f"not a file: {path}")
if not 1 <= args.retention_days <= 30:
ap.error("retention must be between 1 and 30 days")
if not 100 <= args.batch_size <= 10_000:
ap.error("batch size must be between 100 and 10000")
now_seconds = parse_now(args.now)
cutoff = now_seconds - args.retention_days * 86_400
result = {
"time_unit": "unix_epoch_seconds",
"now_seconds": now_seconds,
"retention_days": args.retention_days,
"cutoff_seconds": cutoff,
"mode": "apply" if args.apply else "read_only",
}
if args.apply:
if not args.ack_working_copy:
ap.error("--apply requires --ack-working-copy")
if path.parent != WORK_ROOT:
ap.error(f"writes are restricted to working copies directly in {WORK_ROOT}")
db = sqlite3.connect(path, timeout=5.0, isolation_level=None)
else:
uri = f"file:{path.as_posix()}?mode=ro&immutable=1"
db = sqlite3.connect(uri, uri=True)
db.execute("PRAGMA query_only=ON")
try:
result["before"] = snapshot(db, path, cutoff)
if args.hash:
result["sha256_before"] = sha256(path)
if args.apply:
db.execute("PRAGMA busy_timeout=5000")
result["maintenance"] = delete_batches(db, cutoff, args.batch_size)
result["integrity_check"] = db.execute("PRAGMA integrity_check").fetchone()[0]
result["after"] = snapshot(db, path, cutoff)
if args.hash:
result["sha256_after"] = sha256(path)
print(json.dumps(result, indent=2, sort_keys=True))
finally:
db.close()
if __name__ == "__main__":
main()