Skip to content

[Sync] Clock-rewind completeness for the client-held Sync Cursor #275

Description

@KacperKozak

Problem

updated_at (added in PR #273) is a wall-clock stamp. If the device clock steps backwards, a write can land at or below a cursor the client has already synced, and the client's own updated_at >= watermark scan never picks it up. The row is changed locally and never reaches the server.

Three tables behave differently today:

table on write under a backwards clock step
boards raw now cursor regresses below the watermark — write lost
alerts raw now cursor regresses below the watermark — write lost
telemetry_minute_buckets MAX(existing, now) cursor freezes; >= still catches it, so complete but stale

Found by a Codex review of PR #273. Confirmed against vescape-server design intent.

Why it was not fixed in PR #273

The obvious fix — a ratcheting logical clock, updated_at = max(existing + 1, now) — is actively wrong here. Per the server side:

  • updated_at is the last-write-wins key, and its only planned server-side use.
  • A ratcheting counter never comes back down. One clock rewind on device A permanently inflates its stamps, so A wins every future conflict against device B — even after A's clock is correct. That trades a temporary completeness gap for a permanent correctness bias in the exact mechanism that resolves conflicts.

So updated_at stays a truthful wall clock. The bucket MAX fold is kept because it is bounded and self-correcting (the stamp becomes truthful again once the clock passes the old value), unlike +1.

Context that shapes the fix

From vescape-server (no sync code exists yet — this is design intent from its ADR-0003/0004 and spec, all still cheap to change):

  • The Sync Cursor is client-held. It never crosses the wire. The server stores no watermark and has no opinion about one. The cursor query runs against the phone's own SQLite.
  • Recommended query is >=, not >, with idempotent upsert absorbing the duplicate. That removes the same-millisecond straddle without a tie-break column.
  • Over-sending is free by design. Idempotent upsert is an acceptance criterion; cost is bandwidth only. There is no server-side mtime that reflects back to other devices.
  • A full resync is a unilateral client decision. Set the local watermark to 0 and re-send; the server upserts idempotently. No server cooperation and no new endpoint required.
  • Multi-device is currently out of scope, so the LWW bias above is latent rather than live.

Options

  1. Separate the cursor from the timestamp. Add a strictly-monotonic local sequence column per syncable table, bumped on every write, and scan on that instead of updated_at. Clock-immune completeness with zero contract impact, since the cursor never leaves the device. updated_at stays purely the LWW timestamp. This is the server side's preferred option.
  2. Clock-anomaly full resync. Detect a backwards step (persist last-seen wall clock, compare on boot/write) and reset the local watermark to 0. Needs no schema change and no server support. Cheap insurance if option 1 is too much native work now.
  3. Make boards/alerts match buckets (MAX fold), so all three at least freeze rather than regress. Requires INSERT OR REPLACE -> ON CONFLICT DO UPDATE on iOS and a read-modify-write on Android's @Insert(REPLACE). Superseded by option 1.

Also worth recording here

Deletes are designed as an append-only log of Delete Actions (server ADR-0004), not tombstones and not full-set reconciliation. Hard constraint for this client: a Delete Action records Rider intent. Local retention/GC pruning must never emit one — if the existing DELETE FROM telemetry_frames WHERE captured_at_ms < :beforeMs sweep reached that log, the server would delete exactly the rides the backup exists to preserve.

Related: #274 (telemetry rows are keyed on a mutable BLE id, which is likely wrong for sync regardless).

Likely files

  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt - bucket merge() fold, setAlertRuleEnabled
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt - toBoardEntity / toAlertRuleEntity stamp sites
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt - where a sequence column would land
  • modules/vescape-core/ios/telemetry/AppDataRepository.swift - iOS board/alert upserts and the toggle
  • modules/vescape-core/ios/telemetry/TelemetryDao.swift - upsertBucket conflict clause
  • modules/vescape-core/ios/telemetry/TelemetryDatabase.swift - migration home for any new column

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

    area:dbTouches database / persistent storagearea:nativeTouches native side (modules/vesc-ble, Swift/Kotlin)area:serverVescape backend APIs, relay behavior, server policy, and deployment-facing contractscomplexity:mediumNeeds care, moderate integration surface. Use sonnet.needs-triageMaintainer needs to evaluate this issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions