feat: derive BUN_JSC_forceRAMSize from effective per-agent MemoryMax=
This commit is contained in:
parent
ee52ddc027
commit
bd14cc5c46
4 changed files with 288 additions and 35 deletions
|
|
@ -115,6 +115,91 @@ fn resolve(limits: &AgentLimits, hive_cpu_quota: &str, hive_memory_max: &str) ->
|
|||
(cpu, mem)
|
||||
}
|
||||
|
||||
/// Effective `MemoryMax=` string for `name` against an already-loaded
|
||||
/// map — the memory-only half of [`effective_from`], split out because
|
||||
/// [`effective_memory_bytes_from`] doesn't have a CPU quota to pass.
|
||||
/// No single-agent convenience wrapper (unlike [`effective`] /
|
||||
/// [`effective_from`]): every current caller already has a loaded map
|
||||
/// in hand, so one would just be dead code.
|
||||
#[must_use]
|
||||
fn effective_memory_max_from(
|
||||
limits: &BTreeMap<String, AgentLimits>,
|
||||
name: &str,
|
||||
hive_memory_max: &str,
|
||||
) -> String {
|
||||
limits
|
||||
.get(name)
|
||||
.and_then(|l| l.memory_max.clone())
|
||||
.unwrap_or_else(|| hive_memory_max.to_owned())
|
||||
}
|
||||
|
||||
/// Effective `MemoryMax=` for `name`, as a raw byte count, against an
|
||||
/// already-loaded map — the form `render_flake_with_lookup` uses so it
|
||||
/// doesn't re-read `resource-limits.json` once per agent (same
|
||||
/// reasoning as [`effective_from`] / `container_view::build_all`). No
|
||||
/// single-agent convenience wrapper, for the same reason as
|
||||
/// [`effective_memory_max_from`] — every current caller already has a
|
||||
/// loaded map in hand.
|
||||
///
|
||||
/// Logs a `warn!` naming `name` when the effective value is a RAM
|
||||
/// percentage: unlike `"infinity"`, a percentage IS a real, resolvable
|
||||
/// cap — resolving one just needs host `MemTotal`, which this module
|
||||
/// doesn't track — so today it degrades to `None` (no derived heap
|
||||
/// ceiling) rather than silently guessing wrong against `MemTotal`.
|
||||
#[must_use]
|
||||
pub fn effective_memory_bytes_from(
|
||||
limits: &BTreeMap<String, AgentLimits>,
|
||||
name: &str,
|
||||
hive_memory_max: &str,
|
||||
) -> Option<u64> {
|
||||
let mem = effective_memory_max_from(limits, name, hive_memory_max);
|
||||
if mem.ends_with('%') {
|
||||
tracing::warn!(
|
||||
agent = name,
|
||||
memory_max = %mem,
|
||||
"effective MemoryMax= is a RAM percentage; can't derive a JSC heap ceiling from it \
|
||||
without host MemTotal — leaving BUN_JSC_forceRAMSize unset for this agent"
|
||||
);
|
||||
}
|
||||
parse_bytes(&mem)
|
||||
}
|
||||
|
||||
/// Parse a systemd `MemoryMax=`-style byte-size value (`"4G"`, `"512M"`,
|
||||
/// a bare byte count, optionally with a decimal like `"1.5G"`) into a
|
||||
/// raw byte count. systemd's `K`/`M`/`G`/`T` suffixes are IEC binary
|
||||
/// (1024-based), not decimal — this matches. Pure integer arithmetic
|
||||
/// throughout (via `u128` headroom) rather than `f64`: a `MemoryMax=`
|
||||
/// value is always a small non-negative decimal (enforced by
|
||||
/// [`is_plain_number`] upstream in [`validate_memory_max`]), so floats
|
||||
/// would only add rounding / sign-loss risk for no benefit. Returns
|
||||
/// `None` for `"infinity"` and percentages; see
|
||||
/// [`effective_memory_bytes_from`] for why those can't be turned into a
|
||||
/// byte count here.
|
||||
#[must_use]
|
||||
pub fn parse_bytes(value: &str) -> Option<u64> {
|
||||
if value == "infinity" || value.ends_with('%') {
|
||||
return None;
|
||||
}
|
||||
let (mantissa, exponent) = [('K', 1u32), ('M', 2), ('G', 3), ('T', 4)]
|
||||
.into_iter()
|
||||
.find_map(|(suffix, exp)| {
|
||||
value
|
||||
.strip_suffix([suffix, suffix.to_ascii_lowercase()])
|
||||
.map(|m| (m, exp))
|
||||
})
|
||||
.unwrap_or((value, 0));
|
||||
let scale = u128::from(1024u64.checked_pow(exponent)?);
|
||||
let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
|
||||
let whole: u128 = int_part.parse().ok()?;
|
||||
let mut bytes = whole.checked_mul(scale)?;
|
||||
if !frac_part.is_empty() {
|
||||
let frac_num: u128 = frac_part.parse().ok()?;
|
||||
let frac_denom = 10u128.checked_pow(u32::try_from(frac_part.len()).ok()?)?;
|
||||
bytes = bytes.checked_add(frac_num.checked_mul(scale)? / frac_denom)?;
|
||||
}
|
||||
u64::try_from(bytes).ok()
|
||||
}
|
||||
|
||||
/// Persist the full map. Sorted JSON output keeps meta-repo diffs
|
||||
/// minimal.
|
||||
///
|
||||
|
|
@ -323,4 +408,74 @@ mod tests {
|
|||
assert!(validate_memory_max(v).is_err(), "{v} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bytes_handles_plain_and_suffixed_values() {
|
||||
assert_eq!(parse_bytes("1024"), Some(1024));
|
||||
assert_eq!(parse_bytes("4G"), Some(4 * 1024 * 1024 * 1024));
|
||||
assert_eq!(parse_bytes("512M"), Some(512 * 1024 * 1024));
|
||||
assert_eq!(parse_bytes("4096K"), Some(4096 * 1024));
|
||||
assert_eq!(parse_bytes("2T"), Some(2 * 1024 * 1024 * 1024 * 1024));
|
||||
assert_eq!(
|
||||
parse_bytes("1.5G"),
|
||||
Some(1024 * 1024 * 1024 + 512 * 1024 * 1024)
|
||||
);
|
||||
// Lowercase suffixes accepted, matching `is_byte_size`.
|
||||
assert_eq!(parse_bytes("4g"), Some(4 * 1024 * 1024 * 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bytes_rejects_infinity_and_percentages() {
|
||||
assert_eq!(parse_bytes("infinity"), None);
|
||||
assert_eq!(parse_bytes("50%"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bytes_rejects_garbage() {
|
||||
for v in ["", "abc", "-8G", "8Gi"] {
|
||||
assert_eq!(parse_bytes(v), None, "{v} should not parse");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_memory_bytes_falls_back_to_hive_default() {
|
||||
// Empty map, i.e. no per-agent override — exercises the
|
||||
// "use the hive-wide default" arm end to end.
|
||||
let empty = BTreeMap::new();
|
||||
assert_eq!(
|
||||
effective_memory_bytes_from(&empty, "nobody-configured-this-agent", "4G"),
|
||||
Some(4 * 1024 * 1024 * 1024)
|
||||
);
|
||||
assert_eq!(
|
||||
effective_memory_bytes_from(&empty, "nobody-configured-this-agent", "infinity"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_memory_bytes_from_prefers_per_agent_override() {
|
||||
let map = BTreeMap::from([("sock".to_owned(), limits(None, Some("8G")))]);
|
||||
assert_eq!(
|
||||
effective_memory_bytes_from(&map, "sock", HIVE_MEM),
|
||||
Some(8 * 1024 * 1024 * 1024)
|
||||
);
|
||||
// A different agent not in the map still falls back to the
|
||||
// hive-wide default from the same loaded map (no re-read).
|
||||
assert_eq!(
|
||||
effective_memory_bytes_from(&map, "iris", HIVE_MEM),
|
||||
Some(4 * 1024 * 1024 * 1024)
|
||||
);
|
||||
}
|
||||
|
||||
/// A percentage cap is real and resolvable in principle, but this
|
||||
/// module has no host `MemTotal` to resolve it against — must
|
||||
/// degrade to `None` (no derived heap ceiling) rather than silently
|
||||
/// treating it as unbounded or guessing a number. `warn!` firing is
|
||||
/// exercised for coverage but not asserted on (no tracing test
|
||||
/// subscriber wired up here) — the `None` return is the contract.
|
||||
#[test]
|
||||
fn effective_memory_bytes_from_returns_none_for_percentage() {
|
||||
let map = BTreeMap::from([("sock".to_owned(), limits(None, Some("50%")))]);
|
||||
assert_eq!(effective_memory_bytes_from(&map, "sock", HIVE_MEM), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue