gunnar_server/
push_space.rs1use std::io::{BufRead, Read};
29use std::path::Path;
30
31use gunnar_ssh::space::LEASE_BYTES;
32use gunnar_ssh::SpaceHold;
33
34const MIN_LEASE: u64 = 64 * 1024;
36
37#[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
50pub(crate) struct Gated<'a, 'r> {
58 inner: &'a mut (dyn BufRead + 'r),
59 hold: &'a SpaceHold,
60 credit: u64,
62 consumed: u64,
64 spooled: u64,
66 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 pub(crate) fn cut(&self) -> Option<&str> {
90 self.cut.as_deref()
91 }
92
93 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 let available = self.inner.fill_buf()?.len() as u64;
111 if available == 0 {
112 return Ok(&[]);
113 }
114 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
161pub(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 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
187pub(crate) fn is_spool_name(name: &str) -> bool {
190 name.starts_with("incoming-") && name.ends_with(".pack")
191}
192
193pub 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 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
247pub 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 #[test]
302 fn the_reader_cuts_at_the_room_and_says_so() {
303 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 #[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 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}