lmjtfy.git / packages / budget / src / lib.rs
lib.rsannotatedlib.rssource290 lines · 9.7 KB · raw
1//! Daily budgets shared by every visitor: the arithmetic, and the messages
2//! the Worker and its Durable Object exchange. No I/O and no clock; the
3//! Durable Object supplies the day and keeps the [`Meter`]s.
4//!
5//! Spending is held before a call and settled after it, as jev-http's ledger
6//! does: the hold is the most the call can cost, so two visitors arriving
7//! together cannot both spend the last of the day.
8#![forbid(unsafe_code)]
9
10use serde::{Deserialize, Serialize};
11
12/// Days since the Unix epoch, UTC: the day Workers AI's allocation resets on.
13pub fn day(unix_ms: f64) -> u64 {
14    (unix_ms / 86_400_000.0).floor().max(0.0) as u64
15}
16
17/// A UTC day as `YYYY-MM-DD`, the form Cloudflare's analytics filter takes.
18/// (Days-to-civil, after Howard Hinnant's `civil_from_days`.)
19pub fn date(day: u64) -> String {
20    let z = day as i64 + 719_468;
21    let era = z.div_euclid(146_097);
22    let doe = z.rem_euclid(146_097);
23    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
24    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
25    let mp = (5 * doy + 2) / 153;
26    let d = doy - (153 * mp + 2) / 5 + 1;
27    let m = if mp < 10 { mp + 3 } else { mp - 9 };
28    let y = yoe + era * 400 + i64::from(m <= 2);
29    format!("{y:04}-{m:02}-{d:02}")
30}
31
32/// What has been spent, or is held, on one day.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
34pub struct Meter {
35    day: u64,
36    used: f64,
37}
38
39/// An amount set aside for a call that has not finished.
40#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
41pub struct Hold {
42    day: u64,
43    amount: f64,
44}
45
46/// The day's budget cannot cover the call.
47#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
48pub struct Exhausted {
49    pub remaining: f64,
50}
51
52impl Meter {
53    fn roll(&mut self, today: u64) {
54        if self.day != today {
55            *self = Meter { day: today, used: 0.0 };
56        }
57    }
58
59    /// What is spent or held today.
60    pub fn used(&self, today: u64) -> f64 {
61        if self.day == today { self.used } else { 0.0 }
62    }
63
64    /// Sets `amount` aside, if today's `per_day` still covers it.
65    pub fn hold(&mut self, today: u64, amount: f64, per_day: f64) -> Result<Hold, Exhausted> {
66        self.roll(today);
67        let remaining = (per_day - self.used).max(0.0);
68        if amount > remaining {
69            return Err(Exhausted { remaining });
70        }
71        self.used += amount;
72        Ok(Hold { day: today, amount })
73    }
74
75    /// Takes in a total reported from outside: the account's own figure,
76    /// which counts spending this meter never saw (other programs on the
77    /// same allocation). The meter is raised to it and never lowered by it,
78    /// because the figure lags: spending held or settled here since it was
79    /// measured is already on top.
80    pub fn observe(&mut self, today: u64, total: f64) {
81        self.roll(today);
82        self.used = self.used.max(total);
83    }
84
85    /// Replaces a hold with what the call really cost. A hold from a day that
86    /// has since ended was never counted against today, so only the cost is.
87    pub fn settle(&mut self, today: u64, hold: Hold, actual: f64) {
88        self.roll(today);
89        if hold.day == today {
90            self.used -= hold.amount;
91        }
92        self.used = (self.used + actual).max(0.0);
93    }
94}
95
96/// Which budget.
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum Which {
100    /// Workers AI neurons, for the LLM.
101    Neurons,
102    /// Dollars, for Jev.
103    JevDollars,
104}
105
106impl Which {
107    /// The key the meter is stored under.
108    pub fn key(self) -> &'static str {
109        match self {
110            Which::Neurons => "neurons",
111            Which::JevDollars => "jev_dollars",
112        }
113    }
114}
115
116/// How long a visitor's count runs before it starts again.
117pub const VISIT_WINDOW_MS: f64 = 60_000.0;
118
119/// How many requests each visitor has made in their current minute, so one
120/// person cannot spend the day's budgets for everyone. Kept in memory only:
121/// who a visitor is (their address) is never written anywhere, and a count
122/// that is lost when the object restarts only lets someone start again.
123#[derive(Debug, Default)]
124pub struct Visits(std::collections::HashMap<(String, String), (f64, u32)>);
125
126impl Visits {
127    /// Counts one request of kind `what` by `who`, and says whether it is
128    /// within `per_minute`. A request over the limit is not counted, so
129    /// hammering does not push the end of the wait further off.
130    pub fn admit(&mut self, what: &str, who: &str, now_ms: f64, per_minute: u32) -> bool {
131        // Whoever's minute is over is forgotten.
132        self.0.retain(|_, (started, _)| now_ms - *started < VISIT_WINDOW_MS);
133        let (_, count) = self.0.entry((what.to_owned(), who.to_owned())).or_insert((now_ms, 0));
134        if *count >= per_minute {
135            return false;
136        }
137        *count += 1;
138        true
139    }
140
141    /// How many visitors are being counted right now.
142    pub fn len(&self) -> usize {
143        self.0.len()
144    }
145
146    pub fn is_empty(&self) -> bool {
147        self.0.is_empty()
148    }
149}
150
151/// The Worker to the Durable Object.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum Ask {
155    Hold { which: Which, amount: f64, per_day: f64 },
156    Settle { which: Which, hold: Hold, actual: f64 },
157    /// What is used so far today, for the page.
158    Status,
159    /// One request of kind `what` by the visitor `who`: is it within
160    /// `per_minute`?
161    Visit { what: String, who: String, per_minute: u32 },
162}
163
164/// Whose count of the neurons this is.
165#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum Counted {
168    /// Cloudflare's figure for the whole account, with this Worker's own
169    /// spending since it was read on top.
170    Account,
171    /// Only what this Worker spent: the account's figure could not be read.
172    Own,
173}
174
175/// Today's use of both budgets.
176#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
177pub struct Status {
178    pub neurons: f64,
179    pub counted: Counted,
180    pub jev_dollars: f64,
181}
182
183/// The Durable Object to the Worker.
184#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum Told {
187    Held(Hold),
188    Exhausted(Exhausted),
189    Settled,
190    Status(Status),
191    /// Whether the visit is within its limit.
192    Visit(bool),
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn a_visitor_is_stopped_at_the_limit_and_starts_again_after_a_minute() {
201        let mut visits = Visits::default();
202        for n in 0..10 {
203            assert!(visits.admit("ask", "a", f64::from(n) * 1000.0, 10), "{n}");
204        }
205        assert!(!visits.admit("ask", "a", 10_000.0, 10));
206        assert!(!visits.admit("ask", "a", 59_999.0, 10));
207        // A minute after the first one, not after the last refusal.
208        assert!(visits.admit("ask", "a", 60_000.0, 10));
209    }
210
211    #[test]
212    fn each_visitor_and_each_kind_of_request_is_counted_apart() {
213        let mut visits = Visits::default();
214        assert!(visits.admit("ask", "a", 0.0, 1));
215        assert!(!visits.admit("ask", "a", 1.0, 1));
216        assert!(visits.admit("ask", "b", 1.0, 1));
217        assert!(visits.admit("glance", "a", 1.0, 1));
218    }
219
220    #[test]
221    fn nobody_is_remembered_past_their_minute() {
222        let mut visits = Visits::default();
223        visits.admit("ask", "a", 0.0, 10);
224        visits.admit("ask", "b", 30_000.0, 10);
225        assert_eq!(visits.len(), 2);
226        visits.admit("ask", "c", 61_000.0, 10);
227        assert_eq!(visits.len(), 2);
228        visits.admit("ask", "d", 95_000.0, 10);
229        assert_eq!(visits.len(), 2);
230    }
231
232    #[test]
233    fn a_day_starts_at_midnight_utc() {
234        assert_eq!(day(0.0), 0);
235        assert_eq!(day(86_399_999.0), 0);
236        assert_eq!(day(86_400_000.0), 1);
237    }
238
239    #[test]
240    fn a_day_is_written_as_a_date() {
241        assert_eq!(date(0), "1970-01-01");
242        assert_eq!(date(day(1_790_967_600_000.0)), "2026-10-02");
243        assert_eq!(date(19_782), "2024-02-29");
244        assert_eq!(date(19_783), "2024-03-01");
245    }
246
247    #[test]
248    fn an_observed_total_raises_the_meter_and_never_lowers_it() {
249        let mut meter = Meter::default();
250        // The account has spent 900 that this meter never saw.
251        meter.observe(7, 900.0);
252        let hold = meter.hold(7, 50.0, 1000.0).unwrap();
253        meter.settle(7, hold, 16.0);
254        assert_eq!(meter.used(7), 916.0);
255        // The account's figure has not caught up with the 16 yet.
256        meter.observe(7, 905.0);
257        assert_eq!(meter.used(7), 916.0);
258        // Now it has, and something else spent 30 more.
259        meter.observe(7, 946.0);
260        assert_eq!(meter.used(7), 946.0);
261        assert_eq!(meter.hold(7, 60.0, 1000.0), Err(Exhausted { remaining: 54.0 }));
262    }
263
264    #[test]
265    fn a_hold_is_refused_once_the_day_cannot_cover_it() {
266        let mut meter = Meter::default();
267        let first = meter.hold(7, 60.0, 100.0).unwrap();
268        assert_eq!(meter.hold(7, 60.0, 100.0), Err(Exhausted { remaining: 40.0 }));
269        meter.settle(7, first, 10.0);
270        assert_eq!(meter.used(7), 10.0);
271        assert!(meter.hold(7, 60.0, 100.0).is_ok());
272    }
273
274    #[test]
275    fn a_new_day_starts_empty() {
276        let mut meter = Meter::default();
277        meter.hold(7, 100.0, 100.0).unwrap();
278        assert_eq!(meter.used(8), 0.0);
279        assert!(meter.hold(8, 100.0, 100.0).is_ok());
280    }
281
282    #[test]
283    fn a_hold_settled_after_midnight_costs_only_what_was_spent() {
284        let mut meter = Meter::default();
285        let late = meter.hold(7, 50.0, 100.0).unwrap();
286        meter.hold(8, 20.0, 100.0).unwrap();
287        meter.settle(8, late, 5.0);
288        assert_eq!(meter.used(8), 25.0);
289    }
290}