Offline-First KMP · Part 13

A Version Bump Is Not a Conflict

Cover Image for A Version Bump Is Not a Conflict
Android
androidkmpoffline-firstevent-sourcingconflict-resolutionsync
·6 min read

Part 11 showed you auto-resolve in one clean line. Here's everything that clean line hides.

TL;DR When two people edit the same entity offline, our server tries to auto-resolve before bothering anyone: if their edits touched disjoint columns, there is no real disagreement, so it applies both. Simple on paper. In production the idea broke three times, and each break taught the same lesson: a version mismatch is not yet a conflict, and when you are unsure whether it is one, you ask a human rather than guess.

Part 13 of the Offline-First KMP series: field notes from a production winery app built on Kotlin Multiplatform (KMP). Part 11 sums up conflict auto-resolution in one line: disjoint columns resolve themselves, real overlaps go to a human. That line is true, but not the whole story. This post covers the three things that broke it in production, and why the safe answer is to fail closed.

The Clean Version: Version Snapshots and Disjoint Columns

Every event our client records carries a version snapshot: the version of each entity the user was looking at when they acted. Two cellar hands edit the same docket offline, both at v3. The first edit lands and makes v4. The second arrives still claiming v3: a mismatch, but only a suspicion. So the server compares the columns the two edits wrote:

Second edit arrives: v3 ≠ v4
Disjoint columns
one renamed, the other changed the date
first wrote name
second wrote date
No disagreement: both apply.
Same column, different values
both renamed the docket
first wrote name
second wrote name
A real conflict: a human decides.

That is the clean version. Here is what it leaves out.

Break One: Only Edits You Haven't Seen Can Conflict

The point: compare an event only against the edits its user hadn't seen yet. Anything older is already part of the version they acted on, so it can't disagree with them.

Our first query got this wrong. It loaded the entity's whole history, not just the edits made after the user's snapshot:

One docket, two windows
Someone renames the docket
makes v3
same column: clash
You rename it again, offline
you saw v3
Another user changes the date
makes v4
other column: fine
First query: whole history
Your rename is checked
v3 ≠ v4
false conflict
Someone renames the docket
makes v3
already seen
You rename it again, offline
you saw v3
Another user changes the date
makes v4
other column: fine
Fix: since you looked
Your rename is checked
v3 ≠ v4
auto-resolves

The first rename made v3, the version you were looking at, so it can't have raced you. The fix drops every event your snapshot already contains; what is left is the intervening window. When the server can't prove an event is already in your snapshot, it keeps it: at worst, a human gets asked.

The check also trusts each event's own list of the columns it writes, and hand-written lists rot; Part 16 is how we stop that from happening quietly.

Break Two: The Same Value Is Not a Disagreement

The check assumes two writes to the same column clash. They don't if the values match. Two devices mark the same docket as received offline, both writing status = received. The columns overlap, so the naive check asks a human to settle a disagreement that doesn't exist.

Now an overlap with an identical value is a no-op: recorded as resolved, nothing applied, nobody asked. A conflict requires a difference, not just a collision.

Break Three: Row Locks So the Check Survives Concurrency

Everything above assumes a stable history. On our server, several batches process events for the same workspace in parallel, so another batch could change a docket while the auto-resolver is reading it. So the whole check runs in one transaction that locks every entity in the snapshot first:

One auto-resolve, start to finish
Mismatch found
v3 ≠ v4
Lock the rows
in id order
Re-check the snapshot
versions moved?
Scan the window
now stable
Apply, or ask a human
disjoint or not
Rows locked: other batches wait
Commit
locks released

The lock is one statement:

SELECT 1 FROM <table> WHERE id IN (...) ORDER BY id FOR UPDATE

ORDER BY id makes every transaction take its locks in the same order, so two of them can't deadlock. The re-check catches a version that moved just before the lock; versions only go up, so it can only find more conflicts, never fewer.

Locking is strict by default: if the database adapter can't take row locks, auto-resolve doesn't run at all. A loud failure beats concurrent batches silently skipping conflict detection.

Fail Closed: When Unsure, Ask a Human

The point: auto-resolve may only apply an edit when it is sure. Everything else goes to a human.

Three breaks in one small feature taught us not to trust it to be clever. So every case it can't be sure about ends the same way, as a conflict for a person:

  • more than ten intervening events on the entity
  • an event type it doesn't know, or a payload it can't decode
  • an entity that no longer exists

That makes its mistakes one-sided:

The two ways auto-resolve can be wrong
Too careful
asks when it didn't need to
a person reviews a false conflict
Allowed: an annoyance.
Too confident
applies when it shouldn't have
a real conflict is written silently
Never: it corrupts data.

When the machine is not certain two humans agreed, it asks them.

Comparing columns tells you whether two edits collide, not whether either one still makes sense; Part 17 is where that difference starts to matter.

The Takeaways

  • A version mismatch is a question, not an answer. Disjoint columns are the easy part. The window, equal values and concurrency are where the correctness lives.
  • Only edits the user hadn't seen can conflict. Compare against what changed since their snapshot, not against the entity's whole history.
  • Fail closed, loudly. An auto-resolver that over-asks is an annoyance. One that under-asks corrupts data silently, and the silent failures are the ones that reach your users.