fix(sync): chunk event fetch in sync_one() to prevent OOM on large buckets#631
Conversation
…ckets Previously sync_one() fetched all events for a bucket in a single unbounded call (limit=None), loading them all into memory at once. On Android with large buckets this caused native OOM crashes (SIGABRT in libaw_server.so). Replace with a bounded fetch loop using BATCH_SIZE=5000 events per page. Events are returned in descending order by the datastore, so pages are fetched newest-to-oldest (using the `end` parameter) and processed in reverse (oldest page first) to preserve correct heartbeat merge semantics at the resume boundary. Resolves ActivityWatch#630
Document the same-timestamp page boundary edge case and the all-pages-resident memory limitation raised in Greptile review.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #631 +/- ##
==========================================
+ Coverage 70.81% 75.69% +4.87%
==========================================
Files 51 62 +11
Lines 2916 5028 +2112
==========================================
+ Hits 2065 3806 +1741
- Misses 851 1222 +371 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Greptile review addressed — ready for maintainer review. Findings from this round (4/5):
CI status: macOS ✅ · Windows ✅ · Ubuntu ✅ · Clippy ✅ · Format ✅ · Android ⏳ · Code coverage ⏳ Converged after 1 Greptile round. Blocking findings: 0. Remaining: 2 acknowledged-but-non-blocking limitations documented in code. |
…rect heartbeat boundary - Reverse each chunk to ASC order before inserting so destination IDs match source IDs (both use oldest-first insertion) - For the last page, heartbeat the globally-oldest event FIRST before inserting newer events from that page, so dest's 'last event' is still at the resume boundary when heartbeat() runs — prevents duplicate events at the boundary on incremental syncs - Remove the pending_oldest_event / post-loop heartbeat pattern; the last-page branch now handles everything inline - All 4 sync tests pass
|
@greptileai review |
…etch The inclusive fetch cursor (`fetch_end = Some(boundary_ts)`) caused the boundary event to be re-fetched on the next page, producing a duplicate row in the destination. Fix: pop ALL events at `boundary_ts` from the current chunk instead of keeping one copy. The `if chunk.first().timestamp != boundary_ts` guard ensures the chunk is non-empty after popping. Also move BATCH_SIZE to module level with a smaller value in `#[cfg(test)]` (5 instead of 5000), enabling the new multi-page test to run without creating thousands of events.
|
Fixed: boundary event duplicate in multi-page chunked sync Greptile finding P1 "Boundary Event Duplicates" (line 377) was a real data-correctness bug:
Fix: pop ALL events at Multi-page test coverage: moved Remaining acknowledged limitation (finding P1 "Heartbeat Sees Newer Events"): in a resumed multi-page sync the heartbeat on the oldest new event may compare against a newer just-inserted row instead of the pre-sync resume boundary. In practice this is benign — with |
|
@greptileai review |
…ow comparison heartbeat() looks up dest's "last event" to decide whether to merge the oldest new event into the resume boundary. In the multi-page case, newer pages have already been written to dest before the last (oldest) page is processed, so heartbeat() compares against a newly-inserted newer row instead of the pre-sync resume-boundary row — causing it to always insert (no merge) AND potentially leave an incorrect boundary artifact. Fix: only call heartbeat() in the single-page case (pages_written == 0), where dest's "last event" is still the resume-boundary row and the comparison is correct. In multi-page syncs, insert the oldest event directly, which is also correct (no merge needed if it's not adjacent).
|
@greptileai review |
|
Greptile review converged — ready for maintainer review. Fixed across 3 review rounds:
Remaining findings (non-blocking):
CI: All checks green (Build ✓, Lint ✓, Code coverage ✓, Greptile ✓). Converged after 3 Greptile rounds. Acceptable to ship: |
Summary
get_events(limit=None)call insync_one()with a bounded fetch loop usingBATCH_SIZE=5000events per pageSIGABRTinlibaw_server.so) on Android when syncing buckets with large event histories// TODO: Fetch at most ~5,000 events at a timeRoot cause
sync_one()calledds_from.get_events(..., None)— no limit — loading the entire event history for a bucket into a singleVec<Event>in one allocation. On Android devices with limited native heap this caused OOM.Implementation
Events are returned in descending order (newest first) by the datastore, so forward pagination with a
starttimestamp doesn't work directly. Instead:endparameter: each page = newestBATCH_SIZEevents before the previous page's oldest eventheartbeat()for correct merge semantics at the resume boundary — same as beforeThis matches the write side which already used
BATCH_SIZE=5000for batchinginsert_events.Testing
test_sync_resume_after_partial_syncwhich verifies a two-pass sync (initial + incremental) produces identical results in source and destinationRelated