Нажатие пишет локальную строку и запись в outbox одной транзакцией базы, и экран сразу обновляется из базы. Позже фоновая задача отправляет операцию с её ключом идемпотентности и применяет ответ сервера второй транзакцией.
Одна локальная транзакция: db.withTransaction { upsert row as PENDING; insert outbox op }; если одна запись не удалась, нет ни одной, и экран никогда не покажет правку, которая не уйдёт на сервер
UI читает только базу: экран подписан на Flow из Room, поэтому правка видна мгновенно и переживает смерть процесса; сеть никогда не пишет в UI-состояние напрямую
Запустите одну уникальную задачу: enqueueUniqueWork с сетевым ограничением; WorkManager выполнит её, когда сеть вернётся, даже если приложение убили
Отправка: воркер берёт голову каждой цепочки и шлёт её с Idempotency-Key, выданным при постановке в очередь, и версией, от которой делалась правка
Применение ответа: при успехе записать серверную версию строки и удалить операцию одной транзакцией; при ошибке классифицировать её: повтор с backoff, rebase при конфликте или парковка в виде ошибки, которую видит пользователь
Pull после push: затем забрать дельту, чтобы изменения с других устройств легли поверх ваших
AndroidSenior
Офлайн-синхронизация и конфликты
Outbox мутаций, ключи идемпотентности, delta sync, tombstones, LWW vs CRDT, backoff, сходимость
Закрыто вопросов
0 / 21
- 0Повторить
- 1Часто спрашивают
Вопросы
Строка outbox должна хранить всё, из чего запрос собирается заново по одним данным: ключ идемпотентности, целевую сущность, операцию с версионированным payload и учёт повторов.
**opId: ключ идемпотентности, выданный один раз при постановке в очередь и одинаковый для всех попыток
entityType + entityId: id, сгенерированный клиентом, чтобы на строку можно было ссылаться до того, как о ней узнает сервер
kind + payload + payloadVersion: что сделать, с какими данными и в каком формате писала та сборка
attempts, status, baseVersion: состояние повторов и база для конфликтов, переживающие смерть процесса
Частая ошибка**: хранить готовый запрос, заголовок авторизации или лямбду; храните намерение («увеличить на единицу»), а не значение от снимка, которого больше нет
Строка хранит своё состояние синхронизации в колонке базы, а список рисуется только из базы. Тогда бейдж зависит от данных, а не от того, что сеть делает прямо сейчас.
Мигание возникает из-за двух источников записи или из смены идентичности: сетевой колбэк, который тоже кладёт данные в UI-состояние, рисует строку дважды, а смена ключа или поля сортировки при подтверждении заставляет элемент пересоздаться или прыгнуть.
**syncState**: SYNCED, PENDING, SENDING, FAILED, пишется в базу, а не в поле, которое стирает смерть процесса
**localId**: идентичность строки на всю её жизнь и key элемента списка; никогда не подменяйте его серверным id
**createdAt: ключ сортировки, выставленный один раз, чтобы подтверждение ничего не переставляло
lastErrorCode: упавшая строка объясняет себя и предлагает Retry; удаляет её только явное «удалить», одной транзакцией вместе с её операциями
Правило**: сеть никогда не трогает экран; она пишет строки, а UI остаётся чистой функцией от локальной базы
Клиент выдаёт ключ идемпотентности один раз, когда платёж кладётся в outbox, и шлёт тот же ключ при каждой попытке. Сервер запоминает ключ вместе с первым результатом и при повторе отдаёт сохранённый ответ вместо второго списания.
Повторы безопасны не потому, что клиент аккуратен, а потому что сервер пообещал дедуплицировать ключ, который надёжно помнит.
Выдавайте при постановке в очередь, а не в retry-цикле: ключ на каждую попытку превращает повтор в новый платёж; храните его в строке outbox и шлите заголовком Idempotency-Key
Сервер: одна транзакция: ключ, статус и тело ответа пишутся вместе со списанием; запись после оставляет окно, где деньги списаны, а ключа нет
Сервер: три случая: если ключ и payload те же, вернуть сохранённый ответ; если ключ тот же, а payload другой, ответить 422; если ключ ещё выполняется, ответить 409
Срок хранения: окно должно быть длиннее горизонта повторов клиента, обычно 7–30 дней; операцию старше окна сверяют с состоянием сервера, а не шлют вслепую
Оставьте клиентский id первичным ключом на всю жизнь строки, а серверный храните рядом в nullable-колонке. Именно переписывание первичного ключа ломает внешние ключи, payload'ы в очереди и идентичность в списке.
Клиент выдаёт id потому, что строку нужно править и на неё нужно ссылаться до того, как о ней узнает сервер: созданному офлайн ответу надо на что-то указывать.
Лучше всего не сводить вовсе: пусть сервер принимает клиентский id как id строки и делает upsert по нему; тогда повтор идемпотентен и ничего никогда не переименовывается
**Иначе храните remoteId рядом с localId**: переводите только на границе API; все локальные ссылки остаются на localId
Берите сортируемый id: UUIDv4 уникален, но не упорядочен; ULID и UUIDv7 кладут метку времени в старшие биты, поэтому строки сортируются по времени создания, а вставки в индекс остаются локальными
Частая ошибка: аккуратные на вид delete + insert при подтверждении: дочерние строки, мутации в очереди и ключи списка всё ещё держат старый id, и вы только что осиротили их все
Упорядочивайте очередь по группам зависимостей, а не глобально: каждая операция несёт groupKey (корневая сущность, здесь пост) и монотонный seq внутри него. Пост и его комментарий уходят по порядку, а несвязанные группы отправляются параллельно.
FIFO на агрегат: операции одной группы строго упорядочены, в полёте всегда одна; это один индексный запрос по groupKey, status и seq
**Явный dependsOn**: если граф не дерево, операция называет opId, которого ждёт, и становится доступной только после его успеха
Разрешайте id в момент отправки: postId у комментария клиентский; ищите серверное соответствие при отправке, а не при постановке в очередь, когда его ещё нет
Частая ошибка: одна глобальная FIFO, где одна застрявшая операция останавливает все несвязанные мутации; обратная ошибка возникает при параллельной отправке без группировки, где комментарий получает 404, потому что поста ещё нет
Он удаляет каждую операцию до того, как сервер её получил, и глотает любую ошибку, поэтому падение, таймаут или 500 теряют правку; а когда удаление перенесут после успеха, свежий ключ UUID.randomUUID() заставит каждый повтор применять правку дважды.
**Сначала outbox.delete**: если процесс умрёт или push бросит исключение, операции уже нет и никто её не переотправит; удаляйте только после успеха, в том же withTransaction, что и upsert серверной строки
**UUID.randomUUID() на каждую отправку**: сегодня безвредно лишь потому, что ничего не повторяется; когда удаление перенесут после успеха, запрос, который дошёл, но отвалился по таймауту, уйдёт снова под новым ключом, и сервер применит его дважды; шлите op.opId, выданный один раз при постановке в очередь
**catch (e: Exception) + Result.success()**: таймаут, 500 и 403 выглядят одинаково, и ни один не повторяется; к тому же он ловит CancellationException, которым WorkManager останавливает воркер. Временные ошибки повторяйте с backoff, постоянные паркуйте как видимые ошибки
**Простой цикл по all(): после сбоя он идёт к следующей операции, а это может быть правка строки, создание которой только что упало; останавливайте эту цепочку и продолжайте остальные
Строка лога**: op.payloadJson содержит текст пользователя, теперь он в logcat и в любом crash-репортере, который собирает логи; логируйте id операции, тип и статус
Два триггера могут запустить две корутины drain() одновременно; каждая берёт свой снимок outbox.pending(), поэтому каждая операция отправляется дважды, а два цикла перемешиваются. Сделайте слив очереди последовательным (single-flight) через Mutex и читайте outbox под блокировкой.
Почему так происходит: приложение выходит на передний план, и в ту же секунду возвращается сеть; каждый scope.launch читает одни и те же строки до того, как любой из циклов что-то удалил
Чем это грозит: ключ идемпотентности спасает, только если сервер дедуплицирует; дубликат, пришедший, пока первый запрос ещё в обработке, получит 409, а эндпоинт без поддержки ключа применит правку дважды
Как работает исправление: withLock заставляет второй слив ждать; затем она заново читает outbox и находит только то, что ещё в очереди, то есть обычно ничего
В продакшене: пусть триггеры только ставят одну уникальную задачу WorkManager (enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, …)), тогда не пересекутся даже два процесса или воркер и ручной слив
Как поймать: тест, который дёргает оба триггера против фейкового API, приостанавливающегося внутри push, и проверяет, что каждый opId отправлен ровно один раз
Курсор по метке времени корректен, только если сервер фильтрует и сортирует по одному ключу, детерминированно разрешает совпадения и ставит метку в момент коммита. Иначе границы страниц и параллельные транзакции оставляют дыры, которые уже не заполнятся.
Один ключ для фильтра и сортировки: WHERE (updated_at, id) > (:ts, :id) ORDER BY updated_at, id; сортировка по id при фильтре по updated_at теряет строки, как только страница заполняется
Детерминированное разрешение совпадений: у многих строк одна и та же метка (при точности до секунды это целая секунда); без id в курсоре строгий > навсегда пропускает остаток, а >= в нём зацикливается
Метка в момент коммита: второй сервер приложения, отстающий на 300 мс, или транзакция, прочитавшая часы до вашего курсора и закоммитившаяся после, попадает под водяной знак и остаётся невидимой навсегда; берите последовательность, выдаваемую при коммите
На клиенте: пишите строки и курсор одной транзакцией, чтобы наполовину применённой страницы не бывало; один курсор на тип сущности
Слишком старый курсор: сервер отвечает 410 Gone, а клиент делает полную пересинхронизацию, а не повторяет запрос
Лучше всего: непрозрачный курсор, который выдаёт сервер, а клиент только возвращает: тогда это инвариант сервера
Протокол принял «нет в дельте» за «удалено». Дельта содержит только то, что изменилось, поэтому отсутствие значит «не менялось»: удаления обязаны ехать явными tombstone.
Сервер оставляет строку с deleted_at, отдаёт её в дельте с флагом удаления, а клиент применяет локальное удаление. Клиент, чья последняя синхронизация старше окна хранения tombstone, должен пересинхронизироваться полностью, а не тянуть дельту.
Срок хранения работает как обещание: сервер чистит tombstone по истечении фиксированного окна, обычно 30–90 дней, и объявляет его; клиент, отсутствовавший дольше, не может узнать, что пропустил, и обязан синхронизироваться заново
Проверка после двух месяцев офлайна: клиент хранит время последней успешной синхронизации; если она старше окна, выбрасывайте синхронизированные строки и синхронизируйтесь с нуля, но не outbox: эти правки на сервере ещё не лежат. Сервер, который знает, что курсор слишком старый, отвечает 410 Gone и требует того же пути
Что внутри tombstone: id, тип и время удаления, но не payload; это же честный ответ на требование удалить данные
Конфликты: незасинхроненную локальную правку строки, вернувшейся как tombstone, показывают пользователю, а не молча выбрасывают
Операция несёт версию, от которой делалась правка (If-Match или baseVersion), и сервер сравнивает её с текущей внутри записи. Несовпадение означает отказ 409 или 412 с текущим состоянием; клиент перебазирует своё намерение на него или спрашивает пользователя.
Откуда берётся база: серверная версия, сохранённая в локальной строке при последнем pull и скопированная в операцию при постановке в очередь; читать её в момент отправки значит сравнивать с версией, которую пользователь не правил
Проверка на сервере атомарна: UPDATE notes SET ..., version = version + 1 WHERE id = ? AND version = ?; ноль обновлённых строк означает конфликт. Отдельная проверка «прочитать, потом записать» попадает в гонку с другим запросом
Форма в HTTP: на ETag в If-Match отвечают 412 Precondition Failed, на версию в теле обычно 409 Conflict; в обоих случаях верните текущую сущность, чтобы клиенту не понадобился лишний запрос
Клиент при отказе: сохраните серверную строку как новую базу, примените поверх ожидающее намерение и сливайте автоматически, если поля не пересекаются; иначе сохраните оба варианта и покажите конфликт. Перебазированная операция получает новую базу и новый ключ идемпотентности, потому что её payload изменился
Частая ошибка: повторять 409 с backoff: от ожидания версия не меняется, поэтому операция падает вечно и блокирует свою цепочку
Выбирайте на каждое поле отдельно, по тому, что это за значение: last-write-wins для заголовка, OR-Set (CRDT) для списка тегов, а для счётчика не сливать ничего, вместо этого отправить «+1 от этого пользователя» и дать серверу сложить.
Общий вывод: синхронизируйте намерение, а не итоговое значение. Большинство конфликтов исчезает, когда мутация звучит как «добавить тег work», а не «выставить теги в a, b и work».
Last-write-wins: одна версия на запись, строка проигравшего выбрасывается целиком. Дёшево, но правка теряется, когда два устройства меняют разные поля. Подходит для переключателя, и сравнивать надо серверную версию, а не часы устройства
Мердж по полям: версия на каждое поле, слияние колонка за колонкой. Цена складывается из метаданных и функции слияния; он всё равно теряет одну из двух правок одного поля и может собрать запись, которой никто не писал: нормально для профиля, неверно для денег
CRDT: слияние встроено в сам тип: PN-Counter для параллельных инкрементов, OR-Set для добавления и удаления, последовательные типы для совместного текста. Цена складывается из растущих с историей метаданных и настоящей зависимости (Automerge, Yjs)
Упорядочивайте по тому, что контролирует сервер, или по причинности, которую можно доказать: серверный порядковый номер, часы Лампорта или version vector. Время по часам устройства оставьте только полем для показа.
Часы устройства отвечают на вопрос «когда пользователь это увидел», а последовательность или вектор отвечают на вопрос «что было раньше». Спешащий телефон ломает мердж потому, что полю для показа поручили упорядочивание.
Серверная последовательность: номер, выданный при коммите, монотонный в пределах аккаунта: полный порядок, очевидно корректный, но два офлайн-устройства не упорядочатся друг относительно друга, пока не синхронизируются
Часы Лампорта: каждая реплика держит счётчик и при получении ставит max(local, received) + 1; при равенстве счётчиков порядок задаёт id устройства и даёт полный порядок, не противоречащий причинности, но одновременные правки он не различает
Version vector: по счётчику на реплику; сравнение двух векторов говорит: A раньше B, B раньше A или конкурентны; это единственный из трёх, который обнаруживает конфликт, а не затирает один вариант
Гибридные логические часы (HLC): время по часам устройства плюс счётчик Лампорта: сортируются как метка времени, но не идут назад, когда назад идут часы телефона
Частая ошибка: System.currentTimeMillis() прыгает при коррекции NTP; SystemClock.elapsedRealtime() монотонный, но обнуляется при перезагрузке и ничего не значит на другом устройстве
Никогда не делайте слепой upsert строки, у которой есть ожидающие операции: сохраните серверную строку как новую базу и проиграйте ожидающие операции поверх неё, чтобы экран показывал серверное состояние плюс неотправленное намерение пользователя.
Почему ломается: пришедшая строка новее вашей последней синхронизации, но старее локальной правки; обычный upsert откатывает текст, который пользователь только что набрал, и он вернётся, только когда операция дойдёт и пройдёт следующий pull
База плюс наложение: храните серверный снимок отдельно от того, что показывает UI; видимая строка получается из базы с ожидающими операциями, проигранными в порядке seq. Офлайн-кэш Firestore устроен так же: ожидающие записи накладываются на серверный снимок
Вариант подешевле: пропускайте строки с ожидающими операциями и забирайте их заново, когда outbox опустеет; просто, но до этого пользователь не видит удалённых изменений этой строки
Push перед pull: сначала разгрести outbox сужает окно, но не закрывает его: пользователь может править посреди pull
Ваше же эхо: pull возвращает и строки, которые записали ваши подтверждённые операции; игнорируйте всё, у чего серверная версия не новее базы, которая у вас есть
Сохранение вливает более новый заголовок в операцию, которую воркер уже отправляет; когда этот запрос проходит, delete(op.opId) удаляет строку, в которой теперь лежит новый заголовок; он так и не уходит, а заметка показана синхронизированной, хотя на сервере остался старый.
Гонка: воркер прочитал op с "Hel", пользователь сохраняет "Hello", updatePayload переписывает ту же строку, приходит подтверждение для "Hel", и удаление уносит с собой "Hello"
Почему compare-and-delete не спасает: если оставить строку при изменившемся payload и отправить её снова под тем же opId, сервер вернёт сохранённый ответ для "Hel" или отклонит запрос с 422; один ключ идемпотентности должен означать один payload
Как работает исправление: воркер захватывает операцию (QUEUED в SENDING) в той же транзакции, в которой её читает, а сохранения сливаются только с операциями в QUEUED; правка, сделанная во время отправки, становится новой операцией и уходит следующей; неудачная или прерванная отправка возвращает SENDING в QUEUED, и повтор безопасен, потому что opId не меняется, а payload не может измениться, пока операция в SENDING
**markSynced: только когда для заметки не осталось других операций, иначе бейдж показывает «синхронизировано», пока правка ещё в очереди
Как поймать**: тест с фейковым API, который приостанавливается внутри putTitle, сохраняет заметку повторно во время этой паузы и проверяет, что на сервере оказался последний заголовок
Ждёт только цепочка самой 87-й; независимые цепочки продолжают отправляться. При таймауте 87-я остаётся головой цепочки и повторяется с backoff; 403 означает постоянную ошибку: 87-я одной транзакцией уходит из очереди, зависящие от неё операции блокируются, а пользователь видит правку с пометкой «не загружено».
Временная ошибка: I/O, 408, 429, 5xx
87-я остаётся головой с attempts + 1 и nextAttemptAt; ничто за ней в этой цепочке отправлять нельзя, ведь правка после упавшего создания получит 404
Потерянный ответ значит, что 87-я, возможно, уже применена: переотправьте с тем же ключом идемпотентности, и сервер вернёт исходный результат
401 не даёт повода что-то выбрасывать: обновите токен один раз, иначе ставьте всю очередь на паузу до повторного входа; 409 значит rebase, а не повтор
Постоянная ошибка: 400, 403, 404, 410, 422
Одна транзакция: удалить операцию, пометить все следующие операции по той же сущности blockedBy ею, пометить локальную строку «не загружено» и записать ошибку с исходным payload
Заблокированные зависимые операции освобождаются, когда пользователь повторяет или отбрасывает упавшую правку; тогда blockedBy сбрасывается
Никогда не повторяйте её бесконечно (батарея, заклинившая цепочка) и никогда не удаляйте payload вместе с операцией: пользователь видел, как правка появилась, и считает её сохранённой
У фонового прогона нет экрана, поэтому toast некуда показать; строка показывает своё состояние, а в списке проблем появляется одна запись, текст по-прежнему читается
Почему 88–200 идут дальше
Разбейте очередь по агрегату: порядок обещан внутри цепочки одной сущности, а не всего приложения, поэтому одна застрявшая заметка не блокирует весь аккаунт
Отложенные мутации представляют собой сохранённые данные, записанные сборкой, которой больше нет, поэтому у outbox должно быть своё поле версии и свой путь апгрейда. Миграция базы переносит таблицу, но не смысл лежащих в ней блобов.
Считайте outbox wire-форматом между двумя версиями вашего же приложения и версионируйте его как API.
Строка описывает сама себя: колонки opType, payloadVersion и непрозрачная строка payload; схема таблицы стоит на месте, пока формы payload меняются
**Стабильный opType**: строковая константа вроде "note.update", а не имя класса: R8 переименовывает классы, а переезд пакета молча ломает соответствие
Никогда не сохраняйте граф объектов: положите класс в бинарном виде, и один рефакторинг сделает все строки в очереди нечитаемыми
Апгрейд при чтении: декодируйте по паре (opType, payloadVersion) и прогоняйте JSON через цепочку маленьких апгрейдов до текущей формы; всё выше этого слоя видит только сегодняшнюю модель
Dead-letter для нечитаемого: исключение в прогоне ломает синхронизацию этому пользователю, тихое удаление теряет его правку; держите старые декодеры столько, сколько реально живёт очередь
Считайте фото отдельной задачей с собственной докачкой: копируйте байты в хранилище приложения сразу при выборе, заливайте их отдельно по частям, а мутация поста пусть несёт только ссылку, которая станет серверным blob id.
Байты едут по своему расписанию, id едут в очереди.
Порядок относительно поста: две операции в одной цепочке: attachment.upload(localId), затем post.create(..., blobId), заблокированная, пока первая не запишет id; операция загрузки блокирует только цепочку этого поста, остальные цепочки продолжают разгребаться
Почему не один multipart-запрос: он не умеет докачку: таймаут на 95% переотправляет все 12 МБ, и записать, что уже дошло, некуда
Докачиваемая загрузка: создайте сессию, шлите чанки через PUT с Content-Range, сохраняйте URL сессии и подтверждённый offset после каждого; при возобновлении спрашивайте offset у сервера, а не доверяйте последней локальной записи
Переживите уход в фон: воркер WorkManager, который стримит из файла и вызывает setForeground (foreground service типа dataSync, с Android 15 ограничен примерно шестью часами в сутки); если пользователь нажал «Опубликовать» онлайн, альтернативой служит задача JobScheduler типа user-initiated data transfer на Android 14+; массив байтов в памяти не переживёт ничто
Диск: копируйте из URI пикера сразу, уменьшайте до постановки в очередь, ограничьте суммарный объём ожидающих вложений и удаляйте локальную копию, как только сохранён blob id
Все клиенты повторяют попытки и синхронизируются по одному детерминированному таймеру, поэтому весь парк устройств идёт в фазе. Лечится рандомизацией, а не другой фиксированной задержкой: рандомизируйте всё, что иначе весь парк сделает в одну секунду.
Джиттер в backoff: full jitter, delay = random(0, min(cap, base * 2^attempt)); чистый экспоненциальный backoff держит стадо вместе, ведь после сбоя все устройства повторяют ровно через 1с, 2с, 4с
**Уважайте Retry-After: на 429 и 503 он важнее любой клиентской формулы; это единственный канал, которым бэкенд может сказать миллиону устройств разойтись
Ограничьте сверху: потолок задержки и потолок попыток, после которого операция паркуется на разбор, а не повторяется бесконечно
Склеивайте триггеры: выход в foreground, восстановление сети, локальная постановка в очередь и периодический минимум ставят одну и ту же уникальную задачу; прогон на каждое событие сети даёт тот же всплеск в миниатюре, ведь поднявшаяся вышка синхронна для всех устройств на ней
Разводите триггеры по часам**: если синхронизация привязана к фиксированному времени (будильник в 09:00, плановый push, синхронизация при разблокировке), добавьте стабильный сдвиг на установку, выведенный из install id; периодическая работа WorkManager привязана ко времени постановки в очередь и уже разъезжается сама
Замените часы, сеть и источник случайности на то, чем управляет тест, гоняйте оба устройства и фейковый сервер в памяти и проверяйте инварианты, а не снимок итогового состояния.
Тест, который ждёт время по часам, измеряет загрузку CI, а не сходимость. Оттуда и красные сборки.
Фейковый сервер: говорит на настоящих эндпоинтах, держит настоящее состояние и умеет вести себя плохо: потерять запрос, применить запись и потерять ответ, доставить операцию дважды, переставить пачку, ответить 409
**Один Random с сидом и виртуальное время**: тот же сид задаёт расписание сбоев и джиттер backoff, а runTest с advanceUntilIdle() сжимает часы backoff в микросекунды; печатайте сид, чтобы красная сборка воспроизводилась
Сходимость: восстановить сеть, прогнать очереди, затем проверить deviceA.state == deviceB.state == server.state
Идемпотентность: каждая операция изменила состояние сервера ровно один раз, хотя сбои доставили часть из них дважды; именно эту проверку обычно пропускают, и именно её ломает потерянный ack
Ничего не потеряно: каждая принятая мутация либо на сервере, либо в таблице ошибок, а создание никогда не применяется после своей правки; дальше прогоните те же инварианты на тысячах сидов
Сигналом служит возраст самой старой несинхронизированной записи, отданный как распределение по установкам. Доля успешных запросов эту когорту не видит, потому что заклинивший клиент со временем вообще перестаёт слать запросы.
Возраст самой старой операции в очереди: p95 и p99, снимается при каждом выходе в foreground; здоровый парк живёт на минутах, застрявшая когорта утягивает хвост в дни; на это и ставьте алерт
Глубина очереди на установку: сумма по всему парку бессмысленна, а если смотреть как распределение, там и живут эти 2%; ноль завершённых прогонов на активного пользователя в день не виден ни одной метрике по запросам
Ошибки по классам: временные, постоянные, конфликты, ошибки декодирования, dead-letter, в разрезе версии приложения, версии ОС и страны; когорта в 2% почти всегда один срез
Dead-letter и расхождение часов: алерт на любое ненулевое dead-letter; время устройства минус заголовок Date сервера объясняет много жалоб «смёржило не так»
Что попадает в лог: только идентификаторы и формы (id операции, тип операции, статус, request id), но не значения payload