Skip to main content

rapidgzip_core/
runtime.rs

1//! Lock-free decoder telemetry and runtime worker-budget control.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
7use std::sync::{Condvar, Mutex};
8use std::time::Instant;
9
10const NO_BEST_WORKER_COUNT: usize = usize::MAX;
11
12/// Decoder implementation selected for the current input.
13#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
14#[non_exhaustive]
15pub enum DecoderPath {
16    /// Input classification has not completed yet.
17    #[default]
18    Starting,
19    /// Serial container inflation on the caller or coordinator thread.
20    Sequential,
21    /// Direct copying of independently indexed stored DEFLATE blocks.
22    Stored,
23    /// Independent inflation of densely spaced ordinary gzip members.
24    DenseMembers,
25    /// Comparing exact and speculative service rates before path selection.
26    MarkerAdmission,
27    /// The rapidgzip marker/window pipeline for gzip, zlib, or raw DEFLATE.
28    MarkerWindow,
29    /// Independent inflation of indexed BGZF blocks.
30    Bgzf,
31    /// Plain zlib-rs inflation resumed from caller-supplied index checkpoints.
32    IndexedParallel,
33}
34
35impl DecoderPath {
36    const fn encoded(self) -> u8 {
37        match self {
38            Self::Starting => 0,
39            Self::Sequential => 1,
40            Self::Stored => 2,
41            Self::DenseMembers => 3,
42            Self::MarkerAdmission => 4,
43            Self::MarkerWindow => 5,
44            Self::Bgzf => 6,
45            Self::IndexedParallel => 7,
46        }
47    }
48
49    const fn from_encoded(value: u8) -> Self {
50        match value {
51            1 => Self::Sequential,
52            2 => Self::Stored,
53            3 => Self::DenseMembers,
54            4 => Self::MarkerAdmission,
55            5 => Self::MarkerWindow,
56            6 => Self::Bgzf,
57            7 => Self::IndexedParallel,
58            _ => Self::Starting,
59        }
60    }
61}
62
63/// Current high-level constraint on decoder progress.
64///
65/// This is an approximate observation assembled from relaxed atomic loads. It
66/// describes rapidgzip's own task state, not operating-system CPU accounting.
67#[derive(Clone, Copy, Debug, PartialEq)]
68#[non_exhaustive]
69pub enum DecoderPressure {
70    /// Input classification or worker startup is still in progress.
71    Starting,
72    /// The final decoded-output handoff is blocked by its consumer.
73    ConsumerBound {
74        /// Fraction of live workers not currently executing a decoder task.
75        idle_worker_fraction: f32,
76    },
77    /// All admitted workers are busy while runnable work remains queued.
78    DecoderBound {
79        /// Approximate number of immediately runnable decoder tasks.
80        queued_tasks: usize,
81    },
82    /// Empirical calibration selected a stable worker count.
83    Converged {
84        /// Worker count selected by empirical calibration.
85        at_workers: usize,
86    },
87    /// No decoder task was running or immediately runnable when sampled.
88    Idle,
89    /// The complete compressed stream has reached a terminal state.
90    Finished,
91}
92
93/// Approximate, lock-free snapshot of a running decoder.
94///
95/// Fields are loaded independently with relaxed atomic ordering. A snapshot is
96/// therefore suitable for telemetry and scheduling feedback, but is not a
97/// transactionally consistent record of a single instant.
98#[derive(Clone, Copy, Debug, PartialEq)]
99#[non_exhaustive]
100pub struct DecoderStats {
101    /// Decoder implementation selected for the input.
102    pub path: DecoderPath,
103    /// Immutable maximum worker budget supplied to the builder.
104    pub configured_workers: usize,
105    /// Current application-controlled ceiling on decoder workers.
106    pub worker_limit: usize,
107    /// Effective decode-concurrency target after application and adaptive limits.
108    ///
109    /// This is an admission target, not the number of tasks currently executing
110    /// or the number of live operating-system threads.
111    pub active_workers: usize,
112    /// Approximate number of decoder workers, or the synchronous sequential
113    /// caller, currently decoding.
114    pub busy_workers: usize,
115    /// Live decoder-worker operating-system threads.
116    ///
117    /// This can temporarily exceed [`Self::active_workers`] while a lower limit
118    /// takes effect. In particular, a worker that owns a completed result may
119    /// remain parked on a bounded handoff until output advances or the decode is
120    /// cancelled.
121    pub spawned_workers: usize,
122    /// Live coordinator and scanner operating-system threads.
123    pub auxiliary_threads: usize,
124    /// Empirically selected worker count, once calibration has completed.
125    pub best_workers: Option<usize>,
126    /// Decompressed bytes emitted into the final output handoff.
127    pub decompressed_bytes: u64,
128    /// Decompressed bytes returned through [`std::io::Read`].
129    pub consumed_bytes: u64,
130    /// Completed framing units: gzip members, or one zlib/raw stream.
131    pub member_count: u64,
132    /// Average decoded-output production rate since decoder startup.
133    pub decode_throughput_bps: f64,
134    /// Average [`std::io::Read`] consumption rate since decoder startup.
135    pub consumer_throughput_bps: f64,
136    /// Current high-level decoder pressure classification.
137    pub pressure: DecoderPressure,
138}
139
140/// Invalid runtime decoder-worker limit.
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub struct WorkerLimitError {
143    requested: usize,
144    configured: usize,
145}
146
147impl WorkerLimitError {
148    /// Rejected worker count.
149    pub const fn requested(self) -> usize {
150        self.requested
151    }
152
153    /// Maximum worker count configured for the decoder.
154    pub const fn configured(self) -> usize {
155        self.configured
156    }
157}
158
159impl Display for WorkerLimitError {
160    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
161        write!(
162            formatter,
163            "worker limit {} is outside 1..={}",
164            self.requested, self.configured
165        )
166    }
167}
168
169impl Error for WorkerLimitError {}
170
171/// Cloneable telemetry and control handle for a running [`crate::DecoderReader`].
172///
173/// The handle remains usable after the reader has moved into another component
174/// such as a FASTQ parser. Cloning a handle does not create decoder workers.
175#[derive(Clone)]
176pub struct DecoderHandle {
177    pub(crate) state: Arc<RuntimeState>,
178}
179
180impl fmt::Debug for DecoderHandle {
181    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
182        formatter
183            .debug_struct("DecoderHandle")
184            .field("stats", &self.stats())
185            .finish()
186    }
187}
188
189impl DecoderHandle {
190    pub(crate) fn new(state: Arc<RuntimeState>) -> Self {
191        Self { state }
192    }
193
194    /// Returns an approximate lock-free snapshot of decoder activity.
195    pub fn stats(&self) -> DecoderStats {
196        self.state.stats()
197    }
198
199    /// Changes the maximum number of decoder workers that may accept work.
200    ///
201    /// The method is nonblocking. Workers already executing a task finish it and
202    /// publish any completed result they own before retiring. A worker whose
203    /// bounded result handoff is blocked therefore remains live until output
204    /// advances or the decode is cancelled. Raising the limit allows the
205    /// coordinator to create replacement workers lazily when useful work is
206    /// available.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`WorkerLimitError`] when `workers` is zero or exceeds the
211    /// immutable worker budget supplied to [`crate::DecoderBuilder`].
212    pub fn set_worker_limit(&self, workers: usize) -> Result<(), WorkerLimitError> {
213        self.state.set_worker_limit(workers)
214    }
215}
216
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
218pub(crate) enum AuxiliaryKind {
219    Coordinator,
220    Scanner,
221}
222
223pub(crate) struct ThreadRegistration {
224    state: Arc<RuntimeState>,
225    auxiliary: bool,
226}
227
228impl Drop for ThreadRegistration {
229    fn drop(&mut self) {
230        let counter = if self.auxiliary {
231            &self.state.auxiliary_threads
232        } else {
233            &self.state.spawned_workers
234        };
235        counter.fetch_sub(1, Ordering::Relaxed);
236    }
237}
238
239pub(crate) struct BusyRegistration<'a>(&'a RuntimeState);
240
241impl Drop for BusyRegistration<'_> {
242    fn drop(&mut self) {
243        self.0.busy_workers.fetch_sub(1, Ordering::Relaxed);
244    }
245}
246
247/// State shared by the reader, coordinator, scanner, and decoder workers.
248pub(crate) struct RuntimeState {
249    configured_workers: usize,
250    worker_limit: AtomicUsize,
251    adaptive_target: AtomicUsize,
252    limit_epoch: AtomicUsize,
253    path: AtomicU8,
254    busy_workers: AtomicUsize,
255    spawned_workers: AtomicUsize,
256    auxiliary_threads: AtomicUsize,
257    queued_tasks: AtomicUsize,
258    best_workers: AtomicUsize,
259    decompressed_bytes: AtomicU64,
260    consumed_bytes: AtomicU64,
261    member_count: AtomicU64,
262    consumer_blocked: AtomicBool,
263    terminal: AtomicBool,
264    terminal_elapsed_nanos: AtomicU64,
265    started: Instant,
266    limit_mutex: Mutex<()>,
267    limit_signal: Condvar,
268}
269
270impl RuntimeState {
271    pub(crate) fn new(configured_workers: usize) -> Arc<Self> {
272        Arc::new(Self {
273            configured_workers,
274            worker_limit: AtomicUsize::new(configured_workers),
275            adaptive_target: AtomicUsize::new(1),
276            limit_epoch: AtomicUsize::new(0),
277            path: AtomicU8::new(DecoderPath::Starting.encoded()),
278            busy_workers: AtomicUsize::new(0),
279            spawned_workers: AtomicUsize::new(0),
280            auxiliary_threads: AtomicUsize::new(0),
281            queued_tasks: AtomicUsize::new(0),
282            best_workers: AtomicUsize::new(NO_BEST_WORKER_COUNT),
283            decompressed_bytes: AtomicU64::new(0),
284            consumed_bytes: AtomicU64::new(0),
285            member_count: AtomicU64::new(0),
286            consumer_blocked: AtomicBool::new(false),
287            terminal: AtomicBool::new(false),
288            terminal_elapsed_nanos: AtomicU64::new(0),
289            started: Instant::now(),
290            limit_mutex: Mutex::new(()),
291            limit_signal: Condvar::new(),
292        })
293    }
294
295    fn set_worker_limit(&self, workers: usize) -> Result<(), WorkerLimitError> {
296        if workers == 0 || workers > self.configured_workers {
297            return Err(WorkerLimitError {
298                requested: workers,
299                configured: self.configured_workers,
300            });
301        }
302        let previous = self.worker_limit.swap(workers, Ordering::Relaxed);
303        if previous != workers {
304            self.limit_epoch.fetch_add(1, Ordering::Relaxed);
305            self.limit_signal.notify_all();
306        }
307        Ok(())
308    }
309
310    pub(crate) fn limit_epoch(&self) -> usize {
311        self.limit_epoch.load(Ordering::Relaxed)
312    }
313
314    /// Current application-controlled ceiling before adaptive throttling.
315    pub(crate) fn application_worker_limit(&self) -> usize {
316        self.worker_limit.load(Ordering::Relaxed)
317    }
318
319    pub(crate) fn set_adaptive_target(&self, workers: usize) {
320        let workers = workers.clamp(1, self.configured_workers);
321        let previous = self.adaptive_target.swap(workers, Ordering::Relaxed);
322        if previous != workers {
323            self.limit_signal.notify_all();
324        }
325    }
326
327    pub(crate) fn effective_worker_limit(&self) -> usize {
328        let adaptive = self.adaptive_target.load(Ordering::Relaxed);
329        let requested = self.worker_limit.load(Ordering::Relaxed);
330        if self.consumer_blocked.load(Ordering::Relaxed) {
331            1
332        } else {
333            adaptive.min(requested).max(1)
334        }
335    }
336
337    pub(crate) fn wait_for_limit_change(&self, timeout: std::time::Duration) {
338        let guard = self
339            .limit_mutex
340            .lock()
341            .expect("runtime limit mutex poisoned");
342        let _guard = self
343            .limit_signal
344            .wait_timeout(guard, timeout)
345            .expect("runtime limit mutex poisoned");
346    }
347
348    pub(crate) fn notify_limit_waiters(&self) {
349        self.limit_signal.notify_all();
350    }
351
352    pub(crate) fn set_path(&self, path: DecoderPath) {
353        self.path.store(path.encoded(), Ordering::Relaxed);
354    }
355
356    pub(crate) fn register_worker(self: &Arc<Self>) -> ThreadRegistration {
357        self.spawned_workers.fetch_add(1, Ordering::Relaxed);
358        ThreadRegistration {
359            state: Arc::clone(self),
360            auxiliary: false,
361        }
362    }
363
364    pub(crate) fn register_auxiliary(self: &Arc<Self>, _kind: AuxiliaryKind) -> ThreadRegistration {
365        self.auxiliary_threads.fetch_add(1, Ordering::Relaxed);
366        ThreadRegistration {
367            state: Arc::clone(self),
368            auxiliary: true,
369        }
370    }
371
372    pub(crate) fn begin_task(&self) -> BusyRegistration<'_> {
373        self.busy_workers.fetch_add(1, Ordering::Relaxed);
374        BusyRegistration(self)
375    }
376
377    pub(crate) fn set_queued_tasks(&self, count: usize) {
378        self.queued_tasks.store(count, Ordering::Relaxed);
379    }
380
381    pub(crate) fn set_best_workers(&self, workers: Option<usize>) {
382        self.best_workers
383            .store(workers.unwrap_or(NO_BEST_WORKER_COUNT), Ordering::Relaxed);
384    }
385
386    pub(crate) fn add_decompressed_bytes(&self, count: usize) {
387        self.decompressed_bytes
388            .fetch_add(count as u64, Ordering::Relaxed);
389    }
390
391    pub(crate) fn add_consumed_bytes(&self, count: usize) {
392        self.consumed_bytes
393            .fetch_add(count as u64, Ordering::Relaxed);
394    }
395
396    pub(crate) fn set_member_count(&self, count: u64) {
397        self.member_count.store(count, Ordering::Relaxed);
398    }
399
400    pub(crate) fn set_consumer_blocked(&self, blocked: bool) {
401        let previous = self.consumer_blocked.swap(blocked, Ordering::Relaxed);
402        if previous != blocked {
403            self.limit_signal.notify_all();
404        }
405    }
406
407    pub(crate) fn mark_terminal(&self) {
408        let elapsed_nanos = u64::try_from(self.started.elapsed().as_nanos())
409            .unwrap_or(u64::MAX)
410            .max(1);
411        let _ = self.terminal_elapsed_nanos.compare_exchange(
412            0,
413            elapsed_nanos,
414            Ordering::Relaxed,
415            Ordering::Relaxed,
416        );
417        self.terminal.store(true, Ordering::Relaxed);
418        self.consumer_blocked.store(false, Ordering::Relaxed);
419        self.queued_tasks.store(0, Ordering::Relaxed);
420        self.notify_limit_waiters();
421    }
422
423    fn stats(&self) -> DecoderStats {
424        let path = DecoderPath::from_encoded(self.path.load(Ordering::Relaxed));
425        let configured_workers = self.configured_workers;
426        let worker_limit = self.worker_limit.load(Ordering::Relaxed);
427        let adaptive_target = self.adaptive_target.load(Ordering::Relaxed);
428        let consumer_blocked = self.consumer_blocked.load(Ordering::Relaxed);
429        let terminal = self.terminal.load(Ordering::Relaxed);
430        let active_workers = if terminal {
431            0
432        } else if consumer_blocked {
433            1
434        } else {
435            adaptive_target.min(worker_limit).max(1)
436        };
437        let busy_workers = self.busy_workers.load(Ordering::Relaxed);
438        let spawned_workers = self.spawned_workers.load(Ordering::Relaxed);
439        let auxiliary_threads = self.auxiliary_threads.load(Ordering::Relaxed);
440        let queued_tasks = self.queued_tasks.load(Ordering::Relaxed);
441        let best_workers = match self.best_workers.load(Ordering::Relaxed) {
442            NO_BEST_WORKER_COUNT => None,
443            workers => Some(workers),
444        };
445        let decompressed_bytes = self.decompressed_bytes.load(Ordering::Relaxed);
446        let consumed_bytes = self.consumed_bytes.load(Ordering::Relaxed);
447        let member_count = self.member_count.load(Ordering::Relaxed);
448        let terminal_elapsed_nanos = self.terminal_elapsed_nanos.load(Ordering::Relaxed);
449        let elapsed = if terminal_elapsed_nanos == 0 {
450            self.started.elapsed().as_secs_f64()
451        } else {
452            terminal_elapsed_nanos as f64 / 1_000_000_000.0
453        }
454        .max(f64::MIN_POSITIVE);
455        let pressure = if terminal {
456            DecoderPressure::Finished
457        } else if consumer_blocked {
458            let idle = spawned_workers.saturating_sub(busy_workers);
459            let idle_worker_fraction = if spawned_workers == 0 {
460                1.0
461            } else {
462                idle as f32 / spawned_workers as f32
463            };
464            DecoderPressure::ConsumerBound {
465                idle_worker_fraction,
466            }
467        } else if queued_tasks != 0 && busy_workers >= active_workers {
468            DecoderPressure::DecoderBound { queued_tasks }
469        } else if let Some(at_workers) = best_workers {
470            DecoderPressure::Converged { at_workers }
471        } else if busy_workers == 0 && queued_tasks == 0 {
472            DecoderPressure::Idle
473        } else {
474            DecoderPressure::Starting
475        };
476
477        DecoderStats {
478            path,
479            configured_workers,
480            worker_limit,
481            active_workers,
482            busy_workers,
483            spawned_workers,
484            auxiliary_threads,
485            best_workers,
486            decompressed_bytes,
487            consumed_bytes,
488            member_count,
489            decode_throughput_bps: decompressed_bytes as f64 / elapsed,
490            consumer_throughput_bps: consumed_bytes as f64 / elapsed,
491            pressure,
492        }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::{DecoderHandle, DecoderPath, DecoderPressure, RuntimeState};
499
500    #[test]
501    fn runtime_limits_are_validated_and_visible() {
502        let state = RuntimeState::new(8);
503        let handle = DecoderHandle::new(state);
504        handle.set_worker_limit(3).unwrap();
505        let stats = handle.stats();
506        assert_eq!(stats.configured_workers, 8);
507        assert_eq!(stats.worker_limit, 3);
508        assert_eq!(stats.active_workers, 1);
509        assert_eq!(handle.set_worker_limit(0).unwrap_err().requested(), 0);
510        assert_eq!(handle.set_worker_limit(9).unwrap_err().configured(), 8);
511    }
512
513    #[test]
514    fn consumer_backpressure_caps_admission() {
515        let state = RuntimeState::new(8);
516        state.set_adaptive_target(6);
517        let worker = state.register_worker();
518        let _busy = state.begin_task();
519        state.set_consumer_blocked(true);
520        let stats = DecoderHandle::new(Arc::clone(&state)).stats();
521        assert_eq!(stats.active_workers, 1);
522        assert!(matches!(
523            stats.pressure,
524            DecoderPressure::ConsumerBound { .. }
525        ));
526        drop(worker);
527    }
528
529    #[test]
530    fn marker_admission_path_is_visible_in_telemetry() {
531        let state = RuntimeState::new(4);
532        state.set_path(DecoderPath::MarkerAdmission);
533        assert_eq!(
534            DecoderHandle::new(state).stats().path,
535            DecoderPath::MarkerAdmission
536        );
537    }
538
539    use std::sync::Arc;
540}