Repository navigation
fix(aw-sync): replace destination-as-marker cursor with per-source rowid cursor - #789
TimeToBuildBob wants to merge 19 commits into
Conversation
|
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
|
@greptileai review |
Review convergence summary — commit
|
| 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.
Review convergence summary — head
|
| 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:
- Continued activity stops growing: the known trailing-event heartbeat-extension gap, tracked in aw-sync: rowid cursor misses heartbeat extensions to trailing open event #790 (Greptile acknowledged it)
- Peers pull the wrong database: false positive, retracted (
.cursors/dot-dir is skipped by both walkers)
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.
|
@greptileai review |
2be8333 to
be89462
Compare
|
@greptileai review |
|
@greptileai review |
🤖 AI code reviewReplaces 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 findingsConfidence 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.
This is a How this was verified: static preflight: fix-commit + touched-files scan (rule 7) Files changed (8) — the diff as I read it
Previous review passes
Reviewed Maintainer commands
|
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
|
@greptileai review |
|
@greptileai review |
|
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. |
…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
|
@greptileai review |
|
Fixed in `1133053`. The collision: Fix: encode |
Status update — 2026-10-08All CI checks pass. Two P1 findings addressed since last review: Finding 1 (timestamp-path reconcile bug, commits Finding 2 (cursor_key collision, commit 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. |
…, 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
|
Property-based conformance harness for aw-sync, drafted against this branch: One thing it surfaced that matters for this PR's review: the rowid cursor path is unreachable from On the timestamp path, same result at |
|
Convergence adjudication deferred (freshness rechecked 2026-10-10): current head is |
…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
913f6b5 to
6622f9c
Compare
|
Rebased onto current master ( The only conflict was in the datastore response enum: preserved master's Local verification: |
…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
|
Convergence adjudication deferred (freshness recheck, 2026-10-11): current head is State at
Awaiting the automatic re-review of |
Problem
sync_oneused 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.dbDatastore next to the sync database.Key format:
sync.cursor.{src_device_id}.{bucket_id}SQLite
events.idis monotonically increasing in insertion order.WHERE id > last_cursor_rowidcatches 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-datastoreget_events_since_rowid(bucket_id, since_rowid, limit)toDatastoreInstanceandDatastorepublic API (Command::GetEventsSinceRowid).aw-sync/accessmethodget_events_since_rowidtrait method returningNoneforAwClient/ HTTP peers (rowid queries are not supported over HTTP).Datastoreoverride returnsSome(…).aw-sync/syncopen_or_create_cursor_ds,cursor_db_root,cursor_key.cursor_dsis threaded throughsync_run→pull_from_remotes→sync_datastores_with_cursor→sync_one.sync_one: batches events since the stored cursor, persistsmax(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 newsync_datastores_with_cursorcarries the cursor parameters.aw-query(incidental fix)#[allow]attributes introduced in 7ed5bb8 (clippy::block_scrutineeduplicated acrosslib.rsandparser.rs) that blockedcargo clippy --deny warnings.Tests
New
rowid_cursor_testsmodule inaw-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