"""Opt-in retention for known uploads; never sweep the vendor's entire gallery.""" import json import time from datetime import UTC, datetime, timedelta from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.storage.database import now_iso class Retention: def __init__(self, service): self.service = service self.db = service.db self.checked = 0.0 def info(self, device_id, image_id): row = self.db.one( "SELECT * FROM image_retention WHERE device_id=? AND image_id=?", (device_id, image_id), ) return { "starred": bool(row and row["starred"]), "expires_at": row["expires_at"] if row else None, } def star(self, device_id, image_id, starred): with self.service.lock: row = self.db.one( "SELECT cleanup_operation FROM image_retention WHERE device_id=? AND image_id=?", (device_id, image_id), ) if row and row["cleanup_operation"]: op = self.db.one( "SELECT write_started FROM operations WHERE id=?", (row["cleanup_operation"],), ) if op and op["write_started"]: raise FlowError( E.CONFLICT, "Deletion has begun; reconcile before starring", 409 ) if image_id not in { x.image_id for x in self.service.registry.get(device_id).images() }: raise FlowError(E.NOT_FOUND, "Gallery image not found", 404) self.db.execute( "INSERT INTO image_retention(device_id,image_id,starred) VALUES(?,?,?) ON CONFLICT(device_id,image_id) DO UPDATE SET starred=excluded.starred", (device_id, image_id, int(starred)), ) return self.info(device_id, image_id) def register(self, device_id, image_id, operation_id): expires = (datetime.now(UTC) + timedelta(days=7)).isoformat() self.db.execute( "INSERT INTO image_retention(device_id,image_id,starred,expires_at,upload_operation) VALUES(?,?,0,?,?) ON CONFLICT(device_id,image_id) DO NOTHING", (device_id, image_id, expires, operation_id), ) def protected(self, device_id, image_id): state = self.service.state(device_id) ids = {state["previous_image_id"], state["base_image_id"]} ids.update( x["image_id"] for x in self.db.all( "SELECT image_id FROM overrides WHERE device_id=?", (device_id,) ) ) for row in self.db.all("SELECT payload FROM playlists"): p = json.loads(row["payload"]) if p.get("device_id") == device_id: ids.update(p.get("image_ids", [])) for row in self.db.all("SELECT payload FROM jobs"): job = json.loads(row["payload"]) if job.get("device_id") == device_id: ids.add(job.get("source", {}).get("image_id")) return image_id in ids def eligible(self, device_id, image_id): row = self.db.one( "SELECT * FROM image_retention WHERE device_id=? AND image_id=?", (device_id, image_id), ) return bool( row and row["upload_operation"] and not row["starred"] and row["expires_at"] <= now_iso() and not self.protected(device_id, image_id) ) def sweep(self): if time.monotonic() - self.checked < 3600: return self.checked = time.monotonic() with self.service.lock: if ( not self.service.settings.mock and not self.service.settings.write_enabled ): return rows = self.db.all( "SELECT * FROM image_retention WHERE starred=0 AND expires_at<=? AND cleanup_operation IS NULL", (now_iso(),), ) # Bound work per cycle; device reads occur only when there is due work. for row in rows[:20]: device_id, image_id = row["device_id"], row["image_id"] if self.service.blocked(device_id) or not self.eligible( device_id, image_id ): continue driver = self.service.registry.get(device_id) device = driver.device(refresh=True) if device.status != "online" or device.current_content.id == image_id: continue id = self.service.submit( "delete", {"image_id": image_id, "confirm_current": False, "retention": True}, device_id, "retention", "retention-" + row["upload_operation"], "retention-" + row["upload_operation"] + "-" + datetime.now(UTC).strftime("%Y%m%d%H"), ) self.db.execute( "UPDATE image_retention SET cleanup_operation=? WHERE device_id=? AND image_id=?", (id, device_id, image_id), )