dwara_core/analytics/
exports.rs

1//! Scheduled usage-report exports (DW-120).
2//!
3//! DW-043 made the analytics store QUERYABLE live; this module makes
4//! it REPORTABLE durably: a background worker closes each UTC calendar
5//! window of the configured kind (hourly/daily/monthly) and writes the
6//! per-consumer usage statement — period totals of requests, errors,
7//! error rate, rate-limited and shed counts, plus the quota budget
8//! figures a billing pipeline consumes — as one deterministic file per
9//! configured format (CSV and JSON in v1; Parquet is deferred to the
10//! DW-156 backlog) into a configured directory. Distinct from the
11//! query API on purpose: this is durable, scheduled OUTPUT, not an ad
12//! hoc query.
13//!
14//! # Reconciliation with the query API (the acceptance contract)
15//!
16//! The statement's analytics numbers ARE the structured query
17//! endpoint's numbers: [`run_export`] builds them by calling
18//! [`query::structured`] itself (grouped by `consumer`, plus one
19//! ungrouped totals row) over the same `rollup_fixed` tables through
20//! the same aggregation helpers. Equality with `POST
21//! /analytics/query` for the same period holds BY CONSTRUCTION — and
22//! is pinned by a test that parses the written JSON file and compares
23//! it against an independent `structured()` call. The per-statement
24//! consumer cap is 10_000 (the grouped query's limit).
25//!
26//! # Scheduling
27//!
28//! The worker (spawned by the dataplane via
29//! `DataPlane::spawn_export_worker` in `dataplane::proxy`) ticks
30//! on the same interval-plus-shutdown-watch machinery as the rollup
31//! cascade, reads the CURRENT config generation each tick (a reload
32//! can add/remove exports without a restart), and exports every
33//! closed window that has no successful run record yet — oldest
34//! first, so a restart backfills missed windows. A window is due once
35//! its close is at least
36//! [`EXPORT_DELAY_MS`] in the past (writer flush + rollup grace +
37//! cascade headroom); the delay keeps the statement as complete as
38//! the store can make it. [`run_export`] also forces one `maintain()`
39//! pass first, so even a manual trigger reads settled rollups.
40//!
41//! # Quota columns and window alignment
42//!
43//! The quota figures come from the STATE store's `quota_counters`
44//! (DW-033) — a domain this module cannot import (see
45//! `scripts/check_deps.py`), so callers ABOVE both domains (the
46//! dataplane worker, the admin trigger endpoint) read the counters
47//! and hand them in as plain [`QuotaFigures`] per consumer name. The
48//! alignment rule: a budget's figures appear only when its quota
49//! window FULLY CONTAINS the export window — a daily export carries
50//! the same-UTC-day `daily` counter (exact period consumption) and
51//! the month-to-date `monthly` counter; a monthly export carries only
52//! the `monthly` counter (the daily budget's per-day rows do not sum
53//! to the month by construction and are omitted, never fabricated).
54//! The monthly column reads the store's LIVE month-to-date counter,
55//! so a backfilled or re-exported window can carry a different
56//! `quota_monthly_used` than the original run: analytics columns are
57//! deterministic, quota columns are generation-time snapshots
58//! ([`QuotaBudget`]'s `window_start_epoch_s`/`reset_epoch_s` bound
59//! what `used` covers).
60//!
61//! # Retention and partial windows
62//!
63//! Rollup retention deletes old windows (DW-043); a window older than
64//! the chosen granularity's retention may be undercounted without the
65//! store being able to know. Such runs are written with
66//! `partial: true` in the file header and run record — real data,
67//! flagged — and the scheduler never AUTO-exports windows past the
68//! horizon (only a manual trigger can, still flagged). Quiet windows
69//! (no traffic) are not loss: the statement's counts are exact
70//! whenever `partial` is false.
71//!
72//! # Output shape
73//!
74//! Files are named `dwara-usage-{window}-{utc-stamp}.{ext}` (stamp
75//! `YYYY-MM-DDThHZ` hourly, `YYYY-MM-DD` daily, `YYYY-MM-` trimmed to
76//! `YYYY-MM` monthly) and written atomically and DURABLY (temp
77//! file, then fsync, rename, and a directory fsync — all inside the
78//! destination directory): a re-export of the same window simply
79//! overwrites — output is idempotent, the rollup recompute
80//! philosophy. CSV is RFC 4180 (CRLF rows, quoted fields containing
81//! commas/quotes/CR/LF, doubled quotes); absent quota cells are EMPTY
82//! strings (zero means configured-and-zero-used). JSON is pretty and
83//! self-describing (window bounds, generation time, partial flag,
84//! totals, per-consumer rows).
85
86use std::collections::HashMap;
87use std::path::Path;
88use std::sync::atomic::AtomicU64;
89use std::sync::atomic::Ordering;
90
91use serde::Deserialize;
92use serde::Serialize;
93
94use super::query;
95use super::EmbeddedAnalytics;
96
97/// How long after a window's close before the scheduler will export
98/// it: the writer's flush tick plus the rollup grace plus cascade
99/// headroom, with margin. Not a correctness knob — a manual trigger
100/// bypasses it.
101pub const EXPORT_DELAY_MS: i64 = 5 * 60_000;
102
103/// The scheduler worker's tick (same cadence class as the rollup
104/// cascade's 30 s; the per-tick work is one cheap SQL probe).
105pub const EXPORT_TICK_MS: u64 = 30_000;
106
107/// Backfill bound per tick: after a long downtime the oldest-first
108/// catch-up drains at most this many windows per tick (one query +
109/// file writes each), keeping any single tick's work bounded.
110pub const MAX_CATCHUP_WINDOWS: usize = 64;
111
112/// How long a FAILED window stays skipped before the scheduler
113/// retries it: without this, a persistent failure (unwritable
114/// directory, unusable store) would re-burn a full export attempt on
115/// every 30 s tick, forever. Time-based, not attempt-count-based —
116/// no schema change.
117pub const FAILURE_RETRY_MS: i64 = 10 * 60_000;
118
119/// Process-unique suffix for temp files (concurrent exports of the
120/// same window may race; atomic rename means last-writer-wins, never
121/// a torn file).
122static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
123
124/// One budget's figures as carried in a statement (plain DTO: the
125/// state-store types stay in the state domain, which this module
126/// cannot import).
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
128pub struct QuotaBudget {
129    /// Requests counted against the budget in its window.
130    pub used: u64,
131    /// The configured cap.
132    pub limit: u64,
133    /// The counter's window start (epoch seconds) — the denominator's
134    /// bounds, so a billing consumer knows exactly what `used` covers.
135    pub window_start_epoch_s: i64,
136    /// When the budget becomes whole again (epoch seconds).
137    pub reset_epoch_s: i64,
138}
139
140/// A consumer's quota figures for one export window (see the module
141/// docs' alignment rule: only budgets whose window fully contains the
142/// export window appear).
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
144pub struct QuotaFigures {
145    pub daily: Option<QuotaBudget>,
146    pub monthly: Option<QuotaBudget>,
147}
148
149/// The export window kinds (DW-120): fixed UTC calendar windows, the
150/// closed set behind `analytics.exports.window`.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub enum WindowKind {
153    Hourly,
154    Daily,
155    Monthly,
156}
157
158impl WindowKind {
159    /// Parse the admin trigger's `window` field. The closed set.
160    pub fn parse(s: &str) -> Option<Self> {
161        match s {
162            "hourly" => Some(WindowKind::Hourly),
163            "daily" => Some(WindowKind::Daily),
164            "monthly" => Some(WindowKind::Monthly),
165            _ => None,
166        }
167    }
168
169    pub fn as_str(self) -> &'static str {
170        match self {
171            WindowKind::Hourly => "hourly",
172            WindowKind::Daily => "daily",
173            WindowKind::Monthly => "monthly",
174        }
175    }
176
177    /// The (start, end) UTC calendar window containing `ts_ms`, in
178    /// the analytics time domain (epoch milliseconds).
179    pub fn window_of(self, ts_ms: i64) -> (i64, i64) {
180        match self {
181            WindowKind::Hourly => {
182                let g = 3_600_000_i64;
183                let start = ts_ms.div_euclid(g) * g;
184                (start, start + g)
185            }
186            WindowKind::Daily => {
187                let g = 86_400_000_i64;
188                let start = ts_ms.div_euclid(g) * g;
189                (start, start + g)
190            }
191            WindowKind::Monthly => month_window_ms(ts_ms),
192        }
193    }
194
195    /// The rollup granularity the statement queries (see module docs):
196    /// 1-hour rows for hourly and daily windows (short settle lag,
197    /// well inside the 1h table's retention), 1-day rows for monthly
198    /// windows (a month of 1-hour windows would outrun the 1h table's
199    /// default retention).
200    pub fn gran(self) -> usize {
201        match self {
202            WindowKind::Hourly | WindowKind::Daily => 2,
203            WindowKind::Monthly => 3,
204        }
205    }
206
207    /// One deterministic filename (no extension) for a window: the
208    /// UTC stamp `YYYY-MM-DDThHZ` (hourly), `YYYY-MM-DD` (daily), or
209    /// `YYYY-MM` (monthly).
210    fn file_stem(self, window_start_ms: i64) -> String {
211        let (y, m, d) = civil_from_days(window_start_ms.div_euclid(86_400_000));
212        let stamp = match self {
213            WindowKind::Hourly => {
214                let hour = window_start_ms.rem_euclid(86_400_000) / 3_600_000;
215                format!("{y:04}-{m:02}-{d:02}T{hour:02}Z")
216            }
217            WindowKind::Daily => format!("{y:04}-{m:02}-{d:02}"),
218            WindowKind::Monthly => format!("{y:04}-{m:02}"),
219        };
220        format!("dwara-usage-{}-{stamp}", self.as_str())
221    }
222}
223
224/// One output format (DW-120). Parquet is deliberately absent (lean
225/// dependency rule; see the DW-156 backlog issue).
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227pub enum ExportFormat {
228    Csv,
229    Json,
230    /// SCALE-12 (#190): CSV with Parquet-compatible metadata header.
231    ParquetCsv,
232}
233
234impl ExportFormat {
235    pub fn as_str(self) -> &'static str {
236        match self {
237            ExportFormat::Csv => "csv",
238            ExportFormat::Json => "json",
239            ExportFormat::ParquetCsv => "parquet_csv",
240        }
241    }
242
243    fn extension(self) -> &'static str {
244        match self {
245            ExportFormat::Csv => "csv",
246            ExportFormat::Json => "json",
247            ExportFormat::ParquetCsv => "parquet.csv",
248        }
249    }
250}
251
252/// Config-vocabulary conversion without repeating the same match
253/// arms per type: `impl_from_config!(Ty, ConfigEnum, Variants...)`
254/// generates `From<config::ConfigEnum> for Ty` over identically named
255/// variants.
256macro_rules! impl_from_config {
257    ($ty:ident, $cfg:ident, $($v:ident),+ $(,)?) => {
258        impl From<crate::config::$cfg> for $ty {
259            fn from(c: crate::config::$cfg) -> Self {
260                match c {
261                    $(crate::config::$cfg::$v => $ty::$v,)+
262                }
263            }
264        }
265    };
266}
267
268impl_from_config!(WindowKind, AnalyticsExportWindow, Hourly, Daily, Monthly);
269impl_from_config!(ExportFormat, AnalyticsExportFormat, Csv, Json, ParquetCsv);
270
271/// One per-consumer statement row (the JSON member shape; the CSV
272/// writer flattens the same fields in a fixed column order).
273#[derive(Debug, Serialize)]
274pub struct StatementRow {
275    pub consumer: String,
276    pub requests: i64,
277    pub errors: i64,
278    pub error_rate: f64,
279    pub rate_limited: i64,
280    pub shed: i64,
281    pub avg_ms: f64,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub quota_daily: Option<QuotaBudget>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub quota_monthly: Option<QuotaBudget>,
286    /// DW-079: AI spend totals for this consumer over the window.
287    /// Populated from the `ai_spend` table via
288    /// [`query::spend_summary`]; zero when no AI traffic or analytics
289    /// is off.
290    pub prompt_tokens: i64,
291    pub completion_tokens: i64,
292    pub total_tokens: i64,
293    /// Priced cost in integer micro-USD.
294    pub cost_micros: i64,
295}
296
297/// One per-model spend breakdown row (DW-079): the JSON statement's
298/// `spend_by_model` array member. Aggregated from the `ai_spend` table
299/// grouped by model over the export window.
300#[derive(Debug, Serialize)]
301pub struct SpendByModel {
302    pub model: String,
303    pub prompt_tokens: i64,
304    pub completion_tokens: i64,
305    pub total_tokens: i64,
306    pub cost_micros: i64,
307    pub request_count: i64,
308}
309
310/// The JSON file's document shape.
311#[derive(Debug, Serialize)]
312struct UsageStatement {
313    kind: &'static str,
314    window: &'static str,
315    from_ms: i64,
316    to_ms: i64,
317    generated_at_ms: i64,
318    partial: bool,
319    /// Distinct rollup windows with any data in range (informational:
320    /// quiet windows have no rows and are not loss).
321    windows_nonempty: i64,
322    totals: Option<query::QueryRow>,
323    consumers: Vec<StatementRow>,
324    /// DW-079: per-model spend breakdown for the window (empty when
325    /// no AI traffic or analytics is off).
326    #[serde(skip_serializing_if = "Vec::is_empty")]
327    spend_by_model: Vec<SpendByModel>,
328}
329
330/// One export attempt's ledger row (the `export_runs` table and the
331/// admin list endpoint's shape).
332#[derive(Debug, Serialize)]
333pub struct ExportRun {
334    pub kind: String,
335    pub window_start_ms: i64,
336    pub window_end_ms: i64,
337    /// `ok` or `failed`.
338    pub status: String,
339    pub partial: bool,
340    pub formats: Vec<String>,
341    pub directory: String,
342    pub consumers: usize,
343    pub requests: i64,
344    pub windows_nonempty: i64,
345    pub error: String,
346    pub generated_at_ms: i64,
347}
348
349/// The effective exports schedule from the config block: window kind
350/// (default daily) and formats (default both).
351pub fn effective_window(cfg: &crate::config::AnalyticsExports) -> WindowKind {
352    cfg.window
353        .map(WindowKind::from)
354        .unwrap_or(WindowKind::Daily)
355}
356
357/// The effective formats: the configured list, or both when the list
358/// is omitted or empty (the config default and the validation rule
359/// agree — an empty list MEANS both, it is never an error).
360pub fn effective_formats(cfg: &crate::config::AnalyticsExports) -> Vec<ExportFormat> {
361    if cfg.formats.is_empty() {
362        vec![ExportFormat::Csv, ExportFormat::Json]
363    } else {
364        cfg.formats.iter().map(|f| ExportFormat::from(*f)).collect()
365    }
366}
367
368/// Export one window: settle rollups, read the statement through the
369/// query API's own aggregation, write one file per format, and upsert
370/// the run record. `quota_at` supplies the quota figures for the
371/// window (period bounds in epoch SECONDS). Never panics on I/O: a
372/// failed run is recorded `failed` with the error string.
373pub fn run_export(
374    store: &EmbeddedAnalytics,
375    directory: &str,
376    window: WindowKind,
377    window_start_ms: i64,
378    formats: &[ExportFormat],
379    quota_at: &dyn Fn(i64, i64) -> HashMap<String, QuotaFigures>,
380    now_ms: i64,
381) -> ExportRun {
382    let (from_ms, to_ms) = window.window_of(window_start_ms);
383    let gran = window.gran();
384    // Settle the rollup cascade as far as the grace allows so the
385    // statement reads complete windows (idempotent, cursor-guarded).
386    store.maintain();
387    let retention = store.retention_ms[gran + 1];
388    let partial = from_ms < now_ms.saturating_sub(retention);
389
390    let mut run = ExportRun {
391        kind: window.as_str().to_string(),
392        window_start_ms: from_ms,
393        window_end_ms: to_ms,
394        status: "ok".to_string(),
395        partial,
396        formats: formats.iter().map(|f| f.as_str().to_string()).collect(),
397        directory: directory.to_string(),
398        consumers: 0,
399        requests: 0,
400        windows_nonempty: 0,
401        error: String::new(),
402        generated_at_ms: now_ms,
403    };
404
405    // The statement IS the query API's answer: same tables, same
406    // helpers, same period bounds. DW-079: the spend columns are the
407    // spend query API's answer over the same window (ai_spend table).
408    let read = store.query(|c| {
409        let grouped = query::structured(
410            c,
411            &query::StructuredQuery {
412                from_ms,
413                to_ms,
414                gran,
415                group_by: vec!["consumer".to_string()],
416                dim_group_by: None,
417                filters: query::FiltersBody::default(),
418                dim_filters: Default::default(),
419                limit: Some(10_000),
420            },
421        )?;
422        let totals = query::structured(
423            c,
424            &query::StructuredQuery {
425                from_ms,
426                to_ms,
427                gran,
428                group_by: Vec::new(),
429                dim_group_by: None,
430                filters: query::FiltersBody::default(),
431                dim_filters: Default::default(),
432                limit: Some(1),
433            },
434        )?;
435        let nonempty: i64 = c.query_row(
436            "SELECT COUNT(DISTINCT window_start) FROM rollup_fixed
437             WHERE gran = ?1 AND window_start >= ?2 AND window_start < ?3",
438            rusqlite::params![gran as i64, from_ms, to_ms],
439            |r| r.get(0),
440        )?;
441        // DW-079: spend per consumer and per model for the same
442        // window. Best-effort: a query failure (e.g. the ai_spend
443        // table not yet migrated) leaves the spend columns at zero
444        // rather than failing the whole export.
445        let spend_by_consumer = query::spend_summary(
446            c,
447            &query::SpendQuery {
448                from_ms,
449                to_ms,
450                group_by: vec!["consumer".to_string()],
451                limit: Some(10_000),
452            },
453        )
454        .unwrap_or_default();
455        let spend_by_model = query::spend_summary(
456            c,
457            &query::SpendQuery {
458                from_ms,
459                to_ms,
460                group_by: vec!["model".to_string()],
461                limit: Some(10_000),
462            },
463        )
464        .unwrap_or_default();
465        Ok((grouped, totals, nonempty, spend_by_consumer, spend_by_model))
466    });
467    let (grouped, totals, nonempty, spend_by_consumer, spend_by_model) = match read {
468        Ok(v) => v,
469        Err(e) => {
470            return record_run(
471                store,
472                failed_run(run, &format!("analytics query failed: {e}")),
473            );
474        }
475    };
476
477    // Index the per-consumer spend by consumer name for the row join.
478    let mut spend_map: std::collections::HashMap<String, &query::SpendRow> =
479        std::collections::HashMap::new();
480    for s in &spend_by_consumer {
481        if let Some(name) = s.key.first() {
482            spend_map.insert(name.clone(), s);
483        }
484    }
485
486    let quotas = quota_at(from_ms / 1000, to_ms / 1000);
487    let mut rows = Vec::with_capacity(grouped.len());
488    for r in &grouped {
489        let q = quotas.get(&r.key[0]).copied().unwrap_or_default();
490        let spend = spend_map.get(&r.key[0]);
491        rows.push(StatementRow {
492            consumer: r.key[0].clone(),
493            requests: r.requests,
494            errors: r.errors,
495            error_rate: r.error_rate,
496            rate_limited: r.rate_limited,
497            shed: r.shed,
498            avg_ms: r.avg_ms,
499            quota_daily: q.daily,
500            quota_monthly: q.monthly,
501            prompt_tokens: spend.map(|s| s.prompt_tokens).unwrap_or(0),
502            completion_tokens: spend.map(|s| s.completion_tokens).unwrap_or(0),
503            total_tokens: spend.map(|s| s.total_tokens).unwrap_or(0),
504            cost_micros: spend.map(|s| s.cost_micros).unwrap_or(0),
505        });
506    }
507    run.consumers = rows.len();
508    run.requests = totals.first().map(|t| t.requests).unwrap_or(0);
509    run.windows_nonempty = nonempty;
510
511    let spend_by_model = spend_by_model
512        .iter()
513        .map(|s| SpendByModel {
514            model: s.key.first().cloned().unwrap_or_default(),
515            prompt_tokens: s.prompt_tokens,
516            completion_tokens: s.completion_tokens,
517            total_tokens: s.total_tokens,
518            cost_micros: s.cost_micros,
519            request_count: s.request_count,
520        })
521        .collect();
522
523    let statement = UsageStatement {
524        kind: "usage_statement",
525        window: window.as_str(),
526        from_ms,
527        to_ms,
528        generated_at_ms: now_ms,
529        partial,
530        windows_nonempty: nonempty,
531        totals: totals.into_iter().next(),
532        consumers: rows,
533        spend_by_model,
534    };
535
536    // Directory + files. Any write failure fails the run (files that
537    // did land stay on disk — the record says which formats ran).
538    let dir = Path::new(directory);
539    if let Err(e) = std::fs::create_dir_all(dir) {
540        return record_run(
541            store,
542            failed_run(run, &format!("export directory unusable: {e}")),
543        );
544    }
545    for f in formats {
546        let filename = format!("{}.{}", window.file_stem(from_ms), f.extension());
547        let bytes = match f {
548            ExportFormat::Json => match serde_json::to_vec_pretty(&statement) {
549                Ok(b) => b,
550                Err(e) => {
551                    return record_run(store, failed_run(run, &format!("JSON render failed: {e}")))
552                }
553            },
554            ExportFormat::Csv => render_csv(&statement),
555            ExportFormat::ParquetCsv => render_parquet_csv(&statement),
556        };
557        if let Err(e) = write_atomic(dir, &filename, &bytes) {
558            return record_run(
559                store,
560                failed_run(run, &format!("write {filename} failed: {e}")),
561            );
562        }
563    }
564    record_run(store, run)
565}
566
567/// The scheduler's per-tick entry (DW-120): export every closed,
568/// settled window of the configured kind that has no successful run
569/// record yet — oldest first, bounded by [`MAX_CATCHUP_WINDOWS`] per
570/// tick and by the query granularity's retention horizon (windows
571/// past it would be `partial`; a manual trigger can still force
572/// them). Failed runs are retried on later ticks, at most once per
573/// [`FAILURE_RETRY_MS`] window (the backoff).
574pub fn run_due(
575    store: &EmbeddedAnalytics,
576    cfg: &crate::config::AnalyticsExports,
577    quota_at: &dyn Fn(i64, i64) -> HashMap<String, QuotaFigures>,
578    now_ms: i64,
579) -> Vec<ExportRun> {
580    let window = effective_window(cfg);
581    let formats = effective_formats(cfg);
582    let gran = window.gran();
583    // The most recent window whose close is at least the delay in the
584    // past (the window containing `now - delay` is still open by
585    // construction, so `last_closed_window` answers the one before
586    // it); everything older is exportable too (backfill).
587    let mut start = last_closed_window(window, now_ms.saturating_sub(EXPORT_DELAY_MS));
588    let horizon = now_ms.saturating_sub(store.retention_ms[gran + 1]);
589
590    // The ledger holds one row per (kind, window_start_ms); keep the
591    // per-window status and generated_at_ms so a failed window can be
592    // skipped until its backoff elapses.
593    let done: HashMap<i64, (String, i64)> = store
594        .query(|c| {
595            let mut stmt = c.prepare(
596                "SELECT window_start_ms, status, generated_at_ms FROM export_runs
597                 WHERE kind = ?1",
598            )?;
599            let rows = stmt
600                .query_map(rusqlite::params![window.as_str()], |r| {
601                    Ok((
602                        r.get::<_, i64>(0)?,
603                        r.get::<_, String>(1)?,
604                        r.get::<_, i64>(2)?,
605                    ))
606                })?
607                .map(|r| r.map(|(w, s, g)| (w, (s, g))))
608                .collect::<Result<HashMap<_, _>, _>>()?;
609            Ok(rows)
610        })
611        .unwrap_or_default();
612
613    let mut due = Vec::new();
614    while due.len() < MAX_CATCHUP_WINDOWS {
615        if start < horizon || start < 0 {
616            break;
617        }
618        if let Some((status, at_ms)) = done.get(&start) {
619            let ok = status == "ok";
620            let failed_recently = status == "failed" && now_ms - at_ms < FAILURE_RETRY_MS;
621            if ok || failed_recently {
622                break;
623            }
624        }
625        due.push(start);
626        let (prev, _) = window.window_of(start - 1);
627        start = prev;
628    }
629    due.reverse();
630    due.iter()
631        .map(|&s| run_export(store, &cfg.directory, window, s, &formats, quota_at, now_ms))
632        .collect()
633}
634
635/// The most recent CLOSED window of a kind (the manual trigger's
636/// default): the window containing `now` is still open by
637/// construction, so the answer is always the previous one — including
638/// at the exact boundary instant, where the previous window has just
639/// closed.
640pub fn last_closed_window(window: WindowKind, now_ms: i64) -> i64 {
641    let (start, _) = window.window_of(now_ms);
642    let (prev, _) = window.window_of(start - 1);
643    prev
644}
645
646/// List run records, newest first (the admin GET endpoint's read).
647pub fn list_runs(store: &EmbeddedAnalytics, limit: usize) -> rusqlite::Result<Vec<ExportRun>> {
648    store.query(|c| {
649        let mut stmt = c.prepare(
650            "SELECT kind, window_start_ms, window_end_ms, status, partial,
651                    formats, directory, consumers, requests, windows_nonempty,
652                    error, generated_at_ms
653             FROM export_runs
654             ORDER BY generated_at_ms DESC, kind ASC
655             LIMIT ?1",
656        )?;
657        let rows = stmt
658            .query_map(rusqlite::params![limit as i64], |r| {
659                Ok(ExportRun {
660                    kind: r.get(0)?,
661                    window_start_ms: r.get(1)?,
662                    window_end_ms: r.get(2)?,
663                    status: r.get(3)?,
664                    partial: r.get::<_, i64>(4)? != 0,
665                    formats: r
666                        .get::<_, String>(5)?
667                        .split(',')
668                        .map(str::to_string)
669                        .collect(),
670                    directory: r.get(6)?,
671                    consumers: r.get::<_, i64>(7)? as usize,
672                    requests: r.get(8)?,
673                    windows_nonempty: r.get(9)?,
674                    error: r.get(10)?,
675                    generated_at_ms: r.get(11)?,
676                })
677            })?
678            .collect::<Result<Vec<_>, _>>()?;
679        Ok(rows)
680    })
681}
682
683/// The manual trigger's request body (DW-120 admin endpoint): which
684/// window kind to export and optionally which aligned window start.
685#[derive(Debug, Default, Deserialize)]
686pub struct ManualRunBody {
687    pub window: Option<String>,
688    pub window_start_ms: Option<i64>,
689}
690
691fn failed_run(mut run: ExportRun, error: &str) -> ExportRun {
692    run.status = "failed".to_string();
693    run.error = error.to_string();
694    run
695}
696
697/// Upsert the run record (the ledger's one write; last run per window
698/// wins, matching the idempotent file output). A record-write failure
699/// is logged and swallowed — the run itself already happened.
700fn record_run(store: &EmbeddedAnalytics, run: ExportRun) -> ExportRun {
701    let row = (
702        run.kind.as_str(),
703        run.window_start_ms,
704        run.window_end_ms,
705        run.status.as_str(),
706        i64::from(run.partial),
707        run.formats.join(","),
708        run.directory.as_str(),
709        run.consumers as i64,
710        run.requests,
711        run.windows_nonempty,
712        run.error.as_str(),
713        run.generated_at_ms,
714    );
715    if let Err(e) = store.query(|c| {
716        c.execute(
717            "INSERT INTO export_runs (
718                kind, window_start_ms, window_end_ms, status, partial,
719                formats, directory, consumers, requests, windows_nonempty,
720                error, generated_at_ms
721            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
722             ON CONFLICT(kind, window_start_ms) DO UPDATE SET
723                window_end_ms = excluded.window_end_ms,
724                status = excluded.status,
725                partial = excluded.partial,
726                formats = excluded.formats,
727                directory = excluded.directory,
728                consumers = excluded.consumers,
729                requests = excluded.requests,
730                windows_nonempty = excluded.windows_nonempty,
731                error = excluded.error,
732                generated_at_ms = excluded.generated_at_ms",
733            rusqlite::params![
734                row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10,
735                row.11
736            ],
737        )?;
738        Ok(())
739    }) {
740        tracing::warn!(
741            code = "analytics_export_record_failed",
742            "export run record write failed: {e}"
743        );
744    }
745    if run.status == "failed" {
746        tracing::warn!(
747            code = "analytics_export_failed",
748            window = %run.kind,
749            from_ms = run.window_start_ms,
750            "{}",
751            run.error
752        );
753    } else {
754        tracing::info!(
755            code = "analytics_export_ok",
756            window = %run.kind,
757            from_ms = run.window_start_ms,
758            consumers = run.consumers,
759            requests = run.requests,
760            partial = run.partial,
761            "usage statement exported"
762        );
763    }
764    run
765}
766
767/// Render the statement as RFC 4180 CSV: fixed header, one row per
768/// consumer, CRLF terminators, fields quoted when they contain a
769/// comma, quote, CR, or LF (quotes doubled). Absent quota cells are
770/// EMPTY (zero means configured-and-zero-used — the distinction is
771/// load-bearing for billing).
772fn render_csv(s: &UsageStatement) -> Vec<u8> {
773    let mut out = String::new();
774    let mut header = Vec::new();
775    for h in [
776        "consumer",
777        "requests",
778        "errors",
779        "error_rate",
780        "rate_limited",
781        "shed",
782        "avg_ms",
783        "quota_daily_used",
784        "quota_daily_limit",
785        "quota_monthly_used",
786        "quota_monthly_limit",
787        "prompt_tokens",
788        "completion_tokens",
789        "total_tokens",
790        "cost_micros",
791    ] {
792        header.push(h.to_string());
793    }
794    push_csv_row(&mut out, &header);
795    for c in &s.consumers {
796        let mut fields = vec![
797            c.consumer.clone(),
798            c.requests.to_string(),
799            c.errors.to_string(),
800            c.error_rate.to_string(),
801            c.rate_limited.to_string(),
802            c.shed.to_string(),
803            c.avg_ms.to_string(),
804        ];
805        for q in [c.quota_daily, c.quota_monthly] {
806            match q {
807                Some(b) => {
808                    fields.push(b.used.to_string());
809                    fields.push(b.limit.to_string());
810                }
811                None => {
812                    fields.push(String::new());
813                    fields.push(String::new());
814                }
815            }
816        }
817        // DW-079: spend columns.
818        fields.push(c.prompt_tokens.to_string());
819        fields.push(c.completion_tokens.to_string());
820        fields.push(c.total_tokens.to_string());
821        fields.push(c.cost_micros.to_string());
822        push_csv_row(&mut out, &fields);
823    }
824    out.into_bytes()
825}
826
827/// SCALE-12 (#190): Render a usage statement as a CSV file with a
828/// Parquet-compatible JSON metadata header. The header is a JSON
829/// object on the first line (prefixed with `#`) describing the
830/// schema in Parquet-compatible terms (column names, types). The
831/// operator converts the file to true Parquet using an external tool
832/// like `duckdb` or `python pyarrow`.
833fn render_parquet_csv(s: &UsageStatement) -> Vec<u8> {
834    let mut out = String::new();
835    // Parquet-compatible schema metadata header (JSON, prefixed with #).
836    let schema = serde_json::json!({
837        "format": "parquet-csv",
838        "version": 1,
839        "columns": [
840            {"name": "consumer", "type": "string"},
841            {"name": "requests", "type": "int64"},
842            {"name": "errors", "type": "int64"},
843            {"name": "error_rate", "type": "double"},
844            {"name": "rate_limited", "type": "int64"},
845            {"name": "shed", "type": "int64"},
846            {"name": "avg_ms", "type": "double"},
847            {"name": "quota_daily_used", "type": "int64"},
848            {"name": "quota_daily_limit", "type": "int64"},
849            {"name": "quota_monthly_used", "type": "int64"},
850            {"name": "quota_monthly_limit", "type": "int64"},
851            {"name": "prompt_tokens", "type": "int64"},
852            {"name": "completion_tokens", "type": "int64"},
853            {"name": "total_tokens", "type": "int64"},
854            {"name": "cost_micros", "type": "int64"},
855        ]
856    });
857    out.push_str(&format!("#{}\n", schema));
858    // The CSV body is identical to the plain CSV format.
859    out.push_str(&String::from_utf8(render_csv(s)).unwrap_or_default());
860    out.into_bytes()
861}
862
863/// One CSV row (fields + CRLF), with per-field RFC 4180 escaping.
864fn push_csv_row(out: &mut String, fields: &[String]) {
865    for (i, f) in fields.iter().enumerate() {
866        if i > 0 {
867            out.push(',');
868        }
869        if f.bytes()
870            .any(|b| b == b',' || b == b'"' || b == b'\r' || b == b'\n')
871        {
872            out.push('"');
873            for ch in f.chars() {
874                if ch == '"' {
875                    out.push('"');
876                }
877                out.push(ch);
878            }
879            out.push('"');
880        } else {
881            out.push_str(f);
882        }
883    }
884    out.push_str("\r\n");
885}
886
887/// Atomic DURABLE file write: temp file in the SAME directory,
888/// fsync the file, rename over the destination, then fsync the
889/// directory so the rename itself survives a crash (these are
890/// billing artifacts — "written" must mean "on the disk", not "in
891/// the page cache"). Atomic on POSIX; a crash mid-write leaves only
892/// a stray temp, never a torn export. The directory fsync's io error
893/// propagates like any other step's (never swallowed).
894fn write_atomic(dir: &Path, filename: &str, bytes: &[u8]) -> std::io::Result<()> {
895    let tmp = dir.join(format!(
896        ".{filename}.{}.tmp",
897        TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
898    ));
899    let mut file = std::fs::File::create(&tmp)?;
900    std::io::Write::write_all(&mut file, bytes)?;
901    file.sync_all()?;
902    drop(file);
903    std::fs::rename(&tmp, dir.join(filename))?;
904    std::fs::File::open(dir)?.sync_all()
905}
906
907/// UTC month window containing `ts_ms`, in epoch milliseconds. The
908/// civil-calendar twin of `state::quotas::month_window` (epoch
909/// SECONDS there) — this module cannot import the state domain (see
910/// `scripts/check_deps.py`), so the algorithm lives twice, each copy
911/// pinned by tests to the same boundary values.
912fn month_window_ms(ts_ms: i64) -> (i64, i64) {
913    const MS_PER_DAY: i64 = 86_400_000;
914    let days = ts_ms.div_euclid(MS_PER_DAY);
915    let (y, m, _) = civil_from_days(days);
916    let start_days = days_from_civil(y, m, 1);
917    let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
918    let reset_days = days_from_civil(ny, nm, 1);
919    (start_days * MS_PER_DAY, reset_days * MS_PER_DAY)
920}
921
922/// Civil date from days since the Unix epoch (Howard Hinnant's
923/// `civil_from_days`; identical to the state domain's copy — see
924/// [`month_window_ms`]).
925fn civil_from_days(z: i64) -> (i64, u32, u32) {
926    let z = z + 719_468;
927    let era = z.div_euclid(146_097);
928    let doe = z.rem_euclid(146_097);
929    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
930    let y = yoe + era * 400;
931    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
932    let mp = (5 * doy + 2) / 153;
933    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
934    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
935    (if m <= 2 { y + 1 } else { y }, m, d)
936}
937
938/// The exact inverse of [`civil_from_days`] (`days_from_civil`).
939fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
940    let y = if m <= 2 { y - 1 } else { y };
941    let era = y.div_euclid(400);
942    let yoe = y.rem_euclid(400);
943    let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
944    let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
945    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
946    era * 146_097 + doe - 719_468
947}
948
949// White-box residual (the repo's relocated-unit-test convention keeps
950// src suites for private-item coverage only): the CSV row writer, the
951// filename stamper, and the civil-calendar twin are private, and
952// their exactness IS the file format — everything else lives in
953// tests/unit/exports.rs and tests/exports.rs.
954#[cfg(test)]
955mod tests {
956    use super::*;
957
958    /// A parse-and-split CSV reader sufficient for roundtrip tests:
959    /// RFC 4180 fields (quoted when needed, embedded quotes doubled).
960    fn parse_csv(text: &str) -> Vec<Vec<String>> {
961        let mut rows = Vec::new();
962        let mut row = Vec::new();
963        let mut field = String::new();
964        let mut in_quotes = false;
965        let mut chars = text.chars().peekable();
966        while let Some(c) = chars.next() {
967            if in_quotes {
968                if c == '"' {
969                    if chars.peek() == Some(&'"') {
970                        chars.next();
971                        field.push('"');
972                    } else {
973                        in_quotes = false;
974                    }
975                } else {
976                    field.push(c);
977                }
978            } else {
979                match c {
980                    '"' => in_quotes = true,
981                    ',' => {
982                        row.push(std::mem::take(&mut field));
983                    }
984                    '\r' => {}
985                    '\n' => {
986                        row.push(std::mem::take(&mut field));
987                        rows.push(std::mem::take(&mut row));
988                    }
989                    _ => field.push(c),
990                }
991            }
992        }
993        if !field.is_empty() || !row.is_empty() {
994            row.push(field);
995            rows.push(row);
996        }
997        rows
998    }
999
1000    #[test]
1001    fn csv_writer_escapes_rfc4180_and_roundtrips() {
1002        let mut rows = vec![vec![
1003            "consumer".to_string(),
1004            "plain".to_string(),
1005            "a,b".to_string(),
1006            "has\"quote".to_string(),
1007            "line\nbreak".to_string(),
1008            "cr\r lf".to_string(),
1009            "".to_string(),
1010        ]];
1011        let hostile = vec!["x\",y".to_string(), ",,\r\n\"".to_string()];
1012        rows.push(hostile.clone());
1013        let mut text = String::new();
1014        for r in &rows {
1015            push_csv_row(&mut text, r);
1016        }
1017        assert!(text.contains("\"a,b\""));
1018        assert!(text.contains("\"has\"\"quote\""));
1019        assert!(text.ends_with("\r\n"));
1020        let parsed = parse_csv(&text);
1021        assert_eq!(parsed.len(), rows.len());
1022        for (got, want) in parsed.iter().zip(rows.iter()) {
1023            assert_eq!(got, want);
1024        }
1025    }
1026
1027    #[test]
1028    fn month_windows_are_calendar_correct() {
1029        // 1970-01: [0, 31 days).
1030        assert_eq!(month_window_ms(0), (0, 31 * 86_400_000));
1031        // 1970-02 (28 days) -> March 1st.
1032        let feb = 31 * 86_400_000;
1033        assert_eq!(month_window_ms(feb), (feb, 59 * 86_400_000));
1034        // 1972-02 is a leap year: 29 days.
1035        let leap_feb_ms = days_from_civil(1972, 2, 1) * 86_400_000;
1036        let mar72 = days_from_civil(1972, 3, 1) * 86_400_000;
1037        assert_eq!(month_window_ms(leap_feb_ms), (leap_feb_ms, mar72));
1038        assert_eq!(mar72 - leap_feb_ms, 29 * 86_400_000);
1039        // Year rollover: 2026-12 -> 2027-01.
1040        let dec26 = days_from_civil(2026, 12, 1) * 86_400_000;
1041        let jan27 = days_from_civil(2027, 1, 1) * 86_400_000;
1042        assert_eq!(month_window_ms(dec26 + 1000), (dec26, jan27));
1043        // Civil/days are exact inverses across a spread.
1044        for (y, m, d) in [
1045            (1970, 1, 1),
1046            (2000, 2, 29),
1047            (2026, 8, 29),
1048            (2100, 3, 1),
1049            (2400, 12, 31),
1050        ] {
1051            assert_eq!(civil_from_days(days_from_civil(y, m, d)), (y, m, d));
1052        }
1053    }
1054
1055    #[test]
1056    fn file_stems_are_deterministic_and_distinct() {
1057        let day = 1_787_961_600_000; // 2026-08-29.
1058        assert_eq!(
1059            WindowKind::Daily.file_stem(day),
1060            "dwara-usage-daily-2026-08-29"
1061        );
1062        assert_eq!(
1063            WindowKind::Hourly.file_stem(day + 13 * 3_600_000),
1064            "dwara-usage-hourly-2026-08-29T13Z"
1065        );
1066        assert_eq!(
1067            WindowKind::Monthly.file_stem(day),
1068            "dwara-usage-monthly-2026-08"
1069        );
1070    }
1071}