use anyhow::{Context, Result};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use rusqlite::{Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
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,
}
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 struct Scanner {
conn: Connection,
path: PathBuf,
next_rowid: i64,
rows: Vec<RawRow>,
best: HashMap<(String, String), (u64, i64)>,
emitted: HashSet<(String, String)>,
pub session_meta: HashMap<String, (String, Option<String>)>,
}
fn session_meta(conn: &Connection) -> Result<HashMap<String, (String, Option<String>)>> {
let mut out = HashMap::new();
let mut st = conn.prepare("SELECT id, COALESCE(model,''), title FROM sessions")?;
let rows = st.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
))
})?;
for r in rows {
let (id, m, t) = r?;
out.insert(id, (m, t));
}
Ok(out)
}
fn max_rowid(conn: &Connection) -> Result<i64> {
Ok(conn.query_row(
"SELECT COALESCE(MAX(row_id), 0) FROM message_nodes",
[],
|r| r.get(0),
)?)
}
impl Scanner {
pub fn open(path: &Path) -> Result<Self> {
let conn = open(path)?;
let models = session_meta(&conn)?;
let cur_max = max_rowid(&conn)?;
let (rows, next_rowid) = match cache::load(path, cur_max) {
Some((max_rowid, cached)) => (cached, max_rowid + 1),
None => (Vec::new(), 0),
};
let mut best = HashMap::with_capacity(rows.len());
for r in &rows {
let e = best
.entry((r.session.clone(), r.mid.clone()))
.or_insert((0, i64::MAX));
e.0 = e.0.max(r.ntp);
e.1 = e.1.min(r.ts);
}
Ok(Self {
conn,
path: path.to_path_buf(),
next_rowid,
rows,
best,
emitted: HashSet::new(),
session_meta: models,
})
}
fn scan(&self, lo: i64, hi: i64, mp: &MultiProgress) -> Result<Vec<RawRow>> {
let file_len = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);
let stop = prefetch(&self.path, file_len * lo as u64 / (hi as u64 + 1));
let span = hi - lo;
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 {}", self.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();
let path = &self.path;
std::thread::scope(|s| {
let mut handles = Vec::new();
for w in 0..workers {
let wlo = lo + w as i64 * chunk;
let whi = (wlo + chunk).min(hi);
if wlo >= whi {
break;
}
handles.push(s.spawn(move || scan_range(path, wlo, whi)));
}
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();
}
stop.store(true, Ordering::Relaxed);
let mut out = Vec::new();
for p in parts {
out.extend(p?);
}
Ok(out)
}
pub fn tick(&mut self, mp: &MultiProgress) -> Result<Vec<DbCall>> {
let cur_max = max_rowid(&self.conn)?;
let mut out = Vec::new();
if cur_max < self.next_rowid {
return Ok(out);
}
let new_rows = self.scan(self.next_rowid, cur_max + 1, mp)?;
self.next_rowid = cur_max + 1;
for r in new_rows {
let key = (r.session.clone(), r.mid.clone());
let e = self.best.entry(key.clone()).or_insert((0, i64::MAX));
e.0 = e.0.max(r.ntp);
e.1 = e.1.min(r.ts);
if e.0 > 0 && self.emitted.insert(key) {
out.push(DbCall {
session: r.session.clone(),
ts: e.1,
prompt: e.0,
});
}
self.rows.push(r);
}
self.session_meta = session_meta(&self.conn)?;
cache::save(&self.path, cur_max, &self.rows);
Ok(out)
}
}