feat(query): cache results for finished past periods - #174
Conversation
Clients split long views (Year, All time) into one query per day and recompute all of them on every load. Past days rarely change, so cache query2 results in memory keyed by (query text, timeperiod), for periods that ended at least 10 minutes ago. Every write through ServerAPI records the time range it affected (the full extent of inserted, replaced, merged or deleted events) and drops overlapping entries; bucket create/update/delete/import clears the cache. A generation counter keeps a query that raced with an overlapping write from storing a stale result. In-memory only (bounded LRU, 10k entries / 128 MB), so a restart starts clean. Enabled by default (config: query_cache = true). Clients can bypass it per request with ?cache=false or "cache": false in the body.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
| stale = [ | ||
| k | ||
| for k, (period, _, _) in self._entries.items() | ||
| if any(_overlaps(r, period) for r in ranges) | ||
| ] |
There was a problem hiding this comment.
Large writes block cache reads When a bulk import or backfill supplies many events, invalidation checks every event range against every cached period while holding the cache lock. With 100,000 events and 10,000 cached periods, that can mean up to a billion overlap checks, substantially slowing the write and blocking cache reads. Group or coalesce the affected ranges before scanning cached entries.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed in d9497c8. Affected ranges are now coalesced before scanning (sorted and merged; past 64 disjoint ranges it falls back to one bounding range, so a big import over-invalidates rather than scanning the cache per event). A write call also logs a single generation, so bulk inserts no longer flood the write log. Worst case is now about 64 x entries overlap checks per write. Tests: test_coalesce_bulk_write_ranges, test_bulk_write_is_one_generation.
| return None | ||
| self._entries.move_to_end(key) | ||
| self.hits += 1 | ||
| return entry[1] |
There was a problem hiding this comment.
Callers can change cached results The cache stores the original result object and returns it by reference. If an in-process caller changes a returned list or dictionary, later calls for the same query and period receive those changes instead of the query result. Copy mutable results when storing or returning them.
There was a problem hiding this comment.
Kept as shared, and documented on QueryCache.get in d9497c8. I measured a deepcopy of a real busy-day result (fullDesktopQuery with 442 category rules, 76 KB JSON) at about 6 ms, which would roughly double warm All time (7.7 s to about 15 s for 1471 days). The only caller is the REST handler, which serializes the result without mutating it. If an in-process caller that mutates results shows up, copying at that call site is the cheaper fix.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a2f244f04b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| self.last_event[bucket_id] = heartbeat | ||
| self._last_event_unverified.discard(bucket_id) |
There was a problem hiding this comment.
Keep out-of-order heartbeat replacements marked unverified
When this heartbeat is older than an existing event, insert does not make it the datastore's last event, but this code clears the verification marker and records the older heartbeat in self.last_event. A subsequent matching heartbeat then calls replace_last, replacing the newer stored event while affected contains only the older heartbeat ranges; consequently, a cached period containing the replaced newer event is never invalidated and continues returning stale data. Keep the bucket unverified unless the inserted heartbeat is confirmed to be the stored last event, or invalidate the actual replace_last target.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, confirmed. Fixed in 34be7ab: dropped the unverified-marker bookkeeping, and every merge now reads the actually stored last event (one LIMIT 1 query) while the cache is enabled, so whatever replace_last() overwrites is always invalidated. Regression test test_merge_after_out_of_order_heartbeat_invalidates_replaced_event fails without the fix.
…results A bulk import checked every event range against every cached period under the lock. Merge overlapping ranges first, fall back to one bounding range past 64, and log one generation per write call instead of one per event.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9497c8b2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| @staticmethod | ||
| def key(query: str, period: TimeRange) -> str: | ||
| normalized = "\n".join(line.strip() for line in query.splitlines()).strip() |
There was a problem hiding this comment.
Preserve whitespace inside query literals in cache keys
For queries containing multiline string literals, stripping every physical line changes semantically significant whitespace: for example, RETURN = """a\n b"""; and RETURN = """a\nb"""; produce the same key despite returning different strings. Once the first query is cached for a past period, the second can therefore receive its result; hash the exact query text or use syntax-aware normalization instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in a9bb4a0: the key now hashes the exact query text (only the timeperiod is normalized, to UTC). Test covers the multiline-literal case.
replace_last() overwrites the datastore's last event, which is not always self.last_event: an out-of-order heartbeat is inserted without becoming the stored last event, so the unverified-marker bookkeeping could miss the range of the event actually replaced. Read the stored last event (LIMIT 1) on every merge while the cache is enabled instead.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 34be7ab7e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for e in events: | ||
| if e.id is not None: | ||
| old = self.db[bucket_id].get_by_id(e.id) | ||
| if old: | ||
| affected.append(event_range(old)) |
There was a problem hiding this comment.
Serialize replacements before capturing old ranges
When the threaded server handles two event updates for the same ID concurrently, both requests can read the same old range here before either insert executes. If request A moves the event from day 0 to day 1 and a query then caches day 1 before request B moves it to day 2, B invalidates only day 0 and day 2, leaving the cached day-1 result stale indefinitely. Keep the old-range lookup, mutation, and invalidation atomic; the analogous read-before-write paths in delete_event and heartbeat replacement need the same protection.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed. Fixed in a9bb4a0: all write methods (create/update/delete/import bucket, create_events, delete_event, heartbeat) now run under one RLock covering the old-range read, the mutation and the invalidation, so concurrent replacements of the same event serialize. SQLite serializes writes anyway, so throughput is unchanged in practice.
- Two concurrent updates of the same event could both read the same old range before either wrote, leaving a period in between cached stale. Writes now run under one RLock covering old-range read, mutation and invalidation. - Per-line whitespace normalization of the key could conflate queries whose string literals differ only in whitespace. Hash the exact text.
|
@codex review |
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🤖 AI code reviewAdds an in-memory query result cache for finished past periods, keyed by query text and UTC-normalized timeperiod, with invalidation on writes through ServerAPI. Introduces QueryCache class, a write lock serializing mutations, cache opt-out via config and REST flag, and a new test file. Wires the cache through main.py, server.py, rest.py, and config.py. Needs a look — P2 onlyConfidence 4/5 1 finding ·
|
| name = request.args["name"] | ||
| query = request.get_json() | ||
| cache = request.args.get("cache", "true").lower() not in ("false", "0", "no") | ||
| if query.get("cache") is False: |
There was a problem hiding this comment.
|
@TimeToBuildBob and the equivalent for aw-server-rust? |
|
No equivalent in aw-server-rust yet. The design ports cleanly:
Filed ActivityWatch/aw-server-rust#763 to track it. |
|
Update: the equivalent now exists — ActivityWatch/aw-server-rust#764, CI green (Android/Ubuntu/macOS/Windows/clippy/fmt), with criterion benchmarks on the Rust workload (cold day-query ~3.4 ms vs ~58 ns cache hit on 5,000 events). Same invalidation design as the Python port: only finished periods past a 10-minute margin are cached, and every write endpoint records its affected extent and drops overlapping entries. |
* feat(query): cache results for finished past periods Port of ActivityWatch/aw-server#174 to aw-server-rust. Clients such as aw-webui split long views (Year, All time) into one request per day and recompute every one on each page load; past days rarely change. Only periods that ended at least 10 minutes ago are cached. Every write handler in bucket.rs/import.rs records the extent it affected and drops overlapping entries; bucket create/delete/import clears the cache. A write-generation counter refuses to store a result if an overlapping write raced the query. Opt out per request with ?cache=false, or globally with query_cache = false in config.toml. Refs #763 Refs ActivityWatch/aw-server#174 Git-Session-Id: 72a5 * fix(query): address cache correctness, memory, and profiling review findings - charge the cache key's own bytes against the byte budget, so a large request-supplied query text cannot pin memory while its result is tiny - hold a write lock across read-old-ranges + write + invalidate in the event write handlers, closing the concurrent-replacement window that could leave an intermediate period cached - clear the cache after an import even when it fails partway, since earlier buckets may already have been written - apply the configured query_cache opt-out on Android - serialize a result exactly once: the cache stores the serialized body and the endpoint joins those strings into the response - add a criterion benchmark measuring the avoided work (cold query vs cache hit) on a finished day of 5,000 events Git-Session-Id: 72a5 * chore(deps): record criterion in Cargo.lock for aw-server The previous commit added criterion as an aw-server dev-dependency and the benchmark target without regenerating the lockfile, which breaks --locked builds. Git-Session-Id: 72a5
Part of ActivityWatch/activitywatch#1465 (Year / Custom range / All time in the Activity view).
aw-webui splits long views into one query per day (ActivityWatch/aw-webui#951), and every page load recomputes every day. On a real 4-year database with a large category set, Year takes ~3 min and All time ~10 min, every time.
query2already takes acacheargument but ignored it, and aw-client-js only caches in the open tab.This caches
query2results for finished past periods, in memory, keyed by (query text, timeperiod).Design
What is cached: a (query, timeperiod) result, only if the period ended at least 10 minutes ago. Today and the current hour are always computed fresh. The key is a hash of the exact query text and the period normalized to UTC. The category rules are part of the query text, so editing categories naturally misses the cache.
Invalidation (correctness does not assume the past never changes):
find_bucket) and period-bounded reads (query_bucket,query_bucket_eventcount), which return events whose extent overlaps the period.ServerAPI. After each write it records the time range affected and drops cached entries whose period overlaps it:create_events(including imports and aw-sync style backfills): each event's full extent, plus the old extent of any event replaced by ID.heartbeat: the previous last event and the merged/inserted event.replace_last()overwrites the stored last event, which is not alwaysself.last_event(other writes, out-of-order heartbeats), so the stored last event is read (one LIMIT 1 query per merge) and included too.delete_event: the deleted event's extent.find_bucketresolves to.finally, so failed writes also invalidate).Storage: in-memory, bounded LRU (10k entries / 128 MB estimated via JSON size). Deliberately not persisted: a restart starts clean, which also covers any change that bypassed the API (editing the SQLite file while the server is stopped). Year on the database below uses ~14 MB.
Opt-out: enabled by default so aw-webui benefits without changes (aw-client-js sends neither
namenor a cache flag). Per request:?cache=falseor"cache": falsein the body. Globally:query_cache = falseinaw-server.toml.aw-server-rust: has no query cache either (
aw-server/src/endpoints/query.rsevaluates every timeperiod). Not changed here; the same design would port directly (its writes also all go through the datastore API).Benchmark
Real
fullDesktopQueryfrom aw-webui master (38 statements, 37 KB, 442 regex category rules), one request per day over HTTP, against a copy of a 1.9 GB / ~4-year database (aw-core 686389d, peewee 3.17.6):cache=false(today's behaviour)Memory: 14 MB for Year, 47 MB for all 1471 days.
Filling the cache adds no measurable overhead (cold is within noise of
cache=false). The cold path is dominated bycategorize; speeding that up is separate work in aw-core.Tests
tests/test_query_cache.py: hit/miss, key normalization (timezone only), overlap vs non-overlap invalidation for heartbeat / insert / delete / replace-by-ID, bucket changes clearing, merge after an out-of-order heartbeat, bulk-write coalescing, current and future periods never cached, the write-during-computation race, write-log overflow, LRU bounds, per-request and config opt-out, REST flag. Added tomake test.Note (unrelated, found while benchmarking): aw-core allows
peewee <5, but peewee 4.x returnsBucketModel.createdas adatetime, which breaksiso8601.parse_dateinaw_datastore/storages/peewee.pyon existing databases. The lockfiles pin 3.17, so releases are unaffected.