use std::collections::VecDeque;
use std::fs::File;
use std::io::{self, BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use flate2::read::GzDecoder;
use serde::Deserialize;
use zstd::stream::read::Decoder as ZstdDecoder;
use crate::snapshot::Snapshot;
pub const FRAME_CACHE_MAX: usize = 1024;
pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
const ZSTD_WINDOW_LOG_MAX: u32 = 27;
pub const MAX_HEADER_HOSTS: usize = 1024;
pub const SCAN_BUDGET_PER_TICK: usize = 1024;
#[derive(Debug, thiserror::Error)]
pub enum ReplayError {
#[error("replay: cannot open `{path}`: {source}")]
Open {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("replay: read error: {0}")]
Io(#[from] io::Error),
#[error("replay: unsupported schema version {found}, this all-smi supports schema 1")]
UnsupportedSchema { found: u32 },
#[error("replay: no usable frames in `{0}`")]
Empty(PathBuf),
#[error("replay: invalid timecode `{0}` — use HH:MM:SS, MM:SS, or seconds")]
InvalidTimecode(String),
#[error("replay: frame sequence overflow at line {line}")]
SeqOverflow { line: u64 },
}
#[derive(Clone, Debug, Deserialize)]
pub struct ReplayHeader {
#[serde(default)]
#[allow(dead_code)]
pub interval_ms: Option<u64>,
#[serde(default)]
pub hosts: Vec<String>,
#[serde(default)]
#[allow(dead_code)]
pub all_smi_version: Option<String>,
}
#[derive(Deserialize)]
struct RawFrame {
schema: u32,
#[serde(default)]
header: bool,
#[serde(default)]
index: bool,
#[serde(default)]
interval_ms: Option<u64>,
#[serde(default)]
hosts: Vec<String>,
#[serde(default)]
all_smi_version: Option<String>,
}
#[derive(Clone)]
pub struct ReplayFrame {
pub seq: u64,
pub snapshot: Snapshot,
pub timestamp: DateTime<Utc>,
}
pub struct Replayer {
path: PathBuf,
header: Option<ReplayHeader>,
reader: BufReader<Box<dyn Read + Send>>,
cache: VecDeque<ReplayFrame>,
cursor: Option<usize>,
next_disk_seq: u64,
eof: bool,
index_points: Vec<IndexPoint>,
line_number: u64,
}
#[derive(Clone, Copy, Debug)]
struct IndexPoint {
seq: u64,
line: u64,
}
impl Replayer {
pub fn open(path: &Path) -> Result<Self, ReplayError> {
let reader = open_reader(path).map_err(|e| ReplayError::Open {
path: path.to_path_buf(),
source: e,
})?;
let mut this = Self {
path: path.to_path_buf(),
header: None,
reader,
cache: VecDeque::new(),
cursor: None,
next_disk_seq: 0,
eof: false,
index_points: Vec::new(),
line_number: 0,
};
this.prime()?;
Ok(this)
}
fn prime(&mut self) -> Result<(), ReplayError> {
loop {
let line = match self.read_line()? {
Some(s) => s,
None => return Ok(()),
};
match self.classify_line(&line)? {
ClassifiedLine::Header(h) => {
self.header = Some(h);
}
ClassifiedLine::Index(seq) => {
self.record_index_point(seq);
}
ClassifiedLine::Data(snap) => {
let frame = ReplayFrame {
seq: self.next_disk_seq,
timestamp: parse_ts(&snap.timestamp),
snapshot: snap,
};
self.next_disk_seq =
self.next_disk_seq
.checked_add(1)
.ok_or(ReplayError::SeqOverflow {
line: self.line_number,
})?;
self.cache.push_back(frame);
self.cursor = Some(0);
return Ok(());
}
ClassifiedLine::Ignore => {}
}
}
}
fn read_line(&mut self) -> io::Result<Option<String>> {
if self.eof {
return Ok(None);
}
let mut buf = Vec::new();
let mut oversized = false;
loop {
let remaining = MAX_LINE_BYTES.saturating_sub(buf.len());
if remaining == 0 {
oversized = true;
let mut discard = Vec::new();
loop {
discard.clear();
let n = self.reader.read_until(b'\n', &mut discard)?;
if n == 0 || discard.ends_with(b"\n") {
break;
}
}
break;
}
let chunk = {
let mut scratch = Vec::new();
let n = (&mut self.reader)
.take(remaining as u64)
.read_until(b'\n', &mut scratch)?;
if n == 0 {
break;
}
scratch
};
let ends_with_newline = chunk.ends_with(b"\n");
buf.extend_from_slice(&chunk);
if ends_with_newline {
break;
}
if buf.len() >= MAX_LINE_BYTES {
continue;
}
if chunk.is_empty() {
break;
}
}
if buf.is_empty() && !oversized {
self.eof = true;
return Ok(None);
}
self.line_number += 1;
if oversized {
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
cap = MAX_LINE_BYTES,
"replay: oversized NDJSON line, skipping"
);
return Ok(Some(String::new()));
}
if buf.ends_with(b"\n") {
buf.pop();
if buf.ends_with(b"\r") {
buf.pop();
}
}
let s = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
error = %e,
"replay: non-UTF-8 NDJSON line, skipping"
);
String::new()
}
};
Ok(Some(s))
}
fn classify_line(&self, line: &str) -> Result<ClassifiedLine, ReplayError> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(ClassifiedLine::Ignore);
}
let raw: RawFrame = match serde_json::from_str(trimmed) {
Ok(r) => r,
Err(e) => {
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
error = %e,
"replay: ignoring malformed NDJSON line"
);
return Ok(ClassifiedLine::Ignore);
}
};
if raw.schema != 1 {
return Err(ReplayError::UnsupportedSchema { found: raw.schema });
}
if raw.header {
let mut hosts = raw.hosts;
if hosts.len() > MAX_HEADER_HOSTS {
tracing::warn!(
path = %self.path.display(),
requested = hosts.len(),
cap = MAX_HEADER_HOSTS,
"replay: header hosts list exceeds cap, truncating"
);
hosts.truncate(MAX_HEADER_HOSTS);
}
return Ok(ClassifiedLine::Header(ReplayHeader {
interval_ms: raw.interval_ms,
hosts,
all_smi_version: raw.all_smi_version,
}));
}
if raw.index {
#[derive(Deserialize)]
struct IndexFrame {
seq: u64,
}
let idx: IndexFrame = match serde_json::from_str::<IndexFrame>(trimmed) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
error = %e,
"replay: ignoring malformed index frame"
);
return Ok(ClassifiedLine::Ignore);
}
};
if idx.seq == 0 || idx.seq >= u64::MAX / 2 {
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
seq = idx.seq,
"replay: ignoring index frame with implausible seq"
);
return Ok(ClassifiedLine::Ignore);
}
return Ok(ClassifiedLine::Index(idx.seq));
}
let snap: Snapshot = match serde_json::from_str(trimmed) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
error = %e,
"replay: ignoring malformed data frame"
);
return Ok(ClassifiedLine::Ignore);
}
};
Ok(ClassifiedLine::Data(snap))
}
pub fn header(&self) -> Option<&ReplayHeader> {
self.header.as_ref()
}
pub fn current(&self) -> Option<&ReplayFrame> {
self.cursor.and_then(|c| self.cache.get(c))
}
pub fn next(&mut self) -> Result<Option<&ReplayFrame>, ReplayError> {
match self.cursor {
Some(c) if c + 1 < self.cache.len() => {
self.cursor = Some(c + 1);
}
_ => {
if !self.read_next_data_frame()? {
return Ok(self.current());
}
self.cursor = Some(self.cache.len() - 1);
}
}
Ok(self.current())
}
pub fn prev(&mut self) -> Result<Option<&ReplayFrame>, ReplayError> {
match self.cursor {
Some(c) if c > 0 => {
self.cursor = Some(c - 1);
Ok(self.current())
}
Some(_) | None => {
let Some(current_seq) = self.current().map(|f| f.seq) else {
return Ok(None);
};
if current_seq == 0 {
return Ok(self.current());
}
self.rewind_and_seek_to(current_seq - 1)?;
Ok(self.current())
}
}
}
pub fn seek(
&mut self,
offset_from_start: Duration,
) -> Result<Option<&ReplayFrame>, ReplayError> {
let first_ts = self.first_frame_timestamp()?;
let target = first_ts + chrono::Duration::from_std(offset_from_start).unwrap_or_default();
self.rewind_and_seek_to(0)?;
loop {
let done = {
let cur = match self.current() {
Some(f) => f,
None => return Ok(None),
};
cur.timestamp >= target
};
if done {
break;
}
if self.next()?.is_none() {
break;
}
}
Ok(self.current())
}
pub fn frames_seen(&self) -> u64 {
self.next_disk_seq
}
#[cfg(test)]
pub fn index_points_seen(&self) -> usize {
self.index_points.len()
}
pub fn at_eof(&self) -> bool {
self.eof
}
pub fn elapsed(&self) -> Option<Duration> {
let cur = self.current()?;
let first = self.cache.front()?;
(cur.timestamp - first.timestamp).to_std().ok()
}
fn first_frame_timestamp(&mut self) -> Result<DateTime<Utc>, ReplayError> {
if let Some(first) = self.cache.front()
&& first.seq == 0
{
return Ok(first.timestamp);
}
self.rewind_and_seek_to(0)?;
self.current()
.map(|f| f.timestamp)
.ok_or_else(|| ReplayError::Empty(self.path.clone()))
}
fn rewind_and_seek_to(&mut self, target_seq: u64) -> Result<(), ReplayError> {
let reader = open_reader(&self.path).map_err(|e| ReplayError::Open {
path: self.path.clone(),
source: e,
})?;
self.reader = reader;
self.cache.clear();
self.cursor = None;
self.next_disk_seq = 0;
self.eof = false;
self.line_number = 0;
let mut nearest_line: u64 = 0;
let mut nearest_seq: u64 = 0;
for p in &self.index_points {
if p.seq <= target_seq && p.seq > nearest_seq {
nearest_seq = p.seq;
nearest_line = p.line;
}
}
if nearest_line > 0 {
for _ in 0..nearest_line {
let n = skip_one_line_bounded(&mut self.reader)?;
if n == 0 {
self.eof = true;
break;
}
self.line_number += 1;
}
self.next_disk_seq = nearest_seq.saturating_add(1);
}
loop {
if !self.read_next_data_frame()? {
break;
}
let landed_seq = self
.cache
.back()
.map(|f| f.seq)
.unwrap_or(self.next_disk_seq.saturating_sub(1));
if landed_seq >= target_seq {
self.cursor = Some(self.cache.len() - 1);
break;
}
}
if self.cursor.is_none() && !self.cache.is_empty() {
self.cursor = Some(self.cache.len() - 1);
}
Ok(())
}
fn read_next_data_frame(&mut self) -> Result<bool, ReplayError> {
let mut rejects_this_call: usize = 0;
loop {
let line = match self.read_line()? {
Some(s) => s,
None => return Ok(false),
};
match self.classify_line(&line)? {
ClassifiedLine::Header(h) => {
if self.header.is_none() {
self.header = Some(h);
}
rejects_this_call = rejects_this_call.saturating_add(1);
}
ClassifiedLine::Index(seq) => {
self.record_index_point(seq);
rejects_this_call = rejects_this_call.saturating_add(1);
}
ClassifiedLine::Data(snap) => {
let frame = ReplayFrame {
seq: self.next_disk_seq,
timestamp: parse_ts(&snap.timestamp),
snapshot: snap,
};
self.next_disk_seq =
self.next_disk_seq
.checked_add(1)
.ok_or(ReplayError::SeqOverflow {
line: self.line_number,
})?;
self.cache.push_back(frame);
if self.cache.len() > FRAME_CACHE_MAX {
let evicted = self.cache.pop_front();
if let Some(c) = self.cursor
&& c > 0
{
self.cursor = Some(c - 1);
}
drop(evicted);
}
return Ok(true);
}
ClassifiedLine::Ignore => {
rejects_this_call = rejects_this_call.saturating_add(1);
}
}
if rejects_this_call >= SCAN_BUDGET_PER_TICK {
tracing::debug!(
path = %self.path.display(),
line = self.line_number,
rejects = rejects_this_call,
"replay: scan budget exhausted, yielding to driver"
);
return Ok(false);
}
}
}
fn record_index_point(&mut self, seq: u64) {
if seq >= self.next_disk_seq {
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
seq,
next_disk_seq = self.next_disk_seq,
"replay: ignoring index frame pointing past observed data"
);
return;
}
if let Some(last) = self.index_points.last()
&& seq <= last.seq
{
tracing::warn!(
path = %self.path.display(),
line = self.line_number,
seq,
prev_seq = last.seq,
"replay: ignoring non-monotonic index frame"
);
return;
}
if self.line_number == 0 {
tracing::warn!(
path = %self.path.display(),
seq,
"replay: ignoring index frame before any data frames"
);
return;
}
self.index_points.push(IndexPoint {
seq,
line: self.line_number,
});
}
}
enum ClassifiedLine {
Header(ReplayHeader),
Index(u64),
Data(Snapshot),
Ignore,
}
fn parse_ts(ts: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(ts)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now))
}
fn open_reader(path: &Path) -> io::Result<BufReader<Box<dyn Read + Send>>> {
let file = File::open(path)?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase());
let boxed: Box<dyn Read + Send> = match ext.as_deref() {
Some("zst") => {
let mut dec = ZstdDecoder::new(file)?;
dec.window_log_max(ZSTD_WINDOW_LOG_MAX)?;
Box::new(dec)
}
Some("gz") => Box::new(GzDecoder::new(file)),
_ => Box::new(file),
};
Ok(BufReader::with_capacity(64 * 1024, boxed))
}
fn skip_one_line_bounded<R: BufRead>(reader: &mut R) -> io::Result<usize> {
let mut total: usize = 0;
loop {
let remaining = MAX_LINE_BYTES.saturating_sub(total);
if remaining == 0 {
let mut discard = Vec::new();
loop {
discard.clear();
let n = reader.read_until(b'\n', &mut discard)?;
total = total.saturating_add(n);
if n == 0 || discard.ends_with(b"\n") {
return Ok(total);
}
}
}
let mut scratch = Vec::new();
let n = reader
.by_ref()
.take(remaining as u64)
.read_until(b'\n', &mut scratch)?;
total = total.saturating_add(n);
if n == 0 {
return Ok(total);
}
if scratch.ends_with(b"\n") {
return Ok(total);
}
}
}
pub fn parse_timecode(s: &str) -> Result<Duration, ReplayError> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(ReplayError::InvalidTimecode(s.to_string()));
}
let parts: Vec<&str> = trimmed.split(':').collect();
let seconds: u64 = match parts.len() {
1 => parts[0]
.parse::<u64>()
.map_err(|_| ReplayError::InvalidTimecode(s.to_string()))?,
2 => {
let m = parts[0]
.parse::<u64>()
.map_err(|_| ReplayError::InvalidTimecode(s.to_string()))?;
let sec = parts[1]
.parse::<u64>()
.map_err(|_| ReplayError::InvalidTimecode(s.to_string()))?;
m * 60 + sec
}
3 => {
let h = parts[0]
.parse::<u64>()
.map_err(|_| ReplayError::InvalidTimecode(s.to_string()))?;
let m = parts[1]
.parse::<u64>()
.map_err(|_| ReplayError::InvalidTimecode(s.to_string()))?;
let sec = parts[2]
.parse::<u64>()
.map_err(|_| ReplayError::InvalidTimecode(s.to_string()))?;
h * 3600 + m * 60 + sec
}
_ => return Err(ReplayError::InvalidTimecode(s.to_string())),
};
Ok(Duration::from_secs(seconds))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
#[test]
fn parse_timecode_accepts_various_forms() {
assert_eq!(parse_timecode("30").unwrap(), Duration::from_secs(30));
assert_eq!(parse_timecode("1:30").unwrap(), Duration::from_secs(90));
assert_eq!(
parse_timecode("01:02:03").unwrap(),
Duration::from_secs(3723)
);
}
#[test]
fn parse_timecode_rejects_junk() {
assert!(parse_timecode("junk").is_err());
assert!(parse_timecode("1:2:3:4").is_err());
assert!(parse_timecode("").is_err());
}
fn write_small_fixture(path: &std::path::Path) {
let mut f = File::create(path).unwrap();
writeln!(
f,
"{{\"schema\":1,\"header\":true,\"interval_ms\":1000,\"hosts\":[\"a\"]}}"
)
.unwrap();
for i in 0..3 {
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:{i:02}Z\",\"hostname\":\"a\",\"gpus\":[],\"cpus\":[],\"memory\":[]}}"
)
.unwrap();
}
}
#[test]
fn replayer_opens_and_primes_first_frame() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("small.ndjson");
write_small_fixture(&path);
let r = Replayer::open(&path).unwrap();
let first = r.current().expect("priming materializes frame 0");
assert_eq!(first.seq, 0);
assert_eq!(first.snapshot.hostname, "a");
}
#[test]
fn replayer_rejects_schema_v2_with_exact_message() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v2.ndjson");
{
let mut f = File::create(&path).unwrap();
writeln!(
f,
"{{\"schema\":2,\"timestamp\":\"2026-04-20T00:00:00Z\",\"hostname\":\"a\"}}"
)
.unwrap();
}
let err = match Replayer::open(&path) {
Ok(_) => panic!("expected schema v2 to be rejected"),
Err(e) => e,
};
let msg = err.to_string();
assert_eq!(
msg, "replay: unsupported schema version 2, this all-smi supports schema 1",
"error message must match issue spec exactly"
);
}
#[test]
fn replayer_steps_forward_and_backward() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("step.ndjson");
write_small_fixture(&path);
let mut r = Replayer::open(&path).unwrap();
assert_eq!(r.current().unwrap().seq, 0);
let f1 = r.next().unwrap().expect("frame 1 exists");
assert_eq!(f1.seq, 1);
let back = r.prev().unwrap().expect("prev lands on frame 0");
assert_eq!(back.seq, 0);
}
#[test]
fn replayer_seek_lands_on_target_frame() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("seek.ndjson");
write_small_fixture(&path);
let mut r = Replayer::open(&path).unwrap();
let landed = r.seek(Duration::from_secs(2)).unwrap().expect("frame 2");
assert_eq!(landed.seq, 2);
}
#[test]
fn replayer_gzip_decoder_works() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("small.ndjson.gz");
{
use flate2::Compression;
use flate2::write::GzEncoder;
let f = File::create(&path).unwrap();
let mut enc = GzEncoder::new(f, Compression::default());
writeln!(
enc,
"{{\"schema\":1,\"header\":true,\"interval_ms\":1000,\"hosts\":[\"a\"]}}"
)
.unwrap();
for i in 0..3 {
writeln!(
enc,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:{i:02}Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
}
enc.finish().unwrap();
}
let r = Replayer::open(&path).unwrap();
assert!(r.current().is_some(), "gzip stream primes frame 0");
}
#[test]
fn replayer_zstd_decoder_works() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("small.ndjson.zst");
{
let f = File::create(&path).unwrap();
let mut enc = zstd::stream::write::Encoder::new(f, 3).unwrap();
writeln!(
enc,
"{{\"schema\":1,\"header\":true,\"interval_ms\":1000,\"hosts\":[\"a\"]}}"
)
.unwrap();
for i in 0..3 {
writeln!(
enc,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:{i:02}Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
}
enc.finish().unwrap();
}
let r = Replayer::open(&path).unwrap();
assert!(r.current().is_some(), "zstd stream primes frame 0");
}
#[test]
fn replayer_seek_across_index_frame_preserves_absolute_seq() {
use std::fs::File;
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("indexed.ndjson");
{
let mut f = File::create(&path).unwrap();
for i in 0..10u64 {
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:{i:02}Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
if i == 5 {
writeln!(
f,
"{{\"schema\":1,\"index\":true,\"seq\":5,\"byte_offset\":0}}"
)
.unwrap();
}
}
}
let mut r = Replayer::open(&path).unwrap();
while !r.at_eof() {
if r.next().unwrap().is_none() {
break;
}
}
assert!(
r.index_points_seen() >= 1,
"priming walk must have observed the seq=5 index frame"
);
let landed = r
.seek(Duration::from_secs(7))
.unwrap()
.expect("frame at 7s");
assert_eq!(
landed.seq, 7,
"seek across an index frame must preserve absolute sequence numbering"
);
}
#[test]
fn replayer_skips_corrupted_tail_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("corrupt.ndjson");
{
let mut f = File::create(&path).unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:00Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:01Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
write!(f, "{{\"schema\":1,\"timestamp\":\"not-finished").unwrap();
}
let mut r = Replayer::open(&path).unwrap();
assert_eq!(r.current().unwrap().seq, 0);
let next = r.next().unwrap().unwrap();
assert_eq!(next.seq, 1);
let eof_check = r.next().unwrap();
assert!(
eof_check.is_none() || eof_check.unwrap().seq == 1,
"a truncated tail line must not materialize as a new frame"
);
}
#[test]
fn replayer_skips_oversized_line_without_oom() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oversized.ndjson");
{
let mut f = File::create(&path).unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:00Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
let huge = vec![b'x'; 20 * 1024 * 1024];
f.write_all(&huge).unwrap();
f.write_all(b"\n").unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:01Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
}
let mut r = Replayer::open(&path).unwrap();
assert_eq!(r.current().unwrap().seq, 0, "priming reads frame 0");
let next = r.next().unwrap().expect("post-oversized frame");
assert_eq!(next.seq, 1);
}
#[test]
fn replayer_rejects_zstd_window_above_cap() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("big-window.ndjson.zst");
{
let f = File::create(&path).unwrap();
let mut enc = zstd::stream::write::Encoder::new(f, 3).unwrap();
enc.set_parameter(zstd::zstd_safe::CParameter::WindowLog(28))
.unwrap();
writeln!(
enc,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:00Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
enc.finish().unwrap();
}
let result = Replayer::open(&path);
match result {
Err(ReplayError::Open { .. }) | Err(ReplayError::Io(_)) => {
}
Ok(mut r) => {
assert!(
r.current().is_none(),
"a file with window_log > cap must not materialize frames"
);
assert!(r.next().is_err() || r.next().unwrap().is_none());
}
Err(other) => panic!("expected Open/Io error, got {other:?}"),
}
}
#[test]
fn replayer_rejects_index_frame_with_implausible_seq() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("overflow-idx.ndjson");
{
let mut f = File::create(&path).unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:00Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
writeln!(
f,
"{{\"schema\":1,\"index\":true,\"seq\":18446744073709551615,\"byte_offset\":0}}"
)
.unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:01Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
}
let mut r = Replayer::open(&path).unwrap();
while !r.at_eof() {
if r.next().unwrap().is_none() {
break;
}
}
assert_eq!(
r.index_points_seen(),
0,
"index frame with implausible seq must be rejected at ingest"
);
}
#[test]
fn replayer_truncates_oversized_header_hosts() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("many-hosts.ndjson");
{
let mut f = File::create(&path).unwrap();
let hosts: Vec<String> = (0..5000).map(|i| format!("host-{i}")).collect();
let header = serde_json::json!({
"schema": 1,
"header": true,
"interval_ms": 1000,
"hosts": hosts,
});
writeln!(f, "{header}").unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:00Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
}
let r = Replayer::open(&path).unwrap();
let header = r.header().expect("header parsed");
assert!(
header.hosts.len() <= MAX_HEADER_HOSTS,
"hosts len must be capped at MAX_HEADER_HOSTS, got {}",
header.hosts.len()
);
assert_eq!(header.hosts.len(), MAX_HEADER_HOSTS);
}
#[test]
fn replayer_next_disk_seq_overflow_is_surfaced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("seq-overflow.ndjson");
{
let mut f = File::create(&path).unwrap();
for i in 0..3 {
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:{i:02}Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
}
}
let mut r = Replayer::open(&path).unwrap();
r.next_disk_seq = u64::MAX;
let err = r.next();
match err {
Err(ReplayError::SeqOverflow { .. }) => { }
Err(other) => panic!("expected SeqOverflow, got {other}"),
Ok(_) => panic!("expected SeqOverflow, got Ok"),
}
}
#[test]
fn replayer_scan_budget_prevents_runaway_loop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("garbage.ndjson");
{
let mut f = File::create(&path).unwrap();
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:00Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
for _ in 0..SCAN_BUDGET_PER_TICK + 10 {
writeln!(f, "not-json").unwrap();
}
writeln!(
f,
"{{\"schema\":1,\"timestamp\":\"2026-04-20T00:00:01Z\",\"hostname\":\"a\",\"gpus\":[]}}"
)
.unwrap();
}
let mut r = Replayer::open(&path).unwrap();
assert_eq!(r.current().unwrap().seq, 0);
let mut found_frame_1 = false;
for _ in 0..3 {
match r.next() {
Ok(Some(f)) if f.seq == 1 => {
found_frame_1 = true;
break;
}
Ok(_) => continue,
Err(e) => panic!("unexpected error: {e}"),
}
}
assert!(
found_frame_1,
"frame 1 must be reachable after budget-limited scan"
);
}
}