use anyhow::{Context, Result};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use rusqlite::{Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use super::cache;
const SCAN_SQL: &str = include_str!("message_nodes.sql");
#[derive(Debug)]
pub struct DbCall {
pub session: String,
pub ts: i64,
pub prompt: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawRow {
pub row_id: i64,
pub session: String,
pub mid: String,
pub ntp: u64,
pub ts: i64,
}
#[derive(Debug, Default)]
pub struct DbData {
pub session_models: HashMap<String, String>,
pub calls: Vec<DbCall>,
}
fn prefetch(path: &Path, offset: u64) -> Arc<AtomicBool> {
let stop = Arc::new(AtomicBool::new(false));
for suffix in ["", "-wal"] {
let p = PathBuf::from(format!("{}{suffix}", path.display()));
let stop = stop.clone();
std::thread::spawn(move || {
use std::io::{Read, Seek, SeekFrom};
let mut f = match std::fs::File::open(&p) {
Ok(f) => f,
Err(_) => return,
};
if suffix.is_empty() && f.seek(SeekFrom::Start(offset)).is_err() {
return;
}
let mut buf = vec![0u8; 8 << 20];
while !stop.load(Ordering::Relaxed) && matches!(f.read(&mut buf), Ok(n) if n > 0) {}
});
}
stop
}
fn open(path: &Path) -> Result<Connection> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("cannot open {}", path.display()))?;
conn.pragma_update(None, "mmap_size", 8_000_000_000i64)?;
conn.pragma_update(None, "cache_size", -2_000_000i64)?;
Ok(conn)
}
fn scan_range(path: &Path, lo: i64, hi: i64) -> Result<Vec<RawRow>> {
let conn = open(path)?;
let mut st = conn.prepare(SCAN_SQL)?;
let rs = st.query_map([lo, hi], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, f64>(3)?,
r.get::<_, i64>(4)?,
))
})?;
let mut out = Vec::new();
for r in rs {
let (row_id, session, mid, ntp, ts) = r?;
out.push(RawRow {
row_id,
session,
mid,
ntp: ntp as u64,
ts,
});
}
Ok(out)
}
fn workers() -> usize {
std::env::var("LLMSTAT_WORKERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get().min(8))
.unwrap_or(4)
})
.max(1)
}
pub fn load(path: &Path, mp: &MultiProgress) -> Result<DbData> {
let conn = open(path)?;
let mut session_models = HashMap::new();
{
let mut st = conn.prepare("SELECT id, COALESCE(model,'') FROM sessions")?;
let rows = st.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
for r in rows {
let (id, m) = r?;
session_models.insert(id, m);
}
}
let cur_max: i64 = conn.query_row(
"SELECT COALESCE(MAX(row_id), 0) FROM message_nodes",
[],
|r| r.get(0),
)?;
let (mut rows, start) = match cache::load(path, cur_max) {
Some((max_rowid, cached)) => (cached, max_rowid + 1),
None => (Vec::new(), 0),
};
if start <= cur_max {
let file_len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let stop = prefetch(path, file_len * start as u64 / (cur_max as u64 + 1));
let span = cur_max - start + 1;
let workers = if span < 20_000 { 1 } else { workers() };
let chunk = (span + workers as i64 - 1) / workers as i64;
let pb = (span >= 50_000).then(|| {
let pb = mp.add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}").expect("static template"),
);
pb.set_message(format!("scanning {}", path.display()));
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
});
let t0 = std::time::Instant::now();
let mut parts: Vec<Result<Vec<RawRow>>> = Vec::new();
std::thread::scope(|s| {
let mut handles = Vec::new();
for w in 0..workers {
let lo = start + w as i64 * chunk;
let hi = (lo + chunk).min(cur_max + 1);
if lo >= hi {
break;
}
handles.push(s.spawn(move || scan_range(path, lo, hi)));
}
for h in handles {
parts.push(
h.join()
.unwrap_or_else(|_| Err(anyhow::anyhow!("scan panicked"))),
);
}
});
tracing::debug!(workers, elapsed = ?t0.elapsed(), "message_nodes scan");
if let Some(pb) = pb {
pb.finish_and_clear();
}
for p in parts {
rows.extend(p?);
}
stop.store(true, Ordering::Relaxed);
cache::save(path, cur_max, &rows);
}
let mut best: HashMap<(String, String), (u64, i64)> = HashMap::new();
for r in rows {
let e = best.entry((r.session, r.mid)).or_insert((0, i64::MAX));
e.0 = e.0.max(r.ntp);
e.1 = e.1.min(r.ts);
}
let calls = best
.into_iter()
.map(|((session, _), (prompt, ts))| DbCall {
session,
ts,
prompt,
})
.collect();
Ok(DbData {
session_models,
calls,
})
}