Files
ducoterra c054b47df5
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 187_sync_peak_memory_line
**Phase 187 — final verification pass: all criteria met, repo 100% green.**

Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.

Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)

Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.

Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).

Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.

Next pending phase: `188_chunks_hnsw_index`.
2026-10-16 15:22:51 -04:00

156 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Process memory as a number — current RSS, peak RSS, and a log formatter
(phase 187, task 01, D1, LOCKED A1).
**Pure stdlib:** this module imports the standard library only — no FastAPI,
no ``app.api``, no third-party package — mirroring ``app/core/response_budget.py``.
**The incident (owner, 2026-10-15).** Reconstructing "how much RAM did the
sync actually use?" meant reading ``systemd`` unit-teardown lines from three
boots (``brain-of-code-app.service: … 3.3G memory peak``), and boot −1 — the
run that killed the box — had no teardown record at all because the VM never
shut down cleanly. The app itself had no opinion about its own memory: no
log line, no status field, nothing to ``grep``. These helpers give the sync's
lifecycle lines (task 02) a number so the next incident starts from data
instead of archaeology.
**The helpers.**
* :func:`rss_mb` — the process's CURRENT resident set size in MB. Linux:
parse ``VmRSS`` from ``/proc/self/status`` (kB, floored to whole MB). On a
platform without ``/proc`` — or on any failure reading it — fall back to
``resource.getrusage(RUSAGE_SELF).ru_maxrss // 1024``, which is the
process HIGH-WATER mark, not a current value: the best available proxy.
(``ru_maxrss`` is kilobytes on Linux, the deploy target.)
* :func:`rss_peak_mb` — ``ru_maxrss // 1024``, the process high-water mark.
``None`` only where the ``resource`` module is unavailable (Windows); the
import is guarded so this module still loads there.
* :func:`cgroup_peak_mb` — ``/sys/fs/cgroup/memory.peak`` (cgroup v2, bytes,
floored to MB) when that file exists and parses, else ``None``. Read it
for context, never as the app's number — see the caveat below.
* :func:`fmt_mb` — ``"?"`` for ``None``, else ``str(v)``, so a log line
renders ``rss_mb=?`` and never ``rss_mb=None`` (task 02 single-sources
the rendering through this one function).
**The never-raise contract.** Every helper returns ``int`` or ``None``;
none may propagate an exception to a caller on the sync or request path.
Memory is diagnostics — a failed ``/proc`` read must never fail the turn,
the sync, or a status poll that asked for a number, so every read is
wrapped and every failure mode degrades to ``None`` (or to the proxy).
**The container/cgroup caveat (written ONCE, here, so no future reader
conflates the numbers).** Inside a container ``/sys/fs/cgroup/memory.peak``
is usually absent (the HOST reads it), and the host's number counts PAGE
CACHE too: the incident box's app cgroup showed ~2.16 GB of ``file``
against ~211 MB of ``anon`` while idle, and ``systemd`` reported
``3.3G memory peak`` for the unit while the uvicorn process was a fraction
of that. This module therefore reports PROCESS numbers (LOCKED A1); an
operator dashboard may legitimately show a larger cgroup number — neither
is wrong, and the log says which one it is.
"""
from __future__ import annotations
import contextlib
from types import ModuleType
#: Procfs file carrying ``VmRSS`` (current RSS in kB) among the other
#: ``/proc/self`` identity fields. Module-level so tests can point it at a
#: stub file (or a nonexistent path) without patching ``open`` globally.
PROC_STATUS_PATH = "/proc/self/status"
#: cgroup v2 high-water mark, in BYTES. Usually absent inside a container —
#: see the module docstring's caveat; ``None`` is the normal answer there.
CGROUP_PEAK_PATH = "/sys/fs/cgroup/memory.peak"
#: ``resource`` is Unix-only. Guarded so the module loads (and every helper
#: answers ``None`` where it cannot know) on platforms without it.
_resource: ModuleType | None
try:
import resource as _resource
except ImportError:
_resource = None
#: The ``/proc/self/status`` label holding current RSS, in kB.
_VM_RSS_LABEL = "VmRSS:"
def rss_mb() -> int | None:
"""Current process RSS in whole MB, or ``None`` if unknowable.
Linux path: the ``VmRSS:`` line of ``/proc/self/status`` ("kB" is
implied by the kernel; the value is floored with integer division).
Any failure — no ``/proc`` (non-Linux), unreadable file, a file
without the line, a line whose value is not an integer — falls back
to :func:`rss_peak_mb`, the high-water proxy. ``KeyError`` is caught
defensively per the phase contract; the line-scanning form below
itself cannot raise it (the absent-line case is a fall-through).
"""
with contextlib.suppress(OSError, KeyError, ValueError, IndexError), open(
PROC_STATUS_PATH, encoding="utf-8"
) as fh:
for line in fh:
if line.startswith(_VM_RSS_LABEL):
return int(line.split()[1]) // 1024
return rss_peak_mb()
def rss_peak_mb() -> int | None:
"""Process high-water RSS in whole MB, or ``None`` without ``resource``.
``ru_maxrss`` is the high-water mark, never a current value (kilobytes
on Linux, the deploy target), floored to whole MB. The ``getrusage``
call itself is wrapped to honor the never-raise contract even though
``RUSAGE_SELF`` cannot realistically fail.
**One honest edge, measured here (so nobody "fixes" this helper):** the
kernel refreshes its high-water LAZILY and the value FLOORs to whole
MB, so a growing process can report a CURRENT ``VmRSS`` ABOVE its
reported ``ru_maxrss`` — observed on this repo's own suite at
``VmRSS`` 217,288 kB against ``ru_maxrss`` 216,832 kB, and again as
``rss_mb=164`` against ``rss_peak_mb=162``. This helper stays the RAW
kernel reading (it is a diagnostic, and a diagnostic that quietly
edits the OS is worse than the imprecision).
The CALLER that publishes the pair is what normalizes it:
:func:`app.api.sync._sample_rss` — the single sampling point behind
both sync lifecycle lines, ``GET /api/sync/status`` and the Sources
page's clause — lifts a peak that sits below the current reading up to
that reading, because a high-water BELOW the process's own current set
is not a smaller number but a false one. So the RUN's numbers carry
``peak >= current``, while a raw ``rss_peak_mb()`` call may still sit
under a raw ``rss_mb()``.
"""
if _resource is None:
return None
try:
return int(_resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss) // 1024
except (OSError, AttributeError, TypeError, ValueError):
return None
def cgroup_peak_mb() -> int | None:
"""The cgroup v2 ``memory.peak`` in whole MB, or ``None``.
Context for operators, NOT the app's number: inside a container this
file is usually absent (the host reads it), and the host's number
counts PAGE CACHE too (~2.16 GB ``file`` against ~211 MB ``anon`` on
the idle incident box; ``systemd`` said ``3.3G memory peak`` for the
unit while the uvicorn process was a fraction of that). PROCESS
numbers are what the sync logs — module docstring has the full
caveat, LOCKED A1.
"""
try:
with open(CGROUP_PEAK_PATH, encoding="utf-8") as fh:
return int(fh.read().strip()) // (1024 * 1024)
except (OSError, ValueError):
return None
def fmt_mb(value: int | None) -> str:
"""Render an MB reading for a log line: ``?`` when unknown, digits when not.
``0`` is a VALUE, not missing — it renders ``"0"``. This is the single
place the ``rss_mb=?`` convention lives (task 02 formats every line
through here).
"""
return "?" if value is None else str(value)