@@ -15,7 +15,7 @@ use rand::distr::{Distribution, StandardUniform};
1515use rand:: { Rng , RngExt } ;
1616use serde:: { Deserialize , Serialize } ;
1717
18- use crate :: payload:: dogstatsd:: PAYLOAD_BYTE_LIMIT ;
18+ use crate :: payload:: dogstatsd:: DATAGRAM_BYTE_LIMIT ;
1919use 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 ) ]
259259pub 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
276277impl 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 ) ]
333334pub 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.
397420fn 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\n metric_contexts: 1\n event_contexts: 4\n service_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