7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
177 lines
6.6 KiB
Python
177 lines
6.6 KiB
Python
"""Concurrency tests for ``DatabaseManager.open_user_database`` cold-open.
|
|
|
|
Regression test for the race where two simultaneous first-opens of the same
|
|
user's database both ran Alembic migrations against one database file at once,
|
|
with the loser failing (Alembic's non-thread-safe module-level proxy / the
|
|
``alembic_version`` row update). The per-user init lock must serialize the
|
|
cold-open so the engine build + migration runs exactly once.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import local_deep_research.database.initialize as init_mod
|
|
from local_deep_research.database.encrypted_db import DatabaseManager
|
|
|
|
|
|
@pytest.fixture
|
|
def db_manager(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"local_deep_research.database.encrypted_db.get_data_directory",
|
|
lambda: tmp_path,
|
|
)
|
|
manager = DatabaseManager()
|
|
yield manager
|
|
for username in list(manager.connections.keys()):
|
|
manager.close_user_database(username)
|
|
|
|
|
|
def test_get_init_lock_is_per_user_and_stable(db_manager):
|
|
"""Same user -> same lock (serializes); different users -> different locks."""
|
|
alice_1 = db_manager._get_init_lock("alice")
|
|
alice_2 = db_manager._get_init_lock("alice")
|
|
bob = db_manager._get_init_lock("bob")
|
|
assert alice_1 is alice_2
|
|
assert alice_1 is not bob
|
|
|
|
|
|
def test_concurrent_cold_open_runs_init_once(db_manager, monkeypatch):
|
|
"""N simultaneous first-opens of one user trigger exactly one cold-open."""
|
|
username, password = "raceuser", "TestPassword123!"
|
|
db_manager.create_user_database(username, password)
|
|
# Evict (and dispose) the cached engine so the next opens are cold-opens
|
|
# that hit the build + migrate path concurrently. The DB file remains.
|
|
db_manager.close_user_database(username)
|
|
|
|
n_threads = 8
|
|
calls = []
|
|
barrier = threading.Barrier(n_threads)
|
|
|
|
def counting_init(engine, *args, **kwargs):
|
|
# create_user_database already migrated the DB to head, so a no-op is
|
|
# correct here; the sleep widens the window so all threads pile up on
|
|
# the per-user lock while the first thread is inside the cold-open.
|
|
calls.append(threading.current_thread().name)
|
|
# Real wall-clock sleep widens the race window so the other threads
|
|
# pile up on the per-user lock; freezegun cannot model concurrent
|
|
# thread timing, so the sleep is intentional here.
|
|
time.sleep(0.25) # allow: unmarked-sleep
|
|
|
|
monkeypatch.setattr(init_mod, "initialize_database", counting_init)
|
|
|
|
results = []
|
|
errors = []
|
|
|
|
def worker():
|
|
barrier.wait()
|
|
try:
|
|
results.append(db_manager.open_user_database(username, password))
|
|
except Exception as exc: # noqa: BLE001 - record any failure
|
|
errors.append(exc)
|
|
|
|
threads = [
|
|
threading.Thread(target=worker, name=f"open-{i}")
|
|
for i in range(n_threads)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=30)
|
|
|
|
assert not errors, f"cold-open raced and failed: {errors!r}"
|
|
assert len(results) == n_threads
|
|
# The cold-open (hence the migration) ran exactly once despite n_threads
|
|
# simultaneous first-opens.
|
|
assert len(calls) == 1, (
|
|
f"init ran {len(calls)}x; expected 1 (cold-open not serialized)"
|
|
)
|
|
# Every caller received the single engine the cold-open built and cached.
|
|
assert results[0] is not None
|
|
assert all(r is results[0] for r in results)
|
|
|
|
|
|
def test_close_user_database_keeps_init_lock(db_manager):
|
|
"""close_user_database intentionally retains the per-user init lock.
|
|
|
|
Dropping it on close would let a concurrent open that already holds a
|
|
reference to the old lock race a later open that creates a fresh one --
|
|
two cold-opens migrating one DB file at once, the race the lock prevents.
|
|
The lock is kept (bounded, one small Lock per username); only
|
|
close_all_databases clears the dict wholesale, at shutdown.
|
|
"""
|
|
username, password = "lockcleanup", "TestPassword123!"
|
|
db_manager.create_user_database(username, password)
|
|
db_manager._get_init_lock(username) # ensure the lock exists
|
|
assert username in db_manager._init_locks
|
|
|
|
db_manager.close_user_database(username)
|
|
|
|
# Retained on purpose -- see docstring; only close_all clears it.
|
|
assert username in db_manager._init_locks
|
|
|
|
|
|
def test_close_all_databases_clears_init_locks(db_manager):
|
|
"""close_all_databases clears the per-user init-lock dict too."""
|
|
for name in ("user_a", "user_b"):
|
|
db_manager.create_user_database(name, "TestPassword123!")
|
|
db_manager._get_init_lock(name)
|
|
assert db_manager._init_locks
|
|
|
|
db_manager.close_all_databases()
|
|
|
|
assert db_manager._init_locks == {}
|
|
|
|
|
|
def test_concurrent_opens_of_different_users_run_in_parallel(
|
|
db_manager, monkeypatch
|
|
):
|
|
"""Cold-opens of *different* users must not serialize against each other.
|
|
|
|
The lock is deliberately per-user, not a single global init lock, so two
|
|
users opening at once proceed in parallel. This is asserted
|
|
deterministically (no timing): a 2-party barrier inside the patched init
|
|
only releases when *both* users' cold-opens are inside it simultaneously.
|
|
If the opens serialized (e.g. a regression to one global lock), the first
|
|
thread would block at the barrier while holding the lock and the second
|
|
could never reach it -> the barrier times out with BrokenBarrierError,
|
|
surfaced as an error and failing the test.
|
|
"""
|
|
users = [("alice_par", "TestPassword123!"), ("bob_par", "TestPassword123!")]
|
|
for name, pw in users:
|
|
db_manager.create_user_database(name, pw)
|
|
# Evict the cached engine so the next open is a cold-open.
|
|
db_manager.close_user_database(name)
|
|
|
|
both_inside_init = threading.Barrier(len(users))
|
|
|
|
def barrier_init(engine, *args, **kwargs):
|
|
# Both users' cold-opens must be in here at once for this to return;
|
|
# a global lock would deadlock one of them out and trip the timeout.
|
|
both_inside_init.wait(timeout=10)
|
|
|
|
monkeypatch.setattr(init_mod, "initialize_database", barrier_init)
|
|
|
|
results = []
|
|
errors = []
|
|
|
|
def worker(name, pw):
|
|
try:
|
|
results.append(db_manager.open_user_database(name, pw))
|
|
except Exception as exc: # noqa: BLE001 - record any failure
|
|
errors.append(exc)
|
|
|
|
threads = [
|
|
threading.Thread(target=worker, args=(n, p), name=f"open-{n}")
|
|
for n, p in users
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=30)
|
|
|
|
assert not errors, f"cross-user opens serialized / failed: {errors!r}"
|
|
assert len(results) == len(users)
|
|
assert all(r is not None for r in results)
|