Watch
0
0
Fork
You've already forked hyperhive
0

retry every failed meta build each round, a timeout included, and drop the attempt count

This commit is contained in:
atlas 2026-10-26 18:39:09 +01:00
commit 59456c3c58

View file

@ -115,10 +115,8 @@ pub(crate) enum SwarmNodeKind {
/// runs only when the build is green. See `meta_build`.
///
/// Carries no rev: it reads the head when it runs, which in most DAGs is
/// after the `UpdateMeta` that commits it. `attempt` is how many builds of
/// this deploy already ended in an error from `nix`; the build after the
/// second such error is red.
BuildMeta { agent: String, attempt: u32 },
/// after the `UpdateMeta` that commits it.
BuildMeta { agent: String },
/// Re-mint `agent`'s queue secret if it is still stored and old enough.
/// See `agent_renewal`.
///
@ -204,6 +202,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::MintAgentIdentity { agent }
| SwarmNodeKind::MintAgentForgeToken { agent }
| SwarmNodeKind::MintAgentMatrixAccount { agent }
| SwarmNodeKind::BuildMeta { agent }
| SwarmNodeKind::RenewAgentQueueCredential { agent } => {
serde_json::json!({ "agent": agent })
}
@ -216,9 +215,6 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::BuildAgent { agent, rev, pr } => {
serde_json::json!({ "agent": agent, "rev": rev, "pr": pr })
}
SwarmNodeKind::BuildMeta { agent, attempt } => {
serde_json::json!({ "agent": agent, "attempt": attempt })
}
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => {
serde_json::json!({ "agent": agent, "hive": hive, "rev": rev })
}
@ -418,9 +414,7 @@ async fn run_node_work(
SwarmNodeKind::BuildAgent { agent, rev, pr } => {
build_agent(&deps, forge, agent, rev, pr).await
}
SwarmNodeKind::BuildMeta { agent, attempt } => {
build_meta(&deps, forge, agent, attempt).await
}
SwarmNodeKind::BuildMeta { agent } => build_meta(&deps, forge, agent).await,
SwarmNodeKind::RenewAgentQueueCredential { agent } => renew_queue_credential(&agent).await,
SwarmNodeKind::SetAgentWanted { hive, agent } => {
set_agent_wanted(deps.wanted.as_deref(), &hive, &agent).await
@ -608,9 +602,8 @@ async fn build_meta(
deps: &WorkerDeps,
forge: Option<Arc<forge::Client>>,
agent: String,
attempt: u32,
) -> hive_jobq::scheduler::Outcome {
meta_build::run(forge.as_deref(), deps.queue.as_ref(), agent, attempt).await
meta_build::run(forge.as_deref(), deps.queue.as_ref(), agent).await
}
/// What an `UpdateMeta` pass decided: the deploys to send, and the placements
@ -748,21 +741,16 @@ async fn record_fanout(
/// parent. The build waits on it, since the build reads the head that update
/// commits. The deploy keeps its own edge to it too: `skipped_deploys` finds
/// the deploys a failed update skipped by that edge.
///
/// `attempt` is the build's `BuildMeta::attempt`: 0 for a deploy no build has
/// failed for yet.
fn gated_deploy<'a>(
b: &'a hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
hive: &str,
agent: &str,
rev: Option<String>,
update: Option<hive_jobq::builder::NodeGuid>,
attempt: u32,
) -> hive_jobq::builder::NodeRef<'a, SwarmNodeKind, SwarmResourceKind> {
let mut build = b
.node(SwarmNodeKind::BuildMeta {
agent: agent.to_owned(),
attempt,
})
.needs(SwarmResourceKind::Builder);
if let Some(update) = update {
@ -784,9 +772,9 @@ fn gated_deploy<'a>(
/// Declare one build and 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. A deploy the build gate
/// skipped counts as sent: the pin is recorded, so one red agent does not
/// resend every healthy one on each retry. A deploy skipped because the build
/// gave no verdict is re-declared by `queue_meta_update`.
/// skipped counts as sent: the pin is recorded, so one failing agent does
/// not resend every healthy one on each retry. A deploy skipped by a failed
/// build is re-declared by `queue_meta_update` on every round.
fn declare_fan_out(
grown: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
fan_out: &FanOut,
@ -797,7 +785,7 @@ fn declare_fan_out(
}
let deploys: Vec<hive_jobq::builder::NodeGuid> = placements
.iter()
.map(|(hive, agent)| gated_deploy(grown, hive, agent, None, None, 0).guid())
.map(|(hive, agent)| gated_deploy(grown, hive, agent, None, None).guid())
.collect();
if let FanOut::Record { pin } = fan_out {
let mut record = grown.node(SwarmNodeKind::RecordFanout { pin: pin.clone() });
@ -1235,7 +1223,7 @@ fn merged_deploy_job(
.node(SwarmNodeKind::UpdateMeta)
.needs(SwarmResourceKind::Meta);
let root = update_meta.guid();
let _deploy = gated_deploy(b, hive, agent, Some(rev.to_owned()), Some(root), 0);
let _deploy = gated_deploy(b, hive, agent, Some(rev.to_owned()), Some(root));
vec![root]
}
@ -1359,7 +1347,7 @@ pub(crate) fn declare_agent_job(
// queue at all fails this node — but `TriggerDeploy` has nothing to
// publish to on such a host either, so the strict edge cancels a node
// that could not have succeeded anyway.
let trigger_deploy = gated_deploy(b, hive, agent, None, Some(update_meta.guid()), 0)
let trigger_deploy = gated_deploy(b, hive, agent, None, Some(update_meta.guid()))
.after_any(mint_identity)
.after_any(mint_forge_token)
// `after_any` for the same reason: the container reads this token
@ -1564,9 +1552,8 @@ pub(crate) fn spawn_meta_update_timer(
enum SkipCause {
/// It waited on an `UpdateMeta` that `Failed`.
FailedUpdate,
/// It waited on a `BuildMeta` that gave no verdict or whose first `nix`
/// error asks for another build ([`meta_build::is_retried`]).
UnfinishedBuild,
/// It waited on a `BuildMeta` that `Failed`, whatever the reason.
FailedBuild,
}
/// A deploy [`skipped_deploys`] found.
@ -1575,18 +1562,15 @@ struct SkippedDeploy {
agent: String,
rev: Option<String>,
cause: SkipCause,
/// The `BuildMeta::attempt` of the build that replaces the failed one.
attempt: u32,
}
/// The deploys the timer declares again: for each agent, its latest
/// `TriggerDeploy` when the scheduler skipped it because an `UpdateMeta` it
/// waited on `Failed`, or because the `BuildMeta` it waited on gave no verdict
/// or met its first `nix` error. A deploy skipped for another reason is left
/// alone. A red build, the second `nix` error, is a verdict on the config, which
/// a config merge answers with a build of its own. The pause declaration or the
/// config seeding failing means the update that would unblock the deploy was
/// skipped too, and deploying past those is the race those edges close.
/// `TriggerDeploy` when the scheduler skipped it because an `UpdateMeta` or a
/// `BuildMeta` it waited on `Failed`. A failed build is built again on every
/// round until one succeeds. A deploy skipped for another reason is left alone:
/// the pause declaration or the config seeding failing means the update that
/// would unblock the deploy was skipped too, and deploying past those is the
/// race those edges close.
fn skipped_deploys(
graph: &hive_jobq::Graph<SwarmNodeKind, SwarmResourceKind>,
) -> Vec<SkippedDeploy> {
@ -1608,28 +1592,18 @@ fn skipped_deploys(
let SwarmNodeKind::TriggerDeploy { hive, agent, rev } = &n.payload else {
return None;
};
let deps = || {
n.deps.iter().filter_map(|d| match d {
hive_jobq::Dep::Node { id, .. } => graph.node(*id),
hive_jobq::Dep::Resource { .. } => None,
let failed = |is: fn(&SwarmNodeKind) -> bool| {
n.deps.iter().any(|d| match d {
hive_jobq::Dep::Node { id, .. } => graph.node(*id).is_some_and(|dep| {
is(&dep.payload) && dep.state == hive_jobq::State::Failed
}),
hive_jobq::Dep::Resource { .. } => false,
})
};
let failed_update = deps().any(|dep| {
matches!(dep.payload, SwarmNodeKind::UpdateMeta)
&& dep.state == hive_jobq::State::Failed
});
let unfinished_build = deps().find_map(|dep| match (&dep.payload, &dep.error) {
(SwarmNodeKind::BuildMeta { attempt, .. }, Some(error))
if dep.state == hive_jobq::State::Failed && meta_build::is_retried(error) =>
{
Some(meta_build::next_attempt(*attempt, error))
}
_ => None,
});
let (cause, attempt) = if failed_update {
(SkipCause::FailedUpdate, 0)
} else if let Some(attempt) = unfinished_build {
(SkipCause::UnfinishedBuild, attempt)
let cause = if failed(|p| matches!(p, SwarmNodeKind::UpdateMeta)) {
SkipCause::FailedUpdate
} else if failed(|p| matches!(p, SwarmNodeKind::BuildMeta { .. })) {
SkipCause::FailedBuild
} else {
return None;
};
@ -1638,7 +1612,6 @@ fn skipped_deploys(
agent: agent.clone(),
rev: rev.clone(),
cause,
attempt,
})
})
.collect()
@ -1650,11 +1623,10 @@ fn skipped_deploys(
/// run skipped ([`skipped_deploys`]), so an agent created or merged while the
/// update was failing is deployed by the run that succeeds.
///
/// When the latest `UpdateMeta` is `Done`, a deploy skipped because the builder
/// gave no verdict still gets a fresh build and deploy, with no update, so a
/// builder that was down or not yet updated is caught up once it answers. That
/// adds nodes on every tick while the builder stays away. Returns whether a job
/// was inserted.
/// When the latest `UpdateMeta` is `Done`, a deploy skipped by a failed build
/// still gets a fresh build and deploy, with no update, so a build that failed
/// is tried again on every round until it succeeds. That adds two nodes per
/// failing agent on every tick. Returns whether a job was inserted.
fn queue_meta_update(
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
) -> Result<bool> {
@ -1680,7 +1652,7 @@ fn queue_meta_update(
.is_none_or(|n| n.state != hive_jobq::State::Done);
let skipped: Vec<SkippedDeploy> = skipped_deploys(sched.graph())
.into_iter()
.filter(|s| update_due || s.cause == SkipCause::UnfinishedBuild)
.filter(|s| update_due || s.cause == SkipCause::FailedBuild)
.collect();
if !update_due && skipped.is_empty() {
return Ok(false);
@ -1694,7 +1666,7 @@ fn queue_meta_update(
});
let deploys: Vec<_> = skipped
.into_iter()
.map(|s| gated_deploy(b, &s.hive, &s.agent, s.rev, update, s.attempt).guid())
.map(|s| gated_deploy(b, &s.hive, &s.agent, s.rev, update).guid())
.collect();
update.map_or(deploys, |update| vec![update])
})
@ -2869,81 +2841,66 @@ mod tests {
assert_eq!(fan_out, FanOut::Record { pin: "p1".into() });
}
/// A red build for one agent skips that agent's deploy only, and the pin
/// is still recorded: the red agent is not sent again with every healthy
/// one on the next retry. The timer declares nothing for it either, since a
/// red build is a verdict on its config.
/// A failed build for one agent skips that agent's deploy only, and the pin
/// is still recorded, so the healthy agents are not sent again. The timer
/// declares a build and deploy for the failed agent alone, whatever the
/// build failed with: `nix`'s error, a timeout, or no verdict.
#[tokio::test]
async fn a_red_build_skips_its_deploy_and_the_pin_is_still_recorded() {
use hive_jobq::State;
let pins = Arc::new(FakePins::holding("p0"));
let (_, sched) = pass_with(
&pins,
&report("p1", true),
&PLACED,
None,
false,
Some(("argus", "error: builder for argus failed".to_owned())),
)
.await;
let mut builds = states_of(&sched, "build_meta");
builds.sort_by_key(|s| format!("{s:?}"));
assert_eq!(builds, [State::Done, State::Done, State::Failed]);
let mut deploys = states_of(&sched, "trigger_deploy");
deploys.sort_by_key(|s| format!("{s:?}"));
assert_eq!(deploys, [State::Done, State::Done, State::Skipped]);
assert_eq!(states_of(&sched, "record_fanout"), [State::Done]);
assert_eq!(pins.held().as_deref(), Some("p1"));
// The failed build rolls the update up `Failed`, so the timer queues an
// update, and only an update: no deploy is declared again.
assert!(queue_meta_update(&sched).expect("tick"));
assert_eq!(states_of(&sched, "trigger_deploy").len(), 3);
assert_eq!(states_of(&sched, "build_meta").len(), 3);
assert_eq!(states_of(&sched, "update_meta").len(), 2);
}
/// A build that gave no verdict skips its deploy and still records the pin,
/// and the timer declares a build and deploy for that agent alone.
#[tokio::test]
async fn an_unfinished_build_is_declared_again_for_that_agent_alone() {
async fn a_failed_build_skips_its_deploy_and_the_pin_is_still_recorded() {
use hive_jobq::State;
use hive_jobq_wire::WireNode as _;
let pins = Arc::new(FakePins::holding("p0"));
let error = format!("{}no swarm builder is listening", meta_build::RETRY_PREFIX);
let (_, sched) = pass_with(
&pins,
&report("p1", true),
&PLACED,
None,
false,
Some(("argus", error)),
)
.await;
assert_eq!(states_of(&sched, "record_fanout"), [State::Done]);
assert_eq!(pins.held().as_deref(), Some("p1"));
for error in [
"error: builder for argus failed".to_owned(),
"timed out: nix did not finish".to_owned(),
format!(
"{}no swarm builder is listening",
meta_build::NO_VERDICT_PREFIX
),
] {
let pins = Arc::new(FakePins::holding("p0"));
let (_, sched) = pass_with(
&pins,
&report("p1", true),
&PLACED,
None,
false,
Some(("argus", error.clone())),
)
.await;
let mut builds = states_of(&sched, "build_meta");
builds.sort_by_key(|s| format!("{s:?}"));
assert_eq!(builds, [State::Done, State::Done, State::Failed], "{error}");
let mut deploys = states_of(&sched, "trigger_deploy");
deploys.sort_by_key(|s| format!("{s:?}"));
assert_eq!(
deploys,
[State::Done, State::Done, State::Skipped],
"{error}"
);
assert_eq!(states_of(&sched, "record_fanout"), [State::Done], "{error}");
assert_eq!(pins.held().as_deref(), Some("p1"), "{error}");
assert!(queue_meta_update(&sched).expect("tick"));
let guard = sched.lock().unwrap();
let again = |label: &str| {
let mut found: Vec<_> = guard
.graph()
.nodes()
.filter(|n| n.payload.label() == label)
.filter(|n| n.state == State::Pending)
.map(|n| match &n.payload {
SwarmNodeKind::BuildMeta { agent, .. }
| SwarmNodeKind::TriggerDeploy { agent, .. } => agent.clone(),
other => panic!("not a build or a deploy: {other:?}"),
})
.collect();
found.sort();
found
};
assert_eq!(again("build_meta"), ["argus"]);
assert_eq!(again("trigger_deploy"), ["argus"]);
assert!(queue_meta_update(&sched).expect("tick"), "{error}");
let guard = sched.lock().unwrap();
let again = |label: &str| {
let mut found: Vec<_> = guard
.graph()
.nodes()
.filter(|n| n.payload.label() == label)
.filter(|n| n.state == State::Pending)
.map(|n| match &n.payload {
SwarmNodeKind::BuildMeta { agent }
| SwarmNodeKind::TriggerDeploy { agent, .. } => agent.clone(),
other => panic!("not a build or a deploy: {other:?}"),
})
.collect();
found.sort();
found
};
assert_eq!(again("build_meta"), ["argus"], "{error}");
assert_eq!(again("trigger_deploy"), ["argus"], "{error}");
}
}
/// With nothing placed there is nothing to wait on, and the pin is
@ -3854,89 +3811,6 @@ mod tests {
nodes.into_iter().map(|(_, rev)| rev).collect()
}
/// A merged config's build that gave no verdict, with the update `Done`:
/// the deploy is skipped, and the timer declares a build and the same
/// deploy again, without an update, until the builder answers.
#[tokio::test]
async fn an_unfinished_build_is_retried_until_the_builder_answers() {
use hive_jobq::State;
let sched = fresh_sched();
sched
.lock()
.unwrap()
.insert_job(None, |b| merged_deploy_job(b, "pr1ma", "atlas", "abc"))
.expect("the graph inserts");
let unavailable = format!("{}no swarm builder is listening", meta_build::RETRY_PREFIX);
drain_failing_build(&sched, "atlas", unavailable.clone()).await;
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
assert_eq!(update_metas(&sched), 1);
assert!(queue_meta_update(&sched).expect("tick"));
assert_eq!(update_metas(&sched), 1, "the retry needs no update");
drain_failing_build(&sched, "atlas", unavailable).await;
assert_eq!(
deploys_of(&sched, "atlas"),
[State::Skipped, State::Skipped]
);
assert!(queue_meta_update(&sched).expect("tick"));
drain(&sched, false, false).await;
assert_eq!(
deploys_of(&sched, "atlas"),
[State::Skipped, State::Skipped, State::Done]
);
// The merged deploy's rev survives every retry.
assert_eq!(
deploy_revs(&sched, "atlas"),
[
Some("abc".to_owned()),
Some("abc".to_owned()),
Some("abc".to_owned())
]
);
assert!(
!queue_meta_update(&sched).expect("tick"),
"the answer ends the retries"
);
}
/// A red build is a verdict: the timer declares nothing for it. The test
/// above is the control that the same shape with an unfinished build is
/// declared again.
#[tokio::test]
async fn a_red_build_is_not_declared_again() {
use hive_jobq::State;
let sched = fresh_sched();
sched
.lock()
.unwrap()
.insert_job(None, |b| merged_deploy_job(b, "pr1ma", "atlas", "abc"))
.expect("the graph inserts");
drain_failing_build(&sched, "atlas", "error: builder for x failed".to_owned()).await;
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
assert!(!queue_meta_update(&sched).expect("tick"));
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
}
/// The `attempt` of `agent`'s builds, oldest first.
fn build_attempts(sched: &SharedSched, agent: &str) -> Vec<u32> {
let guard = sched.lock().unwrap();
let mut nodes: Vec<_> = guard
.graph()
.nodes()
.filter_map(|n| match &n.payload {
SwarmNodeKind::BuildMeta { agent: a, attempt } if a == agent => {
Some((n.id, *attempt))
}
_ => None,
})
.collect();
nodes.sort_by_key(|(id, _)| *id);
nodes.into_iter().map(|(_, attempt)| attempt).collect()
}
fn merged_atlas() -> SharedSched {
let sched = fresh_sched();
sched
@ -3947,55 +3821,54 @@ mod tests {
sched
}
/// `nix` failing once is built again on the next tick, as the second
/// attempt; failing again is red, and the tick declares nothing more.
/// A merged config's build that fails, with the update `Done`, whatever it
/// fails with: the deploy is skipped, and the timer declares a build and
/// the same deploy again on every round, without an update, until a build
/// succeeds. Then it stops.
#[tokio::test]
async fn a_first_nix_error_is_built_once_more_and_the_second_is_red() {
async fn a_failed_build_is_built_again_every_round_until_it_succeeds() {
use hive_jobq::State;
let sched = merged_atlas();
let first = format!("{}error: fetch failed", meta_build::FIRST_FAILURE_PREFIX);
drain_failing_build(&sched, "atlas", first).await;
let errors = [
"error: attribute missing".to_owned(),
"timed out: nix did not finish".to_owned(),
format!(
"{}no swarm builder is listening",
meta_build::NO_VERDICT_PREFIX
),
];
drain_failing_build(&sched, "atlas", errors[0].clone()).await;
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
assert_eq!(build_attempts(&sched, "atlas"), [0]);
assert_eq!(update_metas(&sched), 1);
assert!(queue_meta_update(&sched).expect("tick"));
assert_eq!(build_attempts(&sched, "atlas"), [0, 1]);
drain_failing_build(&sched, "atlas", "error: attribute missing".to_owned()).await;
for error in &errors[1..] {
assert!(queue_meta_update(&sched).expect("tick"));
assert_eq!(update_metas(&sched), 1, "the retry needs no update");
drain_failing_build(&sched, "atlas", error.clone()).await;
}
assert_eq!(
deploys_of(&sched, "atlas"),
[State::Skipped, State::Skipped]
[State::Skipped, State::Skipped, State::Skipped]
);
assert!(!queue_meta_update(&sched).expect("tick"));
assert_eq!(build_attempts(&sched, "atlas"), [0, 1]);
}
/// A build that passes after a first `nix` error sends the deploy, once.
#[tokio::test]
async fn a_build_that_passes_after_a_first_nix_error_deploys() {
use hive_jobq::State;
let sched = merged_atlas();
let first = format!("{}error: fetch failed", meta_build::FIRST_FAILURE_PREFIX);
drain_failing_build(&sched, "atlas", first).await;
// A success on a later round deploys, once.
assert!(queue_meta_update(&sched).expect("tick"));
drain(&sched, false, false).await;
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped, State::Done]);
assert!(!queue_meta_update(&sched).expect("tick"));
}
/// A build with no verdict keeps its attempt, however many ticks it takes,
/// so a builder that is away does not use up the second `nix` attempt.
#[tokio::test]
async fn a_build_with_no_verdict_keeps_its_attempt() {
let sched = merged_atlas();
let away = format!("{}no builder", meta_build::RETRY_PREFIX);
drain_failing_build(&sched, "atlas", away.clone()).await;
assert!(queue_meta_update(&sched).expect("tick"));
drain_failing_build(&sched, "atlas", away).await;
assert!(queue_meta_update(&sched).expect("tick"));
assert_eq!(build_attempts(&sched, "atlas"), [0, 0, 0]);
assert_eq!(
deploys_of(&sched, "atlas"),
[State::Skipped, State::Skipped, State::Skipped, State::Done]
);
// The merged deploy's rev survives every retry.
assert!(
deploy_revs(&sched, "atlas")
.iter()
.all(|rev| rev.as_deref() == Some("abc"))
);
assert!(
!queue_meta_update(&sched).expect("tick"),
"the success ends the retries"
);
}
}