nodedb_types/timeseries/series.rs
1//! Series identity and catalog.
2
3use std::collections::HashMap;
4use std::hash::{DefaultHasher, Hash, Hasher};
5
6use serde::{Deserialize, Serialize};
7
8/// Unique identifier for a timeseries (hash of metric name + sorted tag set).
9pub type SeriesId = u64;
10
11/// The canonical key for a series — used for collision detection in the
12/// series catalog. Two `SeriesKey`s that hash to the same `SeriesId` are
13/// a collision; the catalog rehashes with a salt.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct SeriesKey {
16 pub metric: String,
17 pub tags: Vec<(String, String)>,
18}
19
20impl SeriesKey {
21 pub fn new(metric: impl Into<String>, mut tags: Vec<(String, String)>) -> Self {
22 tags.sort();
23 Self {
24 metric: metric.into(),
25 tags,
26 }
27 }
28
29 /// Compute the SeriesId for this key with an optional collision salt.
30 pub fn to_series_id(&self, salt: u64) -> SeriesId {
31 let mut hasher = DefaultHasher::new();
32 salt.hash(&mut hasher);
33 self.metric.hash(&mut hasher);
34 for (k, v) in &self.tags {
35 k.hash(&mut hasher);
36 v.hash(&mut hasher);
37 }
38 hasher.finish()
39 }
40}
41
42/// Persistent catalog that maps SeriesId → SeriesKey with collision detection.
43///
44/// On insert, if the SeriesId already maps to a *different* SeriesKey, the
45/// catalog rehashes with incrementing salts until it finds a free slot.
46/// This is one lookup per new series (not per row).
47#[derive(Debug, Default, Serialize, Deserialize)]
48pub struct SeriesCatalog {
49 /// SeriesId → (SeriesKey, salt used to produce this ID).
50 entries: HashMap<SeriesId, (SeriesKey, u64)>,
51}
52
53impl SeriesCatalog {
54 pub fn new() -> Self {
55 Self::default()
56 }
57
58 /// Resolve a SeriesKey to its SeriesId, registering it if new.
59 ///
60 /// Returns the SeriesId (potentially rehashed if the natural hash collided).
61 pub fn resolve(&mut self, key: &SeriesKey) -> SeriesId {
62 let mut salt = 0u64;
63 loop {
64 let id = key.to_series_id(salt);
65 match self.entries.get(&id) {
66 None => {
67 self.entries.insert(id, (key.clone(), salt));
68 return id;
69 }
70 Some((existing_key, _)) if existing_key == key => {
71 return id;
72 }
73 Some(_) => {
74 salt += 1;
75 }
76 }
77 }
78 }
79
80 /// Look up a SeriesId to get its canonical key.
81 pub fn get(&self, id: SeriesId) -> Option<&SeriesKey> {
82 self.entries.get(&id).map(|(k, _)| k)
83 }
84
85 /// Number of registered series.
86 pub fn len(&self) -> usize {
87 self.entries.len()
88 }
89
90 pub fn is_empty(&self) -> bool {
91 self.entries.is_empty()
92 }
93}
94
95/// Persistent identity of a NodeDB-Lite database instance.
96///
97/// Generated as a CUID2 on first `open()`, stored in redb metadata.
98/// Scope = one database file. Not a device ID, user ID, or app ID.
99pub type LiteId = String;
100
101/// Battery state reported by the host application for battery-aware flushing.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
103pub enum BatteryState {
104 /// Battery level is sufficient (>50%) or device is on AC power.
105 Normal,
106 /// Battery is low (<20%) and not charging. Defer non-critical I/O.
107 Low,
108 /// Device is currently charging. Safe to flush.
109 Charging,
110 /// Battery state unknown (desktop, non-mobile). Treat as Normal.
111 #[default]
112 Unknown,
113}
114
115impl BatteryState {
116 /// Whether flushing should be deferred in battery-aware mode.
117 pub fn should_defer_flush(&self) -> bool {
118 matches!(self, Self::Low)
119 }
120}