fix(datastore): make legacy Android bucket merge linear instead of quadratic - #679
Conversation
…adratic migrate_test_bucket_names moved disjoint legacy events with a correlated NOT EXISTS subquery per legacy event. The subquery had no lower bound on starttime, so every legacy event rescanned all earlier events in both buckets: O(n^2). On phones with a couple of years of aw-watcher-android history this kept the single datastore worker busy for hours on every app start, which blanked the web UI (every API request queued behind it) and produced ANRs in every main-thread datastore call (widget refresh, heartbeats). Measured: 40k+40k events took 41 s on a desktop CPU and doubles four-fold per doubling of data; a 150k-event fixture wedged the v0.14.0 app on an emulator indefinitely. Replace it with one sorted scan over both buckets and a sweep that keeps still-open events in a min-heap keyed by endtime, then move the movable ids in batched UPDATEs. Same strict-overlap semantics; 100k+10k events now merge in well under a second in a debug build. Adds a large-history regression test with a wall-clock bound and a zero-duration edge-case test. Fixes ActivityWatch/aw-android#261
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #679 +/- ##
==========================================
+ Coverage 70.81% 80.14% +9.32%
==========================================
Files 51 67 +16
Lines 2916 6059 +3143
==========================================
+ Hits 2065 4856 +2791
- Misses 851 1203 +352 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Greptile P1: scanning the open heap per event is still quadratic for stacked histories. A positive-duration event overlaps every still-open event, so bump an epoch instead of walking the heap. Drain marks events whose push-epoch is stale. Zero-duration same-start remains a scan (those events never stay in the open set). Adds a 20k stacked-history regression test. Git-Session-Id: 4425efde-7d1b-51bc-8e33-4bffa60f8b9f
|
@greptileai review |
|
Verified on device (emulator) with the CI-built 2.3 s for the merge in an unoptimized build, Two observations from the run, both pre-existing and out of scope here:
|
|
CI-green and mergeable (Greptile 5/5) — 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. |
…263) Pulls ActivityWatch/aw-server-rust#679: migrate_test_bucket_names moved legacy events with a correlated overlap subquery per event (O(n^2)), which kept the single datastore worker busy for hours on real histories and blanked the web UI while producing ANRs on every main-thread datastore call. Now a sorted scan with an endtime-heap sweep; 150k-event fixture migrates in ~2 s on an emulator. Fixes #261
Fixes ActivityWatch/aw-android#261 (blank web view + "ActivityWatch isn't responding" on v0.14.0).
Root cause
migrate_test_bucket_names(merge path from #661, first wired into the app by aw-android#244, shipped in v0.14.0) moved disjoint legacy events with a correlatedNOT EXISTSsubquery per legacy event. The subquery has no lower bound onstarttime, so for every legacy event SQLite rescans all earlier events in both buckets: O(n²).Every user who ran an older release (which wrote to
aw-watcher-android-test_<host>) and then v0.14.0b2 (which createdaw-watcher-android_<host>) has both buckets, so the merge path runs for them on the first v0.14.0 start. With a couple of years of history it keeps the single datastore worker thread busy for hours. Everything else queues behind it:GET /api/0/settings/never answers) → blank white WebViewgetBuckets) → ANRPlay vitals confirm the shape: the top ANR clusters are the main thread parked in
aw_datastore::worker::Datastore::get_buckets→crossbeam_channel::recv, triggered from the widget refresh broadcast,WebWatcher,SystemJobServiceand theLOG_DATAalarm. And because a single overlapping cutover heartbeat leaves the merge "partial", the app re-runs the same migration on every start.Evidence
Benchmark of the shipped SQL on synthetic disjoint data (desktop CPU, both buckets the same size):
Time quadruples per doubling. A phone with ~300k events is in the hours range, and phones are several times slower than this.
Reproduced on an Android 16 emulator with the v0.14.0 release APK and a 150k+15k event fixture:
Migrating 'aw-watcher-android-test' bucket names…is logged at service start, the web UI's first requestGET /api/0/settings/is matched and never answered,/api/0/infotimes out, and the WebView stays white.Fix
One
SELECT id, starttime, endtime, bucketrow … ORDER BY starttimeover both buckets, then a sweep that keeps still-open events in a min-heap keyed byendtime. Each overlapping pair is examined exactly once, so the pass is O(n log n). Movable ids are reassigned with batchedUPDATE … WHERE id IN (…). Overlap semantics are unchanged (stricta.start < b.end AND b.start < a.end); all six existing merge tests pass untouched.Tests
test_migrate_test_bucket_names_merges_large_history_quickly: 100k legacy + 10k destination events plus one overlapping cutover event; asserts the merge finishes under a 30 s wall-clock bound (it takes well under a second in a debug build; the old query would take minutes) and that exactly the overlapping pair stays behind.test_migrate_test_bucket_names_zero_duration_event_at_shared_start_stays: pins the strict-overlap edge case the sweep has to special-case.Note:
cargo clippy --all-targets -D warningswith a local rustc 1.90 flags a pre-existingunnecessary_mut_passedinlegacy_import.rs:200, unrelated to this change; not touched here to keep the hotfix minimal.Follow-ups (aw-android side, separate)
BackgroundService.onStartCommandran twice on one launch in the repro, so the migration coroutine is queued twice; harmless once the migration is fast, but worth guarding.getBucketsfrom heartbeat paths and the widget provider) will ANR whenever the worker is busy for >5 s; they should move off the main thread.