Irbisa · Шпаргалка

Офлайн-синхронизация и конфликты

Outbox мутаций, ключи идемпотентности, delta sync, tombstones, LWW vs CRDT, backoff, сходимость

Senior-разработчик · 21 вопрос

  1. 01

    Пройдите путь офлайн-первой записи: что происходит между нажатием «Сохранить» и подтверждением от сервера?

    Нажатие пишет локальную строку и запись в outbox одной транзакцией базы, и экран сразу обновляется из базы.

  2. 02

    Мутация пролежала в outbox три недели и пережила два обновления приложения. Что должна была сохранить эта строка, чтобы её всё ещё можно было повторить?

    Строка outbox должна хранить всё, из чего запрос собирается заново по одним данным: ключ идемпотентности, целевую сущность, операцию с версионированным payload и учёт повторов.

    @Entity(tableName = "outbox")data class PendingOp(  @PrimaryKey val opId: String,      // idempotency key: minted once, never regenerated  val groupKey: String,              // aggregate root: the ordering scope  val seq: Long,                     // monotonic within the group  val entityType: String,…
  3. 03

    Что должна нести локально созданная строка, чтобы список показывал «отправляется» и «ошибка» без мигания и прыжков строки?

    Строка хранит своё состояние синхронизации в колонке базы, а список рисуется только из базы.

    enum class SyncState { SYNCED, PENDING, SENDING, FAILED } @Entity(tableName = "notes")data class NoteEntity(  @PrimaryKey val localId: String,      // stable for the row's whole life  val remoteId: String? = null,         // arrives later, never becomes the key…
  4. 04

    POST платежа отвалился по таймауту до того, как клиент увидел ответ, и клиент повторяет запрос. Что не даёт списать деньги дважды и кто выдаёт ключ?

    Клиент выдаёт ключ идемпотентности один раз, когда платёж кладётся в outbox, и шлёт тот же ключ при каждой попытке.

    interface OrdersApi {  @POST("orders")  suspend fun create(    @Header("Idempotency-Key") key: String,    @Body body: CreateOrder  ): Response<Order>…
  5. 05

    Строка создана офлайн с клиентским UUID, а сервер при первой синхронизации возвращает свой собственный id. Как свести их, не поломав все ссылки?

    Оставьте клиентский id первичным ключом на всю жизнь строки, а серверный храните рядом в nullable-колонке.

    @Entity(tableName = "posts")data class PostEntity(  @PrimaryKey val localId: String,   // ULID minted on the device, never rewritten  val remoteId: String? = null,      // filled in on the first successful sync  val title: String)…
  6. 06

    Комментарий в очереди ссылается на пост, который сам ещё лежит в outbox несинхронизированным. Как очередь гарантирует, что пост уйдёт первым?

    Упорядочивайте очередь по группам зависимостей, а не глобально: каждая операция несёт groupKey (корневая сущность, здесь пост) и монотонный seq внутри него.

    @Daointerface OutboxDao {  @Query("SELECT DISTINCT groupKey FROM outbox WHERE status = 'READY' AND nextAttemptAt <= :now")  suspend fun readyGroups(now: Long): List<String>   // the head of one group: strictly one op in flight, in seq order…
  7. 07

    Этот воркер разгребает outbox. Какие строки теряют правку пользователя или применяют её дважды и как это исправить?

    class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {  override suspend fun doWork(): Result {    for (op in outbox.all()) {      outbox.delete(op.opId)      try {        val saved = api.push(UUID.randomUUID().toString(), op.payloadJson)        notes.upsert(saved.toEntity())      } catch (e: Exception) {        Log.w("sync", "failed: ${op.payloadJson}")      }    }    return Result.success()  }}

    Он удаляет каждую операцию до того, как сервер её получил, и глотает любую ошибку, поэтому падение, таймаут или 500 теряют правку; а когда удаление перенесут после успеха, свежий ключ UUID.randomUUID() заставит каждый повтор применять правку дважды.

  8. 08

    Найдите баг: когда приложение выходит на передний план ровно в момент возврата сети, в логе сервера каждая правка из очереди приходит дважды.

    class SyncManager(  private val outbox: OutboxDao,  private val api: NotesApi,  private val scope: CoroutineScope,) {  fun onNetworkAvailable() { scope.launch { drain() } }  fun onAppForeground() { scope.launch { drain() } }   private suspend fun drain() {    for (op in outbox.pending()) {           // ordered by seq      api.push(op.opId, op.payloadJson)      outbox.delete(op.opId)    }  }}

    Два триггера могут запустить две корутины drain() одновременно; каждая берёт свой снимок outbox.pending(), поэтому каждая операция отправляется дважды, а два цикла перемешиваются.

  9. 09

    Клиент шлёт ?updated_since=<последняя метка>, а пользователи жалуются, что часть строк просто не приходит. Что сервер обязан гарантировать, чтобы такой курсор был корректен?

    Курсор по метке времени корректен, только если сервер фильтрует и сортирует по одному ключу, детерминированно разрешает совпадения и ставит метку в момент коммита.

    @Serializabledata class Cursor(val updatedAt: Long, val id: String)   // value + tie-break @Serializabledata class DeltaPage(val rows: List<NoteDto>, val next: Cursor?, val hasMore: Boolean)…
  10. 10

    Телефон возвращается после двух месяцев офлайна и всё ещё показывает заметки, которые все остальные давно удалили. Что не так в протоколе синхронизации?

    Протокол принял «нет в дельте» за «удалено».

  11. 11

    Ваша правка в очереди сделана от версии 5, а сервер уже на версии 7. Как сервер это замечает и что клиент делает с отказом?

    Операция несёт версию, от которой делалась правка (If-Match или baseVersion), и сервер сравнивает её с текущей внутри записи.

    interface NotesApi {  @PUT("notes/{id}")  suspend fun update(    @Path("id") id: String,    @Header("Idempotency-Key") key: String,    @Header("If-Match") ifMatch: String,       // "\"5\"": the version this edit started from…
  12. 12

    Для заголовка заметки, счётчика лайков и общего списка тегов что вы выберете: last-write-wins, мердж по полям или CRDT, и во что обойдётся каждый вариант?

    Выбирайте на каждое поле отдельно, по тому, что это за значение: last-write-wins для заголовка, OR-Set (CRDT) для списка тегов, а для счётчика не сливать ничего, вместо этого отправить «+1 от этого пользователя» и дать серверу сложить.

    // LWW: one version for the whole record, compared on a server-assigned valuefun mergeLww(local: Note, remote: Note): Note =  if (remote.serverVersion >= local.serverVersion) remote else local   // the loser's edits are gone // OR-Set: every add carries a unique tag, so a concurrent add beats a remove that never saw itdata class OrSet(…
  13. 13

    Мердж постоянно выбирает не того победителя, потому что на одном телефоне часы спешат на сорок минут. По чему тогда упорядочивать события?

    Упорядочивайте по тому, что контролирует сервер, или по причинности, которую можно доказать: серверный порядковый номер, часы Лампорта или version vector.

    // Lamport clock: a counter that only moves forward and catches up on every receivedata class Stamp(val counter: Long, val nodeId: String) : Comparable<Stamp> {  override fun compareTo(other: Stamp) =    compareValuesBy(this, other, Stamp::counter, Stamp::nodeId)   // node id breaks ties}…
  14. 14

    Pull с дельтой приходит, пока правки пользователя в тех же строках ещё лежат в outbox. Как применить его, не затерев эти правки?

    Никогда не делайте слепой upsert строки, у которой есть ожидающие операции: сохраните серверную строку как новую базу и проиграйте ожидающие операции поверх неё, чтобы экран показывал серверное состояние плюс неотправленное намерение пользователя.

    suspend fun applyPage(page: DeltaPage) = db.withTransaction {  for (dto in page.rows) {    val server = dto.toEntity()    if (server.version <= (base.versionOf(server.id) ?: -1)) continue   // our own echo     base.upsert(server)                                    // the server's truth, kept apart…
  15. 15

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

    suspend fun saveNote(id: String, title: String) = db.withTransaction {  notes.updateTitle(id, title, SyncState.PENDING)  val pending = outbox.pendingUpdateFor(id)  if (pending != null) outbox.updatePayload(pending.opId, title)   // coalesce: one op per note  else outbox.insert(PendingOp(opId = newUlid(), noteId = id, title = title))} suspend fun sendNext() {                     // the worker calls this in a loop  val op = outbox.next() ?: return  api.putTitle(op.opId, op.noteId, op.title)  db.withTransaction {    outbox.delete(op.opId)    notes.markSynced(op.noteId)  }}

    Сохранение вливает более новый заголовок в операцию, которую воркер уже отправляет; когда этот запрос проходит, delete(op.opId) удаляет строку, в которой теперь лежит новый заголовок; он так и не уходит, а заметка показана синхронизированной, хотя на сервере остался старый.

  16. 16

    Прогон очереди из 200 мутаций падает на 87-й. Что происходит с 88–200 и что меняется, если 87-я упала с 403, а не по таймауту?

    Ждёт только цепочка самой 87-й; независимые цепочки продолжают отправляться.

    sealed interface Outcome {  data class Done(val entity: EntityDto) : Outcome  data object Retry : Outcome                          // keep the head, back off  data object Reauth : Outcome                         // pause the whole queue, drop nothing  data class Rebase(val server: EntityDto) : Outcome   // merge on the server state  data class Permanent(val code: Int) : Outcome…
  17. 17

    Вы выкатываете обновление, которое меняет форму payload мутации, а у тысяч пользователей в outbox лежат операции старого формата. Что делать?

    Отложенные мутации представляют собой сохранённые данные, записанные сборкой, которой больше нет, поэтому у outbox должно быть своё поле версии и свой путь апгрейда.

    private const val CURRENT_NOTE_UPDATE = 3private val json = Json { ignoreUnknownKeys = true }   // tomorrow's extra keys must not throw // one upgrade chain per op type, applied on readfun upgradeNoteUpdate(version: Int, raw: String): NoteUpdateV3 {  var v = version…
  18. 18

    К посту, созданному офлайн, приложено фото на 12 МБ. Как отправить байты, не блокируя очередь мутаций и не забивая диск пользователя?

    Считайте фото отдельной задачей с собственной докачкой: копируйте байты в хранилище приложения сразу при выборе, заливайте их отдельно по частям, а мутация поста пусть несёт только ссылку, которая станет серверным blob id.

    private const val CHUNK = 4L * 1024 * 1024 suspend fun upload(a: Attachment): String {  val session = a.sessionUrl    ?: api.createSession(a.bytes).also { dao.setSession(a.localId, it) }…
  19. 19

    Бэкенд видит всплеск синхронизаций в 09:00 каждое утро и ещё один в ту же минуту, когда заканчивается сбой. Что не так с планировщиком на клиенте?

    Все клиенты повторяют попытки и синхронизируются по одному детерминированному таймеру, поэтому весь парк устройств идёт в фазе.

    private val BASE = 30.secondsprivate val CAP = 6.hours // full jitter: two devices that failed together must not retry togetherfun backoffDelay(attempt: Int, retryAfter: Duration? = null): Duration {  retryAfter?.let { return it }                              // the server's number wins…
  20. 20

    Как проверить, что два устройства и сервер действительно сходятся, и при этом не получить набор тестов, который краснеет каждый раз, когда CI тормозит?

    Замените часы, сеть и источник случайности на то, чем управляет тест, гоняйте оба устройства и фейковый сервер в памяти и проверяйте инварианты, а не снимок итогового состояния.

    class FakeServer(private val rnd: Random) {  val state = mutableMapOf<String, Note>()  val applyCount = mutableMapOf<String, Int>()      // op id -> times it really mutated state  private val seen = mutableMapOf<String, Note>()   // idempotency key -> original result  var faults: FaultPlan = FaultPlan.none…
  21. 21

    Синхронизация уже месяц тихо не работает у 2% пользователей. Какие цифры сказали бы вам об этом?

    Сигналом служит возраст самой старой несинхронизированной записи, отданный как распределение по установкам.