use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
use crate::store::Kind;
pub const HISTORY: usize = 32;
pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(500);
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Column {
Kind,
Name,
Size,
Written,
Rate,
Last,
}
impl Column {
pub const ALL: &'static [Column] = &[
Column::Kind,
Column::Name,
Column::Size,
Column::Written,
Column::Rate,
Column::Last,
];
pub fn label(self) -> &'static str {
match self {
Column::Kind => "kind",
Column::Name => "file",
Column::Size => "size",
Column::Written => "written",
Column::Rate => "rate",
Column::Last => "last",
}
}
pub fn descending_by_default(self) -> bool {
!matches!(self, Column::Kind | Column::Name)
}
pub fn next(self) -> Column {
let i = Column::ALL.iter().position(|&c| c == self).unwrap_or(0);
Column::ALL[(i + 1) % Column::ALL.len()]
}
pub fn prev(self) -> Column {
let i = Column::ALL.iter().position(|&c| c == self).unwrap_or(0);
Column::ALL[(i + Column::ALL.len() - 1) % Column::ALL.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Sort {
pub column: Column,
pub desc: bool,
}
impl Sort {
pub fn by(column: Column) -> Self {
Sort {
column,
desc: column.descending_by_default(),
}
}
pub fn to(self, column: Column) -> Self {
Sort::by(column)
}
pub fn inverted(self) -> Self {
Sort {
column: self.column,
desc: !self.desc,
}
}
pub fn arrow(self) -> &'static str {
if self.desc {
"▼"
} else {
"▲"
}
}
}
impl Default for Sort {
fn default() -> Self {
Sort::by(Column::Last)
}
}
#[derive(Debug, Clone)]
pub struct Target {
pub path: PathBuf,
pub kind: Kind,
pub size: u64,
pub wal: u64,
pub mtime: SystemTime,
pub written: u64,
pub last_write: Option<Instant>,
pub history: VecDeque<u64>,
pub present: bool,
pub by_table: Vec<(String, u64)>,
pub table_delta: Vec<(String, u64)>,
seen_frames: u32,
salts: Option<(u32, u32)>,
owners: std::collections::HashMap<u32, String>,
mapped: bool,
}
impl Target {
fn new(path: PathBuf, kind: Kind) -> Self {
let (size, wal, mtime, present) = probe(&path, kind);
Target {
path,
kind,
size,
wal,
mtime,
written: 0,
last_write: None,
history: VecDeque::new(),
present,
by_table: Vec::new(),
table_delta: Vec::new(),
seen_frames: 0,
salts: None,
owners: std::collections::HashMap::new(),
mapped: false,
}
}
fn attribute_wal(&mut self) {
if self.kind != Kind::Sqlite {
return;
}
let new = match crate::wal::read_frames_after(&self.path, self.seen_frames, self.salts) {
Some(n) => n,
None => return,
};
if new.restarted {
self.seen_frames = 0;
self.mapped = false;
}
self.salts = Some((new.salt1, new.salt2));
if new.frames.is_empty() {
self.seen_frames = new.total_frames;
return;
}
let unknown = new
.frames
.iter()
.any(|f| f.live && !self.owners.contains_key(&f.page));
if !self.mapped || unknown {
if let Ok(map) = crate::recover::page_owners(&self.path) {
self.owners = map;
self.mapped = true;
}
}
let mut acc: std::collections::HashMap<String, u64> =
self.by_table.iter().cloned().collect();
let mut delta: std::collections::HashMap<String, u64> = Default::default();
for f in new.frames.iter().filter(|f| f.live) {
let owner = self
.owners
.get(&f.page)
.cloned()
.unwrap_or_else(|| format!("page {} (unmapped)", f.page));
*acc.entry(owner.clone()).or_insert(0) += new.page_size as u64;
*delta.entry(owner).or_insert(0) += new.page_size as u64;
}
self.table_delta = delta.into_iter().collect();
self.seen_frames = new.total_frames;
let mut out: Vec<(String, u64)> = acc.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
self.by_table = out;
}
pub fn rate(&self, interval: Duration) -> f64 {
if self.history.is_empty() || interval.is_zero() {
return 0.0;
}
let take = 4.min(self.history.len());
let sum: u64 = self.history.iter().rev().take(take).sum();
sum as f64 / (interval.as_secs_f64() * take as f64)
}
pub fn active(&self, window: Duration) -> bool {
self.last_write.is_some_and(|t| t.elapsed() < window)
}
pub fn table_rate(&self, table: &str, interval: Duration) -> f64 {
if interval.is_zero() {
return 0.0;
}
self.table_delta
.iter()
.find(|(n, _)| n == table)
.map(|(_, b)| *b as f64 / interval.as_secs_f64())
.unwrap_or(0.0)
}
pub fn owners_snapshot(&self) -> std::collections::HashMap<u32, String> {
self.owners.clone()
}
pub fn name(&self) -> &str {
self.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
}
}
fn probe(path: &Path, kind: Kind) -> (u64, u64, SystemTime, bool) {
match std::fs::metadata(path) {
Ok(m) => {
let wal = if kind == Kind::Sqlite {
wal_size(path)
} else {
0
};
(
m.len(),
wal,
m.modified().unwrap_or(SystemTime::UNIX_EPOCH),
true,
)
}
Err(_) => (0, 0, SystemTime::UNIX_EPOCH, false),
}
}
fn wal_size(path: &Path) -> u64 {
let mut name = path.as_os_str().to_os_string();
name.push("-wal");
std::fs::metadata(PathBuf::from(name))
.map(|m| m.len())
.unwrap_or(0)
}
pub struct Watcher {
pub targets: Vec<Target>,
pub interval: Duration,
pub paused: bool,
started: Instant,
last_tick: Instant,
pub ticks: u64,
}
impl Watcher {
pub fn new(targets: impl IntoIterator<Item = (PathBuf, Kind)>) -> Self {
let mut seen = std::collections::HashSet::new();
let targets: Vec<Target> = targets
.into_iter()
.filter(|(p, _)| seen.insert(p.clone()))
.map(|(p, k)| Target::new(p, k))
.collect();
Watcher {
targets,
interval: DEFAULT_INTERVAL,
paused: false,
started: Instant::now(),
last_tick: Instant::now(),
ticks: 0,
}
}
pub fn tick(&mut self) -> bool {
if self.paused || self.last_tick.elapsed() < self.interval {
return false;
}
self.last_tick = Instant::now();
self.ticks += 1;
for t in &mut self.targets {
let (size, wal, mtime, present) = probe(&t.path, t.kind);
let grew = size.saturating_sub(t.size) + wal.saturating_sub(t.wal);
let touched = grew > 0 || mtime != t.mtime || present != t.present;
if grew > 0 {
t.written += grew;
}
if touched {
t.last_write = Some(Instant::now());
}
t.size = size;
t.wal = wal;
t.mtime = mtime;
t.present = present;
t.history.push_back(grew);
if t.history.len() > HISTORY {
t.history.pop_front();
}
t.attribute_wal();
}
true
}
#[cfg(test)]
pub fn sorted(&self, sort: Sort) -> Vec<usize> {
self.sorted_filtered(sort, "")
}
pub fn sorted_filtered(&self, sort: Sort, filter: &str) -> Vec<usize> {
let needle = filter.to_lowercase();
let mut idx: Vec<usize> = (0..self.targets.len())
.filter(|&i| {
needle.is_empty()
|| self.targets[i]
.path
.to_str()
.map(|p| p.to_lowercase().contains(&needle))
.unwrap_or(false)
})
.collect();
let interval = self.interval;
idx.sort_by(|&a, &b| {
let (x, y) = (&self.targets[a], &self.targets[b]);
let ord = match sort.column {
Column::Kind => format!("{:?}", x.kind).cmp(&format!("{:?}", y.kind)),
Column::Name => x.name().to_lowercase().cmp(&y.name().to_lowercase()),
Column::Size => x.size.cmp(&y.size),
Column::Written => x.written.cmp(&y.written),
Column::Rate => x.rate(interval).total_cmp(&y.rate(interval)),
Column::Last => write_age(x).cmp(&write_age(y)).reverse(),
};
ord.then_with(|| x.path.cmp(&y.path))
});
if sort.desc {
idx.reverse();
}
idx
}
pub fn total_written(&self) -> u64 {
self.targets.iter().map(|t| t.written).sum()
}
pub fn total_rate(&self) -> f64 {
self.targets.iter().map(|t| t.rate(self.interval)).sum()
}
pub fn active_count(&self, window: Duration) -> usize {
self.targets.iter().filter(|t| t.active(window)).count()
}
pub fn elapsed(&self) -> Duration {
self.started.elapsed()
}
}
fn write_age(t: &Target) -> Duration {
t.last_write.map(|w| w.elapsed()).unwrap_or(Duration::MAX)
}
pub fn spark(sample: u64, peak: u64) -> char {
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if sample == 0 || peak == 0 {
return ' ';
}
let level = ((sample as f64 / peak as f64) * BARS.len() as f64).ceil() as usize;
BARS[level.clamp(1, BARS.len()) - 1]
}
#[cfg(test)]
mod attribution_tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let seq = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let p =
std::env::temp_dir().join(format!("zdbview_attr_{}_{seq}_{name}", std::process::id()));
for suffix in ["", "-wal", "-shm"] {
let mut n = p.as_os_str().to_os_string();
n.push(suffix);
let _ = std::fs::remove_file(PathBuf::from(n));
}
p
}
#[test]
fn writes_are_attributed_to_the_table_whose_pages_changed() {
let path = scratch("attr.db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "wal_autocheckpoint", 0).unwrap();
conn.execute_batch(
"CREATE TABLE busy (id INTEGER PRIMARY KEY, v TEXT);
CREATE TABLE quiet (id INTEGER PRIMARY KEY, v TEXT);
INSERT INTO quiet (v) VALUES ('one');",
)
.unwrap();
let mut w = Watcher::new([(path.clone(), Kind::Sqlite)]);
w.interval = Duration::from_millis(0);
assert!(w.tick());
w.targets[0].by_table.clear();
for i in 0..300 {
conn.execute(
"INSERT INTO busy (v) VALUES (?1)",
[format!("row {i} padded out so pages fill up quickly")],
)
.unwrap();
}
assert!(w.tick());
let by: Vec<(String, u64)> = w.targets[0].by_table.clone();
assert!(!by.is_empty(), "the log must attribute something");
let bytes_for =
|name: &str| -> u64 { by.iter().filter(|(n, _)| n == name).map(|(_, b)| *b).sum() };
assert!(
bytes_for("busy") > 0,
"the table that was written must appear: {by:?}"
);
assert_eq!(
bytes_for("quiet"),
0,
"the table that was not written must not: {by:?}"
);
assert!(
bytes_for("busy") > bytes_for("sqlite_schema"),
"the data pages outweigh the schema page: {by:?}"
);
assert_eq!(by[0].0, "busy", "{by:?}");
conn.execute_batch("CREATE INDEX busy_v ON busy(v)")
.unwrap();
for i in 0..200 {
conn.execute("INSERT INTO busy (v) VALUES (?1)", [format!("more {i}")])
.unwrap();
}
assert!(w.tick());
let by = w.targets[0].by_table.clone();
assert!(
by.iter().any(|(n, b)| n == "index busy_v" && *b > 0),
"index pages are named as indexes: {by:?}"
);
drop(conn);
for suffix in ["", "-wal", "-shm"] {
let mut n = path.as_os_str().to_os_string();
n.push(suffix);
let _ = std::fs::remove_file(PathBuf::from(n));
}
}
#[test]
fn an_archive_is_not_attributed() {
let path = scratch("shard.rkyv");
std::fs::write(&path, b"not a database at all").unwrap();
let mut w = Watcher::new([(path.clone(), Kind::Rkyv)]);
w.interval = Duration::from_millis(0);
assert!(w.tick());
std::fs::write(&path, b"grown, but still not a database").unwrap();
assert!(w.tick());
assert!(w.targets[0].written > 0, "the growth is still counted");
assert!(
w.targets[0].by_table.is_empty(),
"but nothing is attributed to a table"
);
let _ = std::fs::remove_file(&path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn dir(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("zdbview_watch_{}_{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn append(path: &Path, bytes: &[u8]) {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.unwrap();
f.write_all(bytes).unwrap();
}
fn sample(w: &mut Watcher) {
w.interval = Duration::ZERO;
assert!(w.tick());
w.interval = DEFAULT_INTERVAL;
}
#[test]
fn growth_is_counted_as_written_bytes() {
let d = dir("growth");
let f = d.join("shard.rkyv");
append(&f, b"0123456789");
let mut w = Watcher::new([(f.clone(), Kind::Rkyv)]);
assert_eq!(w.targets[0].size, 10);
assert_eq!(w.targets[0].written, 0, "the starting size is not a write");
assert!(w.targets[0].last_write.is_none());
append(&f, &[b'x'; 90]);
sample(&mut w);
assert_eq!(w.targets[0].size, 100);
assert_eq!(w.targets[0].written, 90, "only the growth counts");
assert!(w.targets[0].last_write.is_some());
assert!(w.targets[0].active(ACTIVE_WINDOW));
assert_eq!(w.total_written(), 90);
sample(&mut w);
assert_eq!(w.targets[0].written, 90);
assert_eq!(*w.targets[0].history.back().unwrap(), 0);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn sqlite_wal_growth_counts_for_the_database() {
let d = dir("wal");
let db = d.join("data.db");
append(&db, b"SQLite format 3\0");
let mut w = Watcher::new([(db.clone(), Kind::Sqlite)]);
assert_eq!(w.targets[0].wal, 0);
let wal = d.join("data.db-wal");
append(&wal, &[b'w'; 4096]);
sample(&mut w);
assert_eq!(w.targets[0].size, 16, "the database itself is untouched");
assert_eq!(w.targets[0].wal, 4096);
assert_eq!(w.targets[0].written, 4096, "WAL growth is the write");
assert!(w.targets[0].active(ACTIVE_WINDOW));
let before = w.targets[0].written;
std::fs::write(&wal, b"").unwrap();
append(&db, &[b'd'; 1000]);
sample(&mut w);
assert_eq!(w.targets[0].wal, 0);
assert_eq!(w.targets[0].written, before + 1000);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_rewritten_shard_is_activity_without_inflating_the_total() {
let d = dir("rewrite");
let f = d.join("s.rkyv");
append(&f, &[b'a'; 5000]);
let mut w = Watcher::new([(f.clone(), Kind::Rkyv)]);
sample(&mut w);
let before = w.targets[0].written;
std::fs::write(&f, [b'b'; 100]).unwrap();
sample(&mut w);
assert_eq!(w.targets[0].size, 100);
assert_eq!(w.targets[0].written, before, "a shrink adds no bytes");
assert!(
w.targets[0].last_write.is_some(),
"but it still counts as a touch"
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_vanished_file_is_marked_absent_without_erroring() {
let d = dir("gone");
let f = d.join("s.rkyv");
append(&f, b"x");
let mut w = Watcher::new([(f.clone(), Kind::Rkyv)]);
assert!(w.targets[0].present);
std::fs::remove_file(&f).unwrap();
sample(&mut w);
assert!(!w.targets[0].present);
assert_eq!(w.targets[0].size, 0);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn pausing_stops_sampling() {
let d = dir("pause");
let f = d.join("s.rkyv");
append(&f, b"x");
let mut w = Watcher::new([(f.clone(), Kind::Rkyv)]);
w.paused = true;
w.interval = Duration::ZERO;
append(&f, &[b'y'; 50]);
assert!(!w.tick(), "a paused watcher must not sample");
assert_eq!(w.targets[0].written, 0);
w.paused = false;
assert!(w.tick());
assert_eq!(w.targets[0].written, 50);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn history_is_bounded_and_rate_averages_recent_samples() {
let d = dir("rate");
let f = d.join("s.rkyv");
append(&f, b"x");
let mut w = Watcher::new([(f.clone(), Kind::Rkyv)]);
w.interval = Duration::from_secs(1);
for _ in 0..HISTORY + 10 {
append(&f, &[b'z'; 1000]);
w.interval = Duration::ZERO;
w.tick();
w.interval = Duration::from_secs(1);
}
assert_eq!(
w.targets[0].history.len(),
HISTORY,
"history must be capped"
);
let rate = w.targets[0].rate(Duration::from_secs(1));
assert!((rate - 1000.0).abs() < 1.0, "rate was {rate}");
assert!(w.total_rate() > 0.0);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn duplicate_paths_are_watched_once() {
let d = dir("dupes");
let f = d.join("s.rkyv");
append(&f, b"x");
let w = Watcher::new([
(f.clone(), Kind::Rkyv),
(f.clone(), Kind::Rkyv),
(d.join("other.db"), Kind::Sqlite),
]);
assert_eq!(w.targets.len(), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn ordering_puts_the_interesting_row_first() {
let d = dir("order");
let (a, b, c) = (d.join("a.rkyv"), d.join("b.rkyv"), d.join("c.rkyv"));
append(&a, &[b'a'; 10]);
append(&b, &[b'b'; 20000]);
append(&c, &[b'c'; 100]);
let mut w = Watcher::new([
(a.clone(), Kind::Rkyv),
(b.clone(), Kind::Rkyv),
(c.clone(), Kind::Rkyv),
]);
append(&c, &[b'c'; 9000]);
append(&a, &[b'a'; 10]);
sample(&mut w);
let by = |s: Sort| -> Vec<String> {
w.sorted(s)
.iter()
.map(|&i| w.targets[i].name().to_string())
.collect()
};
assert_eq!(
by(Sort::by(Column::Written))[0],
"c.rkyv",
"most bytes first"
);
assert_eq!(by(Sort::by(Column::Rate))[0], "c.rkyv", "fastest first");
assert_eq!(
by(Sort::by(Column::Size))[0],
"b.rkyv",
"largest file first"
);
assert_eq!(
by(Sort::by(Column::Name)),
vec!["a.rkyv", "b.rkyv", "c.rkyv"],
"names ascend by default"
);
let asc = by(Sort::by(Column::Size).inverted());
let mut desc = by(Sort::by(Column::Size));
desc.reverse();
assert_eq!(asc, desc, "inverting a column mirrors it");
let recent = by(Sort::by(Column::Last));
assert_eq!(recent.last().unwrap(), "b.rkyv", "idle file sorts last");
assert_eq!(w.active_count(ACTIVE_WINDOW), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn columns_cycle_both_ways() {
let mut c = Column::Kind;
let mut seen = vec![c];
for _ in 0..Column::ALL.len() - 1 {
c = c.next();
assert!(!seen.contains(&c), "{c:?} repeated early");
seen.push(c);
}
assert_eq!(c.next(), Column::Kind, "wraps forward");
assert_eq!(
Column::Kind.prev(),
*Column::ALL.last().unwrap(),
"wraps back"
);
for c in Column::ALL {
assert!(!c.label().is_empty());
assert_eq!(
Sort::by(*c).desc,
!matches!(c, Column::Kind | Column::Name),
"{c:?} default direction"
);
}
let s = Sort::by(Column::Size);
assert_eq!(s.arrow(), "▼");
assert_eq!(s.inverted().arrow(), "▲");
assert_eq!(s.inverted().column, Column::Size);
assert_eq!(Sort::default().column, Column::Last, "recent writes first");
}
#[test]
fn sorted_filtered_narrows_to_matching_paths() {
let d = dir("filter");
for name in ["alpha.rkyv", "beta.db", "alpha2.rkyv"] {
append(&d.join(name), b"x");
}
let w = Watcher::new([
(d.join("alpha.rkyv"), Kind::Rkyv),
(d.join("beta.db"), Kind::Sqlite),
(d.join("alpha2.rkyv"), Kind::Rkyv),
]);
let names = |f: &str| -> Vec<String> {
w.sorted_filtered(Sort::by(Column::Name), f)
.iter()
.map(|&i| w.targets[i].name().to_string())
.collect()
};
assert_eq!(names("").len(), 3);
assert_eq!(names("alpha"), vec!["alpha.rkyv", "alpha2.rkyv"]);
assert_eq!(
names("BETA"),
vec!["beta.db"],
"matching is case-insensitive"
);
assert_eq!(names("zdbview_watch").len(), 3);
assert!(names("nothing-here").is_empty());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn spark_scales_and_shows_any_write() {
assert_eq!(spark(0, 100), ' ', "no write is blank");
assert_eq!(spark(100, 100), '█', "the peak is full height");
assert_eq!(spark(1, 1_000_000), '▁', "a tiny write still shows");
assert_eq!(spark(50, 100), '▄');
assert_eq!(spark(10, 0), ' ', "no peak yet");
assert_eq!(spark(500, 100), '█');
}
}