#!/usr/bin/env bash # vision-manager — start/stop the mangalord-vision (llama.cpp) container by # name according to the analysis backlog in Postgres. # # Why this exists: llama-server has no idle-unload and pins ~4.4 GiB. On the # 8 GiB box vision must only run while there is analysis work. This sidecar is # the SINGLE owner of the vision lifecycle and the only component with Docker # access (scoped through docker-socket-proxy) — the internet-facing backend # never gets the socket. See VISION-AUTOSCALE.md. # # It is deliberately dumb: a poll loop with psql + curl + docker. No web # surface, no state beyond an in-memory idle timer. If it dies, vision keeps # running (harmless RAM); a MAX_UPTIME backstop bounds that. set -euo pipefail # ---- Config (all overridable via env) -------------------------------------- : "${DATABASE_URL:?DATABASE_URL must be set (use the read-only vision_manager role)}" VISION_CONTAINER="${VISION_CONTAINER:-mangalord-vision}" VISION_HEALTH_URL="${VISION_HEALTH_URL:-http://mangalord-vision:8000/health}" # DOCKER_HOST points at the scoped docker-socket-proxy by default. export DOCKER_HOST="${DOCKER_HOST:-tcp://docker-socket-proxy:2375}" POLL_INTERVAL="${POLL_INTERVAL:-20}" # seconds between backlog checks STOP_DEBOUNCE="${STOP_DEBOUNCE:-600}" # idle seconds before stopping vision START_HEALTH_TIMEOUT="${START_HEALTH_TIMEOUT:-300}" # max wait for /health 200 HEALTH_POLL_INTERVAL="${HEALTH_POLL_INTERVAL:-5}" # poll cadence while warming HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-5}" # per-probe curl timeout, seconds MAX_UPTIME="${MAX_UPTIME:-0}" # >0: force-stop after N idle-or-not seconds running (backstop); 0 disables # When >0, refuse to start vision while a crawl is running (Chromium + vision # together OOM the 8 GiB box). Set to 0 on roomier hosts. RESPECT_CRAWL_MUTEX="${RESPECT_CRAWL_MUTEX:-1}" PSQL=(psql "$DATABASE_URL" -At -v ON_ERROR_STOP=1) log() { echo "[vision-manager] $(date -u +%FT%TZ) $*"; } # ---- Queries --------------------------------------------------------------- # Pending analysis work: pending OR currently-leased analyze_page jobs. Note # the queue column is `state` (not `status`) and the kind lives in the JSONB # payload — see backend/migrations/0012_crawler.sql. pending_analysis() { "${PSQL[@]}" -c \ "SELECT count(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page' AND state IN ('pending','running');" 2>/dev/null || echo "ERR" } # A crawl is in-flight if any non-analyze job is currently running. crawl_running() { "${PSQL[@]}" -c \ "SELECT count(*) FROM crawler_jobs WHERE payload->>'kind' <> 'analyze_page' AND state = 'running';" 2>/dev/null || echo "ERR" } # ---- Docker helpers -------------------------------------------------------- # `docker inspect .State.Running` → true/false; empty if the container is # absent (defined elsewhere and not yet created) — treated as not-running. vision_running() { docker inspect -f '{{.State.Running}}' "$VISION_CONTAINER" 2>/dev/null || echo "false" } vision_ready() { curl -fsS -m "$HEALTH_TIMEOUT" -o /dev/null "$VISION_HEALTH_URL" 2>/dev/null } start_vision() { log "starting $VISION_CONTAINER" # Idempotent: starting an already-running container is a docker no-op. if ! docker start "$VISION_CONTAINER" >/dev/null 2>&1; then log "ERROR: docker start failed (is $VISION_CONTAINER defined on this host?)" return 1 fi local waited=0 while ! vision_ready; do if [ "$waited" -ge "$START_HEALTH_TIMEOUT" ]; then log "WARNING: $VISION_CONTAINER not healthy after ${START_HEALTH_TIMEOUT}s; leaving it running" return 1 fi sleep "$HEALTH_POLL_INTERVAL" waited=$((waited + HEALTH_POLL_INTERVAL)) done log "$VISION_CONTAINER healthy after ~${waited}s" } stop_vision() { log "stopping $VISION_CONTAINER (idle ${STOP_DEBOUNCE}s)" if docker stop "$VISION_CONTAINER" >/dev/null 2>&1; then return 0 fi log "WARNING: docker stop failed; will retry next tick" return 1 } # ---- Main loop ------------------------------------------------------------- log "started: container=$VISION_CONTAINER health=$VISION_HEALTH_URL poll=${POLL_INTERVAL}s debounce=${STOP_DEBOUNCE}s docker_host=$DOCKER_HOST" idle_for=0 # seconds the queue has been empty while vision is running up_for=0 # seconds vision has been running (for MAX_UPTIME backstop) crawl_deferred=0 # 1 while a start is held off by the crawl mutex (log on flip only) while true; do pending="$(pending_analysis)" running="$(vision_running)" # Treat anything non-numeric (the "ERR" sentinel, or a stray psql notice on # stdout) as "query failed" — never feed it to `-gt`, which under # `set -e` would otherwise exit-2 and kill the loop. case "$pending" in ''|*[!0-9]*) log "WARNING: backlog query failed (got '${pending}'); will retry next tick" sleep "$POLL_INTERVAL" continue ;; esac if [ "$running" = "true" ]; then up_for=$((up_for + POLL_INTERVAL)) else up_for=0 fi if [ "$pending" -gt 0 ]; then idle_for=0 if [ "$running" != "true" ]; then if [ "$RESPECT_CRAWL_MUTEX" != "0" ] && [ "$(crawl_running)" != "0" ]; then # Log once per deferral episode, not every poll, so a long crawl # doesn't flood the log. if [ "$crawl_deferred" != "1" ]; then log "deferring start: $pending analysis job(s) pending but a crawl is running (RAM mutex)" crawl_deferred=1 fi else crawl_deferred=0 start_vision || true fi else crawl_deferred=0 fi else crawl_deferred=0 # No work. Debounce the stop so bursty enqueues don't thrash the load # cycle, and own the stop on our OWN timer (never wait for a backend # "drained" signal — leak safety). if [ "$running" = "true" ]; then idle_for=$((idle_for + POLL_INTERVAL)) if [ "$idle_for" -ge "$STOP_DEBOUNCE" ]; then # Only reset the timers if the stop actually took; otherwise let them # keep counting so we retry on the next tick rather than waiting out # another full debounce window. if stop_vision; then idle_for=0 up_for=0 fi fi else idle_for=0 fi fi # Backstop: bound how long vision can stay up regardless of debounce state. if [ "$MAX_UPTIME" -gt 0 ] && [ "$running" = "true" ] && [ "$up_for" -ge "$MAX_UPTIME" ]; then log "MAX_UPTIME ${MAX_UPTIME}s reached; force-stopping $VISION_CONTAINER" if stop_vision; then idle_for=0 up_for=0 fi fi sleep "$POLL_INTERVAL" done