Skip to main content

krabka_bench_driver/soak/
recovery.rs

1//! A disaster-recovery drill, judged: which records the capture had to hold,
2//! which it lost, and how long recovery took.
3//!
4//! A drill runs a contract's load against a source cluster, captures the
5//! restore inputs while the load runs, keeps the load going for the declared
6//! `post_capture_load`, and then loses the whole source cluster. A fresh
7//! cluster is restored from the capture alone, and the records are read back.
8//!
9//! The capture boundary is the instant the capture started. Every record the
10//! source acknowledged before it must be restored: [`bound_evidence`] cuts each
11//! producer's ledger there, so the reconciler reports a missing one as lost,
12//! by the same rules as a soak. A record acknowledged after the boundary may
13//! or may not be restored, which is exactly how the reconciler already treats
14//! a send whose outcome is unknown. The ones that are missing are the drill's
15//! data loss: [`PathRecovery`] counts them, and the recovery point is the age,
16//! at the loss, of the oldest acknowledged record that did not come back.
17//!
18//! [`check_recovery`] holds the measured recovery point and recovery time
19//! against the contract's [`RecoveryObjectives`].
20
21use {
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/// When one send was acknowledged.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Ack {
36    pub seq: u64,
37    pub unix_ms: i64,
38}
39
40/// One commit the group coordinator acknowledged.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct CommitAck {
43    pub unix_ms: i64,
44    /// The committed next offset of each partition the commit named.
45    pub offsets: BTreeMap<i32, i64>,
46}
47
48/// One producer's acknowledgement times.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ProducerAcks {
51    pub producer: String,
52    pub acks: Vec<Ack>,
53}
54
55/// One committing group's acknowledged commits.
56#[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/// What a drill's load recorded against the source cluster, before the loss.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct LoadRecord {
67    /// The run as a soak records it: its identity, its producers' ledgers and
68    /// its paths' measurements. Readers and commit checks come after the loss.
69    pub run: Evidence,
70    pub acks: Vec<ProducerAcks>,
71    pub commits: Vec<CommitHistory>,
72}
73
74/// The two instants a drill is measured from.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Boundary {
77    /// When the capture started: the capture boundary.
78    pub capture_started_unix_ms: i64,
79    /// When the source cluster was lost.
80    pub disaster_unix_ms: i64,
81}
82
83/// How one path came through the loss.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PathRecovery {
86    pub path: String,
87    pub producer: String,
88    /// Records the source acknowledged, before the loss.
89    pub acknowledged: u64,
90    /// Of those, the ones acknowledged before the capture boundary.
91    pub before_boundary: u64,
92    /// Records acknowledged after the boundary that no reader of the restored
93    /// cluster found.
94    pub lost_beyond_boundary: SeqSet,
95    /// When the oldest acknowledged record that no reader found was
96    /// acknowledged. `None` when every acknowledged record came back.
97    pub oldest_loss_unix_ms: Option<i64>,
98}
99
100/// The recovery half of a drill's evidence.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct RecoveryEvidence {
103    pub capture_started_unix_ms: i64,
104    pub disaster_unix_ms: i64,
105    /// When the restored cluster first served authenticated produce and
106    /// consume of the captured data. `None` when it never did.
107    pub serving_unix_ms: Option<i64>,
108    pub paths: Vec<PathRecovery>,
109}
110
111/// The records of `acks` acknowledged at or before `instant`.
112#[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/// The last commit of each partition acknowledged at or before `instant`.
121#[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
130/// The records every one of `readers` found. A record counts as restored only
131/// when each declared reader read it.
132fn 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/// How `ledger`'s path came through the loss, given when each of its sends was
143/// acknowledged and what the restored cluster's readers found.
144#[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/// What the restored cluster served, after the loss.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct RestoredReadings {
172    pub readers: Vec<ReaderLedger>,
173    /// Committed-offset checks of the restored cluster, each against the last
174    /// commit acknowledged before the capture boundary.
175    pub commits: Vec<CommitCheck>,
176    pub serving_unix_ms: Option<i64>,
177    pub ended_unix_ms: i64,
178}
179
180/// The evidence the judge grades a drill by.
181///
182/// Each producer's ledger keeps only the acknowledgements from before the
183/// capture boundary, so the reconciler requires exactly those to be restored
184/// and permits the rest either way. The recovery section records what was lost
185/// beyond the boundary.
186#[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/// A drill's recovery point and recovery time, held against its objectives.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct RecoveryVerdict {
233    pub objectives: RecoveryObjectives,
234    /// The age at the loss, in milliseconds, of the oldest acknowledged
235    /// record that did not come back. Zero when every acknowledged record came
236    /// back.
237    pub rpo_ms: i64,
238    /// Milliseconds from the loss until the restored cluster served. `None`
239    /// when it never did.
240    pub rto_ms: Option<i64>,
241    /// Records acknowledged after the capture boundary and not restored.
242    pub lost_beyond_boundary: u64,
243    /// Every way the drill missed its objectives or its evidence falls short.
244    pub problems: Vec<String>,
245}
246
247fn human(time: Time) -> String { time.human().to_string() }
248
249/// Judge a drill's recovery against the contract's objectives. `None` for a
250/// contract that is not a drill.
251#[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                // The load must keep writing at its floor between the capture and
291                // the loss, or there is no tail for the recovery point to measure.
292                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;