Модуль 5. Идемпотентность и Bulkhead в Serverless
Цель модуля: Защитить Git-хостинг от конкурентных push (два пользователя обновляют один ref одновременно) и от перегрузки git-gateway (100 одновременных clone убивают NFS). Реализовать Bulkhead через concurrency_limit в Terraform и идемпотентность через YDB UNIQUE constraint. Понять, что идемпотентный ответ (409) не потребляет concurrency-слот.
Стадия роста проекта: Интеграции. Git Smart HTTP работает, метаданные в YDB, Outbox доставляет события. Но теперь Вася и Петя делают push одновременно — и один перезаписывает другой. А 100 одновременных clone убивают NFS. Первый вопрос: «Как защитить refs от гонок и NFS от перегрузки в Serverless, где нет chan struct{}?»
Перед началом: Модули 1–4 (Setup, Smart HTTP, YDB, Outbox)
Инструменты: Yandex Cloud (concurrency_limit), YDB Serverless (UNIQUE constraint), Terraform, Go 1.22+
Трудоёмкость: ~6 ч (теория 1.5 ч · практика 3.5 ч · тестирование 1 ч)
🔥 Инцидент: два push — один победитель, другой потерян
Вася и Петя работают в одном репозитории. Оба делают git push origin main почти одновременно:
- Вася читает
refs/heads/main = abc123, готовит push наdef456. - Петя читает
refs/heads/main = abc123, готовит push на789012. - Вася обновляет ref:
abc123 → def456. Успех. - Петя обновляет ref:
abc123 → 789012. Успех. Перезаписал Васю.
В Git это называется non-fast-forward. Git-клиент ожидает, что сервер отклонит push, если before SHA не совпадает с текущим HEAD. Но наш сервер не проверяет — просто пишет. Результат: коммиты Васи потеряны.
Вторая проблема: в пятницу вечером 100 разработчиков одновременно делают git clone перед уходом домой. Каждый clone читает packfile с NFS. NFS не выдерживает 100 одновременных потоков чтения — latency растёт до 30 секунд, таймауты, ошибки. API тоже падает, потому что все Cloud Functions конкурируют за NFS.
Три задачи:
- Bulkhead — изолировать git-операции от API:
concurrency_limit = 5на git-gateway,concurrency_limit = 100на api. - Идемпотентность — атомарный UPDATE с условием
WHERE sha = $expected_sha(из Модуля 3) + UNIQUE constraint на outbox_events. - Связь — идемпотентный ответ (409 Conflict) возвращается ДО входа в concurrency-слот, не потребляя ресурс.
Сквозной User Story: «Вася и Петя делают git push origin main одновременно. В Модуле 3 атомарный UPDATE с условием защищает от гонок на уровне данных. В этом модуле мы добавляем Bulkhead: concurrency_limit = 5 на git-gateway изолирует git-операции, а идемпотентный ответ 409 возвращается до входа в слот. В Модуле 6 — как merge PR проходит через стейт-машину Saga.»
📖 Теория: Bulkhead и идемпотентность в Serverless
Глоссарий модуля:
Bulkhead (Переборка) — изоляция ресурсов: отказ в одном пуле не убивает остальные. В Serverless — concurrency_limit на Cloud Function в Terraform.
concurrency_limit — максимальное число одновременных вызовов Cloud Function. Настраивается в Terraform. При превышении — 503 Service Unavailable.
Идемпотентность — повторное выполнение операции даёт тот же результат. В Git: push с тем же SHA = no-op. В API: повторный запрос с тем же Idempotency-Key = тот же ответ.
Атомарный UPDATE с условием — UPDATE refs SET sha = $new WHERE sha = $expected. Если SHA не совпал — 0 строк затронуто, конфликт.
UNIQUE constraint — ограничение уникальности в YDB. Повторный INSERT с тем же ключом → ошибка, операция не выполняется.
Fast-forward — правило Git: push принимается, только если новый SHA является потомком текущего. Non-fast-forward → reject.
Ментальная модель Bulkhead: «Представьте корабль с водонепроницаемыми переборками. Если одна секция затоплена — вода не распространяется на остальные. Bulkhead в git-gateway — это concurrency_limit = 5: если 5 git-операций зависли (медленный NFS), API продолжает работать, потому что у него свой пул concurrency_limit = 100. Git-операции не топят API.»
Ментальная модель идемпотентности: «Представьте, что вы отправляете письмо с номером. Если не получили подтверждение — отправляете копию с тем же номером. Получатель видит номер и не обрабатывает повторно. В Git: push с тем же SHA — это копия письма с тем же номером. Сервер видит, что SHA уже в refs, и отвечает "ok" без повторной записи.»
5.1. Bulkhead в Serverless: concurrency_limit
В классической архитектуре (Архитектура v2, Модуль 5) Bulkhead реализуется через chan struct{}:
go// Классический Bulkhead (K8s / VPS) type GitHandler struct { sem chan struct{} // Пул на 5 слотов } func (h *GitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { select { case h.sem <- struct{}{}: defer func() { <-h.sem }() default: http.Error(w, "503 git pool overloaded", 503) return } // Обработка git-операции }
В Serverless нет долгоживущих процессов. Замена — concurrency_limit в Terraform:
hcl# Terraform: Bulkhead через concurrency_limit # Git Gateway: максимум 5 одновременных git-операций resource "yandex_function" "git-gateway" { name = "git-hosting-git-gateway" runtime = "golang122" memory = 512 timeout = 600 concurrency_limit = 5 # ← Bulkhead: 5 одновременных стримов # ... } # API: максимум 100 одновременных запросов resource "yandex_function" "api" { name = "git-hosting-api" runtime = "golang122" memory = 256 timeout = 30 concurrency_limit = 100 # ← API не зависит от git-операций # ... }
При превышении concurrency_limit Yandex Cloud возвращает 503 Service Unavailable автоматически. Код функции не меняется — платформа сама ограничивает параллелизм.
Ключевой инсайт: если 5 git-операций зависли (медленный NFS), API продолжает работать. Git-операции не топят API. Это и есть Bulkhead.
5.2. Идемпотентность: атомарный UPDATE + UNIQUE
В Git идемпотентность работает через SHA:
go// Идемпотентный push: если SHA уже в refs — no-op func (h *PushHandler) HandlePush(ctx context.Context, req PushRequest) error { // (1) Проверяем fast-forward: expected_sha должен быть текущим HEAD // Если expected_sha != current_sha → конфликт (кто-то уже обновил) // Если expected_sha == new_sha → no-op (идемпотентность) if req.ExpectedSHA == req.NewSHA { return nil // Push с тем же SHA — идемпотентный no-op } // (2) Атомарный UPDATE с условием (из Модуля 3) res, err := h.ydb.Execute(ctx, ` UPDATE refs SET sha = $new_sha, updated_at = CurrentUtcTimestamp(), version = version + 1 WHERE repo_id = $repo_id AND ref_name = $ref_name AND sha = $expected_sha `, params) if res.RowsAffected() == 0 { return ErrConflict // 409: SHA изменился конкурентным push } return nil }
Для outbox_events — UNIQUE constraint на event_id:
sql-- YDB: UNIQUE constraint (первичный ключ уже уникален) -- Повторный UPSERT с тем же event_id → перезапись, не дубль UPSERT INTO outbox_events (event_id, repo_id, event_type, payload, status, created_at) VALUES ($event_id, $repo_id, 'RepositoryPushed', $payload, 'pending', CurrentUtcTimestamp())
5.3. Критичная связь: идемпотентный ответ НЕ потребляет concurrency-слот
Рекомендация из ревью: если Idempotency-Key сработал (409 Conflict), он не должен потреблять ресурс concurrency.
Проблема: если проверка идемпотентности происходит ПОСЛЕ входа в concurrency-слот, то 100 конкурентных push с одним SHA займут все 5 слотов и вернут 409. Слоты потрачены впустую.
Решение: проверка идемпотентности ДО входа в слот:
go// cmd/git-gateway/main.go func handleGitRequest(w http.ResponseWriter, r *http.Request) { // (1) СНАРУЖИ concurrency-слота: быстрая проверка идемпотентности // Читаем текущий SHA из YDB (~0.5 RU, быстро) currentSHA, err := getCurrentSHA(r.Context(), repoID, refName) if err != nil { http.Error(w, "internal error", 500) return } // (2) Идемпотентность: push с тем же SHA → 200 OK без входа в слот if currentSHA == newSHA { w.WriteHeader(200) // Идемпотентный no-op, слот не занят return } // (3) Конфликт: SHA изменился → 409 без входа в слот if currentSHA != expectedSHA { http.Error(w, "409 Conflict: non-fast-forward", 409) return } // (4) Только теперь входим в concurrency-слот (тяжёлая операция: git-http-backend) // concurrency_limit = 5 ограничивает параллельные git-операции runGitHTTPBackend(w, r) }
Экономия: при 100 конкурентных push с одним SHA — 99 возвращают 409/200 мгновенно, не занимая слот. Только 1 реальный push входит в слот.
5.4. Rate Limiter на API Gateway
Дополнительная защита от перегрузки — Rate Limiter на API Gateway (из Модуля 1):
hcl# API Gateway: Rate Limiter x-yc-apigateway-plugin-rate-limiter: requests_per_second: 2 burst: 10 key_type: ip
Это защищает от DDoS и случайных скриптов, делающих 1000 запросов/сек. Rate Limiter срабатывает ДО Cloud Function — запрос даже не доходит до функции.
5.5. Этот паттерн в архетипах
Bulkhead и идемпотентность проявляются по-разному в четырёх архетипах:
| Архетип | Bulkhead | Идемпотентность | Почему критично |
|---|---|---|---|
| Финтех | Пул платежей vs пул баланса (Worker Pool) | Idempotency-Key + UNIQUE index | Двойное списание = деньги потеряны |
| Git-хостинг | concurrency_limit=5 на git-gateway | SHA-based: push с тем же SHA = no-op | Конкурентный push = потеря коммитов |
| Мессенджер | WebSocket пул vs REST пул | Дедупликация по message_id | Дубль сообщения = спам |
| Лента | Пул записи vs пул чтения | Идемпотентная публикация поста | Дубль поста = мусор в ленте |
Общий принцип: Bulkhead изолирует ресурсы, идемпотентность защищает от дублей. В Serverless Bulkhead = concurrency_limit, в K8s = chan struct{} или Worker Pool.
5.6. Компромиссы (Trade-offs)
| Решение | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| concurrency_limit (Serverless) | Платформа ограничивает параллелизм; код не меняется | Нет гибкости (нельзя динамически менять) | Serverless MVP |
| chan struct{} (K8s) | Гибкость; динамическое изменение | Нужен долгоживущий процесс | K8s / VPS |
| Идемпотентность ДО слота | 409/200 не потребляют слот | Доп. запрос в YDB для проверки SHA | Всегда для git-операций |
| Идемпотентность ПОСЛЕ слота | Проще код | Слоты тратятся на 409 | Антипаттерн |
| Rate Limiter на Gateway | Защита до функции | Грубое ограничение (по IP) | Всегда для публичных API |
Вывод: для Serverless Git-хостинга — concurrency_limit + идемпотентность ДО слота + Rate Limiter на Gateway. См. ADR-SV5.
🛠 Практические задания
Сценарий: Вася и Петя делают git push origin main одновременно. 100 пользователей делают git clone одновременно. Цель: ни один push не потерян, API не падает при 100 clone.
Задача 1: Bulkhead через concurrency_limit
Настроить в Terraform: concurrency_limit = 5 на git-gateway, concurrency_limit = 100 на api. Проверить: при 6 одновременных push 6-й получает 503.
✅ Готово, когда:
[ ] Terraform: concurrency_limit = 5 на git-gateway
[ ] Terraform: concurrency_limit = 100 на api
[ ] При 6 одновременных push: 5 обрабатываются, 6-й → 503
[ ] API продолжает работать при 5 занятых git-слотах
Задача 2: Идемпотентность ДО concurrency-слота
Реализовать проверку SHA до входа в слот: currentSHA == newSHA → 200 no-op, currentSHA != expectedSHA → 409 Conflict.
✅ Готово, когда: [ ] Проверка SHA происходит ДО git-http-backend (до входа в слот) [ ] Push с тем же SHA → 200 OK, слот не занят [ ] Push с устаревшим SHA → 409 Conflict, слот не занят [ ] Только реальный push (SHA совпадает с expected) входит в слот
Задача 3: Тест конкурентных push
Написать тест: 10 горутин делают push с разными SHA на один ref. Ровно 1 должен succeed, 9 — получить 409.
✅ Готово, когда:
[ ] 10 конкурентных push → ровно 1 успех, 9 конфликтов
[ ] Нет потерянных коммитов
[ ] Тест проходит с -race
Задача 4: Нагрузочный тест clone
k6: 100 одновременных clone. Проверить: git-gateway обрабатывает 5, остальные ждут или получают 503. API не падает.
✅ Готово, когда: [ ] k6: 100 одновременных clone [ ] git-gateway: максимум 5 одновременных (остальные 503 или очередь) [ ] API: продолжает отвечать на REST-запросы [ ] NFS: latency не превышает 5 сек (иначе — нужно больше concurrency или кэш)
⚠️ Антипаттерн для разбора
go// Go Cloud Function (Антипаттерн) func handleGitRequest(w http.ResponseWriter, r *http.Request) { // ❌ 1. Нет проверки идемпотентности до входа в слот // Все 100 конкурентных push входят в слоты и конкурируют // ❌ 2. Нет concurrency_limit в Terraform // 100 одновременных clone убивают NFS // ❌ 3. UPDATE refs без условия WHERE sha = $expected h.ydb.Execute(ctx, "UPDATE refs SET sha = $new_sha WHERE ref_name = $ref", params) // Конкурентный push перезаписывает другой }
Вопросы студенту:
- Что произойдёт при 100 одновременных clone без concurrency_limit? (NFS перегружен, все таймауты)
- Что произойдёт при 10 конкурентных push без условия WHERE sha = $expected? (Один перезапишет других)
- Почему проверка идемпотентности ПОСЛЕ входа в слот — антипаттерн? (Слоты тратятся на 409)
📐 Задание по визуализации (Mermaid)
Студенту необходимо нарисовать Flowchart: входящий push → проверка идемпотентности (до слота) → вход в concurrency-слот → git-http-backend → UPDATE refs.
Обязательные элементы:
- Проверка SHA до входа в слот (ветвление: 200 no-op / 409 Conflict / вход в слот)
- concurrency_limit = 5 (5 слотов + 503 при переполнении)
- Атомарный UPDATE с условием (rowsAffected = 1 или 0)
Шаблон для ориентира: /serverless-mvp/diagrams/05-bulkhead-idempotency.svg
🧩 Проверь себя
🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 5
Привет! Я начинаю Модуль 5 курса «Git Hosting на Go: Serverless MVP на Yandex Cloud». Выступи в роли Наставника-Архитектора.
Сначала проверь понимание: дай короткий ответ (2–3 предложения) — чем Bulkhead в Serverless (concurrency_limit) отличается от Bulkhead в K8s (chan struct{}), и почему идемпотентный ответ 409 не должен потреблять concurrency-слот.
Затем выдай задание:
- Настроить concurrency_limit = 5 на git-gateway и concurrency_limit = 100 на api в Terraform.
- Реализовать проверку идемпотентности (SHA) ДО входа в concurrency-слот.
- Написать тест: 10 конкурентных push → ровно 1 успех, 9 конфликтов.
- Нагрузочный тест k6: 100 одновременных clone.
Покажи мне код Антипаттерна (нет concurrency_limit, нет проверки SHA до слота, UPDATE без условия) и попроси объяснить, почему каждый из трёх пунктов убьёт систему.
НЕ ПИШИ за меня Terraform и handler. Дождись моего ответа с архитектурным планом. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «идемпотентность после слота», «нет Rate Limiter», «тест не проверяет 503»).
📝 Чек-лист ментора
- Bulkhead:
concurrency_limit = 5на git-gateway,concurrency_limit = 100на api. - Идемпотентность ДО слота: проверка SHA происходит до git-http-backend.
- 409/200 no-op не потребляют concurrency-слот.
- Атомарный UPDATE:
WHERE sha = $expected_sha, конфликт → 409. - Rate Limiter: на API Gateway, 2 req/s, burst 10.
- Тест: 10 конкурентных push → 1 успех, 9 конфликтов.
- Нагрузочный тест: 100 clone → git-gateway держит 5, API не падает.
- Mermaid-диаграмма: ветвление (200 no-op / 409 / вход в слот / 503).
- ADR-SV5: «Bulkhead: concurrency_limit vs chan struct{} vs Rate Limiter».
🔗 Мостик к Модулю 6 (PR Saga)
В конце ревью скажи студенту:
«Отлично, конкурентные push защищены: атомарный UPDATE с условием отклоняет non-fast-forward, Bulkhead изолирует git-операции от API, идемпотентный ответ не тратит слот. Но что если пользователь нажимает Merge PR? Это многошаговый процесс: validate CI checks → git merge → notify watchers → update search index. Если git merge прошёл, а индексация упала — PR зависает в статусе merging бесконечно. В Модуле 6 мы реализуем PR merge как стейт-машину Saga на Cloud Function Timer Trigger. Ключевой инсайт: git merge необратим — компенсация не ROLLBACK, а mark merge-failed + retry упавшего шага.»
Сквозной User Story: «Вася сделал push, Петя получил 409 (Модуль 5). Теперь Вася открывает PR и нажимает Merge. В Модуле 6 мы посмотрим, как saga-orchestrator ведёт стейт-машину: VALIDATING → MERGING → NOTIFYING → INDEXING → MERGED. Если индексация падает — PR не зависает, а помечается merge-failed с retry.»