1use {
22 super::{
23 contract::{Contract, Profile, RecoveryObjectives},
24 evidence::{CommitCheck, Evidence, ProducerLedger, ReaderLedger},
25 ledger::SeqSet,
26 },
27 krabka_units::{fmt::Human as _, prelude::*},
28 num_traits::ToPrimitive as _,
29 serde::{Deserialize, Serialize},
30 std::collections::BTreeMap,
31};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Ack {
36 pub seq: u64,
37 pub unix_ms: i64,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct CommitAck {
43 pub unix_ms: i64,
44 pub offsets: BTreeMap<i32, i64>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ProducerAcks {
51 pub producer: String,
52 pub acks: Vec<Ack>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CommitHistory {
58 pub group: String,
59 pub topic: String,
60 pub partitions: i32,
61 pub acks: Vec<CommitAck>,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct LoadRecord {
67 pub run: Evidence,
70 pub acks: Vec<ProducerAcks>,
71 pub commits: Vec<CommitHistory>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Boundary {
77 pub capture_started_unix_ms: i64,
79 pub disaster_unix_ms: i64,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PathRecovery {
86 pub path: String,
87 pub producer: String,
88 pub acknowledged: u64,
90 pub before_boundary: u64,
92 pub lost_beyond_boundary: SeqSet,
95 pub oldest_loss_unix_ms: Option<i64>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct RecoveryEvidence {
103 pub capture_started_unix_ms: i64,
104 pub disaster_unix_ms: i64,
105 pub serving_unix_ms: Option<i64>,
108 pub paths: Vec<PathRecovery>,
109}
110
111#[must_use]
113pub fn acknowledged_by(acks: &[Ack], instant: i64) -> SeqSet {
114 acks.iter()
115 .filter(|ack| ack.unix_ms <= instant)
116 .map(|ack| ack.seq)
117 .collect()
118}
119
120#[must_use]
122pub fn committed_by(history: &[CommitAck], instant: i64) -> BTreeMap<i32, i64> {
123 let mut committed = BTreeMap::new();
124 for ack in history.iter().filter(|ack| ack.unix_ms <= instant) {
125 committed.extend(&ack.offsets);
126 }
127 committed
128}
129
130fn restored(readers: &[&ReaderLedger]) -> SeqSet {
133 let mut readers = readers.iter();
134 let Some(first) = readers.next() else {
135 return SeqSet::new();
136 };
137 readers.fold(first.seen.clone(), |found, reader| {
138 found.intersection(&reader.seen)
139 })
140}
141
142#[must_use]
145pub fn path_recovery(
146 ledger: &ProducerLedger,
147 acks: &[Ack],
148 readers: &[&ReaderLedger],
149 boundary: Boundary,
150) -> PathRecovery {
151 let before = acknowledged_by(acks, boundary.capture_started_unix_ms);
152 let found = restored(readers);
153 let missing = ledger.acked.difference(&found);
154 let oldest_loss_unix_ms = acks
155 .iter()
156 .filter(|ack| missing.contains(ack.seq))
157 .map(|ack| ack.unix_ms)
158 .min();
159 PathRecovery {
160 path: ledger.path.clone(),
161 producer: ledger.producer.clone(),
162 acknowledged: ledger.acked.len(),
163 before_boundary: before.intersection(&ledger.acked).len(),
164 lost_beyond_boundary: missing.difference(&before),
165 oldest_loss_unix_ms,
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct RestoredReadings {
172 pub readers: Vec<ReaderLedger>,
173 pub commits: Vec<CommitCheck>,
176 pub serving_unix_ms: Option<i64>,
177 pub ended_unix_ms: i64,
178}
179
180#[must_use]
187pub fn bound_evidence(
188 load: &LoadRecord,
189 boundary: Boundary,
190 restored: RestoredReadings,
191) -> Evidence {
192 let no_acks = Vec::new();
193 let acks_of = |producer: &str| {
194 load.acks
195 .iter()
196 .find(|acks| acks.producer == producer)
197 .map_or(&no_acks, |acks| &acks.acks)
198 };
199 let mut paths = Vec::new();
200 let mut producers = Vec::new();
201 for ledger in &load.run.producers {
202 let acks = acks_of(&ledger.producer);
203 let readers: Vec<&ReaderLedger> = restored
204 .readers
205 .iter()
206 .filter(|reader| reader.producer == ledger.producer)
207 .collect();
208 paths.push(path_recovery(ledger, acks, &readers, boundary));
209 let mut cut = ledger.clone();
210 cut.acked = ledger
211 .acked
212 .intersection(&acknowledged_by(acks, boundary.capture_started_unix_ms));
213 producers.push(cut);
214 }
215 Evidence {
216 ended_unix_ms: restored.ended_unix_ms,
217 producers,
218 readers: restored.readers,
219 commits: restored.commits,
220 recovery: Some(RecoveryEvidence {
221 capture_started_unix_ms: boundary.capture_started_unix_ms,
222 disaster_unix_ms: boundary.disaster_unix_ms,
223 serving_unix_ms: restored.serving_unix_ms,
224 paths,
225 }),
226 ..load.run.clone()
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct RecoveryVerdict {
233 pub objectives: RecoveryObjectives,
234 pub rpo_ms: i64,
238 pub rto_ms: Option<i64>,
241 pub lost_beyond_boundary: u64,
243 pub problems: Vec<String>,
245}
246
247fn human(time: Time) -> String { time.human().to_string() }
248
249#[must_use]
252pub fn check_recovery(contract: &Contract, evidence: &Evidence) -> Option<RecoveryVerdict> {
253 if contract.profile != Profile::DisasterRecovery {
254 return None;
255 }
256 let objectives = contract.disaster_recovery.clone()?;
257 let mut problems = Vec::new();
258 let Some(recovery) = &evidence.recovery else {
259 problems.push("the evidence records no recovery".to_owned());
260 return Some(RecoveryVerdict {
261 objectives,
262 rpo_ms: 0,
263 rto_ms: None,
264 lost_beyond_boundary: 0,
265 problems,
266 });
267 };
268 let held = recovery.disaster_unix_ms - recovery.capture_started_unix_ms;
269 if held < objectives.post_capture_load.millis_i64() {
270 problems.push(format!(
271 "the source was lost {} after the capture started, before the declared \
272 post_capture_load of {}",
273 human(Time::from_millis(held.max(0))),
274 human(objectives.post_capture_load)
275 ));
276 }
277 for path in &contract.paths {
278 match recovery
279 .paths
280 .iter()
281 .find(|measured| measured.path == path.name)
282 {
283 None => problems.push(format!("path `{}` has no recovery measurement", path.name)),
284 Some(measured) if measured.before_boundary == 0 => problems.push(format!(
285 "path `{}` acknowledged nothing before the capture boundary, so the capture \
286 proved nothing about it",
287 path.name
288 )),
289 Some(measured) => {
290 let after = measured
293 .acknowledged
294 .saturating_sub(measured.before_boundary);
295 let seconds = Time::from_millis(held.max(0)).secs_f64();
296 let rate = if seconds > 0.0 {
297 after.to_f64().unwrap_or(f64::MAX) / seconds
298 } else {
299 0.0
300 };
301 if rate < path.throughput_floor.per_sec_f64() {
302 problems.push(format!(
303 "path `{}` acknowledged {after} record(s) between the capture boundary \
304 and the loss, {rate:.1}/s against its floor of {}/s, so the loss window \
305 was not exercised",
306 path.name,
307 path.throughput_floor.per_sec_f64()
308 ));
309 }
310 }
311 }
312 }
313 let rpo = Time::from_millis(
314 recovery
315 .paths
316 .iter()
317 .filter_map(|path| path.oldest_loss_unix_ms)
318 .map(|oldest| (recovery.disaster_unix_ms - oldest).max(0))
319 .max()
320 .unwrap_or(0),
321 );
322 if rpo > objectives.rpo {
323 problems.push(format!(
324 "recovery point: the oldest lost record was acknowledged {} before the loss, past \
325 the objective of {}",
326 human(rpo),
327 human(objectives.rpo)
328 ));
329 }
330 let rto = recovery
331 .serving_unix_ms
332 .filter(|serving| *serving >= recovery.disaster_unix_ms)
333 .map(|serving| Time::from_millis(serving - recovery.disaster_unix_ms));
334 match rto {
335 None => problems.push(
336 "recovery time: the restored cluster never served authenticated produce and consume"
337 .to_owned(),
338 ),
339 Some(rto) if rto > objectives.rto => problems.push(format!(
340 "recovery time: the restored cluster served {} after the loss, past the objective \
341 of {}",
342 human(rto),
343 human(objectives.rto)
344 )),
345 Some(_) => {}
346 }
347 Some(RecoveryVerdict {
348 objectives,
349 rpo_ms: rpo.millis_i64(),
350 rto_ms: rto.map(Time::millis_i64),
351 lost_beyond_boundary: recovery
352 .paths
353 .iter()
354 .map(|path| path.lost_beyond_boundary.len())
355 .sum(),
356 problems,
357 })
358}
359
360#[cfg(test)]
361mod tests;