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-19 — 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`** — the counter this
95 /// doc used to name has a producer only under [`Overflow::DropTail`],
96 /// so under the default [`Overflow::Block`] it is permanently zero and
97 /// an author following the instruction would read a zero and conclude
98 /// their rule had fired. `objects_delivered` has a producer under every
99 /// overflow policy. (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-19 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 a session driven without a proxy
255 /// behaves exactly as it did before this field existed.
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 made it depend on exactly that. A catch-all
429 /// placed first — which is the value `ClassRule::default()` hands you,
430 /// since `Matcher::default()` claims everything — matched every unit and
431 /// so no later rule was ever examined, silencing every diagnostic behind
432 /// it for the session's whole life. Measured: same profile, same
433 /// traffic, class order reversed, 1 report against 0.
434 ///
435 /// The winner itself is skipped, and that is not the same thing: a rule
436 /// that *matched* cannot have been defeated by an absent key, so
437 /// reporting it would be a false positive on a working rule.
438 ///
439 /// The cost is one `Matcher::unmatchable_fields` per rule per unit —
440 /// three `is_some` tests over a fixed-size array, no allocation, and the
441 /// relaxed `fetch_or` is reached only when a field is genuinely absent.
442 /// A profile whose keys are all carried never touches an atomic.
443 pub(crate) fn classify(
444 &self,
445 side: ProxySide,
446 meta: &ObjectMeta,
447 unit_index: u64,
448 mut report: impl FnMut(usize, MatcherField),
449 ) -> Class {
450 let mut claimed: Option<Class> = None;
451 for (index, rule) in self.profile.classes().iter().enumerate() {
452 if claimed.is_none() && rule.matcher.matches(side, meta, unit_index) {
453 claimed = Some(Class::Rule(index));
454 continue;
455 }
456 for field in rule.matcher.unmatchable_fields(meta).into_iter().flatten() {
457 self.report_once(index, field, &mut report);
458 }
459 }
460 claimed.unwrap_or(Class::Default)
461 }
462
463 /// [`Self::classify`] for a datagram.
464 ///
465 /// The same two-part sweep — first match wins, every rule that did not
466 /// match is asked whether it *could* have — over
467 /// [`Matcher::matches_datagram`] and
468 /// [`Matcher::unmatchable_fields_datagram`] instead of their framed
469 /// siblings. The report-once mask is the one `classify` uses, so a
470 /// session carrying both never reports a `(class, field)` pair twice and
471 /// whichever carrier arrives first is the one that says it.
472 ///
473 /// `unit_index` counts hook-visible datagrams per forwarding direction;
474 /// see [`Matcher::matches_datagram`] for why that is the only scope a
475 /// datagram has.
476 ///
477 /// The draft is taken as an argument rather than read off the unit
478 /// because an [`AnyDatagramMeta`] does not carry one — it is the resolved
479 /// identity, not the header — where an [`ObjectMeta`] does.
480 pub(crate) fn classify_datagram(
481 &self,
482 side: ProxySide,
483 draft: DraftVersion,
484 meta: &AnyDatagramMeta,
485 unit_index: u64,
486 mut report: impl FnMut(usize, MatcherField),
487 ) -> Class {
488 let mut claimed: Option<Class> = None;
489 for (index, rule) in self.profile.classes().iter().enumerate() {
490 if claimed.is_none() && rule.matcher.matches_datagram(side, meta, unit_index) {
491 claimed = Some(Class::Rule(index));
492 continue;
493 }
494 for field in rule.matcher.unmatchable_fields_datagram(draft, meta).into_iter().flatten()
495 {
496 self.report_once(index, field, &mut report);
497 }
498 }
499 claimed.unwrap_or(Class::Default)
500 }
501
502 /// Set `(index, field)`'s bit and call `report` only if this is the
503 /// first time anyone has.
504 ///
505 /// One relaxed `fetch_or`; see [`Self::unmatchable`] for why the losing
506 /// side of a race costs one duplicate event rather than a lock.
507 fn report_once(
508 &self,
509 index: usize,
510 field: MatcherField,
511 report: &mut impl FnMut(usize, MatcherField),
512 ) {
513 let Some(mask) = self.unmatchable.get(index) else { return };
514 if mask.fetch_or(field.bit(), Ordering::Relaxed) & field.bit() == 0 {
515 report(index, field);
516 }
517 }
518
519 /// Whether a unit of `bytes` fits behind `queued_objects` units holding
520 /// `queued_bytes`, and what to do if it does not.
521 ///
522 /// The byte test counts the arriving unit (`queued_bytes + bytes >
523 /// depth_bytes`) and the object test does not (`queued_objects >=
524 /// depth_objects`), because they answer different questions: the byte
525 /// depth is a budget the queue may not exceed, and the object depth is
526 /// a count of slots, all of which are taken.
527 ///
528 /// [`Overflow::Block`] answers [`Admission::Admit`] here and always
529 /// will: under `Block` a full queue is one the read branch is not
530 /// polling, so a unit reaching this function proves there was room for
531 /// it. Deciding to drop it here as well would double-count the same
532 /// limit and silently make `Block` destructive.
533 pub(crate) fn admit(
534 &self,
535 bytes: usize,
536 queued_bytes: usize,
537 queued_objects: usize,
538 ) -> Admission {
539 let queue = self.profile.queue();
540 let fits = queued_objects < queue.depth_objects
541 && queued_bytes.saturating_add(bytes) <= queue.depth_bytes;
542 if fits {
543 return Admission::Admit;
544 }
545 match queue.overflow {
546 Overflow::Block => Admission::Admit,
547 Overflow::DropTail => Admission::DropTail,
548 Overflow::ResetStream { code } => Admission::ResetStream { code },
549 }
550 }
551
552 // ── release ─────────────────────────────────────────────────────
553
554 /// Ask whether a queued unit of `class` holding `bytes` may go at `now`,
555 /// debiting its bucket when the answer is yes.
556 ///
557 /// **The release seam.** Called from `PendingQueue::pop_next_due` and
558 /// from nowhere else, exactly once per released unit.
559 /// [`Class::Default`] and [`Class::Unshapeable`] answer [`Acquire::Now`]
560 /// unconditionally and touch no lock: neither names a bucket, so there is
561 /// nothing to charge and nothing to arbitrate. That is not a shortcut — it
562 /// is what *no rule claimed this* means. A profile that wants a catch-all
563 /// charges one by writing a class with an all-`None`
564 /// [`Matcher`](super::Matcher).
565 ///
566 /// The discipline is consulted **before** the bucket, and the order is
567 /// load-bearing: charging first would let a class the discipline is
568 /// holding back spend tokens the class ahead of it is entitled to, and
569 /// the debit is not refundable.
570 ///
571 /// # While pacing is switched off
572 ///
573 /// Every unit is granted here, before the bucket is read and before the
574 /// discipline is consulted, so nothing is debited and no class is parked
575 /// while the switch is clear.
576 ///
577 /// **It is read when a queue asks, and a queue asks when its head's park
578 /// expires.** This function is the whole of the release decision, and a
579 /// queue that has been refused here parks its head on the deadline the
580 /// refusal named. Switching pacing off does not reach into that park; it
581 /// changes the answer the *next* call gives. So the delay between the
582 /// switch and a stream resuming is the park that was already running,
583 /// and which park that is depends on why the head was refused:
584 ///
585 /// * refused by a bucket that will refill — the refill instant, which is
586 /// one unit's worth of the configured rate;
587 /// * refused by a bucket that never refills, or one whose burst cannot
588 /// cover a unit — no instant exists, so the park is the queue's
589 /// `max_hold` clamp, which on a stopped class is the whole of it;
590 /// * held back by the discipline — the class ahead withdrawing its
591 /// demand, which is now immediate, because that class is granted here
592 /// too.
593 ///
594 /// The middle case is the one to know: switching pacing off does **not**
595 /// promptly release a class configured at zero. Nothing here can, and
596 /// nothing else in this crate can either — the park is a timer a queue
597 /// armed, the queues are per stream, and no session-wide wake reaches
598 /// them.
599 ///
600 /// Nothing leaves a bucket half-charged whichever way the switch moves:
601 /// the debit happens on the same call as the grant or not at all.
602 pub(crate) fn acquire(&self, class: Class, bytes: u64, now: Instant) -> Acquire {
603 if !self.enabled.load(Ordering::Relaxed) {
604 return Acquire::Now;
605 }
606 let Class::Rule(index) = class else {
607 return Acquire::Now;
608 };
609 let Some(&bucket) = self.class_bucket.get(index) else {
610 return Acquire::Now;
611 };
612 let mut state = self.state.lock().expect("shape scheduler");
613
614 if let Some(gate) = self.discipline_holds(&mut state, index, bucket) {
615 return Acquire::Starved(gate);
616 }
617
618 let config = &self.profile.buckets()[bucket];
619 match charge(&mut state.buckets[bucket], config.rate_bps, config.burst_bytes, bytes, now) {
620 Grant::Now => {
621 if self.profile.discipline() == Discipline::WeightedRoundRobin {
622 let credit = &mut state.credits[index];
623 *credit = credit.saturating_sub(1);
624 }
625 Acquire::Now
626 }
627 Grant::Later(at) => Acquire::Later(at),
628 Grant::Never => Acquire::Never,
629 Grant::LargerThanBurst { burst_bytes, unit_bytes } => {
630 Acquire::LargerThanBurst { burst_bytes, unit_bytes }
631 }
632 }
633 }
634
635 /// Claim the once-per-session right to report that `class`'s burst is
636 /// smaller than one of its own units, and say what to call the class.
637 ///
638 /// `Some(name)` for the first caller and `None` for every one after, so
639 /// a class whose every unit is refused for the whole session reports
640 /// once rather than once per release attempt. The name comes back with
641 /// the claim because the caller needs both and asking twice would let a
642 /// caller claim one class and label another.
643 ///
644 /// **Once per session per class, not once per stream.** A burst that
645 /// cannot cover an object is a property of the profile, so every stream
646 /// carrying that class reproduces it and a per-stream report would say
647 /// the same thing as many times as the session has streams. The rows
648 /// that keep counting are the class's own `tokens_exhausted_episodes`
649 /// and the `HoldClamped` report on each clamped unit.
650 ///
651 /// Answers `None` for the two rows that name no bucket: neither
652 /// [`Class::Default`] nor [`Class::Unshapeable`] reaches [`charge`] at
653 /// all, so neither can have produced the refusal this reports.
654 pub(crate) fn claim_burst_report(&self, class: Class) -> Option<String> {
655 let Class::Rule(index) = class else { return None };
656 let claimed = self.burst_reported.get(index)?.swap(true, Ordering::Relaxed);
657 if claimed {
658 return None;
659 }
660 Some(self.class_name(index))
661 }
662
663 /// Whether the discipline is holding `index` back, and the gate to wait
664 /// on if it is.
665 ///
666 /// Scoped to the classes that **share `bucket`**: a discipline arbitrates
667 /// between classes competing for one bucket, and two classes with their
668 /// own buckets are not competing for anything.
669 fn discipline_holds(
670 &self,
671 state: &mut SchedState,
672 index: usize,
673 bucket: usize,
674 ) -> Option<Gate> {
675 let discipline = self.profile.discipline();
676 // Whoever asked first: the bucket alone decides, and it decides by
677 // arriving at `charge` first. Answered before anything is collected,
678 // so the default discipline allocates nothing per unit.
679 if discipline == Discipline::Fifo {
680 return None;
681 }
682
683 let classes = self.profile.classes();
684 // The classes this one is actually competing with: same bucket, not
685 // itself, and holding something to send.
686 let rivals: Vec<usize> = (0..classes.len())
687 .filter(|&j| j != index && self.class_bucket[j] == bucket && state.demand[j] > 0)
688 .collect();
689
690 match discipline {
691 Discipline::Fifo => return None,
692 Discipline::StrictPriority => {
693 let mine = classes[index].priority;
694 if !rivals.iter().any(|&j| classes[j].priority > mine) {
695 return None;
696 }
697 }
698 Discipline::WeightedRoundRobin => {
699 if state.credits[index] > 0 {
700 return None;
701 }
702 if rivals.iter().any(|&j| state.credits[j] > 0) {
703 // Somebody else still owes this round: wait for them.
704 // Deliberately *not* a refill — refilling here would let
705 // a class that spent its share take a second one while a
706 // rival still had credit, and the ratio would collapse to
707 // whoever polls most often.
708 } else {
709 // Every class with demand has spent its share, so the
710 // round is over. Refill and wake the ones parked on it.
711 for (j, rule) in classes.iter().enumerate() {
712 if self.class_bucket[j] == bucket {
713 state.credits[j] = u32::from(rule.weight);
714 }
715 }
716 self.wake_bucket(state, bucket);
717 return None;
718 }
719 }
720 }
721
722 // Parked. One gate per park, created under the lock that just read
723 // the demand it waits on, so a withdrawal cannot slip between the
724 // decision and the wait.
725 Some(state.parked[index].get_or_insert_with(Gate::new).clone())
726 }
727
728 /// Release every class parked on `bucket`.
729 ///
730 /// Over-waking is safe and deliberate: a woken class re-asks and parks
731 /// again on a *fresh* gate if it is still held back, which costs one
732 /// loop iteration. Under-waking is a stall until `max_hold`, so the
733 /// asymmetry decides the direction to err in.
734 fn wake_bucket(&self, state: &mut SchedState, bucket: usize) {
735 for j in 0..state.parked.len() {
736 if self.class_bucket[j] == bucket {
737 if let Some(gate) = state.parked[j].take() {
738 gate.release();
739 }
740 }
741 }
742 }
743
744 /// Record that one stream queue now holds an unreleased head of `class`.
745 ///
746 /// Demand, not depth: one per *queue*, whatever it holds behind that
747 /// head. A discipline arbitrates between classes that have something to
748 /// send, and a class with ten streams waiting is not ten times more
749 /// entitled than a class with one.
750 pub(crate) fn declare_demand(&self, class: Class) {
751 let Class::Rule(index) = class else { return };
752 let mut state = self.state.lock().expect("shape scheduler");
753 if let Some(slot) = state.demand.get_mut(index) {
754 *slot += 1;
755 }
756 }
757
758 /// Undo one [`Self::declare_demand`], waking whatever it was holding back.
759 /// Called from every path that can retire a head — a grant, a `clear`, the
760 /// teardown drain, and `PendingQueue`'s `Drop`. The wake is unconditional
761 /// rather than *only when the count reached zero*, because the classes it
762 /// wakes re-ask and park again for free, while a missed wake is a
763 /// `max_hold` stall on a stream that has nothing wrong with it.
764 pub(crate) fn withdraw_demand(&self, class: Class) {
765 let Class::Rule(index) = class else { return };
766 let Some(&bucket) = self.class_bucket.get(index) else { return };
767 let mut state = self.state.lock().expect("shape scheduler");
768 if let Some(slot) = state.demand.get_mut(index) {
769 *slot = slot.saturating_sub(1);
770 }
771 self.wake_bucket(&mut state, bucket);
772 }
773}
774
775impl std::fmt::Debug for Scheduler {
776 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
777 f.debug_struct("Scheduler").field("profile", &self.profile).finish_non_exhaustive()
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use crate::shape::{
785 BucketConfig, ClassRule, Discipline, MatchKind, Matcher, QueueConfig, RangeSet,
786 };
787 use crate::types::DataStreamType;
788 use moqtap_codec::version::DraftVersion;
789 use std::cell::RefCell;
790
791 fn meta() -> ObjectMeta {
792 ObjectMeta {
793 draft: DraftVersion::Draft19,
794 stream_kind: DataStreamType::Subgroup,
795 track_alias: Some(7),
796 group_id: 3,
797 subgroup_id: Some(4),
798 object_id: 11,
799 publisher_priority: Some(128),
800 index_in_stream: 0,
801 payload_len: 16,
802 status: None,
803 end_of_range: None,
804 }
805 }
806
807 /// A datagram carrying every key one can carry, so a test can knock a
808 /// single one out and attribute the result.
809 fn datagram_meta() -> AnyDatagramMeta {
810 AnyDatagramMeta {
811 track_alias: 7,
812 group_id: 3,
813 object_id: 11,
814 publisher_priority: Some(128),
815 status: None,
816 }
817 }
818
819 fn class(name: &str, matcher: Matcher) -> ClassRule {
820 ClassRule {
821 name: name.to_string(),
822 bucket: "b".to_string(),
823 matcher,
824 weight: 1,
825 ..ClassRule::default()
826 }
827 }
828
829 fn scheduler(classes: Vec<ClassRule>, queue: QueueConfig) -> Scheduler {
830 let bucket = BucketConfig { name: "b".to_string(), ..BucketConfig::default() };
831 Scheduler::new(
832 ShapeProfile::try_new(vec![bucket], classes, queue, Discipline::Fifo)
833 .expect("the fixture names its own bucket"),
834 )
835 }
836
837 /// Collect every `(class index, field)` a classification reported.
838 fn reports<'a>(
839 s: &'a Scheduler,
840 meta: &'a ObjectMeta,
841 ) -> impl FnMut(u64) -> Vec<(usize, MatcherField)> + 'a {
842 move |unit_index| {
843 let seen = RefCell::new(Vec::new());
844 s.classify(ProxySide::ClientToProxy, meta, unit_index, |i, f| {
845 seen.borrow_mut().push((i, f));
846 });
847 seen.into_inner()
848 }
849 }
850
851 /// The first rule that claims a unit wins, and a unit no rule claims
852 /// falls to the default row rather than to the first rule.
853 ///
854 /// *Ablation:* return `Class::Rule(0)` instead of `Class::Default` —
855 /// the `Default` assertion reddens with `left: Rule(0)`.
856 #[test]
857 fn the_first_matching_rule_wins_and_the_rest_fall_to_default() {
858 let s = scheduler(
859 vec![
860 class(
861 "high",
862 Matcher { object_id: Some(RangeSet::new([0..=5])), ..Matcher::default() },
863 ),
864 class(
865 "mid",
866 Matcher { object_id: Some(RangeSet::new([0..=20])), ..Matcher::default() },
867 ),
868 ],
869 QueueConfig::default(),
870 );
871
872 let low = ObjectMeta { object_id: 3, ..meta() };
873 assert_eq!(
874 s.classify(ProxySide::ClientToProxy, &low, 0, |_, _| {}),
875 Class::Rule(0),
876 "both rules claim object 3; configured order decides"
877 );
878 assert_eq!(s.classify(ProxySide::ClientToProxy, &meta(), 0, |_, _| {}), Class::Rule(1));
879 let out = ObjectMeta { object_id: 99, ..meta() };
880 assert_eq!(s.classify(ProxySide::ClientToProxy, &out, 0, |_, _| {}), Class::Default);
881 }
882
883 /// A rule keyed on a field the wire did not carry falls to default
884 /// **and** reports, once per session per `(class, field)`.
885 ///
886 /// The three separate assertions are the three cases that must not be
887 /// conflated: absent-and-keyed reports, present-but-wrong does not, and
888 /// a second unit of the same shape does not report again.
889 ///
890 /// *Ablation:* drop the `fetch_or` guard and report unconditionally —
891 /// the "once" assertion reddens with two entries for the same pair.
892 #[test]
893 fn an_absent_key_reports_once_per_class_and_field() {
894 let s = scheduler(
895 vec![class(
896 "video",
897 Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
898 )],
899 QueueConfig::default(),
900 );
901
902 let absent = ObjectMeta { subgroup_id: None, ..meta() };
903 let mut classify = reports(&s, &absent);
904 assert_eq!(
905 classify(0),
906 vec![(0, MatcherField::SubgroupId)],
907 "the wire carried no subgroup ID, so this rule can never fire: say so"
908 );
909 assert_eq!(classify(1), vec![], "once per session per (class, field), not per unit");
910 }
911
912 /// A key that is *present but out of range* is a rule working, not a
913 /// rule that cannot work — so nothing is reported.
914 ///
915 /// Without this the report would fire on every ordinary non-match and
916 /// mean nothing.
917 #[test]
918 fn a_present_but_unmatched_key_reports_nothing() {
919 let s = scheduler(
920 vec![class(
921 "video",
922 Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
923 )],
924 QueueConfig::default(),
925 );
926 let other = ObjectMeta { subgroup_id: Some(9), ..meta() };
927 assert_eq!(reports(&s, &other)(0), vec![]);
928 }
929
930 /// A rule aimed at datagrams claims a datagram, walks past a framed
931 /// object, and reports nothing about either.
932 ///
933 /// Three assertions because the middle one is what the first would
934 /// otherwise be satisfied by: a `stream_kind` key that had stopped being
935 /// read would claim both, and a rule that claimed neither would fall to
936 /// default on both. The third is the inversion a live `Datagram` rule
937 /// brings — the silence such a rule used to report is not silence any
938 /// more, because it is live and simply did not claim *this* unit.
939 ///
940 /// *Ablation (measured):* have `MatchKind::is_matchable_on` answer
941 /// `false` for `Datagram` again, which is where this started. The first
942 /// run of it left this test **green** and reddened two others, which is
943 /// what put the fresh scheduler below in: an assertion above it had
944 /// already consumed the report.
945 ///
946 /// ```text
947 /// ---- shape::scheduler::tests::a_datagram_rule_claims_a_datagram stdout ----
948 /// assertion `left == right` failed: a Datagram rule is live now, so a
949 /// framed object walking past it is an ordinary non-match and not a
950 /// diagnostic
951 /// left: [(0, StreamKind)]
952 /// right: []
953 /// ```
954 #[test]
955 fn a_datagram_rule_claims_a_datagram() {
956 let s = scheduler(
957 vec![class(
958 "dgram",
959 Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
960 )],
961 QueueConfig::default(),
962 );
963 let dgram = datagram_meta();
964 assert_eq!(
965 s.classify_datagram(
966 ProxySide::ClientToProxy,
967 DraftVersion::Draft19,
968 &dgram,
969 0,
970 |_, _| {}
971 ),
972 Class::Rule(0),
973 "a class aimed at datagrams must claim one"
974 );
975
976 let m = meta();
977 assert_eq!(
978 s.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
979 Class::Default,
980 "...and must not claim a framed object, or `stream_kind` is not a key"
981 );
982
983 // A **fresh** scheduler for the report, and this is load-bearing:
984 // the report-once mask is set by whichever call reaches it first,
985 // including one whose reporter discards what it is handed. Asking
986 // the scheduler above would read a mask the assertion before it had
987 // already consumed, and the assertion would pass for every possible
988 // implementation. Measured — the ablation below left it green.
989 let fresh = scheduler(
990 vec![class(
991 "dgram",
992 Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
993 )],
994 QueueConfig::default(),
995 );
996 assert_eq!(
997 reports(&fresh, &m)(0),
998 vec![],
999 "a Datagram rule is live now, so a framed object walking past it is \
1000 an ordinary non-match and not a diagnostic"
1001 );
1002 }
1003
1004 /// **A rule behind the winner is still checked**, so a rule that can
1005 /// never fire is not silenced by whichever rule happened to win.
1006 ///
1007 /// `classify` returned at the first match, so every rule after it —
1008 /// including its unmatchability check — was skipped. A catch-all placed
1009 /// first is the value `ClassRule::default()` hands you, and it silenced
1010 /// every diagnostic behind it for the session's whole life.
1011 ///
1012 /// The two legs are the **same two rules in the two orders**, which is
1013 /// what makes this attributable to the short-circuit and to nothing
1014 /// else: same profile, same unit, same report-once state, one
1015 /// difference. The classification is asserted on both legs too, because
1016 /// a "fix" that reported the shadowed rule by also *letting it win*
1017 /// would break first-match-wins, and the second assertion is what
1018 /// forbids it.
1019 ///
1020 /// *Ablation, recorded:* restore the `return Class::Rule(index)` in
1021 /// place of `claimed = Some(..); continue;` —
1022 ///
1023 /// ```text
1024 /// assertion `left == right` failed: a rule behind the winner can still be
1025 /// one that can never fire, and the winner claiming this unit is not a
1026 /// statement about it
1027 /// left: []
1028 /// right: [(1, TrackAlias)]
1029 /// ```
1030 #[test]
1031 fn a_rule_shadowed_by_a_catch_all_is_still_checked() {
1032 let catch_all = || class("everything", Matcher::default());
1033 // A rule keyed on the Track Alias, against a fetch unit, which
1034 // carries a Request ID where a subgroup unit carries an alias — so
1035 // the key is one the unit could not have had rather than one whose
1036 // value was wrong.
1037 let by_alias = || {
1038 class(
1039 "aliased",
1040 Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1041 )
1042 };
1043 let m = ObjectMeta { stream_kind: DataStreamType::Fetch, track_alias: None, ..meta() };
1044
1045 let shadowed = scheduler(vec![catch_all(), by_alias()], QueueConfig::default());
1046 assert_eq!(
1047 reports(&shadowed, &m)(0),
1048 vec![(1, MatcherField::TrackAlias)],
1049 "a rule behind the winner can still be one that can never fire, and \
1050 the winner claiming this unit is not a statement about it"
1051 );
1052 assert_eq!(
1053 shadowed.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
1054 Class::Rule(0),
1055 "...and reporting it must not promote it: the first match still wins"
1056 );
1057
1058 // The control leg: the same two rules in the other order, which
1059 // reported before the fix and must still report exactly once.
1060 let ordered = scheduler(vec![by_alias(), catch_all()], QueueConfig::default());
1061 assert_eq!(reports(&ordered, &m)(0), vec![(0, MatcherField::TrackAlias)]);
1062 assert_eq!(reports(&ordered, &m)(1), vec![], "still once per session per pair");
1063 }
1064
1065 /// The winner is **not** reported, however unmatchable its keys look.
1066 ///
1067 /// The guard against sweeping every rule unconditionally instead,
1068 /// which would fire `ShapeRuleUnmatchable` on the very class that is
1069 /// doing the work and make the report noise.
1070 ///
1071 /// Nearly every key makes this vacuous — an absent key never matches,
1072 /// so a rule keyed on one cannot be the winner. `stream_kind` is the
1073 /// exception and therefore the fixture: `kind_matches(Fetch, Fetch)` is
1074 /// `true` while `Fetch::is_matchable_on(Draft19)` is `false`, so a
1075 /// draft-19 fetch `ObjectMeta` is a unit that a Fetch rule both claims
1076 /// and is "unmatchable" for. The framer never builds one — drafts 15-19
1077 /// have no fetch object codec, so every fetch stream is bypassed at its
1078 /// header — which is exactly why it has to be built here: it is the only
1079 /// input that can tell the `continue` from a `return`.
1080 ///
1081 /// *Ablation, recorded:* drop the `continue` so the winner falls into
1082 /// the sweep — `left: [(0, StreamKind)] / right: []`.
1083 #[test]
1084 fn the_winning_rule_is_never_reported_as_unmatchable() {
1085 let s = scheduler(
1086 vec![class(
1087 "fetch",
1088 Matcher { stream_kind: Some(MatchKind::Fetch), ..Matcher::default() },
1089 )],
1090 QueueConfig::default(),
1091 );
1092 let claimed = ObjectMeta {
1093 draft: DraftVersion::Draft19,
1094 stream_kind: DataStreamType::Fetch,
1095 ..meta()
1096 };
1097 // The reporting assertion goes **first**, and deliberately: the mask
1098 // is once-per-session, so a classification run with a no-op reporter
1099 // would spend the budget and leave the ablation invisible. Measured
1100 // — with the assertions the other way round the `continue` ablation
1101 // ran green.
1102 assert_eq!(
1103 reports(&s, &claimed)(0),
1104 vec![],
1105 "a rule that claimed the unit is a rule that fired; reporting it as \
1106 unable to fire would be a false positive on a working class"
1107 );
1108 assert_eq!(
1109 s.classify(ProxySide::ClientToProxy, &claimed, 0, |_, _| {}),
1110 Class::Rule(0),
1111 "the fixture is only meaningful if the rule really does win"
1112 );
1113
1114 // The contrast in the same body, so the assertion above is not
1115 // passing because this scheduler never reports: a rule against a
1116 // unit it does *not* claim, keyed on something that unit could not
1117 // have carried, reports at once. The key rather than the kind,
1118 // because naming a kind is no longer something a rule can be dead
1119 // for — see `Matcher::unmatchable_fields`.
1120 let s = scheduler(
1121 vec![class(
1122 "aliased",
1123 Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1124 )],
1125 QueueConfig::default(),
1126 );
1127 let unaliased = ObjectMeta { track_alias: None, ..claimed };
1128 assert_eq!(reports(&s, &unaliased)(0), vec![(0, MatcherField::TrackAlias)]);
1129 }
1130
1131 /// The depth reaches `PendingQueue` under `Block` and nowhere else.
1132 ///
1133 /// *Ablation:* return the depth for every overflow — `DropTail`'s
1134 /// `None` assertion reddens, which is the read branch being switched
1135 /// off under the one policy that needs it on.
1136 #[test]
1137 fn only_block_installs_a_blocking_depth() {
1138 let depths = QueueConfig { depth_objects: 4, depth_bytes: 99, ..QueueConfig::default() };
1139 let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1140
1141 let blocking = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1142 assert_eq!(blocking.blocking_depth(), Some(QueueDepth { bytes: 99, objects: 4 }));
1143
1144 for overflow in [Overflow::DropTail, Overflow::ResetStream { code: 7 }] {
1145 assert_eq!(
1146 scheduler(vec![class("c", Matcher::default())], with(overflow)).blocking_depth(),
1147 None,
1148 "{overflow:?} must leave the read branch enabled"
1149 );
1150 }
1151 }
1152
1153 /// Both depth limits, and the policy each one selects.
1154 /// *Ablation:* make the object test `>` instead of `>=` — the *the fourth
1155 /// slot is taken* row admits a fifth unit and reddens.
1156 #[test]
1157 fn admission_applies_both_depths_and_then_the_policy() {
1158 let depths = QueueConfig { depth_objects: 4, depth_bytes: 100, ..QueueConfig::default() };
1159 let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1160 let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::DropTail));
1161
1162 assert_eq!(s.admit(10, 0, 0), Admission::Admit, "an empty queue takes anything that fits");
1163 assert_eq!(s.admit(10, 90, 3), Admission::Admit, "exactly at the byte depth still fits");
1164 assert_eq!(s.admit(11, 90, 3), Admission::DropTail, "one byte over the byte depth");
1165 assert_eq!(s.admit(1, 0, 4), Admission::DropTail, "all four object slots are taken");
1166 assert_eq!(s.admit(1, 0, 3), Admission::Admit, "the fourth slot is free");
1167
1168 let s = scheduler(
1169 vec![class("c", Matcher::default())],
1170 with(Overflow::ResetStream { code: 0x2A }),
1171 );
1172 assert_eq!(s.admit(1, 0, 4), Admission::ResetStream { code: 0x2A });
1173
1174 // `Block` never decides here: a unit that reached admission under
1175 // `Block` proves the queue had room, because the read branch is
1176 // what enforces the limit.
1177 let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1178 assert_eq!(s.admit(1, 0, 4), Admission::Admit);
1179 }
1180
1181 // ── release ─────────────────────────────────────────────────────
1182
1183 /// A shaper over one bucket shared by every class, with the discipline
1184 /// and the per-class `(priority, weight)` given.
1185 fn shared_bucket(
1186 rate: Option<u64>,
1187 burst: u64,
1188 discipline: Discipline,
1189 classes: &[(&str, u8, u16)],
1190 now: Instant,
1191 ) -> Scheduler {
1192 let bucket = BucketConfig {
1193 name: "b".to_string(),
1194 rate_bps: rate,
1195 burst_bytes: burst,
1196 ..BucketConfig::default()
1197 };
1198 let rules = classes
1199 .iter()
1200 .map(|(name, priority, weight)| ClassRule {
1201 name: (*name).to_string(),
1202 bucket: "b".to_string(),
1203 matcher: Matcher::default(),
1204 priority: *priority,
1205 weight: *weight,
1206 })
1207 .collect();
1208 Scheduler::at(
1209 ShapeProfile::try_new(vec![bucket], rules, QueueConfig::default(), discipline)
1210 .expect("the fixture names its own bucket"),
1211 now,
1212 )
1213 }
1214
1215 fn granted(a: &Acquire) -> bool {
1216 matches!(a, Acquire::Now)
1217 }
1218
1219 /// Neither of the two rows that name no bucket can be charged one, and
1220 /// neither is arbitrated: an unclassified unit is not a class competing
1221 /// for anything.
1222 ///
1223 /// *Ablation:* charge `Class::Default` to bucket 0 — the zero-rate arm
1224 /// answers `Never` and the assertion reddens, which is a unit no rule
1225 /// claimed being paced by a bucket no rule pointed it at.
1226 #[test]
1227 fn the_unclassified_rows_are_never_charged_a_bucket() {
1228 let base = Instant::now();
1229 // A bucket that grants nothing at all, so a charge is unmissable.
1230 let s = shared_bucket(Some(0), 0, Discipline::Fifo, &[("only", 0, 1)], base);
1231 for class in [Class::Default, Class::Unshapeable] {
1232 assert!(
1233 granted(&s.acquire(class, 4096, base)),
1234 "{class:?} names no bucket, so there is nothing to charge it against"
1235 );
1236 }
1237 // ...and the class that *does* name the bucket is refused, so the
1238 // assertion above is not passing because the bucket grants freely.
1239 assert!(matches!(s.acquire(Class::Rule(0), 1, base), Acquire::Never));
1240 }
1241
1242 /// The bound `charge` proves, reached through `acquire`: a rate-limited
1243 /// class is granted, then refused with the instant its own refill lands.
1244 #[test]
1245 fn a_rate_limited_class_is_refused_with_its_refill_instant() {
1246 let base = Instant::now();
1247 // 1000 bytes/s, 1000-byte burst: the first 1000-byte unit fits, the
1248 // second needs a full second.
1249 let s = shared_bucket(Some(1_000), 1_000, Discipline::Fifo, &[("v", 0, 1)], base);
1250 assert!(granted(&s.acquire(Class::Rule(0), 1_000, base)));
1251 match s.acquire(Class::Rule(0), 1_000, base) {
1252 Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1253 other => panic!("expected a refill instant, got {other:?}"),
1254 }
1255 assert!(granted(&s.acquire(Class::Rule(0), 1_000, base + Duration::from_secs(1))));
1256 }
1257
1258 /// **A burst too small for one unit is its own answer, claimable once**
1259 /// — not the answer a stopped class gives and not the answer a merely
1260 /// slow class gives.
1261 /// All three postures are driven in one body against the same unit size,
1262 /// because the whole claim is that they are distinguishable and a single
1263 /// posture cannot show that. Before the split, the first and the second
1264 /// were the same value and the run reported the same clamp for both, so *1
1265 /// MB/s delivering at the hold clamp* looked exactly like *this class was
1266 /// configured to stop*.
1267 ///
1268 /// *Ablation, recorded:* fold the two refusals back together in `charge`
1269 /// (`if rate == 0 || need > cap { Grant::Never }`) — the first
1270 /// assertion panics with
1271 /// `a rate that was asked for and a burst that cannot cover one object is a
1272 /// misconfiguration, not a rate limit: Never`.
1273 #[test]
1274 fn a_burst_below_one_unit_is_separable_from_a_stopped_or_a_slow_class() {
1275 let base = Instant::now();
1276 const UNIT: u64 = 1_000;
1277
1278 // The defect: a real rate, a burst that cannot hold one object.
1279 let mis_sized = shared_bucket(Some(1_000_000), 100, Discipline::Fifo, &[("v", 0, 1)], base);
1280 match mis_sized.acquire(Class::Rule(0), UNIT, base) {
1281 Acquire::LargerThanBurst { burst_bytes, unit_bytes } => {
1282 assert_eq!((burst_bytes, unit_bytes), (100, UNIT), "both figures, as configured");
1283 }
1284 other => panic!(
1285 "a rate that was asked for and a burst that cannot cover one object \
1286 is a misconfiguration, not a rate limit: {other:?}"
1287 ),
1288 }
1289 assert_eq!(
1290 mis_sized.claim_burst_report(Class::Rule(0)).as_deref(),
1291 Some("v"),
1292 "the report names the class whose rate is not being applied"
1293 );
1294 assert_eq!(
1295 mis_sized.claim_burst_report(Class::Rule(0)),
1296 None,
1297 "once per session per class: the refusal repeats on every unit and the \
1298 report must not"
1299 );
1300 assert_eq!(
1301 mis_sized.claim_burst_report(Class::Default),
1302 None,
1303 "the rows that name no bucket never reached a bucket to be refused by"
1304 );
1305
1306 // A class configured to stop. Same unit, same clamp downstream, and
1307 // deliberately a different answer: nothing is wrong with it.
1308 let stopped = shared_bucket(Some(0), 0, Discipline::Fifo, &[("v", 0, 1)], base);
1309 assert!(
1310 matches!(stopped.acquire(Class::Rule(0), UNIT, base), Acquire::Never),
1311 "a zero rate is a class doing what it was asked, whatever the burst is"
1312 );
1313
1314 // A class that is merely slow: the burst covers a unit, so the
1315 // bucket refuses with the instant it will hold one again.
1316 let slow = shared_bucket(Some(1_000), UNIT, Discipline::Fifo, &[("v", 0, 1)], base);
1317 assert!(granted(&slow.acquire(Class::Rule(0), UNIT, base)));
1318 match slow.acquire(Class::Rule(0), UNIT, base) {
1319 Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1320 other => panic!("a burst that holds a unit names a refill instant: {other:?}"),
1321 }
1322 }
1323
1324 /// Strict priority's mechanism, without QUIC: on one shared non-zero
1325 /// bucket, the
1326 /// lower-priority class is refused **while the higher one has demand**
1327 /// and released the moment that demand withdraws.
1328 ///
1329 /// The two halves are one test because neither means anything alone: a
1330 /// park that is never released is a stall, and a release that never
1331 /// parked is `Fifo` wearing a different name.
1332 ///
1333 /// *Ablation:* drop the `wake_bucket` call from `withdraw_demand` — the
1334 /// gate assertion reddens, and in the session that is a low class stalled
1335 /// to `max_hold` after the high class has finished.
1336 #[test]
1337 fn strict_priority_parks_the_low_class_until_the_high_one_withdraws() {
1338 let base = Instant::now();
1339 let s = shared_bucket(
1340 Some(1_000_000),
1341 1_000_000,
1342 Discipline::StrictPriority,
1343 &[("audio", 9, 1), ("video", 1, 1)],
1344 base,
1345 );
1346
1347 // Nobody is holding anything: the bucket alone decides, and it grants.
1348 assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1349
1350 s.declare_demand(Class::Rule(0));
1351 let gate = match s.acquire(Class::Rule(1), 10, base) {
1352 Acquire::Starved(gate) => gate,
1353 other => panic!("audio has demand and outranks video; got {other:?}"),
1354 };
1355 assert!(!gate.is_released(), "the blocking class is still holding it");
1356 // The high class is not held back by the low one's demand.
1357 s.declare_demand(Class::Rule(1));
1358 assert!(granted(&s.acquire(Class::Rule(0), 10, base)));
1359
1360 s.withdraw_demand(Class::Rule(0));
1361 assert!(
1362 gate.is_released(),
1363 "the gate a starved class parked on opens when its blocker drains"
1364 );
1365 assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1366 }
1367
1368 /// Weighted round robin's mechanism, without QUIC: two classes with
1369 /// demand on one bucket
1370 /// under `WeightedRoundRobin` are granted in their **weight ratio**,
1371 /// because the ratio is a count and not a rate.
1372 ///
1373 /// The bucket is deliberately unlimited, so nothing but the discipline
1374 /// can shape the counts, and each class asks the way a stream's release
1375 /// branch does — `while let Some(unit) = pop_next_due(..)`, i.e. until it
1376 /// is refused. Asking exactly once per turn instead would cap a class at
1377 /// one grant per turn whatever its weight, and the harness rather than
1378 /// the discipline would be setting the ratio. (Measured: that shape gave
1379 /// `[40, 14]` for weights 3:1.)
1380 ///
1381 /// The ratio is asserted as a **band**, not an equality, and the reason
1382 /// is arithmetic rather than caution: a round hands out `weight` grants
1383 /// per class, so the totals are exact only when sampled on a round
1384 /// boundary and the last partial round moves them by at most `weight`.
1385 /// The band still separates 3:1 from 1:1 by a factor of three.
1386 ///
1387 /// *Ablation:* give both classes weight 1 — the counts come out equal and
1388 /// the `>= 2x` assertion reddens.
1389 #[test]
1390 fn weighted_round_robin_grants_in_the_weight_ratio() {
1391 let base = Instant::now();
1392 let s = shared_bucket(
1393 None,
1394 0,
1395 Discipline::WeightedRoundRobin,
1396 &[("a", 0, 3), ("b", 0, 1)],
1397 base,
1398 );
1399 s.declare_demand(Class::Rule(0));
1400 s.declare_demand(Class::Rule(1));
1401
1402 let mut counts = [0u32; 2];
1403 for _ in 0..20 {
1404 for (index, count) in counts.iter_mut().enumerate() {
1405 while granted(&s.acquire(Class::Rule(index), 1, base)) {
1406 *count += 1;
1407 }
1408 }
1409 }
1410 assert!(counts[1] > 0, "the low-weight class must still be scheduled, not starved");
1411 assert!(
1412 counts[0] >= 2 * counts[1],
1413 "weights 3:1 must give the heavy class at least twice the count: {counts:?}"
1414 );
1415 assert!(counts[0] <= 4 * counts[1], "weights 3:1 are a share, not a monopoly: {counts:?}");
1416 }
1417
1418 /// A class with no rival holding demand takes the whole bucket under
1419 /// `WeightedRoundRobin`, rather than stalling once its own credits run
1420 /// out. A round is over when everyone *who wants it* has had their share.
1421 ///
1422 /// *Ablation:* refill only when `credits[index] == 0` for every class
1423 /// including those with no demand — this loop stops after three grants
1424 /// and reddens.
1425 #[test]
1426 fn weighted_round_robin_does_not_wait_for_a_class_with_nothing_to_send() {
1427 let base = Instant::now();
1428 let s = shared_bucket(
1429 None,
1430 0,
1431 Discipline::WeightedRoundRobin,
1432 &[("a", 0, 3), ("b", 0, 1)],
1433 base,
1434 );
1435 s.declare_demand(Class::Rule(0));
1436 for i in 0..20 {
1437 assert!(granted(&s.acquire(Class::Rule(0), 1, base)), "grant {i} was refused");
1438 }
1439 }
1440
1441 /// `Fifo` arbitrates nothing: demand from another class changes no
1442 /// answer, and the bucket is the only thing that can refuse.
1443 ///
1444 /// This is the control for the two discipline tests above — without it
1445 /// they could both be passing because `acquire` refuses whenever *any*
1446 /// other class has demand.
1447 #[test]
1448 fn fifo_never_parks_a_class() {
1449 let base = Instant::now();
1450 let s = shared_bucket(None, 0, Discipline::Fifo, &[("a", 9, 1), ("b", 0, 1)], base);
1451 s.declare_demand(Class::Rule(0));
1452 for _ in 0..10 {
1453 assert!(granted(&s.acquire(Class::Rule(1), 1_000, base)));
1454 }
1455 }
1456
1457 /// A withdrawal that outnumbers its declarations does not wrap the count
1458 /// into a permanent block.
1459 ///
1460 /// The queue withdraws from `clear`, from the teardown drain and from
1461 /// `Drop`, and those can overlap; a `u64` going through zero would make
1462 /// a class look like it had four billion streams waiting and starve
1463 /// everything under it for the rest of the session.
1464 #[test]
1465 fn withdrawing_more_than_was_declared_saturates_at_zero() {
1466 let base = Instant::now();
1467 let s =
1468 shared_bucket(None, 0, Discipline::StrictPriority, &[("hi", 9, 1), ("lo", 0, 1)], base);
1469 s.declare_demand(Class::Rule(0));
1470 s.withdraw_demand(Class::Rule(0));
1471 s.withdraw_demand(Class::Rule(0));
1472 s.withdraw_demand(Class::Rule(0));
1473 assert!(granted(&s.acquire(Class::Rule(1), 1, base)));
1474 }
1475}