* test(browser): seed resets on a scratch copy and publish them in one commit (#384) The concept and plain resets made hundreds of committed transactions on the live database; on a slow CI disk they queued behind WAL checkpoint syncs and took 30+ s. Each reset now copies the live database into a scratch database (backup API, no syncs), runs the unchanged seeder against it through an API proxy, and replaces the rows of the tables the seed changed in one live transaction. A commit by another live connection meanwhile fails the reset. tests/browser/reset_equivalence.py proves every reset kind leaves the same rows, files and response as the old path (PM_TEST_SEED_ON_LIVE=1). * test(browser): empty media folders by a rename, delete them in the background (#384) With the database commits gone, throttled resets still stalled in rmtree: unlinking the last fixture's files waits for their pending writeback. Each reseed now moves the old output and input folders into <root>/reset-trash and makes empty ones; a background thread deletes the trash, and the server's cleanup stops it and removes the rest before the temp root goes. * fix(test): recheck the live data version under the publish's write lock (#384) A writer whose transaction was open while the seed ran committed before BEGIN IMMEDIATE returned and its rows were overwritten. The publish now rechecks data_version under the lock, and restores foreign_keys and detaches the scratch database on every error path. * test(browser): keep the reset scratch database in /dev/shm when there is one (#384) Throttled runs still waited on the runner's disk while creating and rewriting the scratch database, whose pages were under writeback. * test(browser): keep the scratch on disk when /dev/shm is short, delete in place after cleanup (#384) The largest scratch copy is about 8 MB; Docker's default /dev/shm is 64 MB and shared with the browsers. A reset that empties a folder after the server's cleanup closed its trash deletes in place instead of starting a thread nobody stops. * fix(test): drop the gallery caches only after the reset's rows are published (#384) The seeders drop the Images listing at their end. Run on the scratch copy, that drop came before the publish, so a background walk in between could cache per-file values built from the live database's old rows: CI's inspector tag chip found 0 of 0 images. The proxy now records the drop and the seeder makes it after the publish, as the old reset did. * fix(test): remove /dev/shm reset scratch left by killed browser servers (#384) A server killed outright never runs its cleanup, and tmpfs keeps the scratch folder in memory until reboot: the Python suite's server tests left one per killed server. Scratch folders now carry the owner's pid, and each new one first removes those whose process is gone (POSIX only).
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""Prove the scratch-seeded resets leave the state the old resets left (#384).
|
|
|
|
Run by hand on Linux (the browser server's platform):
|
|
|
|
python -m tests.browser.reset_equivalence
|
|
|
|
It starts the browser server three times: twice seeding on the live database
|
|
as before #384 (``PM_TEST_SEED_ON_LIVE=1``) and once with the scratch seeder.
|
|
Each server gets the same sequence: every reset kind of ``/__test__/reset``
|
|
and ``/__test__/concept/reset``, with other fixtures' resets and API writes
|
|
between them. After every reset it reads every table's rows (by rowid), the
|
|
output and input file trees (size, SHA-256, mtime) and the response body.
|
|
|
|
Values that differ between the two old runs are wall-clock values (created_at,
|
|
an unpinned concept "now", file mtimes); they are masked. Everything else must
|
|
be equal between the old runs and the new one. Exit status 0 means equal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
READY_TIMEOUT_S = 60
|
|
CLOCK = "<clock>"
|
|
|
|
RESETS = [
|
|
("/__test__/reset", {}),
|
|
("/__test__/reset", {"extra_images": 2}),
|
|
("/__test__/reset", {"extra_images": 108}),
|
|
("/__test__/reset", {"include_video": True}),
|
|
("/__test__/reset", {"prompt_inspector": True}),
|
|
("/__test__/reset", {"native_library": True}),
|
|
("/__test__/reset", {"native_library": True, "extra_prompts": 125}),
|
|
("/__test__/reset", {"native_library": True, "extra_variants": 41}),
|
|
("/__test__/reset", {"workspace_visual": True}),
|
|
("/__test__/reset", {"full_inspector": True}),
|
|
(
|
|
"/__test__/reset",
|
|
{"full_inspector": True, "include_video": True, "extra_images": 2},
|
|
),
|
|
("/__test__/reset", {"chronological_history": True}),
|
|
("/__test__/reset", {"chronological_history": True, "history_runs": 85}),
|
|
("/__test__/concept/reset", {}),
|
|
("/__test__/concept/reset", {"extra_images": 100}),
|
|
("/__test__/concept/reset", {"seedless_images": 2, "short_seedless": True}),
|
|
("/__test__/concept/reset", {"now": 1_780_000_000}),
|
|
]
|
|
# Writes by other fixtures and the API between resets; the resets must keep or
|
|
# replace them exactly as before.
|
|
DIRTY = [
|
|
("/__test__/tagging/reset", {}),
|
|
("/__test__/system/reset", None),
|
|
(
|
|
"/__test__/saved-views/raw",
|
|
{"page": "images", "name": "dirty", "state": "{}"},
|
|
),
|
|
]
|
|
|
|
|
|
class Server:
|
|
def __init__(self, on_live: bool) -> None:
|
|
self.tmp = tempfile.TemporaryDirectory(prefix="pm-equivalence-")
|
|
env = {**os.environ, "TMPDIR": self.tmp.name}
|
|
env.update(TEMP=self.tmp.name, TMP=self.tmp.name)
|
|
env.pop("PM_TEST_SEED_ON_LIVE", None)
|
|
if on_live:
|
|
env["PM_TEST_SEED_ON_LIVE"] = "1"
|
|
self.process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"tests.browser.server",
|
|
"--port",
|
|
"0",
|
|
"--exit-on-stdin-eof",
|
|
],
|
|
cwd=REPO_ROOT,
|
|
env=env,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
line = self.process.stdout.readline().decode()
|
|
if not line.startswith("PM_TEST_SERVER_PORT="):
|
|
raise RuntimeError(f"no port line: {line!r}")
|
|
self.base = f"http://127.0.0.1:{int(line.split('=', 1)[1])}"
|
|
deadline = time.monotonic() + READY_TIMEOUT_S
|
|
while True:
|
|
try:
|
|
self.post_json("GET", "/prompt_manager/test", None)
|
|
break
|
|
except OSError:
|
|
if time.monotonic() > deadline or self.process.poll() is not None:
|
|
raise
|
|
time.sleep(0.1)
|
|
(self.root,) = Path(self.tmp.name).glob("pm-browser-*")
|
|
|
|
def post_json(self, method: str, path: str, data) -> dict:
|
|
body = None if data is None else json.dumps(data).encode()
|
|
request = urllib.request.Request(
|
|
self.base + path,
|
|
data=body if method == "POST" else None,
|
|
method=method,
|
|
headers={"Content-Type": "application/json"} if body else {},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=120) as response: # nosec B310
|
|
text = response.read().decode()
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError:
|
|
return {"text": text[:200]}
|
|
|
|
def stop(self) -> None:
|
|
self.process.stdin.close()
|
|
self.process.wait(timeout=READY_TIMEOUT_S)
|
|
self.process.stdout.close()
|
|
self.tmp.cleanup()
|
|
|
|
|
|
def read_tables(path: Path) -> dict:
|
|
conn = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
|
|
try:
|
|
tables = {}
|
|
names = [
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
)
|
|
]
|
|
for name in names:
|
|
quoted = '"' + name.replace('"', '""') + '"'
|
|
columns = [row[1] for row in conn.execute(f"PRAGMA table_info({quoted})")]
|
|
try:
|
|
cursor = conn.execute(f"SELECT rowid, * FROM {quoted} ORDER BY rowid")
|
|
columns = ["rowid", *columns]
|
|
except sqlite3.OperationalError:
|
|
cursor = conn.execute(f"SELECT * FROM {quoted} ORDER BY 1")
|
|
rows = [
|
|
[value.hex() if isinstance(value, bytes) else value for value in row]
|
|
for row in cursor
|
|
]
|
|
tables[name] = {"columns": columns, "rows": rows}
|
|
return tables
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def read_files(root: Path) -> dict:
|
|
files = {}
|
|
for top in ("output", "input"):
|
|
for path in sorted((root / top).rglob("*")):
|
|
if path.is_file():
|
|
stat = path.stat()
|
|
files[path.relative_to(root).as_posix()] = [
|
|
stat.st_size,
|
|
hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
stat.st_mtime_ns,
|
|
]
|
|
return files
|
|
|
|
|
|
def flatten(value, prefix="") -> dict:
|
|
if isinstance(value, dict):
|
|
out = {}
|
|
for key, item in value.items():
|
|
out.update(flatten(item, f"{prefix}/{key}"))
|
|
return out
|
|
if isinstance(value, list):
|
|
out = {f"{prefix}#len": len(value)}
|
|
for index, item in enumerate(value):
|
|
out.update(flatten(item, f"{prefix}[{index}]"))
|
|
return out
|
|
return {prefix: value}
|
|
|
|
|
|
def snapshot(server: Server, response: dict) -> dict:
|
|
flat = {}
|
|
for table, data in read_tables(server.root / "browser.db").items():
|
|
flat[f"table {table}#rows"] = len(data["rows"])
|
|
for index, row in enumerate(data["rows"]):
|
|
for column, value in zip(data["columns"], row):
|
|
flat[f"table {table}[{index}].{column}"] = value
|
|
for name, (size, digest, mtime) in read_files(server.root).items():
|
|
flat[f"file {name}"] = [size, digest]
|
|
flat[f"file {name}#mtime"] = mtime
|
|
flat["files#count"] = sum(
|
|
1 for key in flat if key.startswith("file ") and "#" not in key
|
|
)
|
|
for key, value in flatten(response).items():
|
|
flat[f"response{key}"] = value
|
|
# Each server has its own temp root and a random storage epoch; name them
|
|
# instead of masking every column that carries them.
|
|
epoch = flat.get("table execution_history_metadata[0].storage_epoch")
|
|
names = [(str(server.root), "<root>")]
|
|
if isinstance(epoch, str) and epoch:
|
|
names.append((epoch, "<epoch>"))
|
|
for key, value in flat.items():
|
|
if isinstance(value, str):
|
|
for text, name in names:
|
|
value = value.replace(text, name)
|
|
flat[key] = value
|
|
return flat
|
|
|
|
|
|
def run(on_live: bool) -> list[dict]:
|
|
server = Server(on_live)
|
|
try:
|
|
states = []
|
|
for index, (path, data) in enumerate(RESETS * 2):
|
|
dirty_path, dirty_data = DIRTY[index % len(DIRTY)]
|
|
server.post_json("POST", dirty_path, dirty_data)
|
|
started = time.monotonic()
|
|
response = server.post_json("POST", path, data)
|
|
elapsed = time.monotonic() - started
|
|
print(f" {'old' if on_live else 'new'} {path} {data}: {elapsed:.2f}s")
|
|
states.append(snapshot(server, response))
|
|
return states
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def column_key(key: str) -> str:
|
|
"""``table t[3].created_at`` -> ``table t.created_at`` (mask whole columns)."""
|
|
if key.startswith("table ") and "[" in key:
|
|
head, _, tail = key.partition("[")
|
|
return head + tail.partition("]")[2]
|
|
return key
|
|
|
|
|
|
def main() -> int:
|
|
old_a, old_b, new = run(True), run(True), run(False)
|
|
failures = 0
|
|
masked_all = set()
|
|
for step, (path, data) in enumerate(RESETS * 2):
|
|
a, b, n = old_a[step], old_b[step], new[step]
|
|
masked = {
|
|
column_key(key)
|
|
for key in set(a) | set(b)
|
|
if a.get(key, CLOCK) != b.get(key, CLOCK)
|
|
}
|
|
masked_all |= masked
|
|
keys = set(a) | set(n)
|
|
diffs = [
|
|
key
|
|
for key in sorted(keys)
|
|
if column_key(key) not in masked
|
|
and a.get(key, "<missing>") != n.get(key, "<missing>")
|
|
]
|
|
tables = len(
|
|
{k.split("[")[0].split("#")[0] for k in a if k.startswith("table ")}
|
|
)
|
|
status = "EQUAL" if not diffs else f"DIFFERENT ({len(diffs)} values)"
|
|
print(
|
|
f"{status}: {path} {json.dumps(data)}: {tables} tables, "
|
|
f"{a['files#count']} files, {len(keys)} values, "
|
|
f"{len(masked)} masked columns"
|
|
)
|
|
for key in diffs[:20]:
|
|
old, now = a.get(key, "<missing>"), n.get(key, "<missing>")
|
|
print(f" {key}: old {old!r} new {now!r}")
|
|
failures += bool(diffs)
|
|
print("masked (differ between the two old runs):")
|
|
for key in sorted(masked_all):
|
|
print(f" {key}")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|