1use 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
73const STAGING_SUFFIX: &str = ".dedupe-tmp";
75const OLD_SUFFIX: &str = ".dedupe-old";
77
78type Row = (usize, i64, u64);
83
84struct PartInfo {
86 path: PathBuf,
88 group: usize,
90 windows: BTreeSet<i64>,
92}
93
94#[derive(Debug, Default, PartialEq, Eq)]
96struct FolderStats {
97 files_before: usize,
99 files_after: usize,
101 rows_before: usize,
103 rows_after: usize,
105 conflicts: usize,
107 superseded: usize,
110 bytes_before: u64,
112 bytes_after: u64,
114}
115
116#[derive(Debug, PartialEq, Eq)]
118enum Outcome {
119 Rewritten(FolderStats),
122 Clean,
124 Skipped(String),
126}
127
128fn 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
142fn 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
154fn 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
180fn 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
205fn 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
220fn 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
238struct PartCache<'a> {
240 parts: &'a [PartInfo],
242 loaded: HashMap<usize, Archive>,
244}
245
246impl<'a> PartCache<'a> {
247 fn new(parts: &'a [PartInfo]) -> Self {
249 Self {
250 parts,
251 loaded: HashMap::new(),
252 }
253 }
254
255 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
293fn 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
315fn 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
336fn 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
368fn suffixed(path: &Path, suffix: &str) -> PathBuf {
370 PathBuf::from(format!("{}{suffix}", path.display()))
371}
372
373fn sync_dir(dir: &Path) -> Result<()> {
375 fs::File::open(dir)?.sync_all()?;
376 Ok(())
377}
378
379fn 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 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 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 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
587fn 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
644fn 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; const STEP: i64 = 10_000_000; 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 #[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 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 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 #[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 #[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 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 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 #[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}