Skip to main content

archiver/
store.rs

1//! The `archiver` binary: writes LabJack scans from NATS JetStream to Parquet files.
2//!
3//! For every LabJack read, the streamer publishes one FlatBuffer `Scan` per channel to
4//! the local NATS JetStream. The archiver reads the same dashboard configuration as the
5//! streamer (from KV bucket `avenabox` by default), attaches one durable pull consumer
6//! per enabled channel, rebuilds each sample's timestamp from the scan header, and writes the
7//! samples to Parquet files under
8//! `<PARQUET_DIR>/asset<NNN>/<YYYY-MM-DD>/ch<NN>/part-<NNNN>.parquet`. Each file carries
9//! the channel's calibration as key-value metadata. The exporter later serves these files
10//! as CSV over NATS.
11//!
12//! At startup the archiver can mirror the configuration entry from a central NATS server
13//! into local KV, and it keeps mirroring updates in a background task. It then watches
14//! the local KV key and starts, stops or restarts channel writers as the configuration
15//! changes. Ctrl-C closes every open file and acks its messages before the process exits.
16//!
17//! # Configuration
18//!
19//! * `NATS_SERVERS` - Comma-separated local NATS server URLs. Default:
20//!   `nats://127.0.0.1:4222`.
21//! * `NATS_CREDS_FILE` - Credentials file for the local NATS connection. Default:
22//!   `apt.creds`.
23//! * `JS_DOMAIN` - Optional JetStream domain for the local connection.
24//! * `PARQUET_DIR` - Root directory for Parquet output. Default: `parquet`.
25//! * `CFG_BUCKET` - Local KV bucket holding the dashboard configuration. Default:
26//!   `avenabox`. Also the fallback for `CENTRAL_CFG_BUCKET`.
27//! * `CFG_KEY` - Local KV key holding the dashboard configuration. Default:
28//!   `labjackd.config.macbook`. Also the fallback for `CENTRAL_CFG_KEY`.
29//! * `CENTRAL_NATS_SERVERS` - Central NATS server URLs to mirror configuration from.
30//!   Central mirroring is off when neither this nor `CFG_NATS_SERVERS` is set.
31//! * `CFG_NATS_SERVERS` - Fallback for `CENTRAL_NATS_SERVERS`.
32//! * `CENTRAL_CFG_BUCKET` - Central KV bucket to mirror from. Default: `CFG_BUCKET`,
33//!   then `avenabox`.
34//! * `CENTRAL_CFG_KEY` - Central KV key to mirror from. Default: `CFG_KEY`, then
35//!   `unknown-site.macbook.unknown-source.config`.
36//! * `CENTRAL_JS_DOMAIN` - Optional JetStream domain on the central server. Falls back to
37//!   `CFG_JS_DOMAIN`.
38//! * `CENTRAL_NATS_CREDS_FILE` - Credentials file for the central server. Default: the
39//!   value used for `NATS_CREDS_FILE`.
40//! * `ARCHIVER_STATE_DIR` - Directory for the replay-guard checkpoints. Default:
41//!   `<PARQUET_DIR>/.archiver-state`.
42//! * `ARCHIVER_REPLAY_GUARD` - `off` (or `0`, `false`, `no`) turns the replay guard off.
43//!
44//! # Design
45//!
46//! * **Aligned source-time rotation.** A file covers one window of `rotate_secs` that
47//!   starts at a multiple of `rotate_secs` since the Unix epoch (see [`rotation_window`]),
48//!   measured in sample time rather than wall-clock time. Files line up across channels,
49//!   and replaying a backlog of several hours still produces one file per window. A new
50//!   file also starts at a UTC date change or when sample time goes backwards.
51//! * **One row group per file.** A row group holds up to [`ROWS_PER_ROW_GROUP`] rows, which
52//!   is more than a normal window, so a file is usually a single row group. Files are
53//!   written with zstd compression and delta-encoded timestamps (see
54//!   [`archive_format::writer_properties`]).
55//! * **Acks after close and fsync.** A JetStream message is acked only after every sample
56//!   in it is inside a file that has been closed, fsynced and renamed from
57//!   `.parquet.inprogress` to `.parquet` (see [`ParquetLogger::close`]). If the process
58//!   or machine dies first, the messages stay unacked and JetStream redelivers them once
59//!   the consumer's `ack_wait` expires, so they are written again into a new file.
60//! * **Early close at the pending-ack cap.** When a file holds
61//!   [`MAX_PENDING_ACKS_PER_FILE`] unacked messages it is closed early, which keeps the
62//!   consumer below its `max_ack_pending` limit ([`CONSUMER_MAX_ACK_PENDING`]) so delivery
63//!   never stalls.
64//! * **Idle close.** A file that has received no data for [`IDLE_CLOSE_AFTER`] is closed,
65//!   for example after the streamer stops, so its messages are acked promptly.
66//! * **Quarantine of unfinished files.** At startup every leftover `.parquet.inprogress`
67//!   file (no footer, from a crash) is renamed aside and kept for diagnosis (see
68//!   [`quarantine_incomplete_files`]). Its samples come back through redelivery.
69//! * **Replay guard.** JetStream can lose a durable consumer's progress (its state file
70//!   is replaced without an fsync, so a power cut can leave it empty) and then deliver
71//!   the whole stream again under deliver policy `all`. Each channel saves the
72//!   consumer's ack floor to its own fsynced checkpoint and acks messages at or below it
73//!   without writing them (see [`ReplayState`] and [`replay_guard`]).
74//! * **Filters are never edited in place.** When a channel's subject changes, an existing
75//!   consumer with the old filter is left alone and a consumer named after the new
76//!   subject is used (see [`filtered_consumer_name`]).
77
78use async_nats;
79use async_nats::ConnectOptions;
80use async_nats::jetstream::{
81    self,
82    consumer::pull,
83    kv::{self, Operation},
84};
85use chrono::{DateTime, NaiveDate, Utc};
86use futures_util::StreamExt;
87use parquet::{
88    column::writer::ColumnWriter, file::writer::SerializedFileWriter,
89    schema::parser::parse_message_type,
90};
91use std::{
92    collections::HashMap,
93    fs, io,
94    path::{Path, PathBuf},
95    sync::Arc,
96    time::{Instant, SystemTime, UNIX_EPOCH},
97};
98use tokio::sync::{oneshot, watch};
99use tokio::time::Duration;
100
101mod archive_format;
102mod calibration;
103mod nats_config;
104mod replay_guard;
105mod subjects;
106#[cfg(test)]
107mod test_nats;
108mod sample_data_generated {
109    #![allow(dead_code, unused_imports)]
110    include!("data_generated.rs");
111}
112use sample_data_generated::sampler;
113
114use archive_format::{SAMPLE_SCHEMA, writer_properties_for_calibration};
115use calibration::CalibrationSpec;
116use replay_guard::ReplayGuard;
117use serde::{Deserialize, Serialize};
118
119/// Error type used by the archiver for fallible async setup and IO paths.
120type DynError = Box<dyn std::error::Error + Send + Sync>;
121
122#[allow(dead_code)]
123#[derive(Debug, Clone, Deserialize)]
124/// Raw top-level dashboard configuration loaded from NATS KV.
125///
126/// This is the JSON shape the dashboard writes. [`sample_config_from_nested`] turns it
127/// into the archiver's [`SampleConfig`].
128struct NestedConfig {
129    /// LabJack device name; used as the source ID when `source_id` is absent.
130    labjack_name: String,
131    /// Asset number; names the `asset<NNN>` output directory.
132    asset_number: u32,
133    /// Maximum channel count from the dashboard. Parsed but not used by the archiver.
134    max_channels: u32,
135    /// Site identifier used in the structured subject namespace.
136    #[serde(default)]
137    site_id: Option<String>,
138    /// Box identifier used in the subject and the durable consumer name.
139    #[serde(default)]
140    box_id: Option<String>,
141    /// Source type used in the durable consumer name (`labjack` when absent).
142    #[serde(default)]
143    source_type: Option<String>,
144    /// Source identifier used in the subject and the durable consumer name.
145    #[serde(default)]
146    source_id: Option<String>,
147    /// Subject root, for example `avenabox` or `avenars`.
148    nats_subject: String,
149    /// Name of the JetStream stream holding the channel subjects.
150    nats_stream: String,
151    /// Length of one file rotation window, in seconds.
152    rotate_secs: u64,
153    /// Nested sensor settings section.
154    sensor_settings: SensorConfig,
155}
156
157#[allow(dead_code)]
158#[derive(Debug, Clone, Deserialize)]
159/// Raw sensor settings section from the dashboard configuration.
160///
161/// The archiver uses the enabled channel list and the calibration map from this section.
162/// It copies the sampling fields into [`SampleConfig`] and ignores the rest. The serde
163/// aliases accept the older names `scan_rate` and `sampling_rate`.
164struct SensorConfig {
165    /// Scans per LabJack stream read (older name `scan_rate`).
166    #[serde(rename = "scans_per_read", alias = "scan_rate")]
167    scans_per_read: i32,
168    /// Requested scan rate in Hz (older name `sampling_rate`).
169    #[serde(rename = "scan_rate_hz", alias = "sampling_rate")]
170    scan_rate_hz: f64,
171    /// Channels to archive; one consumer and writer task is started per entry.
172    channels_enabled: Vec<u8>,
173    /// Gain setting. Parsed but not used by the archiver.
174    gains: i32,
175    /// Per-channel data formats. Parsed but not used by the archiver.
176    data_formats: Vec<String>,
177    /// Per-channel measurement units. Parsed but not used by the archiver.
178    measurement_units: Vec<String>,
179    /// Streamer on/off switch. Parsed but not used by the archiver.
180    labjack_on_off: bool,
181    /// Calibrations keyed by channel number as a string, for example `"8"`.
182    calibrations: Option<HashMap<String, CalibrationSpec>>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186/// Normalized archiver configuration used to manage channel loggers.
187///
188/// This shape combines stream identity, channel list, rotation cadence, and
189/// parsed per-channel calibrations. `main` compares each channel's derived subject,
190/// consumer name, stream, asset and `rotate_secs` against the running writer to decide
191/// whether to restart it.
192struct SampleConfig {
193    /// Scans per LabJack stream read. Not used by the archiver.
194    scans_per_read: i32,
195    /// Requested scan rate in Hz. Not used by the archiver.
196    scan_rate_hz: f64,
197    /// Channels to archive.
198    channels: Vec<u8>,
199    /// Asset number; names the `asset<NNN>` output directory.
200    asset_number: u32,
201    /// LabJack device name; fallback source ID for subjects and consumer names.
202    labjack_name: String,
203    /// Site identifier for the subject namespace.
204    site_id: Option<String>,
205    /// Box identifier for the subject namespace and consumer names.
206    box_id: Option<String>,
207    /// Source type for consumer names.
208    source_type: Option<String>,
209    /// Source identifier for the subject namespace and consumer names.
210    source_id: Option<String>,
211    /// Subject root, for example `avenabox` or `avenars`.
212    nats_subject: String,
213    /// JetStream stream that holds the channel subjects.
214    nats_stream: String,
215    /// Length of one file rotation window, in seconds.
216    rotate_secs: u64,
217    /// Calibration per channel; channels without an entry use the default spec.
218    calibrations: HashMap<u8, CalibrationSpec>,
219}
220
221impl From<(SensorConfig, &SampleConfig)> for SampleConfig {
222    /// Replaces the sensor settings of a config while keeping its identity fields.
223    ///
224    /// Channels, sampling fields and calibrations come from the new sensor section.
225    /// Asset, names, subject, stream and `rotate_secs` are copied from `base`.
226    ///
227    /// # Arguments
228    ///
229    /// * `raw` - New sensor settings section.
230    /// * `base` - Existing config whose identity fields are kept.
231    fn from((raw, base): (SensorConfig, &SampleConfig)) -> Self {
232        let calibrations = parse_calibrations(&raw);
233        SampleConfig {
234            scans_per_read: raw.scans_per_read,
235            scan_rate_hz: raw.scan_rate_hz,
236            channels: raw.channels_enabled,
237            asset_number: base.asset_number,
238            labjack_name: base.labjack_name.clone(),
239            site_id: base.site_id.clone(),
240            box_id: base.box_id.clone(),
241            source_type: base.source_type.clone(),
242            source_id: base.source_id.clone(),
243            nats_subject: base.nats_subject.clone(),
244            nats_stream: base.nats_stream.clone(),
245            rotate_secs: base.rotate_secs,
246            calibrations,
247        }
248    }
249}
250
251/// Parses dashboard calibration map keys into numeric LabJack channels.
252///
253/// Invalid channel keys are ignored after logging because one malformed
254/// calibration entry should not prevent unrelated channels from being archived.
255///
256/// # Arguments
257///
258/// * `raw` - Sensor settings whose `calibrations` map is read.
259///
260/// # Returns
261///
262/// Calibrations keyed by channel number. Empty when the section has no
263/// `calibrations` map.
264fn parse_calibrations(raw: &SensorConfig) -> HashMap<u8, CalibrationSpec> {
265    let mut out = HashMap::new();
266    let Some(calibrations) = raw.calibrations.as_ref() else {
267        return out;
268    };
269    for (key, spec) in calibrations {
270        match key.parse::<u8>() {
271            Ok(ch) => {
272                out.insert(ch, spec.clone());
273            }
274            Err(_) => {
275                eprintln!("[logger] Invalid calibration channel key '{key}', expected u8.");
276            }
277        }
278    }
279    out
280}
281
282/// Converts the nested dashboard configuration into archiver runtime config.
283///
284/// Calibration keys are parsed with [`parse_calibrations`]; `max_channels` and the
285/// unused sensor fields are dropped.
286///
287/// # Arguments
288///
289/// * `nested` - Configuration as deserialized from the KV entry.
290fn sample_config_from_nested(nested: NestedConfig) -> SampleConfig {
291    let calibrations = parse_calibrations(&nested.sensor_settings);
292    let raw = nested.sensor_settings;
293    SampleConfig {
294        scans_per_read: raw.scans_per_read,
295        scan_rate_hz: raw.scan_rate_hz,
296        channels: raw.channels_enabled,
297        asset_number: nested.asset_number,
298        labjack_name: nested.labjack_name,
299        site_id: nested.site_id,
300        box_id: nested.box_id,
301        source_type: nested.source_type,
302        source_id: nested.source_id,
303        nats_subject: nested.nats_subject,
304        nats_stream: nested.nats_stream,
305        rotate_secs: nested.rotate_secs,
306        calibrations,
307    }
308}
309
310/// Returns a trimmed environment variable value when it is set and non-empty.
311///
312/// # Arguments
313///
314/// * `name` - Environment variable name.
315///
316/// # Returns
317///
318/// `None` when the variable is unset, not valid Unicode, or only whitespace.
319fn env_nonempty(name: &str) -> Option<String> {
320    std::env::var(name)
321        .ok()
322        .map(|value| value.trim().to_string())
323        .filter(|value| !value.is_empty())
324}
325
326#[derive(Debug, Clone)]
327/// Connection and key details for mirroring dashboard config from central NATS.
328struct CentralKvSyncConfig {
329    /// Central NATS server addresses.
330    servers: Vec<async_nats::ServerAddr>,
331    /// Credentials file for the central connection.
332    creds_path: String,
333    /// Central KV bucket holding the configuration.
334    bucket: String,
335    /// Central KV key holding the configuration.
336    key: String,
337    /// Optional JetStream domain on the central server.
338    domain: Option<String>,
339}
340
341/// Builds optional central KV mirroring settings from environment variables.
342///
343/// Reads `CENTRAL_NATS_SERVERS` (or `CFG_NATS_SERVERS`), `CENTRAL_CFG_BUCKET` (or
344/// `CFG_BUCKET`, default `avenabox`), `CENTRAL_CFG_KEY` (or `CFG_KEY`, default
345/// `unknown-site.macbook.unknown-source.config`), `CENTRAL_JS_DOMAIN` (or
346/// `CFG_JS_DOMAIN`) and `CENTRAL_NATS_CREDS_FILE`.
347///
348/// # Arguments
349///
350/// * `creds_path` - Local credentials file, used when `CENTRAL_NATS_CREDS_FILE` is unset.
351///
352/// # Returns
353///
354/// `None` when no central server list is configured, which turns mirroring off.
355///
356/// # Errors
357///
358/// Returns an error if the server list contains an entry that is not a valid NATS
359/// server address, or no usable entry at all.
360fn central_kv_sync_config_from_env(
361    creds_path: &str,
362) -> Result<Option<CentralKvSyncConfig>, DynError> {
363    let Some(raw_servers) =
364        env_nonempty("CENTRAL_NATS_SERVERS").or_else(|| env_nonempty("CFG_NATS_SERVERS"))
365    else {
366        return Ok(None);
367    };
368
369    let servers = nats_config::servers_from_env_var("CENTRAL_NATS_SERVERS", &raw_servers)
370        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
371    let bucket = env_nonempty("CENTRAL_CFG_BUCKET")
372        .or_else(|| env_nonempty("CFG_BUCKET"))
373        .unwrap_or_else(|| "avenabox".to_string());
374    let key = env_nonempty("CENTRAL_CFG_KEY")
375        .or_else(|| env_nonempty("CFG_KEY"))
376        .unwrap_or_else(|| "unknown-site.macbook.unknown-source.config".to_string());
377    let domain = env_nonempty("CENTRAL_JS_DOMAIN").or_else(|| env_nonempty("CFG_JS_DOMAIN"));
378    let creds_path =
379        env_nonempty("CENTRAL_NATS_CREDS_FILE").unwrap_or_else(|| creds_path.to_string());
380
381    Ok(Some(CentralKvSyncConfig {
382        servers,
383        creds_path,
384        bucket,
385        key,
386        domain,
387    }))
388}
389
390/// Connects to NATS with a credentials file and explicit server list.
391///
392/// # Arguments
393///
394/// * `servers` - Server addresses to connect to.
395/// * `creds_path` - Path of the NATS credentials file.
396///
397/// # Errors
398///
399/// Returns an error if the credentials file cannot be loaded or the connection fails.
400async fn connect_nats_with_creds(
401    servers: Vec<async_nats::ServerAddr>,
402    creds_path: String,
403) -> Result<async_nats::Client, DynError> {
404    let opts = ConnectOptions::with_credentials_file(creds_path).await?;
405    Ok(opts.connect(servers).await?)
406}
407
408/// Opens or creates the KV bucket that contains dashboard configuration.
409///
410/// If opening the bucket fails for any reason, the function tries to create it with a
411/// history of 5 revisions per key.
412///
413/// # Arguments
414///
415/// * `js` - JetStream context of the server that holds the bucket.
416/// * `bucket` - Bucket name, for example `avenabox`.
417///
418/// # Errors
419///
420/// Returns an error if the bucket cannot be opened and creating it also fails.
421async fn ensure_kv_bucket(js: &jetstream::Context, bucket: &str) -> Result<kv::Store, DynError> {
422    if let Ok(store) = js.get_key_value(bucket).await {
423        return Ok(store);
424    }
425    Ok(js
426        .create_key_value(kv::Config {
427            bucket: bucket.to_string(),
428            history: 5,
429            ..Default::default()
430        })
431        .await?)
432}
433
434/// Copies the configured central KV entry into local KV during startup.
435///
436/// The remote payload is deserialized before writing locally so invalid central
437/// configuration does not replace the local copy. The local key is written only when
438/// its value differs from the central one. The central bucket is created if it does not
439/// exist (through [`ensure_kv_bucket`]).
440///
441/// # Arguments
442///
443/// * `sync_cfg` - Central server, credentials, bucket and key.
444/// * `local_store` - Local KV bucket to write into.
445/// * `local_key` - Local key to write.
446///
447/// # Errors
448///
449/// Returns an error if connecting to the central server or opening its bucket fails, if
450/// the central key does not exist, if its value is not a valid [`NestedConfig`], or if
451/// reading or writing the local key fails.
452async fn mirror_central_kv_once(
453    sync_cfg: &CentralKvSyncConfig,
454    local_store: &kv::Store,
455    local_key: &str,
456) -> Result<(), DynError> {
457    let client =
458        connect_nats_with_creds(sync_cfg.servers.clone(), sync_cfg.creds_path.clone()).await?;
459    let remote_js = nats_config::jetstream_context_for_domain(client, sync_cfg.domain.as_deref());
460    let remote_store = ensure_kv_bucket(&remote_js, &sync_cfg.bucket).await?;
461    let remote_entry = remote_store
462        .entry(sync_cfg.key.as_str())
463        .await?
464        .ok_or_else(|| format!("central KV key '{}' not found", sync_cfg.key))?;
465    serde_json::from_slice::<NestedConfig>(&remote_entry.value)?;
466    let should_put = match local_store.entry(local_key).await? {
467        Some(local_entry) => local_entry.value.as_ref() != remote_entry.value.as_ref(),
468        None => true,
469    };
470    if should_put {
471        local_store
472            .put(local_key, remote_entry.value.clone())
473            .await?;
474        println!(
475            "[logger] Mirrored central KV '{}:{}' into local '{}'",
476            sync_cfg.bucket, sync_cfg.key, local_key
477        );
478    }
479    Ok(())
480}
481
482/// Continuously mirrors central KV updates into the local configuration bucket.
483///
484/// This task reconnects after setup or watch failures and only writes updates
485/// that parse as a valid dashboard configuration. Each retry waits 5 seconds. Delete and
486/// purge events are logged and ignored, and a value equal to the local one is not
487/// rewritten. The function never returns; `main` runs it with `tokio::spawn`.
488///
489/// # Arguments
490///
491/// * `sync_cfg` - Central server, credentials, bucket and key.
492/// * `local_store` - Local KV bucket to write into.
493/// * `local_key` - Local key to write.
494async fn run_central_kv_sync(
495    sync_cfg: CentralKvSyncConfig,
496    local_store: kv::Store,
497    local_key: String,
498) {
499    loop {
500        let client =
501            match connect_nats_with_creds(sync_cfg.servers.clone(), sync_cfg.creds_path.clone())
502                .await
503            {
504                Ok(client) => client,
505                Err(err) => {
506                    let err = err.to_string();
507                    eprintln!("[logger] Central KV connect failed: {err}");
508                    tokio::time::sleep(Duration::from_secs(5)).await;
509                    continue;
510                }
511            };
512        let remote_js =
513            nats_config::jetstream_context_for_domain(client, sync_cfg.domain.as_deref());
514        let remote_store = match ensure_kv_bucket(&remote_js, &sync_cfg.bucket).await {
515            Ok(store) => store,
516            Err(err) => {
517                let err = err.to_string();
518                eprintln!("[logger] Central KV bucket setup failed: {err}");
519                tokio::time::sleep(Duration::from_secs(5)).await;
520                continue;
521            }
522        };
523        let mut watch = match remote_store.watch(sync_cfg.key.as_str()).await {
524            Ok(watch) => watch,
525            Err(err) => {
526                let err = err.to_string();
527                eprintln!("[logger] Central KV watch setup failed: {err}");
528                tokio::time::sleep(Duration::from_secs(5)).await;
529                continue;
530            }
531        };
532
533        println!(
534            "[logger] Watching central KV '{}:{}' for local key '{}'",
535            sync_cfg.bucket, sync_cfg.key, local_key
536        );
537        while let Some(event) = watch.next().await {
538            match event {
539                Ok(entry) if entry.operation == Operation::Put => {
540                    if let Err(err) = serde_json::from_slice::<NestedConfig>(&entry.value) {
541                        eprintln!("[logger] Ignoring invalid central KV update: {err}");
542                        continue;
543                    }
544                    match local_store.entry(local_key.as_str()).await {
545                        Ok(Some(local_entry))
546                            if local_entry.value.as_ref() == entry.value.as_ref() => {}
547                        Ok(_) => {
548                            if let Err(err) = local_store
549                                .put(local_key.as_str(), entry.value.clone())
550                                .await
551                            {
552                                eprintln!("[logger] Failed to mirror central KV update: {err}");
553                            } else {
554                                println!(
555                                    "[logger] Mirrored central KV rev {} into local KV",
556                                    entry.revision
557                                );
558                            }
559                        }
560                        Err(err) => eprintln!("[logger] Failed to inspect local KV key: {err}"),
561                    }
562                }
563                Ok(entry) => {
564                    eprintln!(
565                        "[logger] Ignoring central KV {:?} for '{}'",
566                        entry.operation, entry.key
567                    );
568                }
569                Err(err) => {
570                    eprintln!("[logger] Central KV watch error: {err}");
571                    break;
572                }
573            }
574        }
575        tokio::time::sleep(Duration::from_secs(5)).await;
576    }
577}
578
579#[allow(dead_code)]
580/// Buffered writer for one channel's Parquet file.
581///
582/// Rows are buffered to form row groups. Files are partitioned by asset, UTC
583/// date, channel, and monotonically increasing part index. While open, the file is
584/// named `part-<NNNN>.parquet.inprogress`; [`Self::close`] renames it to
585/// `part-<NNNN>.parquet`.
586struct ParquetLogger {
587    /// Parquet writer over the `.inprogress` file.
588    writer: SerializedFileWriter<fs::File>,
589    /// Path of the file while it is being written.
590    inprogress_path: PathBuf,
591    /// Path the file is renamed to when it is closed.
592    final_path: PathBuf,
593    /// Buffered timestamps (Unix nanoseconds) of the current row group.
594    timestamps: Vec<i64>,
595    /// Buffered sample values of the current row group, parallel to `timestamps`.
596    values: Vec<f64>,
597    /// Row count at which the buffer is written as a row group.
598    max_rows: usize,
599    /// Timestamp (Unix nanoseconds) of the first row written to this file.
600    first_timestamp_unix_ns: Option<i64>,
601    /// Number of row groups written to the file so far.
602    row_groups_written: usize,
603    /// UTC date of the samples in this file.
604    date: NaiveDate,
605    /// Asset number the file belongs to.
606    asset: u32,
607    /// LabJack channel the file belongs to.
608    channel: u8,
609    /// Part index in the file name.
610    file_index: usize,
611}
612
613/// Maximum row groups in one file before it is rotated.
614///
615/// Parquet's hard limit is 32,767 row groups per file. With [`ROWS_PER_ROW_GROUP`] rows
616/// per group a normal rotation window never comes close; this is a safety limit that
617/// starts a new file well before the hard limit.
618const MAX_ROW_GROUPS_PER_FILE: usize = 30_000;
619
620/// Rows buffered before they are written as one row group (1,048,576).
621///
622/// One row group normally holds a whole rotation period (600,000 rows for five
623/// minutes at 2 kHz). Large row groups let dictionary and delta encoding work
624/// across the file instead of restarting every 1,000 rows. An unfinished file
625/// has no footer and is quarantined whole, so smaller row groups would not make
626/// buffered rows any safer; unacked JetStream messages are what protect them.
627const ROWS_PER_ROW_GROUP: usize = 1 << 20;
628
629/// Unacked messages that force the open file to close early.
630///
631/// Messages are acked only after the file holding their samples is closed. The
632/// file is closed early if this many messages are waiting, so the consumer can
633/// never reach its `max_ack_pending` limit ([`CONSUMER_MAX_ACK_PENDING`]) and stall.
634const MAX_PENDING_ACKS_PER_FILE: usize = 20_000;
635/// `max_ack_pending` set on each durable consumer, in messages.
636///
637/// Kept well above [`MAX_PENDING_ACKS_PER_FILE`] so the per-file cap is always reached
638/// first.
639const CONSUMER_MAX_ACK_PENDING: i64 = 50_000;
640
641/// Time without new data after which the open file is closed (60 s).
642///
643/// Close an open file when no data has arrived for this long, for example when
644/// the streamer is stopped, so its messages are acked promptly.
645const IDLE_CLOSE_AFTER: Duration = Duration::from_secs(60);
646/// How often each writer task checks for an idle file (15 s).
647const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(15);
648
649/// Returns the index of the aligned rotation window holding a timestamp.
650///
651/// Windows start at multiples of `rotate_secs` since the Unix epoch, so with a
652/// five-minute period every file covers :00-:05, :05-:10 and so on. Floor division is
653/// used, so timestamps before the epoch get negative indices.
654///
655/// # Arguments
656///
657/// * `timestamp_unix_ns` - Sample time in Unix nanoseconds.
658/// * `rotate_secs` - Window length in seconds. `0` is treated as `1`.
659///
660/// # Returns
661///
662/// The number of whole windows between the Unix epoch and the timestamp.
663///
664/// # Examples
665///
666/// ```text
667/// rotation_window(299_999_999_999, 300) == 0
668/// rotation_window(300_000_000_000, 300) == 1
669/// rotation_window(-1, 300)              == -1
670/// ```
671fn rotation_window(timestamp_unix_ns: i64, rotate_secs: u64) -> i64 {
672    let rotate_ns = (rotate_secs.max(1) as i64).saturating_mul(1_000_000_000);
673    timestamp_unix_ns.div_euclid(rotate_ns)
674}
675
676/// Runtime state for one active channel consumer and writer task.
677///
678/// The archiver keeps this state so KV updates can stop removed channels,
679/// rotate calibration metadata, or respawn consumers when subject identity
680/// changes. The identity fields are the values the task was started with.
681struct ChannelLogger {
682    /// Handle of the writer task; `main` restarts the channel when it has finished.
683    handle: tokio::task::JoinHandle<()>,
684    /// Sends the graceful stop request; taken by [`Self::stop`].
685    stop_tx: Option<oneshot::Sender<()>>,
686    /// Sends calibration updates to the writer task.
687    calibration_tx: watch::Sender<CalibrationSpec>,
688    /// Calibration last sent to the writer task.
689    calibration: CalibrationSpec,
690    /// JetStream subject the consumer filters on.
691    subject: String,
692    /// JetStream stream the consumer belongs to.
693    stream_name: String,
694    /// Durable consumer name.
695    consumer_name: String,
696    /// Asset number used in output paths.
697    asset: u32,
698    /// Rotation window length in seconds.
699    rotate_secs: u64,
700}
701
702impl ChannelLogger {
703    /// Requests an orderly writer shutdown and waits for the task to finish.
704    ///
705    /// On a graceful stop the task closes its open file (writing the Parquet footer) and
706    /// acks the messages in it before it ends. If the task has already ended, the stop
707    /// request is ignored and only the join happens. A join error (for example a panic in
708    /// the task) is logged.
709    async fn stop(mut self) {
710        if let Some(stop_tx) = self.stop_tx.take() {
711            let _ = stop_tx.send(());
712        }
713        if let Err(err) = self.handle.await {
714            eprintln!("[logger] Channel writer task failed to join: {err}");
715        }
716    }
717}
718
719impl ParquetLogger {
720    /// Creates a new Parquet file for one asset, channel, date, and part index.
721    ///
722    /// The file is created at
723    /// `<parquet_root>/asset<NNN>/<YYYY-MM-DD>/ch<NN>/part-<NNNN>.parquet.inprogress`,
724    /// creating directories as needed. The calibration spec is written into file-level
725    /// key-value metadata (key `calibration`) so exported data can be interpreted
726    /// without consulting the live config. If the spec cannot be serialized, `{}` is
727    /// stored instead.
728    ///
729    /// # Arguments
730    ///
731    /// * `asset` - Asset number for the `asset<NNN>` directory.
732    /// * `channel` - LabJack channel for the `ch<NN>` directory.
733    /// * `file_index` - Part index for the file name, usually from [`next_file_index`].
734    /// * `date` - UTC date of the samples, for the date directory.
735    /// * `calibration` - Calibration stored in the file metadata.
736    /// * `parquet_root` - Root output directory.
737    ///
738    /// # Panics
739    ///
740    /// Panics if the directory or file cannot be created, or if the Parquet writer
741    /// cannot be set up.
742    fn new(
743        asset: u32,
744        channel: u8,
745        file_index: usize,
746        date: NaiveDate,
747        calibration: CalibrationSpec,
748        parquet_root: &Path,
749    ) -> Self {
750        let dir = parquet_root
751            .join(format!("asset{:03}", asset))
752            .join(date.format("%Y-%m-%d").to_string())
753            .join(format!("ch{:02}", channel));
754
755        fs::create_dir_all(&dir).unwrap();
756        let final_path = dir.join(format!("part-{:04}.parquet", file_index));
757        let inprogress_path = dir.join(format!("part-{:04}.parquet.inprogress", file_index));
758
759        let schema = Arc::new(parse_message_type(SAMPLE_SCHEMA).unwrap());
760        let calibration_json =
761            serde_json::to_string(&calibration).unwrap_or_else(|_| "{}".to_string());
762        let props = Arc::new(writer_properties_for_calibration(calibration_json));
763        let file = fs::File::create(&inprogress_path).unwrap();
764        let writer = SerializedFileWriter::new(file, schema, props).unwrap();
765
766        Self {
767            writer,
768            inprogress_path,
769            final_path,
770            timestamps: Vec::new(),
771            values: Vec::new(),
772            max_rows: ROWS_PER_ROW_GROUP,
773            first_timestamp_unix_ns: None,
774            row_groups_written: 0,
775            date,
776            asset,
777            channel,
778            file_index,
779        }
780    }
781
782    /// Buffers the leading samples of a run that belong in this file.
783    ///
784    /// The run's samples are at `first_timestamp_unix_ns`, `first_timestamp_unix_ns +
785    /// sample_interval_ns` and so on, one per value. The first one must belong in this
786    /// file (see [`Self::accepts`]). The samples are buffered up to the first one that
787    /// belongs in another file or the row group limit, whichever comes first (see
788    /// [`Self::run_len`]), and the row group is flushed if it is full. This writes the
789    /// same rows and row groups as buffering the samples one by one with a rotation
790    /// check before each. The first call also records the file's first timestamp,
791    /// which [`Self::should_rotate_before`] uses to find the file's rotation window.
792    ///
793    /// # Arguments
794    ///
795    /// * `first_timestamp_unix_ns` - Time of the first value, in Unix nanoseconds.
796    /// * `sample_interval_ns` - Time between values.
797    /// * `rotate_secs` - Rotation window length in seconds.
798    /// * `values` - Raw sample values, in time order. Every one of their timestamps
799    ///   must fit in an `i64`.
800    ///
801    /// # Returns
802    ///
803    /// How many values were buffered: at least 1 unless `values` is empty.
804    ///
805    /// # Errors
806    ///
807    /// Returns an error if writing the full row group fails (see [`Self::flush`]).
808    /// The file is then unusable and should be dropped without closing it.
809    fn write_run(
810        &mut self,
811        first_timestamp_unix_ns: i64,
812        sample_interval_ns: u64,
813        rotate_secs: u64,
814        values: impl ExactSizeIterator<Item = f64>,
815    ) -> Result<usize, DynError> {
816        if values.len() == 0 {
817            return Ok(0);
818        }
819        self.first_timestamp_unix_ns
820            .get_or_insert(first_timestamp_unix_ns);
821        let count = self.run_len(
822            first_timestamp_unix_ns,
823            sample_interval_ns,
824            values.len(),
825            rotate_secs,
826        );
827        // No wrap-around for a valid run: every timestamp fits in an i64, so the
828        // interval does too whenever there is more than one sample.
829        let step = sample_interval_ns as i64;
830        self.timestamps.extend(
831            (0..count as i64).map(|k| first_timestamp_unix_ns.wrapping_add(k.wrapping_mul(step))),
832        );
833        self.values.extend(values.take(count));
834        if self.timestamps.len() >= self.max_rows {
835            self.flush()?;
836        }
837        Ok(count)
838    }
839
840    /// Returns whether a sample belongs in this file.
841    ///
842    /// It does when it has the file's UTC date and [`Self::should_rotate_before`] is
843    /// `false`.
844    fn accepts(&self, timestamp_unix_ns: i64, rotate_secs: u64) -> bool {
845        self.date == timestamp_ns_to_utc_date(timestamp_unix_ns)
846            && !self.should_rotate_before(timestamp_unix_ns, rotate_secs)
847    }
848
849    /// Counts how many samples of a run can be written to this file in one go.
850    ///
851    /// The run starts at `timestamp_unix_ns`, which this file must accept (see
852    /// [`Self::accepts`]), and rises by `sample_interval_ns` per sample. The file's
853    /// first timestamp must already be recorded. Timestamps only rise, and the file
854    /// accepts every timestamp from its first one up to the end of its rotation window
855    /// or UTC date, so the accepted samples are a prefix of the run. The count is also
856    /// capped at the rows left before the row group is full, so a flush (which can make
857    /// [`Self::should_rotate_before`] change its answer) always ends a run.
858    ///
859    /// Checks the last sample first, which settles the common case of a whole message
860    /// inside one window, and otherwise binary searches for the first rejected sample.
861    ///
862    /// # Arguments
863    ///
864    /// * `timestamp_unix_ns` - Time of the run's first sample.
865    /// * `sample_interval_ns` - Time between samples.
866    /// * `max_len` - Samples left in the run. Every one of their timestamps must fit
867    ///   in an `i64`.
868    /// * `rotate_secs` - Rotation window length in seconds.
869    ///
870    /// # Returns
871    ///
872    /// A count between 1 and `max_len`, or 0 when `max_len` is 0.
873    fn run_len(
874        &self,
875        timestamp_unix_ns: i64,
876        sample_interval_ns: u64,
877        max_len: usize,
878        rotate_secs: u64,
879    ) -> usize {
880        let room = self.max_rows.saturating_sub(self.timestamps.len()).max(1);
881        let max_len = max_len.min(room);
882        let step = sample_interval_ns as i64;
883        let at = |k: usize| timestamp_unix_ns.wrapping_add((k as i64).wrapping_mul(step));
884        if max_len <= 1 || self.accepts(at(max_len - 1), rotate_secs) {
885            return max_len;
886        }
887        // Sample `lo` is accepted and sample `hi` is not.
888        let (mut lo, mut hi) = (0, max_len - 1);
889        while hi - lo > 1 {
890            let mid = lo + (hi - lo) / 2;
891            if self.accepts(at(mid), rotate_secs) {
892                lo = mid;
893            } else {
894                hi = mid;
895            }
896        }
897        hi
898    }
899
900    /// Writes the current buffer as a Parquet row group.
901    ///
902    /// Does nothing when the buffer is empty. Otherwise writes the timestamp and value
903    /// columns, closes the row group and clears the buffer. The data is not synced to
904    /// disk here; that happens in [`Self::close`].
905    ///
906    /// # Errors
907    ///
908    /// Returns an error if the Parquet writer fails to start, write or close the row
909    /// group or one of its columns. The buffer is left as it was, and the file should
910    /// be treated as broken.
911    fn flush(&mut self) -> Result<(), DynError> {
912        if self.timestamps.is_empty() {
913            return Ok(());
914        }
915        let mut rg = self.writer.next_row_group()?;
916
917        // column 0: timestamps
918        {
919            let mut scw = rg.next_column()?.ok_or("missing timestamp column")?;
920            let mut cw = scw.untyped();
921            if let ColumnWriter::Int64ColumnWriter(typed) = &mut cw {
922                typed.write_batch(&self.timestamps, None, None)?;
923            }
924            scw.close()?;
925        }
926
927        // column 1: values
928        {
929            let mut scw = rg.next_column()?.ok_or("missing value column")?;
930            let mut cw = scw.untyped();
931            if let ColumnWriter::DoubleColumnWriter(typed) = &mut cw {
932                typed.write_batch(&self.values, None, None)?;
933            }
934            scw.close()?;
935        }
936
937        rg.close()?;
938        self.row_groups_written += 1;
939        self.timestamps.clear();
940        self.values.clear();
941        Ok(())
942    }
943
944    /// Returns whether the next source sample belongs in a new file.
945    ///
946    /// Files cover aligned windows of source time (see [`rotation_window`]).
947    /// Using source time rather than wall-clock time keeps backlog replay
948    /// correct: several hours of samples can be consumed in a few seconds.
949    ///
950    /// # Arguments
951    ///
952    /// * `timestamp_unix_ns` - Time of the next sample, in Unix nanoseconds.
953    /// * `rotate_secs` - Rotation window length in seconds.
954    ///
955    /// # Returns
956    ///
957    /// `true` if the file already has [`MAX_ROW_GROUPS_PER_FILE`] row groups, if the
958    /// sample is earlier than the file's first sample, or if it falls in a different
959    /// rotation window. `false` for an empty file otherwise.
960    fn should_rotate_before(&self, timestamp_unix_ns: i64, rotate_secs: u64) -> bool {
961        if self.row_groups_written >= MAX_ROW_GROUPS_PER_FILE {
962            return true;
963        }
964
965        let Some(first_timestamp_unix_ns) = self.first_timestamp_unix_ns else {
966            return false;
967        };
968        if timestamp_unix_ns < first_timestamp_unix_ns {
969            return true;
970        }
971
972        rotation_window(timestamp_unix_ns, rotate_secs)
973            != rotation_window(first_timestamp_unix_ns, rotate_secs)
974    }
975
976    /// Flushes buffered rows, writes the footer, syncs, and publishes the file.
977    ///
978    /// The data and the rename are both flushed to disk before returning, so a
979    /// caller that acks JetStream messages afterwards cannot lose them to a
980    /// power cut. The steps are: write remaining rows, write the footer, fsync the
981    /// file, rename `.parquet.inprogress` to `.parquet`, then fsync the directory.
982    ///
983    /// A failed directory fsync is logged but does not fail the close; see
984    /// [`sync_directory_after_publish`] for why.
985    ///
986    /// # Returns
987    ///
988    /// The final `.parquet` path.
989    ///
990    /// # Errors
991    ///
992    /// Returns an error if writing the remaining rows or the footer, syncing the file,
993    /// or renaming it fails. The file then keeps its `.inprogress` name, so it is never
994    /// read and is quarantined at the next start.
995    fn close(mut self) -> Result<PathBuf, DynError> {
996        self.flush()?;
997        let file = self.writer.into_inner()?;
998        file.sync_all()?;
999        drop(file);
1000        fs::rename(&self.inprogress_path, &self.final_path)?;
1001        if let Some(dir) = self.final_path.parent() {
1002            sync_directory_after_publish(dir);
1003        }
1004        Ok(self.final_path)
1005    }
1006}
1007
1008/// Fsyncs the directory of a file that has just been renamed into place.
1009///
1010/// A failure is logged and otherwise ignored. By the time this runs the file's
1011/// contents, footer included, have been fsynced and the rename has succeeded, so
1012/// readers already see a complete `.parquet` file. Reporting the close as failed at
1013/// this point would leave its messages unacked, and JetStream would redeliver them
1014/// into a second file, duplicating every sample in the archive. Treating it as a
1015/// success risks less: the rename can only be lost if power fails before the
1016/// filesystem commits it on its own (a few seconds on ext4), and even then the
1017/// complete file survives under its `.inprogress` name and is kept by
1018/// [`quarantine_incomplete_files`] rather than deleted.
1019///
1020/// # Arguments
1021///
1022/// * `dir` - Directory holding the published file.
1023///
1024/// # Returns
1025///
1026/// `true` if the directory was synced, `false` if opening or syncing it failed.
1027fn sync_directory_after_publish(dir: &Path) -> bool {
1028    match fs::File::open(dir).and_then(|d| d.sync_all()) {
1029        Ok(()) => true,
1030        Err(err) => {
1031            eprintln!(
1032                "[logger] Published file in {} but failed to fsync the directory: {err}",
1033                dir.display()
1034            );
1035            false
1036        }
1037    }
1038}
1039
1040/// Renames unfinished writer files under `root` so they are kept but never read.
1041///
1042/// A crash or power loss can leave a `.parquet.inprogress` file without a Parquet
1043/// footer. Every such file, in any subdirectory, is renamed to
1044/// `<name>.unfinished.quarantined-<unix_ms>-<n>`. The original bytes are preserved for
1045/// diagnosis. The quarantined name still starts with `part-<NNNN>`, so
1046/// [`next_file_index`] skips that index and later data goes to a new part. The samples
1047/// themselves come back through JetStream redelivery, because their messages were
1048/// never acked. Completed `.parquet` files are not opened or checked.
1049///
1050/// # Arguments
1051///
1052/// * `root` - Parquet output root. A missing directory is not an error.
1053///
1054/// # Returns
1055///
1056/// The new paths of the quarantined files.
1057///
1058/// # Errors
1059///
1060/// Returns an error if a directory cannot be read, a file type cannot be determined,
1061/// or a rename fails. Files renamed before the error stay renamed.
1062fn quarantine_incomplete_files(root: &Path) -> io::Result<Vec<PathBuf>> {
1063    // Walks `dir` recursively, renaming unfinished files and appending their new paths.
1064    fn visit(dir: &Path, quarantined: &mut Vec<PathBuf>, stamp: u128) -> io::Result<()> {
1065        if !dir.exists() {
1066            return Ok(());
1067        }
1068
1069        for entry in fs::read_dir(dir)? {
1070            let entry = entry?;
1071            let path = entry.path();
1072            if entry.file_type()?.is_dir() {
1073                visit(&path, quarantined, stamp)?;
1074                continue;
1075            }
1076
1077            let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
1078                continue;
1079            };
1080            if !name.ends_with(".parquet.inprogress") {
1081                continue;
1082            }
1083
1084            let quarantine_name = format!(
1085                "{name}.unfinished.quarantined-{stamp}-{}",
1086                quarantined.len()
1087            );
1088            let quarantine_path = path.with_file_name(quarantine_name);
1089            fs::rename(&path, &quarantine_path)?;
1090            quarantined.push(quarantine_path);
1091        }
1092        Ok(())
1093    }
1094
1095    let stamp = SystemTime::now()
1096        .duration_since(UNIX_EPOCH)
1097        .unwrap_or_default()
1098        .as_millis();
1099    let mut quarantined = Vec::new();
1100    visit(root, &mut quarantined, stamp)?;
1101    Ok(quarantined)
1102}
1103
1104/// Scans a channel/day directory to find the next available Parquet file index.
1105///
1106/// Every entry whose name starts with `part-<number>` counts, including
1107/// `.inprogress` and quarantined files, so a new file never reuses their index. The
1108/// directory is created if it does not exist.
1109///
1110/// # Arguments
1111///
1112/// * `parquet_root` - Root output directory.
1113/// * `asset` - Asset number for the `asset<NNN>` directory.
1114/// * `channel` - LabJack channel for the `ch<NN>` directory.
1115/// * `date` - UTC date for the date directory.
1116///
1117/// # Returns
1118///
1119/// One more than the highest existing part index, or `1` for an empty directory.
1120///
1121/// # Panics
1122///
1123/// Panics if the directory cannot be created or read.
1124fn next_file_index(parquet_root: &Path, asset: u32, channel: u8, date: NaiveDate) -> usize {
1125    let dir = parquet_root
1126        .join(format!("asset{:03}", asset))
1127        .join(date.format("%Y-%m-%d").to_string())
1128        .join(format!("ch{:02}", channel));
1129
1130    std::fs::create_dir_all(&dir).unwrap();
1131    let mut max_idx = 0;
1132    for entry in std::fs::read_dir(&dir).unwrap().flatten() {
1133        if let Some(num) = entry
1134            .file_name()
1135            .to_str()
1136            .and_then(|name| name.strip_prefix("part-"))
1137            .and_then(|name| name.split('.').next())
1138            .and_then(|value| value.parse::<usize>().ok())
1139        {
1140            max_idx = max_idx.max(num);
1141        }
1142    }
1143    max_idx + 1
1144}
1145
1146/// Converts source identity text into a durable consumer-name token.
1147///
1148/// ASCII letters and digits are lowercased and kept, as are `-` and `_`. Whitespace,
1149/// `.` and `/` become `-`. Every other character is dropped. Leading and trailing `-`
1150/// are trimmed.
1151///
1152/// # Arguments
1153///
1154/// * `raw` - Identity text such as a box ID or source ID.
1155///
1156/// # Returns
1157///
1158/// The token, or `unknown` if nothing is left.
1159///
1160/// # Examples
1161///
1162/// ```text
1163/// sanitize_consumer_token("MU1 Box.A") == "mu1-box-a"
1164/// sanitize_consumer_token(" ..! ")     == "unknown"
1165/// ```
1166fn sanitize_consumer_token(raw: &str) -> String {
1167    let mut out = String::with_capacity(raw.len());
1168    for ch in raw.trim().chars() {
1169        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1170            out.push(ch.to_ascii_lowercase());
1171        } else if ch.is_whitespace() || ch == '.' || ch == '/' {
1172            out.push('-');
1173        }
1174    }
1175
1176    let out = out.trim_matches('-').to_string();
1177    if out.is_empty() {
1178        "unknown".to_string()
1179    } else {
1180        out
1181    }
1182}
1183
1184/// Builds the durable JetStream consumer name for one archived channel.
1185///
1186/// The name is `archiver-<box>-<source_type>-<source_id>-<channel>-current`, with each
1187/// part passed through [`sanitize_consumer_token`]. Missing values default to
1188/// `unknown-box`, `labjack` and the LabJack name. The channel number is not
1189/// zero-padded.
1190///
1191/// # Arguments
1192///
1193/// * `cfg` - Archiver config supplying the identity fields.
1194/// * `channel` - LabJack channel number.
1195///
1196/// # Examples
1197///
1198/// ```text
1199/// box_id = "box-01", source_type = None, source_id = "MU1", channel = 8
1200///   -> "archiver-box-01-labjack-mu1-8-current"
1201/// ```
1202fn archiver_consumer_name(cfg: &SampleConfig, channel: u8) -> String {
1203    format!(
1204        "archiver-{}-{}-{}-{}-current",
1205        sanitize_consumer_token(cfg.box_id.as_deref().unwrap_or("unknown-box")),
1206        sanitize_consumer_token(cfg.source_type.as_deref().unwrap_or("labjack")),
1207        sanitize_consumer_token(
1208            cfg.source_id
1209                .as_deref()
1210                .unwrap_or(cfg.labjack_name.as_str())
1211        ),
1212        channel
1213    )
1214}
1215
1216/// Files closed while handling one payload.
1217///
1218/// The writer task uses it to decide whether pending messages can be acked (a file
1219/// closed) or must be left for redelivery (a close failed).
1220#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1221struct CloseOutcome {
1222    /// Files closed and published successfully.
1223    closed: usize,
1224    /// Files whose close failed.
1225    failed: usize,
1226}
1227
1228impl CloseOutcome {
1229    /// Counts and logs the result of one [`ParquetLogger::close`] call.
1230    ///
1231    /// # Arguments
1232    ///
1233    /// * `result` - Result returned by the close.
1234    /// * `channel` - Channel of the file, for the log message.
1235    /// * `file_index` - Part index of the file, for the log message.
1236    fn record(&mut self, result: Result<PathBuf, DynError>, channel: u8, file_index: usize) {
1237        match result {
1238            Ok(path) => {
1239                self.closed += 1;
1240                println!("[logger] Closed {}", path.display());
1241            }
1242            Err(err) => {
1243                self.failed += 1;
1244                eprintln!(
1245                    "[logger] Failed to finalize channel {channel:02} part {file_index}: {err}"
1246                );
1247            }
1248        }
1249    }
1250}
1251
1252/// Decodes one FlatBuffer scan payload and writes its samples to Parquet.
1253///
1254/// The payload contains the first sample timestamp plus a fixed interval, so
1255/// this function reconstructs each sample timestamp and rotates files at
1256/// aligned source-time windows and UTC date changes. The returned outcome tells
1257/// the caller which buffered JetStream messages are now safe to ack.
1258///
1259/// Before each sample, the open file is closed and a new one opened (with the next
1260/// free part index) when there is no open file, when the sample's UTC date differs
1261/// from the file's, or when [`ParquetLogger::should_rotate_before`] says so. A single
1262/// payload can therefore close a file part way through. The check is made once per run
1263/// of samples rather than once per sample: [`ParquetLogger::write_run`] buffers, in one
1264/// go, as many of the following samples as the open file accepts. The files and row
1265/// groups are the same as with a check per sample.
1266///
1267/// Sequence gaps and resets are logged but do not stop the write. A payload that is
1268/// not a valid FlatBuffer `Scan` is logged and writes nothing. If a sample timestamp
1269/// overflows, the rest of that payload is dropped. If closing a file or writing a row
1270/// group fails, the failure is counted in the outcome and the rest of the payload is
1271/// skipped, since the message will be redelivered.
1272///
1273/// # Arguments
1274///
1275/// * `payload` - FlatBuffer `Scan` bytes from one JetStream message.
1276/// * `channel` - LabJack channel the payload belongs to.
1277/// * `asset` - Asset number for output paths.
1278/// * `parquet_root` - Root output directory.
1279/// * `active_calibration` - Calibration stored in any file opened here.
1280/// * `rotate_secs` - Rotation window length in seconds.
1281/// * `logger` - Open file of the channel, if any; replaced when the file rotates.
1282/// * `file_index` - Part index of the open file; updated when a new file is opened.
1283/// * `last_sequence` - Sequence number of the previous scan, for gap detection;
1284///   updated to this scan's sequence.
1285///
1286/// # Returns
1287///
1288/// How many files were closed and how many closes failed while writing this payload.
1289///
1290/// # Panics
1291///
1292/// Panics if a new file cannot be created (see [`ParquetLogger::new`] and
1293/// [`next_file_index`]).
1294fn process_scan_payload(
1295    payload: &[u8],
1296    channel: u8,
1297    asset: u32,
1298    parquet_root: &Path,
1299    active_calibration: &CalibrationSpec,
1300    rotate_secs: u64,
1301    logger: &mut Option<ParquetLogger>,
1302    file_index: &mut usize,
1303    last_sequence: &mut Option<u64>,
1304) -> CloseOutcome {
1305    let mut outcome = CloseOutcome::default();
1306    if let Ok(scan) = flatbuffers::root::<sampler::Scan>(payload) {
1307        let sequence = scan.sequence();
1308        match *last_sequence {
1309            Some(previous) if sequence == previous + 1 => {}
1310            Some(previous) if sequence > previous + 1 => {
1311                eprintln!(
1312                    "[logger] Channel {channel:02} sequence gap: expected {}, got {}",
1313                    previous + 1,
1314                    sequence
1315                );
1316            }
1317            Some(previous) if sequence <= previous => {
1318                println!(
1319                    "[logger] Channel {channel:02} sequence reset/new run: previous {}, current {}",
1320                    previous, sequence
1321                );
1322            }
1323            _ => {}
1324        }
1325        *last_sequence = Some(sequence);
1326
1327        if let Some(vals) = scan.values() {
1328            let first_sample_unix_ns = scan.first_sample_unix_ns();
1329            let sample_interval_ns = scan.sample_interval_ns();
1330            let representable =
1331                representable_samples(first_sample_unix_ns, sample_interval_ns, vals.len());
1332
1333            let mut index = 0;
1334            while index < vals.len() {
1335                let timestamp_unix_ns = match sample_timestamp_ns(
1336                    first_sample_unix_ns,
1337                    sample_interval_ns,
1338                    index,
1339                ) {
1340                    Ok(ts) => ts,
1341                    Err(err) => {
1342                        eprintln!(
1343                            "[logger] Channel {channel:02} timestamp overflow at sequence {} sample {}: {}",
1344                            sequence, index, err
1345                        );
1346                        break;
1347                    }
1348                };
1349
1350                if !logger
1351                    .as_ref()
1352                    .is_some_and(|l| l.accepts(timestamp_unix_ns, rotate_secs))
1353                {
1354                    let sample_date = timestamp_ns_to_utc_date(timestamp_unix_ns);
1355                    if let Some(l) = logger.take() {
1356                        outcome.record(l.close(), channel, *file_index);
1357                        if outcome.failed > 0 {
1358                            // This message stays unacked and is redelivered whole,
1359                            // so writing the rest of it now would store those
1360                            // samples twice.
1361                            return outcome;
1362                        }
1363                    }
1364                    *file_index = next_file_index(parquet_root, asset, channel, sample_date);
1365                    *logger = Some(ParquetLogger::new(
1366                        asset,
1367                        channel,
1368                        *file_index,
1369                        sample_date,
1370                        active_calibration.clone(),
1371                        parquet_root,
1372                    ));
1373                }
1374
1375                let Some(log) = logger.as_mut() else {
1376                    break;
1377                };
1378                // Sample `index` fitted, so `representable` is above it; the run
1379                // stops before the first timestamp that would overflow.
1380                let run = representable.saturating_sub(index).max(1);
1381                let written = log.write_run(
1382                    timestamp_unix_ns,
1383                    sample_interval_ns,
1384                    rotate_secs,
1385                    vals.iter().skip(index).take(run),
1386                );
1387                let len = match written {
1388                    Ok(len) => len,
1389                    Err(err) => {
1390                        // The half-written file is dropped without a footer. It keeps
1391                        // its `.inprogress` name, is quarantined at the next start, and
1392                        // its samples come back through redelivery.
1393                        outcome.record(Err(err), channel, *file_index);
1394                        *logger = None;
1395                        return outcome;
1396                    }
1397                };
1398                index += len;
1399            }
1400        }
1401    } else {
1402        eprintln!("[logger] Channel {channel:02} received invalid FlatBuffer payload");
1403    }
1404    outcome
1405}
1406
1407/// Acks messages whose samples are all inside closed, synced Parquet files.
1408///
1409/// Drains `pending`. A failed ack is counted and logged, not retried; JetStream
1410/// redelivers that message after `ack_wait`, so its samples are written a second time
1411/// into a later file.
1412///
1413/// # Arguments
1414///
1415/// * `pending` - Messages to ack; empty on return.
1416/// * `channel` - Channel number, for the log message.
1417async fn ack_messages(pending: &mut Vec<jetstream::Message>, channel: u8) {
1418    let mut failures = 0usize;
1419    for msg in pending.drain(..) {
1420        if msg.ack().await.is_err() {
1421            failures += 1;
1422        }
1423    }
1424    if failures > 0 {
1425        eprintln!(
1426            "[logger] Failed to ack {failures} JetStream message(s) for channel {channel:02}; they will be redelivered"
1427        );
1428    }
1429}
1430
1431/// Closes the open file (if any) and settles the messages it holds.
1432///
1433/// On success the pending messages are acked. On failure they are dropped
1434/// without an ack, so JetStream redelivers them after `ack_wait` and they are
1435/// written again into a new file. With no open file, the pending messages are acked.
1436///
1437/// # Arguments
1438///
1439/// * `logger` - Open file of the channel; `None` on return.
1440/// * `pending` - Messages whose samples are in the open file; empty on return.
1441/// * `channel` - Channel number, for log messages.
1442/// * `file_index` - Part index of the open file, for log messages.
1443async fn close_and_settle(
1444    logger: &mut Option<ParquetLogger>,
1445    pending: &mut Vec<jetstream::Message>,
1446    channel: u8,
1447    file_index: usize,
1448) {
1449    let mut outcome = CloseOutcome::default();
1450    if let Some(l) = logger.take() {
1451        outcome.record(l.close(), channel, file_index);
1452    }
1453    if outcome.failed > 0 {
1454        eprintln!(
1455            "[logger] Leaving {} message(s) for channel {channel:02} unacked for redelivery",
1456            pending.len()
1457        );
1458        pending.clear();
1459    } else {
1460        ack_messages(pending, channel).await;
1461    }
1462}
1463
1464/// How often each writer task saves its replay-guard checkpoint (60 s).
1465const CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
1466
1467/// Skipped messages between two progress lines while the replay guard is skipping.
1468const SKIP_LOG_EVERY: u64 = 10_000;
1469
1470/// Reads the replay-guard settings.
1471///
1472/// `ARCHIVER_REPLAY_GUARD=off` (also `0`, `false` or `no`) turns the guard off.
1473/// Otherwise checkpoints live in `ARCHIVER_STATE_DIR`, or in `.archiver-state` under
1474/// the Parquet root when that is not set.
1475///
1476/// # Arguments
1477///
1478/// * `parquet_root` - Root output directory.
1479///
1480/// # Returns
1481///
1482/// The checkpoint directory, or `None` when the guard is off.
1483fn replay_state_dir_from_env(parquet_root: &Path) -> Option<PathBuf> {
1484    replay_state_dir(
1485        env_nonempty("ARCHIVER_REPLAY_GUARD").as_deref(),
1486        env_nonempty("ARCHIVER_STATE_DIR").as_deref(),
1487        parquet_root,
1488    )
1489}
1490
1491/// Pure part of [`replay_state_dir_from_env`].
1492fn replay_state_dir(
1493    guard: Option<&str>,
1494    dir: Option<&str>,
1495    parquet_root: &Path,
1496) -> Option<PathBuf> {
1497    if guard.is_some_and(|v| {
1498        matches!(
1499            v.trim().to_ascii_lowercase().as_str(),
1500            "off" | "0" | "false" | "no"
1501        )
1502    }) {
1503        return None;
1504    }
1505    Some(
1506        dir.map(PathBuf::from)
1507            .unwrap_or_else(|| parquet_root.join(".archiver-state")),
1508    )
1509}
1510
1511/// Durable consumer name used when the base consumer filters on another subject.
1512///
1513/// The base name gets `-f` and an FNV-1a hash of the subject, so each subject gets
1514/// its own stable consumer and no consumer's filter is ever changed in place.
1515///
1516/// # Examples
1517///
1518/// ```text
1519/// filtered_consumer_name("archiver-mu1-labjack-lj2-8-current", "avenars.i69.mu1.lj2.live.ch08")
1520///     -> "archiver-mu1-labjack-lj2-8-current-f<8 hex digits>"
1521/// ```
1522fn filtered_consumer_name(base: &str, subject: &str) -> String {
1523    let mut hash: u32 = 0x811c_9dc5;
1524    for byte in subject.bytes() {
1525        hash ^= u32::from(byte);
1526        hash = hash.wrapping_mul(0x0100_0193);
1527    }
1528    format!("{base}-f{hash:08x}")
1529}
1530
1531/// Replay protection for one channel writer.
1532///
1533/// JetStream can lose a durable consumer's progress (an empty or missing consumer state
1534/// file after a power cut) and then deliver the whole stream again. This keeps the
1535/// archiver's own checkpoint of the consumer's ack floor per stream and subject (see
1536/// [`replay_guard`]) and acks messages at or below it without writing them.
1537struct ReplayState {
1538    /// Skips messages at or below the trusted checkpoint.
1539    guard: ReplayGuard,
1540    /// Checkpoint file; `None` when the guard is off.
1541    path: Option<PathBuf>,
1542    /// Stream name, stored in the checkpoint.
1543    stream: String,
1544    /// Stream creation time in Unix nanoseconds, stored in the checkpoint.
1545    stream_created_unix_ns: i64,
1546    /// Channel subject, stored in the checkpoint.
1547    subject: String,
1548    /// Floor last written to the checkpoint file.
1549    saved: u64,
1550    /// When the checkpoint was last refreshed.
1551    last_refresh: Instant,
1552}
1553
1554impl ReplayState {
1555    /// Loads the checkpoint for a channel and compares it with the consumer.
1556    ///
1557    /// A checkpoint that does not match the stream as it is now is ignored with a log
1558    /// line (see [`replay_guard::trusted_floor`]). If the consumer's ack floor is below a
1559    /// trusted checkpoint, JetStream has lost the consumer's progress; that is logged
1560    /// and the messages up to the checkpoint will be skipped.
1561    ///
1562    /// # Arguments
1563    ///
1564    /// * `state_dir` - Checkpoint directory, or `None` when the guard is off.
1565    /// * `stream` - Stream info as fetched when the channel started.
1566    /// * `subject` - Channel subject.
1567    /// * `consumer_name` - Durable consumer name, for log lines.
1568    /// * `consumer_ack_floor` - The consumer's ack floor stream sequence.
1569    fn attach(
1570        state_dir: Option<&Path>,
1571        stream: &jetstream::stream::Info,
1572        subject: &str,
1573        consumer_name: &str,
1574        consumer_ack_floor: u64,
1575    ) -> Self {
1576        let stream_created_unix_ns = stream.created.unix_timestamp_nanos() as i64;
1577        let mut state = Self {
1578            guard: ReplayGuard::new(0),
1579            path: state_dir
1580                .map(|dir| replay_guard::checkpoint_path(dir, &stream.config.name, subject)),
1581            stream: stream.config.name.clone(),
1582            stream_created_unix_ns,
1583            subject: subject.to_string(),
1584            saved: 0,
1585            last_refresh: Instant::now(),
1586        };
1587        let Some(path) = state.path.as_deref() else {
1588            return state;
1589        };
1590        match replay_guard::load(path) {
1591            Ok(None) => {}
1592            Ok(Some(checkpoint)) => match replay_guard::trusted_floor(
1593                &checkpoint,
1594                &state.stream,
1595                stream_created_unix_ns,
1596                stream.state.last_sequence,
1597                subject,
1598            ) {
1599                Ok(floor) => {
1600                    state.saved = floor;
1601                    state.guard = ReplayGuard::new(floor);
1602                    if consumer_ack_floor < floor {
1603                        eprintln!(
1604                            "[logger] Consumer '{consumer_name}' ack floor {consumer_ack_floor} is behind the archiver checkpoint {floor} for {subject}: JetStream lost the consumer's progress. Messages up to stream sequence {floor} are already archived and will be acked without writing."
1605                        );
1606                    }
1607                }
1608                Err(reason) => eprintln!(
1609                    "[logger] Ignoring replay checkpoint {}: {reason}",
1610                    path.display()
1611                ),
1612            },
1613            Err(err) => eprintln!(
1614                "[logger] Ignoring unreadable replay checkpoint {}: {err}",
1615                path.display()
1616            ),
1617        }
1618        state
1619    }
1620
1621    /// Logs the first skipped message of a run and then every [`SKIP_LOG_EVERY`].
1622    fn note_skip(&self, channel: u8, stream_seq: u64) {
1623        let skipped = self.guard.skipped_in_run();
1624        if skipped == 1 {
1625            eprintln!(
1626                "[logger] Channel {channel:02}: stream sequence {stream_seq} is at or below the archiver checkpoint {}; acking already-archived messages without writing them",
1627                self.guard.floor()
1628            );
1629        } else if skipped.is_multiple_of(SKIP_LOG_EVERY) {
1630            eprintln!(
1631                "[logger] Channel {channel:02}: skipped {skipped} already-archived message(s) so far"
1632            );
1633        }
1634    }
1635
1636    /// Logs the end of a run of skipped messages, if one was in progress.
1637    fn end_skip_run(&mut self, channel: u8) {
1638        if let Some(run) = self.guard.take_run() {
1639            eprintln!(
1640                "[logger] Channel {channel:02}: skipped {} already-archived message(s), stream sequences {}..={}",
1641                run.count, run.first, run.last
1642            );
1643        }
1644    }
1645
1646    /// Saves the consumer's ack floor as the new checkpoint if it has moved up.
1647    ///
1648    /// Without `force` this runs at most once per [`CHECKPOINT_INTERVAL`]. Failures are
1649    /// logged; the old checkpoint stays valid, only less recent.
1650    ///
1651    /// # Arguments
1652    ///
1653    /// * `consumer` - The channel's durable consumer.
1654    /// * `channel` - Channel number, for log lines.
1655    /// * `force` - Refresh now, regardless of the interval.
1656    async fn checkpoint(
1657        &mut self,
1658        consumer: &mut jetstream::consumer::PullConsumer,
1659        channel: u8,
1660        force: bool,
1661    ) {
1662        if self.path.is_none() || (!force && self.last_refresh.elapsed() < CHECKPOINT_INTERVAL) {
1663            return;
1664        }
1665        self.last_refresh = Instant::now();
1666        let floor = match consumer.info().await {
1667            Ok(info) => info.ack_floor.stream_sequence,
1668            Err(err) => {
1669                eprintln!(
1670                    "[logger] Channel {channel:02}: cannot read consumer info for the replay checkpoint: {err}"
1671                );
1672                return;
1673            }
1674        };
1675        if floor <= self.saved {
1676            return;
1677        }
1678        let Some(path) = self.path.clone() else {
1679            return;
1680        };
1681        let checkpoint = replay_guard::Checkpoint {
1682            stream: self.stream.clone(),
1683            stream_created_unix_ns: self.stream_created_unix_ns,
1684            subject: self.subject.clone(),
1685            archived_through_seq: floor,
1686        };
1687        match tokio::task::spawn_blocking(move || replay_guard::save(&path, &checkpoint)).await {
1688            Ok(Ok(())) => {
1689                self.saved = floor;
1690                self.guard.raise(floor);
1691            }
1692            Ok(Err(err)) => {
1693                eprintln!("[logger] Channel {channel:02}: cannot save the replay checkpoint: {err}")
1694            }
1695            Err(err) => {
1696                eprintln!("[logger] Channel {channel:02}: replay checkpoint task failed: {err}")
1697            }
1698        }
1699    }
1700}
1701
1702/// Consumer settings that let acks wait until a file is closed.
1703///
1704/// `ack_wait` must outlast one full rotation window plus the idle-close delay,
1705/// or JetStream would redeliver messages that are still waiting in an open file. It is
1706/// set to three rotation windows plus [`IDLE_CLOSE_AFTER`] plus 120 seconds of margin
1707/// (18 minutes for a 300 second window). The consumer uses explicit acks and
1708/// [`CONSUMER_MAX_ACK_PENDING`].
1709///
1710/// # Arguments
1711///
1712/// * `consumer_name` - Durable consumer name.
1713/// * `subject` - Subject the consumer filters on.
1714/// * `rotate_secs` - Rotation window length in seconds.
1715fn archiver_consumer_config(consumer_name: &str, subject: &str, rotate_secs: u64) -> pull::Config {
1716    pull::Config {
1717        durable_name: Some(consumer_name.to_string()),
1718        filter_subject: subject.to_string(),
1719        ack_policy: jetstream::consumer::AckPolicy::Explicit,
1720        ack_wait: Duration::from_secs(rotate_secs.saturating_mul(3))
1721            + IDLE_CLOSE_AFTER
1722            + Duration::from_secs(120),
1723        max_ack_pending: CONSUMER_MAX_ACK_PENDING,
1724        ..Default::default()
1725    }
1726}
1727
1728/// Starts the durable pull consumer and writer task for one channel.
1729///
1730/// The returned [`ChannelLogger`] lets the config watcher update calibration
1731/// metadata or gracefully stop and respawn the task when identity changes.
1732///
1733/// The consumer is created with [`archiver_consumer_config`] if it does not exist. An
1734/// existing consumer whose `ack_wait` or `max_ack_pending` differs is updated in place.
1735/// An existing consumer that filters on a different subject is left alone, and the
1736/// consumer named by [`filtered_consumer_name`] is used instead.
1737///
1738/// Messages at or below the replay-guard checkpoint are acked without being written
1739/// (see [`ReplayState`]), and the checkpoint is refreshed from the consumer's ack floor
1740/// every [`CHECKPOINT_INTERVAL`] and when the task ends.
1741///
1742/// The spawned task reads messages and writes them with [`process_scan_payload`],
1743/// holding each message unacked until its file is closed. It closes the open file and
1744/// settles its messages (see [`close_and_settle`]) when:
1745///
1746/// * [`MAX_PENDING_ACKS_PER_FILE`] messages are waiting,
1747/// * no data has arrived for [`IDLE_CLOSE_AFTER`] (checked every
1748///   [`IDLE_CHECK_INTERVAL`]),
1749/// * a different calibration arrives, so the next file carries the new metadata,
1750/// * the task ends because of a stop request, a consumer error, the end of the
1751///   message stream, or the calibration sender being dropped.
1752///
1753/// If attaching the message stream fails, the task logs the error and ends at once. A
1754/// panic inside the task (for example an output directory that cannot be created) also
1755/// ends it. In both cases `main` sees the task as finished and restarts the channel on
1756/// its next health check.
1757///
1758/// # Arguments
1759///
1760/// * `js` - Local JetStream context.
1761/// * `stream_name` - Stream that holds the channel subject.
1762/// * `consumer_name` - Durable consumer name, from [`archiver_consumer_name`].
1763/// * `subject` - Channel subject to filter on.
1764/// * `asset` - Asset number for output paths.
1765/// * `channel` - LabJack channel number.
1766/// * `rotate_secs` - Rotation window length in seconds.
1767/// * `calibration` - Initial calibration for the channel.
1768/// * `parquet_root` - Root output directory.
1769/// * `state_dir` - Directory for replay-guard checkpoints (see [`ReplayState`]);
1770///   `None` turns the guard off.
1771///
1772/// # Errors
1773///
1774/// Returns an error if the stream cannot be found, or the consumer cannot be created,
1775/// fetched or updated.
1776async fn spawn_channel_logger(
1777    js: jetstream::Context,
1778    stream_name: String,
1779    consumer_name: String,
1780    subject: String,
1781    asset: u32,
1782    channel: u8,
1783    rotate_secs: u64,
1784    calibration: CalibrationSpec,
1785    parquet_root: PathBuf,
1786    state_dir: Option<PathBuf>,
1787) -> Result<ChannelLogger, DynError> {
1788    let stream = js.get_stream(stream_name.as_str()).await?;
1789    let mut durable_name = consumer_name.clone();
1790    let mut consumer: jetstream::consumer::PullConsumer = stream
1791        .get_or_create_consumer(
1792            durable_name.as_str(),
1793            archiver_consumer_config(&durable_name, &subject, rotate_secs),
1794        )
1795        .await?;
1796    // A durable consumer's filter is never changed in place. Updating it keeps the
1797    // old delivery position, so messages on the new subject below that position would
1798    // be skipped, and not updating it would leave the channel reading the old subject.
1799    // A consumer named after the new subject starts cleanly instead.
1800    let existing_filter = consumer.cached_info().config.filter_subject.clone();
1801    if existing_filter != subject {
1802        durable_name = filtered_consumer_name(&consumer_name, &subject);
1803        eprintln!(
1804            "[logger] Consumer '{}' filters on '{}', not {}; using consumer '{}' for the new subject",
1805            consumer_name, existing_filter, subject, durable_name
1806        );
1807        consumer = stream
1808            .get_or_create_consumer(
1809                durable_name.as_str(),
1810                archiver_consumer_config(&durable_name, &subject, rotate_secs),
1811            )
1812            .await?;
1813    }
1814    let desired = archiver_consumer_config(&durable_name, &subject, rotate_secs);
1815    // Durable consumers created by earlier versions keep their old ack_wait
1816    // and max_ack_pending. Both are editable in place, and the delivery
1817    // position is untouched, so no data is replayed or skipped.
1818    let existing = consumer.cached_info().config.clone();
1819    if existing.ack_wait != desired.ack_wait || existing.max_ack_pending != desired.max_ack_pending
1820    {
1821        consumer = stream.update_consumer(desired.clone()).await?;
1822        println!(
1823            "[logger] Updated consumer '{}': ack_wait {:?} -> {:?}, max_ack_pending {} -> {}",
1824            durable_name,
1825            existing.ack_wait,
1826            desired.ack_wait,
1827            existing.max_ack_pending,
1828            desired.max_ack_pending
1829        );
1830    }
1831
1832    let mut replay = ReplayState::attach(
1833        state_dir.as_deref(),
1834        stream.cached_info(),
1835        &subject,
1836        &durable_name,
1837        consumer.cached_info().ack_floor.stream_sequence,
1838    );
1839
1840    let logger_subject = subject.clone();
1841    let logger_consumer_name = durable_name.clone();
1842    let (calibration_tx, mut calibration_rx) = watch::channel(calibration.clone());
1843    let (stop_tx, mut stop_rx) = oneshot::channel();
1844    let calibration_for_task = calibration.clone();
1845    let handle = tokio::spawn(async move {
1846        let mut consumer = consumer;
1847        let mut messages = match consumer.messages().await {
1848            Ok(messages) => messages,
1849            Err(err) => {
1850                eprintln!(
1851                    "[logger] Failed to attach JetStream consumer '{}' for {}: {}",
1852                    logger_consumer_name, logger_subject, err
1853                );
1854                return;
1855            }
1856        };
1857        println!(
1858            "[logger] Attached JetStream consumer '{}' to {}",
1859            logger_consumer_name, logger_subject
1860        );
1861
1862        // Files are opened by data and rotated by source time. This ticker only
1863        // closes a file that has stopped receiving data; it never opens one,
1864        // so it cannot race the source-time rotation or leave empty parts.
1865        let mut idle_check = tokio::time::interval(IDLE_CHECK_INTERVAL);
1866        idle_check.tick().await;
1867        let mut logger: Option<ParquetLogger> = None;
1868        let mut file_index =
1869            next_file_index(&parquet_root, asset, channel, Utc::now().date_naive());
1870        let mut active_calibration = calibration_for_task;
1871        let mut last_sequence: Option<u64> = None;
1872        // Messages whose samples are in the open file, with their payloads dropped.
1873        let mut pending: Vec<jetstream::Message> = Vec::new();
1874        let mut last_data = Instant::now();
1875
1876        loop {
1877            tokio::select! {
1878                maybe = messages.next() => {
1879                    match maybe {
1880                        Some(Ok(msg)) => {
1881                            let stream_seq = msg.info().map(|info| info.stream_sequence).unwrap_or(0);
1882                            if replay.guard.is_archived(stream_seq) {
1883                                replay.note_skip(channel, stream_seq);
1884                                // A failed ack only means the message comes back and
1885                                // is skipped again.
1886                                let _ = msg.ack().await;
1887                                continue;
1888                            }
1889                            replay.end_skip_run(channel);
1890                            last_data = Instant::now();
1891                            let outcome = process_scan_payload(
1892                                &msg.payload,
1893                                channel,
1894                                asset,
1895                                &parquet_root,
1896                                &active_calibration,
1897                                rotate_secs,
1898                                &mut logger,
1899                                &mut file_index,
1900                                &mut last_sequence,
1901                            );
1902                            if outcome.failed > 0 {
1903                                // Let JetStream redeliver everything that was in
1904                                // the failed file, including this message.
1905                                eprintln!(
1906                                    "[logger] Leaving {} message(s) for channel {channel:02} unacked for redelivery",
1907                                    pending.len() + 1
1908                                );
1909                                pending.clear();
1910                            } else {
1911                                if outcome.closed > 0 {
1912                                    // Earlier messages are entirely inside the
1913                                    // closed file. This one may have samples in
1914                                    // the new file too, so it waits.
1915                                    ack_messages(&mut pending, channel).await;
1916                                }
1917                                // Only the reply subject is needed to ack. The payload
1918                                // is a slice of the connection's shared read buffer, so
1919                                // holding it until the file closes would keep a
1920                                // window's worth of raw messages in memory next to
1921                                // the decoded samples.
1922                                let mut msg = msg;
1923                                msg.message.payload = Default::default();
1924                                pending.push(msg);
1925                            }
1926                            if pending.len() >= MAX_PENDING_ACKS_PER_FILE {
1927                                close_and_settle(&mut logger, &mut pending, channel, file_index).await;
1928                            }
1929                        }
1930                        Some(Err(err)) => {
1931                            eprintln!(
1932                                "[logger] JetStream consumer '{}' error on {}: {}",
1933                                logger_consumer_name, logger_subject, err
1934                            );
1935                            break;
1936                        }
1937                        None => {
1938                            eprintln!(
1939                                "[logger] JetStream consumer '{}' ended for {}",
1940                                logger_consumer_name, logger_subject
1941                            );
1942                            break;
1943                        }
1944                    }
1945                }
1946                _ = idle_check.tick() => {
1947                    replay.checkpoint(&mut consumer, channel, false).await;
1948                    if logger.is_some() && last_data.elapsed() >= IDLE_CLOSE_AFTER {
1949                        println!(
1950                            "[logger] No data on channel {channel:02} for {}s; closing the open file.",
1951                            IDLE_CLOSE_AFTER.as_secs()
1952                        );
1953                        close_and_settle(&mut logger, &mut pending, channel, file_index).await;
1954                    }
1955                }
1956                changed = calibration_rx.changed() => {
1957                    if changed.is_err() {
1958                        break;
1959                    }
1960                    let updated = calibration_rx.borrow().clone();
1961                    if updated != active_calibration {
1962                        // The next sample opens a new file carrying the new
1963                        // calibration metadata.
1964                        close_and_settle(&mut logger, &mut pending, channel, file_index).await;
1965                        println!(
1966                            "[logger] Calibration updated for channel {channel:02}; rotating file."
1967                        );
1968                        active_calibration = updated;
1969                    }
1970                }
1971                _ = &mut stop_rx => {
1972                    println!("[logger] Graceful stop requested for channel {channel:02}");
1973                    break;
1974                }
1975            }
1976        }
1977
1978        close_and_settle(&mut logger, &mut pending, channel, file_index).await;
1979        replay.end_skip_run(channel);
1980        replay.checkpoint(&mut consumer, channel, true).await;
1981    });
1982
1983    Ok(ChannelLogger {
1984        handle,
1985        stop_tx: Some(stop_tx),
1986        calibration_tx,
1987        calibration,
1988        subject,
1989        stream_name,
1990        consumer_name,
1991        asset,
1992        rotate_secs,
1993    })
1994}
1995
1996/// Computes the Unix timestamp for a sample index within a FlatBuffer scan.
1997///
1998/// # Arguments
1999///
2000/// * `first_sample_unix_ns` - Time of the scan's first sample, in Unix nanoseconds.
2001/// * `sample_interval_ns` - Time between samples, in nanoseconds.
2002/// * `index` - Zero-based sample index within the scan.
2003///
2004/// # Returns
2005///
2006/// `first_sample_unix_ns + sample_interval_ns * index`, in Unix nanoseconds.
2007///
2008/// # Errors
2009///
2010/// Returns an error if the result does not fit in an `i64`.
2011///
2012/// # Examples
2013///
2014/// ```text
2015/// sample_timestamp_ns(1_000, 500_000, 3) == Ok(1_501_000)
2016/// ```
2017fn sample_timestamp_ns(
2018    first_sample_unix_ns: u64,
2019    sample_interval_ns: u64,
2020    index: usize,
2021) -> Result<i64, String> {
2022    let timestamp = (first_sample_unix_ns as u128)
2023        .checked_add((sample_interval_ns as u128).saturating_mul(index as u128))
2024        .ok_or_else(|| "sample timestamp overflowed u128".to_string())?;
2025    i64::try_from(timestamp).map_err(|_| "sample timestamp exceeds i64 range".to_string())
2026}
2027
2028/// Counts the leading samples of a scan whose timestamps fit in an `i64`.
2029///
2030/// These are exactly the indices for which [`sample_timestamp_ns`] succeeds.
2031///
2032/// # Arguments
2033///
2034/// * `first_sample_unix_ns` - Time of the scan's first sample, in Unix nanoseconds.
2035/// * `sample_interval_ns` - Time between samples, in nanoseconds.
2036/// * `count` - Number of samples in the scan.
2037fn representable_samples(
2038    first_sample_unix_ns: u64,
2039    sample_interval_ns: u64,
2040    count: usize,
2041) -> usize {
2042    let Some(headroom) = (i64::MAX as u64).checked_sub(first_sample_unix_ns) else {
2043        return 0;
2044    };
2045    if sample_interval_ns == 0 {
2046        return count;
2047    }
2048    let last_index = headroom / sample_interval_ns;
2049    usize::try_from(last_index).map_or(count, |last| count.min(last.saturating_add(1)))
2050}
2051
2052/// Converts a Unix nanosecond timestamp to its UTC calendar date.
2053///
2054/// # Arguments
2055///
2056/// * `timestamp_unix_ns` - Time in Unix nanoseconds.
2057///
2058/// # Examples
2059///
2060/// ```text
2061/// timestamp_ns_to_utc_date(1_754_395_200_000_000_000) == 2025-08-05
2062/// ```
2063fn timestamp_ns_to_utc_date(timestamp_unix_ns: i64) -> NaiveDate {
2064    DateTime::<Utc>::from_timestamp_nanos(timestamp_unix_ns).date_naive()
2065}
2066
2067#[tokio::main]
2068/// Starts the archiver service and reacts to configuration updates.
2069///
2070/// Startup, in order:
2071///
2072/// 1. Quarantines unfinished files under `PARQUET_DIR` (see
2073///    [`quarantine_incomplete_files`]).
2074/// 2. Connects to local NATS with `NATS_CREDS_FILE` and opens (or creates) the config KV
2075///    bucket.
2076/// 3. If central mirroring is configured, copies the central config once (a failure is
2077///    logged, not fatal) and later starts [`run_central_kv_sync`] in the background.
2078/// 4. Loads the config from `CFG_KEY` and starts one writer per enabled channel with
2079///    [`spawn_channel_logger`].
2080///
2081/// It then loops until Ctrl-C or the end of the KV watch:
2082///
2083/// * Every 5 seconds it restarts any channel whose writer task has finished or is
2084///   missing (for example after a consumer error or a failed start).
2085/// * On a KV put it parses the new config (invalid values are logged and ignored),
2086///   stops writers for removed channels, restarts writers whose subject, stream,
2087///   consumer name, asset or `rotate_secs` changed, sends calibration-only changes to
2088///   the running writer, and starts writers for new channels. Delete and purge events
2089///   are ignored.
2090///
2091/// On exit it stops every writer, which closes its file and acks its messages.
2092///
2093/// # Errors
2094///
2095/// Returns an error if `NATS_SERVERS` is invalid, if quarantining fails, if the
2096/// credentials cannot be loaded or the NATS connection fails, if the KV bucket cannot
2097/// be opened or created, if the central server list is invalid, if the config key is
2098/// missing or does not parse, if the KV watch cannot be started, if any initial channel
2099/// writer fails to start, or if listening for Ctrl-C fails.
2100async fn main() -> Result<(), DynError> {
2101    let servers = nats_config::servers_from_env()
2102        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
2103    let parquet_root =
2104        PathBuf::from(std::env::var("PARQUET_DIR").unwrap_or_else(|_| "parquet".into()));
2105    for path in quarantine_incomplete_files(&parquet_root)? {
2106        eprintln!(
2107            "[logger] Quarantined unfinished Parquet writer file: {}",
2108            path.display()
2109        );
2110    }
2111    let state_dir = replay_state_dir_from_env(&parquet_root);
2112    match &state_dir {
2113        Some(dir) => println!("[logger] Replay guard checkpoints in {}", dir.display()),
2114        None => println!("[logger] Replay guard is off (ARCHIVER_REPLAY_GUARD)"),
2115    }
2116
2117    let creds_path = std::env::var("NATS_CREDS_FILE").unwrap_or_else(|_| "apt.creds".into());
2118    let sample_opts = ConnectOptions::with_credentials_file(creds_path.clone())
2119        .await
2120        .map_err(|e| format!("Failed to load creds: {}", e))?;
2121
2122    let nc = sample_opts
2123        .connect(servers)
2124        .await
2125        .map_err(|e| format!("NATS connect failed: {}", e))?;
2126
2127    println!("Connected to sample NATS via creds!");
2128    let js = nats_config::jetstream_context(nc);
2129
2130    // Load the config from local KV, mirroring it from central NATS first if configured.
2131    let bucket = std::env::var("CFG_BUCKET").unwrap_or_else(|_| "avenabox".into());
2132    let key = std::env::var("CFG_KEY").unwrap_or_else(|_| "labjackd.config.macbook".into());
2133    let store = ensure_kv_bucket(&js, bucket.as_str()).await?;
2134    let central_sync_cfg = central_kv_sync_config_from_env(&creds_path)?;
2135    if let Some(sync_cfg) = central_sync_cfg.as_ref() {
2136        if let Err(err) = mirror_central_kv_once(sync_cfg, &store, &key).await {
2137            eprintln!("[logger] Initial central-to-local KV mirror failed: {err}");
2138        }
2139    }
2140    let entry = store.entry(key.as_str()).await?.ok_or("KV key not found")?;
2141
2142    let nested = serde_json::from_slice::<NestedConfig>(&entry.value)?;
2143    let mut cfg: SampleConfig = sample_config_from_nested(nested);
2144
2145    println!("[logger] Loaded config: {:?}", cfg);
2146
2147    // Create channel loggers and watch KV changes in the main task so
2148    // shutdown can wait for every active Parquet writer to close.
2149    let mut watch = store.watch(key.as_str()).await?;
2150    let mut active: HashMap<u8, ChannelLogger> = HashMap::new();
2151    if let Some(sync_cfg) = central_sync_cfg {
2152        tokio::spawn(run_central_kv_sync(sync_cfg, store.clone(), key.clone()));
2153    }
2154
2155    // initial subscriptions
2156    for ch in &cfg.channels {
2157        let subject = subjects::live_labjack_channel_subject(
2158            &cfg.nats_subject,
2159            cfg.asset_number,
2160            *ch,
2161            cfg.site_id.as_deref(),
2162            cfg.box_id.as_deref(),
2163            Some(&cfg.labjack_name),
2164            cfg.source_type.as_deref(),
2165            cfg.source_id.as_deref(),
2166        );
2167        let calibration = cfg.calibrations.get(ch).cloned().unwrap_or_default();
2168        let consumer_name = archiver_consumer_name(&cfg, *ch);
2169        let h = spawn_channel_logger(
2170            js.clone(),
2171            cfg.nats_stream.clone(),
2172            consumer_name,
2173            subject,
2174            cfg.asset_number,
2175            *ch,
2176            cfg.rotate_secs,
2177            calibration,
2178            parquet_root.clone(),
2179            state_dir.clone(),
2180        )
2181        .await?;
2182        active.insert(*ch, h);
2183    }
2184
2185    println!("[logger] Watching KV for config changes...");
2186    let shutdown = tokio::signal::ctrl_c();
2187    tokio::pin!(shutdown);
2188    let mut health_check = tokio::time::interval(Duration::from_secs(5));
2189    // Skip the immediate first tick; initial channel setup just completed.
2190    health_check.tick().await;
2191
2192    loop {
2193        let entry = tokio::select! {
2194            signal = &mut shutdown => {
2195                signal?;
2196                println!("[logger] Shutdown signal received.");
2197                break;
2198            }
2199            event = watch.next() => {
2200                match event {
2201                    Some(Ok(entry)) => entry,
2202                    Some(Err(err)) => {
2203                        eprintln!("[logger] KV watch error: {err}");
2204                        continue;
2205                    }
2206                    None => {
2207                        eprintln!("[logger] KV watch ended; shutting down writers.");
2208                        break;
2209                    }
2210                }
2211            }
2212            _ = health_check.tick() => {
2213                let restart_channels: Vec<u8> = cfg
2214                    .channels
2215                    .iter()
2216                    .copied()
2217                    .filter(|channel| {
2218                        active
2219                            .get(channel)
2220                            .map(|logger| logger.handle.is_finished())
2221                            .unwrap_or(true)
2222                    })
2223                    .collect();
2224
2225                for channel in restart_channels {
2226                    if let Some(logger) = active.remove(&channel) {
2227                        eprintln!(
2228                            "[logger] Channel {channel:02} writer ended; restarting it."
2229                        );
2230                        logger.stop().await;
2231                    } else {
2232                        eprintln!(
2233                            "[logger] Channel {channel:02} writer is missing; starting it."
2234                        );
2235                    }
2236
2237                    let subject = subjects::live_labjack_channel_subject(
2238                        &cfg.nats_subject,
2239                        cfg.asset_number,
2240                        channel,
2241                        cfg.site_id.as_deref(),
2242                        cfg.box_id.as_deref(),
2243                        Some(&cfg.labjack_name),
2244                        cfg.source_type.as_deref(),
2245                        cfg.source_id.as_deref(),
2246                    );
2247                    let consumer_name = archiver_consumer_name(&cfg, channel);
2248                    let calibration = cfg
2249                        .calibrations
2250                        .get(&channel)
2251                        .cloned()
2252                        .unwrap_or_default();
2253                    match spawn_channel_logger(
2254                        js.clone(),
2255                        cfg.nats_stream.clone(),
2256                        consumer_name,
2257                        subject,
2258                        cfg.asset_number,
2259                        channel,
2260                        cfg.rotate_secs,
2261                        calibration,
2262                        parquet_root.clone(),
2263                        state_dir.clone(),
2264                    )
2265                    .await
2266                    {
2267                        Ok(logger) => {
2268                            active.insert(channel, logger);
2269                        }
2270                        Err(err) => {
2271                            eprintln!(
2272                                "[logger] Failed to restart channel {channel:02}: {err}"
2273                            );
2274                        }
2275                    }
2276                }
2277                continue;
2278            }
2279        };
2280
2281        if entry.operation != Operation::Put {
2282            continue;
2283        }
2284        let new_cfg = match serde_json::from_slice::<NestedConfig>(&entry.value)
2285            .map(sample_config_from_nested)
2286        {
2287            Ok(config) => config,
2288            Err(err) => {
2289                eprintln!("[logger] Ignoring invalid KV config update: {err}");
2290                continue;
2291            }
2292        };
2293        println!("[logger] KV config update detected: {:?}", new_cfg);
2294
2295        let removed_channels: Vec<u8> = active
2296            .keys()
2297            .copied()
2298            .filter(|channel| !new_cfg.channels.contains(channel))
2299            .collect();
2300        for channel in removed_channels {
2301            if let Some(logger) = active.remove(&channel) {
2302                println!("[logger] Gracefully removing channel {channel}");
2303                logger.stop().await;
2304            }
2305        }
2306
2307        for channel in &new_cfg.channels {
2308            let subject = subjects::live_labjack_channel_subject(
2309                &new_cfg.nats_subject,
2310                new_cfg.asset_number,
2311                *channel,
2312                new_cfg.site_id.as_deref(),
2313                new_cfg.box_id.as_deref(),
2314                Some(&new_cfg.labjack_name),
2315                new_cfg.source_type.as_deref(),
2316                new_cfg.source_id.as_deref(),
2317            );
2318            let consumer_name = archiver_consumer_name(&new_cfg, *channel);
2319            let calibration = new_cfg
2320                .calibrations
2321                .get(channel)
2322                .cloned()
2323                .unwrap_or_default();
2324
2325            let mut needs_respawn = !active.contains_key(channel);
2326            if let Some(logger) = active.get_mut(channel) {
2327                needs_respawn = logger.subject != subject
2328                    || logger.stream_name != new_cfg.nats_stream
2329                    || logger.consumer_name != consumer_name
2330                    || logger.asset != new_cfg.asset_number
2331                    || logger.rotate_secs != new_cfg.rotate_secs;
2332                if logger.calibration != calibration && !needs_respawn {
2333                    if logger.calibration_tx.send(calibration.clone()).is_ok() {
2334                        logger.calibration = calibration.clone();
2335                    } else {
2336                        needs_respawn = true;
2337                    }
2338                }
2339            }
2340
2341            if !needs_respawn {
2342                continue;
2343            }
2344            if let Some(logger) = active.remove(channel) {
2345                println!("[logger] Gracefully restarting channel {channel}");
2346                logger.stop().await;
2347            } else {
2348                println!("[logger] Adding channel {channel}");
2349            }
2350
2351            match spawn_channel_logger(
2352                js.clone(),
2353                new_cfg.nats_stream.clone(),
2354                consumer_name,
2355                subject,
2356                new_cfg.asset_number,
2357                *channel,
2358                new_cfg.rotate_secs,
2359                calibration,
2360                parquet_root.clone(),
2361                state_dir.clone(),
2362            )
2363            .await
2364            {
2365                Ok(logger) => {
2366                    active.insert(*channel, logger);
2367                }
2368                Err(err) => {
2369                    eprintln!("[logger] Failed to start channel {channel}: {err}");
2370                }
2371            }
2372        }
2373        cfg = new_cfg;
2374    }
2375
2376    println!("[logger] Closing {} channel writer(s)...", active.len());
2377    for (_, logger) in active {
2378        logger.stop().await;
2379    }
2380    println!("[logger] Shutdown complete.");
2381    Ok(())
2382}
2383
2384#[cfg(test)]
2385mod tests {
2386    use super::*;
2387    use crate::test_nats::TestNats;
2388    use parquet::file::reader::{FileReader, SerializedFileReader};
2389    use uuid::Uuid;
2390
2391    impl ParquetLogger {
2392        /// Buffers one timestamped value and flushes when the row group is full:
2393        /// [`ParquetLogger::write_run`] with a single sample.
2394        fn write_row(&mut self, timestamp_unix_ns: i64, val: f64) -> Result<(), DynError> {
2395            self.write_run(timestamp_unix_ns, 0, 1, std::iter::once(val))
2396                .map(|_| ())
2397        }
2398    }
2399
2400    /// Returns a unique, not yet created directory under the system temp directory.
2401    fn temporary_parquet_root(name: &str) -> PathBuf {
2402        std::env::temp_dir().join(format!("rust-ljm-{name}-{}", Uuid::new_v4()))
2403    }
2404
2405    /// A closed file is renamed to `.parquet` and reads back with its one row.
2406    #[test]
2407    fn completed_writer_is_atomically_published_and_readable() {
2408        let root = temporary_parquet_root("finalize");
2409        let date = NaiveDate::from_ymd_opt(2026, 8, 5).expect("valid date");
2410        let mut logger = ParquetLogger::new(1001, 11, 1, date, CalibrationSpec::default(), &root);
2411        logger
2412            .write_row(1_754_395_200_000_000_000, 1.25)
2413            .expect("row should buffer");
2414
2415        let final_path = logger.close().expect("writer should finalize");
2416        assert!(final_path.exists());
2417        assert!(!final_path.with_extension("parquet.inprogress").exists());
2418
2419        let file = fs::File::open(&final_path).expect("final file should open");
2420        let reader = SerializedFileReader::new(file).expect("final file should be readable");
2421        assert_eq!(reader.metadata().file_metadata().num_rows(), 1);
2422        fs::remove_dir_all(root).expect("temporary directory should be removable");
2423    }
2424
2425    /// A writer failure during close is returned as an error instead of panicking, and
2426    /// the file is not published.
2427    #[test]
2428    fn failed_close_returns_error_and_leaves_file_unpublished() {
2429        let root = temporary_parquet_root("failed-close");
2430        let date = NaiveDate::from_ymd_opt(2026, 8, 5).expect("valid date");
2431        let mut logger = ParquetLogger::new(1001, 11, 1, date, CalibrationSpec::default(), &root);
2432        logger
2433            .write_row(1_754_395_200_000_000_000, 1.25)
2434            .expect("row should buffer");
2435        // Finishing the writer early makes the buffered row group fail to write.
2436        logger.writer.finish().expect("footer should write");
2437        let inprogress_path = logger.inprogress_path.clone();
2438        let final_path = logger.final_path.clone();
2439
2440        let mut outcome = CloseOutcome::default();
2441        outcome.record(logger.close(), 11, 1);
2442        assert_eq!(
2443            outcome,
2444            CloseOutcome {
2445                closed: 0,
2446                failed: 1
2447            }
2448        );
2449        assert!(inprogress_path.exists());
2450        assert!(!final_path.exists());
2451        fs::remove_dir_all(root).expect("temporary directory should be removable");
2452    }
2453
2454    /// A failed row group write mid-payload is counted as a failure, drops the broken
2455    /// file and skips the rest of the payload.
2456    #[test]
2457    fn failed_row_group_write_is_reported_and_stops_the_payload() {
2458        let root = temporary_parquet_root("failed-flush");
2459        let first_ns = 1_754_395_200_000_000_000_u64;
2460        let date = timestamp_ns_to_utc_date(first_ns as i64);
2461        let mut logger = ParquetLogger::new(1001, 3, 1, date, CalibrationSpec::default(), &root);
2462        logger
2463            .write_row(first_ns as i64, 0.5)
2464            .expect("row should buffer");
2465        logger.max_rows = 2;
2466        logger.writer.finish().expect("footer should write");
2467        let mut logger = Some(logger);
2468        let mut file_index = 1;
2469        let mut last_sequence = None;
2470
2471        let payload = scan_payload(first_ns + 1_000, 1_000, 7, &[1.0, 2.0, 3.0]);
2472        let outcome = process_scan_payload(
2473            &payload,
2474            3,
2475            1001,
2476            &root,
2477            &CalibrationSpec::default(),
2478            300,
2479            &mut logger,
2480            &mut file_index,
2481            &mut last_sequence,
2482        );
2483        assert_eq!(
2484            outcome,
2485            CloseOutcome {
2486                closed: 0,
2487                failed: 1
2488            }
2489        );
2490        assert!(logger.is_none());
2491        assert_eq!(
2492            file_index, 1,
2493            "no new file should be opened for the rest of the payload"
2494        );
2495        fs::remove_dir_all(root).expect("temporary directory should be removable");
2496    }
2497
2498    /// A directory that cannot be synced is reported but does not panic.
2499    #[test]
2500    fn directory_sync_failure_is_reported_without_failing() {
2501        let root = temporary_parquet_root("dir-sync");
2502        fs::create_dir_all(&root).expect("temporary directory should be creatable");
2503        assert!(sync_directory_after_publish(&root));
2504        assert!(!sync_directory_after_publish(&root.join("missing")));
2505        fs::remove_dir_all(root).expect("temporary directory should be removable");
2506    }
2507
2508    /// Rotation happens at aligned window edges, on backwards time and at the row group cap.
2509    #[test]
2510    fn writer_rotates_at_aligned_source_windows_and_before_row_group_limit() {
2511        let root = temporary_parquet_root("source-rotation");
2512        let date = NaiveDate::from_ymd_opt(2026, 8, 18).expect("valid date");
2513        // 2026-08-18 00:05:00 UTC is a five-minute boundary.
2514        let window_start = 1_787_011_500_000_000_000_i64;
2515        assert_eq!(window_start % 300_000_000_000, 0);
2516        let start = window_start + 10_000_000_000;
2517        let mut logger = ParquetLogger::new(1001, 0, 1, date, CalibrationSpec::default(), &root);
2518        logger.write_row(start, 1.0).expect("row should buffer");
2519
2520        // Same window: no rotation, even 289.999 s after the first sample.
2521        assert!(!logger.should_rotate_before(window_start + 299_999_999_999, 300));
2522        // Next aligned window starts only 290 s after the first sample.
2523        assert!(logger.should_rotate_before(window_start + 300_000_000_000, 300));
2524        // Time going backwards always starts a new file.
2525        assert!(logger.should_rotate_before(start - 1, 300));
2526
2527        logger.row_groups_written = MAX_ROW_GROUPS_PER_FILE;
2528        assert!(logger.should_rotate_before(start + 1, 300));
2529
2530        let _ = logger.close().expect("writer should finalize");
2531        fs::remove_dir_all(root).expect("temporary directory should be removable");
2532    }
2533
2534    /// Window indices are floor multiples of the period since the epoch.
2535    #[test]
2536    fn rotation_windows_align_to_epoch_multiples() {
2537        assert_eq!(rotation_window(0, 300), 0);
2538        assert_eq!(rotation_window(299_999_999_999, 300), 0);
2539        assert_eq!(rotation_window(300_000_000_000, 300), 1);
2540        assert_eq!(rotation_window(-1, 300), -1);
2541        // A zero period must not divide by zero.
2542        assert_eq!(rotation_window(5_000_000_000, 0), 5);
2543    }
2544
2545    /// A zstd, delta-encoded file holds one row group and reads back exactly.
2546    #[test]
2547    fn compressed_file_round_trips_exactly_through_the_exporter_reader() {
2548        use parquet::basic::{Compression, Encoding};
2549        use parquet::record::RowAccessor;
2550
2551        let root = temporary_parquet_root("round-trip");
2552        let date = NaiveDate::from_ymd_opt(2026, 9, 14).expect("valid date");
2553        let mut logger = ParquetLogger::new(1001, 8, 1, date, CalibrationSpec::default(), &root);
2554        let start = 1_789_401_600_000_000_000_i64;
2555        let rows = 60_000; // 30 s at 2 kHz
2556        let expected: Vec<(i64, f64)> = (0..rows)
2557            .map(|i| {
2558                // 16-bit ADC steps around 3.72 V, like the live pressure channels.
2559                let counts = (i * 7919 % 97) as f64;
2560                (start + i as i64 * 500_000, 3.70 + counts * 0.000_315_6)
2561            })
2562            .collect();
2563        for (ts, v) in &expected {
2564            logger.write_row(*ts, *v).expect("row should buffer");
2565        }
2566        let path = logger.close().expect("writer should finalize");
2567
2568        let reader = SerializedFileReader::new(fs::File::open(&path).expect("open"))
2569            .expect("compressed file should be readable");
2570        let meta = reader.metadata();
2571        assert_eq!(meta.num_row_groups(), 1);
2572        let rg = meta.row_group(0);
2573        assert!(matches!(rg.column(0).compression(), Compression::ZSTD(_)));
2574        assert!(
2575            rg.column(0)
2576                .encodings()
2577                .contains(&Encoding::DELTA_BINARY_PACKED)
2578        );
2579        assert!(rg.column(1).encodings().contains(&Encoding::RLE_DICTIONARY));
2580
2581        // Read back exactly as the exporter does.
2582        let actual: Vec<(i64, f64)> = reader
2583            .get_row_iter(None)
2584            .expect("row iterator")
2585            .map(|row| {
2586                let row = row.expect("row");
2587                (
2588                    row.get_long(0).expect("ts"),
2589                    row.get_double(1).expect("value"),
2590                )
2591            })
2592            .collect();
2593        assert_eq!(actual, expected);
2594        fs::remove_dir_all(root).expect("temporary directory should be removable");
2595    }
2596
2597    /// Builds a FlatBuffer scan payload like the streamer publishes.
2598    fn scan_payload(first_ns: u64, interval_ns: u64, sequence: u64, values: &[f64]) -> Vec<u8> {
2599        let mut builder = flatbuffers::FlatBufferBuilder::new();
2600        let values = builder.create_vector(values);
2601        let scan = sampler::Scan::create(
2602            &mut builder,
2603            &sampler::ScanArgs {
2604                first_sample_unix_ns: first_ns,
2605                sample_interval_ns: interval_ns,
2606                actual_scan_rate_hz: 1e9 / interval_ns as f64,
2607                sequence,
2608                values: Some(values),
2609            },
2610        );
2611        builder.finish(scan, None);
2612        builder.finished_data().to_vec()
2613    }
2614
2615    /// One test scan: first timestamp, interval and values.
2616    type TestScan = (u64, u64, Vec<f64>);
2617    /// Rows of one archived file: timestamp and value.
2618    type TestRows = Vec<(i64, f64)>;
2619
2620    /// Groups samples into files with one rotation check per sample, the way the
2621    /// archiver did before it checked once per run.
2622    fn files_by_per_sample_check(scans: &[TestScan], rotate_secs: u64) -> Vec<TestRows> {
2623        let mut files: Vec<(NaiveDate, i64, TestRows)> = Vec::new();
2624        for (first, interval, values) in scans {
2625            for (index, v) in values.iter().enumerate() {
2626                let Ok(ts) = sample_timestamp_ns(*first, *interval, index) else {
2627                    break;
2628                };
2629                let date = timestamp_ns_to_utc_date(ts);
2630                let rotate = files.last().is_none_or(|(d, start, _)| {
2631                    *d != date
2632                        || ts < *start
2633                        || rotation_window(ts, rotate_secs) != rotation_window(*start, rotate_secs)
2634                });
2635                if rotate {
2636                    files.push((date, ts, Vec::new()));
2637                }
2638                files.last_mut().expect("file").2.push((ts, *v));
2639            }
2640        }
2641        files.into_iter().map(|(_, _, rows)| rows).collect()
2642    }
2643
2644    /// Rows of every archived file under `root`, in date and part order.
2645    fn archived_rows(root: &Path) -> Vec<TestRows> {
2646        use parquet::record::RowAccessor;
2647        let mut paths = archived_files(root);
2648        paths.sort();
2649        paths
2650            .iter()
2651            .map(|path| {
2652                SerializedFileReader::new(fs::File::open(path).expect("open"))
2653                    .expect("readable")
2654                    .get_row_iter(None)
2655                    .expect("row iterator")
2656                    .map(|row| {
2657                        let row = row.expect("row");
2658                        (row.get_long(0).expect("ts"), row.get_double(1).expect("v"))
2659                    })
2660                    .collect()
2661            })
2662            .collect()
2663    }
2664
2665    /// Writing a run at a time splits samples into exactly the files a check per
2666    /// sample does: across window edges inside a message, a UTC midnight that is not
2667    /// a window edge, time going backwards, a zero interval and a timestamp overflow.
2668    #[test]
2669    fn runs_rotate_exactly_like_a_check_per_sample() {
2670        let root = temporary_parquet_root("runs");
2671        // 7 s windows do not divide a day, so midnight falls inside a window.
2672        let rotate_secs = 7;
2673        let midnight = 1_789_430_400_000_000_000_u64; // 2026-09-15 00:00:00 UTC
2674        assert_eq!(midnight % 86_400_000_000_000, 0);
2675        let mut scans: Vec<TestScan> = Vec::new();
2676        // 3.7 s messages from 30 s before midnight, crossing several windows each.
2677        for k in 0..20_u64 {
2678            let first = midnight - 30_000_000_000 + k * 3_700_000_000;
2679            let values = (0..100).map(|i| (k * 100 + i) as f64).collect();
2680            scans.push((first, 37_000_000, values));
2681        }
2682        // Time goes backwards, then a message with every sample at one time.
2683        scans.push((midnight + 1_000_000_000, 450_000_000, vec![-1.0; 40]));
2684        scans.push((midnight + 30_000_000_000, 0, vec![-2.0; 10]));
2685        // An empty message, then one whose last two timestamps do not fit in an i64.
2686        scans.push((midnight + 40_000_000_000, 1_000, Vec::new()));
2687        scans.push((
2688            i64::MAX as u64 - 2_500,
2689            1_000,
2690            vec![-3.0, -4.0, -5.0, -6.0, -7.0],
2691        ));
2692
2693        let mut logger = None;
2694        let mut file_index = 0;
2695        let mut last_sequence = None;
2696        for (sequence, (first, interval, values)) in scans.iter().enumerate() {
2697            let outcome = process_scan_payload(
2698                &scan_payload(*first, *interval, sequence as u64, values),
2699                8,
2700                1001,
2701                &root,
2702                &CalibrationSpec::default(),
2703                rotate_secs,
2704                &mut logger,
2705                &mut file_index,
2706                &mut last_sequence,
2707            );
2708            assert_eq!(outcome.failed, 0);
2709        }
2710        logger.take().expect("open file").close().expect("close");
2711
2712        let expected = files_by_per_sample_check(&scans, rotate_secs);
2713        assert!(expected.len() > 10, "the scans cross many files");
2714        assert_eq!(expected.last().expect("file").len(), 3);
2715        assert_eq!(archived_rows(&root), expected);
2716        fs::remove_dir_all(root).expect("temporary directory should be removable");
2717    }
2718
2719    /// A run that fills the row group is split there, so row groups keep their size.
2720    #[test]
2721    fn runs_split_at_the_row_group_limit() {
2722        let root = temporary_parquet_root("run-row-groups");
2723        let first_ns = 1_754_395_200_000_000_000_u64;
2724        let date = timestamp_ns_to_utc_date(first_ns as i64);
2725        let mut logger = ParquetLogger::new(1001, 3, 1, date, CalibrationSpec::default(), &root);
2726        logger.max_rows = 4;
2727        logger.write_row(first_ns as i64, 0.0).expect("row");
2728        let mut logger = Some(logger);
2729        let mut file_index = 1;
2730        let mut last_sequence = None;
2731        let values: Vec<f64> = (1..=10).map(f64::from).collect();
2732        let payload = scan_payload(first_ns + 1_000, 1_000, 0, &values);
2733        let outcome = process_scan_payload(
2734            &payload,
2735            3,
2736            1001,
2737            &root,
2738            &CalibrationSpec::default(),
2739            300,
2740            &mut logger,
2741            &mut file_index,
2742            &mut last_sequence,
2743        );
2744        assert_eq!(outcome, CloseOutcome::default());
2745        let path = logger.take().expect("open file").close().expect("close");
2746        let reader =
2747            SerializedFileReader::new(fs::File::open(&path).expect("open")).expect("readable");
2748        let sizes: Vec<i64> = reader
2749            .metadata()
2750            .row_groups()
2751            .iter()
2752            .map(|rg| rg.num_rows())
2753            .collect();
2754        assert_eq!(sizes, vec![4, 4, 3]);
2755        let rows = archived_rows(&root);
2756        let expected: Vec<(i64, f64)> = (0..=10)
2757            .map(|i| (first_ns as i64 + i * 1_000, i as f64))
2758            .collect();
2759        assert_eq!(rows, vec![expected]);
2760        fs::remove_dir_all(root).expect("temporary directory should be removable");
2761    }
2762
2763    /// The count of representable samples matches where `sample_timestamp_ns` fails.
2764    #[test]
2765    fn representable_samples_matches_timestamp_overflow() {
2766        let max = i64::MAX as u64;
2767        for (first, interval, count) in [
2768            (0, 1_000, 10),
2769            (max - 2_500, 1_000, 5),
2770            (max, 1, 3),
2771            (max, 0, 3),
2772            (max + 1, 0, 3),
2773            (0, u64::MAX, 3),
2774        ] {
2775            let fitting = (0..count)
2776                .take_while(|&i| sample_timestamp_ns(first, interval, i).is_ok())
2777                .count();
2778            assert_eq!(
2779                representable_samples(first, interval, count),
2780                fitting,
2781                "first {first} interval {interval}"
2782            );
2783        }
2784    }
2785
2786    /// End-to-end check against a real NATS server:
2787    /// an existing consumer with the old settings is updated in place, messages
2788    /// stay unacked until their file is closed, and files split at aligned
2789    /// windows. Run with `AVENA_TEST_NATS_URL=nats://127.0.0.1:4222`.
2790    #[tokio::test]
2791    #[ignore = "needs a JetStream-enabled nats-server; set AVENA_TEST_NATS_URL"]
2792    async fn archiver_defers_acks_until_file_close_on_real_nats() {
2793        let Ok(url) = std::env::var("AVENA_TEST_NATS_URL") else {
2794            return;
2795        };
2796        let client = async_nats::connect(url)
2797            .await
2798            .expect("connect to test NATS");
2799        let js = jetstream::new(client);
2800        let stream_name = format!("test-{}", Uuid::new_v4().simple());
2801        let subject = format!("{stream_name}.ch08");
2802        let stream = js
2803            .create_stream(jetstream::stream::Config {
2804                name: stream_name.clone(),
2805                subjects: vec![format!("{stream_name}.>")],
2806                ..Default::default()
2807            })
2808            .await
2809            .expect("create stream");
2810
2811        // A durable consumer as created by the currently deployed archiver.
2812        let consumer_name = "archiver-test-current".to_string();
2813        stream
2814            .create_consumer(pull::Config {
2815                durable_name: Some(consumer_name.clone()),
2816                filter_subject: subject.clone(),
2817                ack_policy: jetstream::consumer::AckPolicy::Explicit,
2818                ack_wait: Duration::from_secs(30),
2819                ..Default::default()
2820            })
2821            .await
2822            .expect("create old-style consumer");
2823
2824        let root = temporary_parquet_root("nats-integration");
2825        let logger = spawn_channel_logger(
2826            js.clone(),
2827            stream_name.clone(),
2828            consumer_name.clone(),
2829            subject.clone(),
2830            1001,
2831            8,
2832            300,
2833            CalibrationSpec::default(),
2834            root.clone(),
2835            None,
2836        )
2837        .await
2838        .expect("spawn channel logger");
2839
2840        let mut info_stream = js.get_stream(&stream_name).await.expect("stream");
2841        let info = info_stream
2842            .consumer_info(&consumer_name)
2843            .await
2844            .expect("consumer info");
2845        let expected = archiver_consumer_config(&consumer_name, &subject, 300);
2846        assert_eq!(
2847            info.config.ack_wait, expected.ack_wait,
2848            "ack_wait updated in place"
2849        );
2850        assert_eq!(info.config.max_ack_pending, CONSUMER_MAX_ACK_PENDING);
2851
2852        // Five 1 s messages at 100 Hz, all inside one aligned window.
2853        let interval = 10_000_000_u64;
2854        let window = 1_790_000_100_u64 / 300 * 300; // an aligned five-minute boundary, in seconds
2855        let base = (window + 100) * 1_000_000_000;
2856        let values = vec![3.72; 100];
2857        for k in 0..5_u64 {
2858            js.publish(
2859                subject.clone(),
2860                scan_payload(base + k * 1_000_000_000, interval, k, &values).into(),
2861            )
2862            .await
2863            .expect("publish")
2864            .await
2865            .expect("stored");
2866        }
2867
2868        async fn pending_of(s: &mut jetstream::stream::Stream, name: &str) -> (usize, u64) {
2869            let info = s.consumer_info(name).await.expect("consumer info");
2870            (info.num_ack_pending, info.num_pending)
2871        }
2872        let mut waited = 0;
2873        while pending_of(&mut info_stream, &consumer_name).await != (5, 0) && waited < 50 {
2874            tokio::time::sleep(Duration::from_millis(100)).await;
2875            waited += 1;
2876        }
2877        assert_eq!(
2878            pending_of(&mut info_stream, &consumer_name).await,
2879            (5, 0),
2880            "open file holds its acks"
2881        );
2882
2883        // A message in the next window closes the first file and acks its five
2884        // messages; the new message waits for its own file.
2885        let next = (window + 300) * 1_000_000_000;
2886        js.publish(
2887            subject.clone(),
2888            scan_payload(next, interval, 5, &values).into(),
2889        )
2890        .await
2891        .expect("publish")
2892        .await
2893        .expect("stored");
2894        waited = 0;
2895        while pending_of(&mut info_stream, &consumer_name).await != (1, 0) && waited < 50 {
2896            tokio::time::sleep(Duration::from_millis(100)).await;
2897            waited += 1;
2898        }
2899        assert_eq!(
2900            pending_of(&mut info_stream, &consumer_name).await,
2901            (1, 0),
2902            "closed file's messages acked"
2903        );
2904
2905        // Graceful stop closes the second file and acks the rest.
2906        logger.stop().await;
2907        tokio::time::sleep(Duration::from_millis(300)).await;
2908        assert_eq!(
2909            pending_of(&mut info_stream, &consumer_name).await,
2910            (0, 0),
2911            "stop settles every message"
2912        );
2913
2914        let mut files: Vec<PathBuf> = Vec::new();
2915        for day in fs::read_dir(root.join("asset1001")).expect("asset dir") {
2916            for f in fs::read_dir(day.expect("day").path().join("ch08")).expect("channel dir") {
2917                files.push(f.expect("file").path());
2918            }
2919        }
2920        files.sort();
2921        assert_eq!(files.len(), 2, "one file per aligned window: {files:?}");
2922        let rows: Vec<i64> = files
2923            .iter()
2924            .map(|p| {
2925                SerializedFileReader::new(fs::File::open(p).expect("open"))
2926                    .expect("readable")
2927                    .metadata()
2928                    .file_metadata()
2929                    .num_rows()
2930            })
2931            .collect();
2932        assert_eq!(rows.iter().sum::<i64>(), 600);
2933
2934        js.delete_stream(&stream_name).await.expect("delete stream");
2935        fs::remove_dir_all(root).expect("temporary directory should be removable");
2936    }
2937
2938    /// Every archived Parquet file under `root`, sorted.
2939    fn archived_files(root: &Path) -> Vec<PathBuf> {
2940        let mut files = Vec::new();
2941        let Ok(assets) = fs::read_dir(root) else {
2942            return files;
2943        };
2944        for asset in assets.flatten() {
2945            if !asset.file_name().to_string_lossy().starts_with("asset") {
2946                continue;
2947            }
2948            for day in fs::read_dir(asset.path()).expect("asset dir").flatten() {
2949                for ch in fs::read_dir(day.path()).expect("day dir").flatten() {
2950                    for f in fs::read_dir(ch.path()).expect("channel dir").flatten() {
2951                        if f.path().extension().is_some_and(|e| e == "parquet") {
2952                            files.push(f.path());
2953                        }
2954                    }
2955                }
2956            }
2957        }
2958        files.sort();
2959        files
2960    }
2961
2962    /// Polls a consumer until `done` accepts its info, for up to five seconds.
2963    async fn wait_for_consumer(
2964        js: &jetstream::Context,
2965        stream: &str,
2966        consumer: &str,
2967        done: impl Fn(&jetstream::consumer::Info) -> bool,
2968    ) -> jetstream::consumer::Info {
2969        let s = js.get_stream(stream).await.expect("stream");
2970        for _ in 0..100 {
2971            if let Ok(info) = s.consumer_info(consumer).await
2972                && done(&info)
2973            {
2974                return info;
2975            }
2976            tokio::time::sleep(Duration::from_millis(50)).await;
2977        }
2978        s.consumer_info(consumer).await.expect("consumer info")
2979    }
2980
2981    /// Publishes one 1 s scan of 100 samples starting `offset_s` into an aligned window.
2982    async fn publish_scan(js: &jetstream::Context, subject: &str, offset_s: u64, seq: u64) {
2983        let window = 1_790_000_100_u64 / 300 * 300;
2984        js.publish(
2985            subject.to_string(),
2986            scan_payload(
2987                (window + offset_s) * 1_000_000_000,
2988                10_000_000,
2989                seq,
2990                &[1.5; 100],
2991            )
2992            .into(),
2993        )
2994        .await
2995        .expect("publish")
2996        .await
2997        .expect("stored");
2998    }
2999
3000    /// Reproduces the field replay: after JetStream loses a durable consumer's
3001    /// progress (here the consumer is deleted and recreated with deliver policy all,
3002    /// which is what an empty `o.dat` after a power cut amounts to), the whole
3003    /// stream is delivered again. With the replay guard the old messages are acked
3004    /// without writing; without it they are archived a second time.
3005    #[tokio::test]
3006    async fn replay_guard_skips_already_archived_messages_after_consumer_state_loss() {
3007        let Some(server) = TestNats::start("replay_guard") else {
3008            return;
3009        };
3010        let js = server.jetstream().await;
3011        let stream_name = "replay";
3012        let subject = "avenars.s.b.src.live.ch08";
3013        js.create_stream(jetstream::stream::Config {
3014            name: stream_name.into(),
3015            subjects: vec!["avenars.s.b.src.live.*".into()],
3016            ..Default::default()
3017        })
3018        .await
3019        .expect("create stream");
3020        let root = temporary_parquet_root("replay-guard");
3021        let state_dir = root.join(".archiver-state");
3022        let consumer = "archiver-test-8-current";
3023        let spawn = |state: Option<PathBuf>| {
3024            spawn_channel_logger(
3025                js.clone(),
3026                stream_name.into(),
3027                consumer.into(),
3028                subject.into(),
3029                1001,
3030                8,
3031                300,
3032                CalibrationSpec::default(),
3033                root.clone(),
3034                state,
3035            )
3036        };
3037
3038        // First run archives three messages into one file.
3039        for k in 0..3 {
3040            publish_scan(&js, subject, 10 + k, k).await;
3041        }
3042        let logger = spawn(Some(state_dir.clone())).await.expect("spawn");
3043        wait_for_consumer(&js, stream_name, consumer, |i| i.num_ack_pending == 3).await;
3044        logger.stop().await;
3045        let info = wait_for_consumer(&js, stream_name, consumer, |i| {
3046            i.ack_floor.stream_sequence == 3
3047        })
3048        .await;
3049        assert_eq!(info.ack_floor.stream_sequence, 3);
3050        assert_eq!(archived_files(&root).len(), 1);
3051        // A quick restart saves the checkpoint at the consumer's ack floor.
3052        spawn(Some(state_dir.clone()))
3053            .await
3054            .expect("spawn")
3055            .stop()
3056            .await;
3057        let path = replay_guard::checkpoint_path(&state_dir, stream_name, subject);
3058        let saved = replay_guard::load(&path).expect("readable").expect("saved");
3059        assert_eq!(saved.archived_through_seq, 3);
3060
3061        // JetStream loses the consumer's progress; new data arrives meanwhile.
3062        js.get_stream(stream_name)
3063            .await
3064            .expect("stream")
3065            .delete_consumer(consumer)
3066            .await
3067            .expect("delete consumer");
3068        publish_scan(&js, subject, 310, 3).await;
3069
3070        let logger = spawn(Some(state_dir.clone())).await.expect("spawn");
3071        wait_for_consumer(&js, stream_name, consumer, |i| {
3072            i.ack_floor.stream_sequence == 3 && i.num_ack_pending == 1
3073        })
3074        .await;
3075        logger.stop().await;
3076        let info = wait_for_consumer(&js, stream_name, consumer, |i| {
3077            i.ack_floor.stream_sequence == 4
3078        })
3079        .await;
3080        assert_eq!(
3081            (info.ack_floor.stream_sequence, info.num_ack_pending),
3082            (4, 0)
3083        );
3084        let files = archived_files(&root);
3085        assert_eq!(files.len(), 2, "only the new message is written: {files:?}");
3086
3087        // The same loss without the guard archives every old message again.
3088        js.get_stream(stream_name)
3089            .await
3090            .expect("stream")
3091            .delete_consumer(consumer)
3092            .await
3093            .expect("delete consumer");
3094        let logger = spawn(None).await.expect("spawn");
3095        wait_for_consumer(&js, stream_name, consumer, |i| i.num_ack_pending == 1).await;
3096        logger.stop().await;
3097        assert_eq!(
3098            archived_files(&root).len(),
3099            4,
3100            "without the guard the backlog is written again"
3101        );
3102        fs::remove_dir_all(root).expect("temporary directory should be removable");
3103    }
3104
3105    /// A subject change never edits an existing consumer's filter; the channel moves
3106    /// to a consumer named after the new subject and archives its messages.
3107    #[tokio::test]
3108    async fn subject_change_uses_a_new_consumer_instead_of_editing_the_filter() {
3109        let Some(server) = TestNats::start("subject_change") else {
3110            return;
3111        };
3112        let js = server.jetstream().await;
3113        let old_subject = "avenars.v1.b.src.ch08";
3114        let new_subject = "avenars.s.b.src.live.ch08";
3115        let stream = js
3116            .create_stream(jetstream::stream::Config {
3117                name: "rename".into(),
3118                subjects: vec!["avenars.v1.b.src.*".into(), "avenars.s.b.src.live.*".into()],
3119                ..Default::default()
3120            })
3121            .await
3122            .expect("create stream");
3123        let base = "archiver-test-8-current";
3124        stream
3125            .create_consumer(archiver_consumer_config(base, old_subject, 300))
3126            .await
3127            .expect("old consumer");
3128        publish_scan(&js, new_subject, 10, 0).await;
3129
3130        let root = temporary_parquet_root("subject-change");
3131        let logger = spawn_channel_logger(
3132            js.clone(),
3133            "rename".into(),
3134            base.into(),
3135            new_subject.into(),
3136            1001,
3137            8,
3138            300,
3139            CalibrationSpec::default(),
3140            root.clone(),
3141            None,
3142        )
3143        .await
3144        .expect("spawn");
3145        let alt = filtered_consumer_name(base, new_subject);
3146        wait_for_consumer(&js, "rename", &alt, |i| i.num_ack_pending == 1).await;
3147        logger.stop().await;
3148        let info = wait_for_consumer(&js, "rename", &alt, |i| i.num_ack_pending == 0).await;
3149        assert_eq!(info.config.filter_subject, new_subject);
3150        assert_eq!(info.ack_floor.stream_sequence, 1);
3151        let s = js.get_stream("rename").await.expect("stream");
3152        let old = s.consumer_info(base).await.expect("old consumer");
3153        assert_eq!(
3154            old.config.filter_subject, old_subject,
3155            "old filter untouched"
3156        );
3157        assert_eq!(archived_files(&root).len(), 1);
3158        fs::remove_dir_all(root).expect("temporary directory should be removable");
3159    }
3160
3161    #[test]
3162    fn filtered_consumer_names_are_stable_and_distinct() {
3163        let a = filtered_consumer_name("archiver-x-8-current", "avenars.a.ch08");
3164        assert_eq!(
3165            a,
3166            filtered_consumer_name("archiver-x-8-current", "avenars.a.ch08")
3167        );
3168        assert_ne!(
3169            a,
3170            filtered_consumer_name("archiver-x-8-current", "avenars.b.ch08")
3171        );
3172        assert!(a.starts_with("archiver-x-8-current-f"));
3173        assert_eq!(a.len(), "archiver-x-8-current-f".len() + 8);
3174    }
3175
3176    #[test]
3177    fn replay_guard_directory_defaults_and_can_be_turned_off() {
3178        let root = Path::new("/data/parquet");
3179        assert_eq!(
3180            replay_state_dir(None, None, root),
3181            Some(PathBuf::from("/data/parquet/.archiver-state"))
3182        );
3183        assert_eq!(
3184            replay_state_dir(Some("on"), Some("/var/lib/avena"), root),
3185            Some(PathBuf::from("/var/lib/avena"))
3186        );
3187        for off in ["off", "0", "false", "No"] {
3188            assert_eq!(replay_state_dir(Some(off), None, root), None);
3189        }
3190    }
3191
3192    /// The consumer's `ack_wait` and `max_ack_pending` leave room for an open file.
3193    #[test]
3194    fn consumer_ack_wait_outlasts_a_file_and_pending_limit_has_headroom() {
3195        let cfg = archiver_consumer_config("archiver-test", "avenars.test.ch08", 300);
3196        assert_eq!(cfg.durable_name.as_deref(), Some("archiver-test"));
3197        assert_eq!(cfg.filter_subject, "avenars.test.ch08");
3198        assert!(cfg.ack_wait > Duration::from_secs(300) + IDLE_CLOSE_AFTER + IDLE_CHECK_INTERVAL);
3199        assert!(cfg.max_ack_pending > MAX_PENDING_ACKS_PER_FILE as i64);
3200    }
3201
3202    /// Startup renames `.inprogress` files and leaves completed `.parquet` files alone.
3203    #[test]
3204    fn startup_quarantines_unfinished_without_scanning_completed_parquet() {
3205        let root = temporary_parquet_root("quarantine");
3206        let channel_dir = root.join("asset1001/2026-08-05/ch11");
3207        fs::create_dir_all(&channel_dir).expect("channel directory should be created");
3208        fs::write(channel_dir.join("part-0001.parquet.inprogress"), b"partial")
3209            .expect("unfinished file should be written");
3210        fs::write(channel_dir.join("part-0002.parquet"), b"not parquet")
3211            .expect("corrupt file should be written");
3212
3213        let quarantined = quarantine_incomplete_files(&root).expect("quarantine should succeed");
3214        assert_eq!(quarantined.len(), 1);
3215        assert!(quarantined.iter().all(|path| path.exists()));
3216        assert!(!channel_dir.join("part-0001.parquet.inprogress").exists());
3217        assert!(channel_dir.join("part-0002.parquet").exists());
3218        fs::remove_dir_all(root).expect("temporary directory should be removable");
3219    }
3220}