1use std::{
95 fs,
96 path::{Path, PathBuf},
97 sync::Arc,
98};
99
100use anyhow::{Context, Result, anyhow};
101use async_nats::{ConnectOptions, HeaderMap};
102use async_trait::async_trait;
103use axum::{
104 Router,
105 extract::{
106 State,
107 ws::{Message, WebSocket, WebSocketUpgrade},
108 },
109 response::IntoResponse,
110 routing::get,
111};
112use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, Utc};
113use futures_util::StreamExt;
114use parquet::{
115 column::reader::get_typed_column_reader,
116 data_type::{DataType, DoubleType, Int64Type},
117 file::{
118 metadata::RowGroupMetaData,
119 reader::{FileReader, RowGroupReader, SerializedFileReader},
120 statistics::Statistics,
121 },
122};
123use serde::{Deserialize, Serialize};
124use serde_json::json;
125use std::io::Write as _;
126mod calibration;
127mod filters;
128mod nats_config;
129mod subjects;
130#[cfg(test)]
131mod test_nats;
132
133use calibration::{CalibrationFormula, CalibrationSpec};
134use filters::{ChannelFilterSettings, ExportFilter};
135
136const EXPORT_FRAME_HEADER: &str = "Avena-Export-Frame";
138const EXPORT_FRAME_META: &str = "meta";
140const EXPORT_FRAME_CHUNK: &str = "chunk";
142const EXPORT_FRAME_SUMMARY: &str = "summary";
144const EXPORT_FRAME_COMPLETE: &str = "complete";
146const EXPORT_FRAME_ERROR: &str = "error";
148const EXPORT_FRAME_CANCEL: &str = "cancel";
150
151#[derive(Debug)]
156struct ExportCancelled {
157 chunks_sent: usize,
159 bytes_sent: usize,
161}
162
163impl std::fmt::Display for ExportCancelled {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 write!(
166 f,
167 "export cancelled by the client after {} chunk(s), {} bytes",
168 self.chunks_sent, self.bytes_sent
169 )
170 }
171}
172
173impl std::error::Error for ExportCancelled {}
174
175fn is_cancel_message(message: &async_nats::Message) -> bool {
181 if message
182 .headers
183 .as_ref()
184 .and_then(|h| h.get(EXPORT_FRAME_HEADER))
185 .is_some_and(|v| v.as_str() == EXPORT_FRAME_CANCEL)
186 {
187 return true;
188 }
189 !message.payload.is_empty()
190 && serde_json::from_slice::<serde_json::Value>(&message.payload)
191 .ok()
192 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_owned))
193 .is_some_and(|t| t == EXPORT_FRAME_CANCEL)
194}
195const DEFAULT_EXPORTER_MODE: &str = "worker";
197const DEFAULT_EXPORTER_ADDR: &str = "0.0.0.0:9001";
199
200#[derive(Serialize)]
201#[serde(rename_all = "camelCase")]
202struct MetaFrame<'a> {
206 #[serde(rename = "type")]
208 frame_type: &'static str,
209 file_name: &'a str,
211 content_type: &'a str,
213}
214
215#[derive(Serialize)]
216#[serde(rename_all = "camelCase")]
217struct SummaryFrame<'a> {
221 #[serde(rename = "type")]
223 frame_type: &'static str,
224 bytes_sent: usize,
226 missing_channels: &'a [u8],
228}
229
230#[derive(Serialize)]
231struct ErrorFrame<'a> {
235 #[serde(rename = "type")]
237 frame_type: &'static str,
238 message: &'a str,
240}
241
242#[derive(Clone)]
243struct AppState {
245 mode: ExporterMode,
247 parquet_root: Arc<PathBuf>,
249}
250
251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
252enum ExporterMode {
254 Direct,
256 Worker,
258}
259
260impl ExporterMode {
261 fn from_env() -> Result<Self> {
271 match std::env::var("EXPORTER_MODE")
272 .unwrap_or_else(|_| DEFAULT_EXPORTER_MODE.to_string())
273 .trim()
274 .to_ascii_lowercase()
275 .as_str()
276 {
277 "direct" | "local" => Ok(Self::Direct),
278 "worker" => Ok(Self::Worker),
279 other => Err(anyhow!(
280 "invalid EXPORTER_MODE '{other}', expected direct or worker"
281 )),
282 }
283 }
284}
285
286#[derive(Debug, Deserialize, Serialize, Clone)]
287#[serde(rename_all = "lowercase")]
288enum ExportFormat {
290 Csv,
292 Parquet,
295}
296
297#[derive(Debug, Deserialize, Serialize, Clone)]
298struct ExportRequest {
312 asset: u32,
314 channels: Vec<u8>,
317 start: String,
319 end: String,
322 #[serde(default = "default_format")]
324 format: ExportFormat,
325 download_name: Option<String>,
328 ack_subject: Option<String>,
333 #[serde(default)]
338 filters: Option<serde_json::Value>,
339}
340
341const CSV_HEADER: &[u8] = b"timestamp,channel,raw_value,calibrated_value,calibration_id\n";
343const CSV_HEADER_FILTERED: &[u8] =
345 b"timestamp,channel,raw_value,calibrated_value,calibration_id,filtered_value\n";
346
347#[derive(Debug, Clone, Default)]
349struct ExportFilters {
350 by_channel: std::collections::HashMap<u8, ChannelFilterSettings>,
352}
353
354impl ExportFilters {
355 fn from_request(req: &ExportRequest) -> Option<Self> {
363 let map = req.filters.as_ref()?.as_object()?;
364 let by_channel: std::collections::HashMap<u8, ChannelFilterSettings> = map
365 .iter()
366 .filter_map(|(key, value)| {
367 let channel = key.trim().parse::<u8>().ok()?;
368 let settings = ChannelFilterSettings::from_value(value);
369 (req.channels.contains(&channel) && settings.is_active())
370 .then_some((channel, settings))
371 })
372 .collect();
373 (!by_channel.is_empty()).then_some(Self { by_channel })
374 }
375
376 fn for_channel(&self, channel: u8) -> Option<&ChannelFilterSettings> {
378 self.by_channel.get(&channel)
379 }
380}
381
382type CalibrationTable = Arc<Vec<(CalibrationSpec, String)>>;
385
386struct FilterInputRow {
388 timestamp_unix_ns: i64,
389 raw_value: f64,
390 calibration: usize,
392}
393
394const FILTER_LEAD_IN_S: f64 = filters::TEMPLATE_MEMORY_S;
397
398struct FilteredChannelRows {
406 groups: Vec<Vec<FileSpan>>,
408 slices: std::collections::VecDeque<(usize, i64, i64)>,
410 calibrations: CalibrationTable,
414 filter: ExportFilter<FilterInputRow>,
415 start_ns: i64,
416 end_ns: i64,
417 finished: bool,
418}
419
420impl FilteredChannelRows {
421 fn new(
427 root: &Path,
428 asset: u32,
429 channel: u8,
430 start: DateTime<Utc>,
431 end: DateTime<Utc>,
432 settings: &ChannelFilterSettings,
433 ) -> Result<Self> {
434 let start_ns = datetime_to_unix_ns(start);
435 let end_ns = datetime_to_unix_ns(end);
436 let lead_in = ChronoDuration::milliseconds((FILTER_LEAD_IN_S * 1000.0) as i64);
437 let settle = ChronoDuration::milliseconds(
438 ((filters::settle_seconds(settings) + 0.1) * 1000.0).ceil() as i64,
439 );
440 let (from, to) = (start - lead_in, end + settle);
441 let (from_ns, to_ns) = (datetime_to_unix_ns(from), datetime_to_unix_ns(to));
442 let mut groups = Vec::new();
443 let mut slices = std::collections::VecDeque::new();
444 for day in date_range(from.date_naive(), to.date_naive()) {
445 let day_dir = root
446 .join(format!("asset{asset:03}"))
447 .join(day.format("%Y-%m-%d").to_string())
448 .join(format!("ch{channel:02}"));
449 if !day_dir.exists() {
450 continue;
451 }
452 for group in overlapping_file_groups(&day_dir, from_ns, to_ns)? {
453 for (a, b) in merge_slices(&group, from_ns, to_ns) {
454 slices.push_back((groups.len(), a, b));
455 }
456 groups.push(group);
457 }
458 }
459 Ok(Self {
460 groups,
461 slices,
462 calibrations: Arc::new(Vec::new()),
463 filter: ExportFilter::new(settings.clone()),
464 start_ns,
465 end_ns,
466 finished: false,
467 })
468 }
469
470 fn next_batch(
476 &mut self,
477 ) -> Option<(Vec<filters::FilteredRow<FilterInputRow>>, CalibrationTable)> {
478 let mut out = Vec::new();
479 if let Some((group, from_ns, to_ns)) = self.slices.pop_front() {
480 let merged = read_merged_rows(&self.groups[group], from_ns, to_ns);
481 let indices: Vec<usize> = merged
484 .calibrations
485 .into_iter()
486 .map(|entry| {
487 let known = self.calibrations.iter().position(|(spec, id)| {
488 *id == entry.1 && same_formula(&spec.formula, &entry.0.formula)
489 });
490 known.unwrap_or_else(|| {
491 let table = Arc::make_mut(&mut self.calibrations);
492 table.push(entry);
493 table.len() - 1
494 })
495 })
496 .collect();
497 let table = &self.calibrations;
498 let calibrate =
499 |row: &FilterInputRow, value: f64| table[row.calibration].0.apply(value);
500 for (timestamp_unix_ns, raw_value, calibration) in merged.rows {
501 let row = FilterInputRow {
502 timestamp_unix_ns,
503 raw_value,
504 calibration: indices[calibration],
505 };
506 self.filter
507 .push(timestamp_unix_ns, raw_value, row, &calibrate, &mut out);
508 }
509 } else if !self.finished {
510 self.finished = true;
511 let table = &self.calibrations;
512 let calibrate =
513 |row: &FilterInputRow, value: f64| table[row.calibration].0.apply(value);
514 self.filter.finish(&calibrate, &mut out);
515 } else {
516 return None;
517 }
518 let (start_ns, end_ns) = (self.start_ns, self.end_ns);
519 out.retain(|r| (start_ns..=end_ns).contains(&r.row.timestamp_unix_ns));
520 Some((out, Arc::clone(&self.calibrations)))
521 }
522}
523
524fn default_format() -> ExportFormat {
526 ExportFormat::Csv
527}
528
529#[tokio::main]
530async fn main() -> Result<()> {
542 let mode = ExporterMode::from_env()?;
543 let listen_addr =
544 std::env::var("EXPORTER_ADDR").unwrap_or_else(|_| DEFAULT_EXPORTER_ADDR.into());
545 let parquet_root = std::env::var("PARQUET_DIR").unwrap_or_else(|_| "parquet".into());
546 let root_path = PathBuf::from(parquet_root);
547
548 if matches!(mode, ExporterMode::Direct | ExporterMode::Worker) && !root_path.exists() {
549 println!(
550 "[exporter] Warning: parquet directory '{}' does not exist.",
551 root_path.display()
552 );
553 }
554
555 match mode {
556 ExporterMode::Worker => run_worker(root_path).await,
557 ExporterMode::Direct => {
558 let state = AppState {
559 mode,
560 parquet_root: Arc::new(root_path),
561 };
562
563 let app = Router::new()
564 .route("/export", get(handle_ws))
565 .with_state(state);
566
567 println!(
568 "[exporter] mode={} listening on ws://{listen_addr}/export",
569 match mode {
570 ExporterMode::Direct => "direct",
571 ExporterMode::Worker => "worker",
572 }
573 );
574
575 let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
576 axum::serve(listener, app).await?;
577 Ok(())
578 }
579 }
580}
581
582async fn connect_nats_from_env() -> Result<async_nats::Client> {
593 let creds_path = std::env::var("NATS_CREDS_FILE").unwrap_or_else(|_| "apt.creds".into());
594 let opts = ConnectOptions::with_credentials_file(creds_path)
595 .await
596 .map_err(|e| anyhow!("failed to load NATS creds: {e}"))?;
597 let servers = nats_config::servers_from_env().map_err(|e| anyhow!("{e}"))?;
598 let client = opts
599 .connect(servers)
600 .await
601 .map_err(|e| anyhow!("NATS connect failed: {e}"))?;
602 Ok(client)
603}
604
605fn worker_box_id_from_env() -> Result<String> {
614 std::env::var("EXPORT_BOX_ID")
615 .or_else(|_| std::env::var("BOX_ID"))
616 .map(|value| sanitize_token(&value))
617 .map_err(|_| anyhow!("worker mode requires EXPORT_BOX_ID or BOX_ID"))
618}
619
620fn sanitize_token(raw: &str) -> String {
641 let mut out = String::with_capacity(raw.len());
642 for ch in raw.trim().chars() {
643 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
644 out.push(ch.to_ascii_lowercase());
645 } else if ch.is_whitespace() || ch == '.' || ch == '/' {
646 out.push('-');
647 }
648 }
649
650 let out = out.trim_matches('-').to_string();
651 if out.is_empty() {
652 "unknown".to_string()
653 } else {
654 out
655 }
656}
657
658async fn run_worker(parquet_root: PathBuf) -> Result<()> {
682 let client = connect_nats_from_env().await?;
683 let state = AppState {
684 mode: ExporterMode::Worker,
685 parquet_root: Arc::new(parquet_root),
686 };
687 let nats_subject = std::env::var("NATS_SUBJECT").unwrap_or_else(|_| "avenars".to_string());
688 let site_id = std::env::var("SITE_ID").unwrap_or_else(|_| "unknown-site".to_string());
689 let box_id = worker_box_id_from_env()?;
690 let source_type = std::env::var("SOURCE_TYPE").unwrap_or_else(|_| "labjack".to_string());
691 let source_id = std::env::var("SOURCE_ID").ok();
692 let labjack_name = std::env::var("LABJACK_NAME").ok();
693 let asset = std::env::var("ASSET_NUMBER")
694 .ok()
695 .and_then(|raw| raw.trim().parse::<u32>().ok());
696 let subject = subjects::archive_export_request_subject(
697 &nats_subject,
698 asset,
699 Some(&site_id),
700 Some(&box_id),
701 labjack_name.as_deref(),
702 Some(&source_type),
703 source_id.as_deref(),
704 );
705 let mut subscriber = client
706 .subscribe(subject.clone())
707 .await
708 .map_err(|e| anyhow!("failed to subscribe to export worker subject '{subject}': {e}"))?;
709
710 println!("[exporter] worker listening on NATS subject '{subject}'");
711
712 while let Some(message) = subscriber.next().await {
713 let client = client.clone();
714 let state = state.clone();
715 tokio::spawn(async move {
716 let Some(reply) = message.reply.clone() else {
717 eprintln!("[exporter] ignoring NATS export request without reply subject");
718 return;
719 };
720 if let Err(err) = process_nats_request(
721 client.clone(),
722 reply.clone(),
723 message.payload.to_vec(),
724 state,
725 )
726 .await
727 {
728 if let Some(cancelled) = err.downcast_ref::<ExportCancelled>() {
729 println!("[exporter] {cancelled}; stopped the export on {reply}");
730 return;
731 }
732 eprintln!("[exporter] worker request failed: {err:#}");
733 let _ = publish_nats_json(
734 &client,
735 reply,
736 EXPORT_FRAME_ERROR,
737 &json!({"type":"error","message": err.to_string()}),
738 )
739 .await;
740 }
741 });
742 }
743
744 Ok(())
745}
746
747async fn process_nats_request(
779 nc: async_nats::Client,
780 reply: async_nats::Subject,
781 payload: Vec<u8>,
782 state: AppState,
783) -> Result<()> {
784 let mut req: ExportRequest =
785 serde_json::from_slice(&payload).map_err(|e| anyhow!("invalid request payload: {e}"))?;
786
787 if req.channels.is_empty() {
788 publish_nats_json(
789 &nc,
790 reply,
791 EXPORT_FRAME_ERROR,
792 &json!({"type":"error","message":"no channels requested"}),
793 )
794 .await?;
795 return Ok(());
796 }
797 req.channels.sort_unstable();
798 req.channels.dedup();
799
800 let (start, end) = parse_range(&req.start, &req.end)?;
801 if end < start {
802 publish_nats_json(
803 &nc,
804 reply,
805 EXPORT_FRAME_ERROR,
806 &json!({"type":"error","message":"end must be after start"}),
807 )
808 .await?;
809 return Ok(());
810 }
811
812 if !matches!(req.format, ExportFormat::Csv) {
813 publish_nats_json(
814 &nc,
815 reply,
816 EXPORT_FRAME_ERROR,
817 &json!({"type":"error","message":"parquet streaming not yet supported"}),
818 )
819 .await?;
820 return Ok(());
821 }
822
823 let file_name = req.download_name.clone().unwrap_or_else(|| {
824 format!(
825 "labjack_asset{:03}_{}_{}.csv",
826 req.asset,
827 start.format("%Y%m%dT%H%M%S"),
828 end.format("%Y%m%dT%H%M%S"),
829 )
830 });
831
832 publish_nats_json(
833 &nc,
834 reply.clone(),
835 EXPORT_FRAME_META,
836 &json!({
837 "type": "meta",
838 "fileName": file_name,
839 "contentType": "text/csv"
840 }),
841 )
842 .await?;
843
844 let ack_sub = match req.ack_subject.as_deref().filter(|s| !s.trim().is_empty()) {
845 Some(subject) => Some(nc.subscribe(subject.to_string()).await?),
846 None => None,
847 };
848 let mut stream = NatsCsvStreamer::new(nc, reply, ack_sub, req.asset, start, end)
849 .with_filters(ExportFilters::from_request(&req));
850 let missing = stream
851 .stream_channels(&state.parquet_root, &req.channels)
852 .await?;
853 stream.finish(missing).await?;
854 Ok(())
855}
856
857async fn publish_nats_json(
870 nc: &async_nats::Client,
871 reply: async_nats::Subject,
872 frame: &str,
873 value: &serde_json::Value,
874) -> Result<()> {
875 let mut headers = HeaderMap::new();
876 headers.insert(EXPORT_FRAME_HEADER, frame);
877 nc.publish_with_headers(reply, headers, serde_json::to_vec(value)?.into())
878 .await?;
879 Ok(())
880}
881
882async fn handle_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
891 ws.on_upgrade(move |socket| async move {
892 if let Err(err) = process_socket(socket, state).await {
893 eprintln!("[exporter] websocket error: {err:#}");
894 }
895 })
896}
897
898async fn process_socket(mut socket: WebSocket, state: AppState) -> Result<()> {
916 let request = read_export_request(&mut socket).await?;
917
918 match state.mode {
919 ExporterMode::Direct => {
920 let mut sink = WebSocketSink::new(socket);
921 if let Err(err) = serve_export_request(&state.parquet_root, &mut sink, &request).await {
922 sink.send_error(&err.to_string()).await.ok();
923 sink.send_complete().await.ok();
924 sink.send_close().await.ok();
925 }
926 }
927 ExporterMode::Worker => {
928 return Err(anyhow!("worker mode does not serve websocket exports"));
929 }
930 }
931
932 Ok(())
933}
934
935async fn read_export_request(socket: &mut WebSocket) -> Result<ExportRequest> {
950 let Some(msg) = socket.next().await else {
951 return Err(anyhow!("websocket closed before export request"));
952 };
953
954 let Message::Text(text) = msg? else {
955 socket
956 .send(Message::Text(
957 json!({"type":"error","message":"expected JSON request"}).to_string(),
958 ))
959 .await
960 .ok();
961 return Err(anyhow!("expected JSON request"));
962 };
963
964 let req: ExportRequest =
965 serde_json::from_str(&text).map_err(|e| anyhow!("invalid request payload: {e}"))?;
966 Ok(req)
967}
968
969async fn serve_export_request<S: ExportSink + Send>(
988 parquet_root: &Path,
989 sink: &mut S,
990 req: &ExportRequest,
991) -> Result<()> {
992 let mut req = req.clone();
993
994 if req.channels.is_empty() {
995 return Err(anyhow!("no channels requested"));
996 }
997 req.channels.sort_unstable();
998 req.channels.dedup();
999
1000 let (start, end) = parse_range(&req.start, &req.end)?;
1001 if end < start {
1002 return Err(anyhow!("end must be after start"));
1003 }
1004
1005 if !matches!(req.format, ExportFormat::Csv) {
1006 return Err(anyhow!("parquet streaming not yet supported"));
1007 }
1008
1009 let file_name = req.download_name.clone().unwrap_or_else(|| {
1010 format!(
1011 "labjack_asset{:03}_{}_{}.csv",
1012 req.asset,
1013 start.format("%Y%m%dT%H%M%S"),
1014 end.format("%Y%m%dT%H%M%S"),
1015 )
1016 });
1017
1018 sink.send_meta(&file_name, "text/csv").await?;
1019
1020 let mut stream = CsvStreamer::new(sink, req.asset, start, end)
1021 .with_filters(ExportFilters::from_request(&req));
1022 let missing = stream.stream_channels(parquet_root, &req.channels).await?;
1023 stream.finish(missing).await?;
1024 Ok(())
1025}
1026
1027#[async_trait]
1028trait ExportSink {
1034 async fn send_meta(&mut self, file_name: &str, content_type: &str) -> Result<()>;
1041 async fn send_chunk(&mut self, data: Vec<u8>) -> Result<()>;
1047 async fn send_summary(&mut self, bytes_sent: usize, missing_channels: &[u8]) -> Result<()>;
1054 async fn send_complete(&mut self) -> Result<()>;
1056 async fn send_error(&mut self, message: &str) -> Result<()>;
1062}
1063
1064struct WebSocketSink {
1068 socket: WebSocket,
1070}
1071
1072impl WebSocketSink {
1073 fn new(socket: WebSocket) -> Self {
1079 Self { socket }
1080 }
1081
1082 async fn send_close(&mut self) -> Result<()> {
1088 self.socket.send(Message::Close(None)).await?;
1089 Ok(())
1090 }
1091}
1092
1093#[async_trait]
1094impl ExportSink for WebSocketSink {
1095 async fn send_meta(&mut self, file_name: &str, content_type: &str) -> Result<()> {
1097 self.socket
1098 .send(Message::Text(serde_json::to_string(&MetaFrame {
1099 frame_type: EXPORT_FRAME_META,
1100 file_name,
1101 content_type,
1102 })?))
1103 .await?;
1104 Ok(())
1105 }
1106
1107 async fn send_chunk(&mut self, data: Vec<u8>) -> Result<()> {
1109 self.socket.send(Message::Binary(data)).await?;
1110 Ok(())
1111 }
1112
1113 async fn send_summary(&mut self, bytes_sent: usize, missing_channels: &[u8]) -> Result<()> {
1115 self.socket
1116 .send(Message::Text(serde_json::to_string(&SummaryFrame {
1117 frame_type: EXPORT_FRAME_SUMMARY,
1118 bytes_sent,
1119 missing_channels,
1120 })?))
1121 .await?;
1122 Ok(())
1123 }
1124
1125 async fn send_complete(&mut self) -> Result<()> {
1127 self.socket
1128 .send(Message::Text(json!({"type":"complete"}).to_string()))
1129 .await?;
1130 Ok(())
1131 }
1132
1133 async fn send_error(&mut self, message: &str) -> Result<()> {
1135 self.socket
1136 .send(Message::Text(serde_json::to_string(&ErrorFrame {
1137 frame_type: EXPORT_FRAME_ERROR,
1138 message,
1139 })?))
1140 .await?;
1141 Ok(())
1142 }
1143}
1144
1145const CHUNK_SLACK: usize = 4096;
1148const CSV_CHUNKS_AHEAD: usize = 2;
1150
1151struct CsvJob {
1153 root: PathBuf,
1155 asset: u32,
1157 start: DateTime<Utc>,
1159 end: DateTime<Utc>,
1161 filters: Option<ExportFilters>,
1163 channels: Vec<u8>,
1165}
1166
1167fn spawn_csv(
1189 job: CsvJob,
1190 chunk_size: usize,
1191) -> (
1192 tokio::sync::mpsc::Receiver<Vec<u8>>,
1193 tokio::task::JoinHandle<Result<Vec<u8>>>,
1194) {
1195 let (tx, rx) = tokio::sync::mpsc::channel(CSV_CHUNKS_AHEAD);
1196 let handle = tokio::task::spawn_blocking(move || {
1197 let filtered = job.filters.is_some();
1198 let (read_tx, read_rx) = std::sync::mpsc::sync_channel(0);
1199 std::thread::Builder::new()
1200 .name("export-reader".into())
1201 .spawn(move || read_rows(&job, &read_tx))
1202 .context("failed to start the export reader thread")?;
1203 CsvBuilder::new(filtered, chunk_size, tx).run(read_rx)
1204 });
1205 (rx, handle)
1206}
1207
1208enum ReadItem {
1210 Channel(u8),
1212 Plain(MergedRows),
1214 Filtered(Vec<filters::FilteredRow<FilterInputRow>>, CalibrationTable),
1217 Failed(anyhow::Error),
1219 Done,
1221}
1222
1223fn read_rows(job: &CsvJob, tx: &std::sync::mpsc::SyncSender<ReadItem>) {
1238 let start_ns = datetime_to_unix_ns(job.start);
1239 let end_ns = datetime_to_unix_ns(job.end);
1240 let send = |item| tx.send(item).is_ok();
1241 for &channel in &job.channels {
1242 if !send(ReadItem::Channel(channel)) {
1243 return;
1244 }
1245 if let Some(settings) = job.filters.as_ref().and_then(|f| f.for_channel(channel)) {
1246 let mut rows = match FilteredChannelRows::new(
1247 &job.root, job.asset, channel, job.start, job.end, settings,
1248 ) {
1249 Ok(rows) => rows,
1250 Err(err) => {
1251 send(ReadItem::Failed(err));
1252 return;
1253 }
1254 };
1255 while let Some((batch, calibrations)) = rows.next_batch() {
1256 if !send(ReadItem::Filtered(batch, calibrations)) {
1257 return;
1258 }
1259 }
1260 continue;
1261 }
1262 for day in date_range(job.start.date_naive(), job.end.date_naive()) {
1263 let day_dir = job
1264 .root
1265 .join(format!("asset{:03}", job.asset))
1266 .join(day.format("%Y-%m-%d").to_string())
1267 .join(format!("ch{channel:02}"));
1268 if !day_dir.exists() {
1269 continue;
1270 }
1271 let groups = match overlapping_file_groups(&day_dir, start_ns, end_ns) {
1272 Ok(groups) => groups,
1273 Err(err) => {
1274 send(ReadItem::Failed(err));
1275 return;
1276 }
1277 };
1278 for group in groups {
1279 for (from_ns, to_ns) in merge_slices(&group, start_ns, end_ns) {
1280 if !send(ReadItem::Plain(read_merged_rows(&group, from_ns, to_ns))) {
1281 return;
1282 }
1283 }
1284 }
1285 }
1286 }
1287 send(ReadItem::Done);
1288}
1289
1290struct CsvBuilder {
1292 filtered: bool,
1294 chunk_size: usize,
1296 chunk: Vec<u8>,
1298 rows: CsvRowWriter,
1300 tx: tokio::sync::mpsc::Sender<Vec<u8>>,
1302}
1303
1304impl CsvBuilder {
1305 fn new(filtered: bool, chunk_size: usize, tx: tokio::sync::mpsc::Sender<Vec<u8>>) -> Self {
1308 let mut chunk = Vec::with_capacity(chunk_size + CHUNK_SLACK);
1309 chunk.extend_from_slice(if filtered {
1310 CSV_HEADER_FILTERED
1311 } else {
1312 CSV_HEADER
1313 });
1314 Self {
1315 filtered,
1316 chunk_size,
1317 chunk,
1318 rows: CsvRowWriter::new(),
1319 tx,
1320 }
1321 }
1322
1323 fn run(mut self, items: std::sync::mpsc::Receiver<ReadItem>) -> Result<Vec<u8>> {
1334 let plain = if self.filtered {
1335 FilteredColumn::Calibrated
1336 } else {
1337 FilteredColumn::Absent
1338 };
1339 let mut missing = Vec::new();
1340 let mut current: Option<(u8, bool)> = None;
1341 loop {
1342 let item = items
1343 .recv()
1344 .map_err(|_| anyhow!("export reader stopped unexpectedly"))?;
1345 match item {
1346 ReadItem::Channel(channel) => {
1347 if let Some((previous, false)) = current {
1348 missing.push(previous);
1349 }
1350 current = Some((channel, false));
1351 self.rows.set_channel(channel);
1352 }
1353 ReadItem::Plain(merged) => {
1354 if let Some((_, found)) = current.as_mut() {
1355 *found |= !merged.rows.is_empty();
1356 }
1357 for &(timestamp_unix_ns, raw_value, calibration) in &merged.rows {
1358 let (spec, calibration_id) = &merged.calibrations[calibration];
1359 self.rows.write_row(
1360 &mut self.chunk,
1361 timestamp_unix_ns,
1362 raw_value,
1363 spec,
1364 calibration_id,
1365 plain,
1366 );
1367 self.end_row()?;
1368 }
1369 }
1370 ReadItem::Filtered(batch, calibrations) => {
1371 if let Some((_, found)) = current.as_mut() {
1372 *found |= !batch.is_empty();
1373 }
1374 for filtered in batch {
1375 let row = filtered.row;
1376 let (spec, calibration_id) = &calibrations[row.calibration];
1377 self.rows.write_row(
1378 &mut self.chunk,
1379 row.timestamp_unix_ns,
1380 row.raw_value,
1381 spec,
1382 calibration_id,
1383 FilteredColumn::Value(filtered.filtered),
1384 );
1385 self.end_row()?;
1386 }
1387 }
1388 ReadItem::Failed(err) => {
1389 let channel = current.map_or(0, |(channel, _)| channel);
1390 return Err(anyhow!("channel {channel:02}: {err}"));
1391 }
1392 ReadItem::Done => break,
1393 }
1394 }
1395 if let Some((previous, false)) = current {
1396 missing.push(previous);
1397 }
1398 self.send_chunk()?;
1399 Ok(missing)
1400 }
1401
1402 fn send_chunk(&mut self) -> Result<()> {
1408 if self.chunk.is_empty() {
1409 return Ok(());
1410 }
1411 let next = Vec::with_capacity(self.chunk_size + CHUNK_SLACK);
1412 let data = std::mem::replace(&mut self.chunk, next);
1413 self.tx
1414 .blocking_send(data)
1415 .map_err(|_| anyhow!("export stopped"))
1416 }
1417
1418 fn end_row(&mut self) -> Result<()> {
1424 if self.chunk.len() >= self.chunk_size {
1425 self.send_chunk()?;
1426 }
1427 Ok(())
1428 }
1429}
1430
1431struct CsvStreamer<'a, S: ExportSink + Send> {
1437 sink: &'a mut S,
1439 bytes_sent: usize,
1441 asset: u32,
1443 start: DateTime<Utc>,
1445 end: DateTime<Utc>,
1447 filters: Option<ExportFilters>,
1449}
1450
1451struct NatsCsvStreamer {
1466 client: async_nats::Client,
1468 reply: async_nats::Subject,
1470 ack_sub: Option<async_nats::Subscriber>,
1472 chunks_since_flush: usize,
1474 unacked: usize,
1476 chunks_sent: usize,
1478 bytes_sent: usize,
1480 asset: u32,
1482 start: DateTime<Utc>,
1484 end: DateTime<Utc>,
1486 filters: Option<ExportFilters>,
1488}
1489
1490impl NatsCsvStreamer {
1491 const CHUNK_SIZE: usize = 512 * 1024;
1493 const FLUSH_EVERY_CHUNKS: usize = 8;
1496 const ACK_TIMEOUT_SECS: u64 = 30;
1498
1499 fn new(
1511 client: async_nats::Client,
1512 reply: async_nats::Subject,
1513 ack_sub: Option<async_nats::Subscriber>,
1514 asset: u32,
1515 start: DateTime<Utc>,
1516 end: DateTime<Utc>,
1517 ) -> Self {
1518 Self {
1519 client,
1520 reply,
1521 ack_sub,
1522 chunks_since_flush: 0,
1523 unacked: 0,
1524 chunks_sent: 0,
1525 bytes_sent: 0,
1526 asset,
1527 start,
1528 end,
1529 filters: None,
1530 }
1531 }
1532
1533 fn with_filters(mut self, filters: Option<ExportFilters>) -> Self {
1536 self.filters = filters;
1537 self
1538 }
1539
1540 async fn stream_channels(&mut self, parquet_root: &Path, channels: &[u8]) -> Result<Vec<u8>> {
1560 let (mut chunks, csv) = spawn_csv(
1561 CsvJob {
1562 root: parquet_root.to_path_buf(),
1563 asset: self.asset,
1564 start: self.start,
1565 end: self.end,
1566 filters: self.filters.clone(),
1567 channels: channels.to_vec(),
1568 },
1569 Self::CHUNK_SIZE,
1570 );
1571 while let Some(data) = chunks.recv().await {
1572 self.publish_chunk(data).await?;
1573 }
1574 csv.await.map_err(|e| anyhow!("CSV thread failed: {e}"))?
1575 }
1576
1577 async fn publish_chunk(&mut self, data: Vec<u8>) -> Result<()> {
1590 self.poll_ack_subject()?;
1591 let len = data.len();
1592 let mut headers = HeaderMap::new();
1593 headers.insert(EXPORT_FRAME_HEADER, EXPORT_FRAME_CHUNK);
1594 self.client
1595 .publish_with_headers(self.reply.clone(), headers, data.into())
1596 .await?;
1597 self.bytes_sent += len;
1598 self.chunks_sent += 1;
1599 self.chunks_since_flush += 1;
1600 if self.ack_sub.is_some() {
1601 self.unacked += 1;
1602 }
1603 if self.chunks_since_flush >= Self::FLUSH_EVERY_CHUNKS {
1604 self.client.flush().await?;
1605 self.chunks_since_flush = 0;
1606 }
1607 if self.unacked >= Self::FLUSH_EVERY_CHUNKS {
1608 self.wait_for_acks().await?;
1609 }
1610 Ok(())
1611 }
1612
1613 fn cancelled(&self) -> anyhow::Error {
1615 ExportCancelled {
1616 chunks_sent: self.chunks_sent,
1617 bytes_sent: self.bytes_sent,
1618 }
1619 .into()
1620 }
1621
1622 fn handle_ack_message(&mut self, message: &async_nats::Message) -> Result<()> {
1628 if is_cancel_message(message) {
1629 return Err(self.cancelled());
1630 }
1631 self.unacked = self.unacked.saturating_sub(1);
1632 Ok(())
1633 }
1634
1635 fn poll_ack_subject(&mut self) -> Result<()> {
1641 use futures_util::FutureExt as _;
1642 loop {
1643 let Some(ack_sub) = self.ack_sub.as_mut() else {
1644 return Ok(());
1645 };
1646 match ack_sub.next().now_or_never() {
1647 Some(Some(message)) => self.handle_ack_message(&message)?,
1648 _ => return Ok(()),
1649 }
1650 }
1651 }
1652
1653 async fn wait_for_acks(&mut self) -> Result<()> {
1666 if self.ack_sub.is_none() || self.unacked == 0 {
1667 return Ok(());
1668 }
1669 self.client.flush().await?;
1670 while self.unacked > 0 {
1671 let Some(ack_sub) = self.ack_sub.as_mut() else {
1672 return Ok(());
1673 };
1674 let message = tokio::time::timeout(
1675 std::time::Duration::from_secs(Self::ACK_TIMEOUT_SECS),
1676 ack_sub.next(),
1677 )
1678 .await
1679 .map_err(|_| anyhow!("timed out waiting for export chunk acknowledgement"))?
1680 .ok_or_else(|| anyhow!("export acknowledgement subscription closed"))?;
1681 self.handle_ack_message(&message)?;
1682 }
1683 Ok(())
1684 }
1685
1686 async fn finish(mut self, mut missing_channels: Vec<u8>) -> Result<()> {
1702 self.wait_for_acks().await?;
1703 missing_channels.sort_unstable();
1704 missing_channels.dedup();
1705 publish_nats_json(
1706 &self.client,
1707 self.reply.clone(),
1708 EXPORT_FRAME_SUMMARY,
1709 &json!({
1710 "type": "summary",
1711 "bytesSent": self.bytes_sent,
1712 "missingChannels": missing_channels
1713 }),
1714 )
1715 .await?;
1716 publish_nats_json(
1717 &self.client,
1718 self.reply.clone(),
1719 EXPORT_FRAME_COMPLETE,
1720 &json!({"type":"complete"}),
1721 )
1722 .await?;
1723 self.client.flush().await?;
1724 Ok(())
1725 }
1726}
1727
1728impl<'a, S: ExportSink + Send> CsvStreamer<'a, S> {
1729 const CHUNK_SIZE: usize = 128 * 1024;
1731
1732 fn new(sink: &'a mut S, asset: u32, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
1741 Self {
1742 sink,
1743 bytes_sent: 0,
1744 asset,
1745 start,
1746 end,
1747 filters: None,
1748 }
1749 }
1750
1751 fn with_filters(mut self, filters: Option<ExportFilters>) -> Self {
1754 self.filters = filters;
1755 self
1756 }
1757
1758 async fn stream_channels(&mut self, parquet_root: &Path, channels: &[u8]) -> Result<Vec<u8>> {
1778 let (mut chunks, csv) = spawn_csv(
1779 CsvJob {
1780 root: parquet_root.to_path_buf(),
1781 asset: self.asset,
1782 start: self.start,
1783 end: self.end,
1784 filters: self.filters.clone(),
1785 channels: channels.to_vec(),
1786 },
1787 Self::CHUNK_SIZE,
1788 );
1789 while let Some(data) = chunks.recv().await {
1790 self.bytes_sent += data.len();
1791 self.sink.send_chunk(data).await?;
1792 }
1793 csv.await.map_err(|e| anyhow!("CSV thread failed: {e}"))?
1794 }
1795
1796 async fn finish(self, mut missing_channels: Vec<u8>) -> Result<()> {
1809 missing_channels.sort_unstable();
1810 missing_channels.dedup();
1811 self.sink
1812 .send_summary(self.bytes_sent, &missing_channels)
1813 .await?;
1814 self.sink.send_complete().await?;
1815 Ok(())
1816 }
1817}
1818
1819struct Rfc3339Formatter {
1831 second: Option<i64>,
1833 prefix: String,
1835}
1836
1837impl Rfc3339Formatter {
1838 fn new() -> Self {
1840 Self {
1841 second: None,
1842 prefix: String::new(),
1843 }
1844 }
1845
1846 fn write(&mut self, out: &mut Vec<u8>, timestamp_unix_ns: i64) {
1863 let second = timestamp_unix_ns.div_euclid(1_000_000_000);
1864 let nanos = timestamp_unix_ns.rem_euclid(1_000_000_000) as u32;
1865 if self.second != Some(second) {
1866 let text = DateTime::<Utc>::from_timestamp(second, 0)
1868 .map(|instant| instant.to_rfc3339())
1869 .unwrap_or_default();
1870 self.prefix = text.trim_end_matches("+00:00").to_string();
1872 self.second = Some(second);
1873 }
1874 out.extend_from_slice(self.prefix.as_bytes());
1875 if nanos == 0 {
1876 } else if nanos.is_multiple_of(1_000_000) {
1877 push_fraction(out, nanos / 1_000_000, 3);
1878 } else if nanos.is_multiple_of(1_000) {
1879 push_fraction(out, nanos / 1_000, 6);
1880 } else {
1881 push_fraction(out, nanos, 9);
1882 }
1883 out.extend_from_slice(b"+00:00");
1884 }
1885}
1886
1887fn push_fraction(out: &mut Vec<u8>, mut value: u32, width: usize) {
1896 let mut text = *b".000000000";
1897 for digit in text[1..=width].iter_mut().rev() {
1898 *digit = b'0' + (value % 10) as u8;
1899 value /= 10;
1900 }
1901 out.extend_from_slice(&text[..=width]);
1902}
1903
1904#[derive(Clone, Copy)]
1906enum FilteredColumn {
1907 Absent,
1909 Calibrated,
1911 Value(f64),
1913}
1914
1915const VALUE_CACHE_SLOTS: usize = 4096;
1917const VALUE_CACHE_TEXT: usize = 48;
1919const VALUE_CACHE_WINDOW: u32 = 1024;
1921const VALUE_CACHE_BYPASS: u32 = 64 * 1024;
1924
1925#[derive(Clone, Copy)]
1927struct ValueSlot {
1928 bits: u64,
1930 generation: u32,
1932 raw_len: u8,
1934 len: u8,
1936 text: [u8; VALUE_CACHE_TEXT],
1938}
1939
1940struct ValueTextCache {
1955 slots: Vec<ValueSlot>,
1957 formula: CalibrationFormula,
1959 generation: u32,
1961 lookups: u32,
1963 hits: u32,
1965 bypass: u32,
1967}
1968
1969impl ValueTextCache {
1970 fn new() -> Self {
1972 Self {
1973 slots: Vec::new(),
1974 formula: CalibrationFormula::Identity,
1975 generation: 1,
1976 lookups: 0,
1977 hits: 0,
1978 bypass: 0,
1979 }
1980 }
1981
1982 fn write(&mut self, out: &mut Vec<u8>, raw: f64, spec: &CalibrationSpec) -> usize {
1989 let start = out.len();
1990 if self.bypass > 0 {
1991 self.bypass -= 1;
1992 return start + write_values(out, raw, spec) + 1;
1993 }
1994 self.lookups += 1;
1995 if self.lookups == VALUE_CACHE_WINDOW {
1996 if self.hits < VALUE_CACHE_WINDOW / 4 {
1997 self.bypass = VALUE_CACHE_BYPASS;
1998 }
1999 self.lookups = 0;
2000 self.hits = 0;
2001 }
2002 if !same_formula(&self.formula, &spec.formula) {
2003 self.formula = spec.formula.clone();
2004 self.generation = self.generation.wrapping_add(1);
2005 if self.generation == 0 {
2006 self.slots.clear();
2007 self.generation = 1;
2008 }
2009 }
2010 if self.slots.is_empty() {
2011 self.slots = vec![
2012 ValueSlot {
2013 bits: 0,
2014 generation: 0,
2015 raw_len: 0,
2016 len: 0,
2017 text: [0; VALUE_CACHE_TEXT],
2018 };
2019 VALUE_CACHE_SLOTS
2020 ];
2021 }
2022 let bits = raw.to_bits();
2023 let index =
2024 (bits.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> (64 - VALUE_CACHE_SLOTS.ilog2())) as usize;
2025 let slot = &mut self.slots[index];
2026 if slot.generation == self.generation && slot.bits == bits {
2027 self.hits += 1;
2028 out.extend_from_slice(&slot.text[..usize::from(slot.len)]);
2029 return start + usize::from(slot.raw_len) + 1;
2030 }
2031 let raw_len = write_values(out, raw, spec);
2032 let text = &out[start..];
2033 if text.len() <= VALUE_CACHE_TEXT {
2034 slot.bits = bits;
2035 slot.generation = self.generation;
2036 slot.raw_len = raw_len as u8;
2037 slot.len = text.len() as u8;
2038 slot.text[..text.len()].copy_from_slice(text);
2039 }
2040 start + raw_len + 1
2041 }
2042}
2043
2044fn write_values(out: &mut Vec<u8>, raw: f64, spec: &CalibrationSpec) -> usize {
2050 let start = out.len();
2051 let _ = write!(out, "{raw},{}", spec.apply(raw));
2052 out[start..].iter().position(|b| *b == b',').unwrap_or(0)
2054}
2055
2056fn same_formula(a: &CalibrationFormula, b: &CalibrationFormula) -> bool {
2059 let same = |x: &f64, y: &f64| x.to_bits() == y.to_bits();
2060 match (a, b) {
2061 (CalibrationFormula::Identity, CalibrationFormula::Identity) => true,
2062 (
2063 CalibrationFormula::Linear { a: a1, b: b1 },
2064 CalibrationFormula::Linear { a: a2, b: b2 },
2065 ) => same(a1, a2) && same(b1, b2),
2066 (
2067 CalibrationFormula::Polynomial { coeffs: c1 },
2068 CalibrationFormula::Polynomial { coeffs: c2 },
2069 ) => c1.len() == c2.len() && c1.iter().zip(c2).all(|(x, y)| same(x, y)),
2070 _ => false,
2071 }
2072}
2073
2074struct CsvRowWriter {
2084 timestamps: Rfc3339Formatter,
2086 values: ValueTextCache,
2088 channel_field: Vec<u8>,
2090}
2091
2092impl CsvRowWriter {
2093 fn new() -> Self {
2095 Self {
2096 timestamps: Rfc3339Formatter::new(),
2097 values: ValueTextCache::new(),
2098 channel_field: Vec::new(),
2099 }
2100 }
2101
2102 fn set_channel(&mut self, channel: u8) {
2104 self.channel_field.clear();
2105 let _ = write!(self.channel_field, ",ch{channel:02},");
2106 }
2107
2108 fn write_row(
2119 &mut self,
2120 out: &mut Vec<u8>,
2121 timestamp_unix_ns: i64,
2122 raw_value: f64,
2123 spec: &CalibrationSpec,
2124 calibration_id: &str,
2125 filtered: FilteredColumn,
2126 ) {
2127 self.timestamps.write(out, timestamp_unix_ns);
2128 out.extend_from_slice(&self.channel_field);
2129 let calibrated_start = self.values.write(out, raw_value, spec);
2130 let calibrated_end = out.len();
2131 out.push(b',');
2132 out.extend_from_slice(calibration_id.as_bytes());
2133 match filtered {
2134 FilteredColumn::Absent => {}
2135 FilteredColumn::Calibrated => {
2136 out.push(b',');
2137 if spec.apply(raw_value).is_finite() {
2138 out.extend_from_within(calibrated_start..calibrated_end);
2139 }
2140 }
2141 FilteredColumn::Value(value) => {
2142 out.push(b',');
2143 if value.is_finite() {
2144 let _ = write!(out, "{value}");
2145 }
2146 }
2147 }
2148 out.push(b'\n');
2149 }
2150}
2151
2152fn datetime_to_unix_ns(instant: DateTime<Utc>) -> i64 {
2162 instant
2163 .timestamp_nanos_opt()
2164 .unwrap_or(if instant.timestamp() < 0 {
2165 i64::MIN
2166 } else {
2167 i64::MAX
2168 })
2169}
2170
2171struct MatchedFile {
2173 calibration: CalibrationSpec,
2175 calibration_id: String,
2177 rows: Vec<(i64, f64)>,
2179}
2180
2181fn row_group_may_match(meta: &RowGroupMetaData, start_ns: i64, end_ns: i64) -> bool {
2193 match meta.column(0).statistics() {
2194 Some(Statistics::Int64(stats)) => match (stats.min_opt(), stats.max_opt()) {
2195 (Some(min), Some(max)) => *max >= start_ns && *min <= end_ns,
2196 _ => true,
2197 },
2198 _ => true,
2199 }
2200}
2201
2202fn read_column<T: DataType>(row_group: &dyn RowGroupReader, index: usize) -> Result<Vec<T::T>> {
2222 let rows = usize::try_from(row_group.metadata().num_rows()).unwrap_or(0);
2223 let mut reader = get_typed_column_reader::<T>(row_group.get_column_reader(index)?);
2224 let mut values = Vec::with_capacity(rows);
2225 loop {
2226 let (records, _, _) = reader.read_records(rows.max(1), None, None, &mut values)?;
2227 if records == 0 {
2228 break;
2229 }
2230 }
2231 Ok(values)
2232}
2233
2234fn read_matching_rows(path: &Path, start_ns: i64, end_ns: i64) -> Result<MatchedFile> {
2262 let file = fs::File::open(path)
2263 .with_context(|| format!("failed to open parquet file {}", path.display()))?;
2264 let reader = SerializedFileReader::new(file)
2265 .with_context(|| format!("failed to create reader for {}", path.display()))?;
2266 let calibration = read_calibration_from_metadata(&reader, path);
2267 let calibration_id = calibration.id_or_default().to_string();
2268
2269 let mut rows = Vec::new();
2270 for index in 0..reader.num_row_groups() {
2271 if !row_group_may_match(reader.metadata().row_group(index), start_ns, end_ns) {
2272 continue;
2273 }
2274 let row_group = reader.get_row_group(index)?;
2275 let timestamps = read_column::<Int64Type>(row_group.as_ref(), 0)?;
2276 let values = read_column::<DoubleType>(row_group.as_ref(), 1)?;
2277 if timestamps.len() != values.len() {
2278 return Err(anyhow!(
2279 "row group {index} in {} has {} timestamps but {} values",
2280 path.display(),
2281 timestamps.len(),
2282 values.len()
2283 ));
2284 }
2285 rows.extend(
2286 timestamps
2287 .into_iter()
2288 .zip(values)
2289 .filter(|(ts, _)| *ts >= start_ns && *ts <= end_ns),
2290 );
2291 }
2292
2293 Ok(MatchedFile {
2294 calibration,
2295 calibration_id,
2296 rows,
2297 })
2298}
2299
2300fn read_calibration_from_metadata(
2316 reader: &SerializedFileReader<fs::File>,
2317 path: &Path,
2318) -> CalibrationSpec {
2319 let Some(kv) = reader.metadata().file_metadata().key_value_metadata() else {
2320 return CalibrationSpec::default();
2321 };
2322
2323 let mut calibration_json = None;
2324 for item in kv {
2325 if item.key == "calibration" {
2326 calibration_json = item.value.as_deref();
2327 break;
2328 }
2329 }
2330
2331 let Some(json) = calibration_json else {
2332 return CalibrationSpec::default();
2333 };
2334
2335 match serde_json::from_str::<CalibrationSpec>(json) {
2336 Ok(spec) => spec,
2337 Err(err) => {
2338 eprintln!(
2339 "[exporter] invalid calibration metadata in {}: {err}",
2340 path.display()
2341 );
2342 CalibrationSpec::default()
2343 }
2344 }
2345}
2346
2347const MERGE_SLICE_NS: i64 = 300 * 1_000_000_000;
2353
2354struct FileSpan {
2356 path: PathBuf,
2358 min_ns: i64,
2360 max_ns: i64,
2362}
2363
2364struct MergedRows {
2366 calibrations: Vec<(CalibrationSpec, String)>,
2368 rows: Vec<(i64, f64, usize)>,
2370}
2371
2372fn file_time_span(path: &Path) -> Result<Option<(i64, i64)>> {
2391 let file = fs::File::open(path)
2392 .with_context(|| format!("failed to open parquet file {}", path.display()))?;
2393 let reader = SerializedFileReader::new(file)
2394 .with_context(|| format!("failed to create reader for {}", path.display()))?;
2395 let mut span: Option<(i64, i64)> = None;
2396 let mut widen = |min: i64, max: i64| {
2397 span = Some(match span {
2398 Some((lo, hi)) => (lo.min(min), hi.max(max)),
2399 None => (min, max),
2400 });
2401 };
2402 for index in 0..reader.num_row_groups() {
2403 let meta = reader.metadata().row_group(index);
2404 if meta.num_rows() == 0 {
2405 continue;
2406 }
2407 let from_statistics = match meta.column(0).statistics() {
2408 Some(Statistics::Int64(stats)) => stats.min_opt().zip(stats.max_opt()),
2409 _ => None,
2410 };
2411 if let Some((min, max)) = from_statistics {
2412 widen(*min, *max);
2413 continue;
2414 }
2415 let row_group = reader.get_row_group(index)?;
2416 let timestamps = read_column::<Int64Type>(row_group.as_ref(), 0)?;
2417 if let (Some(min), Some(max)) = (timestamps.iter().min(), timestamps.iter().max()) {
2418 widen(*min, *max);
2419 }
2420 }
2421 Ok(span)
2422}
2423
2424fn overlapping_file_groups(
2452 day_dir: &Path,
2453 start_ns: i64,
2454 end_ns: i64,
2455) -> Result<Vec<Vec<FileSpan>>> {
2456 let mut spans = Vec::new();
2457 for entry in fs::read_dir(day_dir)?.filter_map(|entry| entry.ok()) {
2458 let path = entry.path();
2459 let is_parquet = path
2460 .file_name()
2461 .and_then(|name| name.to_str())
2462 .map(|name| name.ends_with(".parquet"))
2463 .unwrap_or(false);
2464 if !is_parquet {
2465 continue;
2466 }
2467 match file_time_span(&path) {
2468 Ok(Some((min_ns, max_ns))) if max_ns >= start_ns && min_ns <= end_ns => {
2469 spans.push(FileSpan {
2470 path,
2471 min_ns,
2472 max_ns,
2473 });
2474 }
2475 Ok(_) => {}
2476 Err(err) => {
2477 eprintln!("[exporter] skipping {} due to error: {err}", path.display());
2478 }
2479 }
2480 }
2481 spans.sort_by(|a, b| (a.min_ns, &a.path).cmp(&(b.min_ns, &b.path)));
2482
2483 let mut groups: Vec<Vec<FileSpan>> = Vec::new();
2484 let mut group_max = i64::MIN;
2485 for span in spans {
2486 match groups.last_mut() {
2487 Some(group) if span.min_ns <= group_max => {
2488 group_max = group_max.max(span.max_ns);
2489 group.push(span);
2490 }
2491 _ => {
2492 group_max = span.max_ns;
2493 groups.push(vec![span]);
2494 }
2495 }
2496 }
2497 for group in &mut groups {
2498 group.sort_by(|a, b| a.path.cmp(&b.path));
2499 }
2500 Ok(groups)
2501}
2502
2503fn merge_slices(group: &[FileSpan], start_ns: i64, end_ns: i64) -> Vec<(i64, i64)> {
2521 let group_min = group.iter().map(|f| f.min_ns).min().unwrap_or(i64::MAX);
2522 let group_max = group.iter().map(|f| f.max_ns).max().unwrap_or(i64::MIN);
2523 let from = group_min.max(start_ns);
2524 let to = group_max.min(end_ns);
2525 if from > to {
2526 return Vec::new();
2527 }
2528 if group.len() == 1 {
2529 return vec![(from, to)];
2530 }
2531
2532 let mut slices = Vec::new();
2533 let mut slice = from.div_euclid(MERGE_SLICE_NS);
2534 loop {
2535 let slice_start = slice.saturating_mul(MERGE_SLICE_NS).max(from);
2536 let slice_end = slice
2537 .saturating_add(1)
2538 .saturating_mul(MERGE_SLICE_NS)
2539 .saturating_sub(1)
2540 .min(to);
2541 slices.push((slice_start, slice_end));
2542 if slice_end >= to {
2543 break;
2544 }
2545 slice += 1;
2546 }
2547 slices
2548}
2549
2550fn calibration_preference(spec: &CalibrationSpec) -> u8 {
2568 match spec.formula {
2569 CalibrationFormula::Identity => 0,
2570 _ => 1,
2571 }
2572}
2573
2574fn sort_and_drop_duplicates(
2592 rows: &mut Vec<(i64, f64, usize)>,
2593 calibrations: &[(CalibrationSpec, String)],
2594) {
2595 if !rows.is_sorted_by_key(|row| row.0) {
2596 rows.sort_by_key(|row| row.0);
2597 }
2598 if rows.windows(2).all(|pair| pair[0].0 != pair[1].0) {
2600 return;
2601 }
2602 let preference = |index: usize| calibration_preference(&calibrations[index].0);
2603
2604 let mut kept: Vec<(i64, f64, usize)> = Vec::with_capacity(rows.len());
2605 let mut run_start = 0;
2606 while run_start < rows.len() {
2607 let timestamp = rows[run_start].0;
2608 let mut run_end = run_start + 1;
2609 while run_end < rows.len() && rows[run_end].0 == timestamp {
2610 run_end += 1;
2611 }
2612 let first_kept = kept.len();
2613 for &(ts, value, calibration) in &rows[run_start..run_end] {
2614 match kept[first_kept..]
2616 .iter_mut()
2617 .find(|row| row.1.to_bits() == value.to_bits())
2618 {
2619 Some(row) => {
2620 if preference(calibration) > preference(row.2) {
2621 row.2 = calibration;
2622 }
2623 }
2624 None => kept.push((ts, value, calibration)),
2625 }
2626 }
2627 run_start = run_end;
2628 }
2629 *rows = kept;
2630}
2631
2632fn read_merged_rows(group: &[FileSpan], from_ns: i64, to_ns: i64) -> MergedRows {
2644 let mut calibrations = Vec::new();
2645 let mut rows = Vec::new();
2646 for file in group {
2647 if file.max_ns < from_ns || file.min_ns > to_ns {
2648 continue;
2649 }
2650 match read_matching_rows(&file.path, from_ns, to_ns) {
2651 Ok(matched) => {
2652 let index = calibrations.len();
2653 calibrations.push((matched.calibration, matched.calibration_id));
2654 rows.extend(matched.rows.into_iter().map(|(ts, v)| (ts, v, index)));
2655 }
2656 Err(err) => {
2657 eprintln!(
2658 "[exporter] skipping {} due to error: {err}",
2659 file.path.display()
2660 );
2661 }
2662 }
2663 }
2664 sort_and_drop_duplicates(&mut rows, &calibrations);
2665 MergedRows { calibrations, rows }
2666}
2667
2668fn parse_range(start: &str, end: &str) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
2681 let start = DateTime::parse_from_rfc3339(start)
2682 .map_err(|e| anyhow!("invalid start timestamp: {e}"))?
2683 .with_timezone(&Utc);
2684 let end = DateTime::parse_from_rfc3339(end)
2685 .map_err(|e| anyhow!("invalid end timestamp: {e}"))?
2686 .with_timezone(&Utc);
2687 Ok((start, end))
2688}
2689
2690fn date_range(start: NaiveDate, end: NaiveDate) -> Vec<NaiveDate> {
2703 let mut days = Vec::new();
2704 let mut current = start;
2705 while current <= end {
2706 days.push(current);
2707 current += ChronoDuration::days(1);
2708 }
2709 days
2710}
2711
2712#[cfg(test)]
2713mod tests {
2714 use super::*;
2715 use parquet::{
2716 column::writer::ColumnWriter,
2717 file::{metadata::KeyValue, properties::WriterProperties, writer::SerializedFileWriter},
2718 record::RowAccessor,
2719 schema::parser::parse_message_type,
2720 };
2721 use std::time::Instant;
2722
2723 struct ExportRun {
2725 frames: Vec<String>,
2727 chunks_after_cancel: usize,
2729 result: Result<()>,
2731 elapsed: std::time::Duration,
2733 }
2734
2735 #[derive(Clone, Copy)]
2737 enum ClientEnd {
2738 AckAll,
2740 CancelAfter(usize),
2743 CancelWhileBlocked,
2746 }
2747
2748 async fn run_worker_export(server: &crate::test_nats::TestNats, end: ClientEnd) -> ExportRun {
2750 let root = std::env::temp_dir().join(format!("exporter-cancel-{}", uuid::Uuid::new_v4()));
2751 let dir = root
2752 .join("asset1001")
2753 .join(at(DAY_START).date_naive().format("%Y-%m-%d").to_string())
2754 .join("ch08");
2755 write_part(&dir, 1, &samples(0, 150_000), IDENTITY);
2756
2757 let client = async_nats::connect(&server.url).await.unwrap();
2758 let reply = client.new_inbox();
2759 let ack_subject = client.new_inbox();
2760 let mut replies = client.subscribe(reply.clone()).await.unwrap();
2761 client.flush().await.unwrap();
2762 let request = json!({
2763 "asset": 1001,
2764 "channels": [8],
2765 "start": at(DAY_START).to_rfc3339(),
2766 "end": at(DAY_START + 86_000 * 1_000_000_000).to_rfc3339(),
2767 "ack_subject": ack_subject,
2768 });
2769 let state = AppState {
2770 mode: ExporterMode::Worker,
2771 parquet_root: Arc::new(root.clone()),
2772 };
2773 let worker = tokio::spawn(process_nats_request(
2774 client.clone(),
2775 reply.into(),
2776 serde_json::to_vec(&request).unwrap(),
2777 state,
2778 ));
2779
2780 let mut frames = Vec::new();
2781 let mut chunks = 0usize;
2782 let mut cancelled_at: Option<Instant> = None;
2783 let mut chunks_after_cancel = 0usize;
2784 let started = Instant::now();
2785 let send_cancel = |header: bool| {
2786 let client = client.clone();
2787 let subject = ack_subject.clone();
2788 async move {
2789 if header {
2790 let mut headers = HeaderMap::new();
2791 headers.insert(EXPORT_FRAME_HEADER, EXPORT_FRAME_CANCEL);
2792 client
2793 .publish_with_headers(subject, headers, "".into())
2794 .await
2795 .unwrap();
2796 } else {
2797 client
2798 .publish(subject, r#"{"type":"cancel"}"#.into())
2799 .await
2800 .unwrap();
2801 }
2802 client.flush().await.unwrap();
2803 }
2804 };
2805 loop {
2806 let next =
2807 tokio::time::timeout(std::time::Duration::from_secs(2), replies.next()).await;
2808 let Ok(Some(message)) = next else {
2809 if let (ClientEnd::CancelWhileBlocked, None) = (end, cancelled_at) {
2811 send_cancel(false).await;
2812 cancelled_at = Some(Instant::now());
2813 continue;
2814 }
2815 break;
2816 };
2817 let frame = message
2818 .headers
2819 .as_ref()
2820 .and_then(|h| h.get(EXPORT_FRAME_HEADER))
2821 .map(|v| v.as_str().to_string())
2822 .unwrap_or_default();
2823 frames.push(frame.clone());
2824 if frame == EXPORT_FRAME_COMPLETE || frame == EXPORT_FRAME_ERROR {
2825 break;
2826 }
2827 if frame != EXPORT_FRAME_CHUNK {
2828 continue;
2829 }
2830 chunks += 1;
2831 if cancelled_at.is_some() {
2832 chunks_after_cancel += 1;
2833 continue;
2834 }
2835 match end {
2836 ClientEnd::CancelAfter(n) if chunks == n => {
2837 send_cancel(true).await;
2838 cancelled_at = Some(Instant::now());
2839 }
2840 ClientEnd::CancelWhileBlocked if chunks > 8 => {}
2841 _ => {
2842 client
2843 .publish(ack_subject.clone(), "".into())
2844 .await
2845 .unwrap();
2846 }
2847 }
2848 }
2849 let result = tokio::time::timeout(std::time::Duration::from_secs(10), worker)
2850 .await
2851 .expect("exporter returns without waiting for its ack timeout")
2852 .unwrap();
2853 let elapsed = cancelled_at.unwrap_or(started).elapsed();
2854 fs::remove_dir_all(root).unwrap();
2855 ExportRun {
2856 frames,
2857 chunks_after_cancel,
2858 result,
2859 elapsed,
2860 }
2861 }
2862
2863 #[tokio::test]
2865 async fn worker_export_completes_for_a_client_that_only_acks() {
2866 let Some(server) = crate::test_nats::TestNats::start("export_ack_all") else {
2867 return;
2868 };
2869 let run = run_worker_export(&server, ClientEnd::AckAll).await;
2870 run.result.expect("export succeeds");
2871 let chunks = run.frames.iter().filter(|f| *f == "chunk").count();
2872 assert!(
2873 chunks > 16,
2874 "the test export spans several ack rounds: {chunks}"
2875 );
2876 assert_eq!(run.frames.first().map(String::as_str), Some("meta"));
2877 assert_eq!(
2878 &run.frames[run.frames.len() - 2..],
2879 ["summary".to_string(), "complete".to_string()]
2880 );
2881 }
2882
2883 #[tokio::test]
2886 async fn worker_export_stops_on_cancel_while_streaming() {
2887 let Some(server) = crate::test_nats::TestNats::start("export_cancel") else {
2888 return;
2889 };
2890 let run = run_worker_export(&server, ClientEnd::CancelAfter(3)).await;
2891 let err = run.result.expect_err("export is cancelled");
2892 let cancelled = err.downcast_ref::<ExportCancelled>().expect("cancel error");
2893 assert!(cancelled.chunks_sent < 3 + NatsCsvStreamer::FLUSH_EVERY_CHUNKS);
2896 assert!(run.chunks_after_cancel < NatsCsvStreamer::FLUSH_EVERY_CHUNKS);
2897 assert!(!run.frames.iter().any(|f| f == "summary" || f == "complete"));
2898 assert!(run.elapsed < std::time::Duration::from_secs(5));
2899 }
2900
2901 #[tokio::test]
2904 async fn worker_export_stops_on_cancel_while_waiting_for_acks() {
2905 let Some(server) = crate::test_nats::TestNats::start("export_cancel_blocked") else {
2906 return;
2907 };
2908 let run = run_worker_export(&server, ClientEnd::CancelWhileBlocked).await;
2909 let err = run.result.expect_err("export is cancelled");
2910 let cancelled = err.downcast_ref::<ExportCancelled>().expect("cancel error");
2911 assert_eq!(
2912 cancelled.chunks_sent,
2913 2 * NatsCsvStreamer::FLUSH_EVERY_CHUNKS
2914 );
2915 assert_eq!(run.chunks_after_cancel, 0);
2916 assert!(run.elapsed < std::time::Duration::from_secs(5));
2917 }
2918
2919 fn reference_rows(path: &Path, start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<(i64, f64)> {
2921 let reader = SerializedFileReader::new(fs::File::open(path).unwrap()).unwrap();
2922 let mut out = Vec::new();
2923 for row in reader.get_row_iter(None).unwrap() {
2924 let row = row.unwrap();
2925 let ts_ns = row.get_long(0).unwrap();
2926 let text = DateTime::<Utc>::from_timestamp_nanos(ts_ns).to_rfc3339();
2927 let parsed = DateTime::parse_from_rfc3339(&text)
2928 .unwrap()
2929 .with_timezone(&Utc);
2930 if parsed < start || parsed > end {
2931 continue;
2932 }
2933 out.push((ts_ns, row.get_double(1).unwrap()));
2934 }
2935 out
2936 }
2937
2938 fn write_file(path: &Path, row_groups: &[Vec<(i64, f64)>]) {
2941 let schema = Arc::new(
2942 parse_message_type(
2943 "message schema { REQUIRED INT64 timestamp_unix_ns; REQUIRED DOUBLE value; }",
2944 )
2945 .unwrap(),
2946 );
2947 let props = Arc::new(WriterProperties::builder().build());
2948 let mut writer =
2949 SerializedFileWriter::new(fs::File::create(path).unwrap(), schema, props).unwrap();
2950 for group in row_groups {
2951 let mut rg = writer.next_row_group().unwrap();
2952 let ts: Vec<i64> = group.iter().map(|(t, _)| *t).collect();
2953 let vs: Vec<f64> = group.iter().map(|(_, v)| *v).collect();
2954 let mut col = rg.next_column().unwrap().unwrap();
2955 if let ColumnWriter::Int64ColumnWriter(w) = col.untyped() {
2956 w.write_batch(&ts, None, None).unwrap();
2957 }
2958 col.close().unwrap();
2959 let mut col = rg.next_column().unwrap().unwrap();
2960 if let ColumnWriter::DoubleColumnWriter(w) = col.untyped() {
2961 w.write_batch(&vs, None, None).unwrap();
2962 }
2963 col.close().unwrap();
2964 rg.close().unwrap();
2965 }
2966 writer.close().unwrap();
2967 }
2968
2969 fn at(ns: i64) -> DateTime<Utc> {
2971 DateTime::<Utc>::from_timestamp_nanos(ns)
2972 }
2973
2974 #[derive(Default)]
2976 struct CollectingSink {
2977 chunks: Vec<Vec<u8>>,
2979 }
2980
2981 #[async_trait]
2982 impl ExportSink for CollectingSink {
2983 async fn send_meta(&mut self, _file_name: &str, _content_type: &str) -> Result<()> {
2984 Ok(())
2985 }
2986 async fn send_chunk(&mut self, data: Vec<u8>) -> Result<()> {
2987 self.chunks.push(data);
2988 Ok(())
2989 }
2990 async fn send_summary(&mut self, _bytes_sent: usize, _missing: &[u8]) -> Result<()> {
2991 Ok(())
2992 }
2993 async fn send_complete(&mut self) -> Result<()> {
2994 Ok(())
2995 }
2996 async fn send_error(&mut self, _message: &str) -> Result<()> {
2997 Ok(())
2998 }
2999 }
3000
3001 async fn export_chunks(
3003 root: &Path,
3004 asset: u32,
3005 channels: &[u8],
3006 start: i64,
3007 end: i64,
3008 ) -> Vec<Vec<u8>> {
3009 let mut sink = CollectingSink::default();
3010 let mut streamer = CsvStreamer::new(&mut sink, asset, at(start), at(end));
3011 let missing = streamer.stream_channels(root, channels).await.unwrap();
3012 streamer.finish(missing).await.unwrap();
3013 sink.chunks
3014 }
3015
3016 fn export_without_merging(
3019 root: &Path,
3020 asset: u32,
3021 channels: &[u8],
3022 start: i64,
3023 end: i64,
3024 ) -> Vec<u8> {
3025 let mut csv = b"timestamp,channel,raw_value,calibrated_value,calibration_id\n".to_vec();
3026 for &channel in channels {
3027 for day in date_range(at(start).date_naive(), at(end).date_naive()) {
3028 let day_dir = root
3029 .join(format!("asset{asset:03}"))
3030 .join(day.format("%Y-%m-%d").to_string())
3031 .join(format!("ch{channel:02}"));
3032 let Ok(entries) = fs::read_dir(&day_dir) else {
3033 continue;
3034 };
3035 let mut files: Vec<PathBuf> = entries
3036 .map(|e| e.unwrap().path())
3037 .filter(|p| p.to_string_lossy().ends_with(".parquet"))
3038 .collect();
3039 files.sort();
3040 for path in files {
3041 let matched = read_matching_rows(&path, start, end).unwrap();
3042 let mut formatter = Rfc3339Formatter::new();
3043 for (ts, raw) in matched.rows {
3044 let cal = matched.calibration.apply(raw);
3045 let id = &matched.calibration_id;
3046 writeln!(
3047 csv,
3048 "{},ch{channel:02},{raw},{cal},{id}",
3049 format_ts(&mut formatter, ts)
3050 )
3051 .unwrap();
3052 }
3053 }
3054 }
3055 }
3056 csv
3057 }
3058
3059 fn write_part(dir: &Path, index: usize, rows: &[(i64, f64)], calibration: &str) {
3061 fs::create_dir_all(dir).unwrap();
3062 let schema = Arc::new(
3063 parse_message_type(
3064 "message schema { REQUIRED INT64 timestamp_unix_ns; REQUIRED DOUBLE value; }",
3065 )
3066 .unwrap(),
3067 );
3068 let props = Arc::new(
3069 WriterProperties::builder()
3070 .set_key_value_metadata(Some(vec![KeyValue::new(
3071 "calibration".to_string(),
3072 calibration.to_string(),
3073 )]))
3074 .build(),
3075 );
3076 let path = dir.join(format!("part-{index:04}.parquet"));
3077 let mut writer =
3078 SerializedFileWriter::new(fs::File::create(path).unwrap(), schema, props).unwrap();
3079 let mut rg = writer.next_row_group().unwrap();
3080 let ts: Vec<i64> = rows.iter().map(|(t, _)| *t).collect();
3081 let vs: Vec<f64> = rows.iter().map(|(_, v)| *v).collect();
3082 let mut col = rg.next_column().unwrap().unwrap();
3083 if let ColumnWriter::Int64ColumnWriter(w) = col.untyped() {
3084 w.write_batch(&ts, None, None).unwrap();
3085 }
3086 col.close().unwrap();
3087 let mut col = rg.next_column().unwrap().unwrap();
3088 if let ColumnWriter::DoubleColumnWriter(w) = col.untyped() {
3089 w.write_batch(&vs, None, None).unwrap();
3090 }
3091 col.close().unwrap();
3092 rg.close().unwrap();
3093 writer.close().unwrap();
3094 }
3095
3096 const DAY_START: i64 = 1_790_035_200_000_000_000;
3098 const STEP: i64 = 100_000_000;
3100 const IDENTITY: &str = r#"{"id":null,"type":"identity"}"#;
3102 const LINEAR: &str = r#"{"id":"tp3586","type":"linear","a":62.5,"b":-25.0}"#;
3104
3105 fn samples(from: i64, count: i64) -> Vec<(i64, f64)> {
3107 (from..from + count)
3108 .map(|i| (DAY_START + i * STEP, 0.4 + (i % 997) as f64 * 1e-4))
3109 .collect()
3110 }
3111
3112 #[tokio::test]
3115 async fn export_without_overlaps_is_unchanged() {
3116 let root = std::env::temp_dir().join(format!("exporter-clean-{}", uuid::Uuid::new_v4()));
3117 let day = |d: i64| {
3118 let date = at(DAY_START + d * 86_400_000_000_000).date_naive();
3119 root.join("asset1001")
3120 .join(date.format("%Y-%m-%d").to_string())
3121 };
3122 let window = 3_000; write_part(&day(0).join("ch08"), 1, &samples(0, window), IDENTITY);
3126 write_part(&day(0).join("ch08"), 2, &samples(window, window), LINEAR);
3127 write_part(
3128 &day(0).join("ch08"),
3129 3,
3130 &samples(2 * window, 1_000),
3131 IDENTITY,
3132 );
3133 write_part(&day(1).join("ch08"), 1, &samples(864_000, 500), IDENTITY);
3134 write_part(&day(0).join("ch09"), 1, &samples(100, 700), LINEAR);
3135
3136 for (start, end) in [
3137 (DAY_START - STEP, DAY_START + 2 * 86_400_000_000_000),
3138 (DAY_START + 1_234 * STEP, DAY_START + 7_000 * STEP),
3139 (DAY_START + 3_000 * STEP, DAY_START + 3_000 * STEP),
3140 ] {
3141 let chunks = export_chunks(&root, 1001, &[8, 9], start, end).await;
3142 let want = export_without_merging(&root, 1001, &[8, 9], start, end);
3143 assert!(
3144 want.len() > 3 * CsvStreamer::<CollectingSink>::CHUNK_SIZE || start > DAY_START
3145 );
3146 assert_eq!(chunks.concat(), want, "range {start}..{end}");
3147 let sizes: Vec<usize> = chunks.iter().map(Vec::len).collect();
3149 let mut expected_sizes = Vec::new();
3150 let mut pending = 0;
3151 for line in want.split_inclusive(|b| *b == b'\n') {
3152 pending += line.len();
3153 if pending >= CsvStreamer::<CollectingSink>::CHUNK_SIZE {
3154 expected_sizes.push(pending);
3155 pending = 0;
3156 }
3157 }
3158 if pending > 0 {
3159 expected_sizes.push(pending);
3160 }
3161 assert_eq!(sizes, expected_sizes);
3162 }
3163 fs::remove_dir_all(root).unwrap();
3164 }
3165
3166 #[cfg(unix)]
3169 #[tokio::test]
3170 async fn unreadable_day_folder_fails_after_the_completed_chunks() {
3171 use std::os::unix::fs::PermissionsExt as _;
3172 let root = std::env::temp_dir().join(format!("exporter-unread-{}", uuid::Uuid::new_v4()));
3173 let day = root
3174 .join("asset1001")
3175 .join(at(DAY_START).date_naive().format("%Y-%m-%d").to_string());
3176 write_part(&day.join("ch08"), 1, &samples(0, 3_000), IDENTITY);
3177 fs::create_dir_all(day.join("ch09")).unwrap();
3178 fs::set_permissions(day.join("ch09"), fs::Permissions::from_mode(0o000)).unwrap();
3179 let listable = fs::read_dir(day.join("ch09")).is_ok(); let (start, end) = (DAY_START, DAY_START + 3_000 * STEP);
3181 let mut sink = CollectingSink::default();
3182 let mut streamer = CsvStreamer::new(&mut sink, 1001, at(start), at(end));
3183 let result = streamer.stream_channels(&root, &[8, 9]).await;
3184 fs::set_permissions(day.join("ch09"), fs::Permissions::from_mode(0o755)).unwrap();
3185 if !listable {
3186 let err = result.expect_err("listing ch09 fails");
3187 assert!(err.to_string().starts_with("channel 09: "), "{err}");
3188 let want = export_without_merging(&root, 1001, &[8], start, end);
3189 let full = want.len() / CsvStreamer::<CollectingSink>::CHUNK_SIZE;
3190 assert!(full >= 1);
3191 assert_eq!(sink.chunks.len(), full);
3192 assert!(want.starts_with(&sink.chunks.concat()));
3193 }
3194 fs::remove_dir_all(root).unwrap();
3195 }
3196
3197 #[tokio::test]
3200 async fn overlapping_duplicate_files_are_exported_once() {
3201 let root = std::env::temp_dir().join(format!("exporter-dupes-{}", uuid::Uuid::new_v4()));
3202 let date = at(DAY_START).date_naive();
3203 let dir = root
3204 .join("asset1001")
3205 .join(date.format("%Y-%m-%d").to_string())
3206 .join("ch08");
3207 let window = 3_000;
3208 write_part(&dir, 1, &samples(0, window), IDENTITY);
3210 write_part(&dir, 2, &samples(window, window), IDENTITY);
3211 write_part(&dir, 3, &samples(1_500, window), LINEAR);
3214 let mut copy = samples(window, window);
3216 copy[10].1 = 9.75;
3217 write_part(&dir, 4, ©, IDENTITY);
3218 write_part(&dir, 5, &samples(4 * window, 100), LINEAR);
3220
3221 let chunks = export_chunks(
3222 &root,
3223 1001,
3224 &[8],
3225 DAY_START,
3226 DAY_START + 86_399 * 1_000_000_000,
3227 )
3228 .await;
3229 let csv = String::from_utf8(chunks.concat()).unwrap();
3230 let lines: Vec<&str> = csv.lines().skip(1).collect();
3231
3232 let mut expected: Vec<(i64, f64, &str)> = Vec::new();
3233 for (ts, v) in samples(0, 2 * window) {
3234 let index = (ts - DAY_START) / STEP;
3235 let calibration = if (1_500..4_500).contains(&index) {
3236 LINEAR
3237 } else {
3238 IDENTITY
3239 };
3240 expected.push((ts, v, calibration));
3241 if index == window + 10 {
3242 expected.push((ts, 9.75, IDENTITY));
3243 }
3244 }
3245 for (ts, v) in samples(4 * window, 100) {
3246 expected.push((ts, v, LINEAR));
3247 }
3248 assert_eq!(lines.len(), expected.len());
3249 let mut formatter = Rfc3339Formatter::new();
3250 for (line, (ts, raw, calibration)) in lines.iter().zip(&expected) {
3251 let spec: CalibrationSpec = serde_json::from_str(calibration).unwrap();
3252 let want = format!(
3253 "{},ch08,{raw},{},{}",
3254 format_ts(&mut formatter, *ts),
3255 spec.apply(*raw),
3256 spec.id_or_default()
3257 );
3258 assert_eq!(*line, want);
3259 }
3260 fs::remove_dir_all(root).unwrap();
3261 }
3262
3263 #[test]
3265 fn duplicates_are_dropped_by_timestamp_and_value_bits() {
3266 let calibrations = vec![
3267 (CalibrationSpec::default(), "identity".to_string()),
3268 (
3269 serde_json::from_str::<CalibrationSpec>(LINEAR).unwrap(),
3270 "tp3586".to_string(),
3271 ),
3272 ];
3273 let mut rows = vec![
3274 (30, 1.0, 0),
3275 (10, 1.0, 0),
3276 (20, 2.0, 0),
3277 (20, 3.0, 0),
3278 (10, 1.0, 1),
3279 (20, 2.0, 1),
3280 (20, f64::NAN, 0),
3281 (20, f64::NAN, 1),
3282 (30, 1.0, 0),
3283 ];
3284 sort_and_drop_duplicates(&mut rows, &calibrations);
3285 let got: Vec<(i64, u64, usize)> =
3286 rows.iter().map(|(t, v, c)| (*t, v.to_bits(), *c)).collect();
3287 assert_eq!(
3288 got,
3289 vec![
3290 (10, 1.0f64.to_bits(), 1),
3291 (20, 2.0f64.to_bits(), 1),
3292 (20, 3.0f64.to_bits(), 0),
3293 (20, f64::NAN.to_bits(), 1),
3294 (30, 1.0f64.to_bits(), 0),
3295 ]
3296 );
3297 }
3298
3299 #[test]
3301 fn overlapping_files_are_grouped_and_sliced() {
3302 let span = |name: &str, min_ns: i64, max_ns: i64| FileSpan {
3303 path: PathBuf::from(name),
3304 min_ns,
3305 max_ns,
3306 };
3307 let single = [span("a", DAY_START + 5, DAY_START + MERGE_SLICE_NS * 2)];
3308 assert_eq!(
3309 merge_slices(&single, i64::MIN, i64::MAX),
3310 vec![(DAY_START + 5, DAY_START + MERGE_SLICE_NS * 2)]
3311 );
3312 let pair = [
3313 span("a", DAY_START + 5, DAY_START + MERGE_SLICE_NS),
3314 span("b", DAY_START + 10, DAY_START + MERGE_SLICE_NS + 7),
3315 ];
3316 assert_eq!(
3317 merge_slices(&pair, i64::MIN, DAY_START + MERGE_SLICE_NS + 3),
3318 vec![
3319 (DAY_START + 5, DAY_START + MERGE_SLICE_NS - 1),
3320 (DAY_START + MERGE_SLICE_NS, DAY_START + MERGE_SLICE_NS + 3),
3321 ]
3322 );
3323
3324 let root = std::env::temp_dir().join(format!("exporter-groups-{}", uuid::Uuid::new_v4()));
3325 write_part(&root, 1, &samples(0, 3_000), IDENTITY);
3326 write_part(&root, 2, &samples(3_000, 3_000), IDENTITY);
3327 write_part(&root, 3, &samples(2_999, 2), IDENTITY);
3328 write_part(&root, 4, &samples(9_000, 10), IDENTITY);
3329 let groups = overlapping_file_groups(&root, i64::MIN, i64::MAX).unwrap();
3330 let names: Vec<Vec<String>> = groups
3331 .iter()
3332 .map(|g| {
3333 g.iter()
3334 .map(|f| f.path.file_name().unwrap().to_string_lossy().into_owned())
3335 .collect()
3336 })
3337 .collect();
3338 assert_eq!(
3339 names,
3340 vec![
3341 vec![
3342 "part-0001.parquet",
3343 "part-0002.parquet",
3344 "part-0003.parquet"
3345 ],
3346 vec!["part-0004.parquet"],
3347 ]
3348 );
3349 let groups = overlapping_file_groups(&root, DAY_START + 9_000 * STEP, i64::MAX).unwrap();
3351 assert_eq!(groups.len(), 1);
3352 fs::remove_dir_all(root).unwrap();
3353 }
3354
3355 #[test]
3357 fn new_reader_matches_reference_for_every_range_shape() {
3358 let dir = std::env::temp_dir().join(format!("exporter-test-{}", uuid::Uuid::new_v4()));
3359 fs::create_dir_all(&dir).unwrap();
3360 let path = dir.join("part-0001.parquet");
3361 let base = 1_790_000_000_000_000_000_i64;
3362 let group = |from: i64, n: i64| -> Vec<(i64, f64)> {
3363 (0..n)
3364 .map(|i| (base + (from + i) * 10_000_000, 3.7 + i as f64 * 1e-4))
3365 .collect()
3366 };
3367 let mut last = group(250, 40);
3370 last[3].1 = f64::NAN;
3371 write_file(
3372 &path,
3373 &[group(0, 100), group(100, 100), group(200, 100), last],
3374 );
3375
3376 let ns = |k: i64| base + k * 10_000_000;
3377 for (start, end) in [
3378 (ns(-50), ns(1_000)), (ns(0), ns(0)), (ns(120), ns(180)), (ns(99), ns(100)), (ns(260), ns(270)), (ns(500), ns(600)), (ns(-100), ns(-1)), ] {
3386 let got = read_matching_rows(&path, start, end).unwrap().rows;
3387 let want = reference_rows(&path, at(start), at(end));
3388 assert_eq!(got.len(), want.len(), "range {start}..{end}");
3389 for (g, w) in got.iter().zip(&want) {
3390 assert_eq!(g.0, w.0);
3391 assert!(g.1 == w.1 || (g.1.is_nan() && w.1.is_nan()));
3392 }
3393 }
3394 fs::remove_dir_all(dir).unwrap();
3395 }
3396
3397 #[test]
3399 fn row_groups_outside_the_range_are_skipped() {
3400 let dir = std::env::temp_dir().join(format!("exporter-skip-{}", uuid::Uuid::new_v4()));
3401 fs::create_dir_all(&dir).unwrap();
3402 let path = dir.join("part-0001.parquet");
3403 let group =
3404 |from: i64| -> Vec<(i64, f64)> { (from..from + 10).map(|t| (t, 1.0)).collect() };
3405 write_file(&path, &[group(0), group(100), group(200)]);
3406 let reader = SerializedFileReader::new(fs::File::open(&path).unwrap()).unwrap();
3407 let meta = reader.metadata();
3408 assert!(row_group_may_match(meta.row_group(0), 5, 105));
3409 assert!(row_group_may_match(meta.row_group(1), 5, 105));
3410 assert!(!row_group_may_match(meta.row_group(2), 5, 105));
3411 assert!(!row_group_may_match(meta.row_group(0), 10, 99));
3412 assert!(row_group_may_match(meta.row_group(0), 9, 9));
3413 fs::remove_dir_all(dir).unwrap();
3414 }
3415
3416 #[test]
3419 fn calibration_unit_round_trips_through_parquet_metadata() {
3420 let dir = std::env::temp_dir().join(format!("exporter-unit-{}", uuid::Uuid::new_v4()));
3421 let spec: CalibrationSpec =
3422 serde_json::from_str(r#"{"type":"linear","a":70.1,"b":-8.1,"unit":"kPa"}"#).unwrap();
3423 write_part(&dir, 1, &[(1, 1.0)], &serde_json::to_string(&spec).unwrap());
3424 let path = dir.join("part-0001.parquet");
3425 let reader = SerializedFileReader::new(fs::File::open(&path).unwrap()).unwrap();
3426 let read = read_calibration_from_metadata(&reader, &path);
3427 assert_eq!(read, spec);
3428 assert_eq!(read.unit.as_deref(), Some("kPa"));
3429 assert_eq!(read.id, None);
3430 let matched = read_matching_rows(&path, 0, 10).unwrap();
3431 assert_eq!(matched.calibration_id, "linear");
3432 assert_eq!(matched.rows, vec![(1, 1.0)]);
3433 fs::remove_dir_all(dir).unwrap();
3434 }
3435
3436 #[test]
3439 fn row_writer_matches_writeln_byte_for_byte() {
3440 let specs: Vec<CalibrationSpec> = [
3441 IDENTITY,
3442 LINEAR,
3443 r#"{"id":"z","type":"linear","a":0.0,"b":-0.0}"#,
3444 r#"{"id":"z","type":"linear","a":-0.0,"b":-0.0}"#,
3445 r#"{"type":"polynomial","coeffs":[0.1,2.0,0.03]}"#,
3446 r#"{"type":"linear","a":1e300,"b":0.0}"#,
3447 ]
3448 .iter()
3449 .map(|json| serde_json::from_str(json).unwrap())
3450 .collect();
3451 let mut raws = vec![
3452 0.0,
3453 -0.0,
3454 1.0,
3455 -1.5,
3456 3.7215,
3457 f64::NAN,
3458 f64::INFINITY,
3459 f64::NEG_INFINITY,
3460 f64::MIN_POSITIVE,
3461 1e-300,
3462 1e300,
3463 f64::MAX,
3464 ];
3465 let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
3467 for i in 0..200_000u64 {
3468 x ^= x << 13;
3469 x ^= x >> 7;
3470 x ^= x << 17;
3471 raws.push(if i < 100_000 {
3472 0.4 + (x % 300) as f64 * (20.0 / 65_536.0)
3473 } else {
3474 f64::from_bits(x >> 2)
3475 });
3476 }
3477 let mut writer = CsvRowWriter::new();
3478 let mut got = Vec::new();
3479 let mut want = Vec::new();
3480 for (i, &raw) in raws.iter().enumerate() {
3481 let channel = (i / 50_000) as u8 * 7;
3482 let spec = &specs[(i / 997) % specs.len()];
3483 let id = spec.id_or_default();
3484 let ts = 1_790_000_000_000_000_000 + i as i64 * 499_999;
3485 let cal = spec.apply(raw);
3486 let filtered = match i % 4 {
3487 0 => FilteredColumn::Absent,
3488 1 => FilteredColumn::Calibrated,
3489 2 => FilteredColumn::Value(raw * 0.5),
3490 _ => FilteredColumn::Value(f64::NAN),
3491 };
3492 writer.set_channel(channel);
3493 writer.write_row(&mut got, ts, raw, spec, id, filtered);
3494 let ts = timestamp_unix_ns_to_rfc3339(ts);
3495 match filtered {
3496 FilteredColumn::Absent => {
3497 writeln!(want, "{ts},ch{channel:02},{raw},{cal},{id}").unwrap();
3498 }
3499 FilteredColumn::Calibrated | FilteredColumn::Value(_) => {
3500 let value = match filtered {
3501 FilteredColumn::Value(value) => value,
3502 _ => cal,
3503 };
3504 write!(want, "{ts},ch{channel:02},{raw},{cal},{id},").unwrap();
3505 if value.is_finite() {
3506 write!(want, "{value}").unwrap();
3507 }
3508 want.push(b'\n');
3509 }
3510 }
3511 if got != want {
3512 panic!(
3513 "row {i} differs:\n got {}\nwant {}",
3514 String::from_utf8_lossy(&got),
3515 String::from_utf8_lossy(&want)
3516 );
3517 }
3518 got.clear();
3519 want.clear();
3520 }
3521 }
3522
3523 fn timestamp_unix_ns_to_rfc3339(timestamp_unix_ns: i64) -> String {
3525 DateTime::<Utc>::from_timestamp_nanos(timestamp_unix_ns).to_rfc3339()
3526 }
3527
3528 fn format_ts(formatter: &mut Rfc3339Formatter, timestamp_unix_ns: i64) -> String {
3530 let mut out = Vec::new();
3531 formatter.write(&mut out, timestamp_unix_ns);
3532 String::from_utf8(out).expect("timestamps are ASCII")
3533 }
3534
3535 #[test]
3537 fn fast_formatter_matches_chrono_exactly() {
3538 let mut f = Rfc3339Formatter::new();
3539 assert_eq!(
3540 format_ts(&mut f, 1_790_000_000_008_000_000),
3541 "2026-09-21T14:13:20.008+00:00"
3542 );
3543 let mut check = |ns: i64| {
3544 assert_eq!(
3545 format_ts(&mut f, ns),
3546 timestamp_unix_ns_to_rfc3339(ns),
3547 "ns={ns}"
3548 )
3549 };
3550 for ns in [
3551 0,
3552 1,
3553 999,
3554 1_000,
3555 1_000_000,
3556 999_999_999,
3557 1_000_000_000,
3558 -1,
3559 -1_000,
3560 -1_000_000,
3561 -999_999_999,
3562 -1_000_000_000,
3563 -1_000_000_001,
3564 i64::MIN,
3565 i64::MAX,
3566 1_790_000_000_000_000_000,
3567 1_790_000_000_008_000_000,
3568 1_790_000_000_008_123_000,
3569 1_790_000_000_008_123_456,
3570 1_790_000_000_010_000_000,
3571 1_790_000_000_500_000_000,
3572 ] {
3573 check(ns);
3574 }
3575 for k in 0..5_000_i64 {
3577 check(1_790_000_000_000_000_000 + k * 500_000);
3578 check(1_789_999_999_478_000_000 + k * 10_000_000);
3579 }
3580 let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
3582 for _ in 0..1_000_000 {
3583 x ^= x << 13;
3584 x ^= x >> 7;
3585 x ^= x << 17;
3586 check(x as i64);
3587 }
3588 }
3589
3590 #[test]
3592 fn range_bounds_convert_to_nanoseconds_and_clamp() {
3593 assert_eq!(
3594 datetime_to_unix_ns(at(1_790_000_000_008_000_000)),
3595 1_790_000_000_008_000_000
3596 );
3597 let far = DateTime::parse_from_rfc3339("3000-01-01T00:00:00Z")
3598 .unwrap()
3599 .with_timezone(&Utc);
3600 assert_eq!(datetime_to_unix_ns(far), i64::MAX);
3601 let early = DateTime::parse_from_rfc3339("1000-01-01T00:00:00Z")
3602 .unwrap()
3603 .with_timezone(&Utc);
3604 assert_eq!(datetime_to_unix_ns(early), i64::MIN);
3605 }
3606
3607 #[derive(Default)]
3608 struct CountingSink {
3609 bytes: usize,
3610 }
3611
3612 #[async_trait]
3613 impl ExportSink for CountingSink {
3614 async fn send_meta(&mut self, _file_name: &str, _content_type: &str) -> Result<()> {
3615 Ok(())
3616 }
3617 async fn send_chunk(&mut self, data: Vec<u8>) -> Result<()> {
3618 self.bytes += data.len();
3619 Ok(())
3620 }
3621 async fn send_summary(&mut self, _bytes_sent: usize, _missing: &[u8]) -> Result<()> {
3622 Ok(())
3623 }
3624 async fn send_complete(&mut self) -> Result<()> {
3625 Ok(())
3626 }
3627 async fn send_error(&mut self, _message: &str) -> Result<()> {
3628 Ok(())
3629 }
3630 }
3631
3632 #[tokio::test(flavor = "multi_thread")]
3637 #[ignore = "benchmark"]
3638 async fn benchmark_export_pipeline() {
3639 use parquet::basic::{Compression, Encoding, ZstdLevel};
3640 use parquet::schema::types::ColumnPath;
3641 let root = std::env::temp_dir().join(format!("exporter-bench-{}", uuid::Uuid::new_v4()));
3642 let dir = root
3643 .join("asset1001")
3644 .join(at(DAY_START).date_naive().format("%Y-%m-%d").to_string())
3645 .join("ch08");
3646 fs::create_dir_all(&dir).unwrap();
3647 let step = 500_000; let per_file = 600_000i64;
3649 let files = 4;
3650 let lsb = 20.0 / 65_536.0;
3651 let mut seed = 0x2545_f491_4f6c_dd1du64;
3652 for part in 0..files {
3653 let mut ts = Vec::new();
3654 let mut vs = Vec::new();
3655 for i in 0..per_file {
3656 seed ^= seed << 13;
3657 seed ^= seed >> 7;
3658 seed ^= seed << 17;
3659 let distinct = std::env::var("BENCH_DISTINCT")
3660 .ok()
3661 .and_then(|v| v.parse::<u64>().ok())
3662 .unwrap_or(81);
3663 let noise = (seed % distinct) as f64 - (distinct / 2) as f64;
3664 ts.push(DAY_START + (part * per_file + i) * step);
3665 vs.push(0.4 + noise * lsb);
3666 }
3667 let timestamp = ColumnPath::from("timestamp_unix_ns");
3668 let props = Arc::new(
3669 WriterProperties::builder()
3670 .set_compression(Compression::ZSTD(ZstdLevel::default()))
3671 .set_column_dictionary_enabled(timestamp.clone(), false)
3672 .set_column_encoding(timestamp, Encoding::DELTA_BINARY_PACKED)
3673 .set_key_value_metadata(Some(vec![KeyValue::new(
3674 "calibration".to_string(),
3675 LINEAR.to_string(),
3676 )]))
3677 .build(),
3678 );
3679 let schema = Arc::new(
3680 parse_message_type(
3681 "message schema { REQUIRED INT64 timestamp_unix_ns; REQUIRED DOUBLE value; }",
3682 )
3683 .unwrap(),
3684 );
3685 let path = dir.join(format!("part-{part:04}.parquet"));
3686 let mut writer =
3687 SerializedFileWriter::new(fs::File::create(path).unwrap(), schema, props).unwrap();
3688 let mut rg = writer.next_row_group().unwrap();
3689 let mut col = rg.next_column().unwrap().unwrap();
3690 if let ColumnWriter::Int64ColumnWriter(w) = col.untyped() {
3691 w.write_batch(&ts, None, None).unwrap();
3692 }
3693 col.close().unwrap();
3694 let mut col = rg.next_column().unwrap().unwrap();
3695 if let ColumnWriter::DoubleColumnWriter(w) = col.untyped() {
3696 w.write_batch(&vs, None, None).unwrap();
3697 }
3698 col.close().unwrap();
3699 rg.close().unwrap();
3700 writer.close().unwrap();
3701 }
3702 let (start, end) = (DAY_START, DAY_START + files * per_file * step);
3703 let rows = (files * per_file) as f64;
3704 let t = Instant::now();
3705 let mut sink = CountingSink::default();
3706 let mut streamer = CsvStreamer::new(&mut sink, 1001, at(start), at(end));
3707 let missing = streamer.stream_channels(&root, &[8]).await.unwrap();
3708 streamer.finish(missing).await.unwrap();
3709 let plain = t.elapsed().as_secs_f64();
3710 println!(
3711 "plain export: {plain:.3} s ({:.0} ns/row), {} MB CSV",
3712 plain * 1e9 / rows,
3713 sink.bytes / 1_000_000
3714 );
3715 let req = ExportRequest {
3716 asset: 1001,
3717 channels: vec![8],
3718 start: at(start).to_rfc3339(),
3719 end: at(end).to_rfc3339(),
3720 format: ExportFormat::Csv,
3721 download_name: None,
3722 ack_subject: None,
3723 filters: Some(
3724 serde_json::json!({"8": {"despike": true, "remove_10hz": true,
3725 "remove_11_9hz": true, "highpass_hz": 1.0, "lowpass_hz": 100.0}}),
3726 ),
3727 };
3728 let t = Instant::now();
3729 let mut sink = CountingSink::default();
3730 serve_export_request(&root, &mut sink, &req).await.unwrap();
3731 let filtered = t.elapsed().as_secs_f64();
3732 println!(
3733 "filtered export: {filtered:.3} s ({:.0} ns/row), {} MB CSV",
3734 filtered * 1e9 / rows,
3735 sink.bytes / 1_000_000
3736 );
3737 fs::remove_dir_all(root).unwrap();
3738 }
3739
3740 async fn export_filtered(
3742 root: &Path,
3743 channels: &[u8],
3744 start: i64,
3745 end: i64,
3746 filters: serde_json::Value,
3747 ) -> Vec<u8> {
3748 let req = ExportRequest {
3749 asset: 1001,
3750 channels: channels.to_vec(),
3751 start: at(start).to_rfc3339(),
3752 end: at(end).to_rfc3339(),
3753 format: ExportFormat::Csv,
3754 download_name: None,
3755 ack_subject: None,
3756 filters: Some(filters),
3757 };
3758 let mut sink = CollectingSink::default();
3759 serve_export_request(root, &mut sink, &req).await.unwrap();
3760 sink.chunks.concat()
3761 }
3762
3763 fn square_samples(from: i64, count: i64) -> Vec<(i64, f64)> {
3766 (from..from + count)
3767 .map(|i| {
3768 let square = if i % 10 < 5 { 0.003 } else { -0.003 };
3769 let pulse = if i == 7000 { 0.02 } else { 0.0 };
3770 (DAY_START + i * 10_000_000, 0.5 + square + pulse)
3771 })
3772 .collect()
3773 }
3774
3775 #[tokio::test]
3778 async fn exports_without_active_filters_are_unchanged() {
3779 let root = std::env::temp_dir().join(format!("exporter-nofilt-{}", uuid::Uuid::new_v4()));
3780 let dir = root
3781 .join("asset1001")
3782 .join(at(DAY_START).date_naive().format("%Y-%m-%d").to_string());
3783 write_part(&dir.join("ch08"), 1, &samples(0, 3_000), LINEAR);
3784 let (start, end) = (DAY_START + 100 * STEP, DAY_START + 2_000 * STEP);
3785 let plain = export_chunks(&root, 1001, &[8], start, end).await.concat();
3786 assert!(plain.starts_with(CSV_HEADER));
3787 for filters in [
3788 serde_json::json!({}),
3789 serde_json::json!({"8": {"despike": false, "highpass_hz": null, "lowpass_hz": 0}}),
3790 serde_json::json!({"9": {"remove_10hz": true}}),
3791 serde_json::json!("not a map"),
3792 ] {
3793 assert_eq!(
3794 export_filtered(&root, &[8], start, end, filters).await,
3795 plain
3796 );
3797 }
3798 fs::remove_dir_all(root).unwrap();
3799 }
3800
3801 #[tokio::test]
3805 async fn filtered_exports_add_a_filtered_value_column() {
3806 let root = std::env::temp_dir().join(format!("exporter-filt-{}", uuid::Uuid::new_v4()));
3807 let dir = root
3808 .join("asset1001")
3809 .join(at(DAY_START).date_naive().format("%Y-%m-%d").to_string());
3810 for part in 0..4 {
3812 write_part(
3813 &dir.join("ch08"),
3814 part,
3815 &square_samples(part as i64 * 3_000, 3_000),
3816 LINEAR,
3817 );
3818 }
3819 write_part(&dir.join("ch09"), 1, &samples(0, 900), IDENTITY);
3820 let start = DAY_START + 6_000 * 10_000_000;
3821 let end = DAY_START + 8_000 * 10_000_000;
3822 let settings = serde_json::json!({"remove_10hz": true, "highpass_hz": 1.0});
3823 let filtered = export_filtered(
3824 &root,
3825 &[8, 9],
3826 start,
3827 end,
3828 serde_json::json!({"8": settings.clone()}),
3829 )
3830 .await;
3831 let plain = export_chunks(&root, 1001, &[8, 9], start, end)
3832 .await
3833 .concat();
3834
3835 let text = String::from_utf8(filtered).unwrap();
3836 let plain = String::from_utf8(plain).unwrap();
3837 let mut lines = text.lines();
3838 assert_eq!(
3839 lines.next().unwrap().as_bytes(),
3840 CSV_HEADER_FILTERED.strip_suffix(b"\n").unwrap()
3841 );
3842 let rows: Vec<&str> = lines.collect();
3843 let plain_rows: Vec<&str> = plain.lines().skip(1).collect();
3844 assert_eq!(rows.len(), plain_rows.len());
3845
3846 let settings = ChannelFilterSettings::from_value(&settings);
3848 let from = start - (FILTER_LEAD_IN_S * 1e9) as i64;
3849 let to =
3850 end + ((filters::settle_seconds(&settings) + 0.1) * 1000.0).ceil() as i64 * 1_000_000;
3851 let spec: CalibrationSpec = serde_json::from_str(LINEAR).unwrap();
3852 let calibrate = |_: &i64, v: f64| spec.apply(v);
3853 let mut filter = ExportFilter::new(settings);
3854 let mut out = Vec::new();
3855 for (ts, v) in square_samples(0, 12_000) {
3856 if (from..=to).contains(&ts) {
3857 filter.push(ts, v, ts, &calibrate, &mut out);
3858 }
3859 }
3860 filter.finish(&calibrate, &mut out);
3861 let want: std::collections::HashMap<i64, f64> =
3862 out.into_iter().map(|r| (r.row, r.filtered)).collect();
3863
3864 let mut square_left: f64 = 0.0;
3865 for (row, plain_row) in rows.iter().zip(&plain_rows) {
3866 let (head, value) = row.rsplit_once(',').unwrap();
3867 assert_eq!(head, *plain_row);
3868 let fields: Vec<&str> = head.split(',').collect();
3869 let value: f64 = value.parse().unwrap();
3870 if fields[1] == "ch09" {
3871 assert_eq!(value, fields[3].parse::<f64>().unwrap());
3872 continue;
3873 }
3874 let ts = datetime_to_unix_ns(DateTime::parse_from_rfc3339(fields[0]).unwrap().into());
3875 assert_eq!(value, want[&ts], "row {row}");
3876 let index = (ts - DAY_START) / 10_000_000;
3877 if (index - 7000).abs() > 150 {
3878 square_left = square_left.max(value.abs());
3879 }
3880 }
3881 assert!(square_left < 0.01, "square wave left: {square_left}");
3885 let pulse = rows
3886 .iter()
3887 .find(|r| {
3888 r.contains("ch08")
3889 && r.starts_with(&at(DAY_START + 7000 * 10_000_000).to_rfc3339()[..22])
3890 })
3891 .unwrap();
3892 let pulse: f64 = pulse.rsplit_once(',').unwrap().1.parse().unwrap();
3893 assert!((pulse - 1.25).abs() < 0.1, "pulse {pulse}");
3894 fs::remove_dir_all(root).unwrap();
3895 }
3896}