Паттерн «Transactional Outbox»
Как этот сервис гарантирует надёжное выполнение фоновых задач: интерактивная карта потока, UML-диаграммы и разбор реализации в репозитории.
1. Проблема: двойная запись
Нужно одновременно сохранить задачу в базу данных и доставить сообщение в очередь обработки. Это две разные системы, и атомарность между ними отсутствует: процесс может упасть между записью в БД и публикацией сообщения. Результат — задача сохранена, но никогда не будет обработана (или наоборот: сообщение «улетело», а записи нет).
Transactional Outbox устраняет разрыв: сообщение о событии записывается в обычную таблицу
outbox в той же базе и в той же транзакции, что и бизнес-данные. Атомарность
гарантирует сама СУБД. Отдельный фоновый процесс (relay) вычитывает неопубликованные сообщения
и доставляет их обработчику.
2. Интерактивная карта потока
UML sequence-диаграмма полного жизненного цикла задачи в этом сервисе. Кликните шаг на диаграмме — под ней появится описание и фрагмент кода из репозитория. Или управляйте шагами кнопками.
Кликните любой шаг на диаграмме — здесь появится описание, файлы в репозитории и фрагмент реализации. Или нажмите «Автопроигрывание».
3. UML: диаграмма состояний задачи
Жизненный цикл задачи от создания до результата. Кликните состояние, чтобы узнать, что оно означает и какой код переводит задачу в него.
Кликните состояние на диаграмме, чтобы прочитать описание.
4. UML: архитектура решения
Пять проектов решения и их зависимости (стрелка = «ссылается на»). Кликните проект, чтобы увидеть его ответственность и ключевые типы.
Кликните проект на диаграмме, чтобы прочитать описание.
5. Inbox: почему повторная доставка не страшна
Relay помечает сообщение опубликованным после отправки в канал. Если процесс упадёт между этими
действиями, при перезапуске сообщение уйдёт в обработку ещё раз — это и есть семантика
at-least-once. Чтобы дубли не приводили к повторному выполнению работы, у обработчика есть
inbox: таблица с уникальным индексом по MessageId. Первая доставка вставляет
запись, повторная — получает отказ от СУБД, и обработка молча прекращается.
Сообщение, которое циркулирует в системе, — JSON-конверт:
{
"messageId": "8f3c2a41-…", // ключ дедупликации в inbox
"taskId": "a1b2c3d4-…", // какая задача обрабатывается
"envelopeType": "ProcessTask",
"payloadJson": "{\"taskId\":\"a1b2c3d4-…\",\"payload\":\"…\"}"
}6. Повторы с экспоненциальной задержкой и dead letter
Если ITaskExecutor бросил исключение, задача не теряется: она возвращается в очередь
с LastError и NextAttemptAt = сейчас + 2с · 2ⁿ, а в outbox создаётся новое
сообщение. После исчерпания попыток (MaxAttempts = 5) задача попадает в dead letter
с причиной и числом попыток — её можно вернуть в очередь кнопкой Replay
(POST /api/dead-letters/{id}/replay).
| Сбой на попытке | Расчёт задержки | Пауза до следующей попытки |
|---|---|---|
| 1 | 2с × 2¹ | 4 секунды |
| 2 | 2с × 2² | 8 секунд |
| 3 | 2с × 2³ | 16 секунд |
| 4 | 2с × 2⁴ | 32 секунды |
| 5 | — | Dead letter попытки исчерпаны |
Для проверки политики в payload можно передать failUntilAttempt — количество попыток,
завершающихся ошибкой, прежде чем задача выполнится успешно:
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{
"payload": "{\"note\":\"демо ретраев\",\"failUntilAttempt\":3}",
"runAt": "2026-10-11T12:00:00Z",
"idempotencyKey": "order-42"
}'
# первые две попытки упадут, третья выполнится успешно;
# наблюдать статусы: GET /api/tasks и страница «Задачи»7. Где что в коде
| Файл | Ответственность |
|---|---|
src/Scheduler.App/Api/SchedulerEndpoints.cs |
Minimal API: создание задач, списки, replay dead letter, диагностика outbox |
src/Scheduler.Application/Scheduling/ScheduleTaskService.cs |
Планирование: идемпотентность по ключу, сборка задачи + outbox, одна транзакция |
src/Scheduler.App/Hosting/OutboxRelayHostedService.cs |
Фоновый цикл relay: периодический опрос неопубликованных сообщений |
src/Scheduler.Application/Outbox/OutboxRelayService.cs |
Публикация конвертов в канал, пометка Published, перевод задачи в Queued |
src/Scheduler.Infrastructure/Messaging/TaskProcessChannel.cs |
Ограниченный Channel между relay и обработчиком (backpressure через Wait) |
src/Scheduler.App/Hosting/TaskProcessorHostedService.cs |
Фоновый обработчик: чтение конвертов, DI-scope на сообщение, изоляция сбоев |
src/Scheduler.Application/Processing/TaskMessageProcessor.cs |
Inbox-дедупликация, откладка по RunAt, оптимистичная блокировка, retry / dead letter |
src/Scheduler.Infrastructure/Persistence/EfSchedulerPersistence.cs |
Все операции с БД: транзакция задача+outbox, inbox, статусы, dead letter, replay |
src/Scheduler.Infrastructure/Processing/DefaultTaskExecutor.cs |
Исполнитель по умолчанию: парсинг payload, симуляция сбоев через failUntilAttempt |