use super::*;
use std::time::Duration;
fn creditor(
allowance: &Arc<WarmingAllowance>,
) -> (Arc<dyn WarmingCreditSink>, StopHandle, Arc<Metrics>) {
let metrics = Arc::new(Metrics::new());
let (sink, handle) = spawn_warming_creditor(Arc::clone(allowance), Arc::clone(&metrics));
(sink, handle, metrics)
}
async fn eventually(mut cond: impl FnMut() -> bool) -> bool {
for _ in 0..500 {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
false
}
const S1: SourceId = SourceId::from_bytes([1u8; 32]);
const S2: SourceId = SourceId::from_bytes([2u8; 32]);
const H1: Hash = Hash::from_bytes([10u8; 32]);
const H2: Hash = Hash::from_bytes([11u8; 32]);
#[test]
fn source_id_round_trips_through_its_accessors() {
let raw = [7u8; 32];
let id = SourceId::from_bytes(raw);
assert_eq!(id.as_bytes(), &raw);
assert_eq!(id.to_bytes(), raw);
assert_eq!(<[u8; 32]>::from(id), raw);
assert_eq!(SourceId::from(raw), id);
}
#[test]
fn a_fresh_source_starts_with_the_full_budget() {
let a = WarmingAllowance::new(1000, 0); a.debit_speculative(S1, H1, 999);
assert!(a.available(S1)); a.debit_speculative(S1, H2, 1);
assert!(!a.available(S1)); }
#[test]
fn duds_spend_the_budget_then_block() {
let a = WarmingAllowance::new(1000, 0); a.debit_speculative(S1, H1, 1000); a.credit_serve(H1, 600); assert!(a.available(S1)); a.debit_speculative(S1, H2, 1000); assert!(!a.available(S1)); }
#[test]
fn re_served_blob_refunds_and_keeps_warming() {
let a = WarmingAllowance::new(1000, 0);
a.debit_speculative(S1, H2, 1000); a.debit_speculative(S1, H1, 1000); a.credit_serve(H1, 600); assert!(!a.available(S1));
a.credit_serve(H1, 600); assert!(a.available(S1)); }
#[test]
fn credit_is_capped_at_budget() {
let a = WarmingAllowance::new(1000, 0);
a.debit_speculative(S1, H1, 100);
for _ in 0..100 {
a.credit_serve(H1, 600);
}
a.debit_speculative(S1, H2, 1000);
assert!(!a.available(S1));
}
#[test]
fn debt_is_floored_at_minus_budget() {
let a = WarmingAllowance::new(1000, 0);
a.debit_speculative(S1, H1, 10_000); a.credit_serve(H1, 1000); assert!(!a.available(S1));
a.credit_serve(H1, 1); assert!(a.available(S1));
}
#[tokio::test]
async fn channel_sink_credits_through_the_background_aggregator() {
let allowance = Arc::new(WarmingAllowance::new(1000, 0));
let (sink, _handle, _metrics) = creditor(&allowance);
allowance.debit_speculative(S1, H2, 1000); allowance.debit_speculative(S1, H1, 1000); sink.credit(H1, 600); sink.credit(H1, 600);
assert!(
eventually(|| allowance.available(S1)).await,
"the aggregator must apply the enqueued credits"
);
}
#[test]
fn a_blocked_debit_has_not_published_its_tag() {
let allowance = Arc::new(WarmingAllowance::new(1000, 0));
let held = allowance
.state
.lock()
.unwrap_or_else(PoisonError::into_inner);
let buyer = Arc::clone(&allowance);
let debit = std::thread::spawn(move || buyer.debit_speculative(S1, H1, 1000));
std::thread::sleep(Duration::from_millis(100));
let tag_while_blocked = allowance.source_for(H1);
drop(held);
let joined = debit.join().is_ok();
assert!(joined, "the debiting thread panicked");
assert_eq!(
tag_while_blocked, None,
"a debit blocked on the ledger lock must not have published its tag"
);
assert_eq!(allowance.source_for(H1), Some(S1));
assert!(!allowance.available(S1), "the debit landed");
}
#[tokio::test]
async fn credit_does_not_wait_on_the_ledger_lock() {
let allowance = Arc::new(WarmingAllowance::new(1000, 0));
let (sink, _handle, _metrics) = creditor(&allowance);
allowance.debit_speculative(S1, H1, 1000);
let held = allowance
.state
.lock()
.unwrap_or_else(PoisonError::into_inner);
let (tx, rx) = std::sync::mpsc::channel::<()>();
let serving = Arc::clone(&sink);
let crediter = std::thread::spawn(move || {
serving.credit(H1, 600);
let _ = tx.send(());
});
let landed = rx.recv_timeout(Duration::from_secs(10)).is_ok();
drop(held);
let joined = crediter.join().is_ok();
assert!(landed, "a serve credit waited on the ledger lock");
assert!(joined, "the crediting thread panicked");
}
#[tokio::test]
async fn a_queued_credit_cannot_follow_a_retagged_hash() {
let allowance = Arc::new(WarmingAllowance::new(1000, 0));
let (sink, _handle, _metrics) = creditor(&allowance);
allowance.debit_speculative(S1, H1, 1000);
sink.credit(H1, 600);
sink.credit(H1, 600);
allowance.forget(H1);
allowance.debit_speculative(S2, H1, 1000);
assert!(
eventually(|| allowance.available(S1)).await,
"the credits must land on the source that was tagged at serve time"
);
assert!(
!allowance.available(S2),
"a credit for S1's serve must never vindicate S2's speculative buy"
);
}
#[tokio::test]
async fn an_untagged_hash_never_reaches_the_queue() {
let allowance = Arc::new(WarmingAllowance::new(1000, 0));
let (sink, _handle, metrics) = creditor(&allowance);
for _ in 0..(WARMING_CREDIT_CAPACITY * 4) {
sink.credit(H1, 600); }
assert!(
!metrics_line_nonzero(&metrics, "decdn_warming_credits_dropped_total"),
"untagged serves must not fill the queue or count as drops"
);
}
#[tokio::test]
async fn a_full_queue_drops_conservatively_and_counts_it() {
let allowance = Arc::new(WarmingAllowance::new(1_000_000, 0));
let metrics = Arc::new(Metrics::new());
let (tx, _rx) = mpsc::channel(WARMING_CREDIT_CAPACITY);
let sink = ChannelWarmingCreditSink {
tx,
allowance: Arc::clone(&allowance),
metrics: Arc::clone(&metrics),
closed_logged: AtomicBool::new(false),
};
allowance.debit_speculative(S1, H1, 1_000_000);
assert!(!allowance.available(S1), "the speculative buy drains S1");
for _ in 0..(WARMING_CREDIT_CAPACITY + 64) {
sink.credit(H1, 10_000); }
assert!(
metrics_line_nonzero(&metrics, "decdn_warming_credits_dropped_total"),
"a dropped credit must be counted, not only logged"
);
assert!(
!allowance.available(S1),
"a dropped credit must never move the ledger in the crediting direction"
);
}
#[tokio::test]
async fn a_closed_queue_drops_and_counts_without_failing_the_serve() {
let allowance = Arc::new(WarmingAllowance::new(1000, 0));
let metrics = Arc::new(Metrics::new());
let (sink, handle) = spawn_warming_creditor(Arc::clone(&allowance), Arc::clone(&metrics));
allowance.debit_speculative(S1, H1, 1000);
assert!(
handle.shutdown().await.is_ok(),
"the aggregator must exit cleanly"
);
sink.credit(H1, 600);
assert!(
metrics_line_nonzero(&metrics, "decdn_warming_credits_dropped_total"),
"a credit into a closed queue must be counted"
);
assert!(
!allowance.available(S1),
"the dropped credit stays unapplied"
);
}
#[tokio::test]
async fn shutdown_flushes_the_queued_tail() {
let allowance = Arc::new(WarmingAllowance::new(1000, 0));
let metrics = Arc::new(Metrics::new());
let (sink, handle) = spawn_warming_creditor(Arc::clone(&allowance), Arc::clone(&metrics));
allowance.debit_speculative(S1, H1, 1000);
sink.credit(H1, 600);
sink.credit(H1, 600);
assert!(
handle.shutdown().await.is_ok(),
"the aggregator must exit cleanly"
);
assert!(
allowance.available(S1),
"credits enqueued before the stop signal must still be applied"
);
}
fn metrics_line_nonzero(metrics: &Metrics, name: &str) -> bool {
let Ok(text) = metrics.encode() else {
return false;
};
text.lines().any(|l| {
l.split_once(' ')
.is_some_and(|(k, v)| k == name && v.trim() != "0")
})
}
#[test]
fn enabled_tracks_a_nonzero_budget() {
assert!(WarmingAllowance::new(1000, 0).enabled());
assert!(!WarmingAllowance::new(0, 0).enabled());
}
#[test]
fn a_zero_budget_disables_warming_for_every_source() {
let a = WarmingAllowance::new(0, 0);
assert!(
!a.available(S1),
"a fresh source is not available with warming off"
);
a.debit_speculative(S1, H1, 10); assert!(!a.available(S1));
assert_eq!(
a.blocked_source_count(),
0,
"warming off reports no griefing, even with a spent bucket present"
);
}
#[test]
fn blocked_source_count_tracks_spent_ledgers() {
let a = WarmingAllowance::new(1000, 0); assert_eq!(a.blocked_source_count(), 0, "no ledger entries yet");
a.debit_speculative(S1, H1, 1000); assert_eq!(a.blocked_source_count(), 1, "S1 is now blocked");
a.debit_speculative(S2, H2, 1); assert_eq!(a.blocked_source_count(), 1, "only S1 is blocked");
a.credit_serve(H1, 1); assert_eq!(a.blocked_source_count(), 0, "S1 recovered");
}
#[test]
fn sources_are_independent_and_forget_stops_credit() {
let a = WarmingAllowance::new(1000, 0);
a.debit_speculative(S1, H1, 1000);
a.forget(H1);
a.credit_serve(H1, 600); assert!(!a.available(S1)); assert!(a.available(S2));
}