Files
wehub-resource-sync 7a0da7932b
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

481 lines
18 KiB
Python

"""Unit tests for ChatService concurrency safety.
These tests verify thread-safety for concurrent operations.
Uses concurrent.futures.ThreadPoolExecutor to simulate concurrent access.
"""
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from contextlib import contextmanager
import threading
from tests.chat.conftest import setup_query_mock_with_session
class TestChatConcurrency:
"""Tests for concurrent operations on ChatService."""
def test_concurrent_session_creation_unique_ids(self):
"""Test that concurrent session creation generates unique IDs."""
from src.local_deep_research.chat.service import ChatService
created_ids = []
lock = threading.Lock()
@contextmanager
def mock_get_user_db_session(username, password=None):
mock_session = MagicMock()
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
yield mock_session
def create_session_task():
service = ChatService(username="testuser")
session_id = service.create_session(
initial_query=f"Query {threading.current_thread().name}"
)
with lock:
created_ids.append(session_id)
return session_id
# Apply patch at test level (outside threads) to avoid race conditions
# during patch application/removal across threads
with patch(
"src.local_deep_research.chat.service.get_user_db_session",
mock_get_user_db_session,
):
# Create sessions concurrently
num_threads = 10
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [
executor.submit(create_session_task)
for _ in range(num_threads)
]
for future in as_completed(futures):
future.result() # Ensure all complete
# All IDs should be unique
assert len(created_ids) == num_threads
assert len(set(created_ids)) == num_threads # All unique
# All should be valid UUIDs
for session_id in created_ids:
uuid.UUID(session_id) # Raises ValueError if invalid
def test_multiple_sessions_same_user_isolated(self):
"""Test that multiple sessions for the same user are independent."""
from src.local_deep_research.chat.service import ChatService
sessions_created = []
lock = threading.Lock()
# Track message counts for each session
session_message_counts = {}
@contextmanager
def mock_get_user_db_session(username, password=None):
mock_session = MagicMock()
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
# Mock session lookup to return appropriate mock
def mock_filter_by(id=None, **kwargs):
result = MagicMock()
if id in session_message_counts:
mock_chat_session = MagicMock()
mock_chat_session.id = id
mock_chat_session.message_count = session_message_counts[id]
result.first.return_value = mock_chat_session
else:
result.first.return_value = None
return result
mock_session.query.return_value.filter_by = mock_filter_by
yield mock_session
def create_and_use_session():
service = ChatService(username="testuser")
session_id = service.create_session()
with lock:
sessions_created.append(session_id)
session_message_counts[session_id] = 0
return session_id
# Patch at module level before spawning threads
with patch(
"src.local_deep_research.chat.service.get_user_db_session",
mock_get_user_db_session,
):
# Create multiple sessions concurrently
num_sessions = 5
with ThreadPoolExecutor(max_workers=num_sessions) as executor:
futures = [
executor.submit(create_and_use_session)
for _ in range(num_sessions)
]
for f in as_completed(futures):
f.result()
# Each session should be created independently
assert len(sessions_created) == num_sessions
assert len(set(sessions_created)) == num_sessions
def test_concurrent_reads_during_write_consistent(self):
"""Test that reads during writes return consistent data."""
from src.local_deep_research.chat.service import ChatService
# Shared state for the mock
mock_sessions = {}
write_lock = threading.Lock()
read_results = []
results_lock = threading.Lock()
@contextmanager
def mock_get_user_db_session(username, password=None):
mock_session = MagicMock()
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
def mock_filter_by(id=None, status="active", **kwargs):
result = MagicMock()
with write_lock:
if id and id in mock_sessions:
session_obj = MagicMock()
session_obj.id = id
session_obj.title = mock_sessions[id]["title"]
session_obj.status = "active"
session_obj.message_count = 0
session_obj.created_at = datetime(
2024, 1, 1, tzinfo=UTC
)
result.first.return_value = session_obj
else:
result.first.return_value = None
# For list queries
sessions_list = [
MagicMock(
id=sid,
title=sdata["title"],
status="active",
message_count=0,
created_at=datetime(2024, 1, 1, tzinfo=UTC),
)
for sid, sdata in mock_sessions.items()
]
result.order_by.return_value.offset.return_value.limit.return_value.all.return_value = sessions_list
return result
mock_session.query.return_value.filter_by = mock_filter_by
yield mock_session
def write_task(session_id, title):
"""Write task that creates/updates a session."""
with write_lock:
mock_sessions[session_id] = {"title": title}
def read_task():
"""Read task that lists sessions."""
service = ChatService(username="testuser")
sessions = service.list_sessions()
with results_lock:
read_results.append(len(sessions))
return sessions
# First create some sessions
for i in range(5):
write_task(f"session-{i}", f"Title {i}")
# Apply patch at test level (outside threads) to avoid race conditions
with patch(
"src.local_deep_research.chat.service.get_user_db_session",
mock_get_user_db_session,
):
# Now do concurrent reads
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(read_task) for _ in range(10)]
for future in as_completed(futures):
future.result()
# All reads should have gotten consistent results
assert len(read_results) == 10
# All should have found the same number of sessions (5)
assert all(count == 5 for count in read_results)
def test_rapid_message_sends_no_data_loss(self):
"""Test that rapidly sending messages doesn't lose any messages."""
from src.local_deep_research.chat.service import ChatService
messages_added = []
lock = threading.Lock()
mock_session_obj = MagicMock()
mock_session_obj.id = "test-session"
mock_session_obj.message_count = 0
@contextmanager
def mock_get_user_db_session(username, password=None):
mock_session = MagicMock()
def mock_add(message):
with lock:
messages_added.append(message.content)
mock_session.add = mock_add
mock_session.commit = MagicMock()
# Set up query mock to handle with_for_update() chain
setup_query_mock_with_session(mock_session, mock_session_obj)
yield mock_session
def send_message_task(msg_content):
service = ChatService(username="testuser")
message_id = service.add_message(
session_id="test-session",
role="user",
content=msg_content,
message_type="query",
)
return message_id
# Patch at module level before spawning threads
with patch(
"src.local_deep_research.chat.service.get_user_db_session",
mock_get_user_db_session,
):
# Send many messages rapidly
num_messages = 20
message_contents = [f"Message {i}" for i in range(num_messages)]
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [
executor.submit(send_message_task, content)
for content in message_contents
]
results = [f.result() for f in as_completed(futures)]
# All messages should have been sent
assert len(messages_added) == num_messages
# All message IDs should be returned
assert len(results) == num_messages
# All message contents should be present (order may vary)
assert set(messages_added) == set(message_contents)
def test_concurrent_context_updates_preserve_data(self):
"""Test that concurrent context updates don't lose entities."""
from src.local_deep_research.chat.service import ChatService
# The implementation uses with_for_update() for row-level locking
# to ensure atomic read-modify-write operations
mock_session_obj = MagicMock()
mock_session_obj.id = "test-session"
mock_session_obj.accumulated_context = {
"key_entities": [],
"topics": [],
"summary": "",
}
lock = threading.Lock()
update_count = 0
@contextmanager
def mock_get_user_db_session(username, password=None):
nonlocal update_count
# with_for_update() is a no-op on SQLite (see the service comment),
# so the read-modify-write of accumulated_context is not serialized
# by the DB. In production, concurrent updates to a SINGLE session's
# context do not occur because only one research runs per chat
# session at a time (the per-session in-flight guard). Hold `lock`
# for the whole session context here to mirror that real-world
# serialization, so the test deterministically verifies the MERGE
# accumulates every entity rather than clobbering. (Full
# multi-writer fidelity would require a real DB.)
with lock:
mock_session = MagicMock()
# Set up query mock to handle with_for_update() chain
setup_query_mock_with_session(mock_session, mock_session_obj)
def mock_commit():
nonlocal update_count
update_count += 1
mock_session.commit = mock_commit
yield mock_session
def update_context_task(entity_name):
service = ChatService(username="testuser")
service.update_accumulated_context(
"test-session",
new_entities=[entity_name],
)
return entity_name
# Update context concurrently
num_updates = 5
entity_names = [f"entity_{i}" for i in range(num_updates)]
# Apply patch at test level (outside threads) to avoid race conditions
with patch(
"src.local_deep_research.chat.service.get_user_db_session",
mock_get_user_db_session,
):
with ThreadPoolExecutor(max_workers=num_updates) as executor:
futures = [
executor.submit(update_context_task, name)
for name in entity_names
]
for future in as_completed(futures):
future.result()
# All updates should have been committed
assert update_count == num_updates
# And — the actual point of the test — the merge must have ACCUMULATED
# every entity rather than clobbering prior ones: a regression that
# overwrote key_entities on each update would still pass a commit count.
preserved = mock_session_obj.accumulated_context["key_entities"]
assert set(preserved) == set(entity_names)
def test_thread_local_services_independent(self):
"""Test that ChatService instances in different threads are independent."""
from src.local_deep_research.chat.service import ChatService
service_ids = []
lock = threading.Lock()
def create_service_task():
service = ChatService(
username=f"user_{threading.current_thread().name}"
)
with lock:
service_ids.append(id(service))
return service
# Create services in different threads
num_threads = 5
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [
executor.submit(create_service_task) for _ in range(num_threads)
]
services = [f.result() for f in as_completed(futures)]
# Each thread should have its own service instance
assert len(services) == num_threads
assert len(set(service_ids)) == num_threads # All unique object IDs
class TestAddMessageSequenceRace:
"""Real-SQLite (not mocked) coverage for the add_message sequence race.
test_rapid_message_sends_no_data_loss mocks the DB entirely and
therefore cannot catch IntegrityError collisions on
uq_chat_message_session_seq. These tests exercise the actual retry path
against a file-backed SQLite DB.
"""
def _setup_real_db(self, tmp_path):
"""Create a file-backed SQLite DB with chat tables + a seeded session.
:memory: isn't usable here because it isn't shared across threads.
"""
import uuid
from datetime import datetime, UTC
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.local_deep_research.database.models import (
Base,
ChatSession,
)
db_path = tmp_path / "chat_race.db"
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session_id = str(uuid.uuid4())
with Session() as db:
db.add(
ChatSession(
id=session_id,
title="race test",
status="active",
accumulated_context={
"key_entities": [],
"topics": [],
"summary": "",
},
created_at=datetime.now(UTC),
message_count=0,
)
)
db.commit()
return engine, Session, session_id
def test_concurrent_add_message_unique_sequence_numbers(self, tmp_path):
"""10 concurrent add_message calls produce 10 unique sequence_numbers.
This exercises the add_message sequence-number race: without
retry-on-IntegrityError, two callers would both compute the same
`session.message_count + 1`
and one would fail with a 500 when the uq_chat_message_session_seq
constraint fires.
"""
from contextlib import contextmanager
from src.local_deep_research.chat.service import ChatService
from src.local_deep_research.database.models import ChatMessage
engine, Session, session_id = self._setup_real_db(tmp_path)
@contextmanager
def real_get_user_db_session(username, password=None):
with Session() as db:
yield db
service = ChatService(username="testuser")
num_messages = 10
results = []
errors = []
errors_lock = threading.Lock()
def add_one(i):
try:
return service.add_message(
session_id=session_id,
role="user",
content=f"message {i}",
message_type="query",
)
except Exception as e: # noqa: BLE001
with errors_lock:
errors.append(e)
return None
with patch(
"src.local_deep_research.chat.service.get_user_db_session",
real_get_user_db_session,
):
with ThreadPoolExecutor(max_workers=num_messages) as executor:
futures = [
executor.submit(add_one, i) for i in range(num_messages)
]
for f in as_completed(futures):
results.append(f.result())
assert errors == [], f"add_message surfaced errors: {errors}"
assert len([r for r in results if r]) == num_messages
with Session() as db:
sequences = sorted(
r[0]
for r in db.query(ChatMessage.sequence_number)
.filter_by(session_id=session_id)
.all()
)
# 10 unique sequence numbers, 1..10, no collisions
assert sequences == list(range(1, num_messages + 1))
engine.dispose()