Skip to main content

mitosis/
budget.rs

1//! RAM and disk budgets, the clone cap and the FIFO queue that creates wait in.
2
3use crate::clone_backend::free_space;
4use crate::ctx::Ctx;
5use crate::error::{ErrorCode, MitosisError, Result};
6use crate::events;
7use crate::proc_util::{process_start_time, rss_bytes};
8use crate::state::CloneStatus;
9use serde::{Deserialize, Serialize};
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13use tokio::sync::OwnedMutexGuard;
14
15/// What a new clone is assumed to need when no clone is running to measure.
16const DEFAULT_RAM_ESTIMATE: u64 = 64 * 1024 * 1024;
17/// A waiting create looks again at least this often, in case a clone exited without a destroy.
18const RECHECK: Duration = Duration::from_secs(1);
19/// A create queued behind others gives up on the line after at least this long, even with no wait.
20const MIN_LINE_WAIT: Duration = Duration::from_millis(500);
21
22#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
23pub struct CloneUsage {
24    pub id: String,
25    pub name: Option<String>,
26    pub template: String,
27    pub rss_bytes: Option<u64>,
28    pub own_disk_bytes: Option<u64>,
29    pub created_at: i64,
30    pub expires_at: i64,
31    pub protected: bool,
32    pub disk_warn: bool,
33    /// `ready`, `sleeping` or `starting`.
34    #[serde(default)]
35    pub status: String,
36}
37
38#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
39pub struct Usage {
40    pub clones: Vec<CloneUsage>,
41    /// Memory of the running clones (a clone still starting counts as one estimate).
42    pub ram_bytes: u64,
43    pub disk_bytes: u64,
44    pub free_disk_bytes: u64,
45    /// Creates waiting for room.
46    pub queued: usize,
47    pub max_clones: usize,
48    pub ram_budget: Option<u64>,
49    pub disk_budget: Option<u64>,
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum Limit {
54    Count,
55    Ram,
56    Disk,
57}
58
59impl Limit {
60    fn name(self) -> &'static str {
61        match self {
62            Limit::Count => "count",
63            Limit::Ram => "ram",
64            Limit::Disk => "disk",
65        }
66    }
67}
68
69/// The limit a new clone would break, if any.
70pub fn fits(usage: &Usage, new_ram_estimate: u64) -> Option<Limit> {
71    if usage.clones.len() >= usage.max_clones {
72        return Some(Limit::Count);
73    }
74    if usage
75        .ram_budget
76        .is_some_and(|b| usage.ram_bytes.saturating_add(new_ram_estimate) > b)
77    {
78        return Some(Limit::Ram);
79    }
80    if usage.disk_budget.is_some_and(|b| usage.disk_bytes >= b) {
81        return Some(Limit::Disk);
82    }
83    None
84}
85
86/// What one more clone is expected to use: the average of the running ones.
87pub fn ram_estimate(running_rss: &[u64]) -> u64 {
88    if running_rss.is_empty() {
89        return DEFAULT_RAM_ESTIMATE;
90    }
91    running_rss.iter().sum::<u64>() / running_rss.len() as u64
92}
93
94fn human(b: u64) -> String {
95    match b {
96        0..=999 => format!("{b} B"),
97        1_000..=999_999 => format!("{:.0} kB", b as f64 / 1e3),
98        1_000_000..=999_999_999 => format!("{:.0} MB", b as f64 / 1e6),
99        _ => format!("{:.1} GB", b as f64 / 1e9),
100    }
101}
102
103/// Reads the store and (for the resources asked for) the processes and disk. Blocking.
104fn collect(ctx: &Ctx, want_ram: bool, want_disk: bool) -> Result<(Usage, u64)> {
105    let cfg = ctx.config();
106    let rows: Vec<_> = ctx
107        .store()
108        .list_clones()?
109        .into_iter()
110        .filter(|c| c.status != CloneStatus::Stopping)
111        .collect();
112    // A Sleeping row with an operation running is waking (or going to sleep): its server uses
113    // memory while the row still says Sleeping.
114    let busy: std::collections::HashSet<String> = ctx
115        .in_flight
116        .lock()
117        .unwrap_or_else(|e| e.into_inner())
118        .keys()
119        .cloned()
120        .collect();
121    let waking =
122        |r: &crate::state::CloneRow| r.status == CloneStatus::Sleeping && busy.contains(&r.id);
123    let mut clones = Vec::with_capacity(rows.len());
124    for r in &rows {
125        let rss = r
126            .pg_pid
127            .filter(|_| want_ram && (r.status == CloneStatus::Ready || waking(r)))
128            .filter(|pid| process_start_time(*pid) == r.pg_start_time)
129            .and_then(rss_bytes);
130        let own = want_disk
131            .then(|| ctx.backend.private_size(&r.dir))
132            .flatten();
133        clones.push(CloneUsage {
134            id: r.id.clone(),
135            name: r.name.clone(),
136            template: r.template.clone(),
137            rss_bytes: rss,
138            own_disk_bytes: own,
139            created_at: r.created_at,
140            expires_at: r.expires_at,
141            protected: r.protected,
142            disk_warn: own.is_some_and(|b| b > cfg.clone_disk_warn),
143            status: match r.status {
144                CloneStatus::Ready => "ready",
145                CloneStatus::Sleeping => "sleeping",
146                _ => "starting",
147            }
148            .into(),
149        });
150    }
151    let running: Vec<u64> = clones.iter().filter_map(|c| c.rss_bytes).collect();
152    let estimate = ram_estimate(&running);
153    // A clone that is still starting (or waking) has no reading yet, but it will use memory. A
154    // sleeping one uses none: it counts for the cap and the disk only.
155    let unread = if want_ram {
156        rows.iter()
157            .zip(&clones)
158            .filter(|(r, c)| {
159                (r.status != CloneStatus::Sleeping || waking(r)) && c.rss_bytes.is_none()
160            })
161            .count() as u64
162    } else {
163        0
164    };
165    let usage = Usage {
166        ram_bytes: running
167            .iter()
168            .sum::<u64>()
169            .saturating_add(estimate.saturating_mul(unread)),
170        disk_bytes: clones.iter().filter_map(|c| c.own_disk_bytes).sum(),
171        free_disk_bytes: free_space(&ctx.home.clones_dir()).unwrap_or(0),
172        queued: ctx.queued.load(Ordering::SeqCst),
173        max_clones: cfg.max_clones,
174        ram_budget: cfg.ram_budget,
175        disk_budget: cfg.disk_budget,
176        clones,
177    };
178    Ok((usage, estimate))
179}
180
181async fn snapshot(ctx: &Arc<Ctx>, full: bool) -> Result<(Usage, u64)> {
182    let c = ctx.clone();
183    let (ram, disk) = {
184        let cfg = ctx.config();
185        (
186            full || cfg.ram_budget.is_some(),
187            full || cfg.disk_budget.is_some(),
188        )
189    };
190    tokio::task::spawn_blocking(move || collect(&c, ram, disk))
191        .await
192        .map_err(|e| {
193            MitosisError::new(ErrorCode::Internal, format!("background task failed: {e}"))
194        })?
195}
196
197/// Everything `mitosis top` shows.
198pub async fn usage(ctx: &Arc<Ctx>) -> Result<Usage> {
199    snapshot(ctx, true).await.map(|(u, _)| u)
200}
201
202/// The setting that waking one more sleeping clone without a request (gc's wakes) would break,
203/// if any: a wake adds a running server, so only awake clones count toward `max_clones`, and its
204/// memory toward `ram_budget`; disk does not grow.
205pub(crate) async fn wake_blocked(ctx: &Arc<Ctx>) -> Result<Option<&'static str>> {
206    let (mut usage, estimate) = snapshot(ctx, false).await?;
207    usage.clones.retain(|c| c.status != "sleeping");
208    usage.disk_budget = None;
209    Ok(fits(&usage, estimate).map(|limit| match limit {
210        Limit::Count => "max_clones",
211        Limit::Ram => "ram_budget",
212        Limit::Disk => "disk_budget",
213    }))
214}
215
216/// `max_clones` is reached: names the setting and up to three of the clones (`labels`: names,
217/// else ids).
218pub(crate) fn cap_reached(max_clones: usize, labels: &[String]) -> MitosisError {
219    let mut shown = labels
220        .iter()
221        .take(3)
222        .cloned()
223        .collect::<Vec<_>>()
224        .join(", ");
225    if labels.len() > 3 {
226        shown.push_str(&format!(" and {} more", labels.len() - 3));
227    }
228    let of = if shown.is_empty() {
229        String::new()
230    } else {
231        format!(": {shown}")
232    };
233    MitosisError::new(
234        ErrorCode::CapReached,
235        format!("clone limit reached (max_clones = {max_clones}){of}"),
236    )
237    .with_hint(
238        "destroy one with `mitosis destroy <id-or-name>` (or the clone_destroy tool), or raise max_clones (mitosis config set max_clones <n>)",
239    )
240}
241
242/// The error for a create that ran out of wait: names the limit and the biggest clones.
243fn exhausted(u: &Usage, limit: Limit, estimate: u64) -> MitosisError {
244    // `; largest: a (1 GB), …`, or nothing when no clone uses any of it.
245    let largest = |size: &dyn Fn(&CloneUsage) -> u64| {
246        let mut v: Vec<&CloneUsage> = u.clones.iter().filter(|c| size(c) > 0).collect();
247        v.sort_by_key(|c| std::cmp::Reverse(size(c)));
248        let top = v
249            .iter()
250            .take(3)
251            .map(|c| {
252                let label = c.name.as_deref().unwrap_or(&c.id);
253                format!("{label} ({})", human(size(c)))
254            })
255            .collect::<Vec<_>>()
256            .join(", ");
257        if top.is_empty() {
258            top
259        } else {
260            format!("; largest: {top}")
261        }
262    };
263    match limit {
264        Limit::Count => {
265            let labels: Vec<String> = u
266                .clones
267                .iter()
268                .map(|c| c.name.clone().unwrap_or_else(|| c.id.clone()))
269                .collect();
270            cap_reached(u.max_clones, &labels)
271        }
272        Limit::Ram => MitosisError::new(
273            ErrorCode::BudgetFull,
274            format!(
275                "ram_budget of {} is full: clones use {} and a new one needs about {}{}",
276                human(u.ram_budget.unwrap_or(0)),
277                human(u.ram_bytes),
278                human(estimate),
279                largest(&|c| c.rss_bytes.unwrap_or(0)),
280            ),
281        )
282        .with_hint("destroy a clone, wait for one to expire, or raise it: mitosis config set ram_budget <size>"),
283        Limit::Disk => MitosisError::new(
284            ErrorCode::BudgetFull,
285            format!(
286                "disk_budget of {} is full: clones use {}{}",
287                human(u.disk_budget.unwrap_or(0)),
288                human(u.disk_bytes),
289                largest(&|c| c.own_disk_bytes.unwrap_or(0)),
290            ),
291        )
292        .with_hint("destroy a clone, or raise it: mitosis config set disk_budget <size>"),
293    }
294}
295
296/// Counts a create as queued while it waits.
297struct InQueue<'a>(&'a AtomicUsize);
298
299impl<'a> InQueue<'a> {
300    fn new(n: &'a AtomicUsize) -> Self {
301        n.fetch_add(1, Ordering::SeqCst);
302        InQueue(n)
303    }
304}
305
306impl Drop for InQueue<'_> {
307    fn drop(&mut self) {
308        self.0.fetch_sub(1, Ordering::SeqCst);
309    }
310}
311
312/// Permission to reserve one clone row. Hold it until the row exists: the next create is not
313/// checked against the limits before then.
314pub struct Admission {
315    _turn: OwnedMutexGuard<()>,
316    pub waited: Duration,
317}
318
319tokio::task_local! {
320    /// Turns true when the client that sent the running request has hung up.
321    pub(crate) static CLIENT_GONE: tokio::sync::watch::Receiver<bool>;
322    /// Turns true when the daemon starts shutting down.
323    pub(crate) static DAEMON_STOPPING: tokio::sync::watch::Receiver<bool>;
324}
325
326/// Resolves when the daemon starts shutting down; never, outside a daemon request.
327async fn daemon_stopping() {
328    if let Ok(mut rx) = DAEMON_STOPPING.try_with(|r| r.clone()) {
329        if rx.wait_for(|stopping| *stopping).await.is_ok() {
330            return;
331        }
332    }
333    std::future::pending::<()>().await
334}
335
336/// Resolves when the client of the current request hangs up; never, outside a daemon request.
337async fn client_gone() {
338    if let Ok(mut rx) = CLIENT_GONE.try_with(|r| r.clone()) {
339        if rx.wait_for(|gone| *gone).await.is_ok() {
340            return;
341        }
342    }
343    std::future::pending::<()>().await
344}
345
346/// Waits in line (first come, first served) until a new clone fits the limits, at most `wait`.
347/// Wakes on `ctx.room`, and looks again every second as a fallback. If the client hangs up or the
348/// daemon starts shutting down while it waits, it leaves the line (dropping its place); once
349/// admitted it is not cancelled.
350pub async fn admit(ctx: &Arc<Ctx>, wait: Duration) -> Result<Admission> {
351    tokio::select! {
352        r = admit_in_line(ctx, wait) => r,
353        () = client_gone() => Err(MitosisError::new(
354            ErrorCode::InvalidRequest,
355            "the client disconnected while the create was queued",
356        )),
357        () = daemon_stopping() => Err(MitosisError::new(
358            ErrorCode::DaemonUnavailable,
359            "the daemon is shutting down",
360        )
361        .with_hint("try again: the next command starts the daemon")),
362    }
363}
364
365/// Puts the idle clone with the oldest activity to sleep (reason `evicted`); false when no clone
366/// can be slept. A clone with an open connection is never chosen. Needs the router listening,
367/// which is what wakes a sleeping clone.
368async fn sleep_least_recent_idle(ctx: &Arc<Ctx>) -> bool {
369    if !crate::router::listening(ctx) {
370        return false;
371    }
372    let mut idle = match crate::lifecycle::idle_clones(ctx, crate::templates::now()).await {
373        Ok(idle) => idle,
374        Err(e) => {
375            crate::lifecycle::log(ctx, &format!("eviction: {e}"));
376            return false;
377        }
378    };
379    idle.retain(|(r, _)| r.status == CloneStatus::Ready);
380    idle.sort_by_key(|(_, since)| *since);
381    for (row, _) in idle {
382        match crate::clones::sleep(ctx, &row.id, events::reason::EVICTED).await {
383            Ok(()) => return true,
384            Err(e) => crate::lifecycle::log(ctx, &format!("eviction: clone {}: {e}", row.id)),
385        }
386    }
387    false
388}
389
390async fn admit_in_line(ctx: &Arc<Ctx>, wait: Duration) -> Result<Admission> {
391    let start = Instant::now();
392    let _counted = InQueue::new(&ctx.queued);
393    // tokio's mutex is fair: creates get their turn in the order they asked.
394    let turn =
395        tokio::time::timeout(wait.max(MIN_LINE_WAIT), ctx.admission.clone().lock_owned()).await;
396    let Ok(turn) = turn else {
397        let (u, est) = snapshot(ctx, false).await?;
398        let limit = fits(&u, est);
399        events::emit(
400            ctx,
401            events::QUEUE_TIMEOUT,
402            None,
403            None,
404            serde_json::json!({
405                "limit": limit.map(Limit::name),
406                "waited_ms": u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
407                "behind_other_creates": limit.is_none(),
408            }),
409        );
410        return Err(limit.map_or_else(
411            || {
412                MitosisError::new(
413                    ErrorCode::CapReached,
414                    "other creates are still ahead of this one in the queue",
415                )
416                .with_hint("try again, or raise the wait: mitosis clone --wait <duration>")
417            },
418            |l| exhausted(&u, l, est),
419        ));
420    };
421    let deadline = start + wait;
422    let mut waited_for_room = false;
423    loop {
424        // Registered before the check, so a destroy right after it is not missed.
425        let woken = ctx.room.notified();
426        tokio::pin!(woken);
427        woken.as_mut().enable();
428        let (u, est) = snapshot(ctx, false).await?;
429        let limit = fits(&u, est);
430        // Only the RAM budget is in the way: sleeping an idle clone may make room at once.
431        if limit == Some(Limit::Ram)
432            && u.disk_budget.is_none_or(|b| u.disk_bytes < b)
433            && sleep_least_recent_idle(ctx).await
434        {
435            continue;
436        }
437        let Some(limit) = limit else {
438            if waited_for_room {
439                events::emit(
440                    ctx,
441                    events::QUEUE_WAITED,
442                    None,
443                    None,
444                    serde_json::json!({
445                        "waited_ms": u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
446                    }),
447                );
448            }
449            return Ok(Admission {
450                _turn: turn,
451                waited: start.elapsed(),
452            });
453        };
454        let left = deadline.saturating_duration_since(Instant::now());
455        if left.is_zero() {
456            events::emit(
457                ctx,
458                events::QUEUE_TIMEOUT,
459                None,
460                None,
461                serde_json::json!({
462                    "limit": limit.name(),
463                    "waited_ms": u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
464                }),
465            );
466            return Err(exhausted(&u, limit, est));
467        }
468        waited_for_room = true;
469        let _ = tokio::time::timeout(left.min(RECHECK), woken).await;
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn c(id: &str, rss: Option<u64>, disk: Option<u64>) -> CloneUsage {
478        CloneUsage {
479            id: id.into(),
480            name: None,
481            template: "main".into(),
482            rss_bytes: rss,
483            own_disk_bytes: disk,
484            created_at: 0,
485            expires_at: 0,
486            protected: false,
487            disk_warn: false,
488            status: "ready".into(),
489        }
490    }
491
492    fn usage(n: usize, ram: u64, disk: u64) -> Usage {
493        Usage {
494            clones: (0..n).map(|i| c(&i.to_string(), None, None)).collect(),
495            ram_bytes: ram,
496            disk_bytes: disk,
497            free_disk_bytes: 0,
498            queued: 0,
499            max_clones: 3,
500            ram_budget: None,
501            disk_budget: None,
502        }
503    }
504
505    #[test]
506    fn fits_cases() {
507        assert_eq!(fits(&usage(2, 0, 0), 10), None);
508        assert_eq!(fits(&usage(3, 0, 0), 10), Some(Limit::Count));
509        // Budgets off: any RAM or disk figure passes.
510        assert_eq!(fits(&usage(1, u64::MAX / 2, u64::MAX), u64::MAX / 2), None);
511
512        let mut u = usage(1, 90, 0);
513        u.ram_budget = Some(100);
514        assert_eq!(fits(&u, 10), None); // 90 + 10 is exactly the budget
515        assert_eq!(fits(&u, 11), Some(Limit::Ram));
516
517        let mut u = usage(1, 0, 99);
518        u.disk_budget = Some(100);
519        assert_eq!(fits(&u, 10), None);
520        u.disk_bytes = 100;
521        assert_eq!(fits(&u, 10), Some(Limit::Disk));
522
523        // The count is checked first.
524        let mut u = usage(3, 1000, 1000);
525        u.ram_budget = Some(1);
526        u.disk_budget = Some(1);
527        assert_eq!(fits(&u, 10), Some(Limit::Count));
528    }
529
530    #[test]
531    fn ram_estimate_average_and_default() {
532        assert_eq!(ram_estimate(&[]), 64 * 1024 * 1024);
533        assert_eq!(ram_estimate(&[100, 200, 300]), 200);
534        assert_eq!(ram_estimate(&[7]), 7);
535    }
536
537    #[test]
538    fn exhausted_names_limit_and_largest_three() {
539        let mut u = usage(0, 500, 0);
540        u.ram_budget = Some(400);
541        u.clones = vec![
542            c("a", Some(10), None),
543            c("b", Some(400), None),
544            c("c", Some(50), None),
545            c("d", Some(40), None),
546        ];
547        let e = exhausted(&u, Limit::Ram, 100);
548        assert_eq!(e.code, ErrorCode::BudgetFull);
549        assert!(e.message.contains("ram_budget"), "{}", e.message);
550        let at = |id: &str| e.message.find(&format!("{id} (")).unwrap_or(usize::MAX);
551        assert!(at("b") < at("c") && at("c") < at("d") && at("a") == usize::MAX);
552        let e = exhausted(&u, Limit::Disk, 0);
553        assert!(e.message.contains("disk_budget"), "{}", e.message);
554        assert_eq!(exhausted(&u, Limit::Count, 0).code, ErrorCode::CapReached);
555    }
556
557    #[test]
558    fn cap_message_names_the_setting_and_at_most_three() {
559        let labels: Vec<String> = ["a", "b", "c", "d", "e"].map(String::from).to_vec();
560        let e = cap_reached(5, &labels);
561        assert_eq!(e.code, ErrorCode::CapReached);
562        assert_eq!(
563            e.message,
564            "clone limit reached (max_clones = 5): a, b, c and 2 more"
565        );
566        assert_eq!(
567            cap_reached(2, &labels[..2]).message,
568            "clone limit reached (max_clones = 2): a, b"
569        );
570        assert_eq!(
571            cap_reached(0, &[]).message,
572            "clone limit reached (max_clones = 0)"
573        );
574    }
575
576    #[test]
577    fn budget_message_has_no_empty_largest_list() {
578        let mut u = usage(0, 0, 0);
579        u.ram_budget = Some(1);
580        let e = exhausted(&u, Limit::Ram, 64);
581        assert!(!e.message.contains("largest"), "{}", e.message);
582        u.disk_budget = Some(1);
583        u.clones = vec![c("a", None, Some(0))];
584        let e = exhausted(&u, Limit::Disk, 0);
585        assert!(!e.message.contains("largest"), "{}", e.message);
586    }
587
588    #[test]
589    fn a_waking_clone_counts_for_ram() {
590        let tmp = tempfile::Builder::new()
591            .prefix("mt")
592            .tempdir_in(crate::paths::test_tmp())
593            .unwrap();
594        let ctx = Ctx::open(
595            crate::paths::Home::at(tmp.path().to_path_buf()),
596            crate::ctx::Config::default(),
597        )
598        .unwrap();
599        let row = crate::state::CloneRow {
600            id: "cb0550f".into(),
601            name: None,
602            template: "main".into(),
603            dir: ctx.home.clones_dir().join("cb0550f"),
604            port: 54320,
605            socket_dir: ctx.home.sockets_dir().join("cb0550f"),
606            password: "x".repeat(32),
607            database: "app".into(),
608            pg_pid: None,
609            pg_start_time: None,
610            parent_pid: None,
611            parent_start_time: None,
612            created_at: 0,
613            expires_at: i64::MAX,
614            durable: false,
615            status: CloneStatus::Sleeping,
616            generation: 1,
617            protected: false,
618            suspect: false,
619        };
620        ctx.store().insert_clone(&row).unwrap();
621        assert_eq!(
622            collect(&ctx, true, false).unwrap().0.ram_bytes,
623            0,
624            "asleep: no memory"
625        );
626        let _waking = crate::clones::mark(&ctx, &row.id, crate::ctx::InFlightOp::Create).unwrap();
627        assert_eq!(
628            collect(&ctx, true, false).unwrap().0.ram_bytes,
629            DEFAULT_RAM_ESTIMATE,
630            "waking: one estimate until it can be measured"
631        );
632    }
633}