Skip to content

Commit 215cdf3

Browse files
authored
Merge pull request #14 from pbudzik/feat/explain-verify-period
Phase D operability: explain + verify endpoints
2 parents 4dc7b03 + 012200e commit 215cdf3

5 files changed

Lines changed: 520 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ zstd = "0.13.3"
3131
[dev-dependencies]
3232
proptest = "1.11.0"
3333
tempfile = "3.27.0"
34+
http-body-util = "0.1"

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Spec-aligned routes (§9.1, §12.2, §12.3):
5757
POST /v1/usage/batch { "events": [UsageEvent, ...] }
5858
GET /v1/accounts/{account_id}/usage ?from&to&group_by&product_id&meter_id&model_id&source
5959
GET /v1/accounts/{account_id}/usage/events ?from&to&meter_id&product_id
60+
GET /v1/accounts/{account_id}/explain ?from&to — breakdown + segment provenance + corrections
61+
GET /v1/accounts/{account_id}/verify ?from&to — raw-vs-rollup drift check
6062
POST /v1/query/json { "source", "account_id", "from", "to", "group_by", "filters", "metrics" }
6163
POST /v1/query/sql { "query": "SELECT meter_id, SUM(quantity) FROM usage_events WHERE account_id = '...' GROUP BY meter_id" }
6264
GET /health

src/api/http_server.rs

Lines changed: 219 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,37 @@ use crate::runtime::state::{AppState, FlushMessage};
2121
use crate::runtime::config::DurabilityMode;
2222

2323
pub 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

4757
pub 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)]
445654
mod tests {
446655
use super::*;

0 commit comments

Comments
 (0)