Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: let a swarm cap replace the hive's answer, not join its chain

mara's ruling: "swarm level overrides replace the hive ui limits. otherwise
dont change the logic."

  swarm value present  ->  it is the value; nothing hive-side is consulted
  swarm value absent   ->  exactly today's chain: per-agent field, else hive

Per field, so a swarm memory ceiling leaves CPU on the hive's chain.

The fallback chain itself is not touched: effective_from_with_swarm calls
effective_from as it was and lets the swarm replace its result, and
effective_with_swarm delegates to effective, so there stays one place the
chain lives. `an_empty_swarm_map_leaves_the_existing_chain_bit_for_bit`
asserts the wrapper is a no-op across every shape the chain distinguishes.

The swarm's caps live in their own swarm-resource-limits.json, not in the
operator's resource-limits.json: different writers, different lifetimes,
and a swarm write into the operator's file would make "reset to hive
defaults" destroy something the operator never set. It is read and written
through the same read_map / write_map, so an unreadable file is an error,
and write_dropins resolves both files through limits_to_apply, whose
keep-the-last-applied-drop-in policy covers the swarm file too.

stage_limits hands limits_to_apply the swarm caps file too, so the
agent's memory-max-bytes copy, which it sizes its heap and subagents
against, is the MemoryMax= systemd enforces: the swarm cap when there is
one.

Nothing writes swarm-resource-limits.json yet; the reader comes with the
swarm's agent-limits bucket later in this series.
This commit is contained in:
atlas 2026-10-12 11:26:18 +02:00 • committed by mara
commit 37f6a845a0
4 changed files with 232 additions and 9 deletions

View file

@ -187,6 +187,86 @@ fn set_limits_at(path: &Path, name: &str, limits: &AgentLimits) -> std::io::Resu
super::write_map(path, &current)
}
/// Caps the swarm controller declared for this hive's agents.
///
/// A separate file from the operator's overrides above, not a merge into it:
/// the two have different writers and different lifetimes, and a swarm
/// declaration landing in the file an operator edits would make "reset to
/// hive defaults" destroy something the operator never set.
const SWARM_LIMITS_FILE: &str = "swarm-resource-limits.json";
#[must_use]
pub fn swarm_limits_path() -> PathBuf {
crate::paths::meta_root().join(SWARM_LIMITS_FILE)
}
/// Read the swarm-declared caps. An absent file is the empty map — no swarm
/// opinion, so the hive's own chain decides. A file that exists but can't be
/// read or parsed is an error, for the reason [`read`] gives: reading it as
/// empty would silently drop a cap.
pub fn read_swarm() -> std::io::Result<BTreeMap<String, AgentLimits>> {
super::read_map(&swarm_limits_path())
}
/// Persist the swarm-declared caps atomically.
///
/// # Errors
/// The meta dir cannot be created, or the file cannot be written.
pub fn write_swarm(map: &BTreeMap<String, AgentLimits>) -> std::io::Result<()> {
super::write_map(&swarm_limits_path(), map)
}
/// [`effective_from`] with the swarm's declaration on top.
///
/// A swarm cap **replaces** the hive's answer for that field rather than
/// joining the fallback chain as another tier: a tier a hive can out-set is
/// not a cap. Per field, so a swarm memory ceiling leaves CPU on the hive's
/// chain.
///
/// The chain itself is untouched — [`effective_from`] is called exactly as
/// before and its result is what a missing swarm value falls back to.
#[must_use]
pub fn effective_from_with_swarm(
swarm: &BTreeMap<String, AgentLimits>,
limits: &BTreeMap<String, AgentLimits>,
name: &str,
hive_cpu_quota: &str,
hive_memory_max: &str,
) -> (String, String) {
let (cpu, mem) = effective_from(limits, name, hive_cpu_quota, hive_memory_max);
match swarm.get(name) {
Some(s) => (
s.cpu_quota.clone().unwrap_or(cpu),
s.memory_max.clone().unwrap_or(mem),
),
None => (cpu, mem),
}
}
/// [`effective_from_with_swarm`] for a caller resolving a single agent, from
/// the operator overrides at `limits_path` and the swarm caps at `swarm_path`.
///
/// Delegates to [`effective`] rather than re-deriving the hive's answer, so
/// there stays exactly one place the fallback chain lives. Errors as [`read`]
/// does, for either file.
pub fn effective_with_swarm(
limits_path: &Path,
swarm_path: &Path,
name: &str,
hive_cpu_quota: &str,
hive_memory_max: &str,
) -> std::io::Result<(String, String)> {
let (cpu, mem) = effective(limits_path, name, hive_cpu_quota, hive_memory_max)?;
let swarm: BTreeMap<String, AgentLimits> = super::read_map(swarm_path)?;
Ok(match swarm.get(name) {
Some(s) => (
s.cpu_quota.clone().unwrap_or(cpu),
s.memory_max.clone().unwrap_or(mem),
),
None => (cpu, mem),
})
}
/// Validate a systemd `CPUQuota=` value. Percentages only, and values
/// above 100% are legitimate (one full core is 100%).
///
@ -525,4 +605,52 @@ mod tests {
);
}
}
#[test]
fn a_swarm_cap_replaces_the_hive_answer_rather_than_joining_the_chain() {
// The whole point of the ruling: a tier a hive can out-set is not a
// cap. The agent override below is the strongest hive-side value
// there is, and the swarm still wins.
let hive_side = BTreeMap::from([("sock".to_owned(), limits(Some("800%"), Some("16G")))]);
let swarm = BTreeMap::from([("sock".to_owned(), limits(Some("150%"), Some("2G")))]);
assert_eq!(
effective_from_with_swarm(&swarm, &hive_side, "sock", HIVE_CPU, HIVE_MEM),
("150%".to_owned(), "2G".to_owned())
);
}
#[test]
fn a_swarm_cap_is_per_field_like_every_other_tier() {
// A swarm that caps memory and says nothing about CPU leaves CPU on
// the hive's chain — here the per-agent override, not the hive default.
let hive_side = BTreeMap::from([("sock".to_owned(), limits(Some("800%"), None))]);
let swarm = BTreeMap::from([("sock".to_owned(), limits(None, Some("2G")))]);
assert_eq!(
effective_from_with_swarm(&swarm, &hive_side, "sock", HIVE_CPU, HIVE_MEM),
("800%".to_owned(), "2G".to_owned())
);
}
#[test]
fn an_empty_swarm_map_leaves_the_existing_chain_bit_for_bit() {
// The control that makes the two tests above mean something: the
// wrapper must be a no-op when the swarm has said nothing, for every
// shape the old chain distinguishes. If this drifts, the swarm tier
// changed behaviour it was explicitly told not to change.
let none = BTreeMap::new();
let cases = [
BTreeMap::new(),
BTreeMap::from([("sock".to_owned(), limits(Some("400%"), Some("8G")))]),
BTreeMap::from([("sock".to_owned(), limits(Some("400%"), None))]),
BTreeMap::from([("sock".to_owned(), limits(None, Some("8G")))]),
BTreeMap::from([("other".to_owned(), limits(Some("100%"), Some("1G")))]),
];
for hive_side in &cases {
assert_eq!(
effective_from_with_swarm(&none, hive_side, "sock", HIVE_CPU, HIVE_MEM),
effective_from(hive_side, "sock", HIVE_CPU, HIVE_MEM),
"the wrapper changed the answer with no swarm value present"
);
}
}
}