use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use argonauts_core::RunId;
use argonauts_core::events::run::RunLifecycle;
use argonauts_core::events::{RunEvent, RunEventKind};
use argonauts_core::journal::read_all;
use argonauts_core::names::ModelRef;
use argonauts_core::paths::{EVENT_LOG_FILE, write_private_file};
use serde::Serialize;
use serde::de::DeserializeOwned;
use time::{Date, OffsetDateTime};
use super::{LedgerLine, UsageLedger, local_day, month_name};
use crate::work_ledger::{self, Work, WorkLedger, WorkLine};
const DONE: &str = ".rebuilt-1";
const BUSY: &str = ".rebuilding";
const STALE: Duration = Duration::from_secs(10 * 60);
const TRIES: usize = 5;
impl UsageLedger {
pub(crate) fn rebuilt_once(&self) {
if let Err(err) = self.rebuild_once() {
tracing::warn!("couldn't rebuild the usage records from the runs' logs: {err}");
}
}
pub(crate) fn rebuild_once(&self) -> io::Result<()> {
if !self.dir.is_dir() || self.dir.join(DONE).exists() {
return Ok(());
}
let Some(_busy) = Busy::take(&self.dir)? else {
return Ok(());
};
self.rebuild()?;
write_private_file(&self.dir.join(DONE), b"")
}
pub(crate) fn rebuild(&self) -> io::Result<()> {
let logs = run_logs(&self.state);
let work = WorkLedger::beside(&self.dir);
let mut named: BTreeMap<RunId, Filed> = BTreeMap::new();
for line in self.read_since(None) {
if let Some(run) = line.run {
let filed = named.entry(run).or_insert_with(|| Filed::under(line.repo));
filed.ended.get_or_insert(line.day);
filed.hour = filed.hour.or(line.hour);
}
}
for line in work.read_since(None) {
let ended = matches!(line.what, Work::Run { .. }).then_some(line.day);
let filed = named
.entry(line.run)
.or_insert_with(|| Filed::under(line.repo));
filed.ended = filed.ended.or(ended);
}
let mut rebuilt = BTreeSet::new();
let (mut usage, mut done) = (Vec::new(), Vec::new());
for (run, filed) in &named {
let Some(events) = logs.get(run).and_then(|log| read_all(log).ok()) else {
continue;
};
let Some(at) = ended_at(&events.events) else {
continue;
};
let repo = Path::new(&filed.repo);
let ended = filed.ended.unwrap_or_else(|| local_day(at));
usage.extend(
(self.lines_of(repo, run, &events.events, at).into_iter()).map(|line| LedgerLine {
day: ended,
hour: filed.hour.or(line.hour),
..line
}),
);
done.extend(work.lines_of(repo, run, &events.events, ended));
rebuilt.insert(run.clone());
}
let catalog = self.catalog;
rewrite(
&self.dir,
usage,
|line: &LedgerLine| line.day,
|line| {
if line.run.as_ref().is_some_and(|run| rebuilt.contains(run)) {
return None;
}
let model = ModelRef::new(line.agent.clone(), line.model.clone());
Some(LedgerLine {
model: work_ledger::filed_as(catalog, &model).model,
..line.clone()
})
},
)?;
rewrite(
work.dir(),
done,
|line: &WorkLine| line.day,
|line| {
if rebuilt.contains(&line.run) {
return None;
}
let mut line = line.clone();
work.refile(&mut line);
Some(line)
},
)
}
}
struct Filed {
repo: String,
ended: Option<Date>,
hour: Option<u8>,
}
impl Filed {
fn under(repo: String) -> Self {
Self {
repo,
ended: None,
hour: None,
}
}
}
fn run_logs(state: &Path) -> BTreeMap<RunId, PathBuf> {
let folders = |dir: &Path| -> Vec<PathBuf> {
fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|entry| Some(entry.ok()?.path()))
.filter(|path| path.is_dir())
.collect()
})
.unwrap_or_default()
};
let mut logs = BTreeMap::new();
for repo in folders(state) {
for run in folders(&repo.join("runs")) {
let log = run.join(EVENT_LOG_FILE);
let id = run.file_name().and_then(|name| name.to_str());
if let Some(id) = id.and_then(|id| RunId::new(id).ok())
&& log.is_file()
{
logs.insert(id, log);
}
}
}
logs
}
fn ended_at(events: &[RunEvent]) -> Option<OffsetDateTime> {
let finished = events.iter().rev().find_map(|event| match &event.kind {
RunEventKind::Run(RunLifecycle::Finished { .. }) => Some(event.ts),
_ => None,
});
finished.or_else(|| events.last().map(|event| event.ts))
}
fn rewrite<L: Serialize + DeserializeOwned + PartialEq>(
dir: &Path,
added: Vec<L>,
day_of: fn(&L) -> Date,
keep: impl Fn(&L) -> Option<L>,
) -> io::Result<()> {
let mut by_month: BTreeMap<String, Vec<L>> = BTreeMap::new();
for line in added {
by_month
.entry(month_name(day_of(&line)))
.or_default()
.push(line);
}
let mut months: BTreeSet<String> = by_month.keys().cloned().collect();
if let Ok(entries) = fs::read_dir(dir) {
let named = entries.filter_map(|entry| {
let name = entry.ok()?.file_name().into_string().ok()?;
Some(name.strip_suffix(".jsonl")?.to_owned())
});
months.extend(named);
}
for month in months {
let added = by_month.remove(&month).unwrap_or_default();
let path = dir.join(format!("{month}.jsonl"));
let mut tries = 0;
loop {
let had = fs::read_to_string(&path).unwrap_or_default();
let text = month_text(&had, &added, &keep)?;
if text == had {
break;
}
let fresh = dir.join(format!(".{month}.jsonl.rebuilt"));
write_private_file(&fresh, text.as_bytes())?;
tries += 1;
if fs::read_to_string(&path).unwrap_or_default() != had && tries < TRIES {
continue;
}
fs::rename(&fresh, &path)?;
break;
}
}
Ok(())
}
fn month_text<L: Serialize + DeserializeOwned + PartialEq>(
had: &str,
added: &[L],
keep: &impl Fn(&L) -> Option<L>,
) -> io::Result<String> {
let mut text = String::new();
for raw in had.lines().filter(|raw| !raw.trim().is_empty()) {
match serde_json::from_str::<L>(raw) {
Ok(line) => match keep(&line) {
Some(kept) if kept == line => text.push_str(raw),
Some(kept) => {
text.push_str(&serde_json::to_string(&kept).map_err(io::Error::other)?)
}
None => continue,
},
Err(_) => text.push_str(raw),
}
text.push('\n');
}
for line in added {
text.push_str(&serde_json::to_string(line).map_err(io::Error::other)?);
text.push('\n');
}
Ok(text)
}
struct Busy(PathBuf);
impl Busy {
fn take(dir: &Path) -> io::Result<Option<Self>> {
let path = dir.join(BUSY);
let stale = fs::metadata(&path)
.and_then(|meta| meta.modified())
.is_ok_and(|at| {
SystemTime::now()
.duration_since(at)
.is_ok_and(|age| age > STALE)
});
if stale {
let _ = fs::remove_file(&path);
}
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(_) => Ok(Some(Self(path))),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(None),
Err(err) => Err(err),
}
}
}
impl Drop for Busy {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
#[cfg(test)]
#[path = "rebuild_tests.rs"]
mod tests;