Core Question
Core Question
Four sessions built four standalone mechanisms — a token bucket that refills itself, the lazy refill math underneath it, a leaky bucket that drains itself, and a class tree that validates its own shape. None of them, alone, shapes traffic. What does it take to wire them into something that classifies a packet and decides its fate, and what exactly is it about that wiring that keeps one class's exhaustion from leaking into another's?
Outcome
Outcome
By the end of this session, the learner should be able to:
- name every field on
ToyShaperand which earlier session's module owns the mechanism behind it - trace
enqueue()'s three steps — classify, refill, spend — and say which function from which session each step calls - explain why
run_contention()proves isolation, and say precisely what would have to change inToyShaperfor that isolation to break - predict, from the source alone, what
enqueue()does when asked about a class name that isn't inself.buckets - read
shaper.traceas the record of what actually happened, the same wayToyTcpEndpoint.traceworked in the TCP track's capstone
Read Order
Read Order
- Read the module docstring at the top of
shaper_loop.py - Read the
importblock — four names from three sibling modules - Read
EnqueueResult - Read
ToyShaper's field list - Read
tick() - Read
enqueue() - Read
run_contention()and its docstring - Run
examples/qos/session_05_walkthrough.py
Read It Like Code
Read It Like Code
ToyShaper(
tree,
buckets,
clock,
trace,
)Parts List
Parts List
Every field and every function shaper_loop.py imports was taught by an earlier session in this track. The capstone's only new code is the wiring between them.
| Import | Session that taught it | What it contributes to ToyShaper |
|---|---|---|
classes.ClassTree | 04 | tree — describes how class names relate to each other. ToyShaper stores it but enqueue() never actually consults it; see Toy Model Boundary. |
token_bucket.TokenBucket, try_consume, ConsumeOutcome | 01 | buckets — one TokenBucket per class name; enqueue()'s entire accept/throttle decision is a direct call to try_consume(). |
refill.compute_refill (via token_bucket.try_consume, not imported directly) | 02 | The lazy-refill arithmetic that fires every time try_consume() runs — shaper_loop.py never calls it itself, try_consume() does, on the caller's behalf. |
leaky_bucket.LeakyBucket, offer, OfferOutcome | 03 | Not part of ToyShaper at all — used only in the module's own __main__ block to run the same burst through a leaky bucket side-by-side, for comparison. |
Decision Flow
Decision Flow
enqueue(class_name, size, now):
1. bucket = self.buckets[class_name] # classify: plain dict lookup, no fallback
2. consumed = try_consume(bucket, size, now) # Session 01+02's refill-then-spend
3. append one line to self.trace describing class_name, size, now, outcome, tokens_remaining
4. return EnqueueResult(class_name, consumed.outcome, consumed.tokens_remaining)
run_contention(shaper, offered):
for each (class_name, size) in offered, in order:
call shaper.enqueue(class_name, size, shaper.clock) # same clock value every time
return all results as a tupleReading Lens
Reading Lens
The important move in this session is to stop asking "what does the shaper do" and start asking, at every line: which earlier session's mechanism is actually doing the work here, and which parts of ToyShaper are just bookkeeping around it?
- in
enqueue(), which line is Session 01's code, verbatim, and which lines are new to this module? - in
run_contention(),shaper.clockis read once per offer but never advanced between offers — what does that tell you about what "the same instant" means in this toy? - when
bucketinenqueue()is looked up, what enforces that this bucket belongs toclass_nameand not some other class? (Look at whatself.bucketsactually is.)
Toy Model Boundary
Toy Model Boundary
There is no queue, no scheduler, and no delay — every enqueue() call is an instantaneous accept-or-throttle decision against one class's own bucket balance, exactly like Session 01's try_consume() in isolation. Nothing here models a packet waiting, being reordered, or being dequeued later; THROTTLED means the offer was rejected outright, not queued for a retry.
ToyShaper carries a tree: ClassTree field, but enqueue() never reads it. The class hierarchy from Session 04 — and the borrowable() computation that comes with it — is not wired into the shaping decision at all in this toy. A class that is THROTTLED on its own bucket does not borrow from its parent's slack, even though borrowable() could, in principle, say slack exists. Wiring borrowable() into enqueue() would be the natural next step past this course, and it is exactly the gap between this toy and a real HTB-style shaper: real HTB lets a class actually draw on a parent's spare capacity when its own bucket runs dry; here, guaranteed_rate and the class tree describe an intended hierarchy but never touch a single token.
There is one shared clock: int on ToyShaper, moved only by tick(); run_contention() does not advance it between offers, which is what makes "the same instant" a precise, reproducible claim in the walkthrough rather than an approximation.
Code Landmarks
Code Landmarks
The module docstring's second paragraph
Names the headline directly: "one class exhausting its bucket must not affect another class's bucket, because each class owns an independent TokenBucket keyed by name." Read this before run_contention() so the function reads as a proof of a stated claim, not just a demo.
enqueue()'s first line
bucket = self.buckets[class_name] — a plain dict index, not .get() with a default. There is no branch here for a class name that isn't a key.
enqueue()'s trace line
Every call appends to self.trace regardless of outcome — THROTTLED calls are logged exactly as ALLOWED calls are. The trace is a complete record, not a log of successes.
run_contention()'s docstring
Spells out the exact scenario the walkthrough runs: video's bucket drained low, bulk's untouched, now held constant — and states the expected result before the code runs it.
The __main__ block's leaky-bucket comparison
After proving isolation, the block runs the identical burst through a fresh LeakyBucket from Session 03 and gets the opposite outcome — OVERFLOWED where the token bucket said ALLOWED. This is not part of ToyShaper's own logic; it is the module reaching back to tie Session 01 and Session 03 together one more time.
Failure Questions
Failure Questions
Use the source file to answer these:
enqueue()doesbucket = self.buckets[class_name]with no existence check. Ifclass_nameis not a key inself.buckets, what exception is raised, and at which exact line does it happen — insideenqueue(), or inside some function it calls?run_contention()'s scenario throttlesvideoand allowsbulkin the same call. Point to the single field onToyShaperwhose type (dict[str, TokenBucket], one bucket per name) is the entire reason video's throttle cannot touch bulk's balance. What would have to change about that field's type for isolation to break?ToyShaper.treeis populated with a full class hierarchy in the walkthrough (default/video/bulk) butenqueue()never readsself.tree. If a class isTHROTTLEDon its own bucket whileborrowable()(Session 04) would report slack available from its parent, does anything in this module route that slack to the throttled class?run_contention()callsshaper.enqueue(class_name, size, shaper.clock)for every offer in the tuple, readingshaper.clockfresh each time but never callingshaper.tick()between offers. Ifofferedcontains two entries for the same class name, do they see the same token balance as of the start of the call, or does the first offer's consumption affect the second's outcome?- The
__main__block builds aLeakyBucketwithcapacity=20, level=20— already full — and offers it the same 10-unit burst that a freshTokenBucketallows. Readoffer()inleaky_bucket.py: why does an already-full leaky bucket reject a burst that an already-full token bucket would allow?
Walkthrough
Walkthrough
Run this:
PYTHONPATH=src python3 examples/qos/session_05_walkthrough.py
The walkthrough builds a shaper with two classes — video nearly drained (5 of 20 tokens) and bulk at full capacity — and runs run_contention() at a single instant to show video THROTTLED while bulk sails through ALLOWED, proving isolation. It then advances the clock and retries video to show the same bucket that was just throttled re-allowing once time has passed. It calls enqueue() with a class name that was never registered in self.buckets and confirms the real code's actual behavior — a KeyError, not a graceful THROTTLED result. Finally it checks that shaper.trace is non-empty and contains the exact throttle line from the first contention check.
Done When
Done When
The learner can say all of the following without looking at notes:
- "A shaper is not a new algorithm — it's Session 01's token bucket, one per class, wired to a classify step and a trace."
- "Isolation between classes comes from each class owning its own TokenBucket object, not from any scheduling or ordering trick."
- "This toy's class tree from Session 04 is stored but never consulted — borrowing on paper and borrowing in the shaping decision are two different things here."
References
References
- RFC 2475 (An Architecture for Differentiated Services) — the general framing of sorting traffic into classes with distinct treatment, which this shaper implements in miniature for one node.
- This module's per-class hierarchical shaping is HTB-flavored (Hierarchical Token Bucket, as implemented in Linux
tc), but only in shape: real HTB performs live borrowing, letting a class draw on a parent's spare bandwidth as actual usage shifts.ToyShaperstores aClassTreebutenqueue()never reads it — no borrowing, live or otherwise, happens in this toy's shaping decision.
Continue