use std::io::{BufRead, Read};
use std::path::Path;
use gunnar_ssh::space::LEASE_BYTES;
use gunnar_ssh::SpaceHold;
const MIN_LEASE: u64 = 64 * 1024;
#[derive(Debug)]
pub(crate) struct NoRoom(pub(crate) String);
impl std::fmt::Display for NoRoom {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for NoRoom {}
pub(crate) struct Gated<'a, 'r> {
inner: &'a mut (dyn BufRead + 'r),
hold: &'a SpaceHold,
credit: u64,
consumed: u64,
spooled: u64,
spool_above: u64,
cut: Option<String>,
}
impl<'a, 'r> Gated<'a, 'r> {
pub(crate) fn new(inner: &'a mut (dyn BufRead + 'r), hold: &'a SpaceHold, spool_above: u64) -> Self {
Gated {
inner,
hold,
credit: 0,
consumed: 0,
spooled: 0,
spool_above,
cut: None,
}
}
pub(crate) fn cut(&self) -> Option<&str> {
self.cut.as_deref()
}
pub(crate) fn consumed(&self) -> u64 {
self.consumed
}
fn no_room(line: &str) -> std::io::Error {
std::io::Error::other(NoRoom(line.to_owned()))
}
}
impl BufRead for Gated<'_, '_> {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
if let Some(line) = &self.cut {
return Err(Self::no_room(line));
}
if self.credit == 0 {
let available = self.inner.fill_buf()?.len() as u64;
if available == 0 {
return Ok(&[]);
}
let want = (self.consumed / 4)
.clamp(MIN_LEASE, LEASE_BYTES)
.max(available.min(LEASE_BYTES));
match self.hold.admit(want) {
Ok(granted) => self.credit = granted,
Err(line) => {
let err = Self::no_room(&line);
self.cut = Some(line);
return Err(err);
}
}
}
let allowed = usize::try_from(self.credit).unwrap_or(usize::MAX);
let buf = self.inner.fill_buf()?;
let n = buf.len().min(allowed);
Ok(&buf[..n])
}
fn consume(&mut self, amount: usize) {
self.inner.consume(amount);
let amount = amount as u64;
self.credit = self.credit.saturating_sub(amount);
self.consumed = self.consumed.saturating_add(amount);
let on_disk = crate::storage::spooled_on_disk(self.consumed, self.spool_above);
if on_disk > self.spooled {
self.hold.realize(on_disk - self.spooled);
self.spooled = on_disk;
}
}
}
impl Read for Gated<'_, '_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = {
let available = self.fill_buf()?;
let n = available.len().min(buf.len());
buf[..n].copy_from_slice(&available[..n]);
n
};
self.consume(n);
Ok(n)
}
}
pub(crate) fn is_space_failure(err: &anyhow::Error, at_the_floor: impl FnOnce() -> bool) -> bool {
let typed = err
.chain()
.filter_map(|e| e.downcast_ref::<std::io::Error>())
.any(gunnar_ssh::space::is_out_of_space);
if typed {
return true;
}
let text = format!("{err:#}");
if text.contains("No space left on device") || text.contains("os error 28)") || text.contains("os error 122)") {
return true;
}
(text.contains("Operation canceled") || text.contains("os error 125)")) && at_the_floor()
}
pub(crate) fn is_spool_name(name: &str) -> bool {
name.starts_with("incoming-") && name.ends_with(".pack")
}
pub fn sweep_spool_leftovers(repos_root: &Path) -> (u64, u64) {
let (mut files, mut bytes) = (0u64, 0u64);
let mut stack = vec![repos_root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
let in_repo = dir.extension().is_some_and(|e| e == "git")
|| dir.file_name().is_some_and(|n| n == "gunnar-git.znippy");
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.path().symlink_metadata() else { continue };
let name = entry.file_name().to_string_lossy().into_owned();
if meta.is_dir() {
if !in_repo || name == "gunnar-git.znippy" {
stack.push(path);
}
continue;
}
if in_repo && meta.is_file() && is_spool_name(&name) {
match std::fs::remove_file(&path) {
Ok(()) => {
files += 1;
bytes += meta.len();
tracing::warn!(
target: "gunnar::store",
event = "push.spool.swept",
path = %path.display(),
bytes = meta.len(),
"a pushed pack's spool a crash left behind was deleted at start"
);
}
Err(err) => tracing::warn!(
target: "gunnar::store",
event = "push.spool.sweep_failed",
path = %path.display(),
%err,
"a spool a crash left behind could not be deleted"
),
}
}
}
}
(files, bytes)
}
pub fn sweep_lfs_spool(spool_dir: &Path) -> (u64, u64) {
let (mut files, mut bytes) = (0u64, 0u64);
let Ok(entries) = std::fs::read_dir(spool_dir) else {
return (0, 0);
};
for entry in entries.flatten() {
let Ok(meta) = entry.path().symlink_metadata() else { continue };
if meta.is_file() && entry.file_name().to_string_lossy().ends_with(".part") && std::fs::remove_file(entry.path()).is_ok() {
files += 1;
bytes += meta.len();
}
}
(files, bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[derive(Debug)]
struct Fixed(u64, u64);
impl gunnar_ssh::SpaceProbe for Fixed {
fn measure(&self) -> std::io::Result<(u64, u64)> {
Ok((self.0, self.1))
}
}
const MIB: u64 = 1 << 20;
fn ledger(free: u64) -> Arc<gunnar_ssh::SpaceLedger> {
Arc::new(
gunnar_ssh::SpaceLedger::with_probe(
"/data",
gunnar_ssh::DiskFloor { percent: 0, bytes: 64 * MIB },
Arc::new(Fixed(free, 10 * 1024 * MIB)),
)
.with_small_lane(0),
)
}
#[test]
fn the_reader_cuts_at_the_room_and_says_so() {
let ledger = ledger(64 * MIB + (2 * MIB * gunnar_ssh::space::PUSH_RESERVE_PERMILLE).div_ceil(1000));
let hold = ledger.hold(3);
let bytes = vec![1u8; 5 * MIB as usize];
let mut source = std::io::Cursor::new(bytes);
let mut gated = Gated::new(&mut source, &hold, u64::MAX);
let mut got = Vec::new();
let err = gated.read_to_end(&mut got).expect_err("5 MiB does not fit in 2");
assert_eq!(got.len() as u64, 2 * MIB, "exactly what was granted was handed on");
assert!(err.get_ref().is_some_and(|e| e.is::<NoRoom>()), "{err:?}");
assert!(gated.cut().is_some_and(|l| l.starts_with(gunnar_ssh::FULL_NOW)));
assert_eq!(ledger.reserved(), 0, "the cut push holds nothing");
}
#[test]
fn spooled_bytes_are_given_back_as_they_are_consumed() {
let ledger = ledger(10 * 1024 * MIB);
let hold = ledger.hold(3);
let mut source = std::io::Cursor::new(vec![1u8; 3 * MIB as usize]);
let mut gated = Gated::new(&mut source, &hold, MIB);
let mut sink = Vec::new();
gated.read_to_end(&mut sink).unwrap();
assert_eq!(sink.len() as u64, 3 * MIB);
assert_eq!(hold.held(), hold.promise_for(3 * MIB) - 3 * MIB);
}
#[test]
fn the_sweep_takes_spools_in_repositories_and_nothing_else() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("team/sub/one.git");
let archive = repo.join("gunnar-git.znippy");
std::fs::create_dir_all(&archive).unwrap();
for f in [archive.join("incoming-x.pack"), repo.join("incoming-y.pack")] {
std::fs::write(f, [0u8; 10]).unwrap();
}
let keep = [
archive.join("incoming-notes.txt"),
root.path().join("team/incoming-z.pack"),
archive.join("objects.graph"),
];
for f in &keep {
std::fs::write(f, [0u8; 3]).unwrap();
}
assert_eq!(sweep_spool_leftovers(root.path()), (2, 20));
assert!(keep.iter().all(|f| f.exists()));
}
}