1use crate::Result;
2use crate::daemon_id::DaemonId;
3use crate::log_parse::ParsedLog;
4use crate::log_store::{
5 ArchiveHook, FieldFilter, LogEntry, LogQuery, LogStore, MessageFilter, escape_like_pattern,
6};
7use chrono::{DateTime, Local, TimeZone};
8use log::error;
9use miette::IntoDiagnostic;
10use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
11use std::collections::HashSet;
12use std::io::{BufRead, BufReader, Write};
13use std::path::PathBuf;
14use std::sync::Mutex;
15
16fn text_to_json_literal(value: &str) -> String {
28 if value.eq_ignore_ascii_case("true") {
30 return "true".to_string();
31 }
32 if value.eq_ignore_ascii_case("false") {
33 return "false".to_string();
34 }
35 if value.eq_ignore_ascii_case("null") {
37 return "null".to_string();
38 }
39 if let Ok(serde_json::Value::Number(_)) = serde_json::from_str(value) {
43 return value.to_string();
44 }
45 serde_json::to_string(value).unwrap_or_else(|_| format!(r#""{value}""#))
47}
48
49fn add_regexp_function(conn: &Connection) -> Result<()> {
55 use std::cell::RefCell;
56
57 let cache: RefCell<lru::LruCache<String, regex::Regex>> =
61 RefCell::new(lru::LruCache::new(std::num::NonZeroUsize::new(32).unwrap()));
62
63 conn.create_scalar_function(
64 "regexp",
65 2,
66 rusqlite::functions::FunctionFlags::SQLITE_UTF8
67 | rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC,
68 move |ctx| {
69 let pattern: String = ctx.get(0)?;
70 let text: String = ctx.get(1)?;
71
72 let mut cache = cache.borrow_mut();
73 let re = match cache.get(&pattern) {
74 Some(re) => re.clone(),
75 None => {
76 let re = regex::Regex::new(&pattern)
77 .map_err(|e| rusqlite::Error::UserFunctionError(e.to_string().into()))?;
78 cache.put(pattern.clone(), re.clone());
79 re
80 }
81 };
82 Ok(re.is_match(&text))
83 },
84 )
85 .into_diagnostic()
86}
87
88pub struct SqliteLogStore {
90 conn: Mutex<Connection>,
91 path: PathBuf,
92}
93
94const PARALLEL_QUERY_THRESHOLD: usize = 200_000;
101
102const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
106
107fn enable_wal(conn: &Connection) {
118 const ATTEMPTS: usize = 5;
119 for attempt in 0..ATTEMPTS {
120 match conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0)) {
121 Ok(mode) if mode.eq_ignore_ascii_case("wal") => return,
122 Ok(_) => {}
123 Err(e) => {
124 debug!("could not read journal_mode: {e}");
125 return;
126 }
127 }
128 if conn.execute_batch("PRAGMA journal_mode = WAL;").is_ok() {
129 return;
130 }
131 if attempt + 1 < ATTEMPTS {
132 std::thread::sleep(std::time::Duration::from_millis(20));
133 }
134 }
135 debug!("log store is not in WAL mode; another connection may be switching it");
136}
137
138impl SqliteLogStore {
139 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
141 let path = path.into();
142 if let Some(parent) = path.parent() {
143 std::fs::create_dir_all(parent).into_diagnostic()?;
144 }
145 let conn = Connection::open(&path).into_diagnostic()?;
146
147 conn.busy_timeout(BUSY_TIMEOUT).into_diagnostic()?;
155
156 add_regexp_function(&conn)?;
157
158 enable_wal(&conn);
159
160 conn.execute_batch(
161 "PRAGMA synchronous = NORMAL;
162 PRAGMA mmap_size = 268435456;",
163 )
164 .into_diagnostic()?;
165 conn.execute(
166 "CREATE TABLE IF NOT EXISTS log_entries (
167 id INTEGER PRIMARY KEY AUTOINCREMENT,
168 daemon_id TEXT NOT NULL,
169 timestamp INTEGER NOT NULL,
170 message TEXT NOT NULL,
171 level TEXT,
172 msg TEXT,
173 logger TEXT,
174 fields_json TEXT
175 );",
176 [],
177 )
178 .into_diagnostic()?;
179 conn.execute(
180 "CREATE INDEX IF NOT EXISTS idx_daemon_ts ON log_entries(daemon_id, timestamp);",
181 [],
182 )
183 .into_diagnostic()?;
184 conn.execute(
185 "CREATE INDEX IF NOT EXISTS idx_daemon_id ON log_entries(daemon_id, id);",
186 [],
187 )
188 .into_diagnostic()?;
189 conn.execute(
190 "CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp);",
191 [],
192 )
193 .into_diagnostic()?;
194
195 let existing_cols: Vec<String> = {
198 let mut stmt = conn
199 .prepare("PRAGMA table_info(log_entries)")
200 .into_diagnostic()?;
201 let rows = stmt
202 .query_map([], |row| row.get::<_, String>(1))
203 .into_diagnostic()?;
204 rows.filter_map(|r| r.ok()).collect()
205 };
206 for col in ["level", "msg", "logger", "fields_json"] {
207 if !existing_cols.iter().any(|c| c == col) {
208 conn.execute(
209 &format!("ALTER TABLE log_entries ADD COLUMN {col} TEXT"),
210 [],
211 )
212 .into_diagnostic()?;
213 }
214 }
215 conn.execute(
216 "CREATE INDEX IF NOT EXISTS idx_daemon_level_ts ON log_entries(daemon_id, level, timestamp);",
217 [],
218 )
219 .into_diagnostic()?;
220
221 conn.execute(
222 "CREATE TABLE IF NOT EXISTS log_clear_generations (
223 daemon_id TEXT PRIMARY KEY,
224 generation INTEGER NOT NULL DEFAULT 0
225 );",
226 [],
227 )
228 .into_diagnostic()?;
229 Ok(Self {
230 conn: Mutex::new(conn),
231 path,
232 })
233 }
234
235 fn row_to_entry(row: &rusqlite::Row) -> rusqlite::Result<LogEntry> {
236 let id: i64 = row.get(0)?;
237 let daemon_id: String = row.get(1)?;
238 let ts_millis: i64 = row.get(2)?;
239 let message: String = row.get(3)?;
240 let level: Option<String> = row.get(4)?;
241 let msg: Option<String> = row.get(5)?;
242 let logger: Option<String> = row.get(6)?;
243 let fields_json: Option<String> = row.get(7)?;
244 let timestamp = Local
245 .timestamp_millis_opt(ts_millis)
246 .single()
247 .unwrap_or_else(Local::now);
248 Ok(LogEntry {
249 id,
250 daemon_id,
251 timestamp,
252 message,
253 level,
254 msg,
255 logger,
256 fields_json,
257 })
258 }
259
260 fn archive_entries(
268 &self,
269 entries: &[LogEntry],
270 archive_hook: &ArchiveHook,
271 daemon_id: &DaemonId,
272 reason: &str,
273 ) -> Result<()> {
274 use crate::inherited_fds::CloseInheritedFds;
275 use crate::shell::{HideConsoleWindow, ShellScript};
276 use std::process::{Command, Stdio};
277
278 if entries.is_empty() {
279 return Ok(());
280 }
281
282 let shell =
289 crate::settings::resolve_shell().map_err(|e| miette::miette!("archive hook: {e}"))?;
290 let (shell_program, shell_args) = shell.split_first().unwrap();
291
292 for chunk in entries.chunks(archive_hook.batch_size.max(1)) {
293 let mut child = Command::new(shell_program)
294 .shell_script(shell_program, shell_args, &archive_hook.command)
295 .stdin(Stdio::piped())
296 .stdout(Stdio::null())
297 .stderr(Stdio::piped())
298 .env("PITCHFORK_DAEMON_ID", daemon_id.qualified())
299 .env("PITCHFORK_ARCHIVE_REASON", reason)
300 .hide_console_window()
301 .close_inherited_fds()
302 .spawn()
303 .into_diagnostic()
304 .map_err(|e| miette::miette!("failed to spawn archive hook: {e}"))?;
305
306 let write_result = {
310 let stdin = child.stdin.take().expect("piped stdin should be available");
311 let mut stdin = std::io::BufWriter::new(stdin);
312 let mut result = Ok(());
313 for entry in chunk {
314 let line = serde_json::json!({
315 "id": entry.id,
316 "daemon_id": entry.daemon_id,
317 "timestamp": entry.timestamp.to_rfc3339(),
318 "message": entry.message,
319 });
320 if let Err(e) = writeln!(stdin, "{}", line) {
321 result = Err(miette::miette!(
322 "failed to write to archive hook stdin: {e}"
323 ));
324 break;
325 }
326 }
327 if result.is_ok()
335 && let Err(e) = stdin.flush()
336 {
337 result = Err(miette::miette!("failed to flush archive hook stdin: {e}"));
338 }
339 result
340 };
342
343 if let Err(e) = write_result {
344 let _ = child.kill();
345 let _ = child.wait();
346 return Err(e);
347 }
348
349 let output = child.wait_with_output().into_diagnostic()?;
350 if !output.status.success() {
351 let stderr = String::from_utf8_lossy(&output.stderr);
352 return Err(miette::miette!(
353 "archive hook failed with status {}: {stderr}",
354 output.status
355 ));
356 }
357 }
358
359 Ok(())
360 }
361
362 fn delete_by_ids(&self, ids: &[i64]) -> Result<u64> {
367 const SQLITE_MAX_VARS: usize = 999;
368
369 let mut total = 0u64;
370 let conn = self.conn.lock().unwrap();
371 for chunk in ids.chunks(SQLITE_MAX_VARS) {
372 if chunk.is_empty() {
373 continue;
374 }
375 let placeholders: Vec<String> = (1..=chunk.len()).map(|i| format!("?{i}")).collect();
376 let sql = format!(
377 "DELETE FROM log_entries WHERE id IN ({})",
378 placeholders.join(", ")
379 );
380 total += conn
381 .execute(&sql, rusqlite::params_from_iter(chunk.iter()))
382 .into_diagnostic()? as u64;
383 }
384 Ok(total)
385 }
386
387 pub fn rotate_by_age(
394 &self,
395 daemon_id: &DaemonId,
396 max_age: chrono::Duration,
397 archive_hook: Option<&ArchiveHook>,
398 ) -> Result<u64> {
399 let cutoff = (Local::now() - max_age).timestamp_millis();
400 let hook = archive_hook.filter(|h| h.is_enabled());
401
402 if let Some(hook) = hook {
403 let mut total_deleted = 0u64;
404 loop {
405 let entries: Vec<LogEntry> = {
407 let conn = self.conn.lock().unwrap();
408 let mut stmt = conn
409 .prepare(
410 "SELECT id, daemon_id, timestamp, message, level, msg, logger, fields_json FROM log_entries
411 WHERE daemon_id = ?1 AND timestamp < ?2
412 ORDER BY timestamp ASC, id ASC
413 LIMIT ?3",
414 )
415 .into_diagnostic()?;
416 stmt.query_map(
417 params![daemon_id.qualified(), cutoff, hook.batch_size as i64],
418 Self::row_to_entry,
419 )
420 .into_diagnostic()?
421 .collect::<rusqlite::Result<Vec<_>>>()
422 .into_diagnostic()?
423 };
424
425 if entries.is_empty() {
426 break;
427 }
428
429 let batch_ids: Vec<i64> = entries.iter().map(|e| e.id).collect();
430
431 self.archive_entries(&entries, hook, daemon_id, "age")?;
433
434 let deleted = self.delete_by_ids(&batch_ids)?;
436 total_deleted += deleted;
437 }
438 Ok(total_deleted)
439 } else {
440 let conn = self.conn.lock().unwrap();
441 let rows = conn
442 .execute(
443 "DELETE FROM log_entries WHERE daemon_id = ?1 AND timestamp < ?2",
444 params![daemon_id.qualified(), cutoff],
445 )
446 .into_diagnostic()?;
447 Ok(rows as u64)
448 }
449 }
450
451 pub fn rotate_by_count(
459 &self,
460 daemon_id: &DaemonId,
461 max_count: u64,
462 archive_hook: Option<&ArchiveHook>,
463 ) -> Result<u64> {
464 let hook = archive_hook.filter(|h| h.is_enabled());
465
466 let to_delete: i64 = {
468 let conn = self.conn.lock().unwrap();
469 let count: i64 = conn
470 .query_row(
471 "SELECT COUNT(*) FROM log_entries WHERE daemon_id = ?1",
472 [daemon_id.qualified()],
473 |row| row.get(0),
474 )
475 .into_diagnostic()?;
476 count.saturating_sub(max_count as i64)
477 };
478
479 if to_delete <= 0 {
480 return Ok(0);
481 }
482
483 if let Some(hook) = hook {
484 let mut total_deleted = 0u64;
485 let mut remaining = to_delete;
486 loop {
487 let batch_len = remaining.min(hook.batch_size as i64);
488
489 let entries: Vec<LogEntry> = {
491 let conn = self.conn.lock().unwrap();
492 let mut stmt = conn
493 .prepare(
494 "SELECT id, daemon_id, timestamp, message, level, msg, logger, fields_json FROM log_entries
495 WHERE daemon_id = ?1
496 ORDER BY timestamp ASC, id ASC
497 LIMIT ?2",
498 )
499 .into_diagnostic()?;
500 stmt.query_map(
501 params![daemon_id.qualified(), batch_len],
502 Self::row_to_entry,
503 )
504 .into_diagnostic()?
505 .collect::<rusqlite::Result<Vec<_>>>()
506 .into_diagnostic()?
507 };
508
509 if entries.is_empty() {
510 break;
511 }
512
513 let fetched = entries.len() as i64;
514 let batch_ids: Vec<i64> = entries.iter().map(|e| e.id).collect();
515
516 self.archive_entries(&entries, hook, daemon_id, "count")?;
518
519 let deleted = self.delete_by_ids(&batch_ids)?;
521 total_deleted += deleted;
522 remaining -= fetched;
523 }
524 Ok(total_deleted)
525 } else {
526 let conn = self.conn.lock().unwrap();
527 let rows = conn
528 .execute(
529 "DELETE FROM log_entries WHERE id IN (
530 SELECT id FROM log_entries WHERE daemon_id = ?1
531 ORDER BY timestamp ASC, id ASC LIMIT ?2
532 )",
533 params![daemon_id.qualified(), to_delete],
534 )
535 .into_diagnostic()?;
536 Ok(rows as u64)
537 }
538 }
539
540 pub fn migrate_daemon_text_logs(&self, daemon_id: &DaemonId) -> Result<u64> {
545 let text_path = daemon_id.log_path();
546 if !text_path.exists() {
547 return Ok(0);
548 }
549
550 let file = std::fs::File::open(&text_path).into_diagnostic()?;
551 let reader = BufReader::new(file);
552 let re = regex::Regex::new(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ([\w./-]+) (.*)$")
553 .expect("invalid regex");
554
555 let mut current_timestamp: Option<DateTime<Local>> = None;
556 let mut current_message = String::new();
557 let mut entries = Vec::with_capacity(1000);
558 let mut total_migrated: u64 = 0;
559
560 for line in reader.lines() {
561 let line = line.into_diagnostic()?;
562 if let Some(caps) = re.captures(&line) {
563 if let Some(ts) = current_timestamp.take() {
564 entries.push((ts, std::mem::take(&mut current_message)));
565 }
566 let ts_str = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
567 let msg = caps.get(3).map(|m| m.as_str()).unwrap_or_default();
568 if let Ok(naive) =
569 chrono::NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d %H:%M:%S")
570 {
571 current_timestamp = Local.from_local_datetime(&naive).single();
572 current_message = msg.to_string();
573 }
574 } else if current_timestamp.is_some() {
575 current_message.push('\n');
576 current_message.push_str(&line);
577 }
578
579 if entries.len() >= 1000 {
580 total_migrated += self.insert_batch(daemon_id, &entries)?;
581 entries.clear();
582 }
583 }
584
585 if let Some(ts) = current_timestamp {
586 entries.push((ts, std::mem::take(&mut current_message)));
587 }
588
589 if !entries.is_empty() {
590 total_migrated += self.insert_batch(daemon_id, &entries)?;
591 }
592
593 if total_migrated > 0
594 && let Err(e) = std::fs::remove_file(&text_path)
595 {
596 log::warn!(
597 "failed to remove legacy log file after migration {}: {e}",
598 text_path.display()
599 );
600 }
601
602 Ok(total_migrated)
603 }
604
605 fn insert_batch(
606 &self,
607 daemon_id: &DaemonId,
608 entries: &[(DateTime<Local>, String)],
609 ) -> Result<u64> {
610 let mut conn = self.conn.lock().unwrap();
611 let tx = conn.transaction().into_diagnostic()?;
612 let mut count = 0u64;
613 {
614 let mut stmt = tx
615 .prepare(
616 "INSERT INTO log_entries (daemon_id, timestamp, message) VALUES (?1, ?2, ?3)",
617 )
618 .into_diagnostic()?;
619 for (ts, msg) in entries {
620 stmt.execute(params![daemon_id.qualified(), ts.timestamp_millis(), msg])
621 .into_diagnostic()?;
622 count += 1;
623 }
624 }
625 tx.commit().into_diagnostic()?;
626 Ok(count)
627 }
628
629 fn build_query_sql(
634 opts: &LogQuery,
635 id_range: Option<(i64, i64)>,
636 ) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
637 let mut conditions = Vec::new();
638 let mut query_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
639
640 if !opts.daemon_ids.is_empty() {
641 let placeholders: Vec<String> = (1..=opts.daemon_ids.len())
642 .map(|i| format!("?{}", i))
643 .collect();
644 conditions.push(format!("daemon_id IN ({})", placeholders.join(", ")));
645 for id in &opts.daemon_ids {
646 query_params.push(Box::new(id.clone()));
647 }
648 }
649
650 if let Some(from) = opts.from {
651 conditions.push(format!("timestamp >= ?{}", query_params.len() + 1));
652 query_params.push(Box::new(from.timestamp_millis()));
653 }
654
655 if let Some(to) = opts.to {
656 conditions.push(format!("timestamp <= ?{}", query_params.len() + 1));
657 query_params.push(Box::new(to.timestamp_millis()));
658 }
659
660 if let Some(after_id) = opts.after_id {
661 conditions.push(format!("id > ?{}", query_params.len() + 1));
662 query_params.push(Box::new(after_id));
663 }
664
665 if let Some(before_id) = opts.before_id {
666 conditions.push(format!("id < ?{}", query_params.len() + 1));
667 query_params.push(Box::new(before_id));
668 }
669
670 if let Some((start, end)) = id_range {
671 conditions.push(format!("id > ?{}", query_params.len() + 1));
672 query_params.push(Box::new(start));
673 conditions.push(format!("id <= ?{}", query_params.len() + 1));
674 query_params.push(Box::new(end));
675 }
676
677 let mut message_conditions = Vec::new();
678 for filter in &opts.message_filters {
679 match filter {
680 MessageFilter::Contains {
681 pattern,
682 case_sensitive,
683 } => {
684 let param_index = query_params.len() + 1;
685 if *case_sensitive {
686 message_conditions
687 .push(format!("INSTR(message, ?{idx}) > 0", idx = param_index));
688 query_params.push(Box::new(pattern.clone()));
689 } else {
690 let escaped = escape_like_pattern(pattern);
691 let param = format!("%{}%", escaped);
692 message_conditions.push(format!(
693 "LOWER(message) LIKE LOWER(?{idx}) ESCAPE '\\'",
694 idx = param_index
695 ));
696 query_params.push(Box::new(param));
697 }
698 }
699 MessageFilter::Regex { pattern } => {
700 let param_index = query_params.len() + 1;
701 message_conditions.push(format!("message REGEXP ?{param_index}"));
702 query_params.push(Box::new(pattern.clone()));
703 }
704 }
705 }
706 if !message_conditions.is_empty() {
707 conditions.push(format!("({})", message_conditions.join(" OR ")));
708 }
709
710 for filter in &opts.field_filters {
711 match filter {
712 FieldFilter::LevelMin(level) => {
713 let matching = crate::log_store::levels_at_or_above(level);
714 if matching.is_empty() {
715 conditions.push("0".to_string());
717 } else {
718 let placeholders = matching
719 .iter()
720 .map(|l| {
721 let idx = query_params.len() + 1;
722 query_params.push(Box::new((*l).to_string()));
723 format!("?{idx}")
724 })
725 .collect::<Vec<_>>()
726 .join(", ");
727 conditions.push(format!("level IN ({placeholders})"));
728 }
729 }
730 FieldFilter::FieldEq { key, value } => {
731 let key_idx = query_params.len() + 1;
732 let val_idx = query_params.len() + 2;
733 let json_literal = text_to_json_literal(value);
751 let raw_idx = query_params.len() + 3;
752 conditions.push(format!(
753 "EXISTS (SELECT 1 FROM json_each(fields_json) \
754 WHERE json_each.key = ?{key_idx} \
755 AND (json_each.value IS json_extract(?{val_idx}, '$') \
756 OR json_each.value IS ?{raw_idx}))"
757 ));
758 query_params.push(Box::new(key.clone()));
759 query_params.push(Box::new(json_literal));
760 query_params.push(Box::new(value.clone()));
761 }
762 FieldFilter::LoggerContains(pattern) => {
763 let param_index = query_params.len() + 1;
764 conditions.push(format!("logger LIKE ?{param_index} ESCAPE '\\'"));
765 let escaped = crate::log_store::escape_like_pattern(pattern);
766 query_params.push(Box::new(format!("%{escaped}%")));
767 }
768 }
769 }
770
771 let where_clause = if conditions.is_empty() {
772 String::new()
773 } else {
774 format!("WHERE {}", conditions.join(" AND "))
775 };
776
777 let order = if opts.order_desc { "DESC" } else { "ASC" };
778
779 let limit_clause = opts
780 .limit
781 .map(|n| format!("LIMIT {}", n))
782 .unwrap_or_default();
783
784 let columns = if opts.include_structured {
785 "id, daemon_id, timestamp, message, level, msg, logger, fields_json"
786 } else {
787 "id, daemon_id, timestamp, message, NULL, NULL, NULL, NULL"
788 };
789
790 let sql = format!(
791 "SELECT {columns} FROM log_entries {} ORDER BY timestamp {}, id {} {}",
792 where_clause, order, order, limit_clause
793 );
794
795 (sql, query_params)
796 }
797
798 fn should_parallelize(opts: &LogQuery) -> bool {
800 if opts.daemon_ids.len() != 1 {
801 return false;
802 }
803 if opts.after_id.is_some() {
806 return false;
807 }
808 let limit = opts.limit.unwrap_or(usize::MAX);
809 if limit < PARALLEL_QUERY_THRESHOLD {
810 return false;
811 }
812 std::thread::available_parallelism()
813 .map(|n| n.get() >= 2)
814 .unwrap_or(false)
815 }
816
817 fn query_parallel(&self, opts: &LogQuery) -> Result<Vec<LogEntry>> {
824 let max_threads = 2;
829 let num_threads = std::thread::available_parallelism()
830 .map(|n| n.get().min(max_threads))
831 .unwrap_or(1);
832
833 let max_id: Option<i64> = {
834 let conn = self.conn.lock().unwrap();
835 conn.query_row(
836 "SELECT MAX(id) FROM log_entries WHERE daemon_id = ?1",
837 params![&opts.daemon_ids[0]],
838 |row| row.get(0),
839 )
840 .ok()
841 };
842
843 let Some(max_id) = max_id else {
844 return Ok(Vec::new());
845 };
846 if max_id == 0 {
847 return Ok(Vec::new());
848 }
849
850 let shard_size = (max_id as usize).div_ceil(num_threads);
851 let path = self.path.clone();
852 let opts = opts.clone();
853 let needs_regexp = opts
854 .message_filters
855 .iter()
856 .any(|f| matches!(f, MessageFilter::Regex { .. }));
857
858 let shards: Vec<Result<Vec<LogEntry>>> = std::thread::scope(|s| {
859 (0..num_threads)
860 .map(|i| {
861 let start = (i * shard_size) as i64;
862 let end = if i == num_threads - 1 {
863 max_id
864 } else {
865 ((i + 1) * shard_size) as i64
866 };
867 let opts = opts.clone();
868 let path = path.clone();
869 s.spawn(move || -> Result<Vec<LogEntry>> {
870 let conn = Connection::open_with_flags(
871 &path,
872 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
873 )
874 .into_diagnostic()?;
875 conn.execute_batch(
876 "PRAGMA mmap_size = 268435456;
877 PRAGMA query_only = 1;",
878 )
879 .into_diagnostic()?;
880 if needs_regexp {
881 add_regexp_function(&conn)?;
882 }
883
884 let (sql, query_params) = Self::build_query_sql(&opts, Some((start, end)));
885 Self::execute_built_query(&conn, &sql, &query_params)
886 })
887 })
888 .map(|h| h.join().unwrap())
889 .collect()
890 });
891
892 let mut merged = Vec::new();
893 if opts.order_desc {
894 for shard in shards.into_iter().rev() {
895 merged.extend(shard?);
896 }
897 } else {
898 for shard in shards {
899 merged.extend(shard?);
900 }
901 }
902
903 if let Some(limit) = opts.limit
904 && merged.len() > limit
905 {
906 merged.truncate(limit);
907 }
908
909 Ok(merged)
910 }
911
912 fn execute_built_query(
914 conn: &Connection,
915 sql: &str,
916 query_params: &[Box<dyn rusqlite::ToSql>],
917 ) -> Result<Vec<LogEntry>> {
918 let mut stmt = conn.prepare(sql).into_diagnostic()?;
919 let params_ref: Vec<&dyn rusqlite::ToSql> =
920 query_params.iter().map(|p| p.as_ref()).collect();
921 let rows = stmt
922 .query_map(params_ref.as_slice(), Self::row_to_entry)
923 .into_diagnostic()?;
924 let mut entries = Vec::new();
925 for row in rows {
926 entries.push(row.into_diagnostic()?);
927 }
928 Ok(entries)
929 }
930
931 pub fn distinct_loggers(&self, daemon_id: &str) -> Result<Vec<String>> {
936 let conn = self.conn.lock().unwrap();
937 let mut stmt = conn
938 .prepare(
939 "SELECT DISTINCT logger FROM ( \
940 SELECT logger FROM log_entries \
941 WHERE daemon_id = ?1 AND logger IS NOT NULL \
942 ORDER BY id DESC LIMIT 5000 \
943 ) ORDER BY logger",
944 )
945 .into_diagnostic()?;
946 let rows = stmt
947 .query_map(params![daemon_id], |row| row.get::<_, String>(0))
948 .into_diagnostic()?;
949 let mut loggers = Vec::new();
950 for row in rows {
951 loggers.push(row.into_diagnostic()?);
952 }
953 Ok(loggers)
954 }
955
956 pub fn distinct_field_keys(&self, daemon_id: &str) -> Result<Vec<String>> {
962 let conn = self.conn.lock().unwrap();
963 let mut stmt = conn
964 .prepare(
965 "SELECT DISTINCT je.key \
966 FROM ( \
967 SELECT fields_json FROM log_entries \
968 WHERE daemon_id = ?1 AND fields_json IS NOT NULL \
969 ORDER BY id DESC LIMIT 5000 \
970 ), json_each(fields_json) AS je \
971 ORDER BY je.key",
972 )
973 .into_diagnostic()?;
974 let rows = stmt
975 .query_map(params![daemon_id], |row| row.get::<_, String>(0))
976 .into_diagnostic()?;
977 let mut keys = Vec::new();
978 for row in rows {
979 keys.push(row.into_diagnostic()?);
980 }
981 Ok(keys)
982 }
983}
984
985impl LogStore for SqliteLogStore {
986 fn append(&self, daemon_id: &DaemonId, message: &str) -> Result<()> {
987 let id = daemon_id.qualified();
988 let msg = message.to_string();
989
990 let conn = self.conn.lock().unwrap();
991 let ts = Local::now().timestamp_millis();
994 let _ = conn
995 .execute(
996 "INSERT INTO log_entries (daemon_id, timestamp, message) VALUES (?1, ?2, ?3)",
997 params![id, ts, msg],
998 )
999 .into_diagnostic()?;
1000 Ok(())
1001 }
1002
1003 fn append_batch(&self, daemon_id: &DaemonId, messages: &[String]) -> Result<()> {
1004 if messages.is_empty() {
1005 return Ok(());
1006 }
1007 let id = daemon_id.qualified();
1008
1009 let mut conn = self.conn.lock().unwrap();
1010 let tx = conn
1015 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1016 .into_diagnostic()?;
1017 let base_ts = Local::now().timestamp_millis();
1018 {
1019 let mut stmt = tx
1020 .prepare(
1021 "INSERT INTO log_entries (daemon_id, timestamp, message) VALUES (?1, ?2, ?3)",
1022 )
1023 .into_diagnostic()?;
1024 for msg in messages.iter() {
1025 let ts = base_ts;
1032 stmt.execute(params![id, ts, msg]).into_diagnostic()?;
1033 }
1034 }
1035 tx.commit().into_diagnostic()?;
1036 Ok(())
1037 }
1038
1039 fn append_structured(&self, daemon_id: &DaemonId, parsed: &ParsedLog) -> Result<()> {
1040 let id = daemon_id.qualified();
1041
1042 let conn = self.conn.lock().unwrap();
1043 let ts = Local::now().timestamp_millis();
1046 let _ = conn
1047 .execute(
1048 "INSERT INTO log_entries (daemon_id, timestamp, message, level, msg, logger, fields_json) \
1049 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1050 params![id, ts, parsed.message, parsed.level, parsed.msg, parsed.logger, parsed.fields_json],
1051 )
1052 .into_diagnostic()?;
1053 Ok(())
1054 }
1055
1056 fn append_structured_batch(&self, daemon_id: &DaemonId, entries: &[ParsedLog]) -> Result<()> {
1057 if entries.is_empty() {
1058 return Ok(());
1059 }
1060 let id = daemon_id.qualified();
1061
1062 let mut conn = self.conn.lock().unwrap();
1063 let tx = conn
1068 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1069 .into_diagnostic()?;
1070 let base_ts = Local::now().timestamp_millis();
1071 {
1072 let mut stmt = tx
1073 .prepare(
1074 "INSERT INTO log_entries (daemon_id, timestamp, message, level, msg, logger, fields_json) \
1075 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1076 )
1077 .into_diagnostic()?;
1078 for entry in entries.iter() {
1079 let ts = base_ts;
1084 stmt.execute(params![
1085 id,
1086 ts,
1087 entry.message,
1088 entry.level,
1089 entry.msg,
1090 entry.logger,
1091 entry.fields_json
1092 ])
1093 .into_diagnostic()?;
1094 }
1095 }
1096 tx.commit().into_diagnostic()?;
1097 Ok(())
1098 }
1099
1100 fn query(&self, opts: &LogQuery) -> Result<Vec<LogEntry>> {
1101 if Self::should_parallelize(opts)
1103 && self.path.as_os_str() != ":memory:"
1104 && let Ok(entries) = self.query_parallel(opts)
1105 {
1106 return Ok(entries);
1107 }
1108 let conn = self.conn.lock().unwrap();
1112 let (sql, query_params) = Self::build_query_sql(opts, None);
1113 Self::execute_built_query(&conn, &sql, &query_params)
1114 }
1115
1116 fn tail(&self, daemon_id: &DaemonId, after_id: Option<i64>) -> Result<Vec<LogEntry>> {
1117 self.query(&LogQuery {
1118 daemon_ids: vec![daemon_id.qualified()],
1119 from: None,
1120 to: None,
1121 limit: None,
1122 order_desc: false,
1123 after_id,
1124 before_id: None,
1125 message_filters: Vec::new(),
1126 field_filters: Vec::new(),
1127 include_structured: false,
1128 })
1129 }
1130
1131 fn clear(&self, daemon_ids: &[DaemonId]) -> Result<()> {
1132 let mut conn = self.conn.lock().unwrap();
1133 let tx = conn.transaction().into_diagnostic()?;
1134 for id in daemon_ids {
1135 tx.execute(
1136 "DELETE FROM log_entries WHERE daemon_id = ?1",
1137 params![id.qualified()],
1138 )
1139 .into_diagnostic()?;
1140 tx.execute(
1141 "INSERT INTO log_clear_generations (daemon_id, generation)
1142 VALUES (?1, 1)
1143 ON CONFLICT(daemon_id) DO UPDATE SET generation = generation + 1",
1144 params![id.qualified()],
1145 )
1146 .into_diagnostic()?;
1147 }
1148 tx.commit().into_diagnostic()?;
1149 Ok(())
1150 }
1151
1152 fn last_id(&self, daemon_id: &DaemonId) -> Result<Option<i64>> {
1153 let conn = self.conn.lock().unwrap();
1154 let id: Option<i64> = conn
1157 .query_row(
1158 "SELECT MAX(id) FROM log_entries WHERE daemon_id = ?1",
1159 params![daemon_id.qualified()],
1160 |row| row.get(0),
1161 )
1162 .into_diagnostic()?;
1163 Ok(id)
1164 }
1165
1166 fn list_daemon_ids(&self) -> Result<Vec<String>> {
1167 let conn = self.conn.lock().unwrap();
1168 let mut stmt = conn
1169 .prepare("SELECT DISTINCT daemon_id FROM log_entries")
1170 .into_diagnostic()?;
1171 let ids = stmt
1172 .query_map([], |row| {
1173 let id: String = row.get(0)?;
1174 Ok(id)
1175 })
1176 .into_diagnostic()?
1177 .filter_map(|r| r.ok())
1178 .collect();
1179 Ok(ids)
1180 }
1181
1182 fn apply_retention(
1183 &self,
1184 policy: &super::RetentionPolicy,
1185 excluded_daemon_ids: &[DaemonId],
1186 archive_hook: Option<&ArchiveHook>,
1187 ) -> Result<u64> {
1188 let daemon_ids = self.list_daemon_ids()?;
1189 let excluded: HashSet<String> = excluded_daemon_ids.iter().map(|d| d.qualified()).collect();
1190 let mut total = 0u64;
1191 for id_str in daemon_ids {
1192 if excluded.contains(&id_str) {
1193 continue;
1194 }
1195 let id = DaemonId::parse(&id_str).unwrap_or_else(|_| {
1196 DaemonId::try_new("global", &id_str).unwrap_or_else(|_| DaemonId::pitchfork())
1197 });
1198 if let Some(dur) = policy.age {
1199 total += self.rotate_by_age(&id, dur, archive_hook)?;
1200 }
1201 if let Some(n) = policy.count {
1202 total += self.rotate_by_count(&id, n, archive_hook)?;
1203 }
1204 }
1205 Ok(total)
1206 }
1207
1208 fn apply_retention_for_daemon(
1209 &self,
1210 daemon_id: &DaemonId,
1211 policy: &super::RetentionPolicy,
1212 archive_hook: Option<&ArchiveHook>,
1213 ) -> Result<u64> {
1214 let mut total = 0u64;
1215 if let Some(dur) = policy.age {
1216 total += self.rotate_by_age(daemon_id, dur, archive_hook)?;
1217 }
1218 if let Some(n) = policy.count {
1219 total += self.rotate_by_count(daemon_id, n, archive_hook)?;
1220 }
1221 Ok(total)
1222 }
1223
1224 fn last_clear_generation(&self, daemon_id: &DaemonId) -> Result<Option<u64>> {
1225 let conn = self.conn.lock().unwrap();
1226 let mut stmt = conn
1227 .prepare("SELECT generation FROM log_clear_generations WHERE daemon_id = ?1")
1228 .into_diagnostic()?;
1229 let generation: Option<i64> = stmt
1230 .query_row(params![daemon_id.qualified()], |row| row.get(0))
1231 .optional()
1232 .into_diagnostic()?;
1233 generation
1234 .map(|generation| {
1235 u64::try_from(generation)
1236 .map_err(|_| miette::miette!("log clear generation cannot be negative"))
1237 })
1238 .transpose()
1239 }
1240
1241 fn query_with_generation(
1242 &self,
1243 opts: &LogQuery,
1244 daemon_id: &DaemonId,
1245 ) -> Result<(Vec<LogEntry>, Option<u64>)> {
1246 let conn = self.conn.lock().unwrap();
1247 conn.execute_batch("BEGIN").into_diagnostic()?;
1256 let result = (|| {
1257 let (sql, query_params) = Self::build_query_sql(opts, None);
1258 let entries = Self::execute_built_query(&conn, &sql, &query_params)?;
1259 let generation: Option<i64> = conn
1260 .query_row(
1261 "SELECT generation FROM log_clear_generations WHERE daemon_id = ?1",
1262 params![daemon_id.qualified()],
1263 |row| row.get(0),
1264 )
1265 .optional()
1266 .into_diagnostic()?;
1267 let generation = generation
1268 .map(|g| {
1269 u64::try_from(g)
1270 .map_err(|_| miette::miette!("log clear generation cannot be negative"))
1271 })
1272 .transpose()?;
1273 Ok((entries, generation))
1274 })();
1275 let _ = conn.execute_batch("ROLLBACK");
1277 result
1278 }
1279}
1280
1281use once_cell::sync::Lazy;
1283use std::sync::Arc;
1284
1285pub static LOG_STORE: Lazy<Arc<SqliteLogStore>> = Lazy::new(|| {
1286 let path = crate::env::PITCHFORK_LOGS_DIR.join("logs.db");
1287 let mut is_fallback = false;
1288 let store = Arc::new(SqliteLogStore::open(&path).unwrap_or_else(|e| {
1289 error!(
1290 "failed to open log store at {}: {e}. Falling back to in-memory store; logs will not persist across restarts.",
1291 path.display()
1292 );
1293 is_fallback = true;
1294 SqliteLogStore::open(":memory:").expect("in-memory SQLite should always open")
1295 }));
1296
1297 if !is_fallback {
1301 if let Err(e) = auto_migrate_legacy_logs(&store) {
1302 warn!("legacy log auto-migration failed: {e}");
1303 }
1304 } else {
1305 warn!(
1306 "skipping legacy log auto-migration because log store is in-memory (no durable destination)"
1307 );
1308 }
1309
1310 store
1311});
1312
1313fn auto_migrate_legacy_logs(store: &SqliteLogStore) -> Result<()> {
1321 let logs_dir = &*crate::env::PITCHFORK_LOGS_DIR;
1322 if !logs_dir.exists() {
1323 return Ok(());
1324 }
1325
1326 let Ok(entries) = std::fs::read_dir(logs_dir) else {
1327 return Ok(());
1328 };
1329
1330 let mut total_migrated = 0u64;
1331 let mut migrated_ids = Vec::new();
1332
1333 for entry in entries.flatten() {
1334 let path = entry.path();
1335 if !path.is_dir() {
1336 continue;
1337 }
1338 let file_name = path
1340 .file_name()
1341 .map_or(String::new(), |n| n.to_string_lossy().to_string());
1342 if file_name == "pitchfork" {
1343 continue;
1344 }
1345
1346 if !file_name.contains("--") {
1348 continue;
1349 }
1350 let log_file = path.join(format!("{file_name}.log"));
1351 if !log_file.exists() {
1352 continue;
1353 }
1354
1355 let daemon_id = match DaemonId::from_safe_path(&file_name) {
1356 Ok(id) => id,
1357 Err(_) => continue,
1358 };
1359
1360 if daemon_id == DaemonId::pitchfork() {
1363 continue;
1364 }
1365
1366 match store.migrate_daemon_text_logs(&daemon_id) {
1367 Ok(0) => {}
1368 Ok(n) => {
1369 total_migrated += n;
1370 migrated_ids.push(daemon_id.qualified());
1371 }
1372 Err(e) => {
1373 warn!(
1374 "failed to migrate text logs for {}: {e}",
1375 daemon_id.qualified()
1376 );
1377 }
1378 }
1379 }
1380
1381 if total_migrated > 0 {
1382 warn!(
1383 "auto-migrated {total_migrated} legacy log entries from {count} daemon(s): {ids}",
1384 count = migrated_ids.len(),
1385 ids = migrated_ids.join(", ")
1386 );
1387 }
1388
1389 Ok(())
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::text_to_json_literal;
1395 use super::*;
1396 use crate::log_store::LogStore;
1397
1398 #[test]
1399 fn test_json_literal_booleans() {
1400 assert_eq!(text_to_json_literal("true"), "true");
1401 assert_eq!(text_to_json_literal("TRUE"), "true");
1402 assert_eq!(text_to_json_literal("false"), "false");
1403 assert_eq!(text_to_json_literal("False"), "false");
1404 }
1405
1406 #[test]
1407 fn test_json_literal_null() {
1408 assert_eq!(text_to_json_literal("null"), "null");
1409 assert_eq!(text_to_json_literal("NULL"), "null");
1410 }
1411
1412 #[test]
1413 fn test_json_literal_valid_numbers() {
1414 assert_eq!(text_to_json_literal("42"), "42");
1415 assert_eq!(text_to_json_literal("-1"), "-1");
1416 assert_eq!(text_to_json_literal("0"), "0");
1417 assert_eq!(text_to_json_literal("3.14"), "3.14");
1418 assert_eq!(text_to_json_literal("1e10"), "1e10");
1419 assert_eq!(text_to_json_literal("1.5e-3"), "1.5e-3");
1420 }
1421
1422 #[test]
1423 fn test_json_literal_rejects_invalid_json_numbers() {
1424 assert_eq!(text_to_json_literal("+42"), r#""+42""#);
1427 assert_eq!(text_to_json_literal("1."), r#""1.""#);
1428 assert_eq!(text_to_json_literal(".5"), r#"".5""#);
1429 assert_eq!(text_to_json_literal("inf"), r#""inf""#);
1430 assert_eq!(text_to_json_literal("nan"), r#""nan""#);
1431 assert_eq!(text_to_json_literal("infinity"), r#""infinity""#);
1432 }
1433
1434 #[test]
1435 fn test_json_literal_strings() {
1436 assert_eq!(text_to_json_literal("hello"), r#""hello""#);
1437 assert_eq!(text_to_json_literal("req_1"), r#""req_1""#);
1438 assert_eq!(text_to_json_literal(r#"a"b"#), r#""a\"b""#);
1440 }
1441
1442 #[test]
1446 fn write_waits_for_a_contended_lock() {
1447 let dir = tempfile::tempdir().unwrap();
1448 let path = dir.path().join("logs.db");
1449 let store = SqliteLogStore::open(&path).unwrap();
1450
1451 let blocker = Connection::open(&path).unwrap();
1454 blocker.busy_timeout(BUSY_TIMEOUT).unwrap();
1455 blocker.execute_batch("BEGIN IMMEDIATE").unwrap();
1456 let releaser = std::thread::spawn(move || {
1457 std::thread::sleep(std::time::Duration::from_millis(300));
1458 blocker.execute_batch("COMMIT").unwrap();
1459 });
1460
1461 let id = DaemonId::try_new("test", "blocked").unwrap();
1462 let entries = vec![crate::log_parse::parse("held-lock-line", "text")];
1463 store
1464 .append_structured_batch(&id, &entries)
1465 .expect("a contended write must wait for the lock, not fail");
1466
1467 releaser.join().unwrap();
1468 let found = store
1469 .query(&LogQuery {
1470 daemon_ids: vec![id.qualified()],
1471 ..Default::default()
1472 })
1473 .unwrap()
1474 .len();
1475 assert_eq!(found, 1, "the batch written under contention was lost");
1476 }
1477
1478 #[test]
1484 fn concurrent_writers_do_not_lose_batches() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let path = dir.path().join("logs.db");
1487
1488 const WRITERS: usize = 4;
1489 const BATCHES: usize = 15;
1490 const PER_BATCH: usize = 10;
1491
1492 let barrier = std::sync::Barrier::new(WRITERS);
1497
1498 std::thread::scope(|scope| {
1499 for writer in 0..WRITERS {
1500 let path = path.clone();
1501 let barrier = &barrier;
1502 scope.spawn(move || {
1503 barrier.wait();
1506 let store = SqliteLogStore::open(&path).unwrap();
1507 let id = DaemonId::try_new("test", format!("w{writer}")).unwrap();
1508 for batch in 0..BATCHES {
1509 let entries: Vec<ParsedLog> = (0..PER_BATCH)
1510 .map(|i| {
1511 crate::log_parse::parse(&format!("w{writer}-{batch}-{i}"), "text")
1512 })
1513 .collect();
1514 store
1515 .append_structured_batch(&id, &entries)
1516 .expect("concurrent batch write must not fail");
1517 }
1518 });
1519 }
1520 });
1521
1522 let store = SqliteLogStore::open(&path).unwrap();
1523 for writer in 0..WRITERS {
1524 let id = DaemonId::try_new("test", format!("w{writer}")).unwrap();
1525 let found = store
1526 .query(&LogQuery {
1527 daemon_ids: vec![id.qualified()],
1528 ..Default::default()
1529 })
1530 .unwrap()
1531 .len();
1532 assert_eq!(
1533 found,
1534 BATCHES * PER_BATCH,
1535 "writer {writer} lost entries: got {found}"
1536 );
1537 }
1538 }
1539
1540 #[test]
1546 fn batch_rows_share_single_timestamp() {
1547 let dir = tempfile::tempdir().unwrap();
1548 let path = dir.path().join("logs.db");
1549 let store = SqliteLogStore::open(&path).unwrap();
1550 let id = DaemonId::try_new("test", "burst").unwrap();
1551
1552 let entries: Vec<ParsedLog> = (0..50)
1553 .map(|i| crate::log_parse::parse(&format!("line-{i}"), "text"))
1554 .collect();
1555 store.append_structured_batch(&id, &entries).unwrap();
1556
1557 let conn = store.conn.lock().unwrap();
1558 let timestamps: Vec<i64> = {
1559 let mut stmt = conn
1560 .prepare("SELECT timestamp FROM log_entries ORDER BY id")
1561 .unwrap();
1562 let rows = stmt.query_map([], |row| row.get::<_, i64>(0)).unwrap();
1563 rows.map(|r| r.unwrap()).collect()
1564 };
1565 assert_eq!(timestamps.len(), 50);
1566 let distinct: std::collections::HashSet<i64> = timestamps.into_iter().collect();
1567 assert_eq!(
1568 distinct.len(),
1569 1,
1570 "all rows in a batch must share one timestamp so (timestamp, id) ordering matches insertion order"
1571 );
1572 }
1573
1574 #[test]
1579 fn burst_batches_preserve_insertion_order() {
1580 let dir = tempfile::tempdir().unwrap();
1581 let path = dir.path().join("logs.db");
1582 let store = SqliteLogStore::open(&path).unwrap();
1583 let id = DaemonId::try_new("test", "burst").unwrap();
1584
1585 const BATCHES: usize = 3;
1589 const PER_BATCH: usize = 300;
1590 for batch in 0..BATCHES {
1591 let entries: Vec<ParsedLog> = (0..PER_BATCH)
1592 .map(|i| crate::log_parse::parse(&format!("{}", batch * PER_BATCH + i), "text"))
1593 .collect();
1594 store.append_structured_batch(&id, &entries).unwrap();
1595 }
1596
1597 let entries = store
1598 .query(&LogQuery {
1599 daemon_ids: vec![id.qualified()],
1600 ..Default::default()
1601 })
1602 .unwrap();
1603 assert_eq!(entries.len(), BATCHES * PER_BATCH);
1604 for (n, entry) in entries.iter().enumerate() {
1605 assert_eq!(
1606 entry.message,
1607 n.to_string(),
1608 "log line {n} read back out of order (got '{}')",
1609 entry.message
1610 );
1611 }
1612 }
1613}