rate/token_bucket.hpp — a lazily-refilled TokenBucket (starts full: burst
then throttle, IDM behaviour; rate 0 == unlimited; burst caps idle
accumulation) and RateLimiter, the global -> per-queue -> per-task
hierarchy (docs/04 §6). acquire(task, n) peeks every applicable level and
commits on all-or-none so a blocked attempt never leaks tokens at a level
that had them; held under one mutex so a concurrent detach can't dangle
the bucket it's using. vdm/ids.hpp gains QueueId.
Tests: burst/refill/cap/unlimited for the bucket; tightest-level-binds,
no-partial-consumption, detach-safety, and an 8-thread aggregate-rate
check for the hierarchy. Green under ASan/UBSan and TSan.
Engine-API review (DAEMON signed off, no sched/ or dispatch rewrite):
- Engine::rate_limiter() accessor added (limiter.set -> set_global_limit).
- Checksum::Algo gains sha512 to match the wire Checksum set.
- DownloadSpec: DAEMON creates save_path's parent dir before start();
missing dir -> Error::path_rejected (made explicit).
- cancel(): documented to fire on_state(_, cancelled, nullopt) then
on_finished(Err{canceled}), in that order; download.cancel ==
cancel(false), download.remove == cancel(true).
- engine-api-m1.md: the five open questions resolved with DAEMON's
answers (probe_hint optional, single cancel flag, {restart,
keep_partial, abort} is the whole set, per-task 4 Hz is fine,
refresh_url restarts all segments after a validating re-probe).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
187 lines
6.5 KiB
C++
187 lines
6.5 KiB
C++
// vdm/rate/token_bucket.hpp — a lazily-refilled token bucket, and the global -> queue ->
|
|
// task limiter hierarchy built on it (docs/04 §6).
|
|
//
|
|
// A segment worker calls RateLimiter::acquire(task, n) after receiving n body bytes. If
|
|
// every applicable level (task, its queue, global) has n tokens, it consumes n from each
|
|
// and returns 0. Otherwise it consumes nothing and returns how long to wait before
|
|
// retrying — the worker returns CURL_WRITEFUNC_PAUSE and schedules a curl_easy_pause
|
|
// resume after that delay (the "precision" layer on top of CURLOPT_MAX_RECV_SPEED_LARGE).
|
|
//
|
|
// This header compiles standalone.
|
|
|
|
#ifndef VDM_RATE_TOKEN_BUCKET_HPP
|
|
#define VDM_RATE_TOKEN_BUCKET_HPP
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <unordered_map>
|
|
|
|
#include "vdm/ids.hpp"
|
|
|
|
namespace vdm::rate {
|
|
|
|
// rate_bps == 0 means unlimited: consume() always succeeds and never waits.
|
|
class TokenBucket {
|
|
public:
|
|
TokenBucket() = default;
|
|
// `burst` caps how many tokens accumulate while idle; 0 => 1 second's worth.
|
|
explicit TokenBucket(std::uint64_t rate_bps, std::uint64_t burst = 0) {
|
|
set_rate(rate_bps, burst);
|
|
}
|
|
|
|
void set_rate(std::uint64_t rate_bps, std::uint64_t burst = 0) {
|
|
std::lock_guard lk(mu_);
|
|
const bool was_unlimited = rate_ == 0;
|
|
rate_ = rate_bps;
|
|
cap_ = burst ? burst : rate_bps; // 1 s of burst by default
|
|
// A freshly-limited bucket starts full: you may transfer at burst speed
|
|
// immediately, then it throttles (classic token bucket / IDM behaviour). Lowering
|
|
// an existing limit only clamps down — it never hands out a fresh burst.
|
|
if (rate_bps > 0 && was_unlimited)
|
|
tokens_ = cap_;
|
|
else if (tokens_ > cap_)
|
|
tokens_ = cap_;
|
|
last_ = clock::now();
|
|
}
|
|
[[nodiscard]] std::uint64_t rate() const {
|
|
std::lock_guard lk(mu_);
|
|
return rate_;
|
|
}
|
|
|
|
// Consume `n` if available; otherwise consume nothing. Returns the wait until `n`
|
|
// tokens *would* be available (0 when it consumed).
|
|
[[nodiscard]] std::chrono::nanoseconds consume(std::uint64_t n) {
|
|
std::lock_guard lk(mu_);
|
|
if (rate_ == 0)
|
|
return {};
|
|
refill_locked();
|
|
if (tokens_ >= n) {
|
|
tokens_ -= n;
|
|
return {};
|
|
}
|
|
const std::uint64_t deficit = n - tokens_;
|
|
// ns to earn `deficit` tokens at rate_ bytes/s
|
|
return std::chrono::nanoseconds{
|
|
static_cast<std::int64_t>((deficit * 1'000'000'000ull + rate_ - 1) / rate_)};
|
|
}
|
|
|
|
// Two-phase for the hierarchy: check every level, then commit on all or none.
|
|
[[nodiscard]] std::chrono::nanoseconds peek(std::uint64_t n) {
|
|
std::lock_guard lk(mu_);
|
|
if (rate_ == 0)
|
|
return {};
|
|
refill_locked();
|
|
if (tokens_ >= n)
|
|
return {};
|
|
const std::uint64_t deficit = n - tokens_;
|
|
return std::chrono::nanoseconds{
|
|
static_cast<std::int64_t>((deficit * 1'000'000'000ull + rate_ - 1) / rate_)};
|
|
}
|
|
void commit(std::uint64_t n) {
|
|
std::lock_guard lk(mu_);
|
|
if (rate_ == 0)
|
|
return;
|
|
tokens_ = tokens_ >= n ? tokens_ - n : 0;
|
|
}
|
|
|
|
private:
|
|
using clock = std::chrono::steady_clock;
|
|
void refill_locked() {
|
|
auto now = clock::now();
|
|
auto dt = std::chrono::duration_cast<std::chrono::nanoseconds>(now - last_).count();
|
|
if (dt <= 0)
|
|
return;
|
|
last_ = now;
|
|
// added = rate_ * dt / 1e9, guarding overflow for very long idle gaps
|
|
long double added =
|
|
static_cast<long double>(rate_) * static_cast<long double>(dt) / 1'000'000'000.0L;
|
|
std::uint64_t add =
|
|
added >= static_cast<long double>(cap_) ? cap_ : static_cast<std::uint64_t>(added);
|
|
tokens_ = tokens_ + add > cap_ ? cap_ : tokens_ + add;
|
|
}
|
|
|
|
mutable std::mutex mu_;
|
|
std::uint64_t rate_ = 0;
|
|
std::uint64_t cap_ = 0;
|
|
std::uint64_t tokens_ = 0;
|
|
clock::time_point last_ = clock::now();
|
|
};
|
|
|
|
// The hierarchy. All limits default to 0 (unlimited). A task with no queue is limited by
|
|
// task + global only.
|
|
class RateLimiter {
|
|
public:
|
|
void set_global_limit(std::uint64_t bps) { global_.set_rate(bps); }
|
|
[[nodiscard]] std::uint64_t global_limit() const { return global_.rate(); }
|
|
|
|
void set_queue_limit(QueueId q, std::uint64_t bps) {
|
|
std::lock_guard lk(mu_);
|
|
queues_[q].set_rate(bps);
|
|
}
|
|
void set_task_limit(TaskId t, std::uint64_t bps) {
|
|
std::lock_guard lk(mu_);
|
|
tasks_[t].set_rate(bps);
|
|
}
|
|
|
|
void attach_task(TaskId t, std::optional<QueueId> q) {
|
|
std::lock_guard lk(mu_);
|
|
tasks_.try_emplace(t);
|
|
if (q) {
|
|
task_queue_[t] = *q;
|
|
queues_.try_emplace(*q);
|
|
} else {
|
|
task_queue_.erase(t);
|
|
}
|
|
}
|
|
void detach_task(TaskId t) {
|
|
std::lock_guard lk(mu_);
|
|
tasks_.erase(t);
|
|
task_queue_.erase(t);
|
|
}
|
|
|
|
// Consume `n` bytes against task, queue and global. 0 => consumed everywhere. > 0 =>
|
|
// consumed nowhere; wait that long and retry. Held under mu_ for its whole duration
|
|
// so a concurrent detach_task() can't invalidate the bucket it is using.
|
|
[[nodiscard]] std::chrono::nanoseconds acquire(TaskId t, std::uint64_t n) {
|
|
std::lock_guard lk(mu_);
|
|
TokenBucket *tb = nullptr;
|
|
TokenBucket *qb = nullptr;
|
|
if (auto it = tasks_.find(t); it != tasks_.end())
|
|
tb = &it->second;
|
|
if (auto qit = task_queue_.find(t); qit != task_queue_.end())
|
|
if (auto q = queues_.find(qit->second); q != queues_.end())
|
|
qb = &q->second;
|
|
|
|
// peek all, then commit all or none — no level "leaks" tokens on a partial miss.
|
|
std::chrono::nanoseconds wait{};
|
|
if (tb)
|
|
wait = std::max(wait, tb->peek(n));
|
|
if (qb)
|
|
wait = std::max(wait, qb->peek(n));
|
|
wait = std::max(wait, global_.peek(n));
|
|
if (wait.count() > 0)
|
|
return wait;
|
|
|
|
if (tb)
|
|
tb->commit(n);
|
|
if (qb)
|
|
qb->commit(n);
|
|
global_.commit(n);
|
|
return {};
|
|
}
|
|
|
|
private:
|
|
mutable std::mutex mu_; // guards the maps AND serialises acquire()
|
|
TokenBucket global_;
|
|
std::unordered_map<QueueId, TokenBucket> queues_;
|
|
std::unordered_map<TaskId, TokenBucket> tasks_;
|
|
std::unordered_map<TaskId, QueueId> task_queue_;
|
|
};
|
|
|
|
} // namespace vdm::rate
|
|
|
|
#endif // VDM_RATE_TOKEN_BUCKET_HPP
|