POST /prompt_manager/maintenance/preview reviews duplicate_prompts, optimize, unused_components or missing_links on its own mode=ro snapshot and answers a Plan (scope, protected reasons, backup policy, media, at most 50 detail rows). The fingerprint, targets and judged sources stay in a server-side PlanStore under a single-use token (15 min, 8 plans, 16 MiB, never truncated; a restart forgets them), bound to the database it read. The previews reuse the legacy queries: duplicate groups and the merge are shared with cleanup_duplicates, ORPHANED_PROMPTS_SQL is built from the #239 protection predicates the plan reports, and missing links are judged by the health check's link_states. Legacy /maintenance keeps its shape; vacuum now reports success:false when VACUUM fails, and cleanup_orphaned_images skips links under unavailable sources.
139 lines
4.8 KiB
Python
139 lines
4.8 KiB
Python
"""The reviewed-plan store behind maintenance previews (#249 System S6).
|
|
|
|
A preview issues a plan: its operation, the fingerprint of exactly what was
|
|
reviewed, the payload an apply needs (target ids, the sources it judged) and
|
|
the identity of the database it describes. Everything is held here, in
|
|
server memory, under a single-use token (``secrets.token_urlsafe(32)``); the
|
|
client only ever sees the token and the public plan. Plans expire after
|
|
``ttl`` seconds and are gone after a restart.
|
|
|
|
The store keeps at most ``max_plans`` plans and ``max_bytes`` of payload
|
|
(measured as compact JSON). A plan larger than ``max_bytes`` is refused with
|
|
``ScopeTooLarge``: it is never cut down to fit, since an apply must do
|
|
exactly what was reviewed. A new plan that fits makes room by dropping the
|
|
oldest ones, which then read as expired.
|
|
|
|
A consumed token is remembered (the last ``MAX_USED``) with the job it
|
|
started, so a second apply of it is told which job ran it.
|
|
"""
|
|
|
|
import json
|
|
import secrets
|
|
import time
|
|
from collections import OrderedDict
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
class PlanExpired(Exception):
|
|
"""No live plan has the token: unknown, expired, dropped or another operation's."""
|
|
|
|
|
|
class PlanUsed(Exception):
|
|
"""The token was already applied; ``job_id`` is the job that applied it."""
|
|
|
|
def __init__(self, job_id):
|
|
super().__init__("This plan was already applied")
|
|
self.job_id = job_id
|
|
|
|
|
|
class ScopeTooLarge(Exception):
|
|
"""The plan's payload exceeds the store's byte budget."""
|
|
|
|
|
|
@dataclass
|
|
class StoredPlan:
|
|
"""One reviewed plan, as only the server sees it."""
|
|
|
|
operation: str
|
|
fingerprint: str
|
|
payload: dict
|
|
db_identity: tuple
|
|
expires_at: float # wall-clock seconds since the epoch, for the response
|
|
deadline: float = field(repr=False) # on the store's monotonic clock
|
|
size: int = field(repr=False)
|
|
|
|
|
|
def _payload_size(payload):
|
|
return len(json.dumps(payload, separators=(",", ":"), default=str))
|
|
|
|
|
|
class PlanStore:
|
|
"""Reviewed maintenance plans under single-use tokens."""
|
|
|
|
MAX_USED = 64 # consumed tokens remembered for plan-used answers
|
|
|
|
def __init__(
|
|
self,
|
|
ttl=900,
|
|
max_plans=8,
|
|
max_bytes=16 * 2**20,
|
|
*,
|
|
clock=time.monotonic,
|
|
wall_clock=time.time,
|
|
):
|
|
self.ttl = ttl
|
|
self.max_plans = max_plans
|
|
self.max_bytes = max_bytes
|
|
self._clock = clock
|
|
self._wall_clock = wall_clock
|
|
self._plans = OrderedDict() # token -> StoredPlan, oldest first
|
|
self._used = OrderedDict() # token -> job id, oldest first
|
|
|
|
def issue(self, operation, fingerprint, payload, db_identity) -> str:
|
|
"""Hold a plan and return its token; ScopeTooLarge when it cannot fit."""
|
|
size = _payload_size(payload)
|
|
if size > self.max_bytes:
|
|
raise ScopeTooLarge(f"The plan needs {size} bytes")
|
|
self._drop_expired()
|
|
while self._plans and (
|
|
len(self._plans) >= self.max_plans
|
|
or self._held_bytes() + size > self.max_bytes
|
|
):
|
|
self._plans.popitem(last=False)
|
|
token = secrets.token_urlsafe(32)
|
|
self._plans[token] = StoredPlan(
|
|
operation=operation,
|
|
fingerprint=fingerprint,
|
|
payload=payload,
|
|
db_identity=tuple(db_identity),
|
|
expires_at=self._wall_clock() + self.ttl,
|
|
deadline=self._clock() + self.ttl,
|
|
size=size,
|
|
)
|
|
return token
|
|
|
|
def peek(self, token, operation=None) -> StoredPlan:
|
|
"""The live plan of ``token``, left in place; raises PlanUsed or PlanExpired."""
|
|
if not isinstance(token, str):
|
|
raise PlanExpired("No such plan")
|
|
if token in self._used:
|
|
raise PlanUsed(self._used[token])
|
|
plan = self._plans.get(token)
|
|
if plan is None or (operation is not None and plan.operation != operation):
|
|
raise PlanExpired("No such plan")
|
|
if self._clock() > plan.deadline:
|
|
del self._plans[token]
|
|
raise PlanExpired("The plan expired")
|
|
return plan
|
|
|
|
def consume(self, token, operation=None, *, job_id=None) -> StoredPlan:
|
|
"""Take the plan of ``token`` once; later calls raise PlanUsed(job_id)."""
|
|
plan = self.peek(token, operation)
|
|
del self._plans[token]
|
|
self._used[token] = job_id
|
|
while len(self._used) > self.MAX_USED:
|
|
self._used.popitem(last=False)
|
|
return plan
|
|
|
|
def discard(self, token):
|
|
"""Forget a plan that can no longer apply (its database was replaced)."""
|
|
self._plans.pop(token, None)
|
|
|
|
def _held_bytes(self):
|
|
return sum(plan.size for plan in self._plans.values())
|
|
|
|
def _drop_expired(self):
|
|
now = self._clock()
|
|
for token in [t for t, plan in self._plans.items() if now > plan.deadline]:
|
|
del self._plans[token]
|