Skip to main content

pitchfork_cli/log_store/
sqlite.rs

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
16/// Convert a text filter value to a JSON literal string for use with
17/// SQLite's `json()` function. This ensures `json_each.value` (which returns
18/// native SQLite types) is compared against the correct type:
19///
20/// - `"true"` / `"false"` → JSON boolean (SQLite integer 1/0)
21/// - `"null"` → JSON null (SQLite NULL)
22/// - `"42"` / `"3.14"` → JSON number (SQLite integer/real)
23/// - `"hello"` → JSON string `"hello"`
24///
25/// Without this conversion, binding `8080` as text would never match
26/// `json_each.value` returning integer `8080`.
27fn text_to_json_literal(value: &str) -> String {
28    // Boolean
29    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    // Null
36    if value.eq_ignore_ascii_case("null") {
37        return "null".to_string();
38    }
39    // Number: validate as a JSON number (stricter than f64::parse, which
40    // accepts "+42", "1.", ".5", "inf", "nan" — none are valid JSON and
41    // would cause SQLite's json_extract to fail).
42    if let Ok(serde_json::Value::Number(_)) = serde_json::from_str(value) {
43        return value.to_string();
44    }
45    // String: JSON-escape and quote
46    serde_json::to_string(value).unwrap_or_else(|_| format!(r#""{value}""#))
47}
48
49/// Registers a `regexp` SQL function backed by the `regex` crate.
50///
51/// SQLite does not ship a REGEXP implementation by default. This function
52/// is registered on every new connection so that `message REGEXP ?` works
53/// consistently across queries.
54fn add_regexp_function(conn: &Connection) -> Result<()> {
55    use std::cell::RefCell;
56
57    // Cache compiled regexes per connection to avoid recompiling the same
58    // pattern on every row evaluation. The cache is small and thread-local
59    // because scalar functions are invoked on the connection's thread.
60    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
88/// SQLite-backed log store with WAL mode for concurrent readers.
89pub struct SqliteLogStore {
90    conn: Mutex<Connection>,
91    path: PathBuf,
92}
93
94/// Minimum result-set size to trigger parallel query.
95///
96/// Each parallel shard opens a new read-only SQLite connection (~5-12ms
97/// overhead per connection for PRAGMA setup + regexp function registration).
98/// Below this threshold, single-threaded is faster because the connection
99/// overhead exceeds the query savings.
100const PARALLEL_QUERY_THRESHOLD: usize = 200_000;
101
102/// How long a statement waits for a contended lock before giving up. Generous
103/// enough to outlast any batch insert or retention prune, short enough that a
104/// genuinely stuck writer still surfaces as an error rather than hanging.
105const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
106
107/// Put the database into WAL mode, tolerating a lost race.
108///
109/// Switching journal modes needs an exclusive lock and, unlike ordinary
110/// statements, does not consult the busy handler — so concurrent opens collide
111/// here no matter how large `busy_timeout` is. WAL is recorded in the database
112/// header and persists across connections, so only the first open has to
113/// succeed: retry briefly in case a peer is mid-switch, and if it still has not
114/// taken, carry on rather than fail. A connection that never personally set WAL
115/// still works correctly; refusing to open the log store over a transient
116/// pragma race would be far worse than running one connection unoptimised.
117fn 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    /// Open or create the SQLite log store at the given path.
140    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        // A contended write must wait for the lock rather than fail: WAL
148        // allows concurrent readers but still serializes writers, and a writer
149        // with no timeout takes SQLITE_BUSY immediately and drops its work.
150        // rusqlite already defaults to this value, but state it explicitly so
151        // the requirement is visible here and does not rest on a dependency's
152        // default. Set before anything else, since the statements below take
153        // locks of their own.
154        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        // Migrate existing tables: add columns introduced in this version.
196        // Must run BEFORE creating indexes that reference the new columns.
197        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    /// Pipe `entries` to the daemon's archive hook as JSON lines, in batches
261    /// of `batch_size`, one hook process per batch.
262    ///
263    /// `reason` (`"age"` or `"count"`) reaches the hook as
264    /// `PITCHFORK_ARCHIVE_REASON`. Returns an error if the shell cannot be
265    /// resolved, the hook cannot be spawned or written to, or it exits
266    /// non-zero; the caller then deletes none of `entries`.
267    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        // The archive hook is a shell command like any other daemon script, so
283        // it runs under the same resolved shell rather than a hardcoded `sh`,
284        // which does not exist on a stock Windows machine. Resolve here rather
285        // than when the hook is built: a bad setting must skip the batch, not
286        // drop the hook, or retention would prune entries that were never
287        // archived.
288        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            // Write entries to stdin. On error, kill and reap the child
307            // before propagating — Child::drop does not call wait(), so
308            // without this a crashed hook would leave a zombie process.
309            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                // Explicitly flush so a buffer-drain failure (e.g. the hook
328                // exited early and closed stdin) is surfaced as an error
329                // rather than being silently swallowed by BufWriter::drop.
330                // On a small batch where all data fits in the 8 KB buffer no
331                // individual writeln! has touched the underlying pipe yet, so
332                // without this flush the failure would be lost and the entries
333                // deleted without ever being delivered to the hook.
334                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                // BufWriter + ChildStdin drop here, closing stdin (EOF signal).
341            };
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    /// Delete rows matching the given IDs, returning the number of rows deleted.
363    ///
364    /// Chunks at 999 to stay within SQLite's `SQLITE_MAX_VARIABLE_NUMBER`
365    /// limit on versions ≤ 3.31 (e.g. Ubuntu 20.04 LTS).
366    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    /// Rotate (delete) old log entries for a specific daemon based on retention policy.
388    ///
389    /// When an archive hook is configured, entries are fetched in batches of
390    /// `batch_size`, the hook is invoked *without* holding the SQLite mutex
391    /// (so concurrent log appends are not blocked), and then deleted by ID.
392    /// Row-read errors are propagated rather than silently dropped.
393    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                // Fetch one batch under lock, then release.
406                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                // Run the hook without holding the mutex.
432                self.archive_entries(&entries, hook, daemon_id, "age")?;
433
434                // Re-acquire lock and delete exactly the archived IDs.
435                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    /// Rotate (delete) old log entries keeping only the most recent `max_count` rows
452    /// for a specific daemon.
453    ///
454    /// When an archive hook is configured, entries are fetched in batches of
455    /// `batch_size`, the hook is invoked *without* holding the SQLite mutex
456    /// (so concurrent log appends are not blocked), and then deleted by ID.
457    /// Row-read errors are propagated rather than silently dropped.
458    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        // Determine how many entries to delete.
467        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                // Fetch one batch under lock, then release.
490                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                // Run the hook without holding the mutex.
517                self.archive_entries(&entries, hook, daemon_id, "count")?;
518
519                // Re-acquire lock and delete exactly the archived IDs.
520                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    /// Migrate existing text logs for a daemon into SQLite.
541    ///
542    /// Reads the legacy text file line-by-line (streaming) and inserts in
543    /// batches of 1000 to avoid loading multi-GB files into memory at once.
544    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    /// Build the SQL query string and parameters for the given options.
630    ///
631    /// `id_range` is used by `query_parallel` to shard the query by id range.
632    /// When `Some((start, end))`, adds `id > start AND id <= end` to the WHERE clause.
633    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                        // Unknown level: no results
716                        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                    // Use json_each with parameterized key to avoid JSON path
734                    // interpolation. This correctly handles keys containing '.'
735                    // (e.g. "request.id") which json_extract would treat as
736                    // nested traversal, and prevents SQL injection from
737                    // untrusted key input (CLI --field doesn't validate keys).
738                    //
739                    // Match both the typed JSON value and the raw text.
740                    // json_each.value has no type affinity, so comparing it
741                    // IS ? (text) only matches string values, not integers
742                    // or booleans. This prevents a string field storing "42"
743                    // or "true" from being unreachable via field filters.
744                    //
745                    // Typed path:   json_each.value IS json_extract(json_literal, '$')
746                    //   matches numbers, booleans, null (native SQLite types)
747                    // Text path:    json_each.value IS ? (raw text)
748                    //   matches string fields whose value textually resembles
749                    //   a primitive (e.g. field storing "true", "42", "null")
750                    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    /// Returns true if parallel query is beneficial for the given options.
799    fn should_parallelize(opts: &LogQuery) -> bool {
800        if opts.daemon_ids.len() != 1 {
801            return false;
802        }
803        // Skip parallel for incremental tail polls (after_id set) — they
804        // return small batches and the connection overhead dominates.
805        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    /// Query using multiple read-only connections, sharded by id range.
818    ///
819    /// For a single daemon, id order ≈ timestamp order (timestamps are
820    /// assigned in insertion order within a single writer). This lets us
821    /// shard by contiguous id ranges and merge by concatenating shards in
822    /// the right order, without a global sort.
823    fn query_parallel(&self, opts: &LogQuery) -> Result<Vec<LogEntry>> {
824        // Cap parallelism: each shard opens a new read-only connection with
825        // PRAGMA setup + regexp function registration (~5-12ms each). Beyond
826        // 2 threads the connection overhead dominates the query savings for
827        // typical log store sizes.
828        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    /// Execute a built SQL query and collect results into LogEntry.
913    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    /// Return distinct non-null logger values for a daemon, sorted alphabetically.
932    ///
933    /// Scans only the most recent 5000 entries for performance — autocomplete
934    /// only needs a representative sample, not the full history.
935    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    /// Return distinct keys from the `fields_json` column for a daemon.
957    ///
958    /// Uses `json_each` to extract all object keys across recent structured log
959    /// entries, returning a sorted unique list. Useful for jq autocomplete.
960    /// Scans only the most recent 5000 entries for performance.
961    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        // Sample while holding the lock so timestamp order matches insertion
992        // order under concurrent writers.
993        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        // IMMEDIATE acquires the write lock at BEGIN, and we sample only after
1011        // that, so timestamp order matches commit order even across processes
1012        // (sink subprocesses and the supervisor each hold their own
1013        // SqliteLogStore instance on the same database).
1014        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                // Use the same timestamp for the whole batch: `id` is
1026                // AUTOINCREMENT (insertion order), and queries order by
1027                // (timestamp, id), so equal timestamps still sort by
1028                // insertion order. Staggering by `idx` instead overlaps with
1029                // the timestamps of a later batch flushed within this batch's
1030                // fabricated span, interleaving the two batches.
1031                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        // Sample while holding the lock so timestamp order matches insertion
1044        // order under concurrent writers.
1045        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        // IMMEDIATE acquires the write lock at BEGIN, and we sample only after
1064        // that, so timestamp order matches commit order even across processes
1065        // (sink subprocesses and the supervisor each hold their own
1066        // SqliteLogStore instance on the same database).
1067        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                // Same timestamp for the whole batch; queries order by
1080                // (timestamp, id) and `id` is AUTOINCREMENT, so insertion
1081                // order is preserved without the cross-batch overlap that
1082                // staggered timestamps (`base_ts + idx`) caused.
1083                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        // Delegate to parallel path for large single-daemon queries.
1102        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        // Fall back to single-threaded on parallel failure.
1109
1110        // Single-threaded path.
1111        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        // MAX(id) returns NULL when no rows exist for the daemon; the
1155        // Option<i64> decode maps NULL to None automatically.
1156        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        // Wrap both reads in a single transaction so a concurrent clear
1248        // cannot interleave between them. In WAL mode, BEGIN acquires a
1249        // consistent snapshot that both statements share.
1250        //
1251        // Intentionally call build_query_sql + execute_built_query directly
1252        // instead of self.query(): the latter may delegate to query_parallel
1253        // which opens separate connections outside this transaction, breaking
1254        // the snapshot guarantee.
1255        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        // Always rollback (read transaction just needs to end).
1276        let _ = conn.execute_batch("ROLLBACK");
1277        result
1278    }
1279}
1280
1281/// Global singleton log store.
1282use 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    // Auto-migrate any legacy text log files into SQLite on first access.
1298    // This runs once per process startup and is idempotent.
1299    // Skip migration when using the in-memory fallback to prevent data loss.
1300    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
1313/// Auto-migrate legacy text log files into the SQLite log store.
1314///
1315/// Scans the logs directory for directories matching the new-format layout
1316/// (`namespace--name/namespace--name.log`), attempts to parse the directory
1317/// name as a valid safe-path daemon ID, and imports the content into SQLite.
1318/// This is idempotent: re-running it on already-migrated data is a no-op
1319/// because the legacy text files are deleted after successful import.
1320fn 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        // Skip the supervisor's own log directory.
1339        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        // Only consider directories that look like new-format safe-paths
1347        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        // Skip the supervisor's own daemon; its log directory may be present
1361        // under logs/ but should never be imported into the user-facing log store.
1362        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        // f64::parse accepts these but they are not valid JSON numbers,
1425        // so they must be treated as strings (quoted) instead.
1426        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        // String with special chars gets JSON-escaped
1439        assert_eq!(text_to_json_literal(r#"a"b"#), r#""a\"b""#);
1440    }
1441
1442    /// A writer that finds the lock held must wait for it, not give up. WAL
1443    /// serializes writers, so without `busy_timeout` the second writer takes
1444    /// SQLITE_BUSY immediately and silently discards its batch.
1445    #[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        // Hold the write lock from a second connection, then release it after a
1452        // delay that a non-waiting writer could never survive.
1453        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    /// Two processes writing the same store is the normal case, not an edge
1479    /// case: daemon output and retention pruning already collide, and a
1480    /// per-daemon writer multiplies the opportunities. WAL serializes writers,
1481    /// so without `busy_timeout` the loser of that race takes SQLITE_BUSY
1482    /// immediately and silently discards its batch.
1483    #[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        // Release every worker into open() at the same moment. Without this
1493        // the scheduler is free to run them one after another, so the first
1494        // would enable WAL and the rest would never contend for the
1495        // journal-mode switch — leaving the race this guards unexercised.
1496        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                    // A separate connection per writer, as separate processes
1504                    // would have.
1505                    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    /// Regression for the "burst of log lines comes back out of order" bug:
1541    /// rows within one batch must share a single timestamp instead of being
1542    /// staggered by index. Staggered timestamps (`base_ts + idx`) overlap with
1543    /// the timestamps of the next batch flushed moments later, interleaving the
1544    /// two batches under `ORDER BY timestamp, id`.
1545    #[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    /// End-to-end regression for the reported repro (`run = "exec seq 500"`):
1575    /// several batches flushed back-to-back must still read back in insertion
1576    /// order. With staggered per-row timestamps the later batch's fabricated
1577    /// span overlaps the earlier one and interleaves the output.
1578    #[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        // 3 batches of 300 rows, appended as fast as possible — the staggered
1586        // spans (300ms each) are far wider than the inter-batch gap, so the
1587        // old code always overlapped and interleaved these.
1588        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}