Skip to main content

decdn_node/
warming_allowance.rs

1//! Per-source serve-vindicated warming allowance (ADR 041).
2//!
3//! Speculative "warming" buys pull a blob before any client asks for it. The buy
4//! can be wrong: a dud that never gets a second serve. This module bounds the
5//! resulting loss **per upstream source node** (a bonded seller identity), so a
6//! flood of at-market one-hit blobs from one attacker source can only drain that
7//! source's small allowance, never the operational deposit.
8//!
9//! Each source holds an allowance that starts full at `budget` and is clamped to
10//! `[-budget, +budget]`. A never-touched source has no ledger entry and reads as
11//! full. A speculative buy debits the source's ledger by the buy cost and tags
12//! the blob's hash with its source. Every serve of a tagged hash credits the
13//! realized margin back to that source's ledger. A blob re-served often enough
14//! refunds its buy (vindicated; at a market price and the default operator
15//! share, two serves do); a dud served once keeps the fee skim as a loss. The
16//! source keeps warming while its allowance is positive, so its duds
17//! spend the budget down, and once they exhaust it the source is blocked from
18//! further speculative buys until it recovers. Eviction forgets the tag so a
19//! stale hash can never credit a ledger again. A slow time-refill on top of the
20//! serve credits forgives a transient bad patch.
21//!
22//! # Seam
23//!
24//! [`WarmingAllowance`] is the ledger; [`WarmingCreditSink`] is the serve-path
25//! boundary in front of it, mirroring [`crate::receipt_log::ReceiptSink`]. A
26//! stream task ends its serve with a non-blocking
27//! [`WarmingCreditSink::credit`], and the single background task from
28//! [`spawn_warming_creditor`] applies the credit to the ledger. Tests use the
29//! inline `DirectWarmingCreditSink` (a `test-support` type) to keep the credit
30//! observable synchronously.
31//!
32//! The bucket ledger is a plain `Mutex<HashMap>` and every operation on it is
33//! O(1) — nothing iterates under the lock. The seam is not there to escape a
34//! long hold. It is there so a serve's final step joins no queue at all: a
35//! stream that has finished its bytes still holds its lane slot and its floor
36//! reservation until that step returns, so waiting on a lock somebody else
37//! happens to hold costs a serve slot for the length of the wait.
38//!
39//! Two things take that lock in the shipped wiring. The buy loop takes it on
40//! the cache-miss path — [`WarmingAllowance::available`] to gate a speculative
41//! pull, then [`WarmingAllowance::debit_speculative`] when one goes ahead. The
42//! aggregator takes it to apply each queued credit. Eviction does not: its only
43//! contact with the allowance is [`WarmingAllowance::forget`], which touches the
44//! tag map alone.
45//!
46//! The hash-to-source tags live outside that lock in a [`DashMap`], so the
47//! serve path resolves the source itself and enqueues `(source, units)`. That
48//! is what keeps a deferred credit honest: the source is bound at serve time,
49//! so an eviction and a re-warm from a different source between enqueue and
50//! apply cannot redirect the credit (see [`WarmingCreditSink`]).
51
52use std::collections::HashMap;
53use std::sync::atomic::{AtomicBool, Ordering};
54use std::sync::{Arc, Mutex, PoisonError};
55use std::time::Instant;
56
57use dashmap::DashMap;
58use decdn_cache::Hash;
59use tokio::sync::mpsc;
60use tokio_util::sync::CancellationToken;
61
62use crate::metrics::Metrics;
63use crate::stop_handle::StopHandle;
64
65/// The bonded upstream seller node a speculative warming buy is charged to.
66///
67/// A 32-byte node identity, kept distinct at the type level from a content
68/// [`struct@Hash`] so the serve-vindicated accounting can never transpose the two: the
69/// seam passes a source and a hash side by side, and both are 32 bytes wide.
70/// [`WarmingAllowance`] keys every ledger and tag on this type, so a caller that
71/// swaps the arguments of [`WarmingAllowance::debit_speculative`] no longer
72/// compiles.
73#[repr(transparent)]
74#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
75pub struct SourceId([u8; 32]);
76
77impl SourceId {
78    /// Wrap a raw node id. `const` so callers can build one in const contexts.
79    #[must_use]
80    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
81        Self(bytes)
82    }
83
84    /// Borrow the inner bytes.
85    #[must_use]
86    pub const fn as_bytes(&self) -> &[u8; 32] {
87        &self.0
88    }
89
90    /// Consume into the inner bytes.
91    #[must_use]
92    pub const fn to_bytes(self) -> [u8; 32] {
93        self.0
94    }
95}
96
97impl From<[u8; 32]> for SourceId {
98    fn from(bytes: [u8; 32]) -> Self {
99        Self(bytes)
100    }
101}
102
103impl From<SourceId> for [u8; 32] {
104    fn from(value: SourceId) -> Self {
105        value.0
106    }
107}
108
109/// One source node's warming ledger. `remaining` is the signed allowance left:
110/// it starts at `budget`, stays warm while positive, and blocks the source once
111/// speculative losses drive it to zero or below.
112#[derive(Debug)]
113struct Bucket {
114    remaining: i64,
115    last: Instant,
116}
117
118impl Bucket {
119    /// A source's first ledger entry holds the full allowance (ADR 041 §The
120    /// per-source warming allowance), the same state a never-touched source
121    /// reads as.
122    fn full(budget: i64) -> Self {
123        Self {
124            remaining: budget,
125            last: Instant::now(),
126        }
127    }
128}
129
130#[derive(Debug)]
131struct State {
132    buckets: HashMap<SourceId, Bucket>,
133}
134
135/// Bounds speculative warming losses per upstream source node.
136///
137/// See the module docs for the serve-vindicated accounting model.
138///
139/// # Poisoning
140///
141/// Every acquisition of `state` recovers the inner value rather than
142/// propagating the poison. The whole of each critical section is a `HashMap`
143/// entry insert, an `Instant` read (`Instant::elapsed` saturates rather than
144/// panicking), and saturating `i64` arithmetic, so there is no reachable unwind
145/// and recovered state is never torn.
146///
147/// Recovering is also the safe direction on each of the three paths that take
148/// the lock, which is why it is uniform here rather than split per method.
149/// Skipping a [`Self::debit_speculative`] would let a source buy speculatively
150/// without paying the grief cap for it, and skipping the aggregator's apply
151/// would strand a serve's realized margin. Refusing on [`Self::available`] is
152/// the conservative direction for that one call, but it blocks every
153/// speculative buy node-wide for the process lifetime with no signal, which is
154/// a worse operational failure than the unreachable one it guards against.
155#[derive(Debug)]
156pub struct WarmingAllowance {
157    budget: i64,
158    refill_per_sec: u64,
159    state: Mutex<State>,
160    /// Which source speculatively bought each hash. Kept outside `state` so the
161    /// serve path can resolve a hash's source without taking the bucket lock —
162    /// see [`Self::source_for`], the first half of every deferred credit.
163    source_of: DashMap<Hash, SourceId>,
164}
165
166impl WarmingAllowance {
167    /// Creates a new allowance tracker. A source with no recorded activity
168    /// reads as fully available.
169    pub fn new(budget: u64, refill_per_sec: u64) -> Self {
170        Self {
171            budget: i64::try_from(budget).unwrap_or(i64::MAX),
172            refill_per_sec,
173            state: Mutex::new(State {
174                buckets: HashMap::new(),
175            }),
176            source_of: DashMap::new(),
177        }
178    }
179
180    /// Applies the time-based refill to one bucket, capped at `+budget`.
181    fn refill(&self, bucket: &mut Bucket) {
182        let elapsed_secs = bucket.last.elapsed().as_secs();
183        let refill = self.refill_per_sec.saturating_mul(elapsed_secs);
184        let refill = i64::try_from(refill).unwrap_or(i64::MAX);
185        bucket.remaining = bucket.remaining.saturating_add(refill).min(self.budget);
186        bucket.last = Instant::now();
187    }
188
189    /// Reports whether `source` currently has any warming allowance left. A
190    /// source with no recorded activity holds the full budget and is
191    /// available; an existing ledger is available only while its allowance is
192    /// still positive.
193    ///
194    /// A zero budget disables warming ([`Self::enabled`]): every source reads as
195    /// unavailable, including never-seen ones, so `economic_ceiling` never
196    /// selects the warming regime and no speculative buy is authorized. Without
197    /// this short-circuit a fresh source would read available (`None => true`)
198    /// even with warming off, and buy at the market price for its first hash —
199    /// the "zero budget disables warming" semantics would hold only after each
200    /// source's first buy created its bucket.
201    pub fn available(&self, source: SourceId) -> bool {
202        if !self.enabled() {
203            return false;
204        }
205        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
206        match state.buckets.get_mut(&source) {
207            Some(bucket) => {
208                self.refill(bucket);
209                bucket.remaining > 0
210            }
211            None => true,
212        }
213    }
214
215    /// Whether speculative warming is configured at all. A zero budget disables
216    /// it: every above-floor buy is refused and no source ever has a positive
217    /// allowance to spend. Callers gate the "source blocked" telemetry on this
218    /// so a node with warming deliberately off does not report every source as
219    /// griefed down.
220    #[must_use]
221    pub const fn enabled(&self) -> bool {
222        self.budget > 0
223    }
224
225    /// How many sources currently hold no warming allowance — a spent ledger at
226    /// or below zero once its time refill is projected forward. Read-only: it
227    /// projects each bucket's refill without persisting it (unlike
228    /// [`Self::available`], which refills in place), so a metrics scrape never
229    /// mutates the ledger. Sampled onto `decdn_warming_sources_blocked`.
230    ///
231    /// Reads zero when warming is disabled ([`Self::enabled`]), even if buckets
232    /// linger from a config that previously ran warming on: with the budget at
233    /// zero no source is being griefed down, so the gauge stays at zero across
234    /// the reload rather than reporting every spent ledger as blocked.
235    #[must_use]
236    pub fn blocked_source_count(&self) -> usize {
237        if !self.enabled() {
238            return 0;
239        }
240        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
241        state
242            .buckets
243            .values()
244            .filter(|bucket| self.projected_remaining(bucket) <= 0)
245            .count()
246    }
247
248    /// `bucket.remaining` with its time refill projected forward to now, capped
249    /// at `+budget`, without writing it back. The read-only twin of the refill
250    /// [`Self::available`] applies in place.
251    fn projected_remaining(&self, bucket: &Bucket) -> i64 {
252        let elapsed_secs = bucket.last.elapsed().as_secs();
253        let refill = self.refill_per_sec.saturating_mul(elapsed_secs);
254        let refill = i64::try_from(refill).unwrap_or(i64::MAX);
255        bucket.remaining.saturating_add(refill).min(self.budget)
256    }
257
258    /// Debits `source`'s ledger for a speculative buy of `hash` and tags the
259    /// hash with its source so a later serve can credit the right ledger.
260    ///
261    /// The buy loop checks [`Self::available`] when it selects a candidate and
262    /// debits only after the pull completes, so concurrent pulls from one source
263    /// can each pass the check and drive the ledger below zero: the real spend
264    /// past the allowance is every speculative buy still in flight when it
265    /// crossed zero. The ledger is floored at `-budget`, so however far those
266    /// buys overshoot, the source needs at most `budget + 1` units of credit or
267    /// refill to warm again. The floor bounds that recovery debt, not the spend.
268    ///
269    /// The tag is published while the bucket lock is held, before the debit.
270    /// The serve path reads the tag map without that lock, but every credit
271    /// reaches the ledger through `credit_source`, which takes it. So a
272    /// serve that sees the tag has its credit applied only after this debit, and
273    /// a serve that misses the tag also precedes the debit. Publishing the tag
274    /// outside the lock would let a credit land on a bucket this debit is about
275    /// to create full, where the `+budget` cap discards it; publishing it after
276    /// the debit would let a serve see a committed debit with no tag and drop
277    /// its credit.
278    pub fn debit_speculative(&self, source: SourceId, hash: Hash, units: u64) {
279        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
280        self.source_of.insert(hash, source);
281        let bucket = state
282            .buckets
283            .entry(source)
284            .or_insert_with(|| Bucket::full(self.budget));
285        self.refill(bucket);
286        let units = i64::try_from(units).unwrap_or(i64::MAX);
287        bucket.remaining = bucket
288            .remaining
289            .saturating_sub(units)
290            .max(self.budget.saturating_neg());
291    }
292
293    /// The source that speculatively bought `hash`, or `None` if the hash was
294    /// never warmed or has since been forgotten.
295    ///
296    /// One shard-scoped read on the tag map, never the bucket lock. The serve
297    /// path calls this to bind a credit to the source that was tagged at serve
298    /// time, so that a credit applied later cannot follow a tag that changed in
299    /// between.
300    #[must_use]
301    fn source_for(&self, hash: Hash) -> Option<SourceId> {
302        self.source_of.get(&hash).map(|e| *e.value())
303    }
304
305    /// Credits `units` of realized margin directly to `source`, capped at
306    /// `+budget`. This is the ledger half of a serve credit; resolving the
307    /// hash's source is a separate step the caller takes first, so a credit
308    /// applied later still pays the source its tag named at serve time.
309    fn credit_source(&self, source: SourceId, units: u64) {
310        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
311        let bucket = state
312            .buckets
313            .entry(source)
314            .or_insert_with(|| Bucket::full(self.budget));
315        self.refill(bucket);
316        let units = i64::try_from(units).unwrap_or(i64::MAX);
317        bucket.remaining = bucket.remaining.saturating_add(units).min(self.budget);
318    }
319
320    /// Credits the realized margin of a serve back to the source that
321    /// speculatively bought `hash`. No-op if `hash` has no known source
322    /// (never warmed, or forgotten since). The gain is capped at `+budget`.
323    ///
324    /// Resolving the source and applying the credit in one step is what the
325    /// shipped serve path must not do — it binds the source at apply time, so
326    /// an eviction and a re-warm in between would pay the wrong source. The
327    /// production path splits the two across the sink and the aggregator
328    /// instead, which leaves this used only by `DirectWarmingCreditSink` and
329    /// the tests that assert against it. It carries their gate for that reason.
330    #[cfg(any(test, feature = "test-support"))]
331    pub(crate) fn credit_serve(&self, hash: Hash, units: u64) {
332        if let Some(source) = self.source_for(hash) {
333            self.credit_source(source, units);
334        }
335    }
336
337    /// Drops the hash-to-source tag, e.g. on cache eviction, so a stale hash
338    /// can never credit a ledger again.
339    pub fn forget(&self, hash: Hash) {
340        self.source_of.remove(&hash);
341    }
342}
343
344/// Capacity of the warming-credit queue. One credit is produced per completed
345/// serve, and the aggregator applies each with a couple of in-memory map
346/// operations, so this absorbs a burst far larger than the drain can fall
347/// behind on.
348pub const WARMING_CREDIT_CAPACITY: usize = 1024;
349
350/// Non-blocking enqueue boundary for warming serve credits on the serve path.
351///
352/// A stream task calls [`Self::credit`] as its last act on a clean completion,
353/// so the implementation MUST NOT take the bucket lock. Every ledger operation
354/// is O(1), so the hazard is not a long hold — it is that a serve completion
355/// which waits for the lock at all keeps the stream's lane slot and floor
356/// reservation alive for the length of the wait. The buy loop holds that lock
357/// on the cache-miss path and the aggregator holds it to apply credits, so a
358/// serve that took the lock and arrived in either window would pay for it. That
359/// is the cost this contract exists to refuse. The runtime uses the channel sink from
360/// [`spawn_warming_creditor`]; tests use the `test-support`
361/// `DirectWarmingCreditSink`.
362///
363/// An implementation that defers the credit MUST resolve the hash's source
364/// before it hands the credit off, and carry the source rather than the hash.
365/// Resolving at apply time instead would let an eviction ([`WarmingAllowance::forget`])
366/// and a re-warm from a different source land in between, and the credit would
367/// follow the new tag — paying one source for another's serve.
368pub trait WarmingCreditSink: Send + Sync {
369    /// Best-effort, non-blocking credit of `units` for a clean serve of `hash`.
370    /// Never blocks the caller on the bucket lock and never fails the serve.
371    fn credit(&self, hash: Hash, units: u64);
372}
373
374/// Production [`WarmingCreditSink`]: resolves the hash's source, then enqueues
375/// `(source, units)` to the background aggregator over a bounded channel.
376///
377/// A `Full` queue drops the credit and counts it
378/// ([`Metrics::warming_credit_dropped`]). The drop is conservative in the
379/// direction that matters: an un-applied credit leaves the source's ledger lower
380/// than reality, so at worst it blocks speculative buys from a source
381/// that had earned its margin back, and the time refill forgives it. A
382/// sustained non-zero rate means the aggregator is not keeping up and warming
383/// is being throttled by bookkeeping loss rather than by real losses.
384///
385/// A `Closed` channel means the aggregator is gone. That is expected exactly
386/// once, at shutdown; any other time it means the task died and *every*
387/// subsequent credit is lost for the process lifetime, which drives every
388/// source to blocked. The two are indistinguishable from here, so the first
389/// occurrence warns and the rest stay quiet.
390struct ChannelWarmingCreditSink {
391    tx: mpsc::Sender<(SourceId, u64)>,
392    allowance: Arc<WarmingAllowance>,
393    metrics: Arc<Metrics>,
394    /// Latches on the first `Closed`, so a dead aggregator warns once instead of
395    /// once per completed serve.
396    closed_logged: AtomicBool,
397}
398
399impl WarmingCreditSink for ChannelWarmingCreditSink {
400    fn credit(&self, hash: Hash, units: u64) {
401        // Bind the credit to the source tagged right now: the aggregator applies
402        // it later, by which time an eviction and a re-warm could have retagged
403        // the hash to someone else.
404        let Some(source) = self.allowance.source_for(hash) else {
405            return; // never warmed, or forgotten since — nothing to credit
406        };
407        match self.tx.try_send((source, units)) {
408            Ok(()) => {}
409            Err(mpsc::error::TrySendError::Full(_)) => {
410                self.metrics.warming_credit_dropped();
411                tracing::debug!(
412                    event = "warming_credit_dropped",
413                    "warming serve credit dropped: aggregator queue full \
414                     (the source ledger stays conservative and refills on time)"
415                );
416            }
417            Err(mpsc::error::TrySendError::Closed(_)) => {
418                self.metrics.warming_credit_dropped();
419                if !self.closed_logged.swap(true, Ordering::Relaxed) {
420                    tracing::warn!(
421                        event = "warming_credit_sink_closed",
422                        "warming serve credit dropped: aggregator gone. Expected \
423                         during shutdown; at any other time every later credit is \
424                         lost too and speculative warming will stop node-wide."
425                    );
426                }
427            }
428        }
429    }
430}
431
432/// A [`WarmingCreditSink`] that drops every credit. It is the inert default a
433/// fresh [`crate::handlers::client::ClientHandlerDeps`] carries before the
434/// runtime overwrites it with the channel sink from [`spawn_warming_creditor`].
435/// A handler left with this default applies no warming credit, which only keeps
436/// each source's ledger more conservative — the same safe direction a full or
437/// closed aggregator queue takes.
438#[derive(Debug, Default)]
439pub struct NoopWarmingCreditSink;
440
441impl WarmingCreditSink for NoopWarmingCreditSink {
442    fn credit(&self, _hash: Hash, _units: u64) {}
443}
444
445/// Synchronous [`WarmingCreditSink`] that applies each credit inline to a
446/// [`WarmingAllowance`]. Test and loopback support only: it lets a test read the
447/// ledger right after a serve instead of polling for a background drain.
448///
449/// It deliberately *violates* the [`WarmingCreditSink`] non-blocking contract
450/// (it takes the ledger lock on the caller's thread), so it must never be wired
451/// onto the serve path. The `test-support` feature gate keeps it out of every
452/// production build: it compiles only for this crate's own tests and for the
453/// cross-crate integration tests in `tests/`, which cannot see `#[cfg(test)]`
454/// items. The field is private and the type is `#[doc(hidden)]` so it does not
455/// read as a production knob.
456#[cfg(any(test, feature = "test-support"))]
457#[doc(hidden)]
458#[derive(Debug)]
459pub struct DirectWarmingCreditSink(Arc<WarmingAllowance>);
460
461#[cfg(any(test, feature = "test-support"))]
462impl DirectWarmingCreditSink {
463    /// Wrap a ledger as an inline-crediting sink. Test and loopback use only —
464    /// see the type docs; never wire this onto the serve path.
465    #[must_use]
466    pub const fn new(allowance: Arc<WarmingAllowance>) -> Self {
467        Self(allowance)
468    }
469}
470
471#[cfg(any(test, feature = "test-support"))]
472impl WarmingCreditSink for DirectWarmingCreditSink {
473    fn credit(&self, hash: Hash, units: u64) {
474        self.0.credit_serve(hash, units);
475    }
476}
477
478/// Spawn the single background task that owns the credit queue and applies each
479/// credit to `allowance`, returning the [`WarmingCreditSink`] the serve path
480/// enqueues through and the [`StopHandle`] that stops the task.
481///
482/// This is what takes the bucket lock off the stream task (see the module
483/// Seam): a serve completion resolves the source from the tag map — one shard
484/// read, never the bucket lock — and does one bounded `try_send`. This task
485/// takes the bucket lock instead, where waiting behind the buy loop costs a
486/// queued credit rather than a held lane slot.
487///
488/// The handle is returned rather than detached so shutdown can await the drain
489/// and a task that died is visible as a join error instead of as credits that
490/// quietly stop landing. [`StopHandle::shutdown`] ends the loop after it
491/// flushes whatever is already queued (the handle owns the stop token beside
492/// the task, so the join cannot hang on the sinks the client handler holds);
493/// the loop also ends if every sink is dropped first.
494pub fn spawn_warming_creditor(
495    allowance: Arc<WarmingAllowance>,
496    metrics: Arc<Metrics>,
497) -> (Arc<dyn WarmingCreditSink>, StopHandle) {
498    let (tx, rx) = mpsc::channel(WARMING_CREDIT_CAPACITY);
499    let handle = {
500        let allowance = Arc::clone(&allowance);
501        let metrics = Arc::clone(&metrics);
502        StopHandle::spawn(move |shutdown| warming_creditor_loop(rx, allowance, metrics, shutdown))
503    };
504    (
505        Arc::new(ChannelWarmingCreditSink {
506            tx,
507            allowance,
508            metrics,
509            closed_logged: AtomicBool::new(false),
510        }),
511        handle,
512    )
513}
514
515/// Drain loop for the background aggregator (see [`spawn_warming_creditor`]).
516/// Applies each credit FIFO until `shutdown` fires or every sink is dropped,
517/// then flushes the already-enqueued tail so a credit that made it into the
518/// queue before teardown still lands.
519///
520/// Each applied credit bumps [`Metrics::warming_credit_applied`]. That is the
521/// counter an operator reads beside the drop counter: a serve path that never
522/// reaches the ledger drops nothing, so zero drops only means something next to
523/// a non-zero apply count.
524async fn warming_creditor_loop(
525    mut rx: mpsc::Receiver<(SourceId, u64)>,
526    allowance: Arc<WarmingAllowance>,
527    metrics: Arc<Metrics>,
528    shutdown: CancellationToken,
529) {
530    let apply = |source, units| {
531        allowance.credit_source(source, units);
532        metrics.warming_credit_applied();
533    };
534    loop {
535        tokio::select! {
536            biased;
537            maybe = rx.recv() => match maybe {
538                Some((source, units)) => apply(source, units),
539                None => break,
540            },
541            () = shutdown.cancelled() => break,
542        }
543    }
544    rx.close();
545    while let Some((source, units)) = rx.recv().await {
546        apply(source, units);
547    }
548    tracing::debug!("warming-credit aggregator drained and stopped");
549}
550
551#[cfg(test)]
552mod tests;