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}