mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
fix(cache): make shared cache state survive a second instance
Installing a second LoRA Manager instance (standalone or a second ComfyUI install) that shares the settings directory puts two processes on the same cache databases. Three things made that unsafe. - The updater preserved cache/ and model_cache/ but not a legacy recipe_cache/ directory, so a portable install predating the cache/ move lost its recipe database on a git-based update. Add it to _PRESERVE_DIRS and to .gitignore. - Cache connections used the sqlite3 default 5s timeout, which a scanning instance can exceed, turning a concurrent write into "database is locked". Route every shared cache connection through connect_cache_db(), which raises the timeout to 30s and sets busy_timeout + synchronous=NORMAL to match the existing WAL mode. App-private databases (download queue, update history) are unchanged. - A full-table cache replace is a read-modify-write that SQLite cannot make atomic across processes, so two instances could interleave and one snapshot could overwrite the other. Guard the recipe and model save_cache paths with a cross-process advisory lock (flock on POSIX, msvcrt on Windows). Locking is best-effort: if it is unavailable the call proceeds and the SQLite busy timeout is the fallback. The lock file is a hidden sibling of the database and is deliberately never unlinked, so a second process cannot lock a fresh inode.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""Tests for the shared cache SQLite connection settings (:mod:`py.utils.cache_db`).
|
||||
|
||||
Two LoRA Manager processes can share one settings directory, so cache
|
||||
connections must tolerate a competing writer instead of failing immediately
|
||||
with "database is locked".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
from py.utils.cache_db import CONCURRENT_TIMEOUT_SECONDS, connect_cache_db
|
||||
|
||||
|
||||
def test_busy_timeout_pragma_is_applied(tmp_path):
|
||||
"""The connection must retry inside SQLite, not just at connect() time."""
|
||||
conn = connect_cache_db(str(tmp_path / "cache.sqlite"))
|
||||
try:
|
||||
value = conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
assert value == int(CONCURRENT_TIMEOUT_SECONDS * 1000)
|
||||
|
||||
|
||||
def test_waiting_writer_succeeds_after_competing_writer_commits(tmp_path):
|
||||
"""A blocked writer waits for the lock instead of raising."""
|
||||
db_path = str(tmp_path / "cache.sqlite")
|
||||
|
||||
holder = connect_cache_db(db_path)
|
||||
holder.execute("CREATE TABLE t (v INTEGER)")
|
||||
holder.commit()
|
||||
holder.execute("BEGIN IMMEDIATE")
|
||||
|
||||
def release_after_delay() -> None:
|
||||
time.sleep(0.5)
|
||||
holder.commit()
|
||||
|
||||
releaser = threading.Thread(target=release_after_delay)
|
||||
releaser.start()
|
||||
try:
|
||||
waiter = connect_cache_db(db_path)
|
||||
try:
|
||||
# Under the old 5s default this still worked, but an immediate
|
||||
# failure is what low-timeout connections produced; assert the
|
||||
# write lands rather than propagating "database is locked".
|
||||
waiter.execute("INSERT INTO t VALUES (1)")
|
||||
waiter.commit()
|
||||
finally:
|
||||
waiter.close()
|
||||
finally:
|
||||
releaser.join()
|
||||
holder.close()
|
||||
|
||||
check = connect_cache_db(db_path)
|
||||
try:
|
||||
assert check.execute("SELECT COUNT(*) FROM t").fetchone()[0] == 1
|
||||
finally:
|
||||
check.close()
|
||||
|
||||
|
||||
def test_readwrite_connection_uses_row_factory(tmp_path):
|
||||
conn = connect_cache_db(str(tmp_path / "cache.sqlite"), row_factory=sqlite3.Row)
|
||||
try:
|
||||
conn.execute("CREATE TABLE t (v INTEGER)")
|
||||
conn.execute("INSERT INTO t VALUES (7)")
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT v FROM t").fetchone()
|
||||
assert row["v"] == 7
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_readonly_connection_reads_without_writing(tmp_path):
|
||||
db_path = str(tmp_path / "cache.sqlite")
|
||||
writer = connect_cache_db(db_path)
|
||||
writer.execute("CREATE TABLE t (v INTEGER)")
|
||||
writer.execute("INSERT INTO t VALUES (1)")
|
||||
writer.commit()
|
||||
writer.close()
|
||||
|
||||
conn = connect_cache_db(db_path, readonly=True)
|
||||
try:
|
||||
assert conn.execute("SELECT v FROM t").fetchone()[0] == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_readonly_connection_rejects_writes(tmp_path):
|
||||
db_path = str(tmp_path / "cache.sqlite")
|
||||
writer = connect_cache_db(db_path)
|
||||
writer.execute("CREATE TABLE t (v INTEGER)")
|
||||
writer.commit()
|
||||
writer.close()
|
||||
|
||||
conn = connect_cache_db(db_path, readonly=True)
|
||||
try:
|
||||
try:
|
||||
conn.execute("INSERT INTO t VALUES (1)")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
else: # pragma: no cover - would mean mode=ro was not applied
|
||||
raise AssertionError("read-only connection accepted a write")
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user