1use {
11 serde::{Deserialize, Serialize},
12 sha2::{Digest, Sha256},
13 std::{
14 collections::{BTreeMap, BTreeSet},
15 io,
16 },
17 thiserror::Error,
18};
19
20pub const FORMAT_VERSION: u32 = 1;
22
23pub const MINIMUM_ROUNDS: usize = 3;
27
28pub const MINIMUM_RECORD_BYTES: u32 = 16;
31
32#[derive(Debug, Error)]
34#[non_exhaustive]
35pub enum BudgetError {
36 #[error("{what} format version {found} is not {FORMAT_VERSION}")]
38 FormatVersion {
39 what: &'static str,
41 found: u32,
43 },
44
45 #[error("invalid contract: {0}")]
47 InvalidContract(String),
48
49 #[error("record was measured under contract {recorded} but evaluation was given {supplied}")]
51 ContractMismatch {
52 recorded: String,
54 supplied: String,
56 },
57
58 #[error("invalid record: {0}")]
60 InvalidRecord(String),
61
62 #[error("invalid artifact {path}: {reason}")]
64 InvalidArtifact {
65 path: String,
67 reason: String,
69 },
70
71 #[error("I/O error: {0}")]
73 Io(#[from] io::Error),
74
75 #[error("JSON error: {0}")]
77 Json(#[from] serde_json::Error),
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum Mode {
84 BeforeAfter,
86 PinnedKafka,
88}
89
90impl Mode {
91 #[must_use]
93 pub fn name(self) -> &'static str {
94 match self {
95 Self::BeforeAfter => "before_after",
96 Self::PinnedKafka => "pinned_kafka",
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Direction {
104 HigherIsBetter,
106 LowerIsBetter,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum Metric {
114 RecordsPerSecond,
116 LatencyMsP50,
118 LatencyMsP95,
120 LatencyMsP99,
122 CpuMicrosPerRecord,
124 PeakRssMib,
126}
127
128impl Metric {
129 pub const ALL: [Self; 6] = [
131 Self::RecordsPerSecond,
132 Self::LatencyMsP50,
133 Self::LatencyMsP95,
134 Self::LatencyMsP99,
135 Self::CpuMicrosPerRecord,
136 Self::PeakRssMib,
137 ];
138
139 #[must_use]
141 pub fn direction(self) -> Direction {
142 match self {
143 Self::RecordsPerSecond => Direction::HigherIsBetter,
144 Self::LatencyMsP50
145 | Self::LatencyMsP95
146 | Self::LatencyMsP99
147 | Self::CpuMicrosPerRecord
148 | Self::PeakRssMib => Direction::LowerIsBetter,
149 }
150 }
151
152 #[must_use]
154 pub fn label(self) -> &'static str {
155 match self {
156 Self::RecordsPerSecond => "records/s",
157 Self::LatencyMsP50 => "p50 ms",
158 Self::LatencyMsP95 => "p95 ms",
159 Self::LatencyMsP99 => "p99 ms",
160 Self::CpuMicrosPerRecord => "CPU µs/record",
161 Self::PeakRssMib => "peak RSS MiB",
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum Payload {
170 Random,
172 Zeros,
174}
175
176impl Payload {
177 #[must_use]
179 pub fn name(self) -> &'static str {
180 match self {
181 Self::Random => "random",
182 Self::Zeros => "zeros",
183 }
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct Cell {
191 pub id: String,
193 pub record_bytes: u32,
195 pub payload: Payload,
197 pub compression: String,
199 pub partitions: u32,
201 pub records: u64,
203 pub rate: Option<u64>,
205 pub idle_seconds: u32,
207 pub timeout_seconds: u32,
209 pub budgeted: Vec<Metric>,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct Budget {
217 pub materiality_percent: f64,
219 pub spread_multiplier: f64,
221 pub max_spread_percent: f64,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct ModePolicy {
229 pub gate_performance: bool,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct Cluster {
237 pub brokers: u32,
239 pub replication_factor: u32,
241 pub min_insync_replicas: u32,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct Contract {
249 pub format_version: u32,
251 pub name: String,
253 pub rounds: usize,
255 pub cluster: Cluster,
257 pub kafka_image: String,
260 pub cells: Vec<Cell>,
262 pub budgets: BTreeMap<Metric, Budget>,
264 pub modes: BTreeMap<Mode, ModePolicy>,
266}
267
268fn invalid(reason: impl Into<String>) -> BudgetError { BudgetError::InvalidContract(reason.into()) }
269
270fn is_cell_id(id: &str) -> bool {
271 !id.is_empty()
272 && id
273 .bytes()
274 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
275}
276
277impl Contract {
278 pub fn from_json(bytes: &[u8]) -> Result<Self, BudgetError> {
285 let contract: Self = serde_json::from_slice(bytes)?;
286 contract.validate()?;
287 Ok(contract)
288 }
289
290 pub fn validate(&self) -> Result<(), BudgetError> {
296 if self.format_version != FORMAT_VERSION {
297 return Err(BudgetError::FormatVersion {
298 what: "contract",
299 found: self.format_version,
300 });
301 }
302 if self.rounds < MINIMUM_ROUNDS {
303 return Err(invalid(format!(
304 "rounds must be at least {MINIMUM_ROUNDS}, got {}",
305 self.rounds
306 )));
307 }
308 let cluster = &self.cluster;
309 if cluster.replication_factor == 0
310 || cluster.replication_factor > cluster.brokers
311 || cluster.min_insync_replicas == 0
312 || cluster.min_insync_replicas > cluster.replication_factor
313 {
314 return Err(invalid(
315 "cluster needs 0 < min_insync_replicas <= replication_factor <= brokers",
316 ));
317 }
318 if self.cells.is_empty() {
319 return Err(invalid("the workload matrix has no cells"));
320 }
321 let mut ids = BTreeSet::new();
322 for cell in &self.cells {
323 if !is_cell_id(&cell.id) {
324 return Err(invalid(format!("cell id {:?} is not [a-z0-9-]+", cell.id)));
325 }
326 if !ids.insert(cell.id.as_str()) {
327 return Err(invalid(format!("cell id {} appears twice", cell.id)));
328 }
329 if cell.record_bytes < MINIMUM_RECORD_BYTES {
330 return Err(invalid(format!(
331 "cell {} has records smaller than {MINIMUM_RECORD_BYTES} bytes",
332 cell.id
333 )));
334 }
335 if cell.records == 0 || cell.partitions == 0 || cell.timeout_seconds == 0 {
336 return Err(invalid(format!(
337 "cell {} needs positive records, partitions and timeout",
338 cell.id
339 )));
340 }
341 if cell.rate == Some(0) {
342 return Err(invalid(format!(
343 "cell {} has rate 0; omit it to saturate",
344 cell.id
345 )));
346 }
347 if cell.budgeted.is_empty() {
348 return Err(invalid(format!("cell {} budgets no metric", cell.id)));
349 }
350 for metric in &cell.budgeted {
351 if !self.budgets.contains_key(metric) {
352 return Err(invalid(format!(
353 "cell {} budgets {metric:?}, which has no budget",
354 cell.id
355 )));
356 }
357 }
358 }
359 for (metric, budget) in &self.budgets {
360 let values = [
361 budget.materiality_percent,
362 budget.spread_multiplier,
363 budget.max_spread_percent,
364 ];
365 if values
366 .iter()
367 .any(|value| !value.is_finite() || *value < 0.0)
368 {
369 return Err(invalid(format!(
370 "budget for {metric:?} must be finite and non-negative"
371 )));
372 }
373 }
374 for mode in [Mode::BeforeAfter, Mode::PinnedKafka] {
375 if !self.modes.contains_key(&mode) {
376 return Err(invalid(format!("no policy for mode {}", mode.name())));
377 }
378 }
379 Ok(())
380 }
381
382 #[must_use]
384 pub fn cell(&self, id: &str) -> Option<&Cell> { self.cells.iter().find(|cell| cell.id == id) }
385}
386
387#[must_use]
389pub fn contract_digest(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) }