Skip to content

Commit 2452073

Browse files
committed
chore(antithesis): dogstatsd generation with byte limit
This commit updates the harness driver to build dogstatsd to a byte limit, in the manner of datadog/lading. I have been unable to investigate SMPTNG-7611 well owing to the error log emission by Datadog Agent. I've added property tests to assert the payload limit is obeyed, accepting that this means making the dogstatsd generator pure with regard to Rng and may not use antithesis SDK's random_* directly.
1 parent f08152f commit 2452073

11 files changed

Lines changed: 295 additions & 154 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/antithesis/harness/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ serde = { workspace = true }
2525
serde_json = { workspace = true }
2626
serde_yaml = { workspace = true }
2727

28+
[dev-dependencies]
29+
proptest = { workspace = true }
30+
2831
[lints.clippy]
2932
all = "deny"
3033
complexity = "deny"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 377cba92b14295b3fe5c6f2738a398ee042be8e35f529398ecf9e275e6b0a1da # shrinks to seed = 0

test/antithesis/harness/src/driver.rs

Lines changed: 37 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -15,74 +15,42 @@ use std::sync::mpsc::sync_channel;
1515
use std::thread::{self, sleep};
1616
use std::time::{Duration, Instant};
1717

18-
use antithesis_sdk::random::{random_choice, AntithesisRng};
19-
use rand::{rand_core::UnwrapErr, RngExt};
18+
use antithesis_sdk::random::AntithesisRng;
19+
use rand::seq::IndexedRandom;
20+
use rand::RngExt;
2021

2122
use crate::payload::dogstatsd;
23+
pub use crate::payload::dogstatsd::Batch;
2224

2325
const SEND_RETRY_BUDGET: Duration = Duration::from_secs(5);
2426
const SEND_RETRY_BACKOFF: Duration = Duration::from_millis(1);
2527

26-
/// Per-batch composition: 50% clean, 25% feral, 25% mixed.
27-
#[derive(Clone, Copy, Debug)]
28-
pub enum Batch {
29-
/// Every line clean.
30-
Clean,
31-
/// Every line feral.
32-
Feral,
33-
/// A per-line clean-or-feral mix.
34-
Mixed,
35-
}
36-
37-
impl Batch {
38-
/// Sample a batch composition: half clean, a quarter feral, a quarter mixed.
39-
#[must_use]
40-
pub fn sample() -> Self {
41-
match random_choice(&[Batch::Clean, Batch::Clean, Batch::Feral, Batch::Mixed]) {
42-
Some(Batch::Feral) => Batch::Feral,
43-
Some(Batch::Mixed) => Batch::Mixed,
44-
_ => Batch::Clean,
45-
}
46-
}
47-
48-
/// The vibe for one line drawn from this batch.
49-
fn vibe(self) -> dogstatsd::Vibe {
50-
match self {
51-
Batch::Clean => dogstatsd::Vibe::Clean,
52-
Batch::Feral => dogstatsd::Vibe::Feral,
53-
Batch::Mixed => dogstatsd::sample_vibe(),
54-
}
28+
/// Sample a line composition: half clean, a quarter feral, a quarter mixed.
29+
#[must_use]
30+
pub fn sample() -> Batch {
31+
let mut rng = AntithesisRng;
32+
match [Batch::Clean, Batch::Clean, Batch::Feral, Batch::Mixed].choose(&mut rng) {
33+
Some(Batch::Feral) => Batch::Feral,
34+
Some(Batch::Mixed) => Batch::Mixed,
35+
_ => Batch::Clean,
5536
}
5637
}
5738

58-
/// A generated dogstatsd line queued for the sockets.
59-
enum Line {
60-
/// A single-value line.
61-
Single { bytes: Vec<u8> },
62-
/// A multi-value `:`-packed metric.
63-
Multi {
64-
/// The encoded line.
65-
bytes: Vec<u8>,
66-
/// The number of values in the packed run.
67-
count: usize,
68-
},
69-
}
70-
71-
impl Line {
72-
/// The encoded bytes to ship over a socket.
73-
fn bytes(&self) -> &[u8] {
74-
match self {
75-
Line::Single { bytes } | Line::Multi { bytes, .. } => bytes,
76-
}
77-
}
39+
/// A generated payload queued for the sockets: the packed bytes and what they hold.
40+
struct Datagram {
41+
/// The `\n`-packed payload bytes to ship over a socket.
42+
bytes: Vec<u8>,
43+
/// The lines and largest packed run in `bytes`.
44+
payload: dogstatsd::Payload,
7845
}
7946

8047
/// What a driver run shipped, for anchoring assertions.
8148
#[derive(Clone, Debug)]
8249
pub struct Stats {
83-
/// Lines pulled from the channel, whether or not any send succeeded.
50+
/// Payloads pulled from the channel, whether or not any send succeeded.
8451
pub received: usize,
85-
/// Successful sends per socket, indexed as the sockets were passed to [`run`].
52+
/// Lines delivered per socket, summed across payloads, indexed as the sockets
53+
/// were passed to [`run`].
8654
pub sent: Vec<usize>,
8755
/// Largest packed run that reached each socket, indexed likewise. Zero when
8856
/// no multi-value line reached that socket.
@@ -92,30 +60,30 @@ pub struct Stats {
9260
pub timed_out: bool,
9361
}
9462

95-
/// Drive one batch of sampled `DogStatsD` lines to every socket, blocking through
96-
/// backpressure so on success `sent[i] == received` for all `i`.
63+
/// Drive a batch of sampled `DogStatsD` payloads to every socket, blocking
64+
/// through transient backpressure so every payload reaches every socket.
65+
///
66+
/// A peer that leaves mid-batch, or backpressure that outlasts the retry budget,
67+
/// ends the run early with a partial [`Stats`] rather than an error.
9768
///
9869
/// # Errors
9970
///
100-
/// Errors if a worker thread panics. Sustained backpressure past the retry budget
101-
/// is reported via [`Stats::timed_out`], not as an error.
71+
/// Errors if a worker thread panics. Sustained backpressure is reported via
72+
/// [`Stats::timed_out`], not as an error.
10273
pub fn run(batch: Batch, sockets: Vec<UnixDatagram>) -> anyhow::Result<Stats> {
10374
let count = {
104-
let mut rng = UnwrapErr(AntithesisRng);
75+
let mut rng = AntithesisRng;
10576
rng.random_range(0..=10_000u64)
10677
};
10778

108-
let (tx, rx) = sync_channel::<Line>(2024);
79+
let (tx, rx) = sync_channel::<Datagram>(2024);
10980

11081
let producer = thread::spawn(move || {
111-
let mut rng = UnwrapErr(AntithesisRng);
82+
let mut rng = AntithesisRng;
11283
for _ in 0..count {
11384
let mut bytes = Vec::new();
114-
let line = match dogstatsd::send(&mut rng, &mut bytes, batch.vibe()) {
115-
None => Line::Single { bytes },
116-
Some(count) => Line::Multi { bytes, count },
117-
};
118-
if tx.send(line).is_err() {
85+
let payload = dogstatsd::write_payload(&mut rng, &mut bytes, batch, dogstatsd::PAYLOAD_BYTE_LIMIT);
86+
if tx.send(Datagram { bytes, payload }).is_err() {
11987
break;
12088
}
12189
}
@@ -126,15 +94,13 @@ pub fn run(batch: Batch, sockets: Vec<UnixDatagram>) -> anyhow::Result<Stats> {
12694
let mut sent = vec![0usize; sockets.len()];
12795
let mut max_packed = vec![0usize; sockets.len()];
12896
let mut timed_out = false;
129-
'recv: while let Ok(line) = rx.recv() {
97+
'recv: while let Ok(datagram) = rx.recv() {
13098
received += 1;
13199
for (i, socket) in sockets.iter().enumerate() {
132-
match deliver(socket, line.bytes()) {
100+
match deliver(socket, &datagram.bytes) {
133101
Delivery::Sent => {
134-
sent[i] += 1;
135-
if let Line::Multi { count, .. } = &line {
136-
max_packed[i] = max_packed[i].max(*count);
137-
}
102+
sent[i] += datagram.payload.lines;
103+
max_packed[i] = max_packed[i].max(datagram.payload.max_packed);
138104
}
139105
// Peer left mid-batch after Antithesis killed the SUT. Stop and
140106
// report the partial batch rather than failing the run.

test/antithesis/harness/src/payload/dogstatsd.rs

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,27 @@ fn choose_message<R: Rng + ?Sized>(rng: &mut R) -> Message {
101101
}
102102
}
103103

104-
/// Write one `DogStatsD` message of a sampled type to `buf` at the given vibe.
104+
/// The `dogstatsd_buffer_size` default, via Datadog Agent.
105+
pub const PAYLOAD_BYTE_LIMIT: usize = 8_192;
106+
107+
/// What a generated payload holds, for anchoring assertions.
108+
#[derive(Clone, Copy, Debug, Default)]
109+
pub struct Payload {
110+
/// Lines packed into the buffer.
111+
pub lines: usize,
112+
/// Largest packed multi-value run among those lines. Zero when none.
113+
pub max_packed: usize,
114+
}
115+
116+
/// Append one `DogStatsD` line of a sampled type to `buf`. When a line would
117+
/// exceed `limit`, drop it whole rather than shear it, leaving `buf` unchanged.
118+
/// A non-empty line always ends in `\n`.
105119
///
106120
/// Returns the packed value count when a multi-value metric was emitted, else
107121
/// `None`.
108-
pub fn send<R: Rng + ?Sized>(rng: &mut R, buf: &mut Vec<u8>, vibe: Vibe) -> Option<usize> {
109-
buf.clear();
110-
match choose_message(rng) {
122+
pub fn write_line<R: Rng + ?Sized>(rng: &mut R, buf: &mut Vec<u8>, vibe: Vibe, limit: usize) -> Option<usize> {
123+
let start = buf.len();
124+
let packed = match choose_message(rng) {
111125
Message::Event => {
112126
events::write(rng, buf, vibe);
113127
None
@@ -117,5 +131,126 @@ pub fn send<R: Rng + ?Sized>(rng: &mut R, buf: &mut Vec<u8>, vibe: Vibe) -> Opti
117131
None
118132
}
119133
Message::Metric => metrics::write(rng, buf, vibe),
134+
};
135+
if buf.len() - start > limit {
136+
// Drop a whole line that exceeds the limit rather than shear it mid-token.
137+
// A sheared fragment is exactly the parse-error spew this generator avoids.
138+
buf.truncate(start);
139+
return None;
140+
}
141+
packed
142+
}
143+
144+
/// Per-run line composition: every line clean, every line feral, or a per-line
145+
/// clean-or-feral mix.
146+
#[derive(Clone, Copy, Debug)]
147+
pub enum Batch {
148+
/// Every line clean.
149+
Clean,
150+
/// Every line feral.
151+
Feral,
152+
/// Each line independently clean or feral.
153+
Mixed,
154+
}
155+
156+
impl Batch {
157+
/// The vibe for one line of this batch, sampled per call so `Mixed` interleaves.
158+
fn vibe<R: Rng + ?Sized>(self, rng: &mut R) -> Vibe {
159+
match self {
160+
Batch::Clean => Vibe::Clean,
161+
Batch::Feral => Vibe::Feral,
162+
Batch::Mixed => sample_vibe(rng),
163+
}
164+
}
165+
}
166+
167+
/// Fill `buf` with `\n`-terminated lines, packing whole lines straight into it
168+
/// until the next would exceed `limit` total bytes. A line that overruns the
169+
/// remaining budget rolls back and ends the payload. A single line too large to
170+
/// fit at all gets skipped so a later, smaller line can still pack. `buf` holds
171+
/// only whole lines and never exceeds `limit`. Each line takes its vibe from
172+
/// `batch`, so a `Mixed` payload interleaves clean and feral lines. Clears `buf`
173+
/// first.
174+
pub fn write_payload<R: Rng + ?Sized>(rng: &mut R, buf: &mut Vec<u8>, batch: Batch, limit: usize) -> Payload {
175+
buf.clear();
176+
let mut payload = Payload::default();
177+
// Consecutive oversized lines write_line dropped. Bounded so a run of them
178+
// cannot spin when `limit` is smaller than any line.
179+
let mut skipped = 0u8;
180+
loop {
181+
let vibe = batch.vibe(rng);
182+
let start = buf.len();
183+
let packed = write_line(rng, buf, vibe, limit);
184+
if buf.len() > limit {
185+
// This whole line overruns the remaining budget. Roll it back and stop.
186+
buf.truncate(start);
187+
break;
188+
}
189+
if buf.len() == start {
190+
// write_line dropped a line too large to fit at all. Skip it and try
191+
// another rather than end the payload early.
192+
skipped += 1;
193+
if skipped >= 16 {
194+
break;
195+
}
196+
continue;
197+
}
198+
skipped = 0;
199+
payload.lines += 1;
200+
if let Some(count) = packed {
201+
payload.max_packed = payload.max_packed.max(count);
202+
}
203+
}
204+
payload
205+
}
206+
207+
#[cfg(test)]
208+
mod test {
209+
use proptest::prelude::*;
210+
use rand::rngs::SmallRng;
211+
use rand::SeedableRng;
212+
213+
use super::{write_line, write_payload, Batch, Vibe};
214+
215+
fn any_vibe() -> impl Strategy<Value = Vibe> {
216+
prop_oneof![Just(Vibe::Clean), Just(Vibe::Feral)]
217+
}
218+
219+
fn any_batch() -> impl Strategy<Value = Batch> {
220+
prop_oneof![Just(Batch::Clean), Just(Batch::Feral), Just(Batch::Mixed)]
221+
}
222+
223+
/// Lines carry no interior newline and each is `\n`-terminated, so the line
224+
/// count equals the newline count.
225+
#[allow(clippy::naive_bytecount)]
226+
fn newline_count(buf: &[u8]) -> usize {
227+
buf.iter().filter(|&&b| b == b'\n').count()
228+
}
229+
230+
proptest! {
231+
#[test]
232+
fn write_line_stays_within_its_limit(seed: u64, limit: u16, vibe in any_vibe()) {
233+
let mut rng = SmallRng::seed_from_u64(seed);
234+
let limit = usize::from(limit);
235+
let mut buf = Vec::new();
236+
write_line(&mut rng, &mut buf, vibe, limit);
237+
238+
prop_assert!(buf.len() <= limit);
239+
if !buf.is_empty() {
240+
prop_assert_eq!(buf[buf.len() - 1], b'\n');
241+
prop_assert_eq!(newline_count(&buf), 1);
242+
}
243+
}
244+
245+
#[test]
246+
fn write_payload_stays_within_its_limit(seed: u64, limit: u16, batch in any_batch()) {
247+
let mut rng = SmallRng::seed_from_u64(seed);
248+
let limit = usize::from(limit);
249+
let mut buf = Vec::new();
250+
let payload = write_payload(&mut rng, &mut buf, batch, limit);
251+
252+
prop_assert!(buf.len() <= limit);
253+
prop_assert_eq!(newline_count(&buf), payload.lines);
254+
}
120255
}
121256
}

0 commit comments

Comments
 (0)