Watch
0
0
Fork
You've already forked hyperhive
0

redeclare the deploys a failed meta update skipped on the timer's retry

queue_meta_update adds a TriggerDeploy behind each retry UpdateMeta (after_ok) for every agent whose latest deploy waited on a failed update. A created or merged agent whose deploy was skipped is deployed by the run that succeeds; a deploy skipped for another reason, or an agent already deployed, gets nothing.
This commit is contained in:
atlas 2026-10-25 14:52:55 +01:00
commit 633139069f
2 changed files with 196 additions and 7 deletions

View file

@ -149,7 +149,9 @@ or after a success), before it deploys a merged config PR, and
before the first deploy of a new agent. Those deploys run only after the before the first deploy of a new agent. Those deploys run only after the
update succeeded: a hive builds only from `internal/meta`, so after a failed update succeeded: a hive builds only from `internal/meta`, so after a failed
update the scheduler skips the deploy node, and the graph shows the update as update the scheduler skips the deploy node, and the graph shows the update as
failed and the deploy as skipped (see failed and the deploy as skipped. Each ten-minute retry of a failed update
declares a fresh deploy behind it for every agent whose latest deploy the failed update
skipped, so the run that succeeds sends them (see
[Config changes](../agent-lifecycle/config-changes.md#the-swarms-meta-flake)). A render [Config changes](../agent-lifecycle/config-changes.md#the-swarms-meta-flake)). A render
equal to the committed `flake.nix` sends no lock. equal to the committed `flake.nix` sends no lock.

View file

@ -1488,8 +1488,53 @@ pub(crate) fn spawn_meta_update_timer(
}); });
} }
/// The deploys a failed `UpdateMeta` skipped, as `(hive, agent, rev)`: for each
/// agent, its latest `TriggerDeploy` when it waited on an `UpdateMeta` that
/// `Failed` (so the scheduler skipped it). A deploy skipped for another reason
/// (the pause declaration or the config seeding failed) is left alone: the
/// update that would unblock it was skipped too, and deploying past those is
/// the race those edges close.
fn skipped_deploys(
graph: &hive_jobq::Graph<SwarmNodeKind, SwarmResourceKind>,
) -> Vec<(String, String, Option<String>)> {
let mut latest = std::collections::BTreeMap::new();
for n in graph.nodes() {
if let SwarmNodeKind::TriggerDeploy { hive, agent, .. } = &n.payload {
let key = (hive.clone(), agent.clone());
if latest
.get(&key)
.is_none_or(|l: &&hive_jobq::Node<_, _>| l.id < n.id)
{
latest.insert(key, n);
}
}
}
latest
.into_values()
.filter(|n| {
n.deps.iter().any(|d| match d {
hive_jobq::Dep::Node { id, .. } => graph.node(*id).is_some_and(|dep| {
matches!(dep.payload, SwarmNodeKind::UpdateMeta)
&& dep.state == hive_jobq::State::Failed
}),
hive_jobq::Dep::Resource { .. } => false,
})
})
.filter_map(|n| match &n.payload {
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => {
Some((hive.clone(), agent.clone(), rev.clone()))
}
_ => None,
})
.collect()
}
/// Insert one `UpdateMeta` job when none is in flight and the latest one, by /// Insert one `UpdateMeta` job when none is in flight and the latest one, by
/// node id, is not `Done` (or none exists). Returns whether a job was inserted. /// node id, is not `Done` (or none exists), with a fresh `TriggerDeploy`
/// behind it (`after_ok`) for every deploy the failed run skipped
/// ([`skipped_deploys`]), so an agent created or merged while the update was
/// failing is deployed by the run that succeeds. Returns whether a job was
/// inserted.
fn queue_meta_update( fn queue_meta_update(
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>, sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
) -> Result<bool> { ) -> Result<bool> {
@ -1516,13 +1561,18 @@ fn queue_meta_update(
{ {
return Ok(false); return Ok(false);
} }
let skipped = skipped_deploys(sched.graph());
sched sched
.insert_job(None, |b| { .insert_job(None, |b| {
vec![ let update = b
b.node(SwarmNodeKind::UpdateMeta) .node(SwarmNodeKind::UpdateMeta)
.needs(SwarmResourceKind::Meta) .needs(SwarmResourceKind::Meta);
.guid(), for (hive, agent, rev) in skipped {
] let _ = b
.node(SwarmNodeKind::TriggerDeploy { hive, agent, rev })
.after_ok(update);
}
vec![update.guid()]
}) })
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(true) Ok(true)
@ -3241,6 +3291,143 @@ mod tests {
assert!(!super::queue_meta_update(&sched).expect("tick")); assert!(!super::queue_meta_update(&sched).expect("tick"));
assert_eq!(update_metas(&sched), 2); assert_eq!(update_metas(&sched), 2);
} }
type SharedSched = std::sync::Arc<
std::sync::Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
>;
/// Declare `agent`'s creation, as the endpoint does, and run every node to
/// the end: the meta update fails when `update_meta_fails`, the pause
/// declaration when `set_wanted_fails`, everything else succeeds.
async fn create_and_run(
sched: &SharedSched,
agent: &str,
update_meta_fails: bool,
set_wanted_fails: bool,
) {
sched
.lock()
.unwrap()
.insert_job(None, |b| super::declare_agent_job(b, agent, "pr1ma", false))
.expect("the graph inserts");
drain(sched, update_meta_fails, set_wanted_fails).await;
}
/// Run every runnable node; outcomes as for [`create_and_run`].
async fn drain(sched: &SharedSched, update_meta_fails: bool, set_wanted_fails: bool) {
use hive_jobq::scheduler::{Outcome, Scheduler};
while let Some(runner) = Scheduler::claim_next(sched, |_id, kind, builder| async move {
let outcome = match kind {
SwarmNodeKind::UpdateMeta if update_meta_fails => {
Outcome::Failed("no builder".to_owned())
}
SwarmNodeKind::SetAgentWanted { .. } if set_wanted_fails => {
Outcome::Failed("no queue".to_owned())
}
_ => Outcome::Done,
};
(builder, outcome)
}) {
runner.await.1.expect("no growth declared");
}
}
/// The states of `agent`'s `TriggerDeploy` nodes, oldest first.
fn deploys_of(sched: &SharedSched, agent: &str) -> Vec<hive_jobq::State> {
let guard = sched.lock().unwrap();
let mut nodes: Vec<_> = guard
.graph()
.nodes()
.filter(|n| matches!(&n.payload, SwarmNodeKind::TriggerDeploy { agent: a, .. } if a == agent))
.collect();
nodes.sort_by_key(|n| n.id);
nodes.into_iter().map(|n| n.state).collect()
}
/// `bob` deployed under a working update; then `atlas` was created while
/// the update failed, so its deploy is `Skipped`.
async fn bob_deployed_and_atlas_skipped() -> SharedSched {
use hive_jobq::State;
let sched: SharedSched =
std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
create_and_run(&sched, "bob", false, false).await;
create_and_run(&sched, "atlas", true, false).await;
assert_eq!(deploys_of(&sched, "bob"), [State::Done]);
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
sched
}
/// An agent created while the update was failing is deployed by the run
/// that succeeds, exactly once.
#[tokio::test]
async fn the_next_successful_update_deploys_the_agent_a_failed_one_skipped() {
use hive_jobq::State;
let sched = bob_deployed_and_atlas_skipped().await;
assert!(super::queue_meta_update(&sched).expect("tick"));
// A retry whose update fails too skips the new deploy as well.
drain(&sched, true, false).await;
assert_eq!(
deploys_of(&sched, "atlas"),
[State::Skipped, State::Skipped]
);
assert!(super::queue_meta_update(&sched).expect("tick"));
drain(&sched, false, false).await;
assert_eq!(
deploys_of(&sched, "atlas"),
[State::Skipped, State::Skipped, State::Done]
);
// The success ends the retries: no tick adds a node, so no second deploy.
assert!(!super::queue_meta_update(&sched).expect("tick"));
assert_eq!(
deploys_of(&sched, "atlas"),
[State::Skipped, State::Skipped, State::Done]
);
}
/// An agent whose latest deploy ran gets no extra one from the retry, even
/// when an older deploy of its was skipped.
#[tokio::test]
async fn an_agent_that_was_deployed_gets_no_extra_deploy_from_the_retry() {
use hive_jobq::State;
let sched = bob_deployed_and_atlas_skipped().await;
assert!(super::queue_meta_update(&sched).expect("tick"));
drain(&sched, false, false).await;
assert_eq!(deploys_of(&sched, "bob"), [State::Done]);
// atlas now holds an old Skipped deploy and a Done one. Another
// creation fails its update; the retry deploys carol and only carol.
create_and_run(&sched, "carol", true, false).await;
assert!(super::queue_meta_update(&sched).expect("tick"));
drain(&sched, false, false).await;
assert_eq!(deploys_of(&sched, "carol"), [State::Skipped, State::Done]);
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped, State::Done]);
assert_eq!(deploys_of(&sched, "bob"), [State::Done]);
}
/// A deploy skipped because the pause declaration failed stays skipped: the
/// retry must not deploy past the declaration it waits for.
#[tokio::test]
async fn a_deploy_skipped_for_another_reason_is_not_redeclared() {
use hive_jobq::State;
let sched: SharedSched =
std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
create_and_run(&sched, "atlas", false, true).await;
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
assert!(super::queue_meta_update(&sched).expect("tick"));
drain(&sched, false, false).await;
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
}
} }
#[cfg(test)] #[cfg(test)]