use super::filter::{matches_filter, EventFilter};
use super::index::{
build_index_from_events, validate_index_consistency, validate_job_id, EventIndex,
};
use super::io::{read_events_from_file_with_offsets, save_index};
use super::stats::EventStats;
use super::{EventRecord, MapReduceEvent};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs::{self, File};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{info, warn};
pub type EventHandler = Box<dyn Fn(&EventRecord) -> Result<()> + Send + Sync>;
#[async_trait]
pub trait EventStore: Send + Sync {
async fn append(&self, event: MapReduceEvent) -> Result<()>;
async fn query(&self, filter: EventFilter) -> Result<Vec<EventRecord>>;
async fn replay(&self, job_id: &str, handler: EventHandler) -> Result<()>;
async fn aggregate(&self, job_id: &str) -> Result<EventStats>;
async fn index(&self, job_id: &str) -> Result<EventIndex>;
}
#[allow(dead_code)]
pub struct FileEventStore {
base_path: PathBuf,
}
#[allow(dead_code)]
impl FileEventStore {
pub fn new(base_path: PathBuf) -> Self {
Self { base_path }
}
fn job_events_dir(&self, job_id: &str) -> PathBuf {
self.base_path.join("events").join(job_id)
}
async fn find_event_files(&self, job_id: &str) -> Result<Vec<PathBuf>> {
let dir = self.job_events_dir(job_id);
if !dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
let mut entries = fs::read_dir(&dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
files.push(path);
}
}
files.sort();
Ok(files)
}
async fn read_events_from_file(&self, path: &Path) -> Result<Vec<EventRecord>> {
let file = File::open(path)
.await
.with_context(|| format!("Failed to open event file: {}", path.display()))?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut events = Vec::new();
let mut line_number = 0;
while let Some(line) = lines
.next_line()
.await
.with_context(|| format!("Failed to read line from {}", path.display()))?
{
line_number += 1;
match serde_json::from_str::<EventRecord>(&line) {
Ok(event) => events.push(event),
Err(e) => warn!(
"Failed to parse event at {}:{} - {}: {}",
path.display(),
line_number,
e,
&line[..line.len().min(100)]
),
}
}
Ok(events)
}
}
#[async_trait]
impl EventStore for FileEventStore {
async fn append(&self, _event: MapReduceEvent) -> Result<()> {
Err(anyhow::anyhow!("Use EventLogger for appending events"))
}
async fn query(&self, filter: EventFilter) -> Result<Vec<EventRecord>> {
let job_id = filter
.job_id
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Job ID required for query"))?;
let files = self.find_event_files(job_id).await?;
let mut all_events = Vec::new();
for file in files {
let events = self.read_events_from_file(&file).await?;
for event in events {
if matches_filter(&event, &filter) {
all_events.push(event);
if let Some(limit) = filter.limit {
if all_events.len() >= limit {
return Ok(all_events);
}
}
}
}
}
Ok(all_events)
}
async fn replay(&self, job_id: &str, handler: EventHandler) -> Result<()> {
let files = self.find_event_files(job_id).await?;
for file in files {
let events = self.read_events_from_file(&file).await?;
for event in events {
handler(&event)?;
}
}
Ok(())
}
async fn aggregate(&self, job_id: &str) -> Result<EventStats> {
let files = self.find_event_files(job_id).await?;
let mut stats = EventStats {
job_id: job_id.to_string(),
total_events: 0,
event_counts: HashMap::new(),
success_count: 0,
failure_count: 0,
duration_ms: None,
time_range: None,
};
let mut start_time: Option<DateTime<Utc>> = None;
let mut end_time: Option<DateTime<Utc>> = None;
for file in files {
let events = self.read_events_from_file(&file).await?;
for event in events {
stats.total_events += 1;
let event_name = event.event.event_name().to_string();
*stats.event_counts.entry(event_name).or_insert(0) += 1;
if start_time.is_none() || event.timestamp < start_time.unwrap() {
start_time = Some(event.timestamp);
}
if end_time.is_none() || event.timestamp > end_time.unwrap() {
end_time = Some(event.timestamp);
}
match &event.event {
MapReduceEvent::AgentCompleted { .. } => stats.success_count += 1,
MapReduceEvent::AgentFailed { .. } => stats.failure_count += 1,
_ => {}
}
}
}
if let (Some(start), Some(end)) = (start_time, end_time) {
stats.duration_ms = Some((end - start).num_milliseconds());
stats.time_range = Some((start, end));
}
Ok(stats)
}
async fn index(&self, job_id: &str) -> Result<EventIndex> {
validate_job_id(job_id)?;
let job_dir = self.job_events_dir(job_id);
if !job_dir.exists() {
return Err(anyhow!(
"Cannot index nonexistent job: no events directory found at {}",
job_dir.display()
));
}
let files = self
.find_event_files(job_id)
.await
.with_context(|| format!("Failed to find event files for job {}", job_id))?;
let mut file_events = Vec::new();
for file_path in files {
let events = read_events_from_file_with_offsets(&file_path)
.await
.with_context(|| format!("Failed to read events from {}", file_path.display()))?;
file_events.push((file_path, events));
}
let mut index = build_index_from_events(job_id, file_events);
validate_index_consistency(&mut index)?;
let index_path = self.job_events_dir(job_id).join("index.json");
save_index(&index, &index_path)
.await
.with_context(|| format!("Failed to save index for job {}", job_id))?;
info!(
"Created index for job {} with {} events",
job_id, index.total_events
);
Ok(index)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cook::execution::events::index::{
calculate_time_range, increment_event_count, update_time_range,
};
use crate::cook::execution::mapreduce::MapReduceConfig;
use tempfile::TempDir;
use uuid::Uuid;
include!("test_pure_functions.rs");
#[test]
fn test_validate_job_id_empty() {
let result = validate_job_id("");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Job ID cannot be empty"));
}
#[test]
fn test_validate_job_id_invalid_characters() {
let result = validate_job_id("job/with/slashes");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid characters"));
let result = validate_job_id("job with spaces");
assert!(result.is_err());
let result = validate_job_id("job#with@special!");
assert!(result.is_err());
}
#[test]
fn test_validate_job_id_valid() {
assert!(validate_job_id("valid-job-123").is_ok());
assert!(validate_job_id("job_with_underscores").is_ok());
assert!(validate_job_id("AlphaNumeric123").is_ok());
assert!(validate_job_id("a").is_ok());
assert!(validate_job_id("123").is_ok());
}
#[test]
fn test_validate_job_id_too_long() {
let long_id = "a".repeat(256);
let result = validate_job_id(&long_id);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("too long"));
}
#[test]
fn test_validate_index_consistency_inverted_time_range() {
let mut index = EventIndex {
job_id: "test-job".to_string(),
event_counts: HashMap::new(),
time_range: (Utc::now() + chrono::Duration::seconds(10), Utc::now()),
file_offsets: Vec::new(),
total_events: 0,
};
let result = validate_index_consistency(&mut index);
assert!(result.is_ok());
assert!(index.time_range.0 <= index.time_range.1);
}
#[test]
fn test_validate_index_consistency_count_mismatch() {
let mut index = EventIndex {
job_id: "test-job".to_string(),
event_counts: {
let mut counts = HashMap::new();
counts.insert("job_started".to_string(), 5);
counts.insert("agent_completed".to_string(), 3);
counts
},
time_range: (Utc::now(), Utc::now()),
file_offsets: Vec::new(),
total_events: 10, };
let result = validate_index_consistency(&mut index);
assert!(result.is_ok());
assert_eq!(index.total_events, 8);
}
#[test]
fn test_validate_index_consistency_empty_job_id() {
let mut index = EventIndex {
job_id: String::new(),
event_counts: HashMap::new(),
time_range: (Utc::now(), Utc::now()),
file_offsets: Vec::new(),
total_events: 0,
};
let result = validate_index_consistency(&mut index);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty job_id"));
}
#[tokio::test]
async fn test_index_with_invalid_job_id() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let result = store.index("").await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Job ID cannot be empty"));
let result = store.index("job/with/slashes").await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid characters"));
}
#[tokio::test]
async fn test_save_index_creates_parent_directory() {
let temp_dir = TempDir::new().unwrap();
let deep_path = temp_dir
.path()
.join("non")
.join("existent")
.join("path")
.join("index.json");
let index = EventIndex {
job_id: "test-job".to_string(),
event_counts: HashMap::new(),
time_range: (Utc::now(), Utc::now()),
file_offsets: Vec::new(),
total_events: 0,
};
let result = save_index(&index, &deep_path).await;
assert!(result.is_ok(), "Should create parent directories");
assert!(deep_path.exists(), "Index file should exist");
let content = fs::read_to_string(&deep_path).await.unwrap();
let parsed: EventIndex = serde_json::from_str(&content).unwrap();
assert_eq!(parsed.job_id, "test-job");
}
#[tokio::test]
async fn test_index_concurrent_calls_same_job() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "concurrent-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "test".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
let event_file = events_dir.join("events.jsonl");
fs::write(&event_file, serde_json::to_string(&event).unwrap())
.await
.unwrap();
let store = std::sync::Arc::new(store);
let mut handles = vec![];
for _ in 0..5 {
let store_clone = store.clone();
let job_id = job_id.to_string();
let handle = tokio::spawn(async move { store_clone.index(&job_id).await });
handles.push(handle);
}
for handle in handles {
let result = handle.await.unwrap();
assert!(result.is_ok(), "Concurrent index should not fail");
}
}
#[test]
fn test_index_invariants() {
use chrono::Duration;
let test_cases = vec![
EventIndex {
job_id: "test".to_string(),
event_counts: HashMap::new(),
time_range: (Utc::now(), Utc::now()),
file_offsets: Vec::new(),
total_events: 0,
},
EventIndex {
job_id: "test".to_string(),
event_counts: {
let mut counts = HashMap::new();
counts.insert("job_started".to_string(), 1);
counts.insert("agent_started".to_string(), 5);
counts.insert("agent_completed".to_string(), 5);
counts
},
time_range: (Utc::now(), Utc::now() + Duration::seconds(100)),
file_offsets: vec![],
total_events: 11,
},
];
for mut index in test_cases {
let result = validate_index_consistency(&mut index);
assert!(result.is_ok());
assert!(index.time_range.0 <= index.time_range.1);
let sum: usize = index.event_counts.values().sum();
assert_eq!(index.total_events, sum);
assert!(!index.job_id.is_empty());
}
}
#[tokio::test]
#[ignore] async fn test_event_store_query() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "test-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "test-correlation".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
let event_file = events_dir.join("test.jsonl");
let json = serde_json::to_string(&event).unwrap();
fs::write(&event_file, &json).await.unwrap();
assert!(event_file.exists(), "Event file should exist");
let file_content = fs::read_to_string(&event_file).await.unwrap();
let parsed: EventRecord = serde_json::from_str(&file_content)
.expect("Should be able to parse the event we just wrote");
assert_eq!(parsed.event.job_id(), job_id);
let files = store.find_event_files(job_id).await.unwrap();
assert_eq!(files.len(), 1, "Should find 1 event file");
let filter = EventFilter {
job_id: Some(job_id.to_string()),
..Default::default()
};
let results = store.query(filter).await.unwrap();
assert_eq!(results.len(), 1, "Should find 1 event");
assert_eq!(results[0].event.job_id(), job_id);
}
#[tokio::test]
async fn test_index_creates_index_for_job_with_events() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().to_path_buf();
let store = FileEventStore::new(base_path.clone());
let job_id = "test-job-index";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event_file = events_dir.join("events-001.jsonl");
let timestamp = Utc::now();
let events = vec![
EventRecord {
id: Uuid::new_v4(),
timestamp,
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp,
},
metadata: HashMap::new(),
},
EventRecord {
id: Uuid::new_v4(),
timestamp,
correlation_id: "corr-2".to_string(),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: "agent-1".to_string(),
item_id: "item-1".to_string(),
worktree: "worktree-1".to_string(),
attempt: 1,
},
metadata: HashMap::new(),
},
EventRecord {
id: Uuid::new_v4(),
timestamp,
correlation_id: "corr-3".to_string(),
event: MapReduceEvent::AgentCompleted {
job_id: job_id.to_string(),
agent_id: "agent-1".to_string(),
duration: chrono::Duration::seconds(30),
commits: vec!["abc123".to_string()],
json_log_location: None,
},
metadata: HashMap::new(),
},
];
let mut file_content = String::new();
for event in &events {
file_content.push_str(&serde_json::to_string(event).unwrap());
file_content.push('\n');
}
fs::write(&event_file, &file_content).await.unwrap();
let result = store.index(job_id).await;
assert!(result.is_ok(), "Index operation should succeed");
let index = result.unwrap();
assert_eq!(index.job_id, job_id, "Job ID should match");
assert_eq!(index.total_events, 3, "Should count all events");
assert!(
index.event_counts.contains_key("job_started"),
"Should have job_started count"
);
assert!(
index.event_counts.contains_key("agent_started"),
"Should have agent_started count"
);
assert!(
index.event_counts.contains_key("agent_completed"),
"Should have agent_completed count"
);
let index_path = events_dir.join("index.json");
assert!(index_path.exists(), "Index file should exist on disk");
let index_content = fs::read_to_string(&index_path).await.unwrap();
let parsed_index: EventIndex =
serde_json::from_str(&index_content).expect("Index should be valid JSON");
assert_eq!(parsed_index.job_id, job_id, "Parsed index should match");
assert_eq!(
parsed_index.total_events, 3,
"Parsed index should have correct count"
);
}
#[tokio::test]
async fn test_index_with_no_event_files() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().to_path_buf();
let store = FileEventStore::new(base_path.clone());
let job_id = "empty-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let non_event_file = events_dir.join("readme.txt");
fs::write(&non_event_file, "This is not an event file")
.await
.unwrap();
let result = store.index(job_id).await;
assert!(result.is_ok(), "Index should succeed even with no events");
let index = result.unwrap();
assert_eq!(index.job_id, job_id);
assert_eq!(index.total_events, 0, "Should have zero events");
assert!(
index.event_counts.is_empty(),
"Event counts should be empty"
);
let index_path = events_dir.join("index.json");
assert!(index_path.exists(), "Index file should be created");
}
#[tokio::test]
async fn test_index_with_nonexistent_job() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().to_path_buf();
let store = FileEventStore::new(base_path);
let job_id = "nonexistent-job";
let result = store.index(job_id).await;
assert!(
result.is_err(),
"Index should fail when job directory doesn't exist"
);
}
#[tokio::test]
async fn test_index_aggregates_multiple_event_files() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().to_path_buf();
let store = FileEventStore::new(base_path.clone());
let job_id = "multi-file-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let timestamp1 = Utc::now() - chrono::Duration::hours(2);
let timestamp2 = Utc::now() - chrono::Duration::hours(1);
let timestamp3 = Utc::now();
let event_file1 = events_dir.join("events-001.jsonl");
let events1 = vec![
EventRecord {
id: Uuid::new_v4(),
timestamp: timestamp1,
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: timestamp1,
},
metadata: HashMap::new(),
},
EventRecord {
id: Uuid::new_v4(),
timestamp: timestamp1,
correlation_id: "corr-2".to_string(),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: "agent-1".to_string(),
item_id: "item-1".to_string(),
worktree: "worktree-1".to_string(),
attempt: 1,
},
metadata: HashMap::new(),
},
];
let mut content1 = String::new();
for event in &events1 {
content1.push_str(&serde_json::to_string(event).unwrap());
content1.push('\n');
}
fs::write(&event_file1, &content1).await.unwrap();
let event_file2 = events_dir.join("events-002.jsonl");
let events2 = vec![
EventRecord {
id: Uuid::new_v4(),
timestamp: timestamp2,
correlation_id: "corr-3".to_string(),
event: MapReduceEvent::AgentCompleted {
job_id: job_id.to_string(),
agent_id: "agent-1".to_string(),
duration: chrono::Duration::seconds(30),
commits: vec!["abc123".to_string()],
json_log_location: None,
},
metadata: HashMap::new(),
},
EventRecord {
id: Uuid::new_v4(),
timestamp: timestamp2,
correlation_id: "corr-4".to_string(),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: "agent-2".to_string(),
item_id: "item-2".to_string(),
worktree: "worktree-2".to_string(),
attempt: 1,
},
metadata: HashMap::new(),
},
];
let mut content2 = String::new();
for event in &events2 {
content2.push_str(&serde_json::to_string(event).unwrap());
content2.push('\n');
}
fs::write(&event_file2, &content2).await.unwrap();
let event_file3 = events_dir.join("events-003.jsonl");
let event3 = EventRecord {
id: Uuid::new_v4(),
timestamp: timestamp3,
correlation_id: "corr-5".to_string(),
event: MapReduceEvent::JobCompleted {
job_id: job_id.to_string(),
duration: chrono::Duration::hours(2),
success_count: 10,
failure_count: 0,
},
metadata: HashMap::new(),
};
let content3 = format!("{}\n", serde_json::to_string(&event3).unwrap());
fs::write(&event_file3, &content3).await.unwrap();
let result = store.index(job_id).await;
assert!(result.is_ok(), "Index should succeed with multiple files");
let index = result.unwrap();
assert_eq!(index.job_id, job_id);
assert_eq!(
index.total_events, 5,
"Should aggregate all events from all files"
);
assert_eq!(index.event_counts.get("job_started"), Some(&1));
assert_eq!(index.event_counts.get("agent_started"), Some(&2));
assert_eq!(index.event_counts.get("agent_completed"), Some(&1));
assert_eq!(index.event_counts.get("job_completed"), Some(&1));
let (start, end) = index.time_range;
assert!(
start <= timestamp1,
"Time range start should be at or before earliest event"
);
assert!(
end >= timestamp3,
"Time range end should be at or after latest event"
);
assert_eq!(
index.file_offsets.len(),
5,
"Should have file offsets for all events"
);
}
#[tokio::test]
async fn test_index_calculates_correct_time_range() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().to_path_buf();
let store = FileEventStore::new(base_path.clone());
let job_id = "time-range-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let earliest_time = Utc::now() - chrono::Duration::days(1);
let middle_time = Utc::now() - chrono::Duration::hours(12);
let latest_time = Utc::now();
let event_file = events_dir.join("events-001.jsonl");
let events = vec![
EventRecord {
id: Uuid::new_v4(),
timestamp: middle_time,
correlation_id: "corr-2".to_string(),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: "agent-1".to_string(),
item_id: "item-1".to_string(),
worktree: "worktree-1".to_string(),
attempt: 1,
},
metadata: HashMap::new(),
},
EventRecord {
id: Uuid::new_v4(),
timestamp: earliest_time,
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: earliest_time,
},
metadata: HashMap::new(),
},
EventRecord {
id: Uuid::new_v4(),
timestamp: latest_time,
correlation_id: "corr-3".to_string(),
event: MapReduceEvent::JobCompleted {
job_id: job_id.to_string(),
duration: chrono::Duration::days(1),
success_count: 10,
failure_count: 0,
},
metadata: HashMap::new(),
},
];
let mut file_content = String::new();
for event in &events {
file_content.push_str(&serde_json::to_string(event).unwrap());
file_content.push('\n');
}
fs::write(&event_file, &file_content).await.unwrap();
let result = store.index(job_id).await;
assert!(result.is_ok(), "Index should succeed");
let index = result.unwrap();
let (start, end) = index.time_range;
assert_eq!(
start, earliest_time,
"Time range start should match earliest event timestamp"
);
assert_eq!(
end, latest_time,
"Time range end should match latest event timestamp"
);
let duration = end - start;
assert!(
duration >= chrono::Duration::hours(23),
"Duration should be approximately 24 hours"
);
}
#[tokio::test]
async fn test_index_handles_malformed_json() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().to_path_buf();
let store = FileEventStore::new(base_path.clone());
let job_id = "malformed-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event_file = events_dir.join("events-001.jsonl");
let valid_event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
let mut content = String::new();
content.push_str(&serde_json::to_string(&valid_event).unwrap());
content.push('\n');
content.push_str("{ invalid json }\n"); content.push_str(&serde_json::to_string(&valid_event).unwrap());
content.push('\n');
content.push_str("not even close to json\n"); content.push_str(&serde_json::to_string(&valid_event).unwrap());
content.push('\n');
fs::write(&event_file, &content).await.unwrap();
let result = store.index(job_id).await;
assert!(
result.is_ok(),
"Index should handle malformed JSON gracefully"
);
let index = result.unwrap();
assert_eq!(
index.total_events, 3,
"Should skip malformed lines and count only valid events"
);
assert_eq!(
index.event_counts.get("job_started"),
Some(&3),
"Should have counted all valid JobStarted events"
);
}
#[tokio::test]
async fn test_index_persists_and_deserializes_correctly() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().to_path_buf();
let store = FileEventStore::new(base_path.clone());
let job_id = "persistence-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event_file = events_dir.join("events-001.jsonl");
let timestamp = Utc::now();
let event1 = EventRecord {
id: Uuid::new_v4(),
timestamp,
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp,
},
metadata: HashMap::new(),
};
let event2 = EventRecord {
id: Uuid::new_v4(),
timestamp,
correlation_id: "corr-2".to_string(),
event: MapReduceEvent::AgentCompleted {
job_id: job_id.to_string(),
agent_id: "agent-1".to_string(),
duration: chrono::Duration::seconds(30),
commits: vec!["abc123".to_string(), "def456".to_string()],
json_log_location: None,
},
metadata: HashMap::new(),
};
let mut content = String::new();
content.push_str(&serde_json::to_string(&event1).unwrap());
content.push('\n');
content.push_str(&serde_json::to_string(&event2).unwrap());
content.push('\n');
fs::write(&event_file, &content).await.unwrap();
let result = store.index(job_id).await;
assert!(result.is_ok(), "Index creation should succeed");
let original_index = result.unwrap();
let index_path = events_dir.join("index.json");
assert!(index_path.exists(), "Index file should exist on disk");
let index_content = fs::read_to_string(&index_path).await.unwrap();
let parsed_index: EventIndex =
serde_json::from_str(&index_content).expect("Index file should contain valid JSON");
assert_eq!(parsed_index.job_id, original_index.job_id);
assert_eq!(parsed_index.total_events, original_index.total_events);
assert_eq!(
parsed_index.event_counts, original_index.event_counts,
"Event counts should match"
);
assert_eq!(
parsed_index.time_range, original_index.time_range,
"Time range should match"
);
assert_eq!(
parsed_index.file_offsets.len(),
original_index.file_offsets.len(),
"File offsets count should match"
);
let json_value: serde_json::Value =
serde_json::from_str(&index_content).expect("Should parse as JSON value");
assert!(
json_value.get("job_id").is_some(),
"Should have job_id field"
);
assert!(
json_value.get("event_counts").is_some(),
"Should have event_counts field"
);
assert!(
json_value.get("time_range").is_some(),
"Should have time_range field"
);
assert!(
json_value.get("file_offsets").is_some(),
"Should have file_offsets field"
);
assert!(
json_value.get("total_events").is_some(),
"Should have total_events field"
);
assert!(
index_content.contains('\n'),
"Index should be pretty-printed"
);
}
#[test]
fn test_update_time_range_first_event() {
let event_time = Utc::now();
let (start, end) = update_time_range(None, None, event_time);
assert_eq!(start, Some(event_time));
assert_eq!(end, Some(event_time));
}
#[test]
fn test_update_time_range_earlier_than_start() {
let current_start = Utc::now();
let current_end = current_start + chrono::Duration::hours(1);
let earlier_time = current_start - chrono::Duration::hours(1);
let (start, end) = update_time_range(Some(current_start), Some(current_end), earlier_time);
assert_eq!(start, Some(earlier_time));
assert_eq!(end, Some(current_end));
}
#[test]
fn test_update_time_range_later_than_end() {
let current_start = Utc::now();
let current_end = current_start + chrono::Duration::hours(1);
let later_time = current_end + chrono::Duration::hours(1);
let (start, end) = update_time_range(Some(current_start), Some(current_end), later_time);
assert_eq!(start, Some(current_start));
assert_eq!(end, Some(later_time));
}
#[test]
fn test_update_time_range_within_range() {
let current_start = Utc::now();
let current_end = current_start + chrono::Duration::hours(2);
let within_time = current_start + chrono::Duration::hours(1);
let (start, end) = update_time_range(Some(current_start), Some(current_end), within_time);
assert_eq!(start, Some(current_start));
assert_eq!(end, Some(current_end));
}
#[test]
fn test_increment_event_count_first_occurrence() {
let mut counts = HashMap::new();
increment_event_count(&mut counts, "job_started".to_string());
assert_eq!(counts.get("job_started"), Some(&1));
}
#[test]
fn test_increment_event_count_subsequent_occurrence() {
let mut counts = HashMap::new();
counts.insert("agent_completed".to_string(), 3);
increment_event_count(&mut counts, "agent_completed".to_string());
assert_eq!(counts.get("agent_completed"), Some(&4));
}
#[tokio::test]
async fn test_save_index_success() {
let temp_dir = TempDir::new().unwrap();
let index_path = temp_dir.path().join("index.json");
let index = EventIndex {
job_id: "test-job".to_string(),
event_counts: HashMap::new(),
time_range: (Utc::now(), Utc::now()),
file_offsets: Vec::new(),
total_events: 5,
};
let result = save_index(&index, &index_path).await;
assert!(result.is_ok());
assert!(index_path.exists());
}
#[tokio::test]
async fn test_save_index_invalid_path() {
let index = EventIndex {
job_id: "test-job".to_string(),
event_counts: HashMap::new(),
time_range: (Utc::now(), Utc::now()),
file_offsets: Vec::new(),
total_events: 5,
};
let result = save_index(&index, Path::new("/nonexistent/dir/index.json")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_index_with_empty_directory_creates_empty_index() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "empty-dir-job";
fs::create_dir_all(store.job_events_dir(job_id))
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
assert_eq!(result.total_events, 0);
assert!(result.event_counts.is_empty());
}
#[tokio::test]
async fn test_index_creates_directory_when_missing() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "missing-dir-job";
let result = store.index(job_id).await;
assert!(
result.is_err(),
"Index should not create directories for nonexistent jobs"
);
}
#[tokio::test]
async fn test_index_with_only_invalid_json_events() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "invalid-events-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event_file = events_dir.join("events-001.jsonl");
fs::write(&event_file, "invalid\n{bad:json}\n")
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
assert_eq!(result.total_events, 0);
}
#[tokio::test]
async fn test_index_time_range_with_single_event() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "single-event-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event_time = Utc::now();
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: event_time,
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: event_time,
},
metadata: HashMap::new(),
};
fs::write(
&events_dir.join("events-001.jsonl"),
format!("{}\n", serde_json::to_string(&event).unwrap()),
)
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
assert_eq!(result.time_range, (event_time, event_time));
}
#[tokio::test]
async fn test_index_time_range_with_multiple_events() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "multi-time-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let start_time = Utc::now() - chrono::Duration::hours(2);
let end_time = Utc::now();
let event1 = EventRecord {
id: Uuid::new_v4(),
timestamp: start_time,
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: start_time,
},
metadata: HashMap::new(),
};
let event2 = EventRecord {
id: Uuid::new_v4(),
timestamp: end_time,
correlation_id: "corr-2".to_string(),
event: MapReduceEvent::JobCompleted {
job_id: job_id.to_string(),
duration: chrono::Duration::hours(2),
success_count: 10,
failure_count: 0,
},
metadata: HashMap::new(),
};
let content = format!(
"{}\n{}\n",
serde_json::to_string(&event1).unwrap(),
serde_json::to_string(&event2).unwrap()
);
fs::write(&events_dir.join("events-001.jsonl"), content)
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
assert_eq!(result.time_range, (start_time, end_time));
}
#[tokio::test]
async fn test_index_default_time_range_no_valid_events() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "no-valid-events-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
fs::write(&events_dir.join("events-001.jsonl"), "invalid\n")
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
let (start, end) = result.time_range;
let diff = (end - start).num_milliseconds().abs();
assert!(
diff < 100,
"Time range should be nearly identical when no events"
);
}
#[tokio::test]
async fn test_index_handles_files_in_sorted_order() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "sorted-files-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let time1 = Utc::now() - chrono::Duration::hours(3);
let time2 = Utc::now() - chrono::Duration::hours(2);
let time3 = Utc::now() - chrono::Duration::hours(1);
for (filename, timestamp) in [
("events-003.jsonl", time3),
("events-001.jsonl", time1),
("events-002.jsonl", time2),
] {
let event = EventRecord {
id: Uuid::new_v4(),
timestamp,
correlation_id: format!("corr-{}", filename),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: format!("agent-{}", filename),
item_id: "item-1".to_string(),
worktree: "worktree-1".to_string(),
attempt: 1,
},
metadata: HashMap::new(),
};
fs::write(
events_dir.join(filename),
format!("{}\n", serde_json::to_string(&event).unwrap()),
)
.await
.unwrap();
}
let result = store.index(job_id).await.unwrap();
assert_eq!(result.total_events, 3);
assert_eq!(result.file_offsets.len(), 3);
assert!(result.file_offsets[0]
.file_path
.to_str()
.unwrap()
.contains("events-001.jsonl"));
assert!(result.file_offsets[1]
.file_path
.to_str()
.unwrap()
.contains("events-002.jsonl"));
assert!(result.file_offsets[2]
.file_path
.to_str()
.unwrap()
.contains("events-003.jsonl"));
}
#[tokio::test]
async fn test_index_idempotent_multiple_calls() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "idempotent-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
fs::write(
events_dir.join("events-001.jsonl"),
format!("{}\n", serde_json::to_string(&event).unwrap()),
)
.await
.unwrap();
let result1 = store.index(job_id).await.unwrap();
let result2 = store.index(job_id).await.unwrap();
let result3 = store.index(job_id).await.unwrap();
assert_eq!(result1.total_events, result2.total_events);
assert_eq!(result2.total_events, result3.total_events);
assert_eq!(result1.job_id, result2.job_id);
assert_eq!(result1.event_counts, result2.event_counts);
assert_eq!(result1.time_range, result2.time_range);
let index_path = events_dir.join("index.json");
assert!(index_path.exists());
let index_content = fs::read_to_string(&index_path).await.unwrap();
let parsed: EventIndex = serde_json::from_str(&index_content).unwrap();
assert_eq!(parsed.total_events, result3.total_events);
}
#[tokio::test]
async fn test_index_with_varying_event_sizes() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "varying-sizes-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let small_event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "s".to_string(),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: "a".to_string(),
item_id: "i".to_string(),
worktree: "w".to_string(),
attempt: 1,
},
metadata: HashMap::new(),
};
let mut large_metadata = HashMap::new();
for i in 0..100 {
large_metadata.insert(
format!("key_{}", i),
serde_json::Value::String(format!("value_{}_with_some_data", i)),
);
}
let large_event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "large-correlation-id-with-lots-of-data".to_string(),
event: MapReduceEvent::AgentCompleted {
job_id: job_id.to_string(),
agent_id: "agent-with-long-identifier".to_string(),
duration: chrono::Duration::seconds(3600),
commits: vec!["commit1".to_string(); 50],
json_log_location: Some("/very/long/path/to/logs/session-id-here.json".to_string()),
},
metadata: large_metadata,
};
let content = format!(
"{}\n{}\n",
serde_json::to_string(&small_event).unwrap(),
serde_json::to_string(&large_event).unwrap()
);
fs::write(events_dir.join("events-001.jsonl"), content)
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
assert_eq!(result.total_events, 2);
assert_eq!(result.event_counts.get("agent_started"), Some(&1));
assert_eq!(result.event_counts.get("agent_completed"), Some(&1));
assert_eq!(result.file_offsets.len(), 2);
assert!(result.file_offsets[0].byte_offset < result.file_offsets[1].byte_offset);
}
#[tokio::test]
async fn test_index_preserves_file_offset_metadata() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "offset-metadata-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event_id = Uuid::new_v4();
let event_time = Utc::now();
let event = EventRecord {
id: event_id,
timestamp: event_time,
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: event_time,
},
metadata: HashMap::new(),
};
fs::write(
events_dir.join("events-001.jsonl"),
format!("{}\n", serde_json::to_string(&event).unwrap()),
)
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
assert_eq!(result.file_offsets.len(), 1);
let offset = &result.file_offsets[0];
assert_eq!(offset.event_id, event_id);
assert_eq!(offset.timestamp, event_time);
assert_eq!(offset.line_number, 1);
assert_eq!(offset.byte_offset, 0);
assert!(offset
.file_path
.to_str()
.unwrap()
.contains("events-001.jsonl"));
}
#[tokio::test]
async fn test_index_aggregates_multiple_files_different_sizes() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "multi-size-files-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event1 = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
fs::write(
events_dir.join("events-001.jsonl"),
format!("{}\n", serde_json::to_string(&event1).unwrap()),
)
.await
.unwrap();
let mut content2 = String::new();
for i in 0..5 {
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: format!("corr-{}", i),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: format!("agent-{}", i),
item_id: format!("item-{}", i),
worktree: format!("worktree-{}", i),
attempt: 1,
},
metadata: HashMap::new(),
};
content2.push_str(&serde_json::to_string(&event).unwrap());
content2.push('\n');
}
fs::write(events_dir.join("events-002.jsonl"), content2)
.await
.unwrap();
let mut content3 = String::new();
for i in 0..10 {
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: format!("corr-complete-{}", i),
event: MapReduceEvent::AgentCompleted {
job_id: job_id.to_string(),
agent_id: format!("agent-{}", i),
duration: chrono::Duration::seconds(30),
commits: vec![format!("commit-{}", i)],
json_log_location: None,
},
metadata: HashMap::new(),
};
content3.push_str(&serde_json::to_string(&event).unwrap());
content3.push('\n');
}
fs::write(events_dir.join("events-003.jsonl"), content3)
.await
.unwrap();
let result = store.index(job_id).await.unwrap();
assert_eq!(result.total_events, 16); assert_eq!(result.event_counts.get("job_started"), Some(&1));
assert_eq!(result.event_counts.get("agent_started"), Some(&5));
assert_eq!(result.event_counts.get("agent_completed"), Some(&10));
assert_eq!(result.file_offsets.len(), 16);
}
#[tokio::test]
async fn test_index_error_nonexistent_directory() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "nonexistent-dir-job";
let result = store.index(job_id).await;
assert!(
result.is_err(),
"Index should fail gracefully for nonexistent directory"
);
let error = result.unwrap_err();
let error_msg = format!("{}", error);
assert!(!error_msg.is_empty(), "Error should have a message");
}
#[tokio::test]
async fn test_index_error_propagation_from_file_read() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "read-error-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
let event_file = events_dir.join("events-001.jsonl");
fs::write(
&event_file,
format!("{}\n", serde_json::to_string(&event).unwrap()),
)
.await
.unwrap();
let result = store.index(job_id).await;
assert!(result.is_ok(), "Initial index should succeed");
let error_result: Result<EventIndex> = Err(anyhow::anyhow!("Simulated I/O error"));
assert!(error_result.is_err(), "Error propagation works correctly");
}
#[tokio::test]
async fn test_index_handles_corrupted_event_file_gracefully() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "corrupted-file-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let valid_event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
let mut content = String::new();
content.push_str(&serde_json::to_string(&valid_event).unwrap());
content.push('\n');
content.push_str("{\"incomplete\": \n"); content.push_str(&serde_json::to_string(&valid_event).unwrap());
content.push('\n');
content.push_str("{\"id\": \"not-a-uuid\", \"invalid\": true}\n"); content.push_str(&serde_json::to_string(&valid_event).unwrap());
content.push('\n');
content.push_str("completely invalid\n");
content.push_str(&serde_json::to_string(&valid_event).unwrap());
content.push('\n');
fs::write(events_dir.join("events-001.jsonl"), content)
.await
.unwrap();
let result = store.index(job_id).await;
assert!(
result.is_ok(),
"Index should handle corrupted lines gracefully"
);
let index = result.unwrap();
assert_eq!(
index.total_events, 4,
"Should count only valid events, skipping corrupted lines"
);
assert_eq!(index.event_counts.get("job_started"), Some(&4));
}
#[tokio::test]
async fn test_index_with_empty_lines_and_whitespace() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "whitespace-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "corr-1".to_string(),
event: MapReduceEvent::JobStarted {
job_id: job_id.to_string(),
config: MapReduceConfig {
agent_timeout_secs: None,
continue_on_failure: false,
batch_size: None,
enable_checkpoints: true,
input: "test.json".to_string(),
json_path: "$.items".to_string(),
max_parallel: 5,
max_items: None,
offset: None,
},
total_items: 10,
timestamp: Utc::now(),
},
metadata: HashMap::new(),
};
let mut content = String::new();
content.push_str(&serde_json::to_string(&event).unwrap());
content.push('\n');
content.push('\n'); content.push_str(&serde_json::to_string(&event).unwrap());
content.push('\n');
content.push_str(" \n"); content.push_str(&serde_json::to_string(&event).unwrap());
content.push('\n');
content.push_str("\t\n");
fs::write(events_dir.join("events-001.jsonl"), content)
.await
.unwrap();
let result = store.index(job_id).await;
assert!(result.is_ok(), "Index should handle empty lines");
let index = result.unwrap();
assert_eq!(
index.total_events, 3,
"Should count only valid events, ignoring empty lines"
);
}
#[tokio::test]
async fn test_index_error_types_are_descriptive() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "error-message-job";
let result = store.index(job_id).await;
assert!(result.is_err(), "Should fail for nonexistent job");
let error = result.unwrap_err();
let error_string = format!("{}", error);
assert!(
!error_string.is_empty(),
"Error message should not be empty"
);
let debug_string = format!("{:?}", error);
assert!(
!debug_string.is_empty(),
"Debug output should provide details"
);
}
#[tokio::test]
async fn test_index_with_large_number_of_events() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "large-scale-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let num_files = 10;
let events_per_file = 100;
let total_events = num_files * events_per_file;
for file_num in 0..num_files {
let mut content = String::new();
for event_num in 0..events_per_file {
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: format!("corr-{}-{}", file_num, event_num),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: format!("agent-{}-{}", file_num, event_num),
item_id: format!("item-{}", event_num),
worktree: format!("worktree-{}", event_num),
attempt: 1,
},
metadata: HashMap::new(),
};
content.push_str(&serde_json::to_string(&event).unwrap());
content.push('\n');
}
fs::write(
events_dir.join(format!("events-{:03}.jsonl", file_num)),
content,
)
.await
.unwrap();
}
let result = store.index(job_id).await;
assert!(result.is_ok(), "Index should handle 1000+ events");
let index = result.unwrap();
assert_eq!(index.total_events, total_events);
assert_eq!(index.event_counts.get("agent_started"), Some(&total_events));
assert_eq!(index.file_offsets.len(), total_events);
let index_path = events_dir.join("index.json");
assert!(index_path.exists());
let index_content = fs::read_to_string(&index_path).await.unwrap();
let parsed: EventIndex = serde_json::from_str(&index_content).unwrap();
assert_eq!(parsed.total_events, total_events);
}
#[tokio::test]
async fn test_index_with_very_large_event_records() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "large-records-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let mut large_metadata = HashMap::new();
let large_string = "x".repeat(10000); for i in 0..100 {
large_metadata.insert(
format!("large_key_{}", i),
serde_json::Value::String(large_string.clone()),
);
}
let large_event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: "large-event".to_string(),
event: MapReduceEvent::AgentCompleted {
job_id: job_id.to_string(),
agent_id: "agent-large".to_string(),
duration: chrono::Duration::seconds(3600),
commits: vec!["commit".to_string(); 100],
json_log_location: Some("/path/to/large/log.json".to_string()),
},
metadata: large_metadata,
};
let mut content = String::new();
for _ in 0..5 {
content.push_str(&serde_json::to_string(&large_event).unwrap());
content.push('\n');
}
fs::write(events_dir.join("events-001.jsonl"), content)
.await
.unwrap();
let result = store.index(job_id).await;
assert!(
result.is_ok(),
"Index should handle large event records (>1MB)"
);
let index = result.unwrap();
assert_eq!(index.total_events, 5);
assert_eq!(index.file_offsets.len(), 5);
for i in 1..index.file_offsets.len() {
assert!(
index.file_offsets[i].byte_offset > index.file_offsets[i - 1].byte_offset,
"Byte offsets should increase for large records"
);
}
}
#[tokio::test]
async fn test_index_with_long_running_job_time_range() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "long-running-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let start_time = Utc::now() - chrono::Duration::hours(48);
let end_time = Utc::now();
let interval = chrono::Duration::hours(4);
let mut content = String::new();
let mut current_time = start_time;
let mut event_count = 0;
while current_time <= end_time {
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: current_time,
correlation_id: format!("corr-{}", event_count),
event: MapReduceEvent::AgentStarted {
job_id: job_id.to_string(),
agent_id: format!("agent-{}", event_count),
item_id: format!("item-{}", event_count),
worktree: format!("worktree-{}", event_count),
attempt: 1,
},
metadata: HashMap::new(),
};
content.push_str(&serde_json::to_string(&event).unwrap());
content.push('\n');
current_time += interval;
event_count += 1;
}
fs::write(events_dir.join("events-001.jsonl"), content)
.await
.unwrap();
let result = store.index(job_id).await;
assert!(
result.is_ok(),
"Index should handle long-running jobs (>24 hours)"
);
let index = result.unwrap();
assert!(index.total_events > 10, "Should have multiple events");
let (range_start, range_end) = index.time_range;
let duration = range_end - range_start;
assert!(
duration >= chrono::Duration::hours(24),
"Time range should span at least 24 hours"
);
assert!(
duration <= chrono::Duration::hours(49),
"Time range should be within expected bounds"
);
}
#[tokio::test]
async fn test_index_performance_with_many_small_files() {
let temp_dir = TempDir::new().unwrap();
let store = FileEventStore::new(temp_dir.path().to_path_buf());
let job_id = "many-files-job";
let events_dir = store.job_events_dir(job_id);
fs::create_dir_all(&events_dir).await.unwrap();
let num_files = 50;
let events_per_file = 10;
for file_num in 0..num_files {
let mut content = String::new();
for event_num in 0..events_per_file {
let event = EventRecord {
id: Uuid::new_v4(),
timestamp: Utc::now(),
correlation_id: format!("corr-{}-{}", file_num, event_num),
event: MapReduceEvent::AgentCompleted {
job_id: job_id.to_string(),
agent_id: format!("agent-{}", event_num),
duration: chrono::Duration::seconds(30),
commits: vec![format!("commit-{}", event_num)],
json_log_location: None,
},
metadata: HashMap::new(),
};
content.push_str(&serde_json::to_string(&event).unwrap());
content.push('\n');
}
fs::write(
events_dir.join(format!("events-{:03}.jsonl", file_num)),
content,
)
.await
.unwrap();
}
let start = std::time::Instant::now();
let result = store.index(job_id).await;
let elapsed = start.elapsed();
assert!(
result.is_ok(),
"Index should handle many small files efficiently"
);
let index = result.unwrap();
assert_eq!(index.total_events, num_files * events_per_file);
assert_eq!(index.file_offsets.len(), num_files * events_per_file);
assert!(
elapsed.as_secs() < 5,
"Index should complete in <5s, took {:?}",
elapsed
);
}
}