главная/Идемпотентность при отправке emails
Идемпотентность в задачах рассылки

Идемпотентность при отправке emails

Задача «отправлять отчёт по email один раз в месяц» выглядит простой, пока система не сталкивается с реальными условиями эксплуатации.

Планировщик может запуститься дважды, приложение может работать в нескольких инстансах, а процесс — упасть во время выполнения. В результате пользователь получает несколько одинаковых писем или, наоборот, не получает письмо вообще.

Типичная ошибка — пытаться решать проблему через хранение последней даты отправки в user_meta или в любом другом простом состоянии.

Такой подход не защищает от гонок, когда два процесса одновременно проверяют условие «ещё не отправляли» и оба выполняют отправку. Кроме того, он не решает сценарий, при котором система успевает зафиксировать «отправлено», но падает до фактической отправки письма, или наоборот.

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

Принцип решения

Практически надёжный способ построить идемпотентную отправку отчётов — хранить факты запусков в отдельной таблице и использовать уникальный ключ, который соответствует бизнес-операции. Это позволяет базе данных стать источником истины: она определяет, был ли отчёт за конкретный период уже запущен или отправлен.

Ключевой момент заключается в том, что система должна учитывать не только состояние «отправлено», но и состояние «в процессе».

Иначе возможен критичный сценарий: запись о запуске уже создана, но процесс упал до отправки письма. При следующем запуске система увидит существующую запись и решит, что повтор не нужен — письмо будет потеряно.

Таблица запусков

Ниже пример минимальной схемы, которая покрывает основные требования:

CREATE TABLE report_runs (
    idempotency_key TEXT PRIMARY KEY,
    status TEXT NOT NULL, -- processing, sent, failed
    created_at TIMESTAMP DEFAULT now(),
    updated_at TIMESTAMP DEFAULT now(),
    sent_at TIMESTAMP NULL
);

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

Идемпотентный ключ как бизнес-идентификатор

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

  • тип отчёта
  • отчётный период
  • получатель

Например, для отчёта за январь 2026 пользователю user@example.com ключ может выглядеть так:

monthly_report:2026-01:user@example.com

Такой ключ означает: «ежемесячный отчёт за период 2026-01 этому получателю». Именно он должен быть уникальным, независимо от того, сколько раз задача была запущена.

Логика выполнения

При запуске cron-задачи система сначала пытается зарегистрировать выполнение:

INSERT INTO report_runs (idempotency_key, status)
VALUES ('monthly_report:2026-01:user@example.com', 'processing')
ON CONFLICT DO NOTHING;

Если вставка успешна, текущий процесс получил право на выполнение: он первый, кто пытается отправить отчёт за этот период.

Если вставка не произошла, запись уже существует. В этом случае система должна проверить статус.

Если статус sent, выполнение не требуется: письмо уже было отправлено.

Если статус processing, это может означать, что другой процесс прямо сейчас выполняет задачу.

Однако в реальных системах важно учитывать сбои. Поэтому обычно вводят правило «таймаута»: если запись находится в processing дольше определённого времени (например, 30 минут), она считается зависшей, и выполнение разрешается повторить.

Если статус failed, задачу можно запускать повторно по стандартной политике ретраев.


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

В сценарии, когда письмо было отправлено, но процесс упал до обновления статуса (processing → sent), повторный запуск может привести к повторной отправке.

Чтобы избежать этого, на практике используют дополнительный источник истины: журнал отправок у email-провайдера (message id, события delivery, webhook-логи) или собственную таблицу sent_emails, в которую сохраняется уникальный идентификатор отправки.


Это позволяет отличать «не отправляли» от «отправили, но не успели зафиксировать статус» и снижает риск дублей при ретраях.

После успешной отправки письма статус фиксируется в таблице:

UPDATE report_runs
SET status = 'sent',
    sent_at = now(),
    updated_at = now()
WHERE idempotency_key = 'monthly_report:2026-01:user@example.com';

Такой подход даёт две ключевые гарантии.

Во-первых, он защищает от дублей при параллельных запусках. Даже если cron сработает дважды или система запущена в нескольких инстансах, только один процесс сможет зарегистрировать операцию с данным ключом.

Во-вторых, он защищает от потерь при сбоях. Статус processing позволяет отличить «операция уже завершена» от «операция была начата, но могла не завершиться». Таймаут на processing даёт возможность безопасно восстановиться после падения процесса.