* 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. * feat(maintenance): apply reviewed plans as Activity jobs POST /prompt_manager/maintenance/apply {token} starts a maintenance job (records admission, not cancellable, groups changed/protected/errors). The token is consumed on the loop turn the job starts: a second apply is 409 plan-used naming the job, a busy refusal keeps the token, an unknown or expired one is 410 plan-expired, and a restore since the review is 409 storage-changed. With the safety backup policy on (maintenance.safety_backup, default true, saved through the settings route with the revision), the job first writes a backup into a temp file beside the database, makes it a standalone rollback-journal file, verifies it, fsyncs it and renames it onto a unique <db>.backup_<YYYYmmdd_HHMMSS_ffffff>[_n] name; any failure leaves no file and nothing changed. The apply then rechecks the reviewed fingerprint inside BEGIN IMMEDIATE (plan-changed writes nothing), deletes in batches of 400 ids, counts changed and protected records, logs the backup by name only and prunes old safety backups. With the policy off the log and the status line say "No safety backup". * feat(maintenance): find and remove duplicate files through Activity Duplicate image files move onto the job registry: - POST /prompt_manager/jobs/duplicates finds content-identical media files over every effective source as a cancellable read job ("Find duplicate image files", group groups_found); the server holds the groups per job and GET /prompt_manager/maintenance/duplicates/{id} pages them ten at a time. - The duplicate_files preview binds a removal to the reviewed keepers. Every keeper and removal is checked again: containment as text before any filesystem access, then not a link, still inside its source, same size and hash. The plan lists the database links that move. - Apply runs duplicate_cleanup (records, not cancellable): the promised safety backup, then per file a recheck, the unlink with every handle closed and its thumbnail. A changed file is kept, the first failed unlink stops the job with a coded message (never the OS text), and only removed files have their image links and favorites moved to the kept copy. - The legacy scan, stream, status and delete routes keep their shapes on the same registry kinds; the hashing, grouping and thumbnail removal are shared with them. Coordinator rulings on Tasks 15-16: - Safety backups share the restore pool of 10 `<db>.backup_*` files; the pool is now also pruned when an apply fails after its backup. - Plans report the safety backup's size and the free disk space, and apply refuses up front (507 insufficient-space) when the backup or VACUUM does not fit, so no job fails halfway for space. * fix(maintenance): never remove the kept copy; move records before unlink Final review of #313: - C1: the duplicate find tells files apart by (st_dev, st_ino), so a source listed twice, a symlinked source inside another and a hardlink are one file. The preview and the job refuse a removal that os.path.samestat matches with its kept copy. - I1: each file's records move to its verified kept copy in one short transaction before that file is unlinked; a failed move deletes nothing and a failed unlink leaves the records on the identical kept copy. The status line counts files removed and not removed. - I2: links are looked up under every spelling of the source (the real path, the configured and monitored folders, ComfyUI's output). - I5: image tags join the kept copy as their union and the removed copy's pending, undecided Needs review items are settled; the plan says so. - I3: the VACUUM disk check measures SQLite's real temporary folder. - M1: the token is looked at again with no await before the job starts. - M2: the backup decision is made once and handed to the job. - M3: backups are linked into place; startup sweeps interrupted temps. - M4: Optimize runs alone (new "exclusive" admission) and its plan says the database is locked while VACUUM runs. - M5: the reviewed duplicate merge is strict: collisions and Library titles are protected, and errors roll back. The legacy cleanup is unchanged. - M7: 2,000 keep entries within a 1 MiB body. M8: event waits in tests. - M9: the preview binds files by size, mtime and inode without hashing; the job hashes right before each unlink. * fix(maintenance): protect one file reached through two mounts Re-review of #313, round 2: - R1: a removal on another device than its kept copy, with the same size and mtimes within 2 s, is protected when its inode is equal or its path within its source is the same ("May be the same file reached through another mount or share"), in the preview and again right before each unlink. The job holds the kept copy open during the unlink, so on Windows deleting the kept copy itself through another path fails. - R2: a file's spellings are found by containment: every configured, monitored, ComfyUI-output or listed folder whose stat identity is a folder above the file. Parent spellings of nested sources and case variants match; favorites move under every listed root holding the file. Lookups stay index searches. - m1: a failed unlink's row says the links now name the kept copy. - m2: a volume with no file ids reads "Can't confirm this is a separate file on this drive". * fix(maintenance): stat duplicate candidates with os.stat Python 3.10's Path.stat uses the os.stat bound when pathlib was imported, so the find and the per-file checks could see different stat calls. The find now calls os.stat directly, like every other check; the mount tests failed on py3.10 only. * fix(maintenance): recheck the kept copy right before each unlink Re-review 2 of #313: - n1: the cross-mount guard also matches when one path within its source ends with the other on a whole folder name (sources listed at different depths, e.g. 2024/x.png and x.png on a FUSE or noserverino mount). - n2: after a file's records move and right before its unlink, the held kept copy must still be the file at its path (fstat of the hold against os.stat) and the cross-mount guard must still hold; otherwise the file is kept, its records move back to it, and it counts as not removed. * test(maintenance): the kept-copy race tests hold on Windows On Windows the removal job holds the kept copy open without FILE_SHARE_DELETE, so the tests' attempt to delete or replace it during the record move is refused. The tests now assert that native outcome: the kept copy stays and the removal goes ahead. * feat(database): store each workflow and prompt graph once generated_images.workflow_data and prompt_metadata repeated the same large JSON text for every image of a run. Each distinct text now lives once in workflow_documents, keyed by the sha256 of its exact UTF-8 bytes and zlib-compressed; image rows reference it through workflow_document_id and prompt_document_id. - New writes (link_image_to_prompt, pair ingestion for scan, monitor and Needs review) store the new form. Old rows keep their inline text and read the same until the migration rewrites them. - Every reader goes through one accessor (read_stored_text, resolve_image_row): gallery and legacy image lists, image by id, prompt info for the inspector and metadata page, diagnostics. Payloads keep their shape. Inline text wins when present; a document that fails its length or hash check reads as unreadable, never as other text. - No trigger (restore validators refuse them): a document goes in the same transaction as its last reference (image and prompt deletes, missing-link cleanup, duplicate merges, duplicate-file link merges, ingestion updates), and a sweep at startup catches anything a path missed. - verify_database_file validates the table, the id columns, their foreign keys and indexes, and the stored values. * feat(database): migrate old workflow rows in batches, then reclaim space The workflow storage migration rewrites old-form image rows (text inline) to document ids, batch by batch: - Each batch is one BEGIN IMMEDIATE transaction of at most 200 rows or 16 MB of text, found by a rowid range search and read one row at a time. - Each text is stored or reused, then read back through the one accessor before its row changes; a text that would not come back identical fails and rolls back the whole batch. - A value that is not text stays inline and is counted as protected. - A crash or a stop leaves committed batches new form and the rest old form; both read the same, and a rerun continues. No media file is opened. - Unreferenced documents are swept at the end. The maintenance preview gains a read-only workflow_storage plan (records to rewrite, inline bytes where SQLite has octet_length, the Reclaim space and older-version notes). reclaim_space sweeps, runs VACUUM and truncates the WAL so the freed pages go back to the disk. * feat(maintenance): store each workflow once as an Activity job The reviewed maintenance plan gains workflow_storage. Applying it starts "Store each workflow once": kind maintenance, records admission, cancellable, groups changed/protected/errors. - The promised verified safety backup comes first; a failed backup ends the job with nothing rewritten. The apply's up-front disk check covers it. - Old-form rows are rewritten batch by batch on the event loop's executor, with progress after each committed batch. Cancel stops between batches; a failed batch rolls back whole and fails the job with a coded message. Either way every record reads as before and the next apply continues. - When every row was looked at, "Reclaim space" starts: exclusive (runs alone), not cancellable. It checks the disk for VACUUM's copies of the live pages, then sweeps, runs VACUUM and truncates the WAL. If another job holds the registry it does not start, and the finished migration says so and names Optimize database. - No image or video file is opened; tests track every open under the output folder and compare the media bytes and mtimes. * feat(maintenance): document the workflow storage contract TESTING.md's maintenance contract gains the workflow storage rules: the documents table and its two forms, the one accessor, garbage in the same transaction plus the sweeps, the batched migration and Reclaim space, the media and memory guarantees, and that a deduped backup cannot be restored into an older version. A test covers the preview on SQLite before 3.43 (no octet_length, as in older Windows Python builds): the inline-bytes detail is left out. * feat(database): keep the migration safe row by row and beside other writers Final review fixes M1, M2, M3 and M5 for the workflow storage dedupe: - M1: a batch also ends after 0.5 s, and the loop leaves the write lock free for 0.15 s after each committed batch (longer than SQLite's longest busy poll), so the image monitor and run counting on another connection get in between. RED: a threaded writer with a 2 s busy timeout hit "database is locked" (4 of 5 links landed). - M2: each row runs in its own savepoint. A row that cannot be rewritten (undecodable text, a text that would not come back) is rolled back alone, stays inline and is listed in errors; the rest of the batch goes on. - M3: a reused document is decompressed and checked before its id is handed out, and repaired from the text (whose hash is its key) when damaged; verify_database_file reads every document and refuses a damaged one. - M5: the two legacy generated_images rebuilds refuse a file that already has the document id columns instead of dropping them. - The preview names the pre-migration backup and says it is always taken. * feat(maintenance): always back up before storing workflows once Final review fixes I1, I2 and I3 for the workflow storage dedupe: - I1: the migration always takes a verified backup, whatever maintenance.safety_backup says; the plan reports backup.policy "required" and the disk check counts it. - I2: that backup is prompts.db.pre-workflow-storage_<stamp>, outside the pruned prompts.db.backup_* pool and kept until the user removes it; the job's last line and the preview name it, and the legacy restore accepts it. README's changelog warns that an older version opens and restores a rewritten database but shows no workflows, and says how to go back. - I3: tools/workflow_text_digests.py writes the sha256 of every record's stored texts (read-only; inline before the rewrite, through the accessor after), so a diff before and after proves byte identity. - A row the migration could not rewrite is listed in the job's errors and named in its last line, and Reclaim space still runs. * feat(database): fail the batch on system errors and name a damaged document Re-review fixes R1 and R6 (database side), plus R3's helpers: - R1: a row is kept inline only for its own cause: text SQLite cannot decode, or a text that would not come back. A full disk, an I/O error, a malformed database or a bug now fails the batch and rolls it back, instead of listing every remaining row as "could not be rewritten". - R6: verify_database_file names the damaged document and says what to do (restore a backup from before the damage, or store an image with the same workflow, which repairs it). - R3: pre_migration_backups lists the kept backups, earliest first; the plan says "Nothing to rewrite" and names an existing kept backup, and the older version note says new images use the new form from the first start and names the earliest backup as the way back. * 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).
425 lines
17 KiB
Python
425 lines
17 KiB
Python
"""Store each workflow once, as a maintenance job (py/api/workflow_storage.py).
|
|
|
|
``POST /prompt_manager/maintenance/preview {operation:"workflow_storage"}``
|
|
reviews the rewrite; applying it starts a ``records`` job that takes the
|
|
verified safety backup first, rewrites old-form rows batch by batch (Cancel
|
|
stops after the current batch) and then starts "Reclaim space", an
|
|
``exclusive`` job with its own disk check. No image file is ever opened.
|
|
"""
|
|
|
|
import asyncio
|
|
import builtins
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from unittest.mock import patch
|
|
|
|
from PIL import Image, PngImagePlugin
|
|
|
|
from database import workflow_storage as ws
|
|
from database.workflow_documents import DocumentReader, resolve_image_row, store_text
|
|
from py.api import maintenance
|
|
from py.api.jobs import MAINTENANCE_JOB_KIND
|
|
|
|
try:
|
|
from tests.test_api_maintenance import ApplyTestCase
|
|
from tests.test_workflow_documents import sample_prompt_graph, sample_workflow
|
|
except ImportError: # discovered with tests/ as the top-level directory
|
|
from test_api_maintenance import ApplyTestCase
|
|
from test_workflow_documents import sample_prompt_graph, sample_workflow
|
|
|
|
WAIT = 20
|
|
OPERATION = "workflow_storage"
|
|
|
|
|
|
def _digest(path):
|
|
with open(path, "rb") as handle:
|
|
return hashlib.sha256(handle.read()).hexdigest()
|
|
|
|
|
|
class WorkflowStorageTestCase(ApplyTestCase):
|
|
def old_library(self, count=12, media=False):
|
|
"""Old-form rows (text inline), four images per run."""
|
|
prompt_id = self.save("a cat")
|
|
conn = self.api.db.model.get_connection()
|
|
for index in range(count):
|
|
run = index // 4
|
|
path = self.output_dir / f"{index}.png"
|
|
workflow = json.dumps(sample_workflow(run, nodes=120))
|
|
if media:
|
|
info = PngImagePlugin.PngInfo()
|
|
info.add_text("workflow", workflow)
|
|
with Image.new("RGB", (4, 4)) as image:
|
|
image.save(path, pnginfo=info)
|
|
with conn:
|
|
conn.execute(
|
|
"INSERT INTO generated_images (prompt_id, image_path, filename, "
|
|
"file_path, workflow_data, prompt_metadata, parameters) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, '{}')",
|
|
(
|
|
prompt_id,
|
|
str(path),
|
|
path.name,
|
|
str(path),
|
|
workflow,
|
|
json.dumps(sample_prompt_graph(run)),
|
|
),
|
|
)
|
|
return prompt_id
|
|
|
|
def texts(self):
|
|
conn = self.api.db.model.get_connection()
|
|
reader = DocumentReader(conn)
|
|
return {
|
|
row["id"]: resolve_image_row(reader, row)
|
|
for row in conn.execute(
|
|
"SELECT id, workflow_data, prompt_metadata, workflow_document_id, "
|
|
"prompt_document_id FROM generated_images ORDER BY id"
|
|
)
|
|
}
|
|
|
|
def inline_count(self):
|
|
conn = self.api.db.model.get_connection()
|
|
return conn.execute(
|
|
"SELECT COUNT(*) FROM generated_images WHERE workflow_data IS NOT NULL"
|
|
).fetchone()[0]
|
|
|
|
def reclaim_job(self):
|
|
jobs = [job for job in self.api._jobs.list() if job.title == "Reclaim space"]
|
|
return jobs[0] if jobs else None
|
|
|
|
async def finish(self, job):
|
|
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
|
reclaim = self.reclaim_job()
|
|
if reclaim is not None and reclaim.task is not None:
|
|
await asyncio.wait_for(asyncio.shield(reclaim.task), WAIT)
|
|
return reclaim
|
|
|
|
async def start(self):
|
|
plan = await self.preview(OPERATION)
|
|
body = await self.apply(plan["token"])
|
|
return plan, body["job"], self.api._jobs.get(body["job"]["id"])
|
|
|
|
|
|
class TestWorkflowStorageJob(WorkflowStorageTestCase):
|
|
async def test_preview_reviews_the_rewrite_and_writes_nothing(self):
|
|
self.old_library()
|
|
db_path = self.api.db.model.db_path
|
|
before = _digest(db_path), _digest(db_path + "-wal")
|
|
plan = await self.preview(OPERATION)
|
|
self.assertEqual((_digest(db_path), _digest(db_path + "-wal")), before)
|
|
self.assertEqual(plan["operation"], OPERATION)
|
|
self.assertEqual(plan["scope"]["count"], 12)
|
|
self.assertEqual(plan["scope"]["label"], "12 image records to rewrite")
|
|
self.assertFalse(plan["media"]["deletes_files"])
|
|
self.assertEqual(
|
|
plan["media"]["label"],
|
|
"No image or video file is opened or changed; only the database.",
|
|
)
|
|
self.assertEqual(plan["backup"]["policy"], "required")
|
|
self.assertEqual(
|
|
plan["backup"]["destination_label"],
|
|
"prompts.db.pre-workflow-storage_<date and time>",
|
|
)
|
|
self.assertGreater(plan["backup"]["expected_bytes"], 0)
|
|
self.assertTrue(plan["disk"]["sufficient"])
|
|
|
|
async def test_apply_backs_up_rewrites_then_reclaims_space(self):
|
|
prompt_id = self.old_library(count=40)
|
|
before = self.texts()
|
|
listed = await self.client.request(
|
|
"GET", f"/prompt_manager/prompts/{prompt_id}/images"
|
|
)
|
|
listed_before = await listed.json()
|
|
size_before = self.database_bytes()
|
|
plan, summary, job = await self.start()
|
|
self.assertEqual(summary["kind"], MAINTENANCE_JOB_KIND)
|
|
self.assertEqual(summary["title"], "Store each workflow once")
|
|
self.assertEqual(summary["admission"], "records")
|
|
self.assertTrue(summary["cancellable"])
|
|
self.assertEqual(summary["scope"], {"operation": OPERATION})
|
|
reclaim = await self.finish(job)
|
|
self.assertEqual(job.state, "finished", job.log)
|
|
self.assertEqual(job.counters, {"changed": 40, "protected": 0, "errors": 0})
|
|
self.assertEqual(job.total, 40)
|
|
self.assertEqual(job.processed, 40)
|
|
self.assertEqual(self.inline_count(), 0)
|
|
self.assertEqual(self.texts(), before)
|
|
self.assertEqual(self.backups(), []) # never in the pruned pool
|
|
[backup] = [
|
|
name
|
|
for name in os.listdir(self.tmpdir)
|
|
if name.startswith("prompts.db.pre-workflow-storage_")
|
|
]
|
|
self.assertIn(f"Pre-migration backup {backup} verified", "\n".join(job.log))
|
|
self.assertEqual(
|
|
job.status_line,
|
|
f"Rewrote 40 image records · Pre-migration backup {backup}",
|
|
)
|
|
# The safety backup holds the old form, verified.
|
|
conn = sqlite3.connect(os.path.join(self.tmpdir, backup))
|
|
try:
|
|
inline = conn.execute(
|
|
"SELECT COUNT(*) FROM generated_images WHERE workflow_data IS NOT NULL"
|
|
).fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
self.assertEqual(inline, 40)
|
|
# Reclaim space ran alone afterwards and gave the space back.
|
|
self.assertIsNotNone(reclaim)
|
|
self.assertEqual(reclaim.admission, "exclusive")
|
|
self.assertFalse(reclaim.cancellable)
|
|
self.assertEqual(reclaim.state, "finished", reclaim.log)
|
|
self.assertIn("Reclaim space started", "\n".join(job.log))
|
|
self.assertLess(self.database_bytes(), size_before / 3)
|
|
wal = self.api.db.model.db_path + "-wal"
|
|
self.assertEqual(os.path.getsize(wal) if os.path.exists(wal) else 0, 0)
|
|
self.assertRegex(reclaim.status_line, r"^Reclaimed [\d.,]+ MB · ")
|
|
listed = await self.client.request(
|
|
"GET", f"/prompt_manager/prompts/{prompt_id}/images"
|
|
)
|
|
self.assertEqual(await listed.json(), listed_before)
|
|
self.assertEqual(self.strays(), [backup]) # only the kept backup
|
|
self.assertNotIn(self.tmpdir, json.dumps(job.report()))
|
|
|
|
async def test_a_backup_failure_rewrites_nothing(self):
|
|
self.old_library()
|
|
from py.api import workflow_storage as api_storage
|
|
|
|
with patch.object(
|
|
api_storage,
|
|
"take_safety_backup",
|
|
side_effect=maintenance.SafetyBackupFailed("no"),
|
|
):
|
|
_plan, _summary, job = await self.start()
|
|
await self.finish(job)
|
|
self.assertEqual(job.state, "failed")
|
|
self.assertEqual(job.last_event["code"], "backup-failed")
|
|
self.assertEqual(self.inline_count(), 12)
|
|
self.assertIsNone(self.reclaim_job())
|
|
|
|
async def test_cancel_stops_after_the_current_batch_and_a_rerun_continues(self):
|
|
self.old_library()
|
|
before = self.texts()
|
|
real = self.api.db.migrate_workflow_batch
|
|
holder = {}
|
|
|
|
def first_then_cancel(*args, **kwargs):
|
|
result = real(*args, **kwargs)
|
|
holder["job"].cancelled = True # Cancel pressed during batch one
|
|
return result
|
|
|
|
with (
|
|
patch.object(ws, "BATCH_ROWS", 5),
|
|
patch.object(
|
|
self.api.db, "migrate_workflow_batch", side_effect=first_then_cancel
|
|
),
|
|
):
|
|
plan = await self.preview(OPERATION)
|
|
body = await self.apply(plan["token"])
|
|
holder["job"] = job = self.api._jobs.get(body["job"]["id"])
|
|
await self.finish(job)
|
|
self.assertEqual(job.state, "cancelled")
|
|
self.assertEqual(job.counters["changed"], 5)
|
|
self.assertEqual(self.inline_count(), 7)
|
|
self.assertIn("run it again to continue", job.status_line)
|
|
self.assertIsNone(self.reclaim_job())
|
|
self.assertEqual(self.texts(), before)
|
|
_plan, _summary, again = await self.start()
|
|
await self.finish(again)
|
|
self.assertEqual(again.state, "finished", again.log)
|
|
self.assertEqual(again.counters["changed"], 7)
|
|
self.assertEqual(self.inline_count(), 0)
|
|
self.assertEqual(self.texts(), before)
|
|
|
|
async def test_a_failed_batch_fails_the_job_and_every_record_still_reads(self):
|
|
self.old_library()
|
|
before = self.texts()
|
|
real = self.api.db.migrate_workflow_batch
|
|
calls = []
|
|
|
|
def failing(*args, **kwargs):
|
|
calls.append(1)
|
|
if len(calls) == 2:
|
|
raise sqlite3.OperationalError("disk I/O error")
|
|
return real(*args, **kwargs)
|
|
|
|
with (
|
|
patch.object(ws, "BATCH_ROWS", 5),
|
|
patch.object(self.api.db, "migrate_workflow_batch", side_effect=failing),
|
|
):
|
|
_plan, _summary, job = await self.start()
|
|
await self.finish(job)
|
|
self.assertEqual(job.state, "failed")
|
|
self.assertIn(
|
|
"The batch in progress was rolled back; every record reads as before.",
|
|
job.status_line,
|
|
)
|
|
self.assertNotIn("disk I/O", job.status_line)
|
|
self.assertEqual(job.counters["changed"], 5)
|
|
self.assertEqual(job.counters["errors"], 1)
|
|
self.assertEqual(self.texts(), before)
|
|
self.assertIsNone(self.reclaim_job())
|
|
|
|
async def test_reclaim_space_that_cannot_start_says_why(self):
|
|
self.old_library()
|
|
release = asyncio.Event()
|
|
|
|
async def held(job):
|
|
await release.wait()
|
|
|
|
reader = self.api._jobs.start(
|
|
"health", "Health check", admission="read", runner=held
|
|
)
|
|
try:
|
|
_plan, _summary, job = await self.start()
|
|
await self.finish(job)
|
|
finally:
|
|
release.set()
|
|
await asyncio.wait_for(reader.task, WAIT)
|
|
self.assertEqual(job.state, "finished", job.log)
|
|
self.assertIsNone(self.reclaim_job())
|
|
self.assertIn(
|
|
"Reclaim space did not start: Health check is running. "
|
|
"Run Optimize database to give the space back.",
|
|
"\n".join(job.log),
|
|
)
|
|
self.assertIn("Reclaim space did not start", job.status_line)
|
|
|
|
async def test_reclaim_space_checks_the_disk_first(self):
|
|
self.old_library()
|
|
before = self.texts()
|
|
_plan, _summary, job = await self.start()
|
|
free = {"bytes": 10**12}
|
|
|
|
def free_bytes(_path):
|
|
return free["bytes"]
|
|
|
|
with patch.object(maintenance, "_free_bytes", side_effect=free_bytes):
|
|
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
|
reclaim = self.reclaim_job()
|
|
free["bytes"] = 1024
|
|
# The reclaim job checks when it runs, after the rewrite.
|
|
await asyncio.wait_for(asyncio.shield(reclaim.task), WAIT)
|
|
self.assertEqual(reclaim.state, "failed")
|
|
self.assertEqual(reclaim.last_event["code"], "insufficient-space")
|
|
self.assertIn("Not enough free disk space", reclaim.status_line)
|
|
self.assertEqual(self.texts(), before)
|
|
|
|
async def test_apply_refuses_up_front_when_the_backup_does_not_fit(self):
|
|
self.old_library()
|
|
plan = await self.preview(OPERATION)
|
|
with patch.object(maintenance, "_free_bytes", return_value=1024):
|
|
body = await self.apply(plan["token"], status=507)
|
|
self.assertEqual(body["code"], "insufficient-space")
|
|
self.assertEqual(self.inline_count(), 12)
|
|
|
|
async def test_the_jobs_never_open_a_media_file(self):
|
|
self.old_library(media=True)
|
|
media = str(self.output_dir)
|
|
before = {
|
|
name: (
|
|
_digest(os.path.join(media, name)),
|
|
os.stat(os.path.join(media, name)),
|
|
)
|
|
for name in os.listdir(media)
|
|
}
|
|
opened = []
|
|
real_open, real_os_open = builtins.open, os.open
|
|
|
|
def tracking_open(file, *args, **kwargs):
|
|
opened.append(str(file))
|
|
return real_open(file, *args, **kwargs)
|
|
|
|
def tracking_os_open(path, *args, **kwargs):
|
|
opened.append(str(path))
|
|
return real_os_open(path, *args, **kwargs)
|
|
|
|
with patch("builtins.open", tracking_open), patch("os.open", tracking_os_open):
|
|
with patch.object(Image, "open", side_effect=AssertionError("opened")):
|
|
_plan, _summary, job = await self.start()
|
|
reclaim = await self.finish(job)
|
|
self.assertEqual(job.state, "finished", job.log)
|
|
self.assertEqual(reclaim.state, "finished", reclaim.log)
|
|
self.assertEqual([path for path in opened if path.startswith(media)], [])
|
|
for name, (digest, stat) in before.items():
|
|
path = os.path.join(media, name)
|
|
self.assertEqual(_digest(path), digest)
|
|
self.assertEqual(os.stat(path).st_mtime_ns, stat.st_mtime_ns)
|
|
|
|
async def test_a_second_apply_of_the_token_is_plan_used(self):
|
|
self.old_library(count=4)
|
|
plan = await self.preview(OPERATION)
|
|
body = await self.apply(plan["token"])
|
|
await self.finish(self.api._jobs.get(body["job"]["id"]))
|
|
again = await self.apply(plan["token"], status=409)
|
|
self.assertEqual(again["code"], "plan-used")
|
|
|
|
|
|
class TestWorkflowStorageOutcomes(WorkflowStorageTestCase):
|
|
async def test_kept_values_and_swept_documents_are_logged(self):
|
|
prompt_id = self.old_library(count=4)
|
|
conn = self.api.db.model.get_connection()
|
|
with conn:
|
|
conn.execute(
|
|
"INSERT INTO generated_images (prompt_id, image_path, filename, "
|
|
"file_path, workflow_data) VALUES (?, '/x/b', 'b', '/x/b', x'00')",
|
|
(prompt_id,),
|
|
)
|
|
store_text(conn, "a document no record references")
|
|
_plan, _summary, job = await self.start()
|
|
await self.finish(job)
|
|
log = "\n".join(job.log)
|
|
self.assertEqual(job.counters["protected"], 1)
|
|
self.assertIn(
|
|
"Rewrote 4 image records; 1 stored value not text, kept as is", log
|
|
)
|
|
self.assertIn("Removed 1 unused workflow document", log)
|
|
|
|
async def reclaim_with(self, **patches):
|
|
self.old_library(count=4)
|
|
before = self.texts()
|
|
with contextlib.ExitStack() as stack:
|
|
for name, value in patches.items():
|
|
stack.enter_context(patch.object(self.api.db, name, value))
|
|
_plan, _summary, job = await self.start()
|
|
reclaim = await self.finish(job)
|
|
self.assertEqual(self.texts(), before)
|
|
return reclaim
|
|
|
|
async def test_a_failed_reclaim_says_the_database_is_unchanged(self):
|
|
reclaim = await self.reclaim_with(
|
|
reclaim_space=lambda: (_ for _ in ()).throw(
|
|
sqlite3.OperationalError("database is locked")
|
|
)
|
|
)
|
|
self.assertEqual(reclaim.state, "failed")
|
|
self.assertEqual(
|
|
reclaim.status_line, "Reclaim space failed; the database is unchanged."
|
|
)
|
|
|
|
async def test_a_reclaim_whose_disk_cannot_be_measured_fails_cleanly(self):
|
|
from py.api import workflow_storage as api_storage
|
|
|
|
with patch.object(api_storage, "live_bytes", side_effect=OSError("gone")):
|
|
reclaim = await self.reclaim_with()
|
|
self.assertEqual(reclaim.state, "failed")
|
|
self.assertNotIn("gone", reclaim.status_line)
|
|
|
|
async def test_a_busy_checkpoint_says_the_rest_follows_a_restart(self):
|
|
reclaim = await self.reclaim_with(
|
|
reclaim_space=lambda: {
|
|
"before_bytes": 3 * 2**20,
|
|
"after_bytes": 2**20,
|
|
"documents_removed": 0,
|
|
"checkpoint_busy": True,
|
|
}
|
|
)
|
|
self.assertEqual(
|
|
reclaim.status_line,
|
|
"Reclaimed 2.0 MB · the database is now 1.0 MB "
|
|
"(the rest after the next restart)",
|
|
)
|