1use std::io::Write;
10use std::path::PathBuf;
11use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
12use std::sync::{Arc, Mutex};
13
14use tracing_appender::non_blocking::{NonBlocking, NonBlockingBuilder, WorkerGuard};
15
16use crate::runtime::EffectiveScope;
17
18pub struct SlowQueryOpts {
23 pub log_dir: PathBuf,
24 pub threshold_ms: u64,
25 pub sample_pct: u8,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum QueryKind {
32 Select,
33 Insert,
34 Update,
35 Delete,
36 Bulk,
37 Aggregate,
38 DDL,
39 Internal,
40}
41
42impl QueryKind {
43 pub const ALL: [QueryKind; 8] = [
47 Self::Select,
48 Self::Insert,
49 Self::Update,
50 Self::Delete,
51 Self::Bulk,
52 Self::Aggregate,
53 Self::DDL,
54 Self::Internal,
55 ];
56
57 pub fn as_str(self) -> &'static str {
58 match self {
59 Self::Select => "select",
60 Self::Insert => "insert",
61 Self::Update => "update",
62 Self::Delete => "delete",
63 Self::Bulk => "bulk",
64 Self::Aggregate => "aggregate",
65 Self::DDL => "ddl",
66 Self::Internal => "internal",
67 }
68 }
69
70 pub fn index(self) -> usize {
73 match self {
74 Self::Select => 0,
75 Self::Insert => 1,
76 Self::Update => 2,
77 Self::Delete => 3,
78 Self::Bulk => 4,
79 Self::Aggregate => 5,
80 Self::DDL => 6,
81 Self::Internal => 7,
82 }
83 }
84}
85
86pub struct SlowQueryLogger {
91 writer: Mutex<NonBlocking>,
93 _guard: WorkerGuard,
95 threshold_ms: AtomicU64,
96 sample_pct: AtomicU8,
98 above_count: AtomicU64,
101 store: std::sync::OnceLock<std::sync::Arc<super::slow_query_store::SlowQueryStore>>,
104}
105
106impl SlowQueryLogger {
107 pub fn new(opts: SlowQueryOpts) -> Arc<Self> {
108 let path = reddb_file::layout::legacy_slow_query_log_path(&opts.log_dir);
109 Self::open_at(path, opts.threshold_ms, opts.sample_pct)
110 }
111
112 pub fn for_destination(
117 dest: &crate::storage::layout::LogDestination,
118 fallback_log_dir: &std::path::Path,
119 threshold_ms: u64,
120 sample_pct: u8,
121 ) -> Arc<Self> {
122 use crate::storage::layout::LogDestination;
123 let path = match dest {
124 LogDestination::File(p) => p.clone(),
125 LogDestination::Stderr => {
126 return Self::from_writer(std::io::stderr(), threshold_ms, sample_pct);
127 }
128 LogDestination::Syslog => {
129 tracing::warn!(
130 target: "reddb::slow",
131 "slow-query LogDestination::Syslog requested; sink not implemented, falling back to file"
132 );
133 reddb_file::layout::legacy_slow_query_log_path(fallback_log_dir)
134 }
135 };
136 Self::open_at(path, threshold_ms, sample_pct)
137 }
138
139 fn open_at(path: PathBuf, threshold_ms: u64, sample_pct: u8) -> Arc<Self> {
140 let file = (|| -> std::io::Result<std::fs::File> {
141 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
142 std::fs::create_dir_all(parent)?;
143 }
144 std::fs::OpenOptions::new()
145 .create(true)
146 .append(true)
147 .open(&path)
148 })();
149 match file {
150 Ok(file) => Self::from_writer(file, threshold_ms, sample_pct),
151 Err(error) => {
152 tracing::warn!(
153 target: "reddb::slow",
154 path = %path.display(),
155 %error,
156 "slow-query file sink unavailable; using stderr"
157 );
158 Self::from_writer(std::io::stderr(), threshold_ms, sample_pct)
159 }
160 }
161 }
162
163 fn from_writer(
164 sink: impl Write + Send + 'static,
165 threshold_ms: u64,
166 sample_pct: u8,
167 ) -> Arc<Self> {
168 let (writer, guard) = NonBlockingBuilder::default()
169 .buffered_lines_limit(65_536)
170 .lossy(true)
171 .finish(sink);
172
173 Arc::new(Self {
174 writer: Mutex::new(writer),
175 _guard: guard,
176 threshold_ms: AtomicU64::new(threshold_ms),
177 sample_pct: AtomicU8::new(sample_pct.min(100)),
178 above_count: AtomicU64::new(0),
179 store: std::sync::OnceLock::new(),
180 })
181 }
182
183 pub fn attach_store(&self, store: std::sync::Arc<super::slow_query_store::SlowQueryStore>) {
189 let _ = self.store.set(store);
190 }
191
192 pub fn record(
195 &self,
196 kind: QueryKind,
197 duration_ms: u64,
198 sql_redacted: String,
199 scope: &EffectiveScope,
200 ) {
201 if duration_ms < self.threshold_ms.load(Ordering::Relaxed) {
203 return;
204 }
205
206 let pct = u64::from(self.sample_pct.load(Ordering::Relaxed));
210 if pct < 100 {
211 let n = self.above_count.fetch_add(1, Ordering::Relaxed);
212 if (n % 100) >= pct {
213 return;
214 }
215 }
216
217 self.emit(kind, duration_ms, sql_redacted, scope);
218 }
219
220 fn emit(
221 &self,
222 kind: QueryKind,
223 duration_ms: u64,
224 sql_redacted: String,
225 scope: &EffectiveScope,
226 ) {
227 let ts_ms = crate::utils::now_unix_millis();
228 let tenant = scope.tenant.as_deref().unwrap_or("").to_string();
229 let identity = scope
230 .identity
231 .as_ref()
232 .map(|(u, _)| u.as_str())
233 .unwrap_or("")
234 .to_string();
235
236 if let Some(store) = self.store.get() {
240 store.push(super::slow_query_store::SlowQueryEvent {
241 ts_ms,
242 kind: kind.as_str(),
243 duration_ms,
244 sql_redacted: sql_redacted.clone(),
245 tenant_hash: super::slow_query_store::hash_label(&tenant),
246 identity_hash: super::slow_query_store::hash_label(&identity),
247 });
248 }
249
250 let mut map = std::collections::BTreeMap::new();
251 map.insert(
252 "ts_ms".to_string(),
253 crate::json::Value::Number(ts_ms as f64),
254 );
255 map.insert(
256 "kind".to_string(),
257 crate::json::Value::String(kind.as_str().to_string()),
258 );
259 map.insert(
260 "duration_ms".to_string(),
261 crate::json::Value::Number(duration_ms as f64),
262 );
263 map.insert("sql".to_string(), crate::json::Value::String(sql_redacted));
264 map.insert("tenant".to_string(), crate::json::Value::String(tenant));
265 map.insert("identity".to_string(), crate::json::Value::String(identity));
266
267 let obj = crate::json::Value::Object(map);
268 if let Ok(mut line) = crate::json::to_string(&obj) {
269 line.push('\n');
270 if let Ok(mut w) = self.writer.lock() {
271 let _ = w.write_all(line.as_bytes());
272 }
273 }
274 }
275}
276
277#[cfg(test)]
282mod tests {
283 use std::collections::HashSet;
284 use std::sync::mpsc;
285 use std::time::Duration;
286
287 use super::*;
288 use crate::runtime::EffectiveScope;
289 use crate::storage::transaction::snapshot::Snapshot;
290
291 struct TempSlowDir(PathBuf);
292
293 impl std::ops::Deref for TempSlowDir {
294 type Target = PathBuf;
295
296 fn deref(&self) -> &PathBuf {
297 &self.0
298 }
299 }
300
301 impl AsRef<std::path::Path> for TempSlowDir {
302 fn as_ref(&self) -> &std::path::Path {
303 &self.0
304 }
305 }
306
307 impl Drop for TempSlowDir {
308 fn drop(&mut self) {
309 let _ = std::fs::remove_dir_all(&self.0);
310 }
311 }
312
313 fn tmp_dir() -> TempSlowDir {
314 let mut d = std::env::temp_dir();
315 d.push(format!(
316 "reddb-slow-{}-{}",
317 std::process::id(),
318 crate::utils::now_unix_nanos()
319 ));
320 TempSlowDir(d)
321 }
322
323 fn logger(dir: &TempSlowDir, threshold_ms: u64, sample_pct: u8) -> Arc<SlowQueryLogger> {
324 SlowQueryLogger::new(SlowQueryOpts {
325 log_dir: dir.0.clone(),
326 threshold_ms,
327 sample_pct,
328 })
329 }
330
331 fn empty_scope() -> EffectiveScope {
332 EffectiveScope {
333 captured_snapshot: None,
334 tenant: None,
335 identity: None,
336 auth_principal: None,
337 snapshot: Snapshot {
338 xid: 0,
339 in_progress: HashSet::new(),
340 },
341 visible_collections: None,
342 }
343 }
344
345 #[test]
346 fn unavailable_log_directory_does_not_panic_or_drop_store_events() {
347 let directory = tempfile::tempdir().expect("temporary directory");
348 let blocked = directory.path().join("not-a-directory");
349 std::fs::write(&blocked, b"file").expect("unavailable directory fixture");
350 let logger = SlowQueryLogger::new(SlowQueryOpts {
351 log_dir: blocked.join("slow"),
352 threshold_ms: 0,
353 sample_pct: 100,
354 });
355 let store = super::super::slow_query_store::SlowQueryStore::new(4);
356 logger.attach_store(Arc::clone(&store));
357 logger.record(QueryKind::Select, 42, "SELECT ?".into(), &empty_scope());
358 assert_eq!(store.len(), 1);
359 assert!(blocked.is_file());
360 }
361
362 #[test]
363 fn stderr_destination_does_not_create_a_fallback_file() {
364 let directory = tempfile::tempdir().expect("temporary directory");
365 let fallback = directory.path().join("unused");
366 let logger = SlowQueryLogger::for_destination(
367 &crate::storage::layout::LogDestination::Stderr,
368 &fallback,
369 100,
370 100,
371 );
372 drop(logger);
373 assert!(
374 !fallback.exists(),
375 "stderr logging created filesystem artifacts"
376 );
377 }
378
379 fn flush(log: Arc<SlowQueryLogger>) {
380 drop(log);
383 }
384
385 fn read_log_lines(dir: &TempSlowDir) -> Vec<crate::json::Value> {
386 let path = reddb_file::layout::legacy_slow_query_log_path(dir);
387 let body = std::fs::read_to_string(&path).unwrap_or_default();
388 body.lines()
389 .filter(|l| !l.is_empty())
390 .map(|l| crate::json::from_str::<crate::json::Value>(l).expect("valid JSON"))
391 .collect()
392 }
393
394 #[test]
399 fn below_threshold_no_file_writes() {
400 let dir = tmp_dir();
401 let log = logger(&dir, 1000, 100);
402 let scope = empty_scope();
403
404 for _ in 0..10_000 {
405 log.record(QueryKind::Select, 5, "SELECT 1".into(), &scope);
406 }
407
408 flush(log);
409 let lines = read_log_lines(&dir);
410 assert!(
411 lines.is_empty(),
412 "expected zero writes, got {}",
413 lines.len()
414 );
415 }
416
417 #[test]
418 fn below_threshold_fast_path_does_not_wait_for_writer() {
419 let dir = tmp_dir();
420 let log = logger(&dir, 1000, 100);
421
422 let writer_guard = log.writer.lock().expect("writer lock");
426 let worker_log = Arc::clone(&log);
427 let (done_tx, done_rx) = mpsc::channel();
428 let worker = std::thread::spawn(move || {
429 let scope = empty_scope();
430 for _ in 0..10_000 {
431 worker_log.record(QueryKind::Select, 5, "SELECT 1".into(), &scope);
432 }
433 let _ = done_tx.send(());
434 });
435
436 let completed_without_writer = done_rx.recv_timeout(Duration::from_secs(1)).is_ok();
437 drop(writer_guard);
438 worker.join().expect("below-threshold worker");
439 assert!(
440 completed_without_writer,
441 "below-threshold calls waited for the slow-query writer"
442 );
443 }
444
445 #[test]
450 fn above_threshold_emits_json_line() {
451 let dir = tmp_dir();
452 let log = logger(&dir, 10, 100);
453 let scope = empty_scope();
454
455 log.record(QueryKind::Select, 100, "SELECT * FROM t".into(), &scope);
456 flush(log);
457
458 let lines = read_log_lines(&dir);
459 assert_eq!(lines.len(), 1, "expected 1 line");
460 let v = &lines[0];
461 assert_eq!(v.get("kind").and_then(|x| x.as_str()), Some("select"));
462 assert_eq!(v.get("duration_ms").and_then(|x| x.as_i64()), Some(100));
463 let sql = v.get("sql").and_then(|x| x.as_str());
464 assert_eq!(sql, Some("SELECT * FROM t"));
465 }
466
467 #[test]
468 fn json_line_has_all_required_fields() {
469 let dir = tmp_dir();
470 let log = logger(&dir, 0, 100);
471 let scope = empty_scope();
472
473 log.record(
474 QueryKind::Insert,
475 42,
476 "INSERT INTO t VALUES (1)".into(),
477 &scope,
478 );
479 flush(log);
480
481 let lines = read_log_lines(&dir);
482 assert_eq!(lines.len(), 1);
483 let v = &lines[0];
484 assert!(v.get("ts_ms").is_some(), "missing ts_ms");
485 assert!(v.get("kind").is_some(), "missing kind");
486 assert!(v.get("duration_ms").is_some(), "missing duration_ms");
487 assert!(v.get("sql").is_some(), "missing sql");
488 assert!(v.get("tenant").is_some(), "missing tenant");
489 assert!(v.get("identity").is_some(), "missing identity");
490 }
491
492 #[test]
493 fn all_query_kinds_serialise() {
494 let kinds = [
495 QueryKind::Select,
496 QueryKind::Insert,
497 QueryKind::Update,
498 QueryKind::Delete,
499 QueryKind::Bulk,
500 QueryKind::Aggregate,
501 QueryKind::DDL,
502 QueryKind::Internal,
503 ];
504 for k in kinds {
505 assert!(!k.as_str().is_empty());
506 }
507 }
508
509 #[test]
514 fn sampling_property_10pct() {
515 let dir = tmp_dir();
516 let log = logger(&dir, 0, 10);
518 let scope = empty_scope();
519
520 let calls = 10_000u64;
521 for i in 0..calls {
522 log.record(QueryKind::Select, 1, format!("SELECT {i}"), &scope);
523 }
524 flush(log);
525
526 let lines = read_log_lines(&dir);
527 let emitted = lines.len() as u64;
528 assert!(
530 emitted >= 800 && emitted <= 1200,
531 "sample_pct=10 over {calls} calls emitted {emitted} (expected 800..=1200)"
532 );
533 }
534
535 #[test]
540 fn adversarial_sql_is_escape_safe() {
541 let payloads: &[(&str, &str)] = &[
542 ("crlf", "SELECT 1\r\nDROP TABLE t--"),
543 ("nul", "SELECT '\x00'"),
544 ("quote", r#"SELECT "secret" FROM t"#),
545 ("json_inject", r#"SELECT 1},"pwned":true,{"x":"#),
546 ("low_ctrl", "SELECT \x01\x02\x07\x1f"),
547 ("backslash", "SELECT 'C:\\path\\file'"),
548 ];
549
550 for (label, sql) in payloads {
551 let dir = tmp_dir();
552 let log = logger(&dir, 0, 100);
553 let scope = empty_scope();
554
555 log.record(QueryKind::Select, 1, sql.to_string(), &scope);
556 flush(log);
557
558 let path = reddb_file::layout::legacy_slow_query_log_path(&dir);
559 let body = std::fs::read_to_string(&path).unwrap_or_default();
560 let line = body
561 .lines()
562 .find(|l| !l.is_empty())
563 .unwrap_or_else(|| panic!("{label}: no line emitted"));
564
565 assert!(
567 !line.contains('\n'),
568 "{label}: embedded newline in JSONL row"
569 );
570
571 let v: crate::json::Value = crate::json::from_str(line)
573 .unwrap_or_else(|e| panic!("{label}: not valid JSON: {e}\n{line:?}"));
574
575 let recovered = v.get("sql").and_then(|x| x.as_str()).unwrap_or("");
577 assert_eq!(recovered, *sql, "{label}: SQL round-trip mismatch");
578 }
579 }
580
581 #[test]
586 fn at_threshold_boundary_emits() {
587 let dir = tmp_dir();
588 let log = logger(&dir, 50, 100);
589 let scope = empty_scope();
590
591 log.record(QueryKind::Select, 50, "SELECT 1".into(), &scope);
593 flush(log);
594 let lines = read_log_lines(&dir);
595 assert_eq!(lines.len(), 1, "duration == threshold should emit");
596 }
597
598 #[test]
599 fn just_below_threshold_does_not_emit() {
600 let dir = tmp_dir();
601 let log = logger(&dir, 50, 100);
602 let scope = empty_scope();
603
604 log.record(QueryKind::Select, 49, "SELECT 1".into(), &scope);
605 flush(log);
606 let lines = read_log_lines(&dir);
607 assert!(lines.is_empty(), "duration < threshold must not emit");
608 }
609}