Files
ComfyUI/tests-unit/app_test/test_db_write_txn.py
Simon Pinfold 9d3bfc393b Take the SQLite write lock up front for asset scan and output-registration writes (#16480)
* Take the SQLite write lock up front for scan and output-registration writes

The scanner's seeding and reference sync, and executed-output registration,
write through a separate engine whose transactions open with BEGIN
IMMEDIATE, and the database runs in WAL mode. On those paths stat, hashing
and metadata extraction now happen before the write transaction opens, and
reference-sync results are applied only to rows unchanged since they were
observed. busy_timeout stays at pysqlite's 5s default.

A fast-scan batch now commits as one transaction, so an unexpected error
partway through discards the whole batch; the next scan recreates it.
Enrichment, verification, uploads and tagging still write through the
existing sessions.

Migration backups use SQLite's backup API, and a legacy database is
checkpointed before it is relocated, since in WAL mode committed pages can
live in the -wal file that a plain file copy misses.

* Skip relocating a legacy database whose WAL cannot be checkpointed

The checkpoint can report busy without raising; moving the file then would
leave committed pages behind in the -wal. Also keep the source's file mode
on SQLite backups, as the plain copy did.

* Replace run_write_txn with a create_write_session factory

Write paths open the write engine's session the same way the rest of the
code opens create_session(), and commit explicitly. Executed-output
registration reads the new record's fields before committing, so expiry
does not reload them in a second write transaction.

* Document the scanner's pre-transaction observation types

* Document that write sessions must not nest

* Warn that a nested write session looks like lock contention

* Seed a hashed spec with the stat its hash was verified against

* Bound the SQLite backup so a locked destination cannot hang startup

* Time out a backup only while it is blocked
2026-09-23 15:17:11 -07:00

82 lines
2.8 KiB
Python

import sqlite3
import pytest
from sqlalchemy import text
from app.database import db as db_module
@pytest.fixture
def file_db(tmp_path, monkeypatch):
db_path = str(tmp_path / "comfyui.db")
monkeypatch.setattr(db_module.args, "database_url", f"sqlite:///{db_path}")
monkeypatch.setattr(db_module, "Session", None)
monkeypatch.setattr(db_module, "WriteSession", None)
monkeypatch.setattr(db_module, "_db_lock", None)
db_module._init_file_db(db_module.args.database_url)
yield db_path
db_module.Session.kw["bind"].dispose()
db_module.WriteSession.kw["bind"].dispose()
db_module._db_lock.release(force=True)
def _other_writer_can_begin(db_path):
other = sqlite3.connect(db_path, timeout=0, isolation_level=None)
try:
other.execute("BEGIN IMMEDIATE")
other.execute("ROLLBACK")
return True
except sqlite3.OperationalError:
return False
finally:
other.close()
def test_file_db_uses_wal(file_db):
with db_module.create_session() as session:
assert session.execute(text("PRAGMA journal_mode")).scalar_one() == "wal"
def test_write_session_takes_the_write_lock_before_its_first_write(file_db):
with db_module.create_write_session() as session:
session.execute(text("SELECT 1")).scalar_one()
assert _other_writer_can_begin(file_db) is False
def test_read_session_does_not_take_the_write_lock(file_db):
with db_module.create_session() as session:
session.execute(text("SELECT 1")).scalar_one()
assert _other_writer_can_begin(file_db) is True
def test_backup_gives_up_when_the_destination_stays_locked(tmp_path, monkeypatch):
source = tmp_path / "source.db"
destination = tmp_path / "destination.db"
with sqlite3.connect(source) as conn:
conn.execute("CREATE TABLE t (x)")
with sqlite3.connect(destination) as conn:
conn.execute("CREATE TABLE u (y)")
holder = sqlite3.connect(destination, isolation_level=None, timeout=0)
holder.execute("BEGIN IMMEDIATE")
monkeypatch.setattr(db_module, "_BACKUP_TIMEOUT_SECONDS", 0.0)
try:
with pytest.raises(TimeoutError):
db_module._backup_database(str(source), str(destination))
finally:
holder.execute("ROLLBACK")
holder.close()
def test_backup_past_its_deadline_still_completes_when_nothing_blocks_it(tmp_path, monkeypatch):
source = tmp_path / "source.db"
destination = tmp_path / "destination.db"
with sqlite3.connect(source) as conn:
conn.execute("CREATE TABLE t (x)")
conn.execute("INSERT INTO t VALUES (1)")
monkeypatch.setattr(db_module, "_BACKUP_TIMEOUT_SECONDS", -1.0)
db_module._backup_database(str(source), str(destination))
with sqlite3.connect(destination) as conn:
assert conn.execute("SELECT x FROM t").fetchall() == [(1,)]