# Incident: disk-full → PostgreSQL recovery loop → 4h prod downtime

**Дата:** 2026-05-29
**Длительность простоя:** ~4 часа 7 минут (05:41 UTC → 09:48 UTC)
**Серьёзность:** P1 (полная недоступность БД, сайт liderra.ru отдавал HTTP 500)
**Корневая причина:** диск `/dev/vda1` заполнился до 100% из-за неконтролируемого роста `laravel.log` (8.7 ГБ); PostgreSQL вошла в бесконечный PANIC loop при попытках записать checkpoint.

---

## 1. Хронология (UTC)

| Время | Событие |
|---|---|
| 28.05 ~утро | Этап 4 slepok-routing-protection выкачен на прод (PR #28 merged). |
| 28.05 ~день | Stage 5 day-1 monitoring обнаружил 2 P1: F1 (audit-chain race) + F2 (webhook storm 256k от B1+SMS combo). |
| 28.05 + 29.05 ночь | Plans + code-fixes F1/F2 merged на main (commits `f1486015..00671741`). Прод НЕ затронут — ждали ручной выкатки. |
| 29.05 ~04:00 | Шторм webhook-повторов растёт — failed_webhook_jobs от лидов 1110, 1157 уже ~256k. Laravel пишет каждое исключение в `laravel.log` (~30-50 КБ stack trace на запись). |
| 29.05 05:11 UTC | Последняя успешная INSERT в БД (`failed_jobs` + `failed_webhook_jobs` + UPDATE `supplier_leads`) видна в pg_audit логе. |
| 29.05 05:31:04 UTC | Последняя успешная checkpoint завершилась. lsn=0/8BD8B068. |
| 29.05 05:41:03 UTC | `PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp": No space left on device`. PostgreSQL аварийно завершилась. |
| 29.05 05:41:13 UTC | `terminating any other active server processes; all server processes terminated; reinitializing`. Recovery start. |
| 29.05 05:41:14 UTC | `redo done at 0/8BD8BA58 system usage: ...`. Recovery почти готова, нужен end-of-recovery checkpoint. |
| 29.05 05:41:14 UTC | `PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp": No space left on device`. Опять — диск так же забит. **Recovery loop начинается.** |
| 29.05 05:41 → 09:41 | **4 часа в loop:** каждые ~3 минуты PG пытается recovery → end-of-recovery checkpoint → PANIC → restart. |
| 29.05 09:11 UTC | Первый запрос (SELECT через sql-runner). FATAL: not yet accepting connections. Инцидент обнаружен. |
| 29.05 09:25 UTC | Создан `pg-diagnose.yml` workflow, диагностика. Выявлено: `/dev/vda1 19G/19G/0 100%`. |
| 29.05 09:38 UTC | Создан `disk-recover.yml` v1. Освобождено 440 МБ (apt clean + nginx старые gz). Недостаточно — PG ещё в recovery. |
| 29.05 09:46 UTC | Создан `disk-recover.yml` v2. Truncate `laravel.log` (8.7G) + `syslog` (525M) + remove playwright cache (631M). Free space: **11 ГБ**. |
| 29.05 09:48 UTC | `SELECT 1` проходит. PG восстановлена. HTTPS liderra.ru = HTTP 200. |
| 29.05 09:54 UTC | F2 cleanup: 2 supplier_leads resolved (1110, 1157). |
| 29.05 09:57 UTC | F2 cleanup: 420 192 failed_webhook_jobs marked resolved. |
| 29.05 10:00 UTC | F1 migration applied via postgres superuser (advisory-lock в `audit_chain_hash`). Registered в migrations table batch=13. |
| 29.05 10:02 UTC | `deploy.yml` re-run — success. F2 fast-fail код на проде. |
| 29.05 10:10 UTC | Verify: 0 новых unresolved failed_webhook_jobs за час. Шторм остановлен. |
| 29.05 10:24 UTC | F1.5 scheduler heartbeat reset (consecutive_failures=0 для `audit:verify-chains`). |
| 29.05 10:26 UTC | logrotate config `/etc/logrotate.d/laravel-liderra` установлен (size 50M, rotate 5, copytruncate). |

---

## 2. Корневая причина (3 фактора)

### Фактор A: Constraint violation создал бесконечный retry loop

2 лида от поставщика (id 1110, 1157, один и тот же номер `+7***34038`) пришли с комбинацией `B1 + SMS signal_type`. Это нарушает DB constraint `chk_supplier_projects_b1_not_for_sms` (B1 платформа не поддерживает SMS-сигналы).

При каждой попытке обработки:

1. `RouteSupplierLeadJob` пытается INSERT/UPDATE → PostgreSQL отвергает с CHECK constraint.
2. Laravel ловит `QueryException` → пишет в `failed_jobs` + `failed_webhook_jobs` для retry.
3. Stack trace целиком (включая SQL, биндинги, vendor frames) пишется в `laravel.log` через стандартный Laravel handler.
4. Через интервал ретрая (по умолчанию короткий) — новая попытка → goto 1.

**Результат:** 420 192 повтора (на момент cleanup, к моменту incident возможно ~256k). Каждый занимает ~30-50 КБ в `laravel.log` → суммарно **8.7 ГБ**.

### Фактор B: Отсутствие fast-fail логики

В коде `RouteSupplierLeadJob` НЕ было guard'а «если уже падал на constraint — не пытайся снова». F2 code fix (`b28a9c03`) добавляет такой guard через проверку `supplier_lead.error LIKE '%chk_supplier_projects_b1_not_for_sms%'` → early return без INSERT.

Этот фикс был **merged на main 28.05 утром** (commits F2 `f1486015..f97103b0`), но **не выкачен на прод** до момента incident. Если бы был выкачен — повторов было бы 2-3, не 420k.

### Фактор C: Отсутствие size-based log rotation

Существующий daily rotation (`laravel.log` → `laravel.log.1` ежедневно) **недостаточен**: за один день шторма (24 часа × ~5000 повторов/час × ~35 КБ) накопилось 8.7 ГБ — больше места на 19G диске после остальных данных.

Не было ограничения по размеру файла. Не было компрессии.

---

## 3. Что сделано (incident response)

### 3.1. Восстановление прода

| Действие | Workflow | Результат |
|---|---|---|
| Диагностика диска | `pg-diagnose.yml` (новый) | Найден `laravel.log` 8.7G, `syslog` 525M, playwright cache 631M |
| Чистка диска (truncate + rm) | `disk-recover.yml` v1+v2 (новый) | Свободно 0 → 11G |
| Resolve 2 stuck supplier_leads | `sql-runner.yml` | UPDATE 2 |
| Resolve 420k failed_webhook_jobs | `sql-runner.yml` | UPDATE 420192 |
| F1 migration (advisory-lock) | `f1-apply-via-superuser.yml` (новый) | Function updated, registered batch=13 |
| F2 fast-fail deploy | `deploy.yml` | success |
| F1.5 reset watcher | `sql-runner.yml` | UPDATE 1 (consecutive_failures=0) |

### 3.2. Профилактика повторения

| Что | Workflow | Конфиг |
|---|---|---|
| Size-based log rotation | `setup-logrotate.yml` (новый) | `/etc/logrotate.d/laravel-liderra`: size 50M, rotate 5, compress, copytruncate |
| PG log rotation triggered | `sql-runner.yml` `SELECT pg_rotate_logfile()` | Exit 0 (effect depends on `logging_collector` setting) |

---

## 4. Что НЕ сделано (deferred)

### 4.1. F1 chain rebuild — 6 residual mismatches в activity_log (algorithm divergence)

**Что сделано (29.05.2026 incident-followup):** новый workflow
`.github/workflows/f1-rebuild-via-superuser.yml` через `sudo -u postgres psql`
выполняет plpgsql DO-блок с sequential hash recomputation:

- `balance_transactions_y2026_m05` (213 rows, ids 462..674): rebuilt → **0 mismatches**,
  verify-chains intact ✅
- `activity_log_y2026_m05` (186 rows, ids 599..784): rebuilt → **6 mismatches остаются** ⚠️

**Корневая причина 6 residual mismatches — algorithm divergence в самом проекте:**

| Алгоритм | Файл | Chain semantics |
|---|---|---|
| **Trigger** (writes hashes) | `db/schema.sql` `audit_chain_hash()` | `SELECT log_hash FROM <partition> ORDER BY id DESC LIMIT 1` — **global** chain (no tenant filter) |
| **Rebuild canonical** (artisan) | `app/Console/Commands/AuditRebuildChain.php` | Same as trigger — global ORDER BY id |
| **Verify** | `app/Console/Commands/VerifyAuditChains.php` `TABLE_CONFIG['activity_log']` | `'partition' => 'PARTITION BY tenant_id'` — **per-tenant** chain |

Trigger и rebuild создают global chain (per partition table). Verify ожидает
per-tenant chain. Когда `activity_log_y2026_m05` содержит multiple tenants —
trigger-produced global chain не выглядит intact для verify.

`balance_transactions` верифицируется без `partition`-config (global) → matches trigger/rebuild → 0 mismatches.

`activity_log` верифицируется с `partition: 'PARTITION BY tenant_id'` →
divergent от global trigger → 6 mismatches (это **multi-tenant rows where chain order
differs by tenant_id grouping**).

**Не блокирует бизнес:** F1 advisory-lock защищает от *новых* race-mismatches.
Существующие 6 rows — historical data integrity gap в 152-ФЗ журнале, не операционный.

**Что нужно решить (отдельная сессия / ADR):**

Один из двух путей:

1. **Align verify с trigger:** изменить `TABLE_CONFIG['activity_log']['partition']`
   с `'PARTITION BY tenant_id'` на `''` (global). Pros: minimum code change.
   Cons: ослабляет 152-ФЗ guarantee — global chain через всех tenants
   позволяет cross-tenant tampering detection слабее.
2. **Align trigger с verify:** изменить trigger `audit_chain_hash()` чтобы
   читал prev_hash с `WHERE tenant_id = NEW.tenant_id`. Pros: stronger
   per-tenant 152-ФЗ. Cons: миграция всех existing audit rows + rebuild
   tool needs per-tenant variant.

Decision требует ADR + design session. **Pending.**

### 4.2. PG log file 498 МБ

`/var/log/postgresql/postgresql-16-main.log` — текущий лог-файл PG. `sudo bash -c ': > file'` дал Permission denied даже из-под root (вероятно AppArmor profile postgresql).

`SELECT pg_rotate_logfile()` выполнен — effect зависит от `logging_collector` setting в `postgresql.conf`. Если collector enabled — лог ротирован. Если disabled — нет.

**Не критично:** 498 МБ из 19 ГБ при 11 ГБ free.

**Как сделать:** проверить `SHOW logging_collector;` через sql-runner. Если `off` — лог пишется через системный wrapper, тогда нужен `logrotate` config для `/var/log/postgresql/*.log` (аналогично laravel-liderra). Если `on` — `pg_rotate_logfile()` уже сработал, нужно удалить старый файл (`sudo find /var/log/postgresql -name "*.log.*" -mtime +0 -size +100M -delete`).

---

## 5. Уроки / Action Items

### Немедленные (сделано)

- ✅ logrotate config для laravel.log с size-based лимитом
- ✅ F2 fast-fail на проде
- ✅ F1 advisory-lock на проде

### Среднесрочные (TODO)

- ⏸ **F1 chain rebuild через postgres superuser path** — отдельный workflow `f1-rebuild-via-superuser.yml`.
- ⏸ **PG log file rotation** — проверить `logging_collector` setting и применить соответствующий fix.
- ⏸ **Disk usage alert** — добавить cron-задачу `df -h /` с alert если >85% (через scheduler:check-heartbeats или отдельный workflow + telegram).
- ⏸ **Laravel log level review** — рассмотреть уменьшение verbosity для constraint violation errors (они и так в `supplier_leads.error`, не нужны полные stack-trace в файл при каждом ретрае).
- ⏸ **Retry policy** — failed_webhook_jobs ретраил без exponential backoff и без max-attempts limit. Добавить `max_attempts=3` для constraint-violation jobs.

### Процессные

- 🚨 **Deploy F1+F2 после Stage 5 day-1 findings должен был быть ASAP, не через сутки.** Найденные P1 фиксы лежали merged на main и НЕ выкачивались — это создало окно для шторма. Правило: **P1 findings → deploy в течение часов, не суток.**

---

## 6. Workflows созданные в ходе incident response

| File | Назначение |
|---|---|
| `.github/workflows/pg-diagnose.yml` | Read-only SSH diagnostic: systemctl/journalctl/df/free + tail PG logs + WAL size + HTTPS probe |
| `.github/workflows/disk-recover.yml` | Mutating cleanup: truncate laravel.log, syslog, PG log, remove playwright cache, vacuum journald, apt clean |
| `.github/workflows/f1-apply-via-superuser.yml` | Apply F1 migration via `sudo -u postgres psql` + register in migrations table |
| `.github/workflows/setup-logrotate.yml` | Install `/etc/logrotate.d/laravel-liderra` + verify --debug + force initial rotation |
| `.github/workflows/artisan-run.yml` (edit) | Allow `audit:rebuild-chain --partition=<name> --from-id=<n> [--force]` в MUTATING_RE whitelist |

Все workflow read-only-by-default (mutating требуют `confirm_apply=true` или `confirm_mutating=true` input).

---

## 7. Cross-refs

- F1 plan: `docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md`
- F2 plan: `docs/superpowers/plans/2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md`
- Stage 5 monitoring: `docs/superpowers/plans/2026-05-29-stage5-monitoring-checklist.md`
- Stage 5 findings handoff: `docs/superpowers/handoffs/2026-05-29-stage5-findings-merged-handoff.md`
- Memory pending entries (см. handoff §3): `feedback_subagent_falsified_test_results`, `feedback_powershell_bypasses_verify_before_push`, `feedback_subagent_worktree_bootstrap` + новые from this incident: `feedback_disk_full_root_cause_2026_05_29`, `feedback_pg_recovery_panic_loop_pattern`
