phase: 184_sync_bounded_session
Build and Push Containers / build-and-push-app (push) Successful in 1m53s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All gates green; no code changes were needed — the prior failure was a missing final report, not a test failure.

**Verified / implemented (phase 184 final pass)**
- All 4 tasks present in `.agents/phases/complete/184_sync_bounded_session/`; confirmed their output in the worktree: `sync_session_reset_files=200` / `sync_prune_batch=500` on `Settings` (+ non-negative validators), both `SettingMeta` rows under `"Import & sources"`, `.env.example` + `README.md` entries.
- `import_sources` expunges at **exactly two** sites (per-file loop top L972, source boundary L1012) — grep proves no third `expunge`/`expire_all` anywhere in `app/`; `app/db.py:30` untouched.
- `_prune` uses `load_only(id, source, path, is_image, image_path, page_count).order_by(Document.id)` + per-batch commits, with the phase-122/126 guards and `changed_paths` intact.

**Tests / lint / coverage (all 100% green)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4506 passed**, `app/` TOTAL **99%** (>90%).
- `uv run pytest tests/integration/test_import_session_retention.py -v --no-cov` → **12 passed**.
- `uv run pytest tests/e2e/test_sync_session_reset.py -v --no-cov` → **4 passed in isolation**.
- Regression sweep of neighbouring sync suites (each isolated): `test_sync_changed_only` 3, `test_sync_summary_visibility` 3, `test_prune_cascade_cleanup` 3, `test_sync_git_freeze` 2 — all passed.
- `uv run ruff check .` → All checks passed. `uv run pyright` → 0 errors, 0 warnings.

**Completion criteria**
1. Settings + CATALOG + `.env.example` + README; `test_settings_catalog.py` → ✅ green
2. Retention test green with the post-fix bound and both measured numbers in the docstring (pre-fix 12-row monotone staircase / ≈265 MB extrapolated; post-fix ends at 0, add-path peak 14 vs 21→49) → ✅
3. Expunge only at the two documented-safe points; light-row batched prune with guards intact → ✅
4. Every existing importer/sync/removal test green with unchanged expectations → ✅
5. Full suite green, coverage >90%, ruff + pyright clean → ✅
6. Phase E2E green in isolation → ✅
7. README + AGENTS accurate/consistent → ✅
8. Atomic commit — left to the harness (I ran no git write commands).

**Documentation** — Full README (1886 lines) + AGENTS.md (189 lines) review: **verified consistent — no changes needed.** Programmatic cross-checks: every `Settings` field appears in `.env.example` and in the README config reference; every `.env.example` var is documented in that reference; every `tests/…` and `app/…` path cited in README/AGENTS exists; all 10 README page URLs are registered in `app/main.py`; documented E2E port vars (`E2E_APP_PORT_SYNCRESET[_ZERO]`) exist in the test. The only reference-var without a `Settings` field is `BOR_THEME`, which the README explicitly documents as retired (correct as written).

**Notable decisions/deviations** — None. No code, test, or doc edits were made this pass; changes remain entirely in the working tree for the harness to stage. `00_phase.md` still sits in `todo/` while tasks 01–04 are in `complete/` — harness-owned, so I left it untouched.

**Next pending phase:** `185_sync_defer_content_fast_path`.
This commit is contained in:
2026-10-15 23:19:27 -04:00
parent 7b5ea7c2ee
commit 065304b52f
40 changed files with 3512 additions and 13 deletions
+130 -6
View File
@@ -346,7 +346,7 @@ from typing import Any, Protocol
import pymupdf as fitz # PyMuPDF (phase 126, locked A1); `fitz` = the stable API name
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, load_only
from app.config import Settings
from app.db import SessionLocal
@@ -867,6 +867,14 @@ async def import_sources(
failure leaves the row untouched — the next sync retries it).
Driven by committed doc rows, so it is safe under ``limit`` too
(it deletes nothing).
Bounded session (phase 184, task 02): the walk releases its SQLAlchemy
identity map every ``llm.settings.sync_session_reset_files`` files and
once per source before the two post-passes, so the run's resident set
scales with the CADENCE rather than with the size of the knowledge base
(``0`` = never — the exact pre-phase behaviour, the operator's
no-redeploy rollback). The cadence changes no count, no log line and no
committed byte: see the safety argument at the reset site.
"""
if limit is not None and limit <= 0:
raise ValueError("limit must be >= 1")
@@ -875,6 +883,12 @@ async def import_sources(
# toggle is on — the empty set admits nothing, so the toggle-off run
# (walk, counts, prune) stays byte-identical to pre-phase.
image_exts = llm.settings.image_extension_set if llm.settings.images else frozenset()
# Phase 184 (task 02): the identity-map release cadence, read ONCE here —
# before the source loop — so a settings overlay saved mid-run cannot
# change the cadence between two files of the same walk (the importer
# reads ``llm.settings`` per RUN, never per file). ``0`` disables the
# release entirely.
reset_every = llm.settings.sync_session_reset_files
owns_session = session is None
if session is None:
session = SessionLocal()
@@ -922,6 +936,40 @@ async def import_sources(
):
if limit is not None and summary.files >= limit:
break
# Phase 184 (task 02): the identity-map release. There is NO
# automatic one — ``expire_on_commit=False`` (``app/db.py:30``)
# means a commit drops nothing, and ``_unchanged_doc``'s
# ``doc.file_hash = file_digest`` stamp marks every
# already-current row DIRTY, which puts it in the session's
# own strong ``dirty`` set (cyclic GC cannot reach it, see
# ``tests/integration/test_import_session_retention.py``).
# Without this the run holds every Document — its full
# ``content`` included — it walked until the import ENDED:
# the 2026-10-15 incident's second driver, and the reason
# memory grew with the KB rather than with the work.
#
# This is the ONLY safe point, and WHY it is safe:
# ``expunge_all()`` discards UNFLUSHED changes, so it may run
# only where every real write of the finished file is already
# committed. Here the previous file's ``_index_file`` has
# RETURNED — each path commits per file (the changed path
# commits its chunks, ``_store_summary`` commits, and
# ``_unchanged_doc`` commits its date refresh, its stamp and
# whatever the phase-163 strip re-gate wrote) — and what
# ``expunge_all`` then drops is at most the fast path's stamp
# of a value the database ALREADY holds (no UPDATE was
# pending: ``session.is_modified`` is false for it). Nothing
# downstream reaches back into a finished file: ``_index_file``
# returns ``None``, the progress/chunk/image hooks only assign
# fields on the sync's own status object, ``dates_map``/
# ``ignore``/``seen`` are plain data, and ``total`` was
# computed before the loop. The ``EmbeddingError`` handler
# below calls ``session.rollback()`` and touches no object of
# its own, so detached state is irrelevant to it.
# ``reset_every == 0`` → never (the exact pre-phase
# behaviour, the operator's rollback knob).
if reset_every and summary.files and summary.files % reset_every == 0:
session.expunge_all()
rel = path.relative_to(root).as_posix()
seen.add((source, rel))
summary.files += 1
@@ -955,6 +1003,13 @@ async def import_sources(
session.rollback()
summary.errors += 1
logger.error("import: error source=%s path=%s — %s", source, rel, e)
# Phase 184 (task 02): the source-boundary release — one source's
# walk residue never crosses into the next source. Safe for the
# same reason as the per-file reset and additionally because BOTH
# post-passes below select the rows they need fresh (their own
# ``select(Document)``), so no walk object has to survive it.
if reset_every:
session.expunge_all()
# Phase 125 (task 04): the post-index enrichment pass — the
# markdown-FIRST case (a markdown indexed while its
# referenced image had no document yet recorded the
@@ -980,6 +1035,10 @@ async def import_sources(
session, source_names, seen, images=llm.settings.images,
summary=summary,
page_dir=Path(llm.settings.pdf_page_dir).expanduser(),
# Phase 184 (task 03): the commit cadence, read ONCE
# here (the same per-RUN rule as the identity-map
# reset) — ``0`` = the pre-phase single commit.
prune_batch=llm.settings.sync_prune_batch,
)
summary.embed_batches = llm.embed_batches
summary.log()
@@ -3864,6 +3923,7 @@ def _prune(
images: bool = False,
summary: ImportSummary | None = None,
page_dir: Path | None = None,
prune_batch: int = 500,
) -> int:
"""Delete documents of *source_names* whose file is no longer in *seen*.
@@ -3910,10 +3970,51 @@ def _prune(
chunk row, never hydrates a 768-dim embedding, and a mass prune costs
documents instead of chunks × 24.8 KiB (the 2026-10-15 incident: a
3,008-doc prune materialised ~720 MB to delete 72k rows and locked up
a 3,904 MB VM with no swap). The ``changed_paths`` feed and the
image/page copy cleanup above are unchanged, and they only ever read
a 3,904 MB VM with no swap). That fix removed the CHILD rows; a SECOND
driver of the same incident stood on this very walk, and phase 184
(task 03) removed it with the two contracts below.
Phase 184 (task 03) — the light-row contract. The walk below selects
through ``load_only`` and is ordered by ``Document.id``, so its resident
set is independent of the knowledge base: ``content`` averages 15.2 KB
per row (252 MB of markdown across the incident KB), and loading it for
every document of every pruned source — to decide a deletion and then
throw the string away — was that driver. The SIX columns loaded are
exactly what the guards and the cleanup read: ``is_image`` (the
phase-122 images-off toggle guard), ``path`` (that same guard's
``seen`` tuple and the phase-126 ``_EMBEDDED_IMAGE_RE`` parent-PDF
rule), ``source`` (the ``seen`` tuple, ``changed_paths`` and the log
line), ``image_path`` (:func:`_delete_image_copy`), ``id`` +
``page_count`` (:func:`_delete_page_copies`) — and ``content`` is NOT
among them (nor ``summary``, ``full_path``, ``title``,
``image_context``, either hash, or any date). Reading anything else
inside the loop would emit one lazy SELECT per document and undo the
bound; ``tests/integration/test_import_session_retention.py`` pins both
halves (no ``documents.content`` in the SELECT, exactly ONE
``FROM documents`` SELECT for the whole walk). Phase 185 does the same
job for the walk's fast path.
Phase 184 (task 03) — the batch-commit contract. *prune_batch* is
``Settings.sync_prune_batch`` (``BOR_SYNC_PRUNE_BATCH``, read once by
the caller from ``llm.settings``): the loop commits every Nth delete,
so a long prune no longer holds one enormous transaction and its rows
are released as it goes. ``prune_batch=0`` is the exact pre-phase
behaviour — ONE commit after the whole loop — the operator's
no-redeploy rollback (LOCKED A3).
**Atomicity (LOCKED A2, deliberate).** With a non-zero batch a crash
mid-prune leaves a PREFIX pruned instead of rolling the whole prune
back. That is safe HERE, and only here: ``seen`` is recomputed from the
filesystem on every run, so the next sync converges on whatever this
one left, and the import already committed per file throughout the walk
— a run-level transaction never existed to lose. ``summary.pruned``
stays the count of documents actually deleted, unchanged in meaning.
What phase 183 left in place is unchanged: the ``changed_paths`` feed
and the image/page copy cleanup above, which only ever read
``doc.image_path`` / ``doc.page_count`` / ``doc.source`` / ``doc.path``
— never ``doc.chunks``. The **re-embed** path is the deliberate
— never ``doc.chunks`` — and they are exactly the reads the six light
columns cover. The **re-embed** path is the deliberate
exception (LOCKED A2): :func:`_replace_content_chunks` reassigns
``doc.chunks = [...]``, which is collection REPLACEMENT (the ORM must
know which old rows to orphan) and legitimately needs the loaded
@@ -3923,7 +4024,25 @@ def _prune(
if not source_names:
return 0
pruned = 0
docs = session.scalars(select(Document).where(Document.source.in_(source_names))).all()
# Phase 184 (task 03): the light-row walk — see the docstring's
# light-row contract. SIX columns, ``content`` deliberately absent
# (252 MB of markdown per full KB walk), deterministic order so the
# batch prefix is reproducible.
docs = session.scalars(
select(Document)
.options(
load_only(
Document.id,
Document.source,
Document.path,
Document.is_image,
Document.image_path,
Document.page_count,
)
)
.where(Document.source.in_(source_names))
.order_by(Document.id)
).all()
for doc in docs:
if (doc.source, doc.path) not in seen:
if doc.is_image and not images:
@@ -3948,6 +4067,11 @@ def _prune(
session.delete(doc)
pruned += 1
logger.info("import: pruned source=%s path=%s", doc.source, doc.path)
if pruned:
# Phase 184 (task 03): a long prune no longer holds a huge
# transaction — commit the batch (``prune_batch=0`` → never
# here, the single commit below, i.e. the pre-phase run).
if prune_batch and pruned and pruned % prune_batch == 0:
session.commit()
if pruned and not (prune_batch and pruned % prune_batch == 0):
session.commit()
return pruned