Files
brain-of-reese/tests/integration/test_sync_pending_count.py
ducoterra a45f744604
Build and Push Containers / build-and-push-app (push) Successful in 2m5s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 194_sync_pending_count
**Phase 194 — final verification pass: all green.**

**Verified (all 5 tasks already complete; nothing re-implemented needed)**
- `app/rag/importer.py`: `_prescan_pending` + `_pending_hash_stmt` (scalar `documents.file_hash`, own session, ONE `asyncio.to_thread` span, REPLACES the stats pre-walk), `on_scan_progress`, `progress` pair = pending position, `0` branch = byte-identical phase-193 walk.
- `app/config.py`/`settings_catalog.py`/`.env.example`: `sync_pending_count=1` + validator; `app/api/sync.py`: `"scan"` phase, `scan_done`/`scan_total` (additive after `files_total`, kept at every terminal), forced/throttled RSS split; `frontend/assets/sources.js`: `fmtSyncPhaseLabel` scan branch first-arm, `fmtSyncLabel` untouched.

**Tests / lint / coverage (exact commands)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4835 passed**, `app/` 99% (7240 stmts / 16 miss), exit 0
- `uv run pytest tests/e2e/test_sync_pending_count.py -v --no-cov` → **8 passed in 150.81s** (phase suite, isolated)
- 14 sync-lineage E2E suites, one pytest process each (`test_sync_remaining_count`, `…_chunk_progress`, `…_progress_box`, `…_peak_memory_visible`, `…_label_mobile`, `…_upload_progress`, `test_archive_upload_sources`, `…_image_progress`, `…_summary_visibility`, `…_status_freshness`, `test_smoke`, `test_fast_path_deferred_content`, `test_sync_session_reset`, `test_sync_git_freeze`) → **all exit 0**
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**

**Completion criteria** — all met: `N remaining` = predicted change set (E2E arms 1/2/5, first import counts the walk, no-change → no count part); `scan` phase visible + flips to `import`, scan pair reset/kept at terminals (`test_sync_scan_phase.py`); `BOR_SYNC_PENDING_COUNT=0` walk semantics ("7 remaining" arm) + **negative refuses startup naming the field (verified live by importing `app.main`)**; pre-scan off-loop + cancel lands `cancelled` (integration pins); CLI/upload byte-identical (unedited pins green); payload additive (`_PRE_187_STATUS_KEYS | _MEMORY_KEYS | _SCAN_KEYS`); suite green, coverage >90%, lint/types clean.

**Documentation** — 3 fixes: README sync "States" bullet had an **unclosed parenthesis** (pre-existing, 193/194) and the scan-label "total is 0" clause was ambiguous about a `=0` run (it still counts the walk once the import starts); **AGENTS.md rule 17 had a stray `)`** (pre-existing from phase 191). Also verified consistency both directions for all 87 settings ↔ README ↔ `.env.example` ↔ settings catalog, the pages table ↔ `app/main.py` routes, no stale `BOR_` names, and README/AGENTS claims through phase 194.

**Notes/deviations** — no live-infra changes (prod sync knob ships default-on, no deployment touched); no git index/history commands run (all changes left in the working tree for the harness); commit criterion left to the harness as instructed.

**Next pending phase:** none — `.agents/phases/todo/` holds only `194_sync_pending_count`, which is now ready to move to `complete/`.
2026-10-20 06:36:21 -04:00

572 lines
22 KiB
Python

"""Integration: phase 194 — the pre-scan predicts the change set, off the loop.
The owner report that opened the phase (2026-10-19, verbatim): *"I just synced
the prod brain of reese and it said '1216 remaining' when it only had like 10
files to sync."* Prod holds ~10,000 importable files and that run changed ~10.
The phase-193 ``N remaining`` label was arithmetically fine — it was fed the
WALK (every importable file) instead of the WORK. The fix lives in
``app/rag/importer.py``: before the walk, ``_prescan_pending`` hashes every
importable file once and reads ``documents.file_hash`` once per file — the
exact predicate the phase-147 unchanged fast path applies — and the per-file
``progress`` hook reports the position in THAT set.
What this file measures with a real ``import_sources`` run:
* **the parity that makes the number honest** — on a stable knowledge base
the predicted set's size equals the run's terminal ``added + updated``: the
fresh arm (everything pending, exactly what a first import indexes), the
unchanged arm (nothing pending, so no count part at all), and the
incremental arm (one changed file of four — the owner's prod case, where
the walk is 4 and the work is 1);
* **zero entity hydration** — the scan's statement projects exactly
``documents.file_hash`` (no other column, no ORM row), so a 10,000-file
scan retains nothing: the run's session is still empty at the first
``progress`` call, before the processing loop has loaded any row (phase
184/185);
* **the span is OFF the loop** — with a stubbed hash that blocks, a heartbeat
coroutine keeps ticking through the scan and the hash runs on a different
thread than the one that awaited it (the phase-179 pattern of
``test_sync_git_freeze.py``: the git clone used to park the single uvicorn
loop and freeze the ``GET /api/sync/status`` poll the UI runs every 2 s,
and a 10,000-file hash+SELECT span inline is the same defect class);
* **the cancel contract** — cancelling the run at the scan's await lands the
cancellation immediately, while the worker itself runs to completion
(threads are not interruptible, phase 134) and nothing gets indexed.
The CLI and the upload job pass no ``progress``, so they run no scan at all;
their byte-identity is pinned in ``tests/unit/test_sync_pending_count.py``.
"""
from __future__ import annotations
import asyncio
import re
import threading
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any, NamedTuple
import pytest
from sqlalchemy import event, select, text
from sqlalchemy.orm import Session
import app.rag.importer as importer
from app.db import engine
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from tests.fakes import FakeEmbedder
#: The probe's own source-name prefix — nothing else in the KB uses it.
PREFIX = "phase194-it-"
#: A blocking hash of 60 ms per file: four files is a ~250 ms scan — long
#: enough that a heartbeat polling every 5 ms cannot tick during it if the
#: span ever runs on the loop thread, short enough that the suite stays fast.
HASH_BLOCK_S = 0.06
#: The heartbeat tick count that proves the loop was free across ONE scanned
#: file (60 ms / 5 ms ≈ 12 ticks; a blocked loop ticks 0). The ceiling-side
#: assertion is the same number generous: this is loop-freeness, not timing.
MIN_TICKS_PER_FILE = 4
class ProgressCall(NamedTuple):
"""One ``progress(source, rel, done, total)`` report."""
source: str
rel: str
done: int
total: int
class Run(NamedTuple):
"""One real ``import_sources`` pass and everything it reported."""
summary: ImportSummary
progress: list[ProgressCall]
scan: list[tuple[int, int]]
def _llm(**setting_overrides: Any) -> FakeEmbedder:
"""The deterministic in-process fake on the SHIPPED defaults (the
pending-count switch is therefore ON — the phase's default)."""
from app.config import Settings
llm = FakeEmbedder()
llm.settings = Settings(_env_file=None, **setting_overrides) # pyright: ignore[reportCallIssue]
return llm
def _root(tmp_path: Path, name: str, files: dict[str, str]) -> Path:
"""One source root (the ``file://``-style temp dir of the house
import fixtures) with *files* under it."""
assert name.startswith(PREFIX), f"the probe source must be {PREFIX}-prefixed"
root = tmp_path / name
for rel, body in files.items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
return root
def _run(
session: Session,
*roots: Path,
llm: FakeEmbedder | None = None,
**kwargs: Any,
) -> Run:
"""One real import over *roots*, collecting both position hooks."""
progress: list[ProgressCall] = []
scan: list[tuple[int, int]] = []
summary = asyncio.run(
import_sources(
list(roots),
llm or _llm(),
session=session,
progress=lambda s, r, d, t: progress.append(ProgressCall(s, r, d, t)),
on_scan_progress=lambda p, t: scan.append((p, t)),
**kwargs,
)
)
return Run(summary, list(progress), list(scan))
@pytest.fixture()
def statement_capture() -> Iterator[list[str]]:
"""Every statement the engine executes for the duration of ONE test.
The ``before_cursor_execute`` technique of phase 183/185
(``tests/integration/test_import_fast_path_columns.py``): what a SELECT
actually projects is invisible in the ORM's Python API and plain in the
statement it compiles, so a capture is the only way to pin "the pre-scan
reads one column". Removed in teardown, so no other suite sees it.
"""
captured: list[str] = []
def _listener(
conn: object, # noqa: ARG001
cursor: object, # noqa: ARG001
statement: str,
parameters: object, # noqa: ARG001
context: object, # noqa: ARG001
executemany: bool, # noqa: ARG001
) -> None:
captured.append(statement)
event.listen(engine, "before_cursor_execute", _listener)
try:
yield captured
finally:
event.remove(engine, "before_cursor_execute", _listener)
@pytest.fixture(autouse=True)
def _clean_probe_documents(db: Session) -> Iterator[None]:
"""``documents``/``chunks`` are global state — the probe's rows are
removed before and after every test (the ``test_sync_api.py`` hygiene
pattern, narrowed to this module's own source names)."""
_drop(db)
yield
_drop(db)
def _drop(db: Session) -> None:
db.execute(text("DELETE FROM chunks WHERE document_id IN (SELECT id FROM "
"documents WHERE source LIKE :p)"), {"p": f"{PREFIX}%"})
db.execute(text("DELETE FROM documents WHERE source LIKE :p"), {"p": f"{PREFIX}%"})
db.commit()
db.expunge_all()
# --------------------------------------------------------------------------
# The parity: the predicted set IS the work
# --------------------------------------------------------------------------
_FOUR_FILES = {
"a.md": "# A\n\nalpha content\n",
"b.md": "# B\n\nbravo content\n",
"c.md": "# C\n\ncharlie content\n",
"notes/d.md": "# D\n\ndelta content\n",
}
def test_fresh_kb_pending_set_equals_the_added_count(
db: Session, tmp_path: Path
) -> None:
"""Arm 1 — the first import: walk == work, so the numbers are the
phase-193 ones (A1's degenerate parity, the reason the phase-193 E2E
suite never saw the defect).
Every file has no row, so every file is pending: the hook reports
``1/4 … 4/4`` and the terminal says ``added=4`` — the predicted size
equals ``added + updated`` exactly."""
root = _root(tmp_path, f"{PREFIX}fresh", _FOUR_FILES)
run = _run(db, root)
assert (run.summary.files, run.summary.added, run.summary.errors) == (4, 4, 0)
assert [(c.done, c.total) for c in run.progress] == [(1, 4), (2, 4), (3, 4), (4, 4)]
assert run.scan == [(1, 4), (2, 4), (3, 4), (4, 4)]
assert run.progress[-1].total == run.summary.added + run.summary.updated
def test_unchanged_kb_predicts_nothing_and_terminal_says_no_work(
db: Session, tmp_path: Path
) -> None:
"""Arm 2 — the no-change sync: nothing pending, so no count part.
The scan walks all four files and predicts none of them; every ``progress``
call therefore carries ``(0, 0)``, which the phase-64 ``total === 0``
guard renders as a bare label with no ``N remaining`` — the honest shape
for a run with no work, where the pre-phase label said ``4 remaining``
while walking. ``summary.files`` (the log's walked-file count) is
untouched: the run still processed 4 files and still logged 4 unchanged.
"""
root = _root(tmp_path, f"{PREFIX}unchanged", _FOUR_FILES)
seed = _run(db, root)
assert seed.summary.added == 4
run = _run(db, root)
assert (run.summary.files, run.summary.added, run.summary.updated) == (4, 0, 0)
assert run.summary.unchanged == 4
assert [(c.done, c.total) for c in run.progress] == [(0, 0)] * 4
# The scan still walked (and reported) every file — the DENOMINATOR of the
# scan is the importable count, not the prediction.
assert run.scan == [(1, 4), (2, 4), (3, 4), (4, 4)]
assert run.summary.added + run.summary.updated == 0
def test_incremental_kb_counts_the_changed_files_only(
db: Session, tmp_path: Path
) -> None:
"""Arm 3 — the owner's prod case at 1-of-4 scale.
One changed file out of four: the walk is still 4 (``summary.files``, the
log line, and every ``progress`` call naming a file), but the count is the
WORK — ``total`` 1, the position climbing only when the walk reaches the
changed file, and holding at 0 while it moves over the unchanged ones.
The parity pin: the prediction's size equals the terminal's
``added + updated``.
"""
root = _root(tmp_path, f"{PREFIX}incremental", _FOUR_FILES)
seed = _run(db, root)
assert seed.summary.added == 4
(root / "notes" / "d.md").write_text(
"# D\n\ndelta content EDITED on disk\n", encoding="utf-8"
)
run = _run(db, root)
assert (run.summary.files, run.summary.updated, run.summary.unchanged) == (
4,
1,
3,
)
assert [(c.rel, c.done, c.total) for c in run.progress] == [
("a.md", 0, 1),
("b.md", 0, 1),
("c.md", 0, 1),
("notes/d.md", 1, 1),
]
assert run.progress[-1].total == run.summary.added + run.summary.updated
def test_two_changed_files_count_down_to_zero(
db: Session, tmp_path: Path
) -> None:
"""The count the owner asked for, at the scale the label is read at.
Two changed files among four: the denominator is 2 from the first report,
the position advances only at the predicted files (the unchanged walk
between them holds it), and ``total - done`` reaches 0 exactly when the
last predicted file starts — where phase 193's ``N ≥ 1`` guard hides the
count part. The terminal reports the same two files as updated."""
root = _root(tmp_path, f"{PREFIX}two", _FOUR_FILES)
_run(db, root)
(root / "a.md").write_text("# A\n\nalpha edited\n", encoding="utf-8")
(root / "notes" / "d.md").write_text(
"# D\n\ndelta edited\n", encoding="utf-8"
)
run = _run(db, root)
assert (run.summary.updated, run.summary.unchanged) == (2, 2)
assert [(c.rel, c.done, c.total) for c in run.progress] == [
("a.md", 1, 2),
("b.md", 1, 2), # unchanged: the walk moves, the count holds
("c.md", 1, 2),
("notes/d.md", 2, 2),
]
remaining = [c.total - c.done for c in run.progress]
assert remaining == [1, 1, 1, 0], (
f"the label's remaining count went {remaining} — it must hold across "
"the unchanged files and reach 0 at the last predicted one (where "
"phase 193's guard hides the part)"
)
# --------------------------------------------------------------------------
# Zero entity hydration
# --------------------------------------------------------------------------
def test_prescan_reads_one_column_and_hydrates_nothing(
db: Session, tmp_path: Path, statement_capture: list[str]
) -> None:
"""The scan's statement projects EXACTLY ``documents.file_hash`` and the
run's session is still empty when the walk begins.
Three pins on the same claim, because the failure mode is the 2026-10-15
incident (a full walk hydrating 252 MB of markdown):
* every ``documents`` ``(source, path)`` SELECT issued before the first
``progress`` call — i.e. by the scan — names ``file_hash`` and NOTHING
else, no ``content`` above all (the phase-185 column-list technique);
* exactly one such read per importable file;
* ``len(session.identity_map) == 0`` at the first ``progress`` call: the
scan put no ``Document`` anywhere, so the run's retention stays bounded
by the phase-184 cadence instead of growing with the KB.
"""
root = _root(tmp_path, f"{PREFIX}hydrate", _FOUR_FILES)
_run(db, root)
db.expunge_all()
statement_capture.clear()
map_at_first_progress: list[int] = []
def progress(source: str, rel: str, done: int, total: int) -> None: # noqa: ARG001
map_at_first_progress.append(len(db.identity_map))
summary = asyncio.run(
import_sources(
[root], _llm(), session=db, progress=progress, on_scan_progress=None
)
)
assert summary.unchanged == 4
# The scan reads precede the loop's row loads in capture order, and the
# first progress call is the boundary between them. Classified on the
# PROJECTION (phase 185's technique): the scan's projection is the single
# column token, the fast path's is every column but ``content``.
def predicate(stmt: str) -> str:
"""Everything after the statement's ``FROM`` (case-insensitive —
SQLAlchemy compiles the keyword upper-case)."""
return re.split(r"\bfrom\s+documents\b", stmt, maxsplit=1, flags=re.I)[-1]
doc_reads = [
stmt
for stmt in statement_capture
if stmt.lstrip().lower().startswith("select")
and re.search(r"\bfrom\s+documents\b", stmt, re.I)
and "documents.source" in predicate(stmt)
and "documents.path" in predicate(stmt)
]
def projection(stmt: str) -> set[str]:
head = re.split(r"\bfrom\b", stmt, maxsplit=1, flags=re.I)[0]
return set(re.findall(r"documents\.([a-z_]+)", head))
scan_reads = [s for s in doc_reads if projection(s) == {"file_hash"}]
row_loads = [s for s in doc_reads if projection(s) != {"file_hash"}]
assert len(scan_reads) == 4, (
f"the unchanged run issued {len(scan_reads)} single-column file_hash "
"reads — the scan reads each importable file's hash exactly once"
)
assert len(row_loads) == 4, (
f"the loop issued {len(row_loads)} fast-path row loads — the scan must "
"not replace _doc_row_stmt, which stays the only ROW load (phase 185)"
)
assert all(
projection(s) == {"file_hash"} for s in scan_reads
), "a scan read projects more than file_hash — it would hydrate a row"
assert all(
"content" not in projection(s) for s in row_loads
), "the fast-path row load stopped deferring content (phase 185)"
assert map_at_first_progress and map_at_first_progress[0] == 0, (
"the run's session already held entities when the loop reached its "
"first file — the pre-scan leaked a row into the identity map"
)
# --------------------------------------------------------------------------
# The span is off the event loop (the phase-179 pattern)
# --------------------------------------------------------------------------
class _SlowHash:
"""The timing fixture: a PLAIN blocking ``_file_hash`` (like the real one
— the point under test is WHERE it runs), counting its calls.
The thread identity is NOT taken from here: ``_file_hash`` is called both
by the scan (worker thread) and by the processing loop (loop thread), so
the attribution comes from ``on_scan_progress``, which fires on the same
worker thread as the scan's own hashes."""
def __init__(self, real: Callable[[Path], str], block: float = HASH_BLOCK_S) -> None:
self.real = real
self.block = block
self.calls = 0
def __call__(self, path: Path) -> str:
self.calls += 1
time.sleep(self.block)
return self.real(path)
def test_prescan_span_does_not_park_the_event_loop(
db: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""THE phase-179 contract, re-pinned for the pre-scan.
A 4-file scan with a 60 ms blocking hash is ~250 ms of work that used to
be inline. The heartbeat coroutine polls every 5 ms; the ticks between two
consecutive scan reports prove the LOOP kept running while the worker
blocked (a regression that runs the scan inline parks the loop thread and
the heartbeat cannot tick — the task-01 git measurement was 0 ticks over
a 3 s clone). The thread identities are asserted too, so the pin fails on
a machine fast enough to hide the latency.
"""
root = _root(tmp_path, f"{PREFIX}freeze", _FOUR_FILES)
stub = _SlowHash(importer._file_hash)
monkeypatch.setattr(importer, "_file_hash", stub)
ticks = 0
scan_ticks: list[int] = []
scan_positions: list[tuple[int, int]] = []
scan_thread: list[int] = []
def scan_hook(position: int, total: int) -> None:
# Runs on the span's worker thread — the same thread that just hashed.
scan_positions.append((position, total))
scan_ticks.append(ticks)
scan_thread.append(threading.get_ident())
# A progress caller is what opens the gate (the CLI shape, ``progress=None``,
# would skip the scan entirely) — this is the UI sync's shape.
async def main() -> int:
nonlocal ticks
loop_ident = threading.get_ident()
async def heartbeat() -> None:
nonlocal ticks
while True:
await asyncio.sleep(0.005)
ticks += 1
hb = asyncio.create_task(heartbeat())
try:
await import_sources(
[root],
_llm(),
session=db,
progress=lambda s, r, d, t: None,
on_scan_progress=scan_hook,
)
finally:
hb.cancel()
return loop_ident
loop_ident = asyncio.run(main())
assert scan_positions == [(1, 4), (2, 4), (3, 4), (4, 4)]
assert scan_thread and loop_ident is not None
assert all(ident != loop_ident for ident in scan_thread), (
"the pre-scan ran on the event-loop thread — a long scan parks the "
"single uvicorn loop again (phase 179's defect class)"
)
deltas = [
b - a for a, b in zip([0, *scan_ticks], scan_ticks, strict=False)
]
assert min(deltas) >= MIN_TICKS_PER_FILE, (
f"the heartbeat ticked {deltas} times across consecutive scanned files "
f"(expected >= {MIN_TICKS_PER_FILE} each) — the loop was starved while "
"the scan blocked, so the span is not off-loop"
)
# --------------------------------------------------------------------------
# The cancel (phase 134): the run stops, the worker finishes
# --------------------------------------------------------------------------
def test_cancel_during_the_prescan_stops_the_run_while_the_worker_finishes(
db: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The phase-134 cooperative cancel, measured at the scan's await.
Cancelling the run ~80 ms into a ~250 ms scan raises ``CancelledError``
out of ``import_sources`` PROMPTLY (well before the scan's own remaining
work), NOTHING is indexed, and the worker thread runs to completion —
threads are not interruptible, and this is exactly why the span reads
through a session of its own instead of the run's (which the cancel
closes on its way out). The pre-phase-194 pipeline had no await here at
all, so this is the new boundary the UI's Stop button must survive.
"""
root = _root(tmp_path, f"{PREFIX}cancel", _FOUR_FILES)
stub = _SlowHash(importer._file_hash, block=0.12)
monkeypatch.setattr(importer, "_file_hash", stub)
real_prescan = importer._prescan_pending
worker_finished = threading.Event()
worker_results: list[Any] = []
def spy(*args: Any, **kwargs: Any) -> Any:
try:
result = real_prescan(*args, **kwargs)
worker_results.append(result)
return result
finally:
worker_finished.set()
monkeypatch.setattr(importer, "_prescan_pending", spy)
async def main() -> float:
# The hook runs on the WORKER thread, so it only appends to a list —
# the house hook contract (a GIL-atomic store). Touching an
# ``asyncio.Event`` from here would be a cross-thread loop call.
seen: list[tuple[int, int]] = []
def scan_hook(position: int, total: int) -> None:
seen.append((position, total))
task = asyncio.create_task(
import_sources(
[root],
_llm(),
session=db,
progress=lambda s, r, d, t: None,
on_scan_progress=scan_hook,
)
)
# Wait (from the loop) until the scan has reported its first file.
deadline = time.monotonic() + 10.0
while not seen and time.monotonic() < deadline:
await asyncio.sleep(0.005)
assert seen, "the pre-scan never reported a file"
await asyncio.sleep(0.08)
t0 = time.monotonic()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
return time.monotonic() - t0
cancel_latency = asyncio.run(main())
# The cancel is prompt: the run did NOT wait for the remaining scan work.
assert cancel_latency < 0.5, (
f"the cancelled run took {cancel_latency:.3f}s to unblock — it is "
"still waiting for the blocking scan worker"
)
# The worker is not interruptible: it finishes its own session's work.
assert worker_finished.wait(10.0), "the pre-scan worker never completed"
assert stub.calls == 4, (
f"the cancelled run's scan worker hashed {stub.calls} of 4 files — "
"the contract is that the worker runs to completion"
)
# And the pipeline that would have consumed the prediction never ran.
assert db.scalar(
select(select(Document).where(Document.source == root.name).exists())
) is False, "the cancelled run indexed documents — the import never started"