The memory the daemon has already promised to commands it let through.

One free-memory reading cannot stop several agents from starting several builds at once: each is judged in the same second, each sees the same free figure, and each is told there is room. A command's memory only shows up in that figure once the command has grown into it, which for a build is tens of seconds later.

So the daemon keeps a booking for every command it has judged: the megabytes Jev's load answer says it needs. The next command is judged against what is free LESS what is booked. A booking ends when its command does. Until then it counts in full while the command is young, and less as it ages, because by then the command's real use is in the free figure itself and counting both would book the same memory twice.

Every session on the machine talks to this one daemon, so the ledger is the machine's, not a session's.

19use std::collections::HashMap;
20use std::time::{Duration, Instant};

A booking counts in full for this long after its command was let through.

23pub const COUNTED_IN_FULL_FOR: Duration = Duration::from_secs(20);

Past this age a booking counts for nothing: the command has taken what it is going to take, and the free figure shows it. Between the two, the share falls in a straight line.

28pub const COUNTED_UNTIL: Duration = Duration::from_secs(90);
30struct Booking {
31    session: String,
32    mb: f64,
33    at: Instant,
34}

The share of a booking of this age that still counts.

37fn weight(age: Duration) -> f64 {
38    if age <= COUNTED_IN_FULL_FOR {
39        1.0
40    } else if age >= COUNTED_UNTIL {
41        0.0
42    } else {
43        let fading = (COUNTED_UNTIL - COUNTED_IN_FULL_FOR).as_secs_f64();
44        1.0 - (age - COUNTED_IN_FULL_FOR).as_secs_f64() / fading
45    }
46}
48#[derive(Default)]
49pub struct Ledger {

By tool call id.

51    bookings: HashMap<String, Booking>,
52}
54impl Ledger {

Books mb megabytes for the tool call id. Booking the same call again replaces its entry, so a call is never counted twice.

57    pub fn book(&mut self, id: &str, session: &str, mb: f64, now: Instant) {
58        self.bookings.insert(id.to_owned(), Booking { session: session.to_owned(), mb, at: now });
59    }

Ends the booking of a call that has finished, failed or never ran.

62    pub fn release(&mut self, id: &str) {
63        self.bookings.remove(id);
64    }

Ends every booking of a session that has closed.

67    pub fn release_session(&mut self, session: &str) {
68        self.bookings.retain(|_, booking| booking.session != session);
69    }

The megabytes booked right now, each booking weighted by its age.

72    pub fn booked_mb(&self, now: Instant) -> f64 {
73        self.bookings.values().map(|booking| booking.mb * weight(now.saturating_duration_since(booking.at))).sum()
74    }

How many commands hold a booking.

77    pub fn running(&self) -> usize {
78        self.bookings.len()
79    }
80}
82#[cfg(test)]
83mod tests {
84    use super::*;

The case the ledger exists for.

87    #[test]
88    fn five_builds_in_one_second_are_all_counted() {
89        // The case this exists for: 8 GB free, five agents each start a
90        // 3 GB build at the same moment. On the free figure alone all five
91        // fit; with bookings, the third already does not.
92        let now = Instant::now();
93        let mut ledger = Ledger::default();
94        let free = 8000.0;
95        let mut let_through = 0;
96        for call in 0..5 {
97            if free - ledger.booked_mb(now) >= 3000.0 {
98                ledger.book(&format!("call-{call}"), "session", 3000.0, now);
99                let_through += 1;
100            }
101        }
102        assert_eq!(let_through, 2);
103        assert_eq!(ledger.booked_mb(now), 6000.0);
104    }

A booking ends with its command, or with its session, so a crashed session cannot hold memory for ever.

108    #[test]
109    fn a_finished_command_gives_its_booking_back() {
110        let now = Instant::now();
111        let mut ledger = Ledger::default();
112        ledger.book("a", "one", 3000.0, now);
113        ledger.book("b", "two", 500.0, now);
114        ledger.release("a");
115        assert_eq!(ledger.booked_mb(now), 500.0);
116        ledger.release_session("two");
117        assert_eq!(ledger.running(), 0);
118    }

A long-running command must not be counted twice: once here, and once in the machine's own figure after it has taken its memory.

122    #[test]
123    fn a_booking_fades_as_the_command_grows_into_its_memory() {
124        let at = Instant::now();
125        let mut ledger = Ledger::default();
126        ledger.book("a", "one", 1000.0, at);
127        assert_eq!(ledger.booked_mb(at + COUNTED_IN_FULL_FOR), 1000.0);
128        let halfway = COUNTED_IN_FULL_FOR + (COUNTED_UNTIL - COUNTED_IN_FULL_FOR) / 2;
129        assert!((ledger.booked_mb(at + halfway) - 500.0).abs() < 1.0);
130        assert_eq!(ledger.booked_mb(at + COUNTED_UNTIL), 0.0);
131        // Still held, though it no longer counts: only the outcome ends it.
132        assert_eq!(ledger.running(), 1);
133    }

The mod asks about a waiting command many times; its booking is keyed by the call, so it is one booking.

137    #[test]
138    fn booking_a_call_twice_counts_it_once() {
139        let now = Instant::now();
140        let mut ledger = Ledger::default();
141        ledger.book("a", "one", 3000.0, now);
142        ledger.book("a", "one", 3000.0, now);
143        assert_eq!(ledger.booked_mb(now), 3000.0);
144    }
145}