
Идемпотентность при отправке 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 даёт возможность безопасно восстановиться после падения процесса.