pin internal/meta's hyperhive on the controller's rev and rebuild agents when it moves
The swarm controller offers its own running rev (services.hyperhive.c0re. hyperhiveRev) as the hyperhive pin of internal/meta and takes it only when the forge compare API shows it descends from the committed pin; merges to hyperhive main no longer move the pin, and the first commit pins main's head. When a commit moves the pin, every placed agent gets a TriggerDeploy. A deploy after a meta update no longer waits on it succeeding (after_any), so a down builder does not hold back hives that render locally.
This commit is contained in:
parent
5950363a10
commit
22e2d30581
10 changed files with 1027 additions and 148 deletions
|
|
@ -134,12 +134,12 @@ pub(crate) enum SwarmNodeKind {
|
|||
/// agent creation, where the hive deploys what it seeds.
|
||||
rev: Option<String>,
|
||||
},
|
||||
/// Pin the swarm meta flake (`internal/meta`) to the forge's current
|
||||
/// heads: render, lock on the swarm builder, commit. See `meta_flake`.
|
||||
/// 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.
|
||||
///
|
||||
/// Carries nothing: it reads every head when it runs, so one pending node
|
||||
/// covers every trigger queued behind it. Hives build from that repo, so a
|
||||
/// deploy waits for this node.
|
||||
/// covers every trigger queued behind it.
|
||||
UpdateMeta,
|
||||
}
|
||||
|
||||
|
|
@ -395,7 +395,7 @@ pub(crate) async fn run_swarm_node(
|
|||
),
|
||||
Some(client) => publish_deploy(&client, &hive, &agent, rev).await,
|
||||
},
|
||||
SwarmNodeKind::UpdateMeta => update_meta(&deps, forge).await,
|
||||
SwarmNodeKind::UpdateMeta => finish_update_meta(update_meta(&deps, forge).await, &builder),
|
||||
};
|
||||
(builder, outcome)
|
||||
}
|
||||
|
|
@ -565,64 +565,104 @@ pub(crate) async fn build_agent(
|
|||
.await
|
||||
}
|
||||
|
||||
/// The `UpdateMeta` arm, lifted out for the same reason.
|
||||
/// 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.
|
||||
async fn update_meta(
|
||||
deps: &WorkerDeps,
|
||||
forge: Option<Arc<forge::Client>>,
|
||||
) -> hive_jobq::scheduler::Outcome {
|
||||
use hive_jobq::scheduler::Outcome;
|
||||
|
||||
) -> Result<
|
||||
(
|
||||
meta_flake::Report,
|
||||
std::collections::BTreeSet<(String, String)>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let Some(client) = forge else {
|
||||
return Outcome::Failed(
|
||||
return Err(
|
||||
"no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token \
|
||||
not in the swarm secret store), so internal/meta cannot be written"
|
||||
.to_owned(),
|
||||
);
|
||||
};
|
||||
let Some(meta) = meta_deps() else {
|
||||
return Outcome::Failed(format!(
|
||||
return Err(format!(
|
||||
"{} is unset, so there are no swarm values to render internal/meta from",
|
||||
meta_flake::SETTINGS_ENV
|
||||
));
|
||||
};
|
||||
let (Some(queue), Some(wanted)) = (&deps.queue, &deps.wanted) else {
|
||||
return Outcome::Failed(
|
||||
"no swarm queue is configured on this host, so neither the placed agents nor a \
|
||||
lock can be had"
|
||||
return Err(
|
||||
"no swarm queue is configured on this host, so neither the placed agents \
|
||||
nor a lock can be had"
|
||||
.to_owned(),
|
||||
);
|
||||
};
|
||||
let agents = match placed_agents(queue, wanted, &meta.hives).await {
|
||||
Ok(agents) => agents,
|
||||
Err(e) => return Outcome::Failed(format!("{e:#}")),
|
||||
};
|
||||
match forge_node(
|
||||
let placements = placements(wanted, &meta.hives)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
let agents = placed_agents(queue, &placements)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
let report = forge_node(
|
||||
"update_meta",
|
||||
meta_flake::update(
|
||||
client.as_ref(),
|
||||
queue,
|
||||
&meta.settings,
|
||||
&agents,
|
||||
&meta.hives,
|
||||
),
|
||||
meta_flake::update(client.as_ref(), queue, &meta.settings, &agents, &meta.hives),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated) => {
|
||||
tracing::info!(?updated, agents = agents.len(), "update_meta: done");
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
tracing::info!(?report, agents = agents.len(), "update_meta: done");
|
||||
Ok((report, 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.
|
||||
fn finish_update_meta(
|
||||
result: Result<
|
||||
(
|
||||
meta_flake::Report,
|
||||
std::collections::BTreeSet<(String, String)>,
|
||||
),
|
||||
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);
|
||||
Outcome::Done
|
||||
}
|
||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||
Err(e) => Outcome::Failed(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every agent some hive in `hives` places, with its archetype.
|
||||
async fn placed_agents(
|
||||
queue: &async_nats::Client,
|
||||
/// 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(
|
||||
grown: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
|
||||
report: &meta_flake::Report,
|
||||
placements: &std::collections::BTreeSet<(String, String)>,
|
||||
) {
|
||||
if !report.hyperhive_moved {
|
||||
return;
|
||||
}
|
||||
for (hive, agent) in placements {
|
||||
let _ = grown.node(SwarmNodeKind::TriggerDeploy {
|
||||
hive: hive.clone(),
|
||||
agent: agent.clone(),
|
||||
rev: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `(hive, agent)` a hive in `hives` places.
|
||||
async fn placements(
|
||||
wanted: &wanted::WantedWriter,
|
||||
hives: &[String],
|
||||
) -> Result<Vec<meta_flake::Placed>> {
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
) -> Result<std::collections::BTreeSet<(String, String)>> {
|
||||
let mut placed = std::collections::BTreeSet::new();
|
||||
for hive in hives {
|
||||
let Some(declared) = wanted
|
||||
.view(hive)
|
||||
|
|
@ -631,14 +671,24 @@ async fn placed_agents(
|
|||
else {
|
||||
continue;
|
||||
};
|
||||
names.extend(
|
||||
placed.extend(
|
||||
declared
|
||||
.agents
|
||||
.into_iter()
|
||||
.filter(|(_, w)| crate::routes::agents::places_agent(w.state))
|
||||
.map(|(name, _)| name),
|
||||
.map(|(name, _)| (hive.clone(), name)),
|
||||
);
|
||||
}
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// Every agent in `placements`, with its archetype.
|
||||
async fn placed_agents(
|
||||
queue: &async_nats::Client,
|
||||
placements: &std::collections::BTreeSet<(String, String)>,
|
||||
) -> Result<Vec<meta_flake::Placed>> {
|
||||
let names: std::collections::BTreeSet<&String> =
|
||||
placements.iter().map(|(_, agent)| agent).collect();
|
||||
let archetypes = swarm_queue_client::agent_archetype::open_or_create(queue)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("open the agent-archetype bucket: {e}"))?;
|
||||
|
|
@ -646,10 +696,13 @@ async fn placed_agents(
|
|||
for name in names {
|
||||
let (archetype, _, _) = crate::schedules::read_or_default::<
|
||||
Option<swarm_wire::agent_archetype::ArchetypeRef>,
|
||||
>(&archetypes, &name)
|
||||
>(&archetypes, name)
|
||||
.await
|
||||
.map_err(|e| e.context(format!("read the archetype of {name}")))?;
|
||||
placed.push(meta_flake::Placed { name, archetype });
|
||||
placed.push(meta_flake::Placed {
|
||||
name: name.clone(),
|
||||
archetype,
|
||||
});
|
||||
}
|
||||
Ok(placed)
|
||||
}
|
||||
|
|
@ -983,22 +1036,7 @@ pub(crate) async fn queue_merged_config_deploy(
|
|||
.jobq
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert_job(None, |b| {
|
||||
// The hive builds the merged commit from `internal/meta`, so the
|
||||
// flake must pin it before the hive is told.
|
||||
let update_meta = b
|
||||
.node(SwarmNodeKind::UpdateMeta)
|
||||
.needs(SwarmResourceKind::Meta);
|
||||
let root = update_meta.guid();
|
||||
let _deploy = b
|
||||
.node(SwarmNodeKind::TriggerDeploy {
|
||||
hive: hive.clone(),
|
||||
agent: agent.clone(),
|
||||
rev: Some(rev.clone()),
|
||||
})
|
||||
.after_ok(update_meta);
|
||||
vec![root]
|
||||
});
|
||||
.insert_job(None, |b| merged_deploy_job(b, &hive, &agent, &rev));
|
||||
match inserted {
|
||||
Ok(_) => tracing::info!(%agent, %rev, %hive, "config-pr merge: deploy queued"),
|
||||
Err(e) => {
|
||||
|
|
@ -1007,6 +1045,33 @@ pub(crate) async fn queue_merged_config_deploy(
|
|||
}
|
||||
}
|
||||
|
||||
/// The sub-DAG a merged config PR is: the meta update, then the deploy of
|
||||
/// `rev` on `hive`.
|
||||
///
|
||||
/// `after_any`: a hive builds from `internal/meta` only while it pins the
|
||||
/// agent's config commit, and renders locally otherwise, as a hive that
|
||||
/// predates the repo always does. A failed update (no builder, an unreadable
|
||||
/// config repo) must not hold those hives back.
|
||||
fn merged_deploy_job(
|
||||
b: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
|
||||
hive: &str,
|
||||
agent: &str,
|
||||
rev: &str,
|
||||
) -> Vec<hive_jobq::builder::NodeGuid> {
|
||||
let update_meta = b
|
||||
.node(SwarmNodeKind::UpdateMeta)
|
||||
.needs(SwarmResourceKind::Meta);
|
||||
let root = update_meta.guid();
|
||||
let _deploy = b
|
||||
.node(SwarmNodeKind::TriggerDeploy {
|
||||
hive: hive.to_owned(),
|
||||
agent: agent.to_owned(),
|
||||
rev: Some(rev.to_owned()),
|
||||
})
|
||||
.after_any(update_meta);
|
||||
vec![root]
|
||||
}
|
||||
|
||||
/// The sub-DAG one agent creation is: the nodes, and the edges between them.
|
||||
///
|
||||
/// A function rather than a closure inside [`create_agent`](crate::routes::agents::create_agent) so the endpoint's
|
||||
|
|
@ -1095,8 +1160,10 @@ pub(crate) fn declare_agent_job(
|
|||
hive: hive.to_owned(),
|
||||
agent: agent.to_owned(),
|
||||
});
|
||||
// The hive builds the agent from `internal/meta`, which can only carry it
|
||||
// once the agent is declared on a hive and its config repo has a commit.
|
||||
// The hive builds the agent from `internal/meta` once it pins the agent's
|
||||
// config commit, and renders locally until then. `after_any` on the
|
||||
// update, so one that failed (no builder, an unreadable config repo)
|
||||
// still lets the hive build the agent that way.
|
||||
let update_meta = b
|
||||
.node(SwarmNodeKind::UpdateMeta)
|
||||
.needs(SwarmResourceKind::Meta)
|
||||
|
|
@ -1131,7 +1198,7 @@ pub(crate) fn declare_agent_job(
|
|||
agent: agent.to_owned(),
|
||||
rev: None,
|
||||
})
|
||||
.after_ok(update_meta)
|
||||
.after_any(update_meta)
|
||||
.after_any(mint_identity)
|
||||
.after_any(mint_forge_token)
|
||||
// `after_any` for the same reason: the container reads this token
|
||||
|
|
@ -1907,6 +1974,187 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Whether the edge from the `from` node to the `on` node accepts a
|
||||
/// failed `on`, in a graph built by `declare`.
|
||||
fn edge_accepts_failure(
|
||||
declare: impl FnOnce(&hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>),
|
||||
from: &str,
|
||||
on: &str,
|
||||
) -> bool {
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
||||
let mut sched = hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
);
|
||||
sched
|
||||
.insert_job(None, |b| {
|
||||
declare(b);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("the graph inserts");
|
||||
let graph = sched.graph();
|
||||
let id_of = |label: &str| {
|
||||
graph
|
||||
.nodes()
|
||||
.find(|n| n.payload.label() == label)
|
||||
.unwrap_or_else(|| panic!("the graph holds a {label} node"))
|
||||
.id
|
||||
};
|
||||
let (from, on) = (id_of(from), id_of(on));
|
||||
graph
|
||||
.node(from)
|
||||
.expect("just found")
|
||||
.deps
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
hive_jobq::Dep::Node { id, when } if *id == on => Some(*when),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the edge exists")
|
||||
.accepts(hive_jobq::TerminalState::Failed)
|
||||
}
|
||||
|
||||
/// A deploy that waits for the meta update must still run when the update
|
||||
/// fails: a hive that renders locally never needed it.
|
||||
#[test]
|
||||
fn a_failed_meta_update_does_not_hold_back_the_deploys() {
|
||||
let create = |b: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>| {
|
||||
let _ = super::declare_agent_job(b, "atlas", "pr1ma", false);
|
||||
};
|
||||
let merge = |b: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>| {
|
||||
let _ = super::merged_deploy_job(b, "pr1ma", "atlas", "abc");
|
||||
};
|
||||
assert!(edge_accepts_failure(
|
||||
create,
|
||||
"trigger_deploy",
|
||||
"update_meta"
|
||||
));
|
||||
assert!(edge_accepts_failure(merge, "trigger_deploy", "update_meta"));
|
||||
// The same lookup finds the edges that do hold a deploy back.
|
||||
assert!(!edge_accepts_failure(
|
||||
create,
|
||||
"trigger_deploy",
|
||||
"set_agent_wanted"
|
||||
));
|
||||
assert!(!edge_accepts_failure(
|
||||
create,
|
||||
"update_meta",
|
||||
"set_agent_wanted"
|
||||
));
|
||||
}
|
||||
|
||||
/// The scheduler's side of the same edge: with the update failed, the
|
||||
/// deploy is the next node to run.
|
||||
#[tokio::test]
|
||||
async fn the_merged_deploy_runs_after_a_failed_meta_update() {
|
||||
use hive_jobq::scheduler::{Outcome, Scheduler};
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
||||
let mut sched = Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
);
|
||||
sched
|
||||
.insert_job(None, |b| {
|
||||
super::merged_deploy_job(b, "pr1ma", "atlas", "abc")
|
||||
})
|
||||
.expect("the graph inserts");
|
||||
let sched = std::sync::Arc::new(std::sync::Mutex::new(sched));
|
||||
let mut ran = Vec::new();
|
||||
while let Some(runner) = Scheduler::claim_next(&sched, |_id, kind, builder| async move {
|
||||
let outcome = match kind {
|
||||
SwarmNodeKind::UpdateMeta => Outcome::Failed("no builder".to_owned()),
|
||||
_ => Outcome::Done,
|
||||
};
|
||||
(builder, outcome)
|
||||
}) {
|
||||
runner.await.1.expect("no growth declared");
|
||||
ran.push(
|
||||
sched
|
||||
.lock()
|
||||
.unwrap()
|
||||
.graph()
|
||||
.nodes()
|
||||
.filter(|n| n.state.is_terminal())
|
||||
.map(|n| (n.payload.label(), n.state))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
let last = ran.last().expect("both nodes ran");
|
||||
assert!(
|
||||
last.contains(&("update_meta".to_owned(), hive_jobq::State::Failed)),
|
||||
"{last:?}"
|
||||
);
|
||||
assert!(
|
||||
last.contains(&("trigger_deploy".to_owned(), hive_jobq::State::Done)),
|
||||
"{last:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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>)> {
|
||||
use hive_jobq::scheduler::{Outcome, Scheduler};
|
||||
|
||||
let mut sched = Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
);
|
||||
let update = 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);
|
||||
(builder, Outcome::Done)
|
||||
})
|
||||
.expect("the node just inserted is runnable");
|
||||
runner.await.1.expect("the grown nodes insert");
|
||||
let guard = sched.lock().unwrap();
|
||||
guard
|
||||
.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();
|
||||
assert_eq!(
|
||||
moved,
|
||||
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:?}");
|
||||
}
|
||||
|
||||
/// Node labels of the graph [`super::declare_agent_job`] builds, and the
|
||||
/// labels the deploy node waits on.
|
||||
fn creation_graph(archetype: bool) -> (Vec<String>, Vec<String>) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue