Skip to content

Commit c5adfa7

Browse files
committed
telemetry: refuse a snapshot whose latency buckets mean something else
I wrote a version field into the persisted telemetry and never read it. That is the same failure this repository keeps producing: a fact recorded, its consequence not drawn. The consequence is silent corruption. A bucket stores only a COUNT, and its meaning lives entirely in LATENCY_BUCKET_BOUNDS_MICROS, which is not written to the file. Change those bounds and every historical count is reattributed without a word. I measured the damage rather than describing it. 100 probes recorded at 64 ms sit in the bucket whose bound is 100 ms. Under a plausible refinement of the same LENGTH, finer around 50 to 200 ms, which is exactly the region that collapsed identical percentiles on live data hours ago, that same index means 60 ms. So the daemon would report a 60 ms latency for samples that were all 64 ms. Nothing errors, because a same-length change never triggers the resize. load now refuses a snapshot whose version is not the current one and starts the counts over, saying why. Counters are not state the product depends on, so dropping them is the cheap and honest outcome; reporting a latency that never happened is not. THE REAL GUARD IS THE TEST, because the coupling is otherwise invisible. One test pins the bucket bounds AND the version together and says, if this fails you changed one of the two, change the other. I verified it fires: I made exactly the refinement above without bumping the version and watched it fail with that message. The current version still loads, which a second test asserts, so this cannot quietly discard every restart. The live file on this machine is version 1 and reloads unchanged. 204 lib tests green.
1 parent 72e2d67 commit c5adfa7

1 file changed

Lines changed: 110 additions & 2 deletions

File tree

src/telemetry.rs

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,16 +214,35 @@ pub struct TelemetryStore {
214214
}
215215

216216
impl TelemetryStore {
217-
/// Load from disk, or start empty when the file is absent or unreadable.
217+
/// Load from disk, or start empty when the file is absent, unreadable, or
218+
/// written by a different layout.
218219
///
219220
/// A corrupt telemetry file must never stop a daemon from starting. These
220221
/// are counters, not state the product depends on, so a bad read restarts
221222
/// the counts and says so.
223+
///
224+
/// The version check is not ceremony. A bucket holds a COUNT, and its
225+
/// meaning lives entirely in [`LATENCY_BUCKET_BOUNDS_MICROS`], which is not
226+
/// stored beside it. Change those bounds and every historical count is
227+
/// silently reattributed: 100 probes recorded at 64ms sit in the bucket
228+
/// whose bound is 100ms, and under a plausible refinement that same index
229+
/// means 60ms, so the daemon would report a latency that never happened.
230+
/// Nothing errors, because a same-length change never triggers a resize.
231+
/// Refusing a foreign version is what keeps that from being silent.
222232
pub fn load(path: impl Into<PathBuf>) -> Self {
223233
let path = path.into();
224234
let peers = match std::fs::read(&path) {
225235
Ok(bytes) => match serde_json::from_slice::<TelemetrySnapshot>(&bytes) {
226-
Ok(snapshot) => snapshot.peers,
236+
Ok(snapshot) if snapshot.version == SNAPSHOT_VERSION => snapshot.peers,
237+
Ok(snapshot) => {
238+
eprintln!(
239+
"fabric: telemetry at {} is version {}, expected {}; starting the counts over rather than misreading its latency buckets",
240+
path.display(),
241+
snapshot.version,
242+
SNAPSHOT_VERSION
243+
);
244+
BTreeMap::new()
245+
}
227246
Err(error) => {
228247
eprintln!(
229248
"fabric: ignoring unreadable telemetry at {}: {error}",
@@ -559,6 +578,95 @@ mod tests {
559578
);
560579
}
561580

581+
/// Changing the bucket bounds MUST bump the snapshot version.
582+
///
583+
/// This pins both together on purpose, because the coupling is otherwise
584+
/// invisible. A bucket stores only a count; its meaning lives in the bounds,
585+
/// and the bounds are not written to the file. Change them without bumping
586+
/// the version and every historical count is silently reattributed — a
587+
/// same-length change does not even trigger a resize, so nothing errors and
588+
/// the daemon reports latencies that never occurred.
589+
///
590+
/// If this test fails you changed one of the two. Change the other.
591+
#[test]
592+
fn changing_the_latency_buckets_requires_a_new_snapshot_version() {
593+
assert_eq!(
594+
LATENCY_BUCKET_BOUNDS_MICROS,
595+
[
596+
1_000, 2_000, 5_000, 10_000, 20_000, 50_000, 100_000, 200_000, 500_000, 1_000_000,
597+
2_000_000, 5_000_000, 10_000_000, 30_000_000, 60_000_000
598+
],
599+
"the latency buckets changed; bump SNAPSHOT_VERSION so old files are \
600+
discarded instead of silently reinterpreted, then update this test"
601+
);
602+
assert_eq!(
603+
SNAPSHOT_VERSION, 1,
604+
"the snapshot version changed; confirm the bucket bounds above still \
605+
match what that version means"
606+
);
607+
}
608+
609+
/// A file from another layout is discarded, not misread.
610+
#[test]
611+
fn a_foreign_version_starts_clean_rather_than_reinterpreting_buckets() {
612+
let dir = tempfile::tempdir().expect("tempdir");
613+
let path = dir.path().join("telemetry.json");
614+
615+
// A well-formed file that a future layout could plausibly have written:
616+
// the counts are real, but the bounds that gave them meaning are gone.
617+
let store = TelemetryStore::load(&path);
618+
store.record_probe(
619+
"droppy",
620+
true,
621+
Some("relay"),
622+
Some(Duration::from_millis(64)),
623+
);
624+
let mut snapshot = store.snapshot();
625+
snapshot.version = SNAPSHOT_VERSION + 1;
626+
std::fs::write(&path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
627+
628+
let reloaded = TelemetryStore::load(&path);
629+
assert!(
630+
reloaded.peer("droppy").is_none(),
631+
"counts from an unknown layout must be dropped; keeping them would \
632+
report latencies computed against bounds that never applied"
633+
);
634+
635+
// And it still records normally afterwards, rather than staying broken.
636+
reloaded.record_probe(
637+
"droppy",
638+
true,
639+
Some("relay"),
640+
Some(Duration::from_millis(70)),
641+
);
642+
assert_eq!(
643+
reloaded.peer("droppy").unwrap().probe_latency["relay"].samples,
644+
1
645+
);
646+
}
647+
648+
#[test]
649+
fn a_matching_version_is_kept() {
650+
let dir = tempfile::tempdir().expect("tempdir");
651+
let path = dir.path().join("telemetry.json");
652+
{
653+
let store = TelemetryStore::load(&path);
654+
store.record_probe(
655+
"hetz",
656+
true,
657+
Some("direct"),
658+
Some(Duration::from_millis(64)),
659+
);
660+
}
661+
let reloaded = TelemetryStore::load(&path);
662+
assert_eq!(
663+
reloaded.peer("hetz").map(|p| p.probes_reachable),
664+
Some(1),
665+
"the current version must survive a reload, or the check is too strict \
666+
and quietly discards every restart"
667+
);
668+
}
669+
562670
#[test]
563671
fn a_corrupt_file_starts_clean_instead_of_failing() {
564672
let dir = tempfile::tempdir().expect("tempdir");

0 commit comments

Comments
 (0)