Перейти к содержанию

Очередь заданий

Фоновая служба из предыдущего урока наблюдает текущее число курсов. Она может начать заново после остановки, потому что не хранит обязательных заданий. Экспорт каталога устроен иначе: если сервер сообщил, что запрос принят, пользователь ожидает позже увидеть результат или понятный отказ. Сохраним намерение в базе и определим, что означает повтор после сбоя.

lesson-26 архива продолжения содержит полную учебную SQL-модель очереди, команды принятия, захвата, завершения и таблицу сценариев. Это самостоятельная ветка схемы поверх базы курса, без реализованного HTTP-адаптера, планировщика, исполняющего worker и внешней доставки. SQL, задания и проверки не запускались. Результат урока — устойчивое состояние и договорённость переходов; статья не объявляет готовую надёжную очередь внедрённой.

Принятие получает стабильный ключ

Для экспорта зададим ID операции, выбранный клиентом один раз для логического запроса. В базе он имеет уникальное ограничение. Повтор с тем же ID и тем же курсом должен вернуть существующее задание, а повтор с тем же ID и другим курсом — предметный конфликт. Уникальный ключ без сравнения содержимого недостаточен: иначе случайное переиспользование ID silently связало бы пользователя с чужим заданием.

Таблица содержит id, course_id, состояние, число попыток, время следующей доступности, lease token и срок аренды. Допустимые состояния — pending, running, done, failed. Ограничения согласуют lease с running, не позволяя хранить бессмысленную комбинацию. Экспорт не удаляет курс и не меняет его число уроков.

Упрощённая отправка SQL выглядит так:

INSERT INTO catalog_jobs(id, course_id)
VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING;
SELECT id, course_id, state FROM catalog_jobs WHERE id = $1;

Это два оператора в одной транзакции Read Committed. После столкновения с конкурентной вставкой следующий SELECT видит уже зафиксированную строку. Клиентский адаптер должен сравнить course_id, прежде чем подтверждать повтор. Параметры в архиве обозначают values, а не готовую строку для подстановки в текст SQL.

Нельзя отдавать 202 до успешной фиксации принятия. Пока intent существует только в памяти handler, падение процесса потеряет задание. Но и успешная фиксация не означает завершённый экспорт: клиент получает только подтверждение принятого намерения и адрес наблюдения, если такой HTTP-адаптер будет реализован.

Захват ограничен сроком аренды

Один worker выбирает доступную строку и блокирует её через FOR UPDATE SKIP LOCKED, затем в той же транзакции переводит в running. Другой worker не ждёт именно эту заблокированную строку, а может выбрать следующую. Такое чтение не подходит для обычного отчёта каталога, где нужен полный согласованный набор; оно служит координации рабочих потребителей.

WITH candidate AS (
  SELECT id FROM catalog_jobs
  WHERE (state='pending' AND available_at <= clock_timestamp())
     OR (state='running' AND lease_until < clock_timestamp())
  ORDER BY available_at, id LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE catalog_jobs j
SET state='running', attempts=attempts+1,
    lease_token=$1, lease_until=clock_timestamp()+interval '30 seconds'
FROM candidate c WHERE j.id=c.id
RETURNING j.id, j.course_id, j.lease_token;

Token генерируется новым для каждого захвата. Важно хранить его вместе с результатом получения, а не только ID задания. Если worker завис и его аренда истекла, другой получит новый token. Поздний ответ старого работника не должен завершать уже переданное задание.

Срок в тридцать секунд — учебное значение, не подобранный production-таймаут. Долгая работа требует продления аренды по совпадающему token и проверки результата продления. Если продлить не удалось, worker потерял право подтверждать эффект. Одного поля running=true без срока недостаточно: после падения строка могла бы навсегда остаться занятой.

Повтор не должен удваивать эффект

Для маленького экспорта результат сохраняется в catalog_job_results, где ID задания уникален. В одной транзакции worker проверяет свой token и неистёкшую аренду, блокирует строку, сохраняет результат и переводит задание в done. Это координирует эффект, если он находится именно в этой базе.

Проверка срока аренды выполняется после блокировки текущей строки. При повторном исполнении уникальный ID результата предотвращает создание второй записи. Однако он не превращает произвольную внешнюю отправку в exactly-once. Письмо, запись в стороннее object storage или вызов платежей не откатятся вместе с SQL. Там нужны собственный idempotency key, согласованный протокол и восстановление неопределённого исхода.

Старая аренда получает ноль затронутых строк и не должна сообщать успех. Если результат успел зафиксироваться, а ответ worker потерялся, следующий читатель видит done и существующий результат. Если процесс упал до commit, транзакция не оставляет частично готовый экспорт. В обоих случаях важно наблюдать базу, а не делать вывод только по наличию текста в журнале.

Ошибка имеет отдельную судьбу

Временный отказ возвращает задание в pending с будущим available_at, очищая lease только при совпадающем действительном token. После третьей попытки выбранная учебная политика переводит его в failed. Постоянная ошибка входных данных не обязана проходить все повторы; адаптер должен классифицировать её отдельно.

Бесконечный быстрый retry перегружает ту же недоступную зависимость и мешает здоровым заданиям. Задержка и предел попыток являются частью контракта, а не случайной паузой в коде. Также нужна наблюдаемость: сколько pending, сколько просроченных running, сколько failed и почему. В архиве есть SQL наблюдения, но нет подключённых оповещений.

Для будущего исполнения в изолированной схеме создайте одно задание JavaScript, повторите принятие с тем же ID, затем попробуйте тот же ID для Markdown. Ожидаются одна строка, возврат прежнего состояния и конфликт содержимого. Два конкурентных захвата одного задания должны дать только одного действительного владельца аренды. После её истечения новый token делает старое завершение недопустимым.

Эти сценарии требуют реальной проверки SQL и полного адаптера, поэтому сейчас остаются непроверенными. Мы определили смысл принятия, аренды и результата, не обещая доставку внешнего эффекта ровно один раз. Поведение блокирующего захвата описано в документации SELECT PostgreSQL 17, атомарная фиксация связанных действий — в документации транзакций.

Версия строки задания и lease token не являются секретом пользователя, но должны проверяться сервером при переходе состояния. Worker не получает разрешение завершать любую строку только потому, что знает её ID. В SQL завершения сначала блокируется конкретное running-задание с действительной арендой, а отсутствие строки прекращает транзакцию без результата. Файл сценариев требует от будущего адаптера проверить это число и не продолжать второй оператор после отказа. Если просто выполнить несколько листингов вручную подряд без такого решения, защитная модель перестанет действовать. Поэтому архив содержит единый атомарный SQL завершения, который создаёт результат только из строки, прошедшей условие владельца аренды.