@@ -245,6 +245,13 @@ cloud_provider_metadata: []
245245/// Upper bound on datagrams one driver invocation ships in a timeline.
246246const 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
261276impl 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) ]
300402mod 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