use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MissReason {
#[default]
None,
NotACompile,
Unsupported,
Refused,
Other,
}
impl MissReason {
pub(crate) fn classify(reason: &str) -> Self {
let reason = reason.trim();
if reason.is_empty() {
return Self::None;
}
let kind = if let Some((kind, _)) = reason.split_once('|') {
kind.trim()
} else if reason.starts_with("refused:") {
"refused"
} else {
""
};
match kind {
"not-a-compile" => Self::NotACompile,
"unsupported" => Self::Unsupported,
"refused" => Self::Refused,
_ => Self::Other,
}
}
fn is_none(&self) -> bool {
*self == Self::None
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BuildEvent {
pub ts: DateTime<Utc>,
pub crate_name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub root: String,
#[serde(default)]
pub version: String,
pub result: EventResult,
pub elapsed_ms: u64,
#[serde(default)]
pub compile_time_ms: u64,
pub size: u64,
#[serde(default)]
pub cache_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rebuilt_package: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rebuilt_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rebuilt_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rebuilt_paths: Vec<String>,
#[serde(default)]
pub schema: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub session_id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub demands: Vec<kache_core::timeline::KeyDemand>,
#[serde(default)]
pub key_ms: u64,
#[serde(default)]
pub key_hash_hits: u64,
#[serde(default)]
pub key_hash_misses: u64,
#[serde(default)]
pub key_hash_bytes: u64,
#[serde(default)]
pub lookup_ms: u64,
#[serde(default)]
pub restore_ms: u64,
#[serde(default)]
pub store_ms: u64,
#[serde(default)]
pub startup_ms: u64,
#[serde(default)]
pub dep_info_ms: u64,
#[serde(default)]
pub dep_info_runs: u32,
#[serde(default)]
pub prediction_mismatches: u32,
#[serde(default)]
pub flight_wait_ms: u64,
#[serde(default)]
pub permit_wait_ms: u64,
#[serde(default)]
pub store_output_blobs: u32,
#[serde(default)]
pub store_duplicate_blobs: u32,
#[serde(default)]
pub store_new_blobs: u32,
#[serde(default)]
pub compiler_runs: u32,
#[serde(default)]
pub preprocessor_runs: u32,
#[serde(default)]
pub probe_runs: u32,
#[serde(default)]
pub reflinked_bytes: u64,
#[serde(default)]
pub hardlinked_bytes: u64,
#[serde(default)]
pub copied_bytes: u64,
#[serde(default)]
pub store_reflinked_bytes: u64,
#[serde(default)]
pub store_hardlinked_bytes: u64,
#[serde(default)]
pub store_copied_bytes: u64,
#[serde(default)]
pub store_copy_cross_device_bytes: u64,
#[serde(default)]
pub store_copy_permission_bytes: u64,
#[serde(default)]
pub store_copy_ineligible_bytes: u64,
#[serde(default)]
pub store_copy_other_bytes: u64,
#[serde(default)]
pub restore_copy_cross_device_bytes: u64,
#[serde(default)]
pub restore_copy_permission_bytes: u64,
#[serde(default)]
pub restore_copy_exclusive_bytes: u64,
#[serde(default)]
pub restore_copy_other_bytes: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub passthrough_reason: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub skip_reason: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub store_error: String,
#[serde(default, skip_serializing_if = "is_false")]
pub store_handed_off: bool,
#[serde(default)]
pub daemon_store_ms: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub lookup_rejection: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub verify_compare: String,
#[serde(default, skip_serializing_if = "is_false")]
pub fallback: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_attempt: Option<crate::fallback::Attempt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub key_fields: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_env_deps: Option<std::collections::BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub key_diff: Vec<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub key_externs: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "is_false")]
pub key_externs_recorded: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub unit_id: String,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub extern_units: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "MissReason::is_none")]
pub miss_reason: MissReason,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub object_output: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub package: String,
}
impl BuildEvent {
pub fn is_hit(&self) -> bool {
matches!(
self.result,
EventResult::LocalHit | EventResult::PrefetchHit | EventResult::RemoteHit
)
}
pub fn overhead_ms(&self) -> u64 {
if self.is_hit() {
self.elapsed_ms
} else {
self.elapsed_ms.saturating_sub(self.compile_time_ms)
}
}
pub fn wait_ms(&self) -> u64 {
self.flight_wait_ms + self.permit_wait_ms
}
pub fn attributed_ms(&self) -> u64 {
self.startup_ms
+ self.key_ms
+ self.lookup_ms
+ self.wait_ms()
+ self.restore_ms
+ self.store_ms
}
pub fn unattributed_ms(&self) -> u64 {
self.overhead_ms().saturating_sub(self.attributed_ms())
}
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventResult {
LocalHit,
PrefetchHit,
RemoteHit,
Dup,
Miss,
Error,
Passthrough,
Skipped,
}
impl std::fmt::Display for EventResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EventResult::LocalHit => write!(f, "local_hit"),
EventResult::PrefetchHit => write!(f, "prefetch_hit"),
EventResult::RemoteHit => write!(f, "remote_hit"),
EventResult::Dup => write!(f, "dup"),
EventResult::Miss => write!(f, "miss"),
EventResult::Error => write!(f, "error"),
EventResult::Passthrough => write!(f, "passthrough"),
EventResult::Skipped => write!(f, "skipped"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BuildSummaryEvent {
pub ts: DateTime<Utc>,
pub schema: u32,
#[serde(default)]
pub incomplete: bool,
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub root: String,
#[serde(default)]
pub plan_source: String,
#[serde(default)]
pub plan_id: String,
#[serde(default)]
pub closure_reason: String,
#[serde(default)]
pub started_at_ms: u64,
#[serde(default)]
pub last_activity_ms: u64,
#[serde(default)]
pub candidate_keys: u64,
#[serde(default)]
pub downloaded_keys: u64,
#[serde(default)]
pub downloaded_bytes: u64,
#[serde(default)]
pub used_keys: u64,
#[serde(default)]
pub used_bytes: u64,
#[serde(default)]
pub demanded_keys: u64,
#[serde(default)]
pub demanded_candidate_keys: u64,
#[serde(default)]
pub cancelled: bool,
#[serde(default)]
pub list_requests: u64,
#[serde(default)]
pub list_duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatEvent {
pub event: String,
pub ts: DateTime<Utc>,
pub crate_name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub root: String,
pub pid: u32,
pub elapsed_s: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub typical_s: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eta_s: Option<u64>,
#[serde(default)]
pub schema: u32,
}
pub const HEARTBEAT_EVENT_TAG: &str = "heartbeat";
pub const HEARTBEAT_SCHEMA: u32 = 1;
pub const CLEANUP_EVENT_TAG: &str = "target-cleanup";
pub const CLEANUP_SCHEMA: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CleanupEvent {
pub event: String,
pub schema: u32,
pub ts: DateTime<Utc>,
pub target: String,
pub root: String,
pub removed_paths: Vec<String>,
pub removed_units: u64,
pub removed_bytes: u64,
pub reason: String,
}
impl CleanupEvent {
pub fn new(
target: String,
root: String,
removed_paths: Vec<String>,
removed_units: u64,
removed_bytes: u64,
reason: String,
) -> Self {
Self {
event: CLEANUP_EVENT_TAG.to_string(),
schema: CLEANUP_SCHEMA,
ts: Utc::now(),
target,
root,
removed_paths,
removed_units,
removed_bytes,
reason,
}
}
}
pub fn log_cleanup(event_log_path: &Path, event: &CleanupEvent) -> Result<()> {
append_json_line(event_log_path, event)
}
#[derive(Debug, Clone)]
pub enum EventRecord {
Build(Box<BuildEvent>),
Heartbeat(HeartbeatEvent),
}
fn parse_event_line(line: &str) -> Option<EventRecord> {
if let Ok(hb) = serde_json::from_str::<HeartbeatEvent>(line) {
if hb.event == HEARTBEAT_EVENT_TAG {
return Some(EventRecord::Heartbeat(hb));
}
return None;
}
serde_json::from_str::<BuildEvent>(line)
.ok()
.map(|e| EventRecord::Build(Box::new(e)))
}
fn append_log_line(event_log_path: &Path, line: String) -> Result<()> {
if let Some(parent) = event_log_path.parent() {
fs::create_dir_all(parent)?;
}
let lock = open_log_lock(event_log_path).context("opening event log lock")?;
lock.lock().context("locking event log")?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(event_log_path)
.context("opening event log")?;
let write_result = (|| -> Result<()> {
if file.metadata().context("statting event log")?.len() != 0 {
file.seek(SeekFrom::End(-1))
.context("seeking to event log tail")?;
let mut last = [0u8; 1];
std::io::Read::read_exact(&mut file, &mut last)
.context("reading event log tail byte")?;
if last[0] != b'\n' {
file.write_all(b"\n")
.context("terminating abandoned event fragment")?;
}
}
let mut bytes = line.into_bytes();
bytes.push(b'\n');
file.write_all(&bytes).context("writing event to log")
})();
lock.unlock().context("unlocking event log")?;
write_result
}
pub fn append_json_line<T: Serialize>(log_path: &Path, record: &T) -> Result<()> {
let line = serde_json::to_string(record).context("serializing log record")?;
append_log_line(log_path, line)
}
pub fn log_event(event_log_path: &Path, event: &BuildEvent) -> Result<()> {
append_log_line(
event_log_path,
serde_json::to_string(event).context("serializing event")?,
)
}
pub fn log_heartbeat(event_log_path: &Path, event: &HeartbeatEvent) -> Result<()> {
append_log_line(
event_log_path,
serde_json::to_string(event).context("serializing heartbeat")?,
)
}
const TYPICAL_WINDOW: usize = 20;
pub fn typical_compile_ms(event_log_path: &Path, crate_name: &str, root: &str) -> Option<u64> {
let events = read_events(event_log_path).ok()?;
let samples: Vec<u64> = events
.iter()
.filter(|e| {
e.crate_name == crate_name
&& e.root == root
&& e.compile_time_ms > 0
&& matches!(e.result, EventResult::Miss | EventResult::Dup)
})
.map(|e| e.compile_time_ms)
.collect();
let window = &samples[samples.len().saturating_sub(TYPICAL_WINDOW)..];
let center = median(window)?;
let n = window.len() as f64;
let mean = window.iter().sum::<u64>() as f64 / n;
let sigma = (window
.iter()
.map(|&s| {
let d = s as f64 - mean;
d * d
})
.sum::<f64>()
/ n)
.sqrt();
let kept: Vec<u64> = window
.iter()
.copied()
.filter(|&s| sigma == 0.0 || (s as f64 - center as f64).abs() <= 3.0 * sigma)
.collect();
median(&kept)
}
fn median(samples: &[u64]) -> Option<u64> {
if samples.is_empty() {
return None;
}
let mut sorted = samples.to_vec();
sorted.sort_unstable();
Some(sorted[(sorted.len() - 1) / 2])
}
pub fn read_events(event_log_path: &Path) -> Result<Vec<BuildEvent>> {
Ok(read_events_and(event_log_path, || ())?.0)
}
pub fn read_events_and<T>(
event_log_path: &Path,
also: impl FnOnce() -> T,
) -> Result<(Vec<BuildEvent>, T)> {
if !event_log_path.exists() {
return Ok((Vec::new(), also()));
}
let lock = open_log_lock(event_log_path).context("opening event log lock")?;
lock.lock_shared().context("locking event log for read")?;
let file = File::open(event_log_path).context("opening event log")?;
let reader = BufReader::new(&file);
let mut events = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<BuildEvent>(&line) {
Ok(event) => events.push(event),
Err(e) => {
tracing::debug!("skipping invalid event line: {}", e);
}
}
}
let extra = also();
lock.unlock().context("unlocking event log")?;
Ok((events, extra))
}
pub fn read_events_since(event_log_path: &Path, since: DateTime<Utc>) -> Result<Vec<BuildEvent>> {
let all = read_events(event_log_path)?;
Ok(all.into_iter().filter(|e| e.ts >= since).collect())
}
#[cfg(windows)]
fn get_file_identity(file: &std::fs::File) -> Option<(u32, u32, u32)> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
let handle = file.as_raw_handle();
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe { GetFileInformationByHandle(handle as _, &mut info) };
if ok != 0 {
Some((
info.dwVolumeSerialNumber,
info.nFileIndexHigh,
info.nFileIndexLow,
))
} else {
None
}
}
#[derive(Serialize, Deserialize)]
struct RotationMarker {
generation: u64,
prev_len: u64,
retained_len: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
cut_session: Option<String>,
}
fn rotation_marker_path(log_path: &Path) -> PathBuf {
let mut os = log_path.as_os_str().to_owned();
os.push(".rotation");
PathBuf::from(os)
}
fn read_rotation_marker(log_path: &Path) -> Option<RotationMarker> {
let content = fs::read_to_string(rotation_marker_path(log_path)).ok()?;
serde_json::from_str(&content).ok()
}
pub struct EventTailer {
path: PathBuf,
position: u64,
generation: u64,
file: Option<File>,
}
impl EventTailer {
#[cfg(test)]
pub fn new(path: PathBuf) -> Self {
let file = File::open(&path).ok();
let position = file
.as_ref()
.and_then(|file| file.metadata().ok())
.map(|m| m.len())
.unwrap_or(0);
let generation = read_rotation_marker(&path)
.map(|m| m.generation)
.unwrap_or(0);
EventTailer {
path,
position,
generation,
file,
}
}
pub fn from_start(path: PathBuf) -> Self {
let file = File::open(&path).ok();
let generation = read_rotation_marker(&path)
.map(|m| m.generation)
.unwrap_or(0);
EventTailer {
path,
position: 0,
generation,
file,
}
}
#[cfg(test)]
pub fn poll(&mut self) -> Result<Vec<BuildEvent>> {
Ok(self
.poll_records()?
.into_iter()
.filter_map(|r| match r {
EventRecord::Build(e) => Some(*e),
EventRecord::Heartbeat(_) => None,
})
.collect())
}
pub fn poll_records(&mut self) -> Result<Vec<EventRecord>> {
if let Some(parent) = self.path.parent()
&& !parent.exists()
{
return Ok(Vec::new());
}
let lock = open_log_lock(&self.path).context("opening event log lock")?;
lock.lock_shared().context("locking event log for poll")?;
let res = self.poll_records_locked();
let _ = lock.unlock();
res
}
fn poll_records_locked(&mut self) -> Result<Vec<EventRecord>> {
if self.file.is_none() {
match File::open(&self.path) {
Ok(file) => self.file = Some(file),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
}
}
let mut handled_rotation = false;
if let Some(marker) = read_rotation_marker(&self.path) {
if marker.generation == self.generation + 1 {
let dropped = marker.prev_len.saturating_sub(marker.retained_len);
self.position = self
.position
.saturating_sub(dropped)
.min(marker.retained_len);
self.generation = marker.generation;
handled_rotation = true;
} else if marker.generation != self.generation {
self.position = 0;
self.generation = marker.generation;
handled_rotation = true;
}
}
let mut rotated = handled_rotation;
if !handled_rotation && let Some(file) = &self.file {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(m1) = file.metadata()
&& let Ok(m2) = std::fs::metadata(&self.path)
&& (m1.dev() != m2.dev() || m1.ino() != m2.ino())
{
rotated = true;
self.position = 0;
}
}
#[cfg(windows)]
{
if let Some(id1) = get_file_identity(file)
&& let Ok(f2) = std::fs::File::open(&self.path)
&& let Some(id2) = get_file_identity(&f2)
&& id1 != id2
{
rotated = true;
self.position = 0;
}
}
}
if rotated {
match File::open(&self.path) {
Ok(file) => self.file = Some(file),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
self.file = None;
self.position = 0;
return Ok(Vec::new());
}
Err(e) => return Err(e.into()),
}
}
let file = self.file.as_mut().unwrap();
let file_len = file.metadata()?.len();
if file_len < self.position {
self.position = 0;
}
if file_len <= self.position {
return Ok(Vec::new());
}
file.seek(SeekFrom::Start(self.position))?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
let mut records = Vec::new();
let mut consumed = 0usize;
for chunk in buf.split_inclusive(|&b| b == b'\n') {
if chunk.last() != Some(&b'\n') {
break;
}
consumed += chunk.len();
let line = String::from_utf8_lossy(chunk);
let line = line.trim();
if line.is_empty() {
continue;
}
match parse_event_line(line) {
Some(record) => records.push(record),
None => tracing::debug!("skipping invalid event line: {line}"),
}
}
self.position += consumed as u64;
Ok(records)
}
}
#[derive(Debug, PartialEq, Eq)]
struct RetainedSuffix {
start: usize,
cut_session: Option<String>,
}
fn retention_cap(max_size: u64) -> u64 {
max_size / 2
}
fn retained_suffix(lines: &[&str], keep_lines: usize, max_size: u64) -> RetainedSuffix {
let mut suffix = vec![0u64; lines.len() + 1];
for (i, line) in lines.iter().enumerate().rev() {
suffix[i] = suffix[i + 1] + line.len() as u64;
}
let bytes_from = |start: usize| suffix[start];
let by_count = lines.len().saturating_sub(keep_lines);
let newest = lines.len().saturating_sub(1);
let mut start = (by_count..newest)
.find(|&from| bytes_from(from) <= max_size)
.unwrap_or(newest.max(by_count));
let session = lines.last().and_then(|line| line_session(line));
let session_start = session.as_deref().and_then(|session| {
let needle = session_needle(session);
lines.iter().position(|line| line.contains(&needle))
});
if let Some(first) = session_start {
let cap = retention_cap(max_size);
start = (first..start)
.find(|&from| bytes_from(from) <= cap)
.unwrap_or(start);
}
let cut_session = match (session, session_start) {
(Some(session), Some(first)) if first < start => Some(session),
_ => None,
};
RetainedSuffix { start, cut_session }
}
fn line_session(line: &str) -> Option<String> {
#[derive(Deserialize)]
struct SessionOnly {
#[serde(default)]
session_id: String,
}
let parsed: SessionOnly = serde_json::from_str(line.trim_end()).ok()?;
(!parsed.session_id.is_empty()).then_some(parsed.session_id)
}
fn session_needle(session: &str) -> String {
format!(
"\"session_id\":{}",
serde_json::to_string(session).unwrap_or_default()
)
}
pub fn session_cut_by_rotation(event_log_path: &Path) -> Option<String> {
read_rotation_marker(event_log_path)?.cut_session
}
fn rotate_log_impl(
log_path: &Path,
max_size: u64,
keep_lines: usize,
log_label: &str,
fold: impl FnOnce(&[&str]) -> Result<()>,
) -> Result<()> {
if !log_path.exists() {
return Ok(());
}
if let Ok(meta) = fs::metadata(log_path)
&& meta.len() <= max_size
{
return Ok(());
}
let lock = open_log_lock(log_path).context("opening log lock")?;
lock.lock().context("locking log for rotation")?;
let res = (|| -> Result<Option<String>> {
let meta = fs::metadata(log_path)?;
if meta.len() <= max_size {
return Ok(None);
}
if let Some(parent) = log_path.parent()
&& let Some(file_prefix) = log_path.file_name().and_then(|n| n.to_str())
{
let _ = crate::atomic::cleanup_temp_files(
parent,
file_prefix,
std::time::Duration::from_secs(300),
);
}
let content = fs::read_to_string(log_path)?;
let complete_len = content.rfind('\n').map_or(0, |i| i + 1);
let lines: Vec<&str> = content[..complete_len].split_inclusive('\n').collect();
let retained = retained_suffix(&lines, keep_lines, max_size);
let kept = &lines[retained.start..];
let output = kept.concat();
crate::atomic::atomic_replace(log_path, output.as_bytes())
.context("writing and replacing log file atomically")?;
let dropped = lines[..retained.start].concat();
let marker = RotationMarker {
generation: read_rotation_marker(log_path)
.map(|m| m.generation)
.unwrap_or(0)
+ 1,
prev_len: complete_len as u64,
retained_len: output.len() as u64,
cut_session: retained.cut_session,
};
if let Ok(json) = serde_json::to_string(&marker) {
let _ = crate::atomic::atomic_replace(&rotation_marker_path(log_path), json.as_bytes());
}
tracing::info!(
"rotated {}: kept {} of {} lines",
log_label,
kept.len(),
lines.len()
);
Ok(Some(dropped))
})();
let _ = lock.unlock();
if let Some(dropped) = res? {
let lines: Vec<&str> = dropped.split_inclusive('\n').collect();
if let Err(e) = fold(&lines) {
tracing::warn!("folding rotated {log_label} lines: {e:#}");
}
}
Ok(())
}
pub fn rotate_if_needed(event_log_path: &Path, max_size: u64, keep_lines: usize) -> Result<()> {
rotate_log_impl(
event_log_path,
max_size,
keep_lines,
"event log",
|_| Ok(()),
)
}
pub fn rotate_events_if_needed(
event_log_path: &Path,
max_size: u64,
keep_lines: usize,
cache_dir: &Path,
) -> Result<()> {
rotate_log_impl(
event_log_path,
max_size,
keep_lines,
"event log",
|dropped| crate::savings::fold_dropped_lines(cache_dir, dropped),
)
}
pub fn log_summary(summary_log_path: &Path, event: &BuildSummaryEvent) -> Result<()> {
append_summary(summary_log_path, event, false)
}
pub(crate) fn log_summary_durable(
summary_log_path: &Path,
event: &BuildSummaryEvent,
) -> Result<()> {
append_summary(summary_log_path, event, true)
}
fn append_summary(summary_log_path: &Path, event: &BuildSummaryEvent, durable: bool) -> Result<()> {
if let Some(parent) = summary_log_path.parent() {
fs::create_dir_all(parent)?;
}
let lock = open_log_lock(summary_log_path).context("opening summary log lock")?;
lock.lock().context("locking summary log")?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(summary_log_path)
.context("opening summary log")?;
let line = serde_json::to_string(event).context("serializing summary event")?;
let mut bytes = line.into_bytes();
bytes.push(b'\n');
file.write_all(&bytes)
.context("writing summary event to log")?;
if durable {
file.sync_all().context("flushing summary event to disk")?;
if let Some(parent) = summary_log_path.parent() {
crate::atomic::fsync_dir(parent).context("flushing summary directory")?;
}
}
lock.unlock().context("unlocking summary log")?;
Ok(())
}
pub fn read_summaries(summary_log_path: &Path) -> Result<Vec<BuildSummaryEvent>> {
if !summary_log_path.exists() {
return Ok(Vec::new());
}
let lock = open_log_lock(summary_log_path).context("opening summary log lock")?;
lock.lock_shared().context("locking summary log for read")?;
let file = File::open(summary_log_path).context("opening summary log")?;
let reader = BufReader::new(&file);
let mut events = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<BuildSummaryEvent>(&line) {
Ok(event) => events.push(event),
Err(e) => {
tracing::debug!("skipping invalid summary line: {}", e);
}
}
}
lock.unlock().context("unlocking summary log")?;
Ok(events)
}
use crate::daemon::TransferEvent;
pub fn log_transfer(transfer_log_path: &Path, event: &TransferEvent) -> Result<()> {
if let Some(parent) = transfer_log_path.parent() {
fs::create_dir_all(parent)?;
}
let lock = open_log_lock(transfer_log_path).context("opening transfer log lock")?;
lock.lock().context("locking transfer log")?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(transfer_log_path)
.context("opening transfer log")?;
let line = serde_json::to_string(event).context("serializing transfer event")?;
let mut bytes = line.into_bytes();
bytes.push(b'\n');
file.write_all(&bytes)
.context("writing transfer event to log")?;
lock.unlock().context("unlocking transfer log")?;
Ok(())
}
pub fn read_transfers(transfer_log_path: &Path) -> Result<Vec<TransferEvent>> {
if !transfer_log_path.exists() {
return Ok(Vec::new());
}
let lock = open_log_lock(transfer_log_path).context("opening transfer log lock")?;
lock.lock_shared()
.context("locking transfer log for read")?;
let file = File::open(transfer_log_path).context("opening transfer log")?;
let reader = BufReader::new(&file);
let mut events = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<TransferEvent>(&line) {
Ok(event) => events.push(event),
Err(e) => {
tracing::debug!("skipping invalid transfer line: {}", e);
}
}
}
lock.unlock().context("unlocking transfer log")?;
Ok(events)
}
fn open_log_lock(log_path: &Path) -> Result<File> {
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(sidecar_lock_path(log_path))
.context("opening log lock")
}
fn sidecar_lock_path(log_path: &Path) -> PathBuf {
let mut path = log_path.as_os_str().to_owned();
path.push(".lock");
PathBuf::from(path)
}
pub fn read_transfers_since(transfer_log_path: &Path, since_ts: u64) -> Result<Vec<TransferEvent>> {
let all = read_transfers(transfer_log_path)?;
Ok(all
.into_iter()
.filter(|e| e.timestamp >= since_ts)
.collect())
}
pub fn rotate_transfers_if_needed(
transfer_log_path: &Path,
max_size: u64,
keep_lines: usize,
) -> Result<()> {
rotate_log_impl(
transfer_log_path,
max_size,
keep_lines,
"transfer log",
|_| Ok(()),
)
}
#[cfg(test)]
pub fn clear_events(event_log_path: &Path) -> Result<()> {
if !event_log_path.exists() {
return Ok(());
}
let lock = open_log_lock(event_log_path).context("opening event log lock")?;
lock.lock().context("locking event log for clearing")?;
let res = fs::write(event_log_path, "");
let _ = lock.unlock();
res.context("clearing event log")
}
pub struct EventStats {
pub local_hits: usize,
pub prefetch_hits: usize,
pub remote_hits: usize,
pub dups: usize,
pub misses: usize,
pub errors: usize,
pub total_size: u64,
pub total_elapsed_ms: u64,
pub hit_elapsed_ms: u64,
pub miss_elapsed_ms: u64,
pub hit_compile_time_ms: u64,
pub miss_compile_time_ms: u64,
pub total_key_ms: u64,
pub total_lookup_ms: u64,
pub total_restore_ms: u64,
pub total_store_ms: u64,
pub total_startup_ms: u64,
pub total_dep_info_ms: u64,
pub total_dep_info_runs: u64,
pub total_prediction_mismatches: u64,
pub total_flight_wait_ms: u64,
pub total_permit_wait_ms: u64,
pub total_unattributed_ms: u64,
pub store_output_blobs: u32,
pub store_duplicate_blobs: u32,
pub store_new_blobs: u32,
pub reflinked_bytes: u64,
pub hardlinked_bytes: u64,
pub copied_bytes: u64,
pub store_reflinked_bytes: u64,
pub store_hardlinked_bytes: u64,
pub store_copied_bytes: u64,
pub store_copy_cross_device_bytes: u64,
pub store_copy_permission_bytes: u64,
pub store_copy_ineligible_bytes: u64,
pub store_copy_other_bytes: u64,
pub restore_copy_cross_device_bytes: u64,
pub restore_copy_permission_bytes: u64,
pub restore_copy_exclusive_bytes: u64,
pub restore_copy_other_bytes: u64,
pub store_failures: usize,
}
pub fn compute_stats(events: &[BuildEvent]) -> EventStats {
let mut stats = EventStats {
local_hits: 0,
prefetch_hits: 0,
remote_hits: 0,
dups: 0,
misses: 0,
errors: 0,
total_size: 0,
total_elapsed_ms: 0,
hit_elapsed_ms: 0,
miss_elapsed_ms: 0,
hit_compile_time_ms: 0,
miss_compile_time_ms: 0,
total_key_ms: 0,
total_lookup_ms: 0,
total_restore_ms: 0,
total_store_ms: 0,
total_startup_ms: 0,
total_dep_info_ms: 0,
total_dep_info_runs: 0,
total_prediction_mismatches: 0,
total_flight_wait_ms: 0,
total_permit_wait_ms: 0,
total_unattributed_ms: 0,
store_output_blobs: 0,
store_duplicate_blobs: 0,
store_new_blobs: 0,
reflinked_bytes: 0,
hardlinked_bytes: 0,
copied_bytes: 0,
store_reflinked_bytes: 0,
store_hardlinked_bytes: 0,
store_copied_bytes: 0,
store_copy_cross_device_bytes: 0,
store_copy_permission_bytes: 0,
store_copy_ineligible_bytes: 0,
store_copy_other_bytes: 0,
restore_copy_cross_device_bytes: 0,
restore_copy_permission_bytes: 0,
restore_copy_exclusive_bytes: 0,
restore_copy_other_bytes: 0,
store_failures: 0,
};
for event in events {
match event.result {
EventResult::LocalHit => {
stats.local_hits += 1;
stats.hit_elapsed_ms += event.elapsed_ms;
stats.hit_compile_time_ms += event.compile_time_ms;
}
EventResult::PrefetchHit => {
stats.prefetch_hits += 1;
stats.hit_elapsed_ms += event.elapsed_ms;
stats.hit_compile_time_ms += event.compile_time_ms;
}
EventResult::RemoteHit => {
stats.remote_hits += 1;
stats.hit_elapsed_ms += event.elapsed_ms;
stats.hit_compile_time_ms += event.compile_time_ms;
}
EventResult::Dup => {
stats.dups += 1;
stats.miss_elapsed_ms += event.elapsed_ms;
stats.miss_compile_time_ms += if event.compile_time_ms > 0 {
event.compile_time_ms
} else {
event.elapsed_ms
};
}
EventResult::Miss => {
stats.misses += 1;
stats.miss_elapsed_ms += event.elapsed_ms;
stats.miss_compile_time_ms += if event.compile_time_ms > 0 {
event.compile_time_ms
} else {
event.elapsed_ms
};
}
EventResult::Error => stats.errors += 1,
EventResult::Passthrough | EventResult::Skipped => continue,
}
if matches!(event.result, EventResult::Miss | EventResult::Dup)
&& !event.store_error.is_empty()
{
stats.store_failures += 1;
}
stats.total_size += event.size;
stats.total_elapsed_ms += event.elapsed_ms;
stats.total_key_ms += event.key_ms;
stats.total_lookup_ms += event.lookup_ms;
stats.total_restore_ms += event.restore_ms;
stats.total_store_ms += event.store_ms;
stats.total_startup_ms += event.startup_ms;
stats.total_dep_info_ms += event.dep_info_ms;
stats.total_dep_info_runs += u64::from(event.dep_info_runs);
stats.total_prediction_mismatches += u64::from(event.prediction_mismatches);
stats.total_flight_wait_ms += event.flight_wait_ms;
stats.total_permit_wait_ms += event.permit_wait_ms;
stats.total_unattributed_ms += event.unattributed_ms();
stats.store_output_blobs += event.store_output_blobs;
stats.store_duplicate_blobs += event.store_duplicate_blobs;
stats.store_new_blobs += event.store_new_blobs;
stats.reflinked_bytes += event.reflinked_bytes;
stats.hardlinked_bytes += event.hardlinked_bytes;
stats.copied_bytes += event.copied_bytes;
stats.store_reflinked_bytes += event.store_reflinked_bytes;
stats.store_hardlinked_bytes += event.store_hardlinked_bytes;
stats.store_copied_bytes += event.store_copied_bytes;
stats.store_copy_cross_device_bytes += event.store_copy_cross_device_bytes;
stats.store_copy_permission_bytes += event.store_copy_permission_bytes;
stats.store_copy_ineligible_bytes += event.store_copy_ineligible_bytes;
stats.store_copy_other_bytes += event.store_copy_other_bytes;
stats.restore_copy_cross_device_bytes += event.restore_copy_cross_device_bytes;
stats.restore_copy_permission_bytes += event.restore_copy_permission_bytes;
stats.restore_copy_exclusive_bytes += event.restore_copy_exclusive_bytes;
stats.restore_copy_other_bytes += event.restore_copy_other_bytes;
}
stats
}
#[cfg(test)]
impl BuildEvent {
pub(crate) fn new_for_test(crate_name: &str, result: EventResult) -> BuildEvent {
BuildEvent::test_event(crate_name, result, 0, 0, 0, "")
}
#[cfg(test)]
fn test_event(
crate_name: &str,
result: EventResult,
elapsed_ms: u64,
compile_time_ms: u64,
size: u64,
cache_key: &str,
) -> BuildEvent {
BuildEvent {
ts: Utc::now(),
crate_name: crate_name.to_string(),
root: "/work/tree".to_string(),
version: "0.0.0".to_string(),
session_id: String::new(),
demands: Vec::new(),
result,
elapsed_ms,
compile_time_ms,
size,
cache_key: cache_key.to_string(),
rebuilt_package: None,
rebuilt_fingerprint: None,
rebuilt_path: None,
rebuilt_paths: Vec::new(),
schema: 8,
key_ms: 0,
key_hash_hits: 0,
key_hash_misses: 0,
key_hash_bytes: 0,
lookup_ms: 0,
restore_ms: 0,
store_ms: 0,
startup_ms: 0,
dep_info_ms: 0,
dep_info_runs: 0,
prediction_mismatches: 0,
flight_wait_ms: 0,
permit_wait_ms: 0,
store_output_blobs: 0,
store_duplicate_blobs: 0,
store_new_blobs: 0,
compiler_runs: 0,
preprocessor_runs: 0,
probe_runs: 0,
reflinked_bytes: 0,
hardlinked_bytes: 0,
copied_bytes: 0,
store_reflinked_bytes: 0,
store_hardlinked_bytes: 0,
store_copied_bytes: 0,
store_copy_cross_device_bytes: 0,
store_copy_permission_bytes: 0,
store_copy_ineligible_bytes: 0,
store_copy_other_bytes: 0,
restore_copy_cross_device_bytes: 0,
restore_copy_permission_bytes: 0,
restore_copy_exclusive_bytes: 0,
restore_copy_other_bytes: 0,
passthrough_reason: String::new(),
skip_reason: String::new(),
store_error: String::new(),
store_handed_off: false,
daemon_store_ms: 0,
lookup_rejection: String::new(),
verify_compare: String::new(),
fallback: false,
fallback_attempt: None,
exit_code: None,
key_fields: Default::default(),
key_diff: Vec::new(),
key_externs: Default::default(),
key_env_deps: None,
key_externs_recorded: false,
unit_id: String::new(),
extern_units: Default::default(),
miss_reason: MissReason::None,
object_output: String::new(),
package: String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn summary_durability_reports_sync_failure_after_writing_and_releases_lock() {
use std::io::Read;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::OpenOptionsExt;
const CHILD_LOG: &str = "KACHE_TEST_SUMMARY_SYNC_FAILURE_LOCK";
if let Some(path) = std::env::var_os(CHILD_LOG) {
let lock = open_log_lock(Path::new(&path)).unwrap();
lock.try_lock()
.expect("failed append must release its lock");
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("summaries.jsonl");
let fifo = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
let mut reader = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(&path)
.unwrap();
let event: BuildSummaryEvent =
serde_json::from_str(r#"{"ts":"2026-09-20T00:00:00Z","schema":2}"#).unwrap();
let expected = format!("{}\n", serde_json::to_string(&event).unwrap());
assert!(expected.len() <= 512, "must fit the minimum Unix PIPE_BUF");
log_summary(&path, &event).unwrap();
let mut received = String::new();
reader.read_to_string(&mut received).unwrap();
assert_eq!(received, expected);
let error = log_summary_durable(&path, &event).unwrap_err();
assert_eq!(error.to_string(), "flushing summary event to disk");
assert_eq!(
error
.downcast_ref::<std::io::Error>()
.unwrap()
.raw_os_error(),
Some(libc::EINVAL)
);
received.clear();
reader.read_to_string(&mut received).unwrap();
assert_eq!(received, expected, "sync fails after the full append");
let child = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"events::tests::summary_durability_reports_sync_failure_after_writing_and_releases_lock",
])
.env(CHILD_LOG, &path)
.output()
.unwrap();
assert!(
child.status.success(),
"lock probe failed: {}{}",
String::from_utf8_lossy(&child.stdout),
String::from_utf8_lossy(&child.stderr)
);
}
fn test_event(
crate_name: &str,
result: EventResult,
elapsed_ms: u64,
compile_time_ms: u64,
size: u64,
cache_key: &str,
) -> BuildEvent {
BuildEvent::test_event(
crate_name,
result,
elapsed_ms,
compile_time_ms,
size,
cache_key,
)
}
#[test]
fn test_log_and_read_events() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let event = BuildEvent {
ts: Utc::now(),
crate_name: "serde".to_string(),
root: "/work/tree".to_string(),
version: "1.0.210".to_string(),
session_id: String::new(),
demands: Vec::new(),
result: EventResult::LocalHit,
elapsed_ms: 2,
compile_time_ms: 250,
size: 3145728,
cache_key: "abc123".to_string(),
rebuilt_package: None,
rebuilt_fingerprint: None,
rebuilt_path: None,
rebuilt_paths: Vec::new(),
schema: 8,
key_ms: 0,
key_hash_hits: 0,
key_hash_misses: 0,
key_hash_bytes: 0,
lookup_ms: 0,
restore_ms: 0,
store_ms: 0,
startup_ms: 0,
dep_info_ms: 0,
dep_info_runs: 0,
prediction_mismatches: 0,
flight_wait_ms: 0,
permit_wait_ms: 0,
store_output_blobs: 0,
store_duplicate_blobs: 0,
store_new_blobs: 0,
compiler_runs: 0,
preprocessor_runs: 0,
probe_runs: 0,
reflinked_bytes: 0,
hardlinked_bytes: 0,
copied_bytes: 0,
store_reflinked_bytes: 0,
store_hardlinked_bytes: 0,
store_copied_bytes: 0,
store_copy_cross_device_bytes: 0,
store_copy_permission_bytes: 0,
store_copy_ineligible_bytes: 0,
store_copy_other_bytes: 0,
restore_copy_cross_device_bytes: 0,
restore_copy_permission_bytes: 0,
restore_copy_exclusive_bytes: 0,
restore_copy_other_bytes: 0,
passthrough_reason: String::new(),
skip_reason: String::new(),
store_error: String::new(),
store_handed_off: false,
daemon_store_ms: 0,
lookup_rejection: String::new(),
verify_compare: String::new(),
fallback: false,
fallback_attempt: None,
exit_code: None,
key_fields: Default::default(),
key_diff: Vec::new(),
key_externs: Default::default(),
key_env_deps: None,
key_externs_recorded: false,
unit_id: String::new(),
extern_units: Default::default(),
miss_reason: MissReason::None,
object_output: String::new(),
package: String::new(),
};
log_event(&log_path, &event).unwrap();
log_event(&log_path, &event).unwrap();
let events = read_events(&log_path).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].crate_name, "serde");
assert_eq!(events[0].result, EventResult::LocalHit);
}
#[test]
fn miss_reason_classifies_each_arm_and_old_logs_omit_none() {
assert_eq!(MissReason::classify(""), MissReason::None);
assert_eq!(MissReason::classify(" "), MissReason::None);
assert_eq!(
MissReason::classify(" not-a-compile| "),
MissReason::NotACompile
);
assert_eq!(
MissReason::classify("unsupported|link mode"),
MissReason::Unsupported
);
assert_eq!(
MissReason::classify("refused: linker invocation"),
MissReason::Refused
);
assert_eq!(
MissReason::classify("refused|linker invocation"),
MissReason::Refused
);
assert_eq!(
MissReason::classify("compiler spawn failed"),
MissReason::Other
);
let none = BuildEvent::new_for_test("a", EventResult::LocalHit);
let json = serde_json::to_value(&none).unwrap();
assert!(json.get("miss_reason").is_none());
assert!(json.get("object_output").is_none());
let mut other = BuildEvent::new_for_test("a", EventResult::Passthrough);
other.miss_reason = MissReason::Other;
other.passthrough_reason = "compiler spawn failed".to_string();
other.object_output = "a.o".to_string();
let json = serde_json::to_value(&other).unwrap();
assert_eq!(json["miss_reason"], "other");
assert_eq!(json["object_output"], "a.o");
let parsed: BuildEvent = serde_json::from_value(serde_json::json!({
"ts": "2026-01-01T00:00:00Z",
"crate_name": "a",
"result": "miss",
"elapsed_ms": 1,
"size": 1
}))
.unwrap();
assert_eq!(parsed.miss_reason, MissReason::None);
assert_eq!(parsed.object_output, "");
}
#[test]
fn phase_arithmetic_attributes_overhead_and_clamps_the_remainder() {
let mut miss = BuildEvent::new_for_test("m", EventResult::Miss);
miss.elapsed_ms = 300;
miss.compile_time_ms = 100;
miss.startup_ms = 1;
miss.key_ms = 2;
miss.dep_info_ms = 2;
miss.dep_info_runs = 1;
miss.lookup_ms = 4;
miss.flight_wait_ms = 8;
miss.permit_wait_ms = 16;
miss.restore_ms = 32;
miss.store_ms = 64;
assert!(!miss.is_hit());
assert_eq!(miss.overhead_ms(), 200);
assert_eq!(miss.wait_ms(), 24);
assert_eq!(miss.attributed_ms(), 127);
assert_eq!(miss.unattributed_ms(), 73);
for result in [
EventResult::LocalHit,
EventResult::PrefetchHit,
EventResult::RemoteHit,
] {
let mut hit = miss.clone();
hit.result = result;
assert!(hit.is_hit(), "{result:?} is served from cache");
assert_eq!(
hit.overhead_ms(),
300,
"{result:?}: stored compile cost was not spent here"
);
}
let mut hit = miss.clone();
hit.result = EventResult::PrefetchHit;
assert_eq!(
hit.overhead_ms(),
300,
"a hit's compile cost was not spent here"
);
assert_eq!(hit.unattributed_ms(), 173);
let mut dup = miss.clone();
dup.result = EventResult::Dup;
assert!(!dup.is_hit());
assert_eq!(dup.overhead_ms(), 200);
let mut overlapping = miss.clone();
overlapping.elapsed_ms = 120;
assert_eq!(overlapping.overhead_ms(), 20);
assert_eq!(
overlapping.unattributed_ms(),
0,
"phases summing past the overhead clamp at zero"
);
let mut short = miss.clone();
short.elapsed_ms = 50;
assert_eq!(short.overhead_ms(), 0, "compile longer than elapsed clamps");
}
#[test]
fn compute_stats_totals_the_wrapper_phases_of_cacheable_outcomes() {
let mut hit = BuildEvent::new_for_test("h", EventResult::LocalHit);
hit.elapsed_ms = 40;
hit.startup_ms = 3;
hit.key_ms = 10;
hit.dep_info_ms = 6;
hit.dep_info_runs = 1;
hit.prediction_mismatches = 2;
hit.lookup_ms = 2;
hit.restore_ms = 5;
let mut miss = BuildEvent::new_for_test("m", EventResult::Miss);
miss.elapsed_ms = 500;
miss.compile_time_ms = 400;
miss.startup_ms = 4;
miss.key_ms = 20;
miss.dep_info_ms = 9;
miss.dep_info_runs = 1;
miss.prediction_mismatches = 3;
miss.lookup_ms = 1;
miss.flight_wait_ms = 7;
miss.permit_wait_ms = 11;
miss.store_ms = 30;
let mut passthrough = BuildEvent::new_for_test("p", EventResult::Passthrough);
passthrough.elapsed_ms = 1000;
passthrough.startup_ms = 100;
passthrough.dep_info_ms = 100;
passthrough.dep_info_runs = 100;
passthrough.prediction_mismatches = 100;
passthrough.flight_wait_ms = 100;
passthrough.permit_wait_ms = 100;
let stats = compute_stats(&[hit, miss, passthrough]);
assert_eq!(stats.total_startup_ms, 7);
assert_eq!(stats.total_dep_info_ms, 15);
assert_eq!(stats.total_dep_info_runs, 2);
assert_eq!(stats.total_prediction_mismatches, 5);
assert_eq!(stats.total_flight_wait_ms, 7);
assert_eq!(stats.total_permit_wait_ms, 11);
assert_eq!(stats.total_unattributed_ms, 47);
}
#[test]
fn phase_fields_round_trip_and_default_for_older_events() {
let mut event = BuildEvent::new_for_test("foo", EventResult::Miss);
event.startup_ms = 5;
event.dep_info_ms = 6;
event.dep_info_runs = 1;
event.flight_wait_ms = 7;
event.permit_wait_ms = 8;
let mut value = serde_json::to_value(&event).unwrap();
assert_eq!(value["startup_ms"], 5);
assert_eq!(value["dep_info_ms"], 6);
assert_eq!(value["dep_info_runs"], 1);
assert_eq!(value["flight_wait_ms"], 7);
assert_eq!(value["permit_wait_ms"], 8);
let round_trip: BuildEvent = serde_json::from_value(value.clone()).unwrap();
assert_eq!(round_trip.startup_ms, 5);
assert_eq!(round_trip.dep_info_ms, 6);
assert_eq!(round_trip.dep_info_runs, 1);
assert_eq!(round_trip.flight_wait_ms, 7);
assert_eq!(round_trip.permit_wait_ms, 8);
let object = value.as_object_mut().unwrap();
for field in [
"startup_ms",
"dep_info_ms",
"dep_info_runs",
"flight_wait_ms",
"permit_wait_ms",
] {
object.remove(field);
}
let legacy: BuildEvent = serde_json::from_value(value).unwrap();
assert_eq!(legacy.startup_ms, 0);
assert_eq!(legacy.dep_info_ms, 0);
assert_eq!(legacy.dep_info_runs, 0);
assert_eq!(legacy.flight_wait_ms, 0);
assert_eq!(legacy.permit_wait_ms, 0);
assert_eq!(legacy.wait_ms(), 0);
}
#[test]
fn skip_reason_round_trips_and_defaults_for_older_events() {
let mut event = BuildEvent::new_for_test("lib", EventResult::Skipped);
event.skip_reason = "peer-already-committed".into();
let mut value = serde_json::to_value(&event).unwrap();
let current: BuildEvent = serde_json::from_value(value.clone()).unwrap();
assert_eq!(current.skip_reason, "peer-already-committed");
value.as_object_mut().unwrap().remove("skip_reason");
let legacy: BuildEvent = serde_json::from_value(value).unwrap();
assert!(legacy.skip_reason.is_empty());
assert!(
serde_json::to_value(legacy)
.unwrap()
.get("skip_reason")
.is_none()
);
}
#[test]
fn lookup_rejection_round_trips_and_defaults_for_older_events() {
let mut event = BuildEvent::new_for_test("foo.c", EventResult::Miss);
event.lookup_rejection =
"matching entry lacks dep-info required by this invocation".to_string();
let mut value = serde_json::to_value(&event).unwrap();
assert_eq!(
value["lookup_rejection"],
"matching entry lacks dep-info required by this invocation"
);
let round_trip: BuildEvent = serde_json::from_value(value.clone()).unwrap();
assert_eq!(round_trip.lookup_rejection, event.lookup_rejection);
value.as_object_mut().unwrap().remove("lookup_rejection");
let legacy: BuildEvent = serde_json::from_value(value).unwrap();
assert!(legacy.lookup_rejection.is_empty());
}
#[test]
fn verify_compare_round_trips_and_defaults_for_older_events() {
let mut event = BuildEvent::new_for_test("foo", EventResult::LocalHit);
event.verify_compare = "content: libfoo.rlib (byte mismatch)".to_string();
let mut value = serde_json::to_value(&event).unwrap();
assert_eq!(
value["verify_compare"],
"content: libfoo.rlib (byte mismatch)"
);
let round_trip: BuildEvent = serde_json::from_value(value.clone()).unwrap();
assert_eq!(round_trip.verify_compare, event.verify_compare);
value.as_object_mut().unwrap().remove("verify_compare");
let legacy: BuildEvent = serde_json::from_value(value).unwrap();
assert!(legacy.verify_compare.is_empty());
}
fn test_heartbeat(crate_name: &str, elapsed_s: u64) -> HeartbeatEvent {
HeartbeatEvent {
event: HEARTBEAT_EVENT_TAG.to_string(),
ts: Utc::now(),
crate_name: crate_name.to_string(),
root: "/work/tree".to_string(),
pid: 4242,
elapsed_s,
typical_s: Some(471),
eta_s: Some(211),
schema: HEARTBEAT_SCHEMA,
}
}
#[test]
fn heartbeat_lines_are_invisible_to_build_event_readers() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
log_event(
&log_path,
&test_event("gkrust", EventResult::Miss, 471_000, 471_000, 1024, "k1"),
)
.unwrap();
log_heartbeat(&log_path, &test_heartbeat("gkrust", 260)).unwrap();
log_event(
&log_path,
&test_event("serde", EventResult::LocalHit, 2, 250, 64, "k2"),
)
.unwrap();
let builds = read_events(&log_path).unwrap();
assert_eq!(
builds.len(),
2,
"BuildEvent readers must skip the heartbeat line"
);
let hb_line = serde_json::to_string(&test_heartbeat("gkrust", 260)).unwrap();
assert!(
serde_json::from_str::<BuildEvent>(&hb_line).is_err(),
"heartbeat must not deserialize as BuildEvent"
);
let build_line =
serde_json::to_string(&test_event("gkrust", EventResult::Miss, 1, 1, 1, "k")).unwrap();
assert!(
serde_json::from_str::<HeartbeatEvent>(&build_line).is_err(),
"BuildEvent must not deserialize as heartbeat"
);
}
#[test]
fn pre_609_events_deserialize_without_extern_digests() {
let line = r#"{"ts":"2026-07-01T00:00:00Z","crate_name":"serde",
"result":"miss","elapsed_ms":1,"size":2,"cache_key":"k","schema":11,
"key_fields":{"externs":"aaaa"}}"#;
let event: BuildEvent = serde_json::from_str(line).expect("schema 11 must still parse");
assert_eq!(event.schema, 11);
assert!(event.key_externs.is_empty());
assert_eq!(
event.key_fields.get("externs").map(String::as_str),
Some("aaaa")
);
}
#[test]
fn empty_extern_digests_are_not_serialized() {
let event = test_event("serde", EventResult::Miss, 1, 1, 1, "k");
let line = serde_json::to_string(&event).unwrap();
assert!(
!line.contains("key_externs"),
"empty key_externs must not be written: {line}"
);
}
#[test]
fn tailer_poll_records_yields_heartbeats_and_builds() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let mut tailer = EventTailer::from_start(log_path.clone());
log_heartbeat(&log_path, &test_heartbeat("gkrust", 30)).unwrap();
log_event(
&log_path,
&test_event("gkrust", EventResult::Miss, 60_000, 60_000, 1024, "k1"),
)
.unwrap();
let records = tailer.poll_records().unwrap();
assert_eq!(records.len(), 2);
assert!(matches!(&records[0], EventRecord::Heartbeat(h) if h.elapsed_s == 30));
assert!(matches!(&records[1], EventRecord::Build(e) if e.result == EventResult::Miss));
log_heartbeat(&log_path, &test_heartbeat("gkrust", 60)).unwrap();
assert!(
tailer.poll().unwrap().is_empty(),
"builds-only poll must skip a heartbeat-only append"
);
}
#[test]
fn typical_compile_ms_is_a_robust_per_crate_median() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
assert_eq!(
typical_compile_ms(&log_path, "gkrust", "/work/tree"),
None,
"no history → no estimate"
);
for ms in [100_000u64, 101_000, 99_000, 100_500, 100_200] {
log_event(
&log_path,
&test_event("gkrust", EventResult::Miss, ms, ms, 1024, "k"),
)
.unwrap();
}
log_event(
&log_path,
&test_event("gkrust", EventResult::Miss, 900_000, 900_000, 1024, "k"),
)
.unwrap();
log_event(
&log_path,
&test_event("serde", EventResult::Miss, 2_000, 2_000, 64, "k"),
)
.unwrap();
log_event(
&log_path,
&test_event("gkrust", EventResult::LocalHit, 5, 0, 1024, "k"),
)
.unwrap();
let typical = typical_compile_ms(&log_path, "gkrust", "/work/tree").unwrap();
assert!(
(99_000..=101_000).contains(&typical),
"median must sit in the cluster and shed the 900s outlier, got {typical}"
);
assert_eq!(
typical_compile_ms(&log_path, "serde", "/work/tree"),
Some(2_000)
);
}
#[test]
fn test_event_tailer() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let mut tailer = EventTailer::from_start(log_path.clone());
assert_eq!(tailer.poll().unwrap().len(), 0);
let event = test_event("tokio", EventResult::Miss, 5000, 4800, 8388608, "def456");
log_event(&log_path, &event).unwrap();
let new_events = tailer.poll().unwrap();
assert_eq!(new_events.len(), 1);
assert_eq!(tailer.poll().unwrap().len(), 0);
log_event(&log_path, &event).unwrap();
let new_events = tailer.poll().unwrap();
assert_eq!(new_events.len(), 1);
}
#[test]
fn test_event_rotation() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
for i in 0..100 {
let event = test_event(
&format!("crate_{i}"),
EventResult::LocalHit,
1,
25,
1024,
&format!("key_{i}"),
);
log_event(&log_path, &event).unwrap();
}
rotate_if_needed(&log_path, 10000, 10).unwrap();
let events = read_events(&log_path).unwrap();
assert_eq!(events.len(), 10);
assert_eq!(events[0].crate_name, "crate_90");
rotate_if_needed(&log_path, 200, 10).unwrap();
let events = read_events(&log_path).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].crate_name, "crate_99");
}
#[test]
fn test_event_result_display() {
assert_eq!(EventResult::LocalHit.to_string(), "local_hit");
assert_eq!(EventResult::PrefetchHit.to_string(), "prefetch_hit");
assert_eq!(EventResult::RemoteHit.to_string(), "remote_hit");
assert_eq!(EventResult::Dup.to_string(), "dup");
assert_eq!(EventResult::Miss.to_string(), "miss");
assert_eq!(EventResult::Error.to_string(), "error");
assert_eq!(EventResult::Passthrough.to_string(), "passthrough");
assert_eq!(EventResult::Skipped.to_string(), "skipped");
}
#[test]
fn test_read_events_nonexistent_file() {
let events = read_events(Path::new("/nonexistent/events.jsonl")).unwrap();
assert!(events.is_empty());
}
#[test]
fn test_read_events_with_invalid_lines() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let event = test_event("valid", EventResult::Miss, 100, 90, 1024, "key");
log_event(&log_path, &event).unwrap();
use std::io::Write;
let mut f = OpenOptions::new().append(true).open(&log_path).unwrap();
writeln!(f, "this is not json").unwrap();
writeln!(f, "{{}}").unwrap();
let events = read_events(&log_path).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].crate_name, "valid");
}
#[test]
fn test_read_events_since() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let mut old_event = test_event("old", EventResult::Miss, 100, 80, 1024, "key1");
old_event.ts = Utc::now() - chrono::Duration::hours(2);
let new_event = test_event("new", EventResult::LocalHit, 10, 250, 512, "key2");
log_event(&log_path, &old_event).unwrap();
log_event(&log_path, &new_event).unwrap();
let since = Utc::now() - chrono::Duration::hours(1);
let events = read_events_since(&log_path, since).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].crate_name, "new");
}
#[test]
fn test_compute_stats() {
let events = vec![
test_event("a", EventResult::LocalHit, 10, 300, 100, "k1"),
test_event("b", EventResult::PrefetchHit, 5, 250, 150, "k1b"),
test_event("c", EventResult::RemoteHit, 50, 900, 200, "k2"),
test_event("dup", EventResult::Dup, 700, 650, 400, "kdup"),
test_event("d", EventResult::Miss, 1000, 950, 500, "k3"),
test_event("e", EventResult::Error, 5, 0, 0, "k4"),
test_event("f", EventResult::Skipped, 0, 0, 0, "k5"),
test_event("g", EventResult::Passthrough, 25, 0, 0, ""),
];
let stats = compute_stats(&events);
assert_eq!(stats.local_hits, 1);
assert_eq!(stats.prefetch_hits, 1);
assert_eq!(stats.remote_hits, 1);
assert_eq!(stats.dups, 1);
assert_eq!(stats.misses, 1);
assert_eq!(stats.errors, 1);
assert_eq!(stats.total_size, 1350);
assert_eq!(stats.total_elapsed_ms, 1770);
assert_eq!(stats.hit_elapsed_ms, 65);
assert_eq!(stats.miss_elapsed_ms, 1700);
assert_eq!(stats.hit_compile_time_ms, 1450);
assert_eq!(stats.miss_compile_time_ms, 1600);
}
#[test]
fn test_compute_stats_empty() {
let stats = compute_stats(&[]);
assert_eq!(stats.local_hits, 0);
}
#[test]
fn compute_stats_aggregates_copy_reasons() {
let mut event = BuildEvent::new_for_test("a", EventResult::Miss);
event.store_copy_cross_device_bytes = 1;
event.store_copy_permission_bytes = 2;
event.store_copy_ineligible_bytes = 4;
event.store_copy_other_bytes = 8;
event.restore_copy_cross_device_bytes = 16;
event.restore_copy_permission_bytes = 32;
event.restore_copy_exclusive_bytes = 64;
event.restore_copy_other_bytes = 128;
let stats = compute_stats(&[event]);
assert_eq!(stats.store_copy_cross_device_bytes, 1);
assert_eq!(stats.store_copy_permission_bytes, 2);
assert_eq!(stats.store_copy_ineligible_bytes, 4);
assert_eq!(stats.store_copy_other_bytes, 8);
assert_eq!(stats.restore_copy_cross_device_bytes, 16);
assert_eq!(stats.restore_copy_permission_bytes, 32);
assert_eq!(stats.restore_copy_exclusive_bytes, 64);
assert_eq!(stats.restore_copy_other_bytes, 128);
}
#[test]
fn test_clear_events() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let event = test_event("test", EventResult::Miss, 100, 80, 1024, "key");
log_event(&log_path, &event).unwrap();
assert!(!read_events(&log_path).unwrap().is_empty());
clear_events(&log_path).unwrap();
assert!(read_events(&log_path).unwrap().is_empty());
}
#[test]
fn test_clear_events_nonexistent() {
clear_events(Path::new("/nonexistent/events.jsonl")).unwrap();
}
#[test]
fn test_rotate_skips_small_file() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let event = test_event("test", EventResult::Miss, 100, 80, 1024, "key");
log_event(&log_path, &event).unwrap();
let size_before = fs::metadata(&log_path).unwrap().len();
rotate_if_needed(&log_path, 1_000_000, 10).unwrap();
let size_after = fs::metadata(&log_path).unwrap().len();
assert_eq!(size_before, size_after);
}
fn session_line(session: &str, bytes: usize) -> String {
let head = format!("{{\"session_id\":\"{session}\",\"pad\":\"");
let tail = "\"}\n";
let pad = bytes.saturating_sub(head.len() + tail.len());
format!("{head}{}{tail}", "x".repeat(pad))
}
#[test]
fn rotation_keeps_the_whole_build_in_progress() {
let owned: Vec<String> = (0..5)
.map(|_| session_line("old", 50))
.chain((0..8).map(|_| session_line("now", 50)))
.collect();
let lines: Vec<&str> = owned.iter().map(String::as_str).collect();
assert_eq!(
retained_suffix(&lines, 3, 10_000),
RetainedSuffix {
start: 5,
cut_session: None
}
);
assert_eq!(retained_suffix(&lines, 10, 10_000).start, 3);
}
#[test]
fn a_build_larger_than_the_cap_is_cut_and_named() {
let owned: Vec<String> = (0..2)
.map(|_| session_line("old", 100))
.chain((0..6).map(|_| session_line("now", 100)))
.collect();
let lines: Vec<&str> = owned.iter().map(String::as_str).collect();
assert_eq!(
retained_suffix(&lines, 1, 800),
RetainedSuffix {
start: 4,
cut_session: Some("now".to_string())
}
);
assert_eq!(
retained_suffix(&lines, 1, 1200),
RetainedSuffix {
start: 2,
cut_session: None
}
);
assert_eq!(retained_suffix(&lines, 1, 20).start, 7);
}
#[test]
fn lines_without_a_session_rotate_by_count() {
let lines = ["a\n", "b\n", "c\n", "d\n"];
assert_eq!(
retained_suffix(&lines, 2, 1_000),
RetainedSuffix {
start: 2,
cut_session: None
}
);
let unnamed = session_line("", 20);
assert_eq!(line_session(&unnamed), None);
assert_eq!(
line_session(&session_line("s1", 40)),
Some("s1".to_string())
);
}
#[test]
fn choosing_what_to_keep_stays_linear_in_the_log() {
let owned: Vec<String> = (0..50_000)
.map(|i| session_line(if i < 25_000 { "old" } else { "now" }, 60))
.collect();
let lines: Vec<&str> = owned.iter().map(String::as_str).collect();
let started = std::time::Instant::now();
let kept = retained_suffix(&lines, 10, 60 * 1_000);
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"took {:?}",
started.elapsed()
);
assert_eq!(kept.start, 50_000 - 500);
assert_eq!(kept.cut_session.as_deref(), Some("now"));
}
#[test]
fn rotation_keeps_half_the_size_that_triggers_it() {
assert_eq!(retention_cap(64), 32);
assert_eq!(retention_cap(65), 32);
}
#[test]
fn a_rotated_log_still_holds_every_event_of_the_build_in_progress() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let mut event = test_event("old", EventResult::Miss, 100, 80, 1024, "key");
event.session_id = "earlier".to_string();
for _ in 0..60 {
log_event(&log_path, &event).unwrap();
}
event.session_id = "current".to_string();
for _ in 0..30 {
log_event(&log_path, &event).unwrap();
}
let line_len = fs::read_to_string(&log_path)
.unwrap()
.lines()
.next()
.unwrap()
.len() as u64
+ 1;
rotate_if_needed(&log_path, line_len * 64, 5).unwrap();
let kept = read_events(&log_path).unwrap();
assert_eq!(
kept.iter().filter(|e| e.session_id == "current").count(),
30
);
assert!(kept.iter().all(|e| e.session_id == "current"));
assert_eq!(session_cut_by_rotation(&log_path), None);
for _ in 0..40 {
log_event(&log_path, &event).unwrap();
}
rotate_if_needed(&log_path, line_len * 20, 5).unwrap();
assert_eq!(
session_cut_by_rotation(&log_path).as_deref(),
Some("current")
);
}
#[test]
fn test_rotate_nonexistent() {
rotate_if_needed(Path::new("/nonexistent/events.jsonl"), 100, 10).unwrap();
}
#[test]
fn test_rotate_transfers_trims_to_keep_lines_when_oversized() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("transfers.jsonl");
let body: String = (0..100).map(|i| format!("line {i}\n")).collect();
fs::write(&log_path, body).unwrap();
rotate_transfers_if_needed(&log_path, 100, 10).unwrap();
let kept = fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = kept.lines().collect();
assert_eq!(lines.len(), 10, "should keep the last 10 lines");
assert_eq!(lines[0], "line 90", "keeps the tail");
assert_eq!(lines[9], "line 99");
}
#[test]
fn rotation_size_cap_trims_to_exactly_what_fits() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let body: String = (0..100).map(|i| format!("line {i:03}\n")).collect();
fs::write(&log_path, body).unwrap();
rotate_if_needed(&log_path, 180, 50).unwrap();
let kept = fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = kept.lines().collect();
assert_eq!(lines.len(), 20, "exactly the lines that fit are retained");
assert_eq!(lines[0], "line 080");
assert_eq!(lines[19], "line 099");
}
#[test]
fn test_rotate_transfers_skips_small_and_nonexistent() {
rotate_transfers_if_needed(Path::new("/nonexistent/transfers.jsonl"), 100, 10).unwrap();
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("transfers.jsonl");
fs::write(&log_path, "a\nb\n").unwrap();
rotate_transfers_if_needed(&log_path, 1_000_000, 1).unwrap();
assert_eq!(fs::read_to_string(&log_path).unwrap(), "a\nb\n");
}
#[test]
fn test_event_tailer_handles_truncation() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let event = test_event("test", EventResult::Miss, 100, 80, 1024, "key");
for _ in 0..10 {
log_event(&log_path, &event).unwrap();
}
let mut tailer = EventTailer::from_start(log_path.clone());
assert_eq!(tailer.poll().unwrap().len(), 10);
fs::write(&log_path, "").unwrap();
log_event(&log_path, &event).unwrap();
let events = tailer.poll().unwrap();
assert_eq!(events.len(), 1);
}
#[test]
fn test_read_transfers_missing_file_is_empty() {
let got = read_transfers(Path::new("/nonexistent/transfers.jsonl")).unwrap();
assert!(got.is_empty());
}
#[test]
fn test_read_transfers_skips_blank_and_invalid_lines() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("transfers.jsonl");
fs::write(&log, "\n \nnot json at all\n{ partial: \n").unwrap();
let got = read_transfers(&log).unwrap();
assert!(got.is_empty(), "invalid transfer lines are skipped");
}
#[test]
fn test_event_tailer_handles_rename_rotation() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let event = test_event("test", EventResult::Miss, 100, 80, 1024, "key");
for _ in 0..5 {
log_event(&log_path, &event).unwrap();
}
let mut tailer = EventTailer::from_start(log_path.clone());
assert_eq!(tailer.poll().unwrap().len(), 5);
rotate_if_needed(&log_path, 1500, 2).unwrap();
for _ in 0..10 {
log_event(&log_path, &event).unwrap();
}
let events = tailer.poll().unwrap();
assert_eq!(events.len(), 10);
}
#[test]
fn lagging_tailer_gets_retained_undelivered_events_exactly_once() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
for i in 0..10 {
let event = test_event(&format!("c{i}"), EventResult::Miss, 100, 80, 1024, "key");
log_event(&log_path, &event).unwrap();
}
let mut tailer = EventTailer::from_start(log_path.clone());
assert_eq!(tailer.poll().unwrap().len(), 10);
for i in 10..12 {
let event = test_event(&format!("c{i}"), EventResult::Miss, 100, 80, 1024, "key");
log_event(&log_path, &event).unwrap();
}
let max_size = fs::metadata(&log_path).unwrap().len() / 2;
rotate_if_needed(&log_path, max_size, 4).unwrap();
let names: Vec<String> = tailer
.poll()
.unwrap()
.into_iter()
.map(|e| e.crate_name)
.collect();
assert_eq!(
names,
vec!["c10".to_string(), "c11".to_string()],
"undelivered retained events arrive exactly once; delivered ones are not repeated"
);
assert!(
tailer.poll().unwrap().is_empty(),
"cursor lands exactly at the retained end — nothing re-delivered"
);
}
#[test]
fn abandoned_torn_line_does_not_poison_the_next_event() {
use std::io::Write as _;
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let first = test_event("first", EventResult::Miss, 100, 80, 1024, "key");
log_event(&log_path, &first).unwrap();
{
let mut file = OpenOptions::new().append(true).open(&log_path).unwrap();
file.write_all(br#"{"crate_name":"abandoned"#).unwrap();
}
let next = test_event("next", EventResult::Miss, 100, 80, 1024, "key");
log_event(&log_path, &next).unwrap();
let mut tailer = EventTailer::from_start(log_path);
let names: Vec<String> = tailer
.poll()
.unwrap()
.into_iter()
.map(|e| e.crate_name)
.collect();
assert_eq!(
names,
vec!["first".to_string(), "next".to_string()],
"the abandoned fragment is skipped alone; the valid event survives"
);
assert!(tailer.poll().unwrap().is_empty());
}
#[test]
fn torn_final_line_is_not_consumed_until_terminated() {
use std::io::Write as _;
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let whole = test_event("whole", EventResult::Miss, 100, 80, 1024, "key");
log_event(&log_path, &whole).unwrap();
let torn = test_event("torn", EventResult::Miss, 100, 80, 1024, "key");
let torn_line = serde_json::to_string(&torn).unwrap();
let (head, tail) = torn_line.split_at(torn_line.len() / 2);
{
let mut f = OpenOptions::new().append(true).open(&log_path).unwrap();
f.write_all(head.as_bytes()).unwrap();
}
let mut tailer = EventTailer::from_start(log_path.clone());
let first = tailer.poll().unwrap();
assert_eq!(
first.len(),
1,
"only the newline-terminated line is consumed"
);
assert_eq!(first[0].crate_name, "whole");
{
let mut f = OpenOptions::new().append(true).open(&log_path).unwrap();
f.write_all(tail.as_bytes()).unwrap();
f.write_all(b"\n").unwrap();
}
let second = tailer.poll().unwrap();
assert_eq!(second.len(), 1, "completed line is delivered exactly once");
assert_eq!(second[0].crate_name, "torn");
assert!(tailer.poll().unwrap().is_empty());
}
#[test]
fn test_concurrent_log_append_and_rotate() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("events.jsonl");
let first_event = test_event("0", EventResult::LocalHit, 10, 10, 100, "key0");
log_event(&log_path, &first_event).unwrap();
let running = Arc::new(AtomicBool::new(true));
let appender_done = Arc::new(AtomicBool::new(false));
let log_path_clone = log_path.clone();
let running_clone = running.clone();
let rotator = std::thread::spawn(move || {
while running_clone.load(Ordering::Relaxed) {
let _ = rotate_if_needed(&log_path_clone, 65536, 100);
std::thread::sleep(std::time::Duration::from_millis(20));
}
});
let log_path_clone2 = log_path.clone();
let appender_done_clone = appender_done.clone();
let appender = std::thread::spawn(move || {
for i in 1..=200 {
let event = test_event(&i.to_string(), EventResult::LocalHit, 10, 10, 100, "key");
log_event(&log_path_clone2, &event).expect("log_event failed");
std::thread::sleep(std::time::Duration::from_millis(3));
}
appender_done_clone.store(true, Ordering::Relaxed);
});
let log_path_clone3 = log_path.clone();
let running_clone3 = running.clone();
let appender_done_clone2 = appender_done.clone();
let tailer_thread = std::thread::spawn(move || {
let mut tailer = EventTailer::from_start(log_path_clone3);
let mut polled_ids = std::collections::HashSet::new();
while running_clone3.load(Ordering::Relaxed)
|| !appender_done_clone2.load(Ordering::Relaxed)
{
if let Ok(events) = tailer.poll() {
for event in events {
if let Ok(id) = event.crate_name.parse::<usize>() {
polled_ids.insert(id);
}
}
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
if let Ok(events) = tailer.poll() {
for event in events {
if let Ok(id) = event.crate_name.parse::<usize>() {
polled_ids.insert(id);
}
}
}
polled_ids
});
appender.join().unwrap();
running.store(false, Ordering::Relaxed);
rotator.join().unwrap();
let polled_ids = tailer_thread.join().unwrap();
let content = fs::read_to_string(&log_path).unwrap();
assert!(content.ends_with("\n"), "log must end with a newline");
assert!(
!content.ends_with("\n\n"),
"log must not end with double newlines"
);
let mut ids = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let event: BuildEvent =
serde_json::from_str(line).expect("each line must be valid JSON");
let id: usize = event
.crate_name
.parse()
.expect("crate name must be parsed as id");
ids.push(id);
}
assert!(!ids.is_empty(), "at least some events must survive");
assert!(ids.len() > 1, "multiple events must survive");
let start = ids[0];
let end = ids[ids.len() - 1];
let expected: Vec<usize> = (start..=end).collect();
assert_eq!(
ids, expected,
"there must be no missing events or gaps in the surviving log: got {:?}",
ids
);
let mut actual_polled: Vec<usize> = polled_ids.into_iter().collect();
actual_polled.sort();
assert!(
!actual_polled.is_empty(),
"EventTailer must have observed some events"
);
let start_id = actual_polled[0];
let end_id = *actual_polled.last().unwrap();
let expected_sequence: Vec<usize> = (start_id..=end_id).collect();
assert_eq!(
actual_polled, expected_sequence,
"EventTailer must not miss any events in its observed sequence: got {:?}",
actual_polled
);
assert_eq!(
end_id,
200,
"EventTailer stopped following the log across rotation: never observed the \
final event (saw {} ids, max {:?})",
actual_polled.len(),
actual_polled.last()
);
}
}
#[cfg(test)]
mod cleanup_and_rebuild_tests {
use super::*;
#[test]
fn cleanup_round_trip_does_not_count_as_a_build() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("events.jsonl");
let cleanup = CleanupEvent::new(
"/work/target".into(),
"/work".into(),
vec!["/work/target/debug/deps/stale.rlib".into()],
2,
4097,
"unreachable".into(),
);
log_cleanup(&path, &cleanup).unwrap();
let raw = fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(raw.trim()).unwrap();
assert_eq!(json["event"], "target-cleanup");
assert_eq!(json["schema"], 1);
assert_eq!(json["target"], "/work/target");
assert_eq!(json["root"], "/work");
assert_eq!(json["removed_units"], 2);
assert_eq!(json["removed_bytes"], 4097);
assert_eq!(json["reason"], "unreachable");
assert_eq!(
json["removed_paths"][0],
"/work/target/debug/deps/stale.rlib"
);
assert_eq!(
serde_json::from_str::<CleanupEvent>(raw.trim()).unwrap(),
cleanup
);
assert!(serde_json::from_str::<BuildEvent>(raw.trim()).is_err());
assert!(parse_event_line(raw.trim()).is_none());
assert!(read_events(&path).unwrap().is_empty());
let build = BuildEvent::new_for_test("current", EventResult::LocalHit);
log_event(&path, &build).unwrap();
assert_eq!(read_events(&path).unwrap(), vec![build]);
assert_eq!(fs::read_to_string(path).unwrap().lines().count(), 2);
}
#[test]
fn legacy_build_event_omits_rebuilt_claims_and_new_fields_round_trip() {
let event = BuildEvent::new_for_test("example", EventResult::Miss);
let legacy = serde_json::to_value(&event).unwrap();
for field in [
"rebuilt_package",
"rebuilt_fingerprint",
"rebuilt_path",
"rebuilt_paths",
] {
assert!(legacy.get(field).is_none());
}
let restored: BuildEvent = serde_json::from_value(legacy).unwrap();
assert!(restored.rebuilt_package.is_none());
assert!(restored.rebuilt_fingerprint.is_none());
assert!(restored.rebuilt_path.is_none());
assert!(restored.rebuilt_paths.is_empty());
let mut rebuilt = event;
rebuilt.schema = 26;
rebuilt.rebuilt_package = Some("package-name".into());
rebuilt.rebuilt_fingerprint = Some("input-key".into());
rebuilt.rebuilt_path = Some("/target/example".into());
rebuilt.rebuilt_paths = vec!["/target/example".into(), "/target/example.d".into()];
let raw = serde_json::to_string(&rebuilt).unwrap();
assert_eq!(serde_json::from_str::<BuildEvent>(&raw).unwrap(), rebuilt);
}
}