Irbisa · Шпаргалка
Офлайн-синхронизация и конфликты
Outbox мутаций, ключи идемпотентности, delta sync, tombstones, LWW vs CRDT, backoff, сходимость
Пройдите путь офлайн-первой записи: что происходит между нажатием «Сохранить» и подтверждением от сервера?
Мутация пролежала в outbox три недели и пережила два обновления приложения. Что должна была сохранить эта строка, чтобы её всё ещё можно было повторить?
@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,…Что должна нести локально созданная строка, чтобы список показывал «отправляется» и «ошибка» без мигания и прыжков строки?
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…POST платежа отвалился по таймауту до того, как клиент увидел ответ, и клиент повторяет запрос. Что не даёт списать деньги дважды и кто выдаёт ключ?
interface OrdersApi { @POST("orders") suspend fun create( @Header("Idempotency-Key") key: String, @Body body: CreateOrder ): Response<Order>…Строка создана офлайн с клиентским UUID, а сервер при первой синхронизации возвращает свой собственный id. Как свести их, не поломав все ссылки?
@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)…Комментарий в очереди ссылается на пост, который сам ещё лежит в outbox несинхронизированным. Как очередь гарантирует, что пост уйдёт первым?
@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…Этот воркер разгребает 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() }}Найдите баг: когда приложение выходит на передний план ровно в момент возврата сети, в логе сервера каждая правка из очереди приходит дважды.
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) } }}Клиент шлёт
?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)…Телефон возвращается после двух месяцев офлайна и всё ещё показывает заметки, которые все остальные давно удалили. Что не так в протоколе синхронизации?
Ваша правка в очереди сделана от версии 5, а сервер уже на версии 7. Как сервер это замечает и что клиент делает с отказом?
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…Для заголовка заметки, счётчика лайков и общего списка тегов что вы выберете: last-write-wins, мердж по полям или CRDT, и во что обойдётся каждый вариант?
// 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(…Мердж постоянно выбирает не того победителя, потому что на одном телефоне часы спешат на сорок минут. По чему тогда упорядочивать события?
// 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}…Pull с дельтой приходит, пока правки пользователя в тех же строках ещё лежат в outbox. Как применить его, не затерев эти правки?
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…Найдите баг: пользователь правит заметку во время синхронизации, и сервер иногда оставляет старый заголовок, хотя заметка помечена синхронизированной.
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) }}Прогон очереди из 200 мутаций падает на 87-й. Что происходит с 88–200 и что меняется, если 87-я упала с 403, а не по таймауту?
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…Вы выкатываете обновление, которое меняет форму payload мутации, а у тысяч пользователей в 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…К посту, созданному офлайн, приложено фото на 12 МБ. Как отправить байты, не блокируя очередь мутаций и не забивая диск пользователя?
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) }…Бэкенд видит всплеск синхронизаций в 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…Как проверить, что два устройства и сервер действительно сходятся, и при этом не получить набор тестов, который краснеет каждый раз, когда 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…Синхронизация уже месяц тихо не работает у 2% пользователей. Какие цифры сказали бы вам об этом?