Skip to main content

sqlite_objs/
metrics.rs

1//! Strongly-typed VFS activity metrics.
2//!
3//! The sqlite-objs VFS tracks per-connection I/O counters. These are
4//! returned as a newline-separated `key=value` text string via
5//! [`SQLITE_OBJS_FCNTL_STATS`](sqlite_objs_sys::SQLITE_OBJS_FCNTL_STATS)
6//! or `PRAGMA sqlite_objs_stats`.
7//!
8//! This module provides [`VfsMetrics`] — a typed struct that parses the C
9//! output into native Rust i64 fields — and a [`ParseError`] for malformed
10//! input.
11//!
12//! # Example
13//!
14//! ```
15//! use sqlite_objs::metrics::VfsMetrics;
16//!
17//! let text = "\
18//!     disk_reads=10\n\
19//!     disk_writes=5\n\
20//!     disk_bytes_read=40960\n\
21//!     disk_bytes_written=20480\n\
22//!     blob_reads=2\n\
23//!     blob_writes=1\n\
24//!     blob_bytes_read=1048576\n\
25//!     blob_bytes_written=4096\n\
26//!     cache_hits=100\n\
27//!     cache_misses=3\n\
28//!     cache_miss_pages=12\n\
29//!     prefetch_pages=256\n\
30//!     lease_acquires=1\n\
31//!     lease_renewals=0\n\
32//!     lease_releases=1\n\
33//!     syncs=2\n\
34//!     dirty_pages_synced=5\n\
35//!     blob_resizes=0\n\
36//!     revalidations=1\n\
37//!     revalidation_downloads=0\n\
38//!     revalidation_diffs=1\n\
39//!     pages_invalidated=0\n\
40//!     journal_uploads=1\n\
41//!     journal_bytes_uploaded=4096\n\
42//!     wal_uploads=0\n\
43//!     wal_bytes_uploaded=0\n\
44//!     azure_errors=0";
45//!
46//! let m = VfsMetrics::parse(text).unwrap();
47//! assert_eq!(m.disk_reads, 10);
48//! assert_eq!(m.cache_hits, 100);
49//! ```
50
51use std::fmt;
52
53/// Error returned when [`VfsMetrics::parse`] cannot interpret the C output.
54#[derive(Debug, Clone)]
55pub struct ParseError {
56    /// Human-readable description of what went wrong.
57    pub message: String,
58}
59
60impl fmt::Display for ParseError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(f, "metrics parse error: {}", self.message)
63    }
64}
65
66impl std::error::Error for ParseError {}
67
68/// Per-connection VFS activity counters.
69///
70/// All fields are signed 64-bit integers matching the C `sqlite_objs_metrics`
71/// struct. Counters are zeroed when the database file is opened and can be
72/// reset at any time via
73/// [`SQLITE_OBJS_FCNTL_STATS_RESET`](sqlite_objs_sys::SQLITE_OBJS_FCNTL_STATS_RESET).
74#[derive(Debug, Default, Clone, PartialEq, Eq)]
75pub struct VfsMetrics {
76    // -- Disk I/O (local cache file) --
77    /// Number of pread calls to the local cache file.
78    pub disk_reads: i64,
79    /// Number of pwrite calls to the local cache file.
80    pub disk_writes: i64,
81    /// Total bytes read from the local cache file.
82    pub disk_bytes_read: i64,
83    /// Total bytes written to the local cache file.
84    pub disk_bytes_written: i64,
85
86    // -- Azure Blob I/O (network) --
87    /// Number of Azure blob read operations.
88    pub blob_reads: i64,
89    /// Number of Azure blob write operations.
90    pub blob_writes: i64,
91    /// Total bytes downloaded from Azure.
92    pub blob_bytes_read: i64,
93    /// Total bytes uploaded to Azure.
94    pub blob_bytes_written: i64,
95
96    // -- Cache behaviour --
97    /// Page reads satisfied from the local cache.
98    pub cache_hits: i64,
99    /// Page reads that required a network fetch.
100    pub cache_misses: i64,
101    /// Individual pages fetched due to cache misses.
102    pub cache_miss_pages: i64,
103    /// Pages loaded during prefetch (full-blob download at open).
104    pub prefetch_pages: i64,
105
106    // -- Lease / locking --
107    /// Number of blob lease acquisitions.
108    pub lease_acquires: i64,
109    /// Number of blob lease renewals.
110    pub lease_renewals: i64,
111    /// Number of blob lease releases.
112    pub lease_releases: i64,
113
114    // -- Sync --
115    /// Number of xSync calls.
116    pub syncs: i64,
117    /// Dirty pages flushed to Azure during sync.
118    pub dirty_pages_synced: i64,
119    /// Number of blob resize operations.
120    pub blob_resizes: i64,
121
122    // -- Revalidation --
123    /// Number of ETag revalidation checks.
124    pub revalidations: i64,
125    /// Revalidations that required a full re-download.
126    pub revalidation_downloads: i64,
127    /// Revalidations that applied an incremental diff.
128    pub revalidation_diffs: i64,
129    /// Pages invalidated by revalidation.
130    pub pages_invalidated: i64,
131
132    // -- Journal & WAL uploads --
133    /// Number of journal file uploads.
134    pub journal_uploads: i64,
135    /// Total bytes of journal data uploaded.
136    pub journal_bytes_uploaded: i64,
137    /// Number of WAL file uploads.
138    pub wal_uploads: i64,
139    /// Total bytes of WAL data uploaded.
140    pub wal_bytes_uploaded: i64,
141
142    // -- Errors --
143    /// Azure HTTP errors (after retry exhaustion).
144    pub azure_errors: i64,
145}
146
147impl VfsMetrics {
148    /// The number of counters in the metrics struct.
149    pub const FIELD_COUNT: usize = 27;
150
151    /// Parse the `key=value\n` text returned by FCNTL 201 / `PRAGMA sqlite_objs_stats`.
152    ///
153    /// Unrecognised keys are silently ignored so that older Rust code can
154    /// read metrics from a newer C library that added counters. Missing
155    /// keys default to zero.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`ParseError`] if a recognised key has a non-integer value.
160    pub fn parse(text: &str) -> Result<Self, ParseError> {
161        let mut m = VfsMetrics::default();
162
163        for line in text.lines() {
164            let line = line.trim();
165            if line.is_empty() {
166                continue;
167            }
168
169            let (key, value) = match line.split_once('=') {
170                Some(pair) => pair,
171                None => {
172                    return Err(ParseError {
173                        message: format!("expected key=value, got: {line}"),
174                    });
175                }
176            };
177
178            let v: i64 = value.parse().map_err(|_| ParseError {
179                message: format!("invalid integer for key '{key}': {value}"),
180            })?;
181
182            match key {
183                "disk_reads" => m.disk_reads = v,
184                "disk_writes" => m.disk_writes = v,
185                "disk_bytes_read" => m.disk_bytes_read = v,
186                "disk_bytes_written" => m.disk_bytes_written = v,
187                "blob_reads" => m.blob_reads = v,
188                "blob_writes" => m.blob_writes = v,
189                "blob_bytes_read" => m.blob_bytes_read = v,
190                "blob_bytes_written" => m.blob_bytes_written = v,
191                "cache_hits" => m.cache_hits = v,
192                "cache_misses" => m.cache_misses = v,
193                "cache_miss_pages" => m.cache_miss_pages = v,
194                "prefetch_pages" => m.prefetch_pages = v,
195                "lease_acquires" => m.lease_acquires = v,
196                "lease_renewals" => m.lease_renewals = v,
197                "lease_releases" => m.lease_releases = v,
198                "syncs" => m.syncs = v,
199                "dirty_pages_synced" => m.dirty_pages_synced = v,
200                "blob_resizes" => m.blob_resizes = v,
201                "revalidations" => m.revalidations = v,
202                "revalidation_downloads" => m.revalidation_downloads = v,
203                "revalidation_diffs" => m.revalidation_diffs = v,
204                "pages_invalidated" => m.pages_invalidated = v,
205                "journal_uploads" => m.journal_uploads = v,
206                "journal_bytes_uploaded" => m.journal_bytes_uploaded = v,
207                "wal_uploads" => m.wal_uploads = v,
208                "wal_bytes_uploaded" => m.wal_bytes_uploaded = v,
209                "azure_errors" => m.azure_errors = v,
210                _ => { /* forward-compatible: ignore unknown keys */ }
211            }
212        }
213
214        Ok(m)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// Full valid output matching the C formatMetrics() layout.
223    const FULL_SAMPLE: &str = "\
224disk_reads=10\n\
225disk_writes=5\n\
226disk_bytes_read=40960\n\
227disk_bytes_written=20480\n\
228blob_reads=2\n\
229blob_writes=1\n\
230blob_bytes_read=1048576\n\
231blob_bytes_written=4096\n\
232cache_hits=100\n\
233cache_misses=3\n\
234cache_miss_pages=12\n\
235prefetch_pages=256\n\
236lease_acquires=1\n\
237lease_renewals=0\n\
238lease_releases=1\n\
239syncs=2\n\
240dirty_pages_synced=5\n\
241blob_resizes=0\n\
242revalidations=1\n\
243revalidation_downloads=0\n\
244revalidation_diffs=1\n\
245pages_invalidated=0\n\
246journal_uploads=1\n\
247journal_bytes_uploaded=4096\n\
248wal_uploads=0\n\
249wal_bytes_uploaded=0\n\
250azure_errors=0";
251
252    #[test]
253    fn parse_full_sample() {
254        let m = VfsMetrics::parse(FULL_SAMPLE).unwrap();
255        assert_eq!(m.disk_reads, 10);
256        assert_eq!(m.disk_writes, 5);
257        assert_eq!(m.disk_bytes_read, 40960);
258        assert_eq!(m.disk_bytes_written, 20480);
259        assert_eq!(m.blob_reads, 2);
260        assert_eq!(m.blob_writes, 1);
261        assert_eq!(m.blob_bytes_read, 1_048_576);
262        assert_eq!(m.blob_bytes_written, 4096);
263        assert_eq!(m.cache_hits, 100);
264        assert_eq!(m.cache_misses, 3);
265        assert_eq!(m.cache_miss_pages, 12);
266        assert_eq!(m.prefetch_pages, 256);
267        assert_eq!(m.lease_acquires, 1);
268        assert_eq!(m.lease_renewals, 0);
269        assert_eq!(m.lease_releases, 1);
270        assert_eq!(m.syncs, 2);
271        assert_eq!(m.dirty_pages_synced, 5);
272        assert_eq!(m.blob_resizes, 0);
273        assert_eq!(m.revalidations, 1);
274        assert_eq!(m.revalidation_downloads, 0);
275        assert_eq!(m.revalidation_diffs, 1);
276        assert_eq!(m.pages_invalidated, 0);
277        assert_eq!(m.journal_uploads, 1);
278        assert_eq!(m.journal_bytes_uploaded, 4096);
279        assert_eq!(m.wal_uploads, 0);
280        assert_eq!(m.wal_bytes_uploaded, 0);
281        assert_eq!(m.azure_errors, 0);
282    }
283
284    #[test]
285    fn parse_empty_string() {
286        let m = VfsMetrics::parse("").unwrap();
287        assert_eq!(m, VfsMetrics::default());
288    }
289
290    #[test]
291    fn parse_missing_keys_default_to_zero() {
292        let text = "disk_reads=42\ncache_hits=7";
293        let m = VfsMetrics::parse(text).unwrap();
294        assert_eq!(m.disk_reads, 42);
295        assert_eq!(m.cache_hits, 7);
296        assert_eq!(m.blob_reads, 0); // not in input
297    }
298
299    #[test]
300    fn parse_unknown_keys_ignored() {
301        let text = "disk_reads=1\nfuture_counter=999";
302        let m = VfsMetrics::parse(text).unwrap();
303        assert_eq!(m.disk_reads, 1);
304    }
305
306    #[test]
307    fn parse_bad_integer_is_error() {
308        let text = "disk_reads=not_a_number";
309        let err = VfsMetrics::parse(text).unwrap_err();
310        assert!(err.message.contains("disk_reads"));
311        assert!(err.message.contains("not_a_number"));
312    }
313
314    #[test]
315    fn parse_missing_equals_is_error() {
316        let text = "disk_reads 10";
317        let err = VfsMetrics::parse(text).unwrap_err();
318        assert!(err.message.contains("key=value"));
319    }
320
321    #[test]
322    fn parse_blank_lines_ignored() {
323        let text = "\n\ndisk_reads=5\n\n\ncache_hits=3\n\n";
324        let m = VfsMetrics::parse(text).unwrap();
325        assert_eq!(m.disk_reads, 5);
326        assert_eq!(m.cache_hits, 3);
327    }
328
329    #[test]
330    fn parse_whitespace_trimmed() {
331        let text = "  disk_reads=5  \n  cache_hits=3  ";
332        let m = VfsMetrics::parse(text).unwrap();
333        assert_eq!(m.disk_reads, 5);
334        assert_eq!(m.cache_hits, 3);
335    }
336
337    #[test]
338    fn parse_negative_values() {
339        // Counters should never be negative in practice, but the parser
340        // should not reject them (they are valid i64).
341        let text = "disk_reads=-1";
342        let m = VfsMetrics::parse(text).unwrap();
343        assert_eq!(m.disk_reads, -1);
344    }
345
346    #[test]
347    fn default_is_all_zeros() {
348        let m = VfsMetrics::default();
349        assert_eq!(m.disk_reads, 0);
350        assert_eq!(m.azure_errors, 0);
351        assert_eq!(m.wal_bytes_uploaded, 0);
352    }
353}