# Vision-manager — memory-pressure yield (design outline) **Status:** implemented in `vision-manager/manager.sh` (feat/vision-mem-yield). Verifications V1–V4 (§5) all passed against the current backend — expired leases are reclaimed continuously on every claim poll (not boot-only), connection errors requeue with backoff, the readiness gate parks before leasing, and admin re-enqueue recovers any straggler — so no backend changes were required. `RESPECT_CRAWL_MUTEX` is **kept** for now (retiring it is deferred until the watermarks are tuned on the real box). **Extends:** `VISION-AUTOSCALE.md` (the `vision-manager` sidecar) and `vision-manager/manager.sh`. **One-liner:** let the manager *proactively stop* `mangalord-vision` when host memory gets dangerously tight, so a transient spike elsewhere (a Chromium crawl, a CI build, a backend burst) can complete instead of the kernel OOM-killer shooting something stateful (postgres / backend). --- ## 1. Motivation `llama-server` has no idle-unload and pins ~4.4 GiB while up. On the 8 GiB Pi the existing autoscaler already stops it when the **backlog** is empty and (new) when **analysis is disabled**. Neither reacts to *actual memory pressure*: - `mem_limit: 6g` on the vision service caps only **vision's own** growth. It does nothing about **aggregate host** pressure across all services. - The static `RESPECT_CRAWL_MUTEX` only **defers a start** while a crawl runs — it can't stop a vision that's **already running**, and it's blind to every non-crawl memory consumer (CI build, backend spike, a second crawl worker). - The only backstop under true pressure today is the **kernel OOM killer**, which may pick postgres or the backend (long-lived, stateful, painful) rather than the one process that is cheap and safe to lose. A manager-driven stop is the friendly version: it picks the **right, restartable victim** and does it **gracefully (SIGTERM)** before the kernel does something violent. This is the **dynamic generalization of `RESPECT_CRAWL_MUTEX`**: "back off when RAM is actually tight" strictly dominates "never co-run with a crawl." If this ships, `RESPECT_CRAWL_MUTEX` can likely be retired. --- ## 2. Feasibility (confirmed) `/proc/meminfo` inside the manager container reports **host-wide** memory (it is not namespaced), so the manager already sees the truth with **zero new privilege** — no socket-proxy `docker info`, no host mount. Verified live from inside `vision-manager`: ``` MemTotal: 8256576 kB (7.87 GiB) MemAvailable: 1754384 kB → ~79% used, 21% available (vision up + 560 queued) ``` **Use `MemAvailable`, never `MemFree`.** Linux spends "free" RAM on page cache by design, so a `MemFree`-based "used %" sits near 90 % during normal operation and would false-trigger constantly. `MemAvailable` already nets out reclaimable cache. The honest metric is: ``` used_pct = 100 * (1 - MemAvailable / MemTotal) ``` Read it in bash with `awk` over `/proc/meminfo` (both values are in kB): ```sh read_mem_used_pct() { awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} END { if (t>0) printf "%d", 100*(1-a/t); else print "ERR" }' /proc/meminfo } ``` --- ## 3. Behaviour A third gate in the existing poll loop, evaluated **before** the backlog/enable start/stop logic: 1. **Stop gate (active).** If `used_pct >= HIGH_WATERMARK` (e.g. 92) **and** vision is running → `docker stop mangalord-vision`, record a cooldown deadline, log loudly. This is the new capability the crawl mutex never had. 2. **Start inhibit.** While `used_pct >= LOW_WATERMARK` (e.g. 80) **or** a cooldown is in effect → refuse to start vision even if backlog > 0 (same shape as the crawl-mutex deferral: log once per episode, not every tick). 3. **Resume.** Once `used_pct < LOW_WATERMARK` **and** the cooldown has elapsed, normal autoscaler behaviour resumes (start on backlog, idle-stop, etc.). **Hysteresis is mandatory** (two distinct watermarks). A single threshold oscillates around the boundary. **The cooldown is mandatory** and is the subtle part. If vision *itself* is the 4.4 GiB hog, stopping it drops pressure → manager sees backlog → restarts it → pressure climbs → stop … infinite thrash, each cycle paying the ~minutes cold reload. The cooldown (refuse-to-restart for N minutes after a pressure-stop, *regardless of backlog*) is what gives the **other** workload — the one that actually needed the RAM — room to finish before vision muscles back in. The yield only makes sense when something else needs the memory; the cooldown encodes that. ### Gate ordering in the loop ``` loop every POLL_INTERVAL: used = read_mem_used_pct() pending = pending_analysis() if not analysis_enabled(): pending = 0 # existing gate if mem_yield_active(used): pending = 0 # NEW: inhibit starts if used >= HIGH_WATERMARK and vision running: # NEW: active stop stop_vision(); start_cooldown(); continue ... existing start-on-backlog / idle-debounce-stop logic on `pending` ... ``` Note the symmetry with the analysis-disabled gate already in `manager.sh`: forcing `pending = 0` reuses the existing idle/stop path for the *inhibit* case, while the explicit `stop_vision()` handles the *active* case the idle path can't. --- ## 4. Proposed configuration (all env, matching manager.sh style) | Var | Default | Meaning | |---|---|---| | `MEM_YIELD_ENABLED` | `1` | Master switch for the whole feature. | | `MEM_HIGH_WATERMARK_PCT` | `92` | `used_pct ≥` this → active-stop a running vision. | | `MEM_LOW_WATERMARK_PCT` | `80` | `used_pct ≥` this → inhibit starts (no restart until below). | | `MEM_YIELD_COOLDOWN` | `300` | Seconds after a pressure-stop during which restart is refused regardless of backlog. | | `MEM_POLL_INTERVAL` | `POLL_INTERVAL` | Optional faster cadence for the mem check (pressure can spike between 20 s ticks). | Watermarks want tuning on the real box; 92/80 are starting points. Keep `HIGH − LOW ≥ ~10` so the band is wider than normal jitter. --- ## 5. Required verifications (do these BEFORE building) The queue is built for expendability — `crawler_jobs` is a lease queue: ``` state | attempts | max_attempts(=5) | leased_until | last_error | scheduled_at ``` A graceful `docker stop` (SIGTERM → clean llama-server exit) means at most the **one** in-flight `analyze_page` job is affected; the readiness gate (`ANALYSIS_VISION_HEALTH_URL`) then parks the worker so it won't hammer the dead endpoint or burn the other queued jobs. But two things must be confirmed in `backend/src/analysis/daemon.rs` (not visible from the DB schema alone): - [ ] **V1 — reclaim cadence.** The `bugfix/crawler-recovery-hardening` branch added *reclaim-orphaned at startup*. Confirm expired leases are **also** reclaimed on the normal claim poll (i.e. the claim query includes something like `OR (state='running' AND leased_until < now())`). If reclaim is **boot-only**, a pressure-kill strands that one job as `running` with a dead lease until the next backend restart → the page silently never analyses. **This decides how aggressive we can safely be.** - [ ] **V2 — error path requeue.** When the worker's HTTP call to vision is refused (vision killed), confirm the job is returned to `pending` (`attempts++`) and not marked permanently `failed` on a connection error. A connection-refused/transport error should be *retryable*, distinct from a model-returned bad response. - [ ] **V3 — park does not deadlock.** Confirm that when the readiness gate parks the worker, it is **not holding an un-renewed lease** that blocks the job indefinitely (it should either release on park or let the lease expire + be reclaimed per V1). - [ ] **V4 — attempts accounting.** A pressure-kill is "our fault," yet still burns one of the 5 attempts. Confirm repeated yields on the *same* in-flight page can't quietly exhaust retries → permanent `failed`. Mitigations if it's a concern: bump `max_attempts`, or rely on the admin re-enqueue (all/manga/ chapter) you already have to recover stragglers. **Empirical check** (once V1–V3 look right): with analysis on and one job `running`, `docker stop mangalord-vision`, then watch `SELECT state, attempts, last_error FROM crawler_jobs WHERE state IN ('running','pending') AND payload->>'kind'='analyze_page'` — the killed job should reappear as `pending` with `attempts` incremented, and resume when vision restarts. This is the acceptance test for the whole feature. --- ## 6. Pitfalls - **`MemFree` vs `MemAvailable`** — using `MemFree` makes it fire constantly (page cache). Already addressed by §2, but it's the #1 way to get this wrong. - **Thrash / feedback loop** — if vision is the hog, stop→restart oscillates. The cooldown (§3) is the guard; without it the feature is worse than nothing. - **Spikes between ticks** — a Chromium burst can blow past 92 % inside a 20 s poll gap and the kernel OOM-kills before the manager's next read. The valve *reduces* OOM risk, it doesn't *eliminate* it. Consider a shorter `MEM_POLL_INTERVAL` for just the mem read, and treat this as defence-in-depth alongside (not instead of) `mem_limit` and swap/zram headroom. - **Measuring the wrong scope** — confirm `/proc/meminfo` in the container keeps showing **host** totals under this kernel/cgroup setup (it does today: MemTotal 8256576 kB == the Pi). If a future runtime namespaces it, the metric silently becomes the container's tiny cgroup and the gate never/always fires. - **Cold-start cost** — every pressure-stop pays ~minutes to mmap + warm 3.2 GiB on the next start. Don't set the watermarks so tight that normal operation trips them; the backlog will stall during each reload. - **Interaction with the idle-stop and disable gate** — make sure the three reasons vision can be down (idle-drained, analysis-disabled, memory-yield) are **distinctly logged** (flip-only, like the existing gates) so a stop is never misdiagnosed. A single muddy "stopped vision" line will cost debugging time. - **Double-stop / start races** — `docker stop` then an immediate backlog-driven `docker start` in the same or next tick. The cooldown plus checking `vision_running` before acting prevents this; don't drop those guards. - **OOM-killer still owns the tail** — `mem_limit: 6g` caps vision's own growth but the host can still be pushed over by the *sum* of everything else. This feature lowers the probability of a bad OOM kill; pairing it with a little **swap or zram** gives the kernel a cushion to survive the gap between a spike and the manager's reaction. --- ## 7. Recommendation Worth building, as a **third gate** in the existing `manager.sh` poll loop: `MemAvailable`-based metric, two watermarks (hysteresis), a restart cooldown, an **active stop** (the capability the crawl mutex lacks), and distinct flip-only logging. It supersedes `RESPECT_CRAWL_MUTEX` (which can then be removed). **Gate it on verification V1 first** — whether expired leases are reclaimed continuously or only at boot decides whether a yielded job cleanly retries or strands. If V1/V2 hold, this is a low-risk, high-value safety valve; if reclaim is boot-only, fix that in the backend before enabling memory-yield, otherwise each pressure-stop quietly drops a page until the next backend restart. Keep the manager dumb: this is ~30 lines of bash (one `awk` reader, two watermark comparisons, one cooldown timestamp) — no new dependencies, no new privilege, no web surface.