Skip to main content

gunnar_server/
push_space.rs

1//! **The receive path's half of space admission** ([`gunnar_ssh::space`]): the
2//! reader a pushed (or mirrored) pack is read through, which asks the data
3//! set's ledger before every chunk it hands on, and the sweep of spool files a
4//! crash left behind.
5//!
6//! # Where the three copies are told to the ledger
7//!
8//! The hold promises [`gunnar_ssh::PUSH_COPIES`] bytes of /data per wire byte
9//! BEFORE the byte is read, and each copy is given back the moment it is on
10//! the disk, where `statvfs` counts it:
11//!
12//! * the spool — at [`Gated`]'s `consume`, once the ingest has written what it
13//!   consumed ([`crate::storage::spooled_on_disk`] is the rule, kept beside the
14//!   code it describes);
15//! * the transaction log's frame — after `PackFrame::append` returns
16//!   (`transport::PushIngest`);
17//! * the store — when the ingest returns, by dropping the hold; the spool is
18//!   deleted by then too.
19//!
20//! # What the client is told
21//!
22//! A chunk the ledger will not grant cuts the stream with [`NoRoom`]; the
23//! ingest fails on it, its spool drops, and `PushIngest` hands the wire
24//! [`gunnar_wire::Error::Refused`] with the ledger's line, verbatim — which
25//! `git push` prints as `remote unpack failed: the data set is full right now:
26//! …`. Never `the repository is unavailable; try again`.
27
28use std::io::{BufRead, Read};
29use std::path::Path;
30
31use gunnar_ssh::space::LEASE_BYTES;
32use gunnar_ssh::SpaceHold;
33
34/// The smallest ask: one SSH window's worth.
35const MIN_LEASE: u64 = 64 * 1024;
36
37/// **The error a cut chunk is to the ingest.** Its text is the ledger's line;
38/// the caller does not parse it — [`Gated::cut`] says whether the reader cut.
39#[derive(Debug)]
40pub(crate) struct NoRoom(pub(crate) String);
41
42impl std::fmt::Display for NoRoom {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48impl std::error::Error for NoRoom {}
49
50/// **A pack's reader, held to the data set's room.**
51///
52/// Asks [`SpaceHold::admit`] for up to [`LEASE_BYTES`] whenever what it was
53/// granted is spent — one `statvfs` per 4 MiB — and never hands the ingest a
54/// byte it was not granted. It waits for the client's bytes BEFORE asking, so
55/// a push that has sent everything is never refused for room it would not
56/// have used.
57pub(crate) struct Gated<'a, 'r> {
58    inner: &'a mut (dyn BufRead + 'r),
59    hold: &'a SpaceHold,
60    /// Wire bytes granted and not yet consumed.
61    credit: u64,
62    /// Wire bytes the ingest consumed.
63    consumed: u64,
64    /// Of those, the bytes in the spool already given back to the ledger.
65    spooled: u64,
66    /// The ingest's spool threshold (`storage::SPOOL_ABOVE`).
67    spool_above: u64,
68    cut: Option<String>,
69}
70
71impl<'a, 'r> Gated<'a, 'r> {
72    pub(crate) fn new(
73        inner: &'a mut (dyn BufRead + 'r),
74        hold: &'a SpaceHold,
75        spool_above: u64,
76    ) -> Self {
77        Gated {
78            inner,
79            hold,
80            credit: 0,
81            consumed: 0,
82            spooled: 0,
83            spool_above,
84            cut: None,
85        }
86    }
87
88    /// The ledger's line, when this reader cut the stream.
89    pub(crate) fn cut(&self) -> Option<&str> {
90        self.cut.as_deref()
91    }
92
93    /// Wire bytes the ingest consumed.
94    pub(crate) fn consumed(&self) -> u64 {
95        self.consumed
96    }
97
98    fn no_room(line: &str) -> std::io::Error {
99        std::io::Error::other(NoRoom(line.to_owned()))
100    }
101}
102
103impl BufRead for Gated<'_, '_> {
104    fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
105        if let Some(line) = &self.cut {
106            return Err(Self::no_room(line));
107        }
108        if self.credit == 0 {
109            // Bytes first, then the ask: EOF is not a chunk.
110            let available = self.inner.fill_buf()?.len() as u64;
111            if available == 0 {
112                return Ok(&[]);
113            }
114            // What is in hand, at least 64 KiB, growing with what the push has
115            // sent up to one lease: a 3 KiB commit promises 3 KiB × 3, not 12
116            // MiB, and a 512 MiB import asks once per 4 MiB.
117            let want = (self.consumed / 4)
118                .clamp(MIN_LEASE, LEASE_BYTES)
119                .max(available.min(LEASE_BYTES));
120            match self.hold.admit(want) {
121                Ok(granted) => self.credit = granted,
122                Err(line) => {
123                    let err = Self::no_room(&line);
124                    self.cut = Some(line);
125                    return Err(err);
126                }
127            }
128        }
129        let allowed = usize::try_from(self.credit).unwrap_or(usize::MAX);
130        let buf = self.inner.fill_buf()?;
131        let n = buf.len().min(allowed);
132        Ok(&buf[..n])
133    }
134
135    fn consume(&mut self, amount: usize) {
136        self.inner.consume(amount);
137        let amount = amount as u64;
138        self.credit = self.credit.saturating_sub(amount);
139        self.consumed = self.consumed.saturating_add(amount);
140        let on_disk = crate::storage::spooled_on_disk(self.consumed, self.spool_above);
141        if on_disk > self.spooled {
142            self.hold.realize(on_disk - self.spooled);
143            self.spooled = on_disk;
144        }
145    }
146}
147
148impl Read for Gated<'_, '_> {
149    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
150        let n = {
151            let available = self.fill_buf()?;
152            let n = available.len().min(buf.len());
153            buf[..n].copy_from_slice(&available[..n]);
154            n
155        };
156        self.consume(n);
157        Ok(n)
158    }
159}
160
161/// **Did the store fail because the data set filled?** An `ENOSPC`/`EDQUOT`
162/// anywhere in the chain (typed, or as the OS spells it once a layer turned it
163/// into text) is; io_uring's `Operation canceled` (125) — the tail of a linked
164/// chain whose head hit ENOSPC — is only when `at_the_floor` says the disk was
165/// full when it failed, because a chain cancelled for any other reason says the
166/// same words.
167pub(crate) fn is_space_failure(err: &anyhow::Error, at_the_floor: impl FnOnce() -> bool) -> bool {
168    let typed = err
169        .chain()
170        .filter_map(|e| e.downcast_ref::<std::io::Error>())
171        .any(gunnar_ssh::space::is_out_of_space);
172    if typed {
173        return true;
174    }
175    let text = format!("{err:#}");
176    // By errno, not by the OS's sentence for EDQUOT: the open build's sources
177    // carry no money word, and that sentence has one.
178    if text.contains("No space left on device")
179        || text.contains("os error 28)")
180        || text.contains("os error 122)")
181    {
182        return true;
183    }
184    (text.contains("Operation canceled") || text.contains("os error 125)")) && at_the_floor()
185}
186
187/// **Is `name` a pushed pack's spool file?** `incoming-<random>.pack` — the
188/// name `storage::read_pushed_pack_spooling` gives it, and nothing else.
189pub(crate) fn is_spool_name(name: &str) -> bool {
190    name.starts_with("incoming-") && name.ends_with(".pack")
191}
192
193/// **Delete every spool file a crash left under `repos_root`**: in each
194/// `*.git` directory, and in its `gunnar-git.znippy/` beside the archive (the
195/// two places the two engines spool). Run once at start, before any door is
196/// open, so no live push owns one. Returns `(files, bytes)` removed.
197pub fn sweep_spool_leftovers(repos_root: &Path) -> (u64, u64) {
198    let (mut files, mut bytes) = (0u64, 0u64);
199    let mut stack = vec![repos_root.to_path_buf()];
200    while let Some(dir) = stack.pop() {
201        let Ok(entries) = std::fs::read_dir(&dir) else {
202            continue;
203        };
204        let in_repo = dir.extension().is_some_and(|e| e == "git")
205            || dir.file_name().is_some_and(|n| n == "gunnar-git.znippy");
206        for entry in entries.flatten() {
207            let path = entry.path();
208            let Ok(meta) = entry.path().symlink_metadata() else {
209                continue;
210            };
211            let name = entry.file_name().to_string_lossy().into_owned();
212            if meta.is_dir() {
213                // Down the namespace tree, into a repository and its archive;
214                // never further than that.
215                if !in_repo || name == "gunnar-git.znippy" {
216                    stack.push(path);
217                }
218                continue;
219            }
220            if in_repo && meta.is_file() && is_spool_name(&name) {
221                match std::fs::remove_file(&path) {
222                    Ok(()) => {
223                        files += 1;
224                        bytes += meta.len();
225                        tracing::warn!(
226                            target: "gunnar::store",
227                            event = "push.spool.swept",
228                            path = %path.display(),
229                            bytes = meta.len(),
230                            "a pushed pack's spool a crash left behind was deleted at start"
231                        );
232                    }
233                    Err(err) => tracing::warn!(
234                        target: "gunnar::store",
235                        event = "push.spool.sweep_failed",
236                        path = %path.display(),
237                        %err,
238                        "a spool a crash left behind could not be deleted"
239                    ),
240                }
241            }
242        }
243    }
244    (files, bytes)
245}
246
247/// **Delete the `*.part` files an LFS upload's spool left in `spool_dir`**
248/// (`<txlog>/spool/`, `txlog_lfs`): removed by `Drop` when the upload lands or
249/// fails, left behind only by a process that died mid-upload. Run at start.
250pub fn sweep_lfs_spool(spool_dir: &Path) -> (u64, u64) {
251    let (mut files, mut bytes) = (0u64, 0u64);
252    let Ok(entries) = std::fs::read_dir(spool_dir) else {
253        return (0, 0);
254    };
255    for entry in entries.flatten() {
256        let Ok(meta) = entry.path().symlink_metadata() else {
257            continue;
258        };
259        if meta.is_file()
260            && entry.file_name().to_string_lossy().ends_with(".part")
261            && std::fs::remove_file(entry.path()).is_ok()
262        {
263            files += 1;
264            bytes += meta.len();
265        }
266    }
267    (files, bytes)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use std::sync::Arc;
274
275    #[derive(Debug)]
276    struct Fixed(u64, u64);
277    impl gunnar_ssh::SpaceProbe for Fixed {
278        fn measure(&self) -> std::io::Result<(u64, u64)> {
279            Ok((self.0, self.1))
280        }
281    }
282
283    const MIB: u64 = 1 << 20;
284
285    fn ledger(free: u64) -> Arc<gunnar_ssh::SpaceLedger> {
286        Arc::new(
287            gunnar_ssh::SpaceLedger::with_probe(
288                "/data",
289                gunnar_ssh::DiskFloor {
290                    percent: 0,
291                    bytes: 64 * MIB,
292                },
293                Arc::new(Fixed(free, 10 * 1024 * MIB)),
294            )
295            .with_small_lane(0),
296        )
297    }
298
299    /// The reader hands on only what was granted, and cuts BY NAME — an
300    /// `io::Error` whose source is [`NoRoom`] — when nothing more is.
301    #[test]
302    fn the_reader_cuts_at_the_room_and_says_so() {
303        // 64 MiB + what 2 MiB of wire is promised (3 copies + the filesystem's slack): room for 2 MiB.
304        let ledger =
305            ledger(64 * MIB + (2 * MIB * gunnar_ssh::space::PUSH_RESERVE_PERMILLE).div_ceil(1000));
306        let hold = ledger.hold(3);
307        let bytes = vec![1u8; 5 * MIB as usize];
308        let mut source = std::io::Cursor::new(bytes);
309        let mut gated = Gated::new(&mut source, &hold, u64::MAX);
310        let mut got = Vec::new();
311        let err = gated
312            .read_to_end(&mut got)
313            .expect_err("5 MiB does not fit in 2");
314        assert_eq!(
315            got.len() as u64,
316            2 * MIB,
317            "exactly what was granted was handed on"
318        );
319        assert!(err.get_ref().is_some_and(|e| e.is::<NoRoom>()), "{err:?}");
320        assert!(gated
321            .cut()
322            .is_some_and(|l| l.starts_with(gunnar_ssh::FULL_NOW)));
323        assert_eq!(ledger.reserved(), 0, "the cut push holds nothing");
324    }
325
326    /// Bytes past the spool line are given back as they are consumed: the
327    /// ingest has written them by then.
328    #[test]
329    fn spooled_bytes_are_given_back_as_they_are_consumed() {
330        let ledger = ledger(10 * 1024 * MIB);
331        let hold = ledger.hold(3);
332        let mut source = std::io::Cursor::new(vec![1u8; 3 * MIB as usize]);
333        let mut gated = Gated::new(&mut source, &hold, MIB);
334        let mut sink = Vec::new();
335        gated.read_to_end(&mut sink).unwrap();
336        assert_eq!(sink.len() as u64, 3 * MIB);
337        // Granted the 3 MiB in hand (3 MiB at 3× + the filesystem's slack promised); all of it is
338        // in the spool.
339        assert_eq!(hold.held(), hold.promise_for(3 * MIB) - 3 * MIB);
340    }
341
342    #[test]
343    fn the_sweep_takes_spools_in_repositories_and_nothing_else() {
344        let root = tempfile::tempdir().unwrap();
345        let repo = root.path().join("team/sub/one.git");
346        let archive = repo.join("gunnar-git.znippy");
347        std::fs::create_dir_all(&archive).unwrap();
348        for f in [
349            archive.join("incoming-x.pack"),
350            repo.join("incoming-y.pack"),
351        ] {
352            std::fs::write(f, [0u8; 10]).unwrap();
353        }
354        let keep = [
355            archive.join("incoming-notes.txt"),
356            root.path().join("team/incoming-z.pack"),
357            archive.join("objects.graph"),
358        ];
359        for f in &keep {
360            std::fs::write(f, [0u8; 3]).unwrap();
361        }
362        assert_eq!(sweep_spool_leftovers(root.path()), (2, 20));
363        assert!(keep.iter().all(|f| f.exists()));
364    }
365}