Skip to main content

submilli_engine/runtime/
disk_quota.rs

1//! The blueprint's `size_limit`: a byte budget for the files in one VFS.
2//!
3//! Writers reserve before they write and release what they free, so the count
4//! tracks the directory without walking it. Files a program holds open are
5//! tracked too: an open file's data stays on disk after its name is removed, so
6//! its bytes are freed when the last handle closes rather than when the name goes.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
12
13use crate::runtime::fs::FileIdentity;
14
15/// A byte budget for the files in one VFS.
16#[derive(Debug)]
17pub struct DiskQuota {
18    limit: u64,
19    used: AtomicU64,
20    /// The directory couldn't be measured, so nothing that grows it fits.
21    unmeasured: bool,
22    /// Files a reader or writer holds open.
23    open: Mutex<HashMap<FileIdentity, OpenFile>>,
24}
25
26#[derive(Debug, Default)]
27struct OpenFile {
28    readers: u32,
29    writer: bool,
30    /// Bytes whose name is gone, freed once no handle holds the file.
31    pending: u64,
32}
33
34/// What holds a file open: a reader, or the writer that created it and settles
35/// its bytes itself.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Holder {
38    Reader,
39    Writer,
40}
41
42impl DiskQuota {
43    pub fn new(limit: u64, used: u64) -> Self {
44        Self {
45            limit,
46            used: AtomicU64::new(used),
47            unmeasured: false,
48            open: Mutex::default(),
49        }
50    }
51
52    /// A budget for a directory whose usage is unknown: every write that grows it
53    /// is refused.
54    pub fn unmeasured(limit: u64) -> Self {
55        Self {
56            unmeasured: true,
57            ..Self::new(limit, 0)
58        }
59    }
60
61    pub fn limit(&self) -> u64 {
62        self.limit
63    }
64
65    pub fn used(&self) -> u64 {
66        self.used.load(Ordering::Acquire)
67    }
68
69    /// Replace measured usage while the caller exclusively owns the volume and
70    /// no outstanding reservations can change this counter.
71    pub fn set_used(&self, bytes: u64) {
72        self.used.store(bytes, Ordering::Release);
73    }
74
75    pub fn is_unmeasured(&self) -> bool {
76        self.unmeasured
77    }
78
79    /// Claim `bytes`, or refuse without claiming anything when they don't fit.
80    /// Claiming nothing always succeeds, so a write that doesn't grow the files
81    /// works even in a directory already over its limit.
82    pub fn reserve(&self, bytes: u64) -> Result<(), QuotaExceeded> {
83        if bytes == 0 {
84            return Ok(());
85        }
86        if self.unmeasured {
87            return Err(QuotaExceeded::Unmeasured { limit: self.limit });
88        }
89        let mut used = self.used.load(Ordering::Acquire);
90        loop {
91            let Some(next) = used.checked_add(bytes).filter(|next| *next <= self.limit) else {
92                return Err(QuotaExceeded::Over {
93                    limit: self.limit,
94                    used,
95                    requested: bytes,
96                });
97            };
98            match self
99                .used
100                .compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire)
101            {
102                Ok(_) => return Ok(()),
103                Err(actual) => used = actual,
104            }
105        }
106    }
107
108    /// Count bytes already on disk, whether or not they fit: they are there.
109    pub fn record(&self, bytes: u64) {
110        let _ = self
111            .used
112            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
113                Some(used.saturating_add(bytes))
114            });
115    }
116
117    pub fn release(&self, bytes: u64) {
118        let _ = self
119            .used
120            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
121                Some(used.saturating_sub(bytes))
122            });
123    }
124
125    /// Hold `file` open until the returned guard drops.
126    pub fn hold(self: &Arc<Self>, file: FileIdentity, holder: Holder) -> OpenFileGuard {
127        let mut open = self.open_files();
128        let entry = open.entry(file).or_default();
129        match holder {
130            Holder::Reader => entry.readers = entry.readers.saturating_add(1),
131            Holder::Writer => entry.writer = true,
132        }
133        OpenFileGuard {
134            quota: Arc::clone(self),
135            file,
136            holder,
137        }
138    }
139
140    /// Whether a reader or writer holds `file` open, so its bytes outlive its name.
141    pub fn is_held(&self, file: FileIdentity) -> bool {
142        self.open_files().contains_key(&file)
143    }
144
145    /// Whether `file` is a writer's own, still unsettled file.
146    fn is_writers_own(&self, file: FileIdentity) -> bool {
147        self.open_files().get(&file).is_some_and(|held| held.writer)
148    }
149
150    /// A regular file's name was removed or replaced: free its `bytes` now, or
151    /// when the last reader holding it closes. A writer's own file is left for
152    /// the writer to settle.
153    pub fn release_file(&self, file: FileIdentity, bytes: u64) {
154        let mut open = self.open_files();
155        match open.get_mut(&file) {
156            Some(held) if held.writer => {}
157            Some(held) => held.pending = held.pending.saturating_add(bytes),
158            None => {
159                drop(open);
160                self.release(bytes);
161            }
162        }
163    }
164
165    /// Settle the bytes a writer wrote to its own file once the file has no name
166    /// left. Called by the writer while it still holds `file`, so only readers can
167    /// keep the file on disk: free the bytes now, or when the last reader closes.
168    fn release_writers_own_file(&self, file: FileIdentity, bytes: u64) {
169        let mut open = self.open_files();
170        match open.get_mut(&file) {
171            Some(held) if held.readers > 0 => held.pending = held.pending.saturating_add(bytes),
172            _ => {
173                drop(open);
174                self.release(bytes);
175            }
176        }
177    }
178
179    fn open_files(&self) -> MutexGuard<'_, HashMap<FileIdentity, OpenFile>> {
180        // A poisoned lock still holds consistent counters: every update is one
181        // assignment, so recovering it loses nothing.
182        self.open.lock().unwrap_or_else(PoisonError::into_inner)
183    }
184}
185
186/// Keeps a file registered as open with a [`DiskQuota`] while a handle holds it.
187#[derive(Debug)]
188pub struct OpenFileGuard {
189    quota: Arc<DiskQuota>,
190    file: FileIdentity,
191    holder: Holder,
192}
193
194impl Drop for OpenFileGuard {
195    fn drop(&mut self) {
196        let mut open = self.quota.open_files();
197        let Some(held) = open.get_mut(&self.file) else {
198            return;
199        };
200        match self.holder {
201            Holder::Reader => held.readers = held.readers.saturating_sub(1),
202            Holder::Writer => held.writer = false,
203        }
204        if held.readers == 0 && !held.writer {
205            let pending = held.pending;
206            open.remove(&self.file);
207            drop(open);
208            self.quota.release(pending);
209        }
210    }
211}
212
213/// One write's claim on a [`DiskQuota`], from its first byte until it commits or
214/// is abandoned.
215///
216/// A write that replaces a file within one host call, where no program code can
217/// touch that file before it commits, may draw on the file's size first and
218/// reserve only the growth beyond it: its `allowance`. Only a file no handle holds
219/// gives an allowance, since a held file's bytes stay on disk after its name
220/// goes. A write the program can interleave with replaces nothing up front, so
221/// every byte it writes counts until it commits, and re-reads what it replaces
222/// with [`cover`](Self::cover) just before committing. [`commit`](Self::commit)
223/// then frees the replaced file. A write that truncates and rewrites a file itself
224/// ([`in_place`](Self::in_place)) draws on the old contents even while readers
225/// hold the file, since they are gone either way; a writer's own file is left for
226/// the writer to settle. Dropping an uncommitted charge gives back
227/// everything it reserved, for a write whose output is gone.
228#[derive(Debug)]
229pub struct QuotaCharge {
230    quota: Option<Arc<DiskQuota>>,
231    allowance: u64,
232    /// Allowance used so far; never more than `allowance`.
233    drawn: u64,
234    reserved: u64,
235    replaces: Replaces,
236}
237
238/// What a committed write frees.
239#[derive(Debug, Clone, Copy)]
240enum Replaces {
241    /// Nothing: the write's name reached no regular file, or reached a writer's
242    /// own file, which the writer settles.
243    Nothing,
244    /// The regular file the write's new file takes the name of, and its size: the
245    /// allowance's file until `cover` re-reads it.
246    File(FileIdentity, u64),
247    /// The old contents of the file the write rewrites itself: its allowance.
248    InPlace,
249}
250
251impl Replaces {
252    fn file(replaced: Option<(FileIdentity, u64)>) -> Self {
253        replaced.map_or(Self::Nothing, |(file, bytes)| Self::File(file, bytes))
254    }
255}
256
257impl QuotaCharge {
258    /// A charge that may draw on `replaced`, the regular file the write replaces.
259    pub fn new(quota: Option<Arc<DiskQuota>>, replaced: Option<(FileIdentity, u64)>) -> Self {
260        let allowance = match (&quota, replaced) {
261            (Some(quota), Some((file, bytes))) if !quota.is_held(file) => bytes,
262            _ => 0,
263        };
264        Self {
265            quota,
266            allowance,
267            drawn: 0,
268            reserved: 0,
269            replaces: Replaces::file(replaced),
270        }
271    }
272
273    /// A charge for rewriting `rewritten`, a regular file and its size, in place:
274    /// the same file takes the new contents, so the old ones are freed at once even
275    /// while readers hold it. A writer's own file is the writer's to settle, so
276    /// rewriting it frees nothing and every new byte counts.
277    pub fn in_place(quota: Option<Arc<DiskQuota>>, rewritten: Option<(FileIdentity, u64)>) -> Self {
278        let (allowance, replaces) = match (&quota, rewritten) {
279            (Some(quota), Some((file, bytes))) if !quota.is_writers_own(file) => {
280                (bytes, Replaces::InPlace)
281            }
282            _ => (0, Replaces::Nothing),
283        };
284        Self {
285            quota,
286            allowance,
287            drawn: 0,
288            reserved: 0,
289            replaces,
290        }
291    }
292
293    /// Claim `bytes` about to be written: from the allowance first, the rest from
294    /// the quota.
295    pub fn reserve(&mut self, bytes: u64) -> Result<(), QuotaExceeded> {
296        let from_allowance = bytes.min(self.allowance.saturating_sub(self.drawn));
297        let beyond = bytes.saturating_sub(from_allowance);
298        if let Some(quota) = &self.quota {
299            quota.reserve(beyond)?;
300        }
301        self.drawn = self.drawn.saturating_add(from_allowance);
302        self.reserved = self.reserved.saturating_add(beyond);
303        Ok(())
304    }
305
306    /// Give back bytes claimed by the last [`reserve`](Self::reserve) that were
307    /// never written, as after a short write.
308    pub fn unreserve(&mut self, bytes: u64) {
309        let from_reserved = bytes.min(self.reserved);
310        if let Some(quota) = &self.quota {
311            quota.release(from_reserved);
312        }
313        self.reserved = self.reserved.saturating_sub(from_reserved);
314        self.drawn = self
315            .drawn
316            .saturating_sub(bytes.saturating_sub(from_reserved));
317    }
318
319    /// Settle against the file the write is about to replace, as it stands now:
320    /// whatever the write drew from its allowance beyond that file's size must be
321    /// reserved after all, or the write refused.
322    pub fn cover(&mut self, replaced: Option<(FileIdentity, u64)>) -> Result<(), QuotaExceeded> {
323        let replaced_bytes = replaced.map_or(0, |(_, bytes)| bytes);
324        let uncovered = self.drawn.saturating_sub(replaced_bytes);
325        if let Some(quota) = &self.quota {
326            quota.reserve(uncovered)?;
327        }
328        self.drawn = self.drawn.saturating_sub(uncovered);
329        self.reserved = self.reserved.saturating_add(uncovered);
330        self.replaces = Replaces::file(replaced);
331        Ok(())
332    }
333
334    /// The write landed: free what it replaced beyond the allowance it drew on.
335    /// A replaced file goes once no handle holds it; old contents rewritten in
336    /// place go at once.
337    pub fn commit(mut self) {
338        let Some(quota) = self.quota.take() else {
339            return;
340        };
341        match self.replaces {
342            Replaces::Nothing => {}
343            Replaces::File(file, bytes) => {
344                quota.release_file(file, bytes.saturating_sub(self.drawn));
345            }
346            Replaces::InPlace => quota.release(self.allowance.saturating_sub(self.drawn)),
347        }
348    }
349
350    /// The write failed partway, leaving its file at `left` bytes: count the file
351    /// at that size. A file rewritten in place had its old contents counted too;
352    /// otherwise no more than was reserved can be the write's own.
353    pub fn settle_at(mut self, left: u64) {
354        let Some(quota) = self.quota.take() else {
355            return;
356        };
357        match self.replaces {
358            Replaces::InPlace => {
359                let counted = self.allowance.saturating_add(self.reserved);
360                if left < counted {
361                    quota.release(counted - left);
362                } else {
363                    quota.record(left - counted);
364                }
365            }
366            Replaces::Nothing | Replaces::File(..) => {
367                quota.release(self.reserved.saturating_sub(left));
368            }
369        }
370    }
371
372    /// The write failed but its bytes may still be on disk, under a name the
373    /// program gave them: keep them counted rather than giving them back.
374    pub fn keep(mut self) {
375        self.quota = None;
376    }
377
378    /// The write failed and its file, `written`, has no name left: its bytes go
379    /// once every handle on it closes.
380    pub fn release_when_closed(mut self, written: FileIdentity) {
381        if let Some(quota) = self.quota.take() {
382            quota.release_writers_own_file(written, self.reserved);
383        }
384    }
385}
386
387impl Drop for QuotaCharge {
388    fn drop(&mut self) {
389        if let Some(quota) = self.quota.take() {
390            quota.release(self.reserved);
391        }
392    }
393}
394
395/// A write refused by the VFS's size limit: over it, or unable to tell because
396/// the directory couldn't be measured.
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398pub enum QuotaExceeded {
399    /// The write would take the files past the limit.
400    Over {
401        limit: u64,
402        used: u64,
403        requested: u64,
404    },
405    /// The directory couldn't be measured when the run started, so it is
406    /// treated as full.
407    Unmeasured { limit: u64 },
408}
409
410impl fmt::Display for QuotaExceeded {
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        match self {
413            Self::Over {
414                limit,
415                used,
416                requested,
417            } => write!(
418                f,
419                "the filesystem's size limit of {limit} bytes would be exceeded: {used} bytes are in use and this needs {requested} more"
420            ),
421            Self::Unmeasured { limit } => write!(
422                f,
423                "the filesystem couldn't be measured against its size limit of {limit} bytes, so it is treated as full until a run can measure it"
424            ),
425        }
426    }
427}
428
429impl std::error::Error for QuotaExceeded {}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn quota_reserves_within_the_limit_and_refuses_past_it() {
437        let quota = DiskQuota::new(100, 40);
438        quota.reserve(60).expect("fits exactly");
439        let refused = quota.reserve(1).expect_err("one byte over");
440        assert_eq!(
441            refused,
442            QuotaExceeded::Over {
443                limit: 100,
444                used: 100,
445                requested: 1,
446            }
447        );
448        quota.release(30);
449        assert_eq!(quota.used(), 70);
450        quota.release(1_000);
451        assert_eq!(quota.used(), 0, "release never underflows");
452    }
453
454    #[test]
455    fn a_dropped_charge_gives_back_what_it_reserved() {
456        let quota = Arc::new(DiskQuota::new(100, 0));
457        let mut charge = QuotaCharge::new(Some(Arc::clone(&quota)), None);
458        charge.reserve(70).expect("fits");
459        assert_eq!(quota.used(), 70);
460        drop(charge);
461        assert_eq!(quota.used(), 0);
462    }
463
464    #[test]
465    fn a_charge_covers_a_replaced_file_that_shrank() {
466        let quota = Arc::new(DiskQuota::new(100, 90));
467        let file = identity();
468        let mut charge = QuotaCharge::new(Some(Arc::clone(&quota)), Some((file, 90)));
469        charge.reserve(90).expect("drawn from the allowance");
470        assert_eq!(quota.used(), 90);
471        // Something empties the file before the write commits, freeing its bytes;
472        // the 90 the write drew on them must now be reserved after all.
473        quota.release(90);
474        charge
475            .cover(Some((file, 0)))
476            .expect("fits once the file is empty");
477        charge.commit();
478        assert_eq!(quota.used(), 90);
479        let mut again = QuotaCharge::new(Some(Arc::clone(&quota)), Some((file, 90)));
480        again.reserve(90).expect("drawn from the allowance");
481        assert!(
482            again.cover(Some((file, 0))).is_err(),
483            "the file shrank and nothing freed its bytes"
484        );
485    }
486
487    #[test]
488    fn a_held_file_gives_no_allowance() {
489        let quota = Arc::new(DiskQuota::new(100, 60));
490        let file = identity();
491        let reader = quota.hold(file, Holder::Reader);
492        let mut charge = QuotaCharge::new(Some(Arc::clone(&quota)), Some((file, 60)));
493        assert!(
494            charge.reserve(60).is_err(),
495            "the replaced file stays on disk while the reader holds it"
496        );
497        charge.reserve(40).expect("the growth beyond it fits");
498        charge.commit();
499        assert_eq!(quota.used(), 100, "the old file is still counted");
500        drop(reader);
501        assert_eq!(quota.used(), 40, "and freed when the reader closes");
502    }
503
504    #[test]
505    fn a_file_rewritten_in_place_frees_its_old_bytes_while_held() {
506        let quota = Arc::new(DiskQuota::new(100, 60));
507        let file = identity();
508        let _reader = quota.hold(file, Holder::Reader);
509        let mut charge = QuotaCharge::in_place(Some(Arc::clone(&quota)), Some((file, 60)));
510        charge.reserve(30).expect("drawn from the old contents");
511        charge.commit();
512        assert_eq!(quota.used(), 30, "the old contents are gone, held or not");
513    }
514
515    #[test]
516    fn a_failed_rewrite_in_place_counts_what_it_left() {
517        let quota = Arc::new(DiskQuota::new(100, 40));
518        let mut charge = QuotaCharge::in_place(Some(Arc::clone(&quota)), Some((identity(), 40)));
519        charge.reserve(50).expect("room for the new contents");
520        assert_eq!(quota.used(), 50);
521        charge.settle_at(20);
522        assert_eq!(
523            quota.used(),
524            20,
525            "the old contents are gone, 20 new bytes landed"
526        );
527
528        let untouched = Arc::new(DiskQuota::new(100, 40));
529        let mut charge =
530            QuotaCharge::in_place(Some(Arc::clone(&untouched)), Some((identity(), 40)));
531        charge.reserve(10).expect("drawn from the old contents");
532        charge.settle_at(40);
533        assert_eq!(
534            untouched.used(),
535            40,
536            "a copy that failed before truncating left the file"
537        );
538    }
539
540    #[test]
541    fn a_writers_own_file_rewritten_in_place_is_left_for_the_writer() {
542        let quota = Arc::new(DiskQuota::new(100, 60));
543        let file = identity();
544        let _writer = quota.hold(file, Holder::Writer);
545        let mut charge = QuotaCharge::in_place(Some(Arc::clone(&quota)), Some((file, 60)));
546        charge.reserve(30).expect("room for the new contents");
547        charge.commit();
548        assert_eq!(
549            quota.used(),
550            90,
551            "the writer's charge still holds the old contents"
552        );
553    }
554
555    #[test]
556    fn a_removed_file_held_open_stays_counted_until_it_closes() {
557        let quota = Arc::new(DiskQuota::new(100, 60));
558        let file = identity();
559        let reader = quota.hold(file, Holder::Reader);
560        quota.release_file(file, 60);
561        assert_eq!(quota.used(), 60, "an open file's bytes are still on disk");
562        drop(reader);
563        assert_eq!(quota.used(), 0);
564    }
565
566    #[test]
567    fn a_writers_own_file_is_left_for_the_writer_to_settle() {
568        let quota = Arc::new(DiskQuota::new(100, 0));
569        let file = identity();
570        let writer = quota.hold(file, Holder::Writer);
571        quota.record(40);
572        quota.release_file(file, 40);
573        assert_eq!(quota.used(), 40);
574        drop(writer);
575        assert_eq!(
576            quota.used(),
577            40,
578            "releasing it is the writer's charge's job"
579        );
580    }
581
582    /// The identity of a fresh file, for tests that need one.
583    fn identity() -> FileIdentity {
584        let file = tempfile::tempfile().expect("tempfile");
585        let metadata = cap_std::fs::Metadata::from_file(&file).expect("metadata");
586        FileIdentity::of(&metadata).expect("identity")
587    }
588}