forge_server/surface/
usage.rs1use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use forge_primitives::token_usage::UsageReport;
7
8use super::ViewSurface;
9
10const USAGE_WINDOW: Duration = Duration::from_secs(5);
14
15impl ViewSurface {
16 pub async fn usage(self: &Arc<Self>) -> anyhow::Result<UsageReport> {
32 if let Some(report) = self.held_usage() {
33 return Ok(report);
34 }
35 #[cfg(any(test, feature = "testing"))]
38 self.pass_test_usage_gate().await;
39 let _scan = self.usage_scan.lock().await;
44 if let Some(report) = self.held_usage() {
45 return Ok(report);
46 }
47 #[cfg(any(test, feature = "testing"))]
48 self.usage_scans.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
49 let workspace = Arc::clone(&self.workspace);
50 let report = tokio::task::spawn_blocking(move || workspace.scan_usage())
51 .await
52 .map_err(|error| anyhow::anyhow!("the usage scan did not finish: {error}"))?;
53 self.remember_usage(&report);
54 self.refill_pricing();
55 Ok(report)
56 }
57
58 fn refill_pricing(self: &Arc<Self>) {
73 if self.pricing_refill.swap(true, std::sync::atomic::Ordering::AcqRel) {
74 return;
75 }
76 let surface = Arc::clone(self);
77 tokio::spawn(async move {
78 if surface.workspace.refresh_pricing().await {
79 let workspace = Arc::clone(&surface.workspace);
80 match tokio::task::spawn_blocking(move || workspace.scan_usage()).await {
81 Ok(report) => surface.remember_usage(&report),
82 Err(error) => {
83 tracing::warn!(
84 target: "forge_server::surface::usage",
85 %error,
86 "the re-scan after a pricing fetch did not finish; the next ask re-scans",
87 );
88 }
89 }
90 }
91 surface.pricing_refill.store(false, std::sync::atomic::Ordering::Release);
92 });
93 }
94
95 fn held_usage(&self) -> Option<UsageReport> {
97 let held = self.usage_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
98 held.as_ref()
99 .filter(|(at, _)| at.elapsed() < USAGE_WINDOW)
100 .map(|(_, report)| report.clone())
101 }
102
103 fn remember_usage(&self, report: &UsageReport) {
104 let mut held = self.usage_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
105 *held = Some((Instant::now(), report.clone()));
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use std::sync::Arc;
112
113 use crate::surface::ViewSurface;
114
115 const A_RECORD: &str = r#"{"type":"assistant","timestamp":"2025-01-02T03:04:05.000Z","message":{"id":"m-usage","model":"claude-opus-5","usage":{"input_tokens":7,"output_tokens":11,"cache_read_input_tokens":13,"cache_creation_input_tokens":17}}}"#;
121
122 fn pool_with_one_record() -> (Arc<forge_workspace::Workspace>, tempfile::TempDir) {
123 let (workspace, dir) = crate::surface::testing::workspace();
124 let pool = dir.path().join("projects").join("-tmp-proj");
125 std::fs::create_dir_all(&pool).expect("the pool's project dir");
126 std::fs::write(pool.join("a-session.jsonl"), A_RECORD).expect("write the record");
127 (workspace, dir)
128 }
129
130 fn surface(workspace: Arc<forge_workspace::Workspace>) -> Arc<ViewSurface> {
131 Arc::new(ViewSurface::new(workspace))
132 }
133
134 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
144 async fn concurrent_usage_misses_run_one_scan() {
145 let (workspace, _dir) = pool_with_one_record();
146 let surface = Arc::new(ViewSurface::new(workspace));
147 let gate = Arc::new(tokio::sync::Barrier::new(2));
148 surface.seed_test_usage_gate(Arc::clone(&gate));
149
150 let ask = |surface: Arc<ViewSurface>| tokio::spawn(async move { surface.usage().await });
151 let (first, second) = tokio::join!(ask(Arc::clone(&surface)), ask(Arc::clone(&surface)));
152 assert_eq!(
153 first
154 .expect("the first ask's task")
155 .expect("the first ask answers")
156 .lifetime
157 .total
158 .input,
159 7,
160 "the pool's record reaches the first reader",
161 );
162 assert_eq!(
163 second
164 .expect("the second ask's task")
165 .expect("the second ask answers")
166 .lifetime
167 .total
168 .input,
169 7,
170 "and the one scan's report reaches the reader that waited for it",
171 );
172 assert_eq!(surface.test_usage_scans(), 1, "two misses ran one pool scan");
173 }
174
175 #[tokio::test]
179 async fn usage_scans_the_pool_the_transcripts_sit_in() {
180 let (workspace, _dir) = pool_with_one_record();
181
182 let report = surface(workspace).usage().await.expect("the scan answers");
183
184 assert_eq!(
185 report.lifetime.total.input, 7,
186 "the lifetime window carries the record's input tokens"
187 );
188 assert_eq!(
189 report.lifetime.total.output, 11,
190 "and its output tokens, so the read is the scanner's own"
191 );
192 assert_eq!(report.today.total.input, 0, "a record from a past day is in no rolling window");
193 assert_eq!(
194 report.lifetime.by_model.iter().map(|row| row.label.as_str()).collect::<Vec<_>>(),
195 vec!["claude-opus-5"],
196 "and the model it was logged under is the row it lands on",
197 );
198 }
199
200 #[tokio::test]
205 async fn a_second_ask_inside_the_window_is_served_from_the_cache() {
206 let (workspace, dir) = pool_with_one_record();
207 let surface = surface(workspace);
208
209 let first = surface.usage().await.expect("the first scan answers");
210 assert_eq!(first.lifetime.total.input, 7, "precondition: the pool's record is counted");
211
212 std::fs::write(
213 dir.path().join("projects").join("-tmp-proj").join("another.jsonl"),
214 A_RECORD.replace("\"m-usage\"", "\"m-usage-2\""),
215 )
216 .expect("a second record lands in the pool");
217
218 let second = surface.usage().await.expect("the second ask answers");
219 assert_eq!(
220 second.lifetime.total.input, 7,
221 "a ask inside the window answers what the first scan took, so the pool is walked \
222 once per window rather than once per ask",
223 );
224 }
225
226 const PRICING: &str = r#"{
230 "claude-opus-5": {
231 "input_cost_per_token": 0.000001,
232 "output_cost_per_token": 0.000001,
233 "cache_read_input_token_cost": 0.000001,
234 "cache_creation_input_token_cost": 0.000001
235 }
236 }"#;
237
238 #[tokio::test]
245 async fn a_refill_lands_a_priced_report_for_the_next_ask() {
246 use std::sync::atomic::{AtomicUsize, Ordering};
247
248 let calls = Arc::new(AtomicUsize::new(0));
249 let gate = Arc::new(tokio::sync::Semaphore::new(0));
252 let counter = Arc::clone(&calls);
253 let held = Arc::clone(&gate);
254 let fetcher: forge_workspace::PricingFetcher = Arc::new(move || {
255 counter.fetch_add(1, Ordering::AcqRel);
256 let held = Arc::clone(&held);
257 Box::pin(async move {
258 let Ok(_permit) = held.acquire().await else {
259 return None;
260 };
261 Some(PRICING.to_owned())
262 })
263 });
264
265 let (workspace, dir) = crate::surface::testing::workspace_with_pricing_fetcher(fetcher);
266 let pool = dir.path().join("projects").join("-tmp-proj");
267 std::fs::create_dir_all(&pool).expect("the pool's project dir");
268 std::fs::write(pool.join("a-session.jsonl"), A_RECORD).expect("write the record");
269 let surface = surface(workspace);
270
271 let first = surface.usage().await.expect("the first scan answers");
272 assert!(!first.pricing_available, "precondition: the cache is cold");
273
274 for _ in 0..200 {
276 if calls.load(Ordering::Acquire) > 0 {
277 break;
278 }
279 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
280 }
281 assert_eq!(calls.load(Ordering::Acquire), 1, "the scan's own kick did not fetch");
282
283 surface.refill_pricing();
285 surface.refill_pricing();
286 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
287 assert_eq!(calls.load(Ordering::Acquire), 1, "the stampede guard admitted a second fetch");
288
289 gate.add_permits(1);
291 let mut priced = false;
292 for _ in 0..200 {
293 if surface.usage().await.expect("the poll answers").pricing_available {
296 priced = true;
297 break;
298 }
299 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
300 }
301 assert!(priced, "the refill's landing never reached the cache");
302 let report = surface.usage().await.expect("the last ask answers");
303 assert!(
304 report.lifetime.total.cost_usd > 0.0,
305 "and the replaced report prices the record: {}",
306 report.lifetime.total.cost_usd,
307 );
308 assert_eq!(calls.load(Ordering::Acquire), 1, "the landed refill refetched");
309 }
310
311 #[tokio::test]
317 async fn a_cached_price_table_prices_the_report() {
318 let (workspace, dir) = crate::surface::testing::workspace_with_pricing(PRICING);
319 let pool = dir.path().join("projects").join("-tmp-proj");
320 std::fs::create_dir_all(&pool).expect("the pool's project dir");
321 std::fs::write(pool.join("a-session.jsonl"), A_RECORD).expect("write the record");
322
323 let report = surface(workspace).usage().await.expect("the scan answers");
324
325 assert!(report.pricing_available, "a stored table makes the report priced");
326 assert!(
327 report.lifetime.total.cost_usd > 0.0,
328 "and the record's tokens carry a cost, not a zero: {}",
329 report.lifetime.total.cost_usd,
330 );
331 }
332}