Skip to content

Commit 966965a

Browse files
committed
chore(antithesis): Bound contexts in runs
This commit introduces a new protocol to the intake node to allow for context bounding in runs. The purposes are twofold. First, while it's interesting to explore timelines in which there are too many contexts for either lane to handle these are less relevant to me _now_, so I've capped total contexts at 1M. Second, in order for timeseries equality to _work_ I need to be certain that I have at least _some_ timeseries with more than one point emitted into them. Previously drivers were emitting essentially random contexts and it was very unlikely for more than one point to be emitted per context. Oops. Drivers now request N contexts from the intake service for each sub-kind of dogstatsd they emit, meaning all contexts are sourced from a single spot in a topology and not from pure randomness.
1 parent 8f3b592 commit 966965a

27 files changed

Lines changed: 1743 additions & 486 deletions

Cargo.lock

Lines changed: 5 additions & 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ itoa = { workspace = true }
2020
libc = { workspace = true }
2121
num-traits = { workspace = true }
2222
rand = { workspace = true }
23+
reqwest = { workspace = true, features = ["blocking"] }
2324
ryu = { workspace = true }
2425
serde = { workspace = true }
2526
serde_json = { workspace = true }
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 671f360ca24ca5add047c2f93acf185116ebfa9c663637a430a15fa6abc56151 # shrinks to seed = 8436120490560261333, kind = Metric

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use antithesis_sdk::prelude::*;
1414
use antithesis_sdk::random::AntithesisRng;
1515
use anyhow::Context;
1616
use clap::Parser;
17-
use harness::config::DatadogConfig;
17+
use harness::config::{ContextSourceConfig, DatadogConfig};
1818
use rand::rand_core::UnwrapErr;
1919
use serde_json::json;
2020

@@ -63,10 +63,22 @@ fn main() -> anyhow::Result<()> {
6363

6464
// Load-generator view of the same sample, so a generator caps its datagrams
6565
// to the SUT's receive buffer without reading the Agent config.
66+
let driver = config.driver_config(&mut rng);
6667
let driver_path = cli.config_dir.join("driver.yaml");
67-
fs::write(&driver_path, config.driver_config(&mut rng).to_yaml()?.as_bytes())
68+
fs::write(&driver_path, driver.to_yaml()?.as_bytes())
6869
.with_context(|| format!("write driver config {}", driver_path.display()))?;
6970

71+
// Per-kind context-pool caps for this timeline, read lazily by the intake's shared pool. Sampled
72+
// here so the pool's cardinality varies per timeline like every other sampled knob.
73+
let context_source_path = cli.config_dir.join("context_source.yaml");
74+
fs::write(
75+
&context_source_path,
76+
ContextSourceConfig::sample(&mut rng, driver.payload_byte_limit)
77+
.to_yaml()?
78+
.as_bytes(),
79+
)
80+
.with_context(|| format!("write context source config {}", context_source_path.display()))?;
81+
7082
// Per-timeline anchor: counting these in triage tells us how many distinct
7183
// configs the run sampled.
7284
let details = serde_json::to_value(&config).unwrap_or_else(|e| json!({ "serialize_error": e.to_string() }));

test/antithesis/harness/src/config.rs

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,13 @@ cloud_provider_metadata: []
245245
/// Upper bound on datagrams one driver invocation ships in a timeline.
246246
const MAX_DATAGRAMS: usize = 10_000;
247247

248+
/// Upper bound on the working set one driver invocation fetches from the shared context pool.
249+
const MAX_WORKING_SET: u64 = 1_024;
250+
251+
/// The intake's ceiling on one `/contexts` request. A `context_count` past it is rejected there, so a
252+
/// config carrying one is rejected here instead.
253+
const MAX_CONTEXTS_PER_REQUEST: usize = 65_536;
254+
248255
/// Config a load generator reads to shape its output to this timeline's SUT.
249256
/// `first_sample_config` samples it beside `datadog.yaml` from one draw, so the
250257
/// generator and the SUT are driven together.
@@ -256,6 +263,14 @@ pub struct DriverConfig {
256263
pub payload_byte_limit: usize,
257264
/// Datagrams a driver invocation ships this timeline.
258265
pub datagram_count: usize,
266+
/// Distinct contexts a driver invocation fetches from the shared pool as its working set.
267+
///
268+
/// Sampled boundary-biased log-uniform in `1..=1_024`. Valid values run `1..=65_536`, the intake's
269+
/// per-request ceiling. Outside that the intake rejects every `/contexts` request, the driver waits
270+
/// 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.
273+
pub context_count: usize,
259274
}
260275

261276
impl DriverConfig {
@@ -270,6 +285,7 @@ impl DriverConfig {
270285
Self {
271286
payload_byte_limit,
272287
datagram_count: rng.random_range(0..=MAX_DATAGRAMS),
288+
context_count: usize::try_from(Probe::new(1, MAX_WORKING_SET).sample(rng)).unwrap_or(usize::MAX),
273289
}
274290
}
275291

@@ -292,10 +308,96 @@ impl DriverConfig {
292308
pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
293309
let path = config_dir.join("driver.yaml");
294310
let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
295-
serde_yaml::from_str(&yaml).with_context(|| format!("parse driver config from {}", path.display()))
311+
let config: Self =
312+
serde_yaml::from_str(&yaml).with_context(|| format!("parse driver config from {}", path.display()))?;
313+
anyhow::ensure!(
314+
(1..=MAX_CONTEXTS_PER_REQUEST).contains(&config.context_count),
315+
"context_count {} outside 1..={} in {}, the intake would reject every fetch and the driver would ship nothing",
316+
config.context_count,
317+
MAX_CONTEXTS_PER_REQUEST,
318+
path.display()
319+
);
320+
Ok(config)
321+
}
322+
}
323+
324+
/// Upper bound on the distinct contexts a shared pool holds across every kind. The pool retains each
325+
/// minted context, so the ceiling belongs to the total rather than to any one kind.
326+
const MAX_CONTEXTS_TOTAL: u64 = 1_000_000;
327+
328+
/// The per-kind caps a timeline's shared context pool fills to before it recurs existing contexts.
329+
/// `first_sample_config` samples this beside `datadog.yaml` so cardinality varies per timeline. Each
330+
/// 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+
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
333+
pub struct ContextSourceConfig {
334+
/// Bytes a rendered line of any pooled context must fit, this timeline's real datagram budget
335+
/// 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,
339+
/// Distinct metric contexts the pool holds before it recurs the ones it has.
340+
///
341+
/// Sampled in `1..=MAX_CONTEXTS_TOTAL` minus what the other kinds took. A larger cap explores more
342+
/// 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+
pub metric_contexts: usize,
345+
/// Distinct event contexts the pool holds. Same range and trade as [`Self::metric_contexts`].
346+
pub event_contexts: usize,
347+
/// Distinct service-check contexts the pool holds. Same range and trade as
348+
/// [`Self::metric_contexts`].
349+
pub service_check_contexts: usize,
350+
}
351+
352+
impl ContextSourceConfig {
353+
/// Sample the per-kind caps, each boundary-biased log-uniform against the budget still free.
354+
///
355+
/// The draws run in order and each spends from one shared ceiling, so the total is bounded by
356+
/// 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.
358+
#[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+
let free = MAX_CONTEXTS_TOTAL - metric_contexts as u64;
363+
let event_contexts = sample_cap(rng, free - 1);
364+
let free = free - event_contexts as u64;
365+
Self {
366+
payload_byte_limit,
367+
metric_contexts,
368+
event_contexts,
369+
service_check_contexts: sample_cap(rng, free),
370+
}
371+
}
372+
373+
/// Render `self` as a `context_source.yaml` string.
374+
///
375+
/// # Errors
376+
///
377+
/// Returns an error if serialization fails.
378+
pub fn to_yaml(&self) -> anyhow::Result<String> {
379+
serde_yaml::to_string(self).context("serialize context_source.yaml")
380+
}
381+
382+
/// Read the context-source config from the `context_source.yaml` that `first_sample_config` wrote
383+
/// to `config_dir`.
384+
///
385+
/// # Errors
386+
///
387+
/// Returns an error if the config is unreadable or is not valid YAML.
388+
pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
389+
let path = config_dir.join("context_source.yaml");
390+
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()))
296392
}
297393
}
298394

395+
/// A single per-kind cap in `1..=ceiling`. The ceiling is well within `usize` on every supported
396+
/// target, so the saturating conversion is unreachable in practice.
397+
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)
399+
}
400+
299401
#[cfg(test)]
300402
mod tests {
301403
use std::collections::BTreeSet;
@@ -401,6 +503,28 @@ mod tests {
401503
assert_eq!(seen, [true, true]);
402504
}
403505

506+
// The pool holds every minted context, so the ceiling is on the total across kinds rather than on
507+
// each kind alone. Three independent draws at the ceiling would retain three million.
508+
#[test]
509+
fn context_caps_sum_within_the_total_ceiling() {
510+
for seed in 0..64 {
511+
let caps = ContextSourceConfig::sample(&mut SeqRng(seed), 8_192);
512+
let total = (caps.metric_contexts + caps.event_contexts + caps.service_check_contexts) as u64;
513+
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);
516+
}
517+
}
518+
519+
// Randomness still drives each kind rather than the total being split evenly.
520+
#[test]
521+
fn context_caps_vary_per_kind() {
522+
let spread: BTreeSet<usize> = (0..64)
523+
.map(|seed| ContextSourceConfig::sample(&mut SeqRng(seed), 8_192).metric_contexts)
524+
.collect();
525+
assert!(spread.len() > 8, "metric cap barely varies: {spread:?}");
526+
}
527+
404528
#[test]
405529
fn compressor_samples_every_kind() {
406530
let mut seen = BTreeSet::new();

0 commit comments

Comments
 (0)