decdn_cache/probe_hold.rs
1//! Probe-triggered eviction-hold constants (ADR 005 §Probe-triggered
2//! eviction hold, §Hold budget; ADR 040 §Pinning, durable operator-evict, and
3//! the probe-hold stay engine-enforced).
4//!
5//! When a node signs `has_blob: true` in a `ProbeResponse` it tries to keep the
6//! blob eviction-exempt for [`PROBE_HOLD_DURATION`] so it is still resident when
7//! the probing client's follow-up pull arrives. The hold is **best-effort**, not
8//! a guarantee: presence governs the advertisement, and a hold is placed only if
9//! a slot is free (see [`ProbeHoldOutcome::BudgetExhausted`]). All three timing
10//! parameters derive from a single base value [`PROBE_SLASH_WINDOW`], which
11//! remains the on-chain rate-manipulation probe↔stream window (ADR 014).
12
13use std::time::Duration;
14
15/// The on-chain probe↔stream window `SlashJudge` enforces for rate
16/// manipulation (ADR 014; surfaced in ADR 005 §Derived constants). The single
17/// base value the other timing parameters derive from.
18pub const PROBE_SLASH_WINDOW: Duration = Duration::from_secs(30);
19
20/// Extra margin over [`PROBE_SLASH_WINDOW`] absorbing network latency between
21/// signing and the requester opening a stream.
22pub const PROBE_HOLD_MARGIN: Duration = Duration::from_secs(5);
23
24/// How long a blob stays eviction-exempt after the node signs
25/// `has_blob: true` for it: [`PROBE_SLASH_WINDOW`] plus [`PROBE_HOLD_MARGIN`]
26/// (35s) — long enough to cover the probe→pull round trip. Derived from the
27/// slashing window because the two share a timescale, not because a hold has
28/// any slashing consequence (ADR 005 §Derived constants).
29pub const PROBE_HOLD_DURATION: Duration = PROBE_SLASH_WINDOW.saturating_add(PROBE_HOLD_MARGIN);
30
31/// Default maximum number of concurrently held (eviction-exempt) blobs (ADR
32/// 005 §Hold budget). Holds are per-blob: many peers probing one hash share a
33/// single slot. The canonical definition lives in the
34/// `decdn-config-types` leaf crate (#578) so the config default
35/// (`decdn_common::config`) and the cache engine default cannot drift apart;
36/// re-exported here as `crate::probe_hold::DEFAULT_MAX_PROBE_HOLDS`.
37pub use decdn_config_types::DEFAULT_MAX_PROBE_HOLDS;
38
39/// Outcome of [`crate::CacheEngine::try_probe_hold`], carrying two independent
40/// facts (ADR 005 §Probe-triggered eviction hold):
41///
42/// - **whether to advertise** the blob — [`ProbeHoldOutcome::advertises`];
43/// - **whether a hold was actually placed** — [`ProbeHoldOutcome::hold_placed`].
44///
45/// Holds are best-effort, so these are two independent bits:
46/// [`Self::BudgetExhausted`] advertises *without* holding. Use the two
47/// predicates rather than matching on variants, so the mapping stays in one
48/// place; the variants themselves exist so the probe handler can emit the right
49/// metric without a second cache lookup.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ProbeHoldOutcome {
52 /// Blob present (and not operator-evicted) **and** an eviction hold was
53 /// placed, so the blob stays resident for the follow-up pull — sign
54 /// `has_blob: true`.
55 Held,
56 /// Blob absent or operator-evicted — sign `has_blob: false`. Not a
57 /// degradation: the node simply does not have the content.
58 ///
59 /// Deliberately the one outcome with **no** metric. The
60 /// `probe_hold_unavailable` counter records hold *refusals* for content the
61 /// node has; a true negative is not a refusal, and counting it would swamp
62 /// the signal every alert on that metric depends on. Despite the shared
63 /// word, this is not `ProbeHoldUnavailableReason` — that enum's values all
64 /// mean "we have it but would not hold it".
65 Unavailable,
66 /// Blob present but **all hold slots are in use** (`max_probe_holds > 0`
67 /// and the live-hold count has reached it). The caller still advertises
68 /// `has_blob: true` but places no hold, so the blob may be LRU-evicted
69 /// before the pull — costing one wasted round trip, never a slash. Counted
70 /// as `probe_hold_unavailable{reason="exhausted"}` (ADR 005 §Hold budget);
71 /// the operator-actionable remedy for the lost holds is to raise
72 /// `max_probe_holds`.
73 BudgetExhausted,
74 /// Blob present but the eviction-hold path is **disabled by config**
75 /// (`max_probe_holds == 0`) — the operator opt-out from advertising
76 /// store-backed content, so the caller signs `has_blob: false`.
77 /// Operationally distinct from [`Self::BudgetExhausted`] (#739): a
78 /// deliberate choice, not load. Counted as
79 /// `probe_hold_unavailable{reason="disabled"}`, never as
80 /// `{reason="exhausted"}` (whose alert remedy is "increase
81 /// `max_probe_holds`", nonsensical when holds are deliberately off).
82 ///
83 /// Scope note: this silences *store-backed* advertisements only. Content
84 /// servable from a configured origin takes no hold and is advertised
85 /// regardless — see the origin-held fallback in the probe handler.
86 HoldsDisabled,
87}
88
89impl ProbeHoldOutcome {
90 /// Whether this outcome authorises signing `has_blob: true` for the
91 /// **store-backed** answer (the origin-held fallback may still promote a
92 /// `false` afterwards).
93 ///
94 /// Presence governs the answer, not the hold: [`Self::BudgetExhausted`]
95 /// advertises despite placing no hold, because refusing there would let a
96 /// probe flood that fills the hold cache suppress truthful answers.
97 #[must_use]
98 pub const fn advertises(self) -> bool {
99 matches!(self, Self::Held | Self::BudgetExhausted)
100 }
101
102 /// Whether an eviction hold was actually placed, making the blob
103 /// LRU-exempt for [`PROBE_HOLD_DURATION`].
104 ///
105 /// Distinct from [`Self::advertises`]: holds are best-effort, and only
106 /// [`Self::Held`] consumes a slot.
107 #[must_use]
108 pub const fn hold_placed(self) -> bool {
109 matches!(self, Self::Held)
110 }
111}