* 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.
580 lines
23 KiB
Python
580 lines
23 KiB
Python
"""PR #335 final review fixes (#249 System S8): I1, M1 to M7, N1 and N3.
|
|
|
|
- I1: once the stage replaced the library, the restore is done: later steps
|
|
(pruning, the switch) only log, and the job and legacy route say so.
|
|
- M1: a backup download holds the registry only while its snapshot is made.
|
|
- M2: a restore preview leases only its live read; a busy drain keeps the
|
|
reviewed stage.
|
|
- M3: the reviewed-preview store changes only on the event loop.
|
|
- M4: a document whose body is intact but whose key or length is damaged is
|
|
re-keyed, not dropped.
|
|
- M5: a backup download is refused only for a corrupt snapshot; a semantic
|
|
check failure downloads, logged and reported.
|
|
- M6: a cancelled preview removes what it staged.
|
|
- M7 / N1 / N3: repair reads one row at a time; images counted once; the
|
|
fingerprint never commits a caller's transaction.
|
|
|
|
Ordering is driven by events and job state, never by timers.
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import zlib
|
|
from contextlib import closing
|
|
from unittest.mock import patch
|
|
|
|
from aiohttp import web
|
|
|
|
from database import restore_stage
|
|
from database import workflow_documents as wd
|
|
from database.managed_connection import ManagedCursor
|
|
from database.models import PromptModel
|
|
from database.operations import PromptDatabase
|
|
from py.api import admin as admin_mod
|
|
from py.api import backup_restore, jobs
|
|
|
|
try:
|
|
from tests.test_restore_plans import RestoreTestCase, _texts
|
|
except ImportError: # discovered with tests/ as the top-level directory
|
|
from test_restore_plans import RestoreTestCase, _texts
|
|
|
|
WAIT = 60
|
|
|
|
|
|
class TestPostPublishSteps(RestoreTestCase):
|
|
"""I1: a failure after the copy never says the library was not changed."""
|
|
|
|
async def test_job_finishes_when_pruning_fails_after_the_copy(self):
|
|
self.save("old")
|
|
old_db = self.api.db
|
|
generation = self.api._db_generation
|
|
body = await self.preview(self.upload(["restored"]))
|
|
with (
|
|
patch.object(admin_mod, "_prune_safety_backups", side_effect=OSError("x")),
|
|
self.assertLogs(self.api.logger, "WARNING") as logs,
|
|
):
|
|
started = await self.apply(body["token"])
|
|
job = await self.finish(started["job"]["id"])
|
|
self.assertEqual(job.state, "finished", job.log)
|
|
self.assertIn("Restored 1 prompt", job.status_line)
|
|
self.assertNotIn("not changed", "\n".join(job.log))
|
|
self.assertIsNot(self.api.db, old_db)
|
|
self.assertEqual(self.api._db_generation, generation + 1)
|
|
self.assertEqual(_texts(self.db_path), ["restored"])
|
|
self.assertIn("prune", "\n".join(logs.output).lower())
|
|
|
|
async def test_job_adopts_the_library_when_closing_the_old_one_fails(self):
|
|
self.save("old")
|
|
old_db = self.api.db
|
|
body = await self.preview(self.upload(["restored"]))
|
|
try:
|
|
with patch.object(old_db, "close_all", side_effect=RuntimeError("close")):
|
|
started = await self.apply(body["token"])
|
|
job = await self.finish(started["job"]["id"])
|
|
self.assertEqual(job.state, "finished", job.log)
|
|
self.assertIsNot(self.api.db, old_db)
|
|
self.assertEqual(
|
|
[p["text"] for p in self.api.db.search_prompts()], ["restored"]
|
|
)
|
|
finally:
|
|
# The patched close left the old object's executor-thread
|
|
# connections open; close them before teardown removes the files
|
|
# (once the object is collected, close_all_instances misses it).
|
|
await asyncio.get_running_loop().run_in_executor(None, old_db.close_all)
|
|
|
|
async def test_job_finishes_when_opening_the_new_object_fails(self):
|
|
self.save("old")
|
|
generation = self.api._db_generation
|
|
body = await self.preview(self.upload(["restored"]))
|
|
with patch.object(
|
|
backup_restore, "PromptDatabase", side_effect=RuntimeError("open")
|
|
):
|
|
started = await self.apply(body["token"])
|
|
job = await self.finish(started["job"]["id"])
|
|
self.assertEqual(job.state, "finished", job.log)
|
|
# The old object still points at the same, restored file.
|
|
self.assertEqual(
|
|
[p["text"] for p in self.api.db.search_prompts()], ["restored"]
|
|
)
|
|
self.assertEqual(self.api._db_generation, generation + 1)
|
|
|
|
async def test_legacy_restore_succeeds_when_pruning_fails_after_the_copy(self):
|
|
self.save("old")
|
|
old_db = self.api.db
|
|
with patch.object(admin_mod, "_prune_safety_backups", side_effect=OSError("x")):
|
|
resp = await self.client.post(
|
|
"/prompt_manager/restore", data=self.form(self.upload(["restored"]))
|
|
)
|
|
body = await resp.json()
|
|
self.assertEqual(resp.status, 200, body)
|
|
self.assertEqual(
|
|
body["message"], "Database restored successfully. Found 1 prompts."
|
|
)
|
|
self.assertIsNot(self.api.db, old_db)
|
|
|
|
|
|
class TestBackupHoldScope(RestoreTestCase):
|
|
"""M1 and M5: the hold covers the snapshot only; semantic issues warn."""
|
|
|
|
async def test_hold_ends_before_the_first_byte_is_sent(self):
|
|
self.save("keep")
|
|
seen = []
|
|
real = web.StreamResponse.prepare
|
|
|
|
async def prepare(response, request):
|
|
seen.append(self.api._jobs.active("backup"))
|
|
return await real(response, request)
|
|
|
|
with patch.object(web.StreamResponse, "prepare", prepare):
|
|
resp, data = await self.download_backup()
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertTrue(seen)
|
|
self.assertEqual(set(seen), {None}) # no hold while bytes are sent
|
|
[job] = self.api._jobs.list()
|
|
self.assertEqual((job.kind, job.state), ("backup", "finished"))
|
|
self.assertTrue(data.startswith(b"SQLite format 3\x00"))
|
|
|
|
async def test_a_corrupt_snapshot_is_refused(self):
|
|
self.save("keep")
|
|
with patch.object(
|
|
backup_restore, "snapshot_corruption", return_value="Integrity check failed"
|
|
):
|
|
resp = await self.client.get("/prompt_manager/backup")
|
|
self.assertEqual(resp.status, 500)
|
|
self.assertEqual(
|
|
await resp.json(),
|
|
{"success": False, "error": "Failed to create database backup"},
|
|
)
|
|
self.assertEqual(
|
|
[n for n in os.listdir(self.tmpdir) if n.startswith(".pm-")], []
|
|
)
|
|
[job] = self.api._jobs.list()
|
|
self.assertEqual(job.state, "failed")
|
|
|
|
async def test_a_semantic_check_failure_still_downloads_and_says_so(self):
|
|
self.save("keep")
|
|
reason = "Image pair association has a different positive prompt owner"
|
|
with (
|
|
patch.object(
|
|
PromptModel, "verify_database_file", return_value=(False, reason)
|
|
),
|
|
self.assertLogs(self.api.logger, "WARNING") as logs,
|
|
):
|
|
resp, data = await self.download_backup()
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["X-PromptManager-Verify"], reason)
|
|
self.assertTrue(data.startswith(b"SQLite format 3\x00"))
|
|
self.assertIn(reason, "\n".join(logs.output))
|
|
status = await (await self.client.get("/prompt_manager/backup/status")).json()
|
|
self.assertFalse(status["last_download"]["verified"])
|
|
|
|
async def test_a_verified_download_says_ok(self):
|
|
self.save("keep")
|
|
resp, _data = await self.download_backup()
|
|
self.assertEqual(resp.headers["X-PromptManager-Verify"], "ok")
|
|
|
|
|
|
class TestPreviewLease(RestoreTestCase):
|
|
"""M2: upload and staging take no lease; a busy drain keeps the stage."""
|
|
|
|
async def test_staging_holds_no_lease(self):
|
|
leases = []
|
|
real = PromptModel.stage_restore
|
|
|
|
def stage(model, path):
|
|
leases.append(dict(self.api._jobs.leases))
|
|
return real(model, path)
|
|
|
|
with patch.object(PromptModel, "stage_restore", stage):
|
|
await self.preview(self.upload(["restored"]))
|
|
self.assertEqual(leases, [{}])
|
|
|
|
async def test_a_busy_drain_keeps_the_reviewed_stage(self):
|
|
self.save("live")
|
|
body = await self.preview(self.upload(["restored"]))
|
|
lease = self.api._jobs.lease("Stuck preview")
|
|
await lease.__aenter__()
|
|
try:
|
|
with patch.object(jobs, "LEASE_DRAIN_SECONDS", 0):
|
|
started = await self.apply(body["token"])
|
|
job = await self.finish(started["job"]["id"])
|
|
finally:
|
|
await lease.__aexit__(None, None, None)
|
|
self.assertEqual(job.state, "failed")
|
|
self.assertEqual(job.last_event["code"], "busy")
|
|
self.assertIn("review is kept", job.status_line)
|
|
self.assertEqual(len(self.stages()), 1)
|
|
# The same review applies once the requests are done.
|
|
job = await self.finish((await self.apply(body["token"]))["job"]["id"])
|
|
self.assertEqual(job.state, "finished", job.log)
|
|
self.assertEqual(_texts(self.db_path), ["restored"])
|
|
|
|
|
|
class TestStoreOnTheLoop(RestoreTestCase):
|
|
"""M3: entries leave the store on the loop; only file removal is threaded."""
|
|
|
|
async def test_expired_entries_leave_the_store_before_their_files_go(self):
|
|
clock = [1000.0]
|
|
store = self.api._restore_plan_store = backup_restore.RestorePlanStore(
|
|
clock=lambda: clock[0]
|
|
)
|
|
body = await self.preview(self.upload(["restored"]))
|
|
held = store.peek(body["token"])
|
|
entered, release = threading.Event(), threading.Event()
|
|
real = held.staged.discard
|
|
|
|
def slow_discard():
|
|
entered.set()
|
|
release.wait(WAIT)
|
|
real()
|
|
|
|
held.staged.discard = slow_discard
|
|
clock[0] += 10_000
|
|
cleanup = asyncio.ensure_future(self.api._drop_expired_restores())
|
|
loop = asyncio.get_running_loop()
|
|
self.assertTrue(await loop.run_in_executor(None, entered.wait, WAIT))
|
|
# The removal runs on a worker; the store already forgot the entry.
|
|
self.assertEqual(len(store), 0)
|
|
second = await self.preview(self.upload(["other"], "other.db"))
|
|
self.assertEqual(len(store), 1)
|
|
release.set()
|
|
await cleanup
|
|
self.assertEqual(len(self.stages()), 1)
|
|
store.peek(second["token"])
|
|
|
|
async def test_an_expired_preview_is_removed_without_another_request(self):
|
|
clock = [1000.0]
|
|
self.api._restore_plan_store = backup_restore.RestorePlanStore(
|
|
clock=lambda: clock[0], ttl=0
|
|
)
|
|
await self.preview(self.upload(["restored"]))
|
|
# The expiry timer fires without another request: wait on its state.
|
|
while self.stages() or len(self.api._restore_plans):
|
|
await asyncio.sleep(0)
|
|
self.assertEqual(self.stages(), [])
|
|
|
|
|
|
class TestCancelledPreview(RestoreTestCase):
|
|
"""M6: a cancelled preview leaves no stage behind."""
|
|
|
|
async def test_cancel_while_reading_the_live_library(self):
|
|
path = os.path.join(self.tmpdir, "uploads", "up.db")
|
|
self.upload(["restored"], "up.db")
|
|
entered, release = threading.Event(), threading.Event()
|
|
real = self.api._live_fingerprint
|
|
|
|
def slow(db):
|
|
entered.set()
|
|
release.wait(WAIT)
|
|
return real(db)
|
|
|
|
with patch.object(self.api, "_live_fingerprint", side_effect=slow):
|
|
task = asyncio.ensure_future(
|
|
self.api._review_upload(
|
|
self.api.db, self.api._database_identity(self.api.db), path
|
|
)
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
self.assertTrue(await loop.run_in_executor(None, entered.wait, WAIT))
|
|
self.assertEqual(len(self.stages()), 1)
|
|
task.cancel()
|
|
release.set()
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await task
|
|
self.assertEqual(self.stages(), [])
|
|
self.assertEqual(len(self.api._restore_plans), 0)
|
|
|
|
async def test_cancel_while_staging(self):
|
|
path = os.path.join(self.tmpdir, "uploads", "up.db")
|
|
self.upload(["restored"], "up.db")
|
|
entered, release = threading.Event(), threading.Event()
|
|
real = PromptModel.stage_restore
|
|
done = threading.Event()
|
|
|
|
def slow(model, src):
|
|
entered.set()
|
|
release.wait(WAIT)
|
|
try:
|
|
return real(model, src)
|
|
finally:
|
|
done.set()
|
|
|
|
with patch.object(PromptModel, "stage_restore", slow):
|
|
task = asyncio.ensure_future(
|
|
self.api._review_upload(
|
|
self.api.db, self.api._database_identity(self.api.db), path
|
|
)
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
self.assertTrue(await loop.run_in_executor(None, entered.wait, WAIT))
|
|
task.cancel()
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await task
|
|
release.set()
|
|
self.assertTrue(await loop.run_in_executor(None, done.wait, WAIT))
|
|
# The worker's finished stage is removed without another request.
|
|
while self.api._orphan_stages or self.stages():
|
|
await asyncio.sleep(0)
|
|
self.assertEqual(self.stages(), [])
|
|
|
|
|
|
class TestRekeyDocuments(RestoreTestCase):
|
|
"""M4: an intact body with a damaged key or length is re-keyed."""
|
|
|
|
def library(self, damage):
|
|
path = os.path.join(self.tmpdir, "uploads", "d.db")
|
|
self.upload(["x"], "d.db")
|
|
raw = b'{"nodes": [1, 2, 3]}'
|
|
digest = hashlib.sha256(raw).digest()
|
|
with closing(sqlite3.connect(path)) as conn:
|
|
sha, length = damage(digest, len(raw))
|
|
doc = conn.execute(
|
|
"INSERT INTO workflow_documents (sha256, byte_length, data) "
|
|
"VALUES (?,?,?)",
|
|
(sha, length, zlib.compress(raw)),
|
|
).lastrowid
|
|
prompt_id = conn.execute("SELECT id FROM prompts").fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO generated_images (prompt_id, image_path, filename, "
|
|
"workflow_document_id) VALUES (?, '/x/a.png', 'a.png', ?)",
|
|
(prompt_id, doc),
|
|
)
|
|
conn.commit()
|
|
return path, raw.decode()
|
|
|
|
def staged_texts(self, staged):
|
|
with closing(sqlite3.connect(staged.path)) as conn:
|
|
return [
|
|
wd.read_stored_text(conn, None, row[0])
|
|
for row in conn.execute(
|
|
"SELECT workflow_document_id FROM generated_images"
|
|
)
|
|
]
|
|
|
|
def test_a_damaged_key_is_recomputed(self):
|
|
def flip(digest, length):
|
|
bad = bytearray(digest)
|
|
bad[0] ^= 1
|
|
return bytes(bad), length
|
|
|
|
path, text = self.library(flip)
|
|
staged = self.api.db.model.stage_restore(path)
|
|
try:
|
|
self.assertEqual(
|
|
staged.documents, {"repaired": 1, "cleared": 0, "images": 0}
|
|
)
|
|
self.assertEqual(self.staged_texts(staged), [text])
|
|
finally:
|
|
staged.discard()
|
|
|
|
def test_a_damaged_length_is_recomputed(self):
|
|
path, text = self.library(lambda digest, length: (digest, length + 7))
|
|
staged = self.api.db.model.stage_restore(path)
|
|
try:
|
|
self.assertEqual(staged.documents["repaired"], 1)
|
|
self.assertEqual(self.staged_texts(staged), [text])
|
|
finally:
|
|
staged.discard()
|
|
|
|
def test_a_key_that_another_document_holds_is_merged_into_it(self):
|
|
path, text = self.library(lambda digest, length: (b"\0" * 32, length))
|
|
with closing(sqlite3.connect(path)) as conn:
|
|
raw = text.encode()
|
|
conn.execute(
|
|
"INSERT INTO workflow_documents (sha256, byte_length, data) "
|
|
"VALUES (?,?,?)",
|
|
(hashlib.sha256(raw).digest(), len(raw), zlib.compress(raw)),
|
|
)
|
|
conn.commit()
|
|
staged = self.api.db.model.stage_restore(path)
|
|
try:
|
|
self.assertEqual(staged.documents["repaired"], 1)
|
|
self.assertEqual(self.staged_texts(staged), [text])
|
|
with closing(sqlite3.connect(staged.path)) as conn:
|
|
self.assertEqual(
|
|
conn.execute("SELECT COUNT(*) FROM workflow_documents").fetchone(),
|
|
(1,),
|
|
)
|
|
finally:
|
|
staged.discard()
|
|
|
|
|
|
class TestRepairReadsOneRowAtATime(RestoreTestCase):
|
|
"""M7: repair never iterates a managed cursor (128-row prefetch)."""
|
|
|
|
def test_repair_uses_fetchone(self):
|
|
upload = os.path.join(self.tmpdir, "uploads", "dmg.db")
|
|
os.makedirs(os.path.dirname(upload), exist_ok=True)
|
|
src = PromptDatabase(upload)
|
|
prompt_id = self.save("with a workflow", src)
|
|
image = src.link_image_to_prompt(
|
|
prompt_id, os.path.join(self.tmpdir, "a.png"), {"workflow": {"v": 1}}
|
|
)
|
|
conn = src.model.get_connection()
|
|
with conn:
|
|
text = wd.read_stored_text(
|
|
conn,
|
|
None,
|
|
conn.execute(
|
|
"SELECT workflow_document_id FROM generated_images WHERE id=?",
|
|
(image,),
|
|
).fetchone()[0],
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO generated_images (prompt_id, image_path, filename, "
|
|
"workflow_data) VALUES (?, '/x/b.png', 'b.png', ?)",
|
|
(prompt_id, text),
|
|
)
|
|
conn.execute("UPDATE workflow_documents SET data=x'00'")
|
|
try:
|
|
with patch.object(
|
|
ManagedCursor, "fetchmany", side_effect=AssertionError("prefetch")
|
|
):
|
|
result = wd.repair_damaged_documents(conn)
|
|
self.assertEqual(result["repaired"], 1)
|
|
finally:
|
|
src.close_all()
|
|
|
|
|
|
class TestFingerprintTransaction(RestoreTestCase):
|
|
"""N3: the fingerprint never commits a transaction it did not open."""
|
|
|
|
def test_a_callers_transaction_is_left_open(self):
|
|
conn = self.api.db.model.get_connection()
|
|
conn.execute("BEGIN")
|
|
conn.execute("INSERT INTO prompts (text, hash) VALUES ('pending', 'p')")
|
|
restore_stage.live_fingerprint(conn)
|
|
self.assertTrue(conn.in_transaction)
|
|
conn.rollback()
|
|
self.assertEqual([p["text"] for p in self.api.db.search_prompts()], [])
|
|
|
|
|
|
class TestRereviewFixes(RestoreTestCase):
|
|
"""PR #335 re-review: R1 to R4."""
|
|
|
|
def test_r1_a_merge_into_a_damaged_holder_keeps_both_images(self):
|
|
rekey = TestRekeyDocuments.library
|
|
path, text = rekey(self, lambda digest, length: (b"\0" * 32, length))
|
|
raw = text.encode()
|
|
with closing(sqlite3.connect(path)) as conn:
|
|
holder = conn.execute(
|
|
"INSERT INTO workflow_documents (sha256, byte_length, data) "
|
|
"VALUES (?,?,?)",
|
|
(hashlib.sha256(raw).digest(), len(raw), b"garbage-not-zlib"),
|
|
).lastrowid
|
|
prompt_id = conn.execute("SELECT id FROM prompts").fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO generated_images (prompt_id, image_path, filename, "
|
|
"workflow_document_id) VALUES (?, '/x/b.png', 'b.png', ?)",
|
|
(prompt_id, holder),
|
|
)
|
|
conn.commit()
|
|
staged = self.api.db.model.stage_restore(path)
|
|
try:
|
|
self.assertEqual(
|
|
staged.documents, {"repaired": 2, "cleared": 0, "images": 0}
|
|
)
|
|
self.assertEqual(
|
|
TestRekeyDocuments.staged_texts(self, staged), [text, text]
|
|
)
|
|
with closing(sqlite3.connect(staged.path)) as conn:
|
|
self.assertEqual(
|
|
conn.execute("SELECT COUNT(*) FROM workflow_documents").fetchone(),
|
|
(1,),
|
|
)
|
|
finally:
|
|
staged.discard()
|
|
|
|
async def test_r2_a_losing_duplicate_apply_leaves_the_taken_stage(self):
|
|
self.save("live")
|
|
body = await self.preview(self.upload(["restored"]))
|
|
store = self.api._restore_plans
|
|
held = store.peek(body["token"])
|
|
entered, release = threading.Event(), threading.Event()
|
|
real = self.api._live_fingerprint
|
|
|
|
def gated(db):
|
|
entered.set()
|
|
release.wait(WAIT)
|
|
return real(db)
|
|
|
|
with patch.object(self.api, "_live_fingerprint", side_effect=gated):
|
|
second = asyncio.ensure_future(
|
|
self.client.post(
|
|
"/prompt_manager/restore/apply", json={"token": body["token"]}
|
|
)
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
self.assertTrue(await loop.run_in_executor(None, entered.wait, WAIT))
|
|
# Meanwhile the first apply's job took the stage, and a write landed.
|
|
store.take(body["token"], "first-job")
|
|
self.save("a run count")
|
|
release.set()
|
|
resp = await second
|
|
self.assertEqual(resp.status, 409)
|
|
self.assertTrue(os.path.isdir(held.staged.directory))
|
|
await self._discard_quietly(held)
|
|
|
|
async def _discard_quietly(self, held):
|
|
await asyncio.get_running_loop().run_in_executor(None, held.staged.discard)
|
|
|
|
def test_r3_give_back_refuses_a_full_store_or_an_expired_review(self):
|
|
clock = [1000.0]
|
|
store = backup_restore.RestorePlanStore(clock=lambda: clock[0], max_stages=1)
|
|
first = store.issue(_Stage(), "f", "id", {})
|
|
held = store.take(first, "job")
|
|
other = store.issue(_Stage(), "f", "id", {})
|
|
self.assertFalse(store.give_back(first, held)) # the slot was reused
|
|
self.assertEqual(len(store), 1)
|
|
store.pop(other)
|
|
clock[0] = held.expires_at
|
|
self.assertFalse(store.give_back(first, held)) # expired meanwhile
|
|
clock[0] -= 1
|
|
self.assertTrue(store.give_back(first, held))
|
|
self.assertIs(store.peek(first), held)
|
|
|
|
async def test_r4_a_cancelled_backup_removes_its_snapshot(self):
|
|
from aiohttp.test_utils import make_mocked_request
|
|
|
|
self.save("keep")
|
|
entered, release, done = (threading.Event() for _ in range(3))
|
|
real = backup_restore.write_backup_snapshot
|
|
|
|
def slow(model):
|
|
entered.set()
|
|
release.wait(WAIT)
|
|
try:
|
|
return real(model)
|
|
finally:
|
|
done.set()
|
|
|
|
with patch.object(backup_restore, "write_backup_snapshot", slow):
|
|
task = asyncio.ensure_future(
|
|
self.api._stream_backup(
|
|
make_mocked_request("GET", "/prompt_manager/backup")
|
|
)
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
self.assertTrue(await loop.run_in_executor(None, entered.wait, WAIT))
|
|
task.cancel()
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await task
|
|
release.set()
|
|
self.assertTrue(await loop.run_in_executor(None, done.wait, WAIT))
|
|
|
|
async def snapshot_gone():
|
|
while any(n.startswith(".pm-safety-") for n in os.listdir(self.tmpdir)):
|
|
await asyncio.sleep(0)
|
|
|
|
# A ceiling only a regression reaches (the file never goes).
|
|
await asyncio.wait_for(snapshot_gone(), WAIT)
|
|
self.assertIsNone(self.api._jobs.active("backup"))
|
|
|
|
|
|
class _Stage:
|
|
def discard(self):
|
|
pass
|