Skip to content

Commit 8644d54

Browse files
committed
chore(antithesis): is_malformed based load generation
The essential property we want to determine is whether ADP-on/ADP-off emits payloads to intake API that the intake API rejects. This is especially of interest for inputs that we know are ultimately rejected by intake API -- non-utf8 bytes in the wrong spot -- but are _not_ rejected by the SUT. The old mechanism had a feral/clean 'vibe' which served for a while but was confusing to debug. Is feral malformed? Is feral well-formed but wild? Anyway I got tired of it. There's now a predicate which defines whether a payload is well-formed or not -- that is, accepted by ADP-off Datadog Agent -- independent of whether intake API ultimately accepts the payloads that ingress inspires. Later I will build a generator that only emits malformed ingress but that is only hinted at in this work.
1 parent 604b4cf commit 8644d54

10 files changed

Lines changed: 842 additions & 236 deletions

File tree

test/antithesis/harness/proptest-regressions/payload/dogstatsd.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@
66
# everyone who runs the test benefits from these saved cases.
77
cc 377cba92b14295b3fe5c6f2738a398ee042be8e35f529398ecf9e275e6b0a1da # shrinks to seed = 0
88
cc 1f807f27fdbf0b983fc773b815b21d6023e1bce87691d07133b438b862edcc5b # shrinks to seed = 6079028945602138863, limit_bytes = 21
9+
cc 966dfab50a6a0af70bafa9c882cd664e62586012ec563187766e7b07773b588e # shrinks to seed = 816969264511539406, limit_bytes = 9
10+
cc 4470fd2185844af9954fc8e973eee2ac8f6c939167fb7d950e448a5ca2d2b52a # shrinks to seed = 881271281136835980, limit_bytes = 8
11+
cc c084e24d26194bedbf39e3055ace5c28a2d3ce2f2b071be7277a18f49e568849 # shrinks to seed = 6133403856440011550
12+
cc 89407cb915422a73621ab39b63ac3bb99e1a12ebb33825d9f623da074b805f9f # shrinks to seed = 17633913455663304866, limit_bytes = 3

test/antithesis/harness/src/bin/first_sample_config/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ fn main() -> anyhow::Result<()> {
7373
let context_source_path = cli.config_dir.join("context_source.yaml");
7474
fs::write(
7575
&context_source_path,
76-
ContextSourceConfig::sample(&mut rng, driver.payload_byte_limit)
76+
ContextSourceConfig::sample(&mut rng, driver.datagram_byte_limit)
7777
.to_yaml()?
7878
.as_bytes(),
7979
)

test/antithesis/harness/src/config.rs

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use rand::distr::{Distribution, StandardUniform};
1515
use rand::{Rng, RngExt};
1616
use serde::{Deserialize, Serialize};
1717

18-
use crate::payload::dogstatsd::PAYLOAD_BYTE_LIMIT;
18+
use crate::payload::dogstatsd::DATAGRAM_BYTE_LIMIT;
1919
use crate::rand::Probe;
2020

2121
/// Agent log level.
@@ -258,32 +258,33 @@ const MAX_CONTEXTS_PER_REQUEST: usize = 65_536;
258258
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
259259
pub struct DriverConfig {
260260
/// Max bytes a generator packs into one datagram, the smaller of the SUT's
261-
/// sampled receive buffer and [`PAYLOAD_BYTE_LIMIT`]. A datagram this size
261+
/// sampled receive buffer and [`DATAGRAM_BYTE_LIMIT`]. A datagram this size
262262
/// fits one read, so the SUT never truncates a line mid-token.
263-
pub payload_byte_limit: usize,
263+
pub datagram_byte_limit: usize,
264264
/// Datagrams a driver invocation ships this timeline.
265265
pub datagram_count: usize,
266266
/// Distinct contexts a driver invocation fetches from the shared pool as its working set.
267267
///
268268
/// Sampled boundary-biased log-uniform in `1..=1_024`. Valid values run `1..=65_536`, the intake's
269269
/// per-request ceiling. Outside that the intake rejects every `/contexts` request, the driver waits
270270
/// out its fetch budget and ships nothing, so [`Self::read`] rejects such a config rather than
271-
/// letting a timeline generate no load. A larger working set spreads load over more identities and
272-
/// so puts fewer points in each, which is the trade against recurrence.
271+
/// letting a timeline generate no load. Every context in a pull gets a line in every datagram that
272+
/// has room, so a larger pull makes fatter datagrams over more identities rather than thinner
273+
/// series, and the trade is against how often any one identity recurs.
273274
pub context_count: usize,
274275
}
275276

276277
impl DriverConfig {
277278
/// Sample the driver knobs for a SUT whose receive buffer is `buffer_size`.
278279
fn sample<R: Rng + ?Sized>(rng: &mut R, buffer_size: u64) -> Self {
279-
// The min is at most PAYLOAD_BYTE_LIMIT, so a buffer wider than usize
280+
// The min is at most DATAGRAM_BYTE_LIMIT, so a buffer wider than usize
280281
// caps to the ceiling like any other oversized buffer.
281-
let payload_byte_limit = match usize::try_from(buffer_size.min(PAYLOAD_BYTE_LIMIT as u64)) {
282+
let datagram_byte_limit = match usize::try_from(buffer_size.min(DATAGRAM_BYTE_LIMIT as u64)) {
282283
Ok(bytes) => bytes,
283-
Err(_) => PAYLOAD_BYTE_LIMIT,
284+
Err(_) => DATAGRAM_BYTE_LIMIT,
284285
};
285286
Self {
286-
payload_byte_limit,
287+
datagram_byte_limit,
287288
datagram_count: rng.random_range(0..=MAX_DATAGRAMS),
288289
context_count: usize::try_from(Probe::new(1, MAX_WORKING_SET).sample(rng)).unwrap_or(usize::MAX),
289290
}
@@ -304,7 +305,7 @@ impl DriverConfig {
304305
/// # Errors
305306
///
306307
/// Returns an error if the config is unreadable or is not valid YAML with an
307-
/// integer `payload_byte_limit`.
308+
/// integer `datagram_byte_limit`.
308309
pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
309310
let path = config_dir.join("driver.yaml");
310311
let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
@@ -328,19 +329,21 @@ const MAX_CONTEXTS_TOTAL: u64 = 1_000_000;
328329
/// The per-kind caps a timeline's shared context pool fills to before it recurs existing contexts.
329330
/// `first_sample_config` samples this beside `datadog.yaml` so cardinality varies per timeline. Each
330331
/// cap is drawn against the budget the earlier draws left, so a kind's cardinality still varies at
331-
/// random while the three together stay under [`MAX_CONTEXTS_TOTAL`].
332+
/// random while the three together stay under `MAX_CONTEXTS_TOTAL`.
332333
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
333334
pub struct ContextSourceConfig {
334335
/// Bytes a rendered line of any pooled context must fit, this timeline's real datagram budget
335336
/// rather than the protocol ceiling. Mint builds identities against it, so every served context
336-
/// has a rendering the driver can pack. Defaults to the sampled `payload_byte_limit`, which is the
337-
/// smaller of the SUT's receive buffer and [`PAYLOAD_BYTE_LIMIT`].
338-
pub payload_byte_limit: usize,
337+
/// has a rendering the driver can pack. Defaults to the sampled `datagram_byte_limit`, which is the
338+
/// smaller of the SUT's receive buffer and [`DATAGRAM_BYTE_LIMIT`].
339+
pub datagram_byte_limit: usize,
339340
/// Distinct metric contexts the pool holds before it recurs the ones it has.
340341
///
341-
/// Sampled in `1..=MAX_CONTEXTS_TOTAL` minus what the other kinds took. A larger cap explores more
342+
/// Sampled in `2..=MAX_CONTEXTS_TOTAL` minus what the other kinds took. A larger cap explores more
342343
/// identities and puts fewer points in each, and costs memory: the pool retains every context it
343-
/// mints for the life of the run. Zero is not sampled and serves nothing for the kind.
344+
/// mints for the life of the run. Two is the floor because the pool holds one context carrying an
345+
/// invalid UTF-8 byte per kind alongside the rest, and a kind capped at one could hold only one of
346+
/// the two.
344347
pub metric_contexts: usize,
345348
/// Distinct event contexts the pool holds. Same range and trade as [`Self::metric_contexts`].
346349
pub event_contexts: usize,
@@ -354,16 +357,16 @@ impl ContextSourceConfig {
354357
///
355358
/// The draws run in order and each spends from one shared ceiling, so the total is bounded by
356359
/// construction rather than by scaling three independent draws afterwards. Every kind keeps at
357-
/// least one context, since a kind capped at zero panics the pool's draw.
360+
/// least two contexts, one of which carries an invalid UTF-8 byte.
358361
#[must_use]
359-
pub fn sample<R: Rng + ?Sized>(rng: &mut R, payload_byte_limit: usize) -> Self {
360-
// Two contexts held back so the later kinds can each keep their one.
361-
let metric_contexts = sample_cap(rng, MAX_CONTEXTS_TOTAL - 2);
362+
pub fn sample<R: Rng + ?Sized>(rng: &mut R, datagram_byte_limit: usize) -> Self {
363+
// Four contexts held back so the two later kinds can each keep their two.
364+
let metric_contexts = sample_cap(rng, MAX_CONTEXTS_TOTAL - 4);
362365
let free = MAX_CONTEXTS_TOTAL - metric_contexts as u64;
363-
let event_contexts = sample_cap(rng, free - 1);
366+
let event_contexts = sample_cap(rng, free - 2);
364367
let free = free - event_contexts as u64;
365368
Self {
366-
payload_byte_limit,
369+
datagram_byte_limit,
367370
metric_contexts,
368371
event_contexts,
369372
service_check_contexts: sample_cap(rng, free),
@@ -384,18 +387,38 @@ impl ContextSourceConfig {
384387
///
385388
/// # Errors
386389
///
387-
/// Returns an error if the config is unreadable or is not valid YAML.
390+
/// Returns an error if the config is unreadable, is not valid YAML, or caps a kind below two. The
391+
/// pool seeds every kind with one context carrying an invalid UTF-8 byte and one without, so a cap of
392+
/// one cannot hold both and the pool would exceed its own cap assertion. [`Self::sample`] never draws
393+
/// below two, and this rejects a config that reached the pool by another route rather than letting it
394+
/// redden a run.
388395
pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
389396
let path = config_dir.join("context_source.yaml");
390397
let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
391-
serde_yaml::from_str(&yaml).with_context(|| format!("parse context source config from {}", path.display()))
398+
let config: Self = serde_yaml::from_str(&yaml)
399+
.with_context(|| format!("parse context source config from {}", path.display()))?;
400+
for (kind, cap) in [
401+
("metric_contexts", config.metric_contexts),
402+
("event_contexts", config.event_contexts),
403+
("service_check_contexts", config.service_check_contexts),
404+
] {
405+
anyhow::ensure!(
406+
cap >= MIN_CONTEXTS_PER_KIND,
407+
"{kind} is {cap}, which is below the {MIN_CONTEXTS_PER_KIND} the pool seeds"
408+
);
409+
}
410+
Ok(config)
392411
}
393412
}
394413

395-
/// A single per-kind cap in `1..=ceiling`. The ceiling is well within `usize` on every supported
414+
/// The fewest contexts a kind may be capped at. The pool seeds every kind with one context carrying an
415+
/// invalid UTF-8 byte and one without, and both count against the cap.
416+
pub const MIN_CONTEXTS_PER_KIND: usize = 2;
417+
418+
/// A single per-kind cap in `2..=ceiling`. The ceiling is well within `usize` on every supported
396419
/// target, so the saturating conversion is unreachable in practice.
397420
fn sample_cap<R: Rng + ?Sized>(rng: &mut R, ceiling: u64) -> usize {
398-
usize::try_from(Probe::new(1, ceiling.max(1)).sample(rng)).unwrap_or(usize::MAX)
421+
usize::try_from(Probe::new(2, ceiling.max(2)).sample(rng)).unwrap_or(usize::MAX)
399422
}
400423

401424
#[cfg(test)]
@@ -450,14 +473,29 @@ mod tests {
450473
yaml.lines().any(|line| line.starts_with(&format!("{key}:")))
451474
}
452475

476+
// A cap below what the pool seeds would make the pool exceed its own cap assertion and redden the
477+
// run on a config value. Rejected at the boundary, as the sibling driver config is.
478+
#[test]
479+
fn context_source_read_rejects_a_cap_below_the_seeded_minimum() {
480+
let dir = std::env::temp_dir().join(format!("ctxcfg-{}", std::process::id()));
481+
std::fs::create_dir_all(&dir).expect("create temp dir");
482+
std::fs::write(
483+
dir.join("context_source.yaml"),
484+
"datagram_byte_limit: 8192\nmetric_contexts: 1\nevent_contexts: 4\nservice_check_contexts: 4\n",
485+
)
486+
.expect("write config");
487+
let err = ContextSourceConfig::read(&dir).expect_err("a cap of 1 must be rejected");
488+
assert!(err.to_string().contains("metric_contexts"), "{err}");
489+
}
490+
453491
#[test]
454492
fn driver_config_caps_payload_to_the_smaller_bound() {
455-
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 512).payload_byte_limit, 512);
493+
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 512).datagram_byte_limit, 512);
456494
assert_eq!(
457-
DriverConfig::sample(&mut SeqRng(0), 1 << 30).payload_byte_limit,
458-
PAYLOAD_BYTE_LIMIT
495+
DriverConfig::sample(&mut SeqRng(0), 1 << 30).datagram_byte_limit,
496+
DATAGRAM_BYTE_LIMIT
459497
);
460-
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 0).payload_byte_limit, 0);
498+
assert_eq!(DriverConfig::sample(&mut SeqRng(0), 0).datagram_byte_limit, 0);
461499
}
462500

463501
#[test]
@@ -511,8 +549,14 @@ mod tests {
511549
let caps = ContextSourceConfig::sample(&mut SeqRng(seed), 8_192);
512550
let total = (caps.metric_contexts + caps.event_contexts + caps.service_check_contexts) as u64;
513551
assert!(total <= MAX_CONTEXTS_TOTAL, "seed {seed} sampled {total}");
514-
// A kind of zero panics the pool's `random_range(0..0)`, so every kind keeps at least one.
515-
assert!(caps.metric_contexts >= 1 && caps.event_contexts >= 1 && caps.service_check_contexts >= 1);
552+
// The pool seeds every kind with one context carrying an invalid UTF-8 byte and one without,
553+
// and both count against the cap, so a kind capped below two makes the pool exceed its own
554+
// cap assertion.
555+
assert!(
556+
caps.metric_contexts >= MIN_CONTEXTS_PER_KIND
557+
&& caps.event_contexts >= MIN_CONTEXTS_PER_KIND
558+
&& caps.service_check_contexts >= MIN_CONTEXTS_PER_KIND
559+
);
516560
}
517561
}
518562

0 commit comments

Comments
 (0)