open atlas
← Все проекты

infra · advanced · 6d

Очередь задач at-least-once

Собери долговечную очередь задач на Postgres с visibility timeout и идемпотентными консьюмерами, чтобы упавший воркер не терял задачу.

Большинство туториалов по очередям дают тебе управляемый брокер и пропускают сложное: что реально происходит при краше воркера на середине задачи. Этот проект убирает брокер и вынуждает строить сетку безопасности с первых принципов — атомарный захват, который нельзя взять дважды, visibility timeout, воскрешающий задачи от мёртвых воркеров, и идемпотентный консьюмер, превращающий неизбежную повторную доставку в no-op. Разрыв между at-least-once доставкой (что может обещать любая долговечная очередь) и exactly-once эффектом (что реально нужно бизнес-логике) перекрывается исключительно на стороне консьюмера, и понимание этой границы отличает очередь, работающую под нагрузкой, от той, что молча портит состояние при каждом retry.

Результат

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

Этапы

0/3 · 0%
  1. 01Захват задач без двойного забора

    Захватывай задачи через FOR UPDATE SKIP LOCKED, чтобы два воркера не взяли одну строку.

    Критерии готовности
    • Два параллельных воркера, выполняя запрос захвата, никогда не получают одну строку задачи; пропущенная по SKIP LOCKED строка достаётся другому воркеру.
    • Захват помечает строку in-flight в той же транзакции, что и выбирает её.
  2. 02Перепостановка задач от умерших воркеров

    Добавь visibility timeout, который возвращает в очередь задачу, чей воркер умер до ack.

    Критерии готовности
    • Задача, чей воркер умер до ack, снова становится доступной для захвата по истечении visibility timeout, а не теряется.
    • Ещё работающий воркер продлевает аренду до таймаута, чтобы его задачу не увели на полпути.
  3. 03Сделай повторную доставку no-op

    Сделай консьюмер идемпотентным через ключ идемпотентности, чтобы повторная доставка была no-op.

    Критерии готовности
    • Обработка одной задачи дважды (тот же ключ идемпотентности) даёт ровно один эффект; второй проход — no-op.
    • Ядовитая задача, всегда падающая, после N попыток уходит в dead-letter, а не крутится вечно.

Стартер

  • README.md
  • src/queue.ts
  • test/queue.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: 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-тест с убийством воркеров на середине задачи; докажи ноль потерь и ноль небезопасных дублей.

Навыки

SELECT ... FOR UPDATE SKIP LOCKEDvisibility timeoutidempotency keysdead-letter handling