"""Tests for the unified unindexed-document reconciler. These drive ``BackgroundJobScheduler._reconcile_unindexed_documents`` (the self-healing reconciler that indexes EVERY unindexed document — both in- collection library uploads missed by PR #3939's drop-on-saturation auto-index queue AND research downloads that were never ingested) and ``_schedule_reconciler`` (the job lifecycle) with focused mocks, mirroring the mocking style of ``test_download_service_3827_fix.py``. The reconciler handles two cases per tick under one batch cap: (a) in-collection unindexed docs (DocumentCollection.indexed == False) (b) research orphans (Document.research_id set, no default-library link) and is gated by ``sweep_library_collections OR generate_rag`` (the generate_rag arm preserves the legacy research-download indexing behaviour). """ from contextlib import contextmanager from datetime import datetime, UTC from unittest.mock import MagicMock, patch from local_deep_research.scheduler.background import ( _LIBRARY_SWEEP_BATCH, BackgroundJobScheduler, DocumentSchedulerSettings, ) MODULE = "local_deep_research.scheduler.background" # get_default_library_id is imported at module scope in background.py, so patch # the name bound there (not its definition module). DEFAULT_LIB = f"{MODULE}.get_default_library_id" # get_rag_service is imported lazily inside the reconciler, so patch it at its # definition module. FACTORY = ( "local_deep_research.research_library.services.rag_service_factory" ".get_rag_service" ) def _fresh_scheduler(): """Return a fresh scheduler singleton with a registered test user.""" BackgroundJobScheduler._instance = None with patch(f"{MODULE}.BackgroundScheduler"): scheduler = BackgroundJobScheduler() scheduler.user_sessions["testuser"] = { "scheduled_jobs": set(), "last_activity": datetime.now(UTC), } scheduler._credential_store.store("testuser", "testpass") return scheduler def _db_returning(case_a_rows, case_b_rows=None): """Build a MagicMock db whose query chain returns ``case_a_rows`` on the first ``.all()`` (case-a in-collection query) and ``case_b_rows`` on the second (case-b research-orphan query). case_a_rows: list of (document_id, collection_id) tuples. case_b_rows: list of (document_id,) tuples; defaults to empty. """ if case_b_rows is None: case_b_rows = [] db = MagicMock() query = MagicMock() query.join.return_value = query query.outerjoin.return_value = query query.filter.return_value = query query.order_by.return_value = query query.limit.return_value = query # First .all() => case (a) rows, second .all() => case (b) rows. query.all.side_effect = [case_a_rows, case_b_rows] db.query.return_value = query return db, query def _rag_service(): """Return a MagicMock RAG service usable as a context manager that reports successful indexing. """ rag = MagicMock() rag.__enter__ = MagicMock(return_value=rag) rag.__exit__ = MagicMock(return_value=False) rag.index_document.return_value = {"status": "success", "chunk_count": 3} return rag @contextmanager def _patched_session(db): """Patch the lazily-imported get_user_db_session to yield ``db`` and stub SettingsManager + the egress backstop arming + the default-library lookup. """ @contextmanager def fake_get_user_db_session(*a, **kw): yield db with ( patch( "local_deep_research.database.session_context.get_user_db_session", side_effect=fake_get_user_db_session, ), patch( "local_deep_research.settings.manager.SettingsManager", return_value=MagicMock(), ), patch.object( BackgroundJobScheduler, "_arm_egress_backstop", lambda *a, **kw: None, ), patch(DEFAULT_LIB, return_value="default-lib"), ): yield def test_reconciler_indexes_in_collection_unindexed_when_enabled(): """Case (a): with sweep ON, every in-collection unindexed doc is indexed via the per-collection RAG service with force_reindex=False; one service per distinct collection. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) db, _ = _db_returning( [("doc-1", "coll-A"), ("doc-2", "coll-A"), ("doc-3", "coll-B")], case_b_rows=[], ) rag_service = _rag_service() with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, return_value=rag_service) as mock_factory, ): scheduler._reconcile_unindexed_documents("testuser") # Each in-collection unindexed doc indexed exactly once, never reindexed. assert rag_service.index_document.call_count == 3 for call in rag_service.index_document.call_args_list: assert call.kwargs["force_reindex"] is False # One RAG service built per distinct collection (A, B). No orphans here so # the default-library service is not built. built_collections = { c.kwargs.get("collection_id") for c in mock_factory.call_args_list } assert built_collections == {"coll-A", "coll-B"} def test_reconciler_ingests_and_indexes_research_orphan(): """Case (b): a research orphan (research_id set, no default-library link) is ingested into the default library AND indexed via index_document into the default-library collection. index_document's ensure_in_collection does the ingest, so a single call ingests + indexes. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # No in-collection work (case a empty), one research orphan (case b). db, _ = _db_returning([], case_b_rows=[("orphan-doc",)]) rag_service = _rag_service() with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, return_value=rag_service) as mock_factory, ): scheduler._reconcile_unindexed_documents("testuser") # The orphan was indexed into the DEFAULT library collection. rag_service.index_document.assert_called_once() call = rag_service.index_document.call_args assert call.kwargs["document_id"] == "orphan-doc" assert call.kwargs["collection_id"] == "default-lib" assert call.kwargs["force_reindex"] is False # The RAG service for the orphan path was built for the default library. assert any( c.kwargs.get("collection_id") == "default-lib" for c in mock_factory.call_args_list ) def test_reconciler_runs_for_generate_rag_only_no_regression(): """No-regression path: with sweep_library_collections OFF but the legacy generate_rag ON, the reconciler still runs and indexes research orphans — preserving the behaviour of the retired _process_user_documents RAG block. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings( sweep_library_collections=False, generate_rag=True ) assert settings.sweep_library_collections is False db, _ = _db_returning([], case_b_rows=[("orphan-doc",)]) rag_service = _rag_service() with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, return_value=rag_service), ): scheduler._reconcile_unindexed_documents("testuser") # generate_rag alone is enough to drive the reconciler. rag_service.index_document.assert_called_once() assert ( rag_service.index_document.call_args.kwargs["collection_id"] == "default-lib" ) def test_reconciler_is_noop_when_both_disabled(): """With BOTH gating settings OFF (default), the reconciler returns before opening a DB session or building any RAG service. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings() # both False assert settings.sweep_library_collections is False assert settings.generate_rag is False with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), patch( "local_deep_research.database.session_context.get_user_db_session", ) as mock_session, patch(FACTORY) as mock_factory, ): scheduler._reconcile_unindexed_documents("testuser") mock_session.assert_not_called() mock_factory.assert_not_called() def test_reconciler_is_noop_when_scheduler_disabled(): """When the document scheduler is disabled (enabled=False) the reconciler must no-op even if sweep_library_collections is on — the already-live job can keep firing after a runtime disable until the next reschedule, and the setting promises it only runs while the scheduler is enabled. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings( enabled=False, sweep_library_collections=True ) with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), patch( "local_deep_research.database.session_context.get_user_db_session", ) as mock_session, patch(FACTORY) as mock_factory, ): scheduler._reconcile_unindexed_documents("testuser") mock_session.assert_not_called() mock_factory.assert_not_called() def test_reconciler_filters_to_unindexed_only_no_work(): """The case-(a) query filters on DocumentCollection.indexed == False so an already-indexed document is never selected. With both queries empty, no RAG service is built (idempotency: already-indexed docs are never touched). """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) db, query = _db_returning([], case_b_rows=[]) # nothing to do with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY) as mock_factory, ): scheduler._reconcile_unindexed_documents("testuser") # Queries were issued (filters applied) but selected nothing. assert query.filter.called mock_factory.assert_not_called() def test_reconciler_caps_each_case_at_its_own_batch_budget(): """Each case has its OWN _LIBRARY_SWEEP_BATCH budget (independent, NOT a shared/leftover budget). Even when case (a) returns a full batch, the research-orphan path still runs with its own LIMIT(_LIBRARY_SWEEP_BATCH), so total work is bounded at 2 x _LIBRARY_SWEEP_BATCH per tick. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # Case (a) returns a full batch; case (b) also has rows to process. rows = [(f"doc-{i}", "coll-A") for i in range(_LIBRARY_SWEEP_BATCH)] db, query = _db_returning(rows, case_b_rows=[("orphan-doc",)]) rag_service = _rag_service() rag_service.index_document.return_value = { "status": "success", "chunk_count": 1, } with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, return_value=rag_service), ): scheduler._reconcile_unindexed_documents("testuser") # BOTH queries are capped at the module batch constant (independent budgets, # not a single shared one). With case (a) at full batch the orphan query # must STILL use the full _LIBRARY_SWEEP_BATCH limit (no `remaining` # subtraction), so .limit is called with the constant for both cases. assert query.limit.call_args_list == [ ((_LIBRARY_SWEEP_BATCH,), {}), ((_LIBRARY_SWEEP_BATCH,), {}), ] # Case (a) docs (BATCH) plus the one orphan were all indexed: the orphan was # NOT starved by a full case (a). assert rag_service.index_document.call_count == _LIBRARY_SWEEP_BATCH + 1 indexed_collections = { c.kwargs["collection_id"] for c in rag_service.index_document.call_args_list } assert indexed_collections == {"coll-A", "default-lib"} def test_reconciler_per_doc_error_does_not_crash_tick_or_leak_password( loguru_caplog_full, ): """A per-doc indexing error is logged and the loop continues with the next doc; the password is never present in the captured log output. Uses ``loguru_caplog_full`` (which re-enables the package's loguru logging, disabled by default in tests) so the leak assertion is NOT vacuous — the error line really is captured and checked for the secret. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) db, _ = _db_returning( [("doc-1", "coll-A"), ("doc-2", "coll-A")], case_b_rows=[] ) rag_service = _rag_service() # First doc raises an error containing the password; second succeeds. rag_service.index_document.side_effect = [ RuntimeError("boom with secret testpass inside"), {"status": "success", "chunk_count": 2}, ] with loguru_caplog_full.at_level("DEBUG"): with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, return_value=rag_service), ): # Must not raise even though the first doc errored. scheduler._reconcile_unindexed_documents("testuser") # Both docs were attempted (the error didn't abort the loop). assert rag_service.index_document.call_count == 2 text = loguru_caplog_full.text # The per-doc error handler ran (path exercised) ... assert "[RECONCILER] Failed to index document" in text # ... and the plaintext password never appears in the log output. assert "testpass" not in text, "Password leaked into reconciler log output" def test_schedule_reconciler_adds_job_when_sweep_enabled(): """_schedule_reconciler registers the interval job (with max_instances=1) and tracks it in the session's scheduled-jobs set when sweep is ON. """ scheduler = _fresh_scheduler() session_info = scheduler.user_sessions["testuser"] settings = DocumentSchedulerSettings( sweep_library_collections=True, interval_seconds=1800 ) # remove_job has no prior job -> raise JobLookupError so the add path runs. from apscheduler.jobstores.base import JobLookupError scheduler.scheduler.remove_job.side_effect = JobLookupError("library_sweep") scheduler._schedule_reconciler("testuser", settings, session_info) assert scheduler.scheduler.add_job.called kwargs = scheduler.scheduler.add_job.call_args.kwargs assert kwargs["id"] == "testuser_library_sweep" assert kwargs["trigger"] == "interval" assert kwargs["seconds"] == 1800 assert kwargs["max_instances"] == 1 assert "testuser_library_sweep" in session_info["scheduled_jobs"] def test_schedule_reconciler_adds_job_when_generate_rag_enabled(): """The legacy generate_rag setting alone also schedules the reconciler (no-regression: research downloads keep getting indexed). """ scheduler = _fresh_scheduler() session_info = scheduler.user_sessions["testuser"] settings = DocumentSchedulerSettings( sweep_library_collections=False, generate_rag=True, interval_seconds=1800, ) from apscheduler.jobstores.base import JobLookupError scheduler.scheduler.remove_job.side_effect = JobLookupError("library_sweep") scheduler._schedule_reconciler("testuser", settings, session_info) assert scheduler.scheduler.add_job.called assert ( scheduler.scheduler.add_job.call_args.kwargs["id"] == "testuser_library_sweep" ) assert "testuser_library_sweep" in session_info["scheduled_jobs"] def test_schedule_reconciler_removes_job_when_both_disabled(): """When BOTH gating settings are OFF, no job is added and any existing one is removed from both the scheduler and the session's tracked-jobs set. """ scheduler = _fresh_scheduler() session_info = scheduler.user_sessions["testuser"] session_info["scheduled_jobs"].add("testuser_library_sweep") settings = DocumentSchedulerSettings( sweep_library_collections=False, generate_rag=False ) scheduler._schedule_reconciler("testuser", settings, session_info) scheduler.scheduler.remove_job.assert_called_once_with( "testuser_library_sweep" ) scheduler.scheduler.add_job.assert_not_called() assert "testuser_library_sweep" not in session_info["scheduled_jobs"] # --------------------------------------------------------------------------- # reschedule_document_jobs: the settings-save seam that makes a # document_scheduler.* toggle take effect on the next tick instead of only # after the user logs out and back in. # --------------------------------------------------------------------------- def test_reschedule_document_jobs_creates_reconciler_on_enable(): """Toggling sweep ON re-creates the {username}_library_sweep job for an active user (no re-login needed).""" scheduler = _fresh_scheduler() scheduler.is_running = True settings = DocumentSchedulerSettings( enabled=True, sweep_library_collections=True, interval_seconds=1800 ) with patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings ): result = scheduler.reschedule_document_jobs("testuser") assert result is True scheduled_ids = { call.kwargs.get("id") for call in scheduler.scheduler.add_job.call_args_list } assert "testuser_library_sweep" in scheduled_ids def test_reschedule_document_jobs_removes_reconciler_on_disable(): """Toggling both gating settings OFF tears the reconciler job down on the next save (not only on the next login).""" scheduler = _fresh_scheduler() scheduler.is_running = True scheduler.user_sessions["testuser"]["scheduled_jobs"].add( "testuser_library_sweep" ) settings = DocumentSchedulerSettings( enabled=True, sweep_library_collections=False, generate_rag=False, interval_seconds=1800, ) with patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings ): result = scheduler.reschedule_document_jobs("testuser") assert result is True scheduler.scheduler.remove_job.assert_any_call("testuser_library_sweep") add_ids = { call.kwargs.get("id") for call in scheduler.scheduler.add_job.call_args_list } assert "testuser_library_sweep" not in add_ids assert ( "testuser_library_sweep" not in scheduler.user_sessions["testuser"]["scheduled_jobs"] ) def test_reschedule_document_jobs_noop_for_inactive_user(): """A user the scheduler isn't tracking is skipped (their jobs are rebuilt from current settings on their next login) — returns False, no scheduling. """ scheduler = _fresh_scheduler() scheduler.is_running = True result = scheduler.reschedule_document_jobs("ghost") assert result is False scheduler.scheduler.add_job.assert_not_called() def test_reschedule_document_jobs_noop_when_scheduler_not_running(): """No rescheduling is attempted when the scheduler isn't running.""" scheduler = _fresh_scheduler() scheduler.is_running = False result = scheduler.reschedule_document_jobs("testuser") assert result is False scheduler.scheduler.add_job.assert_not_called() def test_reschedule_helper_only_fires_for_document_scheduler_keys(): """The settings-save helper reschedules ONLY when a document_scheduler.* key changed, so an unrelated settings save never churns the document jobs.""" from local_deep_research.web.services.settings_service import ( reschedule_document_jobs_if_needed, ) with patch(f"{MODULE}.get_background_job_scheduler") as get_sched: sched = get_sched.return_value # Unrelated key -> the scheduler is never even looked up. reschedule_document_jobs_if_needed("u", ["llm.temperature"]) get_sched.assert_not_called() # A document_scheduler.* key -> reschedule that user's document jobs. reschedule_document_jobs_if_needed( "u", ["document_scheduler.sweep_library_collections"] ) sched.reschedule_document_jobs.assert_called_once_with("u") # Missing / empty inputs are no-ops (return before touching the scheduler). reschedule_document_jobs_if_needed(None, ["document_scheduler.enabled"]) reschedule_document_jobs_if_needed("u", []) # --------------------------------------------------------------------------- # Finding 1: case (b) must not be starved when case (a) is full of failures # --------------------------------------------------------------------------- def test_case_b_not_starved_when_case_a_full_batch_all_fail(): """Regression guard for the case-(b) starvation bug. Case (a) returns a FULL batch of in-collection docs that ALL fail to index (index_document raises every time, so none flip to indexed=True), AND there is a research orphan pending. With the old shared/leftover budget (``remaining = _LIBRARY_SWEEP_BATCH - len(unindexed)`` == 0) the orphan path would never run. With independent per-case budgets it MUST still run and index the orphan. Mutation check: revert the fix (gate case (b) on ``remaining = _LIBRARY_SWEEP_BATCH - len(unindexed)`` and ``if remaining > 0``) and this test fails because the orphan is never indexed. """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # Case (a): a full batch, all in coll-A. Case (b): one orphan. case_a = [(f"doc-{i}", "coll-A") for i in range(_LIBRARY_SWEEP_BATCH)] db, _ = _db_returning(case_a, case_b_rows=[("orphan-doc",)]) # One service object, used for BOTH the per-collection path (coll-A) and the # default-library path. Case-(a) docs always raise; the orphan succeeds. rag_service = _rag_service() def index_side_effect(*, document_id, collection_id, force_reindex): if collection_id == "coll-A": raise RuntimeError(f"index failed for {document_id}") return {"status": "success", "chunk_count": 1} rag_service.index_document.side_effect = index_side_effect with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, return_value=rag_service), ): scheduler._reconcile_unindexed_documents("testuser") # The orphan was indexed into the default library DESPITE case (a) filling a # whole batch with failures: case (b) is not starved. orphan_calls = [ c for c in rag_service.index_document.call_args_list if c.kwargs["collection_id"] == "default-lib" ] assert len(orphan_calls) == 1 assert orphan_calls[0].kwargs["document_id"] == "orphan-doc" # --------------------------------------------------------------------------- # Finding 2: real in-memory DB so the actual query predicates are exercised # --------------------------------------------------------------------------- def _seed_document( session, source_type_id, *, doc_id, text_content="some text", research_id=None, ): """Insert one Document row with all NOT-NULL columns satisfied.""" from local_deep_research.database.models.library import ( Document, DocumentStatus, ) doc = Document( id=doc_id, source_type_id=source_type_id, research_id=research_id, document_hash=(doc_id + "x" * 64)[:64], file_size=10, file_type="txt", text_content=text_content, status=DocumentStatus.COMPLETED, ) session.add(doc) return doc def _real_db_session(): """Build an in-memory SQLite session with the library schema created and a SourceType + two collections (a default library + a normal collection) seeded. Returns (session, source_type_id, default_lib_id, coll_id). """ import uuid from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from local_deep_research.database.models import Base from local_deep_research.database.models.library import ( Collection, SourceType, ) engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) session = sessionmaker(bind=engine)() st = SourceType( id=str(uuid.uuid4()), name="research_download", display_name="Research Download", ) default_lib = Collection( id="default-lib", name="Library", collection_type="default_library", is_default=True, ) other = Collection( id="coll-A", name="Coll A", collection_type="user_collection", ) session.add_all([st, default_lib, other]) session.commit() return session, st.id, "default-lib", "coll-A" def test_case_a_predicate_selects_only_unindexed_with_text_real_db(): """Finding 2 (a): with a REAL DB, the case-(a) query selects an unindexed in-collection doc with text, and excludes an already-indexed doc and a null-text doc. A wrong predicate (e.g. indexed.is_(True)) fails here. """ from local_deep_research.database.models.library import DocumentCollection session, st_id, default_lib_id, coll_id = _real_db_session() # Doc 1: unindexed, has text, in coll-A -> SHOULD be selected. _seed_document(session, st_id, doc_id="doc-unindexed", text_content="hi") # Doc 2: already indexed in coll-A -> should NOT be selected. _seed_document(session, st_id, doc_id="doc-indexed", text_content="hi") # Doc 3: unindexed but NULL text -> should NOT be selected. _seed_document(session, st_id, doc_id="doc-notext", text_content=None) session.add_all( [ DocumentCollection( document_id="doc-unindexed", collection_id=coll_id, indexed=False, ), DocumentCollection( document_id="doc-indexed", collection_id=coll_id, indexed=True, ), DocumentCollection( document_id="doc-notext", collection_id=coll_id, indexed=False, ), ] ) session.commit() scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) rag_service = _rag_service() indexed_ids = [] rag_service.index_document.side_effect = ( lambda *, document_id, collection_id, force_reindex: ( indexed_ids.append(document_id) or {"status": "success", "chunk_count": 1} ) ) with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(session), patch(FACTORY, return_value=rag_service), ): scheduler._reconcile_unindexed_documents("testuser") # Only the unindexed, text-bearing, in-collection doc was selected for case # (a). (doc-indexed excluded by indexed.is_(False); doc-notext excluded by # text_content.isnot(None).) case_a_indexed = [ i for i in indexed_ids if i in {"doc-unindexed", "doc-indexed"} ] assert case_a_indexed == ["doc-unindexed"] assert "doc-notext" not in indexed_ids def test_case_b_predicate_selects_research_orphans_real_db(): """Finding 2 (b): with a REAL DB, the case-(b) outerjoin/predicate selects a research orphan (research_id set, text not null, NO default-library link) and excludes a research doc already linked in the default library. Inverting ``DocumentCollection.id.is_(None)`` -> ``isnot(None)`` fails here. """ from local_deep_research.database.models.library import DocumentCollection session, st_id, default_lib_id, coll_id = _real_db_session() # Orphan: research_id set, text, NO link in default library -> selected. _seed_document( session, st_id, doc_id="orphan", text_content="t", research_id="r1" ) # Already in default library -> NOT selected by case (b). _seed_document( session, st_id, doc_id="in-lib", text_content="t", research_id="r2" ) session.add( DocumentCollection( document_id="in-lib", collection_id=default_lib_id, indexed=True, ) ) session.commit() scheduler = _fresh_scheduler() # generate_rag-only: the no-regression path that depends on case (b). settings = DocumentSchedulerSettings( sweep_library_collections=False, generate_rag=True ) rag_service = _rag_service() indexed_into_default = [] rag_service.index_document.side_effect = ( lambda *, document_id, collection_id, force_reindex: ( indexed_into_default.append((document_id, collection_id)) or {"status": "success", "chunk_count": 1} ) ) with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(session), patch(FACTORY, return_value=rag_service), ): scheduler._reconcile_unindexed_documents("testuser") default_targets = [ doc_id for (doc_id, coll) in indexed_into_default if coll == "default-lib" ] # The orphan was ingested into the default library; the already-linked doc # was NOT re-selected by the orphan query. assert default_targets == ["orphan"] # --------------------------------------------------------------------------- # Finding 3: per-collection isolation + orphan/per-collection password redaction # --------------------------------------------------------------------------- def test_per_collection_error_isolated_other_collections_still_indexed(): """Finding 3 (i): when coll-B's get_rag_service raises, the tick completes, coll-A's docs are still indexed, and the shared session was rolled back via safe_rollback (the per-collection coll_error handler). """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) db, _ = _db_returning( [("doc-a", "coll-A"), ("doc-b", "coll-B")], case_b_rows=[] ) good_service = _rag_service() def factory_side_effect(username, *, collection_id, db_password): if collection_id == "coll-B": raise RuntimeError("coll-B factory boom") return good_service rollback_calls = [] with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, side_effect=factory_side_effect), patch( f"{MODULE}.safe_rollback", side_effect=lambda *a, **kw: rollback_calls.append(a), ), ): # Must not raise even though coll-B blew up. scheduler._reconcile_unindexed_documents("testuser") # coll-A's doc was indexed despite coll-B failing (cross-collection # isolation). indexed_docs = { c.kwargs["document_id"] for c in good_service.index_document.call_args_list } assert indexed_docs == {"doc-a"} # The per-collection handler rolled back the poisoned session at least once. assert len(rollback_calls) >= 1 def test_per_collection_error_does_not_leak_password(loguru_caplog_full): """Finding 3 (ii): a per-collection failure whose message contains the sentinel password must not leak it into the log output (coll_error path uses redact_secrets). Uses ``loguru_caplog_full`` because the package disables loguru by default in tests; without re-enabling it the capture would be empty and the leak assertion would pass vacuously (it also captures the exception block). """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) db, _ = _db_returning([("doc-a", "coll-A")], case_b_rows=[]) def factory_side_effect(username, *, collection_id, db_password): raise RuntimeError("collection failure with secret testpass inside") with loguru_caplog_full.at_level("DEBUG"): with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, side_effect=factory_side_effect), ): scheduler._reconcile_unindexed_documents("testuser") text = loguru_caplog_full.text # The per-collection error handler ran (proving the path was exercised). assert "[RECONCILER] Failed to index collection" in text # ...and the plaintext password never reached the log output. assert "testpass" not in text, ( "Password leaked into per-collection error log output" ) def test_orphan_path_error_does_not_leak_password(loguru_caplog_full): """Finding 3 (ii): an orphan-path failure whose message contains the sentinel password must not leak it (orphan_error path uses redact_secrets). """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # No case (a) work; one orphan whose service build raises. db, _ = _db_returning([], case_b_rows=[("orphan-doc",)]) def factory_side_effect(username, *, collection_id, db_password): raise RuntimeError("orphan failure with secret testpass inside") with loguru_caplog_full.at_level("DEBUG"): with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, side_effect=factory_side_effect), ): scheduler._reconcile_unindexed_documents("testuser") text = loguru_caplog_full.text assert "[RECONCILER] Failed to index research" in text assert "testpass" not in text, ( "Password leaked into orphan error log output" ) # --------------------------------------------------------------------------- # Finding 4: partial case-(a) budget; case (b) still uses its own full budget # --------------------------------------------------------------------------- def test_case_a_selection_is_randomized_not_deterministic_real_db(): """Finding 1: the case-(a) query uses ``order_by(func.random())`` (not a stable id order) so a block of permanently-failing low-id rows can't pin the LIMIT slots every tick and starve indexable higher-id rows forever. Seed MORE than ``_LIBRARY_SWEEP_BATCH`` selectable in-collection unindexed rows and run the case-(a) SELECTION several times WITHOUT letting any row flip to indexed=True between draws (``index_document`` is a no-op stub that only records which ids were drawn). With a pool of (BATCH + 25) and a LIMIT of BATCH, the probability that two random draws return the identical BATCH-subset is astronomically small (C(75, 50) is ~2.5e19 possible subsets), so "not all draws identical" is a deterministic pass in practice. Mutation check: change BOTH ``order_by(func.random())`` to a stable ``order_by(DocumentCollection.document_id)`` / ``order_by(Document.id)`` and this test fails because every draw returns the SAME id set. """ from local_deep_research.database.models.library import DocumentCollection session, st_id, default_lib_id, coll_id = _real_db_session() # Seed a pool LARGER than the batch so the LIMIT(BATCH) must subsample it. pool_size = _LIBRARY_SWEEP_BATCH + 25 links = [] for i in range(pool_size): # Zero-padded ids so a stable id-order would be a well-defined, fixed # prefix (making the mutation's determinism unambiguous). doc_id = f"doc-{i:04d}" _seed_document(session, st_id, doc_id=doc_id, text_content="hi") links.append( DocumentCollection( document_id=doc_id, collection_id=coll_id, indexed=False, ) ) session.add_all(links) session.commit() scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # Capture the set of document ids drawn on the most recent reconcile run. # CRITICAL: the stub does NOT flip indexed=True, so every draw sees the same # candidate pool — any change in the selected set is due to random ordering, # not to rows leaving the pool. last_draw: set = set() rag_service = _rag_service() def index_side_effect(*, document_id, collection_id, force_reindex): last_draw.add(document_id) return {"status": "success", "chunk_count": 1} rag_service.index_document.side_effect = index_side_effect draws = [] with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(session), patch(FACTORY, return_value=rag_service), ): # Up to 4 draws for extra robustness: assert they are NOT all identical. for _ in range(4): last_draw = set() scheduler._reconcile_unindexed_documents("testuser") draws.append(frozenset(last_draw)) # Sanity: each draw selected exactly the LIMIT (the pool is larger). for draw in draws: assert len(draw) == _LIBRARY_SWEEP_BATCH # Randomized order_by => the draws are NOT all the same BATCH-subset. A # stable order_by(id) would make every draw identical (mutation fails here). assert len(set(draws)) > 1, ( "case-(a) selection appears deterministic across draws; " "order_by(func.random()) regressed to a stable order" ) def test_case_b_orphan_selection_is_randomized_not_deterministic_real_db(): """Finding 4: case-(b) (research orphans) ALSO uses order_by(func.random()) so a block of permanently-failing low-id orphans can't pin the LIMIT slots every tick and starve the rest. The case-(a) randomization test above seeds only case-(a) rows (research_id=None) so it does NOT guard case (b); this seeds > _LIBRARY_SWEEP_BATCH research orphans. Mutation check: change case (b)'s order_by(func.random()) to a stable order_by(Document.id) and this test fails (every draw identical) while the case-(a) test still passes. """ session, st_id, default_lib_id, coll_id = _real_db_session() # Seed a pool of research ORPHANS (research_id set, text, and NO # default-library DocumentCollection link) larger than the batch. With no # link they are excluded from case (a) (which inner-joins DocumentCollection) # and selected only by case (b). pool_size = _LIBRARY_SWEEP_BATCH + 25 for i in range(pool_size): _seed_document( session, st_id, doc_id=f"orphan-{i:04d}", text_content="hi", research_id=f"r{i:04d}", ) session.commit() scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # No-op stub: does NOT create the default-library link, so every orphan # stays an orphan and the candidate pool is stable across draws — any # variation in the selected set is due to random ordering alone. last_draw: set = set() rag_service = _rag_service() def index_side_effect(*, document_id, collection_id, force_reindex): last_draw.add(document_id) return {"status": "success", "chunk_count": 1} rag_service.index_document.side_effect = index_side_effect draws = [] with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(session), patch(FACTORY, return_value=rag_service), ): for _ in range(4): last_draw = set() scheduler._reconcile_unindexed_documents("testuser") draws.append(frozenset(last_draw)) for draw in draws: assert len(draw) == _LIBRARY_SWEEP_BATCH assert len(set(draws)) > 1, ( "case-(b) orphan selection appears deterministic across draws; " "order_by(func.random()) regressed to a stable order" ) def test_partial_case_a_budget_orphan_uses_own_full_budget(): """Finding 4: when case (a) returns a PARTIAL batch (0 < N < BATCH), the orphan query still uses its OWN full _LIBRARY_SWEEP_BATCH limit (independent budgets), and total index_document work is bounded by N + (orphans found). """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # Partial case-(a) batch: 0 < n < _LIBRARY_SWEEP_BATCH so the OLD shared # budget would have reduced the orphan limit to (BATCH - n). n = 3 case_a = [(f"doc-{i}", "coll-A") for i in range(n)] db, query = _db_returning(case_a, case_b_rows=[("orphan-doc",)]) rag_service = _rag_service() with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), _patched_session(db), patch(FACTORY, return_value=rag_service), ): scheduler._reconcile_unindexed_documents("testuser") # Both queries used the full per-case budget — the orphan query's limit is # NOT reduced by the partial case (a) count (no `remaining` subtraction). assert query.limit.call_args_list == [ ((_LIBRARY_SWEEP_BATCH,), {}), ((_LIBRARY_SWEEP_BATCH,), {}), ] # Total work = N case-(a) docs + 1 orphan, all bounded. assert rag_service.index_document.call_count == n + 1 # --------------------------------------------------------------------------- # Finding 2: the egress backstop is armed for the scheduled reconcile run # --------------------------------------------------------------------------- def test_reconciler_arms_egress_backstop_in_body(): """Finding 2: once past the gate, the reconciler arms the PEP-578 audit-hook egress backstop exactly once via ``_arm_egress_backstop(settings_manager, username)`` — defense-in-depth parity with an interactive run, since embedding providers may make network calls during indexing. Drives the reconciler fully THROUGH the body (enabled + sweep ON) with both the case-(a) and case-(b) queries returning EMPTY so it does minimal work, and asserts the backstop was armed once with (settings_manager, username). Uses a spy (MagicMock) for ``_arm_egress_backstop`` rather than the no-op lambda the shared helper installs, so the call is actually observed. Mutation check: delete the ``self._arm_egress_backstop(...)`` line and this test fails (the spy is never called). """ scheduler = _fresh_scheduler() settings = DocumentSchedulerSettings(sweep_library_collections=True) # Both queries empty => the reconciler still passes the gate, opens the # session, arms the backstop, then finds no work. db, _ = _db_returning([], case_b_rows=[]) @contextmanager def fake_get_user_db_session(*a, **kw): yield db # Capture the SettingsManager instance constructed inside the body so we can # assert it is exactly what was passed to the backstop. settings_manager_instance = MagicMock() with ( patch.object( scheduler, "_get_document_scheduler_settings", return_value=settings, ), patch( "local_deep_research.database.session_context.get_user_db_session", side_effect=fake_get_user_db_session, ), patch( "local_deep_research.settings.manager.SettingsManager", return_value=settings_manager_instance, ), # autospec=True preserves the descriptor so the spy is invoked as a # bound method (``self`` is the implicit first arg). patch.object( BackgroundJobScheduler, "_arm_egress_backstop", autospec=True, ) as arm_spy, patch(DEFAULT_LIB, return_value="default-lib"), patch(FACTORY) as mock_factory, ): scheduler._reconcile_unindexed_documents("testuser") # The backstop was armed exactly once with the in-body SettingsManager and # the username (``self`` is the scheduler since autospec keeps it bound). arm_spy.assert_called_once_with( scheduler, settings_manager_instance, "testuser" ) # No work to do, so no RAG service was built. mock_factory.assert_not_called()