Skip to main content

decdn_cache/
metrics.rs

1//! Cache-engine `OpenMetrics` metrics.
2//!
3//! Counters and gauges exposed under the `decdn_cache_*` family per ADR
4//! `appendix-observability`. Two naming rules apply, and they differ by type:
5//!
6//! - **Counters** are exported with an `_total` suffix the encoder appends,
7//!   even though the Rust struct fields here do not carry it — that's
8//!   `iroh-metrics 1.0.1 encoding.rs:109` writing `_total` for every `Counter`.
9//!   So the field `evictions` exports as `decdn_cache_evictions_total`.
10//! - **Gauges** (`bytes`, `size_limit_bytes`, `pinned_count`, added with the
11//!   #1173 eviction driver) get **no** suffix: the field `bytes` exports as
12//!   `decdn_cache_bytes`.
13//!
14//! Never spell `_total` in a counter field name — it would export as
15//! `..._total_total`.
16//!
17//! The `PromQL` fragments below use the exported names so operators can paste
18//! them verbatim into a Prometheus query.
19//!
20//! Operators reason about cache health from four ratios:
21//!
22//! - **Hit rate** — `decdn_cache_hits_total / (decdn_cache_hits_total +
23//!   decdn_cache_misses_total)`. **The two terms do not share a scope.**
24//!   `hits` is bumped only inside `CacheEngine::get`, which the paid serve
25//!   path never calls — it streams through `export_bao_range_stream`.
26//!   `misses` is also bumped by `populate`/`populate_local` and
27//!   `pull_through_range`, which the serve-path fill tiers do call. So on a
28//!   node serving paying clients the numerator is pinned at zero while the
29//!   denominator climbs with real traffic, and the ratio reads a populated,
30//!   permanent 0% — not an empty series. Read the node crate's
31//!   `decdn_serve_cache_{hit,partial_hit,miss}_total` for the serve-path hit
32//!   rate.
33//! - **Origin egress amplification** —
34//!   `decdn_cache_pull_through_bytes_total / decdn_bytes_served_total`.
35//!   Approaches — and on a pure pass-through stays just under — 1.0; trends
36//!   toward 0 as cached content gets re-served. The denominator is the node
37//!   crate's wire-write counter rather than `decdn_cache_bytes_returned_total`,
38//!   which is `get`-scoped and holds at zero under a full serve load. The two
39//!   terms count different byte populations, which is why the pass-through
40//!   ceiling sits below 1.0 rather than on it: `pull_through_bytes` is raw
41//!   origin content, while `bytes_served` is the bao wire form — content plus
42//!   interleaved proof nodes (≈0.4%), over a range widened to enclosing
43//!   16 KiB chunk-group boundaries. Read the ratio as a trend, not against an
44//!   exact 1.0.
45//! - **Origin retry health** —
46//!   `decdn_cache_origin_retry_exhausted_total / decdn_cache_origin_fetches_total`.
47//!   Sustained nonzero rate = user-visible origin failures the retry
48//!   budget couldn't save (#285).
49//! - **GC reclaim ratio** —
50//!   `rate(decdn_cache_gc_bytes_reclaimed_total) /
51//!   rate(decdn_cache_pull_through_bytes_total)` over a recent
52//!   observation window. `pull_through_bytes` counts every origin byte
53//!   we receive, success or failure (#418), so this ratio approaches
54//!   `1.0` in the hostile-origin amplification scenario where almost
55//!   everything we pull is later reclaimed by GC. Healthy nodes sit
56//!   near `0.0`. Use a window at least `2 * cache.gc_interval_sec`
57//!   wide: byte attribution lags one sweep cycle (see
58//!   `gc_bytes_reclaimed`).
59//!
60//! Hit/miss accounting (#418): on `Ok` and on the cache-domain error
61//! returns (`NoOrigin`, evicted `NotFound`, origin `NotFound`,
62//! `HashMismatch`, `BlobTooLarge`, `OriginError`), `CacheEngine::get`
63//! bumps exactly one of `hits` or `misses`.
64//!
65//! Store I/O error carve-out (`CacheError::Store`): these surface via
66//! `tracing::error!` and split by phase.
67//!
68//! - **Local-lookup phase** (`has`, `read_local` before pull-through is
69//!   initiated): a Store error here means the cache can't *determine*
70//!   whether the blob is present, so neither counter is bumped. A
71//!   degraded local store does not silently classify reads as misses.
72//! - **Pull-through phase** (after the miss is already determined —
73//!   e.g., `add_bytes` fails to persist verified origin bytes): the
74//!   `misses` and `pull_through_bytes` bumps that fired earlier in
75//!   `pull_through` stay bumped. The miss genuinely happened, the
76//!   origin egress was genuinely paid, and the failure surfaces both
77//!   via `tracing::error!` and as the get-call's `Err`.
78//!
79//! The cache crate owns this group rather than re-exporting node-side
80//! state so the cache stays self-describing. Node wires `Arc<CacheMetrics>`
81//! into its registry under the `decdn_cache` prefix.
82
83use iroh_metrics::{Counter, Gauge, MetricsGroup};
84use serde::{Deserialize, Serialize};
85
86/// `OpenMetrics` counters and gauges for the cache engine. Register via
87/// `Registry::register(Arc::clone(&cache_metrics) as Arc<dyn MetricsGroup>)`
88/// (or under a sub-registry prefix); the same `Arc` is handed to
89/// [`crate::CacheEngine::open_full`] so engine-side bumps land in the
90/// same encoder output.
91#[derive(Debug, Default, Serialize, Deserialize, MetricsGroup)]
92#[metrics(name = "cache")]
93pub struct CacheMetrics {
94    /// Origin pull-through fetches attempted (one per `pull_through`
95    /// call, success or failure). Denominator for any retry-exhaustion
96    /// alert.
97    pub origin_fetches: Counter,
98    /// Serve-miss fill claims NOT coalesced onto a live same-hash fill whose
99    /// covered range overlaps the request, because the request starts ahead of
100    /// that fill's paid frontier (#2062): attaching would park the request on a
101    /// pull that advances only as the other client pays. Counted once per claim.
102    /// The refused attach opens its own pull, and the part of the overlap not
103    /// yet in the store when that pull starts is fetched twice — duplicate
104    /// egress (own-origin S3, or upstream USDC on the peer tier) in exchange for
105    /// liveness. An overlap already in the store is never fetched again.
106    /// Emitted name: `decdn_cache_fill_not_coalesced_total`.
107    /// Operator-actionable: a sustained rate means clients habitually resume
108    /// ahead of what payers have cleared.
109    pub fill_not_coalesced: Counter,
110    /// Origin fetches that gave up after exhausting `max_retries`
111    /// (#285). Operator-actionable: any nonzero rate = user-visible
112    /// origin failures the retry budget couldn't save.
113    pub origin_retry_exhausted: Counter,
114    /// Times `pull_through` advanced from one origin to the next in
115    /// the operator-configured fallback chain (#284). One bump per
116    /// chain-walk step, *not* per per-origin retry — the latter is
117    /// covered by `origin_retry_exhausted`. Bumped only when there is
118    /// a next entry to try, so a single-origin chain (the common case)
119    /// keeps this counter flat at zero. The denominator
120    /// for a "what fraction of misses needed fallback" alert is
121    /// `decdn_cache_origin_fetches_total`. Sustained nonzero against
122    /// a healthy primary backend suggests the chain is masking an
123    /// outage that should be paged on instead.
124    pub origin_fallback: Counter,
125    /// `get()` calls served from the local store (#418). Includes both
126    /// first-attempt hits and waiter retries that find the blob present
127    /// after a coalesced concurrent pull-through completes (the
128    /// engine's coalescing is between in-process tasks fronting the
129    /// same hash, not between network peers). Paired with `misses`;
130    /// see the module-level doc for the hits-XOR-misses contract and
131    /// the store-error carve-out.
132    pub hits: Counter,
133    /// `get()` calls that did not find the blob locally (#418). Bumped
134    /// once on every operator-evicted `NotFound` return and once at
135    /// the entry of every pull-through — the latter covers all
136    /// pull-through outcomes (`Ok` and any later error, including a
137    /// `Store` error from `add_bytes`) without enumerating each
138    /// variant. See the module-level doc for the contract with `hits`
139    /// and the per-phase store-error carve-out.
140    pub misses: Counter,
141    /// Total bytes returned to the caller of `get()` on success (#418).
142    /// Counts both cache-hit and pull-through-success paths.
143    pub bytes_returned: Counter,
144    /// Bytes fetched from origin during pull-through (#418). Counted
145    /// the moment bytes are received from origin, regardless of whether
146    /// they pass BLAKE3 verification or land in the store — origin
147    /// egress is paid either way. On the range-pull path this includes each
148    /// `{H}.obao4` outboard the origin serves, wrong-length copies too; a
149    /// cached outboard is not re-counted. Distinguishes 'serving from cache'
150    /// vs. 'paying origin egress' when paired with `bytes_returned`.
151    pub pull_through_bytes: Counter,
152    /// iroh-blobs GC sweep cycles observed (#518). Bumped once per
153    /// `add_protected` callback fire, after the pre-sweep snapshot
154    /// succeeds. Iroh-blobs spawns the sweep loop internally when
155    /// `cache.gc_interval_sec > 0`; the cb is our hook into each cycle.
156    /// On-demand reclamation is tracked under #520.
157    ///
158    /// Operator-actionable: a flat-line at zero against a nonzero
159    /// `cache.gc_interval_sec` means the periodic loop never spawned,
160    /// or the snapshot has been failing on every cycle (the engine
161    /// emits a `tracing::warn!` when that happens).
162    ///
163    /// Field name omits `_total`: the `OpenMetrics` encoder appends it
164    /// automatically (`iroh-metrics` 1.0.1 `encoding.rs:109`), so the
165    /// emitted name is `decdn_cache_gc_runs_total`.
166    pub gc_runs: Counter,
167    /// Bytes reclaimed by the iroh-blobs GC, attributed across cycles
168    /// (#518). Computed as the pre-sweep blob-set diff between the
169    /// previous cycle and the current cycle: hashes that vanish across
170    /// that window are exactly what the previous sweep deleted (the
171    /// cache crate has no other public delete path). Sustained nonzero
172    /// rate against zero `rate(pull_through_bytes)` is a red flag —
173    /// either the pull path is failing to promote named tags on
174    /// success, or a hostile origin is amplifying disk usage via
175    /// repeated mid-stream errors.
176    ///
177    /// **Attribution lags one sweep cycle.** The first cycle records a
178    /// baseline and bumps zero bytes; from cycle two onward the bump
179    /// reflects the previous sweep's reclaim. For an alert window
180    /// shorter than `2 * cache.gc_interval_sec` the reading will be
181    /// noisy.
182    ///
183    /// Field name omits `_total`: the `OpenMetrics` encoder appends it
184    /// automatically, so the emitted name is
185    /// `decdn_cache_gc_bytes_reclaimed_total`.
186    pub gc_bytes_reclaimed: Counter,
187    /// Best-effort named-tag deletions that failed (#860/#837). Bumped when
188    /// `evict()` (DMCA/corruption takedown) or the drain-path hash-mismatch
189    /// arm cannot delete a blob's protecting named tag. Serving is unaffected
190    /// — an evict's logical-evicted set still blocks `get`/`has`, and a
191    /// mismatch still returns `HashMismatch` — but the bytes stay GC-protected
192    /// on disk because the tag survives, and nothing re-attempts the delete.
193    ///
194    /// Operator-actionable: any sustained nonzero rate means disk reclaim is
195    /// stuck. For an `evict()` failure that is a DMCA/compliance concern (the
196    /// takedown stopped serving but did not reclaim the bytes); for the drain
197    /// path it is the #837 unbounded-disk-growth leak under a hostile origin.
198    /// A persistently nonzero rate warrants investigating the iroh-blobs tag
199    /// store (a wedged store actor, I/O errors); the engine also emits a
200    /// `tracing::warn!` per failure.
201    ///
202    /// Field name omits `_total`: the `OpenMetrics` encoder appends it
203    /// automatically, so the emitted name is
204    /// `decdn_cache_tag_drop_failures_total`.
205    pub tag_drop_failures: Counter,
206
207    /// Origin size probes that faulted during an origin rescan — a transport
208    /// error from one or more configured origins with none of them confirming
209    /// the object, or the probe walk overrunning its ceiling — rather than
210    /// answering "held" or "not held".
211    ///
212    /// Operator-actionable: a rescan resolves each candidate against the origin
213    /// to decide what this node advertises, so a throttled `HeadObject` window
214    /// during a rescan would otherwise shrink the announce set silently. A
215    /// retry-eligible fault keeps whatever entry an earlier pass indexed, so a
216    /// sustained nonzero rate means the node may still be advertising content
217    /// the origin has since stopped holding — every request for it is then a
218    /// refusal. A candidate first seen inside the fault window has no earlier
219    /// entry and is simply absent until a later rescan resolves it. Correlate
220    /// with the origin backend's own error rate.
221    ///
222    /// Field name omits `_total`: the `OpenMetrics` encoder appends it
223    /// automatically, so the emitted name is
224    /// `decdn_cache_origin_probe_failures_total`.
225    pub origin_probe_failures: Counter,
226
227    /// Origins whose `enumerate` failed during a rescan, counted once per origin
228    /// per rescan.
229    ///
230    /// The sibling of `origin_probe_failures` for the other leg of the same
231    /// rescan, and the more severe of the two: a failed listing contributes no
232    /// candidates at all, so every hash discoverable only through that origin
233    /// leaves the announce set with no per-hash fault to carry a previous entry
234    /// forward. Only the operator pins naming those hashes survive. Operator-
235    /// actionable in the same way — correlate with the origin backend's error
236    /// rate; a later successful rescan restores the listing.
237    ///
238    /// Field name omits `_total`: the `OpenMetrics` encoder appends it
239    /// automatically, so the emitted name is
240    /// `decdn_cache_origin_enumerate_failures_total`.
241    pub origin_enumerate_failures: Counter,
242    /// Per-origin circuit-breaker trips from CLOSED/HALF-OPEN to OPEN
243    /// (#963). Bumped once each time a breaker opens — on crossing the
244    /// `failure_threshold` from CLOSED, or on a failed HALF-OPEN trial.
245    /// Sustained nonzero rate against a configured origin means that
246    /// origin is having a sustained outage and the cache is shedding the
247    /// retry-backoff storm by fast-failing its misses.
248    ///
249    /// Operator-actionable: a breaker that keeps opening points at an
250    /// unhealthy origin (or a backend the chain is masking). Pair with
251    /// `circuit_breaker_short_circuits` to see how much retry-backoff
252    /// work the breaker saved while open.
253    ///
254    /// Field name omits `_total`: the `OpenMetrics` encoder appends it
255    /// automatically, so the emitted name is
256    /// `decdn_cache_circuit_breaker_trips_total`.
257    pub circuit_breaker_trips: Counter,
258    /// Per-origin circuit-breaker recoveries from HALF-OPEN to CLOSED
259    /// (#963). Bumped once each time a half-open trial succeeds and the
260    /// breaker closes. A trip followed by a recovery is the healthy
261    /// outage→recovery cycle; trips without matching recoveries means an
262    /// origin that keeps failing its half-open probes.
263    ///
264    /// Field name omits `_total`: the emitted name is
265    /// `decdn_cache_circuit_breaker_recoveries_total`.
266    pub circuit_breaker_recoveries: Counter,
267    /// Cache misses fast-failed by an OPEN circuit-breaker before the
268    /// retry/backoff loop ran (#963). Each bump is one miss that would
269    /// otherwise have incurred up to the full
270    /// `max_retries`-worth of exponential backoff against a dead origin;
271    /// the breaker shed that cost. The whole point of the feature is to
272    /// drive this counter up during an outage so the retry-exhaustion
273    /// and origin-egress counters stay flat.
274    ///
275    /// Field name omits `_total`: the emitted name is
276    /// `decdn_cache_circuit_breaker_short_circuits_total`.
277    pub circuit_breaker_short_circuits: Counter,
278    // ---- Cache-health / eviction observability (#1173, ADR 040) ----
279    /// Current on-disk cache footprint in bytes (`decdn_cache_bytes`).
280    /// Set by the eviction driver each sweep from
281    /// [`crate::CacheEngine::total_bytes`]. Pairs with `size_limit_bytes`
282    /// for a saturation ratio; when it approaches `size_limit_bytes` the
283    /// cache is at its high-water mark and the driver is actively evicting.
284    pub bytes: Gauge,
285    /// Effective cache ceiling in bytes (`decdn_cache_size_limit_bytes`) — the
286    /// denominator of the saturation ratio against `bytes`. The eviction driver
287    /// sets it every tick: `cache.cache_size_mb × 1 048 576`, clamped down so the
288    /// cache never grows into the last `cache.disk_headroom_mb` of free disk on
289    /// the volume (#1930). It therefore moves with real free space, and reads
290    /// below the configured budget whenever the disk clamp is the binding
291    /// ceiling.
292    pub size_limit_bytes: Gauge,
293    /// Size of the operator-pinned set (`decdn_cache_pinned_count`). Set by
294    /// the eviction driver each sweep from
295    /// [`crate::CacheEngine::pinned_snapshot`]. Pinned hashes are LRU-exempt,
296    /// so a pinned set approaching the cache size is a starvation risk.
297    pub pinned_count: Gauge,
298    /// LRU eviction victims actually released under cache-size pressure
299    /// (`decdn_cache_evictions_total`). Bumped once per hash for which
300    /// [`crate::CacheEngine::release_for_eviction`] reported a real release
301    /// (`Ok(n > 0)`); a pinned refusal or an untagged blob returns `Ok(0)` and
302    /// is deliberately **not** counted, so this measures releases rather than
303    /// attempts. LRU pressure only — operator takedowns are counted separately
304    /// by `evicted_operator`.
305    ///
306    /// A released blob keeps serving until the iroh-blobs GC reclaims it, and a
307    /// serve re-inserts its access-time entry, so the same hash can legitimately
308    /// be re-selected and re-released across ticks.
309    ///
310    /// Field name omits `_total`: the `OpenMetrics` encoder appends it, so
311    /// the emitted name is `decdn_cache_evictions_total`.
312    pub evictions: Counter,
313    /// Bytes released by the eviction driver (`decdn_cache_evictions_bytes_total`),
314    /// summed from the per-hash sizes of successful releases. These are bytes
315    /// made *GC-eligible*, not yet reclaimed — pair with `bytes` to see reclaim
316    /// actually landing.
317    ///
318    /// Field name omits `_total`: the emitted name is
319    /// `decdn_cache_evictions_bytes_total`.
320    pub evictions_bytes: Counter,
321    /// Eviction passes that made no progress while still over
322    /// `eviction_target_pct` (`decdn_cache_evictions_starved_total`). Two
323    /// causes, both operator-actionable but with *different* remedies:
324    ///
325    /// - [`crate::CacheEngine::eviction_candidates`] returned empty. That set is
326    ///   filtered on pinned + probe-held, so the remedy is to raise
327    ///   `cache.cache_size_mb`, lower `max_probe_holds`, or trim the pinned set.
328    ///   The set is also short when the open-time store walk failed
329    ///   ([`Self::recency_seed_failures`]): blobs from before the restart are
330    ///   not candidates until accessed. There the remedy is to restart once the
331    ///   store is healthy.
332    /// - The pass had candidates but released none (all pinned, untagged, or
333    ///   erroring).
334    ///
335    /// A sustained nonzero rate with `bytes ≈ size_limit_bytes` is the alarm.
336    ///
337    /// Field name omits `_total`: the emitted name is
338    /// `decdn_cache_evictions_starved_total`.
339    pub evictions_starved: Counter,
340    /// Cache-size measurement failures in the eviction driver
341    /// (`decdn_cache_size_measure_failures_total`) — a failed `total_bytes()` or
342    /// `size_snapshot()` store walk. Operator-actionable: while nonzero, the
343    /// `bytes` gauge is **stale** (the driver returns before refreshing it) and
344    /// the ceiling is not being enforced, so a saturation dashboard reading off
345    /// `bytes` alone will look healthy while the cache grows.
346    ///
347    /// Field name omits `_total`: the emitted name is
348    /// `decdn_cache_size_measure_failures_total`.
349    pub size_measure_failures: Counter,
350    /// Failed store walks while seeding eviction recency at cache open
351    /// (`decdn_cache_recency_seed_failures_total`). Open still succeeds, but
352    /// until the next restart blobs from before the restart are not eviction
353    /// candidates until accessed. Over the size limit the driver then releases
354    /// only blobs accessed since open, or starves (`evictions_starved`) if
355    /// there are none. Nonzero is the alarm; restart once the store is healthy.
356    ///
357    /// Field name omits `_total`: the emitted name is
358    /// `decdn_cache_recency_seed_failures_total`.
359    pub recency_seed_failures: Counter,
360    /// Partial blobs whose `observe()` failed during a cache-size walk
361    /// (`decdn_cache_partial_size_observe_failures_total`). The walk counts
362    /// such a blob at its last known present bytes, else at `status()`'s size,
363    /// and keeps going, so `bytes` stays fresh but can be off for that blob.
364    /// The walk retries a failed blob once per partial-size TTL, so a store
365    /// entry that stays broken ticks this steadily. The `warn!` beside each
366    /// tick names the hash.
367    ///
368    /// Field name omits `_total`: the emitted name is
369    /// `decdn_cache_partial_size_observe_failures_total`.
370    pub partial_size_observe_failures: Counter,
371    /// Times the in-flight fill-coalescing mutex was found poisoned
372    /// (#1517). A poison means some earlier task panicked while holding
373    /// the map guard; the workspace anti-panic policy (`unwrap_used` /
374    /// `expect_used` / `panic` denied) makes that close to unreachable,
375    /// so **any** nonzero value is a genuine bug worth filing rather
376    /// than an operational condition worth tuning.
377    ///
378    /// The engine recovers the guard via `PoisonError::into_inner`, clears
379    /// the poison, and keeps coalescing — so this is a report, not a
380    /// degradation: it does not mean duplicate origin pulls happened, and
381    /// it does not call for a restart. It is metered because the
382    /// alternative — discarding the error — silently disables coalescing
383    /// for the life of the process, and the only observable is
384    /// `decdn_cache_pull_through_bytes_total` rising faster than request
385    /// volume.
386    ///
387    /// **Counts poisonings, not locks-since-a-poisoning.** Because the
388    /// engine clears the poison, one panic bumps this exactly once; a
389    /// `rate()` panel therefore shows the incidents rather than the
390    /// request rate that followed them. Paired with a single
391    /// `tracing::error!`, latched so a pathological panic loop cannot spam
392    /// the log while this counter keeps the true total.
393    ///
394    /// Note: the bump is skipped when the engine was built with no metrics
395    /// handle (`CacheEngine::open`'s 3-arg form, test-only today — the
396    /// daemon always wires `Some(..)`). There the latched log line is the
397    /// whole signal.
398    ///
399    /// Field name omits `_total`: the `OpenMetrics` encoder appends it
400    /// automatically, so the emitted name is
401    /// `decdn_cache_inflight_mutex_poisoned_total`.
402    pub inflight_mutex_poisoned: Counter,
403    /// Hashes removed via the durable operator-evict path
404    /// (`decdn_cache_evicted_operator_total`) — `decdn node evict` / DMCA
405    /// takedown. Bumped once per takedown that actually stopped serving: an
406    /// idempotent repeat-evict short-circuits on the already-evicted check and
407    /// returns `Ok(())` without bumping, so this counts distinct hashes, not
408    /// calls. Distinct from `evictions` (LRU pressure): this is a permanent,
409    /// `evicted.log`-backed removal, not cache-size management.
410    ///
411    /// Field name omits `_total`: the emitted name is
412    /// `decdn_cache_evicted_operator_total`.
413    pub evicted_operator: Counter,
414    /// Held hashes quarantined because a serve export failed bao validation
415    /// against the content root: the stored bytes or outboard diverged after
416    /// admission. Bumped once per quarantine, not once per failed serve.
417    /// Distinct from `evicted_operator`: a quarantine is not durable, it
418    /// releases the entry to GC, and the hash is re-acquirable once the sweep
419    /// reclaims it.
420    ///
421    /// Operator-actionable: a nonzero rate means disk rot or tampering under
422    /// the cache directory. Check the disk, and confirm that GC is enabled so
423    /// the corrupt entries are reclaimed.
424    ///
425    /// Field name omits `_total`: the emitted name is
426    /// `decdn_cache_held_corruption_quarantined_total`.
427    pub held_corruption_quarantined: Counter,
428    /// Origin reads on the range-pull path (an `{H}.obao4` fetch or one data
429    /// window) that ran past their time budget (ADR 037). The budget is
430    /// `cache.node_pull_stall_window_sec` plus the read size at
431    /// `cache.node_pull_min_throughput_bps`. Each timeout is also an origin
432    /// transport fault, so it advances the origin chain or fails the fill.
433    ///
434    /// Operator-actionable: a nonzero rate means an origin that stalls or
435    /// sustains less than the throughput floor. Check the origin, or lower
436    /// the floor for a slow origin.
437    ///
438    /// Field name omits `_total`: the emitted name is
439    /// `decdn_cache_origin_range_timeouts_total`.
440    pub origin_range_timeouts: Counter,
441    /// Own-origin range draws that found the engine-wide pool of
442    /// `MAX_CONCURRENT_RANGE_PULLS` permits full and waited for one. Every
443    /// draw of every fill shares the pool, and a hung origin holds a permit
444    /// for a whole draw, so a starved fill shows here and not only as a client
445    /// stall. A wait past `RANGE_PULL_PERMIT_WARN_AFTER` also logs a warning.
446    ///
447    /// Operator-actionable: a sustained rate means more concurrent own-origin
448    /// fills than the pool serves, or an origin slow enough to hold permits.
449    /// Check `origin_range_timeouts` and the origin's latency.
450    ///
451    /// Field name omits `_total`: the emitted name is
452    /// `decdn_cache_range_pull_permit_waits_total`.
453    pub range_pull_permit_waits: Counter,
454}