feat(maintenance): reviewed, read-only maintenance plans

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.
This commit is contained in:
Vito Sansevero
2026-10-23 17:16:00 -07:00
parent 6505d339aa
commit f09d2250df
12 changed files with 1684 additions and 113 deletions
+8
View File
@@ -13,6 +13,7 @@ Split into domain-specific mixins for maintainability:
- ReviewRecordingMixin: Needs review recording for the scan and the monitor
- ReviewEventsMixin: the Needs review change stream an open page re-reads on
- SystemRoutesMixin: System health check, Overview and usage statistics
- MaintenanceRoutesMixin: reviewed maintenance plans (preview, apply)
"""
import asyncio
@@ -46,6 +47,8 @@ from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .thumbnail_jobs import ThumbnailJobsMixin
from .system import SystemRoutesMixin
from .maintenance import MaintenanceRoutesMixin
from .maintenance_plans import PlanStore
from .jobs import JobRegistry
from .logging_routes import LoggingRoutesMixin
from .autotag_routes import AutotagRoutesMixin
@@ -285,6 +288,7 @@ class PromptManagerAPI(
ImageTagJobsMixin,
ThumbnailJobsMixin,
SystemRoutesMixin,
MaintenanceRoutesMixin,
LoggingRoutesMixin,
AutotagRoutesMixin,
LoraIntegrationMixin,
@@ -348,6 +352,9 @@ class PromptManagerAPI(
# (py/api/system.py).
self._health_report = None
self._db_generation = 0
# Reviewed maintenance plans under single-use tokens; a restart
# forgets them (py/api/maintenance.py).
self._maintenance_plans = PlanStore()
# Run cleanup on initialization to remove any existing duplicates
try:
@@ -508,6 +515,7 @@ class PromptManagerAPI(
self._register_settings_routes(routes)
self._register_job_routes(routes)
self._register_system_routes(routes)
self._register_maintenance_routes(routes)
self._register_logging_routes(routes)
self._register_autotag_routes(routes)
self._register_lora_routes(routes)
+3 -1
View File
@@ -1112,8 +1112,10 @@ class AdminRoutesMixin:
db.cleanup_duplicates(), "duplicate prompts"
),
"vacuum": vacuum,
# Only under sources that can be read now (never an unmounted drive).
"cleanup_orphaned_images": lambda: count_result(
db.cleanup_missing_images(), "orphaned image records"
db.cleanup_missing_images(self._health_roots()),
"orphaned image records",
),
"check_hash_duplicates": lambda: self._hash_duplicates_result(
db.check_hash_duplicates()
+152
View File
@@ -0,0 +1,152 @@
"""Maintenance routes: reviewed, read-only plans (#249 System S6).
``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews
one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or
``missing_links``; ``database/maintenance.py``) off the event loop and
answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count,
label}, protected:{count, reasons}, backup:{policy, destination_label},
media:{deletes_files, label}, details:[≤50 {label, detail}]}``. The preview
writes nothing. The plan's fingerprint, its targets and the sources it judged
stay in the server's ``PlanStore`` under the token, bound to the database the
preview read (a restore that replaces it invalidates the plan).
The operation enum is strict, ``options`` must be an empty object and the
body is capped at ``PREVIEW_BODY_MAX`` bytes. Errors are ``{success:false,
code, error}`` with ``invalid-request`` (400), ``too-large`` (413: the body,
or a plan over the store's budget), ``storage-changed`` (409: the database
was replaced while the preview read it) or a null code (500); none carries a
server path or exception text.
"""
import datetime
import os
from aiohttp import web
from .maintenance_plans import ScopeTooLarge
try:
from ...database.maintenance import MAINTENANCE_OPERATIONS
except ImportError:
from database.maintenance import MAINTENANCE_OPERATIONS
PREVIEW_BODY_MAX = 4096
# The concept's Maintenance row titles, which name each operation's job.
MAINTENANCE_TITLES = {
"duplicate_prompts": "Duplicate prompt text",
"optimize": "Optimize database",
"unused_components": "Unused prompt components",
"missing_links": "Missing-file links",
}
_OPERATION_ERROR = "operation must be one of: " + ", ".join(MAINTENANCE_OPERATIONS)
def _failure(message, status, code=None, **extra):
return web.json_response(
{"success": False, "code": code, "error": message, **extra}, status=status
)
def _invalid(message):
return _failure(message, 400, "invalid-request")
def _iso(timestamp):
return datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).isoformat()
class MaintenanceRoutesMixin:
"""Mixin providing the /prompt_manager/maintenance plan endpoints."""
def _register_maintenance_routes(self, routes):
@routes.post("/prompt_manager/maintenance/preview")
async def maintenance_preview_route(request):
return await self.preview_maintenance(request)
def _database_identity(self, db):
"""What binds a plan to one database: the swap generation and its file."""
path = os.path.normcase(os.path.realpath(db.model.db_path))
return (self._db_generation, path)
@staticmethod
def _safety_backup_policy():
"""``on`` or ``off``: whether an apply takes a safety backup first."""
return "on"
async def _read_maintenance_body(self, request, allowed):
"""``(data, None)`` for a capped JSON object with only ``allowed`` keys."""
data, error = await self._read_json_body(request, max_bytes=PREVIEW_BODY_MAX)
if error is not None:
if error.status == 413:
return None, _failure("Request body too large", 413, "too-large")
return None, _invalid("Request body must be a JSON object")
unknown = sorted(set(data) - set(allowed))
if unknown:
return None, _invalid("Unknown field: " + ", ".join(unknown)[:200])
return data, None
async def preview_maintenance(self, request):
data, error = await self._read_maintenance_body(
request, ("operation", "options")
)
if error is not None:
return error
operation = data.get("operation")
options = data.get("options", {})
if not isinstance(operation, str) or operation not in MAINTENANCE_OPERATIONS:
return _invalid(_OPERATION_ERROR)
if not isinstance(options, dict) or options:
return _invalid(f"{operation} takes no options")
db = self.db
identity = self._database_identity(db)
try:
roots = (
await self._run_in_executor(self._health_roots)
if operation == "missing_links"
else []
)
draft = await self._run_in_executor(
db.preview_maintenance, operation, roots
)
except Exception:
self.logger.exception(f"Maintenance preview of {operation} failed")
return _failure("Failed to review maintenance", 500)
if identity != self._database_identity(self.db):
return _failure(
"The database was replaced while it was reviewed. Review again.",
409,
"storage-changed",
)
policy = self._safety_backup_policy()
store = self._maintenance_plans
try:
token = store.issue(
operation,
draft.fingerprint,
{
"targets": draft.targets,
"roots": roots,
"protected": draft.protected["count"],
"backup": policy,
},
identity,
)
except ScopeTooLarge:
return _failure(
"This plan is too large to review at once.", 413, "too-large"
)
plan = store.peek(token, operation)
name = os.path.basename(db.model.db_path)
return web.json_response(
{
"success": True,
"token": token,
"expires_at": _iso(plan.expires_at),
**draft.public(),
"backup": {
"policy": policy,
"destination_label": f"{name}.backup_<date and time>",
},
},
headers={"Cache-Control": "no-store"},
)
+138
View File
@@ -0,0 +1,138 @@
"""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]