Files
learningcircuit--local-deep…/tests/database/test_concurrent_user_db_open.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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)