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
1095 lines
41 KiB
Python
1095 lines
41 KiB
Python
"""
|
|
Tests for QueueProcessorV2 error recovery, cleanup, concurrency, and
|
|
process_user_request — replacing fake tests that never import the real class.
|
|
|
|
Source: src/local_deep_research/web/queue/processor_v2.py
|
|
"""
|
|
|
|
import threading
|
|
from contextlib import contextmanager
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.web.queue.processor_v2 import QueueProcessorV2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def processor():
|
|
"""Create a fresh QueueProcessorV2 instance (not started)."""
|
|
return QueueProcessorV2(check_interval=1)
|
|
|
|
|
|
@contextmanager
|
|
def _mock_db_session():
|
|
"""Yield a chainable mock db session."""
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.first.return_value = None
|
|
mock_query.all.return_value = []
|
|
mock_query.count.return_value = 0
|
|
mock_session.query.return_value = mock_query
|
|
yield mock_session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _commit_with_safe_rollback / _delete_queue_row_safely helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCommitWithSafeRollback:
|
|
"""The commit-with-safe-rollback helper is the base primitive used
|
|
throughout _start_queued_researches; verify each branch in isolation."""
|
|
|
|
def test_commit_success_returns_true_no_rollback(self, processor):
|
|
mock_session = Mock()
|
|
result = processor._commit_with_safe_rollback(mock_session, "ctx")
|
|
assert result is True
|
|
mock_session.commit.assert_called_once()
|
|
mock_session.rollback.assert_not_called()
|
|
|
|
def test_commit_failure_returns_false_and_rolls_back(self, processor):
|
|
mock_session = Mock()
|
|
mock_session.commit.side_effect = RuntimeError("commit boom")
|
|
result = processor._commit_with_safe_rollback(mock_session, "ctx")
|
|
assert result is False
|
|
mock_session.commit.assert_called_once()
|
|
mock_session.rollback.assert_called_once()
|
|
|
|
def test_commit_and_rollback_both_raise_does_not_propagate(self, processor):
|
|
"""If both commit and rollback fail, the helper must NOT
|
|
propagate — the caller has no way to recover and we are already
|
|
in a best-effort cleanup path."""
|
|
mock_session = Mock()
|
|
mock_session.commit.side_effect = RuntimeError("commit boom")
|
|
mock_session.rollback.side_effect = RuntimeError("rollback boom")
|
|
result = processor._commit_with_safe_rollback(mock_session, "ctx")
|
|
assert result is False
|
|
|
|
|
|
class TestDeleteQueueRowSafely:
|
|
"""The dup-branch cleanup helper: rollback-first, re-query, delete,
|
|
commit. All failures absorbed — this is a best-effort path."""
|
|
|
|
def _session_with_queued_row(self, queued_row):
|
|
"""Mock session where the QueuedResearch filter_by().first()
|
|
returns ``queued_row`` (or None)."""
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.first.return_value = queued_row
|
|
mock_session.query.return_value = mock_query
|
|
return mock_session
|
|
|
|
def test_deletes_existing_row_and_commits(self, processor):
|
|
queued_row = Mock()
|
|
mock_session = self._session_with_queued_row(queued_row)
|
|
|
|
processor._delete_queue_row_safely(mock_session, "alice", "r-1")
|
|
|
|
mock_session.delete.assert_called_once_with(queued_row)
|
|
mock_session.commit.assert_called_once()
|
|
|
|
def test_no_row_present_does_not_call_delete_but_still_commits(
|
|
self, processor
|
|
):
|
|
"""If the row was already deleted by a concurrent worker / prior
|
|
retry, the helper must not call ``delete()`` with ``None`` but
|
|
still issue the commit to flush the preceding rollback."""
|
|
mock_session = self._session_with_queued_row(None)
|
|
|
|
processor._delete_queue_row_safely(mock_session, "alice", "r-1")
|
|
|
|
mock_session.delete.assert_not_called()
|
|
mock_session.commit.assert_called_once()
|
|
|
|
def test_initial_rollback_failure_does_not_prevent_delete(self, processor):
|
|
"""The pre-emptive rollback is best-effort; if it fails we still
|
|
try the query+delete+commit sequence."""
|
|
queued_row = Mock()
|
|
mock_session = self._session_with_queued_row(queued_row)
|
|
mock_session.rollback.side_effect = RuntimeError("rollback boom")
|
|
|
|
processor._delete_queue_row_safely(mock_session, "alice", "r-1")
|
|
|
|
mock_session.delete.assert_called_once_with(queued_row)
|
|
mock_session.commit.assert_called_once()
|
|
|
|
def test_query_failure_is_absorbed(self, processor):
|
|
"""If the re-query itself raises (session truly poisoned), the
|
|
helper must not propagate — the caller has no handler for this."""
|
|
mock_session = Mock()
|
|
mock_session.query.side_effect = RuntimeError("query boom")
|
|
|
|
# No exception expected.
|
|
processor._delete_queue_row_safely(mock_session, "alice", "r-1")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _start_queued_researches error recovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStartQueuedResearchesErrorRecovery:
|
|
"""When _start_research throws, verify is_processing reset and task FAILED."""
|
|
|
|
def test_error_resets_is_processing(self, processor):
|
|
"""On exception, is_processing must be set back to False so the
|
|
next loop tick can retry."""
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-1"
|
|
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = [queued]
|
|
# Error handler re-queries the row; return the same object.
|
|
mock_query.first.return_value = queued
|
|
mock_session.query.return_value = mock_query
|
|
|
|
mock_queue_service = Mock()
|
|
|
|
# _start_research throws
|
|
processor._start_research = Mock(side_effect=RuntimeError("boom"))
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
assert queued.is_processing is False
|
|
|
|
def test_transient_error_leaves_queued_for_retry(self, processor):
|
|
"""On a single spawn failure, the queue row stays and is_processing
|
|
is reset, allowing the next loop tick to retry."""
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-2"
|
|
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = [queued]
|
|
mock_query.first.return_value = queued
|
|
mock_session.query.return_value = mock_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._start_research = Mock(side_effect=RuntimeError("boom"))
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
# Row must remain queued for retry — not deleted, not marked FAILED.
|
|
mock_session.delete.assert_not_called()
|
|
assert queued.is_processing is False
|
|
assert processor._spawn_retry_counts["res-2"] == 1
|
|
|
|
def test_terminal_failure_after_retry_limit(self, processor):
|
|
"""After SPAWN_RETRY_LIMIT consecutive failures, row is deleted,
|
|
ResearchHistory.status is set to FAILED, and notify_research_failed
|
|
is invoked exactly once."""
|
|
from local_deep_research.constants import ResearchStatus
|
|
from local_deep_research.database.models import (
|
|
QueuedResearch,
|
|
ResearchHistory,
|
|
)
|
|
from local_deep_research.web.queue.processor_v2 import (
|
|
SPAWN_RETRY_LIMIT,
|
|
)
|
|
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-3"
|
|
|
|
research_row = Mock()
|
|
|
|
# Route queries by model class: QueuedResearch re-query returns
|
|
# the queued row; ResearchHistory lookup returns research_row.
|
|
def _make_query(model):
|
|
q = Mock()
|
|
q.filter_by.return_value = q
|
|
q.order_by.return_value = q
|
|
q.limit.return_value = q
|
|
if model is QueuedResearch:
|
|
q.all.return_value = [queued]
|
|
q.first.return_value = queued
|
|
elif model is ResearchHistory:
|
|
q.first.return_value = research_row
|
|
else:
|
|
q.all.return_value = []
|
|
q.first.return_value = None
|
|
return q
|
|
|
|
mock_session = Mock()
|
|
mock_session.query.side_effect = _make_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._start_research = Mock(side_effect=RuntimeError("boom"))
|
|
processor.notify_research_failed = Mock()
|
|
|
|
# Simulate SPAWN_RETRY_LIMIT loop iterations: each call processes
|
|
# the same queued row and fails.
|
|
for _ in range(SPAWN_RETRY_LIMIT):
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
# After exhausting attempts: terminal path triggered once.
|
|
assert "res-3" not in processor._spawn_retry_counts
|
|
assert processor.notify_research_failed.call_count == 1
|
|
call_args = processor.notify_research_failed.call_args
|
|
assert call_args.kwargs["username"] == "alice"
|
|
assert call_args.kwargs["research_id"] == "res-3"
|
|
# Queue row deleted and ResearchHistory marked FAILED.
|
|
mock_session.delete.assert_called_with(queued)
|
|
assert research_row.status == ResearchStatus.FAILED
|
|
|
|
def test_capacity_reject_does_not_count_toward_retry_limit(self, processor):
|
|
"""A SystemAtCapacityError re-raised from _start_research is a
|
|
transient condition (system globally at capacity), NOT a spawn
|
|
failure. It must NOT bump the spawn-retry counter or mark the
|
|
research FAILED — otherwise a busy system destroys perfectly valid
|
|
queued work after a few ticks. Regression for the missing dedicated
|
|
`except SystemAtCapacityError` clause in _start_queued_researches."""
|
|
from local_deep_research.constants import ResearchStatus
|
|
from local_deep_research.database.models import (
|
|
QueuedResearch,
|
|
ResearchHistory,
|
|
)
|
|
from local_deep_research.exceptions import SystemAtCapacityError
|
|
from local_deep_research.web.queue.processor_v2 import (
|
|
SPAWN_RETRY_LIMIT,
|
|
)
|
|
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-cap"
|
|
|
|
research_row = Mock()
|
|
|
|
def _make_query(model):
|
|
q = Mock()
|
|
q.filter_by.return_value = q
|
|
q.order_by.return_value = q
|
|
q.limit.return_value = q
|
|
if model is QueuedResearch:
|
|
q.all.return_value = [queued]
|
|
q.first.return_value = queued
|
|
elif model is ResearchHistory:
|
|
q.first.return_value = research_row
|
|
else:
|
|
q.all.return_value = []
|
|
q.first.return_value = None
|
|
return q
|
|
|
|
mock_session = Mock()
|
|
mock_session.query.side_effect = _make_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._start_research = Mock(
|
|
side_effect=SystemAtCapacityError("at capacity")
|
|
)
|
|
processor.notify_research_failed = Mock()
|
|
|
|
# Run well past SPAWN_RETRY_LIMIT — capacity rejection must never
|
|
# accumulate toward the limit no matter how many ticks pass.
|
|
for _ in range(SPAWN_RETRY_LIMIT + 2):
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
# Never counted toward the retry limit, never marked FAILED, never
|
|
# deleted, never notified — left QUEUED (is_processing reset) so the
|
|
# next tick can retry once capacity frees up.
|
|
assert "res-cap" not in processor._spawn_retry_counts
|
|
processor.notify_research_failed.assert_not_called()
|
|
mock_session.delete.assert_not_called()
|
|
assert research_row.status != ResearchStatus.FAILED
|
|
assert queued.is_processing is False
|
|
|
|
def test_counter_resets_on_successful_start(self, processor):
|
|
"""If a research eventually succeeds after prior failures, the
|
|
retry counter entry is popped so a future fresh failure gets a
|
|
full retry budget."""
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-4"
|
|
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = [queued]
|
|
mock_query.first.return_value = queued
|
|
mock_session.query.return_value = mock_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._spawn_retry_counts["res-4"] = 2 # prior failures
|
|
processor._start_research = Mock() # success this time
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
assert "res-4" not in processor._spawn_retry_counts
|
|
mock_session.delete.assert_called_with(queued)
|
|
|
|
def test_success_deletes_queued_record(self, processor):
|
|
"""On success, the queued_research is deleted from DB."""
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-3"
|
|
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = [queued]
|
|
mock_session.query.return_value = mock_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._start_research = Mock() # success
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
mock_session.delete.assert_called_once_with(queued)
|
|
|
|
def test_respects_available_slots_limit(self, processor):
|
|
"""The .limit() call should use available_slots."""
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = []
|
|
mock_session.query.return_value = mock_query
|
|
|
|
mock_queue_service = Mock()
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 5
|
|
)
|
|
|
|
mock_query.limit.assert_called_with(5)
|
|
|
|
def test_skip_when_already_claimed_by_another_worker(self, processor):
|
|
"""When the atomic UPDATE matches zero rows (another worker
|
|
already flipped is_processing to True since our SELECT), we must
|
|
skip the item without starting or deleting it."""
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-claimed"
|
|
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = [queued]
|
|
# Simulate lost claim race: another worker beat us to it.
|
|
mock_query.update.return_value = 0
|
|
mock_session.query.return_value = mock_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._start_research = Mock()
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
processor._start_research.assert_not_called()
|
|
mock_session.delete.assert_not_called()
|
|
mock_session.refresh.assert_not_called()
|
|
mock_queue_service.update_task_status.assert_not_called()
|
|
|
|
def test_duplicate_research_error_deletes_queue_row_and_does_not_retry(
|
|
self, processor
|
|
):
|
|
"""DuplicateResearchError means a live thread already exists (prior
|
|
attempt's post-spawn commit failed). The queue row must be deleted,
|
|
the retry counter cleared, and the failure-notification path must
|
|
NOT run — that would terminate-status a research that is actually
|
|
running."""
|
|
from local_deep_research.exceptions import DuplicateResearchError
|
|
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-dup"
|
|
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = [queued]
|
|
# The dup-cleanup path re-queries the queued row before deleting.
|
|
mock_query.first.return_value = queued
|
|
mock_session.query.return_value = mock_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._start_research = Mock(
|
|
side_effect=DuplicateResearchError("thread already live")
|
|
)
|
|
processor.notify_research_failed = Mock()
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
# Queue row deleted, counter cleared, no failure notification.
|
|
mock_session.delete.assert_called_with(queued)
|
|
assert "res-dup" not in processor._spawn_retry_counts
|
|
processor.notify_research_failed.assert_not_called()
|
|
|
|
def test_duplicate_research_error_does_not_mark_failed_at_retry_limit(
|
|
self, processor
|
|
):
|
|
"""Even when the retry counter is already at SPAWN_RETRY_LIMIT - 1
|
|
(i.e. one more bump would trigger the terminal FAILED path),
|
|
DuplicateResearchError must NOT increment the counter or mark
|
|
ResearchHistory.status = FAILED. A live thread is running and
|
|
will write its own terminal status."""
|
|
from local_deep_research.database.models import (
|
|
QueuedResearch,
|
|
ResearchHistory,
|
|
)
|
|
from local_deep_research.exceptions import DuplicateResearchError
|
|
from local_deep_research.web.queue.processor_v2 import (
|
|
SPAWN_RETRY_LIMIT,
|
|
)
|
|
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-dup-limit"
|
|
|
|
research_row = Mock()
|
|
# Distinct status sentinel we can check was not overwritten.
|
|
research_row.status = "NOT-FAILED-SENTINEL"
|
|
|
|
def _make_query(model):
|
|
q = Mock()
|
|
q.filter_by.return_value = q
|
|
q.order_by.return_value = q
|
|
q.limit.return_value = q
|
|
if model is QueuedResearch:
|
|
q.all.return_value = [queued]
|
|
q.first.return_value = queued
|
|
elif model is ResearchHistory:
|
|
q.first.return_value = research_row
|
|
else:
|
|
q.all.return_value = []
|
|
q.first.return_value = None
|
|
return q
|
|
|
|
mock_session = Mock()
|
|
mock_session.query.side_effect = _make_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor._spawn_retry_counts["res-dup-limit"] = SPAWN_RETRY_LIMIT - 1
|
|
processor._start_research = Mock(
|
|
side_effect=DuplicateResearchError("thread already live")
|
|
)
|
|
processor.notify_research_failed = Mock()
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
# Counter cleared, not incremented to SPAWN_RETRY_LIMIT.
|
|
assert "res-dup-limit" not in processor._spawn_retry_counts
|
|
# ResearchHistory.status untouched — live thread owns it.
|
|
assert research_row.status == "NOT-FAILED-SENTINEL"
|
|
# No failure notification sent to the user.
|
|
processor.notify_research_failed.assert_not_called()
|
|
|
|
def test_transient_failure_then_duplicate_recovers_cleanly(self, processor):
|
|
"""End-to-end of the CRITICAL scenario this PR fixes: first tick's
|
|
spawn succeeds but the post-spawn commit fails (simulated as a
|
|
RuntimeError from _start_research); second tick finds the live
|
|
thread and gets DuplicateResearchError. The dup branch must
|
|
delete the queue row, clear the counter, and NOT mark FAILED —
|
|
exactly what the original retry loop failed to do."""
|
|
from local_deep_research.database.models import (
|
|
QueuedResearch,
|
|
ResearchHistory,
|
|
)
|
|
from local_deep_research.exceptions import DuplicateResearchError
|
|
|
|
queued = Mock()
|
|
queued.is_processing = False
|
|
queued.research_id = "res-recover"
|
|
|
|
research_row = Mock()
|
|
# Sentinel so we can prove status was never overwritten to FAILED.
|
|
research_row.status = "LIVE-THREAD-OWNED-SENTINEL"
|
|
|
|
def _make_query(model):
|
|
q = Mock()
|
|
q.filter_by.return_value = q
|
|
q.order_by.return_value = q
|
|
q.limit.return_value = q
|
|
if model is QueuedResearch:
|
|
q.all.return_value = [queued]
|
|
q.first.return_value = queued
|
|
elif model is ResearchHistory:
|
|
q.first.return_value = research_row
|
|
else:
|
|
q.all.return_value = []
|
|
q.first.return_value = None
|
|
return q
|
|
|
|
mock_session = Mock()
|
|
mock_session.query.side_effect = _make_query
|
|
|
|
mock_queue_service = Mock()
|
|
processor.notify_research_failed = Mock()
|
|
|
|
# Tick 1: simulate post-spawn commit failure — _start_research
|
|
# raised after the thread was already live. The generic except
|
|
# path bumps the counter and leaves the row for retry.
|
|
processor._start_research = Mock(
|
|
side_effect=RuntimeError("post-spawn commit failed")
|
|
)
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
assert processor._spawn_retry_counts["res-recover"] == 1
|
|
processor.notify_research_failed.assert_not_called()
|
|
|
|
# Tick 2: the live thread from tick 1 is still running, so
|
|
# start_research_process raises DuplicateResearchError.
|
|
processor._start_research = Mock(
|
|
side_effect=DuplicateResearchError("thread already live")
|
|
)
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
# Dup branch: counter cleared, queue row deleted, FAILED never
|
|
# written, user never notified of a failure that didn't happen.
|
|
assert "res-recover" not in processor._spawn_retry_counts
|
|
assert research_row.status == "LIVE-THREAD-OWNED-SENTINEL"
|
|
processor.notify_research_failed.assert_not_called()
|
|
# Queue row was deleted (any call with the queued row counts).
|
|
assert any(
|
|
call.args and call.args[0] is queued
|
|
for call in mock_session.delete.call_args_list
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _reclaim_stranded_queue_rows — crash/restart recovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReclaimStrandedQueueRows:
|
|
"""Verify the reclaim pass recovers queue rows stranded by a crash
|
|
between pre-spawn commit and queue-row deletion."""
|
|
|
|
def _make_session_with_stranded_rows(self, stranded_rows, research_rows):
|
|
"""Build a mock session that returns ``stranded_rows`` for the
|
|
QueuedResearch is_processing=True query and per-id lookups for
|
|
ResearchHistory."""
|
|
from local_deep_research.database.models import (
|
|
QueuedResearch,
|
|
ResearchHistory,
|
|
)
|
|
|
|
def _make_query(model):
|
|
q = Mock()
|
|
q.filter_by.return_value = q
|
|
q.order_by.return_value = q
|
|
q.limit.return_value = q
|
|
if model is QueuedResearch:
|
|
q.all.return_value = stranded_rows
|
|
q.first.return_value = None
|
|
elif model is ResearchHistory:
|
|
|
|
def _first():
|
|
last_filter = q.filter_by.call_args
|
|
rid = last_filter.kwargs.get("id") if last_filter else None
|
|
return research_rows.get(rid)
|
|
|
|
q.first.side_effect = _first
|
|
else:
|
|
q.all.return_value = []
|
|
q.first.return_value = None
|
|
return q
|
|
|
|
session = Mock()
|
|
session.query.side_effect = _make_query
|
|
return session
|
|
|
|
def test_reclaims_row_with_no_live_thread_and_in_progress_status(
|
|
self, processor
|
|
):
|
|
"""After a crash, a row with is_processing=True + status=IN_PROGRESS
|
|
+ no live thread must be reverted to is_processing=False +
|
|
status=QUEUED."""
|
|
from local_deep_research.constants import ResearchStatus
|
|
|
|
stranded = Mock()
|
|
stranded.research_id = "res-stranded"
|
|
stranded.is_processing = True
|
|
|
|
research_row = Mock()
|
|
research_row.status = ResearchStatus.IN_PROGRESS
|
|
|
|
mock_session = self._make_session_with_stranded_rows(
|
|
[stranded], {"res-stranded": research_row}
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.web.routes.globals.is_research_active",
|
|
return_value=False,
|
|
):
|
|
reclaimed = processor._reclaim_stranded_queue_rows(
|
|
mock_session, "alice"
|
|
)
|
|
|
|
assert reclaimed == 1
|
|
assert stranded.is_processing is False
|
|
assert research_row.status == ResearchStatus.QUEUED
|
|
assert mock_session.commit.called
|
|
|
|
def test_does_not_reclaim_row_with_live_thread(self, processor):
|
|
"""A row whose research_id is in _active_research (live thread)
|
|
must NOT be reclaimed — it's legitimately in-flight."""
|
|
from local_deep_research.constants import ResearchStatus
|
|
|
|
stranded = Mock()
|
|
stranded.research_id = "res-live"
|
|
stranded.is_processing = True
|
|
|
|
research_row = Mock()
|
|
research_row.status = ResearchStatus.IN_PROGRESS
|
|
|
|
mock_session = self._make_session_with_stranded_rows(
|
|
[stranded], {"res-live": research_row}
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.web.routes.globals.is_research_active",
|
|
return_value=True,
|
|
):
|
|
reclaimed = processor._reclaim_stranded_queue_rows(
|
|
mock_session, "alice"
|
|
)
|
|
|
|
assert reclaimed == 0
|
|
assert stranded.is_processing is True
|
|
assert research_row.status == ResearchStatus.IN_PROGRESS
|
|
# No commit needed when nothing was reclaimed.
|
|
assert not mock_session.commit.called
|
|
|
|
def test_reclaims_row_without_touching_terminal_status(self, processor):
|
|
"""If a stranded row's ResearchHistory is already COMPLETED/FAILED
|
|
(thread finished cleanly before the crash), only reset
|
|
is_processing; do NOT overwrite the terminal status."""
|
|
from local_deep_research.constants import ResearchStatus
|
|
|
|
stranded = Mock()
|
|
stranded.research_id = "res-done"
|
|
stranded.is_processing = True
|
|
|
|
research_row = Mock()
|
|
research_row.status = ResearchStatus.COMPLETED
|
|
|
|
mock_session = self._make_session_with_stranded_rows(
|
|
[stranded], {"res-done": research_row}
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.web.routes.globals.is_research_active",
|
|
return_value=False,
|
|
):
|
|
reclaimed = processor._reclaim_stranded_queue_rows(
|
|
mock_session, "alice"
|
|
)
|
|
|
|
assert reclaimed == 1
|
|
assert stranded.is_processing is False
|
|
# Terminal status untouched.
|
|
assert research_row.status == ResearchStatus.COMPLETED
|
|
|
|
def test_no_stranded_rows_is_cheap_noop(self, processor):
|
|
"""With zero stranded rows, no commit should fire."""
|
|
mock_session = self._make_session_with_stranded_rows([], {})
|
|
|
|
with patch(
|
|
"local_deep_research.web.routes.globals.is_research_active",
|
|
return_value=False,
|
|
):
|
|
reclaimed = processor._reclaim_stranded_queue_rows(
|
|
mock_session, "alice"
|
|
)
|
|
|
|
assert reclaimed == 0
|
|
assert not mock_session.commit.called
|
|
|
|
def test_start_queued_researches_calls_reclaim_before_select(
|
|
self, processor
|
|
):
|
|
"""_start_queued_researches must invoke _reclaim_stranded_queue_rows
|
|
BEFORE the main QueuedResearch SELECT. If the order were swapped,
|
|
rows just reclaimed (is_processing flipped to False) would still
|
|
be invisible to the SELECT in the same tick — which is the entire
|
|
point of the feature. Use a shared ``call_order`` list to assert
|
|
the ordering, not just that both calls happened."""
|
|
call_order = []
|
|
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = []
|
|
|
|
def _query_side_effect(*args, **kwargs):
|
|
call_order.append("query")
|
|
return mock_query
|
|
|
|
mock_session.query.side_effect = _query_side_effect
|
|
|
|
def _reclaim_side_effect(*args, **kwargs):
|
|
call_order.append("reclaim")
|
|
return 0
|
|
|
|
mock_queue_service = Mock()
|
|
processor._reclaim_stranded_queue_rows = Mock(
|
|
side_effect=_reclaim_side_effect
|
|
)
|
|
|
|
processor._start_queued_researches(
|
|
mock_session, mock_queue_service, "alice", "pw", 1
|
|
)
|
|
|
|
processor._reclaim_stranded_queue_rows.assert_called_once_with(
|
|
mock_session, "alice"
|
|
)
|
|
# Ordering invariant: reclaim must land before the first query.
|
|
assert call_order[0] == "reclaim", (
|
|
f"Expected 'reclaim' first, got order: {call_order}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _start_research_directly cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStartResearchDirectlyCleanup:
|
|
"""When start_research_process throws, verify UserActiveResearch deleted."""
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.UserQueueService")
|
|
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
|
|
@patch("local_deep_research.web.queue.processor_v2.start_research_process")
|
|
def test_cleanup_on_start_failure(
|
|
self, mock_start, mock_get_session, mock_qs, processor
|
|
):
|
|
"""When start_research_process raises, the active record must be
|
|
deleted AND the ResearchHistory row marked FAILED."""
|
|
from local_deep_research.constants import ResearchStatus
|
|
from local_deep_research.database.models import (
|
|
ResearchHistory,
|
|
UserActiveResearch,
|
|
)
|
|
|
|
active_record = Mock()
|
|
research_row = Mock()
|
|
|
|
session1 = Mock() # Create record session
|
|
session2 = Mock() # Cleanup session
|
|
|
|
def _query(model):
|
|
q = Mock()
|
|
q.filter_by.return_value = q
|
|
q.filter.return_value = q
|
|
if model is UserActiveResearch:
|
|
q.first.return_value = active_record
|
|
elif model is ResearchHistory:
|
|
q.first.return_value = research_row
|
|
else:
|
|
q.first.return_value = None
|
|
return q
|
|
|
|
session2.query.side_effect = _query
|
|
|
|
sessions = [session1, session2]
|
|
call_count = [0]
|
|
|
|
@contextmanager
|
|
def fake_session(*args, **kwargs):
|
|
idx = min(call_count[0], len(sessions) - 1)
|
|
call_count[0] += 1
|
|
yield sessions[idx]
|
|
|
|
mock_get_session.side_effect = fake_session
|
|
mock_start.side_effect = RuntimeError("process failed")
|
|
|
|
processor._start_research_directly(
|
|
"alice",
|
|
"res-1",
|
|
"password",
|
|
query="test",
|
|
mode="quick",
|
|
)
|
|
|
|
# Cleanup session deletes the active record AND marks FAILED.
|
|
session2.delete.assert_called_once_with(active_record)
|
|
assert research_row.status == ResearchStatus.FAILED
|
|
session2.commit.assert_called()
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
|
|
@patch("local_deep_research.web.queue.processor_v2.start_research_process")
|
|
def test_success_updates_thread_id(
|
|
self, mock_start, mock_get_session, processor
|
|
):
|
|
"""On success, thread ID is updated in active record."""
|
|
mock_thread = Mock()
|
|
mock_thread.ident = 12345
|
|
mock_start.return_value = mock_thread
|
|
|
|
active_record = Mock()
|
|
mock_session = Mock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value = mock_query
|
|
mock_query.first.return_value = active_record
|
|
mock_session.query.return_value = mock_query
|
|
|
|
@contextmanager
|
|
def fake_session(*args, **kwargs):
|
|
yield mock_session
|
|
|
|
mock_get_session.side_effect = fake_session
|
|
|
|
processor._start_research_directly(
|
|
"alice",
|
|
"res-1",
|
|
"password",
|
|
query="test",
|
|
mode="quick",
|
|
)
|
|
|
|
assert active_record.thread_id == "12345"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# process_user_request
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProcessUserRequest:
|
|
"""Tests for process_user_request return values."""
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.db_manager")
|
|
@patch("local_deep_research.web.queue.processor_v2.session_password_store")
|
|
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
|
|
def test_no_password_returns_zero(
|
|
self, mock_session, mock_pw_store, mock_db, processor
|
|
):
|
|
"""When no password available, returns 0 (line 686)."""
|
|
mock_pw_store.get_session_password.return_value = None
|
|
|
|
result = processor.process_user_request("alice", "sess-1")
|
|
assert result == 0
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.db_manager")
|
|
@patch("local_deep_research.web.queue.processor_v2.session_password_store")
|
|
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
|
|
def test_exception_returns_zero(
|
|
self, mock_session, mock_pw_store, mock_db, processor
|
|
):
|
|
"""When exception occurs, returns 0 (line 690)."""
|
|
mock_pw_store.get_session_password.return_value = "pw"
|
|
mock_db.open_user_database.side_effect = RuntimeError("db error")
|
|
|
|
result = processor.process_user_request("alice", "sess-1")
|
|
assert result == 0
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.db_manager")
|
|
@patch("local_deep_research.web.queue.processor_v2.session_password_store")
|
|
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
|
|
def test_returns_queued_count(
|
|
self, mock_get_session, mock_pw_store, mock_db, processor
|
|
):
|
|
"""When queue has items, returns queued count (line 684)."""
|
|
mock_pw_store.get_session_password.return_value = "pw"
|
|
mock_db.open_user_database.return_value = Mock()
|
|
|
|
mock_queue_service = Mock()
|
|
mock_queue_service.get_queue_status.return_value = {"queued_tasks": 3}
|
|
|
|
mock_session = Mock()
|
|
|
|
@contextmanager
|
|
def fake_session(*args, **kwargs):
|
|
yield mock_session
|
|
|
|
mock_get_session.side_effect = fake_session
|
|
|
|
with patch(
|
|
"local_deep_research.web.queue.processor_v2.UserQueueService",
|
|
return_value=mock_queue_service,
|
|
):
|
|
result = processor.process_user_request("alice", "sess-1")
|
|
|
|
assert result == 3
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.db_manager")
|
|
@patch("local_deep_research.web.queue.processor_v2.session_password_store")
|
|
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
|
|
def test_empty_queue_returns_zero(
|
|
self, mock_get_session, mock_pw_store, mock_db, processor
|
|
):
|
|
"""When queue is empty, returns 0."""
|
|
mock_pw_store.get_session_password.return_value = "pw"
|
|
mock_db.open_user_database.return_value = Mock()
|
|
|
|
mock_queue_service = Mock()
|
|
mock_queue_service.get_queue_status.return_value = {"queued_tasks": 0}
|
|
|
|
mock_session = Mock()
|
|
|
|
@contextmanager
|
|
def fake_session(*args, **kwargs):
|
|
yield mock_session
|
|
|
|
mock_get_session.side_effect = fake_session
|
|
|
|
with patch(
|
|
"local_deep_research.web.queue.processor_v2.UserQueueService",
|
|
return_value=mock_queue_service,
|
|
):
|
|
result = processor.process_user_request("alice", "sess-1")
|
|
|
|
assert result == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# notify_user_activity concurrency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNotifyUserActivityConcurrency:
|
|
"""Concurrent notify_user_activity calls from 20 threads."""
|
|
|
|
def test_concurrent_notify_all_users_registered(self, processor):
|
|
"""All users registered via concurrent notify_user_activity calls."""
|
|
barrier = threading.Barrier(20)
|
|
errors = []
|
|
|
|
def worker(i):
|
|
try:
|
|
barrier.wait(timeout=5)
|
|
processor.notify_user_activity(f"user-{i}", f"sess-{i}")
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
threads = [
|
|
threading.Thread(target=worker, args=(i,)) for i in range(20)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
assert not errors
|
|
assert len(processor._users_to_check) == 20
|
|
for i in range(20):
|
|
assert (f"user-{i}", f"sess-{i}") in processor._users_to_check
|
|
|
|
def test_duplicate_user_session_deduplicated(self, processor):
|
|
"""Same user:session added multiple times is stored once (set behavior)."""
|
|
for _ in range(5):
|
|
processor.notify_user_activity("alice", "sess-1")
|
|
|
|
assert len(processor._users_to_check) == 1
|
|
assert ("alice", "sess-1") in processor._users_to_check
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _spawn_retry_counts concurrent access
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSpawnRetryCountsConcurrency:
|
|
"""Verify _spawn_retry_counts increments are atomic under contention."""
|
|
|
|
def test_concurrent_increments_do_not_lose_updates(self, processor):
|
|
"""Calls the production ``_bump_spawn_retry_count`` helper from
|
|
many threads. Without the lock inside that helper, the
|
|
read-modify-write loses increments under contention and the
|
|
final count is less than ``n_threads``. With the lock, the final
|
|
count must equal ``n_threads``. This is a real mutation test of
|
|
the production lock — removing ``with self._spawn_retry_counts_lock:``
|
|
from the helper makes this test fail."""
|
|
n_threads = 50
|
|
research_id = "contended-id"
|
|
barrier = threading.Barrier(n_threads)
|
|
returned_values = []
|
|
returned_lock = threading.Lock()
|
|
|
|
def worker():
|
|
barrier.wait(timeout=5)
|
|
# Call the production increment path, not the lock directly.
|
|
attempts = processor._bump_spawn_retry_count(research_id)
|
|
with returned_lock:
|
|
returned_values.append(attempts)
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
# Final stored count must equal the number of incrementers.
|
|
assert processor._spawn_retry_counts[research_id] == n_threads
|
|
# Returned values must be 1..n_threads with no duplicates — any
|
|
# duplicate would mean two callers observed the same prior count,
|
|
# i.e. a lost update.
|
|
assert sorted(returned_values) == list(range(1, n_threads + 1))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Start/stop lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProcessorLifecycle:
|
|
"""Basic start/stop behavior."""
|
|
|
|
def test_start_sets_running(self, processor):
|
|
processor.start()
|
|
assert processor.running is True
|
|
processor.stop()
|
|
assert processor.running is False
|
|
|
|
def test_double_start_is_noop(self, processor):
|
|
processor.start()
|
|
processor.start() # should not raise
|
|
processor.stop()
|
|
|
|
def test_stop_without_start(self, processor):
|
|
"""Stopping without starting should not raise."""
|
|
processor.stop()
|
|
assert processor.running is False
|