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;