cqlite_core/storage/write_engine/stats.rs
1//! Write-engine counters and cumulative compaction statistics.
2//!
3//! Extracted verbatim from `write_engine/mod.rs` (issue #1120, epic #1116) as a
4//! behavior-preserving split. Owns the lightweight engine accessor methods
5//! (`memtable_size`, `wal_size`, `total_written`, `l0_count`, ...) and the
6//! public `CompactionStats` snapshot type. `WriteEngine`'s fields are reachable
7//! here because this is a sibling module in the same crate.
8
9use super::WriteEngine;
10use std::time::Duration;
11
12/// Cumulative statistics across all compaction operations (M5.2, Issue #474)
13///
14/// Tracks lifetime totals for monitoring compaction health and throughput.
15/// Updated atomically at the end of each successful merge.
16#[derive(Debug, Clone, Default)]
17pub struct CompactionStats {
18 /// Total number of completed compaction cycles
19 pub compactions_completed: u64,
20 /// Total number of input SSTables consumed
21 pub sstables_merged_in: u64,
22 /// Total number of output SSTables produced
23 pub sstables_produced: u64,
24 /// Total bytes read from input SSTables
25 pub bytes_read: u64,
26 /// Total bytes written to output SSTables
27 pub bytes_written: u64,
28 /// Total rows merged across all compactions
29 pub rows_merged: u64,
30 /// Total wall-clock time spent in compaction
31 pub total_time: Duration,
32}
33
34impl WriteEngine {
35 /// Get the current memtable size in bytes
36 pub fn memtable_size(&self) -> usize {
37 self.memtable.size_bytes()
38 }
39
40 /// Get the current memtable row count
41 pub fn memtable_row_count(&self) -> usize {
42 self.memtable.row_count()
43 }
44
45 /// Get the current WAL size in bytes
46 pub fn wal_size(&self) -> u64 {
47 self.wal.size()
48 }
49
50 /// Get the current generation number
51 pub fn generation(&self) -> u64 {
52 self.generation
53 }
54
55 /// Return the cumulative number of rows written since the engine was opened
56 /// (Issue #486).
57 ///
58 /// This counter is incremented for every row that is successfully inserted
59 /// into the memtable and is NOT reset on flush. It therefore represents
60 /// the total write throughput for the current session.
61 ///
62 /// Note: This counter is in-process only and resets to zero when the engine
63 /// is re-opened. WAL replay rows (recovered from a previous crash) are NOT
64 /// counted; only rows written through `write()` / `write_async()` during
65 /// the current session are counted.
66 pub fn total_written(&self) -> u64 {
67 self.rows_written
68 }
69
70 /// Return the number of L0 SSTables successfully flushed since the engine
71 /// was opened (Issue #486).
72 ///
73 /// Incremented once per successful `flush()` call that produces a non-empty
74 /// SSTable. This is an in-process counter and resets to zero when the
75 /// engine is re-opened.
76 pub fn l0_count(&self) -> u64 {
77 self.l0_count
78 }
79
80 /// Return the cumulative bytes written to flushed L0 SSTables (Data.db plus
81 /// all sibling components) since the engine was opened (issue #1620).
82 ///
83 /// Incremented on every successful flush — including the automatic flushes
84 /// the binding write path now performs via `execute_flushing` — so binding
85 /// write stats stay accurate for automatic flushes, not only explicit
86 /// `flush()` calls. In-process counter; resets to zero on re-open.
87 pub fn total_flushed_bytes(&self) -> u64 {
88 self.total_flushed_bytes
89 }
90}
91
92#[cfg(all(test, feature = "write-support"))]
93mod tests {
94 use super::*;
95 use crate::storage::write_engine::test_support::{
96 create_test_mutation, create_test_schema, flush_n_sstables_sync,
97 };
98 use crate::storage::write_engine::WriteEngineConfig;
99 use tempfile::TempDir;
100
101 #[test]
102 fn test_bytes_written_includes_all_components() {
103 // After a successful merge, cumulative_stats.bytes_written must be at
104 // least as large as the sum of Data.db sizes alone (i.e. it includes
105 // the other component files too).
106 let temp_dir = TempDir::new().unwrap();
107 let schema = create_test_schema();
108
109 let policy = crate::storage::write_engine::STCSPolicy::new(4, 32, 0.5, 1.5, 0).unwrap();
110
111 let config = WriteEngineConfig::new(
112 temp_dir.path().join("data"),
113 temp_dir.path().join("wal"),
114 schema,
115 );
116
117 let mut engine = WriteEngine::new(config).unwrap();
118 let input_paths = flush_n_sstables_sync(&mut engine, 4);
119
120 // Compute the sum of just the Data.db sizes before compaction
121 let data_db_total: u64 = input_paths
122 .iter()
123 .map(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0))
124 .sum();
125
126 engine.set_merge_policy(Box::new(policy)).unwrap();
127 engine.maintenance_step(Duration::from_secs(60)).unwrap();
128
129 let stats = engine.maintenance_stats();
130 // bytes_written counts all output components, so it should be >= what
131 // data_db_total reported for the inputs (output may differ in size but
132 // the multi-component sum must be >= the Data.db-only measurement).
133 // More concretely: if any non-Data component was written, the total
134 // must be larger than data_size alone.
135 //
136 // We assert >= 0 always holds (u64), and additionally that the field
137 // was updated at all (compaction ran).
138 assert_eq!(stats.compactions_completed, 1, "compaction must have run");
139 // The bytes_written field is now the sum of all components.
140 // We can't assert an exact value, but we know:
141 // - data_db_total may be 0 for tiny test SSTables written by the test writer
142 // - if data_db_total > 0, bytes_written >= data_db_total is a reasonable lower bound
143 // - at minimum, the field must equal total_bytes_written (multi-component sum) >= 0
144 let _ = data_db_total; // used above for context; value may be 0 in test environment
145 // The assertion that matters: stats are populated and consistent across calls.
146 // maintenance_stats() returns a clone so two consecutive calls must agree.
147 let stats2 = engine.maintenance_stats();
148 assert_eq!(
149 stats.bytes_written, stats2.bytes_written,
150 "maintenance_stats() must be consistent across calls"
151 );
152 // bytes_written is u64; it is always >= 0. Just confirm the field was set.
153 assert_eq!(
154 stats.sstables_produced, 1,
155 "one output SSTable must have been produced"
156 );
157 }
158
159 // -----------------------------------------------------------------------
160 // Issue #486 — total_written and l0_count non-placeholder behaviour
161 // -----------------------------------------------------------------------
162
163 /// After writing N rows and flushing, `total_written()` == N even though
164 /// `memtable_row_count()` has been reset to 0.
165 #[tokio::test]
166 async fn test_total_written_survives_flush() {
167 let temp_dir = TempDir::new().unwrap();
168 let schema = create_test_schema();
169 let config = WriteEngineConfig::new(
170 temp_dir.path().join("data"),
171 temp_dir.path().join("wal"),
172 schema,
173 );
174 let mut engine = WriteEngine::new(config).unwrap();
175
176 // Write 5 rows to the first "batch"
177 for i in 0..5 {
178 engine
179 .write(create_test_mutation(
180 i,
181 &format!("User{i}"),
182 1_000_000 + i as i64,
183 ))
184 .unwrap();
185 }
186 assert_eq!(engine.total_written(), 5);
187 assert_eq!(engine.memtable_row_count(), 5);
188
189 // Flush — memtable resets but total_written must NOT
190 engine.flush().await.unwrap();
191 assert_eq!(
192 engine.memtable_row_count(),
193 0,
194 "memtable should be empty after flush"
195 );
196 assert_eq!(
197 engine.total_written(),
198 5,
199 "total_written must NOT reset after flush"
200 );
201
202 // Write 3 more rows in a second batch
203 for i in 10..13 {
204 engine
205 .write(create_test_mutation(
206 i,
207 &format!("User{i}"),
208 2_000_000 + i as i64,
209 ))
210 .unwrap();
211 }
212 assert_eq!(
213 engine.total_written(),
214 8,
215 "total_written must accumulate across flushes"
216 );
217 assert_eq!(engine.memtable_row_count(), 3);
218
219 // Flush again
220 engine.flush().await.unwrap();
221 assert_eq!(engine.memtable_row_count(), 0);
222 assert_eq!(
223 engine.total_written(),
224 8,
225 "total_written must still be 8 after second flush"
226 );
227 }
228
229 /// `l0_count()` reflects the number of successful flush operations (not zero).
230 #[tokio::test]
231 async fn test_l0_count_increments_on_flush() {
232 let temp_dir = TempDir::new().unwrap();
233 let schema = create_test_schema();
234 let config = WriteEngineConfig::new(
235 temp_dir.path().join("data"),
236 temp_dir.path().join("wal"),
237 schema,
238 );
239 let mut engine = WriteEngine::new(config).unwrap();
240
241 assert_eq!(engine.l0_count(), 0, "l0_count should start at 0");
242
243 // Flushing an empty memtable produces no SSTable — count stays 0
244 engine.flush().await.unwrap();
245 assert_eq!(
246 engine.l0_count(),
247 0,
248 "empty flush must not increment l0_count"
249 );
250
251 // Write one row and flush — count must become 1
252 engine
253 .write(create_test_mutation(1, "Alice", 1_000_000))
254 .unwrap();
255 engine.flush().await.unwrap();
256 assert_eq!(
257 engine.l0_count(),
258 1,
259 "l0_count must be 1 after first non-empty flush"
260 );
261
262 // Write and flush again — count must become 2
263 engine
264 .write(create_test_mutation(2, "Bob", 2_000_000))
265 .unwrap();
266 engine.flush().await.unwrap();
267 assert_eq!(
268 engine.l0_count(),
269 2,
270 "l0_count must be 2 after second non-empty flush"
271 );
272 }
273
274 /// Verify that `total_written` != `memtable_row_count` after a flush —
275 /// the scenario that proves the placeholder is replaced.
276 #[tokio::test]
277 async fn test_total_written_differs_from_memtable_after_flush() {
278 let temp_dir = TempDir::new().unwrap();
279 let schema = create_test_schema();
280 let config = WriteEngineConfig::new(
281 temp_dir.path().join("data"),
282 temp_dir.path().join("wal"),
283 schema,
284 );
285 let mut engine = WriteEngine::new(config).unwrap();
286
287 // Write 7 rows and flush
288 for i in 0..7 {
289 engine
290 .write(create_test_mutation(
291 i,
292 &format!("User{i}"),
293 1_000_000 + i as i64,
294 ))
295 .unwrap();
296 }
297 engine.flush().await.unwrap();
298
299 // Post-flush: memtable_row_count is 0 but total_written is 7
300 assert_eq!(engine.memtable_row_count(), 0);
301 assert_eq!(engine.total_written(), 7);
302 assert_ne!(
303 engine.total_written() as usize,
304 engine.memtable_row_count(),
305 "total_written must differ from memtable_row_count after flush — \
306 proves neither is a placeholder"
307 );
308 }
309}