Irbisa · Cheatsheet
Offline Sync & Conflicts
Mutation outbox, idempotency keys, delta sync, tombstones, LWW vs CRDT, backoff, convergence
Walk through an offline-first write: what happens between the user tapping Save and the server confirming it?
A mutation has sat in the outbox for three weeks across two app updates. What must that row have stored for it to still be replayable?
@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,…What does a locally created row carry so the list can show sending and failed without the row flickering or jumping?
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…A payment POST times out before the client sees a response, so the client retries. What keeps that from charging twice, and who mints the key?
interface OrdersApi { @POST("orders") suspend fun create( @Header("Idempotency-Key") key: String, @Body body: CreateOrder ): Response<Order>…You create a row offline with a client UUID and the server returns its own id on first sync. How do you reconcile them without breaking every reference?
@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)…A queued comment refers to a post that is itself still sitting unsynced in the outbox. How does the queue guarantee the post goes first?
@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…This worker drains the outbox. Which lines lose a user's edit or apply it twice, and how do you fix them?
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() }}Find the bug: when the app returns to the foreground just as the network comes back, the server log shows every queued edit pushed twice.
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) } }}Your pull sends
?updated_since=<last timestamp>and users report rows that simply never arrive. What must the server guarantee for that cursor to be correct?@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)…A phone comes back after two months offline and still shows notes that everyone else deleted. What did the sync protocol get wrong?
Your queued edit was based on version 5, but the server is already at version 7. How does the server notice, and what does the client do with the rejection?
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…For a note title, a like counter and a shared tag list, which of last-write-wins, field-level merge and a CRDT do you pick, and what does each cost?
// 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(…Your merge keeps picking the wrong winner because one phone's clock is forty minutes fast. What do you order events by instead?
// 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}…A delta pull arrives while the user's edits to the same rows are still in the outbox. How do you apply it without wiping those edits?
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…Find the bug: a user edits a note while sync is running, and the server sometimes keeps an older title even though the note shows as synced.
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) }}A drain of 200 queued mutations fails on number 87. What happens to 88–200, and what changes if 87 failed with a 403 rather than a timeout?
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…You ship an update that changes the shape of a mutation payload, and thousands of users still have old-format ops sitting in their outbox. What now?
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…A post created offline has a 12 MB photo attached. How do you get the bytes up without blocking the mutation queue or filling the user's disk?
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) }…Your backend sees a sync spike at 09:00 every morning and another the minute an outage ends. What is wrong with the client scheduler?
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…How do you test that two devices and a server actually converge, without a suite that goes red whenever CI is slow?
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…Sync has been quietly broken for 2% of your users for a month. Which numbers would have told you?