Skip to main content

databricks_tui/fetchers/
cost.rs

1use crate::cli::DatabricksCli;
2use crate::fetchers::preview::run_sql;
3use crate::shape::TableData;
4
5const BUCKET_CASE: &str = "CASE \
6    WHEN u.sku_name LIKE '%JOBS%' THEN 'Jobs' \
7    WHEN u.sku_name LIKE '%DLT%' THEN 'DLT' \
8    WHEN u.sku_name LIKE '%SQL%' THEN 'SQL' \
9    WHEN u.sku_name LIKE '%ALL_PURPOSE%' THEN 'All-Purpose' \
10    ELSE 'Other' END";
11
12/// Attaches the list price that was in effect when the usage was billed.
13const PRICE_JOIN: &str = "LEFT JOIN system.billing.list_prices lp \
14    ON u.sku_name = lp.sku_name AND u.usage_unit = lp.usage_unit \
15    AND u.usage_end_time >= lp.price_start_time \
16    AND (lp.price_end_time IS NULL OR u.usage_end_time < lp.price_end_time)";
17
18#[derive(Debug, Clone)]
19pub struct CostDay {
20    pub date: String,
21    pub by_bucket: Vec<(String, f64)>,
22    pub total: f64,
23    pub total_usd: f64,
24}
25
26/// One row of "who burned the DBUs": a job, cluster or warehouse.
27#[derive(Debug, Clone)]
28pub struct Spender {
29    pub kind: String,
30    pub id: String,
31    pub dbus: f64,
32    pub usd: f64,
33}
34
35#[derive(Debug, Clone)]
36pub struct CostData {
37    pub days: Vec<CostDay>,
38    /// Per-bucket (name, dbus, usd) totals over the window, largest first.
39    pub buckets: Vec<(String, f64, f64)>,
40    pub total: f64,
41    pub total_usd: f64,
42    /// False when list_prices was unreadable and only DBUs are shown.
43    pub priced: bool,
44    /// Top resources by DBU over the window, largest first.
45    pub spenders: Vec<Spender>,
46    /// True when usage is filtered to the current workspace; false
47    /// means the whole account is shown (workspace id unresolved).
48    pub scoped: bool,
49}
50
51/// `AND u.workspace_id = '<id>'` when the current workspace is known.
52fn ws_clause(workspace_id: Option<&str>) -> String {
53    match workspace_id {
54        Some(id) => format!(" AND u.workspace_id = '{}'", id.replace('\'', "")),
55        None => String::new(),
56    }
57}
58
59fn priced_query(ws: &str) -> String {
60    format!(
61        "SELECT u.usage_date, {BUCKET_CASE} AS bucket, \
62         ROUND(SUM(u.usage_quantity), 2) AS dbus, \
63         ROUND(SUM(u.usage_quantity * COALESCE(lp.pricing.default, 0)), 2) AS usd \
64         FROM system.billing.usage u {PRICE_JOIN} \
65         WHERE u.usage_date >= date_sub(current_date(), 13){ws} \
66         GROUP BY 1, 2 ORDER BY 1"
67    )
68}
69
70const SPENDER_KIND: &str = "CASE \
71    WHEN u.usage_metadata.job_id IS NOT NULL THEN 'job' \
72    WHEN u.usage_metadata.warehouse_id IS NOT NULL THEN 'warehouse' \
73    WHEN u.usage_metadata.cluster_id IS NOT NULL THEN 'cluster' \
74    ELSE 'other' END";
75
76const SPENDER_ID: &str = "COALESCE(u.usage_metadata.job_id, \
77    u.usage_metadata.warehouse_id, u.usage_metadata.cluster_id, u.sku_name)";
78
79fn spenders_query(priced: bool, ws: &str) -> String {
80    let usd = if priced {
81        ", ROUND(SUM(u.usage_quantity * COALESCE(lp.pricing.default, 0)), 2) AS usd"
82    } else {
83        ""
84    };
85    let join = if priced {
86        format!("{PRICE_JOIN} ")
87    } else {
88        String::new()
89    };
90    // With prices, rank by dollars; DBUs are only a proxy without them.
91    let order = if priced { "4" } else { "3" };
92    format!(
93        "SELECT {SPENDER_KIND} AS kind, {SPENDER_ID} AS id, \
94         ROUND(SUM(u.usage_quantity), 2) AS dbus{usd} \
95         FROM system.billing.usage u {join}\
96         WHERE u.usage_date >= date_sub(current_date(), 13){ws} \
97         GROUP BY 1, 2 ORDER BY {order} DESC LIMIT 10"
98    )
99}
100
101fn parse_spenders(table: &TableData) -> Vec<Spender> {
102    table
103        .rows
104        .iter()
105        .filter_map(|row| {
106            let (kind, id, dbus, usd) = match row.as_slice() {
107                [k, i, d, u] => (k, i, d.parse().ok()?, u.parse().unwrap_or(0.0)),
108                [k, i, d] => (k, i, d.parse().ok()?, 0.0),
109                _ => return None,
110            };
111            Some(Spender {
112                kind: kind.clone(),
113                id: id.clone(),
114                dbus,
115                usd,
116            })
117        })
118        .collect()
119}
120
121fn plain_query(ws: &str) -> String {
122    format!(
123        "SELECT u.usage_date, {BUCKET_CASE} AS bucket, \
124         ROUND(SUM(u.usage_quantity), 2) AS dbus \
125         FROM system.billing.usage u \
126         WHERE u.usage_date >= date_sub(current_date(), 13){ws} \
127         GROUP BY 1, 2 ORDER BY 1"
128    )
129}
130
131fn aggregate(table: &TableData, priced: bool) -> CostData {
132    let mut days: Vec<CostDay> = Vec::new();
133    let mut bucket_totals: Vec<(String, f64, f64)> = Vec::new();
134    for row in &table.rows {
135        let (date, bucket, dbus, usd) = match row.as_slice() {
136            [d, b, v, u] => (d, b, v.parse().unwrap_or(0.0), u.parse().unwrap_or(0.0)),
137            [d, b, v] => (d, b, v.parse().unwrap_or(0.0), 0.0),
138            _ => continue,
139        };
140        if days.last().map(|d| &d.date) != Some(date) {
141            days.push(CostDay {
142                date: date.clone(),
143                by_bucket: Vec::new(),
144                total: 0.0,
145                total_usd: 0.0,
146            });
147        }
148        let day = days.last_mut().unwrap();
149        day.by_bucket.push((bucket.clone(), dbus));
150        day.total += dbus;
151        day.total_usd += usd;
152        match bucket_totals.iter_mut().find(|(b, _, _)| b == bucket) {
153            Some((_, t, tu)) => {
154                *t += dbus;
155                *tu += usd;
156            }
157            None => bucket_totals.push((bucket.clone(), dbus, usd)),
158        }
159    }
160    bucket_totals.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
161    let total = bucket_totals.iter().map(|(_, t, _)| t).sum();
162    let total_usd = bucket_totals.iter().map(|(_, _, u)| u).sum();
163
164    CostData {
165        days,
166        buckets: bucket_totals,
167        total,
168        total_usd,
169        priced,
170        spenders: Vec::new(),
171        scoped: false,
172    }
173}
174
175/// A pane item whose spend can be traced in system.billing.usage.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum ResourceKind {
178    Job,
179    Pipeline,
180}
181
182impl ResourceKind {
183    pub fn label(self) -> &'static str {
184        match self {
185            ResourceKind::Job => "job",
186            ResourceKind::Pipeline => "pipeline",
187        }
188    }
189
190    /// The `usage_metadata` field usage is attributed through.
191    fn column(self) -> &'static str {
192        match self {
193            ResourceKind::Job => "job_id",
194            ResourceKind::Pipeline => "dlt_pipeline_id",
195        }
196    }
197}
198
199/// The rolling windows a resource's spend is reported over, as
200/// (label, days). Ordered shortest first.
201pub const WINDOWS: [(&str, i64); 4] = [
202    ("last week", 7),
203    ("last month", 30),
204    ("last quarter", 90),
205    ("last year", 365),
206];
207
208/// Spend over one rolling window ending today.
209#[derive(Debug, Clone)]
210pub struct CostWindow {
211    pub label: &'static str,
212    pub days: i64,
213    pub dbus: f64,
214    pub usd: f64,
215    /// The same-length window immediately before this one, for a trend.
216    /// None when that window predates what `usage` retains (365 days).
217    pub prior: Option<(f64, f64)>,
218}
219
220impl CostWindow {
221    /// Change against the prior window as a fraction, in dollars when
222    /// priced and DBUs otherwise. None when there is nothing to compare
223    /// against — no prior window, or a prior window of zero.
224    pub fn trend(&self, priced: bool) -> Option<f64> {
225        let (prior_dbus, prior_usd) = self.prior?;
226        let (now, before) = if priced {
227            (self.usd, prior_usd)
228        } else {
229            (self.dbus, prior_dbus)
230        };
231        if before <= 0.0 {
232            return None;
233        }
234        Some((now - before) / before)
235    }
236}
237
238/// A single job's or pipeline's spend over the last year.
239#[derive(Debug, Clone)]
240pub struct ResourceCost {
241    pub kind: ResourceKind,
242    pub windows: Vec<CostWindow>,
243    /// Calendar-month totals as (YYYY-MM, dbus, usd), oldest first, at
244    /// most the last 12 months that recorded usage.
245    pub months: Vec<(String, f64, f64)>,
246    /// False when list_prices was unreadable and only DBUs are shown.
247    pub priced: bool,
248    /// True when usage is filtered to the current workspace.
249    pub scoped: bool,
250}
251
252impl ResourceCost {
253    pub fn is_empty(&self) -> bool {
254        self.months.is_empty()
255    }
256}
257
258/// `AND u.usage_metadata.<field> = '<id>'` for the selected resource.
259fn resource_clause(kind: ResourceKind, id: &str) -> String {
260    format!(
261        " AND u.usage_metadata.{} = '{}'",
262        kind.column(),
263        id.replace('\'', "")
264    )
265}
266
267/// Daily usage for one resource over the last 365 days — the retention
268/// of `system.billing.usage`. `days_ago` comes from the warehouse so the
269/// windows are cut on the workspace's own clock, not the client's.
270fn resource_query(priced: bool, kind: ResourceKind, id: &str, ws: &str) -> String {
271    let usd = if priced {
272        ", ROUND(SUM(u.usage_quantity * COALESCE(lp.pricing.default, 0)), 4) AS usd"
273    } else {
274        ""
275    };
276    let join = if priced {
277        format!("{PRICE_JOIN} ")
278    } else {
279        String::new()
280    };
281    let resource = resource_clause(kind, id);
282    format!(
283        "SELECT u.usage_date, \
284         CAST(datediff(current_date(), u.usage_date) AS INT) AS days_ago, \
285         ROUND(SUM(u.usage_quantity), 4) AS dbus{usd} \
286         FROM system.billing.usage u {join}\
287         WHERE u.usage_date >= date_sub(current_date(), 364){resource}{ws} \
288         GROUP BY 1, 2 ORDER BY 1"
289    )
290}
291
292/// One day of a resource's usage: (days_ago, month, dbus, usd).
293type ResourceDay = (i64, String, f64, f64);
294
295fn parse_resource_days(table: &TableData) -> Vec<ResourceDay> {
296    table
297        .rows
298        .iter()
299        .filter_map(|row| {
300            let (date, days_ago, dbus, usd) = match row.as_slice() {
301                [d, a, v, u] => (d, a, v, Some(u)),
302                [d, a, v] => (d, a, v, None),
303                _ => return None,
304            };
305            let days_ago: i64 = days_ago.parse().ok()?;
306            // "2026-07-01" -> "2026-07"
307            let month: String = date.chars().take(7).collect();
308            Some((
309                days_ago,
310                month,
311                dbus.parse().unwrap_or(0.0),
312                usd.map_or(0.0, |u| u.parse().unwrap_or(0.0)),
313            ))
314        })
315        .collect()
316}
317
318fn aggregate_resource(days: &[ResourceDay], kind: ResourceKind, priced: bool) -> ResourceCost {
319    let sum = |from: i64, to: i64| -> (f64, f64) {
320        days.iter()
321            .filter(|(ago, _, _, _)| *ago >= from && *ago < to)
322            .fold((0.0, 0.0), |(d, u), (_, _, dbus, usd)| (d + dbus, u + usd))
323    };
324    let windows = WINDOWS
325        .iter()
326        .map(|(label, span)| {
327            let (dbus, usd) = sum(0, *span);
328            CostWindow {
329                label,
330                days: *span,
331                dbus,
332                usd,
333                // Only compare against a window the table still covers.
334                prior: (span * 2 <= 365).then(|| sum(*span, span * 2)),
335            }
336        })
337        .collect();
338
339    let mut months: Vec<(String, f64, f64)> = Vec::new();
340    for (_, month, dbus, usd) in days {
341        match months.last_mut() {
342            Some((m, d, u)) if m == month => {
343                *d += dbus;
344                *u += usd;
345            }
346            _ => months.push((month.clone(), *dbus, *usd)),
347        }
348    }
349    if months.len() > 12 {
350        months.drain(..months.len() - 12);
351    }
352
353    ResourceCost {
354        kind,
355        windows,
356        months,
357        priced,
358        scoped: false,
359    }
360}
361
362/// Spend for a single job or pipeline: rolling week/month/quarter/year
363/// totals with a prior-window trend, plus per-month totals for the year.
364/// Usage is scoped to the given workspace when its id is known.
365pub async fn fetch_resource(
366    cli: &DatabricksCli,
367    warehouse_id: &str,
368    kind: ResourceKind,
369    id: &str,
370    workspace_id: Option<&str>,
371) -> Result<ResourceCost, String> {
372    let ws = ws_clause(workspace_id);
373    let mut data = match run_sql(cli, &resource_query(true, kind, id, &ws), warehouse_id).await {
374        Ok(table) => aggregate_resource(&parse_resource_days(&table), kind, true),
375        // list_prices may be unreadable — fall back to DBUs only.
376        Err(_) => {
377            let sql = resource_query(false, kind, id, &ws);
378            let table = run_sql(cli, &sql, warehouse_id).await?;
379            aggregate_resource(&parse_resource_days(&table), kind, false)
380        }
381    };
382    data.scoped = workspace_id.is_some();
383    Ok(data)
384}
385
386/// Resolves the numeric id of the workspace behind `host` by matching
387/// its URL in system.access.workspaces_latest. None when the table is
388/// unreadable or the match is not unique.
389pub async fn resolve_workspace_id(
390    cli: &DatabricksCli,
391    warehouse_id: &str,
392    host: &str,
393) -> Option<String> {
394    let hostname = host
395        .trim_start_matches("https://")
396        .trim_start_matches("http://")
397        .trim_end_matches('/')
398        .replace('\'', "");
399    let sql = format!(
400        "SELECT CAST(workspace_id AS STRING) \
401         FROM system.access.workspaces_latest \
402         WHERE workspace_url LIKE '%{hostname}%' LIMIT 2"
403    );
404    let table = run_sql(cli, &sql, warehouse_id).await.ok()?;
405    match table.rows.as_slice() {
406        [row] => row.first().cloned(),
407        _ => None,
408    }
409}
410
411/// Daily DBU usage (and list-price dollar estimates when readable) for
412/// the last 14 days from system.billing tables, plus the top resources
413/// by DBU so spikes can be traced to a job/cluster/warehouse. With a
414/// workspace id, usage is scoped to that workspace instead of the
415/// whole account.
416pub async fn fetch(
417    cli: &DatabricksCli,
418    warehouse_id: &str,
419    workspace_id: Option<&str>,
420) -> Result<CostData, String> {
421    let ws = ws_clause(workspace_id);
422    let mut data = match run_sql(cli, &priced_query(&ws), warehouse_id).await {
423        Ok(table) => aggregate(&table, true),
424        // list_prices may be unreadable — fall back to DBUs only.
425        Err(_) => {
426            let table = run_sql(cli, &plain_query(&ws), warehouse_id).await?;
427            aggregate(&table, false)
428        }
429    };
430    data.scoped = workspace_id.is_some();
431    // Spenders are a bonus — a failure here shouldn't sink the whole view.
432    if let Ok(table) = run_sql(cli, &spenders_query(data.priced, &ws), warehouse_id).await {
433        data.spenders = parse_spenders(&table);
434        if data.priced {
435            data.spenders.sort_by(|a, b| {
436                b.usd
437                    .partial_cmp(&a.usd)
438                    .unwrap_or(std::cmp::Ordering::Equal)
439            });
440        }
441    }
442    Ok(data)
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    /// A day of usage `days_ago` days back, costing 1 DBU / $2.
450    fn day(days_ago: i64, month: &str) -> ResourceDay {
451        (days_ago, month.to_string(), 1.0, 2.0)
452    }
453
454    #[test]
455    fn windows_are_cumulative_and_exclude_their_own_prior() {
456        // One DBU a day for 60 days, today included.
457        let days: Vec<ResourceDay> = (0..60).map(|d| day(d, "2026-07")).collect();
458        let cost = aggregate_resource(&days, ResourceKind::Job, true);
459
460        let week = &cost.windows[0];
461        assert_eq!(week.days, 7);
462        assert_eq!(week.dbus, 7.0);
463        assert_eq!(week.usd, 14.0);
464        // The 7 days before that, not the 7 counted above.
465        assert_eq!(week.prior, Some((7.0, 14.0)));
466        assert_eq!(week.trend(true), Some(0.0));
467
468        assert_eq!(cost.windows[1].dbus, 30.0);
469        assert_eq!(cost.windows[2].dbus, 60.0);
470        assert_eq!(cost.windows[3].dbus, 60.0);
471    }
472
473    #[test]
474    fn year_window_has_no_prior_to_compare_with() {
475        // 365 days of retention can't cover the year before last.
476        let cost = aggregate_resource(&[day(0, "2026-07")], ResourceKind::Job, true);
477        let year = cost.windows.last().unwrap();
478        assert_eq!(year.days, 365);
479        assert_eq!(year.prior, None);
480        assert_eq!(year.trend(true), None);
481    }
482
483    #[test]
484    fn trend_is_none_when_the_prior_window_is_empty() {
485        // Usage started three days ago: nothing to compare against.
486        let days = vec![day(0, "2026-07"), day(1, "2026-07"), day(2, "2026-07")];
487        let cost = aggregate_resource(&days, ResourceKind::Job, true);
488        assert_eq!(cost.windows[0].prior, Some((0.0, 0.0)));
489        assert_eq!(cost.windows[0].trend(true), None);
490    }
491
492    #[test]
493    fn trend_follows_dbus_when_prices_are_unreadable() {
494        let mut days: Vec<ResourceDay> = (0..7)
495            .map(|d| (d, "2026-07".to_string(), 2.0, 0.0))
496            .collect();
497        days.extend((7..14).map(|d| (d, "2026-07".to_string(), 1.0, 0.0)));
498        let cost = aggregate_resource(&days, ResourceKind::Job, false);
499        // 14 DBU this week against 7 last week.
500        assert_eq!(cost.windows[0].trend(false), Some(1.0));
501    }
502
503    #[test]
504    fn months_roll_up_in_order_and_keep_the_last_twelve() {
505        // 14 months of one day each, oldest first as the query returns them.
506        let months: Vec<String> = (1..=12)
507            .map(|m| format!("2025-{m:02}"))
508            .chain((1..=2).map(|m| format!("2026-{m:02}")))
509            .collect();
510        let days: Vec<ResourceDay> = months
511            .iter()
512            .enumerate()
513            .map(|(i, m)| day((13 - i as i64) * 30, m))
514            .collect();
515        let cost = aggregate_resource(&days, ResourceKind::Pipeline, true);
516        assert_eq!(cost.months.len(), 12);
517        // The two oldest months are dropped, and the rest stay in order.
518        assert_eq!(cost.months.first().unwrap().0, "2025-03");
519        assert_eq!(cost.months.last().unwrap().0, "2026-02");
520    }
521
522    #[test]
523    fn same_month_days_are_summed_into_one_bar() {
524        let days = vec![day(1, "2026-07"), day(2, "2026-07"), day(40, "2026-06")];
525        let cost = aggregate_resource(&days, ResourceKind::Job, true);
526        assert_eq!(cost.months.len(), 2);
527        assert_eq!(cost.months[0], ("2026-07".to_string(), 2.0, 4.0));
528    }
529
530    #[test]
531    fn resource_clause_targets_the_right_metadata_field() {
532        assert!(resource_clause(ResourceKind::Job, "42").contains("usage_metadata.job_id = '42'"));
533        assert!(resource_clause(ResourceKind::Pipeline, "abc")
534            .contains("usage_metadata.dlt_pipeline_id = 'abc'"));
535        // Quotes are stripped rather than escaped, as elsewhere here.
536        assert_eq!(
537            resource_clause(ResourceKind::Job, "4'2"),
538            " AND u.usage_metadata.job_id = '42'"
539        );
540    }
541}