moqtap_proxy/shape/scheduler.rs
1//! The session-scoped shaper: classification, admission and release.
2//!
3//! [`Scheduler`] is the one object a shaped session's forwarding tasks
4//! share. It owns the validated [`ShapeProfile`], the report-once state
5//! that keeps a run from repeating itself, and the token buckets, and it
6//! answers three questions per unit:
7//!
8//! 1. **Which class is this?** — [`Scheduler::classify`], the
9//! [`Matcher`](super::Matcher)
10//! over [`ObjectMeta`] plus the fall to [`Class::Default`], plus the
11//! `ShapeRuleUnmatchable` report that keeps that fall from being silent.
12//! 2. **Does it fit?** — [`Scheduler::admit`], the per-stream queue depth
13//! and the [`Overflow`] policy.
14//! 3. **May it go now?** — [`Scheduler::acquire`], the token bucket and the
15//! [`Discipline`] that arbitrates between classes
16//! sharing one.
17//!
18//! The first two are **pure functions of their arguments and the profile**:
19//! neither reads a clock, touches a queue or writes a counter. The third
20//! owns state — that is what a bucket *is* — but it still takes `now` as a
21//! parameter and reads no clock, which is why the arithmetic under it stays
22//! provable from a table. The statistics are the caller's to record, and the
23//! reports are handed to a callback, so everything below is testable with no
24//! QUIC, no runtime and no session.
25//!
26//! # Release is per class; the stream performs it
27//!
28//! [`Scheduler::acquire`] is the whole of the release decision, and it is
29//! reachable from exactly one place: `PendingQueue::pop_next_due`, which is
30//! `&mut self`, is the sole ordering authority, and is called exactly once
31//! per released unit. It is deliberately **not** reachable from
32//! `PendingQueue::head_release`, which is `&self`, fires once per `select!`
33//! iteration rather than once per unit, and is also called by the two
34//! teardown drains and by the control pipes.
35//!
36//! **The control pipes are never shaped.** `pipe_control_passthrough` and
37//! `pipe_control_mutating` install no [`Scheduler`] on their queues at all,
38//! so no control frame can reach a bucket even by accident. Pacing
39//! SUBSCRIBE and ANNOUNCE behind a video bucket would stall MoQT's steady
40//! state and make an idle control stream look like a dead session.
41//!
42//! # Why starvation is a gate and not a poll
43//!
44//! A class that a discipline refuses has no deadline to arm — nothing about
45//! *time* will make it eligible, only the blocking class draining will. So
46//! [`Acquire::Starved`] hands back a [`Gate`], created under the same lock
47//! that reads the demand it is waiting on, and the withdrawal of that demand
48//! releases it. Level-triggered, so a withdrawal that races the park is
49//! observed rather than lost; a fresh gate per park, so a release cannot be
50//! mistaken for the next one. Polling instead would either spin a starved
51//! stream hot or add a latency nobody configured.
52//!
53//! # Why admission is per stream and release is per class
54//!
55//! Queue depth, [`Overflow`] and the framer's elide fix-up all live where
56//! `note_elided` is legal: at admission, on the *arriving* unit, before the
57//! framer's positional cursor has moved past it. Dropping an
58//! already-queued unit at release time would not leave a gap in absolute
59//! object IDs on drafts 14-23 — it would leave every successor decoding a
60//! *wrong* ID. That single fact is why [`Overflow::DropTail`] exists and
61//! `DropHead` does not.
62
63use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
64use std::sync::{Arc, Mutex};
65use std::time::{Duration, Instant};
66
67use crate::action::Gate;
68use crate::types::{ObjectMeta, ProxySide};
69
70use moqtap_codec::dispatch::AnyDatagramMeta;
71
72use moqtap_codec::version::DraftVersion;
73
74use super::bucket::{charge, BucketState, Grant};
75use super::matcher::MatcherField;
76use super::{Discipline, Expiry, Overflow, ShapeProfile};
77
78/// Which row of [`ShapeStats`](super::ShapeStats) a unit is charged to.
79///
80/// `Copy`, and an index rather than a name: the storage is a pre-sized
81/// `Vec<ClassCounters>` in configured order, so charging a unit is one
82/// relaxed `fetch_add` at a known offset and never a map lookup.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub(crate) enum Class {
85 /// The rule at this index in [`ShapeProfile::classes`].
86 Rule(usize),
87 /// No rule claimed the unit.
88 ///
89 /// This is the falsifiable signal that a rule did not fire: an author
90 /// who expected a class to claim everything and reads
91 /// `ShapeStats::default_class.objects_delivered > 0` knows their rule
92 /// did not fire, and the `ShapeRuleUnmatchable` impairment says why.
93 ///
94 /// **`objects_delivered`, not `objects_dropped`** — `objects_dropped`
95 /// has a producer only under [`Overflow::DropTail`], so under the
96 /// default [`Overflow::Block`] it is permanently zero and an author
97 /// told to read it would read a zero and conclude their rule had fired.
98 /// `objects_delivered` has a producer under every overflow policy.
99 /// (The drop row is still the right one to read in a
100 /// `DropTail` fixture, which is why
101 /// `a_datagram_rule_says_so_instead_of_matching_nothing` asserts it.)
102 Default,
103 /// There was nothing to classify: a subgroup stream header, an oversized
104 /// object the framer could only pass through, a bypassed stream's bytes.
105 /// Such a unit still takes an **ordering slot** — it is bytes on a wire
106 /// that has other bytes queued in front of it — but it charges no bucket,
107 /// because no rule can name what no `ObjectMeta` describes. Distinct from
108 /// [`Self::Default`], which is a unit the rules *did* see and none of them
109 /// claimed: conflating them would let *my rule matched nothing* and *there
110 /// was nothing for a rule to match* report as one number.
111 Unshapeable,
112}
113
114/// What admission decided about one **arriving** unit.
115///
116/// There is no `Block` variant, and its absence is the design: `Block` is
117/// not a decision taken about a unit that arrived, it is the read branch
118/// never being polled, so no unit arrives to decide about. That is what
119/// makes [`Self::DropTail`] observable at all — see
120/// [`Scheduler::blocking_depth`].
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub(crate) enum Admission {
123 /// Forward it, exactly as an unshaped session would.
124 Admit,
125 /// Discard it. The caller must run it through the framer's elide path
126 /// so absolute object IDs on drafts 14-23 stay correct, and must admit
127 /// it anyway if an elide guard refuses.
128 DropTail,
129 /// Abandon the destination stream with `code`.
130 ResetStream {
131 /// The application error code to reset with.
132 code: u64,
133 },
134}
135
136/// What release decided about one **queued** unit.
137///
138/// The four "not now" answers are four different waits, and collapsing them
139/// is how a shaper stalls or misreports: a bucket that will refill names an
140/// instant, a bucket that will never refill names nothing at all, a bucket
141/// too small to hold one unit names nothing *and* is a misconfiguration, and
142/// a discipline that is holding the class back names an event.
143#[derive(Clone, Debug)]
144pub(crate) enum Acquire {
145 /// Write it. The tokens have already been debited.
146 Now,
147 /// Not yet — the bucket holds enough at this instant and not before.
148 ///
149 /// *Earliest*, not exact: another stream sharing the bucket may drain it
150 /// again first, in which case the next [`Scheduler::acquire`] answers
151 /// `Later` again with a new instant.
152 Later(Instant),
153 /// Not yet, and no instant exists: the rate is `Some(0)`, so the bucket
154 /// never refills. The caller supplies its own deadline — the unit's
155 /// `max_hold` clamp — because arming a fabricated far-future instant
156 /// would arm a timer that never fires and nothing downstream could tell
157 /// it from a real deadline.
158 Never,
159 /// Not yet, and not ever: this class's `burst_bytes` is smaller than the
160 /// unit at the head of its queue, so its configured rate can never pace
161 /// anything and every unit leaves at the clamp instead.
162 ///
163 /// **Handled exactly as [`Self::Never`] is** — the clamp is the deadline
164 /// in both cases — and kept apart from it for one reason: this one is a
165 /// misconfiguration and `Never` is a configuration. Without the split
166 /// the two produce the same clamp, the same `tokens_exhausted_episodes`
167 /// and the same `HoldClamped`, so an author who wrote a rate and left
168 /// the burst too small reads a throughput unrelated to their number with
169 /// no signal that anything is wrong. The caller pairs this with
170 /// [`Scheduler::claim_burst_report`] and reports it once per class.
171 LargerThanBurst {
172 /// The class's bucket cap.
173 burst_bytes: u64,
174 /// The unit it could not cover.
175 unit_bytes: u64,
176 },
177 /// Not yet, and the reason is not the bucket: another class sharing it
178 /// is ahead under the configured [`Discipline`]. Wait on this gate; it
179 /// is released when the blocking demand withdraws.
180 ///
181 /// Charged to `starved_behind_other_class` and **never** to
182 /// `tokens_exhausted_episodes` — two causes, two counters.
183 Starved(Gate),
184}
185
186/// The per-stream queue depth, in both units at once.
187///
188/// Both limits, always: a queue of small objects reaches neither the byte
189/// depth nor `EgressConfig::max_pending_bytes`, and a queue of large ones
190/// reaches the byte depth long before the object count.
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub(crate) struct QueueDepth {
193 /// Bytes one stream's queue may hold.
194 pub(crate) bytes: usize,
195 /// Objects one stream's queue may hold.
196 pub(crate) objects: usize,
197}
198
199/// One session's shaper.
200///
201/// Held behind an `Arc` on `ForwardCtx` and cloned into every forwarding
202/// task, exactly as the two recorders are — and, unlike them,
203/// **`None` when the session has no profile**. There is nothing for an
204/// unshaped session to share, and an `Option` on the context is what makes
205/// "a session with `shape: None` adds nothing to this path" a compile-time
206/// fact rather than a runtime hope.
207pub(crate) struct Scheduler {
208 profile: ShapeProfile,
209 /// One report-once mask per class, indexed the same way
210 /// [`Class::Rule`] is. Bit `MatcherField::bit` is set the first time
211 /// that `(class, field)` pair is reported.
212 /// Relaxed atomics rather than a `Mutex`: the only operation is *set this
213 /// bit, tell me whether it was already set*, the only consequence of losing
214 /// a race is one duplicate event on a path that fires at most `classes × 4`
215 /// times per session, and taking a lock per non-matching rule per unit
216 /// would put a contended mutex on the data path of every shaped session.
217 unmatchable: Vec<AtomicU8>,
218 /// One report-once flag per class for *this class's burst cannot cover one
219 /// of its own units*, indexed the same way [`Class::Rule`] is.
220 ///
221 /// A separate mask from [`Self::unmatchable`] rather than a fifth bit in
222 /// it, because the two are answers to different questions and share no
223 /// key: that one is per `(class, field)` and is set from classification,
224 /// this one is per class and is set from release. Sharing the storage
225 /// would make the *first* release refusal silence a later unmatchable
226 /// field report on the same class, which is a diagnostic losing to an
227 /// unrelated one.
228 /// A relaxed `swap` for the same reason the other mask is relaxed: the only
229 /// operation is *claim this, tell me whether it was already claimed*, the
230 /// only cost of losing the race is one duplicate event, and the path fires
231 /// at most once per class per session.
232 burst_reported: Vec<AtomicBool>,
233 /// Which bucket each class charges, resolved once at construction.
234 ///
235 /// `ShapeProfile::try_new` has already rejected a class naming a bucket
236 /// that is not configured, so this is a total map and the lookup on the
237 /// data path is an index rather than a string compare.
238 class_bucket: Vec<usize>,
239 /// Everything release mutates, behind one lock.
240 ///
241 /// A `Mutex` and not a set of atomics, and the reason is the gate: a
242 /// class parks on a gate created from the *same* read of the demand that
243 /// decided to park it, and the withdrawal that releases it happens under
244 /// the same lock. Split across atomics there is a window in which a
245 /// withdrawal wakes nobody and the park that follows waits for a wake
246 /// that has already happened. The lock is taken once per released unit,
247 /// not once per byte, on a path that is already doing a `write_all`.
248 state: Mutex<SchedState>,
249 /// Whether this scheduler paces at all.
250 ///
251 /// Shared with every other scheduler built from the same proxy, which is
252 /// what makes turning pacing off a single act rather than a walk over
253 /// the sessions. A scheduler built with no switch of its own owns one
254 /// that is set and never cleared, so pacing is never switched off under
255 /// a session driven without a proxy.
256 ///
257 /// Read in [`Self::acquire`] and nowhere else. Everything a shaper does
258 /// *besides* pacing — classification, the per-stream queue depth, the
259 /// statistics — keeps running while it is clear, so the profile is still
260 /// there to resume with and the rows a caller was reading keep moving.
261 enabled: Arc<AtomicBool>,
262}
263
264/// The scheduler's mutable half.
265struct SchedState {
266 /// One per [`ShapeProfile::buckets`] entry, in configured order.
267 buckets: Vec<BucketState>,
268 /// How many stream queues currently hold an unreleased head of this
269 /// class. The **demand** a discipline arbitrates over.
270 ///
271 /// Maintained by `PendingQueue`, which declares its head's class and
272 /// withdraws on every path that can retire one — including `clear`,
273 /// the teardown drain and `Drop`. A leaked declaration would starve a
274 /// lower class for the rest of the session, which is why the withdrawal
275 /// is a `Drop` obligation and not a list of call sites.
276 demand: Vec<u64>,
277 /// Releases remaining in this round, per class, under
278 /// [`Discipline::WeightedRoundRobin`]. Refilled to
279 /// [`ClassRule::weight`](super::ClassRule::weight) when every class with
280 /// demand in a bucket has spent its own.
281 credits: Vec<u32>,
282 /// The gate each starved class is parked on, or `None` when it is not
283 /// parked. Taken and released by whoever makes the class eligible.
284 parked: Vec<Option<Gate>>,
285}
286
287impl Scheduler {
288 /// Build the shaper for `profile`, with every bucket full as of `now`.
289 ///
290 /// Full rather than empty: an empty burst would put a
291 /// `burst_bytes / rate_bps` delay in front of the first object of every
292 /// session and read as a broken proxy.
293 pub(crate) fn new(profile: ShapeProfile) -> Self {
294 Self::at(profile, Instant::now())
295 }
296
297 /// [`Self::new`], sharing `enabled` with every other scheduler this proxy
298 /// builds.
299 ///
300 /// The switch is handed in rather than owned because turning pacing off
301 /// has to be one act for the whole proxy: a session builds its own
302 /// scheduler, and a per-scheduler flag would have to be found and
303 /// cleared once per session, which is a walk over a set that changes
304 /// while it is being walked.
305 pub(crate) fn with_switch(profile: ShapeProfile, enabled: Arc<AtomicBool>) -> Self {
306 Self::build(profile, Instant::now(), enabled)
307 }
308
309 /// [`Self::new`] with the clock supplied, so the unit tests below fabricate
310 /// their own timeline exactly as [`charge`] lets them.
311 pub(crate) fn at(profile: ShapeProfile, now: Instant) -> Self {
312 Self::build(profile, now, Arc::new(AtomicBool::new(true)))
313 }
314
315 /// The one constructor. Both public ones differ only in where the clock
316 /// and the switch come from.
317 fn build(profile: ShapeProfile, now: Instant, enabled: Arc<AtomicBool>) -> Self {
318 let unmatchable = profile.classes().iter().map(|_| AtomicU8::new(0)).collect();
319 let burst_reported = profile.classes().iter().map(|_| AtomicBool::new(false)).collect();
320 let class_bucket = profile
321 .classes()
322 .iter()
323 .map(|c| {
324 profile
325 .buckets()
326 .iter()
327 .position(|b| b.name == c.bucket)
328 // `try_new` rejects `UnknownBucket`, so this is
329 // unreachable; charging bucket 0 rather than panicking on
330 // a forwarding task is the same total-function discipline
331 // `ShapeRecorder::row` takes.
332 .unwrap_or(0)
333 })
334 .collect();
335 let state = SchedState {
336 buckets: profile
337 .buckets()
338 .iter()
339 .map(|b| BucketState::new(b.burst_bytes, now))
340 .collect(),
341 demand: vec![0; profile.classes().len()],
342 credits: profile.classes().iter().map(|c| u32::from(c.weight)).collect(),
343 parked: profile.classes().iter().map(|_| None).collect(),
344 };
345 Self {
346 profile,
347 unmatchable,
348 burst_reported,
349 class_bucket,
350 state: Mutex::new(state),
351 enabled,
352 }
353 }
354
355 /// The name to label a report with, for a class index.
356 pub(crate) fn class_name(&self, index: usize) -> String {
357 self.profile.classes()[index].name.clone()
358 }
359
360 /// The deadline a queued unit is clamped to, or `None` to inherit
361 /// [`EgressConfig::max_hold`](crate::action::EgressConfig::max_hold).
362 /// Under the default [`Expiry::Deliver`] this is the instant a starved unit
363 /// goes out **anyway**, which is why every starvation fixture is required
364 /// to pin it: *a 0-bps class delivers zero bytes* is a statement about a
365 /// sampling window, and the window only means something against a ceiling
366 /// the fixture wrote down.
367 pub(crate) fn max_hold(&self) -> Option<Duration> {
368 self.profile.queue().max_hold
369 }
370
371 /// What happens to a unit that outlives its clamp.
372 pub(crate) fn on_expiry(&self) -> Expiry {
373 self.profile.queue().on_expiry
374 }
375
376 /// The depth `PendingQueue::accepts_more` must carry, or `None` when
377 /// this profile does not stop reading.
378 ///
379 /// **`Some` only under [`Overflow::Block`]**. Under `DropTail` the
380 /// read branch has to stay enabled, or nothing ever arrives to be
381 /// dropped and `objects_dropped` could only ever be zero; under
382 /// `ResetStream` the arriving unit is what triggers the reset. So the
383 /// depth is installed on the queue for exactly one policy, and the
384 /// other two evaluate it per unit through [`Self::admit`].
385 ///
386 /// Installing it *in* `accepts_more` rather than beside it is what
387 /// gives `Impairment{EgressQueueFull}` a producer here at all: the
388 /// once-per-transition latch is computed inside `PendingQueue::push`
389 /// from `accepts_more()`, so a stream full by `depth_objects` but under
390 /// `EgressConfig::max_pending_bytes` would otherwise never trip it.
391 pub(crate) fn blocking_depth(&self) -> Option<QueueDepth> {
392 let queue = self.profile.queue();
393 match queue.overflow {
394 Overflow::Block => {
395 Some(QueueDepth { bytes: queue.depth_bytes, objects: queue.depth_objects })
396 }
397 Overflow::DropTail | Overflow::ResetStream { .. } => None,
398 }
399 }
400
401 /// Classify one unit, reporting each unmatchable `(class, field)` once
402 /// per session.
403 ///
404 /// Rules are tried in configured order and the **first** match wins, so
405 /// a profile's order is a priority order for classification. A unit no
406 /// rule claims is [`Class::Default`].
407 ///
408 /// `report` is called with the class index and the key that could not
409 /// be carried, at most once per pair for the session's whole life. It
410 /// is a callback rather than a return value because the common answer
411 /// is "nothing to report" and building a collection to say so would
412 /// allocate on the data path.
413 ///
414 /// `unit_index` is the per-**stream** count of hook-visible units,
415 /// which is what `Matcher::every_nth` is defined against — never
416 /// `ObjectMeta::index_in_stream`, which counts oversized objects the
417 /// hook never sees and would silently shift the pattern.
418 ///
419 /// # The diagnostic sweep does not stop at the winner
420 /// The *classification* short-circuits — first match wins, and the loop
421 /// below stops asking `matches` once it has an answer. The unmatchability
422 /// check does **not**, and the asymmetry is what stops a dead rule going
423 /// unreported: *which class is this unit* is about one unit, but *can this
424 /// rule ever fire* is a question about the profile, and a rule's answer to
425 /// it does not depend on whether some earlier rule happened to claim this
426 /// particular unit.
427 ///
428 /// Returning at the winner would make it depend on exactly that. A
429 /// catch-all placed first — which is the value `ClassRule::default()` hands
430 /// you, since `Matcher::default()` claims everything — would match every
431 /// unit, so no later rule would ever be examined, silencing every
432 /// diagnostic behind it for the session's whole life.
433 ///
434 /// The winner itself is skipped, and that is not the same thing: a rule
435 /// that *matched* cannot have been defeated by an absent key, so
436 /// reporting it would be a false positive on a working rule.
437 ///
438 /// The cost is one `Matcher::unmatchable_fields` per rule per unit —
439 /// three `is_some` tests over a fixed-size array, no allocation, and the
440 /// relaxed `fetch_or` is reached only when a field is genuinely absent.
441 /// A profile whose keys are all carried never touches an atomic.
442 pub(crate) fn classify(
443 &self,
444 side: ProxySide,
445 meta: &ObjectMeta,
446 unit_index: u64,
447 mut report: impl FnMut(usize, MatcherField),
448 ) -> Class {
449 let mut claimed: Option<Class> = None;
450 for (index, rule) in self.profile.classes().iter().enumerate() {
451 if claimed.is_none() && rule.matcher.matches(side, meta, unit_index) {
452 claimed = Some(Class::Rule(index));
453 continue;
454 }
455 for field in rule.matcher.unmatchable_fields(meta).into_iter().flatten() {
456 self.report_once(index, field, &mut report);
457 }
458 }
459 claimed.unwrap_or(Class::Default)
460 }
461
462 /// [`Self::classify`] for a datagram.
463 ///
464 /// The same two-part sweep — first match wins, every rule that did not
465 /// match is asked whether it *could* have — over
466 /// [`Matcher::matches_datagram`] and
467 /// [`Matcher::unmatchable_fields_datagram`] instead of their framed
468 /// siblings. The report-once mask is the one `classify` uses, so a
469 /// session carrying both never reports a `(class, field)` pair twice and
470 /// whichever carrier arrives first is the one that says it.
471 ///
472 /// `unit_index` counts hook-visible datagrams per forwarding direction;
473 /// see [`Matcher::matches_datagram`] for why that is the only scope a
474 /// datagram has.
475 ///
476 /// The draft is taken as an argument rather than read off the unit
477 /// because an [`AnyDatagramMeta`] does not carry one — it is the resolved
478 /// identity, not the header — where an [`ObjectMeta`] does.
479 pub(crate) fn classify_datagram(
480 &self,
481 side: ProxySide,
482 draft: DraftVersion,
483 meta: &AnyDatagramMeta,
484 unit_index: u64,
485 mut report: impl FnMut(usize, MatcherField),
486 ) -> Class {
487 let mut claimed: Option<Class> = None;
488 for (index, rule) in self.profile.classes().iter().enumerate() {
489 if claimed.is_none() && rule.matcher.matches_datagram(side, meta, unit_index) {
490 claimed = Some(Class::Rule(index));
491 continue;
492 }
493 for field in rule.matcher.unmatchable_fields_datagram(draft, meta).into_iter().flatten()
494 {
495 self.report_once(index, field, &mut report);
496 }
497 }
498 claimed.unwrap_or(Class::Default)
499 }
500
501 /// Set `(index, field)`'s bit and call `report` only if this is the
502 /// first time anyone has.
503 ///
504 /// One relaxed `fetch_or`; see [`Self::unmatchable`] for why the losing
505 /// side of a race costs one duplicate event rather than a lock.
506 fn report_once(
507 &self,
508 index: usize,
509 field: MatcherField,
510 report: &mut impl FnMut(usize, MatcherField),
511 ) {
512 let Some(mask) = self.unmatchable.get(index) else { return };
513 if mask.fetch_or(field.bit(), Ordering::Relaxed) & field.bit() == 0 {
514 report(index, field);
515 }
516 }
517
518 /// Whether a unit of `bytes` fits behind `queued_objects` units holding
519 /// `queued_bytes`, and what to do if it does not.
520 ///
521 /// The byte test counts the arriving unit (`queued_bytes + bytes >
522 /// depth_bytes`) and the object test does not (`queued_objects >=
523 /// depth_objects`), because they answer different questions: the byte
524 /// depth is a budget the queue may not exceed, and the object depth is
525 /// a count of slots, all of which are taken.
526 ///
527 /// [`Overflow::Block`] answers [`Admission::Admit`] here and always
528 /// will: under `Block` a full queue is one the read branch is not
529 /// polling, so a unit reaching this function proves there was room for
530 /// it. Deciding to drop it here as well would double-count the same
531 /// limit and silently make `Block` destructive.
532 pub(crate) fn admit(
533 &self,
534 bytes: usize,
535 queued_bytes: usize,
536 queued_objects: usize,
537 ) -> Admission {
538 let queue = self.profile.queue();
539 let fits = queued_objects < queue.depth_objects
540 && queued_bytes.saturating_add(bytes) <= queue.depth_bytes;
541 if fits {
542 return Admission::Admit;
543 }
544 match queue.overflow {
545 Overflow::Block => Admission::Admit,
546 Overflow::DropTail => Admission::DropTail,
547 Overflow::ResetStream { code } => Admission::ResetStream { code },
548 }
549 }
550
551 // ── release ─────────────────────────────────────────────────────
552
553 /// Ask whether a queued unit of `class` holding `bytes` may go at `now`,
554 /// debiting its bucket when the answer is yes.
555 ///
556 /// **The release seam.** Called from `PendingQueue::pop_next_due` and
557 /// from nowhere else, exactly once per released unit.
558 /// [`Class::Default`] and [`Class::Unshapeable`] answer [`Acquire::Now`]
559 /// unconditionally and touch no lock: neither names a bucket, so there is
560 /// nothing to charge and nothing to arbitrate. That is not a shortcut — it
561 /// is what *no rule claimed this* means. A profile that wants a catch-all
562 /// charges one by writing a class with an all-`None`
563 /// [`Matcher`](super::Matcher).
564 ///
565 /// The discipline is consulted **before** the bucket, and the order is
566 /// load-bearing: charging first would let a class the discipline is
567 /// holding back spend tokens the class ahead of it is entitled to, and
568 /// the debit is not refundable.
569 ///
570 /// # While pacing is switched off
571 ///
572 /// Every unit is granted here, before the bucket is read and before the
573 /// discipline is consulted, so nothing is debited and no class is parked
574 /// while the switch is clear.
575 ///
576 /// **It is read when a queue asks, and a queue asks when its head's park
577 /// expires.** This function is the whole of the release decision, and a
578 /// queue that has been refused here parks its head on the deadline the
579 /// refusal named. Switching pacing off does not reach into that park; it
580 /// changes the answer the *next* call gives. So the delay between the
581 /// switch and a stream resuming is the park that was already running,
582 /// and which park that is depends on why the head was refused:
583 ///
584 /// * refused by a bucket that will refill — the refill instant, which is
585 /// one unit's worth of the configured rate;
586 /// * refused by a bucket that never refills, or one whose burst cannot
587 /// cover a unit — no instant exists, so the park is the queue's
588 /// `max_hold` clamp, which on a stopped class is the whole of it;
589 /// * held back by the discipline — the class ahead withdrawing its
590 /// demand, which is now immediate, because that class is granted here
591 /// too.
592 ///
593 /// The middle case is the one to know: switching pacing off does **not**
594 /// promptly release a class configured at zero. Nothing here can, and
595 /// nothing else in this crate can either — the park is a timer a queue
596 /// armed, the queues are per stream, and no session-wide wake reaches
597 /// them.
598 ///
599 /// Nothing leaves a bucket half-charged whichever way the switch moves:
600 /// the debit happens on the same call as the grant or not at all.
601 pub(crate) fn acquire(&self, class: Class, bytes: u64, now: Instant) -> Acquire {
602 if !self.enabled.load(Ordering::Relaxed) {
603 return Acquire::Now;
604 }
605 let Class::Rule(index) = class else {
606 return Acquire::Now;
607 };
608 let Some(&bucket) = self.class_bucket.get(index) else {
609 return Acquire::Now;
610 };
611 let mut state = self.state.lock().expect("shape scheduler");
612
613 if let Some(gate) = self.discipline_holds(&mut state, index, bucket) {
614 return Acquire::Starved(gate);
615 }
616
617 let config = &self.profile.buckets()[bucket];
618 match charge(&mut state.buckets[bucket], config.rate_bps, config.burst_bytes, bytes, now) {
619 Grant::Now => {
620 if self.profile.discipline() == Discipline::WeightedRoundRobin {
621 let credit = &mut state.credits[index];
622 *credit = credit.saturating_sub(1);
623 }
624 Acquire::Now
625 }
626 Grant::Later(at) => Acquire::Later(at),
627 Grant::Never => Acquire::Never,
628 Grant::LargerThanBurst { burst_bytes, unit_bytes } => {
629 Acquire::LargerThanBurst { burst_bytes, unit_bytes }
630 }
631 }
632 }
633
634 /// Claim the once-per-session right to report that `class`'s burst is
635 /// smaller than one of its own units, and say what to call the class.
636 ///
637 /// `Some(name)` for the first caller and `None` for every one after, so
638 /// a class whose every unit is refused for the whole session reports
639 /// once rather than once per release attempt. The name comes back with
640 /// the claim because the caller needs both and asking twice would let a
641 /// caller claim one class and label another.
642 ///
643 /// **Once per session per class, not once per stream.** A burst that
644 /// cannot cover an object is a property of the profile, so every stream
645 /// carrying that class reproduces it and a per-stream report would say
646 /// the same thing as many times as the session has streams. The rows
647 /// that keep counting are the class's own `tokens_exhausted_episodes`
648 /// and the `HoldClamped` report on each clamped unit.
649 ///
650 /// Answers `None` for the two rows that name no bucket: neither
651 /// [`Class::Default`] nor [`Class::Unshapeable`] reaches [`charge`] at
652 /// all, so neither can have produced the refusal this reports.
653 pub(crate) fn claim_burst_report(&self, class: Class) -> Option<String> {
654 let Class::Rule(index) = class else { return None };
655 let claimed = self.burst_reported.get(index)?.swap(true, Ordering::Relaxed);
656 if claimed {
657 return None;
658 }
659 Some(self.class_name(index))
660 }
661
662 /// Whether the discipline is holding `index` back, and the gate to wait
663 /// on if it is.
664 ///
665 /// Scoped to the classes that **share `bucket`**: a discipline arbitrates
666 /// between classes competing for one bucket, and two classes with their
667 /// own buckets are not competing for anything.
668 fn discipline_holds(
669 &self,
670 state: &mut SchedState,
671 index: usize,
672 bucket: usize,
673 ) -> Option<Gate> {
674 let discipline = self.profile.discipline();
675 // Whoever asked first: the bucket alone decides, and it decides by
676 // arriving at `charge` first. Answered before anything is collected,
677 // so the default discipline allocates nothing per unit.
678 if discipline == Discipline::Fifo {
679 return None;
680 }
681
682 let classes = self.profile.classes();
683 // The classes this one is actually competing with: same bucket, not
684 // itself, and holding something to send.
685 let rivals: Vec<usize> = (0..classes.len())
686 .filter(|&j| j != index && self.class_bucket[j] == bucket && state.demand[j] > 0)
687 .collect();
688
689 match discipline {
690 Discipline::Fifo => return None,
691 Discipline::StrictPriority => {
692 let mine = classes[index].priority;
693 if !rivals.iter().any(|&j| classes[j].priority > mine) {
694 return None;
695 }
696 }
697 Discipline::WeightedRoundRobin => {
698 if state.credits[index] > 0 {
699 return None;
700 }
701 if rivals.iter().any(|&j| state.credits[j] > 0) {
702 // Somebody else still owes this round: wait for them.
703 // Deliberately *not* a refill — refilling here would let
704 // a class that spent its share take a second one while a
705 // rival still had credit, and the ratio would collapse to
706 // whoever polls most often.
707 } else {
708 // Every class with demand has spent its share, so the
709 // round is over. Refill and wake the ones parked on it.
710 for (j, rule) in classes.iter().enumerate() {
711 if self.class_bucket[j] == bucket {
712 state.credits[j] = u32::from(rule.weight);
713 }
714 }
715 self.wake_bucket(state, bucket);
716 return None;
717 }
718 }
719 }
720
721 // Parked. One gate per park, created under the lock that just read
722 // the demand it waits on, so a withdrawal cannot slip between the
723 // decision and the wait.
724 Some(state.parked[index].get_or_insert_with(Gate::new).clone())
725 }
726
727 /// Release every class parked on `bucket`.
728 ///
729 /// Over-waking is safe and deliberate: a woken class re-asks and parks
730 /// again on a *fresh* gate if it is still held back, which costs one
731 /// loop iteration. Under-waking is a stall until `max_hold`, so the
732 /// asymmetry decides the direction to err in.
733 fn wake_bucket(&self, state: &mut SchedState, bucket: usize) {
734 for j in 0..state.parked.len() {
735 if self.class_bucket[j] == bucket {
736 if let Some(gate) = state.parked[j].take() {
737 gate.release();
738 }
739 }
740 }
741 }
742
743 /// Record that one stream queue now holds an unreleased head of `class`.
744 ///
745 /// Demand, not depth: one per *queue*, whatever it holds behind that
746 /// head. A discipline arbitrates between classes that have something to
747 /// send, and a class with ten streams waiting is not ten times more
748 /// entitled than a class with one.
749 pub(crate) fn declare_demand(&self, class: Class) {
750 let Class::Rule(index) = class else { return };
751 let mut state = self.state.lock().expect("shape scheduler");
752 if let Some(slot) = state.demand.get_mut(index) {
753 *slot += 1;
754 }
755 }
756
757 /// Undo one [`Self::declare_demand`], waking whatever it was holding back.
758 /// Called from every path that can retire a head — a grant, a `clear`, the
759 /// teardown drain, and `PendingQueue`'s `Drop`. The wake is unconditional
760 /// rather than *only when the count reached zero*, because the classes it
761 /// wakes re-ask and park again for free, while a missed wake is a
762 /// `max_hold` stall on a stream that has nothing wrong with it.
763 pub(crate) fn withdraw_demand(&self, class: Class) {
764 let Class::Rule(index) = class else { return };
765 let Some(&bucket) = self.class_bucket.get(index) else { return };
766 let mut state = self.state.lock().expect("shape scheduler");
767 if let Some(slot) = state.demand.get_mut(index) {
768 *slot = slot.saturating_sub(1);
769 }
770 self.wake_bucket(&mut state, bucket);
771 }
772}
773
774impl std::fmt::Debug for Scheduler {
775 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776 f.debug_struct("Scheduler").field("profile", &self.profile).finish_non_exhaustive()
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783 use crate::shape::{
784 BucketConfig, ClassRule, Discipline, MatchKind, Matcher, QueueConfig, RangeSet,
785 };
786 use crate::types::DataStreamType;
787 use moqtap_codec::version::DraftVersion;
788 use std::cell::RefCell;
789
790 fn meta() -> ObjectMeta {
791 ObjectMeta {
792 draft: DraftVersion::Draft19,
793 stream_kind: DataStreamType::Subgroup,
794 track_alias: Some(7),
795 group_id: 3,
796 subgroup_id: Some(4),
797 object_id: 11,
798 publisher_priority: Some(128),
799 index_in_stream: 0,
800 payload_len: 16,
801 status: None,
802 end_of_range: None,
803 }
804 }
805
806 /// A datagram carrying every key one can carry, so a test can knock a
807 /// single one out and attribute the result.
808 fn datagram_meta() -> AnyDatagramMeta {
809 AnyDatagramMeta {
810 track_alias: 7,
811 group_id: 3,
812 object_id: 11,
813 publisher_priority: Some(128),
814 status: None,
815 }
816 }
817
818 fn class(name: &str, matcher: Matcher) -> ClassRule {
819 ClassRule {
820 name: name.to_string(),
821 bucket: "b".to_string(),
822 matcher,
823 weight: 1,
824 ..ClassRule::default()
825 }
826 }
827
828 fn scheduler(classes: Vec<ClassRule>, queue: QueueConfig) -> Scheduler {
829 let bucket = BucketConfig { name: "b".to_string(), ..BucketConfig::default() };
830 Scheduler::new(
831 ShapeProfile::try_new(vec![bucket], classes, queue, Discipline::Fifo)
832 .expect("the fixture names its own bucket"),
833 )
834 }
835
836 /// Collect every `(class index, field)` a classification reported.
837 fn reports<'a>(
838 s: &'a Scheduler,
839 meta: &'a ObjectMeta,
840 ) -> impl FnMut(u64) -> Vec<(usize, MatcherField)> + 'a {
841 move |unit_index| {
842 let seen = RefCell::new(Vec::new());
843 s.classify(ProxySide::ClientToProxy, meta, unit_index, |i, f| {
844 seen.borrow_mut().push((i, f));
845 });
846 seen.into_inner()
847 }
848 }
849
850 /// The first rule that claims a unit wins, and a unit no rule claims
851 /// falls to the default row rather than to the first rule.
852 #[test]
853 fn the_first_matching_rule_wins_and_the_rest_fall_to_default() {
854 let s = scheduler(
855 vec![
856 class(
857 "high",
858 Matcher { object_id: Some(RangeSet::new([0..=5])), ..Matcher::default() },
859 ),
860 class(
861 "mid",
862 Matcher { object_id: Some(RangeSet::new([0..=20])), ..Matcher::default() },
863 ),
864 ],
865 QueueConfig::default(),
866 );
867
868 let low = ObjectMeta { object_id: 3, ..meta() };
869 assert_eq!(
870 s.classify(ProxySide::ClientToProxy, &low, 0, |_, _| {}),
871 Class::Rule(0),
872 "both rules claim object 3; configured order decides"
873 );
874 assert_eq!(s.classify(ProxySide::ClientToProxy, &meta(), 0, |_, _| {}), Class::Rule(1));
875 let out = ObjectMeta { object_id: 99, ..meta() };
876 assert_eq!(s.classify(ProxySide::ClientToProxy, &out, 0, |_, _| {}), Class::Default);
877 }
878
879 /// A rule keyed on a field the wire did not carry falls to default
880 /// **and** reports, once per session per `(class, field)`.
881 ///
882 /// The three separate assertions are the three cases that must not be
883 /// conflated: absent-and-keyed reports, present-but-wrong does not, and
884 /// a second unit of the same shape does not report again.
885 #[test]
886 fn an_absent_key_reports_once_per_class_and_field() {
887 let s = scheduler(
888 vec![class(
889 "video",
890 Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
891 )],
892 QueueConfig::default(),
893 );
894
895 let absent = ObjectMeta { subgroup_id: None, ..meta() };
896 let mut classify = reports(&s, &absent);
897 assert_eq!(
898 classify(0),
899 vec![(0, MatcherField::SubgroupId)],
900 "the wire carried no subgroup ID, so this rule can never fire: say so"
901 );
902 assert_eq!(classify(1), vec![], "once per session per (class, field), not per unit");
903 }
904
905 /// A key that is *present but out of range* is a rule working, not a
906 /// rule that cannot work — so nothing is reported.
907 ///
908 /// Without this the report would fire on every ordinary non-match and
909 /// mean nothing.
910 #[test]
911 fn a_present_but_unmatched_key_reports_nothing() {
912 let s = scheduler(
913 vec![class(
914 "video",
915 Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
916 )],
917 QueueConfig::default(),
918 );
919 let other = ObjectMeta { subgroup_id: Some(9), ..meta() };
920 assert_eq!(reports(&s, &other)(0), vec![]);
921 }
922
923 /// A rule aimed at datagrams claims a datagram, walks past a framed
924 /// object, and reports nothing about either.
925 ///
926 /// Three assertions because the middle one is what the first would
927 /// otherwise be satisfied by: a `stream_kind` key that had stopped being
928 /// read would claim both, and a rule that claimed neither would fall to
929 /// default on both. The third is what a live `Datagram` rule turns the
930 /// silence into: not a rule that can never fire, but one that is live
931 /// and simply did not claim *this* unit.
932 #[test]
933 fn a_datagram_rule_claims_a_datagram() {
934 let s = scheduler(
935 vec![class(
936 "dgram",
937 Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
938 )],
939 QueueConfig::default(),
940 );
941 let dgram = datagram_meta();
942 assert_eq!(
943 s.classify_datagram(
944 ProxySide::ClientToProxy,
945 DraftVersion::Draft19,
946 &dgram,
947 0,
948 |_, _| {}
949 ),
950 Class::Rule(0),
951 "a class aimed at datagrams must claim one"
952 );
953
954 let m = meta();
955 assert_eq!(
956 s.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
957 Class::Default,
958 "...and must not claim a framed object, or `stream_kind` is not a key"
959 );
960
961 // A **fresh** scheduler for the report, and this is load-bearing:
962 // the report-once mask is set by whichever call reaches it first,
963 // including one whose reporter discards what it is handed. Asking
964 // the scheduler above would read a mask the assertion before it had
965 // already consumed, and the assertion would pass for every possible
966 // implementation.
967 let fresh = scheduler(
968 vec![class(
969 "dgram",
970 Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
971 )],
972 QueueConfig::default(),
973 );
974 assert_eq!(
975 reports(&fresh, &m)(0),
976 vec![],
977 "a Datagram rule is live now, so a framed object walking past it is \
978 an ordinary non-match and not a diagnostic"
979 );
980 }
981
982 /// **A rule behind the winner is still checked**, so a rule that can
983 /// never fire is not silenced by whichever rule happened to win.
984 ///
985 /// `classify` returned at the first match, so every rule after it —
986 /// including its unmatchability check — was skipped. A catch-all placed
987 /// first is the value `ClassRule::default()` hands you, and it silenced
988 /// every diagnostic behind it for the session's whole life.
989 ///
990 /// The two legs are the **same two rules in the two orders**, which is
991 /// what makes this attributable to the short-circuit and to nothing
992 /// else: same profile, same unit, same report-once state, one
993 /// difference. The classification is asserted on both legs too, because
994 /// a "fix" that reported the shadowed rule by also *letting it win*
995 /// would break first-match-wins, and the second assertion is what
996 /// forbids it.
997 #[test]
998 fn a_rule_shadowed_by_a_catch_all_is_still_checked() {
999 let catch_all = || class("everything", Matcher::default());
1000 // A rule keyed on the Track Alias, against a fetch unit, which
1001 // carries a Request ID where a subgroup unit carries an alias — so
1002 // the key is one the unit could not have had rather than one whose
1003 // value was wrong.
1004 let by_alias = || {
1005 class(
1006 "aliased",
1007 Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1008 )
1009 };
1010 let m = ObjectMeta { stream_kind: DataStreamType::Fetch, track_alias: None, ..meta() };
1011
1012 let shadowed = scheduler(vec![catch_all(), by_alias()], QueueConfig::default());
1013 assert_eq!(
1014 reports(&shadowed, &m)(0),
1015 vec![(1, MatcherField::TrackAlias)],
1016 "a rule behind the winner can still be one that can never fire, and \
1017 the winner claiming this unit is not a statement about it"
1018 );
1019 assert_eq!(
1020 shadowed.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
1021 Class::Rule(0),
1022 "...and reporting it must not promote it: the first match still wins"
1023 );
1024
1025 // The control leg: the same two rules in the other order, which
1026 // must also report, and exactly once.
1027 let ordered = scheduler(vec![by_alias(), catch_all()], QueueConfig::default());
1028 assert_eq!(reports(&ordered, &m)(0), vec![(0, MatcherField::TrackAlias)]);
1029 assert_eq!(reports(&ordered, &m)(1), vec![], "still once per session per pair");
1030 }
1031
1032 /// The winner is **not** reported, however unmatchable its keys look.
1033 ///
1034 /// The guard against sweeping every rule unconditionally instead,
1035 /// which would fire `ShapeRuleUnmatchable` on the very class that is
1036 /// doing the work and make the report noise.
1037 ///
1038 /// Nearly every key makes this vacuous — an absent key never matches,
1039 /// so a rule keyed on one cannot be the winner. `stream_kind` is the
1040 /// exception and therefore the fixture: `kind_matches(Fetch, Fetch)` is
1041 /// `true` while `Fetch::is_matchable_on(Draft19)` is `false`, so a
1042 /// draft-19 fetch `ObjectMeta` is a unit that a Fetch rule both claims
1043 /// and is "unmatchable" for. The framer never builds one — drafts 15-19
1044 /// have no fetch object codec, so every fetch stream is bypassed at its
1045 /// header — which is exactly why it has to be built here: it is the only
1046 /// input that can tell the `continue` from a `return`.
1047 #[test]
1048 fn the_winning_rule_is_never_reported_as_unmatchable() {
1049 let s = scheduler(
1050 vec![class(
1051 "fetch",
1052 Matcher { stream_kind: Some(MatchKind::Fetch), ..Matcher::default() },
1053 )],
1054 QueueConfig::default(),
1055 );
1056 let claimed = ObjectMeta {
1057 draft: DraftVersion::Draft19,
1058 stream_kind: DataStreamType::Fetch,
1059 ..meta()
1060 };
1061 // The reporting assertion goes **first**, and deliberately: the mask
1062 // is once-per-session, so a classification run with a no-op reporter
1063 // would spend the budget before the reporting assertion runs.
1064 assert_eq!(
1065 reports(&s, &claimed)(0),
1066 vec![],
1067 "a rule that claimed the unit is a rule that fired; reporting it as \
1068 unable to fire would be a false positive on a working class"
1069 );
1070 assert_eq!(
1071 s.classify(ProxySide::ClientToProxy, &claimed, 0, |_, _| {}),
1072 Class::Rule(0),
1073 "the fixture is only meaningful if the rule really does win"
1074 );
1075
1076 // The contrast in the same body, so the assertion above is not
1077 // passing because this scheduler never reports: a rule against a
1078 // unit it does *not* claim, keyed on something that unit could not
1079 // have carried, reports at once. The key rather than the kind,
1080 // because naming a kind is not something a rule can be dead for —
1081 // see `Matcher::unmatchable_fields`.
1082 let s = scheduler(
1083 vec![class(
1084 "aliased",
1085 Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1086 )],
1087 QueueConfig::default(),
1088 );
1089 let unaliased = ObjectMeta { track_alias: None, ..claimed };
1090 assert_eq!(reports(&s, &unaliased)(0), vec![(0, MatcherField::TrackAlias)]);
1091 }
1092
1093 /// The depth reaches `PendingQueue` under `Block` and nowhere else.
1094 #[test]
1095 fn only_block_installs_a_blocking_depth() {
1096 let depths = QueueConfig { depth_objects: 4, depth_bytes: 99, ..QueueConfig::default() };
1097 let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1098
1099 let blocking = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1100 assert_eq!(blocking.blocking_depth(), Some(QueueDepth { bytes: 99, objects: 4 }));
1101
1102 for overflow in [Overflow::DropTail, Overflow::ResetStream { code: 7 }] {
1103 assert_eq!(
1104 scheduler(vec![class("c", Matcher::default())], with(overflow)).blocking_depth(),
1105 None,
1106 "{overflow:?} must leave the read branch enabled"
1107 );
1108 }
1109 }
1110
1111 /// Both depth limits, and the policy each one selects.
1112 #[test]
1113 fn admission_applies_both_depths_and_then_the_policy() {
1114 let depths = QueueConfig { depth_objects: 4, depth_bytes: 100, ..QueueConfig::default() };
1115 let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1116 let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::DropTail));
1117
1118 assert_eq!(s.admit(10, 0, 0), Admission::Admit, "an empty queue takes anything that fits");
1119 assert_eq!(s.admit(10, 90, 3), Admission::Admit, "exactly at the byte depth still fits");
1120 assert_eq!(s.admit(11, 90, 3), Admission::DropTail, "one byte over the byte depth");
1121 assert_eq!(s.admit(1, 0, 4), Admission::DropTail, "all four object slots are taken");
1122 assert_eq!(s.admit(1, 0, 3), Admission::Admit, "the fourth slot is free");
1123
1124 let s = scheduler(
1125 vec![class("c", Matcher::default())],
1126 with(Overflow::ResetStream { code: 0x2A }),
1127 );
1128 assert_eq!(s.admit(1, 0, 4), Admission::ResetStream { code: 0x2A });
1129
1130 // `Block` never decides here: a unit that reached admission under
1131 // `Block` proves the queue had room, because the read branch is
1132 // what enforces the limit.
1133 let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1134 assert_eq!(s.admit(1, 0, 4), Admission::Admit);
1135 }
1136
1137 // ── release ─────────────────────────────────────────────────────
1138
1139 /// A shaper over one bucket shared by every class, with the discipline
1140 /// and the per-class `(priority, weight)` given.
1141 fn shared_bucket(
1142 rate: Option<u64>,
1143 burst: u64,
1144 discipline: Discipline,
1145 classes: &[(&str, u8, u16)],
1146 now: Instant,
1147 ) -> Scheduler {
1148 let bucket = BucketConfig {
1149 name: "b".to_string(),
1150 rate_bps: rate,
1151 burst_bytes: burst,
1152 ..BucketConfig::default()
1153 };
1154 let rules = classes
1155 .iter()
1156 .map(|(name, priority, weight)| ClassRule {
1157 name: (*name).to_string(),
1158 bucket: "b".to_string(),
1159 matcher: Matcher::default(),
1160 priority: *priority,
1161 weight: *weight,
1162 })
1163 .collect();
1164 Scheduler::at(
1165 ShapeProfile::try_new(vec![bucket], rules, QueueConfig::default(), discipline)
1166 .expect("the fixture names its own bucket"),
1167 now,
1168 )
1169 }
1170
1171 fn granted(a: &Acquire) -> bool {
1172 matches!(a, Acquire::Now)
1173 }
1174
1175 /// Neither of the two rows that name no bucket can be charged one, and
1176 /// neither is arbitrated: an unclassified unit is not a class competing
1177 /// for anything.
1178 #[test]
1179 fn the_unclassified_rows_are_never_charged_a_bucket() {
1180 let base = Instant::now();
1181 // A bucket that grants nothing at all, so a charge is unmissable.
1182 let s = shared_bucket(Some(0), 0, Discipline::Fifo, &[("only", 0, 1)], base);
1183 for class in [Class::Default, Class::Unshapeable] {
1184 assert!(
1185 granted(&s.acquire(class, 4096, base)),
1186 "{class:?} names no bucket, so there is nothing to charge it against"
1187 );
1188 }
1189 // ...and the class that *does* name the bucket is refused, so the
1190 // assertion above is not passing because the bucket grants freely.
1191 assert!(matches!(s.acquire(Class::Rule(0), 1, base), Acquire::Never));
1192 }
1193
1194 /// The bound `charge` proves, reached through `acquire`: a rate-limited
1195 /// class is granted, then refused with the instant its own refill lands.
1196 #[test]
1197 fn a_rate_limited_class_is_refused_with_its_refill_instant() {
1198 let base = Instant::now();
1199 // 1000 bytes/s, 1000-byte burst: the first 1000-byte unit fits, the
1200 // second needs a full second.
1201 let s = shared_bucket(Some(1_000), 1_000, Discipline::Fifo, &[("v", 0, 1)], base);
1202 assert!(granted(&s.acquire(Class::Rule(0), 1_000, base)));
1203 match s.acquire(Class::Rule(0), 1_000, base) {
1204 Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1205 other => panic!("expected a refill instant, got {other:?}"),
1206 }
1207 assert!(granted(&s.acquire(Class::Rule(0), 1_000, base + Duration::from_secs(1))));
1208 }
1209
1210 /// **A burst too small for one unit is its own answer, claimable once**
1211 /// — not the answer a stopped class gives and not the answer a merely
1212 /// slow class gives.
1213 /// All three postures are driven in one body against the same unit size,
1214 /// because the whole claim is that they are distinguishable and a single
1215 /// posture cannot show that. Before the split, the first and the second
1216 /// were the same value and the run reported the same clamp for both, so *1
1217 /// MB/s delivering at the hold clamp* looked exactly like *this class was
1218 /// configured to stop*.
1219 #[test]
1220 fn a_burst_below_one_unit_is_separable_from_a_stopped_or_a_slow_class() {
1221 let base = Instant::now();
1222 const UNIT: u64 = 1_000;
1223
1224 // The defect: a real rate, a burst that cannot hold one object.
1225 let mis_sized = shared_bucket(Some(1_000_000), 100, Discipline::Fifo, &[("v", 0, 1)], base);
1226 match mis_sized.acquire(Class::Rule(0), UNIT, base) {
1227 Acquire::LargerThanBurst { burst_bytes, unit_bytes } => {
1228 assert_eq!((burst_bytes, unit_bytes), (100, UNIT), "both figures, as configured");
1229 }
1230 other => panic!(
1231 "a rate that was asked for and a burst that cannot cover one object \
1232 is a misconfiguration, not a rate limit: {other:?}"
1233 ),
1234 }
1235 assert_eq!(
1236 mis_sized.claim_burst_report(Class::Rule(0)).as_deref(),
1237 Some("v"),
1238 "the report names the class whose rate is not being applied"
1239 );
1240 assert_eq!(
1241 mis_sized.claim_burst_report(Class::Rule(0)),
1242 None,
1243 "once per session per class: the refusal repeats on every unit and the \
1244 report must not"
1245 );
1246 assert_eq!(
1247 mis_sized.claim_burst_report(Class::Default),
1248 None,
1249 "the rows that name no bucket never reached a bucket to be refused by"
1250 );
1251
1252 // A class configured to stop. Same unit, same clamp downstream, and
1253 // deliberately a different answer: nothing is wrong with it.
1254 let stopped = shared_bucket(Some(0), 0, Discipline::Fifo, &[("v", 0, 1)], base);
1255 assert!(
1256 matches!(stopped.acquire(Class::Rule(0), UNIT, base), Acquire::Never),
1257 "a zero rate is a class doing what it was asked, whatever the burst is"
1258 );
1259
1260 // A class that is merely slow: the burst covers a unit, so the
1261 // bucket refuses with the instant it will hold one again.
1262 let slow = shared_bucket(Some(1_000), UNIT, Discipline::Fifo, &[("v", 0, 1)], base);
1263 assert!(granted(&slow.acquire(Class::Rule(0), UNIT, base)));
1264 match slow.acquire(Class::Rule(0), UNIT, base) {
1265 Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1266 other => panic!("a burst that holds a unit names a refill instant: {other:?}"),
1267 }
1268 }
1269
1270 /// Strict priority's mechanism, without QUIC: on one shared non-zero
1271 /// bucket, the
1272 /// lower-priority class is refused **while the higher one has demand**
1273 /// and released the moment that demand withdraws.
1274 ///
1275 /// The two halves are one test because neither means anything alone: a
1276 /// park that is never released is a stall, and a release that never
1277 /// parked is `Fifo` wearing a different name.
1278 #[test]
1279 fn strict_priority_parks_the_low_class_until_the_high_one_withdraws() {
1280 let base = Instant::now();
1281 let s = shared_bucket(
1282 Some(1_000_000),
1283 1_000_000,
1284 Discipline::StrictPriority,
1285 &[("audio", 9, 1), ("video", 1, 1)],
1286 base,
1287 );
1288
1289 // Nobody is holding anything: the bucket alone decides, and it grants.
1290 assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1291
1292 s.declare_demand(Class::Rule(0));
1293 let gate = match s.acquire(Class::Rule(1), 10, base) {
1294 Acquire::Starved(gate) => gate,
1295 other => panic!("audio has demand and outranks video; got {other:?}"),
1296 };
1297 assert!(!gate.is_released(), "the blocking class is still holding it");
1298 // The high class is not held back by the low one's demand.
1299 s.declare_demand(Class::Rule(1));
1300 assert!(granted(&s.acquire(Class::Rule(0), 10, base)));
1301
1302 s.withdraw_demand(Class::Rule(0));
1303 assert!(
1304 gate.is_released(),
1305 "the gate a starved class parked on opens when its blocker drains"
1306 );
1307 assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1308 }
1309
1310 /// Weighted round robin's mechanism, without QUIC: two classes with
1311 /// demand on one bucket
1312 /// under `WeightedRoundRobin` are granted in their **weight ratio**,
1313 /// because the ratio is a count and not a rate.
1314 ///
1315 /// The bucket is deliberately unlimited, so nothing but the discipline
1316 /// can shape the counts, and each class asks the way a stream's release
1317 /// branch does — `while let Some(unit) = pop_next_due(..)`, i.e. until it
1318 /// is refused. Asking exactly once per turn instead would cap a class at
1319 /// one grant per turn whatever its weight, and the harness rather than
1320 /// the discipline would be setting the ratio.
1321 ///
1322 /// The ratio is asserted as a **band**, not an equality, and the reason
1323 /// is arithmetic rather than caution: a round hands out `weight` grants
1324 /// per class, so the totals are exact only when sampled on a round
1325 /// boundary and the last partial round moves them by at most `weight`.
1326 /// The band still separates 3:1 from 1:1 by a factor of three.
1327 #[test]
1328 fn weighted_round_robin_grants_in_the_weight_ratio() {
1329 let base = Instant::now();
1330 let s = shared_bucket(
1331 None,
1332 0,
1333 Discipline::WeightedRoundRobin,
1334 &[("a", 0, 3), ("b", 0, 1)],
1335 base,
1336 );
1337 s.declare_demand(Class::Rule(0));
1338 s.declare_demand(Class::Rule(1));
1339
1340 let mut counts = [0u32; 2];
1341 for _ in 0..20 {
1342 for (index, count) in counts.iter_mut().enumerate() {
1343 while granted(&s.acquire(Class::Rule(index), 1, base)) {
1344 *count += 1;
1345 }
1346 }
1347 }
1348 assert!(counts[1] > 0, "the low-weight class must still be scheduled, not starved");
1349 assert!(
1350 counts[0] >= 2 * counts[1],
1351 "weights 3:1 must give the heavy class at least twice the count: {counts:?}"
1352 );
1353 assert!(counts[0] <= 4 * counts[1], "weights 3:1 are a share, not a monopoly: {counts:?}");
1354 }
1355
1356 /// A class with no rival holding demand takes the whole bucket under
1357 /// `WeightedRoundRobin`, rather than stalling once its own credits run
1358 /// out. A round is over when everyone *who wants it* has had their share.
1359 #[test]
1360 fn weighted_round_robin_does_not_wait_for_a_class_with_nothing_to_send() {
1361 let base = Instant::now();
1362 let s = shared_bucket(
1363 None,
1364 0,
1365 Discipline::WeightedRoundRobin,
1366 &[("a", 0, 3), ("b", 0, 1)],
1367 base,
1368 );
1369 s.declare_demand(Class::Rule(0));
1370 for i in 0..20 {
1371 assert!(granted(&s.acquire(Class::Rule(0), 1, base)), "grant {i} was refused");
1372 }
1373 }
1374
1375 /// `Fifo` arbitrates nothing: demand from another class changes no
1376 /// answer, and the bucket is the only thing that can refuse.
1377 ///
1378 /// This is the control for the two discipline tests above — without it
1379 /// they could both be passing because `acquire` refuses whenever *any*
1380 /// other class has demand.
1381 #[test]
1382 fn fifo_never_parks_a_class() {
1383 let base = Instant::now();
1384 let s = shared_bucket(None, 0, Discipline::Fifo, &[("a", 9, 1), ("b", 0, 1)], base);
1385 s.declare_demand(Class::Rule(0));
1386 for _ in 0..10 {
1387 assert!(granted(&s.acquire(Class::Rule(1), 1_000, base)));
1388 }
1389 }
1390
1391 /// A withdrawal that outnumbers its declarations does not wrap the count
1392 /// into a permanent block.
1393 ///
1394 /// The queue withdraws from `clear`, from the teardown drain and from
1395 /// `Drop`, and those can overlap; a `u64` going through zero would make
1396 /// a class look like it had four billion streams waiting and starve
1397 /// everything under it for the rest of the session.
1398 #[test]
1399 fn withdrawing_more_than_was_declared_saturates_at_zero() {
1400 let base = Instant::now();
1401 let s =
1402 shared_bucket(None, 0, Discipline::StrictPriority, &[("hi", 9, 1), ("lo", 0, 1)], base);
1403 s.declare_demand(Class::Rule(0));
1404 s.withdraw_demand(Class::Rule(0));
1405 s.withdraw_demand(Class::Rule(0));
1406 s.withdraw_demand(Class::Rule(0));
1407 assert!(granted(&s.acquire(Class::Rule(1), 1, base)));
1408 }
1409}