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}