thread_broker/lib.rs
1//! Dividing one thread budget between decoding and the work that consumes it.
2//!
3//! # The problem
4//!
5//! A tool that reads compressed input has to split `-t` between threads that
6//! decompress and threads that do the real work — mapping, aligning, counting.
7//! The split matters enormously: measured on a 150 M read-pair FASTQ archive at
8//! a fixed 64 threads, it was worth **5.75x**, while doubling the whole budget
9//! bought 9%.
10//!
11//! It is also unpredictable. The right answer moves by an order of magnitude
12//! with the workload — measured per-thread consumption ranged 0.064 GB/s on a
13//! 96.3 M-k-mer index to 0.43 GB/s on a 1.5 M-k-mer one, a 6.7x spread — so no
14//! constant spans it, and every attempt to *predict* it from a pre-run
15//! measurement landed 7-60% off the optimum depending on the workload.
16//!
17//! # The approach
18//!
19//! Do not search for the split. Solve for it.
20//!
21//! Writing `s_p` and `s_c` for the two stages' per-item costs in thread-time, a
22//! budget `N` finishes both sides together when `d / s_p = (N - d) / s_c`, so
23//!
24//! ```text
25//! d* = N * s_p / (s_p + s_c)
26//! ```
27//!
28//! and neither cost has to be measured on its own. Over a window in which the
29//! pipeline moved `X` items the producer spent `X * s_p` thread-nanoseconds and
30//! the consumer `X * s_c`, so their **ratio of busy times is the ratio of costs
31//! exactly** — the `X` cancels. The whole control law is
32//!
33//! ```text
34//! d* = N * busy_producer / (busy_producer + busy_consumer)
35//! ```
36//!
37//! measured over the same window. No shared unit of work is needed, and a run
38//! mixing compressed and uncompressed inputs is handled without a special case:
39//! the producer simply does less work, and the split follows.
40//!
41//! The one thing that makes any of this valid is measuring **busy time, not wall
42//! time**. Wall time contains blocking; blocking is a function of the split; so
43//! a controller built on wall time is measuring its own actuation. Busy time is
44//! a property of the work rather than of the current assignment, which is what
45//! makes the equation solvable rather than circular. DS2 (Kalavri et al.,
46//! OSDI'18) calls the distinction *true* versus *observed* rate. See [`Work`],
47//! where the contract lives.
48//!
49//! An earlier version of this crate searched instead — grow whichever side looks
50//! starved, one step at a time. It fails for a structural reason worth recording:
51//! consumer starvation reaches zero at the balance point and *stays* zero above
52//! it, so the target set is a half-line and the controller has no restoring force
53//! on it. Measured, it walked to 47 producer slots of 64 and ran **44% slower
54//! than the best split**, reporting near-zero error the whole way. See
55//! [`ProducerPressure`] and `notes/thread-broker/design.md`.
56//!
57//! A model can still be wrong — a producer reading from a saturated disk cannot
58//! use the slots it is given — so the jump is guarded: capped against observed
59//! saturation, ratified against measured throughput, and reverted if it made
60//! things worse. Both requirements — resizing either side mid-run, and both
61//! sides reporting their own busy time — are met by `paraseq` (consumer) and
62//! `rapidgzip` (producer).
63//!
64//! Measured against swept optima on a 10x Flex dataset: **5.0% off at `-t 32`
65//! (one move, settled in 1.4 s)** and **3.4% off at `-t 64` (three moves, 2.6 s)**.
66//!
67//! # What this crate is not
68//!
69//! It is not a thread pool. It owns no threads except the one that samples, and
70//! it creates no workers. It reads two signals and calls two setters. The pools
71//! themselves belong to the caller.
72//!
73//! # Wiring it up
74//!
75//! Implement [`Consumer`] for the side doing the real work and [`Producer`] for
76//! the decompressor, then let the broker run for the duration of the job:
77//!
78//! ```no_run
79//! # use thread_broker::*;
80//! # use std::time::Duration;
81//! # fn demo<C: Consumer + 'static, P: Producer + 'static>(mapper: C, decoders: P)
82//! # -> Result<(), Box<dyn std::error::Error>> {
83//! let broker = ThreadBroker::builder(mapper, decoders)
84//! .budget(32)
85//! .sample_interval(Duration::from_millis(100))
86//! .build()?;
87//!
88//! let running = broker.start()?;
89//! // ... run the job ...
90//! let report = running.finish()?;
91//! tracing::info!(
92//! "decode settled at {} of 32 threads after {:?}",
93//! report.final_producer_limit,
94//! report.time_to_converge,
95//! );
96//! # Ok(())
97//! # }
98//! ```
99//!
100//! # Minimum viable adoption
101//!
102//! A first integration does **not** need to tune every [`BrokerConfig`] field.
103//! The minimum contract is:
104//!
105//! 1. Define one real execution-slot budget shared by both pools. Construct the
106//! pools so every permitted split obeys that budget, choose any valid opening
107//! split, and set only `.budget(...)` plus real consumer/producer floors.
108//! 2. Implement cumulative [`Work`] counters on both adapters. Busy time must
109//! exclude queue/source blocking; counters must be monotonic; progress must
110//! publish often enough that ordinary decision windows are not empty.
111//! 3. Make resize acknowledgement truthful. [`Consumer::live_threads`] and
112//! [`Producer::active_slots`] must continue to include work admitted before a
113//! shrink until it actually retires. Batch geometry must let that happen
114//! inside [`BrokerConfig::resize_timeout`].
115//! 4. Implement [`Producer::pressure`] as a directional classification, not a
116//! desired thread count. If the producer has native cumulative busy time,
117//! the measurement-mode and monitoring hooks can keep their defaults.
118//! 5. Keep broker lifecycle failure separate from application correctness. A
119//! configuration error should fail before work starts; a runtime tuning
120//! failure normally leaves the last valid split in force and should not make
121//! successfully produced output disappear.
122//!
123//! Leave warm-up, sampling, smoothing, blackout, ratification, cap history,
124//! deadband, resurvey, regression tolerance, and [`OpeningPolicy`] at their
125//! defaults initially. Choose a [`SteadyStatePolicy`] only
126//! from the application's workload contract: responsive for possible regime
127//! changes, or a freeze policy for stable long runs where recurring work should
128//! become zero.
129//!
130//! Tune cadence or batch/progress granularity only after measurement shows a
131//! concrete failure: stale/empty decision windows, shrink acknowledgement near
132//! the timeout, poor oracle-gap/convergence results, or material overhead.
133//! Qualify a new adapter with canonical-output equality, a pinned-split sweep,
134//! source-bound and inelastic cases, a mid-run regime change, and both absolute
135//! administrative CPU and whole-process fractional overhead on representative
136//! long runs. That is the adopter boundary; modality-specific knobs belong in
137//! the application until repeated integrations demonstrate a generic policy.
138
139use std::sync::Arc;
140use std::sync::atomic::{AtomicBool, Ordering};
141use std::time::{Duration, Instant};
142
143mod controller;
144pub use controller::{
145 BrokerPhase, BrokerReport, Model, OpeningBracketOutcome, OpeningBracketReport,
146 ProducerCapReason, Rejection, RunningBroker,
147};
148
149/// Whether engaging a *parallel* producer can pay for the threads it takes.
150///
151/// # The economics this encodes
152///
153/// A consumer that decodes its own input inline — each worker inflating under
154/// its own stream's lock, then processing what it produced — is
155/// **work-conserving**: it is never idle, because a thread with less decode work
156/// simply does more consuming, rebalancing at batch granularity with no
157/// coordination. It gives `streams` concurrent decoders for free, where `streams`
158/// is the number of independent input streams.
159///
160/// A parallel producer gives `d` decoders but *takes* `d` threads away from
161/// consuming, and those threads can only decode. So it pays exactly when
162/// `d > streams` — when it can supply more concurrency than the consumer already
163/// gets for nothing.
164///
165/// # Why a threshold rather than that rule
166///
167/// `d` is what the broker *converges to*, so it is unknown when the decision has
168/// to be made. This is the budget-side proxy: with `budget` threads and
169/// `streams` inputs, the producer can only plausibly exceed `streams` decoders
170/// if the budget is several times the stream count.
171///
172/// # Provenance of the default
173///
174/// Measured on a single-cell Flex library (150 bp pairs, human index) and a bulk
175/// SE library (gencode), sweeping `budget` over `{8, 32, 64}` and `streams` over
176/// `{1..16}` — 16 cells. Inline decoding won every cell where `d <= streams` by
177/// 8–28%, and the parallel producer won every cell where `d > streams` by up to
178/// 4.2x. `budget >= 8 * streams` reproduces all 16 verdicts except one boundary
179/// cell that costs ~8%.
180///
181/// The default is calibrated on the *most* decode-bound workload available, so it
182/// errs toward inline decoding for anything less decode-heavy — the direction
183/// that protects a consumer-bound baseline. The asymmetry is deliberate: missing
184/// a parallel win costs up to 4.2x, taking one wrongly costs ~25%, but the
185/// wrongly-taken case is the one a user notices as a regression against not
186/// having the feature at all.
187///
188/// Override it when your workload's decode-to-consume ratio is known to differ.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
190#[serde(deny_unknown_fields, default)]
191pub struct EngagementPolicy {
192 /// Budget threads required per independent input stream before a parallel
193 /// producer is engaged at all.
194 ///
195 /// `0` always engages; a very large value never does.
196 pub min_threads_per_stream: usize,
197}
198
199impl Default for EngagementPolicy {
200 fn default() -> Self {
201 Self {
202 min_threads_per_stream: 8,
203 }
204 }
205}
206
207impl EngagementPolicy {
208 /// Engage a parallel producer for `streams` inputs under `budget` threads?
209 ///
210 /// `streams == 0` never engages: there is nothing for a parallel producer to
211 /// do, so it could only cost threads.
212 pub const fn engages(&self, budget: usize, streams: usize) -> bool {
213 streams != 0 && budget >= self.min_threads_per_stream.saturating_mul(streams)
214 }
215
216 /// Always engage, leaving the decision entirely to the broker.
217 ///
218 /// There is deliberately no `never()` counterpart. This type expresses a
219 /// *threshold*, and no threshold declines every budget — `usize::MAX` still
220 /// engages at a `usize::MAX` budget, so such a constructor would be a lie in
221 /// exactly the case someone would reach for it to be safe. A caller that
222 /// wants the producer off should not construct it at all; piscem exposes
223 /// that as `--decoder serial`.
224 pub const fn always() -> Self {
225 Self {
226 min_threads_per_stream: 0,
227 }
228 }
229}
230
231/// Cumulative work done by one stage.
232///
233/// # Busy time, never wall time
234///
235/// `busy_nanos` must be time a worker spent *doing the work*, with blocking
236/// excluded — a consumer must not count time waiting for input, a producer must
237/// not count time blocked on a full output queue or on the source.
238///
239/// This is the single most important contract in the crate, and the whole
240/// control law rests on it. Wall time contains blocking, and blocking is a
241/// function of the split being controlled, so a controller built on wall time
242/// feeds its own actuation back into its measurement. That endogeneity is what
243/// makes utilisation-style signals degenerate — see [`ProducerPressure`].
244///
245/// Busy time has the opposite property: over a window in which the pipeline
246/// moved `X` items, the two stages spent `X * s_producer` and `X * s_consumer`
247/// thread-nanoseconds respectively, so their **ratio is the ratio of per-item
248/// costs regardless of how many threads either side had**. That is the quantity
249/// the split is solved from, and it is why no shared unit of work is needed:
250/// the `X` cancels.
251///
252/// # Items are a progress measure, not a shared currency
253///
254/// `items` is used only to compare throughput at one split against throughput at
255/// another — for ratifying a move and for noticing a regime change. It need only
256/// be consistent over time within a stage. The two stages may count entirely
257/// different things (records here, decompressed bytes there) without affecting
258/// the split, because the split never divides one by the other.
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260pub struct Work {
261 /// Nanoseconds of real work, summed over this stage's workers.
262 pub busy_nanos: u64,
263 /// Progress in whatever unit is natural for this stage.
264 pub items: u64,
265}
266
267impl Work {
268 fn delta(self, prev: Work) -> Work {
269 Work {
270 busy_nanos: self.busy_nanos.saturating_sub(prev.busy_nanos),
271 items: self.items.saturating_sub(prev.items),
272 }
273 }
274}
275
276/// The side that consumes decoded bytes: mapping, aligning, counting.
277///
278/// Implementors resize a worker pool and report how much work it has done.
279pub trait Consumer: Send + Sync {
280 /// Ask for `n` worker threads. May take effect gradually.
281 ///
282 /// Returning success acknowledges the request, not its completion. The
283 /// broker observes [`Self::live_threads`] before spending the released
284 /// capacity on the producer.
285 fn set_threads(&self, n: usize) -> Result<(), ResizeError>;
286
287 /// Workers **actually running now**, which may lag what was asked for.
288 ///
289 /// Diagnostic only, and deliberately not "the target": the broker tracks
290 /// what it asked for itself. The gap between the two is what you want in a
291 /// log when a split looks wrong, because it separates "the broker decided
292 /// badly" from "the broker decided well and the pool has not caught up".
293 /// Reporting the target here would hide exactly that distinction.
294 fn live_threads(&self) -> usize;
295
296 /// Cumulative work. See [`Work`] for the two contracts that matter.
297 ///
298 /// Publish more often than once per batch. A counter updated only at batch
299 /// boundaries cannot be sampled faster than a batch completes, and a window
300 /// containing no completed batch reads as zero work — indistinguishable
301 /// from a totally idle consumer, and reporting maximum starvation for one
302 /// running flat out. [`BusyMeter`] handles this.
303 fn work(&self) -> Work;
304
305 /// Switch instrumentation between decision-quality and settled cadence.
306 ///
307 /// Most consumers can leave this as a no-op. Consumers for which publishing
308 /// progress is itself measurable work may use a finer cadence only while a
309 /// decision is open and a coarser cadence in steady state.
310 fn set_measurement_mode(&self, _mode: ProducerMeasurementMode) {}
311}
312
313/// The side that produces decoded bytes: a decompressor or pool of them.
314pub trait Producer: Send + Sync {
315 /// Ask for an aggregate concurrency limit of `n`.
316 ///
317 /// Returning success acknowledges the request, not the completion of work
318 /// already admitted under the previous limit.
319 fn set_limit(&self, n: usize) -> Result<(), ResizeError>;
320
321 /// The current limit.
322 fn limit(&self) -> usize;
323
324 /// Slots executing producer work now.
325 ///
326 /// This is the acknowledgement signal for a shrink. It must include work
327 /// admitted before a lower limit was requested and exclude coordinator or
328 /// I/O threads that do not consume controlled execution slots.
329 fn active_slots(&self) -> usize;
330
331 /// Current queued output in the same unit as [`Work::items`], when known.
332 ///
333 /// The controller rejects cost windows in which this buffer changes
334 /// materially, because the two stages then processed different amounts of
335 /// logical work and their busy-time ratio is not a per-item cost ratio.
336 fn buffered_items(&self) -> Option<u64> {
337 None
338 }
339
340 /// Live auxiliary threads outside the controlled execution-slot budget.
341 fn auxiliary_threads(&self) -> usize {
342 0
343 }
344
345 /// Select how aggressively an adapter should observe producer busy time.
346 ///
347 /// Calibration is requested only while the broker is gathering or
348 /// ratifying cost evidence. Monitoring is requested while resizing,
349 /// blacking out contaminated windows, or sitting at a settled split. The
350 /// default is a no-op because producers with native cumulative counters do
351 /// not need a sampling policy.
352 fn set_measurement_mode(&self, _mode: ProducerMeasurementMode) {}
353
354 /// Suggest an observation interval while the broker is settled.
355 ///
356 /// Producers with native cumulative counters may ignore this. An adapter
357 /// that reconstructs busy time by polling should reduce its own observation
358 /// rate when steady-state probes are far apart. It may clamp the request to
359 /// the minimum cadence needed for a useful estimate.
360 fn set_monitoring_interval(&self, _interval: Duration) {}
361
362 /// Optional diagnostics for a sampled producer measurement.
363 fn measurement_stats(&self) -> Option<ProducerMeasurementStats> {
364 None
365 }
366
367 /// Stop any private measurement adapter and return its final diagnostics.
368 ///
369 /// The controller calls this exactly once when it exits. Native cumulative
370 /// counters may use the default snapshot implementation. Sampled adapters
371 /// should join their sampler here so lifetime CPU accounting is complete
372 /// before the report is serialized.
373 fn finish_measurement(&self) -> Option<ProducerMeasurementStats> {
374 self.measurement_stats()
375 }
376
377 /// Why the producer is or is not keeping up.
378 ///
379 /// Used only as a directional guard; it never selects a target. Source-bound
380 /// or inelastic evidence may cap growth. A live [`ProducerPressure::Starved`]
381 /// signal may veto a model-requested shrink while the current limit is
382 /// demonstrably occupied, which protects against allocation-dependent cost
383 /// under-reporting. That exception can retain the current split but cannot
384 /// grow it or choose a larger one. Pressure signals are deliberately not
385 /// used to size the split because they saturate: they cannot distinguish
386 /// "just enough" from "far too much".
387 fn pressure(&self) -> ProducerPressure;
388
389 /// Cumulative work. See [`Work`].
390 fn work(&self) -> Work;
391}
392
393/// Requested fidelity for an adapter that reconstructs cumulative busy time
394/// from instantaneous observations.
395#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
396#[serde(rename_all = "snake_case")]
397pub enum ProducerMeasurementMode {
398 /// Short, high-resolution observation while a cost decision is open.
399 #[default]
400 Calibration,
401 /// Low-frequency observation sufficient to detect a possible regime change.
402 Monitoring,
403}
404
405/// What the broker does after it has established a stable split.
406///
407/// All variants share warm-up, cost measurement, guarded model resizing,
408/// blackout, ratification, and the first clean steady-state check. Responsive
409/// may additionally run an opt-in opening bracket. The two freeze variants make
410/// the calibration/overhead tradeoff explicit rather than overloading one
411/// ambiguous "freeze" setting.
412#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
413#[serde(rename_all = "snake_case")]
414pub enum SteadyStatePolicy {
415 /// Continue low-frequency observation and re-open the controller after a
416 /// persistent workload change. Also permits an opt-in opening bracket.
417 #[default]
418 Responsive,
419 /// Use the model-only path, then stop the controller and release recurring
420 /// measurement adapters after the first stable split is established.
421 ///
422 /// This makes the recurring broker cost zero for the rest of a stable run,
423 /// but intentionally gives up adaptation to later workload regimes. Use it
424 /// only when that tradeoff is known to fit the application.
425 FreezeAfterConvergence,
426 /// Complete the same opt-in opening calibration as
427 /// [`Responsive`](Self::Responsive), then stop the controller and release
428 /// recurring measurement adapters after the first stable split.
429 ///
430 /// This pays a bounded startup calibration cost in exchange for zero
431 /// recurring broker work. It cannot react to a later workload regime.
432 FreezeAfterFullCalibration,
433}
434
435/// How the broker treats the caller's initial split.
436///
437/// The default trusts the allocation-independent cost model. Applications
438/// with measured allocation-dependent scaling can instead ask the broker to
439/// confirm a disagreement between that model and the opening. The experiment
440/// is startup-only, bounded in points and wall time, and does no work when the
441/// first stable model answer agrees with the opening.
442#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
443pub enum OpeningPolicy {
444 /// Treat the opening as an initial hint and follow ordinary model moves.
445 #[default]
446 Fixed,
447 /// Confirm a model/opening disagreement with a bounded local bracket. The
448 /// model answer is rejected only when a statistically separated, material
449 /// throughput gain over the best measured opening/model rate is proven at
450 /// an adjacent point. An inconclusive or regressed comparison may trigger
451 /// that search unless independent empirical-cap evidence confirms the
452 /// model target. The opening is never a fallback; deadline-limited evidence
453 /// also retains the model.
454 Bracket(OpeningBracketConfig),
455}
456
457/// Bounds for [`OpeningPolicy::Bracket`].
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub struct OpeningBracketConfig {
460 /// Maximum allocations measured by the bracket, including the model's
461 /// answer but excluding the already-measured opening.
462 pub max_points: usize,
463 /// Throughput evidence gathered at each candidate allocation.
464 pub horizon: Duration,
465 /// Hard wall-time budget for the startup experiment.
466 pub total_budget: Duration,
467}
468
469impl Default for OpeningBracketConfig {
470 fn default() -> Self {
471 Self {
472 max_points: 3,
473 horizon: Duration::from_millis(200),
474 // Three 200 ms evidence horizons plus their ordinary resize
475 // blackouts fit comfortably below four seconds on the measured
476 // scATAC path, leaving margin for scheduling and acknowledgements.
477 total_budget: Duration::from_secs(4),
478 }
479 }
480}
481
482/// Bounded diagnostics for producer-side busy-time measurement.
483#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
484pub struct ProducerMeasurementStats {
485 /// Cumulative executing-worker time used by the controller. This is exact
486 /// when `accounted_busy_nanos` is present and sampled otherwise.
487 pub busy_nanos: u64,
488 /// Exact event-time executing-worker integral, when the producer supplies
489 /// one. Production adapters may use this as `busy_nanos`; retaining it here
490 /// makes the signal source and sampled-fallback diagnostics explicit.
491 pub accounted_busy_nanos: Option<u64>,
492 /// CPU time from completed producer worker-thread lifetimes, when the
493 /// adapter supports optional component accounting.
494 pub completed_worker_cpu_nanos: Option<u64>,
495 /// CPU time from completed producer auxiliary-thread lifetimes, when the
496 /// adapter supports optional component accounting.
497 pub completed_auxiliary_cpu_nanos: Option<u64>,
498 /// Thread CPU-clock read failures, when component accounting is enabled.
499 pub cpu_accounting_failures: Option<usize>,
500 /// Lifetime CPU time of the measurement sampler itself. This requires only
501 /// one thread-clock read at sampler start and one at sampler exit; no clock
502 /// reads are added to its observation loop.
503 pub sampler_cpu_nanos: Option<u64>,
504 /// Sampler thread-clock reads that failed.
505 pub sampler_cpu_accounting_failures: usize,
506 pub calibration_samples: u64,
507 pub monitoring_samples: u64,
508 pub mode_changes: u64,
509 pub final_mode: ProducerMeasurementMode,
510 /// Wall time spent obtaining instantaneous observations, excluding sleeps.
511 pub observation_nanos: u64,
512 pub calibration_interval_micros: u64,
513 pub monitoring_interval_micros: u64,
514}
515
516/// A two-read lifetime CPU timer for low-overhead administrative accounting.
517///
518/// It must be started and read on the same thread. Constructing it reads that
519/// thread's CPU clock once, and [`Self::elapsed`] reads it once more. It does
520/// not install periodic instrumentation or affect application hot paths.
521#[doc(hidden)]
522pub struct ThreadCpuTimer(Option<cpu_time::ThreadTime>);
523
524impl ThreadCpuTimer {
525 pub fn start() -> Self {
526 Self(cpu_time::ThreadTime::try_now().ok())
527 }
528
529 pub fn elapsed(&self) -> Option<Duration> {
530 self.0.as_ref()?.try_elapsed().ok()
531 }
532}
533
534/// Whether more producer concurrency would buy anything.
535///
536/// # This guards direction; it does not size the split
537///
538/// [`Self::SourceBound`] and [`Self::Inelastic`] may cap how far the producer
539/// grows. [`Self::Starved`] has one bounded exception: it may retain the current
540/// limit when the cost model asks to shrink a producer that is demonstrably
541/// using its present capacity. It cannot choose or increase the target, so the
542/// cost-share model remains the only sizing mechanism. [`Self::Satisfied`] is
543/// diagnostic. This one-sided restriction is deliberate and hard-won.
544///
545/// Every variant here is a *saturating* signal, and an earlier version of this
546/// crate sized the split from one. It does not work, and the reason is
547/// structural rather than a matter of tuning.
548///
549/// Let `starve(d)` be the fraction of consumer time blocked on an empty buffer.
550/// It falls as `d` rises and reaches zero at the balance point — and then
551/// **stays** zero for every larger `d`. So `{d : starve(d) = 0}` is a half-line,
552/// not a point, and a controller driving `starve -> 0` has no restoring force
553/// anywhere on it. Any bias walks it to the boundary while the error stays
554/// genuinely near zero the whole way. Measured: it ran to 47 producer / 17
555/// consumer where 32/32 was optimal, 44% slower, "converging" throughout.
556///
557/// DS2 (Kalavri et al., OSDI'18, section 2) classifies exactly that controller
558/// -- threshold policies over utilisation, queue depth or observed rates -- and
559/// gives the consequence as "incorrect provisioning, oscillations, and long
560/// convergence times".
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub enum ProducerPressure {
563 /// Work is queued behind the limit: more slots would be used.
564 ///
565 /// This variant carried a `wanted` count while the split was grown one
566 /// decision at a time. Nothing reads it now — the split is solved, not
567 /// stepped — and a demand estimate is a poor thing to keep alive with no
568 /// consumer to keep it honest.
569 ///
570 /// May veto removal of an occupied current slot when allocation-dependent
571 /// producer work makes the low-allocation cost estimate incomplete. The
572 /// controller counts that event and requires persistent evidence; this
573 /// variant never requests growth.
574 Starved,
575 /// Keeping up with the consumer. More slots would idle.
576 Satisfied,
577 /// Idle, but *not* because of the limit — the source is the constraint.
578 ///
579 /// Distinct from [`Self::Satisfied`] because the consumer may simultaneously
580 /// be starving, and that combination is the one case where a starved
581 /// consumer must **not** cause the producer to grow: the threads would sit
582 /// waiting on storage. Without this variant a broker spends its whole budget
583 /// on decode for an I/O-bound run and gains nothing.
584 SourceBound,
585 /// Cannot use more concurrency at any price.
586 ///
587 /// For a stream the decompressor can only process serially. Such an input
588 /// reports total consumer starvation forever and can never be helped, so
589 /// without this the broker would spend every thread chasing it.
590 Inelastic,
591}
592
593/// Invalid [`ThreadBroker`] configuration.
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct BrokerConfigError(&'static str);
596
597impl std::fmt::Display for BrokerConfigError {
598 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599 write!(f, "invalid thread-broker configuration: {}", self.0)
600 }
601}
602
603impl std::error::Error for BrokerConfigError {}
604
605/// A pool refused a resize request.
606#[derive(Debug, Clone, PartialEq, Eq)]
607pub struct ResizeError(Box<str>);
608
609impl ResizeError {
610 pub fn new(message: impl Into<String>) -> Self {
611 Self(message.into().into_boxed_str())
612 }
613}
614
615impl std::fmt::Display for ResizeError {
616 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
617 f.write_str(&self.0)
618 }
619}
620
621impl std::error::Error for ResizeError {}
622
623/// Which pipeline side was involved in a broker operation or failure.
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625pub enum ResizeSide {
626 Consumer,
627 Producer,
628}
629
630/// Why a broker could not start or complete safely.
631#[derive(Debug)]
632pub enum BrokerErrorKind {
633 ResizeRefused {
634 side: ResizeSide,
635 requested: usize,
636 source: ResizeError,
637 },
638 ResizeTimedOut {
639 side: ResizeSide,
640 target: usize,
641 observed: usize,
642 timeout: Duration,
643 },
644 /// An adapter violated [`Work`]'s cumulative-counter contract.
645 WorkCountersRegressed {
646 side: ResizeSide,
647 previous: Work,
648 observed: Work,
649 },
650 ThreadSpawn(std::io::Error),
651 ThreadPanicked,
652}
653
654/// A broker failure, optionally carrying the report accumulated before it.
655#[derive(Debug)]
656pub struct BrokerError {
657 kind: BrokerErrorKind,
658 report: Option<Box<BrokerReport>>,
659}
660
661impl BrokerError {
662 pub(crate) fn new(kind: BrokerErrorKind) -> Self {
663 Self { kind, report: None }
664 }
665
666 pub(crate) fn with_report(mut self, report: BrokerReport) -> Self {
667 self.report = Some(Box::new(report));
668 self
669 }
670
671 pub fn kind(&self) -> &BrokerErrorKind {
672 &self.kind
673 }
674
675 pub fn report(&self) -> Option<&BrokerReport> {
676 self.report.as_deref()
677 }
678}
679
680impl std::fmt::Display for BrokerError {
681 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682 match &self.kind {
683 BrokerErrorKind::ResizeRefused {
684 side,
685 requested,
686 source,
687 } => write!(
688 f,
689 "thread broker {side:?} resize to {requested} was refused: {source}"
690 ),
691 BrokerErrorKind::ResizeTimedOut {
692 side,
693 target,
694 observed,
695 timeout,
696 } => write!(
697 f,
698 "thread broker timed out after {timeout:?} waiting for {side:?} to shrink to {target} (observed {observed})"
699 ),
700 BrokerErrorKind::WorkCountersRegressed {
701 side,
702 previous,
703 observed,
704 } => write!(
705 f,
706 "thread broker observed decreasing {side:?} work counters: {previous:?} -> {observed:?}"
707 ),
708 BrokerErrorKind::ThreadSpawn(source) => {
709 write!(f, "could not spawn thread-broker sampler: {source}")
710 }
711 BrokerErrorKind::ThreadPanicked => f.write_str("thread-broker sampler panicked"),
712 }
713 }
714}
715
716impl std::error::Error for BrokerError {
717 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
718 match &self.kind {
719 BrokerErrorKind::ResizeRefused { source, .. } => Some(source),
720 BrokerErrorKind::ThreadSpawn(source) => Some(source),
721 _ => None,
722 }
723 }
724}
725
726/// Tuning, all of it overridable.
727///
728/// Every default below was arrived at by fixing an observed failure, so they
729/// carry real information — but none is a law, and a caller with different
730/// timings should move them.
731#[derive(Debug, Clone, Copy)]
732pub struct BrokerConfig {
733 /// How often to sample and decide.
734 ///
735 /// 100 ms by default, with a 400 ms warm-up over it. An earlier pairing of
736 /// 250 ms and 150 ms was *self-inconsistent*: it failed this type's own
737 /// `warmup >= 2 * sample_interval` rule, so every broker built from the
738 /// defaults failed to build. Nothing caught it, because every unit test
739 /// passed an explicit config — the defaults were the one combination never
740 /// exercised. There is now a test that builds from them.
741 ///
742 /// 250 ms was chosen to match `rapidgzip`'s worker-retirement hysteresis, on
743 /// the grounds that instantaneous fields sampled at that period alias
744 /// against their own duty cycle. That reasoning applies to a producer that
745 /// reports instantaneous worker counts; the signals used here are an
746 /// integrated busy counter and a demand estimate, neither of which aliases.
747 pub sample_interval: Duration,
748 /// How often a responsive broker probes after convergence.
749 ///
750 /// `None` (the default) uses [`Self::sample_interval`], preserving the fully
751 /// responsive behavior. A longer interval changes only settled monitoring;
752 /// warm-up, calibration, resizing, blackout, and ratification continue at
753 /// `sample_interval`.
754 pub steady_probe_interval: Option<Duration>,
755 /// Time discarded before the first decision.
756 ///
757 /// **Load-bearing, not merely tuned.** Every workload measured reported ~99%
758 /// consumer starvation in its first 75 ms, because the consumer's threads
759 /// are still starting and nothing has been processed yet. With no warm-up,
760 /// *every* input looks producer-bound and the broker's first move is always
761 /// wrong.
762 ///
763 /// Must span at least two sampling intervals, or the first decision rests on
764 /// a single window.
765 pub warmup: Duration,
766 /// Recent windows averaged into the rate estimates.
767 ///
768 /// Deciding on a single window makes the broker a noise amplifier: a real
769 /// consumer throws the occasional idle sample from a batch boundary or a
770 /// page fault. Averaging is what makes the *signal* steady, rather than
771 /// trying to out-tune the noise downstream of it.
772 ///
773 /// One disables smoothing.
774 pub smoothing_windows: usize,
775 /// Minimum distance, in threads, between the model's answer and the current
776 /// split before the broker will act.
777 ///
778 /// Moves are not free — each one rebuilds per-thread state on both sides,
779 /// including a multi-MB record buffer per new consumer worker — so the
780 /// broker should not spend one on a difference it cannot be sure about.
781 ///
782 /// # This value is not yet derived, and should be
783 ///
784 /// The measurements it was originally justified from are void. They were
785 /// taken through a path that did not enforce the thread budget, which made a
786 /// sharply peaked throughput surface look like a wide plateau, and the
787 /// conclusion drawn — "precision near the answer is worth almost nothing" —
788 /// does not survive re-measurement.
789 ///
790 /// What the corrected sweeps show, on a decode-bound single-cell workload:
791 ///
792 /// | budget | optimum | surface |
793 /// |---:|---:|---|
794 /// | 32 | 12 slots | 8 slots +29%, 16 +13%, 24 +115% |
795 /// | 64 | 22 slots | 16 slots +17%, 28 +5%, 40 +34% |
796 ///
797 /// Two threads is defensible against both — the broker landed 5.0% and 3.4%
798 /// off the best swept point — but that is an observation, not a derivation,
799 /// and the two budgets differ enough in tolerance that a single constant is
800 /// unlikely to be right for both. It matters most at small budgets, where
801 /// two threads is a large fraction of the answer.
802 pub deadband_threads: usize,
803 /// Samples discarded after a move before the model is trusted again.
804 ///
805 /// Two distinct effects need to decay. The pools converge toward their new
806 /// size over some interval, so a window spanning a move measures a split
807 /// that no longer exists; and the move itself does work — a new consumer
808 /// worker builds its thread state and allocates its record set — which
809 /// inflates busy time without moving any items, biasing the very ratio the
810 /// decision was made from.
811 ///
812 /// Must be at least `smoothing_windows`, or the smoothed estimate still
813 /// contains pre-move windows when the broker next decides.
814 pub blackout_samples: usize,
815 /// Windows of throughput evidence gathered before a move is ratified.
816 /// The default ten-window block spans one second at the default 100 ms
817 /// cadence. Applications should validate its power against stationary
818 /// windows from their real workload; a deliberately changing or pausing
819 /// workload is not a statistically identifiable ratification population.
820 pub ratify_samples: usize,
821 /// Whether a disagreement between the opening and the first stable model
822 /// answer should be confirmed by a bounded startup bracket.
823 pub opening_policy: OpeningPolicy,
824 /// Relative throughput loss that counts as a regression rather than noise.
825 ///
826 /// Only a *regression* reverts a move. Absence of improvement does not,
827 /// because the surface is flat near the optimum: "no better" is the expected
828 /// reading for a correct move, and treating it as failure would revert the
829 /// broker to wherever it happened to start. Flink's equivalent check,
830 /// `detectIneffectiveScaleUp`, compares against a model-*predicted* increase
831 /// for this reason, and ships disabled by default.
832 pub regression_tolerance: f64,
833 /// How far the model's answer must drift from the settled split, in threads,
834 /// before the broker re-opens a decision it had closed.
835 ///
836 /// Settling must not mean going deaf: a file ending or a different sample
837 /// beginning genuinely changes the costs. But adapting continuously spends
838 /// moves on noise forever, and each move rebuilds per-thread state on both
839 /// sides. So the broker settles and then sleeps until surprised. This value
840 /// is only an absolute floor: observed cost-share uncertainty can widen the
841 /// effective threshold, and persistent evidence is required before the
842 /// decision re-opens. Keeping the floor at one preserves sensitivity to a
843 /// one-slot regime change on small budgets without making noisy large
844 /// budgets unstable.
845 ///
846 /// # Why drift rather than a throughput CUSUM
847 ///
848 /// The obvious alternative is to watch throughput and re-arm when it falls —
849 /// a one-sided CUSUM, and the form the autoscaling literature usually
850 /// reaches for. It is genuinely one-sided, and that turns out to matter
851 /// here: when a consumer suddenly gets *cheaper*, throughput does not fall
852 /// at all — the pipeline simply stays pinned at the old producer-limited
853 /// rate while a much better split goes unclaimed. A CUSUM sleeps through it.
854 /// The solved target moves immediately, because it is computed from the cost
855 /// ratio rather than from the outcome, so watching the model detects change
856 /// in both directions with one mechanism instead of two.
857 pub resurvey_distance: usize,
858 /// Wall-clock time model drift must persist before a settled split is
859 /// re-opened.
860 ///
861 /// Distance alone is not enough, and the reason is that the noise in the
862 /// solved target scales with the budget while a fixed distance does not.
863 /// Measured on a 116-second Flex run at `-t 64`: with distance as the only
864 /// gate the broker re-opened 18 times and never reported convergence, while
865 /// the same build at `-t 32` settled once in 1.4 s and never moved again —
866 /// identical workload, identical noise in the *share*, twice the noise in
867 /// threads.
868 ///
869 /// Requiring duration-based persistence separates the two cleanly, because
870 /// it keys on something that actually distinguishes them: sampling noise
871 /// does not survive a second of consecutive windows, and a real regime
872 /// change does. It also costs nothing when there is no drift.
873 pub resurvey_persistence: Duration,
874 /// Maximum time to wait for the shrinking side to release capacity before
875 /// aborting the broker. The other side is never grown before this
876 /// acknowledgement arrives.
877 pub resize_timeout: Duration,
878 /// Absolute queue drift tolerated in one cost window.
879 pub max_buffer_drift_items: u64,
880 /// Queue drift tolerated as a fraction of producer progress in the window.
881 pub max_buffer_drift_fraction: f64,
882 /// Wall-clock history retained for producer scalability caps.
883 pub cap_history: Duration,
884 /// Continuous corroborating evidence required before slack or source
885 /// saturation is allowed to constrain a move.
886 pub cap_persistence: Duration,
887 /// Maximum number of requested/actual samples retained in a report.
888 pub trace_capacity: usize,
889 /// The consumer never drops below this.
890 pub min_consumer_threads: usize,
891 /// Producer concurrency never drops below this, in aggregate.
892 ///
893 /// One is right for a producer whose limit is a pure runtime throttle, and
894 /// that is worth checking rather than assuming. `rapidgzip` used to read
895 /// this limit while *choosing a backend*, so anything below two committed a
896 /// decoder to sequential decoding irreversibly — a caller on such a version,
897 /// or on any producer that couples its limit to a one-time decision, needs
898 /// this at two or more.
899 ///
900 /// Measured on the shared-pool decoder: an aggregate limit of 1 leaves the
901 /// path at `DenseMembers` and still reports a demand of 12, so admission is
902 /// genuinely decoupled and the floor can be a single slot. Note this is
903 /// **aggregate, not per input** — scaling it by input count would reserve
904 /// half a 32-thread budget on an 8-file run that needs no decode at all.
905 pub min_producer_slots: usize,
906}
907
908impl Default for BrokerConfig {
909 fn default() -> Self {
910 Self {
911 sample_interval: Duration::from_millis(100),
912 steady_probe_interval: None,
913 warmup: Duration::from_millis(400),
914 smoothing_windows: 3,
915 deadband_threads: 1,
916 blackout_samples: 4,
917 ratify_samples: 10,
918 opening_policy: OpeningPolicy::Fixed,
919 regression_tolerance: 0.05,
920 resurvey_distance: 1,
921 resurvey_persistence: Duration::from_millis(800),
922 resize_timeout: Duration::from_secs(2),
923 max_buffer_drift_items: 1 << 20,
924 max_buffer_drift_fraction: 0.05,
925 cap_history: Duration::from_millis(800),
926 cap_persistence: Duration::from_millis(300),
927 trace_capacity: 256,
928 min_consumer_threads: 1,
929 min_producer_slots: 1,
930 }
931 }
932}
933
934/// A configured, not-yet-running broker.
935pub struct ThreadBroker<C: Consumer, P: Producer> {
936 pub(crate) consumer: Arc<C>,
937 pub(crate) producer: Arc<P>,
938 pub(crate) budget: usize,
939 pub(crate) config: BrokerConfig,
940 pub(crate) initial_producer_slots: Option<usize>,
941 pub(crate) steady_state_policy: SteadyStatePolicy,
942}
943
944/// Builder for [`ThreadBroker`].
945pub struct ThreadBrokerBuilder<C: Consumer, P: Producer> {
946 consumer: Arc<C>,
947 producer: Arc<P>,
948 budget: Option<usize>,
949 config: BrokerConfig,
950 initial_producer_slots: Option<usize>,
951 steady_state_policy: SteadyStatePolicy,
952}
953
954impl<C: Consumer, P: Producer> ThreadBroker<C, P> {
955 /// Start configuring a broker over these two sides, from the defaults.
956 pub fn builder(consumer: C, producer: P) -> ThreadBrokerBuilder<C, P> {
957 Self::builder_with(consumer, producer, BrokerConfig::default())
958 }
959
960 /// As [`Self::builder`], but starting from `config` rather than the
961 /// defaults.
962 ///
963 /// A whole `BrokerConfig` is taken here, at construction, rather than
964 /// offered as a `.config()` setter. A setter would silently discard every
965 /// individual override called before it — which it did, wiping a floor of 4
966 /// back to 1 and letting a test watch the broker breach a limit it had
967 /// explicitly been given. Taking it up front leaves no ordering to get
968 /// wrong.
969 pub fn builder_with(
970 consumer: C,
971 producer: P,
972 config: BrokerConfig,
973 ) -> ThreadBrokerBuilder<C, P> {
974 ThreadBrokerBuilder {
975 consumer: Arc::new(consumer),
976 producer: Arc::new(producer),
977 budget: None,
978 config,
979 initial_producer_slots: None,
980 steady_state_policy: SteadyStatePolicy::default(),
981 }
982 }
983}
984
985impl<C: Consumer, P: Producer> ThreadBrokerBuilder<C, P> {
986 /// Total threads to divide. Required.
987 pub fn budget(mut self, threads: usize) -> Self {
988 self.budget = Some(threads);
989 self
990 }
991
992 /// Start the producer at this many slots instead of the coarse default.
993 ///
994 /// The default start is deliberately low, because growth is cheap and many
995 /// workloads never need decode threads at all. A caller that already knows
996 /// better — from a previous run, or from an explicit user request — can skip
997 /// the climb. Still clamped to the per-input floor and the budget.
998 pub fn initial_producer_slots(mut self, slots: usize) -> Self {
999 self.initial_producer_slots = Some(slots);
1000 self
1001 }
1002
1003 /// Choose whether a settled broker keeps watching for regime changes.
1004 ///
1005 /// See [`SteadyStatePolicy`] for the responsiveness/overhead tradeoff.
1006 pub fn steady_state_policy(mut self, policy: SteadyStatePolicy) -> Self {
1007 self.steady_state_policy = policy;
1008 self
1009 }
1010
1011 /// See [`BrokerConfig::sample_interval`].
1012 pub fn sample_interval(mut self, interval: Duration) -> Self {
1013 self.config.sample_interval = interval;
1014 self
1015 }
1016
1017 /// Set the interval between responsive steady-state probes.
1018 ///
1019 /// This does not change the high-resolution convergence cadence. The
1020 /// producer is asked to use roughly four monitoring observations per probe,
1021 /// subject to any accuracy floor imposed by its adapter.
1022 pub fn steady_probe_interval(mut self, interval: Duration) -> Self {
1023 self.config.steady_probe_interval = Some(interval);
1024 self
1025 }
1026
1027 /// See [`BrokerConfig::warmup`].
1028 pub fn warmup(mut self, warmup: Duration) -> Self {
1029 self.config.warmup = warmup;
1030 self
1031 }
1032
1033 /// See [`BrokerConfig::smoothing_windows`].
1034 pub fn smoothing_windows(mut self, windows: usize) -> Self {
1035 self.config.smoothing_windows = windows;
1036 self
1037 }
1038
1039 /// See [`BrokerConfig::deadband_threads`].
1040 pub fn deadband_threads(mut self, threads: usize) -> Self {
1041 self.config.deadband_threads = threads;
1042 self
1043 }
1044
1045 /// See [`BrokerConfig::blackout_samples`].
1046 pub fn blackout_samples(mut self, samples: usize) -> Self {
1047 self.config.blackout_samples = samples;
1048 self
1049 }
1050
1051 /// See [`BrokerConfig::ratify_samples`].
1052 pub fn ratify_samples(mut self, samples: usize) -> Self {
1053 self.config.ratify_samples = samples;
1054 self
1055 }
1056
1057 /// See [`BrokerConfig::opening_policy`].
1058 pub fn opening_policy(mut self, policy: OpeningPolicy) -> Self {
1059 self.config.opening_policy = policy;
1060 self
1061 }
1062
1063 /// See [`BrokerConfig::regression_tolerance`].
1064 pub fn regression_tolerance(mut self, fraction: f64) -> Self {
1065 self.config.regression_tolerance = fraction;
1066 self
1067 }
1068
1069 /// See [`BrokerConfig::resurvey_distance`].
1070 pub fn resurvey_distance(mut self, threads: usize) -> Self {
1071 self.config.resurvey_distance = threads;
1072 self
1073 }
1074
1075 /// See [`BrokerConfig::resurvey_persistence`].
1076 pub fn resurvey_persistence(mut self, duration: Duration) -> Self {
1077 self.config.resurvey_persistence = duration;
1078 self
1079 }
1080
1081 /// See [`BrokerConfig::resize_timeout`].
1082 pub fn resize_timeout(mut self, timeout: Duration) -> Self {
1083 self.config.resize_timeout = timeout;
1084 self
1085 }
1086
1087 /// See [`BrokerConfig::min_consumer_threads`].
1088 pub fn min_consumer_threads(mut self, threads: usize) -> Self {
1089 self.config.min_consumer_threads = threads.max(1);
1090 self
1091 }
1092
1093 /// See [`BrokerConfig::min_producer_slots`].
1094 pub fn min_producer_slots(mut self, slots: usize) -> Self {
1095 self.config.min_producer_slots = slots.max(1);
1096 self
1097 }
1098
1099 /// Validate and finish.
1100 ///
1101 /// # Errors
1102 ///
1103 /// Rejects a budget too small to split, a zero sampling interval, and a
1104 /// warm-up shorter than two sampling intervals — the last because a broker
1105 /// that decides on one sample is deciding on the startup transient, which
1106 /// is the failure the warm-up exists to prevent.
1107 pub fn build(self) -> Result<ThreadBroker<C, P>, BrokerConfigError> {
1108 let budget = self.budget.ok_or(BrokerConfigError("budget is required"))?;
1109 let c = &self.config;
1110 if budget < 2 {
1111 return Err(BrokerConfigError(
1112 "budget must be at least 2 to divide between two sides",
1113 ));
1114 }
1115 if c.sample_interval.is_zero() {
1116 return Err(BrokerConfigError("sample_interval must be non-zero"));
1117 }
1118 if c.steady_probe_interval
1119 .is_some_and(|interval| interval.is_zero())
1120 {
1121 return Err(BrokerConfigError(
1122 "steady_probe_interval must be non-zero when configured",
1123 ));
1124 }
1125 if c.warmup < c.sample_interval * 2 {
1126 return Err(BrokerConfigError(
1127 "warmup must span at least two sample intervals, or the first \
1128 decision is made on the startup transient",
1129 ));
1130 }
1131 if !(0.0..1.0).contains(&c.regression_tolerance) {
1132 return Err(BrokerConfigError(
1133 "regression_tolerance must be in 0.0..1.0",
1134 ));
1135 }
1136 if c.smoothing_windows == 0 {
1137 return Err(BrokerConfigError("smoothing_windows must be non-zero"));
1138 }
1139 // A blackout shorter than the smoothing window does not do its job: the
1140 // smoothed estimate the next decision reads would still be carrying
1141 // windows from before the move, so the broker would partly re-decide on
1142 // the split it has already left.
1143 if c.blackout_samples < c.smoothing_windows {
1144 return Err(BrokerConfigError(
1145 "blackout_samples must be at least smoothing_windows, or the \
1146 next decision still averages in pre-move windows",
1147 ));
1148 }
1149 if c.ratify_samples == 0 {
1150 return Err(BrokerConfigError("ratify_samples must be non-zero"));
1151 }
1152 if let OpeningPolicy::Bracket(bracket) = c.opening_policy {
1153 if bracket.max_points == 0 {
1154 return Err(BrokerConfigError(
1155 "opening bracket max_points must be non-zero",
1156 ));
1157 }
1158 if bracket.horizon.is_zero() {
1159 return Err(BrokerConfigError(
1160 "opening bracket horizon must be non-zero",
1161 ));
1162 }
1163 if bracket.total_budget.is_zero() || bracket.total_budget < bracket.horizon {
1164 return Err(BrokerConfigError(
1165 "opening bracket total_budget must be at least its horizon",
1166 ));
1167 }
1168 }
1169 // Re-opening is made harder by persistence. Its distance may equal the
1170 // movement deadband so a one-slot regime change remains observable on
1171 // small budgets.
1172 if c.resurvey_persistence.is_zero() {
1173 return Err(BrokerConfigError("resurvey_persistence must be non-zero"));
1174 }
1175 if c.resize_timeout.is_zero() {
1176 return Err(BrokerConfigError("resize_timeout must be non-zero"));
1177 }
1178 if !(0.0..=1.0).contains(&c.max_buffer_drift_fraction) {
1179 return Err(BrokerConfigError(
1180 "max_buffer_drift_fraction must be in 0.0..=1.0",
1181 ));
1182 }
1183 if c.cap_history.is_zero()
1184 || c.cap_persistence.is_zero()
1185 || c.cap_persistence > c.cap_history
1186 {
1187 return Err(BrokerConfigError(
1188 "cap durations must be non-zero and cap_persistence must not exceed cap_history",
1189 ));
1190 }
1191 if c.trace_capacity == 0 {
1192 return Err(BrokerConfigError("trace_capacity must be non-zero"));
1193 }
1194 if c.resurvey_distance < c.deadband_threads {
1195 return Err(BrokerConfigError(
1196 "resurvey_distance must be at least deadband_threads",
1197 ));
1198 }
1199 if c.min_consumer_threads == 0 || c.min_producer_slots == 0 {
1200 return Err(BrokerConfigError(
1201 "min_consumer_threads and min_producer_slots must be non-zero",
1202 ));
1203 }
1204 if c.min_consumer_threads + c.min_producer_slots > budget {
1205 return Err(BrokerConfigError(
1206 "the two floors do not fit inside the budget",
1207 ));
1208 }
1209 Ok(ThreadBroker {
1210 consumer: self.consumer,
1211 producer: self.producer,
1212 budget,
1213 config: self.config,
1214 initial_producer_slots: self.initial_producer_slots,
1215 steady_state_policy: self.steady_state_policy,
1216 })
1217 }
1218}
1219
1220/// Shared stop flag and wakeup, so even a very sparse steady probe remains
1221/// immediately interruptible at application shutdown.
1222pub(crate) struct Stop {
1223 value: AtomicBool,
1224 mutex: std::sync::Mutex<()>,
1225 wake: std::sync::Condvar,
1226}
1227
1228impl Stop {
1229 pub(crate) fn new() -> Arc<Self> {
1230 Arc::new(Self {
1231 value: AtomicBool::new(false),
1232 mutex: std::sync::Mutex::new(()),
1233 wake: std::sync::Condvar::new(),
1234 })
1235 }
1236 pub(crate) fn is_set(&self) -> bool {
1237 self.value.load(Ordering::Acquire)
1238 }
1239 pub(crate) fn set(&self) {
1240 self.value.store(true, Ordering::Release);
1241 self.wake.notify_all();
1242 }
1243 pub(crate) fn wait_timeout(&self, timeout: Duration) -> bool {
1244 if self.is_set() {
1245 return true;
1246 }
1247 let Ok(guard) = self.mutex.lock() else {
1248 return self.is_set();
1249 };
1250 if self.is_set() {
1251 return true;
1252 }
1253 let _wait = self.wake.wait_timeout(guard, timeout);
1254 self.is_set()
1255 }
1256}
1257
1258/// A window of consumer activity, normalised against the capacity available.
1259pub(crate) fn idle_fraction(busy_delta: u64, threads: usize, window: Duration) -> f64 {
1260 let capacity = (threads as u128).saturating_mul(window.as_nanos());
1261 if capacity == 0 {
1262 return 0.0;
1263 }
1264 let used = (busy_delta as f64 / capacity as f64).clamp(0.0, 1.0);
1265 1.0 - used
1266}
1267
1268pub(crate) fn now() -> Instant {
1269 Instant::now()
1270}
1271
1272// ---------------------------------------------------------------------------
1273// Helpers for implementing `Consumer::busy_nanos`
1274// ---------------------------------------------------------------------------
1275
1276/// A shared counter of time spent doing real work.
1277///
1278/// Every [`Consumer`] needs one, and the obvious implementation of it is wrong
1279/// in a way that is hard to notice, so it is provided rather than left to each
1280/// caller. See [`BusyMeter::timer`].
1281#[derive(Debug, Default)]
1282pub struct BusyMeter {
1283 nanos: std::sync::atomic::AtomicU64,
1284 items: std::sync::atomic::AtomicU64,
1285}
1286
1287impl BusyMeter {
1288 pub fn new() -> Self {
1289 Self::default()
1290 }
1291
1292 /// Everything recorded so far, across every worker.
1293 pub fn work(&self) -> Work {
1294 Work {
1295 busy_nanos: self.nanos.load(Ordering::Relaxed),
1296 items: self.items.load(Ordering::Relaxed),
1297 }
1298 }
1299
1300 /// Total nanoseconds recorded, across every worker.
1301 pub fn nanos(&self) -> u64 {
1302 self.nanos.load(Ordering::Relaxed)
1303 }
1304
1305 /// Start timing a batch.
1306 ///
1307 /// The returned timer publishes periodically *within* the batch, not only
1308 /// at its end. That distinction is the whole reason this type exists.
1309 ///
1310 /// Publishing once per batch cannot be sampled faster than a batch
1311 /// completes. With a 16384-record batch at a realistic 5 us per record, a
1312 /// batch takes ~80 ms against a 250 ms sampling window — so windows
1313 /// routinely contain no completed batch, read a delta of zero, and report
1314 /// **maximum starvation for a consumer running flat out**. Measured
1315 /// directly before this was fixed: a deliberately expensive kernel reported
1316 /// 100.0% idle, the exact opposite of the truth, while cheaper kernels
1317 /// reported less.
1318 pub fn timer(&self) -> BatchTimer<'_> {
1319 BatchTimer {
1320 meter: self,
1321 single_writer_progress: None,
1322 single_writer_progress_total: 0,
1323 mark: Instant::now(),
1324 items_since_publish: 0,
1325 time_items_since_publish: 0,
1326 progress_every: DEFAULT_FLUSH_EVERY,
1327 time_every: DEFAULT_FLUSH_EVERY,
1328 }
1329 }
1330}
1331
1332/// Records published every this many items by default.
1333///
1334/// Keeps the publish interval far below one sampling window even for slow
1335/// consumers, while costing one `Instant::now()` pair per 256 items — nothing
1336/// against a consumer doing real work, and still under a percent for a trivial
1337/// one.
1338pub const DEFAULT_FLUSH_EVERY: u64 = 256;
1339
1340/// Accumulates work time at a granularity the broker can actually resolve.
1341///
1342/// Publishes on drop, so an early return or a `?` still records what was done,
1343/// and so the tail of a batch is never lost.
1344///
1345/// # Construct one per batch
1346///
1347/// The timer measures wall time between publishes, so anything that happens
1348/// while it is alive is charged as work. Constructing it *inside* the
1349/// per-batch callback is what keeps blocking out of the measurement: the
1350/// callback is entered only once a filled buffer is in hand, so the wait for
1351/// that buffer falls between two timers rather than inside one. A timer hoisted
1352/// to thread scope would charge every `fill` to the consumer's busy time — and
1353/// busy time that includes blocking is exactly the wall-time measurement this
1354/// crate exists to avoid.
1355pub struct BatchTimer<'a> {
1356 meter: &'a BusyMeter,
1357 single_writer_progress: Option<&'a std::sync::atomic::AtomicU64>,
1358 single_writer_progress_total: u64,
1359 mark: Instant,
1360 items_since_publish: u64,
1361 time_items_since_publish: u64,
1362 progress_every: u64,
1363 time_every: u64,
1364}
1365
1366impl<'a> BatchTimer<'a> {
1367 /// Publish item progress to a single-writer caller-owned counter.
1368 ///
1369 /// A consumer with several hot workers can give each worker a cache-padded
1370 /// counter and sum those counters in [`Consumer::work`]. Busy time remains
1371 /// in this meter, while fine progress uses a relaxed cumulative store
1372 /// rather than a contended atomic read-modify-write. No other writer may
1373 /// update `counter`; it must remain cumulative across successive timers.
1374 pub fn single_writer_progress_counter(
1375 mut self,
1376 counter: &'a std::sync::atomic::AtomicU64,
1377 ) -> Self {
1378 self.single_writer_progress_total = counter.load(Ordering::Relaxed);
1379 self.single_writer_progress = Some(counter);
1380 self
1381 }
1382
1383 /// Publish both progress and busy time every `n` items instead of the default.
1384 ///
1385 /// Prefer [`Self::progress_every`] when only the completed-item signal needs
1386 /// finer resolution. That avoids adding clock reads to a hot record loop.
1387 pub fn flush_every(mut self, n: u64) -> Self {
1388 let n = n.max(1);
1389 self.progress_every = n;
1390 self.time_every = n;
1391 self
1392 }
1393
1394 /// Publish completed-item progress every `n` items.
1395 ///
1396 /// This cadence is independent of busy-time publication: making throughput
1397 /// visible to short controller windows therefore costs one relaxed counter
1398 /// update per interval, without forcing an `Instant::now()` at that cadence.
1399 pub fn progress_every(mut self, n: u64) -> Self {
1400 self.progress_every = n.max(1);
1401 self
1402 }
1403
1404 /// Publish accumulated busy time every `n` items.
1405 ///
1406 /// Most consumers should retain [`DEFAULT_FLUSH_EVERY`]. This separate
1407 /// control primarily lets a converged freeze policy disable all in-batch
1408 /// measurement for new callbacks.
1409 pub fn time_every(mut self, n: u64) -> Self {
1410 self.time_every = n.max(1);
1411 self
1412 }
1413
1414 /// Call once per item processed.
1415 ///
1416 /// The count is only ever compared against itself over time, so any
1417 /// consistent unit will do; it plays no part in sizing the split.
1418 #[inline]
1419 pub fn tick(&mut self) {
1420 self.items_since_publish += 1;
1421 self.time_items_since_publish += 1;
1422 if self.items_since_publish >= self.progress_every {
1423 self.publish_items();
1424 }
1425 if self.time_items_since_publish >= self.time_every {
1426 self.publish_time();
1427 }
1428 }
1429
1430 /// Count one item when the returned guard leaves scope.
1431 ///
1432 /// Use this when a loop has early exits and throughput must mean completed,
1433 /// rather than started, items. Construct the guard at the top of an
1434 /// iteration; normal completion, `continue`, and `?` all publish the item
1435 /// only after that iteration's work has finished.
1436 #[inline]
1437 pub fn completed_item(&mut self) -> CompletedItem<'_, 'a> {
1438 CompletedItem { timer: self }
1439 }
1440
1441 #[inline]
1442 fn publish_items(&mut self) {
1443 if let Some(counter) = self.single_writer_progress {
1444 self.single_writer_progress_total = self
1445 .single_writer_progress_total
1446 .saturating_add(self.items_since_publish);
1447 counter.store(self.single_writer_progress_total, Ordering::Relaxed);
1448 } else {
1449 self.meter
1450 .items
1451 .fetch_add(self.items_since_publish, Ordering::Relaxed);
1452 }
1453 self.items_since_publish = 0;
1454 }
1455
1456 #[inline]
1457 fn publish_time(&mut self) {
1458 let now = Instant::now();
1459 self.meter
1460 .nanos
1461 .fetch_add((now - self.mark).as_nanos() as u64, Ordering::Relaxed);
1462 self.mark = now;
1463 self.time_items_since_publish = 0;
1464 }
1465}
1466
1467/// Completion guard returned by [`BatchTimer::completed_item`].
1468pub struct CompletedItem<'timer, 'meter> {
1469 timer: &'timer mut BatchTimer<'meter>,
1470}
1471
1472impl Drop for CompletedItem<'_, '_> {
1473 #[inline]
1474 fn drop(&mut self) {
1475 self.timer.tick();
1476 }
1477}
1478
1479impl Drop for BatchTimer<'_> {
1480 fn drop(&mut self) {
1481 self.publish_items();
1482 self.publish_time();
1483 }
1484}