Skip to main content

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}