Skip to main content

dedupe/
dedupe.rs

1//! Offline tool that removes duplicate samples from archived channel folders.
2//!
3//! When the archiver is fed the same JetStream messages again, it writes the same
4//! samples a second time into new part files. Folders written that way hold two or more
5//! copies of every sample, sometimes split at different window boundaries, and the
6//! exporter returns every copy. This tool rewrites each affected channel folder so that
7//! every distinct sample is stored once, in one file per aligned rotation window, the
8//! same layout the archiver writes today. It does not talk to NATS.
9//!
10//! # Usage
11//!
12//! ```text
13//! dedupe <parquet_root> [--dry-run] [--rotate-secs N]
14//! ```
15//!
16//! * `<parquet_root>` - Archive root holding `asset<NNN>/<YYYY-MM-DD>/ch<NN>/`
17//!   folders. The first argument that does not start with `--` is used. Required.
18//! * `--dry-run` - Build and verify each new folder, then delete it and keep the
19//!   original. Reports what a real run would change.
20//! * `--rotate-secs N` - Window length in seconds (default 300). Must match the
21//!   archiver's `rotate_secs`.
22//!
23//! # Design
24//!
25//! A sample is a `(timestamp_unix_ns, value)` pair plus the file's key-value metadata.
26//! Two samples are duplicates only when all three match exactly, with values compared
27//! by their bit patterns. Samples with the same timestamp but different values are all
28//! kept and counted as conflicts.
29//!
30//! One exception crosses metadata: when the same sample is stored both under an
31//! identity (or missing) calibration and under a real one, only the calibrated copy is
32//! kept. An identity calibration means no sensor had been configured yet, and the raw
33//! value is the same in both copies, so the identity copy carries no extra information.
34//! A sample stored only under identity is kept.
35//!
36//! Each channel folder is handled on its own:
37//!
38//! 1. Every part file is read once to find its metadata and which windows its rows
39//!    fall in.
40//! 2. Window by window, the rows of every file touching that window are merged, sorted,
41//!    and exact duplicates dropped. The result is written to a staging folder
42//!    `ch<NN>.dedupe-tmp` next to the original, one file per window (and per distinct
43//!    metadata, if a folder mixes calibrations). Each file is read back and compared
44//!    with what was meant to be written.
45//! 3. An independent check then reads every original file again and confirms each of
46//!    its rows is present in the staged file for its window.
47//! 4. The original folder is renamed to `ch<NN>.dedupe-old`, the staging folder takes
48//!    its name, the day folder is synced, and the old folder is deleted.
49//!
50//! The exporter only reads `ch<NN>` folders, so it never sees the staging or old
51//! folders. Folders for today and yesterday (UTC) are skipped because the archiver may
52//! still be writing to them. Folders holding anything other than finished part files
53//! (such as `.inprogress` or quarantined files) are skipped, as are folders that have
54//! no duplicate rows and already hold one file per window. A rerun resumes safely: a
55//! leftover staging folder is deleted, and a leftover old folder is either deleted
56//! (swap finished) or renamed back (swap interrupted).
57
58use std::{
59    collections::{BTreeMap, BTreeSet, HashMap, HashSet, hash_map::Entry},
60    fs,
61    path::{Path, PathBuf},
62};
63
64use anyhow::{Context, Result, anyhow, bail};
65use chrono::{Duration, NaiveDate, Utc};
66use parquet::file::{metadata::KeyValue, reader::SerializedFileReader};
67
68mod archive_format;
69mod archive_io;
70
71use archive_io::{Archive, read_archive, write_archive};
72
73/// Suffix of the staging folder that holds the rewritten files before the swap.
74const STAGING_SUFFIX: &str = ".dedupe-tmp";
75/// Suffix the original folder is renamed to while the staging folder takes its place.
76const OLD_SUFFIX: &str = ".dedupe-old";
77
78/// One sample, keyed by metadata group so different calibrations never merge.
79///
80/// Fields are the metadata group index, the timestamp in Unix nanoseconds and the
81/// value's bit pattern (`f64::to_bits`).
82type Row = (usize, i64, u64);
83
84/// What was learned about one original part file in the first pass.
85struct PartInfo {
86    /// Path of the part file.
87    path: PathBuf,
88    /// Index into the folder's list of distinct key-value metadata.
89    group: usize,
90    /// Windows (see [`window_of`]) that at least one of the file's rows falls in.
91    windows: BTreeSet<i64>,
92}
93
94/// Counts for one channel folder.
95#[derive(Debug, Default, PartialEq, Eq)]
96struct FolderStats {
97    /// Part files in the original folder, including empty ones.
98    files_before: usize,
99    /// Part files written to the new folder.
100    files_after: usize,
101    /// Rows across all original files.
102    rows_before: usize,
103    /// Distinct rows kept.
104    rows_after: usize,
105    /// Kept rows that share a timestamp with a different value in the same group.
106    conflicts: usize,
107    /// Identity-calibration rows dropped because a calibrated copy of the same sample
108    /// was kept.
109    superseded: usize,
110    /// Bytes of the original part files.
111    bytes_before: u64,
112    /// Bytes of the new part files.
113    bytes_after: u64,
114}
115
116/// Result of processing one channel folder.
117#[derive(Debug, PartialEq, Eq)]
118enum Outcome {
119    /// The folder was rewritten (or, in a dry run, a verified copy was built and
120    /// deleted).
121    Rewritten(FolderStats),
122    /// The folder has no duplicate rows and already holds one file per window.
123    Clean,
124    /// The folder was left alone, for the reason given.
125    Skipped(String),
126}
127
128/// Returns the rotation window a timestamp falls in.
129///
130/// Windows start at multiples of `rotate_secs` since the Unix epoch, as in the
131/// archiver.
132///
133/// # Arguments
134///
135/// * `timestamp_unix_ns` - Sample time in Unix nanoseconds.
136/// * `rotate_secs` - Window length in seconds. `0` is treated as `1`.
137fn window_of(timestamp_unix_ns: i64, rotate_secs: u64) -> i64 {
138    let rotate_ns = (rotate_secs.max(1) as i64).saturating_mul(1_000_000_000);
139    timestamp_unix_ns.div_euclid(rotate_ns)
140}
141
142/// Returns the metadata as comparable `(key, value)` pairs, in order.
143///
144/// # Arguments
145///
146/// * `metadata` - File-level key-value metadata.
147fn metadata_key(metadata: &[KeyValue]) -> Vec<(String, Option<String>)> {
148    metadata
149        .iter()
150        .map(|kv| (kv.key.clone(), kv.value.clone()))
151        .collect()
152}
153
154/// Returns whether the metadata has no real calibration.
155///
156/// True when there is no `calibration` entry, when it cannot be parsed, or when its
157/// `type` is `identity`.
158///
159/// # Arguments
160///
161/// * `metadata` - File-level key-value metadata.
162fn is_identity(metadata: &[KeyValue]) -> bool {
163    let Some(json) = metadata
164        .iter()
165        .find(|kv| kv.key == "calibration")
166        .and_then(|kv| kv.value.as_deref())
167    else {
168        return true;
169    };
170    serde_json::from_str::<serde_json::Value>(json)
171        .ok()
172        .and_then(|v| {
173            v.get("type")
174                .and_then(|t| t.as_str())
175                .map(|t| t == "identity")
176        })
177        .unwrap_or(true)
178}
179
180/// Removes identity-calibration rows whose sample is also kept under a real
181/// calibration.
182///
183/// # Arguments
184///
185/// * `rows` - Sorted, deduplicated rows of one window; changed in place.
186/// * `identity` - For each metadata group, whether it has no real calibration.
187///
188/// # Returns
189///
190/// The number of rows removed.
191fn drop_superseded(rows: &mut Vec<Row>, identity: &[bool]) -> usize {
192    let calibrated: HashSet<(i64, u64)> = rows
193        .iter()
194        .filter(|r| !identity[r.0])
195        .map(|r| (r.1, r.2))
196        .collect();
197    if calibrated.is_empty() {
198        return 0;
199    }
200    let before = rows.len();
201    rows.retain(|r| !(identity[r.0] && calibrated.contains(&(r.1, r.2))));
202    before - rows.len()
203}
204
205/// Reads a whole part file.
206///
207/// # Arguments
208///
209/// * `path` - Part file to read.
210///
211/// # Errors
212///
213/// Returns an error if the file cannot be opened or read.
214fn read_part(path: &Path) -> Result<Archive> {
215    let reader = SerializedFileReader::new(fs::File::open(path)?)
216        .with_context(|| format!("opening {}", path.display()))?;
217    read_archive(&reader).with_context(|| format!("reading {}", path.display()))
218}
219
220/// Returns the rows of `archive` that fall in `window`, tagged with `group`.
221///
222/// # Arguments
223///
224/// * `archive` - Contents of one part file.
225/// * `group` - Metadata group of the file.
226/// * `window` - Window to select.
227/// * `rotate_secs` - Window length in seconds.
228fn rows_in_window(archive: &Archive, group: usize, window: i64, rotate_secs: u64) -> Vec<Row> {
229    archive
230        .timestamps
231        .iter()
232        .zip(&archive.values)
233        .filter(|(ts, _)| window_of(**ts, rotate_secs) == window)
234        .map(|(ts, v)| (group, *ts, v.to_bits()))
235        .collect()
236}
237
238/// Loads part files on demand and drops them once no later window needs them.
239struct PartCache<'a> {
240    /// First-pass information for every non-empty part file.
241    parts: &'a [PartInfo],
242    /// Loaded contents, by index into `parts`.
243    loaded: HashMap<usize, Archive>,
244}
245
246impl<'a> PartCache<'a> {
247    /// Creates an empty cache over `parts`.
248    fn new(parts: &'a [PartInfo]) -> Self {
249        Self {
250            parts,
251            loaded: HashMap::new(),
252        }
253    }
254
255    /// Returns every row of every part file in `window`, tagged with its group.
256    ///
257    /// Files are loaded the first time a window needs them and dropped once their
258    /// last window has been served, so memory holds only the files around the
259    /// current window.
260    ///
261    /// # Arguments
262    ///
263    /// * `window` - Window to collect. Windows must be requested in increasing order.
264    /// * `rotate_secs` - Window length in seconds.
265    ///
266    /// # Returns
267    ///
268    /// The rows (unsorted, duplicates included) and, per source file, the rows that
269    /// file contributed, for the independent check.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if a file cannot be read.
274    fn window_rows(&mut self, window: i64, rotate_secs: u64) -> Result<Vec<(usize, Vec<Row>)>> {
275        let mut out = Vec::new();
276        for (i, part) in self.parts.iter().enumerate() {
277            if !part.windows.contains(&window) {
278                continue;
279            }
280            let archive = match self.loaded.entry(i) {
281                Entry::Occupied(entry) => entry.into_mut(),
282                Entry::Vacant(entry) => entry.insert(read_part(&part.path)?),
283            };
284            out.push((i, rows_in_window(archive, part.group, window, rotate_secs)));
285            if part.windows.last() == Some(&window) {
286                self.loaded.remove(&i);
287            }
288        }
289        Ok(out)
290    }
291}
292
293/// Sorts rows and removes exact duplicates.
294///
295/// # Arguments
296///
297/// * `rows` - Rows to deduplicate; sorted and deduplicated in place.
298///
299/// # Returns
300///
301/// The number of kept rows that share their group and timestamp with another kept
302/// row (a different value at the same time).
303fn sort_unique(rows: &mut Vec<Row>) -> usize {
304    rows.sort_unstable();
305    rows.dedup();
306    let mut conflicts = 0;
307    for pair in rows.windows(2) {
308        if pair[0].0 == pair[1].0 && pair[0].1 == pair[1].1 {
309            conflicts += 1;
310        }
311    }
312    conflicts
313}
314
315/// Returns whether a folder is already clean: one file per window, no empty files, and
316/// no file holding the same row twice.
317///
318/// Files covering different single windows cannot share rows, so this proves there
319/// are no duplicates without merging anything.
320///
321/// # Arguments
322///
323/// * `parts` - First-pass information for the non-empty part files.
324/// * `empty_files` - Part files with no rows.
325/// * `rows_repeated_within_files` - Rows that repeat another row of the same file.
326fn is_clean(parts: &[PartInfo], empty_files: usize, rows_repeated_within_files: usize) -> bool {
327    if empty_files > 0 || rows_repeated_within_files > 0 {
328        return false;
329    }
330    let mut seen = HashSet::new();
331    parts
332        .iter()
333        .all(|p| p.windows.len() == 1 && seen.insert(*p.windows.first().unwrap()))
334}
335
336/// Restores a channel folder left half-done by an interrupted run.
337///
338/// A leftover staging folder is deleted. A leftover old folder is deleted when the
339/// channel folder exists (the swap finished), or renamed back when it does not (the
340/// swap was interrupted between the two renames).
341///
342/// # Arguments
343///
344/// * `channel_dir` - The `ch<NN>` folder.
345///
346/// # Errors
347///
348/// Returns an error if a folder cannot be removed or renamed.
349fn recover(channel_dir: &Path) -> Result<()> {
350    let staging = suffixed(channel_dir, STAGING_SUFFIX);
351    let old = suffixed(channel_dir, OLD_SUFFIX);
352    if old.exists() {
353        if channel_dir.exists() {
354            fs::remove_dir_all(&old)?;
355            println!("removed finished {}", old.display());
356        } else {
357            fs::rename(&old, channel_dir)?;
358            println!("restored {}", channel_dir.display());
359        }
360    }
361    if staging.exists() {
362        fs::remove_dir_all(&staging)?;
363        println!("removed stale {}", staging.display());
364    }
365    Ok(())
366}
367
368/// Returns `path` with `suffix` appended to its last component.
369fn suffixed(path: &Path, suffix: &str) -> PathBuf {
370    PathBuf::from(format!("{}{suffix}", path.display()))
371}
372
373/// Syncs a directory so renames and new entries in it are durable.
374fn sync_dir(dir: &Path) -> Result<()> {
375    fs::File::open(dir)?.sync_all()?;
376    Ok(())
377}
378
379/// Deduplicates one channel folder.
380///
381/// See the module documentation for the steps. The original folder is only replaced
382/// after the staged copy has passed both checks; on any error the staging folder is
383/// deleted and the original is left as it was.
384///
385/// # Arguments
386///
387/// * `channel_dir` - The `ch<NN>` folder to process.
388/// * `rotate_secs` - Window length in seconds.
389/// * `dry_run` - When `true`, build and verify the new folder, then delete it.
390///
391/// # Errors
392///
393/// Returns an error if a file cannot be read or written, a check fails, or the swap
394/// fails.
395fn dedupe_folder(channel_dir: &Path, rotate_secs: u64, dry_run: bool) -> Result<Outcome> {
396    let mut names = Vec::new();
397    for entry in fs::read_dir(channel_dir)? {
398        let name = entry?.file_name().to_string_lossy().into_owned();
399        let is_part = name
400            .strip_prefix("part-")
401            .and_then(|n| n.strip_suffix(".parquet"))
402            .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
403        if !is_part {
404            return Ok(Outcome::Skipped(format!("holds {name}")));
405        }
406        names.push(name);
407    }
408    names.sort();
409    if names.is_empty() {
410        return Ok(Outcome::Skipped("no part files".into()));
411    }
412
413    // Pass 1: metadata group and windows of every file.
414    let mut stats = FolderStats {
415        files_before: names.len(),
416        ..Default::default()
417    };
418    let mut groups: Vec<Vec<KeyValue>> = Vec::new();
419    let mut parts = Vec::new();
420    let mut empty_files = 0;
421    let mut repeated = 0;
422    for name in &names {
423        let path = channel_dir.join(name);
424        stats.bytes_before += fs::metadata(&path)?.len();
425        let archive = read_part(&path)?;
426        if archive.timestamps.is_empty() {
427            empty_files += 1;
428            continue;
429        }
430        stats.rows_before += archive.timestamps.len();
431        let key = metadata_key(&archive.metadata);
432        let group = match groups.iter().position(|g| metadata_key(g) == key) {
433            Some(g) => g,
434            None => {
435                groups.push(archive.metadata.clone());
436                groups.len() - 1
437            }
438        };
439        let mut rows: Vec<(i64, u64)> = archive
440            .timestamps
441            .iter()
442            .zip(&archive.values)
443            .map(|(ts, v)| (*ts, v.to_bits()))
444            .collect();
445        rows.sort_unstable();
446        rows.dedup();
447        repeated += archive.timestamps.len() - rows.len();
448        let windows = archive
449            .timestamps
450            .iter()
451            .map(|ts| window_of(*ts, rotate_secs))
452            .collect();
453        parts.push(PartInfo {
454            path,
455            group,
456            windows,
457        });
458    }
459    if parts.is_empty() {
460        return Ok(Outcome::Skipped("only empty part files".into()));
461    }
462    if is_clean(&parts, empty_files, repeated) {
463        return Ok(Outcome::Clean);
464    }
465    let identity: Vec<bool> = groups.iter().map(|g| is_identity(g)).collect();
466    let all_windows: BTreeSet<i64> = parts
467        .iter()
468        .flat_map(|p| p.windows.iter().copied())
469        .collect();
470
471    // Pass 2: build the staged folder window by window.
472    let staging = suffixed(channel_dir, STAGING_SUFFIX);
473    fs::create_dir(&staging).with_context(|| format!("creating {}", staging.display()))?;
474    let result = (|| -> Result<()> {
475        let mut cache = PartCache::new(&parts);
476        let mut staged: BTreeMap<(i64, usize), PathBuf> = BTreeMap::new();
477        for &window in &all_windows {
478            let mut rows: Vec<Row> = cache
479                .window_rows(window, rotate_secs)?
480                .into_iter()
481                .flat_map(|(_, r)| r)
482                .collect();
483            stats.conflicts += sort_unique(&mut rows);
484            stats.superseded += drop_superseded(&mut rows, &identity);
485            stats.rows_after += rows.len();
486            for (group, metadata) in groups.iter().enumerate() {
487                let archive = Archive {
488                    timestamps: rows.iter().filter(|r| r.0 == group).map(|r| r.1).collect(),
489                    values: rows
490                        .iter()
491                        .filter(|r| r.0 == group)
492                        .map(|r| f64::from_bits(r.2))
493                        .collect(),
494                    metadata: metadata.clone(),
495                };
496                if archive.timestamps.is_empty() {
497                    continue;
498                }
499                let path = staging.join(format!("part-{:04}.parquet", staged.len() + 1));
500                write_archive(&path, &archive)?;
501                let back = read_part(&path)?;
502                if back.timestamps != archive.timestamps
503                    || back.values.len() != archive.values.len()
504                    || back
505                        .values
506                        .iter()
507                        .zip(&archive.values)
508                        .any(|(a, b)| a.to_bits() != b.to_bits())
509                    || metadata_key(&back.metadata) != metadata_key(metadata)
510                {
511                    bail!("{} does not read back as written", path.display());
512                }
513                stats.bytes_after += fs::metadata(&path)?.len();
514                staged.insert((window, group), path);
515            }
516        }
517        stats.files_after = staged.len();
518
519        // Pass 3: every original row must be in the staged file for its window, or, for
520        // an identity row, its sample must be in a calibrated staged file.
521        let mut cache = PartCache::new(&parts);
522        let mut checked = 0usize;
523        for &window in &all_windows {
524            let mut present: HashSet<Row> = HashSet::new();
525            let mut calibrated: HashSet<(i64, u64)> = HashSet::new();
526            for (group, &group_is_identity) in identity.iter().enumerate() {
527                if let Some(path) = staged.get(&(window, group)) {
528                    let back = read_part(path)?;
529                    if back
530                        .timestamps
531                        .iter()
532                        .any(|ts| window_of(*ts, rotate_secs) != window)
533                    {
534                        bail!("{} holds rows outside its window", path.display());
535                    }
536                    for (ts, v) in back.timestamps.iter().zip(&back.values) {
537                        present.insert((group, *ts, v.to_bits()));
538                        if !group_is_identity {
539                            calibrated.insert((*ts, v.to_bits()));
540                        }
541                    }
542                }
543            }
544            for (i, rows) in cache.window_rows(window, rotate_secs)? {
545                if let Some(missing) = rows.iter().find(|r| {
546                    !(present.contains(r) || identity[r.0] && calibrated.contains(&(r.1, r.2)))
547                }) {
548                    bail!(
549                        "row at {} ns from {} is missing from the new folder",
550                        missing.1,
551                        parts[i].path.display()
552                    );
553                }
554                checked += rows.len();
555            }
556        }
557        if checked != stats.rows_before {
558            bail!(
559                "checked {checked} original rows, expected {}",
560                stats.rows_before
561            );
562        }
563        sync_dir(&staging)
564    })();
565    if let Err(err) = result {
566        let _ = fs::remove_dir_all(&staging);
567        return Err(err);
568    }
569
570    if dry_run {
571        fs::remove_dir_all(&staging)?;
572        return Ok(Outcome::Rewritten(stats));
573    }
574
575    let old = suffixed(channel_dir, OLD_SUFFIX);
576    let day_dir = channel_dir
577        .parent()
578        .ok_or_else(|| anyhow!("{} has no parent", channel_dir.display()))?;
579    fs::rename(channel_dir, &old)?;
580    fs::rename(&staging, channel_dir)?;
581    sync_dir(day_dir)?;
582    fs::remove_dir_all(&old)?;
583    sync_dir(day_dir)?;
584    Ok(Outcome::Rewritten(stats))
585}
586
587/// Lists `asset<NNN>/<YYYY-MM-DD>/ch<NN>` folders under `root` older than `cutoff`.
588///
589/// Leftovers from an interrupted run are cleaned up with [`recover`] on the way.
590///
591/// # Arguments
592///
593/// * `root` - Archive root.
594/// * `cutoff` - Days on or after this date are not listed.
595///
596/// # Errors
597///
598/// Returns an error if a folder cannot be listed or recovered.
599fn channel_folders(root: &Path, cutoff: NaiveDate) -> Result<Vec<PathBuf>> {
600    let mut out = Vec::new();
601    for asset in fs::read_dir(root).with_context(|| format!("reading {}", root.display()))? {
602        let asset = asset?.path();
603        if !asset.is_dir()
604            || !asset
605                .file_name()
606                .is_some_and(|n| n.to_string_lossy().starts_with("asset"))
607        {
608            continue;
609        }
610        for day in fs::read_dir(&asset)? {
611            let day = day?.path();
612            let Some(date) = day
613                .file_name()
614                .and_then(|n| NaiveDate::parse_from_str(&n.to_string_lossy(), "%Y-%m-%d").ok())
615            else {
616                continue;
617            };
618            if date >= cutoff || !day.is_dir() {
619                continue;
620            }
621            let mut channels = BTreeSet::new();
622            for entry in fs::read_dir(&day)? {
623                let name = entry?.file_name().to_string_lossy().into_owned();
624                let base = name
625                    .strip_suffix(STAGING_SUFFIX)
626                    .or_else(|| name.strip_suffix(OLD_SUFFIX))
627                    .unwrap_or(&name);
628                if base.starts_with("ch") {
629                    channels.insert(day.join(base));
630                }
631            }
632            for channel_dir in channels {
633                recover(&channel_dir)?;
634                if channel_dir.is_dir() {
635                    out.push(channel_dir);
636                }
637            }
638        }
639    }
640    out.sort();
641    Ok(out)
642}
643
644/// Parses the command line, deduplicates every eligible channel folder and prints a
645/// summary.
646///
647/// # Errors
648///
649/// Returns an error if no `<parquet_root>` is given, `--rotate-secs` is not a number,
650/// scanning fails, or any folder failed (failed folders are left untouched).
651fn main() -> Result<()> {
652    let args: Vec<String> = std::env::args().skip(1).collect();
653    let usage = "usage: dedupe <parquet_root> [--dry-run] [--rotate-secs N]";
654    let mut root = None;
655    let mut rotate_secs = 300u64;
656    let mut dry_run = false;
657    let mut iter = args.iter();
658    while let Some(arg) = iter.next() {
659        match arg.as_str() {
660            "--dry-run" => dry_run = true,
661            "--rotate-secs" => {
662                rotate_secs = iter
663                    .next()
664                    .ok_or_else(|| anyhow!(usage))?
665                    .parse()
666                    .context("--rotate-secs")?
667            }
668            a if !a.starts_with("--") && root.is_none() => root = Some(PathBuf::from(a)),
669            _ => {}
670        }
671    }
672    let root = root.ok_or_else(|| anyhow!(usage))?;
673    let cutoff = Utc::now().date_naive() - Duration::days(1);
674    let folders = channel_folders(&root, cutoff)?;
675    println!(
676        "{} channel folders before {cutoff} under {}{}",
677        folders.len(),
678        root.display(),
679        if dry_run { " (dry run)" } else { "" }
680    );
681
682    let mut total = FolderStats::default();
683    let (mut rewritten, mut clean, mut skipped, mut failed) = (0usize, 0usize, 0usize, 0usize);
684    for folder in &folders {
685        match dedupe_folder(folder, rotate_secs, dry_run) {
686            Ok(Outcome::Rewritten(s)) => {
687                rewritten += 1;
688                println!(
689                    "{}: {} files -> {}, {} rows -> {}{}{}, {:.1} MB -> {:.1} MB",
690                    folder.display(),
691                    s.files_before,
692                    s.files_after,
693                    s.rows_before,
694                    s.rows_after,
695                    if s.superseded > 0 {
696                        format!(" ({} identity copies dropped)", s.superseded)
697                    } else {
698                        String::new()
699                    },
700                    if s.conflicts > 0 {
701                        format!(" ({} conflicting timestamps kept)", s.conflicts)
702                    } else {
703                        String::new()
704                    },
705                    s.bytes_before as f64 / 1e6,
706                    s.bytes_after as f64 / 1e6
707                );
708                total.files_before += s.files_before;
709                total.files_after += s.files_after;
710                total.rows_before += s.rows_before;
711                total.rows_after += s.rows_after;
712                total.conflicts += s.conflicts;
713                total.superseded += s.superseded;
714                total.bytes_before += s.bytes_before;
715                total.bytes_after += s.bytes_after;
716            }
717            Ok(Outcome::Clean) => clean += 1,
718            Ok(Outcome::Skipped(why)) => {
719                skipped += 1;
720                println!("skipped {}: {why}", folder.display());
721            }
722            Err(err) => {
723                failed += 1;
724                eprintln!("FAILED {}: {err:#}", folder.display());
725            }
726        }
727    }
728    println!(
729        "done: rewritten {rewritten} folders ({} files -> {}, {} rows -> {}, {} identity copies dropped, {} conflicts, {:.2} GB -> {:.2} GB), clean {clean}, skipped {skipped}, failed {failed}",
730        total.files_before,
731        total.files_after,
732        total.rows_before,
733        total.rows_after,
734        total.superseded,
735        total.conflicts,
736        total.bytes_before as f64 / 1e9,
737        total.bytes_after as f64 / 1e9
738    );
739    if failed > 0 {
740        bail!("{failed} folder(s) failed; they were left untouched");
741    }
742    Ok(())
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    const BASE: i64 = 1_789_430_400_000_000_000; // 2026-09-15T00:00:00Z
750    const STEP: i64 = 10_000_000; // 100 Hz
751
752    fn calibration(text: &str) -> Vec<KeyValue> {
753        vec![KeyValue::new("calibration".to_string(), text.to_string())]
754    }
755
756    fn write_part(dir: &Path, index: usize, rows: &[(i64, f64)], metadata: Vec<KeyValue>) {
757        let archive = Archive {
758            timestamps: rows.iter().map(|r| r.0).collect(),
759            values: rows.iter().map(|r| r.1).collect(),
760            metadata,
761        };
762        write_archive(&dir.join(format!("part-{index:04}.parquet")), &archive).unwrap();
763    }
764
765    fn samples(from: i64, count: i64) -> Vec<(i64, f64)> {
766        (from..from + count)
767            .map(|k| (BASE + k * STEP, ((k * 7919) % 97) as f64 * 0.01))
768            .collect()
769    }
770
771    fn read_folder(dir: &Path) -> Vec<(String, Archive)> {
772        let mut names: Vec<String> = fs::read_dir(dir)
773            .unwrap()
774            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
775            .collect();
776        names.sort();
777        names
778            .into_iter()
779            .map(|n| {
780                let a = read_part(&dir.join(&n)).unwrap();
781                (n, a)
782            })
783            .collect()
784    }
785
786    fn temp_channel(tag: &str) -> (PathBuf, PathBuf) {
787        let root = std::env::temp_dir().join(format!("dedupe-{tag}-{}", uuid::Uuid::new_v4()));
788        let dir = root.join("asset1001/2026-09-15/ch08");
789        fs::create_dir_all(&dir).unwrap();
790        (root, dir)
791    }
792
793    /// Two full copies, one of them split at different boundaries, plus an empty
794    /// file, collapse to one aligned file per window holding each sample once.
795    #[test]
796    fn duplicate_copies_collapse_to_one_file_per_window() {
797        let (root, dir) = temp_channel("collapse");
798        let cal = calibration(r#"{"type":"linear","a":1.5,"b":0.1}"#);
799        // Copy A: unaligned, like the old archiver (1093 rows, then 30000-row files).
800        write_part(&dir, 1, &samples(0, 1093), cal.clone());
801        write_part(&dir, 2, &samples(1093, 30000), cal.clone());
802        write_part(&dir, 3, &samples(31093, 28907), cal.clone());
803        // Copy B: aligned, written twice.
804        write_part(&dir, 4, &samples(0, 30000), cal.clone());
805        write_part(&dir, 5, &samples(30000, 30000), cal.clone());
806        write_part(&dir, 6, &samples(0, 30000), cal.clone());
807        write_part(&dir, 7, &samples(30000, 30000), cal.clone());
808        write_part(&dir, 8, &[], cal.clone());
809
810        let dry = dedupe_folder(&dir, 300, true).unwrap();
811        assert!(matches!(
812            dry,
813            Outcome::Rewritten(FolderStats {
814                rows_after: 60000,
815                ..
816            })
817        ));
818        assert_eq!(read_folder(&dir).len(), 8, "dry run keeps the original");
819
820        let Outcome::Rewritten(stats) = dedupe_folder(&dir, 300, false).unwrap() else {
821            panic!("expected a rewrite");
822        };
823        assert_eq!(stats.files_before, 8);
824        assert_eq!(stats.files_after, 2);
825        assert_eq!(stats.rows_before, 180000);
826        assert_eq!(stats.rows_after, 60000);
827        assert_eq!(stats.conflicts, 0);
828
829        let files = read_folder(&dir);
830        assert_eq!(files.len(), 2);
831        let expected = samples(0, 60000);
832        let mut got = Vec::new();
833        for (i, (name, a)) in files.iter().enumerate() {
834            assert_eq!(name, &format!("part-{:04}.parquet", i + 1));
835            assert_eq!(metadata_key(&a.metadata), metadata_key(&cal));
836            assert!(
837                a.timestamps
838                    .iter()
839                    .all(|t| window_of(*t, 300) == window_of(a.timestamps[0], 300))
840            );
841            got.extend(a.timestamps.iter().copied().zip(a.values.iter().copied()));
842        }
843        assert_eq!(got.len(), expected.len());
844        assert!(
845            got.iter()
846                .zip(&expected)
847                .all(|(g, e)| g.0 == e.0 && g.1.to_bits() == e.1.to_bits())
848        );
849        assert!(!suffixed(&dir, STAGING_SUFFIX).exists());
850        assert!(!suffixed(&dir, OLD_SUFFIX).exists());
851
852        assert_eq!(dedupe_folder(&dir, 300, false).unwrap(), Outcome::Clean);
853        fs::remove_dir_all(root).unwrap();
854    }
855
856    /// Same timestamp with a different value, NaN values, and files with different
857    /// calibrations are all kept.
858    #[test]
859    fn distinct_rows_and_calibrations_are_kept() {
860        let (root, dir) = temp_channel("distinct");
861        let a = calibration(r#"{"id":"a"}"#);
862        let b = calibration(r#"{"id":"b"}"#);
863        let mut rows = samples(0, 100);
864        rows[5].1 = f64::NAN;
865        write_part(&dir, 1, &rows, a.clone());
866        write_part(&dir, 2, &rows, a.clone());
867        let mut changed = rows.clone();
868        changed[7].1 += 1.0;
869        write_part(&dir, 3, &changed, a.clone());
870        write_part(&dir, 4, &rows, b.clone());
871
872        let Outcome::Rewritten(stats) = dedupe_folder(&dir, 300, false).unwrap() else {
873            panic!("expected a rewrite");
874        };
875        assert_eq!(stats.rows_after, 100 + 1 + 100);
876        assert_eq!(stats.conflicts, 1);
877        let files = read_folder(&dir);
878        assert_eq!(files.len(), 2);
879        assert_eq!(files[0].1.timestamps.len(), 101);
880        assert!(files[0].1.values.iter().any(|v| v.is_nan()));
881        assert_eq!(metadata_key(&files[1].1.metadata), metadata_key(&b));
882        fs::remove_dir_all(root).unwrap();
883    }
884
885    /// Folders with in-progress files are skipped, and an interrupted swap is undone.
886    #[test]
887    fn unfinished_folders_are_skipped_and_interrupted_swaps_recovered() {
888        let (root, dir) = temp_channel("recover");
889        write_part(&dir, 1, &samples(0, 10), vec![]);
890        fs::write(dir.join("part-0002.parquet.inprogress"), b"").unwrap();
891        assert!(matches!(
892            dedupe_folder(&dir, 300, false).unwrap(),
893            Outcome::Skipped(_)
894        ));
895        fs::remove_file(dir.join("part-0002.parquet.inprogress")).unwrap();
896
897        // Interrupted between the two renames: only the old folder and staging exist.
898        let old = suffixed(&dir, OLD_SUFFIX);
899        fs::rename(&dir, &old).unwrap();
900        fs::create_dir(suffixed(&dir, STAGING_SUFFIX)).unwrap();
901        let cutoff = NaiveDate::from_ymd_opt(2026, 9, 20).unwrap();
902        assert_eq!(channel_folders(&root, cutoff).unwrap(), vec![dir.clone()]);
903        assert!(dir.join("part-0001.parquet").exists());
904        assert!(!old.exists());
905        assert!(!suffixed(&dir, STAGING_SUFFIX).exists());
906
907        // Days on or after the cutoff are not listed.
908        let early = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
909        assert!(channel_folders(&root, early).unwrap().is_empty());
910        fs::remove_dir_all(root).unwrap();
911    }
912
913    /// A sample stored under identity and under a real calibration keeps only the
914    /// calibrated copy; samples stored only under identity are kept.
915    #[test]
916    fn identity_copies_give_way_to_calibrated_copies() {
917        let (root, dir) = temp_channel("identity");
918        let identity = calibration(r#"{"id":null,"type":"identity"}"#);
919        let linear = calibration(r#"{"id":"tp3586","type":"linear","a":70.1,"b":-9.1}"#);
920        write_part(&dir, 1, &samples(0, 30000), identity.clone());
921        write_part(&dir, 2, &samples(0, 20000), linear.clone());
922        write_part(&dir, 3, &samples(30000, 100), identity.clone());
923
924        let Outcome::Rewritten(stats) = dedupe_folder(&dir, 300, false).unwrap() else {
925            panic!("expected a rewrite");
926        };
927        assert_eq!(stats.superseded, 20000);
928        assert_eq!(stats.rows_after, 30100);
929        let files = read_folder(&dir);
930        let calibrated: Vec<_> = files
931            .iter()
932            .filter(|(_, a)| metadata_key(&a.metadata) == metadata_key(&linear))
933            .collect();
934        let raw: Vec<_> = files
935            .iter()
936            .filter(|(_, a)| metadata_key(&a.metadata) == metadata_key(&identity))
937            .collect();
938        assert_eq!(
939            calibrated
940                .iter()
941                .map(|(_, a)| a.timestamps.len())
942                .sum::<usize>(),
943            20000
944        );
945        assert_eq!(
946            raw.iter().map(|(_, a)| a.timestamps.len()).sum::<usize>(),
947            10100
948        );
949        assert!(
950            raw.iter()
951                .all(|(_, a)| a.timestamps[0] >= BASE + 20000 * STEP)
952        );
953        assert!(is_identity(&[]));
954        assert!(!is_identity(&linear));
955        fs::remove_dir_all(root).unwrap();
956    }
957}