* refactor(database): stage restores before publishing restore_from_file is now stage_restore + publish_staged (database/ restore_stage.py). The stage lives in a .pm-restore-* folder in the library's own folder, is migrated, has damaged workflow documents repaired from an inline copy or cleared, is verified with every document read and is bound by its SHA-256. Publishing keeps a unique, standalone, 0600 safety backup (microsecond names), copies the pages through the backup API as before and then truncates the WAL, which stayed database-sized after a restore. The safety backup writer moves to database/safety_backup.py so the database layer and maintenance share it. * fix(database): read the verify streams one row at a time The provenance and review-evidence checks iterated their cursors; on a managed connection iteration prefetches 128 rows, so up to 128 large values were held at once while the docstrings promised one. Both now use fetchone. * feat(backup): review a restore, run it as an Activity job, verify backups - POST /restore/preview stages an upload in the library's folder and answers what it holds beside the live library, writing nothing live; DELETE discards it; at most two stages are held (429 too-large). - POST /restore/apply rechecks the live fingerprint (409 plan-changed) and starts "Database restore": exclusive, not cancellable, indeterminate. It waits for request leases, publishes the exact staged bytes, swaps the database, prunes safety backups and logs counts, repairs and the backup. - The legacy /restore runs the same stage and publish as an exclusive hold with its old response, so a restore and the health check, Move library and every other job refuse each other. - JobRegistry.lease: Needs review decisions and maintenance and restore previews are refused while an exclusive job runs, and a restore waits for them before it switches the file. - GET /backup holds a guarded slot, verifies a standalone snapshot before streaming it and removes the closed temp file; GET /backup/status lists the safety backups on disk and this session's verified download. * docs(testing): backup and restore contract * test(system): a restore refuses a running health check The health route test relied on a legacy restore interleaving with a running check; the restore is now exclusive and refuses it, naming the check. The diagnostics backup tests follow the backup handler into py/api/backup_restore.py. * fix(backup): drop stale restore previews off the event loop A restore drops the previews reviewed against the old library; their stage files are now removed in the executor, with the old database's connections. * test(backup): wait for the backup hold to end before reading its state CI (py3.11/3.12 on Linux, every macOS) read the registry right after the client had the last byte, before the handler left its hold and removed its temp file: the job was still running. The tests now wait on that state. * test(images): the scan dialog's Back up now leaves a finished backup hold A backup download now holds a guarded registry slot while it streams (#249 S8), so the jobs list holds that finished hold after the dialog's Back up now; the spec still proves Cancel starts no scan. * fix(backup): final review round for the restore and backup routes - I1: once the stage's copy replaced the library the restore succeeded: pruning, reopening self.db, dropping old previews and closing the old object only log, so the job finishes and the legacy /restore answers 200 instead of "the library was not changed". The generation is always bumped. - M1: a backup download holds its guarded slot only while the snapshot is written; the stream runs outside it, so a stalled download blocks nothing. - M2: a restore preview leases only its read of the live library, not the upload or the staging; a restore whose drain still times out keeps the reviewed stage under its token. - M3: the reviewed-preview store changes only on the event loop; stage folders are removed in the executor. - M4: a document whose body inflates completely while only its recorded hash or only its length disagrees is re-keyed (or merged into the document holding that text) instead of dropped. - M5: a backup download is refused only when the snapshot is not SQLite or fails integrity_check; a semantic check failure still downloads, logged, sent as X-PromptManager-Verify and recorded as not verified. - M6: a refused, failed or cancelled preview removes its stage, also when the cancel lands while a worker still stages; a timer removes expired stages without another request. - M7, N1: repair reads rows one at a time and counts distinct images. - N3: the live fingerprint never commits a transaction it did not open. * test(backup): close the old library the close-failure test leaves open CI (py3.12 Linux and the coverage gate) found the old object's executor-thread connections still open at teardown: the test patches its close_all to fail, and once the object is collected close_all_instances no longer reaches it. The test now closes it itself. * fix(backup): re-review round for restore repair, apply and backup cleanup - R1: when re-keying merges an intact body into a holder whose own body was damaged, the rewritten holder leaves the damaged list, so the clear pass no longer deletes it and unlinks the images just moved to it. - R2: a losing duplicate apply removes a stage only when the store still held it, never one another apply's job has taken. - R3: a restore that times out waiting for requests keeps its review only while it is unexpired and its slot is free; otherwise the stage goes and the job says to try again. - R4: a backup download cancelled while its snapshot is written removes the snapshot once the worker finishes.
653 lines
27 KiB
Python
653 lines
27 KiB
Python
"""Each workflow and prompt graph stored once, compressed (#249 S6 carry-over).
|
|
|
|
``generated_images.workflow_data`` (the UI workflow) and ``prompt_metadata``
|
|
(the API prompt graph) used to hold the same large JSON text again for every
|
|
image of a run. Now each distinct text is one ``workflow_documents`` row:
|
|
|
|
- ``sha256``: the SHA-256 of the text's exact UTF-8 bytes (unique key);
|
|
- ``byte_length``: the length of those bytes;
|
|
- ``data``: ``zlib.compress`` of those bytes.
|
|
|
|
An image row references its texts through ``workflow_document_id`` and
|
|
``prompt_document_id``. Two forms coexist until the migration has rewritten
|
|
every row, and both stay readable forever:
|
|
|
|
- old form: the text inline in ``workflow_data`` / ``prompt_metadata``, no id;
|
|
- new form: the inline column NULL and the document id set.
|
|
|
|
The text is stored and hashed exactly as written, never parsed and
|
|
re-serialized, because a JSON round trip can change bytes (key order,
|
|
spacing, number spelling, duplicate keys, ComfyUI's ``NaN`` tokens).
|
|
|
|
``read_stored_text`` is the one decoder. Inline text, when present, wins (a
|
|
row an older app version rewrote inline stays right); otherwise the document
|
|
is read by its rowid, decompressed within its recorded length and checked
|
|
against its length and hash. A document that fails a check reads as
|
|
unreadable (None, logged), never as other text. ``resolve_image_row`` puts
|
|
the texts back into a ``generated_images`` row and drops the id keys, so
|
|
every payload keeps its shape.
|
|
|
|
No trigger: every restore validator refuses triggers in a restored file. A
|
|
document is removed in code: ``release_documents`` in the same transaction
|
|
that drops a row's last reference, and ``sweep_documents`` (at startup, after
|
|
the migration and before Reclaim space) for anything a path missed.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
import zlib
|
|
from collections import OrderedDict
|
|
from typing import Any, Dict, Iterable, Optional, Set
|
|
|
|
logger = logging.getLogger("prompt_manager.database.workflow_documents")
|
|
|
|
TABLE = "workflow_documents"
|
|
# (inline text column, document id column) on generated_images.
|
|
TEXT_COLUMNS = (
|
|
("workflow_data", "workflow_document_id"),
|
|
("prompt_metadata", "prompt_document_id"),
|
|
)
|
|
DOCUMENT_ID_COLUMNS = tuple(reference for _column, reference in TEXT_COLUMNS)
|
|
INDEXES = {
|
|
"workflow_document_id": "idx_image_workflow_document",
|
|
"prompt_document_id": "idx_image_prompt_document",
|
|
}
|
|
COMPRESSION_LEVEL = 6
|
|
# A text larger than this stays inline: a document is decompressed in one
|
|
# piece, and its recorded length bounds that (a restored file cannot make a
|
|
# small row expand without limit).
|
|
MAX_DOCUMENT_BYTES = 256 * 2**20
|
|
ID_BATCH = 400 # ids bound per statement (under SQLite's old 999 limit)
|
|
|
|
_DDL = (
|
|
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY, sha256 BLOB NOT NULL UNIQUE, "
|
|
"byte_length INTEGER NOT NULL, data BLOB NOT NULL)"
|
|
)
|
|
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
|
|
_REFERENCE_TYPE = f"INTEGER REFERENCES {TABLE}(id)"
|
|
|
|
_UNREFERENCED = (
|
|
"NOT EXISTS (SELECT 1 FROM generated_images g "
|
|
"WHERE g.workflow_document_id=workflow_documents.id) "
|
|
"AND NOT EXISTS (SELECT 1 FROM generated_images g "
|
|
"WHERE g.prompt_document_id=workflow_documents.id)"
|
|
)
|
|
LOOKUP_SQL = (
|
|
"SELECT id, sha256, byte_length, data FROM workflow_documents WHERE sha256=?"
|
|
)
|
|
REPAIR_SQL = "UPDATE workflow_documents SET byte_length=?, data=? WHERE id=?"
|
|
ALL_DOCUMENTS_SQL = "SELECT sha256, byte_length, data, id FROM workflow_documents"
|
|
READ_SQL = "SELECT sha256, byte_length, data FROM workflow_documents WHERE id=?"
|
|
INSERT_SQL = (
|
|
"INSERT INTO workflow_documents (sha256, byte_length, data) VALUES (?, ?, ?)"
|
|
)
|
|
# Built from constants only; the ids are bound as "?" marks.
|
|
RELEASE_SQL = "DELETE FROM workflow_documents WHERE id IN ({marks}) AND "
|
|
RELEASE_SQL += _UNREFERENCED
|
|
# The sweep walks the ids through the small unique index, not the blobs.
|
|
SWEEP_SQL = (
|
|
"DELETE FROM workflow_documents WHERE id IN (SELECT id FROM workflow_documents "
|
|
"INDEXED BY sqlite_autoindex_workflow_documents_1 WHERE "
|
|
)
|
|
SWEEP_SQL += _UNREFERENCED + ")"
|
|
IMAGE_DOCUMENTS_SQL = (
|
|
"SELECT workflow_document_id, prompt_document_id FROM generated_images WHERE "
|
|
)
|
|
|
|
# Statements whose query plans tests/test_workflow_documents.py records.
|
|
QUERY_PLAN_STATEMENTS = {
|
|
"lookup by hash": (LOOKUP_SQL, (b"\0" * 32,)),
|
|
"read by id": (READ_SQL, (1,)),
|
|
"release": (RELEASE_SQL.format(marks="?,?"), (1, 2)),
|
|
"sweep": (SWEEP_SQL, ()),
|
|
"documents of images": (IMAGE_DOCUMENTS_SQL + "id IN (?,?)", (1, 2)),
|
|
"documents of prompts": (IMAGE_DOCUMENTS_SQL + "prompt_id IN (?,?)", (1, 2)),
|
|
}
|
|
|
|
|
|
class DocumentMismatch(Exception):
|
|
"""A document would not give back exactly the text it was made from."""
|
|
|
|
|
|
def _compact(sql: str) -> str:
|
|
return re.sub(r"\s+", "", sql or "").lower()
|
|
|
|
|
|
# ── schema ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _reference_columns(conn: sqlite3.Connection) -> Dict[str, tuple]:
|
|
return {
|
|
row[1]: tuple(row)
|
|
for row in conn.execute("PRAGMA table_info(generated_images)")
|
|
if row[1] in DOCUMENT_ID_COLUMNS
|
|
}
|
|
|
|
|
|
def _reference_error(conn: sqlite3.Connection, columns: Dict[str, tuple]):
|
|
for name, info in columns.items():
|
|
# (cid, name, type, notnull, default, pk)
|
|
if (str(info[2]).upper(), info[3], info[4], info[5]) != ("INTEGER", 0, None, 0):
|
|
return "Workflow document references have an invalid column"
|
|
keys = {
|
|
(row[3], row[2], row[4])
|
|
for row in conn.execute("PRAGMA foreign_key_list(generated_images)")
|
|
if row[3] in DOCUMENT_ID_COLUMNS
|
|
}
|
|
if keys != {(name, TABLE, "id") for name in DOCUMENT_ID_COLUMNS}:
|
|
return "Workflow document references have invalid foreign keys"
|
|
for column, index in INDEXES.items():
|
|
info = conn.execute(f'PRAGMA index_info("{index}")').fetchall()
|
|
owner = conn.execute(
|
|
"SELECT tbl_name FROM sqlite_master WHERE type='index' AND name=?",
|
|
(index,),
|
|
).fetchone()
|
|
if [row[2] for row in info] != [column] or not owner:
|
|
return "Workflow document references are missing their index"
|
|
if owner[0] != "generated_images":
|
|
return "Workflow document references are missing their index"
|
|
return None
|
|
|
|
|
|
def _value_error(conn: sqlite3.Connection):
|
|
if conn.execute(
|
|
"SELECT 1 FROM workflow_documents WHERE typeof(sha256)!='blob' "
|
|
"OR length(sha256)!=32 OR typeof(byte_length)!='integer' OR byte_length<0 "
|
|
"OR byte_length>? OR typeof(data)!='blob' LIMIT 1",
|
|
(MAX_DOCUMENT_BYTES,),
|
|
).fetchone():
|
|
return "Workflow documents contain invalid values"
|
|
for column in DOCUMENT_ID_COLUMNS:
|
|
# typeof() reads no overflow page; a dangling id is a rowid probe.
|
|
# ``column`` is one of DOCUMENT_ID_COLUMNS, never caller data.
|
|
sql = (
|
|
f"SELECT 1 FROM generated_images g WHERE typeof(g.{column}) " # nosec B608
|
|
f"NOT IN ('null','integer') OR (g.{column} IS NOT NULL AND NOT EXISTS "
|
|
f"(SELECT 1 FROM workflow_documents d WHERE d.id=g.{column})) LIMIT 1"
|
|
)
|
|
if conn.execute(sql).fetchone():
|
|
return "Workflow document references point at missing documents"
|
|
return None
|
|
|
|
|
|
def damaged_message(document_id: int) -> str:
|
|
"""The verify reason for a damaged document: which one, and what to do."""
|
|
return (
|
|
f"Workflow document {document_id} is damaged: its text no longer matches "
|
|
"its checksum, and the images that use it show no workflow. To fix it, "
|
|
"restore a backup from before the damage, or store a new image with the "
|
|
"same workflow (that repairs it)."
|
|
)
|
|
|
|
|
|
def _damaged_document(conn: sqlite3.Connection) -> Optional[int]:
|
|
"""The first document failing its length or hash check, or None."""
|
|
for row in conn.execute(ALL_DOCUMENTS_SQL):
|
|
if _decode(row[:3], row[3], quiet=True) is None:
|
|
return row[3]
|
|
return None
|
|
|
|
|
|
def workflow_documents_schema_error(
|
|
conn: sqlite3.Connection, *, allow_missing: bool = True, documents: bool = False
|
|
) -> Optional[str]:
|
|
"""Reject an unsupported document schema or invalid stored values (reads only).
|
|
|
|
A database from before the dedupe has neither the table nor the id
|
|
columns; that is acceptable unless ``allow_missing`` is false. Half of
|
|
the schema never is.
|
|
"""
|
|
objects = conn.execute(
|
|
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
|
|
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
|
(TABLE, TABLE),
|
|
).fetchall()
|
|
has_images = conn.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='generated_images'"
|
|
).fetchone()
|
|
columns = _reference_columns(conn) if has_images else {}
|
|
if not objects and not columns:
|
|
if allow_missing or not has_images:
|
|
return None
|
|
return "Missing workflow document schema"
|
|
if not objects or set(columns) != set(DOCUMENT_ID_COLUMNS):
|
|
return "Incomplete workflow document schema"
|
|
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
|
others = {(kind, name, sql) for kind, name, sql in objects if kind != "table"}
|
|
if len(tables) != 1 or others != {("index", _AUTOINDEX, None)}:
|
|
return "Workflow documents name is reserved by an unsupported schema object"
|
|
# The exact definition fixes the columns, their types and constraints.
|
|
if _compact(tables[0]) != _compact(_DDL):
|
|
return "Workflow documents have an invalid table definition"
|
|
error = _reference_error(conn, columns) or _value_error(conn)
|
|
if error is None and documents:
|
|
# Restore and backup verification only: every document is read.
|
|
damaged = _damaged_document(conn)
|
|
if damaged is not None:
|
|
return damaged_message(damaged)
|
|
return error
|
|
|
|
|
|
def migrate_workflow_documents(conn: sqlite3.Connection) -> None:
|
|
"""Install the table, the id columns and their indexes atomically.
|
|
|
|
Runs after every older migration (the table rebuilds only run on
|
|
databases without ``file_path``, before this). Validates before and
|
|
after, then sweeps documents no row references.
|
|
"""
|
|
conn.execute("SAVEPOINT workflow_documents_schema")
|
|
try:
|
|
error = workflow_documents_schema_error(conn)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
|
present = _reference_columns(conn)
|
|
for column in DOCUMENT_ID_COLUMNS:
|
|
if column not in present:
|
|
conn.execute(
|
|
f"ALTER TABLE generated_images ADD COLUMN {column} "
|
|
f"{_REFERENCE_TYPE}"
|
|
)
|
|
for column, index in INDEXES.items():
|
|
conn.execute(
|
|
f"CREATE INDEX IF NOT EXISTS {index} ON generated_images({column})"
|
|
)
|
|
error = workflow_documents_schema_error(conn, allow_missing=False)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
sweep_documents(conn)
|
|
conn.execute("RELEASE workflow_documents_schema")
|
|
except BaseException:
|
|
conn.execute("ROLLBACK TO workflow_documents_schema")
|
|
conn.execute("RELEASE workflow_documents_schema")
|
|
raise
|
|
|
|
|
|
# ── write ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def storable(text: Any) -> bool:
|
|
"""Whether ``text`` can be a document (text within MAX_DOCUMENT_BYTES)."""
|
|
if not isinstance(text, str):
|
|
return False
|
|
# UTF-8 takes at most 4 bytes a character: most texts need no encoding.
|
|
if len(text) * 4 <= MAX_DOCUMENT_BYTES:
|
|
return True
|
|
return len(text.encode("utf-8")) <= MAX_DOCUMENT_BYTES
|
|
|
|
|
|
def store_text(
|
|
conn: sqlite3.Connection, text: Optional[str], verified: Optional[set] = None
|
|
) -> Optional[int]:
|
|
"""The id of the document holding exactly ``text``; None for None.
|
|
|
|
Reuses the document with the same hash, or inserts a new one whose
|
|
compressed bytes were checked to decompress to ``text``'s bytes. A reused
|
|
document is decompressed and checked first; a damaged one (a restored
|
|
file's corruption under a valid hash) is repaired from ``text``, whose
|
|
hash is its key, so every row that references it reads right again.
|
|
``verified``, when given, holds the ids already checked (a migration
|
|
batch), which are then not read again. Opens a write transaction first
|
|
when none is open, so a concurrent ``release_documents`` cannot remove a
|
|
reused document before the caller references it. Raises TypeError for a
|
|
non-text value and ValueError for a text over MAX_DOCUMENT_BYTES.
|
|
"""
|
|
if text is None:
|
|
return None
|
|
if not isinstance(text, str):
|
|
raise TypeError("Only text is stored as a workflow document")
|
|
raw = text.encode("utf-8")
|
|
if len(raw) > MAX_DOCUMENT_BYTES:
|
|
raise ValueError("Text is too large for a workflow document")
|
|
digest = hashlib.sha256(raw).digest()
|
|
if not conn.in_transaction:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
row = conn.execute(LOOKUP_SQL, (digest,)).fetchone()
|
|
if row is not None and verified is not None and row[0] in verified:
|
|
return row[0]
|
|
data = zlib.compress(raw, COMPRESSION_LEVEL)
|
|
if zlib.decompress(data) != raw: # pragma: no cover - zlib is exact
|
|
raise DocumentMismatch("Compression did not round-trip")
|
|
if row is None:
|
|
document_id = conn.execute(INSERT_SQL, (digest, len(raw), data)).lastrowid
|
|
else:
|
|
document_id = row[0]
|
|
if _decode(row[1:], document_id, quiet=True) != text:
|
|
logger.warning(f"Workflow document {document_id} was damaged; repaired")
|
|
conn.execute(REPAIR_SQL, (len(raw), data, document_id))
|
|
if verified is not None:
|
|
verified.add(document_id)
|
|
return document_id
|
|
|
|
|
|
def stored_values(
|
|
conn: sqlite3.Connection, texts: Dict[str, Optional[str]]
|
|
) -> Dict[str, Any]:
|
|
"""New-form column values for ``{text column: text}`` (writers).
|
|
|
|
Each text becomes ``{column: None, id column: document id}``; None
|
|
stays ``{column: None, id column: None}``; a value that cannot be a
|
|
document (not text, or too large) stays inline with no id.
|
|
"""
|
|
references = dict(TEXT_COLUMNS)
|
|
values: Dict[str, Any] = {}
|
|
for column, text in texts.items():
|
|
reference = references[column]
|
|
if text is None or not storable(text):
|
|
values[column], values[reference] = text, None
|
|
else:
|
|
values[column], values[reference] = None, store_text(conn, text)
|
|
return values
|
|
|
|
|
|
# ── read ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _decode(row, document_id, quiet=False) -> Optional[str]:
|
|
"""``(sha256, byte_length, data)`` as checked text, or None (logged)."""
|
|
if row is None:
|
|
logger.warning(f"Workflow document {document_id} is missing")
|
|
return None
|
|
digest, length, data = bytes(row[0]), row[1], row[2]
|
|
try:
|
|
if not isinstance(length, int) or not 0 <= length <= MAX_DOCUMENT_BYTES:
|
|
raise ValueError("invalid length")
|
|
inflater = zlib.decompressobj()
|
|
raw = inflater.decompress(bytes(data), length + 1)
|
|
if len(raw) != length or inflater.unconsumed_tail or not inflater.eof:
|
|
raise ValueError("length differs")
|
|
if hashlib.sha256(raw).digest() != digest:
|
|
raise ValueError("hash differs")
|
|
return raw.decode("utf-8")
|
|
except (zlib.error, ValueError, TypeError) as error:
|
|
if not quiet:
|
|
logger.warning(f"Workflow document {document_id} is unreadable: {error}")
|
|
return None
|
|
|
|
|
|
def read_stored_text(
|
|
conn: sqlite3.Connection, inline: Any, document_id: Optional[int]
|
|
) -> Any:
|
|
"""The stored text of one column: inline when present, else the document.
|
|
|
|
Returns None when there is neither, or when the document is missing or
|
|
fails its checks (logged). An inline value is returned unchanged.
|
|
"""
|
|
if inline is not None:
|
|
return inline
|
|
if document_id is None:
|
|
return None
|
|
return _decode(conn.execute(READ_SQL, (document_id,)).fetchone(), document_id)
|
|
|
|
|
|
class DocumentReader:
|
|
"""``read_stored_text`` with a small memo for list reads on one connection."""
|
|
|
|
MEMO = 32
|
|
|
|
def __init__(self, conn: sqlite3.Connection):
|
|
self.conn = conn
|
|
self._memo: "OrderedDict[int, Optional[str]]" = OrderedDict()
|
|
|
|
def text(self, inline: Any, document_id: Optional[int]) -> Any:
|
|
if inline is not None or document_id is None:
|
|
return read_stored_text(self.conn, inline, document_id)
|
|
if document_id in self._memo:
|
|
self._memo.move_to_end(document_id)
|
|
return self._memo[document_id]
|
|
text = read_stored_text(self.conn, None, document_id)
|
|
self._memo[document_id] = text
|
|
if len(self._memo) > self.MEMO:
|
|
self._memo.popitem(last=False)
|
|
return text
|
|
|
|
|
|
def resolve_image_row(reader: DocumentReader, row) -> Dict[str, Any]:
|
|
"""A ``generated_images`` row as a dict with its texts read back.
|
|
|
|
The id columns are dropped, so a payload built from it has the shape it
|
|
had before the dedupe. Columns the row did not select stay absent.
|
|
"""
|
|
data = dict(row)
|
|
for column, reference in TEXT_COLUMNS:
|
|
has_reference = reference in data
|
|
document_id = data.pop(reference, None)
|
|
if column in data or has_reference:
|
|
data[column] = reader.text(data.get(column), document_id)
|
|
return data
|
|
|
|
|
|
# ── garbage ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def _chunks(values):
|
|
values = list(values)
|
|
for start in range(0, len(values), ID_BATCH):
|
|
yield values[start : start + ID_BATCH]
|
|
|
|
|
|
def image_documents(conn: sqlite3.Connection, where: str, params) -> Set[int]:
|
|
"""The document ids the image rows matching ``where`` reference.
|
|
|
|
``where`` is a fixed condition written in this package (``id IN (...)``,
|
|
``prompt_id IN (...)``), never caller data; values go in ``params``.
|
|
"""
|
|
ids: Set[int] = set()
|
|
sql = IMAGE_DOCUMENTS_SQL + where
|
|
for row in conn.execute(sql, list(params)): # nosec B608
|
|
ids.update(value for value in row if value is not None)
|
|
return ids
|
|
|
|
|
|
def documents_of_rows(
|
|
conn: sqlite3.Connection, column: str, values: Iterable[Any]
|
|
) -> Set[int]:
|
|
"""Document ids of the image rows whose ``column`` is in ``values``.
|
|
|
|
``column`` is ``id`` or ``prompt_id`` (the rows a prompt delete cascades to).
|
|
"""
|
|
if column not in ("id", "prompt_id"):
|
|
raise ValueError(f"Unsupported column {column!r}")
|
|
ids: Set[int] = set()
|
|
for chunk in _chunks(values):
|
|
marks = ",".join("?" for _ in chunk)
|
|
ids |= image_documents(conn, f"{column} IN ({marks})", chunk)
|
|
return ids
|
|
|
|
|
|
def release_documents(conn: sqlite3.Connection, ids: Iterable[Optional[int]]) -> int:
|
|
"""Delete the documents in ``ids`` that no image row references now.
|
|
|
|
Runs in the caller's transaction, right after the delete or update that
|
|
dropped the references. Returns how many documents were removed.
|
|
"""
|
|
removed = 0
|
|
for chunk in _chunks(sorted({i for i in ids if i is not None})):
|
|
marks = ",".join("?" for _ in chunk)
|
|
# Only "?" marks are interpolated.
|
|
sql = RELEASE_SQL.format(marks=marks)
|
|
removed += conn.execute(sql, chunk).rowcount # nosec B608
|
|
return removed
|
|
|
|
|
|
def sweep_documents(conn: sqlite3.Connection) -> int:
|
|
"""Delete every document no image row references; returns the count."""
|
|
return conn.execute(SWEEP_SQL).rowcount
|
|
|
|
|
|
# ── repair (a restore's stage) ────────────────────────────────────────
|
|
|
|
INLINE_TEXTS_SQL = (
|
|
"SELECT workflow_data, prompt_metadata FROM generated_images "
|
|
"WHERE workflow_data IS NOT NULL OR prompt_metadata IS NOT NULL"
|
|
)
|
|
REKEY_SQL = "UPDATE workflow_documents SET sha256=?, byte_length=? WHERE id=?"
|
|
|
|
|
|
def _rows(conn: sqlite3.Connection, sql: str, params=()):
|
|
"""Each row of ``sql``, read one at a time.
|
|
|
|
Iterating a managed connection's cursor prefetches ``ITER_CHUNK`` (128)
|
|
rows, and a document or inline text can be megabytes.
|
|
"""
|
|
cursor = conn.execute(sql, params)
|
|
while (row := cursor.fetchone()) is not None:
|
|
yield row
|
|
|
|
|
|
def _inflated(data) -> Optional[bytes]:
|
|
"""The complete text bytes ``data`` inflates to, or None.
|
|
|
|
Complete means the zlib stream ends (its Adler-32 checked) within
|
|
MAX_DOCUMENT_BYTES with nothing after it: the body is intact even when
|
|
the row's recorded hash or length is not.
|
|
"""
|
|
try:
|
|
inflater = zlib.decompressobj()
|
|
raw = inflater.decompress(bytes(data), MAX_DOCUMENT_BYTES + 1)
|
|
if not inflater.eof or inflater.unconsumed_tail or inflater.unused_data:
|
|
return None
|
|
if len(raw) > MAX_DOCUMENT_BYTES:
|
|
return None
|
|
raw.decode("utf-8")
|
|
return raw
|
|
except (zlib.error, TypeError, UnicodeDecodeError):
|
|
return None
|
|
|
|
|
|
def _one_column_off(row, raw: bytes) -> bool:
|
|
"""Whether only the recorded hash or only the recorded length is wrong.
|
|
|
|
The two columns check the body independently. When the body inflates
|
|
completely and one of them still agrees with it, that column vouches for
|
|
the body and the other one is the damaged value. When both disagree, the
|
|
body itself is the suspect (another valid stream), so it is not trusted.
|
|
"""
|
|
hash_ok = hashlib.sha256(raw).digest() == bytes(row[0])
|
|
length_ok = row[1] == len(raw)
|
|
return hash_ok != length_ok
|
|
|
|
|
|
def _inline_copies(conn: sqlite3.Connection, wanted: Dict[bytes, int]):
|
|
"""``{document id: text}`` for each wanted hash an inline row still holds."""
|
|
found: Dict[int, str] = {}
|
|
for row in _rows(conn, INLINE_TEXTS_SQL):
|
|
for text in row:
|
|
if not isinstance(text, str) or not storable(text):
|
|
continue
|
|
document_id = wanted.get(hashlib.sha256(text.encode("utf-8")).digest())
|
|
if document_id is not None and document_id not in found:
|
|
found[document_id] = text
|
|
if len(found) == len(wanted):
|
|
break
|
|
return found
|
|
|
|
|
|
def _rekey(conn: sqlite3.Connection, document_id: int, raw: bytes) -> Optional[int]:
|
|
"""Give an intact body its true key, or merge it into the document that has it.
|
|
|
|
Returns the holder's id when its own body was damaged and has just been
|
|
rewritten from ``raw`` (it is repaired, never to be cleared), else None.
|
|
"""
|
|
digest = hashlib.sha256(raw).digest()
|
|
holder = conn.execute(LOOKUP_SQL, (digest,)).fetchone()
|
|
if holder is None or holder[0] == document_id:
|
|
conn.execute(REKEY_SQL, (digest, len(raw), document_id))
|
|
return None
|
|
for column in DOCUMENT_ID_COLUMNS:
|
|
# ``column`` is one of DOCUMENT_ID_COLUMNS, never caller data.
|
|
conn.execute(
|
|
f"UPDATE generated_images SET {column}=? WHERE {column}=?", # nosec B608
|
|
(holder[0], document_id),
|
|
)
|
|
conn.execute("DELETE FROM workflow_documents WHERE id=?", (document_id,))
|
|
if _decode(holder[1:], holder[0], quiet=True) is None:
|
|
conn.execute(
|
|
REPAIR_SQL, (len(raw), zlib.compress(raw, COMPRESSION_LEVEL), holder[0])
|
|
)
|
|
return holder[0]
|
|
return None
|
|
|
|
|
|
def repair_damaged_documents(conn: sqlite3.Connection) -> Dict[str, int]:
|
|
"""Repair or clear every document failing its checks; commit (writes).
|
|
|
|
For a restore's stage only, never the live library. In order:
|
|
|
|
1. A document whose body inflates completely and whose recorded hash or
|
|
length (not both) disagrees is re-keyed from its text, or merged
|
|
into the document that already holds that text (``_one_column_off``).
|
|
2. A damaged body whose exact text an inline row still holds (the same
|
|
SHA-256, its key) is stored again from that text.
|
|
3. Any other damaged document is unreadable for good: its images'
|
|
references are cleared (they already showed no workflow) and the
|
|
document is removed, so the stage verifies.
|
|
|
|
Returns ``{repaired, cleared, images}``: documents repaired (1 and 2),
|
|
documents cleared and the distinct image rows whose reference was
|
|
cleared. Rows are read one at a time.
|
|
"""
|
|
damaged: Dict[bytes, int] = {}
|
|
intact: Dict[int, bytes] = {}
|
|
for row in _rows(conn, ALL_DOCUMENTS_SQL):
|
|
if _decode(row[:3], row[3], quiet=True) is None:
|
|
raw = _inflated(row[2])
|
|
if raw is not None and _one_column_off(row, raw):
|
|
intact[row[3]] = raw
|
|
else:
|
|
damaged[bytes(row[0])] = row[3]
|
|
result = {"repaired": 0, "cleared": 0, "images": 0}
|
|
if not damaged and not intact:
|
|
return result
|
|
with conn:
|
|
healed = []
|
|
for document_id, raw in intact.items():
|
|
holder = _rekey(conn, document_id, raw)
|
|
if holder is not None and holder in damaged.values():
|
|
# Rewritten from the merged body: repaired, not to be cleared
|
|
# (it now also holds the merged images' references).
|
|
damaged = {k: v for k, v in damaged.items() if v != holder}
|
|
healed.append(holder)
|
|
copies = _inline_copies(conn, damaged) if damaged else {}
|
|
for document_id, text in copies.items():
|
|
raw = text.encode("utf-8")
|
|
data = zlib.compress(raw, COMPRESSION_LEVEL)
|
|
conn.execute(REPAIR_SQL, (len(raw), data, document_id))
|
|
lost = sorted(set(damaged.values()) - set(copies))
|
|
images: Set[int] = set()
|
|
for chunk in _chunks(lost):
|
|
marks = ",".join("?" * len(chunk))
|
|
# ``marks`` is only "?" placeholders; the ids are bound.
|
|
for (image_id,) in _rows(
|
|
conn,
|
|
"SELECT id FROM generated_images WHERE " # nosec B608
|
|
f"workflow_document_id IN ({marks}) OR prompt_document_id IN ({marks})",
|
|
chunk + chunk,
|
|
):
|
|
images.add(image_id)
|
|
for column in DOCUMENT_ID_COLUMNS:
|
|
conn.execute(
|
|
f"UPDATE generated_images SET {column}=NULL " # nosec B608
|
|
f"WHERE {column} IN ({marks})",
|
|
chunk,
|
|
)
|
|
conn.execute(
|
|
f"DELETE FROM workflow_documents WHERE id IN ({marks})", # nosec B608
|
|
chunk,
|
|
)
|
|
for document_id in list(intact) + healed + list(copies):
|
|
logger.warning(f"Workflow document {document_id} was damaged; repaired")
|
|
if lost:
|
|
logger.warning(
|
|
f"{len(lost)} damaged workflow document(s) could not be repaired; "
|
|
f"{len(images)} image(s) show no workflow"
|
|
)
|
|
result.update(
|
|
repaired=len(intact) + len(healed) + len(copies),
|
|
cleared=len(lost),
|
|
images=len(images),
|
|
)
|
|
return result
|