Модуль 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.

Три задачи:

  1. Bulkhead — изолировать git-операции от API: concurrency_limit = 5 на git-gateway, concurrency_limit = 100 на api.
  2. Идемпотентность — атомарный UPDATE с условием WHERE sha = $expected_sha (из Модуля 3) + UNIQUE constraint на outbox_events.
  3. Связь — идемпотентный ответ (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 автоматически. Код функции не меняется — платформа сама ограничивает параллелизм.

100%
🖱 Перетащите • Колесо для зума

Ключевой инсайт: если 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 входит в слот.

100%
🖱 Перетащите • Колесо для зума

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-gatewaySHA-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 перезаписывает другой
}

Вопросы студенту:

  1. Что произойдёт при 100 одновременных clone без concurrency_limit? (NFS перегружен, все таймауты)
  2. Что произойдёт при 10 конкурентных push без условия WHERE sha = $expected? (Один перезапишет других)
  3. Почему проверка идемпотентности ПОСЛЕ входа в слот — антипаттерн? (Слоты тратятся на 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

🧩 Проверь себя

1. Что произойдёт при 6 одновременных push с concurrency_limit = 5?
2. Почему идемпотентный ответ (409) не должен потреблять concurrency-слот?
3. Rate Limiter на API Gateway срабатывает...
4. Как Git обеспечивает идемпотентность push?
5. Как реализуется Bulkhead в Serverless?
6. Атомарный UPDATE с условием WHERE sha = $expected_sha защищает от...

🤖 Инструкция для 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-слот.

Затем выдай задание:

  1. Настроить concurrency_limit = 5 на git-gateway и concurrency_limit = 100 на api в Terraform.
  2. Реализовать проверку идемпотентности (SHA) ДО входа в concurrency-слот.
  3. Написать тест: 10 конкурентных push → ровно 1 успех, 9 конфликтов.
  4. Нагрузочный тест 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.»