-
-
Notifications
You must be signed in to change notification settings - Fork 96
perf(aw-transform): cache categorize results per event data to fix month-view timeout #657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |||||||||||
| use aw_models::Event; | ||||||||||||
| use fancy_regex::Regex; | ||||||||||||
| use lru::LruCache; | ||||||||||||
| use std::collections::HashMap; | ||||||||||||
| use std::num::NonZeroUsize; | ||||||||||||
| use std::sync::{Arc, Mutex, OnceLock}; | ||||||||||||
|
|
||||||||||||
|
|
@@ -117,27 +118,40 @@ impl From<Regex> for Rule { | |||||||||||
| /// An event can only have one category, although the category may have a hierarchy, | ||||||||||||
| /// for instance: "Work -> ActivityWatch -> aw-server-rust" | ||||||||||||
| /// If multiple categories match, the deepest one will be chosen. | ||||||||||||
| /// | ||||||||||||
| /// Performance: builds an in-memory cache keyed on the event's data JSON so that | ||||||||||||
| /// events with identical data (same app/title — very common in practice) are only | ||||||||||||
| /// matched against the rule set once. On a month's data with 50k+ events but only | ||||||||||||
| /// a few hundred distinct app/title pairs this reduces regex work by >99%. | ||||||||||||
| pub fn categorize(mut events: Vec<Event>, rules: &[(Vec<String>, Rule)]) -> Vec<Event> { | ||||||||||||
| let mut classified_events = Vec::new(); | ||||||||||||
| for event in events.drain(..) { | ||||||||||||
| classified_events.push(categorize_one(event, rules)); | ||||||||||||
| // Cache: serialized event data → assigned category | ||||||||||||
| let mut category_cache: HashMap<String, Vec<String>> = HashMap::new(); | ||||||||||||
| let mut classified_events = Vec::with_capacity(events.len()); | ||||||||||||
| for mut event in events.drain(..) { | ||||||||||||
| // Key on the full event data. serde_json::Map preserves insertion order, so | ||||||||||||
| // events with the same fields in the same order produce the same key — which | ||||||||||||
| // is the normal case for heartbeat-based watchers. | ||||||||||||
|
Comment on lines
+131
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The current
Suggested change
|
||||||||||||
| let cache_key = serde_json::to_string(&event.data).unwrap_or_default(); | ||||||||||||
| let category = category_cache | ||||||||||||
| .entry(cache_key) | ||||||||||||
| .or_insert_with(|| { | ||||||||||||
| let mut cat = vec!["Uncategorized".into()]; | ||||||||||||
| for (c, rule) in rules { | ||||||||||||
| if rule.matches(&event) { | ||||||||||||
| cat = _pick_highest_ranking_category(cat, c); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| cat | ||||||||||||
| }) | ||||||||||||
| .clone(); | ||||||||||||
| event | ||||||||||||
| .data | ||||||||||||
| .insert("$category".into(), serde_json::json!(category)); | ||||||||||||
| classified_events.push(event); | ||||||||||||
| } | ||||||||||||
| classified_events | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| fn categorize_one(mut event: Event, rules: &[(Vec<String>, Rule)]) -> Event { | ||||||||||||
| let mut category: Vec<String> = vec!["Uncategorized".into()]; | ||||||||||||
| for (cat, rule) in rules { | ||||||||||||
| if rule.matches(&event) { | ||||||||||||
| category = _pick_highest_ranking_category(category, cat); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| event | ||||||||||||
| .data | ||||||||||||
| .insert("$category".into(), serde_json::json!(category)); | ||||||||||||
| event | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /// Tags a list of events | ||||||||||||
| /// | ||||||||||||
| /// An event can have many tags (as opposed to only one category) which will be put into the `$tags` key of | ||||||||||||
|
|
@@ -290,6 +304,63 @@ fn test_categorize_uncategorized() { | |||||||||||
| ); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| #[test] | ||||||||||||
| fn test_categorize_cache_correctness() { | ||||||||||||
| // Verifies that the deduplication cache produces the same result as | ||||||||||||
| // per-event categorization when many events share the same data. | ||||||||||||
| let mut base = Event::default(); | ||||||||||||
| base.data.insert("app".into(), serde_json::json!("firefox")); | ||||||||||||
| base.data | ||||||||||||
| .insert("title".into(), serde_json::json!("GitHub")); | ||||||||||||
|
|
||||||||||||
| let mut other = Event::default(); | ||||||||||||
| other | ||||||||||||
| .data | ||||||||||||
| .insert("app".into(), serde_json::json!("terminal")); | ||||||||||||
| other.data.insert("title".into(), serde_json::json!("bash")); | ||||||||||||
|
|
||||||||||||
| // 50 events with same data, then 1 different event, then 50 more same | ||||||||||||
| let mut events: Vec<Event> = std::iter::repeat(base.clone()) | ||||||||||||
| .take(50) | ||||||||||||
| .chain(std::iter::once(other.clone())) | ||||||||||||
| .chain(std::iter::repeat(base.clone()).take(50)) | ||||||||||||
| .collect(); | ||||||||||||
|
|
||||||||||||
| let rules: Vec<(Vec<String>, Rule)> = vec![ | ||||||||||||
| ( | ||||||||||||
| vec!["Browser".into()], | ||||||||||||
| Rule::Regex(RegexRule::new("firefox", true, Some(vec!["app".into()])).unwrap()), | ||||||||||||
| ), | ||||||||||||
| ( | ||||||||||||
| vec!["Terminal".into()], | ||||||||||||
| Rule::Regex(RegexRule::new("terminal", true, Some(vec!["app".into()])).unwrap()), | ||||||||||||
| ), | ||||||||||||
| ]; | ||||||||||||
|
|
||||||||||||
| events = categorize(events, &rules); | ||||||||||||
|
|
||||||||||||
| assert_eq!(events.len(), 101); | ||||||||||||
| // All firefox events → Browser | ||||||||||||
| for e in events.iter().take(50) { | ||||||||||||
| assert_eq!( | ||||||||||||
| e.data.get("$category").unwrap(), | ||||||||||||
| &serde_json::json!(vec!["Browser"]) | ||||||||||||
| ); | ||||||||||||
| } | ||||||||||||
| // The single terminal event → Terminal | ||||||||||||
| assert_eq!( | ||||||||||||
| events[50].data.get("$category").unwrap(), | ||||||||||||
| &serde_json::json!(vec!["Terminal"]) | ||||||||||||
| ); | ||||||||||||
| // Remaining firefox events → Browser (cache hit path) | ||||||||||||
| for e in events.iter().skip(51) { | ||||||||||||
| assert_eq!( | ||||||||||||
| e.data.get("$category").unwrap(), | ||||||||||||
| &serde_json::json!(vec!["Browser"]) | ||||||||||||
| ); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| #[test] | ||||||||||||
| fn test_tag() { | ||||||||||||
| let mut e = Event::default(); | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new implementation adds JSON serialization, hashing, and category cloning for every event, while the test covers only output correctness. Add representative profiling or benchmark results to validate the stated greater-than-99-percent reduction and guard the month-view improvement against regression.
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!