chore: import upstream snapshot with attribution
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user