You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:02 → Synced 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:
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.
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.
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 twosyncpasses 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:sync_onesetsresume_sync_at = newest.timestamp + newest.duration; with duration 0 that is the newest event's own timestamp, and the source fetch withstart = resumeis 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, bucketaw-stopwatch) contains 286 rows all at2026-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-0running:truerow), soresume_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:02→Synced 285 new eventson 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:
resume_atinclusive (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.running:true→running:falsestopwatch 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.-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.