Backpressure and sizing — wiring every bound so the bottleneck is chosen¶
Genre: descriptive reference. It assembles behaviour specified elsewhere into one narrative a deployer can follow; where it paraphrases, it cites. The normative surface is
../spec/v1.mdand the RFCs it names. The reference implementation’s own allocation seams and their failure semantics are described in../design/allocation-and-backpressure.md, which is the “how this C++ tree does it” companion to the “how a deployment reasons about it” below.
The thesis¶
“No bottlenecks” does not mean unbounded. An unbounded stage is not a fast stage — it is a stage whose failure has been deferred until it can only be answered by an allocator, and on a node with tens of kilobytes of RAM that answer is a reboot. A pipeline with no accidental bottleneck is one where:
Every stage is bounded, and every bound is a real injected resource — a slab, a pool, a byte budget, a slot count — never a synthetic constant (CONTEXT.md §Resource bound).
Exactly one stage per flow is the designated pressure point — the place the deployer has decided the flow will queue or shed, chosen because it is where the loss or the stall is cheapest.
Everything upstream of it refuses by value rather than buffering. A refusal the caller was told about is a sizing datum; a byte silently absorbed into a growing queue is a latency regression that surfaces a week later as an OOM.
Point 3 is the one that makes points 1 and 2 legible. A stage that answers BACKPRESSURE
publishes where the pressure is; a stage that quietly grows hides it. That is why every seam a
peer can provoke draws from tr::mem::block_source_t, whose exhaustion is a nullptr return
rather than a throw
(CONTEXT.md §Block source / failable allocation,
ADR-0065).
The corollary a deployer must internalise: libtracer will not choose the pressure point for you. Every seam defaults to the process heap (on a host, through the slab pool’s value, table and net sub-pools, #1777), so an unwired build is all-heap and has no designated point at all — the bottleneck is then wherever the platform’s allocator happens to give out. Choosing is the deployment’s job (ADR-0079: no composition is the default, policy stays with the deployer).
1. The pipeline map¶
One frame’s life through a receiving node, with every bounded stage, what its bound is made of, what refusal looks like, and the member that observes it.
flowchart LR
RX["transport RX<br/>scratch + frame cap"] --> ARENA["rx arena<br/>TLV decode"]
ARENA --> GW["graph write<br/>ACL + value backend"]
GW --> RING["ring admission<br/>byte-accounted"]
RING --> FAN["fan-out<br/>subscriber snapshot"]
FAN --> FLAT["flat / egress seams<br/>reply + delivery assembly"]
FLAT --> TX["TX pool<br/>slots + reply reserve"]
classDef stage fill:#dff,stroke:#333
class RX,ARENA,GW,RING,FAN,FLAT,TX stage
Stage |
The bound |
Where the capacity comes from |
Refusal |
Observed by |
|---|---|---|---|---|
transport RX |
the link’s own scratch and its peer-agreed max frame — or, on a link put into OWNING delivery, the bounded source the integrator injected (#1565: |
per-connection |
counted drop ( |
|
rx arena |
the injected failable source the terminus decode carves its node table from |
|
refused by value: |
|
graph write |
the ACL gate, plus the value backend the store draws its durable bytes from |
|
|
|
ring admission |
a byte budget: |
|
arm-dependent — see §2 |
|
fan-out |
the subscriber snapshot’s inline prefix, then a heap widen |
|
counted shed of the whole delivery ( |
|
flat / egress seams |
the reply-flatten and egress span tables |
|
counted drop of the reply or the forward hop — drop, never truncate |
|
TX pool |
outstanding sends in flight, plus a reserve slots deep held back for replies |
the link’s ctor ( |
counted |
|
TX payload size classes (ESP httpd link) |
which storage a gathered frame is copied into: the work slot’s inline buffer, an optional bounded LARGE class, or the exceptional tail drawn from the link’s |
the link’s ctor — |
an exhausted large class is a counted |
|
label space (forwarders) |
65535 wire labels per link, and the per-link binding table |
|
a silent degrade, not a loss: the flow falls back to full-route |
|
Two reading rules for this table:
refusedanddroppedare different columns and must never be summed. A refusal was answered by value — somebody was told, and it is a sizing problem. A drop was lost — nobody was told, which is exactly why it is counted, and it may be a correctness problem (core/STYLE.md§Introspection).A ceiling is quoted beside every drop (#1160). A report that says “refused” without saying what ceiling produced the refusal is unactionable, because the effective ceiling is the injected one, never the compile-time default.
The transport plane is outside the refuse-by-value law of §Thesis point 3, by contract.
transport_t::sendreturnsvoidand always will in v1: there is no wire carrier for backpressure (§2 property 2 — the per-edge credit window is the v2 escalation), so a link that dropped a frame has nobody to refuse to. What it owes instead is a count: every drop MUST land indrop_stats(), and that tally — readable in-process, and on the wire asread <any-vertex>:stats.link.<child>— is the whole of the feedback this plane offers. Counted shed IS the transport contract. Re-opening it is scoped to M5 (a real socket transport), where a link owning its own socket buffer would have something to push back with. The two rows this governs are transport RX and TX pool above; every other row in the table refuses by value and the distinction is deliberate.
2. The two pressure arms, stated once¶
Every bounded stage answers pressure in one of exactly two ways. The choice is per flow, not per node — a node routinely runs both, and the archetypes in §3 all mix them.
The reliable arm — refuse admission, answer BACKPRESSURE, the producer retries. Nothing is
lost, nothing is shed, the bound is never exceeded, and the producer is expected to slow down.
This is the arm for control planes and lossless flows: a setpoint that arrives late is still
correct, a setpoint that vanishes is not. It holds even when the seam that refused is the one a
reply would draw from: a request the terminus cannot decode or answer for want of rx or egress
memory is still answered BACKPRESSURE, from a stack-built frame that echoes the request’s own
src and allocates nothing — the reply must not need the memory that just refused
(04 §Exhaustion is a value, #1612). The drop is still counted
(arena_dropped / assemble_dropped / reply_iov_dropped); the reply is what lets the requester
tell a sizing problem from a dead link.
The best-effort arm — shed the oldest, count the loss, raise the gap marker. Latency stays bounded and completeness is sacrificed knowingly. This is the arm for streams where freshness beats completeness: the newest frame of a camera or an IMU is worth more than the one it displaced.
The canonical instance of the pair is the receiving STREAM vertex’s ring, whose arm is declared
at wiring time and read at admission (core/include/libtracer/vertex.hpp:vertex_t::ring_admit,
core/include/libtracer/vertex.hpp:vertex_t::ring_admit):
reliable |
best-effort (the default) |
|
|---|---|---|
refused reservation |
admission refused, ring unchanged |
oldest entry shed whole, reservation released, retried once |
what the producer sees |
the local write answers |
the write succeeds |
what the consumer sees |
nothing — there was no gap |
|
accounting |
nothing lost, nothing to count |
every shed counted ( |
Three properties of this pair that a deployment must design around:
The shed is singular. One entry per refused admission, never a loop until the source relents — a source that has gone to zero converges the ring to empty one write at a time instead of destroying every queued delivery in one stroke.
The reliable arm is LOCAL-producer only in v1. There is no wire carrier for backpressure; the per-edge credit window is parked as the v2 escalation (RFC-0025 §4.6.1 clause 7). A remote producer against a reliable ring does not stall — it sees the receiver’s drop tally after the fact. Plan the remote case as §3 archetype 3 does, or accept that the feedback is asynchronous.
Depth and bytes compose. The declared depth intent retires before the byte bound charges, so a ring at its declared depth funds the new admission out of the entry it was going to drop anyway — and a source sized for exactly N entries does not spuriously shed on the N+1th (
core/include/libtracer/vertex.hpp:vertex_t::ring_admit).
3. Deployment archetypes¶
Four wirings. Each names its designated pressure point — the single stage that is allowed to queue or shed — and what every other stage does instead.
3.1 Bounded MCU node (ESP32-class, tens of KB heap)¶
Composition: folded, or close to it — every seam on
pool_source_tslabs carved from static regions. One slab, whole stack, one cap, tightest RAM; contention-free because the target has one thread (ADR-0079 §folded).TX: depth from the link’s
tx_slot_capacity(), withtx_reply_reserve()slots held back past it. The sum is the link’s true outstanding-send bound.Designated pressure point: two, one per flow — the receiving vertex’s ring on the best-effort arm for sensor streams, the TX pool on the reliable arm for control.
Everything else refuses: the rx arena answers
TLV_NESTING_TOO_DEEP, the value store answersBACKPRESSURE, and neither grows.Note the one cost a pooled RX backend adds: a shared value borrows its whole inbound segment — receive capacity — until it is displaced, not merely for the delivery window. Size against
live shared values × segment_bytes; no per-value knob bounds the number of values (core/include/libtracer/graph.hpp:vertex_policy_t::share_threshold_bytes). A low copy-or-share threshold on a long-held vertex (a config vertex, a rarely-updated setpoint) is exactly the shape that starves a small pool — which is why a NARROW build copies always.
Measured on silicon. The wiring above, run on the hardware it describes: an ESP32-C6
(RISC-V, one application core) on the adopted esp_http_server WebSocket link, library at
v0.15.0, two nodes with 7 and 10 vertices. Every figure below is one board class and one
firmware. It shows the shape of an ESP32-class node; it is not a constant to copy. Each figure
was read from a fresh boot with the node idle, and counters were differenced within that one boot
(§5).
idle |
control arm, 1 session × 4 subs |
control arm, 3 sessions × 4 subs |
bulk ingest into a |
|
|---|---|---|---|---|
free heap (node A) |
79,848 B |
−20,076 B vs idle |
−16,872 B vs idle |
23,936 B at the trough |
flat-seam peak / 24,576 B budget |
0 |
244 B (1.0 %) |
732 B (3.0 %) |
1,026 B (4.2 %) |
|
0 / 0 |
0 / 0 |
24 / 358 |
— |
|
0 |
0 |
358 |
— |
refusals at any other stage |
0 |
0 |
0 |
0 inside the vertex cap |
In the 3-session run, all 358 enqueue drops were the same 358 pool misses, so no event was
counted twice. Not one refusal reached a neighbouring seam. Above the vertex’s cap, the ingest
arm was refused by value (BACKPRESSURE) when the write asked for a reply and dropped silently
when it did not. Both were counted at the flat seam as oversize.
Three sizing rules follow. §3.1 is where an embedder should apply them:
Size the TX pool for widest publisher pass × concurrent subscribed sessions. The pool is
tx_slot_capacity()slots, with the in-calltx_reply_reserve()slot held ON TOP of it (integrations/esp-idf/libtracer/httpd_ws_link.cpp:httpd_ws_link_t::tx_reply_reserve). The per-link knob ishttpd_ws_config_t::tx_pool_slots, which defaults tohttpd_ws_link_t::kDefaultTxPoolSlots. Demand is multiplied by sessions, and pass width alone hides that. In the run above, a pass 4 posts wide fit 5 slots for one session (demand 4). With three sessions, demand was 12 and the pool missed. The arm the fan-out runs on decides how an undersized pool fails. A fan-out issued ON the httpd task (a push provoked by an inbound frame) hits the depth as a hard same-pass limit, while an off-task fan-out waits for the drain and shows up first asstats_t::tx_pool_waits(added latency). It becomestx_pool_missesonly when the drain has fallen behind (integrations/esp-idf/libtracer/include/libtracer_esp/httpd_ws_link.hpp:httpd_ws_link_t::stats_t). Read the two counters as a pair. Waits without misses mean the bound is doing its job; misses mean the pool is too small or the drain has stalled.The seam budgets are not the bound; the heap and the task stacks are. The flat seam never went past 4.2 % of a 24 KiB reservation. Applying §5 literally (
capacity = peak + margin) would size it near 2 KiB and give about 22 KiB back to a heap whose whole idle surplus is about 80 KiB. On this class of node a generous seam is not free. It is the largest single thing competing with the flow it protects. Budget the stacks beside it. The tightest platform task stacks idled at 0.8–2.3 KiB free, so stacks, not seams, are what a careless slab widening actually eats. The link reports its own static cost ashttpd_ws_link_t::buffer_bytes(): RX scratch, plus (tx_slot_capacity()+tx_reply_reserve()) ×tx_inline_bytes(), plus any large class.Budget the transient heap, not the idle heap. Steady-state RAM was never the constraint. The constraint was a working-set dip of about 56 KiB under saturating ingest, against about 80 KiB free. A deployment sized from idle free heap passes every static audit and still fails under its own designed load. Under §5, the run that sets
capacitymust be the saturating one, and the heap’s low-water mark goes in the same snapshot pair as the seam counters.
For the same budget, an earlier pass on this target measured about 700 B per WebSocket session on the server task, plus about 465 B of TCP/IP socket state, and about 430 B per subscription edge.
3.2 Hosted gateway / forwarder¶
Composition: per-thread on a multi-RX host — the only point that survives a fan-out. Size
rx,flatandegressfor peer fan-in, not for the local application.The forward hop is allocation-free in steady state (ADR-0039’s
bench_forward_heap == 0), so pressure genuinely lives at the edges — RX admission and TX egress. A forwarder that appears to be under memory pressure in the middle is a forwarder with a mis-sized edge, not a slow router.Naming the seams:
label_source(),rx_source(),flatten_backend()andegress_backend()(core/include/libtracer/fwd_router.hpp:fwd_router_t::label_source) reach the four injected objects even when the host took the defaults — which is what makes the §5 loop runnable on a gateway at all.Watch the silent degrade: label exhaustion.
labels_used(link)against the wire constant 65535, pluslabels_exhausted(); andlabel_not_found()on the receiving side. Nothing is lost when a label table fills — the flow reverts to full-routeFWD{WRITE}frames, which is correct and larger. It shows up as bandwidth, not as errors, and it is the degrade behind the #1491/#1502 reply-cost story.Designated pressure point: the egress TX pool of the busiest link.
rxis sized soarena_droppedstays zero — andarena_droppedis deliberately split frommalformed_rxso a peer sending garbage never reads as a slab that is too small.
3.3 Bulk-ingest producer → node¶
The #1491/#1494 topology: one producer streaming into one node as fast as the node will take it.
Write with an empty
src. RFC-0004 Amendment 2 makes a zero-lengthsrcmean no reply requested: the receiver executes the write and emits nothing back (RFC-0004 §Amendment 2). This is the whole point of the archetype — the measurement that forced the amendment found throughput peaking at pipeline depth 4 and degrading beyond it, depth 4 being the async TX pool depth the replies travelled through. The writes were not what saturated.Pipeline depth therefore derives from the receiver’s
tx_slot_capacity()only for the flows that still request replies; the unacknowledged stream is off the reply queue entirely.Loss detection is the application’s, because refusal-by-value has been given up deliberately: a sequence counter in the payload, plus §6’s recipes for the pressure numbers the replies would have carried.
Designated pressure point: the receiver’s ring, arm chosen per flow. Note §2 property 2 — a remote producer against a reliable ring gets no stall, so the honest pairing here is best-effort plus sequence-gap accounting, or a reply-requesting control flow alongside.
3.4 Mixed control + stream on one link¶
A streamer must not be able to silence the control plane — a producer that consumes every TX slot would otherwise starve the replies that carry every control answer (the #1494 severity case).
On the ESP httpd link the reply leg does not compete for the pool at all (#1494): a reply serviced in-call, on the httpd task, is written straight to the socket and claims neither a TX slot nor a control-queue message (
integrations/esp-idf/libtracer/httpd_ws_link.cpp:httpd_ws_link_t::send_in_call). So a fan-out sweep of any width, a full pool and a full control mbox all cost throughput and never the answer.Behind that,
tx_reply_reserve()slots are still held back pasttx_slot_capacity()(#1218), so a fan-out sweep cannot reach into them however wide it is, and a publish sweep that exceeds the send bound refuses by value rather than borrowing. The reserve now backs the replies a link cannot service in-call rather than the ordinary request/response answer.Designated pressure point: the stream’s own ring (best-effort), upstream of the shared link. Pushing the stream’s pressure point onto the link is exactly the mistake the reserve exists to survive; a deployment that relies on the reserve rather than on its own ring bound has chosen its bottleneck by accident.
Run the two flows on different vertices with different sources. Per-injection-point, never a shared pool: one flow running its source dry must not affect another (
core/include/libtracer/graph.hpp:vertex_policy_t::ring_source).
3.5 High-rate acquisition — Recipe C: compose → swap → push¶
The archetype where per-sample framing is the bottleneck: an ADC sweep, a PWM timeline, a fixed-rate capture. A TLV header plus a routing walk per sample is an order of magnitude of overhead at 1 Msps, so the samples are batched — and batching in libtracer is user-orchestrated (RFC-0025 §4.1.3, Amendment 4). There is no batch role, no flush counter, no window and no injected clock. A batch is a value the application composed, and the graph never learns that it is one.
Three steps, and only the first is batch-specific:
COMPOSE. Rope your sample values into one batch value:
// The app already holds its samples as views over its own acquisition buffers. tr::view::rope_t batch = tr::wire::compose_batch( my_source, // where the ONE head segment comes from tr::wire::batch_carriage_t::STANDALONE, // or ::FOLDED — see below base_ns, // YOUR clock: sample time of frame 0 samples, // std::span<const view_t> — referenced, not copied offsets_ns); // empty on a uniform stream (0 B/sample)
One small owned segment is allocated — the record header, the
TIME{u64}base child and, on a non-uniform stream, the packedi32offset run — and the sample bytes are roped on behind it as refcounted links. Measured: constant ~196 B per composition from 64 B/sample to 64 KiB/sample, against 459 KB for the copying spelling at the top of that ladder (core/tests/batch_compose_alloc_test.cpp). The payload never reaches an allocator.SWAP.
graph_t::assign/writethe composed value to the vertex. This is the ordinary atomic LKV publish — there is no batch-specific write path and no new op.PUSH.
graph_t::propagate(v), orpropagate(v, emission_mode_t::FOLD)to emit one branch-write frame for a swept subtree instead of oneFWD{WRITE}per vertex.
Where the count and the window live: on your side. batch_count and batch_window_ns are
retired as graph and wire duties. You already hold the sample count you composed and you own
the clock you stamped with, so “flush every N samples” and “flush every T” are if statements in
your acquisition loop — the only place they can be honoured without a graph-plane timer, which
this project does not have and will not add.
Which role, and which carriage. Both are the application’s choice, and they pair:
you want the vertex to hold… |
role |
carriage |
|---|---|---|
the newest window of signal |
|
either; |
the last k windows |
|
|
The two carriages are the same bytes with a different header type (0x80 for a standalone
delivery, VALUE for a branch-write node’s single value), and compose_batch produces either
from one layout. A STREAM vertex is refused by propagate(v, FOLD), permanently: what it
refuses is a per-sample ring, which has no single foldable value. Batching for a fold means
composing onto the vertex the sweep visits — which is step 1 above.
Designated pressure point: unchanged, and still the receiving vertex’s ring, arm chosen per flow. Batching does not move it; it changes how many entries a given rate costs — one ring entry per batch rather than per sample, which is the whole point. Size that ring against the batch size, not the sample size, and remember §7’s rule that the producer never queues for a slow consumer.
Sizing the batch is your byte budget against the receiver’s ring bound: one batch must fit, because a batch is one value.
The block sources a node wires compose in one of three ways. Folded: one source for every seam, one cap, the tightest RAM, contention-free only because the target has one thread. Per-plane: one source for the graph plane and one for the net plane, which buys blast-radius isolation (a peer-provoked flood is fenced in the net-plane store) but not scaling, since it collapses like folded under fan-out. Per-thread: one source per receive thread, the only composition that survives fan-out. Composition decides which seams share a cap; the store’s size is what bounds the node.
4. Do you need the last-known-value?¶
Retention is chosen per vertex the same way the pressure arm is chosen per flow, and it is a
sizing decision: a retaining vertex pays a make_shared plus a publish plus the heap value on
every write. The per-vertex retention switch is the role system — a vertex whose flows
need none of the five planes below should be a HANDLER-role vertex (callbacks, nothing stored).
The model already sanctions this: the role is invisible and the schema is the contract, so
“read returns the last write” is role 1’s contract only
(11-vertex-roles-and-aggregation.md §The contract between
role and schema).
Exactly five planes consume the last-known-value:
Plane |
Needs the LKV because |
|---|---|
Local + remote |
a leaf read serves the stored pointer ( |
|
the wake rides the write sequence and the stripe condvar (retention-free), but the value handed back is served through the same role dispatch |
|
the hard dependency — RFC-0008 §C: |
Composed subtree reads |
RFC-0016 serves landed LKVs only, one atomic load per node ( |
Late-joiner replay |
the durability latch snapshots the LKV at edge-add (RFC-0022 §3.A bit 5, |
Not on the list: the whole callback / delivery plane. Fan-out never reads the slot. A
storing role delivers the just-published pointer (core/src/graph.cpp:graph_t::write_impl); a HANDLER delivers
from the incoming value (core/src/graph.cpp:graph_t::handler_write_deliver). If subscribers are all a vertex has, it does
not need to retain.
What shipped in RFC-0008 Amendment 2¶
The table above reflects shipped behaviour as of RFC-0008 Amendment 2, not the state that preceded it:
awaitat a HANDLER vertex answers theon_read-composed value, notNOT_FOUND. Wire visible:FWD{AWAIT}at a handler terminus went fromERROR{tr::path::not_found}toRESULTplus theVALUE. The degradation that remains is the read contract’s — a handler with noon_readstill answersNOT_FOUND, exactly asreaddoes.assignandpropagaterefuse a non-retaining vertex withSCHEMA_NOT_FOUND(core/src/graph.cpp:if (!retains(v, role)) return,core/src/graph.cpp:graph_t::propagate(vertex_handle_t v)) — the taxonomy’s contract-mismatch status, deliberately notBACKPRESSURE: nothing is under pressure and a retry will never succeed. At a handler vertex the call iswrite, which dispatches the seam and delivers eagerly; the accumulate-then-flush pair needs retention.propagate(v)is[[nodiscard]] result_t<void>, so that refusal cannot be dropped on the floor.
The three stances this table takes¶
Composed branch reads stay landed-only. A subtree read folds the descendants’ landed LKVs and never invokes a descendant handler mid-walk (RFC-0016 stands). A non-retaining child therefore contributes nothing to its parent’s composed read — that is specified, not a defect.
The durability latch stays LKV-only. No
on_readsynthesis at subscribe time; “null ⇒ no latch” is the specified degradation (ADR-0049 §the one admission door, as amended by RFC-0022 §3.A).The read contract is advertised as a
:schemaprose convention, not a wire surface. A peer that needs to know whether a vertex retains reads the schema; there is no protocol field to interrogate, and none is planned.
Two honest caveats¶
The inline slot bytes are not reclaimed by opting out. Every
vertex_tpays the inline value slot (16 B on x86-64, 8 B on rv32, at offset 0) regardless of role — pinned by the #1285 cache-line gate. Choosing HANDLER saves the per-writemake_sharedplus publish and the heap value; it does not shrink the vertex. Per-vertex layout reclaim is deliberately not offered (#1487 marks the slot do-not-touch).HANDLER is the CHEAPER role, at every fan-out and every value shape. This caveat used to say the opposite — avoid HANDLER for throughput on a heavily-subscribed vertex — and both halves of that were wrong: wrong axis (the cost it named was a per-write term, flat in fan-out) and, below the rope’s inline link capacity, wrong direction. #1505 measured it with #1516’s
bench_source_role/bench_source_role_allocand then removed the cause.The handler leg used to take a nothrow rope clone before storing, because it publishes no LKV and so has no stored pointer to deliver. It does not now: the write builds one value on its own frame from the writer’s moved links, hands it to
on_writeby reference and delivers the same value (core/src/graph.cpp:graph_t::handler_write_deliver). Measured x86-64-O3, p50 ns per write:links
fan-out
STORED_VALUEHANDLER1
0
80
60
1
4
120
100
4
0
130
110
4
4
170
150
and one allocation per write fewer than the retaining roles at every link count — the LKV publish it skips, with nothing paid back for it. Before the fix, the clone gave that allocation back once the value spilled past the inline capacity (measured knee: between 2 and 3 links) and made HANDLER ~40 ns/write more expensive there.
handler_write_alloc_testpins the one-block difference at every rung so the inversion cannot return unnoticed.So the sizing rule is simply: choosing HANDLER never costs throughput. Choose it for the retention semantics and take the write-path allocation it removes as a bonus. Neither subscriber count nor value link count is an argument against it.
Also worth knowing before switching a vertex to HANDLER: the announce-write convention used to
instruct “assign and propagate”. For the handler case it now names write
(02-graph-model.md).
5. The measure-then-size loop¶
This replaces guess-and-rerun bisection. It is a host-side procedure today — a bench or staging run with the C++ accessors in reach (see §6 for the remote arm and its gate).
Wire generous bounds. Heap defaults are fine for the first pass; the point is to observe the traffic, not to survive it.
Run representative traffic. Representative means the real payload-size distribution, not the mean — see step 4.
Read
peakper seam.block_source_t::stats()(core/include/libtracer/mem_source.hpp:block_source_t::stats) returnssource_stats_t{capacity, in_use, peak, refused, largest_refused}(core/include/libtracer/mem_source.hpp:source_stats_t), all used-polarity. Free is derived, never reported as the primary.Set
capacity = peak + margin, and take the margin fromlargest_refused, not from the mean. The tail is what refuses. A seam that refused once, for a 9 KB request, against a steady-statepeakof 3 KB needs headroom for the 9 KB — quoting a median request size is measuring the wrong distribution (#1492). On peer-driven seams (rx,flat,egress) the tail is chosen by the peer, so this is the only figure that generalises.Re-run with the bounds armed and assert
refused == 0on every stage except the designated pressure point. For a forwarder,router_stats_t::arena_dropped(core/include/libtracer/fwd_router.hpp:fwd_router_t::drop_stats) is the one to assert zero against when sizingrx. A non-zerorefusedanywhere else means the bottleneck is still being discovered rather than chosen.
Read the counters through the snapshot-coherence clause — core/STYLE.md §Introspection.
Cite it; do not re-derive it. Its consequence for this loop is the operative one: the intended
reading is the difference between two snapshots, never an instant, so step 3 samples at the
start and the end of the run and step 5 asserts on a delta.
Five traps this loop has to name, because each one has produced a wrong size:
Zero means “this seam counts nothing”, not “nothing happened.”
stats()is an optional virtual whose default is all-zero — the honest answer for a source that counts nothing, and the deliberate #932 contract.heap_source_tandnull_source_treport all-zero by design. Before reading a zero as a result, confirm the seam is one that answers.pool_source_t’sin_useis bytes carved, and carving is monotonic — sopeak == in_usefor it, always. That is not a bug and not a leak; it is what “carve” means.bump_source_t’s buffer is its ceiling, upstream spill is deliberately outside all three occupancy fields, and itspeaksurvives areset()— which is exactly what makes a per-iteration bump arena sizeable across a whole run.Never conflate
overflowed()withrefused.pool_source_t::overflowed()counts a recycling degrade — a freed block whose size class did not fit the injected class table, so the block stays carved and nobody was refused. They are independent in both directions and they point at two different knobs: the size-class span versus the slab (core/STYLE.md§Introspection, “names this vocabulary deliberately does not unify”).Quote the ceiling beside every drop (#1160).
capacityin the snapshot is the effective ceiling — the injected slab, not a compile-time constant — which is the only number a sizing decision can be made against.
Beyond the block sources, the same loop reads: synchronized_pool_t’s in_use() / available()
beside capacity() for the shared-pool archetype; route_handle_t::labels_used(link) against the
wire constant 65535 for a forwarder’s label space; transport_t::drop_stats() for every link kind
(both ESP WebSocket links answer it, so a kind-agnostic supervisor no longer reads zero from a
link that was counting); and graph_t::delivery_drops() for the fan-out plane.
When the designated pressure point is remote and unacknowledged — the §3.3 empty-src ingest —
there is no refusal reply to feed step 5. Recipe A’s polled peak / refused replaces that
feedback, and the reply/monitoring cadence must itself be part of the sized load.
6. Monitoring when refusal-by-value is out of reach¶
Two recipes for the case §3.3 creates deliberately: a producer that has given up its replies, and therefore its share of the §5 feedback.
Recipe A — poll-based pressure monitoring¶
The shape. A supervisor issues periodic ordinary READs of a seam’s counter block and reads it
as one snapshot: in_use and peak against capacity, plus refused and dropped watched for
movement (§5’s difference-between-snapshots discipline, not the instant).
In-process supervisors can do this today, against the shipped C++ accessors: a supervisor task
holding the graph_t, the fwd_router_t and the links calls stats(), drop_stats() and
labels_used() on its own cadence and applies §5’s readings. This is the arm to build now.
The remote arm — shipped. The same monitoring is reachable over the protocol: a supervisor
elsewhere on the graph issues an ordinary READ of a node’s reserved :stats field and gets the
seam’s whole counter block back as a single snapshot-coherent TLV — one read, one consistent
block, rather than a field at a time. The spelling is
RFC-0010
§Amendment 1’s:
read <any-vertex>:stats.mem.control ; capacity / in_use / peak / refused / largest_refused
read <any-vertex>:stats.mem.ring ; the same five, for the default receiver-ring source
read <any-vertex>:stats.graph.delivery ; no_target / denied / out_of_memory / fan_out_truncated
read <any-vertex>:stats.router.drops ; the forwarder's seven per-cause cold-path drops
read <any-vertex>:stats.labels.table ; labels_exhausted / refused_bindings / label_not_found / label_resolves
read <any-vertex>:stats.link.<child> ; dropped_rx / malformed_rx / dropped_tx (+ labels_used)
The field is node-scoped — it takes no vertex, and every vertex of the node answers
identically — so a monitor addresses whichever vertex suits it. It is READ-gated (unlike
:identity, which is pre-auth): a supervisor needs READ on the vertex it addresses. It is
read-only and never awaitable, and the whole block is sampled in the single call that answers
the read, which is what makes §5’s difference-between-snapshots reading valid.
The net-plane seams too, and how they get there. The router, label-table and per-link
counters are per fwd_router_t / route_handle_t / link rather than per graph, and L4 still does
not reach DOWN into the net plane to sample them. The wiring points the other way
(RFC-0010
§Amendment 2): the router registers a sampler UP into the graph in its constructor, beside the five
{fn, ctx} seams it already installs there. A node with no router publishes none of these — every
router / labels / link spelling answers SCHEMA_NOT_FOUND, and so does a link sub-key
naming an unregistered or removed child. Two nouns stay in-process on purpose: a link’s
rx_capacity / tx_capacity are per-kind and in per-kind units (buffer bytes on a WebSocket link,
TX-pool slots on CAN), so they remain the concrete accessors
(esp_ws_client_link_t::rx_capacity()) rather than a transport_t seam that would compare two
different units under one name.
Three properties hold by construction:
Isolation is by construction, not by privilege. The read targets whatever vertex the peer already addressed and is bounded by that vertex’s existing list, and pressure in this model is designated per vertex — so a flooded STREAM vertex cannot make a monitoring read of a quiet vertex queue behind it. There is no privileged control arm and none is needed; the reply reserve of §3.4 already protects the reply leg.
A timeout is the signal, not a blind spot. The monitoring READ travels the same fabric it measures. If a saturated shared link delays it past its deadline, that is the hard congestion signal — a fail-deadly health check. The link-seam counters distinguish link saturation from vertex pressure after the fact.
Poll-only. Introspection fields are readable and never awaitable: counters do not bump the write sequence, so nothing wakes on them. A monitor polls; it does not subscribe. An
awaitcarrying a:statsselector answersERROR{tr::schema::not_found}, as every field-tailedawaitdoes.
Recipe B — probe-vertex timing¶
An application convention, and it needs nothing from the library at all.
libtracer stays clock-free — timing is an application convention, never a library service. So measure it as one:
Wire a dedicated probe vertex into the flow being characterised.
The producer writes a
{sequence, local timestamp}payload at a known cadence.The consumer reads or receives it; the timestamp delta is the end-to-end propagation figure, and sequence gaps or duplicates exercise the dedup window. One probe, both measurements.
The probe must ride the same arm, the same link and the same source as the data it characterises — then its numbers are representative by construction. A probe on a reliable arm tells you nothing about a best-effort stream’s shed behaviour, and a probe on its own private slab tells you nothing about the slab the data contends for.
Pair the two: Recipe B tells you what the flow experienced; Recipe A tells you which seam produced it.
7. What must never be the fix¶
The standing rulings this guide must not be read as licence to violate.
No synthetic limits. Every limit is a real injected resource or per-target / per-connection configuration — never a hardcoded magic constant (CONTEXT.md §Resource bound). “Cap it at 32” is not a fix; “size the slab it draws from” is.
No library-chosen capacities. Policy stays with the deployer, and no composition is the default — every injection seam defaults to the platform heap so an unwired build is all-heap and behaves identically (ADR-0079). A “sensible default bound” added inside the library is a bottleneck nobody chose.
The producer never queues for a slow consumer; the receiver pays. Depth belongs to the vertex that wants it — whoever wants a queue makes their own vertex a STREAM and charges its entries, in bytes, to the source they injected. A producer-side queue is a fix that relocates the problem onto the one party that cannot size it.
Counted, never enforced. Nothing in the library reads its own counters. Alarming, throttling and shedding policy on top of these numbers is the deployment’s choice, and adding an enforcement branch inside the library would put policy back where ADR-0079 removed it.
Per-seam, never aggregated. There is no node-wide census object to fold into; a shared one was measured and it causes a cacheline storm (ADR-0067).
Wire-grammar constants are not tunables. NAME ≤ 64 B, PATH body ≤ 1024 B, segment count ≤ 255, field depth ≤ 8, the 65535 label space: these are identical on every peer and making them per-target is “an interoperability failure dressed as a RAM saving”. They are not part of any sizing loop.
Counters are free on the success path. A counter is bumped on the refusal / exhaustion / drop arm and nowhere else. If observing a stage would cost the hot path an instruction, the observation is designed differently —
bench_forward_heap == 0and the rv32 text figure are the standing referees, measured per PR (core/STYLE.md§Introspection, the counting doctrine).
A knob whose overflow changes only cost, such as the rope’s inline link count or the walk stack’s inline slots, is an optimization, not a limit. A transport’s maximum frame size is per-connection configuration.
Where to go next¶
../design/allocation-and-backpressure.md— the reference implementation’s four injected seams, their exact failure semantics, and the one site that still reports exhaustion by throwing.09-memory-substrate.md — the implementation-independent statement that a receiver’s bounds are injected resources and exhaustion is answered by value.
11-vertex-roles-and-aggregation.md — the role/schema contract §4’s decision table rests on.
15-concurrency-and-scaling.md — when adding threads helps, and why an owning read is a write.
12-deployment-profiles.md — the deployment-rung spectrum the §3 archetypes are drawn from.