Skip to content

fix(aw-sync): replace destination-as-marker cursor with per-source rowid cursor - #789

Open
TimeToBuildBob wants to merge 19 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-cursor-rowid
Open

TimeToBuildBob wants to merge 19 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-cursor-rowid

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Problem

sync_one used the newest event end-time in the destination bucket as its resume cursor.
This permanently loses late-arriving events: any event inserted into the source after the initial sync — even one with an old timestamp — is silently skipped on every subsequent pull because its timestamp falls before the stored cursor.

Fixes #696.

Solution

Replace the destination-end-time cursor with a per-source rowid cursor stored in a separate cursors.db Datastore next to the sync database.

Key format: sync.cursor.{src_device_id}.{bucket_id}

SQLite events.id is monotonically increasing in insertion order. WHERE id > last_cursor_rowid catches every inserted event regardless of its timestamp, so late-arriving backfill and out-of-order inserts are picked up on the next pull.

Changes

aw-datastore

  • Add get_events_since_rowid(bucket_id, since_rowid, limit) to DatastoreInstance and Datastore public API (Command::GetEventsSinceRowid).

aw-sync/accessmethod

  • Add default get_events_since_rowid trait method returning None for AwClient / HTTP peers (rowid queries are not supported over HTTP).
  • Datastore override returns Some(…).

aw-sync/sync

  • Helpers: open_or_create_cursor_ds, cursor_db_root, cursor_key.
  • cursor_ds is threaded through sync_run → pull_from_remotes → sync_datastores_with_cursor → sync_one.
  • Rowid fast-path in sync_one: batches events since the stored cursor, persists max(rowid) after each batch. Falls through to the timestamp-based path when the source does not support rowid queries (HTTP peers) or the cursor DB is unavailable.
  • sync_datastores (public API) is unchanged; the new sync_datastores_with_cursor carries the cursor parameters.

aw-query (incidental fix)

  • Remove duplicate #[allow] attributes introduced in 7ed5bb8 (clippy::block_scrutinee duplicated across lib.rs and parser.rs) that blocked cargo clippy --deny warnings.

Tests

New rowid_cursor_tests module in aw-sync/src/sync.rs:

  • late_arriving_backfill_syncs_on_second_pull: inserts 3 events, syncs (establishes cursor at rowid 3), inserts a backfill event at T=5s and a new event at T=40s, syncs again — destination ends up with all 5 events.
  • cursors_are_device_scoped: two source devices each contribute 2 events to independent cursors; verifies buckets accumulate the correct counts after two pulls.

Test run

test result: ok. 74 passed; 0 failed; 0 ignored
  (65 lib tests + 9 internal tests + 24 integration tests + 3 roundtrip tests)

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk] Replaces sync cursor storage and event-fetching logic.

Fix the collision in cursor_key before merging, so distinct sources cannot overwrite each other’s saved progress.

Findings

  1. P1 Different sources share saved progress ▶

Summary

This PR replaces timestamp-based pull progress with saved row IDs for file-backed sources, so newly inserted activity can sync even when its timestamp is old.

  • Adds row-ID reads, saved progress, replay checks, and tests.
  • Adds unclipped event reads through the Rust client and server.
  • Since the last review, changes cursor-key escaping and restores clipped reconciliation for timestamp-only copies.
  • The new escaping can make distinct source IDs share saved progress.
  • TimeToBuildBob accepted the destination durability gap and deferred a server-side commit guarantee.
  • TimeToBuildBob accepted heartbeat-extension limits and deferred refreshing the trailing event.
  • TimeToBuildBob accepted recovery of pre-upgrade backfills as separate work tracked in aw-sync: rowid upgrade bootstrap does not recover backfills lost under the old timestamp cursor #793; bootstrap progress now saves after a successful timestamp pass.
  • TimeToBuildBob accepted the exact-same-timestamp edge case as outside normal watcher use and deferred a stronger identity check.
  • TimeToBuildBob accepted wide destination reads and deferred bounded reads.
  • TimeToBuildBob disputed cursor-file discovery and overlapping-event deletion; the threads withdrew those claims, and the current code also uses unclipped reads for deletion checks.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Source[Peer database] --> Read[Read rows after saved ID]
    Cursor[(Hidden cursor database)] --> Read
    Read --> Compare[Check destination copies]
    Compare --> Write[Copy events to destination]
    Write --> Save[Save highest scanned ID]
    Save --> Cursor
    Fallback[Timestamp-only copy] --> Destination[Local activity server]
    Write --> Destination
Loading

Reviews (10) · Last reviewed commit: "fix(sync): clipped source in reconcile f..." · Reviewed by Greptile

Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-datastore/src/datastore.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.51020% with 60 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.48%. Comparing base (656f3c9) to head (472dfa1).
⚠️ Report is 199 commits behind head on master.

Files with missing lines Patch % Lines
aw-sync/src/sync.rs 82.63% 29 Missing ⚠️
aw-client-rust/src/lib.rs 0.00% 13 Missing ⚠️
aw-sync/src/accessmethod.rs 50.00% 8 Missing ⚠️
aw-datastore/src/datastore.rs 81.25% 6 Missing ⚠️
aw-datastore/src/worker.rs 84.61% 2 Missing ⚠️
aw-server/src/endpoints/bucket.rs 66.66% 1 Missing ⚠️
aw-sync/src/sync_wrapper.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #789       +/-   ##
===========================================
+ Coverage   70.81%   84.48%   +13.66%     
===========================================
  Files          51       82       +31     
  Lines        2916    11763     +8847     
===========================================
+ Hits         2065     9938     +7873     
- Misses        851     1825      +974     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Review convergence summary — commit 4cc9241

All 7 Greptile findings from the prior review round (on af388b1) have been addressed:

Finding Status
P1 Pulls duplicate existing activity Fixed — bootstrap pass detection + per-page cursor save
P1 Continued activity stops growing Acknowledged — known tradeoff; filed #790 as follow-up
P1 Deleted copies cannot be restored Fixed — reset cursor to 0 when destination is empty
P1 Requested dates are ignored Fixed — sync_spec.start filter applied in rowid fast path
P1 Peers pull the wrong database False positive — cursors stored under .cursors/ dot-directory, skipped by both walkers
P1 Damaged rows block later activity Fixed — get_events_since_rowid returns max_scanned_rowid to advance past corrupt rows
P2 Sync paths can drift apart Fixed — sync_datastores delegates to sync_datastores_with_cursor

Greptile re-review on 4cc9241 completed: pass. CI checks queued (pending runner availability).

Domain risk: the heartbeat-extension limitation (#790) is a bounded known tradeoff, not a regression blocking the core #696 correctness fix.

@TimeToBuildBob

TimeToBuildBob commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Review convergence summary — head 2be8333 (updated)

Round 2 of Greptile review is settled. No blocking findings remain.

Finding Classification Status
P1 Date filter drops crossing events Real bug Fixed in 2be8333: the filter is now timestamp + duration > start, the same overlap rule the timestamp path uses
P1 Bootstrap skips uncopied activity Real, but pre-existing (#696): pre-upgrade backfills were already lost under the old cursor Not a regression. One-time source-history reconcile tracked in #793; Greptile agreed it doesn't block this PR

From round 1:

CI on 2be8333: clippy, format, ubuntu and windows pass. macOS, Android and coverage were still running when this was written.

Ready for maintainer review/merge once CI finishes. This PR fixes the core #696 issue: late-arriving and backfilled events are no longer silently lost. Known follow-ups: #790 and #793.

@ErikBjare

Copy link
Copy Markdown
Member

@greptileai review

Comment thread aw-sync/src/sync.rs Outdated
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-cursor-rowid branch from 2be8333 to be89462 Compare October 6, 2026 20:37
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-sync/src/sync.rs
@TimeToBuildBob

TimeToBuildBob commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

Replaces the destination-newest-timestamp resume cursor in aw-sync pulls with a per-source SQLite rowid cursor persisted in a dedicated cursors.db, adding rowid-range datastore queries, an unclipped events HTTP parameter, and a large rowid-cursor test module. Introduces sync_run_with_cursor_root and threads a cursor Datastore through the pull path while keeping the public sync_datastores signature unchanged.

Safe to merge — no P0/P1 findings

Confidence 5/5

✅ No thread-worthy findings. Advisory notes follow; they are retained without opening review threads.

1 advisory finding (summary-only, not scored)

These P2 guard, heuristic, trade-off, or documentation claims are retained for judgment without opening review threads.

⚠️ P2 medium — aw-sync/src/sync.rs

This is a fix(...) PR but no test files are included in the diff. Erik's feedback: 'where is the repro & fixes they are supposed to catch' (gptme#3441), 'that measurement should come with a regression test' (gptme#3446). Add a test that would have caught this bug. (Advisory: Erik merged all such PRs but consistently requested tests.)

Add a test file that reproduces the bug before the fix and passes after it.

How this was verified: static preflight: fix-commit + touched-files scan (rule 7)

Files changed (8) — the diff as I read it
  • aw-client-rust/src/blocking.rs — Adds a blocking proxy_method for get_events_unclipped mirroring the async client method.
  • aw-client-rust/src/lib.rs — Adds async get_events_unclipped that appends unclipped=true to the bucket events request.
  • aw-datastore/src/datastore.rs — Adds get_events_since_rowid and get_max_event_rowid to DatastoreInstance, querying events by rowid order with corrupt-row handling.
  • aw-datastore/src/worker.rs — Adds EventPage response variant and GetEventsSinceRowid/GetMaxEventRowid commands and public Datastore methods.
  • aw-server/src/endpoints/bucket.rs — Adds optional unclipped query parameter to the events GET endpoint and dispatches to get_events_unclipped when true.
  • aw-sync/src/accessmethod.rs — Adds get_events_unclipped, get_events_since_rowid, and get_max_event_rowid to the AccessMethod trait with default None and Datastore/AwClient overrides.
  • aw-sync/src/sync.rs — Adds cursor datastore helpers, threads cursor state through sync_one, and adds a large rowid-cursor test module.
  • aw-sync/src/sync_wrapper.rs — Switches pull_db to sync_run_with_cursor_root so cursor storage lives at the sync root instead of the host subfolder.
Previous review passes
commit score findings engine when
47558010b2d0 3/5 2 llm 2026-10-07 19:59 UTC
dff58323fc70 3/5 1 llm 2026-10-07 21:23 UTC
084af979de66 2/5 2 llm 2026-10-07 21:52 UTC
fbf23ef75d98 5/5 0 llm 2026-10-07 23:43 UTC
113305354470 5/5 0 llm 2026-10-09 04:55 UTC
53dd8b403a4a 2/5 2 llm 2026-10-10 10:04 UTC
913f6b5efce1 5/5 0 llm 2026-10-10 12:32 UTC
6622f9ce9439 2/5 3 subscription 2026-10-10 15:00 UTC
472dfa1ebb00 4/5 3 llm 2026-10-11 03:24 UTC
472dfa1ebb00 4/5 1 llm 2026-10-11 04:04 UTC

Reviewed 472dfa1ebb00 · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 704s · about this reviewer

Maintainer commands

@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.

Comment thread aw-sync/src/sync.rs
TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Oct 7, 2026
reconcile_updated_events only sees the EDIT_RECONCILE_LOOKBACK window and
matches on (timestamp, duration) identity, so a stale dest row survived its
replacement when the edited event was older than the lookback window, or
when the edit changed the duration (identity no longer matches). The rowid
fast path now probes dest for rows starting at a fetched chunk timestamp
holding different data and deletes them before inserting the replacement.

Covers both review findings on ActivityWatch#789: edits of events older than the 7-day
lookback (duplicate rows) and longer replacements (stale short row kept).

Regression tests: edit_older_than_lookback_is_not_duplicated,
longer_replacement_is_not_duplicated — both fail without the fix.

Git-Session-Id: 178f8251-cbc1-5126-97ff-7b84aa7f11b2
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable (Greptile 5/5) — waiting only on a maintainer click.

This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted.

Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Oct 7, 2026
…or, collision-free cursor key

Review findings on ActivityWatch#789 (abab9a4):

- e26d71ce (P1): the rowid path's stale-row deletion keyed on 'some chunk
  event at this timestamp differs' deleted legitimate destination rows that
  merely share a timestamp with a new chunk event (distinct coexisting
  events). Reconcile against the source instead: a dest row is stale only if
  the source currently has no event with the same (end_time, data) at that
  timestamp. RED/GREEN regression: distinct_same_timestamp_events_are_not_deleted.
- df0de192 (P1): the upgrade bootstrap persisted the cursor to source-max
  before the timestamp-path pass it prefaces, so a partway failure permanently
  skipped the not-yet-copied events. The cursor is now persisted only after
  the timestamp path succeeds; on failure the next pass re-bootstraps.
- e597c988 (P2): cursor keys joined device_id and bucket_id with '.' could
  collide for user-controlled ids; join with U+001F instead.

Git-Session-Id: 2722d499-fb70-5f87-a6cf-de6d4670977e
Comment thread aw-sync/src/sync.rs
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-sync/src/sync.rs
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Fixed in `1133053`.

The collision: enc replaced \u{1f} → %1F but did not escape literal %, so cursor_key("A%1F", "B") and cursor_key("A\u{1f}", "B") produced the same key.

Fix: encode % → %25 before \u{1f} → %1F, making "A%1F" → "A%251F" while "A\u{1f}" → "A%1F" — distinct. Added cursor_key_no_collision_between_percent_escape_and_unit_separator test to rowid_cursor_tests to lock in the invariant.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Status update — 2026-10-08

All CI checks pass. Two P1 findings addressed since last review:

Finding 1 (timestamp-path reconcile bug, commits 0a0af08): reconcile_updated_events used get_events_unclipped for all callers. For events edited with both a longer duration AND different data, the unclipped identity (timestamp, 60s) differed from the dest identity (timestamp, 5s), so reconcile skipped the replacement and left stale data in the head. Fixed by passing use_unclipped_source = cursor_ds.is_some() — the timestamp path uses clipped source, matching identities and triggering the data-change replacement. Regression test: timestamp_path_replaces_event_with_changed_duration_and_data.

Finding 2 (cursor_key collision, commit 1133053): enc replaced \u{1f} → %1F without escaping literal %, so cursor_key("A%1F", "B") and cursor_key("A\u{1f}", "B") produced the same key. Fixed by encoding % → %25 before \u{1f} → %1F. Regression test: cursor_key_no_collision_between_percent_escape_and_unit_separator.

Greptile trigger cap (8) was reached before the cursor_key fix could be scored. The last score (4/5) predates this fix; expecting 5/5 with fresh review.

TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Oct 8, 2026
…, and the shrunk zero-duration edit seed

On master: seed_793 (backfill behind cursor), seed_798 (second distinct
event at the same timestamp is dropped) and the shrunk zero-duration edit
case all fail; seed_790 passes (it targets the ActivityWatch#789 rowid path).

Git-Session-Id: 44eea101-0491-4e23-9343-b33a3ee55260
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Property-based conformance harness for aw-sync, drafted against this branch: TimeToBuildBob/aw-server-rust:feat/aw-sync-proptest-conformance (aw-sync/tests/conformance.rs, proptest over insert / heartbeat-extend / edit / delete / sync, one invariant: dest == source by (timestamp, duration, data) after push+pull). Not opening it as a PR until this lands.

One thing it surfaced that matters for this PR's review: the rowid cursor path is unreachable from aw-sync/tests/. sync_datastores delegates with cursor_ds = None, and sync_datastores_with_cursor is private, so an integration test can only drive the timestamp fallback. The in-crate #[cfg(test)] modules cover the rowid path, but any black-box harness (this one, or a future fuzz target) cannot. Suggest exporting sync_datastores_with_cursor (or a thin pub wrapper taking Option<&Datastore> for the cursor store) in this PR so the follow-up harness can run the same operation model through both paths.

On the timestamp path, same result at c535d77 (master) and 1133053 (this head), 0.2 s per run: seed_793 (backfill behind cursor) fails, a second distinct event at the same timestamp is dropped, and editing a zero-duration event after sync leaves the pre-edit row in dest (proptest-shrunk, 3 ops). Expected for the fallback path; noted so the harness's first green run is read against the rowid path, not this one.

Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs Outdated
@TimeToBuildBob

TimeToBuildBob commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Convergence adjudication deferred (freshness rechecked 2026-10-10): current head is 6622f9ce9439. The newest Bob AI review covers that head, but Greptile’s last reviewed commit remains 0a0af08b, preceding the subsequent fixes and rebase. Under this adjudication’s freshness gate, I cannot classify or dismiss findings until review provenance covers the current head. No findings resolved, no merge recommendation, and no additional Greptile round requested. All seven current-head CI check runs pass. The three current-head AI-review findings remain open for the next review/fix reaction; this deferral does not dismiss them or declare the PR merge-ready.

TimeToBuildBob and others added 18 commits October 10, 2026 13:06
…wid cursor

The old sync_one resume logic used the newest event end-time in the
destination bucket as a cursor.  This permanently loses late-arriving
events: any event with an old timestamp but a new SQLite rowid (inserted
after the cursor was recorded) is silently skipped on every subsequent
pull.

Replace with a rowid-based cursor stored in a separate `cursors.db`
Datastore next to the sync database.  Key format:
  sync.cursor.{src_device_id}.{bucket_id}

Changes:
- aw-datastore: add `get_events_since_rowid(bucket_id, since_rowid,
  limit)` to DatastoreInstance + Datastore public API (Command::GetEventsSinceRowid).
- aw-sync/accessmethod: add default `get_events_since_rowid` trait
  method (returns None for AwClient / HTTP peers) with a Datastore
  override that returns Some(…).
- aw-sync/sync: add helpers open_or_create_cursor_ds, cursor_db_root,
  cursor_key; thread cursor_ds through sync_run → pull_from_remotes →
  sync_datastores_with_cursor → sync_one; add rowid fast-path in
  sync_one that batches new events and persists max rowid after each
  batch.
- aw-query: remove duplicate #[allow] attributes introduced upstream in
  7ed5bb8 (clippy::block_scrutinee duplicated across lib.rs + parser.rs)
  that blocked cargo clippy --deny warnings.

Tests added (rowid_cursor_tests module):
- late_arriving_backfill_syncs_on_second_pull: verifies that an event
  inserted after the initial sync (old timestamp, new rowid) is picked
  up on the next pull.
- cursors_are_device_scoped: two source devices each have independent
  cursors, so their buckets accumulate the right event counts.

Fixes ActivityWatch#696

Git-Session-Id: ee6c
P1 (bootstrap): add get_max_event_rowid to AccessMethod trait + Datastore impl;
    on first sync pass (saved_rowid=0, dest non-empty) bootstrap cursor to max
    source rowid and fall through to timestamp path this pass
P1 (retry-safety): persist cursor after every page batch, not just at end of loop
P2 (code dup): sync_datastores now delegates to sync_datastores_with_cursor
P3 (reset on empty dest): reset saved_rowid to 0 when eventcount_to_old == 0
P4 (sync_spec.start filter): filter events by sync_spec.start in rowid fast path
P5 (cursor hidden from discovery): use .cursors/ dot-dir prefix so peer-discovery
    walkers (both list_remote_dbs and find_remotes skip dot-dirs) never find it
P6 (corrupt rows stall): get_events_since_rowid returns (Vec<Event>, max_scanned_rowid)
    so the cursor always advances past corrupt rows; callers use max_scanned_rowid

Co-Authored-By: Bob <timeto@buildbob.com>
Git-Session-Id: 16b2ef11-2cf2-5c6a-ad78-f4eece8f2de4
Use overlap semantics (end > start) to match the timestamp path, so an
event spanning the requested start boundary is no longer dropped.

Git-Session-Id: 9344a3c7-715e-50d3-9c15-1fdabfa340f4
…wid path

Git-Session-Id: d064bf74-81e4-5f95-91d8-5e1b5730d501
…oot across pull modes

- reconcile_updated_events returns the source ids it placed on dest; the
  rowid loop skips them so an edited event (new source row above the
  cursor) is not inserted twice.
- Drop the cursor_db_root heuristic. sync_run stores cursors under
  sync_spec.path (the sync root for every CLI caller); pull_db, whose path
  is a host folder, passes the sync root explicitly via
  sync_run_with_cursor_root. Ordinary and --sync-db pulls now share one
  cursor store.
- Regression test: edited_event_is_not_copied_twice (fails without the fix).

Git-Session-Id: 291c625d-4008-524b-ab4e-a21f8c177350
reconcile_updated_events only sees the EDIT_RECONCILE_LOOKBACK window and
matches on (timestamp, duration) identity, so a stale dest row survived its
replacement when the edited event was older than the lookback window, or
when the edit changed the duration (identity no longer matches). The rowid
fast path now probes dest for rows starting at a fetched chunk timestamp
holding different data and deletes them before inserting the replacement.

Covers both review findings on ActivityWatch#789: edits of events older than the 7-day
lookback (duplicate rows) and longer replacements (stale short row kept).

Regression tests: edit_older_than_lookback_is_not_duplicated,
longer_replacement_is_not_duplicated — both fail without the fix.

Git-Session-Id: 178f8251-cbc1-5126-97ff-7b84aa7f11b2
…ent false deletions

get_events clips returned event timestamps to the query window. The stale-row
deletion loop in the rowid fast path used get_events, so an event spanning
10:00–10:10 fetched with start=10:05 was returned starting at 10:05 — matching
a chunk event at 10:05 by timestamp and triggering deletion of an unrelated dest
event.

Fix: add get_events_unclipped to the AccessMethod trait (default delegates to
get_events for backends that do not clip, e.g. AwClient over HTTP) and use it in
the stale-row loop so original timestamps are preserved for comparison.

Addresses Greptile P1 "Backfills erase overlapping activity" (r4205696147).

Git-Session-Id: c364
A pull that dies between inserting a page and persisting the cursor
replays that page on the next pull. reconcile_updated_events only covers
rows below the resume boundary (window ends exclusively at resume), and
the stale-row delete only fires on differing data, so a replayed
zero-duration row at the boundary was re-inserted as a duplicate.

Skip chunk events whose (end_time, data) fingerprint already exists at
the destination, mirroring the timestamp path's boundary fingerprint
doctrine. Also close the cursor datastore on the pull error path so a
long-lived daemon does not leak the connection across failed passes.

Git-Session-Id: 309fa6bd-4fc2-5827-b087-4a98b9713b66
Three follow-ups from the in-band review of the replay dedup:

- aw-server: add an unclipped query parameter to the bucket events
  endpoint that returns original stored timestamps instead of clipping
  to the query window. The HTTP path clipped server-side, so the
  AccessMethod default for get_events_unclipped silently returned
  clipped data, breaking dedup fidelity and risking false stale-row
  deletes on the pull path.
- aw-client-rust: add get_events_unclipped (async + blocking) passing
  the parameter; older servers ignore it and behave as before.
- aw-sync: reset the rowid cursor when the stored cursor exceeds the
  source bucket's max rowid — the source db or bucket was recreated and
  new rows below the stale cursor would otherwise be skipped forever.

Also force-commit the cursor rollback in the replay test so it is
hermetic against the cursor store's async commit.

Git-Session-Id: 309fa6bd-4fc2-5827-b087-4a98b9713b66
…or, collision-free cursor key

Review findings on ActivityWatch#789 (abab9a4):

- e26d71ce (P1): the rowid path's stale-row deletion keyed on 'some chunk
  event at this timestamp differs' deleted legitimate destination rows that
  merely share a timestamp with a new chunk event (distinct coexisting
  events). Reconcile against the source instead: a dest row is stale only if
  the source currently has no event with the same (end_time, data) at that
  timestamp. RED/GREEN regression: distinct_same_timestamp_events_are_not_deleted.
- df0de192 (P1): the upgrade bootstrap persisted the cursor to source-max
  before the timestamp-path pass it prefaces, so a partway failure permanently
  skipped the not-yet-copied events. The cursor is now persisted only after
  the timestamp path succeeds; on failure the next pass re-bootstraps.
- e597c988 (P2): cursor keys joined device_id and bucket_id with '.' could
  collide for user-controlled ids; join with U+001F instead.

Git-Session-Id: 2722d499-fb70-5f87-a6cf-de6d4670977e
… end_time, data)

A dest copy whose end_time was stored shorter than the source (e.g. clipped
by a previous timestamp-path sync) was falsely deleted by the stale-row check
when a new chunk event arrived at the same timestamp.  The check compared the
full (start, end, data) fingerprint, but the dest's end_time could differ from
the source's true end_time without the event being stale.

True edits always change the event data, so (start, data) is sufficient to
distinguish a stale dest row from a legitimate copy with a different duration.
Using end_time in the key caused data loss when:
  1. Source has event E at T with duration D (e.g. 3600s).
  2. Dest has a clipped copy of E at T with duration D' < D.
  3. A new chunk event arrives at T with different data.
  4. ts_set = {T}, so E's dest copy passes the timestamp guard.
  5. source_fps has (T, T+D, E.data) but dest copy has (T, T+D', E.data).
  6. Fingerprint mismatch -> dest copy deleted even though source still has E.

Adds regression test documenting the intended behaviour (clipped copy is
preserved when source still has an event with matching timestamp and data).

Git-Session-Id: b42e65ba-4cf2-5b81-84da-da88def1a904
…ey and force-commit the cursor

The rowid replay dedup keyed on (end_time, data), so a distinct source event
sharing end time and data with an already-copied event (10:05-10:10 inserted
after 10:00-10:10) was treated as a replay, skipped, and lost once the cursor
advanced past it. A replayed row is byte-identical to the dest copy, so the
full triple identifies replays without dropping overlapping events.

The cursor datastore commits asynchronously; force the commit after each
page so a crash after set_key_value cannot widen the replay window.

Git-Session-Id: cdd94031-99e2-4ad3-bf99-6d31c726a8bf
Three cooperating fixes for the rowid cursor path:

1. reconcile_updated_events: use get_events_unclipped for the source
   fetch. Previously, a duration-only replacement (same timestamp+data,
   longer duration) was clipped at `resume`, making its identity match
   the old destination copy. Reconcile then marked the source rowid as
   "on_dest" and the rowid path skipped it entirely, leaving the stale
   copy in place indefinitely.

2. reconcile stale deletion: guard against false deletion of clipped
   dest copies. With unclipped source, a new event E2 at the same
   (timestamp, duration) as a clipped E1 copy would cause reconcile to
   delete the clipped copy (it sees no matching source row in the group).
   The guard checks if any source event has the same (timestamp, data)
   regardless of duration; if so, the dest copy is a valid clipped
   replica and must not be deleted.

3. rowid stale-row check: detect duration-only replacements via the
   chunk. The SourceFingerprint intentionally excludes end_time to
   protect clipped dest copies. Duration-only replacements are detected
   via a second signal: if the chunk contains an event with the same
   (timestamp, data) as a dest row but a different end_time, the chunk
   is a replacement and the dest copy is stale.

Adds test duration_only_edit_is_not_duplicated covering the case where
a source event is deleted and re-inserted with a longer duration.

Git-Session-Id: a33770c6-763c-55eb-8245-103addd291a6
…sion

Bucket IDs are user-controlled strings; the server does not reject control
characters. A bucket ID containing U+001F would collide with the separator,
causing cursor key aliasing between different (device, bucket) pairs.

Percent-encode U+001F as %1F within each component before joining. For
normal watcher-generated IDs the key format is unchanged (no-op replace).

Git-Session-Id: 8ea1b5b9-7dcf-5326-a3f7-748879a70592
…sion test

For the rowid cursor path, reconcile_updated_events already used
get_events_unclipped. That is correct: unclipped preserves a duration-only
edit's identity so the rowid path (chunk_replaces) can handle it.

For the timestamp path (cursor_ds=None) or push path, unclipped source causes
identity mismatch for events edited with both a longer duration and new data:
the source identity is (timestamp, 60s) while dest holds (timestamp, 5s), so
reconcile finds no matching group and leaves the stale data in place. The
incremental pass then only adds the tail fragment, keeping old data in the head.

Fix: pass cursor_ds.is_some() as use_unclipped_source. With clipped source the
event's identity matches the dest copy (both see 5s, clipped to the resume
boundary), the data mismatch triggers the reconcile replacement, and the
incremental pass picks up the tail.

Add timestamp_path_replaces_event_with_changed_duration_and_data to
rowid_cursor_tests to lock in this regression.

Fixes ActivityWatch#789 (second part).

Git-Session-Id: 9439d2ab-ff72-5ca7-a5a9-41fae3f0e206
…ision

The previous encoding only replaced the unit-separator \u{1f} with %1F but
did not escape literal % characters. This caused a collision: a device ID
"A%1F" and a device ID "A\u{1f}" produced the same cursor key, letting two
distinct sources silently overwrite each other's saved progress.

Fix: percent-encode % → %25 before encoding \u{1f} → %1F, so that
"A%1F" encodes to "A%251F" while "A\u{1f}" encodes to "A%1F" — distinct.

Add cursor_key_no_collision_between_percent_escape_and_unit_separator to
rowid_cursor_tests to lock in the invariant.

Git-Session-Id: 9439d2ab-ff72-5ca7-a5a9-41fae3f0e206
…ng arrives

The duration-only replacement check deleted a dest row whenever the chunk
held an event with the same (timestamp, data) and a different end_time. A
distinct source sibling that only shares (timestamp, data) triggered it too,
deleting the dest copy of an event that is still in the source. Require
that the source no longer holds the dest row's exact interval before
treating it as replaced. This only narrows deletions.

Refs ActivityWatch#798

Git-Session-Id: 94e17b8f-5559-4ee8-9177-16e0d160dfe2
use_unclipped_source was gated on cursor_ds.is_some(), so the first
post-upgrade pull ran unclipped reconcile and then fell through to the
timestamp path. A duration+data edit left the stale dest head in place
and the deferred max-rowid cursor hid the replacement forever.

Gate unclipped source on the rowid fast path actually running (source
supports rowid AND not bootstrap). HTTP peers that fall through the
same way now also get clipped-source reconcile.

Adds bootstrap_timestamp_path_replaces_event_with_changed_duration_and_data.

Git-Session-Id: 0938d91d-cbdf-59dc-9865-70ee9e1de91f
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-cursor-rowid branch from 913f6b5 to 6622f9c Compare October 10, 2026 13:11
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (faeded2), pushed as 6622f9c. GitHub now reports mergeable / CLEAN.

The only conflict was in the datastore response enum: preserved master's FilteredEvents variant alongside this PR's EventPage variant. git range-diff confirms the 18 PR commits are retained; sync logic is unchanged by the rebase.

Local verification: cargo test -p aw-sync -p aw-datastore — 285 passed, 1 ignored; clippy with -D warnings and formatting checks pass. This includes the latest upgrade-bootstrap regression. New-head CI will run separately; no additional Greptile round requested.

Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs
…pt pages

When get_events_since_rowid returns an empty batch (all rows on a page
fail to parse) but still advances max_scanned_rowid, the loop broke out
without persisting the new cursor position.  The next pull then re-scanned
the same corrupt page in an infinite stall.

The comment at the break already stated the intended fix; this commit
implements it.

Git-Session-Id: d14a73a4-480f-4938-9c06-fcbadbfb603c
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Convergence adjudication deferred (freshness recheck, 2026-10-11): current head is 472dfa1ebb00, which postdates the newest Bob AI review (6622f9ce9439, 2026-10-10) and Greptile's last reviewed commit (0a0af08b, reviewed 2026-10-08). Under this adjudication's freshness gate I cannot classify findings against a head no review pass has seen.

State at 472dfa1ebb00:

  • 0 unresolved review threads — the three 2026-10-10 findings were adjudicated: two P1 known-tradeoff items replied+resolved, and the P2 empty-batch cursor-save bug fixed in 472dfa1.
  • All 7 CI checks pass; PR is MERGEABLE.
  • No merge recommendation is posted and no additional Greptile round is requested (Greptile is in backoff at the prior head; the bob-ai-review-sweep.timer re-reviews new heads automatically).

Awaiting the automatic re-review of 472dfa1; a later cycle can adjudicate once review provenance covers this head. Maintainer merge is still required (Bob is pull-only on ActivityWatch repos).

Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs
Comment thread aw-datastore/src/datastore.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

aw-sync: no stored cursor — resume-from-destination silently never syncs late-arriving events

2 participants