Skip to content

Sync push conflicts fire spuriously: localVersion and syncLog.version are different counters #588

Description

@tomymaritano

Found while building the iOS sync client against the real schemas. It affects the shipped desktop product, not just iOS.

The mismatch

POST /sync decides a conflict with (routes/sync.ts:164-168):

if (latestEntry && latestEntry.deviceId !== deviceId &&
    change.localVersion !== undefined &&
    latestEntry.version > change.localVersion) { /* conflict */ }

Those two numbers live in different spaces:

Value Where it comes from Shape
latestEntry.version COALESCE(MAX(version),0)+1 over syncLog filtered only by userId (sync.ts:140-145) global per-user counter, grows with every change to every note
change.localVersion notes.local_version INTEGER DEFAULT 1, +1 per edit (storage-sqlite/src/migrations/011_sync_tracking.ts:16,41) per-note edit counter, small

On any account with more than a handful of total changes, the global version exceeds a single note's edit count. So whenever the last writer was a different device, latestEntry.version > change.localVersion is true and the push is rejected as conflict — regardless of whether anything actually conflicts.

Why nobody has noticed

Push conflicts are not surfaced (SyncService.ts:554-560):

const pushConflicts = pushResult.results.filter(r => r.status === 'conflict');
if (pushConflicts.length > 0) {
  console.warn(`Push conflicts detected for ${pushConflicts.length} notes:`, pushConflicts);
}

A conflicted note is not in successfulNoteIds, so markMultipleAsSynced skips it, it stays dirty, and it is retried on every cycle — forever, with no user-visible signal. The symptom is "my edit never showed up on the other machine", not an error.

Impact

Edit a note on device A, then edit it on device B: B's edit never uploads. This is exactly the P3 mobile goal — type on the phone, see it on the desktop — so it will block that too.

Not verified against a live account: I have no credentials. The above is read from the source in develop and I would rather report it than let iOS quietly inherit the same bug.

Options, all needing a call

  1. Stop sending localVersion. The guard becomes inert and push is last-writer-wins — which is effectively today's behaviour anyway, since the conflicts are discarded. Smallest change, honest about what the system actually does.
  2. Send the server version observed at pull time instead of the local edit counter. Makes the comparison meaningful. Requires clients to persist it per note; a protocol change touching desktop and iOS.
  3. Fix the comparison server-side to scope MAX(version) per note, or compare against a per-note sequence.

Option 2 is the only one that gives real optimistic concurrency; 1 is the honest quick fix. Either way, push conflicts should stop being console.warn.

/cc — blocking decision for dripnex/ios sub-project C (dripnex/ios#11, open question 1).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions