Record standby subscription events with signed durable receipts

This commit is contained in:
root
2026-10-21 09:57:46 +08:00
parent 9faae09851
commit d6eb25a0f6
13 changed files with 860 additions and 4 deletions
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Create durable subscription event receipts/history, with a SQLite backup.
Stop the production backend before --apply and pass --service-stopped to confirm.
No automatic startup DDL exists. Dry run is read-only; only hashes enter history.
Receipt retention is independent of the most recent ten visible update records.
"""
import argparse
import datetime
import hashlib
import json
from pathlib import Path
import sqlite3
DDL = (
"""CREATE TABLE IF NOT EXISTS subscription_event_receipt (
event_id TEXT PRIMARY KEY NOT NULL, key_hash TEXT NOT NULL,
client TEXT NOT NULL, occurred_at INTEGER NOT NULL, source TEXT NOT NULL,
ingested_at INTEGER NOT NULL, fingerprint TEXT NOT NULL, outcome TEXT NOT NULL
)""",
"""CREATE TABLE IF NOT EXISTS subscription_event_binding (
key_hash TEXT PRIMARY KEY NOT NULL, user_id INTEGER,
user_name TEXT NOT NULL, first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
)""",
"CREATE INDEX IF NOT EXISTS idx_subscription_event_binding_user ON subscription_event_binding(user_id)",
"CREATE INDEX IF NOT EXISTS idx_sub_record_user_time ON sub_update_record(user_id,time DESC,id DESC)",
)
def migrate(database: Path, apply=False, service_stopped=False):
if not database.is_file():
raise ValueError("database must already exist")
if apply and not service_stopped:
raise ValueError("stop backend and supply --service-stopped before applying")
uri = database.resolve().as_uri() + ("?mode=rw" if apply else "?mode=ro")
with sqlite3.connect(uri, uri=True) as db:
for table in ("User", "sub_bind", "sub_update_record"):
columns = {row[1] for row in db.execute(f'PRAGMA table_info("{table}")')}
if not columns or (table != "User" and "user_id" not in columns):
raise ValueError("stable user links migration is required first")
unresolved = db.execute("SELECT count(*) FROM sub_bind sb LEFT JOIN User u ON u.id=sb.user_id WHERE sb.user_id IS NOT NULL AND u.id IS NULL").fetchone()[0]
if unresolved:
raise ValueError("resolve non-null dangling subscription user IDs before migration")
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
result = {"apply": apply, "tables_to_create": sorted({"subscription_event_receipt", "subscription_event_binding"} - tables)}
if not apply:
return result
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
backup = database.with_name(database.name + ".before-subscription-events." + stamp)
# Create mode 600 before any sensitive database bytes are written.
backup.touch(mode=0o600, exist_ok=False)
with sqlite3.connect(backup) as snapshot:
db.backup(snapshot)
try:
db.execute("BEGIN IMMEDIATE")
for statement in DDL:
db.execute(statement)
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)
for key, user_id, name in db.execute("SELECT sb.key,sb.user_id,coalesce(u.username,sb.user) FROM sub_bind sb LEFT JOIN User u ON u.id=sb.user_id"):
key_hash = hashlib.sha256(key.encode("utf-8")).hexdigest()
db.execute("""INSERT INTO subscription_event_binding VALUES(?,?,?,?,?)
ON CONFLICT(key_hash) DO UPDATE SET user_name=excluded.user_name,last_seen_at=excluded.last_seen_at
WHERE subscription_event_binding.user_id IS excluded.user_id""", (key_hash, user_id, name, now, now))
db.commit()
except BaseException:
db.rollback()
raise
result["backup"] = str(backup)
result["history_bindings"] = db.execute("SELECT count(*) FROM subscription_event_binding").fetchone()[0]
result["integrity"] = db.execute("PRAGMA quick_check").fetchone()[0]
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("database", type=Path)
parser.add_argument("--apply", action="store_true")
parser.add_argument("--service-stopped", action="store_true")
args = parser.parse_args()
print(json.dumps(migrate(args.database, args.apply, args.service_stopped), ensure_ascii=False))
+82
View File
@@ -0,0 +1,82 @@
import importlib.util
from pathlib import Path
import sqlite3
import tempfile
import unittest
import hashlib
spec = importlib.util.spec_from_file_location("migration", Path(__file__).with_name("migrate_subscription_events.py"))
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
class SubscriptionEventsMigrationTest(unittest.TestCase):
def test_explicit_backup_atomic_and_idempotent(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "main.db"
with sqlite3.connect(path) as db:
db.executescript("""
CREATE TABLE User(id INTEGER PRIMARY KEY,username TEXT);
CREATE TABLE sub_bind(key TEXT,user TEXT,user_id INTEGER);
CREATE TABLE sub_update_record(id INTEGER PRIMARY KEY,user_id INTEGER,time INTEGER);
INSERT INTO User VALUES(3,'alice');
INSERT INTO sub_bind VALUES('public-test-key','alice',3);
""")
dry = migration.migrate(path)
self.assertEqual(2, len(dry["tables_to_create"]))
with self.assertRaises(ValueError):
migration.migrate(path, True)
first = migration.migrate(path, True, True)
self.assertEqual("ok", first["integrity"])
self.assertEqual(0o600, Path(first["backup"]).stat().st_mode & 0o777)
with sqlite3.connect(first["backup"]) as db:
self.assertEqual(0, db.execute("SELECT count(*) FROM sqlite_master WHERE name='subscription_event_binding'").fetchone()[0])
with sqlite3.connect(path) as db:
row = db.execute("SELECT key_hash,user_id,user_name FROM subscription_event_binding").fetchone()
self.assertEqual((hashlib.sha256(b"public-test-key").hexdigest(), 3, "alice"), row)
db.execute("INSERT INTO subscription_event_receipt VALUES('id','hash','v2',0,'primary',1,'fingerprint','recorded')")
second = migration.migrate(path, True, True)
self.assertEqual([], second["tables_to_create"])
with sqlite3.connect(path) as db:
self.assertEqual(1, db.execute("SELECT count(*) FROM subscription_event_receipt").fetchone()[0])
self.assertEqual(1, db.execute("SELECT count(*) FROM subscription_event_binding").fetchone()[0])
def test_legacy_labels_without_user_ids_remain_null(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "main.db"
with sqlite3.connect(path) as db:
db.executescript("""
CREATE TABLE User(id INTEGER PRIMARY KEY,username TEXT);
CREATE TABLE sub_bind(key TEXT,user TEXT,user_id INTEGER);
CREATE TABLE sub_update_record(id INTEGER PRIMARY KEY,user_id INTEGER,time INTEGER);
INSERT INTO sub_bind VALUES('legacy-key','legacy-label',NULL);
""")
result = migration.migrate(path, True, True)
self.assertEqual(1, result["history_bindings"])
with sqlite3.connect(path) as db:
row = db.execute("SELECT key_hash,user_id,user_name FROM subscription_event_binding").fetchone()
self.assertEqual((hashlib.sha256(b"legacy-key").hexdigest(), None, "legacy-label"), row)
self.assertIsNone(db.execute("SELECT user_id FROM sub_bind").fetchone()[0])
self.assertEqual(0, db.execute("SELECT count(*) FROM User").fetchone()[0])
migration.migrate(path, True, True)
with sqlite3.connect(path) as db:
self.assertEqual(1, db.execute("SELECT count(*) FROM subscription_event_binding").fetchone()[0])
def test_orphan_binding_aborts_without_ddl(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "main.db"
with sqlite3.connect(path) as db:
db.executescript("""
CREATE TABLE User(id INTEGER PRIMARY KEY,username TEXT);
CREATE TABLE sub_bind(key TEXT,user TEXT,user_id INTEGER);
CREATE TABLE sub_update_record(id INTEGER PRIMARY KEY,user_id INTEGER,time INTEGER);
INSERT INTO sub_bind VALUES('test','deleted',9);
""")
with self.assertRaises(ValueError):
migration.migrate(path, True, True)
with sqlite3.connect(path) as db:
self.assertEqual(0, db.execute("SELECT count(*) FROM sqlite_master WHERE name LIKE 'subscription_event_%'").fetchone()[0])
if __name__ == "__main__":
unittest.main()