Skip to main content

forge_server/surface/
usage.rs

1//! `usage()`: the token/cost report a `/usage` view drew.
2
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use forge_primitives::token_usage::UsageReport;
7
8use super::ViewSurface;
9
10/// How long a scan answers for. A client refreshing on a timer asks again
11/// every so often, and the pool does not change fast enough to pay a walk per
12/// ask; one walk per window is the same answer a reader would have had.
13const USAGE_WINDOW: Duration = Duration::from_secs(5);
14
15impl ViewSurface {
16    /// Scan the shared session-JSONL pool into the four windows a usage view
17    /// drew.
18    ///
19    /// The scan walks the pool and parses every file behind it, so it runs on
20    /// the blocking pool rather than on the task that serves a socket: a
21    /// connection must not wait on a disk walk, and neither must a render.
22    ///
23    /// A scan that panicked is an error rather than an empty report - an
24    /// all-zero report reads as a pool with no usage in it, which is a
25    /// different answer from "the scan did not run".
26    ///
27    /// A scan also kicks the pricing refill (`refill_pricing`): the answer
28    /// that goes out carries costs from whatever table was cached, the
29    /// first-open behaviour a view has, and the priced report lands in the
30    /// cache for the next ask.
31    pub async fn usage(self: &Arc<Self>) -> anyhow::Result<UsageReport> {
32        if let Some(report) = self.held_usage() {
33            return Ok(report);
34        }
35        // The test gate sits between the miss and the guard, so two asks are
36        // certain to be past their cache check together before either scans.
37        #[cfg(any(test, feature = "testing"))]
38        self.pass_test_usage_gate().await;
39        // **One scan for every concurrent miss.** Two readers asking with a
40        // cold cache would otherwise walk the whole pool side by side and
41        // remember the same answer twice; the second waits here and finds the
42        // first's report when it looks again.
43        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    /// Refresh the pricing table in the background, and re-scan once it
59    /// lands, so the next ask answers priced figures.
60    ///
61    /// **The fetch must not be waited on.** It crosses the network and can
62    /// take seconds - up to the litellm timeout - and a subject's ask is
63    /// answered inside its connection's own loop, where a wait on one ask
64    /// stalls every other message on that socket. This is a view's move
65    /// instead (scan now, re-price when the fetch lands), with the
66    /// re-scan replacing the held report so the re-ask finds it.
67    ///
68    /// `Workspace::refresh_pricing` answers `false` for a cache that is
69    /// still fresh, so a kick costs a redb read when there is nothing to
70    /// fetch - and the once-a-day fetch happens on the first scan after the
71    /// window lapsed, whatever view asked.
72    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    /// The last scan, while it is inside [`USAGE_WINDOW`].
96    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    /// One assistant record as the CLI logs it, which is what the scan
116    /// counts. The timestamp is fixed in the past, so the record lands in
117    /// the lifetime window and in none of the rolling ones whatever the
118    /// clock says - a "now" stamp would flip which windows hold it at
119    /// midnight.
120    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    /// **Two concurrent misses run one scan.** Both callers answer with the
135    /// pool's report, and only one walk of it happened - a reader that
136    /// skipped the guard would double exactly the work the cache exists to
137    /// avoid.
138    ///
139    /// **The race is a barrier, not a large pool.** Both asks are held past
140    /// their cache check and released together, so the second is provably a
141    /// miss contending with the first when the guard is what decides. Without
142    /// the guard both would scan and the counter would read two.
143    #[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    /// Catches a read that answers an empty report rather than the pool's:
176    /// the counts are the record's own, so a verb that skipped the scan
177    /// answers zeroes.
178    #[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    /// A second ask inside the window is served from the first scan, which is
201    /// what keeps a client refreshing on a timer from walking the whole pool
202    /// per ask. The pool changes between the two asks, so a read that re-scanned
203    /// would report the new file.
204    #[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    /// A litellm-shaped table for the one model the record names:
227    /// 1e-6 dollars per token makes the record's tokens carry a
228    /// nonzero cost.
229    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    /// **The refill, driven for real**: a cold scan answers unpriced and
239    /// kicks the fetch; the fetch is held open so the guard can be shown to
240    /// admit exactly one more kick while it is out; its landing re-scans;
241    /// and the replacement is observable because the next ask lands inside
242    /// the held window - it cannot have re-scanned itself, so priced
243    /// figures there can only be the report the refill left.
244    #[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        // One permit, released by the test: the fetch parks on it until the
250        // guard has been probed.
251        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        // The kicked fetch starts and parks on the gate.
275        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        // Two more kicks while the fetch is out: the guard admits neither.
284        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        // The fetch lands; its re-scan replaces the held report.
290        gate.add_permits(1);
291        let mut priced = false;
292        for _ in 0..200 {
293            // Each ask inside the held window answers the report the refill
294            // left - a priced answer IS the replacement.
295            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    /// **A cached price table prices the scan**, which is the half of the
312    /// pricing story that is testable offline: the fetch itself crosses the
313    /// network and is a view's behaviour re-homed (`refill_pricing`),
314    /// verified live. The table is seeded fresh, so the background refill
315    /// finds nothing to fetch and never leaves the machine.
316    #[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}