use std::fs;
use metrics::Unit;
use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshot};
use tempfile::tempdir;
use super::{
TEMP_FILE_ATTEMPTS,
register_metrics,
replace_file_if_unchanged,
rewrite,
temporary_path,
};
const OUTCOME_LABEL: &str = "outcome";
const OUTCOMES: [&str; 3] = ["success", "failure", "unchanged"];
const REPLACE_TOTAL: &str = "mdtablefix_io_replace_total";
const REPLACE_DURATION: &str = "mdtablefix_io_replace_duration_seconds";
const COLLISIONS: &str = "mdtablefix_io_temporary_name_collisions_total";
const EXHAUSTED: &str = "mdtablefix_io_temporary_name_exhausted_total";
const CLEANUP_FAILURES: &str = "mdtablefix_io_temporary_cleanup_failures_total";
#[derive(Debug)]
enum Value {
Counter(u64),
Histogram(Vec<f64>),
}
#[derive(Debug)]
struct Recorded {
name: String,
labels: Vec<(String, String)>,
unit: Option<Unit>,
description: Option<String>,
value: Value,
}
fn recorded<T>(f: impl FnOnce() -> T) -> (T, Vec<Recorded>) {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let value = metrics::with_local_recorder(&recorder, || {
register_metrics();
f()
});
(value, metrics(snapshotter.snapshot()))
}
fn metrics(snapshot: Snapshot) -> Vec<Recorded> {
snapshot
.into_vec()
.into_iter()
.map(|(composite, unit, description, value)| {
let key = composite.key();
let labels = key
.labels()
.map(|label| (label.key().to_string(), label.value().to_string()))
.collect();
let name = key.name().to_string();
let value = match value {
DebugValue::Counter(count) => Value::Counter(count),
DebugValue::Histogram(samples) => {
Value::Histogram(samples.iter().map(|sample| sample.0).collect())
}
DebugValue::Gauge(gauge) => {
panic!("the replacement path must not emit a gauge: {gauge:?}")
}
};
Recorded {
name,
labels,
unit,
description: description.map(|text| text.to_string()),
value,
}
})
.collect()
}
fn find<'a>(recorded: &'a [Recorded], name: &str, labels: &[(&str, &str)]) -> Option<&'a Recorded> {
recorded
.iter()
.find(|metric| metric.name == name && same_labels(&metric.labels, labels))
}
fn count(recorded: &[Recorded], name: &str, labels: &[(&str, &str)]) -> u64 {
match find(recorded, name, labels) {
Some(Recorded {
value: Value::Counter(count),
..
}) => *count,
Some(other) => panic!("{name} must be a counter, found {other:?}"),
None => 0,
}
}
fn samples<'a>(recorded: &'a [Recorded], name: &str, labels: &[(&str, &str)]) -> &'a [f64] {
match find(recorded, name, labels) {
Some(Recorded {
value: Value::Histogram(samples),
..
}) => samples,
Some(other) => panic!("{name} must be a histogram, found {other:?}"),
None => &[],
}
}
fn is_described(recorded: &[Recorded], name: &str, labels: &[(&str, &str)]) -> bool {
find(recorded, name, labels)
.and_then(|metric| metric.description.as_deref())
.is_some_and(|text| !text.is_empty())
}
fn unit(recorded: &[Recorded], name: &str) -> Option<Unit> {
recorded
.iter()
.find(|metric| metric.name == name)
.and_then(|metric| metric.unit)
}
fn outcome_count(recorded: &[Recorded], outcome: &str) -> u64 {
count(recorded, REPLACE_TOTAL, &[(OUTCOME_LABEL, outcome)])
}
fn outcome_samples(recorded: &[Recorded], outcome: &str) -> Vec<f64> {
samples(recorded, REPLACE_DURATION, &[(OUTCOME_LABEL, outcome)]).to_vec()
}
fn same_labels(recorded: &[(String, String)], expected: &[(&str, &str)]) -> bool {
recorded.len() == expected.len()
&& expected.iter().all(|(key, value)| {
recorded.iter().any(|(recorded_key, recorded_value)| {
recorded_key == key && recorded_value == value
})
})
}
fn assert_labels_are_bounded(recorded: &[Recorded]) {
for metric in recorded {
for (key, value) in &metric.labels {
assert_eq!(
*key, OUTCOME_LABEL,
"{} must not label metrics with {key}",
metric.name
);
assert!(
OUTCOMES.contains(&value.as_str()),
"{} must stay inside the declared outcome set: {value}",
metric.name
);
}
}
}
fn fixture(dir: &tempfile::TempDir) -> std::path::PathBuf {
let file = dir.path().join("sample.md");
fs::write(&file, "|A|B|\n|1|2|").expect("write fixture");
file
}
#[test]
fn successful_replacement_is_counted() {
let dir = tempdir().expect("create temporary directory");
let file = fixture(&dir);
let (result, recorded) = recorded(|| rewrite(&file));
result.expect("the rewrite must succeed");
assert_labels_are_bounded(&recorded);
assert_eq!(
outcome_count(&recorded, "success"),
1,
"a completed replacement is a success: {recorded:?}"
);
assert_eq!(
count(&recorded, COLLISIONS, &[]),
0,
"an uncontended name space records no collision: {recorded:?}"
);
assert_eq!(
count(&recorded, EXHAUSTED, &[]),
0,
"a free candidate name records no exhaustion: {recorded:?}"
);
}
#[test]
fn replacement_duration_is_recorded() {
let dir = tempdir().expect("create temporary directory");
let file = fixture(&dir);
let (result, recorded) = recorded(|| rewrite(&file));
result.expect("the rewrite must succeed");
assert_labels_are_bounded(&recorded);
assert_eq!(
unit(&recorded, REPLACE_DURATION),
Some(Unit::Seconds),
"the duration must be declared in seconds: {recorded:?}"
);
assert!(
is_described(&recorded, REPLACE_DURATION, &[(OUTCOME_LABEL, "success")]),
"the histogram must carry a description: {recorded:?}"
);
let durations = outcome_samples(&recorded, "success");
assert_eq!(
durations.len(),
1,
"a replacement records exactly one duration: {recorded:?}"
);
assert!(
durations[0] > 0.0,
"a replacement takes measurable time: {durations:?}"
);
}
#[test]
fn an_occupied_candidate_is_counted_as_a_collision() {
let dir = tempdir().expect("create temporary directory");
let file = fixture(&dir);
let occupied = temporary_path(camino::Utf8Path::new("sample.md"), 0);
fs::write(
dir.path()
.join(occupied.file_name().expect("candidate name")),
"",
)
.expect("occupy the first candidate name");
let (result, recorded) = recorded(|| rewrite(&file));
result.expect("the rewrite must retry past the occupied name");
assert_labels_are_bounded(&recorded);
assert_eq!(
count(&recorded, COLLISIONS, &[]),
1,
"one occupied candidate is one collision: {recorded:?}"
);
assert_eq!(
outcome_count(&recorded, "success"),
1,
"a retried replacement still succeeds: {recorded:?}"
);
}
#[cfg(unix)]
mod unix {
use super::*;
const SYMLINK_DECLINED: &str = "mdtablefix_io_symlink_declined_total";
#[test]
fn a_declined_symlink_is_counted() {
let dir = tempdir().expect("create temporary directory");
fixture(&dir);
let link = dir.path().join("link.md");
std::os::unix::fs::symlink("sample.md", &link).expect("create the symlink");
let (result, recorded) = recorded(|| rewrite(&link));
let error = result.expect_err("a symlink target must be declined");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_labels_are_bounded(&recorded);
assert_eq!(
count(&recorded, SYMLINK_DECLINED, &[]),
1,
"a declined symlink is counted once: {recorded:?}"
);
assert!(
is_described(&recorded, SYMLINK_DECLINED, &[]),
"the counter must carry a description: {recorded:?}"
);
assert_eq!(
outcome_count(&recorded, "failure"),
1,
"a declined symlink is a replacement that did not happen: {recorded:?}"
);
assert_eq!(
count(&recorded, CLEANUP_FAILURES, &[]),
0,
"a declined symlink never created a temporary file: {recorded:?}"
);
}
}
#[test]
fn emitted_metric_names_are_stable() {
let dir = tempdir().expect("create temporary directory");
let file = fixture(&dir);
let ((), recorded) = recorded(|| {
rewrite(&file).expect("the rewrite must succeed");
});
let mut names: Vec<&str> = recorded.iter().map(|metric| metric.name.as_str()).collect();
names.sort_unstable();
assert_eq!(
names,
vec![REPLACE_DURATION, REPLACE_TOTAL],
"only the outcome metrics are expected for an uncontended replacement"
);
}
#[cfg(test)]
#[path = "io_metrics_conditional_tests.rs"]
mod conditional_tests;
#[cfg(test)]
#[path = "io_metrics_failure_tests.rs"]
mod metrics_failure_tests;