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}