Паттерн «Transactional Outbox»

Как этот сервис гарантирует надёжное выполнение фоновых задач: интерактивная карта потока, UML-диаграммы и разбор реализации в репозитории.

1. Проблема: двойная запись

Нужно одновременно сохранить задачу в базу данных и доставить сообщение в очередь обработки. Это две разные системы, и атомарность между ними отсутствует: процесс может упасть между записью в БД и публикацией сообщения. Результат — задача сохранена, но никогда не будет обработана (или наоборот: сообщение «улетело», а записи нет).

HTTP-запроссоздать задачуINSERT в БДуспешно ✓Публикация в очередьпроцесс упал ✗задача «висит» в базе,но обработка не начнётсяданные и событиярассинхронизированы

Transactional Outbox устраняет разрыв: сообщение о событии записывается в обычную таблицу outbox в той же базе и в той же транзакции, что и бизнес-данные. Атомарность гарантирует сама СУБД. Отдельный фоновый процесс (relay) вычитывает неопубликованные сообщения и доставляет их обработчику.

2. Интерактивная карта потока

UML sequence-диаграмма полного жизненного цикла задачи в этом сервисе. Кликните шаг на диаграмме — под ней появится описание и фрагмент кода из репозитория. Или управляйте шагами кнопками.

КлиентUI / HTTPAPIScheduleTaskServiceSQLiteчерез EF CoreOutbox RelayBackgroundServiceChannelочередь в памятиProcessor+ ITaskExecutor1POST /api/taskspayload, runAt, idempotencyKey?2поиск задачи по IdempotencyKeyесли найдена — вернуть существующий taskId3★ INSERT задачи (Pending) + INSERT outboxодна транзакция — один SaveChangesAsync4201 Created{ taskId, wasCreated }5GetPendingOutboxAsyncWHERE Status = PendingPublish (пакет)6★ PublishAsync(envelope)ограниченный канал, FullMode = Wait7outbox → Publishedзадача Pending → Queued8ReadAsync() → OutboxEnvelopeDI-scope создаётся на каждое сообщение9★ Inbox: INSERT MessageId (уникальный индекс)дубль? → обработка прекращена10RunAt в будущем? → DeferUntilAsync+ новый outbox с тем же payload11Version = v ? → Running, Version++защита от гонки обработчиков12★ ITaskExecutor.ExecuteAsyncуспех → Completed · ошибка → retry (2с · 2ⁿ) · попытки вышли → DeadLetter
команда запрос / ответ обычный шаг ★ ключевой шаг паттерна
👆

Кликните любой шаг на диаграмме — здесь появится описание, файлы в репозитории и фрагмент реализации. Или нажмите «Автопроигрывание».

3. UML: диаграмма состояний задачи

Жизненный цикл задачи от создания до результата. Кликните состояние, чтобы узнать, что оно означает и какой код переводит задачу в него.

outbox опубликованVersion совпалуспехошибка → retry, задержка 2с · 2ⁿпопытки исчерпаны (≥ 5)replayPendingQueuedRunningCompletedDeadLetter
👆

Кликните состояние на диаграмме, чтобы прочитать описание.

4. UML: архитектура решения

Пять проектов решения и их зависимости (стрелка = «ссылается на»). Кликните проект, чтобы увидеть его ответственность и ключевые типы.

Scheduler.AppMinimal API · Blazor Server2 × BackgroundServiceScheduler.Domainсущности без зависимостейScheduler.ContractsDTO для API и UIScheduler.Applicationсценарии использованиявесь I/O — через интерфейсыScheduler.Infrastructureреализации адаптеровEF Core 9 + SQLite
👆

Кликните проект на диаграмме, чтобы прочитать описание.

5. Inbox: почему повторная доставка не страшна

Relay помечает сообщение опубликованным после отправки в канал. Если процесс упадёт между этими действиями, при перезапуске сообщение уйдёт в обработку ещё раз — это и есть семантика at-least-once. Чтобы дубли не приводили к повторному выполнению работы, у обработчика есть inbox: таблица с уникальным индексом по MessageId. Первая доставка вставляет запись, повторная — получает отказ от СУБД, и обработка молча прекращается.

Каналсообщение №7доставлено 2 разадоставка 1доставка 2 (дубль)INSERT в inbox ✓MessageId новый → обрабатываемUNIQUE violation ✗дубль → выход без обработкиработа выполненаровно один раз,несмотря на дубли

Сообщение, которое циркулирует в системе, — 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).

Сбой на попытке Расчёт задержки Пауза до следующей попытки
12с × 2¹4 секунды
22с × 2²8 секунд
32с × 2³16 секунд
42с × 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