phase: 183_sync_passive_delete_cascade
All gates green. Final state verified.
**Implemented / verified**
- Task 03's work was **never actually done** (its report was a stub, `tests/e2e/test_prune_cascade_cleanup.py` did not exist) → wrote it: 3 tests — 9-file local source synced from the Sources page (`9 added`), 6 files deleted upstream, re-sync (`0 added · 3 unchanged · 6 pruned`), documents vanished from the tree + `GET /api/documents/content` 404 + total 3, **0 orphan chunk rows** via the test's own SQL, survivor chunk total unchanged; partial-prune chat turn (grounded, non-deflected, `query_log` names the survivor and no pruned doc); total source removal (`DELETE /api/git-sources/{id}`) wipes every doc+chunk of the source with 0 orphans, sibling source untouched. Mock-only (`E2E_REAL_LLM=1` skipped), own port 8199, truncate before/after.
- Tasks 01/02 re-verified: `passive_deletes=True` + mapper pin, probe flipped to `EXPECTED_CHUNK_SELECTS_PER_PRUNED_DOC = 0`, `_prune`/`delete_git_source` docstrings.
- **Drift guard proven non-tautological by my own one-off run**: scratch DB (`alembic upgrade head`) → FK `confdeltype='c'`, guard PASSES, CASCADE delete leaves 0 chunks; FK recreated `ON DELETE NO ACTION` → guard FAILS verbatim and a `passive_deletes` delete raises `ForeignKeyViolation` (never silent orphans). Scratch DB dropped; shared test DB untouched (`'c'`).
**Gates**
- `uv run pytest --cov=app --cov-report=term-missing` → **4488 passed**, `app/` TOTAL **99%** (>90%)
- `uv run pytest tests/integration/test_prune_cascade_statements.py -v` → 4 passed
- `uv run pytest tests/e2e/test_prune_cascade_cleanup.py -v --no-cov` → **3 passed in isolation**; `test_source_removal_cleanup.py` 6 passed, `test_force_push_resync.py` 2 passed (both isolated)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
**Completion criteria**: passive_deletes+pin ✅ · probe post-fix ✅ · drift guard green + proven-failing variant ✅ · full suite/coverage/lint ✅ · E2E in isolation ✅ · docs consistent ✅ · commit = harness-only (per pipeline rule I ran no git write commands).
**Docs**: fixed 4 pre-existing README inconsistencies — per-module E2E ports (claim of "one pair of fixed ports" predates phase 79), debugpy `launch.json` snippet vs the shipped file (`name`/`remoteRoot`), and the page-naming wobble (navbar labels `/sources.html` **RAG**, `/git-sources.html` **Sources** — the Sync button's home is now named correctly in 4 places); added the phase-183 E2E pointer. AGENTS.md verified accurate (rule 12 matches the delete-site audit).
**Deviation**: none from the locked decisions; the E2E is a behaviour pin (byte-identical owner-visible behaviour), the statement-count probe remains the memory teeth, as phase 183 D1/A4 specify.
**Next pending phase**: `184_sync_bounded_session`.
This commit is contained in:
@@ -860,8 +860,11 @@ async def delete_git_source(
|
||||
"""Remove a stored source — **totally** (phase 69); 404 unknown id.
|
||||
|
||||
Total removal, one action: the row, every indexed document of the
|
||||
source (chunks + embeddings via the ``all, delete-orphan`` cascade
|
||||
— ``app.models``), and, for app-managed sources, the on-disk dir
|
||||
source (chunks + embeddings dropped **in the database** by the
|
||||
``chunks_document_id_fkey`` ``ON DELETE CASCADE`` — phase 183,
|
||||
``Document.chunks`` is ``passive_deletes=True``, so the endpoint
|
||||
never loads a chunk row or its vector), and, for app-managed
|
||||
sources, the on-disk dir
|
||||
(the git checkout or the unpacked upload folder). Foreign local
|
||||
directories (the owner's own) are never touched on disk — their row
|
||||
+ index entries still go.
|
||||
|
||||
+11
-1
@@ -225,7 +225,17 @@ class Document(Base):
|
||||
page_strip_mode: Mapped[str | None] = mapped_column(String(16), default=None)
|
||||
|
||||
chunks: Mapped[list[Chunk]] = relationship(
|
||||
back_populates="document", cascade="all, delete-orphan"
|
||||
back_populates="document",
|
||||
cascade="all, delete-orphan",
|
||||
# Phase 183: deleting a Document removes its chunks IN THE DATABASE
|
||||
# (chunks_document_id_fkey is ON DELETE CASCADE — alembic 0001 L54,
|
||||
# pinned live by tests/integration/test_prune_cascade_statements.py).
|
||||
# WITHOUT this keyword the ORM loads every child before deleting it,
|
||||
# which hydrates every 768-dim embedding (~24.8 KiB of Python each):
|
||||
# a 3,008-document prune cost ~720 MB and took the 2026-10-15 VM
|
||||
# (3,904 MB, no swap) down. Do not remove this without re-checking
|
||||
# the FK — the drift guard fails the suite if the cascade is gone.
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3902,6 +3902,23 @@ def _prune(
|
||||
image copy — no orphans when a page-qualifying PDF (its parent or
|
||||
a standalone doc) leaves the source. Callers passing nothing keep
|
||||
the pre-phase copy-cleanup contract.
|
||||
|
||||
Phase 183 — why the ``session.delete`` below costs O(documents).
|
||||
``Document.chunks`` declares ``passive_deletes=True``, so the chunk
|
||||
rows go **in the database** (``chunks_document_id_fkey`` is
|
||||
``ON DELETE CASCADE`` — alembic 0001 L54): this walk never SELECTs a
|
||||
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
|
||||
``doc.image_path`` / ``doc.page_count`` / ``doc.source`` / ``doc.path``
|
||||
— never ``doc.chunks``. 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
|
||||
collection — ``passive_deletes`` does not cover it, and CHANGED files
|
||||
are the only ones that walk it.
|
||||
"""
|
||||
if not source_names:
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user