A bounded producer/consumer queue you can push past its own capacity, and the three answers to what should happen next.
This is the Go simulation behind pigfox.com/demos/backpressure-lab. It has no HTTP surface, no template and no browser dependency — the transport and the page live in the site, and this package is the part that does the work.
Every queue between two components has a capacity. The interesting question is never how big it is, it is what happens when it is full, and there are only three answers:
- Block the producer. Nothing is lost. The producer waits for a slot, so the cost lands on whoever is producing. This is backpressure.
- Drop the newest. The producer never waits. The arriving message is refused and the backlog stays as stale as it already was.
- Drop the oldest. The producer never waits. The longest-waiting message is abandoned to make room, so the backlog stays fresh.
Run the same load through each and they diverge within seconds. The measurement that shows it is not the shed count — it is the rate the producer achieved against the rate it was asked for. Under blocking those two numbers come apart, and that gap is the queue reaching back and slowing its own source.
Nothing in this package calls time.Now or time.Sleep directly. A live session
runs on NewRealClock() and paces itself against the wall, so a viewer sees the
queue fill in real time. The same code under NewVirtualClock(start) runs a
ninety-second session in microseconds and produces the same numbers, because
virtual time only moves when every participating goroutine is waiting — and each
advance releases exactly one sleeper, the earliest, ties broken by join order.
No test in this package sleeps. The whole suite, including a forty-five second session and a goroutine-leak check, runs in well under a second.
clock := backpressurelab.NewVirtualClock(time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC))
lab, err := backpressurelab.New(backpressurelab.Config{
Workers: 1,
ArrivalRate: 300,
ServiceTime: 10 * time.Millisecond,
QueueCapacity: 10,
Policy: backpressurelab.PolicyBlock,
Lifetime: 5 * time.Second,
}, clock)
if err != nil {
return err
}
defer lab.Close()
reason := lab.Run(context.Background())
s := lab.Snapshot()A worker holding a message for its service time is parked on a timer, not looping on a clock read. That is a safety property rather than a style preference: the ceilings here permit thirty-two live workers across a full house of sessions, and thirty-two spinning goroutines would peg every core on the box for as long as anyone kept the page open.
Normalize is the only door into a Config, and the global worker budget is
committed by this package before a single goroutine starts. A limit enforced by
the transport is a limit the second transport does not have.
A request above a ceiling is clamped and reported, never refused silently — a
simulation quietly running numbers other than the ones on screen is worse than a
refusal. Lab.Clamps() is what changed and why.
| knob | range |
|---|---|
| workers | 1–8 per session, 32 across all sessions |
| arrival rate | 1–5000 / second |
| service time | 1–50 ms |
| queue capacity | 1–10000 |
| session lifetime | 1–90 s |
Every message the producer offered is in exactly one of five states, at every instant:
Offered == Completed + Shed + QueueDepth + InFlight + Abandoned
Abandoned exists so that identity closes when a session is cancelled while a
worker holds a message or a producer is mid-stall. A dashboard whose own
arithmetic is wrong is worse than one that says nothing.
Latency is measured end to end, from the instant a message arrived to the instant a worker finished with it, into a fixed bucket ladder with no external dependency and no per-sample retention. A reported percentile is the upper bound of the bucket the quantile falls in, so it never understates — and it can sit above the largest sample ever observed. The overflow bucket has no upper bound to report and returns the observed maximum instead.
The .go files here are byte-identical to their counterparts in
internal/backpressurelab/ in the pigfox2 repository. Two files from that
package are absent rather than changed: view.go and view_test.go, which are
the site's page model — asset hrefs, control defaults, the stream path — and
have nothing to do with the simulation. A diff over the ten files that are here
should be empty; if it is not, the site has a private fork of this package and
that is a defect.
make test # go test -race ./...
make cover # 100% of statements
MIT, Pigfox LLC. See LICENSE.