use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use nodedb_types::timeseries::{SeriesId, TimeRange};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum QueryScope {
#[default]
Local,
Cloud,
Hybrid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeseriesShape {
pub shape_id: String,
pub collection: String,
pub metric: String,
pub group_by: Vec<String>,
pub aggregate: String,
pub interval_ms: u64,
}
#[derive(Debug, Clone)]
pub struct CachedShapeData {
pub shape: TimeseriesShape,
pub buckets: Vec<(i64, f64)>,
pub last_updated_ms: u64,
pub max_ts: i64,
}
impl CachedShapeData {
pub fn staleness_ms(&self, now_ms: u64) -> u64 {
now_ms.saturating_sub(self.last_updated_ms)
}
}
#[derive(Debug, Default)]
pub struct TimeseriesShapeManager {
shapes: HashMap<String, CachedShapeData>,
}
impl TimeseriesShapeManager {
pub fn new() -> Self {
Self::default()
}
pub fn subscribe(&mut self, shape: TimeseriesShape) {
let shape_id = shape.shape_id.clone();
self.shapes.insert(
shape_id,
CachedShapeData {
shape,
buckets: Vec::new(),
last_updated_ms: 0,
max_ts: 0,
},
);
}
pub fn unsubscribe(&mut self, shape_id: &str) {
self.shapes.remove(shape_id);
}
pub fn update(&mut self, shape_id: &str, buckets: Vec<(i64, f64)>, now_ms: u64) {
if let Some(cached) = self.shapes.get_mut(shape_id) {
cached.max_ts = buckets.iter().map(|(ts, _)| *ts).max().unwrap_or(0);
cached.buckets = buckets;
cached.last_updated_ms = now_ms;
}
}
pub fn query(&self, shape_id: &str, range: &TimeRange, now_ms: u64) -> (Vec<(i64, f64)>, u64) {
match self.shapes.get(shape_id) {
Some(cached) => {
let filtered: Vec<(i64, f64)> = cached
.buckets
.iter()
.filter(|(ts, _)| range.contains(*ts))
.copied()
.collect();
(filtered, cached.staleness_ms(now_ms))
}
None => (Vec::new(), u64::MAX),
}
}
pub fn active_shapes(&self) -> Vec<&TimeseriesShape> {
self.shapes.values().map(|c| &c.shape).collect()
}
pub fn len(&self) -> usize {
self.shapes.len()
}
pub fn is_empty(&self) -> bool {
self.shapes.is_empty()
}
pub fn export(&self) -> Vec<(String, CachedShapeData)> {
self.shapes
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
pub fn import(&mut self, entries: Vec<(String, CachedShapeData)>) {
for (k, v) in entries {
self.shapes.insert(k, v);
}
}
}
#[derive(Debug)]
pub struct HybridQueryResult {
pub local: Vec<(i64, f64, SeriesId)>,
pub fleet: Vec<(i64, f64)>,
pub shape_staleness_ms: u64,
pub local_available: bool,
}
pub struct RoutedQueryParams<'a> {
pub scope: QueryScope,
pub collection: &'a str,
pub range: &'a TimeRange,
pub bucket_ms: Option<i64>,
pub shape_id: Option<&'a str>,
pub now_ms: u64,
}
pub fn execute_routed_query(
params: &RoutedQueryParams<'_>,
engine: &super::engine::TimeseriesEngine,
shape_mgr: &TimeseriesShapeManager,
) -> HybridQueryResult {
let RoutedQueryParams {
scope,
collection,
range,
bucket_ms,
shape_id,
now_ms,
} = params;
let _ = bucket_ms;
let local = match scope {
QueryScope::Local | QueryScope::Hybrid => engine.scan(collection, range),
QueryScope::Cloud => {
Vec::new()
}
};
let (fleet, shape_staleness_ms) = match scope {
QueryScope::Hybrid => {
if let Some(sid) = shape_id {
shape_mgr.query(sid, range, *now_ms)
} else {
(Vec::new(), u64::MAX)
}
}
_ => (Vec::new(), u64::MAX),
};
HybridQueryResult {
local_available: !matches!(scope, QueryScope::Cloud),
local,
fleet,
shape_staleness_ms,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_shape(id: &str) -> TimeseriesShape {
TimeseriesShape {
shape_id: id.into(),
collection: "metrics".into(),
metric: "cpu_usage".into(),
group_by: vec![],
aggregate: "avg".into(),
interval_ms: 300_000,
}
}
#[test]
fn subscribe_and_query() {
let mut mgr = TimeseriesShapeManager::new();
mgr.subscribe(make_shape("fleet_cpu"));
assert_eq!(mgr.len(), 1);
let (buckets, staleness) = mgr.query("fleet_cpu", &TimeRange::new(0, 1_000_000), 1000);
assert!(buckets.is_empty());
assert_eq!(staleness, 1000);
mgr.update(
"fleet_cpu",
vec![(300_000, 45.0), (600_000, 52.0), (900_000, 48.0)],
1_000_000,
);
let (buckets, staleness) = mgr.query("fleet_cpu", &TimeRange::new(0, 1_000_000), 1_000_000);
assert_eq!(buckets.len(), 3);
assert_eq!(staleness, 0); }
#[test]
fn unsubscribe_removes() {
let mut mgr = TimeseriesShapeManager::new();
mgr.subscribe(make_shape("s1"));
assert_eq!(mgr.len(), 1);
mgr.unsubscribe("s1");
assert_eq!(mgr.len(), 0);
}
#[test]
fn query_range_filtering() {
let mut mgr = TimeseriesShapeManager::new();
mgr.subscribe(make_shape("s1"));
mgr.update(
"s1",
vec![(100, 1.0), (200, 2.0), (300, 3.0), (400, 4.0)],
500,
);
let (buckets, _) = mgr.query("s1", &TimeRange::new(200, 300), 500);
assert_eq!(buckets.len(), 2);
assert_eq!(buckets[0], (200, 2.0));
assert_eq!(buckets[1], (300, 3.0));
}
#[test]
fn missing_shape_returns_max_staleness() {
let mgr = TimeseriesShapeManager::new();
let (buckets, staleness) = mgr.query("nonexistent", &TimeRange::new(0, 1000), 1000);
assert!(buckets.is_empty());
assert_eq!(staleness, u64::MAX);
}
#[test]
fn export_import_roundtrip() {
let mut mgr = TimeseriesShapeManager::new();
mgr.subscribe(make_shape("s1"));
mgr.update("s1", vec![(100, 42.0)], 200);
let exported = mgr.export();
let mut mgr2 = TimeseriesShapeManager::new();
mgr2.import(exported);
assert_eq!(mgr2.len(), 1);
let (buckets, _) = mgr2.query("s1", &TimeRange::new(0, 1000), 200);
assert_eq!(buckets.len(), 1);
}
#[test]
fn query_scope_default_is_local() {
assert_eq!(QueryScope::default(), QueryScope::Local);
}
}