Go: Не перегружаем Label при сбое — задача с проверкой

Практическая задача на Go по теме «Не перегружаем Label при сбое». Условие содержит контракт функций, предоставленные типы и требования к результату. Используйте выданный шаблон и сохраняйте его публичные объявления. Перед проверкой разберите успешный путь, обработку ошибок и отмену операции, когда она предусмотрена контрактом. Здесь оценивается конкретный компонент сервиса; в полном курсе микросервисов на Go эти компоненты связываются с HTTP, PostgreSQL, gRPC и Kafka.

В уроке 6.4 вы уже научили сервис делать до трёх попыток одного Label-вызова и ждать между ними. Теперь представьте, что одновременно поступает двадцать таких запросов. Каждый запускает свою серию повторов. Каждая серия в свой момент делает физический POST к Label-сервису. Сервис не знает о соседних — и в худшем случае двадцать горутин одновременно атакуют один медленный upstream. Это thundering herd: каждый клиент ведёт себя разумно, но в совокупности они перегружают цель.

Решение — семафор на буферизованном канале. Один общий канал ёмкости 4 разделяется всеми двадцатью горутинами. Отправка токена в канал означает «я занял слот», чтение означает «я освободил слот». Пока в буфере четыре токена, пятая отправка блокирует горутину. Канал считает ширину параллелизма, а не порядок или время.

Что вам нужно реализовать

Реализуйте RunLabelCall в предоставленном шаблоне:

func RunLabelCall(
    ctx    context.Context,
    slots  chan struct{},
    policy AdmissionPolicy,
    call   func(context.Context) error,
) error

Предоставленные типы (не изменяйте):

type AdmissionPolicy string

const AdmissionQueue  AdmissionPolicy = "queue"
const AdmissionReject AdmissionPolicy = "reject"

var ErrLabelBusy = errors.New("label concurrency limit reached")

Гарантированные входные условия

  • ctx и call — не nil.
  • slots — один незакрытый буферизованный канал ёмкости 4, общий для всех двадцати вызовов; никто снаружи функции не отправляет в него и не читает из него.
  • policy — ровно одно из двух значений: AdmissionQueue или AdmissionReject.
  • call — синхронный; получает тот же объект ctx; может вернуть nil, любую ошибку или запаниковать; отмену обрабатывает кооперативно.

Контракт поведения

1. Проверка до допуска

Если ctx.Err() уже не nil при входе в функцию — вернуть эту ошибку немедленно, не трогая канал и не вызывая call.

2. AdmissionQueue — ожидать или отменить

Ожидание свободного места должно реагировать на отмену исходного контекста.

Пока все четыре слота заняты, горутина блокируется в select, не удерживая слот семафора. Порядок входа не гарантирован — Go не обещает FIFO среди ожидающих. Количество ожидающих горутин не ограничено этим семафором.

3. AdmissionReject — немедленный отказ

Если свободного места нет, возвращается ErrLabelBusy без ожидания и без callback. Если отмена уже наблюдаема при отказе, ctx.Err() имеет приоритет.

ErrLabelBusy — это локальное решение об отказе в допуске, не HTTP-статус и не информация о Label-сервисе. Что делать с этой ошибкой дальше — политика слоя выше.

4. Проверка после приобретения

После успешной отправки токена select мог выбрать именно эту ветку в тот момент, когда ctx.Done() уже закрыт — Go допускает случайный выбор среди одновременно готовых веток. Поэтому сразу после приобретения нужно обнаружить уже наблюдаемую отмену, освободить приобретённый слот и вернуть ctx.Err(), не вызывая callback.

После приобретения освобождается только свой слот. Отмена до приобретения не даёт права читать из канала и освобождать слот другого вызова.

5. Освобождение через defer

Для каждого приобретённого слота обеспечьте ровно одно освобождение при любом последующем выходе.

Этот defer выполнится при любом выходе: обычном return, return с ошибкой, и при панике. Паника не подавляется — функция не делает recover. Исходное значение паники должно пройти наружу после освобождения слота.

6. Вызов callback

Вызвать call ровно один раз, передав тот же объект ctx — не обёртку, не context.Background(), а оригинал. Вернуть его ошибку как есть, включая nil. Не интерпретировать, не подменять, не добавлять retry внутри этой функции.

Пример допуска

Пока четыре вызова работают, следующий запрос с AdmissionQueue ждёт свободного места или отмены. При отмене он завершается без callback и не меняет занятость чужих слотов. С AdmissionReject тот же запрос при живом контексте получает ErrLabelBusy сразу. Когда один допущенный вызов завершится, его место станет доступно следующему запросу; порядок ожидающих не фиксируется.

Ошибка или паника внутри допущенного вызова не должна оставлять место занятым. При этом ошибка возвращается без подмены, а паника распространяется с исходным значением. Один слот относится к одному физическому вызову, поэтому во время backoff следующая серия может использовать освободившееся место.

Что функция не делает

  • Не создаёт новый канал, горутину, таймер, контекст, HTTP-запрос или retry-цикл.
  • Не создаёт отдельный канал на каждый вызов — иначе общего ограничения не будет.
  • Не удерживает слот во время backoff между попытками в 6.4 — слот занят только во время одного физического вызова.
  • Не интерпретирует ошибку callback и не заменяет её.
  • Не подавляет панику.
  • Не освобождает чужие токены при отмене до приобретения.

Связь с 6.3 и 6.4

Сохранённые из 6.3 поля — идентификатор запроса, маршрут POST /labels, заголовок Idempotency-Key — остаются неизменными при повторах. Семафор к идемпотентности ничего не добавляет. Решение о следующей попытке принимает исполнитель 6.4 на основе классификатора 6.3 — RunLabelCall в этом не участвует.

Типичное место вызова внутри одной попытки из 6.4 (иллюстрация контекста интеграции, не вводимый код):

// Внутри одной попытки из 6.4; slots общий для всех серий.
var decision RetryDecision
err := RunLabelCall(ctx, slots, AdmissionQueue, func(callCtx context.Context) error {
    decision = callAndClassifySavedPOST(callCtx, savedOperation)
    return nil
})
// Исполнитель 6.4 далее использует err и decision.

Задание

Допишите RunLabelCall в предоставленном шаблоне. Сохраните предоставленные типы, константы, переменную ошибки и публичный JSON-адаптер; отправьте весь код шаблона.

РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Связанные задачи