Skip to main content

hyperdb_mcp/
stats.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Performance telemetry attached to every MCP tool response.
5//!
6//! Each stats struct captures the raw counters (rows, bytes, elapsed time) and
7//! derives throughput metrics on the fly. The [`to_json`](IngestStats::to_json)
8//! methods merge the derived fields into the serialized output so callers get a
9//! single self-contained stats object.
10
11#![allow(
12    clippy::cast_precision_loss,
13    reason = "diagnostic rate/throughput calculations displayed to user; >2^53 bytes is unreachable"
14)]
15
16use serde::Serialize;
17use std::time::Instant;
18
19/// Lightweight wall-clock timer used throughout ingest/query/export paths.
20#[derive(Debug)]
21pub struct StatsTimer {
22    start: Instant,
23}
24
25impl StatsTimer {
26    #[must_use]
27    pub fn start() -> Self {
28        Self {
29            start: Instant::now(),
30        }
31    }
32
33    #[must_use]
34    pub fn elapsed_ms(&self) -> u64 {
35        // `Duration::as_millis` returns `u128`; `as u64` would
36        // silently wrap at ~584 million years, which is absurd in
37        // practice but banned by AGENTS.md §9 (no narrowing integer
38        // casts) because the cast is a latent data-corruption
39        // vector if reached. `u64::try_from` with a saturating
40        // fallback makes the overflow handling explicit.
41        u64::try_from(self.start.elapsed().as_millis()).unwrap_or(u64::MAX)
42    }
43}
44
45/// Telemetry for data ingest operations (`load_data`, `load_file`, one-shot ingest).
46///
47/// The `to_json()` method enriches the base fields with computed throughput:
48/// `rows_per_sec`, `ingest_throughput_mb_sec`, and `compression_ratio`.
49#[derive(Debug, Clone, Serialize)]
50pub struct IngestStats {
51    pub operation: String,
52    /// Rows written to the target table by this call.
53    ///
54    /// For `replace` / `append` this matches the file row count. For
55    /// `merge` it's the count returned by the post-DELETE INSERT —
56    /// treat as "rows written to target" rather than "rows in input".
57    pub rows: u64,
58    pub elapsed_ms: u64,
59    /// Raw input size (string length for inline data, file size for file ingest).
60    pub bytes_read: u64,
61    /// On-disk Hyper storage consumed. Zero when not measured.
62    pub bytes_stored: u64,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub schema_inference_ms: Option<u64>,
65    pub table: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub file_format: Option<String>,
68    /// Advisory warning for the LLM (e.g. "use `load_file` for large inline data").
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub warning: Option<String>,
71    /// `true` when this ingest changed the target's column set —
72    /// today, only `mode = "merge"` can flip this on (via auto-
73    /// `ALTER TABLE ADD COLUMN`). Drives whether the server fires a
74    /// resource-list-changed notification post-call. Default `false`
75    /// so non-merge paths don't have to think about it.
76    #[serde(skip_serializing_if = "is_false")]
77    #[serde(default)]
78    pub schema_changed: bool,
79}
80
81#[allow(
82    clippy::trivially_copy_pass_by_ref,
83    reason = "serde skip_serializing_if requires `&T -> bool`"
84)]
85fn is_false(b: &bool) -> bool {
86    !*b
87}
88
89impl IngestStats {
90    /// Rows ingested per second. Returns `rows` directly if elapsed is zero
91    /// to avoid division by zero.
92    #[must_use]
93    pub fn rows_per_sec(&self) -> u64 {
94        if self.elapsed_ms == 0 {
95            return self.rows;
96        }
97        #[expect(
98            clippy::cast_precision_loss,
99            clippy::cast_possible_truncation,
100            clippy::cast_sign_loss,
101            reason = "diagnostic rate: operands are non-negative u64 throughput counters; Rust's f64→u64 cast saturates at u64::MAX, which is strictly better than overflow for a reported rate"
102        )]
103        let rate = (self.rows as f64 / (self.elapsed_ms as f64 / 1000.0)) as u64;
104        rate
105    }
106
107    /// Input data throughput in MB/s (decimal megabytes).
108    #[must_use]
109    pub fn ingest_throughput_mb_sec(&self) -> f64 {
110        if self.elapsed_ms == 0 {
111            return 0.0;
112        }
113        (self.bytes_read as f64 / 1_000_000.0) / (self.elapsed_ms as f64 / 1000.0)
114    }
115
116    /// Ratio of stored bytes to input bytes. Values < 1.0 indicate Hyper's
117    /// columnar compression is smaller than the source format.
118    #[must_use]
119    pub fn compression_ratio(&self) -> f64 {
120        if self.bytes_read == 0 {
121            return 1.0;
122        }
123        self.bytes_stored as f64 / self.bytes_read as f64
124    }
125
126    /// Serialize to JSON with derived throughput fields merged in.
127    #[must_use]
128    pub fn to_json(&self) -> serde_json::Value {
129        let mut v = serde_json::to_value(self).unwrap_or_default();
130        if let Some(obj) = v.as_object_mut() {
131            obj.insert("rows_per_sec".into(), self.rows_per_sec().into());
132            obj.insert(
133                "ingest_throughput_mb_sec".into(),
134                serde_json::json!(self.ingest_throughput_mb_sec()),
135            );
136            obj.insert(
137                "compression_ratio".into(),
138                serde_json::json!(self.compression_ratio()),
139            );
140        }
141        v
142    }
143}
144
145/// Telemetry for SQL query operations.
146#[derive(Debug, Clone, Serialize)]
147pub struct QueryStats {
148    pub operation: String,
149    pub rows_returned: u64,
150    /// Total rows scanned by the engine (zero when not available from Hyper).
151    pub rows_scanned: u64,
152    pub elapsed_ms: u64,
153    /// Approximate size of the JSON result payload.
154    pub result_size_bytes: u64,
155    pub tables_touched: Vec<String>,
156}
157
158impl QueryStats {
159    /// Scan throughput in rows/sec. Useful for gauging query selectivity.
160    #[must_use]
161    pub fn scan_rate_rows_sec(&self) -> u64 {
162        if self.elapsed_ms == 0 {
163            return self.rows_scanned;
164        }
165        #[expect(
166            clippy::cast_precision_loss,
167            clippy::cast_possible_truncation,
168            clippy::cast_sign_loss,
169            reason = "diagnostic rate: operands are non-negative u64 counters; Rust's f64→u64 cast saturates at u64::MAX, which is strictly better than overflow for a reported rate"
170        )]
171        let rate = (self.rows_scanned as f64 / (self.elapsed_ms as f64 / 1000.0)) as u64;
172        rate
173    }
174
175    /// Serialize to JSON with `scan_rate_rows_sec` merged in.
176    #[must_use]
177    pub fn to_json(&self) -> serde_json::Value {
178        let mut v = serde_json::to_value(self).unwrap_or_default();
179        if let Some(obj) = v.as_object_mut() {
180            obj.insert(
181                "scan_rate_rows_sec".into(),
182                self.scan_rate_rows_sec().into(),
183            );
184        }
185        v
186    }
187}
188
189/// Telemetry for export operations (CSV, Parquet, Arrow IPC, `.hyper`).
190#[derive(Debug, Clone, Serialize)]
191pub struct ExportStats {
192    pub operation: String,
193    pub rows: u64,
194    pub elapsed_ms: u64,
195    pub file_size_bytes: u64,
196    pub format: String,
197    pub output_path: String,
198}
199
200impl ExportStats {
201    /// Export throughput in rows/sec.
202    #[must_use]
203    pub fn rows_per_sec(&self) -> u64 {
204        if self.elapsed_ms == 0 {
205            return self.rows;
206        }
207        #[expect(
208            clippy::cast_precision_loss,
209            clippy::cast_possible_truncation,
210            clippy::cast_sign_loss,
211            reason = "diagnostic rate: operands are non-negative u64 counters; Rust's f64→u64 cast saturates at u64::MAX, which is strictly better than overflow for a reported rate"
212        )]
213        let rate = (self.rows as f64 / (self.elapsed_ms as f64 / 1000.0)) as u64;
214        rate
215    }
216
217    /// Serialize to JSON with `rows_per_sec` merged in.
218    #[must_use]
219    pub fn to_json(&self) -> serde_json::Value {
220        let mut v = serde_json::to_value(self).unwrap_or_default();
221        if let Some(obj) = v.as_object_mut() {
222            obj.insert("rows_per_sec".into(), self.rows_per_sec().into());
223        }
224        v
225    }
226}