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

1385 lines
56 KiB
Python

"""Tests for automatic RAG indexing functionality."""
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _reset_pending_auto_index_jobs():
"""Save/restore the module-global pending-jobs counter around each test.
Several tests mock ``_get_auto_index_executor`` with a no-op ``submit``,
which reserves a slot (``_pending_auto_index_jobs += 1``) that the (never
run) worker never releases. Without this fixture the counter leaks +1 per
such test and the saturation/concurrency tests become order-dependent.
This restores the counter to its pre-test value after every test, keeping
the suite order-independent.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
with rag_module._pending_auto_index_lock:
saved = rag_module._pending_auto_index_jobs
try:
yield
finally:
with rag_module._pending_auto_index_lock:
rag_module._pending_auto_index_jobs = saved
class TestAutoIndexingSetting:
"""Test the auto-indexing setting."""
def test_auto_index_setting_exists_in_defaults(self):
"""Test that the auto_index_enabled setting is defined in defaults."""
import json
from pathlib import Path
defaults_path = Path(
"src/local_deep_research/defaults/default_settings.json"
)
with open(defaults_path) as f:
defaults = json.load(f)
assert "research_library.auto_index_enabled" in defaults
setting = defaults["research_library.auto_index_enabled"]
assert setting["ui_element"] == "checkbox"
assert setting["value"] is False # Default is disabled (opt-in)
assert setting["category"] == "research_library"
class TestTriggerAutoIndex:
"""Test the trigger_auto_index function."""
def test_trigger_auto_index_skips_when_disabled(self):
"""Test that auto-indexing is skipped when disabled in settings."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = False
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
trigger_auto_index(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Executor should NOT be called when disabled
mock_get_executor.assert_not_called()
def test_trigger_auto_index_submits_to_executor_when_enabled(self):
"""Test that auto-indexing submits to executor when enabled."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_get_executor.return_value = mock_executor
trigger_auto_index(
document_ids=["doc1", "doc2"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Executor should be obtained and submit called
mock_get_executor.assert_called_once()
mock_executor.submit.assert_called_once()
def test_trigger_auto_index_skips_empty_document_list(self):
"""Test that auto-indexing is skipped when no documents provided."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
# Should not even try to access the database
trigger_auto_index(
document_ids=[],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
mock_session.assert_not_called()
def test_trigger_auto_index_skips_on_settings_exception(self):
"""Test that auto-indexing is skipped when settings check raises exception."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
# Simulate database error
mock_session.side_effect = Exception("Database connection failed")
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
# Should not raise, just skip
trigger_auto_index(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Executor should NOT be called when settings check fails
mock_get_executor.assert_not_called()
def test_trigger_auto_index_passes_correct_arguments(self):
"""Backpressure wrapper still delegates correct args to the worker.
The backpressure change submits a ``_wrapped_worker`` closure rather
than ``_auto_index_documents_worker`` directly. This test verifies the
full contract of that wrapper:
* a queue slot is reserved before the job is submitted,
* the submitted callable still forwards the exact arguments to the
underlying ``_auto_index_documents_worker``, and
* the reserved slot is released once the wrapped worker finishes.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_get_executor.return_value = mock_executor
with patch(
"local_deep_research.research_library.routes.rag_routes._auto_index_documents_worker"
) as mock_worker:
pending_before = rag_module._pending_auto_index_jobs
trigger_auto_index(
document_ids=["doc1", "doc2", "doc3"],
collection_id="my_collection",
username="alice",
db_password="secret123",
)
# A queue slot must be reserved before submission, so
# the pending counter is incremented while the (not yet
# run) wrapped worker holds the slot.
assert (
rag_module._pending_auto_index_jobs
== pending_before + 1
)
# A wrapper closure is submitted, NOT the worker
# directly (that is the whole point of the wrapper).
mock_executor.submit.assert_called_once()
call_args = mock_executor.submit.call_args
submitted_callable = call_args[0][0]
assert submitted_callable is not mock_worker
assert callable(submitted_callable)
# The wrapper forwards the original arguments unchanged.
assert call_args[0][1] == ["doc1", "doc2", "doc3"]
assert call_args[0][2] == "my_collection"
assert call_args[0][3] == "alice"
assert call_args[0][4] == "secret123"
# The underlying worker has not run yet: the executor
# is mocked, so submit did not invoke the callable.
mock_worker.assert_not_called()
# Simulate the executor running the submitted job. The
# wrapper must forward the args to the real worker and
# then release the reserved slot.
submitted_callable(*call_args[0][1:])
mock_worker.assert_called_once_with(
["doc1", "doc2", "doc3"],
"my_collection",
"alice",
"secret123",
)
# Slot released once the wrapped worker completed.
assert (
rag_module._pending_auto_index_jobs
== pending_before
)
def test_trigger_auto_index_with_single_document(self):
"""Test that auto-indexing works with a single document."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_get_executor.return_value = mock_executor
trigger_auto_index(
document_ids=["single_doc"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
mock_executor.submit.assert_called_once()
def test_trigger_auto_index_with_many_documents(self):
"""Test that auto-indexing works with many documents."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_get_executor.return_value = mock_executor
# Submit 100 documents
doc_ids = [f"doc_{i}" for i in range(100)]
trigger_auto_index(
document_ids=doc_ids,
collection_id="coll1",
username="testuser",
db_password="testpass",
)
mock_executor.submit.assert_called_once()
call_args = mock_executor.submit.call_args
assert len(call_args[0][1]) == 100
def test_trigger_auto_index_drops_job_when_queue_saturated(self):
"""Saturated queue: the job is dropped, not submitted, and warned.
When ``_pending_auto_index_jobs`` is already at the bound, the
backpressure guard must refuse the new submission entirely: it must
NOT touch the executor (no ``submit``), it must emit a warning, and it
must leave the saturation counter untouched (no slot consumed, so the
upload path that called it is unaffected and still succeeded).
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
saturated = rag_module._MAX_PENDING_AUTO_INDEX_JOBS
original_pending = rag_module._pending_auto_index_jobs
rag_module._pending_auto_index_jobs = saturated
try:
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_get_executor.return_value = mock_executor
with patch.object(
rag_module.logger, "warning"
) as mock_warning:
# Must not raise; the upload already succeeded.
trigger_auto_index(
document_ids=["doc1", "doc2"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Job dropped: executor never touched, nothing queued.
mock_get_executor.assert_not_called()
mock_executor.submit.assert_not_called()
# A saturation warning was emitted, and it carries the
# diagnostic context an operator needs: the cap that was
# hit, the number of documents dropped, and which
# collection they belonged to.
mock_warning.assert_called_once()
warning_args = mock_warning.call_args.args
# The cap, the document count (2 docs passed), and the
# collection id must all be present in the positional
# args passed to logger.warning.
assert (
rag_module._MAX_PENDING_AUTO_INDEX_JOBS
in warning_args
)
assert 2 in warning_args # len(["doc1", "doc2"])
assert "coll1" in warning_args
# Counter unchanged: a dropped job consumes no slot.
assert rag_module._pending_auto_index_jobs == saturated
finally:
rag_module._pending_auto_index_jobs = original_pending
def test_trigger_auto_index_releases_slot_when_worker_raises(self):
"""Leak prevention: a slot is released even if the worker raises.
The wrapped worker submitted to the executor wraps the real worker in
``try/finally`` so that the reserved slot is always released. If this
did not happen, a worker that raised would permanently leak its slot
and eventually saturate the queue forever. This asserts the counter
returns to its baseline after the worker raises.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_get_executor.return_value = mock_executor
with patch(
"local_deep_research.research_library.routes.rag_routes._auto_index_documents_worker",
side_effect=RuntimeError("worker boom"),
):
pending_before = rag_module._pending_auto_index_jobs
trigger_auto_index(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Slot reserved while the (not yet run) wrapper holds it.
assert (
rag_module._pending_auto_index_jobs
== pending_before + 1
)
# Run the submitted wrapper; the underlying worker
# raises. The wrapper's finally must still release the
# slot, and the raise must not escape the wrapper in a
# way that prevents that release.
submitted_callable = mock_executor.submit.call_args[0][
0
]
submitted_args = mock_executor.submit.call_args[0][1:]
try:
submitted_callable(*submitted_args)
except RuntimeError:
# The executor would capture this in the Future;
# what matters is the slot was released first.
pass
# Slot released back to baseline despite the failure.
assert (
rag_module._pending_auto_index_jobs
== pending_before
)
def test_trigger_auto_index_releases_slot_and_swallows_submit_failure(
self,
):
"""Submit failure must not turn a committed upload into a 500.
If ``executor.submit`` itself fails (executor shut down, OOM, etc.)
the wrapped worker never runs, so ``trigger_auto_index`` must release
the reserved slot AND must NOT propagate the exception. Propagating
would bubble up to the upload handler and return a 500 even though the
documents were already committed, prompting the client to retry and
create duplicates.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_executor.submit.side_effect = RuntimeError(
"cannot schedule new futures after shutdown"
)
mock_get_executor.return_value = mock_executor
pending_before = rag_module._pending_auto_index_jobs
# Must return normally: the exception is swallowed so the
# already-committed upload is not turned into a failure.
trigger_auto_index(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# submit was attempted and failed.
mock_executor.submit.assert_called_once()
# Reserved slot was released despite the submit failure,
# so a failed submit does not leak a slot.
assert rag_module._pending_auto_index_jobs == pending_before
def test_trigger_auto_index_releases_slot_when_executor_build_fails(self):
"""Slot must be released if _get_auto_index_executor() itself raises.
Building the executor (OS thread/mutex exhaustion) happens after the
slot is reserved. It must be inside the same try/except as submit, so
a failure there releases the slot and is swallowed — otherwise the
reserved slot leaks permanently and erodes the queue bound.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor",
side_effect=RuntimeError("cannot allocate thread"),
):
pending_before = rag_module._pending_auto_index_jobs
# Must return normally: the exception is swallowed.
trigger_auto_index(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Reserved slot was released despite the executor-build
# failure, so it does not leak a slot.
assert rag_module._pending_auto_index_jobs == pending_before
def test_trigger_auto_index_releases_slot_once_on_thread_start_race(self):
"""Thread-start race must release the reserved slot EXACTLY once.
CPython's ThreadPoolExecutor.submit() enqueues the work item BEFORE it
tries to spin up a worker thread. If thread-start raises
RuntimeError("can't start new thread"), the item is already queued, so
a live worker can run the wrapped worker (which releases the slot in
its finally) AND the except block around submit() also fires. Without
an idempotent release that is a DOUBLE release of one reservation,
under-counting in-flight jobs and eroding the OOM bound.
We reproduce that race by making submit() RUN the passed worker (so its
finally releases) and THEN raise the thread-start RuntimeError (so the
except path also runs). The slot must be released exactly once.
Critically, we pre-set the baseline to a NON-ZERO value: at baseline 0
the ``max(0, ...)`` floor in ``_release_auto_index_slot`` would clamp a
double-release back to 0 and silently mask the bug. A non-zero baseline
makes the second (erroneous) release observable as baseline - 1.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
# Non-zero baseline so a double-release is observable (the max(0,..)
# floor would otherwise mask it at baseline 0).
baseline = 5
with rag_module._pending_auto_index_lock:
rag_module._pending_auto_index_jobs = baseline
def submit_runs_then_raises(fn, *args, **kwargs):
# Mirror CPython: the work item is enqueued and a worker runs
# it (releasing the slot in the wrapped worker's finally)...
fn(*args, **kwargs)
# ...and THEN thread-start fails, so the except path fires too.
raise RuntimeError("can't start new thread")
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_executor.submit.side_effect = submit_runs_then_raises
mock_get_executor.return_value = mock_executor
with patch(
"local_deep_research.research_library.routes.rag_routes._auto_index_documents_worker"
):
# Must not raise: the submit failure is swallowed.
trigger_auto_index(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# The slot was released EXACTLY ONCE despite both the
# wrapped worker's finally and the except block firing, so
# the counter returns to exactly the baseline. If the
# release were not idempotent, it would be baseline - 1.
assert rag_module._pending_auto_index_jobs == baseline
class TestAutoIndexExecutor:
"""Test the ThreadPoolExecutor infrastructure for auto-indexing."""
def test_get_auto_index_executor_returns_executor(self):
"""Test that _get_auto_index_executor returns a ThreadPoolExecutor."""
from concurrent.futures import ThreadPoolExecutor
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
assert isinstance(executor, ThreadPoolExecutor)
def test_get_auto_index_executor_returns_same_instance(self):
"""Test that _get_auto_index_executor returns the same singleton instance."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor1 = _get_auto_index_executor()
executor2 = _get_auto_index_executor()
assert executor1 is executor2
def test_shutdown_auto_index_executor(self):
"""Test that _shutdown_auto_index_executor properly shuts down the executor."""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
_shutdown_auto_index_executor,
)
# Ensure executor exists
executor = _get_auto_index_executor()
assert executor is not None
# Shutdown
_shutdown_auto_index_executor()
# Global should be None after shutdown
assert rag_module._auto_index_executor is None
# Getting executor again should create a new one
new_executor = _get_auto_index_executor()
assert new_executor is not None
assert new_executor is not executor
def test_shutdown_resets_pending_jobs_counter(self):
"""Shutdown must reset the pending-jobs counter.
Otherwise a re-created executor (tests / WSGI reload) inherits a
stale, possibly-saturated count and could refuse all future
submissions.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
_shutdown_auto_index_executor,
)
# Simulate slots left reserved (e.g. an interrupted shutdown).
with rag_module._pending_auto_index_lock:
rag_module._pending_auto_index_jobs = 7
_shutdown_auto_index_executor()
assert rag_module._pending_auto_index_jobs == 0
def test_executor_has_bounded_workers(self):
"""Test that the executor has bounded max_workers to prevent thread proliferation."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
# The executor should have max_workers=4 as configured
assert executor._max_workers == 4
def test_executor_submits_work_successfully(self):
"""Test that work can be submitted to the executor."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
result = []
def worker(value):
result.append(value)
future = executor.submit(worker, "test_value")
future.result(timeout=5) # Wait for completion
assert result == ["test_value"]
def test_executor_limits_concurrent_tasks(self):
"""Test that the executor limits concurrent tasks to max_workers."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
max_workers = executor._max_workers
running_count = []
count_lock = threading.Lock()
barrier = threading.Event()
def slow_worker():
with count_lock:
running_count.append(1)
barrier.wait(timeout=5) # Wait until released
with count_lock:
running_count.pop()
# Submit more tasks than max_workers
futures = [executor.submit(slow_worker) for _ in range(max_workers + 2)]
# Give threads time to start
time.sleep(0.2) # allow: unmarked-sleep
# Only max_workers should be running
with count_lock:
concurrent_count = len(running_count)
assert concurrent_count <= max_workers
# Release all workers
barrier.set()
# Wait for all to complete
for f in futures:
f.result(timeout=5)
def test_executor_thread_name_prefix(self):
"""Test that executor threads have the correct name prefix."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
thread_name_captured = []
def capture_thread_name():
thread_name_captured.append(threading.current_thread().name)
future = executor.submit(capture_thread_name)
future.result(timeout=5)
assert len(thread_name_captured) == 1
assert thread_name_captured[0].startswith("auto_index_")
def test_executor_thread_safe_initialization(self):
"""Test that executor initialization is thread-safe under concurrent access."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
_shutdown_auto_index_executor,
)
# Reset executor to test initialization
_shutdown_auto_index_executor()
executors = []
errors = []
barrier = threading.Barrier(10)
def get_executor_concurrently():
try:
barrier.wait(timeout=5) # Synchronize all threads
executor = _get_auto_index_executor()
executors.append(executor)
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=get_executor_concurrently)
for _ in range(10)
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert len(errors) == 0, f"Errors occurred: {errors}"
assert len(executors) == 10
# All should be the same instance
assert all(e is executors[0] for e in executors)
def test_reserve_slot_never_oversubscribes_under_contention(self):
"""Concurrent reservations must never exceed the cap.
``_try_reserve_auto_index_slot`` guards the counter with
``_pending_auto_index_lock``. With the baseline set near the cap, only
``cap - baseline`` reservations may succeed no matter how many threads
race. We fire N threads (N > the remaining capacity), each waiting on a
``threading.Barrier`` so they hit the critical section as close to
simultaneously as possible, then assert:
* the number of successful reservations equals exactly the remaining
capacity (not more), and
* the final counter never exceeds the cap.
Mutation-awareness: if ``with _pending_auto_index_lock:`` is removed
from ``_try_reserve_auto_index_slot``, two threads can both read the
same pre-increment value and both succeed, over-subscribing past the
cap and failing this test. Note this is *probabilistic* — the lost
update only manifests on an actual interleaving — but the Barrier and a
tight remaining capacity (1) maximize contention to make it reliable.
"""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
_try_reserve_auto_index_slot,
)
cap = rag_module._MAX_PENDING_AUTO_INDEX_JOBS
# Leave exactly ONE free slot so any lost update over-subscribes the
# cap and is unambiguously detectable.
remaining = 1
baseline = cap - remaining
n_threads = 16
with rag_module._pending_auto_index_lock:
rag_module._pending_auto_index_jobs = baseline
barrier = threading.Barrier(n_threads)
results = []
results_lock = threading.Lock()
def contend():
barrier.wait(timeout=5) # all threads collide here
ok = _try_reserve_auto_index_slot()
with results_lock:
results.append(ok)
threads = [threading.Thread(target=contend) for _ in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
successes = sum(1 for r in results if r)
# Exactly the remaining capacity may be granted — never more.
assert successes == remaining, (
f"expected {remaining} successful reservation(s), got {successes}"
)
# The counter must never exceed the cap.
assert rag_module._pending_auto_index_jobs <= cap
# And it should land exactly at the cap (baseline + remaining grants).
assert rag_module._pending_auto_index_jobs == baseline + remaining
def test_executor_handles_worker_exceptions(self):
"""Test that worker exceptions don't break the executor."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
def failing_worker():
raise ValueError("Intentional test error")
def succeeding_worker():
return "success"
# Submit a failing task
future1 = executor.submit(failing_worker)
# Submit a succeeding task after
future2 = executor.submit(succeeding_worker)
# First should raise
try:
future1.result(timeout=5)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "Intentional test error" in str(e)
# Second should still succeed
assert future2.result(timeout=5) == "success"
def test_shutdown_is_idempotent(self):
"""Test that calling shutdown multiple times is safe."""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
_shutdown_auto_index_executor,
)
# Ensure executor exists
_get_auto_index_executor()
# Shutdown multiple times should not raise
_shutdown_auto_index_executor()
_shutdown_auto_index_executor()
_shutdown_auto_index_executor()
assert rag_module._auto_index_executor is None
def test_shutdown_when_no_executor_exists(self):
"""Test that shutdown is safe when no executor has been created."""
import local_deep_research.research_library.routes.rag_routes as rag_module
from local_deep_research.research_library.routes.rag_routes import (
_shutdown_auto_index_executor,
)
# Force executor to None
rag_module._auto_index_executor = None
# Should not raise
_shutdown_auto_index_executor()
assert rag_module._auto_index_executor is None
def test_executor_queues_excess_tasks(self):
"""Test that tasks beyond max_workers are queued and eventually executed."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
max_workers = executor._max_workers
total_tasks = max_workers * 3 # Submit 3x more tasks than workers
results = []
results_lock = threading.Lock()
def worker(task_id):
time.sleep(0.05) # Small delay
with results_lock:
results.append(task_id)
return task_id
# Submit many tasks
futures = [executor.submit(worker, i) for i in range(total_tasks)]
# Wait for all to complete
for f in futures:
f.result(timeout=30)
# All tasks should have been executed
assert len(results) == total_tasks
assert set(results) == set(range(total_tasks))
def test_executor_preserves_task_order_within_worker(self):
"""Test that a single worker processes its tasks in order."""
from local_deep_research.research_library.routes.rag_routes import (
_get_auto_index_executor,
)
executor = _get_auto_index_executor()
results = []
def worker(value):
results.append(value)
return value
# Submit tasks sequentially and wait for each
for i in range(5):
future = executor.submit(worker, i)
future.result(timeout=5)
# Results should be in order since we waited for each
assert results == [0, 1, 2, 3, 4]
class TestAutoIndexDocumentsWorker:
"""Test the _auto_index_documents_worker function."""
def test_worker_indexes_documents(self):
"""Test that the worker indexes documents via RAG service."""
from local_deep_research.research_library.routes.rag_routes import (
_auto_index_documents_worker,
)
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
mock_rag_service = MagicMock()
mock_rag_service.index_document.return_value = {"status": "success"}
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
_auto_index_documents_worker(
document_ids=["doc1", "doc2", "doc3"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Should have called index_document for each document
assert mock_rag_service.index_document.call_count == 3
mock_rag_service.index_document.assert_any_call(
"doc1", "coll1", force_reindex=False
)
mock_rag_service.index_document.assert_any_call(
"doc2", "coll1", force_reindex=False
)
mock_rag_service.index_document.assert_any_call(
"doc3", "coll1", force_reindex=False
)
def test_worker_handles_skipped_documents(self):
"""Test that the worker handles already indexed documents."""
from local_deep_research.research_library.routes.rag_routes import (
_auto_index_documents_worker,
)
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
mock_rag_service = MagicMock()
# First succeeds, second skipped, third succeeds
mock_rag_service.index_document.side_effect = [
{"status": "success"},
{"status": "skipped"},
{"status": "success"},
]
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
# Should not raise
_auto_index_documents_worker(
document_ids=["doc1", "doc2", "doc3"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# All documents should have been attempted
assert mock_rag_service.index_document.call_count == 3
def test_worker_handles_indexing_exception(self):
"""Test that the worker handles exceptions during indexing."""
from local_deep_research.research_library.routes.rag_routes import (
_auto_index_documents_worker,
)
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
mock_rag_service = MagicMock()
mock_rag_service.index_document.side_effect = Exception(
"Index failed"
)
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
# Should not raise, even with exception
_auto_index_documents_worker(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
def test_worker_continues_after_exception(self):
"""Test that the worker continues indexing after an exception."""
from local_deep_research.research_library.routes.rag_routes import (
_auto_index_documents_worker,
)
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
mock_rag_service = MagicMock()
# First succeeds, second raises, third succeeds
mock_rag_service.index_document.side_effect = [
{"status": "success"},
Exception("Index failed"),
{"status": "success"},
]
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
# Should not raise
_auto_index_documents_worker(
document_ids=["doc1", "doc2", "doc3"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# All documents should have been attempted
assert mock_rag_service.index_document.call_count == 3
def test_worker_creates_rag_service_with_correct_params(self):
"""Test that the worker creates RAG service with correct parameters."""
from local_deep_research.research_library.routes.rag_routes import (
_auto_index_documents_worker,
)
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
mock_rag_service = MagicMock()
mock_rag_service.index_document.return_value = {"status": "success"}
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
_auto_index_documents_worker(
document_ids=["doc1"],
collection_id="my_collection",
username="alice",
db_password="secret",
)
mock_get_service.assert_called_once_with(
"my_collection", "alice", "secret"
)
def test_worker_with_empty_document_list(self):
"""Test that the worker handles empty document list."""
from local_deep_research.research_library.routes.rag_routes import (
_auto_index_documents_worker,
)
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
mock_rag_service = MagicMock()
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
# Should not raise with empty list
_auto_index_documents_worker(
document_ids=[],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# index_document should not be called
mock_rag_service.index_document.assert_not_called()
def test_worker_handles_rag_service_creation_failure(self):
"""Test that the worker handles RAG service creation failure."""
from local_deep_research.research_library.routes.rag_routes import (
_auto_index_documents_worker,
)
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
mock_get_service.side_effect = Exception(
"Failed to create RAG service"
)
# Should not raise, even with exception
_auto_index_documents_worker(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
class TestAutoIndexIntegration:
"""Integration tests for the auto-indexing system."""
def test_full_flow_with_real_executor(self):
"""Test the full flow using the real executor."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
_get_auto_index_executor,
)
# Ensure executor is initialized
_get_auto_index_executor()
task_executed = threading.Event()
mock_rag_service = MagicMock()
def index_and_signal(doc_id, collection_id, force_reindex=False):
task_executed.set()
return {"status": "success"}
mock_rag_service.index_document.side_effect = index_and_signal
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
trigger_auto_index(
document_ids=["doc1"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Wait for task to execute
assert task_executed.wait(timeout=5), (
"Task was not executed"
)
def test_multiple_concurrent_triggers(self):
"""Test multiple concurrent trigger_auto_index calls."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
_get_auto_index_executor,
)
# Ensure executor is initialized
_get_auto_index_executor()
indexed_docs = []
docs_lock = threading.Lock()
all_done = threading.Event()
expected_count = 5
mock_rag_service = MagicMock()
def track_indexing(doc_id, collection_id, force_reindex=False):
with docs_lock:
indexed_docs.append(doc_id)
if len(indexed_docs) >= expected_count:
all_done.set()
return {"status": "success"}
mock_rag_service.index_document.side_effect = track_indexing
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_rag_service_for_thread"
) as mock_get_service:
# Configure context manager behavior
mock_get_service.return_value.__enter__.return_value = (
mock_rag_service
)
mock_get_service.return_value.__exit__.return_value = None
# Trigger multiple times concurrently
for i in range(expected_count):
trigger_auto_index(
document_ids=[f"doc_{i}"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Wait for all to complete
assert all_done.wait(timeout=10), "Not all tasks completed"
with docs_lock:
assert len(indexed_docs) == expected_count
def test_executor_reused_across_triggers(self):
"""Test that the same executor is reused across multiple triggers."""
from local_deep_research.research_library.routes.rag_routes import (
trigger_auto_index,
)
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_session:
mock_db = MagicMock()
mock_session.return_value.__enter__.return_value = mock_db
mock_settings = MagicMock()
mock_settings.get_bool_setting.return_value = True
with patch(
"local_deep_research.research_library.routes.rag_routes.SettingsManager",
return_value=mock_settings,
):
with patch(
"local_deep_research.research_library.routes.rag_routes._get_auto_index_executor"
) as mock_get_executor:
mock_executor = MagicMock()
mock_get_executor.return_value = mock_executor
# Trigger multiple times
for i in range(3):
trigger_auto_index(
document_ids=[f"doc_{i}"],
collection_id="coll1",
username="testuser",
db_password="testpass",
)
# Same executor should be fetched each time
assert mock_get_executor.call_count == 3
assert mock_executor.submit.call_count == 3