Skip to main content

krabka_bench_driver/soak/
evidence.rs

1//! What a soak run records, in the form the verdict is computed from.
2//!
3//! The harness writes one `evidence.json` at the end of a run, and appends
4//! each fault to `faults.jsonl` as it is injected and cleared, so a run that
5//! dies mid-way still leaves its fault timeline behind. Every timestamp is
6//! wall-clock Unix milliseconds, the coordinate system the broker's own logs
7//! and metrics use, so the fault record lines up with them.
8//!
9//! Records are identified by `(producer, seq)`: each producer numbers what it
10//! sends from zero and writes the number into the record value, so a reader can
11//! say which send a record came from without trusting offsets that a failover
12//! may have reassigned.
13
14use {
15    super::{
16        contract::{Isolation, RecoveryCheck, Semantics},
17        ledger::SeqSet,
18        lifecycle::{Checkpoint, LevelRange},
19        recovery::RecoveryEvidence,
20    },
21    krabka_units::{prelude::*, serde_units},
22    serde::{Deserialize, Serialize},
23    std::{collections::BTreeMap, str},
24};
25
26/// Everything one run recorded.
27#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
28pub struct Evidence {
29    /// The contract's `name`, so evidence cannot be judged against another.
30    pub contract: String,
31    /// [`Contract::digest`](super::contract::Contract::digest) of the contract
32    /// the run executed, so evidence cannot be judged against an edited copy.
33    pub contract_digest: String,
34    pub seed: u64,
35    /// The broker image the run exercised, by digest where the runner has one.
36    pub broker_image: String,
37    pub started_unix_ms: i64,
38    pub ended_unix_ms: i64,
39    /// What the run measured its host as: CPUs, memory, free disk, the Docker
40    /// version. Recorded beside the contract's declared host so a reader can
41    /// tell a run on a smaller machine from one on the declared machine.
42    #[serde(default)]
43    pub environment: BTreeMap<String, String>,
44    pub producers: Vec<ProducerLedger>,
45    pub readers: Vec<ReaderLedger>,
46    pub commits: Vec<CommitCheck>,
47    pub faults: Vec<FaultRecord>,
48    pub recoveries: Vec<RecoveryRecord>,
49    pub paths: Vec<PathMeasurement>,
50    /// Records a self-check deleted from the cluster on purpose. Empty in a soak.
51    #[serde(default)]
52    pub injected: Vec<InjectedLoss>,
53    /// A lifecycle run's release images by side, `source` and `target`, as
54    /// `docker inspect` names them: the identity a rolled broker's image is
55    /// compared with.
56    #[serde(default)]
57    pub releases: BTreeMap<String, String>,
58    /// The feature ranges the source release advertised in `ApiVersions`
59    /// when a lifecycle run booted on it.
60    #[serde(default)]
61    pub source_supports: BTreeMap<String, LevelRange>,
62    /// What a lifecycle run found after each of its transitions.
63    #[serde(default)]
64    pub checkpoints: Vec<Checkpoint>,
65    /// A disaster-recovery drill's capture boundary, loss and recovery.
66    /// Absent from a soak.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub recovery: Option<RecoveryEvidence>,
69}
70
71/// One producer's account of what it sent.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ProducerLedger {
74    pub path: String,
75    /// Unique within the run; written into every record value.
76    pub producer: String,
77    pub semantics: Semantics,
78    /// Sequence numbers `0..attempted` were each sent at least once.
79    pub attempted: u64,
80    /// Sends the broker acknowledged.
81    pub acked: SeqSet,
82    /// Sends that returned an error at least once. Such a record may or may not
83    /// have been written.
84    pub failed: SeqSet,
85    /// Records the application sent more than once, after a failed attempt.
86    pub retried: SeqSet,
87    /// The transactions of a transactional producer, in the order begun.
88    #[serde(default)]
89    pub transactions: Vec<TxnLedger>,
90}
91
92/// One transaction and how it ended.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct TxnLedger {
95    pub id: u64,
96    pub seqs: SeqSet,
97    pub outcome: TxnOutcome,
98}
99
100/// How a transaction ended, as its producer saw it.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum TxnOutcome {
104    /// `EndTxn(commit)` was acknowledged.
105    Committed,
106    /// `EndTxn(abort)` was acknowledged, or the transaction was abandoned
107    /// before its commit was sent.
108    Aborted,
109    /// The commit was sent and its answer never arrived. The transaction may
110    /// have committed or aborted, but it must have done one of them whole.
111    Unknown,
112}
113
114/// What one reader saw of one producer's records, reading from the earliest
115/// offset after the load stopped.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct ReaderLedger {
118    pub path: String,
119    pub isolation: Isolation,
120    pub producer: String,
121    pub seen: SeqSet,
122    /// Records seen at more than one offset.
123    #[serde(default)]
124    pub duplicates: Vec<DuplicateRead>,
125    /// For a compacted path: the highest sequence number read for each key.
126    #[serde(default)]
127    pub latest_by_key: BTreeMap<u64, u64>,
128}
129
130/// One record read at more than one position.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct DuplicateRead {
133    pub seq: u64,
134    /// Every `(partition, offset)` the record was read at.
135    pub positions: Vec<(i32, i64)>,
136}
137
138/// The `fault` of the committed-offset check taken after the load stops.
139pub const FINAL_COMMIT_CHECK: &str = "final";
140
141/// A committed offset checked across a fault.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct CommitCheck {
144    pub group: String,
145    pub topic: String,
146    pub partition: i32,
147    /// The fault this check straddles, or `final` for the end of the run.
148    pub fault: String,
149    /// The last offset the coordinator acknowledged committing before the fault.
150    pub acknowledged: i64,
151    /// What `OffsetFetch` returned once the fault had recovered. `None` when it
152    /// returned nothing at all.
153    pub fetched: Option<i64>,
154    /// The partition's log end offset when `fetched` was read, or `-1` when
155    /// the executor could not read it. The judge reports a check with an
156    /// unknown log end as missing, not as an offset past the log.
157    pub log_end: i64,
158}
159
160/// A fault as it was executed.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct FaultRecord {
163    /// The scheduled occurrence's id, `<plan>#<n>`.
164    pub id: String,
165    pub kind: String,
166    /// The broker it landed on.
167    pub target: String,
168    /// What that broker was when the fault started: `controller`, `leader`,
169    /// `follower`, or a plain `broker`.
170    pub role: String,
171    /// The node id of that broker, once the target resolved.
172    #[serde(default)]
173    pub node: Option<i32>,
174    pub injected_unix_ms: i64,
175    /// When cleanup completed. `None` when it never did.
176    pub cleared_unix_ms: Option<i64>,
177    /// Why cleanup failed, when it did.
178    #[serde(default)]
179    pub cleanup_error: Option<String>,
180    /// What the fault did, for example the bytes a disk fill wrote.
181    #[serde(default)]
182    pub detail: String,
183}
184
185/// One recovery, measured after a fault.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct RecoveryRecord {
188    pub fault: String,
189    pub check: RecoveryCheck,
190    /// The instant the deadline runs from: the injection for a failover, the
191    /// cleanup for a rejoin or a catch-up.
192    pub from_unix_ms: i64,
193    /// When the recovery was observed. `None` when the harness gave up.
194    pub recovered_unix_ms: Option<i64>,
195}
196
197impl RecoveryRecord {
198    /// How long recovery took, when it was observed.
199    ///
200    /// A recovery stamped before the instant it is measured from was not
201    /// observed: a wall-clock step or a broken ledger must not read as an
202    /// instant recovery that meets every deadline.
203    #[must_use]
204    pub fn took(&self) -> Option<Time> {
205        let recovered = self.recovered_unix_ms?;
206        (recovered >= self.from_unix_ms)
207            .then(|| Time::from_millis(recovered.saturating_sub(self.from_unix_ms)))
208    }
209}
210
211/// Latency percentiles over one window kind.
212#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
213pub struct LatencySummary {
214    #[serde(with = "serde_units::numeric::nanos_i64")]
215    pub p50: Time,
216    #[serde(with = "serde_units::numeric::nanos_i64")]
217    pub p99: Time,
218    #[serde(with = "serde_units::numeric::nanos_i64")]
219    pub p999: Time,
220    pub count: u64,
221}
222
223/// One path's measured latency and throughput.
224///
225/// "Steady" is everything outside the fault windows and their recovery
226/// deadlines; "disturbed" is everything inside them. The budgets apply to the
227/// steady windows. The disturbed figures are retained as evidence.
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229pub struct PathMeasurement {
230    pub path: String,
231    pub produce_steady: LatencySummary,
232    pub produce_disturbed: LatencySummary,
233    pub consume_steady: LatencySummary,
234    pub consume_disturbed: LatencySummary,
235    /// Acknowledged records per second over the steady windows.
236    #[serde(with = "serde_units::human::frequency")]
237    pub steady_rate: Frequency,
238}
239
240/// One record a self-check deleted from the cluster.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct InjectedLoss {
243    pub path: String,
244    pub producer: String,
245    pub seq: u64,
246    pub partition: i32,
247    pub offset: i64,
248}
249
250/// The record value prefix that carries a record's identity:
251/// `<producer>/<seq>;`. The rest of the value is padding.
252#[must_use]
253pub fn record_tag(producer: &str, seq: u64) -> String { format!("{producer}/{seq};") }
254
255/// The `(producer, seq)` a record value carries, or `None` for a value that
256/// no soak producer wrote.
257#[must_use]
258pub fn parse_record_tag(value: &[u8]) -> Option<(&str, u64)> {
259    let (tag, _) = value.split_at_checked(value.iter().position(|byte| *byte == b';')?)?;
260    let tag = str::from_utf8(tag).ok()?;
261    let (producer, seq) = tag.rsplit_once('/')?;
262    Some((producer, seq.parse().ok()?))
263}
264
265#[cfg(test)]
266mod tests {
267    use {
268        super::{RecoveryRecord, parse_record_tag, record_tag},
269        crate::soak::contract::RecoveryCheck,
270        assert2::check,
271        krabka_units::prelude::*,
272    };
273
274    #[yare::parameterized(
275        plain = { b"p-0/17;xxxx", Some(("p-0", 17)) },
276        slash_in_name = { b"path/p-1/3;", Some(("path/p-1", 3)) },
277        no_terminator = { b"p-0/17xxxx", None },
278        no_number = { b"p-0/;", None },
279        foreign = { b"hello world", None },
280    )]
281    fn a_record_value_names_its_send(value: &[u8], expected: Option<(&str, u64)>) {
282        check!(parse_record_tag(value) == expected);
283    }
284
285    #[test]
286    fn a_tag_round_trips() {
287        let mut value = record_tag("classic-idempotent-0", 42).into_bytes();
288        value.extend_from_slice(b"padding");
289        check!(parse_record_tag(&value) == Some(("classic-idempotent-0", 42)));
290    }
291
292    #[yare::parameterized(
293        observed = { Some(2_500), Some(millis(1_500)) },
294        never = { None, None },
295        clock_step_back = { Some(900), None },
296        same_instant = { Some(1_000), Some(Time::ZERO) },
297    )]
298    fn a_recovery_reports_how_long_it_took(recovered: Option<i64>, took: Option<Time>) {
299        let record = RecoveryRecord {
300            fault: "f#0".into(),
301            check: RecoveryCheck::IsrRejoin,
302            from_unix_ms: 1_000,
303            recovered_unix_ms: recovered,
304        };
305        check!(record.took() == took);
306    }
307}