Skip to main content

exporter/
exporter.rs

1//! Serves archived Parquet sample files as streamed CSV exports.
2//!
3//! The streamer publishes LabJack samples to the local NATS JetStream, and the
4//! archiver writes them to Parquet files under
5//! `<PARQUET_DIR>/assetNNN/YYYY-MM-DD/chNN/*.parquet`. This binary reads those files
6//! back for a requested asset, channel list and time range, applies the calibration
7//! stored in each file's metadata, and sends the rows as CSV with the columns
8//! `timestamp,channel,raw_value,calibrated_value,calibration_id`. A request that
9//! names noise filters for a channel ([`ExportRequest::filters`]) gets a sixth
10//! column, `filtered_value`, computed by the zero-phase pipeline in [`filters`] over
11//! the range plus margins; the archive itself is never changed.
12//!
13//! It runs in one of two modes that share the request format ([`ExportRequest`])
14//! and the scanning logic:
15//!
16//! * Worker mode (the default and the normal edge-box setup) subscribes to the core
17//!   NATS subject `<NATS_SUBJECT>.<site>.<box>.<source_id>.export.request` and
18//!   answers each request on its reply subject. Every reply message carries the
19//!   header `Avena-Export-Frame` naming the frame: `meta`, then one or more
20//!   `chunk` frames with raw CSV bytes (about 512 KiB each, the last one smaller),
21//!   then `summary` and `complete`. Failures produce an `error` frame. See
22//!   [`process_nats_request`] and [`NatsCsvStreamer`] for the exact payloads, the
23//!   ack-subject backpressure and the cancel message a client can send there.
24//! * Direct mode serves the same export over a WebSocket at `ws://<EXPORTER_ADDR>/export`.
25//!   The client sends one JSON request as a text message and receives the same
26//!   frames as JSON text messages, with CSV chunks (about 128 KiB) as binary
27//!   messages. See [`process_socket`].
28//!
29//! # Configuration
30//!
31//! * `EXPORTER_MODE` - `worker`, or `direct` (alias `local`). Trimmed and
32//!   case-insensitive; any other value is a startup error. Default: `worker`.
33//! * `EXPORTER_ADDR` - TCP listen address for the WebSocket server in direct mode.
34//!   Default: `0.0.0.0:9001`.
35//! * `PARQUET_DIR` - Root of the Parquet archive. A missing directory only logs a
36//!   warning at startup. Default: `parquet`.
37//! * `NATS_SERVERS` - Worker mode. Comma-separated NATS server URLs. Default:
38//!   `nats://127.0.0.1:4222`.
39//! * `NATS_CREDS_FILE` - Worker mode. NATS credentials file. Default: `apt.creds`.
40//! * `NATS_SUBJECT` - Worker mode. Root token of the request subject. Default:
41//!   `avenars`.
42//! * `SITE_ID` - Worker mode. Site token of the request subject. Default:
43//!   `unknown-site`.
44//! * `EXPORT_BOX_ID` - Worker mode. Box token of the request subject; falls back to
45//!   `BOX_ID`. One of the two is required in worker mode.
46//! * `BOX_ID` - Worker mode. Used when `EXPORT_BOX_ID` is unset.
47//! * `SOURCE_ID` - Worker mode. Source token of the request subject. When it is
48//!   unset or empty the token falls back to `LABJACK_NAME`, then `asset<ASSET_NUMBER>`,
49//!   then `unknown-source`, the same order the streamer and the webapp use (see
50//!   `subjects::archive_export_request_subject`).
51//! * `LABJACK_NAME` - Worker mode. The source's LabJack name, used only when
52//!   `SOURCE_ID` is not set.
53//! * `ASSET_NUMBER` - Worker mode. The source's asset number, used only when neither
54//!   `SOURCE_ID` nor `LABJACK_NAME` is set.
55//! * `SOURCE_TYPE` - Worker mode. Read and passed to the subject builder, which
56//!   currently ignores it. Default: `labjack`.
57//!
58//! # Design
59//!
60//! * Worker mode exists so the webapp needs only its existing WebSocket connection
61//!   to central NATS. The browser publishes a request with a reply inbox, the NATS
62//!   leaf routes it to the worker on the box that holds the Parquet files, and the
63//!   CSV comes back the same way. Direct mode requires the client to reach the box
64//!   itself. Core NATS has no flow control, so the worker waits for client acks
65//!   on `ack_subject` every 8 chunks instead of flooding a slow browser.
66//! * Each Parquet row group stores min/max statistics for the timestamp column.
67//!   [`read_matching_rows`] skips row groups whose range lies outside the request
68//!   without decoding them, and reads the two columns with typed readers rather than
69//!   building a row object per sample.
70//! * The archive can hold more than one copy of a sample: an archiver fed the same
71//!   JetStream messages again writes them into new part files, sometimes with other
72//!   window boundaries or another calibration. Each channel-day's files are grouped
73//!   by overlapping time spans ([`overlapping_file_groups`]), found from the footer
74//!   statistics. A file that overlaps nothing is read in one pass, as before.
75//!   Overlapping files are read together in five-minute slices so memory stays
76//!   bounded by one slice rather than a day. Either way the rows are sorted by time
77//!   and each exact `(timestamp, raw value)` pair is sent once ([`read_merged_rows`]);
78//!   a clean file in time order comes out exactly as stored. Rows with the same
79//!   timestamp but different values are all kept.
80//! * Each export runs as a three-stage pipeline ([`spawn_csv`]): a reader thread
81//!   lists and decodes the files (and runs the filters) one merge slice ahead, a CSV
82//!   thread formats the rows into chunks, and the request's async task sends the
83//!   chunks and handles acks. Decoding, formatting and sending overlap, and no file
84//!   reading or formatting blocks the async runtime; the bytes and their order are
85//!   the same as reading, formatting and sending one after another.
86//! * Formatting a row is the main CPU cost of an export, so [`CsvRowWriter`] avoids
87//!   most of it while writing the same bytes as `writeln!`: rows are sent in time
88//!   order, so [`Rfc3339Formatter`] builds the date and time up to the second with
89//!   chrono once per second and writes only the fractional digits per row (identical
90//!   text to chrono's `to_rfc3339`), and [`ValueTextCache`] reuses the
91//!   `raw,calibrated` text of raw values seen before, which with a 16-bit converter
92//!   is nearly every row.
93
94use 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
136/// NATS header whose value names the frame type of each worker-mode reply message.
137const EXPORT_FRAME_HEADER: &str = "Avena-Export-Frame";
138/// Frame name for the metadata frame (`fileName`, `contentType`) sent before any CSV.
139const EXPORT_FRAME_META: &str = "meta";
140/// Frame name for a CSV payload chunk (raw bytes, not JSON).
141const EXPORT_FRAME_CHUNK: &str = "chunk";
142/// Frame name for the final byte count and missing-channel list.
143const EXPORT_FRAME_SUMMARY: &str = "summary";
144/// Frame name marking the end of a successful export.
145const EXPORT_FRAME_COMPLETE: &str = "complete";
146/// Frame name for request validation or processing errors (`message`).
147const EXPORT_FRAME_ERROR: &str = "error";
148/// Frame name a client sends on its `ack_subject` to stop an export.
149const EXPORT_FRAME_CANCEL: &str = "cancel";
150
151/// Error returned when the client cancels a worker-mode export.
152///
153/// [`run_worker`] logs it and sends no `error` frame, since the client has stopped
154/// listening.
155#[derive(Debug)]
156struct ExportCancelled {
157    /// `chunk` frames published before the cancel was seen.
158    chunks_sent: usize,
159    /// CSV bytes published before the cancel was seen.
160    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
175/// Tells whether a message on the ack subject is a cancel request.
176///
177/// A cancel carries the header `Avena-Export-Frame: cancel`, or a JSON body whose
178/// `type` is `cancel` for clients that cannot set headers. Every other message,
179/// including the empty acks, is an ack.
180fn 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}
195/// Runtime mode used when `EXPORTER_MODE` is unset.
196const DEFAULT_EXPORTER_MODE: &str = "worker";
197/// WebSocket listen address used in direct mode when `EXPORTER_ADDR` is unset.
198const DEFAULT_EXPORTER_ADDR: &str = "0.0.0.0:9001";
199
200#[derive(Serialize)]
201#[serde(rename_all = "camelCase")]
202/// Direct-mode metadata frame sent before CSV chunks.
203///
204/// Serializes as `{"type":"meta","fileName":...,"contentType":...}`.
205struct MetaFrame<'a> {
206    /// Always [`EXPORT_FRAME_META`]; serialized as `type`.
207    #[serde(rename = "type")]
208    frame_type: &'static str,
209    /// Suggested download file name; serialized as `fileName`.
210    file_name: &'a str,
211    /// MIME type of the payload (`text/csv`); serialized as `contentType`.
212    content_type: &'a str,
213}
214
215#[derive(Serialize)]
216#[serde(rename_all = "camelCase")]
217/// Direct-mode summary frame sent after all CSV chunks are delivered.
218///
219/// Serializes as `{"type":"summary","bytesSent":...,"missingChannels":[...]}`.
220struct SummaryFrame<'a> {
221    /// Always [`EXPORT_FRAME_SUMMARY`]; serialized as `type`.
222    #[serde(rename = "type")]
223    frame_type: &'static str,
224    /// Total CSV bytes sent, including the header line; serialized as `bytesSent`.
225    bytes_sent: usize,
226    /// Requested channels that produced no rows; serialized as `missingChannels`.
227    missing_channels: &'a [u8],
228}
229
230#[derive(Serialize)]
231/// Direct-mode error frame sent when an export request cannot be served.
232///
233/// Serializes as `{"type":"error","message":...}`.
234struct ErrorFrame<'a> {
235    /// Always [`EXPORT_FRAME_ERROR`]; serialized as `type`.
236    #[serde(rename = "type")]
237    frame_type: &'static str,
238    /// Human-readable error text.
239    message: &'a str,
240}
241
242#[derive(Clone)]
243/// State shared by the Axum WebSocket handler and the NATS worker tasks.
244struct AppState {
245    /// Mode the process was started in.
246    mode: ExporterMode,
247    /// Root of the Parquet archive (`PARQUET_DIR`).
248    parquet_root: Arc<PathBuf>,
249}
250
251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
252/// Exporter runtime mode.
253enum ExporterMode {
254    /// Serve export requests directly over a local WebSocket endpoint.
255    Direct,
256    /// Subscribe to NATS export request subjects and reply with framed chunks.
257    Worker,
258}
259
260impl ExporterMode {
261    /// Reads and validates `EXPORTER_MODE`.
262    ///
263    /// The value is trimmed and lowercased. `direct` and `local` select
264    /// [`ExporterMode::Direct`], `worker` selects [`ExporterMode::Worker`], and an
265    /// unset variable falls back to [`DEFAULT_EXPORTER_MODE`].
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the variable is set to any other value.
270    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")]
288/// Requested export format, written in JSON as `"csv"` or `"parquet"`.
289enum ExportFormat {
290    /// Stream a generated CSV file.
291    Csv,
292    /// Placeholder for future Parquet passthrough support. Requests with this
293    /// format are rejected with the error `parquet streaming not yet supported`.
294    Parquet,
295}
296
297#[derive(Debug, Deserialize, Serialize, Clone)]
298/// Export request accepted by both WebSocket and NATS worker modes.
299///
300/// Sent as a JSON object with snake_case field names, for example:
301///
302/// ```text
303/// {"asset":1,"channels":[0,1],"start":"2026-09-21T14:00:00Z",
304///  "end":"2026-09-21T15:00:00Z","format":"csv",
305///  "download_name":"run.csv","ack_subject":"_INBOX.abc.ack"}
306/// ```
307///
308/// Validation (in [`process_nats_request`] and [`serve_export_request`]) rejects an
309/// empty channel list, unparseable timestamps, `end` before `start`, and any format
310/// other than CSV.
311struct ExportRequest {
312    /// Asset number; selects the `assetNNN` directory under the Parquet root.
313    asset: u32,
314    /// LabJack channel numbers to export. Sorted and deduplicated before use, and
315    /// exported one channel after another in that order. Must not be empty.
316    channels: Vec<u8>,
317    /// Start of the range, RFC 3339 (any offset, converted to UTC). Inclusive.
318    start: String,
319    /// End of the range, RFC 3339 (any offset, converted to UTC). Inclusive, and
320    /// may equal `start`.
321    end: String,
322    /// Output format. Default: `csv`.
323    #[serde(default = "default_format")]
324    format: ExportFormat,
325    /// File name reported in the `meta` frame. Default:
326    /// `labjack_asset<NNN>_<start>_<end>.csv` with times as `%Y%m%dT%H%M%S` in UTC.
327    download_name: Option<String>,
328    /// Worker mode only. Subject on which the client publishes one message per
329    /// received chunk; the worker subscribes to it and pauses every 8 chunks until
330    /// the acks arrive. Missing or blank disables backpressure. Ignored in direct
331    /// mode.
332    ack_subject: Option<String>,
333    /// Noise filters per channel, keyed by channel number as a string, in the shape
334    /// of the config's `sensor_settings.filters` (see [`filters`]). When a requested
335    /// channel has any filter on, every row gets a `filtered_value` column. Absent,
336    /// empty or all off: the CSV is exactly as without this field.
337    #[serde(default)]
338    filters: Option<serde_json::Value>,
339}
340
341/// CSV header of an export without filters.
342const CSV_HEADER: &[u8] = b"timestamp,channel,raw_value,calibrated_value,calibration_id\n";
343/// CSV header of an export with filters.
344const CSV_HEADER_FILTERED: &[u8] =
345    b"timestamp,channel,raw_value,calibrated_value,calibration_id,filtered_value\n";
346
347/// Filters asked for by an export request, per channel.
348#[derive(Debug, Clone, Default)]
349struct ExportFilters {
350    /// Active settings of the requested channels that have any filter on.
351    by_channel: std::collections::HashMap<u8, ChannelFilterSettings>,
352}
353
354impl ExportFilters {
355    /// Reads the request's `filters` for its channels.
356    ///
357    /// # Returns
358    ///
359    /// `None` when no requested channel has a filter on (the export is then exactly
360    /// as without filters). Keys that are not channel numbers, entries that are not
361    /// objects and switches that are not `true` are ignored.
362    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    /// The channel's settings when it has any filter on.
377    fn for_channel(&self, channel: u8) -> Option<&ChannelFilterSettings> {
378        self.by_channel.get(&channel)
379    }
380}
381
382/// Calibrations and CSV calibration ids of a filtered channel, each once, in the
383/// order first seen. Shared with the CSV thread, which reads a batch's rows with it.
384type CalibrationTable = Arc<Vec<(CalibrationSpec, String)>>;
385
386/// One row of a filtered export.
387struct FilterInputRow {
388    timestamp_unix_ns: i64,
389    raw_value: f64,
390    /// Index of the row's calibration in the channel's [`CalibrationTable`].
391    calibration: usize,
392}
393
394/// Seconds read before the export range so the templates have learnt the
395/// interference by the first exported row (their memory).
396const FILTER_LEAD_IN_S: f64 = filters::TEMPLATE_MEMORY_S;
397
398/// Reads one channel over the export range plus lead-in and settling margins and
399/// runs it through the zero-phase filter pipeline, one merge slice at a time.
400///
401/// Rows come out in time order through [`Self::next_batch`], only those inside the
402/// export range, each with its filtered calibrated value. Memory stays bounded by
403/// one slice plus the filter's backward-pass block (see [`filters::ExportFilter`]),
404/// and the batch handed back.
405struct FilteredChannelRows {
406    /// File groups of the channel's day folders, in time order.
407    groups: Vec<Vec<FileSpan>>,
408    /// `(group index, from_ns, to_ns)` slices still to read.
409    slices: std::collections::VecDeque<(usize, i64, i64)>,
410    /// Calibrations of the rows read so far. Rows refer to them by index rather
411    /// than each holding a reference count, which the reader and CSV threads would
412    /// otherwise update for every row.
413    calibrations: CalibrationTable,
414    filter: ExportFilter<FilterInputRow>,
415    start_ns: i64,
416    end_ns: i64,
417    finished: bool,
418}
419
420impl FilteredChannelRows {
421    /// Lists the slices to read.
422    ///
423    /// # Errors
424    ///
425    /// Returns an error if an existing day folder cannot be listed.
426    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    /// Reads the next slice and returns the rows it completes; `None` when done.
471    ///
472    /// # Returns
473    ///
474    /// The rows, and the table their calibration indices refer to.
475    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            // Index in the channel's table of each of the slice's calibrations. Only
482            // the formula (bit for bit) and the id reach the CSV.
483            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
524/// Default export format used when a request omits `format`.
525fn default_format() -> ExportFormat {
526    ExportFormat::Csv
527}
528
529#[tokio::main]
530/// Starts the exporter in direct WebSocket mode or NATS worker mode.
531///
532/// Reads `EXPORTER_MODE`, `EXPORTER_ADDR` and `PARQUET_DIR`, and prints a warning if
533/// the Parquet directory does not exist. Worker mode then hands off to
534/// [`run_worker`]. Direct mode binds `EXPORTER_ADDR` and serves the `/export`
535/// WebSocket route with [`handle_ws`] until the server stops.
536///
537/// # Errors
538///
539/// Returns an error if `EXPORTER_MODE` is invalid, if the listen address cannot be
540/// bound or the server fails (direct mode), or if [`run_worker`] fails.
541async 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
582/// Connects to NATS using the standard credentials and server environment.
583///
584/// Loads the credentials file named by `NATS_CREDS_FILE` (default `apt.creds`) and
585/// connects to the servers listed in `NATS_SERVERS` (see
586/// [`nats_config::servers_from_env`]).
587///
588/// # Errors
589///
590/// Returns an error if the credentials file cannot be loaded, if `NATS_SERVERS`
591/// is invalid or empty, or if the connection fails.
592async 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
605/// Reads the box identifier required to construct the worker request subject.
606///
607/// Uses `EXPORT_BOX_ID`, falling back to `BOX_ID`, and passes the value through
608/// [`sanitize_token`].
609///
610/// # Errors
611///
612/// Returns an error if neither variable is set (or both hold invalid Unicode).
613fn 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
620/// Normalizes namespace text into a subject-safe token.
621///
622/// Trims the input, lowercases ASCII letters, keeps ASCII digits, `-` and `_`,
623/// turns whitespace, `.` and `/` into `-`, and drops every other character. Leading
624/// and trailing `-` are removed.
625///
626/// # Arguments
627///
628/// * `raw` - Text to normalize, such as a box id.
629///
630/// # Returns
631///
632/// The normalized token, or `unknown` if nothing is left.
633///
634/// # Examples
635///
636/// ```text
637/// sanitize_token(" MU1.Box ") == "mu1-box"
638/// sanitize_token("***")       == "unknown"
639/// ```
640fn 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
658/// Runs exporter worker mode by subscribing to one NATS request subject.
659///
660/// Connects with [`connect_nats_from_env`] and subscribes to
661/// `<NATS_SUBJECT>.<SITE_ID>.<box>.<source>.export.request` built by
662/// [`subjects::archive_export_request_subject`]. Each incoming request is handled in
663/// its own Tokio task, with no limit on concurrent exports, and answered on the
664/// message's reply subject by [`process_nats_request`]. Requests without a reply
665/// subject are logged and dropped. If a request fails, the error is logged and a
666/// best-effort `error` frame (`{"type":"error","message":...}`) is published to the
667/// reply subject, possibly after `meta` and some `chunk` frames were already sent.
668///
669/// # Arguments
670///
671/// * `parquet_root` - Root of the Parquet archive.
672///
673/// # Returns
674///
675/// `Ok(())` when the subscription ends.
676///
677/// # Errors
678///
679/// Returns an error if the NATS connection fails, if neither `EXPORT_BOX_ID` nor
680/// `BOX_ID` is set, or if the subscription cannot be created.
681async 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
747/// Validates and serves one NATS export request payload.
748///
749/// Every frame is published to `reply` with the header `Avena-Export-Frame` set to
750/// the frame name. The sequence is:
751///
752/// 1. `meta`: JSON `{"type":"meta","fileName":...,"contentType":"text/csv"}`.
753/// 2. `chunk` (one or more): raw CSV bytes, not JSON. The first chunk starts with
754///    the header line. See [`NatsCsvStreamer`] for sizes and acks.
755/// 3. `summary`: JSON `{"type":"summary","bytesSent":N,"missingChannels":[...]}`.
756/// 4. `complete`: JSON `{"type":"complete"}`.
757///
758/// Validation failures (no channels, `end` before `start`, non-CSV format) publish
759/// only an `error` frame (`{"type":"error","message":...}`) and return `Ok`, with
760/// no `complete` frame. If `ack_subject` is set and not blank, the function
761/// subscribes to it before streaming. A cancel message on that subject stops the
762/// export before the next chunk with an [`ExportCancelled`] error.
763///
764/// # Arguments
765///
766/// * `nc` - Connected NATS client.
767/// * `reply` - Reply subject of the request message.
768/// * `payload` - JSON-encoded [`ExportRequest`].
769/// * `state` - Shared state holding the Parquet root.
770///
771/// # Errors
772///
773/// Returns an error if the payload is not a valid [`ExportRequest`], if `start` or
774/// `end` is not RFC 3339, if a publish, flush or the ack subscription fails, if an
775/// ack does not arrive in time, or if a channel's day directory cannot be listed.
776/// The caller ([`run_worker`]) turns these into an `error` frame, except
777/// [`ExportCancelled`], which it only logs.
778async 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
857/// Publishes a JSON export control frame on a NATS reply subject.
858///
859/// # Arguments
860///
861/// * `nc` - Connected NATS client.
862/// * `reply` - Subject to publish to.
863/// * `frame` - Frame name written to the `Avena-Export-Frame` header.
864/// * `value` - JSON body of the message.
865///
866/// # Errors
867///
868/// Returns an error if `value` cannot be serialized or the publish fails.
869async 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
882/// Upgrades an HTTP request into a WebSocket export session.
883///
884/// The session runs [`process_socket`]; its errors are logged, not returned.
885///
886/// # Arguments
887///
888/// * `ws` - Axum WebSocket upgrade extractor.
889/// * `state` - Shared exporter state.
890async 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
898/// Handles one direct-mode WebSocket export session.
899///
900/// Reads the request with [`read_export_request`] and serves it with
901/// [`serve_export_request`]. If serving fails, it sends an `error` frame, a
902/// `complete` frame and a close frame, ignoring send failures. On success the
903/// socket is dropped after the `complete` frame without an explicit close frame.
904///
905/// # Arguments
906///
907/// * `socket` - Upgraded WebSocket connection.
908/// * `state` - Shared exporter state.
909///
910/// # Errors
911///
912/// Returns an error if the request cannot be read or parsed, or if the process is
913/// in worker mode (not reached in practice, since worker mode starts no WebSocket
914/// server).
915async 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
935/// Reads the initial JSON export request from a WebSocket.
936///
937/// The first message must be a text message holding an [`ExportRequest`]. If it
938/// is any other message type, an `error` frame with `expected JSON request` is
939/// sent first.
940///
941/// # Arguments
942///
943/// * `socket` - WebSocket to read from.
944///
945/// # Errors
946///
947/// Returns an error if the socket closes or fails before a message arrives, if the
948/// first message is not text, or if the text is not a valid [`ExportRequest`].
949async 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
969/// Validates a request and streams its CSV response through an export sink.
970///
971/// Sorts and deduplicates the channels, checks the range and format, sends the
972/// `meta` frame, then streams every channel with [`CsvStreamer`] and finishes with
973/// `summary` and `complete`. Unlike [`process_nats_request`], validation failures
974/// are returned as errors for the caller to report.
975///
976/// # Arguments
977///
978/// * `parquet_root` - Root of the Parquet archive.
979/// * `sink` - Transport that receives the frames.
980/// * `req` - Export request; cloned before normalization.
981///
982/// # Errors
983///
984/// Returns an error if no channels are requested, if `start` or `end` is not RFC
985/// 3339, if `end` is before `start`, if the format is not CSV, if a channel's day
986/// directory cannot be listed, or if the sink fails to send.
987async 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]
1028/// Transport abstraction for framed export responses.
1029///
1030/// Used by [`CsvStreamer`] in direct mode. Worker mode publishes to NATS directly
1031/// through [`NatsCsvStreamer`] and [`publish_nats_json`] instead. Every method
1032/// returns an error if the underlying transport fails to send.
1033trait ExportSink {
1034    /// Sends the file metadata frame.
1035    ///
1036    /// # Arguments
1037    ///
1038    /// * `file_name` - Suggested download file name.
1039    /// * `content_type` - MIME type of the payload.
1040    async fn send_meta(&mut self, file_name: &str, content_type: &str) -> Result<()>;
1041    /// Sends one CSV payload chunk.
1042    ///
1043    /// # Arguments
1044    ///
1045    /// * `data` - Raw CSV bytes.
1046    async fn send_chunk(&mut self, data: Vec<u8>) -> Result<()>;
1047    /// Sends the final export summary.
1048    ///
1049    /// # Arguments
1050    ///
1051    /// * `bytes_sent` - Total CSV bytes sent in chunks, including the header line.
1052    /// * `missing_channels` - Requested channels that produced no rows.
1053    async fn send_summary(&mut self, bytes_sent: usize, missing_channels: &[u8]) -> Result<()>;
1054    /// Sends the completion frame.
1055    async fn send_complete(&mut self) -> Result<()>;
1056    /// Sends an error frame.
1057    ///
1058    /// # Arguments
1059    ///
1060    /// * `message` - Human-readable error text.
1061    async fn send_error(&mut self, message: &str) -> Result<()>;
1062}
1063
1064/// WebSocket implementation of [`ExportSink`].
1065///
1066/// Control frames go out as JSON text messages and CSV chunks as binary messages.
1067struct WebSocketSink {
1068    /// Upgraded client connection.
1069    socket: WebSocket,
1070}
1071
1072impl WebSocketSink {
1073    /// Wraps a WebSocket as an export sink.
1074    ///
1075    /// # Arguments
1076    ///
1077    /// * `socket` - Upgraded client connection.
1078    fn new(socket: WebSocket) -> Self {
1079        Self { socket }
1080    }
1081
1082    /// Sends a WebSocket close frame.
1083    ///
1084    /// # Errors
1085    ///
1086    /// Returns an error if the send fails.
1087    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    /// Sends metadata as a text JSON WebSocket message ([`MetaFrame`]).
1096    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    /// Sends a CSV chunk as a binary WebSocket message.
1108    async fn send_chunk(&mut self, data: Vec<u8>) -> Result<()> {
1109        self.socket.send(Message::Binary(data)).await?;
1110        Ok(())
1111    }
1112
1113    /// Sends byte count and missing channels as a text JSON message ([`SummaryFrame`]).
1114    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    /// Sends `{"type":"complete"}` as a text message.
1126    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    /// Sends a text JSON error message ([`ErrorFrame`]).
1134    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
1145/// Bytes reserved in a chunk buffer beyond its chunk size, so the row that crosses
1146/// the size normally fits without the buffer being reallocated and copied.
1147const CHUNK_SLACK: usize = 4096;
1148/// Finished chunks the CSV thread may hold ready while the previous ones are sent.
1149const CSV_CHUNKS_AHEAD: usize = 2;
1150
1151/// What an export reads, handed to its CSV thread ([`spawn_csv`]).
1152struct CsvJob {
1153    /// Root of the Parquet archive.
1154    root: PathBuf,
1155    /// Asset number used to locate the Parquet partitions.
1156    asset: u32,
1157    /// Inclusive start of the export range.
1158    start: DateTime<Utc>,
1159    /// Inclusive end of the export range.
1160    end: DateTime<Utc>,
1161    /// Filters asked for; `None` for a plain export.
1162    filters: Option<ExportFilters>,
1163    /// Channels to export, in order.
1164    channels: Vec<u8>,
1165}
1166
1167/// Starts building an export's CSV on a blocking thread.
1168///
1169/// The CSV thread formats the rows that a reader thread ([`read_rows`]) reads one
1170/// merge slice ahead, so decoding the next file overlaps formatting the current
1171/// one, also across channels. The channels come one after another, in the given
1172/// order, and the CSV is cut into chunks: the first starts with the header line,
1173/// and every chunk except the last is at least `chunk_size` bytes, exceeding it by
1174/// less than one row. The chunks are sent in order on the returned receiver, up to
1175/// [`CSV_CHUNKS_AHEAD`] ahead of the caller, so building the next chunks overlaps
1176/// sending the current one and waiting for acks, and none of it blocks the async
1177/// runtime. Memory stays bounded by two merge slices (one being formatted, one read
1178/// ahead) and, besides the chunk being sent, [`CSV_CHUNKS_AHEAD`] ready chunks and
1179/// the one being built. The caller stops both threads by dropping the receiver:
1180/// each ends at its next hand-over, the reader after the slice it is reading.
1181///
1182/// # Returns
1183///
1184/// The chunk receiver, and the CSV thread's handle, which yields the channels that
1185/// produced no rows in the range, in request order, once every chunk is sent. On
1186/// the first error from listing a channel's day folder, the handle yields it,
1187/// prefixed with `channel NN:`, after the chunks completed before it.
1188fn 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
1208/// Rows of an export in CSV order, from [`read_rows`] to [`CsvBuilder`].
1209enum ReadItem {
1210    /// The following rows belong to this channel.
1211    Channel(u8),
1212    /// Rows of one merge slice of a channel without filters.
1213    Plain(MergedRows),
1214    /// Rows of a channel with filters, with their filtered values, and the table
1215    /// their calibration indices refer to.
1216    Filtered(Vec<filters::FilteredRow<FilterInputRow>>, CalibrationTable),
1217    /// The current channel's day folders could not be listed; nothing follows.
1218    Failed(anyhow::Error),
1219    /// Every channel was read.
1220    Done,
1221}
1222
1223/// Reads the rows of every channel of an export, in order, and hands them over.
1224///
1225/// A channel with filters goes through [`FilteredChannelRows`]. Otherwise, for each
1226/// UTC date from the start date to the end date inclusive, groups the `.parquet`
1227/// files in `<root>/assetNNN/YYYY-MM-DD/chNN/` (skipping days whose directory does
1228/// not exist) with [`overlapping_file_groups`], and hands over each group's rows,
1229/// one merge slice at a time, in time order with every exact duplicate sample once
1230/// (see [`read_merged_rows`]). For an archive without overlapping files this is the
1231/// same as each file's rows in turn. A file that fails to read is logged and
1232/// skipped, and the export continues; a day folder that cannot be listed ends it
1233/// with [`ReadItem::Failed`].
1234///
1235/// `tx` is a rendezvous channel, so this runs one item ahead of the formatter and
1236/// returns as soon as the formatter has gone.
1237fn 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
1290/// Formats an export's rows into CSV chunks on its CSV thread; see [`spawn_csv`].
1291struct CsvBuilder {
1292    /// Whether the export has a `filtered_value` column.
1293    filtered: bool,
1294    /// Buffer size in bytes at which a chunk is sent.
1295    chunk_size: usize,
1296    /// CSV bytes not yet sent; starts with the header line.
1297    chunk: Vec<u8>,
1298    /// Row formatter.
1299    rows: CsvRowWriter,
1300    /// Where finished chunks go.
1301    tx: tokio::sync::mpsc::Sender<Vec<u8>>,
1302}
1303
1304impl CsvBuilder {
1305    /// Creates a builder and writes the CSV header (with `filtered_value` when
1306    /// `filtered`) into the first chunk.
1307    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    /// Writes every row from the reader, sends the last chunk and returns the
1324    /// channels without rows.
1325    ///
1326    /// Each row's calibration is applied to its raw value. In a filtered export, a
1327    /// channel without filters gets its calibrated value as its filtered value.
1328    ///
1329    /// # Errors
1330    ///
1331    /// Returns the reader's channel error, prefixed with `channel NN:`, an error if
1332    /// the reader stopped early, or an error if the chunk receiver was dropped.
1333    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    /// Sends the current buffer as one chunk, unless it is empty.
1403    ///
1404    /// # Errors
1405    ///
1406    /// Returns an error if the receiver was dropped.
1407    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    /// Sends the buffer once it has reached the chunk size.
1419    ///
1420    /// # Errors
1421    ///
1422    /// Returns an error if the receiver was dropped.
1423    fn end_row(&mut self) -> Result<()> {
1424        if self.chunk.len() >= self.chunk_size {
1425            self.send_chunk()?;
1426        }
1427        Ok(())
1428    }
1429}
1430
1431/// CSV streamer for direct WebSocket exports.
1432///
1433/// Builds the CSV on a CSV thread ([`spawn_csv`]) and hands it to an
1434/// [`ExportSink`] in chunks of about [`Self::CHUNK_SIZE`] bytes. There is no ack
1435/// protocol; backpressure comes from awaiting each send on the sink.
1436struct CsvStreamer<'a, S: ExportSink + Send> {
1437    /// Transport that receives the frames.
1438    sink: &'a mut S,
1439    /// CSV bytes handed to the sink so far.
1440    bytes_sent: usize,
1441    /// Asset number used to locate the Parquet partitions.
1442    asset: u32,
1443    /// Inclusive start of the export range.
1444    start: DateTime<Utc>,
1445    /// Inclusive end of the export range.
1446    end: DateTime<Utc>,
1447    /// Filters asked for; `None` for a plain export.
1448    filters: Option<ExportFilters>,
1449}
1450
1451/// CSV streamer for NATS worker exports.
1452///
1453/// Builds the CSV on a CSV thread ([`spawn_csv`]) and publishes it as `chunk`
1454/// frames (raw bytes with the header `Avena-Export-Frame: chunk`) to the reply
1455/// subject, each at least [`Self::CHUNK_SIZE`] (512 KiB) except the last,
1456/// exceeding it by less than one row. After every [`Self::FLUSH_EVERY_CHUNKS`] (8)
1457/// chunks it flushes the NATS client and, when the request named an
1458/// `ack_subject`, waits until the client has published one ack message per chunk
1459/// sent, allowing [`Self::ACK_TIMEOUT_SECS`] (30 s) for each. The content of ack
1460/// messages is ignored, except that a cancel message (see [`is_cancel_message`])
1461/// stops the export: messages already on the ack subject are read before every
1462/// chunk, so at most the chunk being published when the cancel arrives still goes
1463/// out. Without an ack subject the worker only flushes, and nothing stops it from
1464/// outrunning a slow client.
1465struct NatsCsvStreamer {
1466    /// Connected NATS client.
1467    client: async_nats::Client,
1468    /// Reply subject of the export request.
1469    reply: async_nats::Subject,
1470    /// Subscription to the request's `ack_subject`, if one was given.
1471    ack_sub: Option<async_nats::Subscriber>,
1472    /// Chunks published since the last forced client flush.
1473    chunks_since_flush: usize,
1474    /// Chunks published and not yet acknowledged by the client.
1475    unacked: usize,
1476    /// Chunks published so far.
1477    chunks_sent: usize,
1478    /// CSV bytes published so far.
1479    bytes_sent: usize,
1480    /// Asset number used to locate the Parquet partitions.
1481    asset: u32,
1482    /// Inclusive start of the export range.
1483    start: DateTime<Utc>,
1484    /// Inclusive end of the export range.
1485    end: DateTime<Utc>,
1486    /// Filters asked for; `None` for a plain export.
1487    filters: Option<ExportFilters>,
1488}
1489
1490impl NatsCsvStreamer {
1491    /// Buffer size in bytes at which a `chunk` frame is published (512 KiB).
1492    const CHUNK_SIZE: usize = 512 * 1024;
1493    /// Number of chunks published between forced NATS flushes and between ack
1494    /// waits.
1495    const FLUSH_EVERY_CHUNKS: usize = 8;
1496    /// Maximum time to wait for each client chunk acknowledgement, in seconds.
1497    const ACK_TIMEOUT_SECS: u64 = 30;
1498
1499    /// Creates a NATS CSV streamer.
1500    ///
1501    /// # Arguments
1502    ///
1503    /// * `client` - Connected NATS client.
1504    /// * `reply` - Reply subject of the export request.
1505    /// * `ack_sub` - Subscription to the client's ack subject, or `None` to skip
1506    ///   ack waits.
1507    /// * `asset` - Asset number used to locate the Parquet partitions.
1508    /// * `start` - Inclusive start of the export range.
1509    /// * `end` - Inclusive end of the export range.
1510    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    /// Sets the filters of the export. With `Some`, the header gets a
1534    /// `filtered_value` column and every row a value for it.
1535    fn with_filters(mut self, filters: Option<ExportFilters>) -> Self {
1536        self.filters = filters;
1537        self
1538    }
1539
1540    /// Streams all requested channels, one after another, in the given order.
1541    ///
1542    /// The CSV is built by [`spawn_csv`] and every chunk is published with
1543    /// [`Self::publish_chunk`] as soon as it is ready.
1544    ///
1545    /// # Arguments
1546    ///
1547    /// * `parquet_root` - Root of the Parquet archive.
1548    /// * `channels` - Channels to export.
1549    ///
1550    /// # Returns
1551    ///
1552    /// The channels that produced no rows in the range, in request order.
1553    ///
1554    /// # Errors
1555    ///
1556    /// Returns [`ExportCancelled`] if the client cancelled, an error if a publish,
1557    /// flush or ack wait fails, or the first channel error from [`spawn_csv`],
1558    /// prefixed with `channel NN:`, after the chunks completed before it.
1559    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    /// Publishes one `chunk` frame.
1578    ///
1579    /// First reads what has arrived on the ack subject ([`Self::poll_ack_subject`]),
1580    /// so a cancel stops the export before the next chunk. Adds the chunk to
1581    /// `bytes_sent`, flushes the NATS client every [`Self::FLUSH_EVERY_CHUNKS`]
1582    /// chunks, and calls [`Self::wait_for_acks`] once that many chunks are
1583    /// unacknowledged.
1584    ///
1585    /// # Errors
1586    ///
1587    /// Returns [`ExportCancelled`] if the client cancelled, or an error if the
1588    /// publish or flush fails, or if [`Self::wait_for_acks`] fails.
1589    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    /// Returns the error for a cancel seen after the chunks sent so far.
1614    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    /// Counts one message from the ack subject: an ack, or a cancel.
1623    ///
1624    /// # Errors
1625    ///
1626    /// Returns [`ExportCancelled`] if the message is a cancel.
1627    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    /// Reads, without waiting, every message already received on the ack subject.
1636    ///
1637    /// # Errors
1638    ///
1639    /// Returns [`ExportCancelled`] if one of them is a cancel.
1640    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    /// Waits until the client has acknowledged every chunk sent so far.
1654    ///
1655    /// Returns at once if there is no ack subscription or no unacknowledged chunk.
1656    /// Otherwise flushes the client so the chunks actually leave, then waits for
1657    /// messages on the ack subject until every chunk is acknowledged, allowing
1658    /// [`Self::ACK_TIMEOUT_SECS`] for each.
1659    ///
1660    /// # Errors
1661    ///
1662    /// Returns [`ExportCancelled`] if a cancel arrives, or an error if the flush
1663    /// fails, if any ack takes longer than [`Self::ACK_TIMEOUT_SECS`], or if the ack
1664    /// subscription closes.
1665    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    /// Waits for outstanding acks and sends summary and completion frames.
1687    ///
1688    /// Call after [`Self::stream_channels`], which publishes every chunk. Waits for
1689    /// all outstanding acks, then publishes `summary`
1690    /// (`{"type":"summary","bytesSent":N,"missingChannels":[...]}` with the channels
1691    /// sorted and deduplicated) and `complete` (`{"type":"complete"}`), and flushes
1692    /// the client.
1693    ///
1694    /// # Arguments
1695    ///
1696    /// * `missing_channels` - Channels that produced no rows.
1697    ///
1698    /// # Errors
1699    ///
1700    /// Returns an error if a publish, flush or ack wait fails.
1701    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    /// Buffer size in bytes at which a chunk is sent to the sink (128 KiB).
1730    const CHUNK_SIZE: usize = 128 * 1024;
1731
1732    /// Creates a direct CSV streamer.
1733    ///
1734    /// # Arguments
1735    ///
1736    /// * `sink` - Transport that receives the frames.
1737    /// * `asset` - Asset number used to locate the Parquet partitions.
1738    /// * `start` - Inclusive start of the export range.
1739    /// * `end` - Inclusive end of the export range.
1740    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    /// Sets the filters of the export. With `Some`, the header gets a
1752    /// `filtered_value` column and every row a value for it.
1753    fn with_filters(mut self, filters: Option<ExportFilters>) -> Self {
1754        self.filters = filters;
1755        self
1756    }
1757
1758    /// Streams all requested channels, one after another, in the given order.
1759    ///
1760    /// The CSV is built by [`spawn_csv`] and every chunk is sent as soon as it is
1761    /// ready.
1762    ///
1763    /// # Arguments
1764    ///
1765    /// * `parquet_root` - Root of the Parquet archive.
1766    /// * `channels` - Channels to export.
1767    ///
1768    /// # Returns
1769    ///
1770    /// The channels that produced no rows in the range, in request order.
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns an error if the sink fails to send, or the first channel error from
1775    /// [`spawn_csv`], prefixed with `channel NN:`, after the chunks completed
1776    /// before it.
1777    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    /// Sends the summary and completion frames.
1797    ///
1798    /// Call after [`Self::stream_channels`], which sends every chunk. The missing
1799    /// channels are sorted and deduplicated.
1800    ///
1801    /// # Arguments
1802    ///
1803    /// * `missing_channels` - Channels that produced no rows.
1804    ///
1805    /// # Errors
1806    ///
1807    /// Returns an error if the sink fails to send.
1808    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
1819/// Formats timestamps exactly like chrono's `to_rfc3339`, faster.
1820///
1821/// Archived rows arrive in time order, so the date and time up to the second
1822/// changes only once per 100-2,000 rows. That part is built once per second
1823/// with chrono; only the fractional part is written per row, digit by digit,
1824/// following chrono's `to_rfc3339` rule: no fraction for whole seconds,
1825/// otherwise 3, 6 or 9 digits for millisecond, microsecond or nanosecond
1826/// precision.
1827///
1828/// Correctness does not depend on order: out-of-order timestamps only make the
1829/// prefix rebuild more often.
1830struct Rfc3339Formatter {
1831    /// Unix second the cached prefix belongs to, or `None` before the first call.
1832    second: Option<i64>,
1833    /// `YYYY-MM-DDTHH:MM:SS` text for the cached `second`.
1834    prefix: String,
1835}
1836
1837impl Rfc3339Formatter {
1838    /// Creates a formatter with an empty cache.
1839    fn new() -> Self {
1840        Self {
1841            second: None,
1842            prefix: String::new(),
1843        }
1844    }
1845
1846    /// Appends one timestamp to `out`, reusing the date and time prefix if the
1847    /// second is unchanged since the previous call.
1848    ///
1849    /// # Arguments
1850    ///
1851    /// * `out` - Buffer the text is appended to.
1852    /// * `timestamp_unix_ns` - Nanoseconds since the Unix epoch; any `i64`,
1853    ///   including negative values.
1854    ///
1855    /// # Examples
1856    ///
1857    /// ```text
1858    /// 1_790_000_000_008_000_000 -> "2026-09-21T14:13:20.008+00:00"
1859    /// 1_790_000_000_008_123_000 -> "2026-09-21T14:13:20.008123+00:00"
1860    /// 1_790_000_000_000_000_000 -> "2026-09-21T14:13:20+00:00"
1861    /// ```
1862    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            // Seconds-based constructor: `second * 1e9` would overflow near i64::MIN.
1867            let text = DateTime::<Utc>::from_timestamp(second, 0)
1868                .map(|instant| instant.to_rfc3339())
1869                .unwrap_or_default();
1870            // Whole seconds render as "<date>T<time>+00:00"; keep the part before the offset.
1871            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
1887/// Appends `.` and `value` as exactly `width` decimal digits, zero-padded, like
1888/// `write!(out, ".{value:0width$}")` for a `value` below `10^width`.
1889///
1890/// # Arguments
1891///
1892/// * `out` - Buffer the text is appended to.
1893/// * `value` - Fraction of a second in units of `10^-width` s.
1894/// * `width` - Number of digits, at most 9.
1895fn 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/// Value column of a CSV row after `calibration_id`, if the export has one.
1905#[derive(Clone, Copy)]
1906enum FilteredColumn {
1907    /// Plain export: no `filtered_value` column.
1908    Absent,
1909    /// Channel without filters in a filtered export: the calibrated value again.
1910    Calibrated,
1911    /// Filtered value of the row.
1912    Value(f64),
1913}
1914
1915/// Number of slots in [`ValueTextCache`]; a power of two.
1916const VALUE_CACHE_SLOTS: usize = 4096;
1917/// Longest `raw,calibrated` text a [`ValueTextCache`] slot holds, bytes.
1918const VALUE_CACHE_TEXT: usize = 48;
1919/// Lookups over which [`ValueTextCache`] measures its hit rate.
1920const VALUE_CACHE_WINDOW: u32 = 1024;
1921/// Rows a [`ValueTextCache`] formats directly after a window with fewer than one hit
1922/// in four, before it tries the table again.
1923const VALUE_CACHE_BYPASS: u32 = 64 * 1024;
1924
1925/// One cached `raw,calibrated` text of [`ValueTextCache`].
1926#[derive(Clone, Copy)]
1927struct ValueSlot {
1928    /// `f64::to_bits` of the raw value.
1929    bits: u64,
1930    /// Formula generation the text belongs to; 0 for an empty slot.
1931    generation: u32,
1932    /// Length of the raw part of `text`, before the comma.
1933    raw_len: u8,
1934    /// Length of `text`.
1935    len: u8,
1936    /// `{raw},{calibrated}` as written by `write!`.
1937    text: [u8; VALUE_CACHE_TEXT],
1938}
1939
1940/// Remembers the `raw_value,calibrated_value` text of recently written raw values.
1941///
1942/// Values come from a 16-bit converter and repeat heavily (the archive stores them
1943/// dictionary-encoded), while formatting two `f64` with `Display` is the largest
1944/// cost of a CSV row. This direct-mapped table, keyed by the raw value's bits under
1945/// the current calibration formula, lets a repeated value be copied instead. The
1946/// text is exactly what `write!(out, "{raw},{calibrated}")` produces, because a
1947/// cached entry is that output, and a calibrated value is a pure function of the
1948/// raw bits and the formula's coefficients (compared bit for bit, so `-0.0` and
1949/// `0.0` coefficients are different formulas). A different formula starts a new
1950/// generation, which empties the table without touching it. Data with few repeats
1951/// (a wide-ranging signal) would only pay for the lookups, so after a window of
1952/// [`VALUE_CACHE_WINDOW`] lookups with fewer than a quarter hits the table is
1953/// bypassed for [`VALUE_CACHE_BYPASS`] rows.
1954struct ValueTextCache {
1955    /// Slots, allocated on first use.
1956    slots: Vec<ValueSlot>,
1957    /// Formula the current generation's texts were calibrated with.
1958    formula: CalibrationFormula,
1959    /// Current generation, never 0.
1960    generation: u32,
1961    /// Lookups in the current window.
1962    lookups: u32,
1963    /// Hits in the current window.
1964    hits: u32,
1965    /// Rows still to format without the table.
1966    bypass: u32,
1967}
1968
1969impl ValueTextCache {
1970    /// Creates an empty cache.
1971    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    /// Appends `{raw},{calibrated}` to `out`, `calibrated` being `formula` applied
1983    /// to `raw`.
1984    ///
1985    /// # Returns
1986    ///
1987    /// Where the calibrated text starts in `out`; it runs to the end of `out`.
1988    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
2044/// Appends `{raw},{calibrated}` to `out`, `calibrated` being `spec` applied to `raw`.
2045///
2046/// # Returns
2047///
2048/// The length of the raw part, before the comma.
2049fn 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    // `Display` of a float has no comma, so the first one ends the raw value.
2053    out[start..].iter().position(|b| *b == b',').unwrap_or(0)
2054}
2055
2056/// Tells whether two formulas give the same result for every input: same kind and
2057/// coefficients with the same bits.
2058fn 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
2074/// Writes export CSV rows into a chunk buffer.
2075///
2076/// A row is `<timestamp>,chNN,<raw_value>,<calibrated_value>,<calibration_id>`, plus
2077/// `,<filtered_value>` in a filtered export, then `\n`: byte for byte what
2078/// `writeln!` with `{}` for every field gives (floats in Rust's default `Display`
2079/// format, the filtered value empty when it is not finite). It formats the
2080/// timestamp with [`Rfc3339Formatter`] and the values through [`ValueTextCache`],
2081/// and builds `,chNN,` once per channel, so a row normally costs no allocation and
2082/// at most one float formatting (the filtered value).
2083struct CsvRowWriter {
2084    /// Timestamp formatter, shared by every row of the export.
2085    timestamps: Rfc3339Formatter,
2086    /// Recent `raw,calibrated` texts.
2087    values: ValueTextCache,
2088    /// `,chNN,` of the channel being written.
2089    channel_field: Vec<u8>,
2090}
2091
2092impl CsvRowWriter {
2093    /// Creates a writer; call [`Self::set_channel`] before the first row.
2094    fn new() -> Self {
2095        Self {
2096            timestamps: Rfc3339Formatter::new(),
2097            values: ValueTextCache::new(),
2098            channel_field: Vec::new(),
2099        }
2100    }
2101
2102    /// Sets the channel written in the following rows.
2103    fn set_channel(&mut self, channel: u8) {
2104        self.channel_field.clear();
2105        let _ = write!(self.channel_field, ",ch{channel:02},");
2106    }
2107
2108    /// Appends one row to `out`.
2109    ///
2110    /// # Arguments
2111    ///
2112    /// * `out` - Chunk buffer.
2113    /// * `timestamp_unix_ns` - Sample time.
2114    /// * `raw_value` - Value stored in the Parquet file.
2115    /// * `spec` - Calibration of the row's file.
2116    /// * `calibration_id` - Calibration id written in the fifth column.
2117    /// * `filtered` - The `filtered_value` column, if any.
2118    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
2152/// Converts an instant to Unix nanoseconds, clamping outside the i64 range.
2153///
2154/// Instants too far in the future become `i64::MAX` and instants too far in the
2155/// past become `i64::MIN` (roughly outside the years 1677 to 2262), so a very wide
2156/// request range still covers every stored sample.
2157///
2158/// # Arguments
2159///
2160/// * `instant` - Instant to convert.
2161fn 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
2171/// Rows of one Parquet file that fall inside an export range.
2172struct MatchedFile {
2173    /// Calibration read from the file's `calibration` metadata, or identity.
2174    calibration: CalibrationSpec,
2175    /// Calibration id for the CSV, from [`CalibrationSpec::id_or_default`].
2176    calibration_id: String,
2177    /// `(timestamp_unix_ns, raw_value)` pairs in the order stored in the file.
2178    rows: Vec<(i64, f64)>,
2179}
2180
2181/// Returns whether a row group's timestamp statistics overlap `[start_ns, end_ns]`.
2182///
2183/// Uses the min/max statistics of column 0 (`timestamp_unix_ns`). Row groups
2184/// without usable INT64 statistics are always read, so the check can only skip
2185/// work, never lose rows.
2186///
2187/// # Arguments
2188///
2189/// * `meta` - Metadata of the row group.
2190/// * `start_ns` - Inclusive range start, Unix nanoseconds.
2191/// * `end_ns` - Inclusive range end, Unix nanoseconds.
2192fn 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
2202/// Reads every value of one required column in a row group.
2203///
2204/// Reads in batches of up to the row group's row count until the reader returns no
2205/// more records. Definition and repetition levels are not read, so this is only
2206/// correct for `REQUIRED` columns, which is what the archive schema uses.
2207///
2208/// # Arguments
2209///
2210/// * `row_group` - Row group to read from.
2211/// * `index` - Column index within the row group.
2212///
2213/// # Errors
2214///
2215/// Returns an error if the column reader cannot be created or decoding fails.
2216///
2217/// # Panics
2218///
2219/// Panics (inside the parquet crate) if the column's physical type does not match
2220/// `T`.
2221fn 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
2234/// Reads the rows of one archived file whose timestamps fall in `[start_ns, end_ns]`.
2235///
2236/// Row groups whose timestamp statistics lie entirely outside the range are
2237/// skipped without being decoded, and the two columns are read with typed
2238/// column readers instead of building a generic row object per sample. Column 0
2239/// must be the INT64 timestamp and column 1 the DOUBLE value, as in the archive
2240/// schema. Calibration comes from [`read_calibration_from_metadata`].
2241///
2242/// # Arguments
2243///
2244/// * `path` - Parquet file to read.
2245/// * `start_ns` - Inclusive range start, Unix nanoseconds.
2246/// * `end_ns` - Inclusive range end, Unix nanoseconds.
2247///
2248/// # Returns
2249///
2250/// The matching rows in stored order, with the file's calibration and its id.
2251///
2252/// # Errors
2253///
2254/// Returns an error if the file cannot be opened or parsed as Parquet, if a row
2255/// group or column cannot be read, or if a row group has different numbers of
2256/// timestamps and values.
2257///
2258/// # Panics
2259///
2260/// Panics if column 0 is not INT64 or column 1 is not DOUBLE (see [`read_column`]).
2261fn 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
2300/// Reads calibration metadata written by the archiver from a Parquet file.
2301///
2302/// Looks for the first key-value metadata entry named `calibration` and parses its
2303/// value as a JSON [`CalibrationSpec`].
2304///
2305/// # Arguments
2306///
2307/// * `reader` - Open reader for the file.
2308/// * `path` - File path, used only in the log message.
2309///
2310/// # Returns
2311///
2312/// The parsed calibration, or [`CalibrationSpec::default`] (unnamed identity) if
2313/// the entry is missing, has no value, or is not valid JSON. Invalid JSON is
2314/// logged.
2315fn 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
2347/// Length of the time slices in which overlapping files are merged (300 s).
2348///
2349/// Slices start at multiples of this length since the Unix epoch, the same windows
2350/// the archiver uses for its part files, so a slice of current data lines up with one
2351/// file. Only one slice of rows from each overlapping file is held in memory at a time.
2352const MERGE_SLICE_NS: i64 = 300 * 1_000_000_000;
2353
2354/// Timestamp span of one archived part file, read from its footer.
2355struct FileSpan {
2356    /// Path of the part file.
2357    path: PathBuf,
2358    /// Earliest timestamp in the file, Unix nanoseconds.
2359    min_ns: i64,
2360    /// Latest timestamp in the file, Unix nanoseconds.
2361    max_ns: i64,
2362}
2363
2364/// Rows of one or more files, sorted by time with exact duplicates removed.
2365struct MergedRows {
2366    /// Calibration and CSV calibration id of each file that contributed rows.
2367    calibrations: Vec<(CalibrationSpec, String)>,
2368    /// `(timestamp_unix_ns, raw_value, calibration index)` in timestamp order.
2369    rows: Vec<(i64, f64, usize)>,
2370}
2371
2372/// Finds the earliest and latest timestamp in one archived file.
2373///
2374/// Uses the min/max statistics of the timestamp column in each row group, so
2375/// normally only the footer is read. A row group without usable statistics has its
2376/// timestamp column decoded instead.
2377///
2378/// # Arguments
2379///
2380/// * `path` - Parquet file to inspect.
2381///
2382/// # Returns
2383///
2384/// `(min_ns, max_ns)`, or `None` if the file has no rows.
2385///
2386/// # Errors
2387///
2388/// Returns an error if the file cannot be opened or parsed, or a row group without
2389/// statistics cannot be read.
2390fn 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
2424/// Lists one channel-day folder and groups its files by overlapping time spans.
2425///
2426/// Files are found the same way the export always has: every name ending in
2427/// `.parquet`. Files with no rows, or whose span lies outside `[start_ns, end_ns]`,
2428/// are left out. A file whose span cannot be read is logged and skipped, and the
2429/// export continues.
2430///
2431/// The remaining files are grouped so that files whose spans overlap (or touch at the
2432/// same nanosecond) share a group, directly or through other files. The aligned
2433/// five-minute files the archiver writes never overlap, so each is a group of its own.
2434/// Duplicate copies written by a replayed backlog overlap the originals and end up in
2435/// one group, whatever their boundaries, which is what lets [`read_merged_rows`] drop
2436/// the copies.
2437///
2438/// # Arguments
2439///
2440/// * `day_dir` - `<root>/assetNNN/YYYY-MM-DD/chNN/` folder.
2441/// * `start_ns` - Inclusive range start, Unix nanoseconds.
2442/// * `end_ns` - Inclusive range end, Unix nanoseconds.
2443///
2444/// # Returns
2445///
2446/// The groups in time order. Files inside a group are in file-name order.
2447///
2448/// # Errors
2449///
2450/// Returns an error if the folder cannot be listed.
2451fn 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
2503/// Splits the export range covered by one file group into merge slices.
2504///
2505/// A group of one file is read in a single pass, as before, since it holds no
2506/// copies of another file's rows. A group of overlapping files is read in slices
2507/// aligned to [`MERGE_SLICE_NS`], so memory is bounded by one slice of each file
2508/// instead of the whole group.
2509///
2510/// # Arguments
2511///
2512/// * `group` - Files from [`overlapping_file_groups`].
2513/// * `start_ns` - Inclusive range start, Unix nanoseconds.
2514/// * `end_ns` - Inclusive range end, Unix nanoseconds.
2515///
2516/// # Returns
2517///
2518/// Inclusive `(from_ns, to_ns)` slices in time order, together covering the part of
2519/// `[start_ns, end_ns]` that the group spans.
2520fn 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
2550/// Returns how strongly a copy's calibration is preferred when copies collide.
2551///
2552/// Higher wins. The archiver writes an unnamed identity calibration
2553/// (`{"id":null,"type":"identity"}`) when a channel has no calibration configured,
2554/// so identity there means "no calibration known" rather than a deliberate choice.
2555/// The same raw sample has been found archived once that way and once with the
2556/// channel's real sensor calibration (for example `{"id":"tp3586","type":"linear",..}`),
2557/// when a backlog was archived again after the calibration was set. Both copies hold
2558/// the same raw value, so the calibrated one loses nothing and turns volts into the
2559/// sensor's units, while picking identity would report volts as if they were
2560/// calibrated and make the calibrated column jump between copies. Between two
2561/// non-identity calibrations there is no such rule, and the copy from the earliest
2562/// file (by name, which is the order the archiver wrote them) is kept.
2563///
2564/// # Arguments
2565///
2566/// * `spec` - Calibration of one copy.
2567fn calibration_preference(spec: &CalibrationSpec) -> u8 {
2568    match spec.formula {
2569        CalibrationFormula::Identity => 0,
2570        _ => 1,
2571    }
2572}
2573
2574/// Sorts rows by timestamp and removes exact duplicates.
2575///
2576/// Two rows are duplicates when their timestamps are equal and their values have the
2577/// same bit pattern (`f64::to_bits`), so NaN copies collapse too. Rows with the same
2578/// timestamp but different values are all kept, in their original order. The sort
2579/// is stable and skipped when the rows are already in order, so rows of a single
2580/// clean file come out exactly as stored.
2581///
2582/// When duplicates carry different calibrations, the kept row takes the most
2583/// preferred one (see [`calibration_preference`]); on a tie the earliest row's
2584/// calibration stays.
2585///
2586/// # Arguments
2587///
2588/// * `rows` - `(timestamp_unix_ns, raw_value, calibration index)` rows, in file
2589///   order; replaced by the result.
2590/// * `calibrations` - Calibrations the indices refer to.
2591fn 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    // The usual case, a clean file: no timestamp repeats, so nothing to drop.
2599    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            // Runs are a handful of copies at most, so a linear search is enough.
2615            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
2632/// Reads one slice of a file group, merged into time order without duplicates.
2633///
2634/// Each file whose span overlaps the slice is read with [`read_matching_rows`], and
2635/// the rows are combined with [`sort_and_drop_duplicates`]. A file that fails to read
2636/// is logged and skipped, and the other files are still used.
2637///
2638/// # Arguments
2639///
2640/// * `group` - Files from [`overlapping_file_groups`], in file-name order.
2641/// * `from_ns` - Inclusive slice start, Unix nanoseconds.
2642/// * `to_ns` - Inclusive slice end, Unix nanoseconds.
2643fn 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
2668/// Parses an RFC 3339 start/end range into UTC instants.
2669///
2670/// Does not check the order of the two instants; callers do that.
2671///
2672/// # Arguments
2673///
2674/// * `start` - Range start, RFC 3339 with any offset.
2675/// * `end` - Range end, RFC 3339 with any offset.
2676///
2677/// # Errors
2678///
2679/// Returns an error naming `start` or `end` if either is not valid RFC 3339.
2680fn 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
2690/// Builds an inclusive list of UTC dates covered by an export request.
2691///
2692/// # Arguments
2693///
2694/// * `start` - First date.
2695/// * `end` - Last date, included. If it is before `start` the list is empty.
2696///
2697/// # Examples
2698///
2699/// ```text
2700/// date_range(2026-09-21, 2026-09-23) == [2026-09-21, 2026-09-22, 2026-09-23]
2701/// ```
2702fn 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    /// What a test client saw of one worker-mode export.
2724    struct ExportRun {
2725        /// Frame names in arrival order.
2726        frames: Vec<String>,
2727        /// `chunk` frames received after the cancel was sent.
2728        chunks_after_cancel: usize,
2729        /// What `process_nats_request` returned.
2730        result: Result<()>,
2731        /// Time from the cancel (or the request) until the exporter returned.
2732        elapsed: std::time::Duration,
2733    }
2734
2735    /// How the test client ends an export.
2736    #[derive(Clone, Copy)]
2737    enum ClientEnd {
2738        /// Acks every chunk, like a client without cancel support.
2739        AckAll,
2740        /// Acks the first `n` chunks, then sends a cancel with the header, while
2741        /// the exporter keeps streaming.
2742        CancelAfter(usize),
2743        /// Acks the first eight chunks, lets the exporter block waiting for the next
2744        /// eight acks, then sends a cancel as a JSON body without the header.
2745        CancelWhileBlocked,
2746    }
2747
2748    /// Runs one worker-mode export of about 20 chunks against a real NATS server.
2749    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                // Quiet for 2 s: the exporter is blocked on acks or done.
2810                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    /// A client without cancel support acks every chunk and gets the whole export.
2864    #[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    /// A cancel sent while the exporter is streaming stops it before its next ack
2884    /// wait: no summary or complete frame, and it returns without the ack timeout.
2885    #[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        // Up to eight chunks may be in flight unacknowledged when the cancel is
2894        // sent; nothing is published after the exporter has read it.
2895        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    /// A cancel (here as a JSON body without the header) ends an exporter that is
2902    /// blocked waiting for acks at once, with no further chunk.
2903    #[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    /// The exporter's reading logic before this change, kept as a reference.
2920    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    /// Writes a two-column file with the given row groups (plain, uncompressed,
2939    /// like the archiver's older output).
2940    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    /// Converts Unix nanoseconds to a UTC instant.
2970    fn at(ns: i64) -> DateTime<Utc> {
2971        DateTime::<Utc>::from_timestamp_nanos(ns)
2972    }
2973
2974    /// Export sink that keeps every CSV chunk in memory.
2975    #[derive(Default)]
2976    struct CollectingSink {
2977        /// Chunks in the order they were sent.
2978        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    /// Runs a direct-mode export and returns the CSV chunks it sent.
3002    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    /// The export before duplicate removal: every file of every day in file-name
3017    /// order, rows as stored, each with its own file's calibration.
3018    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    /// Writes one part file with `calibration` metadata into a channel-day folder.
3060    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    /// Unix nanoseconds of 2026-09-21T00:00:00Z, a five-minute boundary.
3097    const DAY_START: i64 = 1_790_035_200_000_000_000;
3098    /// Sample interval of the test data (10 Hz).
3099    const STEP: i64 = 100_000_000;
3100    /// Identity calibration as the archiver writes it with nothing configured.
3101    const IDENTITY: &str = r#"{"id":null,"type":"identity"}"#;
3102    /// A real sensor calibration.
3103    const LINEAR: &str = r#"{"id":"tp3586","type":"linear","a":62.5,"b":-25.0}"#;
3104
3105    /// `count` samples from sample index `from` of the test day.
3106    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    /// Without overlapping files the export is byte for byte what it was before
3113    /// duplicate removal, chunk boundaries included.
3114    #[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        // Three aligned five-minute files on channel 8, one calibrated, plus a file
3123        // on the next day and one on channel 9.
3124        let window = 3_000; // samples per five minutes at 10 Hz
3125        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            // Chunk boundaries depend only on the bytes, so they match too.
3148            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    /// A day folder that cannot be listed fails the export with `channel NN:` after
3167    /// the chunks completed before it; the partial chunk is not sent.
3168    #[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(); // true when run as root
3180        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    /// Overlapping copies with other boundaries and other calibrations are sent once,
3198    /// in time order, preferring the real calibration; conflicting values are kept.
3199    #[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        // Originals: two aligned windows without calibration.
3209        write_part(&dir, 1, &samples(0, window), IDENTITY);
3210        write_part(&dir, 2, &samples(window, window), IDENTITY);
3211        // A replayed backlog copied the second half of window 0 and the first half
3212        // of window 1 into one file, after the calibration was set.
3213        write_part(&dir, 3, &samples(1_500, window), LINEAR);
3214        // A second identity copy of window 1, with one sample whose value differs.
3215        let mut copy = samples(window, window);
3216        copy[10].1 = 9.75;
3217        write_part(&dir, 4, &copy, IDENTITY);
3218        // A later, separate file that overlaps nothing.
3219        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    /// Duplicate removal keeps order, conflicts and the preferred calibration.
3264    #[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    /// Overlapping files form one group read in aligned slices; others stay whole.
3300    #[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        // A range that misses a file leaves it out.
3350        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    /// Checks the typed reader against the row-iterator reference for many ranges.
3356    #[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        // Three row groups, the last one stepping backwards in time like a
3368        // clock re-anchor, plus a NaN value.
3369        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)), // everything
3379            (ns(0), ns(0)),       // single inclusive sample
3380            (ns(120), ns(180)),   // inside one row group
3381            (ns(99), ns(100)),    // across a row-group boundary
3382            (ns(260), ns(270)),   // overlapping, out-of-order timestamps
3383            (ns(500), ns(600)),   // after all data
3384            (ns(-100), ns(-1)),   // before all data
3385        ] {
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    /// Checks the row-group statistics filter on inclusive boundaries.
3398    #[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    /// A calibration with a unit and no id, serialized as the archiver does, is read
3417    /// back from Parquet metadata unchanged and labelled by its formula type.
3418    #[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    /// [`CsvRowWriter`] writes exactly the rows of the earlier `writeln!` code,
3437    /// through cache hits, misses, formula changes, long texts and the bypass.
3438    #[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        // Repeating 16-bit codes, then values that never repeat (cache bypass).
3466        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    /// chrono's RFC 3339 text, the reference [`Rfc3339Formatter`] must match.
3524    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    /// Formats one timestamp with `formatter` into a new string.
3529    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    /// Checks [`Rfc3339Formatter`] against chrono on edge cases and random values.
3536    #[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        // Consecutive samples at 2 kHz and 100 Hz across second boundaries.
3576        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        // Pseudo-random timestamps across the whole i64 range, in random order.
3581        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    /// Checks nanosecond conversion and clamping of out-of-range instants.
3591    #[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    /// Times plain and filtered exports of four synthetic five-minute 2 kHz files
3633    /// written like the archiver's, with `BENCH_DISTINCT` (default 81) distinct raw
3634    /// values. Run with `cargo test --release --bin exporter
3635    /// benchmark_export_pipeline -- --ignored --nocapture`.
3636    #[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; // 2 kHz
3648        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    /// Runs a direct-mode export with a `filters` field and returns the CSV.
3741    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    /// 100 Hz samples from `from` for `count` samples: 0.5 V, a 10 Hz square wave of
3764    /// ±3 mV locked to the sample count and a 12 ms pulse of 20 mV at sample 7000.
3765    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    /// Filters that are off, or on for channels not requested, leave the export
3776    /// byte for byte as without them.
3777    #[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    /// A filtered export keeps every plain column and adds `filtered_value`: the
3802    /// zero-phase pipeline over the range plus the lead-in and settling margins,
3803    /// and the calibrated value on channels without filters.
3804    #[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        // 120 s at 100 Hz in files of 30 s, and channel 9 unfiltered.
3811        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        // The reference: the filter over exactly the rows the exporter reads.
3847        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        // The square wave is 0.1875 kPa after calibration; away from the pulse and
3882        // the high-pass tail around it the filtered rows stay near zero, and the
3883        // pulse is kept.
3884        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}