Skip to content

Commit fc46a18

Browse files
authored
perf: selective FileWatcher invalidation, expand ProjectTokenSummary tests (#115)
- FileWatcher now invalidates only the relevant reader cache per event source: stats-cache changes → StatsCacheReader only, JSONL changes → SessionLogReader only. Fallback timer still invalidates both. Avoids unnecessary JSONL re-scans when only stats-cache changed. - ProjectTokenSummary tests expanded from 1 → 7: zero tokens, id as Identifiable, lastPathComponent display name, Other project for missing cwd, stored cost, large token counts (overflow guard). - DATA_LAYER.md updated to document selective invalidation.
1 parent cd0b4de commit fc46a18

File tree

3 files changed

+93
-7
lines changed

3 files changed

+93
-7
lines changed

AIBattery/Services/FileWatcher.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ final class FileWatcher {
7777

7878
source.setEventHandler { [weak self] in
7979
MainActor.assumeIsolated {
80-
self?.debounceNotify()
80+
self?.debounceNotify(invalidateStatsCache: true, invalidateSessionLog: false)
8181
}
8282
}
8383

@@ -114,7 +114,7 @@ final class FileWatcher {
114114
let box = Unmanaged<WeakBox<FileWatcher>>.fromOpaque(info).takeUnretainedValue()
115115
guard let watcher = box.value else { return }
116116
MainActor.assumeIsolated {
117-
watcher.debounceNotify()
117+
watcher.debounceNotify(invalidateStatsCache: false, invalidateSessionLog: true)
118118
}
119119
}
120120

@@ -163,15 +163,17 @@ final class FileWatcher {
163163
}
164164
}
165165

166-
private func debounceNotify() {
166+
/// Selective invalidation — only clear the cache for the reader whose data actually changed.
167+
/// Stats-cache changes don't require re-scanning JSONL files, and vice versa.
168+
/// Fallback timer invalidates both (safe catch-all when FS events are unavailable).
169+
private func debounceNotify(invalidateStatsCache: Bool = true, invalidateSessionLog: Bool = true) {
167170
guard !isStopped else { return }
168171
debounceWorkItem?.cancel()
169172
let work = DispatchWorkItem { [weak self] in
170173
MainActor.assumeIsolated {
171174
guard let self, !self.isStopped else { return }
172-
// Invalidate reader caches so the next refresh re-scans changed files
173-
SessionLogReader.shared.invalidate()
174-
StatsCacheReader.shared.invalidate()
175+
if invalidateSessionLog { SessionLogReader.shared.invalidate() }
176+
if invalidateStatsCache { StatsCacheReader.shared.invalidate() }
175177
self.onChange()
176178
}
177179
}

Tests/AIBatteryCoreTests/Models/ProjectTokenSummaryTests.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,88 @@ struct ProjectTokenSummaryTests {
1616
)
1717
#expect(summary.totalTokens == 1000)
1818
}
19+
20+
@Test func totalTokens_zeroWhenAllZero() {
21+
let summary = ProjectTokenSummary(
22+
id: "/workspace/empty",
23+
projectName: "empty",
24+
inputTokens: 0,
25+
outputTokens: 0,
26+
cacheReadTokens: 0,
27+
cacheWriteTokens: 0,
28+
estimatedCost: 0
29+
)
30+
#expect(summary.totalTokens == 0)
31+
}
32+
33+
@Test func id_isUsedAsIdentifiable() {
34+
let summary = ProjectTokenSummary(
35+
id: "/Users/kyle/projects/myapp",
36+
projectName: "myapp",
37+
inputTokens: 500,
38+
outputTokens: 250,
39+
cacheReadTokens: 0,
40+
cacheWriteTokens: 0,
41+
estimatedCost: 1.50
42+
)
43+
#expect(summary.id == "/Users/kyle/projects/myapp")
44+
}
45+
46+
@Test func projectName_displaysLastPathComponent() {
47+
// Simulates what UsageAggregator.buildProjectTokens does
48+
let cwd = "/Users/kyle/projects/my-app"
49+
let displayName = URL(fileURLWithPath: cwd).lastPathComponent
50+
let summary = ProjectTokenSummary(
51+
id: cwd,
52+
projectName: displayName,
53+
inputTokens: 100,
54+
outputTokens: 50,
55+
cacheReadTokens: 0,
56+
cacheWriteTokens: 0,
57+
estimatedCost: 0.30
58+
)
59+
#expect(summary.projectName == "my-app")
60+
}
61+
62+
@Test func otherProject_usedForMissingCwd() {
63+
let summary = ProjectTokenSummary(
64+
id: "Other",
65+
projectName: "Other",
66+
inputTokens: 100,
67+
outputTokens: 50,
68+
cacheReadTokens: 0,
69+
cacheWriteTokens: 0,
70+
estimatedCost: 0.10
71+
)
72+
#expect(summary.id == "Other")
73+
#expect(summary.projectName == "Other")
74+
#expect(summary.totalTokens == 150)
75+
}
76+
77+
@Test func estimatedCost_storedDirectly() {
78+
let summary = ProjectTokenSummary(
79+
id: "test",
80+
projectName: "test",
81+
inputTokens: 1_000_000,
82+
outputTokens: 500_000,
83+
cacheReadTokens: 2_000_000,
84+
cacheWriteTokens: 100_000,
85+
estimatedCost: 18.75
86+
)
87+
#expect(summary.estimatedCost == 18.75)
88+
#expect(summary.totalTokens == 3_600_000)
89+
}
90+
91+
@Test func largeTokenCounts_noOverflow() {
92+
let summary = ProjectTokenSummary(
93+
id: "large",
94+
projectName: "large",
95+
inputTokens: 500_000_000,
96+
outputTokens: 200_000_000,
97+
cacheReadTokens: 800_000_000,
98+
cacheWriteTokens: 100_000_000,
99+
estimatedCost: 5000.0
100+
)
101+
#expect(summary.totalTokens == 1_600_000_000)
102+
}
19103
}

spec/DATA_LAYER.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ Pricing table (per million tokens):
392392
- FSEventStream flags: `kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes`
393393
- WeakBox wrapper for C callback to prevent retain cycles
394394
- Debounce: 2 seconds via `DispatchWorkItem`
395-
- **Cache invalidation**: debounced handler calls `SessionLogReader.shared.invalidate()` and `StatsCacheReader.shared.invalidate()` before triggering refresh
395+
- **Selective cache invalidation**: stats-cache watcher only invalidates `StatsCacheReader`, JSONL watcher only invalidates `SessionLogReader` — avoids unnecessary cache rebuilds when only one data source changed. Fallback timer invalidates both (safe catch-all)
396396
- Fallback timer: 60 seconds — starts if either DispatchSource or FSEventStream fails (ensures changes are picked up even if one watcher is unavailable)
397397
- Calls `onChange` closure → triggers `viewModel.refresh()`
398398
- **Stats-cache retry**: if `stats-cache.json` doesn't exist on launch (normal before first `/stats` run), retries with exponential backoff (60s base, doubles each retry, capped at 300s, max 10 retries ~30 min). Counter resets on success or `stopWatching()`

0 commit comments

Comments
 (0)