@@ -21,27 +21,37 @@ use crate::runtime::state::{AppState, FlushMessage};
2121use crate :: runtime:: config:: DurabilityMode ;
2222
2323pub async fn start_server ( state : AppState ) -> Result < ( ) , std:: io:: Error > {
24- let app = Router :: new ( )
24+ let app = build_router ( state. clone ( ) ) ;
25+
26+ let addr: SocketAddr = state. config . http_bind_address . parse ( ) . unwrap ( ) ;
27+ info ! ( "Starting HTTP server on {}" , addr) ;
28+
29+ let listener = TcpListener :: bind ( addr) . await ?;
30+ axum:: serve ( listener, app)
31+ . with_graceful_shutdown ( shutdown_signal ( ) )
32+ . await
33+ }
34+
35+ /// Construct the axum Router with all routes wired up. Exposed so
36+ /// integration tests can drive endpoints via `tower::oneshot` without
37+ /// binding a port.
38+ pub fn build_router ( state : AppState ) -> Router {
39+ Router :: new ( )
2540 . route ( "/health" , get ( || async { "OK" } ) )
2641 // Spec §9.1 — canonical path.
2742 . route ( "/v1/usage/batch" , post ( handle_ingest) )
2843 // Spec §12.2 — account usage with query params.
2944 . route ( "/v1/accounts/{account_id}/usage" , get ( handle_account_usage) )
3045 // Spec §12.3 — raw audit query.
3146 . route ( "/v1/accounts/{account_id}/usage/events" , get ( handle_account_events) )
47+ // Phase D operability — explain a total and verify rollup-vs-raw drift.
48+ . route ( "/v1/accounts/{account_id}/explain" , get ( handle_explain) )
49+ . route ( "/v1/accounts/{account_id}/verify" , get ( handle_verify) )
3250 // Flexible POST query for arbitrary filter shapes.
3351 . route ( "/v1/query/json" , post ( handle_query_json) )
3452 // SQL subset endpoint.
3553 . route ( "/v1/query/sql" , post ( handle_query_sql) )
36- . with_state ( state. clone ( ) ) ;
37-
38- let addr: SocketAddr = state. config . http_bind_address . parse ( ) . unwrap ( ) ;
39- info ! ( "Starting HTTP server on {}" , addr) ;
40-
41- let listener = TcpListener :: bind ( addr) . await ?;
42- axum:: serve ( listener, app)
43- . with_graceful_shutdown ( shutdown_signal ( ) )
44- . await
54+ . with_state ( state)
4555}
4656
4757pub struct AppError ( anyhow:: Error ) ;
@@ -441,6 +451,205 @@ async fn handle_query_sql(
441451 Ok ( Json ( serde_json:: json!( { "data" : results } ) ) )
442452}
443453
454+ /// Phase D operability: `GET /v1/accounts/{account_id}/explain?from&to`
455+ ///
456+ /// Returns the breakdown that contributed to an account's total over a
457+ /// time range — broken out by `(product, meter, model, source, unit)`,
458+ /// plus the list of rollup and raw segment IDs that overlap the range
459+ /// (so an operator can drill into them via `inspect-segment` later), plus
460+ /// the corrections / retractions that affected the total separately.
461+ ///
462+ /// This is the spec's "explain a billing total" primitive — without it,
463+ /// a disagreement between dashboard and invoice is hard to investigate.
464+ #[ derive( serde:: Deserialize , Default ) ]
465+ struct ExplainParams {
466+ from : String ,
467+ to : String ,
468+ }
469+
470+ async fn handle_explain (
471+ State ( state) : State < AppState > ,
472+ Path ( account_id) : Path < String > ,
473+ Query ( params) : Query < ExplainParams > ,
474+ ) -> Result < Json < serde_json:: Value > , AppError > {
475+ use chrono:: DateTime ;
476+ use crate :: query:: executor:: execute_plan;
477+ use crate :: query:: plan:: { AggregationFunction , QueryFilter , QueryPlan , QuerySource } ;
478+ use crate :: model:: ids:: { AccountId , bucket_for_account} ;
479+
480+ let from_ms = DateTime :: parse_from_rfc3339 ( & params. from )
481+ . map ( |dt| dt. timestamp_millis ( ) )
482+ . map_err ( |e| AppError ( anyhow:: anyhow!( "invalid `from`: {}" , e) ) ) ?;
483+ let to_ms = DateTime :: parse_from_rfc3339 ( & params. to )
484+ . map ( |dt| dt. timestamp_millis ( ) )
485+ . map_err ( |e| AppError ( anyhow:: anyhow!( "invalid `to`: {}" , e) ) ) ?;
486+
487+ // Breakdown via the rollup path (with raw fallback for the open-period
488+ // tail). Group by every billing-relevant column so each row is a
489+ // distinct invoice line.
490+ let mut metrics = HashMap :: new ( ) ;
491+ metrics. insert ( "quantity" . to_string ( ) , AggregationFunction :: Sum ) ;
492+ metrics. insert ( "count" . to_string ( ) , AggregationFunction :: Count ) ;
493+ let plan = QueryPlan {
494+ source : QuerySource :: RollupHourly ,
495+ account_id : Some ( account_id. clone ( ) ) ,
496+ from_ms,
497+ to_ms,
498+ filters : vec ! [ ] ,
499+ group_by : vec ! [
500+ "product_id" . into( ) ,
501+ "meter_id" . into( ) ,
502+ "model_id" . into( ) ,
503+ "source" . into( ) ,
504+ "unit" . into( ) ,
505+ ] ,
506+ metrics,
507+ limit : None ,
508+ } ;
509+ let lines = execute_plan ( & state, & plan) . await ;
510+
511+ // Corrections + retractions in the range, returned as raw rows for
512+ // forensic inspection. Empty for Usage-only periods.
513+ let plan_corr = QueryPlan {
514+ source : QuerySource :: RawEvents ,
515+ account_id : Some ( account_id. clone ( ) ) ,
516+ from_ms,
517+ to_ms,
518+ filters : vec ! [ QueryFilter {
519+ field: "kind" . into( ) ,
520+ values: vec![ "Correction" . into( ) , "Retraction" . into( ) ] ,
521+ } ] ,
522+ group_by : vec ! [ ] ,
523+ metrics : HashMap :: new ( ) ,
524+ limit : None ,
525+ } ;
526+ let corrections = execute_plan ( & state, & plan_corr) . await ;
527+
528+ // Segment provenance from the manifest. Filtering by bucket here
529+ // matches the executor's pruning — the same segments the query would
530+ // open are the ones we report.
531+ let ( watermark_ms, rollup_segments, raw_segments) = {
532+ let manifest = state. manifest . read ( ) . await ;
533+ let bucket_count = manifest. bucket_count . max ( 1 ) ;
534+ let target_bucket = bucket_for_account ( & AccountId ( account_id. clone ( ) ) , bucket_count) ;
535+ let rollups: Vec < String > = manifest
536+ . rollup_segments
537+ . iter ( )
538+ . filter ( |s| {
539+ s. bucket == target_bucket
540+ && s. min_timestamp_ms < to_ms
541+ && s. max_timestamp_ms >= from_ms
542+ } )
543+ . map ( |s| s. segment_id . clone ( ) )
544+ . collect ( ) ;
545+ let raws: Vec < String > = manifest
546+ . raw_segments
547+ . iter ( )
548+ . filter ( |s| {
549+ s. bucket == target_bucket
550+ && s. min_timestamp_ms < to_ms
551+ && s. max_timestamp_ms >= from_ms
552+ } )
553+ . map ( |s| s. segment_id . clone ( ) )
554+ . collect ( ) ;
555+ ( manifest. watermarks . hourly_rollup_ms , rollups, raws)
556+ } ;
557+
558+ Ok ( Json ( serde_json:: json!( {
559+ "account_id" : account_id,
560+ "from_ms" : from_ms,
561+ "to_ms" : to_ms,
562+ "watermark_ms" : watermark_ms,
563+ "lines" : lines,
564+ "rollup_segments" : rollup_segments,
565+ "raw_segments" : raw_segments,
566+ "corrections" : corrections,
567+ } ) ) )
568+ }
569+
570+ /// Phase D operability: `GET /v1/accounts/{account_id}/verify?from&to`
571+ ///
572+ /// Computes the same SUM(quantity) two ways — through the rollup path
573+ /// and through a pure raw scan — and reports both totals plus the
574+ /// `drift = raw - rollup`. Drift of zero on a fully-sealed period
575+ /// (where `to <= watermark_ms`) is the invariant; non-zero indicates a
576+ /// rollup bug, a late event that landed below the watermark, or a
577+ /// missing rollup segment that operator-driven `rebuild_rollups` should
578+ /// fix.
579+ #[ derive( serde:: Deserialize , Default ) ]
580+ struct VerifyParams {
581+ from : String ,
582+ to : String ,
583+ }
584+
585+ async fn handle_verify (
586+ State ( state) : State < AppState > ,
587+ Path ( account_id) : Path < String > ,
588+ Query ( params) : Query < VerifyParams > ,
589+ ) -> Result < Json < serde_json:: Value > , AppError > {
590+ use chrono:: DateTime ;
591+ use crate :: query:: executor:: execute_plan;
592+ use crate :: query:: plan:: { AggregationFunction , QueryPlan , QuerySource } ;
593+
594+ let from_ms = DateTime :: parse_from_rfc3339 ( & params. from )
595+ . map ( |dt| dt. timestamp_millis ( ) )
596+ . map_err ( |e| AppError ( anyhow:: anyhow!( "invalid `from`: {}" , e) ) ) ?;
597+ let to_ms = DateTime :: parse_from_rfc3339 ( & params. to )
598+ . map ( |dt| dt. timestamp_millis ( ) )
599+ . map_err ( |e| AppError ( anyhow:: anyhow!( "invalid `to`: {}" , e) ) ) ?;
600+
601+ let mut metrics = HashMap :: new ( ) ;
602+ metrics. insert ( "quantity" . to_string ( ) , AggregationFunction :: Sum ) ;
603+
604+ let plan_raw = QueryPlan {
605+ source : QuerySource :: RawEvents ,
606+ account_id : Some ( account_id. clone ( ) ) ,
607+ from_ms,
608+ to_ms,
609+ filters : vec ! [ ] ,
610+ group_by : vec ! [ ] ,
611+ metrics : metrics. clone ( ) ,
612+ limit : None ,
613+ } ;
614+ let plan_rollup = QueryPlan {
615+ source : QuerySource :: RollupHourly ,
616+ ..plan_raw. clone ( )
617+ } ;
618+
619+ let raw_result = execute_plan ( & state, & plan_raw) . await ;
620+ let rollup_result = execute_plan ( & state, & plan_rollup) . await ;
621+
622+ let raw_total = extract_quantity_sum ( & raw_result) ;
623+ let rollup_total = extract_quantity_sum ( & rollup_result) ;
624+ let drift = raw_total. saturating_sub ( rollup_total) ;
625+ let watermark_ms = state. manifest . read ( ) . await . watermarks . hourly_rollup_ms ;
626+ let period_sealed = to_ms <= watermark_ms;
627+
628+ Ok ( Json ( serde_json:: json!( {
629+ "account_id" : account_id,
630+ "from_ms" : from_ms,
631+ "to_ms" : to_ms,
632+ "watermark_ms" : watermark_ms,
633+ "period_sealed" : period_sealed,
634+ "raw_total" : raw_total. to_string( ) ,
635+ "rollup_total" : rollup_total. to_string( ) ,
636+ "drift" : drift. to_string( ) ,
637+ "matches" : drift == 0 ,
638+ } ) ) )
639+ }
640+
641+ /// Pull SUM(quantity) out of an executor result. Returns 0 when the
642+ /// result is empty (e.g., no events in range).
643+ fn extract_quantity_sum ( result : & [ serde_json:: Value ] ) -> i128 {
644+ result
645+ . iter ( )
646+ . filter_map ( |v| v. get ( "quantity" ) )
647+ . filter_map ( |v| v. as_str ( ) )
648+ . filter_map ( |s| s. parse ( ) . ok ( ) )
649+ . next ( )
650+ . unwrap_or ( 0 )
651+ }
652+
444653#[ cfg( test) ]
445654mod tests {
446655 use super :: * ;
0 commit comments