Repository navigation
feat(queue): bulk-insert unmergeable drained events instead of one heartbeat each - #128
TimeToBuildBob wants to merge 12 commits into
Conversation
|
|
@greptileai review |
|
@greptileai review |
|
@greptileai review |
Review convergence: the remaining findings are mutually exclusiveFour review rounds have now flagged each branch of one decision. They cannot all be satisfied at once:
Rounds 1–4 are consistent with each other only if the client can always determine whether a bulk insert was stored. It can't: What the code does now (
Transient and connection failures — the realistic cases — never enter this path: they retry and reconcile normally, so in practice neither the loss nor the duplicate branch is reached. What I'm asking forThe three properties are only jointly achievable if the ambiguity is removed, and removing it needs a server-side change (idempotency keys / dedup on I'm deliberately not re-triggering review again — the rounds are oscillating between mutually exclusive demands, which is diminishing returns. Leaving this for maintainer adjudication. |
c58f543 to
7448ebe
Compare
|
Status at I'm not pushing another fix round or re-triggering review. Taking one side reopens the other finding, and a real fix needs server-side idempotency for |
When aw-server is unreachable the request queue accumulates one heartbeat per commit interval. On reconnect they were dispatched one HTTP request at a time, so a long outage drained slowly and hammered the server just as it came back (issues ActivityWatch#32 and ActivityWatch#7). RequestQueue now pops queued requests in batches and pre-merges consecutive heartbeats for the same bucket client-side with aw_transform.heartbeat_merge before sending. The merged events still go to the heartbeat endpoint, so the server-side result is unchanged - a run of identical heartbeats that the server would have merged into one event now arrives as one request instead of dozens. Requests are held in an in-memory batch until the whole batch has been handled; a transient error retains and retries the batch, and re-sending an already-delivered heartbeat is a no-op on the server. A non-retryable client error drops only that request and keeps dispatching the rest. Test fills the queue with 200 mergeable heartbeats and asserts the drain uses a single request while the merged event still spans the whole range (no data loss). Git-Session-Id: b337
Git-Session-Id: b337
…, per-request retry Greptile review feedback on ActivityWatch#127: - **Order**: group by bucket, not by full endpoint, and only merge a contiguous run that shares one endpoint. Grouping by endpoint reordered a bucket's heartbeats when their pulsetimes differed (e.g. 10, 20, 10 was sent as 10, 10, 20), which changes the server-side timeline. - **Malformed endpoint**: `pulsetime=1..2` matched the regex but `float()` raised inside coalescing, outside the dispatch error handler, killing the worker. Parsing is now fallible and a malformed pulsetime falls back to sending the request verbatim. - **Partial retry**: dispatch one coalesced request per call and track the index, so a transient error retries only the failed request instead of replaying heartbeats that already reached the server. - **Shutdown**: because one request is dispatched per call, `disconnect()` observes `stop()` between requests instead of blocking on a whole batch. Tests: 21 passed (new: pulsetime-order preservation, malformed pulsetime, partial-retry-does-not-replay). Git-Session-Id: b337
…timing bound Review feedback: the 10k test only checked the first request's duration, so it would pass even if later batches lost heartbeats; assert the summed span across all posted events instead. Remove the wall-clock limit, which made a functional test sensitive to runner load. Git-Session-Id: b337
…ge contiguously Git-Session-Id: 3b002f8a-7967-5ade-8e69-6c86e374a155
Git-Session-Id: 7c62adb8-40f8-58a7-82c2-170ab3f2baaa
…vityWatch#125/ActivityWatch#130 - tests: pass tmp_path to _fresh_queue (ActivityWatch#130 made queues isolated per test) - RequestQueue.__init__: keep _queue_write_failing (ActivityWatch#124) after the batch state - wait_for_queue_empty (ActivityWatch#125): track the in-flight batch instead of _current - drop an import left unused by the rebase Git-Session-Id: 68f2e145-fcff-459d-8e16-c3e43269f5fa
…artbeat each After merging, a drained backlog whose events cannot merge (e.g. changing window titles) still cost one heartbeat request per event. Send the middle of each run via POST /buckets/<id>/events in chunks of 100 instead. - First and last event of a run still go to the heartbeat endpoint: the first so it merges into the server's existing last event, the last so the server's cached last heartbeat lands on the true last event. - Runs that fit within pulsetime stay heartbeat-only: aw-server (Python) does not invalidate its last_event cache on insert, so the final heartbeat could merge into the stale cached event and overwrite inserted ones. - An insert is not idempotent, so a retried insert (and inserts in the first batch after startup, which a crashed run may have delivered) first looks up the chunk's time range and only resends events the server lacks. Refs ActivityWatch#32 Git-Session-Id: 3ed7
Address review: a 4xx on a bulk insert now resends the chunk one event per request, so only the rejected event is dropped. A permanently failing pre-retry lookup sends the chunk anyway (possible duplicate beats dropping never-sent events); connection errors and retryable statuses still retry. Git-Session-Id: 3ed7
…opping it An unreadable pre-retry lookup cannot tell whether a bulk insert was already stored. Resending the chunk then duplicates stored events; dropping it loses events the server never got. Keep the request queued and retry the lookup later, so neither happens. Replaces the send-anyway fallback from f6dd56e. Tests: the duplicate case fails on the previous head (it resent the stored chunk) and passes now; the never-stored case stays queued and drains once the lookup succeeds. Git-Session-Id: f6dd56e
… the queue A permanently unreadable pre-retry lookup deferred the insert forever, blocking the run's last heartbeat and every later request. Retry the lookup up to MAX_RECONCILE_FAILURES times, then make progress without it: - The insert was already attempted and its response lost: assume it landed. Resending could duplicate stored events and inserts are not idempotent, so drop the retried insert (logged at ERROR) rather than block the queue. - Nothing was attempted this run (startup reconcile): the events may never have reached the server, so send the chunk rather than discard it. This keeps both invariants the earlier rounds established: never duplicate, never discard an unattempted insert, and now never block the queue. Git-Session-Id: 0f3c
…ityWatch#125/ActivityWatch#130 - send an empty heartbeat payload instead of dropping it: the post guard should only skip an insert reconciled down to nothing (caught by ActivityWatch#125's test_wait_for_queue_empty_timeout) - tests: pass tmp_path to _fresh_queue (ActivityWatch#130 made queues isolated per test) - tests: silence mypy method-assign on the refuse-then-restore stubs (CI runs make typecheck) Git-Session-Id: 68f2e145-fcff-459d-8e16-c3e43269f5fa
7448ebe to
9fe4e1f
Compare
|
Rebased onto the updated #127 (which is now on master). #125's |
Second half of #32 (follow-up to #127). Stacked on #127 → #115: GitHub can't base a PR on a fork branch, so this targets #115's branch and the diff includes #127. Review only the top commit,
1e819f5.Problem
#127 merges consecutive queued heartbeats, but events that can't merge (changing window titles) still cost one heartbeat request each. A backlog of 300 window changes drains in 300 requests.
Change
Each run of merged events for a bucket is sent as:
POST /buckets/<id>/events, in chunks of 100.The two hazards, and how they're handled
Stale heartbeat cache on aw-server (Python).
create_eventsdoes not invalidateServerAPI.last_event; aw-server-rust does invalidate. Suppose the last heartbeat has the same data as the stale cached first event and lands within pulsetime of it. It then merges, andreplace_last()overwrites the newest inserted event. I reproduced this against aw-server v0.13.2 with the guard disabled. Queueinga,b,c,aat 1s spacing (pulsetime 10) stored[a(3.5s), a, b], socwas lost. This can only happen when the gap between the first event's end and the last event's start is ≤ pulsetime, so such runs stay heartbeat-only. With the guard, the same input stores[a, b, c, a]. (The server bug deserves its own one-line fix; this PR doesn't depend on it.)Inserts are not idempotent. If an insert reaches the server but the response is lost (timeout, reset), a blind retry duplicates the chunk. So before a retried insert is sent, the client GETs the chunk's time range and resends only events the server doesn't already have. This costs one extra request, and only on retry. The same reconciliation runs for inserts in the first batch after startup, because a run that crashed before acknowledging that batch may already have delivered it.
Evidence
tests/test_requestqueue.py: 26 passed. New tests cover:data = request.datafails both lost-response tests, and removing the gap guard fails the short-run test.ruff checkflags only pre-existing issues incli.py/queries.py, which this PR doesn't touch.Refs #32