The One-Millisecond Fix
ActivityWatch's sync daemon was re-importing the same stopwatch event 293 times. The fix was a single-millisecond adjustment to a timestamp query. Here's why it needed to be exactly that.
ActivityWatch's sync daemon was re-importing the same stopwatch event 293 times. The fix was a single-millisecond adjustment to a timestamp query. Here's why it needed to be exactly that.
ActivityWatch’s sync daemon had been quietly duplicating a stopwatch event every five minutes. By the time we found the cause, that one event had accumulated 293 copies.
The fix was a one-line change: subtract one millisecond from a timestamp. But understanding why that fix is correct required discovering that two layers of the same stack silently disagree about what “events starting at T” means.
Background
aw-sync is the Rust daemon that replicates events between ActivityWatch instances — typically from a phone to a desktop. It uses a resume cursor: the timestamp of the last imported event. On resume, it queries the destination for events at the boundary (to build a fingerprint set for deduplication), then imports from the source anything not in that set.
The relevant PR was ActivityWatch/aw-server-rust#713, fixing a related boundary problem. Erik reviewed it against his live instance and noticed one event was still slipping through: a running: true stopwatch event with zero duration — the exact shape of a timer that’s still ticking when the sync runs.
The cursor was sitting at T = 2026-09-14T15:38:02.670Z. According to the existing test suite, that T should work. Erik’s live instance said otherwise.
The measurement
Erik queried his live server (Python aw-server, backed by peewee/SQLite):
| query | rows returned | durations |
|---|---|---|
start=T |
550 | 182.639s only |
start=T, limit=1000 |
550 | 182.639s only |
start=T-1ms |
552 | 182.639s × 550, 0.0s × 2 |
| no params | 552 | both |
Two events were invisible when queried with start=T exactly. Both had duration 0.
Why
The Python aw-server implementation uses peewee’s ORM. Its get_events query translates to roughly:
SELECT * FROM events WHERE endtime > start AND timestamp >= start
For a zero-duration event at T: endtime == timestamp == T. The condition endtime > start evaluates to T > T, which is false. The event is excluded.
The Rust aw-datastore, used directly in tests, uses endtime >= start. For the same event: T >= T is true. Included.
So the fingerprint query — which used start = boundary_ts — worked fine in tests (direct datastore, inclusive) but missed the zero-duration event in production (HTTP, strict). The event never entered the fingerprint set, so every sync pass saw it as new and imported another copy.
The tests passed because they operated against the right layer. Production failed because the sync daemon talks to a different layer with a different contract.
The fix
// Before
let boundary_events = client.get_events(
bucket_id,
Some(sync_params.start),
None,
None,
).await?;
let fingerprints: HashSet<_> = boundary_events
.iter()
.filter(|e| e.timestamp == boundary_ts) // only events exactly at boundary
.map(|e| (e.timestamp, OrderedFloat(e.duration.as_secs_f64())))
.collect();
// After
let boundary_events = client.get_events(
bucket_id,
Some(boundary_ts - Duration::milliseconds(1)), // -1ms: inclusive under both semantics
None,
None,
).await?;
let fingerprints: HashSet<_> = boundary_events
.iter()
.map(|e| (e.timestamp, OrderedFloat(e.duration.as_secs_f64()))) // fingerprint everything
.collect();
Two changes, both required:
-
Query
start = T - 1ms— this shifts the window back just enough that a zero-duration event at T satisfiesendtime > starteven under strict semantics (T > T-1msis true). The fix is correct under both the strict and inclusive interpretations. -
Remove the
timestamp == boundary_tsfilter — the original filter restricted the fingerprint set to events at exactly T. That was the wrong model: what matters is “events the destination already has that could overlap with what we’re about to import,” which is everything in the overlap window. With exact-match fingerprints (timestamp + duration pair), a wider window produces no false deduplication — a fingerprint from 12:34:00.999 cannot match an event at 12:34:01.000.
The new test adds a pre-populated destination with both boundary shapes and verifies that a second sync pass imports zero events.
What this illustrates
The bug wasn’t in the sync logic. It wasn’t in the storage layer. It was in the implicit assumption that a query parameter means the same thing across every interface to the same underlying data.
aw-datastore and aw-server are both correct within their own contracts. The sync daemon, sitting above both, assumed a single contract and was wrong.
This is the category of bug that emerges from layered systems where each layer has reasonable local behavior but the cross-layer contract is never written down. The Python server’s strict endtime > start isn’t a bug — it’s a design decision (arguably a reasonable one, since “events starting at T” often implies the cursor shouldn’t re-read its own position). The problem is that the sync daemon was querying through this layer without knowing the decision existed.
The fix is robust: -1ms is small enough to be harmless (no real event has sub-millisecond resolution in this system) and covers both interpretations. Writing it down in a comment ensures the next person reading the code understands why the query doesn’t use boundary_ts directly.
293 duplicates, 293 days of sync passes, one millisecond.