Skip to content

feat(queue): merge queued heartbeats per bucket before dispatch - #127

Open
TimeToBuildBob wants to merge 7 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/batched-queue-drain
Open

TimeToBuildBob wants to merge 7 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/batched-queue-drain

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Addresses #32 and #7.

Rebased onto master after #130 merged. This is no longer stacked on #115/#130. The current diff changes only aw_client/client.py and tests/test_requestqueue.py; profile, config, CLI, query helpers, and the expanded Makefile test target are already on master.

Problem

When aw-server is unreachable, the persistent queue accumulates one heartbeat per commit interval. Sending them individually on reconnect creates thousands of HTTP requests.

Fix

Pop up to BATCH_SIZE=1000 queued requests and merge consecutive compatible heartbeats per bucket using aw_transform.heartbeat_merge. Send merged events to the heartbeat endpoint, preserving each bucket's order, including changes in pulsetime.

  • Dispatch one coalesced request per call so shutdown is checked between requests.
  • On a transient failure, retry only the failed request; do not replay requests already handled during the current process.
  • Drop only the failed request on permanent errors, then continue.
  • Acknowledge the persistent batch after all its requests are handled. A crash before acknowledgement leaves the batch on disk; this retains at-least-once delivery, not exactly-once crash recovery.
  • wait_for_queue_empty() includes the in-flight batch, not just the queue's unread rows.

Scope

No bulk-insert endpoint, profile migration, or dependency changes. Those are independent of heartbeat batching. This does not claim to solve duplicate delivery across a process crash.

Verification

The expanded Makefile test selection passes: 100 tests on Python 3.13, and 100 tests in a fresh Python 3.9 environment installed from this package's declared runtime dependencies plus pytest, explicitly using the locked aw-core==0.5.17.

Tests cover 200 mergeable heartbeats becoming one request, 10,000 becoming ten requests with contiguous coverage of the complete time range, per-bucket ordering, malformed endpoints, permanent errors, partial retries, and waiting for an in-flight batch.

GitHub CI is pending; local passing tests are not a claim that CI has completed.

@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Medium risk] Changes how queued heartbeat requests are batched and retried.

The PR is not ready to merge while legacy testing heartbeats can be stranded and the declared test setup cannot run the expanded target.

Summary

The PR coalesces queued heartbeats before dispatch and adds profile, configuration, CLI, and multidevice-query changes since the previous review.

  • Queue dispatch now retries one coalesced request at a time, with added coverage for ordering, retries, and backlog spans.
  • Testing-profile queue storage can leave legacy pending heartbeats inaccessible.
  • The expanded test target needs dependencies that are not declared.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Queued heartbeats] --> B[Pop bounded batch]
  B --> C[Coalesce per bucket]
  C --> D[Dispatch next request]
  D -->|Retryable failure| D
  D -->|Handled| E{Batch complete?}
  E -->|No| D
  E -->|Yes| F[Acknowledge batch]
Loading

Reviews (4) · Last reviewed commit: "test(queue): assert the 10k drain's merg..."

Comment thread aw_client/client.py Outdated
Comment thread aw_client/client.py Outdated
Comment thread aw_client/client.py Outdated
Comment thread aw_client/client.py Outdated
TimeToBuildBob added a commit to TimeToBuildBob/aw-client that referenced this pull request Oct 2, 2026
…, 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
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed all four findings in f7e463c:

  • Out-of-order heartbeats: coalescing now groups by bucket, not by full endpoint, and only merges a contiguous run sharing one endpoint. 10, 20, 10 is now sent in exactly that order (new test test_dispatch_preserves_order_when_pulsetimes_differ).
  • Malformed endpoint: _parse_heartbeat_endpoint returns None instead of raising (the regex also only matches a well-formed float), so pulsetime=1..2 is sent verbatim and later heartbeats still deliver (new test).
  • Partial replay: dispatch now sends one coalesced request per call and tracks the index, so a transient error retries only the failed request - delivered heartbeats are never replayed (new test test_partial_retry_does_not_replay_delivered_requests).
  • Shutdown delay: same change means the run loop checks stop() between requests, so disconnect() no longer waits on a whole batch.

tests/test_requestqueue.py: 21 passed; ruff/format/mypy clean.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw_client/client.py
Comment thread tests/test_requestqueue.py Outdated
Comment thread tests/test_requestqueue.py Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Round 2 addressed in 9c0899a:

  • 10k test could miss data loss: now asserts the summed span across all posted events equals the full queued range, not just the first request's duration.
  • Flaky timing bound: the wall-clock limit is gone; the test checks request count and coverage only.
  • Bottleneck measurement: the metric here is HTTP request count, not CPU/IO profiling - the issue (Send heartbeats from queue as batches #32/Many heartbeats and a unavailable server might lead to queue clogging #7) is that a backlog is drained one request per heartbeat. The tests measure it directly: 10,000 mergeable heartbeats now drain in 10 requests (one per BATCH_SIZE batch) instead of 10,000. That is the measurement; adding pytest-profiling would not change the request-count argument.

21 passed; ruff/mypy clean.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Converged at 9c0899a:

  • P1s (4) fixed in f7e463c — order preserved across differing pulsetimes, malformed endpoint no longer aborts the queue, retries no longer replay delivered heartbeats, and disconnect() no longer waits on a whole batch.
  • Test P2s (2) fixed in 9c0899a — the 10k test asserts the summed span across all batches, and the flaky wall-clock bound is gone.
  • P2 (profiling measurement) dispositioned in-thread: the bottleneck is HTTP request count (10k heartbeats → 10 requests; 200 → 1), which the tests measure directly.

All Greptile review threads are now resolved. Head is MERGEABLE/CLEAN; tests/test_requestqueue.py 21 passed, ruff/format/mypy clean. This is a cross-repo PR with no CI on a feature-branch base (workflows filter to master), so it needs a maintainer review/merge. Note the stack: #115 → #127 → #128, and #115 is currently conflicting with master.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Status: converged — all review threads resolved and the PR is mergeable against its stack base. The only blocker is its parent, #115, which has gone CONFLICTING against master. That branch lives in the upstream repo and I have no push access to it, so I can't push the rebase myself; I've flagged the conflict and offered a rebase/hand-over on #115.

Once #115 is rebased/merged (or handed to me), I'll rebase this branch onto it immediately — it should be mechanical, since the only overlap is RequestQueue.__init__ (this PR touches the dispatch loop, not __init__).

TimeToBuildBob added a commit to TimeToBuildBob/aw-client that referenced this pull request Oct 6, 2026
…, 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
@TimeToBuildBob
TimeToBuildBob force-pushed the feat/batched-queue-drain branch from 9c0899a to a960a3a Compare October 6, 2026 19:09
TimeToBuildBob added a commit to TimeToBuildBob/aw-client that referenced this pull request Oct 6, 2026
…, 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
@TimeToBuildBob
TimeToBuildBob changed the base branch from fix/queue-retry-transient-errors to master October 6, 2026 19:09
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Restacked on carry PR #130 (#130). The base has been changed to master — once #130 merges, this PR's diff will show only the batched-heartbeat-merge changes. All 21 tests pass on the rebased branch (a960a3a).

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed the remaining summary-body note in 5f1a2af: the 10k-drain test now asserts that the merged events tile the queued range contiguously. The first event starts at the first queued heartbeat, each one starts where the previous ended, and the last ends at the end of the backlog. That catches a gap or misplacement, not just a total-duration mismatch.

tests/test_requestqueue.py: 21 passed; ruff clean.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Oct 6, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Legacy testing heartbeats stranded aw_client/client.py:552 ▶

    If an installation has testing heartbeats queued under the legacy shared directory, setting AW_PROFILE=testing makes this queue use the isolated testing directory instead. The API-key lookup accounts for legacy testing files, but the queue path does not, so those pending heartbeats are not drained after upgrade.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Fixed lint: black formatting fix (887dcf8).

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

On the remaining outside-diff P1 (legacy testing heartbeats stranded, client.py:552): that code is not part of this PR. The PR's diff touches only the dispatch loop in aw_client/client.py and tests/test_requestqueue.py. The persistqueue_path / profile_suffix / get_data_dir block it flags is already on master byte-for-byte, from the profile-isolation work. Greptile reached it only because this PR is now based on master (restacked on #130). Fixing the profile queue-path migration here would mix two unrelated changes, so it stays out of scope for this PR.

Status at 887dcf8: all 7 checks green, MERGEABLE/CLEAN, all review threads resolved. Merge order: #130 first, then this PR (its diff then shrinks to the batching change). Both merges are a maintainer's call. Bob has pull-only access here.

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
…, 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
@TimeToBuildBob
TimeToBuildBob force-pushed the feat/batched-queue-drain branch from 887dcf8 to 151f55b Compare October 10, 2026 14:11
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto master now that #130 has landed, so the carried #115 commits are gone. Conflicts with #124 and #125 were resolved by keeping both sides. wait_for_queue_empty now tracks the in-flight batch (_current_batch) instead of _current, and the new tests use #130's per-test tmp_path queues. Locally: 109 tests pass, mypy and black are clean. Two failures are pre-existing on master: test_full needs a live server and test_failqueue reads stdin.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Checked both summary-body findings against current head 151f55b:

  • Undeclared test dependencies: could not reproduce. In a fresh Python 3.9 environment, installing this package's declared runtime dependencies plus pytest (with the locked aw-core==0.5.17) runs the complete expanded Makefile test selection: 100 passed. The same selection passes on Python 3.13. No additional dependency is needed to run that target.
  • The review still links to 5f1a2af, before today's rebase. Its profile/config/CLI/query changes are not in the current diff; the queue-path block is identical to master. The earlier outside-diff migration disposition therefore still applies.

Updated the PR description to remove the obsolete stack/CI instructions and document current retry and crash-recovery semantics. No code change or Greptile re-trigger: there is no reproduced batching/dependency defect to fix in these findings. CI remains queued; this is not a claim that the 3/5 score or CI has cleared.

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.

1 participant