feeder rework

This commit is contained in:
Worph
2026-08-24 19:03:09 +02:00
parent d2c6ef4c18
commit 4baf81e2bf
6 changed files with 695 additions and 1 deletions
+241
View File
@@ -0,0 +1,241 @@
//! Per-upstream request budget — a shared token bucket.
//!
//! Lifted from the card feeder's `tmdb_budget.rs` (its `TmdbBudget` half; the
//! per-indexer registry has no counterpart here) and renamed, because the
//! mechanism is upstream-agnostic and the music feeder needs three independent
//! instances rather than one global.
//!
//! **Why three, not one.** MusicBrainz's 1 req/s is a hard published ceiling
//! enforced per source IP; ListenBrainz and the Internet Archive are far more
//! generous. A single shared bucket would either throttle all three to 1/s or
//! let MusicBrainz calls burst past a limit that gets the whole box blocked.
//!
//! Design notes, unchanged from the original:
//! - **Lazy refill.** Tokens accrue on each `acquire` from elapsed wall-clock,
//! so there is no background timer task and no idle cost.
//! - **Cancel-safe.** The token is decremented synchronously under the lock
//! right before `acquire` returns [`Lease::Granted`]; there is no `.await`
//! between the decision to grant and the return, so a dropped `acquire`
//! future (consumer disconnected) never spends a token.
//! - **Global pause on throttle.** One upstream 429/503 freezes *all* grants
//! on that bucket until the Retry-After window elapses, via
//! [`RateBudget::note_throttled`].
//!
//! The critical sections hold a plain `std::sync::Mutex` (no awaits inside),
//! while the waiting happens outside the lock on a `tokio::time::sleep` raced
//! against a `tokio::sync::Notify`.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
/// Outcome of a [`RateBudget::acquire`] call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lease {
/// A token was granted; the caller may make one upstream call.
Granted,
/// The wait deadline elapsed before a token became available. The caller
/// degrades best-effort — it does **not** call the upstream anyway.
DeadlineExceeded,
}
struct BucketState {
tokens: f64,
capacity: f64,
refill_per_sec: f64,
last_refill: Instant,
/// When set and in the future, all grants are frozen.
paused_until: Option<Instant>,
}
impl BucketState {
/// Accrue tokens for elapsed time since the last refill. Called under the
/// lock before inspecting `tokens`.
fn refill(&mut self, now: Instant) {
let elapsed = now.saturating_duration_since(self.last_refill).as_secs_f64();
if elapsed > 0.0 {
self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
self.last_refill = now;
}
}
}
/// Shared token bucket gating one upstream's API calls across the process.
pub struct RateBudget {
state: Mutex<BucketState>,
/// Woken whenever a grant *might* now be possible (a token accrued, or a
/// pause was cleared). Waiters re-check under the lock after waking; a
/// missed wake only costs an extra bounded sleep.
wake: Notify,
}
impl RateBudget {
/// Build a budget sustaining `refill_per_sec` grants/second with a burst
/// ceiling of `capacity` tokens. The bucket starts full, so the first
/// burst is served immediately.
pub fn new(refill_per_sec: f64, capacity: f64) -> Arc<Self> {
Arc::new(Self {
state: Mutex::new(BucketState {
tokens: capacity,
capacity,
refill_per_sec,
last_refill: Instant::now(),
paused_until: None,
}),
wake: Notify::new(),
})
}
/// Wait for one token, giving up after `deadline`. Cancel-safe: dropping
/// the returned future before it resolves [`Lease::Granted`] spends no
/// token.
pub async fn acquire(&self, deadline: Duration) -> Lease {
let give_up_at = Instant::now() + deadline;
loop {
// Decide-or-compute-wait, all under the lock (no awaits inside).
let sleep_for = {
let mut st = self.state.lock().expect("rate budget mutex poisoned");
let now = Instant::now();
if let Some(until) = st.paused_until {
if now >= until {
st.paused_until = None;
}
}
let paused = st.paused_until;
if paused.is_none() {
st.refill(now);
if st.tokens >= 1.0 {
// Atomic grant: decrement and return with no await between.
st.tokens -= 1.0;
return Lease::Granted;
}
}
if now >= give_up_at {
return Lease::DeadlineExceeded;
}
let until_deadline = give_up_at.saturating_duration_since(now);
let wait = match paused {
// Frozen: wake when the pause is scheduled to lift.
Some(until) => until.saturating_duration_since(now),
// Throttled: wake when the next whole token should accrue.
None => {
let needed = 1.0 - st.tokens;
let secs = if st.refill_per_sec > 0.0 {
needed / st.refill_per_sec
} else {
until_deadline.as_secs_f64()
};
Duration::from_secs_f64(secs.max(0.0))
}
};
wait.min(until_deadline)
};
tokio::select! {
_ = tokio::time::sleep(sleep_for) => {}
_ = self.wake.notified() => {}
}
if Instant::now() >= give_up_at {
return Lease::DeadlineExceeded;
}
}
}
/// Feed back an upstream throttle response (429, or MusicBrainz's 503):
/// pause all grants on this bucket until `now + retry_after`. Extends an
/// existing pause but never shortens it.
pub fn note_throttled(&self, retry_after: Duration) {
let until = Instant::now() + retry_after;
{
let mut st = self.state.lock().expect("rate budget mutex poisoned");
st.paused_until = Some(match st.paused_until {
Some(existing) if existing > until => existing,
_ => until,
});
}
// Wake waiters so they recompute their sleep against the new pause.
self.wake.notify_waiters();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn grants_up_to_capacity_immediately() {
let b = RateBudget::new(3.0, 3.0);
for _ in 0..3 {
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
}
}
#[tokio::test(start_paused = true)]
async fn next_token_waits_for_refill() {
let b = RateBudget::new(2.0, 2.0); // 2/s sustained
for _ in 0..2 {
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
}
// Bucket empty; next grant must wait ~0.5 s for one token at 2/s.
let start = Instant::now();
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
assert!(
start.elapsed() >= Duration::from_millis(450),
"expected to wait ~0.5s for refill, waited {:?}",
start.elapsed()
);
}
/// The MusicBrainz shape specifically: 1/s with a burst of 2 must not let
/// three calls through inside one second. This is the property that keeps
/// the box off their block-list.
#[tokio::test(start_paused = true)]
async fn musicbrainz_shape_holds_one_per_second() {
let b = RateBudget::new(1.0, 1.0);
let start = Instant::now();
for _ in 0..4 {
assert_eq!(b.acquire(Duration::from_secs(30)).await, Lease::Granted);
}
// 4 grants from a 2-token bucket refilling at 1/s ⇒ at least 2 s.
assert!(
start.elapsed() >= Duration::from_millis(1_900),
"4 calls at 1/s (burst 2) must take ~2s, took {:?}",
start.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn deadline_exceeded_when_drained() {
let b = RateBudget::new(0.001, 1.0); // effectively no refill within the test
assert_eq!(b.acquire(Duration::from_secs(1)).await, Lease::Granted);
assert_eq!(
b.acquire(Duration::from_millis(200)).await,
Lease::DeadlineExceeded
);
}
#[tokio::test(start_paused = true)]
async fn note_throttled_pauses_grants_even_with_tokens_available() {
let b = RateBudget::new(100.0, 100.0);
b.note_throttled(Duration::from_secs(2));
assert_eq!(
b.acquire(Duration::from_millis(500)).await,
Lease::DeadlineExceeded
);
// After the pause window, grants resume.
assert_eq!(b.acquire(Duration::from_secs(5)).await, Lease::Granted);
}
/// A pause is extended, never shortened — otherwise a late short retry
/// would cancel an earlier long one and walk straight back into the block.
#[tokio::test(start_paused = true)]
async fn a_shorter_retry_after_does_not_shorten_an_existing_pause() {
let b = RateBudget::new(100.0, 100.0);
b.note_throttled(Duration::from_secs(10));
b.note_throttled(Duration::from_secs(1));
assert_eq!(
b.acquire(Duration::from_secs(3)).await,
Lease::DeadlineExceeded
);
}
}