Files
ComfyAssets-ComfyUI_PromptM…/tests/test_execution_history_restore.py
Vito e5da4e1d9d feat(library): backend for native recipe editing, pair archive browsing and portable export (#233)
* feat(library): persist recoverable parent state

* feat(library): expose versioned parent metadata reads

* feat(library): protect parent metadata and archive writes

* feat(library): save native recipes with atomic outcomes

* feat(library): download complete portable library json

* fix(library): name export owners list positives per spec

* fix(library): normalise native category and harden restore and id errors

* fix(library): measure stored titles past embedded NULs
2026-10-18 20:03:15 -07:00

677 lines
30 KiB
Python

"""Restore publishes a fresh epoch and preserves historical attempt records."""
import sqlite3
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from database.models import PromptModel
try:
from tests import test_execution_history_database as history_tests
except ImportError:
import test_execution_history_database as history_tests
class HistoryRestoreTests(unittest.TestCase):
setUp = history_tests.HistoryDatabaseTests.setUp
close_databases = history_tests.HistoryDatabaseTests.close_databases
database = history_tests.HistoryDatabaseTests.database
start = history_tests.HistoryDatabaseTests.start
finish = history_tests.HistoryDatabaseTests.finish
count = history_tests.HistoryDatabaseTests.count
def test_restore_installs_optional_history_read_indexes_without_mutating_source(
self,
):
from database.execution_history_schema import history_schema_error
db = self.database()
start = self.start(db)
db.write_execution_start(start)
conn = db.model.get_connection()
tables = (
"execution_prompt_snapshots",
"execution_contexts",
"execution_run_contexts",
"execution_runs",
)
with conn:
for table in tables:
conn.execute(f"DROP INDEX idx_history_read_{table}")
self.assertIsNone(history_schema_error(conn, allow_missing=False))
source = str(Path(self.temp.name) / "without-read-indexes.db")
self.assertTrue(db.model.backup_database(source))
original = Path(source).read_bytes()
db.model.restore_from_file(source)
conn = db.model.get_connection()
for table in tables:
indexes = {r[1]: r for r in conn.execute(f"PRAGMA index_list({table})")}
self.assertIn(f"idx_history_read_{table}", indexes)
self.assertFalse(indexes[f"idx_history_read_{table}"][2])
self.assertIsNone(history_schema_error(conn, allow_missing=False))
self.assertEqual(Path(source).read_bytes(), original)
with db.recorded_history_snapshot() as epoch:
run = db.get_history_run(start.run_id)
self.assertNotEqual(epoch, run["run"]["storage_epoch"])
self.assertEqual(run["run"]["storage_epoch"], start.storage_epoch)
def test_output_operations_restore_preserves_evidence_and_rejects_stale_finish(
self,
):
from database.execution_history import HistoryEpochChanged
from tests.test_execution_output_database import bundle
db = self.database()
start = self.start(db)
db.write_execution_start(start)
finish = self.finish(start)
db.write_execution_outputs_for_finish(finish, bundle())
evidence = db.get_execution_outputs(start.run_id)
backup = str(Path(self.temp.name) / "output-operations.db")
self.assertTrue(db.model.backup_database(backup))
source = Path(backup).read_bytes()
db.model.restore_from_file(backup)
self.assertEqual(db.get_execution_outputs(start.run_id), evidence)
with self.assertRaises(HistoryEpochChanged):
db.write_execution_outputs_for_finish(finish, bundle("late.png"))
self.assertEqual(db.get_execution_outputs(start.run_id), evidence)
self.assertEqual(Path(backup).read_bytes(), source)
def test_output_wal_backup_restore_preserves_rows_and_historical_epoch(self):
try:
from tests.test_execution_output_schema import capture
except ImportError:
from test_execution_output_schema import capture
db = self.database()
start = self.start(db)
db.write_execution_start(start)
conn = db.model.get_connection()
conn.execute("PRAGMA wal_autocheckpoint=0")
with conn:
capture(conn, run=start.run_id)
expected = [
tuple(row) for row in conn.execute("SELECT * FROM execution_run_outputs")
]
source = str(Path(self.temp.name) / "outputs-wal.db")
self.assertGreater(Path(self.path + "-wal").stat().st_size, 0)
self.assertTrue(db.model.backup_database(source))
original = Path(source).read_bytes()
for _ in range(2):
previous = db.get_execution_epoch()
db.model.restore_from_file(source)
self.assertNotEqual(db.get_execution_epoch(), previous)
self.assertNotEqual(db.get_execution_epoch(), start.storage_epoch)
conn = db.model.get_connection()
self.assertEqual(
[
tuple(row)
for row in conn.execute("SELECT * FROM execution_run_outputs")
],
expected,
)
self.assertEqual(
db.get_execution_run(start.run_id)["storage_epoch"], start.storage_epoch
)
self.assertEqual(
tuple(
conn.execute(
"SELECT state,reason,format_version,source FROM "
"execution_output_capture"
).fetchone()
),
("complete", "reported", 1, "host_final_history"),
)
self.assertEqual(Path(source).read_bytes(), original)
def test_malformed_outputs_preserve_source_live_and_existing_safety_backup(self):
try:
from tests.test_execution_output_schema import capture
except ImportError:
from test_execution_output_schema import capture
live = self.database()
start = self.start(live)
live.write_execution_start(start)
with live.model.get_connection() as conn:
capture(conn, run=start.run_id)
before = list(live.model.get_connection().iterdump())
safety = Path(self.path + ".backup_existing")
safety.write_bytes(b"existing safety backup")
mutations = (
"DROP TABLE execution_run_outputs",
"DROP TABLE execution_output_capture",
"DELETE FROM execution_output_capture",
"DELETE FROM execution_runs",
"CREATE UNIQUE INDEX bad_output ON execution_run_outputs(filename)",
"CREATE INDEX bad_output ON execution_run_outputs(filename)",
"CREATE VIEW bad_output AS SELECT * FROM execution_run_outputs",
"CREATE TRIGGER bad_output AFTER INSERT ON execution_run_outputs "
"BEGIN SELECT 1; END",
"PRAGMA ignore_check_constraints=ON; UPDATE execution_output_capture "
"SET source='filesystem_scan'",
"PRAGMA ignore_check_constraints=ON; UPDATE execution_output_capture "
"SET format_version=2",
"PRAGMA ignore_check_constraints=ON; UPDATE execution_output_capture "
"SET state='unavailable',reason='unavailable'",
"PRAGMA ignore_check_constraints=ON; UPDATE execution_run_outputs "
"SET filename='a' || char(0) || 'b'",
"PRAGMA ignore_check_constraints=ON; UPDATE execution_run_outputs "
"SET filename=replace(hex(zeroblob(1025)),'0','x')",
"DROP TABLE execution_runs; DROP TABLE execution_history_metadata",
)
for index, mutation in enumerate(mutations):
with self.subTest(mutation=mutation):
source = str(Path(self.temp.name) / f"bad-output-{index}.db")
self.assertTrue(live.model.backup_database(source))
with closing(sqlite3.connect(source)) as conn, conn:
conn.executescript(mutation)
original = Path(source).read_bytes()
backups = {
p.name: p.read_bytes()
for p in Path(self.temp.name).glob("live.db.backup_*")
}
self.assertFalse(PromptModel.verify_database_file(source)[0])
with self.assertRaises(ValueError):
live.model.restore_from_file(source)
self.assertEqual(list(live.model.get_connection().iterdump()), before)
self.assertEqual(Path(source).read_bytes(), original)
self.assertEqual(
{
p.name: p.read_bytes()
for p in Path(self.temp.name).glob("live.db.backup_*")
},
backups,
)
def test_legacy_identifier_and_diagnostic_bytes_rejected_before_fetch(self):
try:
from tests.test_execution_context_schema import legacy, RUN
except ImportError:
from test_execution_context_schema import legacy, RUN
from database.execution_history_schema import history_schema_error
for table, column, prefix in (
("execution_history_metadata", "storage_epoch", RUN),
*(
("execution_runs", column, RUN)
for column in (
"run_id",
"session_id",
"job_id",
"storage_epoch",
)
),
("execution_runs", "node_id", "x" * 128),
("execution_runs", "node_type", "x" * 128),
("execution_runs", "exception_type", "x" * 256),
):
with self.subTest(table=table, column=column):
with closing(sqlite3.connect(":memory:")) as conn:
legacy(conn)
malicious = prefix + "\0" + "x" * 1024
conn.execute(f"UPDATE {table} SET {column}=?", (malicious,))
def bounded_text(value):
self.assertNotEqual(value, malicious.encode())
return value.decode()
conn.text_factory = bounded_text
self.assertIsNotNone(history_schema_error(conn))
def test_legacy_metadata_version_payload_is_rejected_before_fetch(self):
try:
from tests.test_execution_context_schema import legacy
except ImportError:
from test_execution_context_schema import legacy
from database.execution_history_schema import history_schema_error
for malicious in ("x" * 1024, b"x" * 1024):
with self.subTest(kind=type(malicious).__name__):
with closing(sqlite3.connect(":memory:")) as conn:
legacy(conn)
conn.execute("PRAGMA ignore_check_constraints=ON")
conn.execute(
"UPDATE execution_history_metadata SET schema_version=?",
(malicious,),
)
def bounded_row(cursor, row):
self.assertFalse(any(value == malicious for value in row))
return row
conn.row_factory = bounded_row
self.assertIsNotNone(history_schema_error(conn))
def test_legacy_four_byte_diagnostics_validate_and_migrate(self):
try:
from tests.test_execution_context_schema import legacy
except ImportError:
from test_execution_context_schema import legacy
from database.execution_history_schema import (
history_schema_error,
migrate_execution_history,
)
with closing(sqlite3.connect(":memory:")) as conn:
legacy(conn)
conn.execute(
"UPDATE execution_runs SET node_id=?,node_type=?,exception_type=?",
("😀" * 128, "😀" * 128, "😀" * 256),
)
self.assertIsNone(history_schema_error(conn))
migrate_execution_history(conn)
self.assertIsNone(history_schema_error(conn, allow_missing=False))
self.assertEqual(
conn.execute(
"SELECT node_id,node_type,exception_type FROM " "execution_runs"
).fetchone(),
("😀" * 128, "😀" * 128, "😀" * 256),
)
def test_committed_wal_backup_includes_history_and_epoch(self):
db = self.database()
conn = db.model.get_connection()
conn.execute("PRAGMA wal_autocheckpoint=0")
start = self.start(db)
db.write_execution_start(start)
db.write_execution_finish(self.finish(start))
row = db.get_execution_run(start.run_id)
backup = str(Path(self.temp.name) / "backup.db")
self.assertGreater(Path(self.path + "-wal").stat().st_size, 0)
self.assertTrue(db.model.backup_database(backup))
restored = self.database(backup)
self.assertEqual(restored.get_execution_epoch(), start.storage_epoch)
self.assertEqual(restored.get_execution_run(start.run_id), row)
def test_restore_fences_stale_commands_preserves_rows_and_notifies_after_publish(
self,
):
from database.execution_history import (
HistoryEpochChanged,
subscribe_history_restore,
)
db = self.database()
start = self.start(db)
db.write_execution_start(start)
backup = str(Path(self.temp.name) / "snapshot.db")
self.assertTrue(db.model.backup_database(backup))
snapshot = Path(backup).read_bytes()
old_row = db.get_execution_run(start.run_id)
notices = []
unsubscribe = subscribe_history_restore(
str(Path(self.temp.name) / "." / "live.db"),
lambda: notices.append(db.get_execution_epoch()),
)
self.addCleanup(unsubscribe)
for _ in range(2):
previous = db.get_execution_epoch()
db.model.restore_from_file(backup)
epoch = db.get_execution_epoch()
self.assertNotEqual(epoch, previous)
self.assertNotEqual(epoch, start.storage_epoch)
self.assertEqual(notices[-1], epoch)
self.assertEqual(db.get_execution_run(start.run_id), old_row)
for command, write in (
(start, db.write_execution_start),
(self.finish(start), db.write_execution_finish),
):
with self.assertRaises(HistoryEpochChanged) as rejected:
write(command)
self.assertEqual(rejected.exception.storage_epoch, epoch)
self.assertEqual(db.get_execution_run(start.run_id), old_row)
fresh = self.start(db, item=1)
self.assertTrue(db.write_execution_start(fresh))
self.assertTrue(db.write_execution_finish(self.finish(fresh)))
self.assertEqual(Path(backup).read_bytes(), snapshot)
unsubscribe()
unsubscribe()
db.model.restore_from_file(backup)
self.assertEqual(len(notices), 2)
def test_old_backup_migrates_with_no_runs_without_changing_source(self):
source = str(Path(self.temp.name) / "old.db")
with closing(sqlite3.connect(source)) as conn, conn:
conn.execute(
"CREATE TABLE prompts (id INTEGER PRIMARY KEY AUTOINCREMENT, text "
"TEXT NOT NULL, created_at TIMESTAMP, updated_at TIMESTAMP, "
"category TEXT, tags TEXT, rating INTEGER, notes TEXT, hash TEXT "
"UNIQUE, workflow_name TEXT)"
)
conn.execute(
"INSERT INTO prompts(id,text,created_at) VALUES(7,'old "
"prompt','2020-01-01')"
)
original = Path(source).read_bytes()
self.assertEqual(PromptModel.verify_database_file(source), (True, "ok"))
db = self.database()
previous = db.get_execution_epoch()
db.model.restore_from_file(source)
self.assertNotEqual(db.get_execution_epoch(), previous)
self.assertEqual(self.count(db), 0)
self.assertEqual(Path(source).read_bytes(), original)
self.assertEqual(
tuple(
db.model.get_connection()
.execute("SELECT id,text FROM prompts")
.fetchone()
),
(7, "old prompt"),
)
def test_wal_and_old_backup_staging_leave_source_unchanged(self):
from database.execution_context_schema import TABLE_COLUMNS
from database.execution_output_schema import TABLE_COLUMNS as OUTPUT_COLUMNS
source = str(Path(self.temp.name) / "v1.db")
old = self.database(source)
start = self.start(old)
old.write_execution_start(start)
with old.model.get_connection() as conn:
conn.execute("INSERT INTO prompts(id,text,run_count) VALUES(7,'old',19)")
for table in reversed(OUTPUT_COLUMNS):
conn.execute(f"DROP TABLE {table}")
for table in reversed(TABLE_COLUMNS):
conn.execute(f"DROP TABLE {table}")
sql = (
conn.execute(
"SELECT sql FROM sqlite_master WHERE "
"name='execution_history_metadata'"
)
.fetchone()[0]
.replace("schema_version = 3", "schema_version = 1")
)
conn.execute("DROP TABLE execution_history_metadata")
conn.execute(sql)
conn.execute(
"INSERT INTO execution_history_metadata VALUES(1,1,?)",
(start.storage_epoch,),
)
conn.execute("PRAGMA wal_autocheckpoint=0")
with conn:
conn.execute("UPDATE prompts SET run_count=31 WHERE id=7")
source_bytes = Path(source).read_bytes()
wal_bytes = Path(source + "-wal").read_bytes()
self.assertEqual(PromptModel.verify_database_file(source), (True, "ok"))
live = self.database()
live.model.restore_from_file(source)
self.assertEqual(Path(source).read_bytes(), source_bytes)
self.assertEqual(Path(source + "-wal").read_bytes(), wal_bytes)
self.assertEqual(
[
r[1]
for r in live.model.get_connection().execute(
"PRAGMA table_info(prompt_library_state)"
)
],
["prompt_id", "title"],
)
self.assertEqual(live.get_execution_run(start.run_id)["record_version"], 1)
self.assertEqual(
live.model.get_connection()
.execute("SELECT run_count FROM prompts WHERE id=7")
.fetchone()[0],
31,
)
self.assertEqual(
live.model.get_connection()
.execute("SELECT COUNT(*) FROM execution_run_contexts")
.fetchone()[0],
0,
)
self.assertEqual(
conn.execute(
"SELECT schema_version FROM execution_history_metadata"
).fetchone()[0],
1,
)
def test_malformed_v2_context_restore_keeps_live_database_unchanged(self):
live = self.database()
start = self.start(live)
live.write_execution_start(start)
with live.model.get_connection() as conn:
conn.execute("INSERT INTO prompts(text,run_count) VALUES('kept',19)")
before = "\n".join(live.model.get_connection().iterdump())
for mutation in (
"DROP TABLE execution_context_capture",
"CREATE UNIQUE INDEX bad_context_unique ON "
"execution_context_texts(text_value)",
"INSERT INTO execution_context_texts VALUES('"
+ "0" * 64
+ "','wrong fingerprint')",
):
with self.subTest(mutation=mutation):
source = str(Path(self.temp.name) / "bad-context.db")
self.assertTrue(live.model.backup_database(source))
with closing(sqlite3.connect(source)) as conn, conn:
# Manufacture v2 explicitly so this still tests old restores.
conn.execute("DROP TABLE execution_run_outputs")
conn.execute("DROP TABLE execution_output_capture")
sql = (
conn.execute(
"SELECT sql FROM sqlite_master WHERE "
"name='execution_history_metadata'"
)
.fetchone()[0]
.replace("schema_version = 3", "schema_version = 2")
)
epoch = conn.execute(
"SELECT storage_epoch FROM execution_history_metadata"
).fetchone()[0]
conn.execute("DROP TABLE execution_history_metadata")
conn.execute(sql)
conn.execute(
"INSERT INTO execution_history_metadata VALUES(1,2,?)", (epoch,)
)
conn.execute(mutation)
original = Path(source).read_bytes()
with self.assertRaises(ValueError):
live.model.restore_from_file(source)
self.assertEqual(
"\n".join(live.model.get_connection().iterdump()), before
)
self.assertEqual(Path(source).read_bytes(), original)
def test_missing_capture_header_rejects_backup_verification_and_restore(self):
try:
from tests.test_execution_context_database import bundle
except ImportError:
from test_execution_context_database import bundle
live = self.database()
start = self.start(live)
live.write_execution_start(start)
live.write_execution_contexts(start.run_id, start.storage_epoch, bundle())
self.assertEqual(live.get_execution_contexts(start.run_id)["state"], "complete")
before = "\n".join(live.model.get_connection().iterdump())
source = str(Path(self.temp.name) / "missing-capture.db")
self.assertTrue(live.model.backup_database(source))
with closing(sqlite3.connect(source)) as conn, conn:
conn.execute("DELETE FROM execution_context_capture")
original = Path(source).read_bytes()
valid, _ = PromptModel.verify_database_file(source)
self.assertFalse(valid)
with self.assertRaises(ValueError):
live.model.restore_from_file(source)
self.assertEqual("\n".join(live.model.get_connection().iterdump()), before)
self.assertEqual(Path(source).read_bytes(), original)
def test_failed_staging_rotation_keeps_live_epoch_and_never_notifies(self):
from database.execution_history import subscribe_history_restore
db = self.database()
start = self.start(db)
db.write_execution_start(start)
source = str(Path(self.temp.name) / "source.db")
db.model.backup_database(source)
source_bytes = Path(source).read_bytes()
notices = []
self.addCleanup(
subscribe_history_restore(self.path, lambda: notices.append(True))
)
with patch(
"database.models.rotate_history_epoch",
side_effect=sqlite3.OperationalError("injected staging failure"),
):
with self.assertRaisesRegex(sqlite3.OperationalError, "injected staging"):
db.model.restore_from_file(source)
self.assertEqual(db.get_execution_epoch(), start.storage_epoch)
self.assertEqual(Path(source).read_bytes(), source_bytes)
self.assertEqual(notices, [])
self.assertTrue(db.write_execution_finish(self.finish(start)))
def test_malformed_history_rejected_before_replacement(self):
from database.execution_history import subscribe_history_restore
db = self.database()
epoch = db.get_execution_epoch()
source = str(Path(self.temp.name) / "bad.db")
other = self.database(source)
with other.model.get_connection() as conn:
conn.execute("DROP INDEX idx_execution_runs_identity")
notices = []
self.addCleanup(
subscribe_history_restore(self.path, lambda: notices.append(True))
)
with self.assertRaises(ValueError):
db.model.restore_from_file(source)
self.assertEqual(db.get_execution_epoch(), epoch)
self.assertEqual(notices, [])
def test_extra_unique_constraints_reject_restore_without_touching_live_or_backup(
self,
):
from database.execution_history import subscribe_history_restore
db = self.database()
start = self.start(db)
db.write_execution_start(start)
row = db.get_execution_run(start.run_id)
with db.model.get_connection() as conn:
conn.execute("INSERT INTO prompts(text) VALUES('live library')")
notices = []
self.addCleanup(
subscribe_history_restore(self.path, lambda: notices.append(True))
)
for table_constraint in (False, True):
with self.subTest(table_constraint=table_constraint):
source = str(
Path(self.temp.name) / f"unique-backup-{table_constraint}.db"
)
self.assertTrue(db.model.backup_database(source))
with closing(sqlite3.connect(source)) as conn, conn:
if table_constraint:
sql = conn.execute(
"SELECT sql FROM sqlite_master "
"WHERE name='execution_runs'"
).fetchone()[0]
conn.execute("ALTER TABLE execution_runs RENAME TO old_runs")
conn.execute(sql[:-1] + ", UNIQUE(job_id))")
conn.execute(
"INSERT INTO execution_runs SELECT * FROM old_runs"
)
conn.execute("DROP TABLE old_runs")
conn.execute(
"CREATE UNIQUE INDEX idx_execution_runs_identity ON "
"execution_runs(session_id, queue_item_id)"
)
conn.execute(
"CREATE INDEX idx_execution_runs_observed ON "
"execution_runs(observed_dequeue_at, run_id)"
)
else:
conn.execute(
"CREATE UNIQUE INDEX unexpected_job_uniqueness ON "
"execution_runs(job_id)"
)
original = Path(source).read_bytes()
with self.assertRaisesRegex(ValueError, "uniqu"):
db.model.restore_from_file(source)
self.assertEqual(db.get_execution_epoch(), start.storage_epoch)
self.assertEqual(db.get_execution_run(start.run_id), row)
self.assertEqual(self.count(db), 1)
self.assertEqual(
db.model.get_connection()
.execute("SELECT text FROM prompts")
.fetchone()[0],
"live library",
)
self.assertEqual(Path(source).read_bytes(), original)
self.assertEqual(notices, [])
self.assertTrue(db.write_execution_start(self.start(db, item=1)))
self.assertEqual(self.count(db), 2)
def test_observer_failure_does_not_change_completed_restore(self):
from database.execution_history import subscribe_history_restore
db = self.database()
source = str(Path(self.temp.name) / "snapshot.db")
db.model.backup_database(source)
original_epoch = db.get_execution_epoch()
def broken():
raise RuntimeError("observer failure")
self.addCleanup(subscribe_history_restore(self.path, broken))
notices = []
self.addCleanup(
subscribe_history_restore(self.path, lambda: notices.append(True))
)
with self.assertLogs(
"prompt_manager.database.execution_history", level="WARNING"
):
db.model.restore_from_file(source)
self.assertNotEqual(db.get_execution_epoch(), original_epoch)
self.assertEqual(notices, [True])
def test_restore_preserves_context_scalars_but_never_reepochs_hints(self):
try:
from tests.test_execution_context_database import bundle
except ImportError:
from test_execution_context_database import bundle
from database.execution_history import HistoryEpochChanged
db = self.database()
with db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompts(id,text) VALUES(1,'positive'),(2,'negative')"
)
conn.execute(
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
"VALUES(7,1,2)"
)
start = self.start(db)
db.write_execution_start(start)
db.write_execution_contexts(start.run_id, start.storage_epoch, bundle())
original = db.get_execution_contexts(start.run_id)
backup = str(Path(self.temp.name) / "contexts.db")
self.assertTrue(db.model.backup_database(backup))
source_bytes = Path(backup).read_bytes()
for _ in range(2):
db.model.restore_from_file(backup)
read = db.get_execution_contexts(start.run_id)
expected = original["contexts"][0].copy()
expected.update(
positive_prompt_id=None, negative_prompt_id=None, pair_id=None
)
self.assertEqual(read["contexts"], [expected])
self.assertEqual(read["state"], "complete")
self.assertEqual(
read["contexts"][0]["reference_epoch"], start.storage_epoch
)
with self.assertRaises(HistoryEpochChanged):
db.write_execution_contexts(start.run_id, start.storage_epoch, bundle())
self.assertFalse(
db.write_execution_contexts(
start.run_id, db.get_execution_epoch(), bundle()
)
)
self.assertEqual(db.get_execution_contexts(start.run_id), read)
self.assertEqual(Path(backup).read_bytes(), source_bytes)
if __name__ == "__main__":
unittest.main()