use chrono::{DateTime, Utc};
use std::io::{BufWriter, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::fs::File;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use std::sync::mpsc::{Sender, channel};
use std::thread::{self, Builder, JoinHandle};
use tracing::{debug, error, info};
#[cfg(feature = "python")]
use pyo3::prelude::*;
use crate::controller::context::ControllerCtx;
use crate::py_json_methods;
use super::{Dispatcher, Overflow, csv_header, csv_row_fixed_width, resource_name_with_suffix};
#[derive(Clone, Debug)]
pub struct LoadedCsv {
channel_names: Vec<String>,
rows: Vec<LoadedCsvRow>,
}
impl LoadedCsv {
pub fn channel_names(&self) -> &[String] {
&self.channel_names
}
pub fn rows(&self) -> &[LoadedCsvRow] {
&self.rows
}
pub fn channel_index(&self, name: &str) -> Option<usize> {
self.channel_names
.iter()
.position(|channel| channel == name)
}
pub fn required_channel_indices<'a>(
&self,
names: impl IntoIterator<Item = &'a str>,
) -> Result<Vec<usize>, String> {
names
.into_iter()
.map(|name| {
self.channel_index(name)
.ok_or_else(|| format!("CSV is missing required channel `{name}`"))
})
.collect()
}
}
#[derive(Clone, Debug)]
pub struct LoadedCsvRow {
pub time: SystemTime,
pub timestamp: i64,
pub channel_values: Vec<f64>,
}
pub fn load_csv(path: impl AsRef<Path>) -> Result<LoadedCsv, String> {
let path = path.as_ref();
let mut reader = ::csv::ReaderBuilder::new()
.has_headers(true)
.from_path(path)
.map_err(|e| format!("Failed to open CSV {}: {e}", path.display()))?;
let headers = reader
.headers()
.map_err(|e| format!("Failed to read CSV header from {}: {e}", path.display()))?
.clone();
let timestamp_idx = csv_column_index(&headers, "timestamp")?;
let time_idx = csv_column_index(&headers, "time")?;
let mut channel_names = Vec::new();
let mut channel_indices = Vec::new();
for (idx, header) in headers.iter().enumerate() {
if idx == timestamp_idx || idx == time_idx {
continue;
}
channel_names.push(header.to_owned());
channel_indices.push(idx);
}
let mut rows = Vec::new();
for (row_idx, record) in reader.records().enumerate() {
let record = record.map_err(|e| {
format!(
"Failed to read CSV row {row_idx} from {}: {e}",
path.display()
)
})?;
let timestamp = parse_csv_timestamp(
csv_field(&record, timestamp_idx, "timestamp", row_idx)?,
row_idx,
)?;
let time = parse_csv_time(csv_field(&record, time_idx, "time", row_idx)?, row_idx)?;
let mut channel_values = Vec::with_capacity(channel_indices.len());
for (&column_idx, channel_name) in channel_indices.iter().zip(channel_names.iter()) {
channel_values.push(parse_csv_f64(
csv_field(&record, column_idx, channel_name, row_idx)?,
row_idx,
channel_name,
)?);
}
rows.push(LoadedCsvRow {
time,
timestamp,
channel_values,
});
}
Ok(LoadedCsv {
channel_names,
rows,
})
}
fn csv_column_index(headers: &::csv::StringRecord, name: &str) -> Result<usize, String> {
headers
.iter()
.position(|header| header == name)
.ok_or_else(|| format!("CSV is missing required column `{name}`"))
}
fn csv_field<'a>(
record: &'a ::csv::StringRecord,
index: usize,
name: &str,
row_idx: usize,
) -> Result<&'a str, String> {
record
.get(index)
.ok_or_else(|| format!("CSV row {row_idx} is missing column `{name}`"))
}
fn parse_csv_timestamp(value: &str, row_idx: usize) -> Result<i64, String> {
value
.trim()
.parse::<i64>()
.map_err(|e| format!("Invalid CSV timestamp on row {row_idx}: {e}"))
}
fn parse_csv_time(value: &str, row_idx: usize) -> Result<SystemTime, String> {
DateTime::parse_from_rfc3339(value.trim())
.map(|time| DateTime::<Utc>::from(time).into())
.map_err(|e| format!("Invalid CSV wall-clock time on row {row_idx}: {e}"))
}
fn parse_csv_f64(value: &str, row_idx: usize, column_name: &str) -> Result<f64, String> {
value
.trim()
.parse::<f64>()
.map_err(|e| format!("Invalid CSV value for `{column_name}` on row {row_idx}: {e}"))
}
#[derive(Serialize, Deserialize, Default)]
#[cfg_attr(feature = "python", pyclass)]
pub struct CsvDispatcher {
chunk_size_megabytes: usize,
overflow_behavior: Overflow,
#[serde(default, skip_serializing_if = "Option::is_none")]
op_name_suffix: Option<String>,
#[serde(skip)]
worker: Option<WorkerHandle>,
}
impl CsvDispatcher {
pub fn new(chunk_size_megabytes: usize, overflow_behavior: Overflow) -> Box<Self> {
Box::new(Self {
chunk_size_megabytes,
overflow_behavior,
op_name_suffix: None,
worker: None,
})
}
pub fn with_op_name_suffix(mut self: Box<Self>, suffix: &str) -> Box<Self> {
self.op_name_suffix = if suffix.is_empty() {
None
} else {
Some(suffix.to_owned())
};
self
}
}
py_json_methods!(
CsvDispatcher,
Dispatcher,
#[new]
#[pyo3(signature=(chunk_size_megabytes, overflow_behavior, op_name_suffix=None))]
fn py_new(
chunk_size_megabytes: usize,
overflow_behavior: Overflow,
op_name_suffix: Option<String>,
) -> PyResult<Self> {
let dispatcher = Self::new(chunk_size_megabytes, overflow_behavior);
let dispatcher = if let Some(suffix) = op_name_suffix {
dispatcher.with_op_name_suffix(&suffix)
} else {
dispatcher
};
Ok(*dispatcher)
}
);
#[typetag::serde]
impl Dispatcher for CsvDispatcher {
fn init(
&mut self,
ctx: &ControllerCtx,
channel_names: &[String],
core_assignment: usize,
) -> Result<(), String> {
if let Some(worker) = self.worker.take() {
worker.terminate()?;
}
let header = csv_header(channel_names);
let total_len = 1024 * 1_024 * self.chunk_size_megabytes;
let resource_name = resource_name_with_suffix(&ctx.op_name, self.op_name_suffix.as_deref());
let filepath = ctx.op_dir.join(format!("{resource_name}.csv"));
info!(
"Initializing CSV dispatcher with file path: {:?}",
&filepath
);
self.worker = Some(WorkerHandle::new(
filepath,
header,
total_len,
self.overflow_behavior,
core_assignment,
)?);
Ok(())
}
fn consume(
&mut self,
time: SystemTime,
timestamp: i64,
channel_values: Vec<f64>,
) -> Result<(), String> {
match &mut self.worker {
Some(worker) => worker
.tx
.send((time, timestamp, channel_values))
.map_err(|e| format!("Failed to queue data to write to CSV: {e}")),
None => Err("Dispatcher must be initialized before consuming data".to_string()),
}
}
fn terminate(&mut self) -> Result<(), String> {
if let Some(worker) = self.worker.take() {
worker.terminate()
} else {
Ok(())
}
}
}
struct WorkerHandle {
pub tx: Sender<(SystemTime, i64, Vec<f64>)>,
thread: JoinHandle<()>,
}
impl WorkerHandle {
fn new(
path: PathBuf,
header: String,
total_size: usize,
overflow_behavior: Overflow,
core_assignment: usize,
) -> Result<Self, String> {
let (tx, rx) = channel::<(SystemTime, i64, Vec<f64>)>();
let original_filename = path.file_stem().unwrap().to_str().unwrap().to_owned();
let header_len = header.len();
let mut writer = new_file(&path, &header, total_size)?;
let mut file_size = header_len;
let mut shard_number: u64 = 0;
let thread = Builder::new()
.name("csv-dispatcher".to_string())
.spawn(move || {
{
let success = core_affinity::set_for_current(core_affinity::CoreId {
id: core_assignment,
});
if !success {
debug!(
"CSV dispatcher: core_affinity::set_for_current returned false \
(expected on macOS and other platforms without hard affinity)"
);
}
}
let mut stringbuf = String::new();
loop {
match rx.recv() {
Err(_) => {
let _ = writer.flush();
let mut file = writer.into_inner().unwrap();
let len = get_file_loc(&mut file);
let _ = file.set_len(len);
return;
}
Ok((time, timestamp, channel_values)) => {
csv_row_fixed_width(
&mut stringbuf,
(time, timestamp, channel_values.as_slice()),
);
let n_to_write = stringbuf.len();
if get_file_loc(&mut writer) as usize + n_to_write > total_size {
match overflow_behavior {
Overflow::Wrap => {
writer.seek(SeekFrom::Start(header_len as u64)).unwrap();
}
Overflow::Error => {
error!(
"CSV file is full with overflow policy set to Error"
);
return;
}
Overflow::NewFile => {
shard_number += 1;
let filename_new =
format!("{original_filename}_{shard_number}.csv");
info!("Reserving new CSV file at {}", &filename_new);
let path_new: PathBuf =
path.parent().unwrap().join(filename_new);
writer = new_file(&path_new, &header, total_size).unwrap();
info!("CSV dispatcher moved to new file at {path_new:?}")
}
}
}
writer.write_all(stringbuf.as_bytes()).unwrap();
file_size = file_size.max(get_file_loc(&mut writer) as usize);
}
}
thread::yield_now();
}
})
.map_err(|e| format!("Failed to spawn CSV dispatcher thread: {e}"))?;
Ok(Self { tx, thread })
}
fn terminate(self) -> Result<(), String> {
drop(self.tx);
self.thread.join().map_err(|payload| {
format!("CSV dispatcher worker panicked: {}", panic_message(payload))
})
}
}
fn panic_message(payload: Box<dyn std::any::Any + Send + 'static>) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_owned()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"unknown panic payload".to_owned()
}
}
fn get_file_loc<T: Seek>(f: &mut T) -> u64 {
f.stream_position().unwrap()
}
fn new_file(path: &PathBuf, header: &str, total_size: usize) -> Result<BufWriter<File>, String> {
let file = File::create(path).map_err(|e| format!("{e}"))?;
file.set_len(total_size as u64)
.map_err(|e| format!("{e}"))?;
let mut writer: BufWriter<File> = BufWriter::new(file);
writer
.write_all(header.as_bytes())
.map_err(|e| format!("{e}"))?;
Ok(writer)
}
#[cfg(test)]
mod tests {
use super::{CsvDispatcher, load_csv};
use crate::controller::context::ControllerCtx;
use crate::dispatcher::{Dispatcher, Overflow};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, UNIX_EPOCH};
static NEXT_TMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
fn unique_temp_dir() -> PathBuf {
let id = NEXT_TMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"deimos_csv_dispatcher_{}_{}",
std::process::id(),
id
))
}
#[test]
fn terminate_finalizes_csv_before_returning() {
let dir = unique_temp_dir();
fs::create_dir_all(&dir).expect("create CSV test output dir");
let mut ctx = ControllerCtx {
op_name: "cal_run".to_owned(),
op_dir: dir.clone(),
..ControllerCtx::default()
};
ctx.dt_ns = 10_000_000;
let channel_names = vec!["ain3".to_owned()];
let mut dispatcher = CsvDispatcher::new(1, Overflow::Error);
dispatcher
.init(&ctx, &channel_names, 0)
.expect("initialize CSV dispatcher");
for idx in 0..1_000_i64 {
dispatcher
.consume(
UNIX_EPOCH + Duration::from_millis(idx as u64),
idx,
vec![idx as f64],
)
.expect("queue CSV row");
}
dispatcher.terminate().expect("terminate CSV dispatcher");
let csv_path = dir.join("cal_run.csv");
let loaded = load_csv(&csv_path).expect("load finalized CSV");
assert_eq!(loaded.channel_names(), channel_names.as_slice());
assert_eq!(loaded.rows().len(), 1_000);
fs::remove_dir_all(dir).expect("remove CSV test output dir");
}
}