infra · advanced · 6d
Очередь задач at-least-once
Собери долговечную очередь задач на Postgres с visibility timeout и идемпотентными консьюмерами, чтобы упавший воркер не терял задачу.
Результат
Очередь, где каждая поставленная задача выполняется хотя бы раз даже при крашах воркеров, а дубликаты обезврежены идемпотентными обработчиками.
Этапы
0/3 · 0%- 01Захват задач без двойного забора
Захватывай задачи через FOR UPDATE SKIP LOCKED, чтобы два воркера не взяли одну строку.
Критерии готовности- Два параллельных воркера, выполняя запрос захвата, никогда не получают одну строку задачи; пропущенная по SKIP LOCKED строка достаётся другому воркеру.
- Захват помечает строку in-flight в той же транзакции, что и выбирает её.
- 02Перепостановка задач от умерших воркеров
Добавь visibility timeout, который возвращает в очередь задачу, чей воркер умер до ack.
Критерии готовности- Задача, чей воркер умер до ack, снова становится доступной для захвата по истечении visibility timeout, а не теряется.
- Ещё работающий воркер продлевает аренду до таймаута, чтобы его задачу не увели на полпути.
- 03Сделай повторную доставку no-op
Сделай консьюмер идемпотентным через ключ идемпотентности, чтобы повторная доставка была no-op.
Критерии готовности- Обработка одной задачи дважды (тот же ключ идемпотентности) даёт ровно один эффект; второй проход — no-op.
- Ядовитая задача, всегда падающая, после N попыток уходит в dead-letter, а не крутится вечно.
Стартер
- README.md
- src/queue.ts
- test/queue.test.ts
Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Атомарность захвата | Воркер выбирает задачу и обновляет её двумя отдельными запросами; при конкурентной нагрузке два воркера изредка захватывают одну строку. | Захват — единственный UPDATE ... WHERE state='pending' RETURNING с FOR UPDATE SKIP LOCKED: конкурирующие воркеры никогда не дублируют захват, а пропущенная строка немедленно доступна следующему поллеру. | Ты можешь объяснить модель конкуренции: SKIP LOCKED масштабируется на множество воркеров без ожидания блокировок, но концентрирует всю ожидающую работу на старейших строках; ты измеряешь потолок пропускной способности захвата и знаешь, когда партиционировать таблицу очереди. |
| Visibility timeout и повторная доставка | Задача упавшего воркера зависает в состоянии 'claimed' до ручного вмешательства; автоматической перепостановки нет. | Sweep-процесс или проверка аренды возвращает в очередь задачи с истёкшим visibility timeout; живой воркер продлевает аренду через heartbeat, чтобы длинные задачи не угонялись. | Ты устанавливаешь таймаут по p99 длительности задачи и можешь сформулировать два режима отказа: слишком короткий — медленный, но живой воркер гонится за собственной задачей; слишком длинный — реальный краш стопорит полосу на минуты — ты документируешь выбранное значение и обоснование. |
| Идемпотентный консьюмер и dead-letter | Повторно доставленные задачи обрабатываются снова, изредка порождая дублирующиеся эффекты; ограничения попыток нет. | Ключ дедупликации, записанный в той же транзакции, что и эффект, делает повторную доставку no-op; после N неудач задача уходит в dead-letter вместо цикличного повтора. | Ты рассуждаешь о границе атомарности: если эффект и ключ дедупа в разных коммитах, краш между ними либо применяет дважды, либо повторяет вечно — твой дизайн делает оба варианта невозможными, и ты доказываешь это хаос-тестом, убивающим воркеров в каждой точке краша. |
Эталонный разбор (спойлер)
Почему at-least-once — это честная базовая гарантия: доставка exactly-once требует распределённой координации, которая либо очень дорога (двухфазный коммит), либо невозможна между разнородными системами. Любая долговечная очередь на едином хранилище может обещать только at-least-once: задача перезапустится, если воркер упадёт до ack, а корректность перекладывается на консьюмер через идемпотентность.
FOR UPDATE SKIP LOCKED как примитив захвата: он объединяет выборку и блокировку в одном операторе, и ни один второй воркер не может увидеть ту же строку; SKIP предотвращает очередь ожидания блокировок — блокирующий FOR UPDATE сериализовал бы всех воркеров на горячей таблице вместо разветвления.
Ловушка настройки visibility timeout: правильный таймаут — чуть выше p99 длительности задачи, не p50 и не консервативный 10x. Слишком короткий — гонка с живыми воркерами; слишком длинный — задачи упавших воркеров стопорят полосу. Heartbeat с обновлением аренды на интервале меньше таймаута — правильный фикс для длинных задач, а не увеличение таймаута.
Глубина dead-letter как самый ранний сигнал: ядовитая задача кладёт каждого воркера, который её касается, и без DLQ она вечно занимает голову очереди, блокируя все последующие задачи. Рост глубины DLQ — первый наблюдаемый симптом того, что что-то выше по цепи сломано или payload битый — алерть на него, а не только на латентность задач.
Сделай по-сеньорски
- Добавь экспоненциальный backoff с jitter и dead-letter очередь после N неудач.
- Прогони chaos-тест с убийством воркеров на середине задачи; докажи ноль потерь и ноль небезопасных дублей.