Как делать11 октября 2026 г.8 мин чтения
Backend-разработкаШаг 11 из 13

Фоновые задачи и очереди: что выносить из запроса

Коротко

Из HTTP-запроса выносят всё, что медленно, зависит от чужих сервисов и не нужно пользователю для ответа: письма и уведомления, вызовы CRM и исходящие вебхуки, отчёты и выгрузки. API кладёт задачу в очередь и сразу отвечает, а отдельный процесс — воркер — выполняет её и при сбое повторяет с растущими паузами. Чтобы повтор не отправил второе сообщение, обработчик делают идемпотентным, а задачи, исчерпавшие попытки, собирают и разбирают отдельно.

РуководствоУровень: СреднийФоновые задачиПрактика: ~1–2 ч

После материала вы сможете

  • определить, какие операции вынести из HTTP-запроса в фоновую очередь, а какие оставить в запросе
  • поставить задачу в очередь BullMQ из Express-обработчика и обработать её воркером с повторами и экспоненциальным backoff
  • защитить обработку от дублей: Idempotency-Key в API, jobId в очереди и отметка «уже отправлено» в воркере
  • найти «мёртвые» задачи через getFailed() и вернуть их в очередь после устранения причины

Проверено на

Node.js 24 LTS (24.18)BullMQ 6.3ioredis 6.0Express 5.3Redis 7.4 (redis:7-alpine)TypeScript 7.0

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Почему долгий запрос — это проблема
  2. 02Очередь, продюсер и воркер
  3. 03Продюсер: API ставит задачу и сразу отвечает
  4. 04Воркер: повторы, backoff и ошибки, которые не лечатся повтором
  5. 05Идемпотентность: повтор не должен отправить второе сообщение
  6. 06«Мёртвые» задачи и наблюдаемость
  7. 07Отложенные и периодические задачи; альтернативы
  8. 08Упражнения

Почему долгий запрос — это проблема

Возьмём API заявок из упражнения к уроку по REST API на Express: обработчик POST /api/leads проверяет данные, сохраняет заявку и отвечает. Соблазн — дописать туда же письмо менеджеру, сообщение в Telegram и передачу в CRM. Тогда время ответа становится суммой времени всех внешних сервисов, а надёжность формы — надёжностью самого слабого из них.

  • Пользователь ждёт и нажимает кнопку ещё раз — появляется дубль заявки.
  • У прокси, балансировщика и самого клиента есть таймауты: запрос обрывается, когда часть работы уже сделана, и непонятно, какая.
  • Внешний сервис лежит — и форма отвечает ошибкой, хотя заявка в базе уже есть.
  • Повторы внутри запроса («попробуем ещё три раза») умножают ожидание, а не решают проблему.

Проверка данных, сохранение заявки

Где выполнять
В запросе
Почему
Без этого нельзя честно ответить «заявка принята»

Письмо или сообщение менеджеру

Где выполнять
В фоне
Почему
Пользователю не нужно ждать доставки; сервис может быть недоступен

Передача в CRM или 1С, исходящий вебхук

Где выполнять
В фоне
Почему
Чужой сервис со своими лимитами, сбоями и задержками

PDF-отчёт, выгрузка в Excel, обработка изображений

Где выполнять
В фоне + статус задачи
Почему
Может занимать минуты и нагружать процессор

Подтверждение оплаты

Где выполнять
По правилам провайдера
Почему
Результат нужен пользователю, а финальный статус обычно приходит вебхуком провайдера

Очередь, продюсер и воркер

В схеме три роли. Продюсер (producer) — код, который ставит задачу в очередь; у нас это обработчик API. Очередь хранит задачи до выполнения; в BullMQ это структуры данных в Redis. Воркер (worker) — отдельный процесс, который забирает задачи по одной или пачкой и выполняет. Если воркер остановлен, задачи не теряются, а копятся и ждут — при условии, что Redis хранит данные на диске (об этом ниже).

  1. 01Форма
  2. 02API — продюсер
  3. 03Redis — очередь
  4. 04Воркер
  5. 05Сервис уведомлений
API только ставит задачу; доставкой и повторами занимается воркер

Задача в BullMQ проходит состояния: waiting — ждёт воркера, active — выполняется, completed или failed — завершена. Между неудачными попытками задача лежит в delayed: ждёт паузу перед повтором.

Код ниже проверен на BullMQ 6.3. В шестой версии клиент Redis ioredis стал необязательной peer-зависимостью: его нужно установить явно, иначе при создании очереди вы получите ошибку «BullMQ could not load the optional 'ioredis' package». Redis для разработки удобно поднять в Docker.

bash
npm init -y && npm pkg set type=module
npm install bullmq ioredis express
npm install -D typescript @types/express @types/node
docker run -d --rm --name kb-redis-jobs -p 6379:6379 redis:7-alpine
Проект, зависимости и Redis 7 в контейнере (--rm удалит его после остановки)

Node.js 24 запускает .ts-файлы напрямую, просто удаляя аннотации типов, поэтому в импортах указано расширение .ts. Компилятор нужен только для проверки: npx tsc --noEmit.

tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
Настройки только для проверки типов: Node сам исполняет TypeScript без сборки
src/redis.ts
import { Redis } from 'ioredis';

const url = process.env.REDIS_URL ?? 'redis://localhost:6379';

// The producer runs inside an HTTP handler: if Redis is down,
// fail fast instead of holding the client's request.
export const producerConnection = () =>
  new Redis(url, { maxRetriesPerRequest: 1, enableOfflineQueue: false });

// The worker must ride out disconnects: BullMQ requires null here.
export const workerConnection = () => new Redis(url, { maxRetriesPerRequest: null });
Два вида подключений: продюсеру — быстрые ошибки, воркеру — терпеливое переподключение
src/queue.ts
import { Queue } from 'bullmq';
import type { Redis } from 'ioredis';

export type LeadNotification = { leadId: string; name: string; phone: string };

export const NOTIFICATIONS = 'notifications';

export function createNotificationsQueue(connection: Redis) {
  return new Queue<LeadNotification>(NOTIFICATIONS, {
    connection,
    defaultJobOptions: {
      attempts: 4, // first try + 3 retries
      backoff: { type: 'exponential', delay: 1000 }, // waits 1 s, 2 s, 4 s
      removeOnComplete: { count: 1000 }, // keep the last 1000 successful jobs
      removeOnFail: { age: 7 * 24 * 3600 }, // keep failed jobs for a week
    },
  });
}
Очередь и параметры задач по умолчанию: 4 попытки, экспоненциальная пауза, автоочистка

Продюсер: API ставит задачу и сразу отвечает

Обработчик сохраняет заявку (в примере — в памяти процесса, в проекте — в таблице БД), ставит задачу lead-created и отвечает 201, не дожидаясь отправки уведомления.

src/api.ts
import { randomUUID } from 'node:crypto';
import express from 'express';
import { createNotificationsQueue } from './queue.ts';
import { producerConnection } from './redis.ts';

type Lead = { id: string; name: string; phone: string; createdAt: string };

const leads = new Map<string, Lead>(); // a database table in a real project
const queue = createNotificationsQueue(producerConnection());
queue.on('error', (err) => console.error('[queue]', err.message || err.name));

// While Redis is down, queue.add waits for a reconnect: don't let the request hang.
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  let timer: NodeJS.Timeout | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`timeout after ${ms} ms`)), ms);
  });
  promise.catch(() => {}); // a late rejection must not crash the process
  try {
    return await Promise.race([promise, timeout]);
  } finally {
    clearTimeout(timer);
  }
}

const app = express();
app.use(express.json());

app.post('/api/leads', async (req, res) => {
  const { name, phone } = req.body ?? {};
  if (typeof name !== 'string' || typeof phone !== 'string') {
    res.status(400).json({ error: 'name and phone are required' });
    return;
  }

  // The client generates the key once per form submission:
  // a repeated request creates neither a second lead nor a second job.
  const id = req.get('Idempotency-Key') ?? randomUUID();
  if (leads.has(id)) {
    res.status(200).json({ id, duplicate: true });
    return;
  }
  leads.set(id, { id, name, phone, createdAt: new Date().toISOString() });

  try {
    // jobId dedupes at the queue level: a job with an existing id is ignored
    const job = queue.add('lead-created', { leadId: id, name, phone }, { jobId: `lead-${id}` });
    await withTimeout(job, 2000);
  } catch (err) {
    // The lead is saved but the notification is not queued: make it visible in logs.
    console.error('[api] enqueue failed', id, (err as Error).message);
  }

  res.status(201).json({ id }); // the response does not wait for the notification
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`[api] http://localhost:${port}`));
Express 5: заявка сохраняется, уведомление уходит в очередь, ответ не ждёт доставки
  • Idempotency-Key — ключ, который клиент генерирует один раз на отправку формы. Повтор того же запроса (двойной клик, повтор после обрыва связи) вернёт ту же заявку, а не создаст новую.
  • jobId: lead-<id> — защита на уровне очереди: по документации BullMQ задача с уже существующим id просто игнорируется. Id не должен содержать двоеточие и не может состоять из одних цифр.
  • withTimeout появился после проверки: когда Redis недоступен, queue.add ждёт переподключения, и в нашем прогоне запрос висел дольше 30 секунд даже с enableOfflineQueue: false. Ограничение в 2 секунды возвращает клиенту ответ вовремя.
terminal
$ curl -s -X POST localhost:3000/api/leads … -w '  HTTP %{http_code}, %{time_total}s\n'
{"id":"ab6a5c91-26cb-4c2a-aac4-f5fe5842624c"}  HTTP 201, 2.019723s

# api
[api] enqueue failed ab6a5c91-26cb-4c2a-aac4-f5fe5842624c timeout after 2000 ms
Реальный вывод: Redis остановлен — API всё равно отвечает за 2 секунды и пишет сбой в лог

Воркер: повторы, backoff и ошибки, которые не лечатся повтором

Обработчик воркера — асинхронная функция. Вернула значение — задача completed, бросила исключение — попытка неудачна. С attempts: 4 и backoff: { type: 'exponential', delay: 1000 } BullMQ ждёт перед повтором 2^(n-1) × delay миллисекунд: 1, 2 и 4 секунды. Без backoff повтор идёт сразу — для упавшего внешнего сервиса это бесполезно.

src/worker.ts
import { UnrecoverableError, Worker, type Job } from 'bullmq';
import { Redis } from 'ioredis';
import { NOTIFICATIONS, type LeadNotification } from './queue.ts';
import { workerConnection } from './redis.ts';

const NOTIFY_URL = process.env.NOTIFY_URL ?? 'http://localhost:4001/notify';
const store = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'); // "already sent" marks
const log = (msg: string) => console.log(new Date().toISOString().slice(11, 19), msg);

async function notifyManager(job: Job<LeadNotification>): Promise<string> {
  const doneKey = `lead-notified:${job.data.leadId}`;
  // A retry after a successful send (e.g. the worker died before completing the job)
  if (await store.exists(doneKey)) return 'already sent';

  const res = await fetch(NOTIFY_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ text: `New lead: ${job.data.name}, ${job.data.phone}` }),
    signal: AbortSignal.timeout(5000), // a hung request is an error, not an endless wait
  });

  // A 4xx (except 429) won't be fixed by retrying: fail now, skip the remaining attempts
  if (res.status >= 400 && res.status < 500 && res.status !== 429) {
    throw new UnrecoverableError(`notify rejected: HTTP ${res.status}`);
  }
  if (!res.ok) throw new Error(`notify unavailable: HTTP ${res.status}`);

  await store.set(doneKey, '1', 'EX', 7 * 24 * 3600);
  return 'sent';
}

const worker = new Worker<LeadNotification, string>(NOTIFICATIONS, notifyManager, {
  connection: workerConnection(),
  concurrency: 5,
});

worker.on('completed', (job, result) => {
  log(`${job.id} ${result} (attempt ${job.attemptsMade})`);
});

worker.on('failed', async (job, err) => {
  if (!job) return;
  if ((await job.getState()) === 'failed') {
    // Out of attempts or unrecoverable: a dead job that needs a human
    log(`DEAD ${job.id} after ${job.attemptsMade} attempt(s): ${err.message}`);
  } else {
    log(`retry ${job.id} attempt ${job.attemptsMade} failed: ${err.message}`);
  }
});

worker.on('error', (err) => console.error('[worker]', err.message));

async function shutdown() {
  log('shutting down');
  await worker.close(); // finish active jobs, take no new ones
  await store.quit();
  process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

log('waiting for jobs');
Воркер: отправка уведомления, классификация ошибок, учёт «мёртвых» задач и корректная остановка

2xx

Что делает воркер
Ставит отметку, задача completed
Почему
Уведомление доставлено

5xx, таймаут, сетевая ошибка

Что делает воркер
throw new Error → повтор с паузой
Почему
Сбой временный, через несколько секунд может пройти

429 Too Many Requests

Что делает воркер
Повтор с паузой
Почему
Лимит запросов; лучше учитывать Retry-After (см. упражнение)

Другие 4xx

Что делает воркер
UnrecoverableError → сразу failed
Почему
Неверные данные или ключ — повтор даст тот же ответ

Чтобы проверить повторы без реального Telegram или почты, поднимем заглушку: первые FAIL_FIRST запросов она отвечает 503, а сообщение без телефона отклоняет с 400.

src/fake-notify.ts
// A local stand-in for the notification service (Telegram, email, CRM).
import { createServer } from 'node:http';

let failFirst = Number(process.env.FAIL_FIRST ?? 2); // how many first requests fail

createServer(async (req, res) => {
  let body = '';
  for await (const chunk of req) body += chunk;
  if (failFirst > 0) {
    failFirst--;
    console.log('[notify] 503 Service Unavailable');
    res.writeHead(503).end();
    return;
  }
  const { text } = JSON.parse(body);
  if (!/\d{10,}/.test(text)) {
    console.log('[notify] 400 Bad Request');
    res.writeHead(400).end();
    return;
  }
  console.log(`[notify] 200 delivered: ${text}`);
  res.writeHead(200).end();
}).listen(4001, () => console.log('[notify] http://localhost:4001'));
Заглушка внешнего сервиса для локального запуска — не для продакшена
bash
FAIL_FIRST=2 node src/fake-notify.ts   # terminal 1: the "notification service"
node src/worker.ts                      # terminal 2: the worker
node src/api.ts                         # terminal 3: the API on :3000
Три процесса в трёх терминалах; lead.json — {"name":"Aigerim","phone":"+77011234567"}
terminal
$ curl -s -X POST localhost:3000/api/leads -H 'content-type: application/json' \
    -H 'Idempotency-Key: 7f3c2a10' --data-binary @lead.json -w '  HTTP %{http_code}, %{time_total}s\n'
{"id":"7f3c2a10"}  HTTP 201, 0.015741s
$ curl …the same command again…
{"id":"7f3c2a10","duplicate":true}  HTTP 200, 0.004159s

# worker
23:24:53 waiting for jobs
23:24:55 retry lead-7f3c2a10 attempt 1 failed: notify unavailable: HTTP 503
23:24:56 retry lead-7f3c2a10 attempt 2 failed: notify unavailable: HTTP 503
23:24:58 lead-7f3c2a10 sent (attempt 3)

# fake-notify
[notify] 503 Service Unavailable
[notify] 503 Service Unavailable
[notify] 200 delivered: New lead: Aigerim, +77011234567
Реальный вывод: ответ за 16 мс, дубль не создал задачу, два отказа 503 и доставка с третьей попытки

По времени в логе видны паузы: первая попытка в :55, вторая через секунду, третья — ещё через две. Сервис уведомлений получил одно успешное сообщение, хотя запрос пришёл дважды. Теперь заявка с некорректным телефоном:

terminal
$ curl -s -X POST localhost:3000/api/leads -H 'content-type: application/json' \
    -H 'Idempotency-Key: 9b1e44d0' -d '{"name":"Test","phone":"none"}'
{"id":"9b1e44d0"}

# worker
23:25:03 DEAD lead-9b1e44d0 after 1 attempt(s): notify rejected: HTTP 400
Реальный вывод: 400 не лечится повтором — задача сразу в failed, три оставшиеся попытки не тратятся

Идемпотентность: повтор не должен отправить второе сообщение

Документация BullMQ прямо советует проектировать задачи с расчётом на сбой: результат должен быть одинаковым, выполнилась задача с первой попытки или после повтора. Это и есть идемпотентность. Повторный запуск реален: кроме явных повторов, задача, воркер которой завис или упал и не продлил блокировку, считается «застрявшей» (stalled) и возвращается в очередь.

  • Уровень API: Idempotency-Key от клиента — одна заявка на одно нажатие.
  • Уровень очереди: jobId — одна задача на одну заявку, пока задача хранится в Redis. После удаления задачи (removeOnComplete, removeOnFail) тот же id можно добавить снова.
  • Уровень обработчика: отметка lead-notified:<id> — если уведомление уже ушло, повтор ничего не отправляет.

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

terminal
$ node check/replay.ts   # the same leadId under a new jobId

# worker
23:25:28 lead-7f3c2a10-replay already sent (attempt 1)

# fake-notify: no new requests
Реальный вывод: обработчик увидел отметку и не вызвал сервис уведомлений

Для другой задачи — «не чаще раза в N секунд», например пересчёт сводки при каждой новой заявке — в BullMQ есть опция deduplication с id и ttl: новые задачи с тем же id в течение окна игнорируются.

«Мёртвые» задачи и наблюдаемость

Когда попытки кончились или брошен UnrecoverableError, задача переходит в набор failed. Это аналог dead letter queue (очереди «мёртвых» писем) в других брокерах: сами по себе такие задачи больше не выполнятся, нужен человек или скрипт. По умолчанию BullMQ хранит завершённые задачи бессрочно, поэтому removeOnComplete и removeOnFail в queue.ts обязательны — иначе Redis постепенно заполнится.

terminal
# fake-notify started with FAIL_FIRST=100: the service is "down"
23:25:09 retry lead-c5d2e9a1 attempt 1 failed: notify unavailable: HTTP 503
23:25:10 retry lead-c5d2e9a1 attempt 2 failed: notify unavailable: HTTP 503
23:25:12 retry lead-c5d2e9a1 attempt 3 failed: notify unavailable: HTTP 503
23:25:16 DEAD lead-c5d2e9a1 after 4 attempt(s): notify unavailable: HTTP 503

$ node src/dead-jobs.ts
lead-c5d2e9a1  attempts=4  reason="notify unavailable: HTTP 503"
lead-9b1e44d0  attempts=1  reason="notify rejected: HTTP 400"
failed: 2
Реальный вывод: сервис недоступен — паузы 1, 2 и 4 секунды, затем задача «мёртвая»
src/dead-jobs.ts
import { createNotificationsQueue } from './queue.ts';
import { producerConnection } from './redis.ts';

const connection = producerConnection();
const queue = createNotificationsQueue(connection);

const failed = await queue.getFailed();
for (const job of failed) {
  console.log(`${job.id}  attempts=${job.attemptsMade}  reason="${job.failedReason}"`);
  if (process.argv.includes('--retry')) await job.retry(); // back to the queue after a fix
}
console.log(`failed: ${failed.length}`);

await queue.close();
await connection.quit(); // we created this connection, so we close it
Список «мёртвых» задач и ручной повтор после исправления причины
terminal
# the service is back (FAIL_FIRST=0)
$ node src/dead-jobs.ts --retry
lead-c5d2e9a1  attempts=4  reason="notify unavailable: HTTP 503"
lead-9b1e44d0  attempts=1  reason="notify rejected: HTTP 400"
failed: 2

# worker
23:25:21 lead-c5d2e9a1 sent (attempt 5)
23:25:21 DEAD lead-9b1e44d0 after 2 attempt(s): notify rejected: HTTP 400
Реальный вывод: после восстановления сервиса одна задача доставлена, вторая снова упала — ей нужен не повтор, а исправленные данные
  • Redis с maxmemory-policy noeviction: если Redis вытесняет ключи, очередь ломается (у образа redis:7-alpine это значение по умолчанию — проверено CONFIG GET).
  • Включена запись на диск: appendonly yes с appendfsync everysec — по документации Redis при аварии теряется не больше секунды записей.
  • Обработчики error на очереди и воркере — иначе ошибки подключения остаются необработанными.
  • Корректная остановка по SIGINT и SIGTERM: worker.close() дожидается текущих задач. В контейнере docker stop завершил наш воркер с кодом 0.
  • Автоочистка removeOnComplete и removeOnFail настроена.
  • В data задачи нет персональных данных: BullMQ хранит её в Redis открытым текстом. В примере имя и телефон лежат в задаче для наглядности — в проекте передавайте только leadId, а данные воркер читает из БД.
  • Число задач в failed (queue.getFailedCount()) уходит в метрики, и на его рост настроено оповещение.
terminal
$ docker stop <container with node src/worker.ts>
23:25:56 waiting for jobs
23:25:57 shutting down
container exit code: 0
Реальный вывод: SIGTERM от docker stop — воркер закрылся и завершился с кодом 0

Отложенные и периодические задачи; альтернативы

Отложенная задача — обычная задача с опцией delay: она лежит в delayed и становится доступна воркеру по истечении паузы. Периодические задачи в BullMQ 6 создаются через Job Schedulers: метод upsertJobScheduler создаёт расписание или обновляет существующее с тем же id. Старый способ — опция repeat в queue.add — в шестой версии удалён, это описано в руководстве по миграции с v5.

src/schedule.ts
import { Queue } from 'bullmq';
import { producerConnection } from './redis.ts';

const connection = producerConnection();
const reports = new Queue('reports', { connection });

// Repeatable job: a lead summary every day at 09:00 Almaty time.
// upsert: running this on every deploy updates the schedule instead of duplicating it.
await reports.upsertJobScheduler(
  'daily-leads-report',
  { pattern: '0 0 9 * * *', tz: 'Asia/Almaty' },
  { name: 'daily-leads-report', data: {} },
);

// Delayed job: remind the manager if nobody picks up the lead within 30 minutes.
await reports.add('lead-reminder', { leadId: '7f3c2a10' }, { delay: 30 * 60 * 1000 });

for (const s of await reports.getJobSchedulers()) {
  console.log(s.key, s.pattern, s.tz, 'next:', new Date(s.next ?? 0).toISOString());
}
console.log(await reports.getJobCounts('delayed'));

await reports.close();
await connection.quit();
Ежедневная сводка по cron-выражению с часовым поясом и отложенное напоминание
terminal
$ node src/schedule.ts
daily-leads-report 0 0 9 * * * Asia/Almaty next: 2026-10-12T04:00:00.000Z
{ delayed: 2 }
Реальный вывод: следующий запуск в 04:00 UTC — это 09:00 по Алматы; в delayed лежат ближайший запуск расписания и напоминание

Cron-выражение здесь из шести полей: первое — секунды. Расписанием сводка только ставится в очередь; выполняет её воркер очереди reports, который пишется так же, как воркер уведомлений.

Cron (системный, CronJob в Kubernetes, планировщик хостинга)

Когда подходит
Периодический скрипт: ночная выгрузка, очистка
Ограничения
Нет очереди задач, повторов и учёта неудач — всё это пишете сами

Управляемые очереди облаков (Amazon SQS, Google Cloud Tasks и аналоги)

Когда подходит
Не хочется поддерживать свой Redis; инфраструктура уже в этом облаке
Ограничения
Привязка к провайдеру, свои лимиты и гарантии доставки — читайте их документацию

BullMQ с бэкендом PostgreSQL

Когда подходит
Redis в проекте нет и заводить его не хочется
Ограничения
По документации BullMQ пропускная способность ниже, основной и самый проверенный вариант — Redis

setTimeout или «отправить без await» в процессе API

Когда подходит
Только эксперименты
Ограничения
Задача теряется при перезапуске, повторов и учёта нет

Упражнения

Как проверено3 · пройдено1 · частичносверено 11 октября 2026 г.
  • запущено, работает

    src/redis.ts, src/queue.ts, src/api.ts, src/worker.ts, src/fake-notify.ts, src/dead-jobs.ts, src/schedule.ts, tsconfig.json

    временный проект: npm i bullmq@6.3.12 ioredis@6.0.0 express@5.3.0, typescript@7.0.2; tsc -p . без ошибок; Node 24.18 исполняет .ts напрямую; Redis 7.4.10 в docker redis:7-alpine (kb-redis-fonovye, --rm)

  • запущено, работает

    сценарии запуска producer+worker (выводы в статье)

    run-demo.sh: A — два 503, доставка с 3-й попытки, паузы 1 с и 2 с по логам, повтор с тем же Idempotency-Key → 200 duplicate; B — 400 → UnrecoverableError → failed с 1 попытки; C — сервис недоступен → 4 попытки (паузы 1/2/4 с) → DEAD; D — dead-jobs --retry после восстановления → sent (attempt 5), 400 снова failed; E — replay той же заявки под новым jobId → 'already sent', сервис не вызван; F — Redis недоступен → 201 за 2,02 с, лог 'timeout after 2000 ms'; G — schedule.ts: next 2026-10-12T04:00Z (09:00 Asia/Almaty), delayed: 2

  • запущено, работает

    graceful shutdown воркера (SIGTERM)

    node src/worker.ts в контейнере node:24.18.0-bookworm-slim (kb-node-fonovye, --rm), docker stop → 'shutting down', exit code 0

  • частично

    команды установки (bash-блок)

    те же пакеты и образ проверены, но в проверке Redis был на порту 6390, API на 3100 (6379 и 3000 заняты другими проектами); в выводах curl в статье порт заменён на 3000, тело запроса для сценария B показано через -d вместо файла

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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