Skip to content

aw-sync: resume cursor re-imports the boundary event every pass (inclusive start, ties at the newest timestamp) — duplicates grow on both push and pull #711

Description

@ErikBjare

Found while testing the Android import on erb-m2 (aw-sync from b0fab73), two independent instances of the same defect.

Symptom 1 — desktop pull, one extra copy per pass

aw-watcher-android-web-chrome-synced-from-poco_f8_ultra: after two sync passes the local bucket has 206 rows for 205 distinct events. The duplicate is the newest event (2026-07-05T22:04:59.285, duration 0, ids 6994237 and 8430699, identical data). Second pass log:

⟳  Syncing bucket 'aw-watcher-android-web-chrome-synced-from-poco_f8_ultra'
   + Resuming at 2026-07-05T22:04:59.285Z
   = Synced 1 new events

sync_one sets resume_sync_at = newest.timestamp + newest.duration; with duration 0 that is the newest event's own timestamp, and the source fetch with start = resume is inclusive, so the same event is fetched and inserted again. Every subsequent pass adds one more copy. Any bucket whose newest event has zero duration (every web/window watcher right after a heartbeat starts, every stopwatch start) hits this.

Symptom 2 — Android push, one extra copy per pass, 285 copies so far

The phone's staging db (poco_f8_ultra/41662faa-…/test.db, bucket aw-stopwatch) contains 286 rows all at 2026-09-14 15:38:02: 285 identical {"label":"Testing","running":false} with duration 182.639 s, plus one {"label":"Testing","running":true} with duration 0. The phone's own datastore has one stopwatch; the push (sync_run, Push) re-inserted the finished event on each 5-minute pass for about a day. Mechanism: two rows share the newest timestamp; get_events(limit=1) returns an arbitrary one of the tie (here the dur-0 running:true row), so resume_at = that timestamp, the inclusive fetch returns both rows, and the finished one is inserted again. Each pass also makes the tie larger.

Pulling that bucket to the desktop then multiplies it: Resuming at 2026-09-14T15:38:02Synced 285 new events on the second pass (266 → 551 rows locally, 1 distinct timestamp).

Fix shape

The cursor cannot be made exact by choosing a better timestamp — ties and zero durations are legal. Idempotent insert at the boundary is what is needed:

  1. Fetch source events from resume_at inclusive (as now), but before inserting, load destination events in [resume_at, resume_at + max_duration_in_batch] and skip any source event whose (timestamp, duration, data) already exists there. Cheap: the window is tiny.
  2. For the running:truerunning:false stopwatch transition (same timestamp, changed data/duration) the correct outcome is a replacement, not a second row — that is the feat(sync): reconcile owner-originated event edits #678 reconcile path (owner-originated edit); worth a test that a stopwatch stop propagates as an edit, not an insert. Related design thread: aw-sync does not pick up manual event edits aw-android#253.
  3. Cleanup for existing installs: a one-off dedupe of exact-duplicate rows in -synced-from- buckets and in Android staging dbs (285 copies on the phone will otherwise keep re-importing). Android side: the phone's staging is derived data — re-staging from its local db after the fix would drop the copies.

Regression tests: newest event with duration 0 → second pass imports 0; two source events sharing the newest timestamp → second pass imports 0.

cc @TimeToBuildBob — this one matters more than #709 for data quality: it silently corrupts every synced bucket over time.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions