Offline outbox
Создаёт durable pipeline local mutation -> Room outbox -> unique WorkManager job -> Ktor -> acknowledged removal. Пользовательское действие считается поставленным в очередь только после
успешной записи Room; запись удаляется только после подтверждённого сервером успеха этой операции.
Сначала определи контракт доставки
Зафиксируй до реализации:
- какие мутации разрешено выполнять offline и какой payload нужен для точного replay;
- стабильный
operationId/ idempotency key и как сервер дедуплицирует повторную отправку; - scope порядка: глобальный, на пользователя, заказ, маршрут или другую aggregate-сущность;
- какие HTTP/domain ошибки retryable, а какие permanent;
- что происходит с permanently failed первой операцией: блокировка очереди, явный dead-letter или показ ошибки пользователю;
- срок жизни операции при logout и смене аккаунта.
Если endpoint не поддерживает идемпотентность и повтор может применить мутацию дважды, не называй pipeline гарантированным. Остановись на проектировании protocol change или явно согласованной компенсации.
Room-модель очереди
Храни минимум stable operation id, owner/aggregate scope, монотонную последовательность, operation type/endpoint, сериализованный request payload и диагностическое состояние попытки. Payload должен быть самодостаточным для replay после process death и обновления access token; не сохраняй transient UI state или секреты, которые можно получить в момент отправки.
- Обеспечь уникальность
operationIdи детерминированныйORDER BYвнутри ordering scope. - Добавь DAO для вставки, чтения следующей порции, обновления retry metadata и удаления подтверждённой записи.
- Регистрируй entity/DAO в
AppDatabaseи увеличивайDATABASE_VERSION. - Если локальное optimistic-изменение и outbox-запись должны появиться вместе, выполни оба DAO-вызова
в одной
AppDatabase.withTransactionна уровне use case. - Не удаляй или заменяй ранее поставленную операцию как «устаревшую», если её успешная отправка не подтверждена сервером. Coalescing требует отдельной явно доказанной бизнес-семантики и не является поведением по умолчанию.
Постановка в очередь
Domain use case сериализует request, создаёт stable idempotency key, атомарно сохраняет outbox и только затем планирует unique one-time work. Повторное планирование безопасно: используй стабильное unique work name для нужного scope и политику, которая не запускает параллельных drain-workers.
Room commit и enqueueUniqueWork не образуют общей транзакции: process death между ними может
оставить запись без worker. Добавь обязательную reconciliation-точку — при старте авторизованной
сессии и восстановлении процесса проверяй наличие pending outbox и повторно планируй drain. Можно
также наблюдать очередь отдельным process-scoped coordinator, но он не заменяет startup recovery.
Worker требует сеть. Настрой backoff по принятой политике и не делай собственный бесконечный retry loop поверх WorkManager. Новая запись, добавленная во время работы worker, должна либо попасть в текущий drain, либо гарантированно вызвать следующий запуск.
Drain и replay
- Worker получает зависимости через принятый Hilt/WorkManager механизм и делегирует операции конкретным use case; он не содержит endpoint-specific бизнес-логику.
- Читай записи в строгом порядке выбранного scope. Не отправляй более позднюю зависимую операцию, пока предыдущая не получила подтверждение.
- Десериализуй payload по operation type, передай тот же
operationIdкак idempotency key и выполни сетевой вызов черезNetworkService/Ktor с обычной обработкой ответа проекта. - Удаляй конкретную запись в Room только после подтверждённого success. Если процесс погиб после server success, но до delete, следующий replay должен быть безопасен благодаря тому же idempotency key.
- На retryable ошибке сохрани диагностику, останови этот ordering scope и верни
Result.retry(); не позволяй следующей записи обогнать неуспешную. - На permanent ошибке сохрани её как видимое blocked/dead-letter состояние и верни результат по явной продуктовой политике. Не удаляй запись и не маскируй ошибку как success.
- Пробрасывай
CancellationException; вfinallyвыполняй только ограниченный cleanup и диагностируй stop reason там, где API это поддерживает.
Согласованность и lifecycle
- Не держи transaction открытой во время HTTP-запроса. Claim/lease нужен только если архитектура допускает несколько consumers; проще обеспечить одного unique worker на scope.
- После refresh с сервера не затирай локальное optimistic state, пока связанная outbox-операция не подтверждена или конфликт не разрешён явно.
- При logout запроси отмену worker нужного account scope и дождись её завершения там, где lifecycle допускает ожидание, затем примени выбранную политику к очереди. Поскольку уже начатый HTTP-запрос мог завершиться, worker проверяет owner/current account перед каждой отправкой и перед Room mutation; отдельно зафиксируй продуктовую семантику ответа, пришедшего во время logout. Никогда не отправляй запись с credentials другого пользователя.
- Закрой окно между owner-check и чтением auth headers: request boundary/interceptor сверяет session generation операции с текущей сессией непосредственно перед отправкой и отклоняет несовпадение либо использует credential snapshot, однозначно привязанный к тому же owner. Не сохраняй access token внутри outbox payload.
- Наблюдаемое количество failures/blocked operations предоставляй отдельным
FlowUseCase, если UI действительно его показывает.
Проверка результата
Проверь постановку без сети, process death после Room commit до enqueue, process death до запуска worker, server success с падением до Room delete, retryable и permanent ошибки, две зависимые операции, конкурентное планирование, добавление операции во время drain и logout со сменой пользователя. Главные утверждения теста: операция не теряется, не обгоняется и не применяется дважды на сервере.