import base64 import json import sqlite3 from edge.events import Event, EventType, Severity class OfflineQueue: def __init__(self, db_path: str, client, max_rows: int = 2000, max_send_attempts: int = 20): self.client = client self.max_rows = max_rows # SD-card guard: evict oldest beyond this self.max_send_attempts = max_send_attempts # drop a poison event after this many tries self.db = sqlite3.connect(db_path) self.db.execute( "CREATE TABLE IF NOT EXISTS queue (" "id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT, " "attempts INTEGER DEFAULT 0)" ) # Migrate an older DB that predates the attempts column. try: self.db.execute("ALTER TABLE queue ADD COLUMN attempts INTEGER DEFAULT 0") except sqlite3.OperationalError: pass # column already exists self.db.commit() def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]: # Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the # cloud dedups it. Rows queued before this field existed simply get a fresh uuid (they # predate dedup, so a first-and-only delivery is correct for them). uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {} e = Event(EventType(payload["event_type"]), Severity(payload["severity"]), payload["camera_id"], payload["track_id"], payload["zone_id"], payload["occurred_at"], payload["duration_s"], payload["metrics"], tuple(payload["spatial"]) if payload.get("spatial") else None, **uuid_kw) return e, snaps def enqueue(self, event: Event, snapshots: list[bytes]) -> None: payload = event.to_payload("") # device_id는 client가 다시 채움 payload["event_type"] = event.event_type.value payload["severity"] = event.severity.value payload["spatial"] = list(event.spatial) if event.spatial else None snaps = [base64.b64encode(s).decode() for s in snapshots] self.db.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)", (json.dumps(payload), json.dumps(snaps))) # Bound the queue so a multi-day outage — or a permanent misconfig that queues every # event forever — cannot fill the SD card and hard-crash the Pi. Keep the newest # max_rows; a fresh fall alert matters more than a stale one. self.db.execute( "DELETE FROM queue WHERE id NOT IN " "(SELECT id FROM queue ORDER BY id DESC LIMIT ?)", (self.max_rows,)) self.db.commit() def try_send(self, event: Event, snapshots: list[bytes]) -> bool: if self.client.send(event, snapshots): return True self.enqueue(event, snapshots) return False def flush(self) -> int: rows = self.db.execute( "SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall() sent = 0 for rid, payload_json, snaps_json, attempts in rows: payload = json.loads(payload_json) snaps = [base64.b64decode(s) for s in json.loads(snaps_json)] event, snapshots = self._rehydrate(payload, snaps) if self.client.send(event, snapshots): self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) self.db.commit() sent += 1 continue # Failed. A bool send() can't tell a transient outage from a permanently-rejected # (poison) event, so count attempts: below the cap, stop and preserve order (an # outage recovers and the whole queue drains next flush); at the cap, DROP this # row and continue so a poison head can never wedge the queue forever. attempts += 1 if attempts >= self.max_send_attempts: self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) self.db.commit() continue self.db.execute("UPDATE queue SET attempts = ? WHERE id = ?", (attempts, rid)) self.db.commit() break # transient — preserve order, retry the whole queue next time return sent def pending(self) -> int: return self.db.execute("SELECT COUNT(*) FROM queue").fetchone()[0]