harness/
driver.rs

1//! Shared `DogStatsD` load-driver engine.
2//!
3//! The engine fetches a working set of contexts from the shared intake pool, then a producer thread
4//! renders per-occurrence values against them into a bounded channel while a consumer thread fans
5//! each datagram out to every socket and tallies per-socket sends. Drivers differ only in how many
6//! sockets they target and which anchors they fire, so both the single-socket and differential
7//! drivers run on this one engine.
8//!
9//! NOTE: this driver intentionally blocks on backpressure from the SUT. Retry
10//! and backoff timers are meant to endure transient errors.
11
12use std::io::ErrorKind;
13use std::os::unix::net::UnixDatagram;
14use std::path::Path;
15use std::sync::mpsc::sync_channel;
16use std::thread::{self, sleep};
17use std::time::{Duration, Instant};
18
19use antithesis_sdk::prelude::*;
20use rand::Rng;
21use serde_json::json;
22
23use crate::contexts::{decode_response, Context};
24use crate::dogstatsd::is_malformed;
25use crate::payload::dogstatsd;
26
27const SEND_RETRY_BUDGET: Duration = Duration::from_secs(5);
28const SEND_RETRY_BACKOFF: Duration = Duration::from_millis(1);
29
30/// How long to keep retrying the context fetch before giving up and running no load this invocation.
31const CONTEXT_FETCH_BUDGET: Duration = Duration::from_secs(30);
32/// Backoff between context-fetch attempts.
33const CONTEXT_FETCH_BACKOFF: Duration = Duration::from_millis(250);
34/// Per-request timeout on a single context fetch.
35const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
36
37/// A generated datagram queued for the sockets: the packed bytes and what they hold.
38struct Datagram {
39    /// The `\n`-packed bytes of one datagram, shipped in a single send.
40    bytes: Vec<u8>,
41    /// The lines and largest packed run in `bytes`.
42    stats: dogstatsd::DatagramStats,
43}
44
45/// What a driver run shipped, for anchoring assertions.
46#[derive(Clone, Debug)]
47pub struct Stats {
48    /// Payloads pulled from the channel, whether or not any send succeeded.
49    pub received: usize,
50    /// Lines delivered per socket, summed across datagrams, indexed as the sockets
51    /// were passed to [`run`].
52    pub sent: Vec<usize>,
53    /// Largest packed run that reached each socket, indexed likewise. Zero when
54    /// no multi-value line reached that socket.
55    pub max_packed: Vec<usize>,
56    /// Whether a send exhausted the retry budget under sustained backpressure.
57    /// Distinguishes a wedged or paused peer from a clean partial batch.
58    pub timed_out: bool,
59}
60
61impl Stats {
62    /// The zero result for `sockets` sockets: nothing received, nothing sent. Reported when the
63    /// context pool is unreachable so a driver invocation degrades to a no-op rather than an error.
64    fn empty(sockets: usize) -> Self {
65        Self {
66            received: 0,
67            sent: vec![0; sockets],
68            max_packed: vec![0; sockets],
69            timed_out: false,
70        }
71    }
72}
73
74/// Pull `context_count` contexts from the intake pool at `intake_addr` once, then drive `count` datagrams
75/// to every socket from that one pull, each datagram a fresh per-occurrence render of every context that
76/// fits under `limit_bytes`, blocking through transient backpressure so every datagram reaches every
77/// socket.
78/// `context_count`, `count`, and `limit_bytes` come from a load generator's
79/// [`crate::config::DriverConfig`], so a datagram never truncates on receive.
80///
81/// An unreachable pool ends the run with an empty [`Stats`] rather than an error, so a driver that
82/// cannot reach the intake for its whole fetch budget degrades to a no-op. A peer that leaves mid-batch,
83/// or backpressure that outlasts the retry budget, ends the run early with a partial [`Stats`].
84///
85/// # Errors
86///
87/// Errors if a worker thread panics, or if a reachable pool serves no contexts. Sustained backpressure
88/// is reported via [`Stats::timed_out`], not as an error.
89pub fn run<R: Rng + Send + 'static>(
90    mut rng: R, intake_addr: &str, context_count: usize, limit_bytes: usize, count: usize, sockets: Vec<UnixDatagram>,
91) -> anyhow::Result<Stats> {
92    // One pull for the whole invocation, reused for every datagram. The pull is what decides whether
93    // this invocation's datagrams carry a non-UTF-8 byte, and a run schedules many invocations, so the
94    // fraction of pulls carrying one is the fraction of datagrams carrying one.
95    let pull = match fetch_contexts(intake_addr, context_count)? {
96        Some(contexts) => {
97            // The pool serves exactly what was asked for or errors, so a body that decodes to no
98            // contexts means the driver and the intake disagree about the request rather than that load
99            // is unavailable. Shipping nothing on it would hide the disagreement.
100            match dogstatsd::Pull::new(contexts) {
101                Some(pull) => pull,
102                None => anyhow::bail!("context pool served nothing for a request of {context_count} contexts"),
103            }
104        }
105        // The intake stayed unreachable for the whole fetch budget, which is what an injected partition
106        // looks like. No load this invocation, not a failure.
107        None => return Ok(Stats::empty(sockets.len())),
108    };
109
110    let (tx, rx) = sync_channel::<Datagram>(2024);
111
112    let producer = thread::spawn(move || {
113        for _ in 0..count {
114            let mut bytes = Vec::new();
115            let stats = dogstatsd::write_datagram(&mut rng, &pull, &mut bytes, limit_bytes);
116            // Green by construction: write_datagram packs only rendered lines the Agent forwards. The
117            // anchor catches any drift that would ship a droppable datagram.
118            assert_always!(
119                is_malformed(&bytes).is_ok(),
120                "driver datagram is well-formed",
121                &json!({})
122            );
123            if tx.send(Datagram { bytes, stats }).is_err() {
124                break;
125            }
126        }
127    });
128
129    let consumer = thread::spawn(move || -> anyhow::Result<Stats> {
130        let mut received = 0usize;
131        let mut sent = vec![0usize; sockets.len()];
132        let mut max_packed = vec![0usize; sockets.len()];
133        let mut timed_out = false;
134        'recv: while let Ok(datagram) = rx.recv() {
135            received += 1;
136            for (i, socket) in sockets.iter().enumerate() {
137                match deliver(socket, &datagram.bytes) {
138                    Delivery::Sent => {
139                        sent[i] += datagram.stats.lines;
140                        max_packed[i] = max_packed[i].max(datagram.stats.max_packed);
141                    }
142                    // Peer left mid-batch after Antithesis killed the SUT. Stop and
143                    // report the partial batch rather than failing the run.
144                    Delivery::Unavailable => break 'recv,
145                    // Backpressure outlasted the retry budget. A legit Antithesis
146                    // pause reaches here, so record it and stop rather than fail.
147                    Delivery::Timeout => {
148                        timed_out = true;
149                        break 'recv;
150                    }
151                }
152            }
153        }
154        Ok(Stats {
155            received,
156            sent,
157            max_packed,
158            timed_out,
159        })
160    });
161
162    producer
163        .join()
164        .map_err(|_| anyhow::anyhow!("producer thread panicked"))?;
165    consumer
166        .join()
167        .map_err(|_| anyhow::anyhow!("consumer thread panicked"))?
168}
169
170/// What a context fetch attempt came back with.
171enum Fetched {
172    /// The pool answered with contexts.
173    Contexts(Vec<Context>),
174    /// Nothing answered, or the body did not decode. Worth retrying.
175    Unreachable,
176    /// The pool answered and refused. Its own invariant broke, so retrying it is pointless.
177    Refused(reqwest::StatusCode),
178}
179
180/// Fetch a pull of `n` contexts from the pool at `intake_addr` over blocking HTTP, retrying through
181/// [`CONTEXT_FETCH_BUDGET`]. Returns `None` when the pool never answers, so the caller degrades to no
182/// load rather than failing, which is what an injected partition looks like.
183///
184/// # Errors
185///
186/// Errors when the pool answers and refuses. A refusal means the intake decided it could not serve this
187/// timeline's config at all, and shipping no load on it would hide that behind an idle driver.
188fn fetch_contexts(intake_addr: &str, n: usize) -> anyhow::Result<Option<Vec<Context>>> {
189    let url = format!("http://{intake_addr}/contexts?n={n}");
190    let client = reqwest::blocking::Client::builder()
191        .timeout(CONTEXT_FETCH_TIMEOUT)
192        .build()
193        .map_err(|e| anyhow::anyhow!("build the context-fetch client: {e}"))?;
194    let deadline = Instant::now() + CONTEXT_FETCH_BUDGET;
195    loop {
196        match try_fetch_contexts(&client, &url) {
197            Fetched::Contexts(contexts) => return Ok(Some(contexts)),
198            Fetched::Refused(status) => {
199                anyhow::bail!("context pool refused a request for {n} contexts with {status}")
200            }
201            Fetched::Unreachable => {}
202        }
203        if Instant::now() >= deadline {
204            return Ok(None);
205        }
206        sleep(CONTEXT_FETCH_BACKOFF);
207    }
208}
209
210/// One context-fetch attempt. A transport error or a body that does not decode is retried. A non-success
211/// status is not: the pool answered, so the fault is in the request or in the pool's own config, and no
212/// amount of waiting fixes either.
213fn try_fetch_contexts(client: &reqwest::blocking::Client, url: &str) -> Fetched {
214    let Ok(response) = client.get(url).send() else {
215        return Fetched::Unreachable;
216    };
217    let status = response.status();
218    if !status.is_success() {
219        return Fetched::Refused(status);
220    }
221    let Ok(body) = response.bytes() else {
222        return Fetched::Unreachable;
223    };
224    match decode_response(&body) {
225        Some(contexts) => Fetched::Contexts(contexts),
226        None => Fetched::Unreachable,
227    }
228}
229
230/// Outcome of delivering one line to a socket.
231enum Delivery {
232    /// The line reached the socket.
233    Sent,
234    /// The peer is gone. Stop the batch and report the partial result.
235    Unavailable,
236    /// Backpressure outlasted the retry budget. Fail the run.
237    Timeout,
238}
239
240fn deliver(socket: &UnixDatagram, bytes: &[u8]) -> Delivery {
241    let deadline = Instant::now() + SEND_RETRY_BUDGET;
242    loop {
243        match socket.send(bytes) {
244            Ok(_) => return Delivery::Sent,
245            Err(e) if is_transient(&e) => {
246                if Instant::now() >= deadline {
247                    return Delivery::Timeout;
248                }
249                sleep(SEND_RETRY_BACKOFF);
250            }
251            Err(_) => return Delivery::Unavailable,
252        }
253    }
254}
255
256fn is_transient(error: &std::io::Error) -> bool {
257    matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted)
258        || error.raw_os_error() == Some(libc::ENOBUFS)
259}
260
261/// Wait for the remote process to bind `path`, intentionally naive. Returns
262/// `None` if the socket is still unavailable after 30 seconds.
263#[must_use]
264pub fn connect_with_retry(path: &Path) -> Option<UnixDatagram> {
265    let deadline = Instant::now() + Duration::from_secs(30);
266    loop {
267        if let Ok(socket) = UnixDatagram::unbound() {
268            if socket.connect(path).is_ok() {
269                return Some(socket);
270            }
271        }
272        if Instant::now() >= deadline {
273            return None;
274        }
275        sleep(Duration::from_millis(250));
276    }
277}