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}