gunnar_server/budget.rs
1//! **The declared budgets**: how many git transfers may be in flight, how many
2//! repository handles may be open, and how much memory the two of them may
3//! hold — every one of them derived from the box and said out loud at start.
4//!
5//! # The numbers this replaces were not chosen, they were emergent
6//!
7//! Three separate ceilings decided gunnar's concurrency before 2026-08-30, and
8//! not one of them was a number anybody picked for the machine it was running
9//! on:
10//!
11//! | the ceiling | what actually set it | measured at |
12//! |---|---|---|
13//! | **~19** concurrent transfers | a point on the CoDel controller's descent: band `[10, 256]`, `limit -= limit/8` every 100 ms window that stands. `256 → 224 → … → 21 → 19 → 17` | the appliance, `already serving its limit of 19`, 268/1000 admitted at 64 clients |
14//! | **25** concurrent pushes | `8 GiB budget / 320 MiB flat per-push reservation`, on any box, for any push | the native rig: 1969 of 3000 push-creates refused `MemoryBudget` at 64 clients while RSS never passed 1.6 GB of 8 GiB |
15//! | **256** open repositories | `DEFAULT_OPEN_REPOS`, bounded by descriptors only — no memory term at all | the 2 GB appliance: collapse at ~1200 repositories, 256 handles holding the RAM the indexer watchdog then could not find |
16//!
17//! Two of the three are *arithmetic*, not decisions, and the third counted the
18//! wrong resource. This module is the decision, in one place, with the three
19//! facts it is derived from named beside it in the log:
20//!
21//! ```text
22//! transfers 64 · open repos 200 · git memory 3.9G (cores 4, fds 1048576, MemAvailable 7.8G)
23//! ```
24//!
25//! # Why it lives here and not in `gunnar-ssh`
26//!
27//! `gunnar-ssh` may not read the operating system — `crate::governor`'s
28//! "OS-less, by law" section is explicit about it, and it is why the memory
29//! budget over there is a *reservation the server owns* rather than a
30//! measurement of the box. That law is kept. This crate is the one that already
31//! reads `/proc` (`crate::limits` for `RLIMIT_NOFILE`, `crate::dashboard::probe`
32//! for `/proc/meminfo`), so the derivation happens here and the *result* is
33//! handed to `gunnar-ssh` as configuration — which is exactly the shape
34//! `SshConfig::memory_budget_bytes` already documented as "the number an
35//! operator sets".
36//!
37//! # Overridable, and the override is honoured exactly
38//!
39//! `--max-concurrent-transfers N`, or `GUNNAR_MAX_TRANSFERS=N` in the
40//! environment (which is how the appliance's kernel command line reaches it:
41//! `korp.env.GUNNAR_MAX_TRANSFERS=N`, since Linux hands every unrecognised
42//! `key=value` boot parameter to `init` as an environment variable). An
43//! operator's number is never raised and never lowered — see
44//! [`gunnar_ssh::governor::LimitBand::for_budget`] for the one place that used
45//! to do both.
46
47use std::sync::OnceLock;
48
49use crate::limits::{self, Nofile};
50
51/// Concurrent transfers per core.
52///
53/// **A transfer is not a core's worth of work end to end.** It waits on the
54/// client's socket, on the disk, and on the indexer behind it; the packing is
55/// the only part that saturates. MEASURED on the rig (2026-08-30, 4 vCPU):
56/// a push-create phase at 8 concurrent clients ran the appliance at **48 %**
57/// CPU and a parallel-clone phase at 87 %, so at 2 transfers per core the box
58/// was already half idle during the phase the gate refuses in.
59///
60/// Sixteen is where the native rig stopped getting faster per accepted
61/// operation, measured 2026-08-30: a 4-core box
62/// holds 64 concurrent push-creates at 162 ms each against 69 ms at 8, and 128
63/// buys nothing but the extra latency. It is a *ceiling*, not a promise: the
64/// governor still sheds inside it, the memory ledger still refuses inside it,
65/// and the descriptor term below can be smaller.
66pub const TRANSFERS_PER_CORE: usize = 16;
67
68/// Descriptors one in-flight transfer costs on top of the handle cache: the
69/// client socket, its channel, and headroom for the store handle it opens if
70/// the cache misses.
71///
72/// Four, against [`limits::FDS_PER_OPEN_REPO`]'s five for a *cached* handle:
73/// a transfer's own descriptors are the socket and the pipe pair, and the
74/// repository it touches is normally already in the cache and already counted
75/// there.
76pub const FDS_PER_TRANSFER: u64 = 4;
77
78/// The transfer ceiling never goes below this, whatever the box says.
79///
80/// A server that admits fewer than eight concurrent transfers is not a forge,
81/// it is a queue with a socket. If the machine genuinely cannot carry eight,
82/// the honest answer is the WARN row this floor produces and not a gate that
83/// refuses everything.
84pub const MIN_TRANSFERS: usize = 8;
85
86/// The transfer ceiling never goes above this.
87///
88/// Half of `gunnar_server::serve::GIT_BLOCKING_THREADS` (512): every in-flight
89/// transfer occupies one blocking thread for its whole life, and the pool also
90/// carries pack indexing, ref transactions and the arrow log. A ceiling at the
91/// pool size would make the pool the real limit again — the exact defect
92/// the unbounded listener was about — and `serve.rs`'s
93/// `the_blocking_pool_is_larger_than_the_transfer_limit` asserts the ordering
94/// rather than leaving it to this paragraph.
95pub const MAX_TRANSFERS: usize = crate::serve::GIT_BLOCKING_THREADS / 2;
96
97/// The share of `MemAvailable` the **admission ledger** may give out to git
98/// operations in flight, in percent.
99///
100/// Half. The other half is not spare: gunnar's own resident baseline sits
101/// outside this budget by construction (`gunnar_ssh::governor`'s
102/// `DEFAULT_MEMORY_BUDGET_BYTES` says so), the handle cache takes
103/// [`HANDLE_CACHE_PERCENT`], and the indexer's watchdog holds its own floor at
104/// 20 % of `MemTotal` (the memory limit). Two budgets
105/// that add to 100 % of the same memory are one budget with extra steps.
106pub const TRANSFER_MEMORY_PERCENT: u64 = 50;
107
108/// The share of `MemAvailable` the **open-repository handle cache** may hold,
109/// in percent.
110///
111/// Twenty. This is the term [`limits::open_repo_budget`] did not have:
112/// it bounded the cache by descriptors and nothing else, and on the 2 GB
113/// appliance 256 cached handles held the RAM that the indexer's watchdog then
114/// spent every push trying and failing to reclaim — `scanned_rows_dropped=0` on
115/// every store, because the memory was in the *other* 255 handles. The box
116/// crawled at 12 s per create, and before the console fix it wedged
117/// outright.
118pub const HANDLE_CACHE_PERCENT: u64 = 20;
119
120/// **What one indexed object costs in a cached open repository**, in bytes.
121///
122/// MEASURED on the reference desktop, 2026-08-31,
123/// `znippy-plugin-git --example rss_per_open_store`: N identical stores opened
124/// one at a time, each read from, all held — the state the handle cache is
125/// actually in. Anonymous RSS out of `/proc/self/smaps_rollup`, with glibc's
126/// `mallinfo2` in-use figure beside it as the control, and the two agree to
127/// within a few percent on every row, so these are **live reachable bytes** and
128/// not allocator retention.
129///
130/// | corpus | objects | MB per additional open store (live, `mallinfo`) | B/object | spread over the per-open steps |
131/// |---|---:|---:|---:|---:|
132/// | synthetic blobs | 10 000 | 4.3 | 430 | 51 % |
133/// | synthetic blobs | 50 000 | 18.7 | 392 | 14 % |
134/// | synthetic blobs | 200 000 | 73.4 | 385 | 16 % |
135/// | synthetic blobs | 400 000 | 146.2 | 383 | 10 % |
136/// | `znippy` pack | 7 314 | 6.8 | **966** | 1.7 % |
137/// | `gunnar` pack | 12 849 | 9.2 | **744** | 2.5 % |
138///
139/// Dead straight over a 40× range of object counts, intercept ≈ 0.5 MB: the
140/// cost of a cached handle **is not a constant, it is the repository's object
141/// count**. Blobs alone are the floor at 382 B; a real repository, which also
142/// folds a commit graph and an ordinal space at open
143/// (`znippy_plugin_git::git_ops::Absorber::refold`), measured 744–966 B, so the
144/// budget takes 768 — the side that keeps the box alive, as the figure this
145/// replaces already said a budget must be.
146pub const BYTES_PER_INDEXED_OBJECT: u64 = 768;
147
148/// **The page cache one store's redb may additionally hold**, once reads have
149/// warmed it: `znippy_plugin_git::arms::DEFAULT_REDB_CACHE_BYTES`.
150///
151/// **One database per store, not two.** The "two per repository" sentence in
152/// `arms.rs` and in `storage::store_cache`'s docs is stale and it is what
153/// the 2026-08-31 reading doubled: `git_ops::open_with_arms` opens exactly
154/// one `ObjectReadStack`, and `znippy-plugin-git`'s own `tests/fd_per_store.rs`
155/// counts exactly one `objects.tail` descriptor per store.
156///
157/// It is a ceiling and not a reservation — which is why the table above, taken
158/// with only 64 reads per store, does not contain it. It IS reached under real
159/// traffic: `znippy-plugin-git`'s `tests/rss_retention_probe.rs`, run on the
160/// same box on the same day, measured **61.8 MB per stack** over 20 stacks of
161/// 300 000 rows with ~43 000 lookups each. A budget must assume the warm case.
162pub const REDB_CACHE_BYTES_PER_OPEN_REPO: u64 = 64 * 1024 * 1024;
163
164/// **The repository size the handle ceiling is derived against**, in objects.
165///
166/// A stated assumption, not a measurement, and it is stated here precisely
167/// because it is the one thing in this module that a box can be wrong about:
168/// `derive` has no catalog and cannot know how big the repositories on this
169/// machine are. A hundred thousand objects is an ordinary mid-sized repository
170/// — `znippy` is 7 314, `gunnar` 12 849, `alice/linux-mirror` is 13 948 244.
171///
172/// **The by-construction fix this is standing in for** is a byte ledger in
173/// [`crate::stores::ServerStores`]'s handle cache: charge each cached handle
174/// [`bytes_per_open_repo`] of *its own* object count and evict when the sum
175/// passes [`HANDLE_CACHE_PERCENT`] of `MemAvailable`, so that one 13.9-million
176/// object repository costs what it costs instead of one slot out of N. That
177/// needs an object count on the storage trait, which is a change in a second
178/// repository; this constant is what makes the ceiling honest until it lands.
179pub const BUDGETED_OBJECTS_PER_REPO: u64 = 100_000;
180
181/// What one **cached open repository** of `objects` objects holds in memory.
182///
183/// # The figure this replaces, and why it was not merely too small
184///
185/// It was `8 * 1024 * 1024`, from the native rig on 2026-08-30: 256 cached
186/// handles against a 1 114 MB RSS, **4.1 MB per handle**, rounded up to 8 MiB.
187/// That measurement was not wrong — it was taken over 15 000 *small*
188/// repositories, and 8 MiB is very nearly the right answer for a repository of
189/// ~10 000 objects (measured above: 6.8 MB at 7 314, 9.2 MB at 12 849).
190///
191/// The defect is the **shape**. A constant cannot be the price of a thing whose
192/// cost is linear in a number the constant does not mention. Priced at 8 MiB, a
193/// 400 000-object repository is under-counted 18×, and `alice/linux-mirror` at
194/// 13 948 244 objects is under-counted **1 300×** — one handle for it is
195/// ~10.7 GB, which is two thirds of the 16 GB box the product ships on. The
196/// derived ceiling on that box was 256 handles; 256 × the real price is not a
197/// budget, it is a number with a unit.
198pub const fn bytes_per_open_repo(objects: u64) -> u64 {
199 REDB_CACHE_BYTES_PER_OPEN_REPO + objects * BYTES_PER_INDEXED_OBJECT
200}
201
202/// What the handle ceiling is derived against: one cached open repository of
203/// [`BUDGETED_OBJECTS_PER_REPO`] objects, with its redb page cache warm.
204pub const BYTES_PER_OPEN_REPO: u64 = bytes_per_open_repo(BUDGETED_OBJECTS_PER_REPO);
205
206/// The environment variable that overrides the derived transfer ceiling.
207///
208/// Reachable from the appliance's kernel command line as
209/// `korp.env.GUNNAR_MAX_TRANSFERS=N` — Linux passes unrecognised `key=value`
210/// boot parameters to `init` as environment variables, which is the same
211/// channel `GUNNAR_NET` and `GUNNAR_DATA_ROOT` already arrive on.
212pub const MAX_TRANSFERS_ENV: &str = "GUNNAR_MAX_TRANSFERS";
213
214/// The environment variable that overrides the derived open-handle ceiling.
215pub const OPEN_REPOS_ENV: &str = "GUNNAR_MAX_OPEN_REPOS";
216
217/// The environment variable that overrides the derived memory budget, in bytes.
218pub const MEMORY_BUDGET_ENV: &str = "GUNNAR_GIT_MEMORY_BYTES";
219
220/// The three facts a budget is derived from.
221///
222/// Taken as a value rather than read inside the derivation so the whole rule is
223/// a pure function of numbers a test can supply — including the boxes this one
224/// is not (2 GB, one core, a thousand descriptors).
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226pub struct Machine {
227 /// CPUs this process may run on (`std::thread::available_parallelism`,
228 /// which reads the affinity mask, so a `taskset` or a cpuset is honoured).
229 pub cores: usize,
230 /// The descriptor soft limit **after** [`limits::nofile`] raised it.
231 pub nofile_soft: u64,
232 /// `MemTotal`, bytes. `0` when `/proc/meminfo` could not be read.
233 pub mem_total: u64,
234 /// `MemAvailable`, bytes. `0` when `/proc/meminfo` could not be read.
235 pub mem_available: u64,
236}
237
238impl Machine {
239 /// Read the box. Never fails: a missing `/proc/meminfo` leaves the memory
240 /// terms at zero, and [`derive`] then simply does not apply them — a budget
241 /// that refused to exist because one input was unreadable would be worse
242 /// than one derived from the two that were.
243 pub fn read(limit: Nofile) -> Machine {
244 let (mut mem_total, mut mem_available) =
245 crate::dashboard::probe::memory().unwrap_or((0, 0));
246 // A cgroup limit is the smaller truth, and gunnar runs in one whenever
247 // it is a container rather than the appliance. `/proc/meminfo` inside a
248 // cgroup still reports the HOST's memory: measured on the reference desktop, a server in
249 // a `MemoryMax=8G` scope read `MemAvailable 432.9G` and would have
250 // budgeted 216 GiB of a box it may use 8 of. The appliance is PID 1
251 // with no limit set, so there it changes nothing.
252 if let Some((cap, used)) = cgroup_memory() {
253 mem_total = if mem_total == 0 {
254 cap
255 } else {
256 mem_total.min(cap)
257 };
258 let free = cap.saturating_sub(used);
259 mem_available = if mem_available == 0 {
260 free
261 } else {
262 mem_available.min(free)
263 };
264 }
265 Machine {
266 cores: std::thread::available_parallelism()
267 .map(|n| n.get())
268 .unwrap_or(1),
269 nofile_soft: limit.soft,
270 mem_total,
271 mem_available,
272 }
273 }
274}
275
276/// `(memory.max, memory.current)` from this process's cgroup v2, in bytes.
277///
278/// `None` when there is no cgroup v2, no limit set (`max`), or the files cannot
279/// be read — every one of which means "the box is the limit", which is what the
280/// caller then uses. The same `/proc/self/cgroup` walk `serve::cpu_quota` does
281/// for `cpu.max`; pure Rust, three file reads, once per process — and once per
282/// `Admin.Capacity`, which clamps the RAM it quotes by the same rule
283/// ([`crate::capacity::MachineFacts::measure`]).
284pub(crate) fn cgroup_memory() -> Option<(u64, u64)> {
285 let own = std::fs::read_to_string("/proc/self/cgroup").ok()?;
286 let rel = own
287 .lines()
288 .find_map(|line| line.strip_prefix("0::"))?
289 .trim()
290 .trim_start_matches('/');
291 let dir = std::path::Path::new("/sys/fs/cgroup").join(rel);
292 let max = std::fs::read_to_string(dir.join("memory.max")).ok()?;
293 let max = max.trim();
294 if max == "max" {
295 return None;
296 }
297 let max: u64 = max.parse().ok()?;
298 let current: u64 = std::fs::read_to_string(dir.join("memory.current"))
299 .ok()?
300 .trim()
301 .parse()
302 .ok()?;
303 Some((max, current))
304}
305
306/// Where a budget's number came from.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum Source {
309 /// Derived from the machine by [`derive`].
310 Derived,
311 /// An operator's flag or environment variable, honoured exactly.
312 Operator,
313}
314
315/// The three ceilings, and the box they were derived from.
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
317pub struct Budgets {
318 /// Concurrent git transfers. Becomes
319 /// `SshConfig::max_concurrent_transfers`, which is the **ceiling of the
320 /// governor's band**; the floor is half of it
321 /// (`gunnar_ssh::governor::LimitBand::for_budget`).
322 pub transfers: usize,
323 /// Repository handles the cache may keep open.
324 pub open_repos: usize,
325 /// Bytes the admission ledger may give out to operations in flight.
326 pub memory_bytes: u64,
327 /// How long a transfer may wait for a slot, in milliseconds.
328 pub wait_ms: u64,
329 /// **What [`Budgets::open_repos`] handles can hold**, at
330 /// [`BYTES_PER_OPEN_REPO`] each, against
331 /// [`Budgets::handle_cache_share`] — the memory the cache is allowed.
332 ///
333 /// Carried as a value rather than left as arithmetic in a doc comment
334 /// because it is the number that was wrong: the ceiling said 256 handles on
335 /// a 16 GB box while one handle really held 137 MB, and nothing anywhere
336 /// multiplied those two together. Now [`announce`] does, out loud.
337 pub handle_cache_bytes: u64,
338 /// `MemAvailable × HANDLE_CACHE_PERCENT / 100`: what the cache may hold.
339 pub handle_cache_share: u64,
340 /// **The floor could not be afforded.** `true` when
341 /// [`limits::MIN_OPEN_REPOS`] handles cost more than
342 /// [`Budgets::handle_cache_share`] — a box too small for the minimum cache
343 /// this server insists on having.
344 ///
345 /// It is not an error and it does not refuse to serve: a server with no
346 /// handle cache is not a server, so the floor still wins and the process
347 /// still starts. What changes is that it is now *said*. The 2 GB
348 /// appliance reached the indexer watchdog's floor with the cache holding the
349 /// memory it was looking for, and every log line on the way there looked
350 /// ordinary.
351 pub over_promised: bool,
352 /// Where [`Budgets::transfers`] came from.
353 pub transfers_from: Source,
354 /// The box.
355 pub machine: Machine,
356}
357
358/// Bytes as `1.5G` / `820M` / `64K`, for one log line that a human reads.
359pub fn human(bytes: u64) -> String {
360 const K: u64 = 1024;
361 match bytes {
362 b if b >= K * K * K => format!("{:.1}G", b as f64 / (K * K * K) as f64),
363 b if b >= K * K => format!("{}M", b / (K * K)),
364 b if b >= K => format!("{}K", b / K),
365 b => format!("{b}"),
366 }
367}
368
369/// **The rule.** Pure, so every box can be tested from any box.
370///
371/// Each ceiling is the *smallest* of its terms, because a budget is a promise
372/// about the resource that runs out first:
373///
374/// ```text
375/// open_repos = min( (soft − RESERVED_FDS) / FDS_PER_OPEN_REPO , // unchanged
376/// MemAvailable × HANDLE_CACHE_PERCENT / BYTES_PER_OPEN_REPO )
377/// clamped to [MIN_OPEN_REPOS, DEFAULT_OPEN_REPOS]
378///
379/// git_memory = MemAvailable × TRANSFER_MEMORY_PERCENT
380///
381/// transfers = min( cores × TRANSFERS_PER_CORE ,
382/// (soft − RESERVED_FDS − open_repos × FDS_PER_OPEN_REPO) / FDS_PER_TRANSFER )
383/// clamped to [MIN_TRANSFERS, MAX_TRANSFERS]
384/// ```
385///
386/// The descriptor term subtracts the handle cache it just sized, because those
387/// descriptors are spoken for: that subtraction is the whole of the bug
388/// stated the other way round — 256 handles at five descriptors each were over
389/// the limit *before* a single transfer had a socket.
390///
391/// There is deliberately **no** memory term on `transfers`. Memory is metered
392/// per operation by the ledger, which now charges what a transfer actually
393/// moves (`gunnar_ssh::governor::WIRE_MEMORY_NUMERATOR`) instead of a flat
394/// per-class constant; turning the same budget into a second, static
395/// concurrency cap is how `8 GiB / 320 MiB = 25` happened.
396pub fn derive(machine: &Machine) -> Budgets {
397 let by_fds_repos =
398 machine.nofile_soft.saturating_sub(limits::RESERVED_FDS) / limits::FDS_PER_OPEN_REPO;
399 let by_mem_repos = if machine.mem_available == 0 {
400 u64::MAX
401 } else {
402 machine.mem_available / 100 * HANDLE_CACHE_PERCENT / BYTES_PER_OPEN_REPO
403 };
404 let open_repos = usize::try_from(by_fds_repos.min(by_mem_repos))
405 .unwrap_or(usize::MAX)
406 .clamp(limits::MIN_OPEN_REPOS, crate::stores::DEFAULT_OPEN_REPOS);
407
408 let memory_bytes = if machine.mem_available == 0 {
409 gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES
410 } else {
411 machine.mem_available / 100 * TRANSFER_MEMORY_PERCENT
412 };
413
414 let by_cores = machine.cores.saturating_mul(TRANSFERS_PER_CORE);
415 let spoken_for = limits::RESERVED_FDS + open_repos as u64 * limits::FDS_PER_OPEN_REPO;
416 let by_fds = usize::try_from(machine.nofile_soft.saturating_sub(spoken_for) / FDS_PER_TRANSFER)
417 .unwrap_or(usize::MAX);
418 let transfers = by_cores.min(by_fds).clamp(MIN_TRANSFERS, MAX_TRANSFERS);
419
420 // The multiplication nobody was doing. `open_repos` is a count and
421 // `BYTES_PER_OPEN_REPO` is a price; the product is the promise, and it is
422 // the promise that has to fit.
423 let handle_cache_share = machine.mem_available / 100 * HANDLE_CACHE_PERCENT;
424 let handle_cache_bytes = (open_repos as u64).saturating_mul(BYTES_PER_OPEN_REPO);
425
426 Budgets {
427 transfers,
428 open_repos,
429 memory_bytes,
430 wait_ms: gunnar_ssh::governor::DEFAULT_ADMISSION_WAIT.as_millis() as u64,
431 transfers_from: Source::Derived,
432 machine: *machine,
433 handle_cache_bytes,
434 handle_cache_share,
435 // Only when the FLOOR is what over-promises. Above the floor the
436 // division above already fits by construction, so a `true` here means
437 // exactly one thing: this box is too small for the minimum cache.
438 over_promised: machine.mem_available > 0 && handle_cache_bytes > handle_cache_share,
439 }
440}
441
442/// Read `name` as a positive `usize`, or `None`.
443fn env_usize(name: &str) -> Option<usize> {
444 std::env::var(name)
445 .ok()?
446 .trim()
447 .parse()
448 .ok()
449 .filter(|n| *n > 0)
450}
451
452/// Read `name` as a positive `u64`, or `None`.
453fn env_u64(name: &str) -> Option<u64> {
454 std::env::var(name)
455 .ok()?
456 .trim()
457 .parse()
458 .ok()
459 .filter(|n| *n > 0)
460}
461
462/// **This process's budgets, derived once and announced once.**
463///
464/// The first caller does the deriving and writes the INFO row; every later call
465/// reads the same answer. It calls [`limits::nofile`], so the descriptor limit
466/// is raised before it is budgeted against — the ordering that made the
467/// 153-handle answer correct.
468pub fn budgets() -> Budgets {
469 static BUDGETS: OnceLock<Budgets> = OnceLock::new();
470 *BUDGETS.get_or_init(|| {
471 let machine = Machine::read(limits::nofile());
472 let mut budgets = derive(&machine);
473 if let Some(n) = env_usize(MAX_TRANSFERS_ENV) {
474 budgets.transfers = n;
475 budgets.transfers_from = Source::Operator;
476 }
477 if let Some(n) = env_usize(OPEN_REPOS_ENV) {
478 budgets.open_repos = n;
479 }
480 if let Some(n) = env_u64(MEMORY_BUDGET_ENV) {
481 budgets.memory_bytes = n;
482 }
483 budgets
484 })
485}
486
487/// One INFO row naming the budgets **and the three facts they came from**.
488///
489/// The facts are the point. A ceiling in a log that a reader cannot tie to
490/// anything about the machine is the same mystery `19` was; with the inputs
491/// beside it, "why 64?" is answered in the line that states it.
492///
493/// **It says what is IN FORCE, not what was derived.** `serve` calls it after
494/// an operator's `--max-concurrent-transfers` has been applied, because a row
495/// that printed the derivation while the server ran on something else would be
496/// a worse lie than no row: the first thing anybody does with this line is
497/// compare it to the refusals underneath it.
498pub fn announce(b: &Budgets) {
499 tracing::info!(
500 target: "gunnar::limits",
501 event = "limits.budget",
502 transfers = b.transfers,
503 transfers_from = ?b.transfers_from,
504 open_repos = b.open_repos,
505 git_memory_bytes = b.memory_bytes,
506 cores = b.machine.cores,
507 fds = b.machine.nofile_soft,
508 mem_total = b.machine.mem_total,
509 mem_available = b.machine.mem_available,
510 wait_ms = b.wait_ms,
511 handle_cache_bytes = b.handle_cache_bytes,
512 handle_cache_share = b.handle_cache_share,
513 bytes_per_open_repo = BYTES_PER_OPEN_REPO,
514 "transfers {} · open repos {} (≤ {} of {} at {}/repo) · git memory {} · admission wait \
515 {} ms (cores {}, fds {}, MemAvailable {})",
516 b.transfers,
517 b.open_repos,
518 human(b.handle_cache_bytes),
519 human(b.handle_cache_share),
520 human(BYTES_PER_OPEN_REPO),
521 human(b.memory_bytes),
522 b.wait_ms,
523 b.machine.cores,
524 b.machine.nofile_soft,
525 human(b.machine.mem_available),
526 );
527 if b.over_promised {
528 tracing::warn!(
529 target: "gunnar::limits",
530 event = "limits.handles.over_promised",
531 open_repos = b.open_repos,
532 handle_cache_bytes = b.handle_cache_bytes,
533 handle_cache_share = b.handle_cache_share,
534 mem_available = b.machine.mem_available,
535 "this box cannot afford the handle-cache floor: {} handles is the minimum this \
536 server keeps, they hold {} at {} each, and the cache's share of {} MemAvailable \
537 is only {}. Expect the indexer watchdog to pause pushes looking for memory the \
538 handle cache is holding — that is the 2 GB box's collapse, and it is now said at start \
539 instead of found at 12 s per create.",
540 b.open_repos,
541 human(b.handle_cache_bytes),
542 human(BYTES_PER_OPEN_REPO),
543 human(b.machine.mem_available),
544 human(b.handle_cache_share),
545 );
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 fn machine(cores: usize, fds: u64, mem_gb: u64) -> Machine {
554 Machine {
555 cores,
556 nofile_soft: fds,
557 mem_total: mem_gb * 1024 * 1024 * 1024,
558 mem_available: mem_gb * 1024 * 1024 * 1024,
559 }
560 }
561
562 /// ★ **The appliance, priced at what a handle was MEASURED to hold.**
563 ///
564 /// The box the product ships on: 4 cores, 16 GB, a raised descriptor limit.
565 /// [`Machine`] takes its inputs as a value precisely so a test can supply a
566 /// box this one is not, and this is the case that was missing: the handle
567 /// ceiling this module derives, times the memory one handle actually holds,
568 /// against the share the module says the cache may have.
569 ///
570 /// **SEEN RED, 2026-08-31, against `BYTES_PER_OPEN_REPO = 8 * 1024 * 1024`:**
571 ///
572 /// ```text
573 /// the derived ceiling of 256 open handles, at the 137M one cached
574 /// repository of 100000 objects was measured to hold, is 34.3G — but the
575 /// handle cache's share of this 16.0G box is 3.2G. The ceiling over-promises
576 /// by 10.7x.
577 /// ```
578 ///
579 /// 256 is what the descriptor term gives, because the memory term divided a
580 /// box by a per-handle price that was 18× too small for the repositories on
581 /// it. Nothing in the log said so: `limits.budget` printed `open repos 256`
582 /// and every one of those handles looked affordable.
583 #[test]
584 fn the_handle_ceiling_may_not_promise_more_memory_than_its_own_share() {
585 let appliance = machine(4, 1 << 20, 16);
586 let b = derive(&appliance);
587 let share = appliance.mem_available / 100 * HANDLE_CACHE_PERCENT;
588 // **The MEASURED price on the right, `derive`'s own count on the left.**
589 // Not `BYTES_PER_OPEN_REPO` on both sides: a test that priced the
590 // ceiling with the same constant the ceiling was derived from is the
591 // identity value in its exact form — it would pass against ANY value of
592 // that constant, including the one that was 18× too small. What binds
593 // here is that `derive`'s divisor must BE the measurement.
594 let measured = bytes_per_open_repo(BUDGETED_OBJECTS_PER_REPO);
595 let promised = b.open_repos as u64 * measured;
596 assert!(
597 promised <= share,
598 "the derived ceiling of {} open handles, at the {} one cached repository of {} \
599 objects was measured to hold, is {} — but the handle cache's share of this {} \
600 box is {}. The ceiling over-promises by {:.1}x.",
601 b.open_repos,
602 human(measured),
603 BUDGETED_OBJECTS_PER_REPO,
604 human(promised),
605 human(appliance.mem_available),
606 human(share),
607 promised as f64 / share as f64,
608 );
609 }
610
611 /// ★ **A handle's price is the repository's object count, and the numbers
612 /// that says are not small.**
613 ///
614 /// The second half of the same defect: a *constant* cannot be the price of a
615 /// linear thing, so the arithmetic is stated here against the two
616 /// repositories that bracket the range the appliance actually holds. Red
617 /// against the old constant on both rows — 8 MiB claimed to be the price of
618 /// a 13.9-million-object repository as readily as of a 7 314-object one.
619 #[test]
620 fn a_handle_is_priced_per_object_and_linux_mirror_does_not_fit_the_box() {
621 // `znippy` itself: 7 314 objects. Measured 6.8 MB of live bytes, which
622 // is what the cold price below should be in the same neighbourhood of.
623 let small = bytes_per_open_repo(7_314);
624 assert!(
625 small < 128 * 1024 * 1024,
626 "an ordinary repository must still be cheap: {}",
627 human(small)
628 );
629 // `alice/linux-mirror`: 13 948 244 objects (the count `memguard`'s
630 // module docs record from the push that panicked the appliance).
631 let mirror = bytes_per_open_repo(13_948_244);
632 assert!(
633 mirror > 10 * 1024 * 1024 * 1024,
634 "one open handle on linux-mirror is {} — the point of this assertion is that it \
635 is larger than the 16 GB box, not smaller",
636 human(mirror)
637 );
638 // …and therefore it cannot be held on the 4 GB standby it has never
639 // replicated to, nor beside 24 other repositories on the 16 GB one.
640 let standby = machine(4, 1 << 20, 4);
641 assert!(
642 mirror > standby.mem_available,
643 "one open handle on linux-mirror ({}) must not fit a 4 GB box ({})",
644 human(mirror),
645 human(standby.mem_available)
646 );
647 }
648
649 /// The rig the 2026-08-30 run measured on: 4 cores, a raised descriptor limit, 8 GiB.
650 /// This is the row the log prints and the row the bench table is against.
651 ///
652 /// **The handle count moved 204 → 11 on 2026-08-31** and that is the fix,
653 /// not a regression: 204 was `8 GiB × 20 % / 8 MiB`, and 8 MiB was the price
654 /// of a repository a twentieth the size of the one the ceiling is now
655 /// derived against ([`BUDGETED_OBJECTS_PER_REPO`]). Eleven handles at 137 MB
656 /// is the same 1.6 GB of memory 204 handles were *supposed* to be; the
657 /// difference is that this number is true. A repository touched past the
658 /// eleventh re-opens, which costs a re-open and never a failure
659 /// (`crate::stores::DEFAULT_OPEN_REPOS`'s own note).
660 #[test]
661 fn the_rig_gets_sixty_four_transfers_and_a_cache_that_fits_its_memory() {
662 let b = derive(&machine(4, 1 << 20, 8));
663 assert_eq!(b.transfers, 64, "4 cores x TRANSFERS_PER_CORE");
664 // 8 GiB x 20 % = 1.6 G, / 137 MB per repository = 11.
665 assert_eq!(b.open_repos, 11);
666 assert_eq!(b.memory_bytes, 8 * 1024 * 1024 * 1024 / 100 * 50);
667 assert!(!b.over_promised, "8 GiB affords the floor");
668 assert!(b.handle_cache_bytes <= b.handle_cache_share);
669 }
670
671 /// **The 2 GB box's collapse, as arithmetic — and the box that cannot afford even
672 /// the floor.**
673 ///
674 /// A 2 GB appliance may not keep 256 repositories open; the descriptor term
675 /// alone said 256, which is exactly the bug. Priced at 8 MiB it said 51,
676 /// which was the same bug with a smaller number. Priced at what a handle
677 /// holds it says **2**, which is under [`limits::MIN_OPEN_REPOS`] — so the
678 /// floor wins, and eight handles at 137 MB is 1.07 GiB of a box with 429 MB
679 /// to give the cache.
680 ///
681 /// That is not made to pass by weakening the assertion. It is
682 /// [`Budgets::over_promised`], and [`announce`] says it in a WARN at start.
683 /// The honest statement about the 2 GB box is that it is too small for this
684 /// server's minimum cache, and the defect was that nothing anywhere said so.
685 #[test]
686 fn a_two_gigabyte_box_cannot_afford_even_the_handle_floor_and_now_says_so() {
687 let small = machine(4, 1 << 20, 2);
688 let by_fds_only = limits::open_repo_budget(small.nofile_soft);
689 let b = derive(&small);
690 assert_eq!(by_fds_only, crate::stores::DEFAULT_OPEN_REPOS);
691 assert_eq!(
692 b.open_repos,
693 limits::MIN_OPEN_REPOS,
694 "the floor, not the division"
695 );
696 assert!(
697 b.over_promised,
698 "8 handles x {} is {}, and this box's share is {} — the budget must NOTICE that",
699 human(BYTES_PER_OPEN_REPO),
700 human(b.handle_cache_bytes),
701 human(b.handle_cache_share)
702 );
703 // Red for the flag itself: a box that CAN afford the floor must not
704 // raise it, or the warning would be noise on every appliance.
705 assert!(!derive(&machine(4, 1 << 20, 16)).over_promised);
706 }
707
708 /// **The 1024-descriptor rig — and what the cheaper store
709 /// buys there.**
710 ///
711 /// The descriptor term is the smaller one on that box, so it is the term
712 /// that decides, and it moved: an open store costs three descriptors
713 /// instead of five ([`limits::FDS_PER_OPEN_REPO`]), so `(1024 - 256) / 3`
714 /// is 256 where `(1024 - 256) / 5` was 153. **+67 % concurrently-open
715 /// repositories at the same `RLIMIT_NOFILE`**, which is the whole point of
716 /// the change: the divisor was three kernel objects and three page-cache
717 /// aliases per repository where one would do.
718 ///
719 /// `transfers` still lands on its floor and that is not a regression - it
720 /// was on the floor before (3 descriptors left over) and is on it now (0),
721 /// because [`limits::RESERVED_FDS`], not the leftovers, is what a session
722 /// draws from. A box that wants more than eight concurrent transfers raises
723 /// its limit; [`limits::nofile`] does exactly that, and this is the shape of
724 /// the box that could not.
725 #[test]
726 fn a_thousand_descriptors_buys_sixty_seven_percent_more_open_repositories() {
727 // 256 GiB and not 64: this test is about the DESCRIPTOR term, so the
728 // memory term must not be the one that binds. At the price a handle was
729 // measured to hold (137 MB), 64 GiB × 20 % buys 95 handles and the
730 // memory term would decide — which would make this test quietly stop
731 // testing descriptors while still passing on the transfers line below.
732 let b = derive(&machine(4, 1024, 256));
733 assert_eq!(b.open_repos, limits::open_repo_budget(1024));
734 assert_eq!(b.open_repos, 256);
735 assert_eq!(
736 1024_u64.saturating_sub(limits::RESERVED_FDS) / 5,
737 153,
738 "the descriptor-only answer"
739 );
740 // The reservation is still whole: 256 handles x 3 + 256 reserved = 1024.
741 assert!(b.open_repos as u64 * limits::FDS_PER_OPEN_REPO + limits::RESERVED_FDS <= 1024);
742 assert_eq!(b.transfers, MIN_TRANSFERS);
743 }
744
745 /// A one-core box is still a server, and a 128-core box does not get 2048.
746 #[test]
747 fn the_floor_and_the_ceiling_both_bind() {
748 assert_eq!(
749 derive(&machine(1, 1 << 20, 64)).transfers,
750 MIN_TRANSFERS.max(16)
751 );
752 assert_eq!(derive(&machine(128, 1 << 20, 512)).transfers, MAX_TRANSFERS);
753 const { assert!(MAX_TRANSFERS < crate::serve::GIT_BLOCKING_THREADS) };
754 }
755
756 /// An unreadable `/proc/meminfo` costs the memory terms and nothing else.
757 #[test]
758 fn no_meminfo_falls_back_rather_than_refusing_to_have_a_budget() {
759 let blind = Machine {
760 cores: 4,
761 nofile_soft: 1 << 20,
762 mem_total: 0,
763 mem_available: 0,
764 };
765 let b = derive(&blind);
766 assert_eq!(b.transfers, 64);
767 assert_eq!(b.open_repos, crate::stores::DEFAULT_OPEN_REPOS);
768 assert_eq!(
769 b.memory_bytes,
770 gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES
771 );
772 }
773
774 #[test]
775 fn bytes_read_like_bytes() {
776 assert_eq!(human(0), "0");
777 assert_eq!(human(2048), "2K");
778 assert_eq!(human(8 * 1024 * 1024), "8M");
779 assert_eq!(human(3 * 1024 * 1024 * 1024 + 512 * 1024 * 1024), "3.5G");
780 }
781}