Skip to content

fix(api): invalidate the heartbeat cache when events are inserted - #178

Merged
ErikBjare merged 4 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/create-events-invalidate-heartbeat-cache
Oct 10, 2026
Merged

ErikBjare merged 4 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/create-events-invalidate-heartbeat-cache

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Bug

ServerAPI.create_events does not invalidate self.last_event[bucket_id]. A heartbeat that follows an insert therefore merges into the cached pre-insert event. When its data matches that event and it falls within pulsetime, replace_last() writes the merge over the newest stored event, which is one of the inserted ones. That event's data is lost.

heartbeat {a} @ t      (pulsetime 10)
insert    {b} @ t+1s
heartbeat {a} @ t+2s
master:   [{a} @ t, 2.5s], [{a} @ t, 0.5s]          ({b} is gone)
fixed:    [{a} @ t], [{b} @ t+1s], [{a} @ t+2s]

aw-server-rust already does this: its datastore worker resets last_heartbeat in the InsertEvents arm.

Why now

I found this while building ActivityWatch/aw-client#128, which bulk-inserts a drained queue backlog between heartbeats. On v0.13.2, queueing a,b,c,a lost c. That PR works around the bug client-side by only bulk-inserting when the run is longer than pulsetime. This PR fixes the bug itself, for any client that mixes inserts and heartbeats.

This is related to, but separate from, #177 (replace by id). With only #177, the stale event is not overwritten but instead stretches over the inserted ones, so the two events overlap. With this PR the heartbeat re-reads the real last event.

Tests

  • New test_heartbeat_after_insert_does_not_merge_into_stale_event (1 and 2 inserted events). Both cases fail on master because the inserted events are lost; both pass with the fix.
  • pytest tests --deselect tests/test_client.py: 104 passed. The three benchmark-fixture tests need pytest-benchmark, which my local env lacks; CI has it.

@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Fixes heartbeat cache invalidation in event operations.

The PR should not merge until CSV exports preserve unambiguous access to event data whose keys match metadata columns.

Summary

The PR invalidates cached heartbeat state after event or bucket changes, and adds bucket metadata handling and CSV export.

  • Regression tests cover heartbeats following inserts and deletion.
  • CSV export supports filtered downloads, expanded data columns, and a JSON fallback for wide data; reserved-name collisions still need attention.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Event insert or deletion] --> B[Invalidate cached last heartbeat]
  B --> C[Next heartbeat reads stored last event]
  C --> D[Merge or insert]
Loading

Reviews (2) · Last reviewed commit: "test(api): compare parsed timestamp, bla..."

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable — 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.

@TimeToBuildBob

TimeToBuildBob commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

This PR invalidates the ServerAPI.last_event heartbeat cache in create_events, delete_event, and delete_bucket so that a subsequent heartbeat re-reads the stored last event instead of merging into a stale cached one. It adds two tests covering heartbeat-after-insert and heartbeat-after-delete scenarios.

Needs a look — P2 only

Confidence 4/5

1 finding · ⚠️ 1 P2

⚠️ P2 medium — tests/test_server.py:334

The new test test_heartbeat_after_insert_does_not_merge_into_stale_event asserts that after inserting events and sending a heartbeat with the same data as the first heartbeat, the final event list contains the inserted events and the new heartbeat as a separate event. However, the test does not verify that the heartbeat actually merged into the correct last event when the data matches. In the n_inserted=2 case, the inserted events have labels 'inserted-0' and 'inserted-1', and the final heartbeat has data 'a'. Since the last stored event before the final heartbeat is 'inserted-1', the heartbeat data differs, so it inserts a new event. The test would pass even if the cache were not invalidated, because the cached last_event is the first heartbeat 'a' at t, and the final heartbeat 'a' at t+3 would merge into that cached event, producing a merged event with duration 1.0 and timestamp t, and then replace_last() would overwrite the stored last event (which is 'inserted-1'), losing it. The test asserts the inserted events are present, so it would fail on master. But the test does not check the duration of the first event after the final heartbeat; it only checks all durations are 0.5. If the cache were not invalidated, the merged event would have duration 1.0, so the assertion [e['duration'] for e in events] == [0.5] * (n_inserted + 2) would fail. So the test does catch the bug. However, the test's assertion on labels is order-dependent: it sorts by timestamp, and the final heartbeat has timestamp t+1+n_inserted, which is after the inserted events, so the order is correct. The test is valid.

How this was verified: Traced the test flow. Without the fix, the final heartbeat merges into cached event 'a' at t, producing duration 1.0, and replace_last overwrites the last inserted event. The duration assertion would fail because the merged event has duration 1.0, not 0.5. So the test is not vacuous.

Consensus: 2/3 passes agreed — pass 2 looked and disagreed
Distinct keys: 1 (general)

Files changed (2) — the diff as I read it
  • aw_server/api.py — Adds self.last_event.pop(bucket_id, None) calls in delete_bucket, create_events, and delete_event to drop the cached heartbeat when writes change stored events.
  • tests/test_server.py — Adds parametrized test for heartbeat after insert and a test for heartbeat after delete, asserting event labels, durations, and timestamps.
Previous review passes
commit score findings engine when
039b9967d8fc 3/5 2 llm 2026-10-02 06:03 UTC
fbe982324cc2 5/5 0 llm 2026-10-02 11:02 UTC

Reviewed 714aa270a4f0 · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 158s · 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 tests/test_server.py
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed the P1 from the review: delete_event and delete_bucket now also invalidate the heartbeat cache (self.last_event.pop(bucket_id, None)), same class of stale-cache hazard as the inserts fixed earlier. Added a regression test where a heartbeat whose data matches the deleted cached event must insert as a new event instead of merging into the deleted one. 6a86b2f

@TimeToBuildBob
TimeToBuildBob force-pushed the fix/create-events-invalidate-heartbeat-cache branch from fbe9823 to 25330e2 Compare October 6, 2026 10:43
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto current master and resolved the additive conflict with the newly merged CSV-export tests, preserving both test blocks. Force-pushed 25330e2.

Local verification: the 3 heartbeat-after-insert/delete cases pass; Black and Ruff pass. Fresh CI is running.

create_events left ServerAPI.last_event untouched, so a heartbeat after an
insert merged into the stale pre-insert event and replace_last() overwrote
the newest inserted event. aw-server-rust already invalidates its cache on
insert (aw-datastore worker, InsertEvents).

Git-Session-Id: 3ed7
Deleting an event that was also the cached heartbeat last_event left
self.last_event pointing at a deleted event. A subsequent heartbeat with
matching data merged into the deleted event and replace_last() then
operated on whatever event was now stored last (or an empty bucket).
Pop the cache in delete_event and delete_bucket, same as create_events,
and add a regression test.

Git-Session-Id: c76bacd2-1c05-56ff-83b6-d9353e6533c5
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/create-events-invalidate-heartbeat-cache branch from 25330e2 to 714aa27 Compare October 6, 2026 12:48
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased again onto master after #180 merged and preserved both the new bucket-metadata regression and this PR's heartbeat-cache regressions. Force-pushed 714aa27.

Local verification: 112 passed (14 client-integration tests deselected); pinned CI Ruff 0.15.22 and Black 23.3.0 pass; git diff --check passes. Fresh CI is running.

@ErikBjare

Copy link
Copy Markdown
Member

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Greptile's 4/5 here is driven by one concern: CSV export columns colliding with data keys named id/timestamp/duration. That code (aw_server/csv_export.py) came in with #179 and is not in this PR's diff, which only touches aw_server/api.py and tests/test_server.py. The summary also credits this PR with "bucket metadata handling and CSV export", which are #180 and #179. It seems to have reviewed against a stale base.

I've made no change on this branch. CI is green on 714aa27, and the summary has no findings for the heartbeat-cache change itself.

The collision is real but small. A data key named id produces a second id header, so the columns are ambiguous by name, though not by position. #179 deliberately matches aw-server-rust's column layout, so fixing it (e.g. a data. prefix on colliding keys) means changing both servers. I'll handle that separately and not hold this PR on it.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

The CSV column collision is now fixed separately in both servers, using the same scheme: #184 and ActivityWatch/aw-server-rust#795. If a data key collides with an earlier header name, it gets a data. prefix.

Comment thread tests/test_server.py
@ErikBjare
ErikBjare merged commit 384b122 into ActivityWatch:master Oct 10, 2026
7 checks passed
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.

2 participants