Watch
0
0
Fork
You've already forked hyperhive
0

fan out the meta pin by a recorded pin, not by the move

This commit is contained in:
atlas 2026-10-25 02:05:24 +02:00 • committed by mara
commit 2b9927fda2
4 changed files with 489 additions and 75 deletions

View file

@ -10,8 +10,8 @@ use crate::routes::AppState;
use crate::routes::agents::{claiming_hives, read_declarations};
use crate::startup::PUBLIC_URL_ENV;
use crate::{
agent_identity, agent_renewal, auth, builder, config_pr, forge, matrix_account, meta_flake,
wanted,
agent_identity, agent_renewal, auth, builder, config_pr, forge, matrix_account, meta_fanout,
meta_flake, wanted,
};
/// Node payload for the swarm-level job graph. Named `Swarm*` rather than
@ -135,12 +135,17 @@ pub(crate) enum SwarmNodeKind {
rev: Option<String>,
},
/// Pin the swarm meta flake (`internal/meta`): render, lock on the swarm
/// builder, commit. See `meta_flake`. When the commit moves the hyperhive
/// pin, a `TriggerDeploy` per placed agent follows.
/// builder, commit. See `meta_flake`. When the pin `internal/meta` holds is
/// not the one `meta_fanout` recorded, a `TriggerDeploy` per placed agent
/// follows, then a `RecordFanout`.
///
/// Carries nothing: it reads every head when it runs, so one pending node
/// covers every trigger queued behind it.
UpdateMeta,
/// Record `pin` in `meta_fanout` as sent to the hives. Runs only once every
/// `TriggerDeploy` declared beside it is done, so a failed or lost deploy
/// leaves the record stale and the next `UpdateMeta` sends them again.
RecordFanout { pin: String },
}
impl hive_jobq_wire::WireNode for SwarmNodeKind {
@ -167,6 +172,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(),
SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(),
SwarmNodeKind::UpdateMeta => "update_meta".to_owned(),
SwarmNodeKind::RecordFanout { .. } => "record_fanout".to_owned(),
}
}
@ -205,6 +211,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::SetAgentWanted { hive, agent } => {
serde_json::json!({ "agent": agent, "hive": hive })
}
SwarmNodeKind::RecordFanout { pin } => serde_json::json!({ "pin": pin }),
}
}
}
@ -396,6 +403,7 @@ pub(crate) async fn run_swarm_node(
Some(client) => publish_deploy(&client, &hive, &agent, rev).await,
},
SwarmNodeKind::UpdateMeta => finish_update_meta(update_meta(&deps, forge).await, &builder),
SwarmNodeKind::RecordFanout { pin } => record_fanout(deps.queue.as_ref(), &pin).await,
};
(builder, outcome)
}
@ -565,18 +573,55 @@ pub(crate) async fn build_agent(
.await
}
/// What an `UpdateMeta` pass decided: the deploys to send, and the placements
/// to send them to.
struct MetaPass {
fan_out: FanOut,
placements: std::collections::BTreeSet<(String, String)>,
}
/// What a pass sends the placed agents.
#[derive(Clone, Debug, PartialEq, Eq)]
enum FanOut {
/// Nothing: the pin is the one recorded.
None,
/// A deploy each, then record `pin`.
Record { pin: String },
/// A deploy each, with nothing to record into: the record cannot be read.
Unrecorded,
}
/// The fan-out for the pin `report` leaves, given the pin `meta_fanout` has
/// recorded. With no readable record, only a move of the pin sends deploys,
/// and nothing can be recorded.
fn decide_fan_out(report: &meta_flake::Report, recorded: Result<Option<String>>) -> FanOut {
match recorded {
Ok(Some(pin)) if pin == report.pin => FanOut::None,
Ok(_) => FanOut::Record {
pin: report.pin.clone(),
},
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
moved = report.hyperhive_moved,
"update_meta: the recorded fan-out pin is unreadable; deploying only on a moved pin"
);
if report.hyperhive_moved {
FanOut::Unrecorded
} else {
FanOut::None
}
}
}
}
/// The `UpdateMeta` arm, lifted out for the same reason: what the update
/// reported, and the placements it rendered for. `Err` is the node's failure.
/// decided to send, and the placements it rendered for. `Err` is the node's
/// failure.
async fn update_meta(
deps: &WorkerDeps,
forge: Option<Arc<forge::Client>>,
) -> Result<
(
meta_flake::Report,
std::collections::BTreeSet<(String, String)>,
),
String,
> {
) -> Result<MetaPass, String> {
let Some(client) = forge else {
return Err(
"no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token \
@ -610,50 +655,79 @@ async fn update_meta(
.await
.map_err(|e| format!("{e:#}"))?;
tracing::info!(?report, agents = agents.len(), "update_meta: done");
Ok((report, placements))
let fan_out = decide_fan_out(&report, meta_fanout::recorded(queue).await);
Ok(MetaPass {
fan_out,
placements,
})
}
/// The `UpdateMeta` node's outcome from what [`update_meta`] reported, with the
/// deploys a moved pin calls for declared into `builder` (the scheduler inserts
/// them under the node when it completes). Synchronous: the builder is not
/// `Sync`, so it cannot be held across an await.
/// The `UpdateMeta` node's outcome from what [`update_meta`] decided, with the
/// deploys declared into `builder` (the scheduler inserts them under the node
/// when it completes). Synchronous: the builder is not `Sync`, so it cannot be
/// held across an await.
fn finish_update_meta(
result: Result<
(
meta_flake::Report,
std::collections::BTreeSet<(String, String)>,
),
String,
>,
result: Result<MetaPass, String>,
builder: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
match result {
Ok((report, placements)) => {
fan_out_if_moved(builder, &report, &placements);
Ok(pass) => {
declare_fan_out(builder, &pass.fan_out, &pass.placements);
Outcome::Done
}
Err(e) => Outcome::Failed(e),
}
}
/// Declare one rebuild of each `(hive, agent)` placement when `report` says
/// the agents' hyperhive moved: nothing else tells them to build again.
fn fan_out_if_moved(
/// The `RecordFanout` arm, lifted out so `run_swarm_node` stays under the line limit.
async fn record_fanout(
queue: Option<&async_nats::Client>,
pin: &str,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
let Some(client) = queue else {
return Outcome::Failed(
"no swarm queue is configured on this host, so the fan-out cannot be recorded"
.to_owned(),
);
};
match meta_fanout::record(client, pin).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
/// Declare one rebuild of each `(hive, agent)` placement, which nothing else
/// tells to build again, and for a [`FanOut::Record`] the `RecordFanout` that
/// waits on every one of them.
fn declare_fan_out(
grown: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
report: &meta_flake::Report,
fan_out: &FanOut,
placements: &std::collections::BTreeSet<(String, String)>,
) {
if !report.hyperhive_moved {
if *fan_out == FanOut::None {
return;
}
for (hive, agent) in placements {
let _ = grown.node(SwarmNodeKind::TriggerDeploy {
hive: hive.clone(),
agent: agent.clone(),
rev: None,
});
let deploys: Vec<hive_jobq::builder::NodeGuid> = placements
.iter()
.map(|(hive, agent)| {
grown
.node(SwarmNodeKind::TriggerDeploy {
hive: hive.clone(),
agent: agent.clone(),
rev: None,
})
.guid()
})
.collect();
if let FanOut::Record { pin } = fan_out {
let mut record = grown.node(SwarmNodeKind::RecordFanout { pin: pin.clone() });
for deploy in deploys {
record = record.after_ok(deploy);
}
}
}
@ -2092,67 +2166,300 @@ mod tests {
);
}
/// Run an `UpdateMeta` node that reports `report`, and return the
/// `(hive, agent, rev)` of every deploy it grew.
async fn grown_deploys(report: meta_flake::Report) -> Vec<(String, String, Option<String>)> {
/// A fan-out record held in memory; `unreadable` stands for a bucket that
/// cannot be opened.
#[derive(Default)]
struct FakePins {
pin: Mutex<Option<String>>,
unreadable: bool,
}
impl FakePins {
fn holding(pin: &str) -> Self {
Self {
pin: Mutex::new(Some(pin.to_owned())),
unreadable: false,
}
}
fn held(&self) -> Option<String> {
self.pin.lock().unwrap().clone()
}
}
impl meta_fanout::PinStore for FakePins {
async fn recorded(&self) -> Result<Option<String>> {
anyhow::ensure!(!self.unreadable, "the bucket did not open");
Ok(self.held())
}
async fn record(&self, pin: &str) -> Result<()> {
*self.pin.lock().unwrap() = Some(pin.to_owned());
Ok(())
}
}
fn report(pin: &str, moved: bool) -> meta_flake::Report {
meta_flake::Report {
updated: meta_flake::Updated::Committed,
pin: pin.to_owned(),
hyperhive_moved: moved,
}
}
type TestScheduler = hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>;
/// One `UpdateMeta` pass against `pins`, with every node it grows run: the
/// deploy to `failing_agent` fails, and `RecordFanout` dies before it
/// writes when `crash_before_record`. Returns what the pass decided and the
/// scheduler it ran in.
async fn pass(
pins: &Arc<FakePins>,
report: &meta_flake::Report,
placed: &[(&str, &str)],
failing_agent: Option<&'static str>,
crash_before_record: bool,
) -> (FanOut, Arc<Mutex<TestScheduler>>) {
use hive_jobq::scheduler::{Outcome, Scheduler};
use meta_fanout::PinStore as _;
let mut sched = Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
);
let update = sched
sched
.append(SwarmNodeKind::UpdateMeta, Vec::new(), None)
.expect("insert");
let sched = std::sync::Arc::new(std::sync::Mutex::new(sched));
let placements: std::collections::BTreeSet<(String, String)> =
[("pr1ma", "atlas"), ("pr1ma", "argus"), ("sec0nd", "iris")]
.into_iter()
.map(|(h, a)| (h.to_owned(), a.to_owned()))
.collect();
let runner = Scheduler::claim_next(&sched, move |_id, _kind, builder| async move {
fan_out_if_moved(&builder, &report, &placements);
let sched = Arc::new(Mutex::new(sched));
let placements: std::collections::BTreeSet<(String, String)> = placed
.iter()
.map(|(h, a)| ((*h).to_owned(), (*a).to_owned()))
.collect();
let fan_out = decide_fan_out(report, pins.recorded().await);
let decided = fan_out.clone();
let update = Scheduler::claim_next(&sched, move |_id, _kind, builder| async move {
declare_fan_out(&builder, &fan_out, &placements);
(builder, Outcome::Done)
})
.expect("the node just inserted is runnable");
runner.await.1.expect("the grown nodes insert");
let guard = sched.lock().unwrap();
guard
update.await.1.expect("the grown nodes insert");
while let Some(runner) = Scheduler::claim_next(&sched, {
let (pins, sched) = (Arc::clone(pins), Arc::clone(&sched));
move |_id, kind, builder| async move {
let outcome = match kind {
SwarmNodeKind::TriggerDeploy { agent, .. }
if failing_agent == Some(agent.as_str()) =>
{
Outcome::Failed("the hive did not take it".to_owned())
}
SwarmNodeKind::RecordFanout { .. } if crash_before_record => {
Outcome::Failed("the controller died".to_owned())
}
SwarmNodeKind::RecordFanout { pin } => {
let deploys_done = sched
.lock()
.unwrap()
.graph()
.nodes()
.filter(|n| matches!(n.payload, SwarmNodeKind::TriggerDeploy { .. }))
.all(|n| n.state == hive_jobq::State::Done);
if deploys_done {
pins.record(&pin).await.expect("recorded");
Outcome::Done
} else {
Outcome::Failed("recorded before the deploys were done".to_owned())
}
}
_ => Outcome::Done,
};
(builder, outcome)
}
}) {
runner.await.1.expect("no growth declared");
}
(decided, sched)
}
/// The states of the nodes labelled `label`.
fn states_of(sched: &Mutex<TestScheduler>, label: &str) -> Vec<hive_jobq::State> {
use hive_jobq_wire::WireNode as _;
sched
.lock()
.unwrap()
.graph()
.nodes()
.filter(|n| n.payload.label() == label)
.map(|n| n.state)
.collect()
}
const PLACED: [(&str, &str); 3] = [("pr1ma", "atlas"), ("pr1ma", "argus"), ("sec0nd", "iris")];
/// A controller with no record sends every placed agent a deploy, whether
/// or not the pin moved, and records the pin once they are done.
#[tokio::test]
async fn a_pin_with_no_record_deploys_every_placed_agent_and_is_recorded() {
let pins = Arc::new(FakePins::default());
let (fan_out, sched) = pass(&pins, &report("p1", false), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::Record { pin: "p1".into() });
assert_eq!(
states_of(&sched, "trigger_deploy"),
[hive_jobq::State::Done; 3]
);
assert_eq!(states_of(&sched, "record_fanout"), [hive_jobq::State::Done]);
assert_eq!(pins.held().as_deref(), Some("p1"));
}
/// The deploys declared are one per placement, to the hive that places it.
#[tokio::test]
async fn each_deploy_goes_to_the_hive_that_places_the_agent() {
let pins = Arc::new(FakePins::default());
let (_, sched) = pass(&pins, &report("p1", false), &PLACED, None, false).await;
let mut deploys: Vec<_> = sched
.lock()
.unwrap()
.graph()
.nodes()
.filter_map(|n| match &n.payload {
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => {
assert_eq!(n.parent, Some(update), "grown under the update");
Some((hive.clone(), agent.clone(), rev.clone()))
}
_ => None,
})
.collect()
}
#[tokio::test]
async fn a_moved_pin_rebuilds_every_placed_agent_and_an_unmoved_one_none() {
let moved = grown_deploys(meta_flake::Report {
updated: meta_flake::Updated::Committed,
hyperhive_moved: true,
})
.await;
let mut moved: Vec<_> = moved.into_iter().collect();
moved.sort();
.collect();
deploys.sort();
assert_eq!(
moved,
deploys,
vec![
("pr1ma".to_owned(), "argus".to_owned(), None),
("pr1ma".to_owned(), "atlas".to_owned(), None),
("sec0nd".to_owned(), "iris".to_owned(), None),
]
);
let unmoved = grown_deploys(meta_flake::Report {
updated: meta_flake::Updated::Committed,
hyperhive_moved: false,
})
.await;
assert!(unmoved.is_empty(), "{unmoved:?}");
}
/// The record, not whether the pin moved, decides: a pin already sent
/// sends nothing even on a pass that moved it, and a different one does.
#[tokio::test]
async fn a_pin_that_is_recorded_deploys_nothing() {
let pins = Arc::new(FakePins::holding("p1"));
let (fan_out, sched) = pass(&pins, &report("p1", true), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::None);
assert!(states_of(&sched, "trigger_deploy").is_empty());
assert!(states_of(&sched, "record_fanout").is_empty());
let (fan_out, sched) = pass(&pins, &report("p2", false), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::Record { pin: "p2".into() });
assert_eq!(states_of(&sched, "trigger_deploy").len(), 3);
assert_eq!(pins.held().as_deref(), Some("p2"));
}
/// A controller that dies after the commit and before the record is
/// written finds the commit unchanged on its next pass, and still sends.
#[tokio::test]
async fn a_pass_that_dies_before_the_record_is_written_sends_again() {
let pins = Arc::new(FakePins::holding("p0"));
let (_, sched) = pass(&pins, &report("p1", true), &PLACED, None, true).await;
assert_eq!(states_of(&sched, "trigger_deploy").len(), 3);
assert_eq!(pins.held().as_deref(), Some("p0"), "nothing was recorded");
// The restart: the commit is the render now, so nothing moved.
let (fan_out, _) = pass(&pins, &report("p1", false), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::Record { pin: "p1".into() });
assert_eq!(pins.held().as_deref(), Some("p1"));
let (fan_out, _) = pass(&pins, &report("p1", false), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::None);
}
/// The record waits on every deploy of its pass, for all of them to be
/// done, not merely finished.
#[tokio::test]
async fn the_record_waits_on_every_deploy_being_done() {
let pins = Arc::new(FakePins::default());
let (_, sched) = pass(&pins, &report("p1", false), &PLACED, None, false).await;
let guard = sched.lock().unwrap();
let graph = guard.graph();
let record = graph
.nodes()
.find(|n| matches!(n.payload, SwarmNodeKind::RecordFanout { .. }))
.expect("a record node");
let mut waits: Vec<_> = record
.deps
.iter()
.map(|d| match d {
hive_jobq::Dep::Node { id, when } => (*id, *when),
hive_jobq::Dep::Resource { .. } => panic!("the record needs no resource"),
})
.collect();
waits.sort_by_key(|(id, _)| id.get());
let mut deploys: Vec<_> = graph
.nodes()
.filter(|n| matches!(n.payload, SwarmNodeKind::TriggerDeploy { .. }))
.map(|n| (n.id, hive_jobq::DepWhen::AFTER_OK))
.collect();
deploys.sort_by_key(|(id, _)| id.get());
assert_eq!(deploys.len(), 3);
assert_eq!(waits, deploys);
}
/// One deploy failing keeps the pin unrecorded: the record is ruled out,
/// the others still run, and the next pass sends again.
#[tokio::test]
async fn a_failed_deploy_leaves_the_pin_unrecorded() {
let pins = Arc::new(FakePins::holding("p0"));
let (_, sched) = pass(&pins, &report("p1", true), &PLACED, Some("argus"), false).await;
let mut deploys = states_of(&sched, "trigger_deploy");
deploys.sort_by_key(|s| format!("{s:?}"));
assert_eq!(
deploys,
[
hive_jobq::State::Done,
hive_jobq::State::Done,
hive_jobq::State::Failed
]
);
assert_eq!(
states_of(&sched, "record_fanout"),
[hive_jobq::State::Skipped]
);
assert_eq!(pins.held().as_deref(), Some("p0"));
let (fan_out, _) = pass(&pins, &report("p1", false), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::Record { pin: "p1".into() });
}
/// With nothing placed there is nothing to wait on, and the pin is
/// recorded all the same.
#[tokio::test]
async fn a_pin_with_no_placed_agent_is_recorded() {
let pins = Arc::new(FakePins::default());
let (_, sched) = pass(&pins, &report("p1", false), &[], None, false).await;
assert!(states_of(&sched, "trigger_deploy").is_empty());
assert_eq!(states_of(&sched, "record_fanout"), [hive_jobq::State::Done]);
assert_eq!(pins.held().as_deref(), Some("p1"));
}
/// While the record cannot be read, a moved pin deploys as it did before
/// the record existed, and there is nothing to record into.
#[tokio::test]
async fn an_unreadable_record_falls_back_to_the_moved_pin() {
let pins = Arc::new(FakePins {
unreadable: true,
..FakePins::default()
});
let (fan_out, sched) = pass(&pins, &report("p1", true), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::Unrecorded);
assert_eq!(
states_of(&sched, "trigger_deploy"),
[hive_jobq::State::Done; 3]
);
assert!(states_of(&sched, "record_fanout").is_empty());
let (fan_out, sched) = pass(&pins, &report("p1", false), &PLACED, None, false).await;
assert_eq!(fan_out, FanOut::None);
assert!(states_of(&sched, "trigger_deploy").is_empty());
}
/// Node labels of the graph [`super::declare_agent_job`] builds, and the