Skip to content

Latest commit

 

History

History
131 lines (96 loc) · 7.07 KB

File metadata and controls

131 lines (96 loc) · 7.07 KB

Notifier Service

Пакет notifier реализует асинхронную отправку уведомлений во внешний сервис с ограничением скорости, повторными попытками и корректным завершением.

Запуск

go test -race -v ./notifier/

Быстрый старт

n := notifier.New(externalClient, notifier.Options{
Workers:   4,
RateLimit: 10,
})

err := n.Send(ctx, notifier.Message{ID: "1", Payload: "hello"})

stats := n.GetStats() // Sent, Failed, Retries

n.Close() // дожидается завершения всех задач

Архитектура

Общая схема

Send() → [tasks chan] → worker 1 → rate limiter → ExternalClient.Post()
                      → worker 2 → rate limiter → ExternalClient.Post()
                      → worker N → rate limiter → ExternalClient.Post()

Сообщения попадают в буферизированный канал tasks. Пул воркеров читает из канала, ожидает разрешения от rate limiter и выполняет отправку.

Обоснование реализации

Worker Pool на горутинах + канал задач

Я использовал стандартный подход в Go – фиксированный пул горутин, которые читают задачи из общего канала. Канал tasks одновременно играет роль очереди и механизма синхронизации: если очередь заполнена, Send() блокируется и не даёт засыпать систему сообщениями.

Альтернатива: можно было бы запускать отдельную горутину на каждое сообщение, но при высокой нагрузке это приведёт к неконтролируемому росту горутин и усложнит ограничение скорости.

Rate Limiter на основе Token Bucket

Отдельная горутина с time.Ticker добавляет токены в буферизированный канал с частотой 1/N секунды, где N – лимит запросов в секунду. Воркер перед отправкой забирает токен из канала – если токенов нет, он ждёт.

Почему не golang.org/x/time/rate? Я решил написать свою реализацию, чтобы показать, как token bucket работает изнутри. Получилось около 30 строк. При необходимости могу переделать на golang.org/x/time/rate.

Размер буфера канала токенов: я выбрал размер буфера равным N, чтобы при простое накопилось до N токенов и первая пачка запросов ушла без ожидания. Больше N токенов не накопится – лишние тики отбрасываются.

Retry с экспоненциальным backoff

При получении HTTP 429 запрос повторяется до 3 раз. Задержка удваивается с каждой попыткой: 500ms → 1s → 2s. Это снижает нагрузку на внешний сервис, когда он перегружен.

Почему retry только на 429? Задание явно указывает на 429. Я думал о retry для 5xx, но не стал добавлять – повтор при серверной ошибке может как помочь, так и ухудшить ситуацию, и мне показалось, что для правильного решения не хватает контекста о поведении внешнего сервиса.

Graceful Shutdown через done-канал

При вызове Close():

  1. Устанавливается флаг closed – новые Send() сразу получают ErrClosed.
  2. Закрывается канал done – все воркеры одновременно получают сигнал.
  3. Каждый воркер обрабатывает оставшиеся задачи в канале tasks (drain).
  4. Close() ждёт завершения всех воркеров через sync.WaitGroup.

Почему done-канал, а не закрытие tasks? Если закрыть tasks из Close(), а Send() в другой горутине попытается записать в него – будет паника. Канал done безопасен: в него никто не пишет, его только закрывают.

Проверка closed через atomic.Bool: позволяет Send() быстро отсечь вызовы после закрытия без блокировки. Дополнительно, select с done-каналом страхует от гонки, если Close() вызвали между проверкой флага и записью в канал.

Метрики на atomic.Int64

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

GetStats() возвращает снимок: значения трёх счётчиков читаются по очереди, поэтому снимок может на долю миллисекунды отставать от реальности. Для метрик мониторинга это приемлемо.

Структура пакета

notifier/
├── contract.go       – публичные интерфейсы, типы, ошибки
├── limiter.go        – rate limiter (token bucket)
├── notifier.go       – реализация Notifier
└── notifier_test.go  – тесты

Покрытие тестами

Сценарий Тест
Успешная отправка TestSendSuccess
Retry при 429 TestRetryOn429
Исчерпание попыток TestMaxRetriesExhausted
Отправка после Close TestSendAfterClose
Отмена контекста TestContextCancellation
Graceful shutdown TestGracefulShutdownProcessesRemaining
Конкурентная отправка TestConcurrentSends
Rate limiting TestRateLimiting
Ответ 5xx TestNon2xxResponseFails
Ошибка клиента TestClientReturnsError
Повторный Close TestDoubleCloseIsIdempotent
Точность метрик TestStatsAccuracy

Все тесты проходят с флагом -race.