8.1 KiB
DEV TASK — ORCH-5: идемпотентность webhook (M-7)
Проект: orchestrator | Сервер: slin@82.22.50.71 | Репо: /home/slin/repos/orchestrator | Контейнер: orchestrator (8500)
Ветка: feature/ORCH-5-webhook-dedup из свежего main (main содержит ORCH-1/1b/2/4/6/7).
⚠️ ГРАБЛЯ push (ORCH-7, НЕ повтори): после коммитов git log origin/main..HEAD (твои коммиты есть), после push git log origin/main..origin/feature/ORCH-5-webhook-dedup ДОЛЖЕН показать твои коммиты. НЕ отчитывайся «PR готов» пока remote-ветка реально не содержит коммитов.
Проблема (M-7)
Webhook-хендлеры (src/webhooks/gitea.py, src/webhooks/plane.py) при каждом запросе делают INSERT INTO events и затем диспетчеризуют → enqueue_job(...). Dedup'а НЕТ — delivery-id нигде не читается (проверено grep'ом: нигде). Повторная доставка одного и того же вебхука (ретрай Gitea/Plane, сетевой реброд, ручной replay) → повторный enqueue → дубль запуска конвейера. Это тот же класс бага, что инцидент ET-009 (параллельные конвейеры на одном репо).
Цель: сделать обработку webhook идемпотентной — один delivery обрабатывается ровно один раз. Повторный с тем же delivery-id → залогировать как duplicate и вернуть {"status":"duplicate"} БЕЗ диспетчеризации/enqueue.
Текущее состояние (верифицировано по живому коду)
gitea_webhook(gitea.py:42): проверяет HMACX-Gitea-Signature→ INSERT events → диспетч (push/pr/status).plane_webhook(plane.py:52): HMACX-Plane-Signature→ INSERT events → ORCH-6 project-filter → диспетч.- Таблица
events(db.py:14):id, timestamp, source, event_type, payload, processed— нет delivery-id, нет UNIQUE. - Gitea шлёт заголовок
X-Gitea-Delivery(GUID на доставку). Plane свой delivery-заголовок шлёт НЕнадёжно.
Что сделать
- Миграция БД (паттерн
_ensure_column, db.py:74):- Добавить колонку
events.delivery_id TEXT(idempotent через_ensure_column). - Создать UNIQUE-индекс:
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_delivery ON events(delivery_id) WHERE delivery_id IS NOT NULL. (partial unique — старые строки с NULL delivery_id не конфликтуют).
- Добавить колонку
- Вычисление delivery-id (helper, напр. в
src/webhooks/_dedup.pyили в db.py):- Gitea:
request.headers.get("X-Gitea-Delivery"). Если пусто → fallbacksha256(source + event_type + body). - Plane: попробовать
X-Plane-Delivery/X-Hook-Deliveryесли есть; иначе fallbacksha256("plane" + body)(стабильный hash тела — повторная доставка идентичного тела даст тот же id). Префиксуй source чтобы gitea/plane не пересекались: итоговыйdelivery_id = f"{source}:{raw_or_hash}".
- Gitea:
- Идемпотентный INSERT + ранний выход:
- Заменить текущий
INSERT INTO events (source,event_type,payload)на INSERT сdelivery_idиINSERT OR IGNORE(или ловитьsqlite3.IntegrityError). - Если строка НЕ вставилась (delivery уже есть) →
logger.info("webhook duplicate delivery_id=..."), вернуть{"status":"duplicate"}и НЕ вызывать диспетч/enqueue. - Если вставилась (новый delivery) → продолжить как сейчас (диспетч).
- ⚠️ Порядок: dedup-проверка ПОСЛЕ HMAC-верификации (невалидную подпись по-прежнему 401), для plane — реши: dedup до или после ORCH-6 project-filter. Рекомендация: dedup СРАЗУ после HMAC и INSERT, ДО project-filter (чтобы повторная доставка не делала лишнюю работу). Но НЕ сломай ORCH-6 поведение
{"status":"ignored"}для unknown project на ПЕРВОЙ доставке.
- Заменить текущий
- Не трогать саму диспетч-логику, enqueue, ORCH-6 фильтр, stage_engine, очередь.
Ограничения
- 🚫 НЕ трогай: nginx, openclaw.json, .env-секреты, deploy-хук, Plane-webhook is_active, ORCH-1/1b/2/4/6/7, stage_engine.py, очередь, HMAC-проверку (только добавляешь dedup ПОСЛЕ неё).
- ⚠️ Миграция должна быть restart-safe и не падать на уже существующей БД с данными (как
_ensure_columnдля jobs). - ⚠️ 9 pre-existing webhook-тестов (401) — это baseline, НЕ чинить и НЕ ломать дополнительно.
- Conventional Commits:
feat(webhook): dedup deliveries by delivery_id (M-7),feat(db): add events.delivery_id + unique index,test(webhook): ....
Тесты (в контейнере)
- Новый
tests/test_webhook_dedup.py:- первый webhook с delivery-id X → обрабатывается (status accepted), enqueue вызван.
- повторный webhook с тем же delivery-id X →
{"status":"duplicate"}, enqueue НЕ вызван (мок enqueue_job, assert call_count не вырос). - два РАЗНЫХ delivery-id → оба обработаны.
- gitea без X-Gitea-Delivery → fallback hash работает (повтор того же тела = duplicate).
- plane fallback hash аналогично.
- миграция на «старой» БД без колонки delivery_id не падает.
- Прогон:
IMG=$(docker inspect orchestrator --format '{{.Config.Image}}'); docker run --rm -v /home/slin/repos/orchestrator:/code -w /code --entrypoint python3 $IMG -m pytest tests/ -q - Baseline: сейчас 136 passed, 9 pre-existing 401 — не трогать. Новые зелёные, старые не сломать.
Acceptance (проверит Стрим вживую)
| # | Что | Критерий |
|---|---|---|
| 1 | миграция | events.delivery_id + partial UNIQUE index, restart-safe, не падает на проде-БД |
| 2 | gitea dedup | повтор X-Gitea-Delivery → duplicate, без enqueue |
| 3 | plane dedup | повтор тела → duplicate (fallback hash) |
| 4 | HMAC цел | невалидная подпись по-прежнему 401 |
| 5 | ORCH-6 цел | unknown project на первой доставке → ignored (не сломано) |
| 6 | тесты | новые зелёные, baseline 136 не сломан |
| 7 | прод | пересобран из ветки, health ok, /queue ok, миграция применилась на живой БД |
Деплой + отчёт
docker compose up -d --build && sleep 6 && curl -s :8500/health && curl -s :8500/queue- Проверь что миграция применилась:
docker exec orchestrator python3 -c "import sqlite3;print([r[1] for r in sqlite3.connect('/app/data/orchestrator.db').execute('PRAGMA table_info(events)')])"— должна бытьdelivery_id. - После каждого блока — короткий отчёт + результат.
- Запушь ветку (ПРОВЕРЬ remote!), открой PR в main. НЕ мержи — мерж делает Стрим после проверки.
- Других исполнителей на репо нет — только ты, не паникуй про параллельные сессии.