use std::sync::OnceLock;
use crate::limits::{self, Nofile};
pub const TRANSFERS_PER_CORE: usize = 16;
pub const FDS_PER_TRANSFER: u64 = 4;
pub const MIN_TRANSFERS: usize = 8;
pub const MAX_TRANSFERS: usize = crate::serve::GIT_BLOCKING_THREADS / 2;
pub const TRANSFER_MEMORY_PERCENT: u64 = 50;
pub const HANDLE_CACHE_PERCENT: u64 = 20;
pub const BYTES_PER_INDEXED_OBJECT: u64 = 768;
pub const REDB_CACHE_BYTES_PER_OPEN_REPO: u64 = 64 * 1024 * 1024;
pub const BUDGETED_OBJECTS_PER_REPO: u64 = 100_000;
pub const fn bytes_per_open_repo(objects: u64) -> u64 {
REDB_CACHE_BYTES_PER_OPEN_REPO + objects * BYTES_PER_INDEXED_OBJECT
}
pub const BYTES_PER_OPEN_REPO: u64 = bytes_per_open_repo(BUDGETED_OBJECTS_PER_REPO);
pub const MAX_TRANSFERS_ENV: &str = "GUNNAR_MAX_TRANSFERS";
pub const OPEN_REPOS_ENV: &str = "GUNNAR_MAX_OPEN_REPOS";
pub const MEMORY_BUDGET_ENV: &str = "GUNNAR_GIT_MEMORY_BYTES";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Machine {
pub cores: usize,
pub nofile_soft: u64,
pub mem_total: u64,
pub mem_available: u64,
}
impl Machine {
pub fn read(limit: Nofile) -> Machine {
let (mut mem_total, mut mem_available) =
crate::dashboard::probe::memory().unwrap_or((0, 0));
if let Some((cap, used)) = cgroup_memory() {
mem_total = if mem_total == 0 {
cap
} else {
mem_total.min(cap)
};
let free = cap.saturating_sub(used);
mem_available = if mem_available == 0 {
free
} else {
mem_available.min(free)
};
}
Machine {
cores: std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1),
nofile_soft: limit.soft,
mem_total,
mem_available,
}
}
}
pub(crate) fn cgroup_memory() -> Option<(u64, u64)> {
let own = std::fs::read_to_string("/proc/self/cgroup").ok()?;
let rel = own
.lines()
.find_map(|line| line.strip_prefix("0::"))?
.trim()
.trim_start_matches('/');
let dir = std::path::Path::new("/sys/fs/cgroup").join(rel);
let max = std::fs::read_to_string(dir.join("memory.max")).ok()?;
let max = max.trim();
if max == "max" {
return None;
}
let max: u64 = max.parse().ok()?;
let current: u64 = std::fs::read_to_string(dir.join("memory.current"))
.ok()?
.trim()
.parse()
.ok()?;
Some((max, current))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
Derived,
Operator,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Budgets {
pub transfers: usize,
pub open_repos: usize,
pub memory_bytes: u64,
pub wait_ms: u64,
pub handle_cache_bytes: u64,
pub handle_cache_share: u64,
pub over_promised: bool,
pub transfers_from: Source,
pub machine: Machine,
}
pub fn human(bytes: u64) -> String {
const K: u64 = 1024;
match bytes {
b if b >= K * K * K => format!("{:.1}G", b as f64 / (K * K * K) as f64),
b if b >= K * K => format!("{}M", b / (K * K)),
b if b >= K => format!("{}K", b / K),
b => format!("{b}"),
}
}
pub fn derive(machine: &Machine) -> Budgets {
let by_fds_repos =
machine.nofile_soft.saturating_sub(limits::RESERVED_FDS) / limits::FDS_PER_OPEN_REPO;
let by_mem_repos = if machine.mem_available == 0 {
u64::MAX
} else {
machine.mem_available / 100 * HANDLE_CACHE_PERCENT / BYTES_PER_OPEN_REPO
};
let open_repos = usize::try_from(by_fds_repos.min(by_mem_repos))
.unwrap_or(usize::MAX)
.clamp(limits::MIN_OPEN_REPOS, crate::stores::DEFAULT_OPEN_REPOS);
let memory_bytes = if machine.mem_available == 0 {
gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES
} else {
machine.mem_available / 100 * TRANSFER_MEMORY_PERCENT
};
let by_cores = machine.cores.saturating_mul(TRANSFERS_PER_CORE);
let spoken_for = limits::RESERVED_FDS + open_repos as u64 * limits::FDS_PER_OPEN_REPO;
let by_fds = usize::try_from(machine.nofile_soft.saturating_sub(spoken_for) / FDS_PER_TRANSFER)
.unwrap_or(usize::MAX);
let transfers = by_cores.min(by_fds).clamp(MIN_TRANSFERS, MAX_TRANSFERS);
let handle_cache_share = machine.mem_available / 100 * HANDLE_CACHE_PERCENT;
let handle_cache_bytes = (open_repos as u64).saturating_mul(BYTES_PER_OPEN_REPO);
Budgets {
transfers,
open_repos,
memory_bytes,
wait_ms: gunnar_ssh::governor::DEFAULT_ADMISSION_WAIT.as_millis() as u64,
transfers_from: Source::Derived,
machine: *machine,
handle_cache_bytes,
handle_cache_share,
over_promised: machine.mem_available > 0 && handle_cache_bytes > handle_cache_share,
}
}
fn env_usize(name: &str) -> Option<usize> {
std::env::var(name)
.ok()?
.trim()
.parse()
.ok()
.filter(|n| *n > 0)
}
fn env_u64(name: &str) -> Option<u64> {
std::env::var(name)
.ok()?
.trim()
.parse()
.ok()
.filter(|n| *n > 0)
}
pub fn budgets() -> Budgets {
static BUDGETS: OnceLock<Budgets> = OnceLock::new();
*BUDGETS.get_or_init(|| {
let machine = Machine::read(limits::nofile());
let mut budgets = derive(&machine);
if let Some(n) = env_usize(MAX_TRANSFERS_ENV) {
budgets.transfers = n;
budgets.transfers_from = Source::Operator;
}
if let Some(n) = env_usize(OPEN_REPOS_ENV) {
budgets.open_repos = n;
}
if let Some(n) = env_u64(MEMORY_BUDGET_ENV) {
budgets.memory_bytes = n;
}
budgets
})
}
pub fn announce(b: &Budgets) {
tracing::info!(
target: "gunnar::limits",
event = "limits.budget",
transfers = b.transfers,
transfers_from = ?b.transfers_from,
open_repos = b.open_repos,
git_memory_bytes = b.memory_bytes,
cores = b.machine.cores,
fds = b.machine.nofile_soft,
mem_total = b.machine.mem_total,
mem_available = b.machine.mem_available,
wait_ms = b.wait_ms,
handle_cache_bytes = b.handle_cache_bytes,
handle_cache_share = b.handle_cache_share,
bytes_per_open_repo = BYTES_PER_OPEN_REPO,
"transfers {} · open repos {} (≤ {} of {} at {}/repo) · git memory {} · admission wait \
{} ms (cores {}, fds {}, MemAvailable {})",
b.transfers,
b.open_repos,
human(b.handle_cache_bytes),
human(b.handle_cache_share),
human(BYTES_PER_OPEN_REPO),
human(b.memory_bytes),
b.wait_ms,
b.machine.cores,
b.machine.nofile_soft,
human(b.machine.mem_available),
);
if b.over_promised {
tracing::warn!(
target: "gunnar::limits",
event = "limits.handles.over_promised",
open_repos = b.open_repos,
handle_cache_bytes = b.handle_cache_bytes,
handle_cache_share = b.handle_cache_share,
mem_available = b.machine.mem_available,
"this box cannot afford the handle-cache floor: {} handles is the minimum this \
server keeps, they hold {} at {} each, and the cache's share of {} MemAvailable \
is only {}. Expect the indexer watchdog to pause pushes looking for memory the \
handle cache is holding — that is the 2 GB box's collapse, and it is now said at start \
instead of found at 12 s per create.",
b.open_repos,
human(b.handle_cache_bytes),
human(BYTES_PER_OPEN_REPO),
human(b.machine.mem_available),
human(b.handle_cache_share),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn machine(cores: usize, fds: u64, mem_gb: u64) -> Machine {
Machine {
cores,
nofile_soft: fds,
mem_total: mem_gb * 1024 * 1024 * 1024,
mem_available: mem_gb * 1024 * 1024 * 1024,
}
}
#[test]
fn the_handle_ceiling_may_not_promise_more_memory_than_its_own_share() {
let appliance = machine(4, 1 << 20, 16);
let b = derive(&appliance);
let share = appliance.mem_available / 100 * HANDLE_CACHE_PERCENT;
let measured = bytes_per_open_repo(BUDGETED_OBJECTS_PER_REPO);
let promised = b.open_repos as u64 * measured;
assert!(
promised <= share,
"the derived ceiling of {} open handles, at the {} one cached repository of {} \
objects was measured to hold, is {} — but the handle cache's share of this {} \
box is {}. The ceiling over-promises by {:.1}x.",
b.open_repos,
human(measured),
BUDGETED_OBJECTS_PER_REPO,
human(promised),
human(appliance.mem_available),
human(share),
promised as f64 / share as f64,
);
}
#[test]
fn a_handle_is_priced_per_object_and_linux_mirror_does_not_fit_the_box() {
let small = bytes_per_open_repo(7_314);
assert!(
small < 128 * 1024 * 1024,
"an ordinary repository must still be cheap: {}",
human(small)
);
let mirror = bytes_per_open_repo(13_948_244);
assert!(
mirror > 10 * 1024 * 1024 * 1024,
"one open handle on linux-mirror is {} — the point of this assertion is that it \
is larger than the 16 GB box, not smaller",
human(mirror)
);
let standby = machine(4, 1 << 20, 4);
assert!(
mirror > standby.mem_available,
"one open handle on linux-mirror ({}) must not fit a 4 GB box ({})",
human(mirror),
human(standby.mem_available)
);
}
#[test]
fn the_rig_gets_sixty_four_transfers_and_a_cache_that_fits_its_memory() {
let b = derive(&machine(4, 1 << 20, 8));
assert_eq!(b.transfers, 64, "4 cores x TRANSFERS_PER_CORE");
assert_eq!(b.open_repos, 11);
assert_eq!(b.memory_bytes, 8 * 1024 * 1024 * 1024 / 100 * 50);
assert!(!b.over_promised, "8 GiB affords the floor");
assert!(b.handle_cache_bytes <= b.handle_cache_share);
}
#[test]
fn a_two_gigabyte_box_cannot_afford_even_the_handle_floor_and_now_says_so() {
let small = machine(4, 1 << 20, 2);
let by_fds_only = limits::open_repo_budget(small.nofile_soft);
let b = derive(&small);
assert_eq!(by_fds_only, crate::stores::DEFAULT_OPEN_REPOS);
assert_eq!(b.open_repos, limits::MIN_OPEN_REPOS, "the floor, not the division");
assert!(
b.over_promised,
"8 handles x {} is {}, and this box's share is {} — the budget must NOTICE that",
human(BYTES_PER_OPEN_REPO),
human(b.handle_cache_bytes),
human(b.handle_cache_share)
);
assert!(!derive(&machine(4, 1 << 20, 16)).over_promised);
}
#[test]
fn a_thousand_descriptors_buys_sixty_seven_percent_more_open_repositories() {
let b = derive(&machine(4, 1024, 256));
assert_eq!(b.open_repos, limits::open_repo_budget(1024));
assert_eq!(b.open_repos, 256);
assert_eq!(
1024_u64.saturating_sub(limits::RESERVED_FDS) / 5,
153,
"the descriptor-only answer"
);
assert!(b.open_repos as u64 * limits::FDS_PER_OPEN_REPO + limits::RESERVED_FDS <= 1024);
assert_eq!(b.transfers, MIN_TRANSFERS);
}
#[test]
fn the_floor_and_the_ceiling_both_bind() {
assert_eq!(
derive(&machine(1, 1 << 20, 64)).transfers,
MIN_TRANSFERS.max(16)
);
assert_eq!(derive(&machine(128, 1 << 20, 512)).transfers, MAX_TRANSFERS);
const { assert!(MAX_TRANSFERS < crate::serve::GIT_BLOCKING_THREADS) };
}
#[test]
fn no_meminfo_falls_back_rather_than_refusing_to_have_a_budget() {
let blind = Machine {
cores: 4,
nofile_soft: 1 << 20,
mem_total: 0,
mem_available: 0,
};
let b = derive(&blind);
assert_eq!(b.transfers, 64);
assert_eq!(b.open_repos, crate::stores::DEFAULT_OPEN_REPOS);
assert_eq!(
b.memory_bytes,
gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES
);
}
#[test]
fn bytes_read_like_bytes() {
assert_eq!(human(0), "0");
assert_eq!(human(2048), "2K");
assert_eq!(human(8 * 1024 * 1024), "8M");
assert_eq!(human(3 * 1024 * 1024 * 1024 + 512 * 1024 * 1024), "3.5G");
}
}