Files
orchestrator/docs/work-items/ORCH-063/06-adr/ADR-001-disk-watchdog.md

18 KiB
Raw Permalink Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-063 architecture architect proposed 2026-06-09 claude-opus-4-8

ADR-001: Disk-watchdog — heartbeat-демон мониторинга заполнения хост-ФС + Telegram-алерт при ≥85%

Work Item: ORCH-063 — INFRA: мониторинг диска mva154 + алерт при >85% Стадия: architecture Сквозная регистрация: docs/architecture/adr/adr-0024-disk-watchdog.md (новый фоновый компонент-демон в ряду reconciler/job_reaper — кросс-каттинговое решение).

Статус

Proposed

Контекст

07.06.2026 диск хоста mva154 (slin@82.22.50.71) тихо дорос до 100% и положил весь конвейер всех проектов (CI красный, очередь Gitea застряла). Корневая боль: у оркестратора нет проактивного сигнала о заполнении диска — оператор узнаёт о проблеме постфактум, когда self-hosting-инстанс orchestrator (8500, один на все прод-проекты, общая БД/очередь) уже встал (BRD §1).

Факты, сверенные с кодом:

  • В оркестраторе уже есть каркас фонового daemon-потока, повторённый дважды: src/reconciler.py::Reconciler (ORCH-053) и src/job_reaper.py (ORCH-065) — оба threading.Thread(daemon=True) + threading.Event, чистый stop через self._stop.wait(interval), контракт start()/stop(timeout)/status(), never-raise на тик, наблюдаемость через GET /queue. Старт/стоп — в src/main.py::lifespan (старт после reaper.start(), стоп в reverse-порядке), снимок — в @app.get("/queue") ("reaper": reaper.status() и др.).
  • Контейнер бежит network_mode: host с bind-mount'ами host-разделов: /home/slin/repos → /repos, ./data → /app/data (docs/operations/INFRA.md §«Тома»). Именно эта ФС переполнилась 07.06. Замер по overlay / контейнера нерепрезентативен (BRD §6, NFR-3).
  • Алерты шлются через src/notifications.py::send_telegram (notifying по умолчанию; silent — только при явном disable_notification).
  • Образец «чистая leaf-логика + тонкая обёртка» уже принят: src/task_deps.py, src/serial_gate.py, src/staging_verdict.py — pure-функции (never-raise) + точечные врезки.

«Как есть» не годится: единственный сигнал о диске — падение всего конвейера. Нужен дешёвый ранний heartbeat-watchdog. ТЗ (02-trz) фиксирует требования; данный ADR фиксирует как (§OQ-1..OQ-5).

Решение

Сводка

Вводим disk-watchdog — новый фоновый daemon-поток src/disk_watchdog.py, точная калька архитектуры reconciler/job_reaper. Демон каждые disk_monitor_interval_s (дефолт 300с) меряет заполнение смонтированных хост-путей через stdlib shutil.disk_usage(path), дедуплицирует пути по физическому устройству (os.stat(path).st_dev), и через чистую функцию решения от (used_pct, threshold, prev_state, now) решает: послать алерт (пересечение порога вверх), повторить (cooldown disk_monitor_realert_s), послать recovery (возврат вниз) или молчать. Состояние анти-спама — in-memory (без миграции БД). Наблюдаемость — аддитивный блок disk_monitor в GET /queue. Kill-switch disk_monitor_enabled. never-raise на каждом уровне. STAGE_TRANSITIONS/QG_CHECKS/check_*/схема БД — не трогаются.

D1 — Способ замера: stdlib shutil.disk_usage (OQ-1, FR-2/NFR-2)

Замер каждого пути — shutil.disk_usage(path) (total/used/free в байтах), used_pct = round(used / total * 100, 1). Чистый системный вызов statvfs, без порождения субпроцесса df на каждом тике (NFR-2: heartbeat порядка минут, дёшево). df отвергнут (см. Альтернативы).

  • Почему репрезентативно для хост-ФС (NFR-3, AC-8): shutil.disk_usage(path) возвращает статистику ФС, которой принадлежит path. На bind-mount'е /repos//app/data это хост-раздел (тот, что переполнился 07.06), а не overlay контейнера. Дефолтный набор путей — /repos,/app/data; shutil.disk_usage("/") не используется как источник истины.
  • Недоступный/несуществующий путь (FileNotFoundError/PermissionError/OSError) → пропуск этого пути с logger.warning, остальные пути меряются дальше (FR-2, AC-6: один битый путь не роняет весь тик).

D2 — Дедуп путей по физическому устройству (OQ-2, FR-2)

Перед замером пути резолвим os.stat(path).st_dev и схлопываем пути с одинаковым st_dev в один логический раздел (ключ дедупа — st_dev; для отображения берём первый успешно резолвнутый путь). На mva154 /repos и /app/data с высокой вероятностью лежат на одном host-разделе (BRD §6) → один алерт, а не два дубля. Дедуп — желательное требование (BRD §6), реализуемое, never-raise: ошибка os.stat → путь обрабатывается как отдельный (fail-open, без потери замера).

D3 — Чистая функция решения + модель состояния (OQ-3, FR-4, AC-3/AC-4)

Решение об отправке вынесено в pure-функцию (юнит-тестируема без потока и реального таймера, AC-3):

decide_action(used_pct, threshold, prev: PathAlertState, now, realert_s) -> Action
# Action ∈ {NONE, ALERT, REALERT, RECOVERY}
  • prev.alerting == False и used_pct >= thresholdALERT (пересечение «ниже→на/выше»);
  • prev.alerting == True и used_pct >= threshold и now - prev.last_alert_at >= realert_sREALERT (cooldown истёк); иначе при alerting && >=thresholdNONE (анти-спам: не на каждом тике, BR-3/AC-3);
  • prev.alerting == True и used_pct < thresholdRECOVERY (переход «выше→ниже», ровно одно сообщение, сброс alerting, BR-4/AC-4);
  • prev.alerting == False и used_pct < thresholdNONE (норма).

Модель состояния (in-memory, per device/path): PathAlertState{alerting: bool, last_alert_at: float|None}, словарь {dedup_key -> PathAlertState} в демоне. Durable-хранение отвергнуто (OQ-3): TRZ §5/NFR-5 допускает in-memory, состояние best-effort. После рестарта alerting сбрасывается → при всё ещё полном диске придёт повторный алерт — это безопасно (ранний сигнал, не SLA). Время инъецируется now-провайдером (дефолт — обёртка над часами; в тестах — фейк), чтобы cooldown/recovery тестировались детерминированно (AC-3).

D4 — Отправка алерта: формат в leaf + send_telegram напрямую (OQ-5, FR-3)

Форматирование текста — pure-функция в disk_watchdog.py (format_alert_message / format_recovery_message, тестируема). Отправка — прямой send_telegram(text) (notifying, не silent — это алерт, как notify_error); отдельный helper в notifications.py не вводим (минимизация поверхности; OQ-5 оставляет выбор за архитектором). Вызов send_telegram обёрнут try/except → ошибка доставки логируется и не роняет тик (BR-6/AC-6; доставка best-effort, BRD §6).

  • Содержимое (действенное, FR-3/AC-2): точка монтирования/путь, занято %, свободно (ГБ и %), порог; текст на русском в стиле существующих notify_*. Пример: 🔴 Диск mva154: /repos заполнен на 87.3% (порог 85%). Свободно 6.2 ГБ (12.7%). Освободите место — риск остановки конвейера всех проектов. Recovery: 🟢 Диск mva154: /repos вернулся ниже порога — 78.1% (свободно 11.0 ГБ).

D5 — Один порог 85% (OQ-4, BR-2)

Один настраиваемый порог disk_monitor_threshold_pct (дефолт 85, зафиксирован Владельцем). Второй «критический» порог (напр. 95%) с усиленным алертом — вне объёма (OQ-4, BRD §8 R-3), кандидат на follow-up. Конфигурируемость порога (BR-5) оставляет рычаг тюнинга.

D6 — Lifecycle и точки врезки (FR-1/FR-5/FR-6, AC-1/AC-5/AC-7)

  • src/disk_watchdog.py (новый leaf) — pure-логика (measure_paths, decide_action, format_*) + класс DiskWatchdog(threading.Thread(daemon=True) + threading.Event) с start()/stop(timeout=5.0)/status(); цикл while not self._stop.is_set(): try: tick(); except: log; self._stop.wait(interval). Модуль-синглтон disk_watchdog = DiskWatchdog().
  • src/config.py — флаги §«Конфигурация» (D7); defensive-валидация значений (порог 1..100, интервалы > 0) → невалидное к дефолту + warning (паттерн reconcile_grace_*).
  • src/main.py::lifespandisk_watchdog.start() последним (после reaper.start(), гард if settings.disk_monitor_enabled), disk_watchdog.stop() первым в finally (reverse-порядок). Демон независим (не трогает очередь/БД) → порядок не критичен, но следуем конвенции.
  • @app.get("/queue") — аддитивный ключ "disk_monitor": disk_watchdog.status(); существующие ключи не меняются; status() never-raise (при ошибке — {"enabled": ...} минимум, FR-6/AC-7). Снимок: enabled, threshold_pct, interval_s, realert_s, paths (по каждому устройству/пути: path, used_pct, free_gb, alerting, last_alert_at).
  • .env.example — дескрипторы ORCH_DISK_* (AC-5).

D7 — Конфигурация (src/config.py, FR-5/AC-5)

Поле (env) Тип / дефолт Назначение
disk_monitor_enabled (ORCH_DISK_MONITOR_ENABLED) bool = True kill-switch; False → демон не стартует (нулевая регрессия, NFR-4).
disk_monitor_interval_s (ORCH_DISK_MONITOR_INTERVAL_S) int = 300 период heartbeat (порядок минут, NFR-2).
disk_monitor_threshold_pct (ORCH_DISK_MONITOR_THRESHOLD_PCT) int = 85 порог алерта (дефолт Владельца).
disk_monitor_realert_s (ORCH_DISK_MONITOR_REALERT_S) int = 21600 cooldown повторного алерта выше порога (~6 ч, анти-спам).
disk_monitor_paths (ORCH_DISK_MONITOR_PATHS) str = "/repos,/app/data" (CSV) отслеживаемые host-пути (NFR-3); пусто → дефолтный набор.

D8 — Инварианты (NFR-5/NFR-6, AC-6)

  • STAGE_TRANSITIONS, реестр QG_CHECKS, check_*, схема существующих таблиц БД — без изменений (watchdog — эксплуатационный демон, не QG; как reconciler/reaper). Новой миграции нет (D3).
  • never-raise на трёх уровнях: per-path (D1), per-tick (внешний try/except в _run), per-send (D4). Сбой watchdog не блокирует и не роняет конвейер (BR-6/NFR-1).
  • Self-hosting безопасность (NFR-6): watchdog только читает заполнение и шлёт Telegram — не трогает диск/контейнер, не рестартит прод. Безопасен для enduro-trails в общем инстансе.

Альтернативы

  • Субпроцесс df -P на каждом тике — отвергнут: лишнее порождение процесса при heartbeat порядка минут (NFR-2), парсинг вывода, зависимость от формата df. shutil.disk_usage — stdlib, без субпроцесса, кроссплатформенно.
  • Замер по / (overlay контейнера) — отвергнут: нерепрезентативен для хост-раздела (NFR-3/AC-8), прямой путь к повтору инцидента 07.06 (ложно-низкое заполнение).
  • Durable-состояние анти-спама (доп. таблица) — отвергнуто: TRZ §5/NFR-5 допускает in-memory; повторный алерт после рестарта при полном диске безопасен; миграция = лишняя поверхность и усложнение отката.
  • Внешний мониторинг (Prometheus/Grafana/node_exporter) — вне объёма (BRD §2.2): тяжёлая инфра-зависимость против принципа «минимум зависимостей, всё в Docker на одном сервере». Дешёвый встроенный heartbeat закрывает боль.
  • Новый endpoint GET /disk — не вводим (TRZ §4 рекомендация): снимок отдаётся блоком в /queue, меньше API-поверхности.

Последствия

  • + Ранний сигнал о заполнении диска до остановки конвейера всех проектов; дешёвая страховка от дорогого группового self-hosting-простоя.
  • + Полная архитектурная калька проверенных reconciler/reaper → низкий риск, знакомый паттерн для ревью/сопровождения.
  • + Чистая pure-логика (decide_action, format_*, measure_paths) юнит-тестируема без потока и таймера (AC-3/AC-6).
  • In-memory состояние → повторный алерт после рестарта при всё ещё полном диске. Митигейшн: это безопасно (ранний сигнал, не SLA; NFR-5) и редко (рестарт прода — событие).
  • Best-effort доставка Telegram (та же send_telegram): алерт может не дойти при сбое сети. Митигейшн: watchdog — ранний сигнал, не гарантия; cooldown-повтор повышает шанс доставки.
  • Дедуп по st_dev не покрывает редкий случай разных устройств для /repos и /app/data (тогда — два независимых алерта, что корректно). Без ущерба.
  • Откат: ORCH_DISK_MONITOR_ENABLED=false (демон не стартует, блок /queue{"enabled": false}, поведение 1:1 как сейчас). Полное удаление — снять врезки в main.py/config.py + удалить leaf; миграций БД нет → откат тривиален (TRZ §7).

Ссылки

  • BRD: docs/work-items/ORCH-063/01-brd.md
  • TRZ: docs/work-items/ORCH-063/02-trz.md
  • Acceptance: docs/work-items/ORCH-063/03-acceptance-criteria.md
  • Инфра: docs/work-items/ORCH-063/07-infra-requirements.md
  • Риски: docs/work-items/ORCH-063/10-tech-risks.md
  • Сквозной ADR: docs/architecture/adr/adr-0024-disk-watchdog.md
  • Сверено по коду: src/reconciler.py (каркас демона), src/job_reaper.py (lifecycle/status), src/main.py (lifespan §94-118, /queue §142-173), src/notifications.py::send_telegram, docs/operations/INFRA.md (bind-mount'ы /repos, /app/data).