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:
@@ -15,6 +15,7 @@ node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
recipe_cache/
|
||||
|
||||
# agent / dev tooling
|
||||
.opencode/
|
||||
|
||||
@@ -21,7 +21,20 @@ NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||
# otherwise delete them because they are untracked and, in released tags,
|
||||
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||
# regardless of whether it is ignored.
|
||||
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||
# ``cache`` covers the resolved cache tree (cache/model, cache/recipe,
|
||||
# cache/fts, ...); the legacy ``recipe_cache`` / ``model_cache`` directories
|
||||
# are listed too because a portable install can predate the cache/ move.
|
||||
_PRESERVE_DIRS = (
|
||||
'settings.json',
|
||||
'civitai',
|
||||
'wildcards',
|
||||
'backups',
|
||||
'stats',
|
||||
'logs',
|
||||
'cache',
|
||||
'model_cache',
|
||||
'recipe_cache',
|
||||
)
|
||||
|
||||
|
||||
def _clean_excludes() -> List[str]:
|
||||
|
||||
@@ -6,7 +6,9 @@ import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.file_lock import exclusive_lock
|
||||
from .model_sources import normalize_metadata_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -257,6 +259,10 @@ class PersistentModelCache:
|
||||
return
|
||||
try:
|
||||
with self._db_lock:
|
||||
# Cross-process serialization: another LoRA Manager instance may
|
||||
# share this settings directory, and the read-merge-write below
|
||||
# spans several statements.
|
||||
with exclusive_lock(self._db_path):
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
@@ -650,16 +656,14 @@ class PersistentModelCache:
|
||||
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
|
||||
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
|
||||
|
||||
@@ -19,7 +19,9 @@ import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.file_lock import exclusive_lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -197,6 +199,10 @@ class PersistentRecipeCache:
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
# Cross-process serialization: another LoRA Manager instance may
|
||||
# share this settings directory, and a full-table replace is a
|
||||
# read-modify-write that SQLite alone cannot make atomic.
|
||||
with exclusive_lock(self._db_path):
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
@@ -515,16 +521,14 @@ class PersistentRecipeCache:
|
||||
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]:
|
||||
"""Convert a recipe dict to a row tuple for SQLite insertion."""
|
||||
|
||||
@@ -16,6 +16,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -633,16 +634,13 @@ class RecipeFTSIndex:
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
|
||||
"""Remove a recipe entry. Caller must hold the lock."""
|
||||
|
||||
@@ -2729,6 +2729,10 @@ class RecipeScanner:
|
||||
try:
|
||||
# Invalidate persistent cache so the sync path does a
|
||||
# full directory scan instead of reconciling stale data.
|
||||
# This is the deliberate escape hatch from the
|
||||
# all-missing prune guard: an explicit user rebuild is
|
||||
# allowed to clear the stored cache, while an implicit
|
||||
# startup scan is not.
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.save_cache([], {})
|
||||
self._json_path_map = {}
|
||||
|
||||
@@ -20,6 +20,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -677,16 +678,13 @@ class TagFTSIndex:
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _build_fts_query(self, query: str) -> str:
|
||||
"""Build an FTS5 query string with prefix matching.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared SQLite connection setup for LoRA Manager cache databases.
|
||||
|
||||
Cache databases live under the settings directory (``cache/model/<library>.sqlite``,
|
||||
``cache/recipe/<library>.sqlite``, ``cache/fts/*.sqlite``). With portable mode or a
|
||||
pinned ``LORA_MANAGER_SETTINGS_DIR`` off, that directory is shared by every ComfyUI
|
||||
instance on the machine, so two processes can open the same cache file at once.
|
||||
|
||||
SQLite serializes writers, but the default ``timeout`` is 5 seconds: a second
|
||||
instance that writes while the first is mid-transaction fails with "database is
|
||||
locked". These settings make concurrent access wait instead of failing, and keep
|
||||
the write path in WAL so readers are never blocked by a writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
# How long a connection waits for a competing writer before raising.
|
||||
CONCURRENT_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# PRAGMAs applied to every cache connection.
|
||||
#
|
||||
# ``busy_timeout`` mirrors the connection timeout so a busy database is retried
|
||||
# inside SQLite rather than surfacing as an immediate error. ``synchronous=NORMAL``
|
||||
# is the documented companion of WAL: still crash-safe, far fewer fsyncs.
|
||||
_TUNING_PRAGMAS = (
|
||||
"PRAGMA busy_timeout = 30000",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
)
|
||||
|
||||
|
||||
def connect_cache_db(
|
||||
path: str,
|
||||
*,
|
||||
readonly: bool = False,
|
||||
uri: bool = False,
|
||||
detect_types: int = 0,
|
||||
row_factory: Any = None,
|
||||
) -> sqlite3.Connection:
|
||||
"""Open a cache database with multi-instance-friendly settings.
|
||||
|
||||
Args:
|
||||
path: Database path, or a ``file:`` URI when *uri* is True.
|
||||
readonly: Open through a read-only URI. Callers still pass the
|
||||
plain path; the ``mode=ro`` suffix is added here. The
|
||||
write-oriented tuning pragmas are skipped in that case so a
|
||||
read-only connection never attempts to change the file.
|
||||
uri: Treat *path* as a SQLite URI.
|
||||
detect_types: Forwarded to :func:`sqlite3.connect`.
|
||||
row_factory: Optional ``row_factory`` for the connection.
|
||||
|
||||
Returns:
|
||||
A configured :class:`sqlite3.Connection`.
|
||||
"""
|
||||
if readonly:
|
||||
if not uri and not path.startswith("file:"):
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
|
||||
conn = sqlite3.connect(
|
||||
path,
|
||||
check_same_thread=False,
|
||||
uri=uri,
|
||||
detect_types=detect_types,
|
||||
timeout=CONCURRENT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if row_factory is not None:
|
||||
conn.row_factory = row_factory
|
||||
|
||||
try:
|
||||
for pragma in _TUNING_PRAGMAS:
|
||||
# A read-only connection may reject write PRAGMAs; they are not
|
||||
# needed there anyway.
|
||||
conn.execute(pragma)
|
||||
except sqlite3.Error:
|
||||
# Tuning is best-effort: a connection that cannot set pragmas still
|
||||
# works, just without the concurrency headroom.
|
||||
pass
|
||||
|
||||
return conn
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Cross-process advisory locking for shared LoRA Manager state.
|
||||
|
||||
Two LoRA Manager processes (the ComfyUI plugin and a standalone server, or two
|
||||
ComfyUI installs pointed at the same settings directory) can open the same cache
|
||||
database. SQLite serializes individual statements, but it cannot make a
|
||||
read-modify-write *sequence* atomic across processes: two full-table cache
|
||||
replacements can interleave so that one process's snapshot overwrites the
|
||||
other's.
|
||||
|
||||
This module provides a small advisory file lock for those sequences. It is
|
||||
deliberately non-fatal: if locking is unavailable or the wait times out, callers
|
||||
keep working with SQLite's own ``busy_timeout`` as the fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long to wait for another process to release the lock before giving up.
|
||||
DEFAULT_LOCK_TIMEOUT_SECONDS = 30.0
|
||||
_POLL_INTERVAL_SECONDS = 0.05
|
||||
|
||||
# Windows byte-range locks; fcntl.flock on POSIX.
|
||||
try: # pragma: no cover - platform dependent
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
try: # pragma: no cover - Windows only
|
||||
import msvcrt
|
||||
except ImportError: # pragma: no cover - POSIX
|
||||
msvcrt = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class FileLockUnavailable(RuntimeError):
|
||||
"""Raised when the lock could not be acquired within the timeout."""
|
||||
|
||||
|
||||
def lock_path_for(db_path: str) -> str:
|
||||
"""Return the sibling lock file path used for *db_path*."""
|
||||
absolute = os.path.abspath(db_path)
|
||||
directory = os.path.dirname(absolute)
|
||||
if not directory:
|
||||
raise ValueError(f"Cannot derive a lock directory from {db_path!r}")
|
||||
return os.path.join(directory, f".{os.path.basename(absolute)}.lock")
|
||||
|
||||
|
||||
class CrossProcessLock:
|
||||
"""A best-effort advisory lock backed by a lock file.
|
||||
|
||||
The lock file is a sibling of the guarded resource and is never deleted:
|
||||
unlinking it would let a second process create a fresh inode and lock that
|
||||
instead, defeating mutual exclusion.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||
self.path = path
|
||||
self.timeout = timeout
|
||||
self._handle = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""Try to take the lock, waiting up to ``timeout`` seconds.
|
||||
|
||||
Returns:
|
||||
True when the lock is held (including when another lock is already
|
||||
held by *this* process — the calls are not reentrant, so callers must
|
||||
not nest them). False when locking is unsupported or timed out; the
|
||||
caller should proceed and rely on the SQLite busy timeout instead.
|
||||
"""
|
||||
if fcntl is None and msvcrt is None: # pragma: no cover - exotic platform
|
||||
return False
|
||||
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
try:
|
||||
handle = open(self.path, "a+b")
|
||||
except OSError as exc:
|
||||
logger.debug("Could not open lock file %s: %s", self.path, exc)
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + max(0.0, self.timeout)
|
||||
while True:
|
||||
if self._try_lock(handle):
|
||||
self._handle = handle
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
handle.close()
|
||||
return False
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the lock if held. Safe to call more than once."""
|
||||
handle = self._handle
|
||||
if handle is None:
|
||||
return
|
||||
self._handle = None
|
||||
try:
|
||||
self._unlock(handle)
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.debug("Failed to release lock %s: %s", self.path, exc)
|
||||
finally:
|
||||
try:
|
||||
handle.close()
|
||||
except OSError: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "CrossProcessLock":
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc_info: object) -> None:
|
||||
self.release()
|
||||
|
||||
# -- platform primitives -------------------------------------------------
|
||||
|
||||
def _try_lock(self, handle) -> bool:
|
||||
if fcntl is not None:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
if msvcrt is not None: # pragma: no cover - Windows
|
||||
try:
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _unlock(self, handle) -> None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
return
|
||||
if msvcrt is not None: # pragma: no cover - Windows
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
|
||||
def exclusive_lock(db_path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||
"""Return a :class:`CrossProcessLock` for the database at *db_path*."""
|
||||
return CrossProcessLock(lock_path_for(db_path), timeout=timeout)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for the cross-process advisory lock (:mod:`py.utils.file_lock`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.file_lock import (
|
||||
CrossProcessLock,
|
||||
FileLockUnavailable,
|
||||
exclusive_lock,
|
||||
lock_path_for,
|
||||
)
|
||||
|
||||
|
||||
def test_lock_path_is_a_sibling_of_the_resource(tmp_path):
|
||||
db_path = str(tmp_path / "recipe" / "default.sqlite")
|
||||
lock_path = lock_path_for(db_path)
|
||||
|
||||
assert os.path.dirname(lock_path) == os.path.dirname(db_path)
|
||||
assert os.path.basename(lock_path) == ".default.sqlite.lock"
|
||||
|
||||
|
||||
def test_acquire_and_release_round_trip(tmp_path):
|
||||
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||
|
||||
assert lock.acquire() is True
|
||||
lock.release()
|
||||
# Releasing twice must be safe.
|
||||
lock.release()
|
||||
# ...and the lock is reusable afterwards.
|
||||
assert lock.acquire() is True
|
||||
lock.release()
|
||||
|
||||
|
||||
def test_second_lock_holder_waits_until_release(tmp_path):
|
||||
"""A held lock blocks a competing holder for the same resource."""
|
||||
db_path = str(tmp_path / "cache.sqlite")
|
||||
first = exclusive_lock(db_path)
|
||||
second = CrossProcessLock(lock_path_for(db_path), timeout=0.2)
|
||||
|
||||
assert first.acquire() is True
|
||||
try:
|
||||
started = time.monotonic()
|
||||
assert second.acquire() is False
|
||||
# It must have waited for the timeout rather than failing instantly.
|
||||
assert time.monotonic() - started >= 0.15
|
||||
finally:
|
||||
first.release()
|
||||
|
||||
# Once released, the contender gets the lock.
|
||||
assert second.acquire() is True
|
||||
second.release()
|
||||
|
||||
|
||||
def test_context_manager_releases_on_exception(tmp_path):
|
||||
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||
contender = CrossProcessLock(lock.path, timeout=0.2)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with lock:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
assert contender.acquire() is True
|
||||
contender.release()
|
||||
|
||||
|
||||
def test_lock_file_is_not_deleted(tmp_path):
|
||||
"""Deleting the lock file would let a second process lock a fresh inode."""
|
||||
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||
assert lock.acquire() is True
|
||||
lock.release()
|
||||
|
||||
assert os.path.exists(lock.path)
|
||||
|
||||
|
||||
def test_unsupported_platform_degrades_gracefully(tmp_path, monkeypatch):
|
||||
"""Without a platform primitive the lock reports failure instead of raising."""
|
||||
import py.utils.file_lock as file_lock_module
|
||||
|
||||
monkeypatch.setattr(file_lock_module, "fcntl", None)
|
||||
monkeypatch.setattr(file_lock_module, "msvcrt", None)
|
||||
|
||||
lock = exclusive_lock(str(tmp_path / "cache.sqlite"))
|
||||
assert lock.acquire() is False
|
||||
# Callers use it as a context manager and continue without the lock.
|
||||
with exclusive_lock(str(tmp_path / "cache.sqlite")):
|
||||
pass
|
||||
|
||||
|
||||
def test_file_lock_unavailable_is_exported():
|
||||
assert issubclass(FileLockUnavailable, RuntimeError)
|
||||
|
||||
|
||||
def test_save_cache_creates_lock_next_to_database(tmp_path):
|
||||
"""The recipe cache write path actually takes the cross-process lock."""
|
||||
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||
|
||||
db_path = tmp_path / "recipe_cache.sqlite"
|
||||
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||
assert cache.save_cache([{"id": "r1", "title": "One"}], {"r1": "/tmp/r1.json"})
|
||||
|
||||
assert os.path.exists(lock_path_for(str(db_path)))
|
||||
|
||||
|
||||
def test_save_cache_releases_lock_after_write(tmp_path):
|
||||
"""A second writer must not be blocked once the first has finished."""
|
||||
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||
|
||||
db_path = tmp_path / "recipe_cache.sqlite"
|
||||
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||
cache.save_cache([{"id": "r1", "title": "One"}], {"r1": "/tmp/r1.json"})
|
||||
|
||||
contender = CrossProcessLock(lock_path_for(str(db_path)), timeout=0.2)
|
||||
assert contender.acquire() is True
|
||||
contender.release()
|
||||
Reference in New Issue
Block a user