Skip to main content

brk/
report.rs

1use std::time::Duration;
2
3use crate::Acceptance;
4
5/// Completed logical attempts, including failures, belonging to one phase.
6#[derive(Clone, Copy, Debug, Default)]
7pub struct Counts {
8    pub sent: u64,
9    pub attempted: u64,
10    pub successful: u64,
11    pub timeout: u64,
12    pub transport: u64,
13    pub status: u64,
14    pub body: u64,
15}
16
17impl Counts {
18    pub fn failed(self) -> u64 {
19        self.attempted - self.successful
20    }
21
22    pub(crate) fn add(
23        &mut self,
24        other: Self,
25    ) {
26        self.sent += other.sent;
27        self.attempted += other.attempted;
28        self.successful += other.successful;
29        self.timeout += other.timeout;
30        self.transport += other.transport;
31        self.status += other.status;
32        self.body += other.body;
33    }
34}
35
36/// Phase zero starts at start; each later phase adds pace per connection.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub struct Phase {
39    pub index: usize,
40    pub connections: usize,
41    pub requests_per_connection: usize,
42    pub total_concurrency: usize,
43    pub duration: Duration,
44}
45
46#[derive(Clone, Copy, Debug)]
47pub struct PhaseStats {
48    pub phase: Phase,
49    pub counts: Counts,
50    /// Includes drain of all attempts admitted before the phase ended.
51    pub elapsed: Duration,
52    /// Connection owners that actually wrote the requested pipeline depth.
53    pub connections_at_target: usize,
54    /// False when execution was interrupted by a client/protocol failure.
55    pub completed: bool,
56    pub cancelled: bool,
57}
58
59impl PhaseStats {
60    pub fn accepted(
61        &self,
62        acceptance: Acceptance,
63    ) -> bool {
64        self.completed
65            && !self.cancelled
66            && self.connections_at_target == self.phase.connections
67            && acceptance.accepts(self.counts.successful, self.counts.attempted)
68    }
69
70    pub fn requests_per_sec(&self) -> f64 {
71        self.counts.successful as f64 / self.elapsed.as_secs_f64()
72    }
73}
74
75/// Bounded history. Acceptance uses fresh phase counts, never cumulative totals.
76#[derive(Clone, Debug, Default)]
77pub struct Stats {
78    pub cpu_workers: usize,
79    pub phases: usize,
80    pub total: Counts,
81    pub last_accepted: Option<PhaseStats>,
82    pub last_phase: Option<PhaseStats>,
83}
84
85#[derive(Debug)]
86pub struct Done {
87    pub stats: Stats,
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum BreakReason {
92    #[error("invalid plan: {0}")]
93    Invalid(&'static str),
94    #[error("client I/O: {0}")]
95    Io(#[from] std::io::Error),
96    #[error("client resources: {0}")]
97    Resources(&'static str),
98    #[error("HTTP protocol: {0}")]
99    Protocol(&'static str),
100    #[error("client worker: {0}")]
101    Worker(&'static str),
102    #[error("the client did not establish the declared concurrency")]
103    Unfulfilled,
104    #[error("the phase exceeded the HTTP acceptance budget")]
105    Acceptance,
106    #[error("concurrency representation exhausted")]
107    Overflow,
108    #[error("cancelled")]
109    Cancelled,
110}
111
112#[derive(Debug, thiserror::Error)]
113#[error("{reason}")]
114pub struct Broken {
115    pub reason: BreakReason,
116    pub stats: Box<Stats>,
117}
118
119/// Runs on the caller at phase boundaries, never on the request path.
120pub trait Observer {
121    /// Workers are ready but no request from this phase has been sent.
122    fn phase_started(
123        &mut self,
124        _phase: &Phase,
125    ) {
126    }
127
128    /// Every admitted attempt has completed, failed or reached its timeout.
129    fn phase_finished(
130        &mut self,
131        _stats: &PhaseStats,
132    ) {
133    }
134}
135
136impl Observer for () {}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    #[test]
142    fn perfect_status_counts_do_not_pass_when_concurrency_was_not_established()
143    {
144        let mut measured = PhaseStats {
145            phase: Phase {
146                index: 0,
147                connections: 4,
148                requests_per_connection: 100,
149                total_concurrency: 400,
150                duration: Duration::from_secs(1),
151            },
152            counts: Counts {
153                attempted: 10_000,
154                successful: 10_000,
155                ..Counts::default()
156            },
157            elapsed: Duration::from_secs(1),
158            connections_at_target: 3,
159            completed: true,
160            cancelled: false,
161        };
162        let accept = "99.99".parse().unwrap();
163        assert!(!measured.accepted(accept));
164        measured.connections_at_target = 4;
165        assert!(measured.accepted(accept));
166        measured.counts.successful = 9_998;
167        assert!(!measured.accepted(accept));
168    }
169}