18 KiB
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 >= threshold→ ALERT (пересечение «ниже→на/выше»);prev.alerting == Trueиused_pct >= thresholdиnow - prev.last_alert_at >= realert_s→ REALERT (cooldown истёк); иначе приalerting && >=threshold→ NONE (анти-спам: не на каждом тике, BR-3/AC-3);prev.alerting == Trueиused_pct < threshold→ RECOVERY (переход «выше→ниже», ровно одно сообщение, сбросalerting, BR-4/AC-4);prev.alerting == Falseиused_pct < threshold→ NONE (норма).
Модель состояния (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::lifespan—disk_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).