Skip to main content

oxidelake_core/
telemetry.rs

1//! Telemetry hub: atomic counters and gauges written by the engine and read as
2//! consistent snapshots by the dashboard. It is the *only* coupling between
3//! `oxidelake-tui` and the rest of the engine.
4
5use std::collections::VecDeque;
6use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7use std::sync::{Arc, PoisonError, RwLock};
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::BackendKind;
13
14/// The three memory tiers tracked by the spill manager.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum MemoryTier {
17    /// Device (VRAM) memory.
18    Device,
19    /// Pinned or pageable host RAM.
20    Host,
21    /// Local disk spill files.
22    Disk,
23}
24
25impl MemoryTier {
26    const fn index(self) -> usize {
27        match self {
28            MemoryTier::Device => 0,
29            MemoryTier::Host => 1,
30            MemoryTier::Disk => 2,
31        }
32    }
33}
34
35/// Live counters for one physical operator instance.
36#[derive(Debug)]
37pub struct OperatorStats {
38    id: usize,
39    name: String,
40    backend: BackendKind,
41    rows_in: AtomicU64,
42    rows_out: AtomicU64,
43    batches: AtomicU64,
44    elapsed_ns: AtomicU64,
45    bytes_h2d: AtomicU64,
46    bytes_d2h: AtomicU64,
47    memory_bytes: AtomicU64,
48}
49
50impl OperatorStats {
51    fn new(id: usize, name: String, backend: BackendKind) -> Self {
52        Self {
53            id,
54            name,
55            backend,
56            rows_in: AtomicU64::new(0),
57            rows_out: AtomicU64::new(0),
58            batches: AtomicU64::new(0),
59            elapsed_ns: AtomicU64::new(0),
60            bytes_h2d: AtomicU64::new(0),
61            bytes_d2h: AtomicU64::new(0),
62            memory_bytes: AtomicU64::new(0),
63        }
64    }
65
66    /// Registration index (stable for the life of the hub).
67    pub const fn id(&self) -> usize {
68        self.id
69    }
70
71    /// Operator display name.
72    pub fn name(&self) -> &str {
73        &self.name
74    }
75
76    /// The backend the operator was planned for.
77    pub const fn backend(&self) -> BackendKind {
78        self.backend
79    }
80
81    /// Records one processed batch.
82    pub fn record_batch(&self, rows_in: u64, rows_out: u64, elapsed: Duration) {
83        self.rows_in.fetch_add(rows_in, Ordering::Relaxed);
84        self.rows_out.fetch_add(rows_out, Ordering::Relaxed);
85        self.batches.fetch_add(1, Ordering::Relaxed);
86        let ns = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX);
87        self.elapsed_ns.fetch_add(ns, Ordering::Relaxed);
88    }
89
90    /// Records bytes moved host→device and device→host.
91    pub fn record_transfer(&self, h2d: u64, d2h: u64) {
92        self.bytes_h2d.fetch_add(h2d, Ordering::Relaxed);
93        self.bytes_d2h.fetch_add(d2h, Ordering::Relaxed);
94    }
95
96    /// Sets the operator's currently allocated bytes.
97    pub fn set_memory_bytes(&self, bytes: u64) {
98        self.memory_bytes.store(bytes, Ordering::Relaxed);
99    }
100
101    /// A point-in-time copy of the counters.
102    pub fn snapshot(&self) -> OperatorSnapshot {
103        OperatorSnapshot {
104            id: self.id,
105            name: self.name.clone(),
106            backend: self.backend,
107            rows_in: self.rows_in.load(Ordering::Relaxed),
108            rows_out: self.rows_out.load(Ordering::Relaxed),
109            batches: self.batches.load(Ordering::Relaxed),
110            elapsed_ns: self.elapsed_ns.load(Ordering::Relaxed),
111            bytes_h2d: self.bytes_h2d.load(Ordering::Relaxed),
112            bytes_d2h: self.bytes_d2h.load(Ordering::Relaxed),
113            memory_bytes: self.memory_bytes.load(Ordering::Relaxed),
114        }
115    }
116}
117
118/// Snapshot of one operator's counters.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct OperatorSnapshot {
121    /// Registration index.
122    pub id: usize,
123    /// Operator display name.
124    pub name: String,
125    /// Planned backend.
126    pub backend: BackendKind,
127    /// Input rows.
128    pub rows_in: u64,
129    /// Output rows.
130    pub rows_out: u64,
131    /// Batches processed.
132    pub batches: u64,
133    /// Total processing time in nanoseconds.
134    pub elapsed_ns: u64,
135    /// Bytes copied host→device.
136    pub bytes_h2d: u64,
137    /// Bytes copied device→host.
138    pub bytes_d2h: u64,
139    /// Currently allocated bytes.
140    pub memory_bytes: u64,
141}
142
143impl OperatorSnapshot {
144    /// Mean per-batch latency in milliseconds (zero when nothing ran).
145    pub fn mean_batch_latency_ms(&self) -> f64 {
146        if self.batches == 0 {
147            0.0
148        } else {
149            self.elapsed_ns as f64 / self.batches as f64 / 1_000_000.0
150        }
151    }
152}
153
154/// Bytes resident per memory tier.
155#[derive(Debug, Default)]
156pub struct TierGauges {
157    bytes: [AtomicU64; 3],
158}
159
160impl TierGauges {
161    /// Overwrites a tier's resident bytes.
162    pub fn set(&self, tier: MemoryTier, bytes: u64) {
163        self.bytes[tier.index()].store(bytes, Ordering::Relaxed);
164    }
165
166    /// Adds to a tier's resident bytes.
167    pub fn add(&self, tier: MemoryTier, bytes: u64) {
168        self.bytes[tier.index()].fetch_add(bytes, Ordering::Relaxed);
169    }
170
171    /// Subtracts from a tier's resident bytes, saturating at zero.
172    pub fn sub(&self, tier: MemoryTier, bytes: u64) {
173        let _ = self.bytes[tier.index()].fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
174            Some(v.saturating_sub(bytes))
175        });
176    }
177
178    /// Current resident bytes for a tier.
179    pub fn get(&self, tier: MemoryTier) -> u64 {
180        self.bytes[tier.index()].load(Ordering::Relaxed)
181    }
182
183    /// Point-in-time copy.
184    pub fn snapshot(&self) -> TierSnapshot {
185        TierSnapshot {
186            device_bytes: self.get(MemoryTier::Device),
187            host_bytes: self.get(MemoryTier::Host),
188            disk_bytes: self.get(MemoryTier::Disk),
189        }
190    }
191}
192
193/// Snapshot of the tier gauges.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
195pub struct TierSnapshot {
196    /// Bytes resident on devices.
197    pub device_bytes: u64,
198    /// Bytes resident in host RAM.
199    pub host_bytes: u64,
200    /// Bytes spilled to disk.
201    pub disk_bytes: u64,
202}
203
204/// Spill activity counters.
205#[derive(Debug, Default)]
206pub struct SpillCounters {
207    demotions: AtomicU64,
208    promotions: AtomicU64,
209    spilled_bytes: AtomicU64,
210    reloaded_bytes: AtomicU64,
211}
212
213impl SpillCounters {
214    /// Records a batch moving to a colder tier.
215    pub fn record_demotion(&self, bytes: u64) {
216        self.demotions.fetch_add(1, Ordering::Relaxed);
217        self.spilled_bytes.fetch_add(bytes, Ordering::Relaxed);
218    }
219
220    /// Records a batch moving to a hotter tier.
221    pub fn record_promotion(&self, bytes: u64) {
222        self.promotions.fetch_add(1, Ordering::Relaxed);
223        self.reloaded_bytes.fetch_add(bytes, Ordering::Relaxed);
224    }
225
226    /// Point-in-time copy.
227    pub fn snapshot(&self) -> SpillSnapshot {
228        SpillSnapshot {
229            demotions: self.demotions.load(Ordering::Relaxed),
230            promotions: self.promotions.load(Ordering::Relaxed),
231            spilled_bytes: self.spilled_bytes.load(Ordering::Relaxed),
232            reloaded_bytes: self.reloaded_bytes.load(Ordering::Relaxed),
233        }
234    }
235}
236
237/// Snapshot of the spill counters.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
239pub struct SpillSnapshot {
240    /// Batches demoted to a colder tier.
241    pub demotions: u64,
242    /// Batches promoted to a hotter tier.
243    pub promotions: u64,
244    /// Total bytes demoted.
245    pub spilled_bytes: u64,
246    /// Total bytes promoted.
247    pub reloaded_bytes: u64,
248}
249
250/// One node of the displayed physical plan.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct PlanNodeSummary {
253    /// Depth in the plan tree (root = 0).
254    pub depth: usize,
255    /// Operator name, e.g. `GpuFilterExec`.
256    pub name: String,
257    /// Backend the node is placed on.
258    pub backend: BackendKind,
259    /// One-line detail (predicate, keys, …).
260    pub detail: String,
261}
262
263/// Most operator registrations a hub keeps. A hub lives as long as its session
264/// and every `Gpu*Exec` instance registers once, so without a bound a
265/// long-lived session would accumulate one entry per operator of every query
266/// it ever ran. Older entries are evicted first; live `OperatorStats` handles
267/// stay valid, they just stop appearing in snapshots.
268pub const MAX_OPERATORS: usize = 4096;
269
270/// The engine-wide telemetry hub.
271#[derive(Debug, Default)]
272pub struct TelemetryHub {
273    operators: RwLock<VecDeque<Arc<OperatorStats>>>,
274    next_operator_id: AtomicUsize,
275    tiers: TierGauges,
276    spill: SpillCounters,
277    plan: RwLock<Vec<PlanNodeSummary>>,
278}
279
280impl TelemetryHub {
281    /// Creates a shared hub.
282    pub fn new() -> Arc<Self> {
283        Arc::new(Self::default())
284    }
285
286    /// Registers an operator and returns its live counters. Ids increase
287    /// monotonically for the life of the hub; the oldest registrations are
288    /// evicted once [`MAX_OPERATORS`] are held.
289    pub fn register_operator(
290        &self,
291        name: impl Into<String>,
292        backend: BackendKind,
293    ) -> Arc<OperatorStats> {
294        let id = self.next_operator_id.fetch_add(1, Ordering::Relaxed);
295        let stats = Arc::new(OperatorStats::new(id, name.into(), backend));
296        let mut ops = self
297            .operators
298            .write()
299            .unwrap_or_else(PoisonError::into_inner);
300        if ops.len() >= MAX_OPERATORS {
301            ops.pop_front();
302        }
303        ops.push_back(Arc::clone(&stats));
304        stats
305    }
306
307    /// Forgets every registered operator (e.g. before running a new query
308    /// whose dashboard should not show the previous one's counters).
309    pub fn clear_operators(&self) {
310        self.operators
311            .write()
312            .unwrap_or_else(PoisonError::into_inner)
313            .clear();
314    }
315
316    /// Replaces the displayed plan.
317    pub fn set_plan(&self, nodes: Vec<PlanNodeSummary>) {
318        *self.plan.write().unwrap_or_else(PoisonError::into_inner) = nodes;
319    }
320
321    /// Memory tier gauges.
322    pub const fn tiers(&self) -> &TierGauges {
323        &self.tiers
324    }
325
326    /// Spill counters.
327    pub const fn spill(&self) -> &SpillCounters {
328        &self.spill
329    }
330
331    /// A consistent point-in-time copy of everything the dashboard renders.
332    pub fn snapshot(&self) -> TelemetrySnapshot {
333        let operators = self
334            .operators
335            .read()
336            .unwrap_or_else(PoisonError::into_inner)
337            .iter()
338            .map(|o| o.snapshot())
339            .collect();
340        let plan = self
341            .plan
342            .read()
343            .unwrap_or_else(PoisonError::into_inner)
344            .clone();
345        TelemetrySnapshot {
346            operators,
347            tiers: self.tiers.snapshot(),
348            spill: self.spill.snapshot(),
349            plan,
350        }
351    }
352}
353
354/// Everything the dashboard renders, captured at one instant.
355#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
356pub struct TelemetrySnapshot {
357    /// Per-operator counters, in registration order.
358    pub operators: Vec<OperatorSnapshot>,
359    /// Memory tier gauges.
360    pub tiers: TierSnapshot,
361    /// Spill counters.
362    pub spill: SpillSnapshot,
363    /// The displayed plan.
364    pub plan: Vec<PlanNodeSummary>,
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn operators_register_in_order_and_accumulate() {
373        let hub = TelemetryHub::new();
374        let a = hub.register_operator("GpuFilterExec", BackendKind::Cuda);
375        let b = hub.register_operator("GpuAggregateExec", BackendKind::CpuSimd);
376        assert_eq!((a.id(), b.id()), (0, 1));
377        a.record_batch(100, 40, Duration::from_millis(2));
378        a.record_batch(100, 60, Duration::from_millis(4));
379        a.record_transfer(800, 480);
380        a.set_memory_bytes(4096);
381        let snap = hub.snapshot();
382        assert_eq!(snap.operators.len(), 2);
383        let op = &snap.operators[0];
384        assert_eq!((op.rows_in, op.rows_out, op.batches), (200, 100, 2));
385        assert_eq!(
386            (op.bytes_h2d, op.bytes_d2h, op.memory_bytes),
387            (800, 480, 4096)
388        );
389        assert!((op.mean_batch_latency_ms() - 3.0).abs() < 1e-9);
390        assert_eq!(snap.operators[1].mean_batch_latency_ms(), 0.0);
391    }
392
393    #[test]
394    fn tiers_and_spill_counters() {
395        let hub = TelemetryHub::new();
396        hub.tiers().add(MemoryTier::Host, 1000);
397        hub.tiers().sub(MemoryTier::Host, 300);
398        hub.tiers().sub(MemoryTier::Disk, 5); // saturates at zero
399        hub.tiers().set(MemoryTier::Device, 42);
400        hub.spill().record_demotion(700);
401        hub.spill().record_promotion(700);
402        let snap = hub.snapshot();
403        assert_eq!(
404            snap.tiers,
405            TierSnapshot {
406                device_bytes: 42,
407                host_bytes: 700,
408                disk_bytes: 0
409            }
410        );
411        assert_eq!(snap.spill.demotions, 1);
412        assert_eq!(snap.spill.promotions, 1);
413        assert_eq!(snap.spill.spilled_bytes, 700);
414    }
415
416    #[test]
417    fn plan_summary_is_replaced() {
418        let hub = TelemetryHub::new();
419        hub.set_plan(vec![PlanNodeSummary {
420            depth: 0,
421            name: "GpuFilterExec".into(),
422            backend: BackendKind::Metal,
423            detail: "a > 1".into(),
424        }]);
425        assert_eq!(hub.snapshot().plan.len(), 1);
426        hub.set_plan(Vec::new());
427        assert!(hub.snapshot().plan.is_empty());
428    }
429}