Skip to content

perf(aw-transform): cache categorize/tag results per event data to fix month-view timeout - #150

Merged
ErikBjare merged 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:perf/cache-categorize-results
Aug 25, 2026
Merged

perf(aw-transform): cache categorize/tag results per event data to fix month-view timeout#150
ErikBjare merged 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:perf/cache-categorize-results

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Companion to ActivityWatch/aw-server-rust#657 — same fix requested by @ErikBjare.

Root cause

categorize() and tag() in aw_transform/classify.py were re-evaluating every regex rule against every event individually. For a typical month with 50 000+ events from aw-watcher-window and 20 category rules, this meant ~1 000 000 regex evaluations per query. The vast majority of those events share identical app+title data — heartbeat-based watchers emit the same payload many times per second.

Fix

Added a function-local dict cache inside both categorize() and tag(), keyed on json.dumps(e.data, sort_keys=True). Only the first occurrence of each distinct data fingerprint is matched against the rule set; subsequent events with identical data reuse the cached result in O(1). Each event receives its own list copy to prevent mutation aliasing across events.

json.dumps(..., sort_keys=True) guarantees a consistent key regardless of dict insertion order.

Expected impact

For a month's worth of data with 50 000 events but only ~200 distinct app/title pairs:

  • Before: 50 000 × 20 = 1 000 000 regex evaluations
  • After: 200 × 20 = 4 000 regex evaluations (~250× less)

Changes

  • aw_transform/classify.py: cache added to categorize() and tag(); _categorize_one() and _tag_one() kept as internal helpers (unchanged)
  • New test test_categorize_cache_correctness: 101 events (50 Firefox + 1 vim + 50 Firefox), verifies correct category per data shape and that mutating one event's category list does not affect others (list-copy correctness)

Same fix as ActivityWatch/aw-server-rust#657 — categorize() and tag()
were re-evaluating every regex rule against every event individually.
Heartbeat-based watchers emit the same app+title payload repeatedly,
so the same regex matches were being recomputed thousands of times.

Added a function-local cache keyed on json.dumps(e.data, sort_keys=True).
Only the first occurrence of each distinct data fingerprint is matched
against the rule set; subsequent events with identical data reuse the
cached result in O(1). Each event receives its own list copy to prevent
mutation aliasing across events.

For a month with 50 000 events but ~200 distinct app/title pairs and 20
category rules: 1 000 000 → 4 000 regex evaluations (~250× less).

Adds test_categorize_cache_correctness: 101 events (50 + 1 + 50), two
distinct data shapes, verifies correct category for each and that
mutating one event's category list does not affect others.
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Greptile Summary

The PR memoizes category and tag rule results by serialized event data to reduce repeated regex evaluation, while copying cached lists onto each event.

  • Adds function-local caches to categorize and tag.
  • Adds a categorization test covering repeated data fingerprints and list-copy isolation.
  • The new serialization-based key can reject transformed event data that classification previously accepted.

Confidence Score: 4/5

The PR should not merge until classification cache keys can handle transformed event data without aborting composed queries.

The new unconditional JSON serialization fails when a supported upstream transformation stores Event objects in the data payload; the missing profiling evidence is additionally actionable but non-blocking.

Files Needing Attention: aw_transform/classify.py

Important Files Changed

Filename Overview
aw_transform/classify.py Adds classification caches, but the JSON cache key can abort composable query pipelines containing non-serializable transformed event data.
tests/test_transforms.py Covers category-cache reuse and list isolation, but not tagging or classification after transforms that embed Event objects.

Reviews (1): Last reviewed commit: "perf(aw-transform): cache categorize/tag..." | Re-trigger Greptile

Comment thread aw_transform/classify.py Outdated
return [_categorize_one(e, classes) for e in events]
cache: Dict[str, Category] = {}
for e in events:
key = json.dumps(e.data, sort_keys=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Serialization breaks transformed events

When chunk_events_by_key precedes categorize, data["subevents"] contains Event objects, so serializing the complete payload for the cache key raises TypeError and aborts the query; tag has the same issue at line 68.

Knowledge Base Used: Event grouping and normalization

Comment thread aw_transform/classify.py
Comment on lines +47 to +54
cache: Dict[str, Category] = {}
for e in events:
key = json.dumps(e.data, sort_keys=True)
if key not in cache:
cache[key] = _pick_category(
[_cls for _cls, rule in classes if rule.match(e)]
)
e.data["$category"] = list(cache[key])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Optimization lacks profiling measurements

These caches implement a performance optimization without profiling or benchmark measurements, so reviewers cannot validate the claimed bottleneck, representative improvement, or the cost of serializing every event payload.

Rule Used: Before implementing performance optimizations, mea... (source)

Learned From
gptme/gptme#707

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!

json.dumps raises TypeError when e.data contains non-serializable values
(e.g. nested Event objects from chunk_events_by_key). query2.py catches
TypeError and maps it to 'invalid amount of arguments', which obscures
the real error and breaks test_query2_query_functions.

Fall back to str(id(e.data)) for non-serializable data — no caching
benefit for those events, but correctness is preserved.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI fixed (pushed d41a406).

Root cause: json.dumps(e.data, sort_keys=True) raised TypeError when event data contained non-JSON-serializable values — specifically, after chunk_events_by_key embeds nested Event objects in e.data["subevents"]. aw_query/query2.py catches all TypeError exceptions on function calls and re-raises them as "invalid amount of arguments", which made the failure look like a signature mismatch.

Fix: Wrap the json.dumps call in a try/except in both categorize() and tag(), falling back to str(id(e.data)) for non-serializable data. That path gets no cache benefit but is correct — no false cache hits.

All 31 tests in test_transforms.py and test_query2.py::test_query2_query_functions pass locally.

@ErikBjare
ErikBjare merged commit 18e0b10 into ActivityWatch:master Aug 25, 2026
4 checks passed
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.

2 participants