antithesis_intake/context_pool.rs
1//! The context pool: per-kind bounded, lazily-filled sets the differential drivers draw from so
2//! contexts recur across flushes.
3//!
4//! Every driver invocation is a fresh process; without coordination each would mint its own contexts
5//! and the space would grow without bound and never recur. The pool holds one shared set per kind
6//! behind a hard per-kind cap: it mints a new context of a sampled kind while that kind is under its
7//! cap, then draws an existing one of that kind at random. Once a kind's cumulative requests exceed
8//! its cap the set is exhausted and its contexts recur across flushes, which is what gives the
9//! differential oracle multi-point curves to align.
10//!
11//! The caps are read from `context_source.yaml` on the first [`Pool::serve`] call. The intake starts
12//! before `first_sample_config` samples that file, but the first `/contexts` request only ever comes
13//! from a driver, which runs after `first_sample_config` — so the config is present by then.
14//!
15//! A kind serves an existing context for one of two reasons and the pool tells them apart. Reaching the
16//! cap is the configured end state. Spending every mint try on a duplicate is a crowded alphabet, which
17//! is counted per kind and reported so a cap that fills slowly is visible. It is never latched: one
18//! unlucky streak must not pin a kind below its cap for the rest of the run. A budget too small to hold
19//! a kind's smallest identity is neither of those, and errors.
20//!
21//! One pull in `NON_UTF8_PULL_RATE` leads with a context carrying an invalid UTF-8 byte. A driver pulls
22//! once per invocation and leads every datagram it sends with that context, so a carrying pull puts the
23//! byte in all of its datagrams and any other pull in none. How many datagrams an invocation sends is
24//! sampled independently of what its pull holds, so across a run the fraction of datagrams carrying the
25//! byte is the fraction of pulls that do. Deciding it here rather than in the driver is what makes the
26//! rate independent of how many contexts a timeline sampled: a driver holding one context gets the same
27//! 1% as one holding a thousand. Such a context is a pool member like any other, minted against the same
28//! budget, deduplicated, and counted against its kind's cap, so bounded cardinality is unaffected.
29
30use std::collections::hash_map::DefaultHasher;
31use std::collections::HashSet;
32use std::hash::{Hash, Hasher};
33use std::path::PathBuf;
34use std::sync::{Mutex, PoisonError};
35
36use antithesis_sdk::prelude::*;
37use anyhow::Context as _;
38use harness::config::ContextSourceConfig;
39use harness::contexts::{Context, Kind};
40use rand::{Rng, RngExt};
41use serde_json::json;
42
43/// A per-kind bounded, lazily-filled pool of contexts.
44#[derive(Debug)]
45pub struct Pool {
46 /// Directory holding `context_source.yaml`, read once on the first serve.
47 config_dir: PathBuf,
48 /// The resolved caps and the minted contexts, behind one lock.
49 state: Mutex<PoolState>,
50}
51
52/// The pool's mutable state: the resolved per-kind caps and the minted contexts.
53#[derive(Debug, Default)]
54struct PoolState {
55 /// The per-kind caps, resolved from the config on the first serve.
56 caps: Option<ContextSourceConfig>,
57 /// Minted metric contexts, grown to the metric cap then drawn from.
58 metric: KindPool,
59 /// Minted event contexts.
60 event: KindPool,
61 /// Minted service-check contexts.
62 service_check: KindPool,
63 /// Requests per kind that spent every mint try on a duplicate, by [`Kind`] order. A streak says the
64 /// budget's alphabet is crowded against the cap, not that it is spent, so the kind keeps minting.
65 collision_streaks: [u64; 3],
66 /// Hashes of every context held, so a duplicate mint does not spend a cap slot. Hashes rather
67 /// than the contexts themselves, since the pool already holds up to a million of them and a
68 /// second copy would double that. A hash collision rejects a distinct identity, which costs one
69 /// remint and nothing else.
70 seen: HashSet<u64>,
71}
72
73impl PoolState {
74 /// Mint one context of each sort for every kind, before any pull is served. A pull that owes the
75 /// invalid byte must find a context of the right kind carrying it, and a pull that does not must find
76 /// one without it, so neither half may be empty once the cap is spent. Every cap is at least two,
77 /// which is what makes room for both, and these seeds count against the cap like any other context.
78 ///
79 /// # Errors
80 ///
81 /// Returns an error when the budget cannot hold a kind's smallest identity.
82 fn seed_halves<R: Rng + ?Sized>(&mut self, caps: ContextSourceConfig, rng: &mut R) -> anyhow::Result<()> {
83 let budget = caps.datagram_byte_limit.saturating_sub(1);
84 // Minted into locals and committed only once all six succeed. Pushing as it goes would leave a
85 // half-seeded pool behind on a failure, and the caller retries, so every retry would stack
86 // another partial seeding against the cap.
87 let mut seeded = Vec::with_capacity(6);
88 for kind in [Kind::Metric, Kind::Event, Kind::ServiceCheck] {
89 for non_utf8 in [false, true] {
90 let context = if non_utf8 {
91 Context::mint_non_utf8_within(kind, rng, budget)
92 } else {
93 Context::mint_within(kind, rng, budget)
94 }
95 .with_context(|| {
96 format!(
97 "no {kind:?} identity could be minted within the datagram budget {budget}, so this \
98 timeline's context source and datagram limit contradict each other"
99 )
100 })?;
101 seeded.push((kind, non_utf8, context));
102 }
103 }
104 for (kind, non_utf8, context) in seeded {
105 let pool = match kind {
106 Kind::Metric => &mut self.metric,
107 Kind::Event => &mut self.event,
108 Kind::ServiceCheck => &mut self.service_check,
109 };
110 self.seen.insert(digest(&context));
111 pool.half(non_utf8).push(context);
112 }
113 Ok(())
114 }
115}
116
117/// One kind's minted contexts, split by whether they carry an invalid UTF-8 byte. The split is storage,
118/// not policy: a pull needs to reach one of each without scanning up to a million members.
119#[derive(Debug, Default)]
120struct KindPool {
121 /// Contexts whose every field is valid UTF-8.
122 utf8: Vec<Context>,
123 /// Contexts carrying an invalid UTF-8 byte in a name or a tag.
124 non_utf8: Vec<Context>,
125}
126
127impl KindPool {
128 /// Contexts held, both halves, which is what a cap counts.
129 fn len(&self) -> usize {
130 self.utf8.len() + self.non_utf8.len()
131 }
132
133 /// Every context held, both halves.
134 #[cfg(test)]
135 fn iter(&self) -> impl Iterator<Item = &Context> {
136 self.utf8.iter().chain(self.non_utf8.iter())
137 }
138
139 /// The half a slot of this sort draws from.
140 fn half(&mut self, non_utf8: bool) -> &mut Vec<Context> {
141 if non_utf8 {
142 &mut self.non_utf8
143 } else {
144 &mut self.utf8
145 }
146 }
147}
148
149/// How many times a duplicate mint is retried before the slot is left for a later request. A small
150/// alphabet under a tight budget collides often, and spinning here would stall the serve.
151const MINT_TRIES: usize = 8;
152
153/// One pull in this many leads with a context carrying an invalid UTF-8 byte, which makes one datagram in
154/// this many carry one.
155const NON_UTF8_PULL_RATE: u32 = 100;
156
157/// The hash a pooled identity is deduplicated by.
158fn digest(context: &Context) -> u64 {
159 let mut hasher = DefaultHasher::new();
160 context.hash(&mut hasher);
161 hasher.finish()
162}
163
164impl Pool {
165 /// A pool that resolves its caps from `context_source.yaml` in `config_dir` on the first serve.
166 #[must_use]
167 pub fn new(config_dir: PathBuf) -> Self {
168 Self {
169 config_dir,
170 state: Mutex::new(PoolState::default()),
171 }
172 }
173
174 /// Serve `n` contexts: for each slot sample a kind, mint a fresh one while that kind is under its
175 /// cap, else draw an existing one of that kind. The whole operation holds the lock, so concurrent
176 /// requests never race on `rng` or overshoot a cap.
177 ///
178 /// # Errors
179 ///
180 /// Returns an error if `context_source.yaml` cannot be read on the first call.
181 pub fn serve<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> anyhow::Result<Vec<Context>> {
182 // A poisoned lock still holds a valid pool; recover it rather than panic.
183 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
184
185 let caps = if let Some(caps) = state.caps {
186 caps
187 } else {
188 let resolved =
189 ContextSourceConfig::read(&self.config_dir).context("read context source config for the pool caps")?;
190 state.seed_halves(resolved, rng)?;
191 state.caps = Some(resolved);
192 resolved
193 };
194
195 let mut out = Vec::with_capacity(n);
196 let mut served_existing = false;
197 // Settled once for the whole pull, and led with, so the byte cannot be lost to a full datagram.
198 let non_utf8_pull = rng.random_range(0..NON_UTF8_PULL_RATE) == 0;
199 for slot in 0..n {
200 let non_utf8 = non_utf8_pull && slot == 0;
201 let kind = Kind::sample(rng);
202 let PoolState {
203 metric,
204 event,
205 service_check,
206 seen,
207 collision_streaks,
208 ..
209 } = &mut *state;
210 let (pool, cap, streaks) = match kind {
211 Kind::Metric => (metric, caps.metric_contexts, &mut collision_streaks[0]),
212 Kind::Event => (event, caps.event_contexts, &mut collision_streaks[1]),
213 Kind::ServiceCheck => (service_check, caps.service_check_contexts, &mut collision_streaks[2]),
214 };
215 // The identity is minted against this timeline's real datagram budget less the newline
216 // every packed line costs, so every served context has a rendering the driver can pack.
217 // A duplicate identity would spend a cap slot without adding a distinct context, so the
218 // kind would stop minting early and the run would explore fewer identities than configured.
219 // Remint on a duplicate instead of pushing it.
220 let mut minted: Option<Context> = None;
221 let budget = caps.datagram_byte_limit.saturating_sub(1);
222 if pool.len() < cap {
223 for try_index in 0..MINT_TRIES {
224 // The byte is minted into the identity. Editing a rendered datagram instead would
225 // mint an identity the pool never issued and the cap never counted, one per edited
226 // datagram, which is how bounded cardinality leaks.
227 let candidate = if non_utf8 {
228 Context::mint_non_utf8_within(kind, rng, budget)
229 } else {
230 Context::mint_within(kind, rng, budget)
231 };
232 // A mint yields nothing either because the budget cannot hold the kind's smallest
233 // identity, which is a contradiction between this timeline's context source and its
234 // datagram limit, or because its own probe loop ran out of tries on content. Only the
235 // first is a config fault. The second is the same bad luck as a duplicate streak and
236 // gets the same treatment: count it and let the slot recur.
237 let Some(context) = candidate else {
238 anyhow::ensure!(
239 Context::mint_within(kind, rng, budget).is_some(),
240 "datagram budget {budget} cannot hold the smallest {kind:?} identity, so this \
241 timeline's context source and datagram limit contradict each other"
242 );
243 *streaks += 1;
244 break;
245 };
246 if seen.insert(digest(&context)) {
247 minted = Some(context);
248 break;
249 }
250 // Every try collided. That is a crowded alphabet, not a spent one, so the kind stays
251 // free to mint on the next request. Latching it here would let one unlucky streak
252 // pin a kind below its cap for the rest of the run. Counted so a cap that fills
253 // slowly is visible rather than a mystery.
254 if try_index + 1 == MINT_TRIES {
255 *streaks += 1;
256 }
257 }
258 }
259 if let Some(context) = minted {
260 pool.half(non_utf8).push(context.clone());
261 out.push(context);
262 } else {
263 // A kind at its cap, or one whose tries all collided, recurs, which is what gives the
264 // oracle its curves. Both halves were seeded when the caps resolved, so neither is ever
265 // empty and a slot owed the byte is never served a context without it.
266 let half = pool.half(non_utf8);
267 let context = half
268 .get(rng.random_range(0..half.len().max(1)))
269 .with_context(|| {
270 format!(
271 "{kind:?} pool holds no {} context to serve",
272 if non_utf8 { "non-UTF-8" } else { "UTF-8" }
273 )
274 })?
275 .clone();
276 served_existing = true;
277 out.push(context);
278 }
279 }
280 let collision_streaks = state.collision_streaks;
281 let metric = state.metric.len();
282 let event = state.event.len();
283 let service_check = state.service_check.len();
284 drop(state);
285
286 assert_always!(
287 metric <= caps.metric_contexts
288 && event <= caps.event_contexts
289 && service_check <= caps.service_check_contexts,
290 "context pool never exceeds its per-kind caps",
291 &json!({
292 "metric": metric,
293 "event": event,
294 "service_check": service_check,
295 "caps": { "metric": caps.metric_contexts, "event": caps.event_contexts, "service_check": caps.service_check_contexts },
296 // Requests that spent every mint try on a duplicate. Reported rather than asserted on: a
297 // cap near what the budget's alphabet can express collides legitimately, so a run that
298 // hits it is not faulty, but a cap that fills slowly has to say why.
299 "collision_streaks": { "metric": collision_streaks[0], "event": collision_streaks[1], "service_check": collision_streaks[2] }
300 })
301 );
302 assert_sometimes!(served_existing, "context source served an existing context", &json!({}));
303 Ok(out)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use std::collections::BTreeSet;
310 use std::collections::HashSet;
311 use std::path::PathBuf;
312 use std::sync::atomic::{AtomicUsize, Ordering};
313
314 use harness::config::ContextSourceConfig;
315 use harness::contexts::{decode_response, encode_response, Context};
316 use rand::rngs::SmallRng;
317 use rand::SeedableRng;
318
319 use super::Pool;
320
321 /// Write a `context_source.yaml` with the given per-kind caps into a fresh temp dir.
322 fn temp_config(metric: usize, event: usize, service_check: usize) -> PathBuf {
323 temp_config_limited(metric, event, service_check, 8_192)
324 }
325
326 /// The same, for a timeline whose datagram limit is `datagram_byte_limit`. A pool mints against that
327 /// limit, so a test that packs must use the same one.
328 fn temp_config_limited(metric: usize, event: usize, service_check: usize, datagram_byte_limit: usize) -> PathBuf {
329 static SEQ: AtomicUsize = AtomicUsize::new(0);
330 let dir = std::env::temp_dir().join(format!(
331 "ctxpool-{}-{}",
332 std::process::id(),
333 SEQ.fetch_add(1, Ordering::Relaxed)
334 ));
335 std::fs::create_dir_all(&dir).expect("create temp config dir");
336 let config = ContextSourceConfig {
337 datagram_byte_limit,
338 metric_contexts: metric,
339 event_contexts: event,
340 service_check_contexts: service_check,
341 };
342 std::fs::write(
343 dir.join("context_source.yaml"),
344 config.to_yaml().expect("render config"),
345 )
346 .expect("write config");
347 dir
348 }
349
350 #[test]
351 fn fills_to_caps_then_repeats() {
352 let mut rng = SmallRng::seed_from_u64(0);
353 // Small metric cap so metric contexts recur within the run.
354 let pool = Pool::new(temp_config(4, 1_000, 1_000));
355
356 let mut metric_wire = BTreeSet::new();
357 let mut metric_total = 0;
358 for _ in 0..50 {
359 for context in pool.serve(5, &mut rng).expect("serve") {
360 if let Context::Metric(_) = context {
361 let mut wire = Vec::new();
362 context.encode(&mut wire);
363 metric_wire.insert(wire);
364 metric_total += 1;
365 }
366 }
367 }
368 assert!(
369 metric_wire.len() <= 4,
370 "distinct metric {} exceeds cap 4",
371 metric_wire.len()
372 );
373 assert!(metric_total > metric_wire.len(), "expected metric repeats");
374 }
375
376 // A cap counts distinct identities. A duplicate mint that consumed a slot would stop the kind
377 // minting early and leave the run exploring fewer identities than configured.
378 #[test]
379 fn pooled_contexts_are_distinct() {
380 let mut rng = SmallRng::seed_from_u64(11);
381 let pool = Pool::new(temp_config(64, 64, 64));
382 let mut all = Vec::new();
383 for _ in 0..16 {
384 all.extend(pool.serve(32, &mut rng).expect("serve"));
385 }
386 let state = pool.state.lock().expect("lock");
387 for held in [&state.metric, &state.event, &state.service_check] {
388 let distinct: HashSet<&Context> = held.iter().collect();
389 assert_eq!(distinct.len(), held.len(), "a kind holds a duplicate identity");
390 }
391 }
392
393 // A collision streak must not pin a kind below its cap. A crowded alphabet collides often, and a
394 // latched flag would stop the kind minting for the rest of the run on one unlucky request.
395 #[test]
396 fn a_collision_streak_does_not_stop_minting() {
397 let mut rng = SmallRng::seed_from_u64(31);
398 let pool = Pool::new(temp_config(4_096, 4_096, 4_096));
399 for _ in 0..40 {
400 pool.serve(64, &mut rng).expect("serve");
401 }
402 let state = pool.state.lock().expect("lock");
403 let streaks: u64 = state.collision_streaks.iter().sum();
404 let minted = state.metric.len() + state.event.len() + state.service_check.len();
405 assert!(
406 minted > 64,
407 "the pool stopped minting after {streaks} collision streaks, holding {minted}"
408 );
409 }
410
411 // The rate the whole design rests on. One pull in a hundred carries a context with an invalid UTF-8
412 // byte, and a pull is one driver invocation, so this is the datagram rate too: the harness pins that
413 // a carrying pull puts the byte in every datagram it packs and a non-carrying one in none.
414 #[test]
415 fn one_pull_in_a_hundred_carries_non_utf8() {
416 use harness::payload::dogstatsd::Pull;
417
418 let mut rng = SmallRng::seed_from_u64(9);
419 let pool = Pool::new(temp_config(1_000_000, 1_000_000, 1_000_000));
420 let pulls = 5_000usize;
421 let mut carrying = 0usize;
422 for _ in 0..pulls {
423 let contexts = pool.serve(1, &mut rng).expect("serve");
424 if Pull::new(contexts).expect("non-empty").carries_non_utf8() {
425 carrying += 1;
426 }
427 }
428 // 0.25% to 1.25%, as integers so the bound carries no rounding of its own.
429 assert!(
430 carrying * 400 >= pulls && carrying * 80 <= pulls,
431 "pulls carrying non-UTF-8 {carrying}/{pulls}, expected ~1%"
432 );
433 }
434
435 // Once a kind is at its cap, every pull recurs rather than mints, and the half selection in that
436 // branch is the only thing that still makes a carrying pull carry. A run spends nearly all of its
437 // life there, so the rate has to survive the cap being spent.
438 #[test]
439 fn the_rate_holds_after_the_caps_fill() {
440 use harness::payload::dogstatsd::Pull;
441
442 let mut rng = SmallRng::seed_from_u64(21);
443 let pool = Pool::new(temp_config(4, 4, 4));
444 // Spend the caps first, so nothing below mints.
445 for _ in 0..50 {
446 pool.serve(8, &mut rng).expect("serve");
447 }
448 let pulls = 5_000usize;
449 let mut carrying = 0usize;
450 for _ in 0..pulls {
451 let contexts = pool.serve(1, &mut rng).expect("serve");
452 if Pull::new(contexts).expect("non-empty").carries_non_utf8() {
453 carrying += 1;
454 }
455 }
456 assert!(
457 carrying * 400 >= pulls && carrying * 80 <= pulls,
458 "after the caps filled, pulls carrying non-UTF-8 {carrying}/{pulls}, expected ~1%"
459 );
460 let state = pool.state.lock().expect("lock");
461 for held in [&state.metric, &state.event, &state.service_check] {
462 assert!(!held.non_utf8.is_empty(), "a kind holds no non-UTF-8 context");
463 assert!(!held.utf8.is_empty(), "a kind holds no UTF-8 context");
464 }
465 }
466
467 #[test]
468 fn serves_exactly_n_and_round_trips_the_wire() {
469 let mut rng = SmallRng::seed_from_u64(7);
470 let pool = Pool::new(temp_config(1_000, 1_000, 1_000));
471 let contexts = pool.serve(9, &mut rng).expect("serve");
472 assert_eq!(contexts.len(), 9);
473
474 let wire = encode_response(&contexts);
475 let decoded = decode_response(&wire);
476 assert_eq!(decoded.as_deref(), Some(contexts.as_slice()));
477 }
478
479 #[test]
480 fn missing_config_is_an_error() {
481 let mut rng = SmallRng::seed_from_u64(1);
482 let pool = Pool::new(std::env::temp_dir().join("ctxpool-does-not-exist"));
483 assert!(pool.serve(1, &mut rng).is_err());
484 }
485}