Coffee Shop (Compound Hold): model notes
Coffee Shop
A coffee shop with one barista and one espresso machine. Drip orders need only the barista; espresso orders need both, with the barista held throughout. The reference model for the compound hold pattern (holding one resource while waiting for another), multi-resource serial coordination, and the richest composition of primitives in the example set: two Facilities, a Priority wait set, forked patience timers, and a deeper state machine.
Problem
Arrivals: Exp(inter-arrival from the barbershop model file, ~3 min cadence). Each customer independently wants:
- Drip (60%) — barista for 2 minutes, then leave.
- Espresso (40%) — barista for 5 minutes total: 4 minutes pulling a shot (during which both barista and machine are busy) plus 1 minute finishing (milk, lid) with the barista only.
Espresso orders take priority at the barista (rank 0 vs 1). All customers have a 5-minute patience; they renege if not served in time. Simulate an 8-hour day and report per-order-type served counts, reneges, and wait times.
This is not a GPSS-book example — it's a puck-idiom showpiece composing the primitives. No single GPSS block maps cleanly because GPSS doesn't have a native "hold resource A while seizing resource B" idiom; the transaction would typically be expressed as two nested SEIZE blocks, which is what this model does, just as puck state transitions.
Model in this directory
Customer(coffee_shop.odin:77-151) — a six-state machine (Arriving → Waiting_For_Barista → {Making_Drip | Waiting_For_Machine → Pulling_Shot → Finishing_Espresso} → Done). The branching is onc.order.Patience_Puck(coffee_shop.odin:208-232) — the fork offspring, identical in shape toreneging's timeout puck.Gen(coffee_shop.odin:236-272) — generator that classifies each arrival as drip vs espresso from a deterministic hash of the customer id.
Shop (coffee_shop.odin:41-48) holds:
barista: sim.Facility— created with.Priorityorder so espresso orders jump the drip queue.espresso_machine: sim.Facility— plain FIFO.- Two pools:
customer_poolandpatience_pool.
Why this shape
The compound hold. When an espresso customer reaches
customer_start_order, it already owns the barista. It then
calls sim.seize(&shop.espresso_machine, &c.puck) — if that
succeeds, pull the shot immediately; if it fails, the customer
suspends on the machine's wait set while still holding the
barista. This is intentional backpressure: a queued espresso
order blocks the barista from helping anyone else until the
machine frees up. The model depends on this behavior — it's the
whole point.
case .Espresso:
c.state = .Waiting_For_Machine
if sim.seize(&c.shop.espresso_machine, &c.puck) {
// Machine was free — straight into Pulling_Shot
} else {
// Holding barista, waiting for machine.
}
The released wakeup lands the customer back in
.Waiting_For_Machine; the tick proc transitions it to
.Pulling_Shot and advances 4 minutes. When the shot is done,
the machine is released first (.Pulling_Shot → .Finishing_Espresso)
but the barista stays held for the finishing minute. This
sequential release is what separates "resource A held throughout
operation X" from "resource A handed off when operation Y
starts" — it's all in the order of sim.release calls across
states.
Priority on the Facility, not a model-owned Set. Unlike
tool_crib — which manages priority in a model-owned Set with
-priority ranks — the coffee shop uses
sim.facility_create(.Priority) and passes rank: f64 = c.order == .Espresso ? 0 : 1 to sim.seize. This is the "cheap priority"
form: policy lives on the Facility, callers pass a rank at seize
time, no dispatcher needed. It works here because:
- The wait list is exactly the barista's waiters — there is no second queue to coordinate with.
- Release already wakes the head of the priority-sorted waiters, so the correct customer is chosen automatically.
- There is no "newly arrived high-priority jumps over waiting
low-priority" scenario that can't be handled at seize time (a
fresh arrival hits
sim.seizedirectly, which either wins immediately or joins the correctly-sorted queue).
Contrast with tool_crib, where two separate generators feed a
shared list and a dispatcher is needed because priority inversions
happen between independent arrivals — the Facility's internal
ordering alone isn't enough. Here, the simpler form suffices.
Six states, not four. The compound hold adds two extra
waypoints: Waiting_For_Machine (arrived at barista, blocked on
machine) and Pulling_Shot (holding both). This is where puck
state machines stretch: each new concurrent resource adds a state,
each new sequential phase adds a state. Five or six states per
entity is around the ceiling for readable puck code; beyond that,
a sub-machine or a helper proc per-phase starts to earn its keep.
Fork-with-flags, not wait_until. Reneging here uses the
same pattern as the reneging example:
a Patience_Puck that fires once, checks being_served, and
either stands down or flips reneged and reactivates the
customer. The renege path explicitly removes the customer from
barista.waiters (coffee_shop.odin:110) —
the customer can only be waiting on the barista at this point
(the renege check happens in .Waiting_For_Barista state), not
on the machine. A customer already holding the barista but
queued on the machine cannot renege in this model; their
patience flag would have been suppressed by being_served = true
in customer_start_order. That's a modeling choice: once you
have the barista, you're committed.
Order type by deterministic hash, not RNG stream. The mix is
60/40 drip/espresso, selected by hash := u64(id) * 2654435761; hash % 100 < 40. This is a deterministic pseudo-RNG that doesn't
consume the model's db.RV streams — a note-to-self placeholder,
same as the patience field in reneging. A clean version would
use a dedicated type_rng stream with db.random_real.
Alternatives considered
Express the espresso path with wait_until
SLX's compound-hold could read:
seize barista;
wait until (machine free); // predicate over shop state
seize machine;
advance 4; release machine;
advance 1; release barista;
In odin-des terms, that would be a Notify_Var on machine
availability and a sim.wait_until in place of the
seize-with-enqueue. It would work, but it's strictly more
machinery than sim.seize already provides (seize handles
"wait until free" correctly via its waiters set). wait_until
earns its keep when the predicate is compound — "machine free
AND barista break not active AND supplies above threshold" —
where no single Facility can answer. For a single-resource wait,
sim.seize is the right tool.
Model the machine as a Storage with capacity 1
Semantically identical, notationally heavier. Facility with
FIFO waiters is the tighter fit for single-unit equipment.
One giant tick proc with nested switches
The six states could flatten into a single body switching on
(order, state) pairs. It would be shorter in line count,
harder to follow. The current split by state is more maintainable
— each case is one event, one transition, one advance or seize.
Share Patience_Puck code with reneging
Patience_Puck here and Timeout_Puck in reneging are
essentially the same thing. Extracting sim.patience(customer, duration, &reneged_flag, &being_served_flag) would collapse both
— but once it's extracted, the naming will inform the next
example that needs it. Deferred, same discipline as
pruning-status.md §"Patterns
emerging" calls for: two occurrences is not enough, and the flag
protocol between parent and child is subtle enough that a helper
needs a careful API (which fields does it write? what happens if
the customer forgets to set being_served?). Leaving both
inlined keeps the code honest for now.
Drop the priority on the barista
If drip and espresso had the same priority, espresso customers
would regularly get stuck behind drip orders and see higher
average wait despite needing the barista longer. The model's
.Priority ordering is the policy lever — worth toggling in a
sensitivity analysis to see the effect.
What this example teaches
This is the reference for:
- The compound hold — acquiring resource B while holding resource A, with resource A held across B's full lifecycle
- Serial release ordering — releasing B before A in one state transition, A in a later one, to implement "A held throughout, B held for phase X"
- Priority via
sim.facility_create(.Priority)+ seize-rank as the light-weight alternative to dispatcher-managed priority (contrast withtool_crib) - Composing fork-for-patience on top of a multi-resource state machine
- Where puck state machines start to stretch — at ~6 states across two resources and two order types, the complexity is right at the threshold for inlining vs extracting sub-machines
CLI
./coffee_shop [options]
Add --json to emit the uniform envelope (metadata, execution_stats,
metrics, details) instead of the default text output.
| Flag | Type | Default | Description |
|---|---|---|---|
--json |
bool | false |
Emit uniform JSON envelope instead of text. |
Example runs:
./coffee_shop # default text run
./coffee_shop --json # uniform envelope
Edit constants in coffee_shop.odin to vary order mix, service
times, patience, or run length — those are not exposed as CLI flags.
Running it
odin run examples/coffee_shop
Default output is a verbose trace (customer-by-customer event log) plus an end-of-day summary by order type.
See also
- Modeling guide §4.2 —
Facilitywith.Priorityorder and seize-time rank - Modeling guide §7 — the canonical customer tick, which this example extends
reneging— source of the patience fork pattern this model reuses; read it first for the fork mechanicstool_crib— the alternative priority implementation (model-owned Set + dispatcher); contrast with the Facility-native approach here to see when each fitsgated_shop— the two-resource shape in a different composition (Gate + Facility, serial admission); this example is the "parallel hold" counterpartdocs/reference/wait-until-and-helpers.md— the deeper alternative tosim.seizewith compound predicates