Files
ComfyAssets-ComfyUI_PromptM…/py/api/workflow_storage.py
T
Vito Sansevero 8d61eda691 feat(maintenance): one kept pre-migration backup and nothing-to-do refusal
Re-review fixes R2, R3, R4, R5 and R6 (job side):

- R3: Apply refuses a plan with nothing to rewrite (409 nothing-to-do, no
  job, no backup). Only the first apply keeps a pre-workflow-storage
  backup; later applies (after a Cancel, a rerun) take a pooled safety
  backup, forced on whatever the policy, so one copy is never pruned.
- R4: a second-connection writer test runs against the live job loop; with
  its pause removed the writer hits "database is locked" (4 of 5 links).
- R5: the digest tool loads only database/workflow_documents.py by path (no
  package start-up, no log handler) and opens a stopped database with
  immutable=1, so it leaves no -wal/-shm; a live one is read through its WAL.
- R6: a backup that fails on a damaged document says which one in the job
  line.
- R2: README's changelog says images recorded by this version use the new
  form from the first start, and the way back is the earliest
  pre-workflow-storage backup (or a pre-update backup if the job never ran).
2026-10-24 06:13:06 -07:00

278 lines
12 KiB
Python

"""Store each workflow once: the migration and Reclaim space jobs (#249 S6).
Reviewed like every maintenance operation (``POST /prompt_manager/
maintenance/preview {operation:"workflow_storage"}``, a ``Plan`` with the
records to rewrite, the backup and the disk check) and applied with its token
(``POST /prompt_manager/maintenance/apply``). The apply starts:
1. **Store each workflow once**: kind ``maintenance``, admission ``records``,
cancellable, groups ``changed``/``protected``/``errors``. It always takes
a verified pre-migration backup, whatever the safety backup policy, named
``<db>.pre-workflow-storage_<stamp>`` outside the pruned ``<db>.backup_*``
pool and kept until the user removes it (``backup-failed`` ends the job
with nothing rewritten). It then rewrites old-form rows batch by batch
(``database/workflow_storage.py``): one transaction per batch of at most
``BATCH_SECONDS``, then ``BATCH_PAUSE`` with the write lock free, progress
after each, Cancel honoured between batches. A row that cannot be
rewritten stays inline and is listed in ``errors``; any other failure
rolls its batch back whole and fails the job. Every record still reads as
before, and the next apply continues from the first old-form row.
2. **Reclaim space**, once every record was looked at: kind ``maintenance``,
admission ``exclusive`` (it runs alone; VACUUM holds the write lock), not
cancellable. It checks the disk for VACUUM's copies of the live pages
(``disk_plan``), then sweeps, runs VACUUM and truncates the WAL. When
another job holds the registry, it does not start and the finished
migration says so and names Optimize database.
Only the database changes: no image or video file is opened.
"""
import asyncio
import sqlite3
from .jobs import MAINTENANCE_JOB_KIND, JobBusy
from .maintenance import (
MAINTENANCE_GROUPS,
PRE_MIGRATION_LABEL,
SafetyBackupFailed,
backup_failed_message,
_fail_job,
_megabytes,
disk_plan,
short_space_message,
take_safety_backup,
)
try:
from ...database import workflow_storage
from ...database.health import readonly_uri
except ImportError:
from database import workflow_storage
from database.health import readonly_uri
OPERATION = workflow_storage.OPERATION
TITLE = "Store each workflow once"
RECLAIM_TITLE = "Reclaim space"
REWRITE_FAILED = (
"Storing workflows once stopped on an error. The batch in progress was "
"rolled back; every record reads as before. Run it again to continue."
)
RECLAIM_FAILED = "Reclaim space failed; the database is unchanged."
ROW_KEPT = "could not be rewritten; it keeps its inline text and reads as before"
NOTHING_LEFT = "Every image record already stores its workflow once"
def _plural(count, one, many=None):
return f"{count:,} {one if count == 1 else (many or one + 's')}"
def live_bytes(db_path):
"""The pages VACUUM copies: page_count minus the free list (blocking)."""
conn = sqlite3.connect(readonly_uri(db_path), uri=True)
try:
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
pages = conn.execute("PRAGMA page_count").fetchone()[0]
free = conn.execute("PRAGMA freelist_count").fetchone()[0]
return page_size * (pages - free)
finally:
conn.close()
class WorkflowStorageRoutesMixin:
"""Mixin for the maintenance routes: the workflow storage jobs."""
def _start_workflow_storage(self, db, backup):
"""Start the migration job (raises JobBusy); the token is the caller's."""
return self._jobs.start(
MAINTENANCE_JOB_KIND,
TITLE,
admission="records",
cancellable=True,
scope={"operation": OPERATION},
groups=MAINTENANCE_GROUPS,
runner=lambda job: self._run_workflow_storage(job, db, backup),
)
async def _run_workflow_storage(self, job, db, _backup_on):
# Always backed up first, whatever the policy: the way back. The first
# apply keeps a pre-migration backup; later ones (after a Cancel, a
# rerun) take a pooled safety backup, so only one copy is never pruned.
counts = await self._run_in_executor(db.workflow_storage_status)
if not counts["rows"]:
job.log_line(NOTHING_LEFT)
job.update(progress=100, status_line=NOTHING_LEFT)
return
kept = await self._run_in_executor(
workflow_storage.pre_migration_backups, db.model.db_path
)
label = "backup" if kept else PRE_MIGRATION_LABEL
backup = await self._backup_before_rewrite(job, db, label, kept)
if backup is None:
return
try:
done = await self._rewrite_workflows(job, db, backup)
finally:
if kept:
await self._prune_backup_pool(job, db)
if done:
job.finish("finished")
self._start_reclaim_space(job, db)
async def _backup_before_rewrite(self, job, db, label, kept):
"""The verified backup's status note, or None (the job failed).
``label`` ``pre-workflow-storage``: kept until the user removes it.
``backup``: a pooled safety backup, while the earliest kept
pre-migration backup (``kept[0]``) stays. A failure ends the job
``backup-failed``, nothing changed.
"""
noun = "safety backup" if kept else "pre-migration backup"
job.update(status_line=f"Taking the {noun}")
job.log_line(f"Taking the {noun} of the database")
try:
name = await self._run_in_executor(take_safety_backup, db.model, label)
except SafetyBackupFailed as error:
self.logger.error("Backup before the rewrite failed", exc_info=True)
_fail_job(job, "backup-failed", backup_failed_message(error))
return None
if kept:
job.log_line(
f"Safety backup {name} verified; the pre-migration backup "
f"{kept[0]} is kept"
)
return f"Safety backup {name}"
job.log_line(
f"Pre-migration backup {name} verified; it is kept until you remove it"
)
return f"Pre-migration backup {name}"
async def _rewrite_workflows(self, job, db, backup):
"""Rewrite every old-form row; True when every row was looked at."""
counts = await self._run_in_executor(db.workflow_storage_status)
job.update(total=counts["rows"], processed=0)
job.update(status_line="Rewriting image records")
job.log_line(f"{_plural(counts['rows'], 'image record')} to rewrite")
rows = protected = errors = 0
last_id = 0
while True:
if job.cancelled:
return self._stopped(job, rows, backup)
try:
batch = await self._run_in_executor(
db.migrate_workflow_batch,
last_id,
workflow_storage.BATCH_ROWS,
workflow_storage.BATCH_BYTES,
)
except Exception:
self.logger.exception("Storing workflows once failed")
_fail_job(job, None, REWRITE_FAILED)
return False
if batch["done"]:
break
last_id = batch["last_id"]
rows += batch["rows"]
protected += batch["protected"]
errors += len(batch["errors"])
job.record_counted("changed", batch["rows"])
job.record_counted("protected", batch["protected"])
for image_id in batch["errors"]:
job.record(
"errors",
{"path": "", "detail": f"Image record {image_id}: {ROW_KEPT}"},
f"! Image record {image_id}: {ROW_KEPT}",
)
total = max(job.total, rows)
job.update(
processed=rows,
total=total,
progress=100 * rows // total if total else 100,
status_line=f"Rewrote {_plural(rows, 'image record')}",
)
job.publish({"type": "progress", "processed": rows, "total": total})
# Leave the write lock free between batches (monitor, run counts).
await asyncio.sleep(workflow_storage.BATCH_PAUSE)
removed = await self._run_in_executor(db.sweep_workflow_documents)
line = f"Rewrote {_plural(rows, 'image record')}"
if protected:
line += f"; {_plural(protected, 'stored value')} not text, kept as is"
if errors:
kept = "keeps its" if errors == 1 else "keep their"
line += (
f"; {_plural(errors, 'image record')} could not be rewritten and "
f"{kept} inline text"
)
job.log_line(line)
if removed:
job.log_line(f"Removed {_plural(removed, 'unused workflow document')}")
job.update(progress=100, status_line=f"{line} · {backup}")
return True
def _stopped(self, job, rows, backup):
line = (
f"Stopped after {_plural(rows, 'image record')}; "
"run it again to continue"
)
job.log_line(line)
job.update(status_line=f"{line} · {backup}")
job.finish("cancelled")
return False
def _start_reclaim_space(self, finished, db):
"""Start Reclaim space after the migration ``finished``, or say why not."""
try:
self._jobs.start(
MAINTENANCE_JOB_KIND,
RECLAIM_TITLE,
admission="exclusive",
cancellable=False,
scope={"operation": OPERATION},
groups=MAINTENANCE_GROUPS,
runner=lambda job: self._run_reclaim_space(job, db),
)
except JobBusy as busy:
line = (
f"Reclaim space did not start: {busy.running.title} is running. "
"Run Optimize database to give the space back."
)
finished.log_line(line)
finished.update(status_line=f"{finished.status_line} · {line}")
return
finished.log_line("Reclaim space started")
async def _run_reclaim_space(self, job, db):
job.update(status_line="Checking free disk space")
try:
live = await self._run_in_executor(live_bytes, db.model.db_path)
_disk, short, _size = await self._run_in_executor(
disk_plan,
db.model.db_path,
backup=False,
vacuum=True,
vacuum_bytes=live,
)
except (OSError, sqlite3.Error):
self.logger.exception("Could not measure the disk space for Reclaim space")
return _fail_job(job, None, RECLAIM_FAILED)
if short is not None:
return _fail_job(job, "insufficient-space", short_space_message(short))
job.update(status_line="Reclaiming space (the database is locked)")
job.log_line("Rebuilding the database file (VACUUM)")
try:
result = await self._run_in_executor(db.reclaim_space)
except Exception:
self.logger.exception("Reclaim space failed")
return _fail_job(job, None, RECLAIM_FAILED)
saved = max(0, result["before_bytes"] - result["after_bytes"])
line = (
f"Reclaimed {_megabytes(saved)} · the database is now "
f"{_megabytes(result['after_bytes'])}"
)
if result["checkpoint_busy"]:
line += " (the rest after the next restart)"
job.record_counted("changed", 1)
job.log_line(line)
job.update(progress=100, status_line=line)