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:
@@ -0,0 +1,667 @@
|
||||
"""Phase 183 E2E (Playwright, mock-only): a mass prune leaves nothing behind.
|
||||
|
||||
Source: owner incident 2026-09-15 → 2026-10-15 — the brain-of-code sync
|
||||
pruned **3,008 litellm docs** and the app hit 4 GB of RAM, locking up a
|
||||
3,904 MB VM (no swap, sharing RAM with a 3.1 GB Postgres) hard enough that
|
||||
boot −1 has no shutdown record at all. The cause was NOT the delete itself:
|
||||
``Document.chunks`` carried ``cascade="all, delete-orphan"`` with **no
|
||||
``passive_deletes``**, so every ``session.delete(doc)`` first SELECTed the
|
||||
document's whole chunk collection — hydrating every 768-dim
|
||||
``chunks.embedding`` (~24.8 KiB of Python each vs 3 KiB on disk) purely to
|
||||
be thrown away, and nothing was released before the single commit
|
||||
(``app/db.py`` builds sessions with ``expire_on_commit=False``).
|
||||
|
||||
The fix under test: ``Document.chunks`` is now ``passive_deletes=True``, so
|
||||
the rows go **in the database** through ``chunks_document_id_fkey``'s
|
||||
``ON DELETE CASCADE`` (alembic 0001 L54) and Python never sees them. That
|
||||
is a *display-of-work* change with exactly one owner-visible contract: the
|
||||
prune must still remove **every chunk row of every pruned document** — zero
|
||||
orphans — while changing nothing else (the counts line, the KB tree, the
|
||||
surviving RAG). This module pins that contract end to end, through the
|
||||
pipeline the owner actually drives (the Sources page Sync button) and
|
||||
through the admin's total source removal.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_prune_cascade_cleanup.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is skipped — the gate is the deterministic
|
||||
prune count + the exact orphan-row query, and test 2's grounding needs the
|
||||
mock-calibrated relevance thresholds (the conftest convention).
|
||||
|
||||
Per-module app env (the ``test_local_directory_sources.py`` /
|
||||
``test_force_push_resync.py`` module-app pattern): this module's app boots
|
||||
on its OWN port (the conftest session app owns 8123 in a combined run; a
|
||||
same-port second uvicorn dies on bind and would drive the wrong server),
|
||||
against the mock LLM, with a dedicated ``BOR_SOURCES_DIR`` /
|
||||
``BOR_PDF_PAGE_DIR`` under ``tmp_path_factory`` and ``BOR_GIT_SOURCES``
|
||||
forced empty (the owner's local gitignored ``.env`` must never leak a real
|
||||
source into the run — the registry rows this suite creates through the
|
||||
authenticated API are the only sources). The fixture KB is a plain
|
||||
(non-git) host directory per test under ``tmp_path`` — the app server runs
|
||||
on the same host, so the path is visible to it — with the stable basename
|
||||
:data:`SOURCE_NAME` (``kind='local'`` rows index under
|
||||
``Path(path).name``, ``app/rag/source_removal.py``) so the SQL assertions
|
||||
name one source whatever the pytest temp root is.
|
||||
|
||||
Isolation: the shared E2E Postgres is truncated BEFORE **and** AFTER every
|
||||
test (``chunks, documents, query_log, kb_overview, git_sources,
|
||||
sources_meta, folder_summaries``) — a leftover registry row would flip a
|
||||
sync onto another suite's source, and a leftover document under this
|
||||
suite's deterministic source name would survive the prune and falsify the
|
||||
count assertions.
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_deleted_files_prune_without_orphans`` — sync 9 files (the counts
|
||||
line reports "9 added"), delete 6 of them upstream, re-sync: the counts
|
||||
line reports "0 added · 3 unchanged · 6 pruned", the 6 documents are
|
||||
gone from the KB (the tree row is absent, ``GET /api/documents/content``
|
||||
404s the pair, ``GET /api/docs`` holds exactly the 3 survivors), the
|
||||
pruned documents' **chunk rows are all gone** (the test's own SQL — the
|
||||
no-orphan half of the phase-183 D4 contract) and the surviving chunk
|
||||
total is exactly the sum of the 3 survivors' chunks.
|
||||
2. ``test_surviving_kb_still_answers_after_a_partial_prune`` — the same
|
||||
pruned state answers a chat question about a SURVIVING document: the
|
||||
turn settles with a real answer (no error state, no empty bubble, the
|
||||
composer recovered) and the durable ``query_log`` row is NOT deflected
|
||||
and records the surviving document — a partial prune never damages the
|
||||
RAG.
|
||||
3. ``test_source_removal_prunes_every_document_of_the_source`` — the admin
|
||||
path (``DELETE /api/git-sources/{id}``, ``app/api/git_sources.py``): the
|
||||
second mass-delete surface ``passive_deletes=True`` covers. Every
|
||||
document AND every chunk row of the removed source is gone (the same
|
||||
orphan query), the count matches what was indexed there, the row is
|
||||
gone, and the OTHER registered source's documents and chunks are
|
||||
untouched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document, QueryLog
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||
# port in a combined run — this module app binds its own port instead.
|
||||
# Env-overridable.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_PRUNECASCADE", "8199"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
SOURCES_URL = "/sources.html"
|
||||
|
||||
#: The fixture directory's basename — the ``source`` the importer records
|
||||
#: for a ``kind='local'`` row (``Path(path).name``), so it is the same
|
||||
#: string in the DB whatever ``tmp_path`` this test got.
|
||||
SOURCE_NAME = "prune-cascade"
|
||||
KEEPER_NAME = "prune-keeper"
|
||||
|
||||
#: The fixture layout: 9 importable files across one folder, 6 of which
|
||||
#: the test deletes upstream (the incident's shape — a bulk deletion in a
|
||||
#: synced source — scaled to what a test sync walks in seconds).
|
||||
KEEP_PATHS = [f"notes/keep-{n}.md" for n in (1, 2, 3)]
|
||||
GONE_PATHS = [f"notes/gone-{n}.md" for n in range(1, 7)]
|
||||
ALL_PATHS = KEEP_PATHS + GONE_PATHS
|
||||
|
||||
#: The sentinel inside the FIRST survivor — test 2's question is answerable
|
||||
#: ONLY from that document, so a grounded, non-deflected turn proves the
|
||||
#: surviving chunk rows are still retrievable after the prune.
|
||||
SENTINEL_DOC = KEEP_PATHS[0]
|
||||
SENTINEL = "warden-lighthouse-RESE-PRUNECASCADE-7f31"
|
||||
QUESTION = "Which keeper tends the warden lighthouse beacon log?"
|
||||
|
||||
#: The mock LLM's byte-stable grounded-answer marker (``mock_llm.
|
||||
#: compose_answer``'s fallback branch) — its presence proves a real,
|
||||
#: grounded answer streamed (the DEFLECT_MODE branch answers with
|
||||
#: different, deterministic text).
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
|
||||
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||
#: fmtSyncTime), any hour/minute.
|
||||
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: the mock LLM, the
|
||||
mock-calibrated gate (so test 2's grounding is deterministic), an
|
||||
empty ``BOR_GIT_SOURCES`` fallback, and scratch
|
||||
``BOR_SOURCES_DIR`` / ``BOR_PDF_PAGE_DIR`` under
|
||||
``tmp_path_factory`` (nothing in this suite clones a repo or renders a
|
||||
PDF — the dirs are set so the run never writes into the operator's
|
||||
own). The session app is never started in this isolated run, so no
|
||||
port clash."""
|
||||
if USE_REAL_LLM:
|
||||
pytest.skip(
|
||||
"mock-only suite — the grounding pin is the mock's byte-stable "
|
||||
"answer on the mock-calibrated gate (E2E_REAL_LLM unsupported)"
|
||||
)
|
||||
scratch = tmp_path_factory.mktemp("bor_prune_cascade")
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated gate (the conftest pattern, phase 112/113): the
|
||||
# token-overlap mock embeddings score far below the production 0.62,
|
||||
# and BOTH floors must stay <= the threshold or the app refuses to
|
||||
# boot (app/config.py's typo guards).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# No env fallback rows: the registry this suite writes through the
|
||||
# authenticated API is the only source list, so every sync's counts
|
||||
# are exactly this test's doing.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_PDF_PAGE_DIR"] = str(scratch / "pdf-pages")
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning",
|
||||
],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB per test (the E2E isolation pattern,
|
||||
``test_source_removal_cleanup.py`` / ``test_force_push_resync.py``).
|
||||
``sources_meta`` (the KB generation counter) and
|
||||
``folder_summaries`` reset with the KB — the sync's overview/bump is an
|
||||
upsert, and a leftover folder summary would ride the very
|
||||
``changed_paths`` feed the prune writes."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, kb_overview, "
|
||||
"git_sources, sources_meta, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(db_ready: None) -> Iterator[None]:
|
||||
_truncate_all()
|
||||
yield
|
||||
_truncate_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fixture source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _doc_body(path: str) -> str:
|
||||
"""One small markdown file per fixture path. The FIRST survivor is the
|
||||
ONLY file carrying the sentinel's words, so test 2's question resolves
|
||||
to exactly one document (and the mock's token-overlap cosine —
|
||||
``sqrt(shared/total)`` — clears the mock-calibrated 0.30 gate only for
|
||||
that one)."""
|
||||
if path == SENTINEL_DOC:
|
||||
return (
|
||||
"# Keeper log\n"
|
||||
"\n"
|
||||
"The keeper tends the warden lighthouse beacon log.\n"
|
||||
f"Marker: {SENTINEL}\n"
|
||||
)
|
||||
return (
|
||||
f"# {path}\n"
|
||||
"\n"
|
||||
f"One unrelated note from the {SOURCE_NAME} fixture source.\n"
|
||||
)
|
||||
|
||||
|
||||
def _make_source_dir(root: Path, name: str, paths: list[str]) -> Path:
|
||||
"""A plain (non-git) directory of importable files — the shape the
|
||||
owner's local sources have. Returns the registered root."""
|
||||
base = root / name
|
||||
for rel in paths:
|
||||
target = base / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(_doc_body(rel), encoding="utf-8")
|
||||
assert not (base / ".git").exists()
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: the DB side of the contract (the phase-183 D4 assertions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _doc_ids(db: Session, source: str, paths: list[str]) -> list[str]:
|
||||
"""The indexed document ids of ``(source, path)``, sorted."""
|
||||
return sorted(
|
||||
str(doc_id)
|
||||
for (doc_id,) in db.execute(
|
||||
select(Document.id).where(
|
||||
Document.source == source, Document.path.in_(paths)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _chunk_count(db: Session, document_ids: list[str]) -> int:
|
||||
"""The chunk rows still belonging to ``document_ids`` — THE orphan
|
||||
probe. Non-zero means the documents are gone and their embeddings (and
|
||||
their FTS rows) stayed behind."""
|
||||
if not document_ids:
|
||||
return 0
|
||||
total = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Chunk)
|
||||
.where(Chunk.document_id.in_([uuid.UUID(x) for x in document_ids]))
|
||||
)
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
def _total_chunks(db: Session) -> int:
|
||||
return int(db.scalar(select(func.count()).select_from(Chunk)) or 0)
|
||||
|
||||
|
||||
def _chunk_counts_by_doc(
|
||||
db: Session, source: str, paths: list[str] | None = None
|
||||
) -> dict[str, int]:
|
||||
"""``path`` → chunk count, for the surviving-total assertion."""
|
||||
query = (
|
||||
select(Document.path, func.count(Chunk.id))
|
||||
.outerjoin(Chunk, Chunk.document_id == Document.id)
|
||||
.where(Document.source == source)
|
||||
.group_by(Document.path)
|
||||
)
|
||||
if paths is not None:
|
||||
query = query.where(Document.path.in_(paths))
|
||||
rows = db.execute(query).all()
|
||||
return {str(path): int(count) for path, count in rows}
|
||||
|
||||
|
||||
def _sources_in_db(db: Session) -> list[str]:
|
||||
return sorted(
|
||||
{
|
||||
str(name)
|
||||
for (name,) in db.execute(
|
||||
select(Document.source).distinct()
|
||||
).all()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: the UI / API side
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _started_at(body: dict[str, Any]) -> float:
|
||||
"""The status body's ``started_at`` as a wall-clock epoch (the server
|
||||
stamps it in UTC, this process runs on the same host — the
|
||||
``test_git_source_branch`` idiom)."""
|
||||
started = body.get("started_at")
|
||||
if not started:
|
||||
return 0.0
|
||||
try:
|
||||
return datetime.fromisoformat(str(started)).timestamp()
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _sync_from_sources_page(page: Page, app_url: str) -> dict[str, Any]:
|
||||
"""Click the Sources page **Sync sources** button and wait for the
|
||||
run's TERMINAL state (the ``test_force_push_resync._wait_sync_state``
|
||||
pattern: the module app is shared across a test's two runs, so a
|
||||
terminal is accepted only after a ``running`` tick was seen or its
|
||||
``started_at`` post-dates the click). Settles the UI on success
|
||||
(``#sync-label`` → "Synced HH:MM" — the never-stale contract, §7.4)."""
|
||||
login(page, app_url, next=SOURCES_URL)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible()
|
||||
clicked_at = time.time()
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
|
||||
deadline = time.monotonic() + 90.0
|
||||
saw_running = False
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = page.request.get(f"{app_url}/api/sync/status")
|
||||
assert r.status == 200, r.text
|
||||
body = r.json()
|
||||
state = body["state"]
|
||||
if state == "running":
|
||||
saw_running = True
|
||||
elif state in ("success", "failed") and (
|
||||
saw_running or _started_at(body) >= clicked_at - 1.0
|
||||
):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise AssertionError(f"sync never reached a terminal state: {body}")
|
||||
|
||||
assert body["state"] == "success", f"sync failed: {body}"
|
||||
expect(btn).to_be_enabled(timeout=15_000)
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
# The counts line is the owner-visible prune report (phase 150 — the
|
||||
# single sync text surface, sources.js fmtSyncResult).
|
||||
return body
|
||||
|
||||
|
||||
def _expect_counts_line(page: Page, expected: str) -> None:
|
||||
"""The Sources page's counts line reads exactly ``expected`` —
|
||||
"9 added" / "0 added · 3 unchanged · 6 pruned"."""
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(expected, timeout=15_000)
|
||||
|
||||
|
||||
def _add_local_source(page: Page, app_url: str, path: Path) -> str:
|
||||
"""Register a local directory through the authenticated API (the
|
||||
phase-49 shape — the page's own form for this is gone; the admin
|
||||
cookie rides ``page.request``). Returns the row id."""
|
||||
r = page.request.post(
|
||||
f"{app_url}/api/git-sources",
|
||||
data={"kind": "local", "path": str(path)},
|
||||
)
|
||||
assert r.status == 201, f"expected 201 for {path}: {r.status} {r.text}"
|
||||
row_id = str(r.json()["id"])
|
||||
assert row_id
|
||||
return row_id
|
||||
|
||||
|
||||
def _api_docs(page: Page, app_url: str) -> list[dict[str, Any]]:
|
||||
"""``GET /api/docs`` as the signed-in page (admin cookie rides along)."""
|
||||
r = page.request.get(f"{app_url}/api/docs")
|
||||
assert r.status == 200, r.text
|
||||
return list(r.json()["documents"])
|
||||
|
||||
|
||||
def _api_doc_pairs(page: Page, app_url: str) -> list[tuple[str, str]]:
|
||||
return sorted((d["source"], d["path"]) for d in _api_docs(page, app_url))
|
||||
|
||||
|
||||
def _api_sources(page: Page, app_url: str) -> list[str]:
|
||||
"""The distinct ``source`` labels the KB holds, read through the
|
||||
signed-in API (the same view the owner gets on the Sources page)."""
|
||||
return sorted({d["source"] for d in _api_docs(page, app_url)})
|
||||
|
||||
|
||||
def _drill_to_fixture_folder(page: Page) -> None:
|
||||
"""Open the KB catalogue down to the fixture's ``notes`` folder (the
|
||||
drill-down tree is the phase-97 shape, the
|
||||
``test_force_push_resync._expect_fixture_row`` idiom)."""
|
||||
crumb = page.locator("#kb-crumb")
|
||||
if not crumb.is_hidden():
|
||||
page.click("#kb-crumb a.kb-crumb-link")
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{SOURCE_NAME}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("notes")')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Upstream deletion + re-sync: the prune is exact and leaves no orphans
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deleted_files_prune_without_orphans(
|
||||
page: Page, app_url: str, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
"""The incident's own shape, end to end: 9 files sync, 6 are deleted
|
||||
upstream, the re-sync prunes exactly those 6 — through the Sources page
|
||||
Sync button, with the counts line as the owner-visible report — and the
|
||||
pruned documents' chunk rows are gone FROM THE DATABASE (the phase-183
|
||||
D4 no-orphan contract, asserted by this test's own SQL)."""
|
||||
page.set_default_timeout(30_000)
|
||||
source = _make_source_dir(tmp_path, SOURCE_NAME, ALL_PATHS)
|
||||
|
||||
login(page, app_url, next=SOURCES_URL)
|
||||
_add_local_source(page, app_url, source)
|
||||
|
||||
# --- sync 1: everything is new ------------------------------------
|
||||
body = _sync_from_sources_page(page, app_url)
|
||||
assert body["detail"]["added"] == len(ALL_PATHS), body
|
||||
assert body["detail"]["pruned"] == 0, body
|
||||
_expect_counts_line(page, f"{len(ALL_PATHS)} added")
|
||||
assert _api_doc_pairs(page, app_url) == [
|
||||
(SOURCE_NAME, path) for path in sorted(ALL_PATHS)
|
||||
]
|
||||
|
||||
# The subject of the prune, captured BEFORE it happens: the 6 doomed
|
||||
# document ids + every survivor's chunk count. The orphan query below
|
||||
# needs ids that no longer exist by the time it runs.
|
||||
with SessionLocal() as db:
|
||||
gone_ids = _doc_ids(db, SOURCE_NAME, GONE_PATHS)
|
||||
keep_counts = _chunk_counts_by_doc(db, SOURCE_NAME, KEEP_PATHS)
|
||||
assert _chunk_count(db, gone_ids) > 0, "the doomed docs must be embedded"
|
||||
assert len(gone_ids) == len(GONE_PATHS), "the doomed documents must exist first"
|
||||
assert all(keep_counts[p] > 0 for p in KEEP_PATHS), keep_counts
|
||||
assert sum(keep_counts.values()) > 0
|
||||
|
||||
# --- the upstream deletion (the test owns the source dir) ----------
|
||||
for rel in GONE_PATHS:
|
||||
(source / rel).unlink()
|
||||
assert not any((source / rel).exists() for rel in GONE_PATHS)
|
||||
|
||||
# --- sync 2: the prune ---------------------------------------------
|
||||
body = _sync_from_sources_page(page, app_url)
|
||||
assert body["detail"]["pruned"] == len(GONE_PATHS), body
|
||||
assert body["detail"]["added"] == 0, body
|
||||
assert body["detail"]["updated"] == 0, body
|
||||
assert body["detail"]["unchanged"] == len(KEEP_PATHS), body
|
||||
_expect_counts_line(
|
||||
page,
|
||||
f"0 added · {len(KEEP_PATHS)} unchanged · {len(GONE_PATHS)} pruned",
|
||||
)
|
||||
|
||||
# The KB no longer serves the deleted files: the catalogue row is gone,
|
||||
# the content route 404s the pair, and the total is exactly 3.
|
||||
_drill_to_fixture_folder(page)
|
||||
expect(page.locator("#docs-tbody tr", has_text=KEEP_PATHS[0])).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text=GONE_PATHS[0])).to_have_count(0)
|
||||
r = page.request.get(
|
||||
f"{app_url}/api/documents/content"
|
||||
f"?source={SOURCE_NAME}&path={GONE_PATHS[0]}"
|
||||
)
|
||||
assert r.status == 404, f"a pruned document must 404, got {r.status}: {r.text}"
|
||||
docs = _api_docs(page, app_url)
|
||||
assert len(docs) == len(KEEP_PATHS), docs
|
||||
assert {d["path"] for d in docs} == set(KEEP_PATHS), docs
|
||||
|
||||
# THE phase-183 assertion: the pruned documents left NO chunk rows
|
||||
# behind (the FK cascade, not the ORM, removed them) and the surviving
|
||||
# chunk total is exactly the survivors' own chunks — nothing orphaned,
|
||||
# nothing collateral-damaged.
|
||||
with SessionLocal() as db:
|
||||
assert _chunk_count(db, gone_ids) == 0, (
|
||||
"pruned documents left orphan chunk rows — the FK cascade did "
|
||||
"not run (Document.chunks' passive_deletes without ON DELETE "
|
||||
"CASCADE)"
|
||||
)
|
||||
assert _doc_ids(db, SOURCE_NAME, GONE_PATHS) == []
|
||||
after = _chunk_counts_by_doc(db, SOURCE_NAME, KEEP_PATHS)
|
||||
assert after == keep_counts, (
|
||||
f"the surviving documents' chunks changed across the prune: {after}"
|
||||
)
|
||||
assert _total_chunks(db) == sum(keep_counts.values())
|
||||
assert _sources_in_db(db) == [SOURCE_NAME]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The surviving KB still answers after a partial prune
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_surviving_kb_still_answers_after_a_partial_prune(
|
||||
page: Page, app_url: str, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
"""The prune's other half: what stays must still work. With the 6 files
|
||||
pruned, the chat answers a question that only the surviving
|
||||
:data:`SENTINEL_DOC` can answer — a real streamed answer (no empty
|
||||
bubble, no error state, the composer recovered) whose durable
|
||||
``query_log`` row is NOT deflected and names the surviving document."""
|
||||
page.set_default_timeout(30_000)
|
||||
source = _make_source_dir(tmp_path, SOURCE_NAME, ALL_PATHS)
|
||||
|
||||
login(page, app_url, next=SOURCES_URL)
|
||||
_add_local_source(page, app_url, source)
|
||||
_sync_from_sources_page(page, app_url)
|
||||
for rel in GONE_PATHS:
|
||||
(source / rel).unlink()
|
||||
body = _sync_from_sources_page(page, app_url)
|
||||
assert body["detail"]["pruned"] == len(GONE_PATHS), body
|
||||
|
||||
# The chat turn, through the real shell.
|
||||
page.goto(f"{app_url}/")
|
||||
expect(page.locator("#message-input")).to_be_visible(timeout=30_000)
|
||||
# A healthy KB: the offline banner stays hidden (the prune did not
|
||||
# empty or break the index the page reports on).
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
page.fill("#message-input", QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(QUESTION)
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
# Never an empty answer bubble, and the grounded (not deflected) mock
|
||||
# answer streamed.
|
||||
expect(bubble).not_to_have_text("", timeout=30_000)
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
# Never stale: the composer recovers (§7.4), and no error state landed.
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
|
||||
# The durable record: grounded on the SURVIVING document.
|
||||
with SessionLocal() as db:
|
||||
rows = db.scalars(select(QueryLog)).all()
|
||||
assert len(rows) == 1, [r.question for r in rows]
|
||||
row = rows[0]
|
||||
assert row.question == QUESTION
|
||||
assert row.deflected is False, (
|
||||
f"a partial prune broke retrieval — the turn deflected "
|
||||
f"(top_score={row.top_score}, fts_hits={row.fts_hits})"
|
||||
)
|
||||
assert f"{SOURCE_NAME}/{SENTINEL_DOC}" in row.sources, row.sources
|
||||
assert all(GONE_PATHS[i] not in row.sources for i in range(len(GONE_PATHS))), (
|
||||
f"a pruned document still grounds a turn: {row.sources}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Total source removal: every document AND chunk row of the source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_source_removal_prunes_every_document_of_the_source(
|
||||
page: Page, app_url: str, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
"""The second mass-delete surface the keyword covers
|
||||
(``delete_git_source``, ``app/api/git_sources.py`` — the endpoint that
|
||||
took 47 s for 2,303 immich docs on the incident day): removing a
|
||||
registered source removes every one of its documents and chunks with
|
||||
zero orphans, and the OTHER registered source stays byte-identical.
|
||||
|
||||
The endpoint's contract is ``204`` with **no body** (phase 69), so the
|
||||
prune count is pinned against what the KB held immediately before the
|
||||
DELETE rather than a response field."""
|
||||
page.set_default_timeout(30_000)
|
||||
doomed = _make_source_dir(tmp_path, SOURCE_NAME, ALL_PATHS)
|
||||
keeper = _make_source_dir(
|
||||
tmp_path, KEEPER_NAME, ["notes/keeper-1.md", "notes/keeper-2.md"]
|
||||
)
|
||||
|
||||
login(page, app_url, next=SOURCES_URL)
|
||||
doomed_id = _add_local_source(page, app_url, doomed)
|
||||
_add_local_source(page, app_url, keeper)
|
||||
_sync_from_sources_page(page, app_url)
|
||||
assert _api_sources(page, app_url) == [SOURCE_NAME, KEEPER_NAME]
|
||||
|
||||
with SessionLocal() as db:
|
||||
doomed_doc_ids = _doc_ids(db, SOURCE_NAME, ALL_PATHS)
|
||||
doomed_chunks = _chunk_count(db, doomed_doc_ids)
|
||||
keeper_before = _chunk_counts_by_doc(db, KEEPER_NAME)
|
||||
assert len(doomed_doc_ids) == len(ALL_PATHS)
|
||||
assert doomed_chunks > 0, "the fixture must embed chunks to prune"
|
||||
|
||||
r = page.request.delete(f"{app_url}/api/git-sources/{doomed_id}")
|
||||
assert r.status == 204, f"expected 204, got {r.status}: {r.text}"
|
||||
|
||||
# Every document of the source is gone and left NO chunk rows; the
|
||||
# remaining source is untouched, chunks included.
|
||||
with SessionLocal() as db:
|
||||
assert _chunk_count(db, doomed_doc_ids) == 0, (
|
||||
"source removal left orphan chunk rows — the FK cascade did "
|
||||
"not run (Document.chunks' passive_deletes without ON DELETE "
|
||||
"CASCADE)"
|
||||
)
|
||||
assert (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Document)
|
||||
.where(Document.source == SOURCE_NAME)
|
||||
)
|
||||
or 0
|
||||
) == 0
|
||||
assert _sources_in_db(db) == [KEEPER_NAME]
|
||||
assert _chunk_counts_by_doc(db, KEEPER_NAME) == keeper_before
|
||||
assert _total_chunks(db) == sum(keeper_before.values())
|
||||
|
||||
# The registry row is gone too, and the API agrees with the DB.
|
||||
r = page.request.get(f"{app_url}/api/git-sources")
|
||||
assert r.status == 200, r.text
|
||||
rows = r.json()["sources"]
|
||||
assert doomed_id not in [row["id"] for row in rows], rows
|
||||
assert len(rows) == 1 and rows[0]["path"] == str(keeper), rows
|
||||
assert _api_doc_pairs(page, app_url) == [
|
||||
(KEEPER_NAME, f"notes/keeper-{n}.md") for n in (1, 2)
|
||||
]
|
||||
# The doomed files stay on disk — a foreign local directory is never
|
||||
# touched (the phase-69 locked decision, unaffected by phase 183).
|
||||
assert doomed.is_dir() and all((doomed / p).is_file() for p in KEEP_PATHS)
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Integration: phase 183 — what a mass prune ACTUALLY loads (the probe).
|
||||
|
||||
**VERDICT (task 01): CONFIRMED — one chunk SELECT per pruned document →
|
||||
FIXED (task 02, ``passive_deletes=True``).** Pruning 200 documents (25
|
||||
chunks each, a real 768-dim ``chunks.embedding`` per chunk) through the
|
||||
importer's own ``_prune`` used to issue **exactly 200** ``SELECT … FROM
|
||||
chunks`` statements — one lazy child-collection load per
|
||||
``session.delete(doc)`` — materialising every 768-dim embedding of every
|
||||
document into Python on its way to the delete. Task 01 pinned that with
|
||||
``EXPECTED_CHUNK_SELECTS_PER_PRUNED_DOC = 1`` GREEN before any code change
|
||||
(the phase-179 task-01 "pin the defect with a probe first" precedent);
|
||||
task 02 declared ``passive_deletes=True`` on
|
||||
``Document.chunks`` and flipped the constant to ``0``. The same fixture
|
||||
now prunes in **2 statements** (the prune's own ``SELECT documents`` plus
|
||||
one batched ``DELETE FROM documents``), **0 chunk SELECTs**, **0 orphan
|
||||
chunk rows** — the ``chunks_document_id_fkey`` ``ON DELETE CASCADE``
|
||||
drops the rows in the database, so Python never sees them.
|
||||
|
||||
:func:`test_chunks_fk_cascades_at_database_level` is the other half of the
|
||||
contract: ``passive_deletes`` is only correct while the FK really
|
||||
cascades, so the live ``pg_constraint`` row is asserted here (a future
|
||||
migration dropping ``ON DELETE CASCADE`` would otherwise let every prune
|
||||
silently orphan chunk rows).
|
||||
|
||||
**The incident (owner, 2026-10-15).** The brain-of-code sync ``started
|
||||
repos=4`` at 11:19:23, reached ``_prune`` at 12:14:14, pruned **3,008
|
||||
litellm docs** — and the VM locked up so hard that boot −1 has no shutdown
|
||||
record at all (4 GB of RAM). The app cgroup had already peaked at 3.3 GB
|
||||
twice that same week. The box has 3,904 MB and **no swap**, and shares that
|
||||
RAM with a 3.1 GB Postgres. 3,008 docs × ~24 chunks ≈ 72k rows whose
|
||||
embeddings the prune pulled into Python to delete them.
|
||||
|
||||
**Mechanism (pre-phase-183).** ``Document.chunks`` carried
|
||||
``cascade="all, delete-orphan"`` and **no ``passive_deletes``** — so
|
||||
``session.delete(doc)`` had to LOAD the child collection first (the ORM has
|
||||
to know which children to orphan/delete), SELECTing every chunk row
|
||||
*including* its ``Vector(768)`` column. Nothing was released afterwards:
|
||||
``app/db.py:30`` builds sessions with ``expire_on_commit=False``, so the 72k
|
||||
hydrated ``Chunk`` objects (and their float lists) stayed resident until the
|
||||
single ``session.commit()`` at ``app/rag/importer.py`` returned — the
|
||||
``passive_deletes=True`` of task 02 is what takes that walk out.
|
||||
|
||||
**Measured unit costs.**
|
||||
|
||||
* A loaded 768-dim ``Chunk.embedding`` costs **24.8 KiB of Python** (list
|
||||
header + 768 float objects — pgvector hands back a ``list[float]``, never
|
||||
a buffer) against **3 KiB on disk**: an 8× expansion per embedding.
|
||||
* **~0.24 MB resident per document deleted** at 25 chunks/doc — measured
|
||||
2026-10-15 against the dev DB with production-shaped rows (400 docs ×
|
||||
25 chunks, ~745-char chunk content): RSS grew linearly, +241 MB over
|
||||
400 deletes.
|
||||
|
||||
**Measured BY THIS FILE (2026-10-15, the fixture below: 200 docs × 25
|
||||
chunks × 745 chars × a 768-float vector, the dev Postgres).**
|
||||
|
||||
* PRE-fix (no ``passive_deletes``) statement capture
|
||||
(``before_cursor_execute``, the fixture below): **203** statements for
|
||||
the whole prune — **200 of them ``SELECT … FROM chunks``** (one per
|
||||
pruned document, the load-bearing pin), plus 1 ``SELECT documents`` (the
|
||||
prune's own walk) and the 2 ``executemany`` ``DELETE``s (``chunks``, then
|
||||
``documents``).
|
||||
* PRE-fix ``VmRSS`` delta: **+165.6 MB for the 200-doc prune**
|
||||
(≈ 0.83 MB/doc). Higher than the 0.24 MB/doc above because this fixture's
|
||||
chunk content and vector are both fully hydrated and the session keeps
|
||||
everything (``expire_on_commit=False``); the ORDER is the point, not the
|
||||
figure.
|
||||
* the same 200 deletes issued as plain ``DELETE FROM documents`` (i.e. what
|
||||
the DB's own ``ON DELETE CASCADE`` does — the shape task 02 lands,
|
||||
measured as a one-off while writing task 01): **0 chunk SELECTs,
|
||||
+0.0 MB RSS**, and still **0 orphan chunk rows** — the ceiling below is
|
||||
picked from that contrast, roughly one quarter of the pre-fix delta.
|
||||
* POST-fix (``passive_deletes=True``, task 02, the same fixture and the
|
||||
same capture): **2** statements — 1 ``SELECT documents`` + 1 batched
|
||||
``executemany`` ``DELETE FROM documents`` — **0 chunk SELECTs**, and the
|
||||
``VmRSS`` delta is **+0.2 MB** (run-to-run +0.1…0.6 MB — allocator noise,
|
||||
two orders of magnitude under the 165.6 MB the hydrating walk cost, and
|
||||
still 200× inside the 40 MB ceiling). Still **0
|
||||
orphan chunk rows**, which is what the D4 assertions inside
|
||||
:func:`test_prune_never_selects_a_chunk_row` and the drift guard
|
||||
:func:`test_chunks_fk_cascades_at_database_level` exist to keep true.
|
||||
|
||||
**Why the memory test branches on the constant.** D1/LOCKED A4: the
|
||||
statement count is the load-bearing, CI-safe pin; the RSS figure is
|
||||
corroboration and must never flake a build on allocator noise. So
|
||||
``test_prune_memory_delta_has_a_ceiling`` asserts the post-fix ceiling
|
||||
(``< 40 MB``); its ``else`` branch, kept for the pre-fix state the constant
|
||||
can be flipped back to, asserts the opposite (the delta lands WELL ABOVE
|
||||
that ceiling) — which is how the ceiling was proven to have teeth before it
|
||||
became the contract.
|
||||
|
||||
**What this file does NOT touch:** the prune's batching (phase 184), the
|
||||
fast-path column set (phase 185),
|
||||
``expire_on_commit``, and the ``doc.chunks = [...]`` orphan-replacement
|
||||
path inside ``_replace_content_chunks`` (``app/rag/importer.py:1033``) —
|
||||
``passive_deletes`` does not cover collection reassignment and a re-embed
|
||||
genuinely needs the loaded collection (phase 183 LOCKED A2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event, func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import engine
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.importer import ImportSummary, _prune
|
||||
|
||||
#: The probe's own source name — nothing else in the KB uses it.
|
||||
PROBE_SOURCE = "cascade-probe"
|
||||
|
||||
#: The production-shaped fixture: 200 documents, 25 chunks each (the
|
||||
#: incident pruned 3,008 docs × ~24 chunks; this is that walk scaled down
|
||||
#: to what a test run can seed in seconds).
|
||||
DOC_COUNT = 200
|
||||
CHUNKS_PER_DOC = 25
|
||||
|
||||
#: ~745 chars, the measured production chunk size (phase 183 baseline).
|
||||
CHUNK_CONTENT = ("The homelab runs podman quadlets on the hnb host. " * 18)[:745]
|
||||
|
||||
#: **0 post-phase-183 — the FK cascade does the work in the database;
|
||||
#: 1 pre-phase-183 was the lazy child load** (one ``SELECT … FROM chunks``
|
||||
#: per deleted document, the waste this phase removed: 5,000 chunk rows and
|
||||
#: 5,000 768-dim embeddings hydrated into Python purely to be deleted).
|
||||
#: The DB's ``ON DELETE CASCADE`` (alembic 0001 L54,
|
||||
#: ``chunks_document_id_fkey`` confdeltype='c' — pinned live by
|
||||
#: :func:`test_chunks_fk_cascades_at_database_level`) deletes the rows
|
||||
#: without Python ever seeing them.
|
||||
EXPECTED_CHUNK_SELECTS_PER_PRUNED_DOC = 0
|
||||
|
||||
#: The POST-phase-183 memory ceiling for a 200-doc prune (~1/4 of the
|
||||
#: measured pre-fix delta of 165.6 MB; the FK-cascade path measures
|
||||
#: +0.2 MB, module docstring).
|
||||
RSS_CEILING_MB = 40.0
|
||||
|
||||
#: The loose upper bound that still applies while the hydrating behaviour
|
||||
#: is the expected one — a runaway allocator must never go unnoticed.
|
||||
PRE_FIX_RSS_SANITY_CEILING_MB = 400.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state(db: Session) -> Iterator[None]:
|
||||
"""``documents`` / ``chunks`` are global state: reset around every
|
||||
test (the ``test_sync_api.py`` truncate pattern)."""
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def statement_capture() -> Iterator[list[str]]:
|
||||
"""Every SQL the engine executes for the duration of ONE test.
|
||||
|
||||
Installed on the engine (the session under test binds to it) and
|
||||
REMOVED in teardown, so no other test of the session ever sees the
|
||||
listener (AGENTS: the suite stays isolated — verified by the full
|
||||
``uv run pytest`` run being green with this file in 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: ``SELECT … FROM chunks`` — the child-collection load. The word boundary
|
||||
#: keeps ``documents`` (and its ``…_id`` aliases) out of the count while
|
||||
#: still matching the real lazy load, whose SQL starts
|
||||
#: ``SELECT chunks.id AS chunks_id, … FROM chunks WHERE chunks.document_id``.
|
||||
_CHUNK_SELECT_RE = re.compile(r"\bfrom\s+chunks\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def chunk_selects(captured: list[str]) -> list[str]:
|
||||
"""The captured statements that SELECT the ``chunks`` table."""
|
||||
return [
|
||||
stmt
|
||||
for stmt in captured
|
||||
if stmt.lstrip().lower().startswith("select") and _CHUNK_SELECT_RE.search(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _seed_docs(session: Session) -> list[UUID]:
|
||||
"""``DOC_COUNT`` indexed documents of :data:`PROBE_SOURCE`, each with
|
||||
``CHUNKS_PER_DOC`` embedded chunks. Committed; returns the doc ids.
|
||||
|
||||
The chunks are added BY ``document_id`` (never through
|
||||
``doc.chunks``) so the relationship stays UNLOADED — the state a real
|
||||
prune starts from, and the only state in which the lazy load is
|
||||
observable.
|
||||
"""
|
||||
ids: list[UUID] = []
|
||||
for n in range(DOC_COUNT):
|
||||
doc = Document(
|
||||
source=PROBE_SOURCE,
|
||||
path=f"docs/doc-{n:04d}.md",
|
||||
full_path=f"/srv/brain/{PROBE_SOURCE}/docs/doc-{n:04d}.md",
|
||||
title=f"Cascade probe doc {n}",
|
||||
content=CHUNK_CONTENT,
|
||||
content_hash=f"{n:064x}",
|
||||
)
|
||||
session.add(doc)
|
||||
session.flush()
|
||||
ids.append(doc.id)
|
||||
# Deterministic 768-float vector, rotated by the doc index (a real
|
||||
# pgvector payload — the point is the HYDRATION cost, so the values
|
||||
# only need to be stable).
|
||||
rot = n % 768
|
||||
embedding = [((i + rot) % 768) / 768 for i in range(768)]
|
||||
session.add_all(
|
||||
[
|
||||
Chunk(
|
||||
document_id=doc.id,
|
||||
position=position,
|
||||
content=CHUNK_CONTENT,
|
||||
embedding=embedding,
|
||||
)
|
||||
for position in range(CHUNKS_PER_DOC)
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
return ids
|
||||
|
||||
|
||||
def _run_prune(session: Session) -> tuple[int, ImportSummary]:
|
||||
"""The prune exactly as ``import_sources`` calls it with a walk that saw
|
||||
NOTHING (``seen`` empty → every doc of the source prunes) and the
|
||||
images toggle OFF (``app/rag/importer.py:3860``)."""
|
||||
summary = ImportSummary()
|
||||
pruned = _prune(
|
||||
session,
|
||||
{PROBE_SOURCE},
|
||||
set(),
|
||||
images=False,
|
||||
summary=summary,
|
||||
page_dir=None,
|
||||
)
|
||||
return pruned, summary
|
||||
|
||||
|
||||
def _vm_rss_mb() -> float | None:
|
||||
"""``VmRSS`` from ``/proc/self/status`` in MB, ``None`` when unreadable
|
||||
(a non-Linux box skips the memory test rather than pinning noise)."""
|
||||
try:
|
||||
for line in Path("/proc/self/status").read_text().splitlines():
|
||||
if line.startswith("VmRSS:"):
|
||||
return int(line.split()[1]) / 1024.0
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prune_never_selects_a_chunk_row(db: Session, statement_capture: list[str]) -> None:
|
||||
"""Zero ``SELECT … FROM chunks`` for 200 pruned documents — the fix.
|
||||
|
||||
POST-phase-183 assertion (``EXPECTED_CHUNK_SELECTS_PER_PRUNED_DOC`` is
|
||||
``0``): ``passive_deletes=True`` means the prune's 200
|
||||
``session.delete(doc)`` calls never hydrate a child collection, so no
|
||||
chunk row and no 768-dim embedding reaches Python — the whole walk is 2
|
||||
statements. PRE-phase-183 this same fixture measured 200 chunk SELECTs
|
||||
and 203 statements (module docstring).
|
||||
|
||||
Every other assertion here (the counts, the ``changed_paths`` feed, and
|
||||
above all the ZERO orphan chunk rows) held byte-identically across the
|
||||
flip — they exist so the fix cannot pass by quietly stopping to delete
|
||||
children (phase 183 D4).
|
||||
"""
|
||||
ids = _seed_docs(db)
|
||||
statement_capture.clear() # the seed's own SQL is not part of the probe
|
||||
|
||||
pruned, summary = _run_prune(db)
|
||||
|
||||
assert pruned == DOC_COUNT
|
||||
expected_selects = DOC_COUNT * EXPECTED_CHUNK_SELECTS_PER_PRUNED_DOC
|
||||
assert len(chunk_selects(statement_capture)) == expected_selects
|
||||
|
||||
# D4 — the no-orphan contract, asserted BEFORE the fix so that after it
|
||||
# "0 orphans" means "unchanged", never "never checked".
|
||||
assert db.scalar(
|
||||
select(func.count()).select_from(Chunk).where(Chunk.document_id.in_(ids))
|
||||
) == 0
|
||||
assert db.scalar(
|
||||
select(func.count()).select_from(Document).where(Document.source == PROBE_SOURCE)
|
||||
) == 0
|
||||
|
||||
# The phase-127 changed_paths feed: one (source, path) per pruned doc,
|
||||
# appended BEFORE the delete (the folder-summary regeneration input).
|
||||
assert sorted(summary.changed_paths) == sorted(
|
||||
(PROBE_SOURCE, f"docs/doc-{n:04d}.md") for n in range(DOC_COUNT)
|
||||
)
|
||||
|
||||
|
||||
def test_prune_memory_delta_has_a_ceiling(db: Session) -> None:
|
||||
"""The corroborating memory figure — a ceiling, never the flaky pin.
|
||||
|
||||
Same 200-doc prune, ``VmRSS`` read before and after (``gc.collect()``
|
||||
on both sides so the allocator gets its fair say). See the module
|
||||
docstring for the measured numbers: **+165.6 MB** while the ORM hydrates
|
||||
the child collections (PRE-phase-183), **+0.2 MB** on the FK-cascade
|
||||
path (POST, task 02) against the 40 MB ceiling pinned below.
|
||||
"""
|
||||
before = _vm_rss_mb()
|
||||
if before is None:
|
||||
pytest.skip("/proc/self/status unreadable — the RSS ceiling is Linux-only")
|
||||
|
||||
ids = _seed_docs(db)
|
||||
gc.collect()
|
||||
baseline = _vm_rss_mb()
|
||||
assert baseline is not None
|
||||
|
||||
pruned, _ = _run_prune(db)
|
||||
gc.collect()
|
||||
after = _vm_rss_mb()
|
||||
assert after is not None
|
||||
delta = after - baseline
|
||||
|
||||
assert pruned == DOC_COUNT
|
||||
assert db.scalar(
|
||||
select(func.count()).select_from(Chunk).where(Chunk.document_id.in_(ids))
|
||||
) == 0
|
||||
|
||||
if EXPECTED_CHUNK_SELECTS_PER_PRUNED_DOC == 0:
|
||||
# POST-phase-183: no embedding is ever hydrated, so the whole prune
|
||||
# must cost well under the ceiling (measured +0.2 MB).
|
||||
assert delta < RSS_CEILING_MB, (
|
||||
f"VmRSS grew {delta:.1f} MB during a cascade prune — chunk rows"
|
||||
" are being hydrated again (passive_deletes removed?)"
|
||||
)
|
||||
else:
|
||||
# PRE-phase-183 (the state the constant can be flipped back to): the
|
||||
# hydration is the behaviour under test, so this fixture must land
|
||||
# WELL ABOVE that ceiling — a ceiling nothing ever crossed would be
|
||||
# a pin in name only.
|
||||
assert delta > RSS_CEILING_MB
|
||||
assert delta < PRE_FIX_RSS_SANITY_CEILING_MB
|
||||
|
||||
|
||||
def test_chunks_fk_cascades_at_database_level(db: Session) -> None:
|
||||
"""The drift guard: every ``chunks`` FK really is ``ON DELETE CASCADE``.
|
||||
|
||||
``passive_deletes=True`` tells the ORM to trust the database with the
|
||||
child rows. That trust is only sound while ``chunks_document_id_fkey``
|
||||
cascades (alembic 0001 L54; production confirmed
|
||||
``confdeltype='c'``); a future migration that recreates the FK without
|
||||
the clause would turn every prune into a silent orphan-generator — the
|
||||
documents would vanish, the chunk rows (and their vectors) would stay,
|
||||
and nothing in the app would notice. So read the LIVE catalog and fail
|
||||
loudly if the cascade is gone (phase 183 A3 — a guard instead of a new
|
||||
migration).
|
||||
|
||||
**The guard is not a tautology — proven 2026-10-15 against a scratch
|
||||
database** (``bor_183_fk_scratch``, created + dropped for the check;
|
||||
the shared test DB was never touched): ``alembic upgrade head``
|
||||
(``confdeltype='c'``, this test GREEN) → ``ALTER TABLE chunks DROP
|
||||
CONSTRAINT chunks_document_id_fkey`` + re-add it ``ON DELETE NO
|
||||
ACTION`` (``confdeltype='a'``) → this exact test FAILED, verbatim:
|
||||
|
||||
```
|
||||
AssertionError: chunks FKs not ON DELETE CASCADE:
|
||||
chunks_document_id_fkey confdeltype='a' (NO ACTION) — Document.chunks'
|
||||
passive_deletes=True would silently orphan chunk rows; restore the
|
||||
clause (alembic 0001 L54)
|
||||
```
|
||||
|
||||
and with the clause still present-but-NO-ACTION the same fixture's
|
||||
prune died on ``psycopg.errors.ForeignKeyViolation`` rather than
|
||||
orphaning rows unnoticed — the ORM really does delegate the child rows
|
||||
to the database now (which is the point, and exactly why the clause is
|
||||
load-bearing).
|
||||
"""
|
||||
# ``pg_constraint.confdeltype`` codes → the DDL clause, so the failure
|
||||
# message names whatever the FK actually does (not always NO ACTION).
|
||||
deltype = {
|
||||
"a": "NO ACTION",
|
||||
"r": "RESTRICT",
|
||||
"c": "CASCADE",
|
||||
"n": "SET NULL",
|
||||
"d": "SET DEFAULT",
|
||||
}
|
||||
rows = db.execute(
|
||||
text(
|
||||
"select conname, confdeltype from pg_constraint"
|
||||
" where conrelid = 'chunks'::regclass and contype = 'f'"
|
||||
" order by conname"
|
||||
)
|
||||
).all()
|
||||
# 'c' = ON DELETE CASCADE (pg_constraint.confdeltype).
|
||||
assert rows, "no foreign key on chunks at all — the cascade cannot hold"
|
||||
assert all(r[1] == "c" for r in rows), (
|
||||
"chunks FKs not ON DELETE CASCADE: "
|
||||
+ ", ".join(
|
||||
f"{r[0]} confdeltype={r[1]!r} ({deltype.get(r[1], 'UNKNOWN')})" for r in rows
|
||||
)
|
||||
+ " — Document.chunks' passive_deletes=True would silently orphan"
|
||||
" chunk rows; restore the clause (alembic 0001 L54)"
|
||||
)
|
||||
|
||||
|
||||
def test_prune_with_a_loaded_collection_still_deletes_the_chunks(db: Session) -> None:
|
||||
"""The other caller state: the collection IS loaded (phase 183 D4).
|
||||
|
||||
``passive_deletes=True`` only promises not to LOAD the children — a
|
||||
caller that already holds them (what ``_replace_content_chunks`` leaves
|
||||
behind in the same sync session, or any future caller that queries
|
||||
eagerly) must still lose the rows. Verified 2026-10-15: the ORM deletes
|
||||
the LOADED children with its own ``DELETE FROM chunks`` and the FK
|
||||
cascade covers whatever is not loaded — no ``SELECT`` of ``chunks``
|
||||
either way, and zero orphan rows in BOTH states. The keyword changes
|
||||
what Python loads, never what the database deletes.
|
||||
"""
|
||||
doc = Document(
|
||||
source=PROBE_SOURCE,
|
||||
path="loaded.md",
|
||||
full_path=f"/srv/brain/{PROBE_SOURCE}/loaded.md",
|
||||
title="Loaded collection",
|
||||
content=CHUNK_CONTENT,
|
||||
content_hash="f" * 64,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Chunk(
|
||||
document_id=doc.id,
|
||||
position=position,
|
||||
content=CHUNK_CONTENT,
|
||||
embedding=[(i + position) % 768 / 768 for i in range(768)],
|
||||
)
|
||||
for position in range(5)
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
row = db.scalar(select(Document).where(Document.source == PROBE_SOURCE))
|
||||
assert row is not None
|
||||
assert len(row.chunks) == 5 # the collection is now IN the identity map
|
||||
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
assert db.scalar(
|
||||
select(func.count()).select_from(Document).where(Document.id == doc.id)
|
||||
) == 0
|
||||
assert db.scalar(
|
||||
select(func.count()).select_from(Chunk).where(Chunk.document_id == doc.id)
|
||||
) == 0
|
||||
@@ -1469,6 +1469,30 @@ def test_prune_records_changed_paths_only_when_summary_given(db, tmp_path: Path)
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
# ---------- phase 183 (task 02): the prune deletes IN the database ----------
|
||||
|
||||
|
||||
def test_document_chunks_relationship_is_passive_deletes() -> None:
|
||||
"""``Document.chunks`` is ``passive_deletes=True`` (phase 183, LOCKED A1).
|
||||
|
||||
The mapper-level pin is the cheapest place to catch the keyword being
|
||||
dropped: without it ``session.delete(doc)`` SELECTs every chunk of the
|
||||
document before deleting it, hydrating every 768-dim
|
||||
``Chunk.embedding`` (~24.8 KiB of Python each) — the shape that made a
|
||||
3,008-document prune cost ~720 MB and locked up the 2026-10-15 VM
|
||||
(3,904 MB, no swap). The other half of the contract — the FK really is
|
||||
``ON DELETE CASCADE`` — is pinned live by
|
||||
``tests/integration/test_prune_cascade_statements.py``
|
||||
(``test_chunks_fk_cascades_at_database_level``), so these two tests
|
||||
fail together the moment either side drifts.
|
||||
"""
|
||||
rel = Document.__mapper__.relationships["chunks"]
|
||||
assert rel.passive_deletes is True
|
||||
# The ORPHAN semantics ``_replace_content_chunks``' re-embed relies on
|
||||
# (``doc.chunks = [...]`` drops the old rows) stay intact (A2).
|
||||
assert "delete-orphan" in str(rel.cascade)
|
||||
|
||||
|
||||
# ---------- phase 129 (task 02): on_chunk_progress plumbing ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user