phase: 184_sync_bounded_session
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:
+130
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user