Watch
0
0
Fork
You've already forked hyperhive
0

c0re: keep power intent in memory, drop agent_power/kv tables and the stop snapshot

This commit is contained in:
damocles 2026-11-07 01:22:18 +01:00 • committed by mara
commit b77fa9edc3

View file

@ -4,8 +4,7 @@
//! [`swarm_wire::wanted`]); this reads its own and acts on it. It is
//! the **repair** path, not the fast one: a deploy event
//! ([`crate::swarm_status`]) is core NATS, so a hive that was down never hears
//! it, and until this existed that left the hive on its old shape with nothing
//! looking wrong.
//! it and would stay on its old shape with nothing looking wrong.
//!
//! # Two things a declaration does not mean
//!
@ -21,13 +20,14 @@
//!
//! # What "diverged" is measured against
//!
//! The hive's **stored power intent**, not the container's observed running
//! state. An agent down while its intent says `Up` is already the boot
//! The power intent this process holds, not the container's observed running
//! state. An agent down while its intent says `Up` is already a queued
//! reconcile's work; a loop reading `is_running` would insert a start DAG
//! behind that reconcile's back on every boot.
//! behind that reconcile's back.
//!
//! One consequence, deliberate: a declaration outranks a local stop — an agent
//! declared `Up` returns at the next boot or watch attach, because the controller owns that.
//! A declaration outranks a local stop, since the controller owns lasting
//! power intent. Nothing is remembered across a restart, so the first pass
//! after one queues a (no-op) start or stop for each declared agent.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
@ -341,26 +341,17 @@ async fn converge(coord: &Arc<Coordinator>, declared: &HiveWanted) -> Result<()>
.map(|spec| spec.name)
.collect();
let mut intents = BTreeMap::new();
for agent in declared.agents.keys() {
match coord.power.get(agent) {
Ok(intent) => {
intents.insert(agent.clone(), intent);
}
// Left out of the map, which is what makes `plan` skip it.
Err(e) => {
tracing::warn!(%agent, error = ?e, "wanted state: cannot read power intent; skipping");
}
}
}
let intents: BTreeMap<String, Option<Wanted>> = declared
.agents
.keys()
.map(|agent| (agent.clone(), coord.power.get(agent)))
.collect();
// The pause marker is a stat, not a store read — it cannot fail the way
// `coord.power.get` above can, so there is no "unreadable, skip" branch
// to mirror. A name that does not parse as an `Ident` is skipped with a
// warning instead, the same defensive shape `crash_watch` uses for the
// same reason: a declaration is attacker-adjacent input (published by
// the swarm controller, not typed by an operator at this hive), so a
// malformed name should not panic the convergence loop.
// A name that does not parse as an `Ident` is skipped with a warning, the
// same defensive shape `crash_watch` uses for the same reason: a
// declaration is attacker-adjacent input (published by the swarm
// controller, not typed by an operator at this hive), so a malformed name
// should not panic the convergence loop.
let mut paused = BTreeMap::new();
for agent in declared.agents.keys() {
match hive_types::Ident::parse(agent) {
@ -463,10 +454,9 @@ struct Plan {
/// Turn one declaration into that plan — pure, so what the loop does with a
/// given declaration is testable without a queue, a container or a store.
///
/// `intents` is the hive's stored power intent per agent, and an agent
/// **missing from the map** is one whose intent could not be read: it is
/// skipped. That is not `Some(None)` — "no row yet" is a reading and gets
/// converged, "the read failed" is not and would be a guess.
/// `intents` is the power intent this process holds per agent: `None` is an
/// agent nothing has set an intent for since the process started, which gets
/// converged like any other that disagrees with the declaration.
///
/// An agent the declaration does not name reaches none of the three lists.
/// That is the whole of "absence is not a deletion order": the agents this
@ -479,10 +469,6 @@ fn plan(
) -> Plan {
let mut plan = Plan::default();
for (agent, decl) in &declared.agents {
// The pause axis does not gate on a readable power intent the way
// the match below does — it has no intent row to fail reading, only
// a stat that always answers — so it is evaluated unconditionally,
// even for an agent this pass otherwise skips.
let is_present = present.contains(agent);
let is_paused = paused.get(agent).copied().unwrap_or(false);
match decide_pause(decl.state, is_present, is_paused) {
@ -491,10 +477,8 @@ fn plan(
PauseConverge::Nothing => {}
}
let Some(intent) = intents.get(agent) else {
continue;
};
match decide(decl.state, is_present, *intent) {
let intent = intents.get(agent).copied().flatten();
match decide(decl.state, is_present, intent) {
Converge::Deploy => plan.deploy.push(agent.clone()),
Converge::Start => plan.start.push(agent.clone()),
Converge::Stop => plan.stop.push(agent.clone()),
@ -659,37 +643,6 @@ mod tests {
);
}
/// An intent that could not be read is skipped rather than treated as "no
/// row". `readable` is the control for the same call.
#[test]
fn an_unreadable_intent_skips_only_that_agent() {
let declared = declaration(&[("unreadable", AgentState::Up), ("readable", AgentState::Up)]);
let planned = plan(
&declared,
&set(&["unreadable", "readable"]),
&intents(&[("readable", None)]),
&paused(&[]),
);
assert_eq!(planned.start, vec!["readable".to_owned()]);
assert!(planned.deploy.is_empty() && planned.stop.is_empty());
}
/// The pause axis does not gate on a readable intent row at all — a
/// `paused` agent this hive has no power-intent row for still gets its
/// pause marker converged, unlike the power axis right above.
#[test]
fn pause_converges_even_when_the_power_intent_is_unreadable() {
let declared = declaration(&[("unreadable", AgentState::Paused)]);
let planned = plan(
&declared,
&set(&["unreadable"]),
&intents(&[]),
&paused(&[("unreadable", false)]),
);
assert_eq!(planned.pause, vec!["unreadable".to_owned()]);
assert!(planned.start.is_empty() && planned.deploy.is_empty());
}
/// A declared-paused agent that is present, running and already carries
/// the marker needs nothing on either axis.
#[test]
@ -738,7 +691,7 @@ mod tests {
#[test]
fn a_declared_agent_this_hive_does_not_have_is_deployed() {
assert_eq!(decide(AgentState::Up, false, None), Converge::Deploy);
// Even holding an intent: a row can outlive its container.
// Even holding an intent: it can outlive its container.
assert_eq!(
decide(AgentState::Up, false, Some(Wanted::Up)),
Converge::Deploy
@ -769,11 +722,11 @@ mod tests {
);
}
/// No row is not agreement. An agent that predates the power store is
/// converged to what it was declared as, not left on whatever it happens
/// to be doing.
/// No intent is not agreement. An agent nothing has set an intent for since
/// this process started is converged to what it was declared as, not left
/// on whatever it happens to be doing.
#[test]
fn an_agent_with_no_intent_row_is_converged() {
fn an_agent_with_no_intent_is_converged() {
assert_eq!(decide(AgentState::Up, true, None), Converge::Start);
assert_eq!(decide(AgentState::Offline, true, None), Converge::Stop);
}