Watch
0
0
Fork
You've already forked hyperhive
0

build a failed meta build once more, and treat a timeout or missing grant as no verdict

This commit is contained in:
atlas 2026-10-26 17:53:39 +01:00
commit 99d2ed5b9e
9 changed files with 621 additions and 111 deletions

View file

@ -194,7 +194,7 @@ runs only when the build is green.
The `build_meta` node reads `internal/meta`'s head commit when it runs, because The `build_meta` node reads `internal/meta`'s head commit when it runs, because
most paths declare it before the update that commits that head. It sends the most paths declare it before the update that commits that head. It sends the
agent and that commit on `.builder.build-meta` and waits up to 61 agent and that commit on `$SWARM.builder.build-meta` and waits up to 61
minutes. The builder takes the same one-build slot as PR checks, and minutes. The builder takes the same one-build slot as PR checks, and
refuses a request that waited 30 seconds for it. It runs `nix build` of refuses a request that waited 30 seconds for it. It runs `nix build` of
`nixosConfigurations.<agent>.config.system.build.toplevel` from `nixosConfigurations.<agent>.config.system.build.toplevel` from
@ -205,22 +205,35 @@ the builder's platform. The hive doesn't reuse the result yet: it still builds
locally, from whatever head it reads at deploy time, which can be newer than locally, from whatever head it reads at deploy time, which can be newer than
the one the swarm built. the one the swarm built.
The node ends one of three ways: The node ends one of three ways, and the error text decides what the ten-minute
timer does:
| Builder's answer | Node | Deploy | Timer | | Builder's answer | Node | Deploy | Timer |
| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ------- | --------------------------------- | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | ------- | ----------------------------------- |
| built | done, closure recorded | runs | | | built | done, closure recorded | runs | |
| `nix` failed or timed out (a red build) | failed, with `nix`'s last lines | skipped | declares nothing | | `nix` exited with an error, first time | failed, `build failed, will be built once more: …` and `nix`'s last lines | skipped | builds again, as the second attempt |
| no builder listening, no reply in time, or the builder couldn't start `nix` (busy for 30 seconds, no forge token) | failed, `not built, will be retried` | skipped | declares a build and deploy again | | `nix` exited with an error, second time (red) | failed, with `nix`'s last lines | skipped | declares nothing |
| no builder listening, no reply in 61 minutes, a reply that doesn't decode, or the builder never ran `nix` (busy for 30 seconds, no forge token, `nix` couldn't start, `nix` hit its one-hour deadline) | failed, `not built, will be retried: …` | skipped | builds again, same attempt |
| the controller couldn't read the `internal/meta` head, find a commit there, grant the builder read on the repo, or write the closure | failed, `not built, will be retried: …` | skipped | builds again, same attempt |
A red build is a verdict on the config: a config merge queues its own build and Only `nix` exiting with an error twice is a verdict on the config. The first
deploy. A build that gave no verdict isn't, so the ten-minute timer declares error may be a fetch hiccup inside `nix`, so the controller builds once more
it again, without a meta update when the latest update succeeded, until the without reading `nix`'s output. A red build holds the deploy until a config
builder answers. That covers a controller that updates before its builder merge queues its own build and deploy, and the pin fan-out still records the
pin, so one red agent doesn't resend the healthy ones. Anything else gave no
verdict, so the timer declares a build and deploy again every ten minutes,
without a meta update when the latest update succeeded, and the attempt count
stays where it was. That covers a controller that updates before its builder
host does: the old builder has no subscriber for the new subject, so the host does: the old builder has no subscriber for the new subject, so the
controller gets no responders at once, and the deploys go out after the builder controller gets no responders at once, and the deploys go out after the builder
updates. Each retry adds a build and deploy node per affected agent while the updates. Each retry adds a build and deploy node per affected agent while the
builder stays away. builder stays away. The build node carries the attempt count as `attempt`.
The controller exports the gauge `swarm.deploys.held`: the agents whose latest
build failed, with the label `reason` set to `red` or `retrying`. The Grafana
alert `hh-deploys-held` fires when the sum stays above zero for 30 minutes, so
a dead builder or a red config shows in Grafana before anyone notices an agent
not updating.
On a built reply the controller writes the agent's key in the `agent-closures` On a built reply the controller writes the agent's key in the `agent-closures`
KV bucket: the `internal/meta` commit, the closure's store path, the builder's KV bucket: the `internal/meta` commit, the closure's store path, the builder's
@ -230,7 +243,8 @@ Nothing reads the bucket yet. The closure stays unrooted in the builder host's
store. store.
The controller gives the `builder` forge account read on `internal/meta` when it The controller gives the `builder` forge account read on `internal/meta` when it
updates the flake, since the repo is private. updates the flake, and again before each build, since the repo is private. A
grant the forge refuses counts as no verdict.
## Credentials ## Credentials

View file

@ -1725,6 +1725,19 @@ in
# renew at half their life, so a fraction over 0.8 means # renew at half their life, so a fraction over 0.8 means
# the renewal has been stuck for days. `hive` and `agent` # the renewal has been stuck for days. `hive` and `agent`
# stay in the grouping so the alert names the holder. # stay in the grouping so the alert names the holder.
# Agents whose deploy the build gate holds back, from the gauge
# swarm-controller exports (`held_deploys.rs`): a build that
# is red, or that keeps getting no answer from the builder.
# Thirty minutes covers a builder restart and the controller's
# ten-minute retries.
(metricThresholdRule {
uid = "hh-deploys-held";
title = "deploys held by the build gate";
expr = ''sum({__name__="swarm.deploys.held"})'';
threshold = 0;
for = "30m";
summary = "swarm-controller is holding back the deploy of one or more agents because the builder's build of internal/meta is red or gets no answer; the failed `build_meta` node on the Jobs page says which";
})
(metricThresholdRule { (metricThresholdRule {
uid = "hh-bao-client-cert-lifetime"; uid = "hh-bao-client-cert-lifetime";
title = "bao client certificate past 80% of its lifetime"; title = "bao client certificate past 80% of its lifetime";

View file

@ -606,6 +606,32 @@ let
] ]
&& query.instant; && query.instant;
} }
{
# The held-deploys alert fires on any agent held for 30 minutes and
# selects the gauge name `held_deploys.rs` spells in Rust. The disk rule
# is the presence control: it goes through the same lookup.
# 🔬 Invert-proof: change `for = "30m"` or the selector on
# `hh-deploys-held` in swarm-grafana.nix, or `METRIC` in held_deploys.rs.
name = "the held-deploys alert fires after 30 minutes on the gauge swarm-controller exports";
ok =
let
groups =
grafanaHere.containers.swarm-grafana.config.services.grafana.provision.alerting.rules.settings.groups;
rules = lib.concatMap (g: g.rules) groups;
source = builtins.readFile ../../swarm-controller/src/held_deploys.rs;
rule = uid: lib.findFirst (r: r.uid == uid) null rules;
held = rule "hh-deploys-held";
threshold =
r:
lib.head
(lib.head (lib.findFirst (q: q.refId == "C") null r.data).model.conditions).evaluator.params;
in
threshold held == 0
&& held.for == "30m"
&& threshold (rule "hh-host-disk-high") == 0.85
&& lib.hasInfix ''{__name__="swarm.deploys.held"}'' (lib.head held.data).model.expr
&& lib.hasInfix ''"swarm.deploys.held"'' source;
}
{ {
# 🩸 The regression this guards: moving bao's metrics scrape into its # 🩸 The regression this guards: moving bao's metrics scrape into its
# own in-container collector made that collector stamp every exported # own in-container collector made that collector stamp every exported

View file

@ -80,12 +80,18 @@ impl Builder {
} }
let mut cmd = self.nix_command(token); let mut cmd = self.nix_command(token);
cmd.args(BUILD_FLAGS).arg(self.meta_attr(job)); cmd.args(BUILD_FLAGS).arg(self.meta_attr(job));
match run(cmd, self.deadline).await { // `nix` not finishing (deadline, spawn or wait error) says nothing about
// the config, so it is `Unavailable`; only an exit of `nix` itself is a
// verdict.
match run_raw(cmd, self.deadline).await {
Err(reason) => FromMetaReply::Unavailable { reason },
Ok((status, out, err)) => match verdict(status, &out, &err) {
Reply::Built { out_path } => FromMetaReply::Built { Reply::Built { out_path } => FromMetaReply::Built {
out_path, out_path,
system: self.system.clone(), system: self.system.clone(),
}, },
Reply::Failed { error_tail } => FromMetaReply::Failed { error_tail }, Reply::Failed { error_tail } => FromMetaReply::Failed { error_tail },
},
} }
} }
@ -274,20 +280,24 @@ fn check_agent_and_rev(agent: &str, rev: &str) -> Result<(), String> {
async fn run(cmd: tokio::process::Command, deadline: Duration) -> Reply { async fn run(cmd: tokio::process::Command, deadline: Duration) -> Reply {
match run_raw(cmd, deadline).await { match run_raw(cmd, deadline).await {
Err(error_tail) => Reply::Failed { error_tail }, Err(error_tail) => Reply::Failed { error_tail },
Ok((status, out, err)) => { Ok((status, out, err)) => verdict(status, &out, &err),
let out_path = String::from_utf8_lossy(&out) }
}
/// What a `nix build` that exited said: the store path it printed, or the tail
/// of what it said.
fn verdict(status: std::process::ExitStatus, out: &[u8], err: &[u8]) -> Reply {
let out_path = String::from_utf8_lossy(out)
.lines() .lines()
.rfind(|l| !l.trim().is_empty()) .rfind(|l| !l.trim().is_empty())
.map(|l| l.trim().to_owned()); .map(|l| l.trim().to_owned());
match out_path { match out_path {
Some(out_path) if status.success() => Reply::Built { out_path }, Some(out_path) if status.success() => Reply::Built { out_path },
_ => Reply::Failed { _ => Reply::Failed {
error_tail: tail(&String::from_utf8_lossy(&err), status), error_tail: tail(&String::from_utf8_lossy(err), status),
}, },
} }
} }
}
}
/// Run `cmd` to its end or `deadline`: its status and the kept end of its /// Run `cmd` to its end or `deadline`: its status and the kept end of its
/// stdout and stderr, or why it did not finish. /// stdout and stderr, or why it did not finish.
@ -815,6 +825,40 @@ mod tests {
(Builder { nix, ..builder() }, dir) (Builder { nix, ..builder() }, dir)
} }
/// `nix` not finishing says nothing about the config: a build past its
/// deadline and a `nix` that cannot start are `Unavailable`. A `nix` that
/// exits non-zero is `Failed`, the control for the same builder.
#[tokio::test]
async fn a_meta_build_that_does_not_finish_is_unavailable_and_one_that_exits_is_failed() {
let (slow, _guard) = scripted("sleep 5");
let slow = Builder {
deadline: Duration::from_millis(200),
..slow
};
let reply = slow.build_from_meta(&meta_job(), "t").await;
assert!(
matches!(&reply, FromMetaReply::Unavailable { reason } if reason.contains("did not finish")),
"{reply:?}"
);
let missing = Builder {
nix: PathBuf::from("/nonexistent/nix"),
..builder()
};
let reply = missing.build_from_meta(&meta_job(), "t").await;
assert!(
matches!(&reply, FromMetaReply::Unavailable { reason } if reason.contains("starting nix")),
"{reply:?}"
);
let (failing, _guard) = scripted("echo 'error: attribute missing' >&2; exit 1");
let reply = failing.build_from_meta(&meta_job(), "t").await;
assert!(
matches!(&reply, FromMetaReply::Failed { error_tail } if error_tail.ends_with("attribute missing")),
"{reply:?}"
);
}
fn lock_job() -> LockMeta { fn lock_job() -> LockMeta {
LockMeta { LockMeta {
flake_nix: "{ outputs = _: { }; }".to_owned(), flake_nix: "{ outputs = _: { }; }".to_owned(),

View file

@ -0,0 +1,179 @@
//! The `swarm.deploys.held` gauge: how many agents have a deploy held back by
//! the build gate, exported on the jobq exporter. Grafana alerts when it stays
//! above zero (`hh-deploys-held`, `nix/host-modules/swarm-grafana.nix`).
//!
//! An agent counts when its latest `BuildMeta` node is `Failed`, once per agent
//! under one of two `reason` labels:
//! - `red`: `nix` exited with an error twice. Nothing retries it; a config merge
//! builds again.
//! - `retrying`: the build gave no verdict or met its first `nix` error, so the
//! ten-minute timer builds it again.
//!
//! A pending, running or finished build does not count, so the value falls to
//! zero when the builder answers.
use std::sync::{Arc, Mutex};
use hive_jobq::State;
use opentelemetry::KeyValue;
use opentelemetry::metrics::MeterProvider as _;
use opentelemetry_sdk::metrics::SdkMeterProvider;
use crate::jobq::{SwarmNodeKind, SwarmResourceKind};
use crate::meta_build;
/// The gauge's name. The Grafana rule selects it by this name.
pub(crate) const METRIC: &str = "swarm.deploys.held";
/// Held agents as `(red, retrying)`.
fn count(graph: &hive_jobq::Graph<SwarmNodeKind, SwarmResourceKind>) -> (u64, u64) {
let mut latest = std::collections::BTreeMap::new();
for n in graph.nodes() {
if let SwarmNodeKind::BuildMeta { agent, .. } = &n.payload
&& latest
.get(agent)
.is_none_or(|l: &&hive_jobq::Node<_, _>| l.id < n.id)
{
latest.insert(agent, n);
}
}
latest
.into_values()
.filter(|n| n.state == State::Failed)
.fold((0, 0), |(red, retrying), n| {
if n.error.as_deref().is_some_and(meta_build::is_retried) {
(red, retrying + 1)
} else {
(red + 1, retrying)
}
})
}
/// Register the gauge on `provider`, reading `jobq` at each export. Nothing when
/// no exporter is configured.
pub(crate) fn register(
provider: Option<&SdkMeterProvider>,
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
) {
let Some(provider) = provider else {
return;
};
let jobq = Arc::clone(jobq);
provider
.meter("hyperhive.deploy_gate")
.u64_observable_gauge(METRIC)
.with_description("agents whose deploy the build gate is holding back, by reason")
.with_callback(move |obs| {
let (red, retrying) = count(
jobq.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.graph(),
);
obs.observe(red, &[KeyValue::new("reason", "red")]);
obs.observe(retrying, &[KeyValue::new("reason", "retrying")]);
})
.build();
}
#[cfg(test)]
mod tests {
use hive_jobq::scheduler::{Outcome, Scheduler};
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData, ResourceMetrics};
use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader};
use super::*;
type Jobq = Arc<Mutex<Scheduler<SwarmNodeKind, SwarmResourceKind>>>;
/// A scheduler where each of `builds` (agent, outcome) ran once, in order.
async fn after(builds: &[(&str, Outcome)]) -> Jobq {
let jobq: Jobq = Arc::new(Mutex::new(Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
for (agent, outcome) in builds {
jobq.lock()
.unwrap()
.append(
SwarmNodeKind::BuildMeta {
agent: (*agent).to_owned(),
attempt: 0,
},
Vec::new(),
None,
)
.expect("insert");
let outcome = outcome.clone();
Scheduler::claim_next(&jobq, move |_, _, b| async move { (b, outcome) })
.expect("runnable")
.await
.1
.expect("no growth");
}
jobq
}
fn red() -> Outcome {
Outcome::Failed("error: attribute missing".to_owned())
}
fn retrying() -> Outcome {
Outcome::Failed(format!("{}no builder", meta_build::RETRY_PREFIX))
}
#[tokio::test]
async fn a_failed_latest_build_holds_its_agent_by_reason() {
let jobq = after(&[
("atlas", red()),
("argus", retrying()),
("iris", Outcome::Done),
])
.await;
assert_eq!(count(jobq.lock().unwrap().graph()), (1, 1));
}
/// Only an agent's latest build counts: a green build after a red one
/// releases it, and a red one after a green one holds it.
#[tokio::test]
async fn only_the_latest_build_of_an_agent_counts() {
let released = after(&[("atlas", red()), ("atlas", Outcome::Done)]).await;
assert_eq!(count(released.lock().unwrap().graph()), (0, 0));
let held = after(&[("atlas", Outcome::Done), ("atlas", red())]).await;
assert_eq!(count(held.lock().unwrap().graph()), (1, 0));
}
/// The gauge reads the graph at export time and labels both reasons, zero
/// included, so a rule sees the series before anything is held.
#[tokio::test]
async fn the_gauge_exports_both_reasons() {
let jobq = after(&[("atlas", red())]).await;
let exporter = InMemoryMetricExporter::default();
let reader = PeriodicReader::builder(exporter.clone()).build();
let provider = SdkMeterProvider::builder().with_reader(reader).build();
register(Some(&provider), &jobq);
provider.force_flush().expect("flush");
let all = exporter.get_finished_metrics().expect("metrics");
let mut points = Vec::new();
for m in all
.last()
.into_iter()
.flat_map(ResourceMetrics::scope_metrics)
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
.filter(|m| m.name() == METRIC)
{
let AggregatedMetrics::U64(MetricData::Gauge(g)) = m.data() else {
panic!("not a u64 gauge: {:?}", m.data());
};
for p in g.data_points() {
let reason = p
.attributes()
.find(|kv| kv.key.as_str() == "reason")
.map(|kv| kv.value.to_string())
.expect("a reason");
points.push((reason, p.value()));
}
}
points.sort();
assert_eq!(points, [("red".to_owned(), 1), ("retrying".to_owned(), 0)]);
}
}

View file

@ -115,8 +115,10 @@ pub(crate) enum SwarmNodeKind {
/// runs only when the build is green. See `meta_build`. /// 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 /// Carries no rev: it reads the head when it runs, which in most DAGs is
/// after the `UpdateMeta` that commits it. /// after the `UpdateMeta` that commits it. `attempt` is how many builds of
BuildMeta { agent: String }, /// this deploy already ended in an error from `nix`; the build after the
/// second such error is red.
BuildMeta { agent: String, attempt: u32 },
/// Re-mint `agent`'s queue secret if it is still stored and old enough. /// Re-mint `agent`'s queue secret if it is still stored and old enough.
/// See `agent_renewal`. /// See `agent_renewal`.
/// ///
@ -202,7 +204,6 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::MintAgentIdentity { agent } | SwarmNodeKind::MintAgentIdentity { agent }
| SwarmNodeKind::MintAgentForgeToken { agent } | SwarmNodeKind::MintAgentForgeToken { agent }
| SwarmNodeKind::MintAgentMatrixAccount { agent } | SwarmNodeKind::MintAgentMatrixAccount { agent }
| SwarmNodeKind::BuildMeta { agent }
| SwarmNodeKind::RenewAgentQueueCredential { agent } => { | SwarmNodeKind::RenewAgentQueueCredential { agent } => {
serde_json::json!({ "agent": agent }) serde_json::json!({ "agent": agent })
} }
@ -215,6 +216,9 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::BuildAgent { agent, rev, pr } => { SwarmNodeKind::BuildAgent { agent, rev, pr } => {
serde_json::json!({ "agent": agent, "rev": rev, "pr": 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 } => { SwarmNodeKind::TriggerDeploy { hive, agent, rev } => {
serde_json::json!({ "agent": agent, "hive": hive, "rev": rev }) serde_json::json!({ "agent": agent, "hive": hive, "rev": rev })
} }
@ -414,16 +418,13 @@ async fn run_node_work(
SwarmNodeKind::BuildAgent { agent, rev, pr } => { SwarmNodeKind::BuildAgent { agent, rev, pr } => {
build_agent(&deps, forge, agent, rev, pr).await build_agent(&deps, forge, agent, rev, pr).await
} }
SwarmNodeKind::BuildMeta { agent } => build_meta(&deps, forge, agent).await, SwarmNodeKind::BuildMeta { agent, attempt } => {
build_meta(&deps, forge, agent, attempt).await
}
SwarmNodeKind::RenewAgentQueueCredential { agent } => renew_queue_credential(&agent).await, SwarmNodeKind::RenewAgentQueueCredential { agent } => renew_queue_credential(&agent).await,
SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted { SwarmNodeKind::SetAgentWanted { hive, agent } => {
None => Outcome::Failed( set_agent_wanted(deps.wanted.as_deref(), &hive, &agent).await
"no swarm queue is configured on this host, so no wanted-state \ }
declaration can be written"
.to_owned(),
),
Some(writer) => declare_new_agent(&writer, &hive, &agent).await,
},
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => match deps.queue { SwarmNodeKind::TriggerDeploy { hive, agent, rev } => match deps.queue {
None => Outcome::Failed( None => Outcome::Failed(
"no swarm queue is configured on this host, so no hive can be told to deploy" "no swarm queue is configured on this host, so no hive can be told to deploy"
@ -607,8 +608,9 @@ async fn build_meta(
deps: &WorkerDeps, deps: &WorkerDeps,
forge: Option<Arc<forge::Client>>, forge: Option<Arc<forge::Client>>,
agent: String, agent: String,
attempt: u32,
) -> hive_jobq::scheduler::Outcome { ) -> hive_jobq::scheduler::Outcome {
meta_build::run(forge.as_deref(), deps.queue.as_ref(), agent).await meta_build::run(forge.as_deref(), deps.queue.as_ref(), agent, attempt).await
} }
/// What an `UpdateMeta` pass decided: the deploys to send, and the placements /// What an `UpdateMeta` pass decided: the deploys to send, and the placements
@ -746,16 +748,21 @@ async fn record_fanout(
/// parent. The build waits on it, since the build reads the head that update /// 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 /// commits. The deploy keeps its own edge to it too: `skipped_deploys` finds
/// the deploys a failed update skipped by that edge. /// 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>( fn gated_deploy<'a>(
b: &'a hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>, b: &'a hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
hive: &str, hive: &str,
agent: &str, agent: &str,
rev: Option<String>, rev: Option<String>,
update: Option<hive_jobq::builder::NodeGuid>, update: Option<hive_jobq::builder::NodeGuid>,
attempt: u32,
) -> hive_jobq::builder::NodeRef<'a, SwarmNodeKind, SwarmResourceKind> { ) -> hive_jobq::builder::NodeRef<'a, SwarmNodeKind, SwarmResourceKind> {
let mut build = b let mut build = b
.node(SwarmNodeKind::BuildMeta { .node(SwarmNodeKind::BuildMeta {
agent: agent.to_owned(), agent: agent.to_owned(),
attempt,
}) })
.needs(SwarmResourceKind::Builder); .needs(SwarmResourceKind::Builder);
if let Some(update) = update { if let Some(update) = update {
@ -790,7 +797,7 @@ fn declare_fan_out(
} }
let deploys: Vec<hive_jobq::builder::NodeGuid> = placements let deploys: Vec<hive_jobq::builder::NodeGuid> = placements
.iter() .iter()
.map(|(hive, agent)| gated_deploy(grown, hive, agent, None, None).guid()) .map(|(hive, agent)| gated_deploy(grown, hive, agent, None, None, 0).guid())
.collect(); .collect();
if let FanOut::Record { pin } = fan_out { if let FanOut::Record { pin } = fan_out {
let mut record = grown.node(SwarmNodeKind::RecordFanout { pin: pin.clone() }); let mut record = grown.node(SwarmNodeKind::RecordFanout { pin: pin.clone() });
@ -967,6 +974,23 @@ async fn mint_hive_sender(homeserver: Option<&str>, hive: &str) -> hive_jobq::sc
} }
} }
/// The `SetAgentWanted` arm, lifted out so `run_node_work` stays under the line
/// limit: it fails without a wanted-state writer.
async fn set_agent_wanted(
wanted: Option<&wanted::WantedWriter>,
hive: &str,
agent: &str,
) -> hive_jobq::scheduler::Outcome {
match wanted {
None => hive_jobq::scheduler::Outcome::Failed(
"no swarm queue is configured on this host, so no wanted-state \
declaration can be written"
.to_owned(),
),
Some(writer) => declare_new_agent(writer, hive, agent).await,
}
}
/// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns /// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns
/// out not to be new: an agent that already has a declaration (other than /// out not to be new: an agent that already has a declaration (other than
/// `Destroyed`, which this treats as reusable) is left alone, so a retried /// `Destroyed`, which this treats as reusable) is left alone, so a retried
@ -1211,7 +1235,7 @@ fn merged_deploy_job(
.node(SwarmNodeKind::UpdateMeta) .node(SwarmNodeKind::UpdateMeta)
.needs(SwarmResourceKind::Meta); .needs(SwarmResourceKind::Meta);
let root = update_meta.guid(); let root = update_meta.guid();
let _deploy = gated_deploy(b, hive, agent, Some(rev.to_owned()), Some(root)); let _deploy = gated_deploy(b, hive, agent, Some(rev.to_owned()), Some(root), 0);
vec![root] vec![root]
} }
@ -1335,7 +1359,7 @@ pub(crate) fn declare_agent_job(
// queue at all fails this node — but `TriggerDeploy` has nothing to // 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 // publish to on such a host either, so the strict edge cancels a node
// that could not have succeeded anyway. // that could not have succeeded anyway.
let trigger_deploy = gated_deploy(b, hive, agent, None, Some(update_meta.guid())) let trigger_deploy = gated_deploy(b, hive, agent, None, Some(update_meta.guid()), 0)
.after_any(mint_identity) .after_any(mint_identity)
.after_any(mint_forge_token) .after_any(mint_forge_token)
// `after_any` for the same reason: the container reads this token // `after_any` for the same reason: the container reads this token
@ -1540,7 +1564,8 @@ pub(crate) fn spawn_meta_update_timer(
enum SkipCause { enum SkipCause {
/// It waited on an `UpdateMeta` that `Failed`. /// It waited on an `UpdateMeta` that `Failed`.
FailedUpdate, FailedUpdate,
/// It waited on a `BuildMeta` that gave no verdict ([`meta_build::is_retried`]). /// It waited on a `BuildMeta` that gave no verdict or whose first `nix`
/// error asks for another build ([`meta_build::is_retried`]).
UnfinishedBuild, UnfinishedBuild,
} }
@ -1550,16 +1575,18 @@ struct SkippedDeploy {
agent: String, agent: String,
rev: Option<String>, rev: Option<String>,
cause: SkipCause, 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 /// The deploys the timer declares again: for each agent, its latest
/// `TriggerDeploy` when the scheduler skipped it because an `UpdateMeta` it /// `TriggerDeploy` when the scheduler skipped it because an `UpdateMeta` it
/// waited on `Failed`, or because the `BuildMeta` it waited on gave no verdict. /// waited on `Failed`, or because the `BuildMeta` it waited on gave no verdict
/// A deploy skipped for another reason is left alone. A red build is a verdict /// or met its first `nix` error. A deploy skipped for another reason is left
/// on the config, which a config merge answers with a build of its own. The /// alone. A red build, the second `nix` error, is a verdict on the config, which
/// pause declaration or the config seeding failing means the update that would /// a config merge answers with a build of its own. The pause declaration or the
/// unblock the deploy was skipped too, and deploying past those is the race /// config seeding failing means the update that would unblock the deploy was
/// those edges close. /// skipped too, and deploying past those is the race those edges close.
fn skipped_deploys( fn skipped_deploys(
graph: &hive_jobq::Graph<SwarmNodeKind, SwarmResourceKind>, graph: &hive_jobq::Graph<SwarmNodeKind, SwarmResourceKind>,
) -> Vec<SkippedDeploy> { ) -> Vec<SkippedDeploy> {
@ -1581,24 +1608,28 @@ fn skipped_deploys(
let SwarmNodeKind::TriggerDeploy { hive, agent, rev } = &n.payload else { let SwarmNodeKind::TriggerDeploy { hive, agent, rev } = &n.payload else {
return None; return None;
}; };
let waited_on = let deps = || {
|wanted: fn(&hive_jobq::Node<SwarmNodeKind, SwarmResourceKind>) -> bool| { n.deps.iter().filter_map(|d| match d {
n.deps.iter().any(|d| match d { hive_jobq::Dep::Node { id, .. } => graph.node(*id),
hive_jobq::Dep::Node { id, .. } => graph.node(*id).is_some_and(wanted), hive_jobq::Dep::Resource { .. } => None,
hive_jobq::Dep::Resource { .. } => false,
}) })
}; };
let cause = if waited_on(|dep| { let failed_update = deps().any(|dep| {
matches!(dep.payload, SwarmNodeKind::UpdateMeta) matches!(dep.payload, SwarmNodeKind::UpdateMeta)
&& dep.state == hive_jobq::State::Failed && dep.state == hive_jobq::State::Failed
}) { });
SkipCause::FailedUpdate let unfinished_build = deps().find_map(|dep| match (&dep.payload, &dep.error) {
} else if waited_on(|dep| { (SwarmNodeKind::BuildMeta { attempt, .. }, Some(error))
matches!(dep.payload, SwarmNodeKind::BuildMeta { .. }) if dep.state == hive_jobq::State::Failed && meta_build::is_retried(error) =>
&& dep.state == hive_jobq::State::Failed {
&& dep.error.as_deref().is_some_and(meta_build::is_retried) Some(meta_build::next_attempt(*attempt, error))
}) { }
SkipCause::UnfinishedBuild _ => None,
});
let (cause, attempt) = if failed_update {
(SkipCause::FailedUpdate, 0)
} else if let Some(attempt) = unfinished_build {
(SkipCause::UnfinishedBuild, attempt)
} else { } else {
return None; return None;
}; };
@ -1607,6 +1638,7 @@ fn skipped_deploys(
agent: agent.clone(), agent: agent.clone(),
rev: rev.clone(), rev: rev.clone(),
cause, cause,
attempt,
}) })
}) })
.collect() .collect()
@ -1662,7 +1694,7 @@ fn queue_meta_update(
}); });
let deploys: Vec<_> = skipped let deploys: Vec<_> = skipped
.into_iter() .into_iter()
.map(|s| gated_deploy(b, &s.hive, &s.agent, s.rev, update).guid()) .map(|s| gated_deploy(b, &s.hive, &s.agent, s.rev, update, s.attempt).guid())
.collect(); .collect();
update.map_or(deploys, |update| vec![update]) update.map_or(deploys, |update| vec![update])
}) })
@ -2357,7 +2389,7 @@ mod tests {
hive_jobq::Dep::Node { id, when } => graph hive_jobq::Dep::Node { id, when } => graph
.node(*id) .node(*id)
.filter(|n| { .filter(|n| {
matches!(&n.payload, SwarmNodeKind::BuildMeta { agent: a } if a == agent) matches!(&n.payload, SwarmNodeKind::BuildMeta { agent: a, .. } if a == agent)
}) })
.map(|n| (n, *when)), .map(|n| (n, *when)),
hive_jobq::Dep::Resource { .. } => None, hive_jobq::Dep::Resource { .. } => None,
@ -2641,7 +2673,7 @@ mod tests {
{ {
Outcome::Failed("the hive did not take it".to_owned()) Outcome::Failed("the hive did not take it".to_owned())
} }
SwarmNodeKind::BuildMeta { agent } SwarmNodeKind::BuildMeta { agent, .. }
if failing_build.as_ref().is_some_and(|(a, _)| *a == agent) => if failing_build.as_ref().is_some_and(|(a, _)| *a == agent) =>
{ {
Outcome::Failed(failing_build.map(|(_, error)| error).expect("matched")) Outcome::Failed(failing_build.map(|(_, error)| error).expect("matched"))
@ -2902,7 +2934,7 @@ mod tests {
.filter(|n| n.payload.label() == label) .filter(|n| n.payload.label() == label)
.filter(|n| n.state == State::Pending) .filter(|n| n.state == State::Pending)
.map(|n| match &n.payload { .map(|n| match &n.payload {
SwarmNodeKind::BuildMeta { agent } SwarmNodeKind::BuildMeta { agent, .. }
| SwarmNodeKind::TriggerDeploy { agent, .. } => agent.clone(), | SwarmNodeKind::TriggerDeploy { agent, .. } => agent.clone(),
other => panic!("not a build or a deploy: {other:?}"), other => panic!("not a build or a deploy: {other:?}"),
}) })
@ -3786,7 +3818,9 @@ mod tests {
let error = error.clone(); let error = error.clone();
move |_id, kind, builder| async move { move |_id, kind, builder| async move {
let outcome = match kind { let outcome = match kind {
SwarmNodeKind::BuildMeta { agent: a } if a == agent => Outcome::Failed(error), SwarmNodeKind::BuildMeta { agent: a, .. } if a == agent => {
Outcome::Failed(error)
}
_ => Outcome::Done, _ => Outcome::Done,
}; };
(builder, outcome) (builder, outcome)
@ -3885,6 +3919,84 @@ mod tests {
assert!(!queue_meta_update(&sched).expect("tick")); assert!(!queue_meta_update(&sched).expect("tick"));
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]); 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
.lock()
.unwrap()
.insert_job(None, |b| merged_deploy_job(b, "pr1ma", "atlas", "abc"))
.expect("the graph inserts");
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.
#[tokio::test]
async fn a_first_nix_error_is_built_once_more_and_the_second_is_red() {
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;
assert_eq!(deploys_of(&sched, "atlas"), [State::Skipped]);
assert_eq!(build_attempts(&sched, "atlas"), [0]);
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;
assert_eq!(
deploys_of(&sched, "atlas"),
[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;
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]);
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -45,6 +45,7 @@ mod forge;
mod forge_account; mod forge_account;
mod forge_events; mod forge_events;
mod github_account; mod github_account;
mod held_deploys;
mod inference; mod inference;
mod issue_report; mod issue_report;
mod jobq; mod jobq;
@ -176,6 +177,7 @@ async fn main() -> Result<()> {
let jobq_metrics_provider = let jobq_metrics_provider =
hive_jobq_metrics::spawn_exporter(Arc::clone(&jobq), "swarm-controller", jobq_http_client); hive_jobq_metrics::spawn_exporter(Arc::clone(&jobq), "swarm-controller", jobq_http_client);
register_cert_gauges(jobq_metrics_provider.as_ref()); register_cert_gauges(jobq_metrics_provider.as_ref());
held_deploys::register(jobq_metrics_provider.as_ref(), &jobq);
let webhook_secret = load_webhook_secret(); let webhook_secret = load_webhook_secret();

View file

@ -8,11 +8,17 @@
//! key itself: the builder holds no grant on the bucket. A `Built` reply and //! key itself: the builder holds no grant on the bucket. A `Built` reply and
//! nothing else writes, so a red build leaves the previous record. //! nothing else writes, so a red build leaves the previous record.
//! //!
//! The node ends one of three ways. `Done` is a green build. A red build, the //! The node ends one of three ways. `Done` is a green build. Every `Failed` skips
//! builder's `Failed` reply, is `Failed` with `nix`'s tail: the deploy behind it //! the deploy behind it, and the Jobs page shows why. What decides whether the
//! is skipped and the Jobs page shows why. Anything that stopped the build from //! timer builds again (`jobq::queue_meta_update`) is the error text:
//! giving a verdict is `Failed` with [`RETRY_PREFIX`], which //! - No verdict (no builder, a timeout, a forge error, no `internal/meta` head,
//! `jobq::queue_meta_update` retries on the meta timer. //! a missing read grant, a failed closure write): [`RETRY_PREFIX`]. The next
//! build has the same `attempt`.
//! - `nix` exited with an error on the first attempt: [`FIRST_FAILURE_PREFIX`]
//! and `nix`'s tail. The next build is `attempt + 1`, which catches a fetch
//! hiccup inside `nix` without reading its output.
//! - `nix` exited with an error again: `nix`'s tail alone. That is a red build:
//! the deploy stays held until a config merge, and nothing retries it.
use std::fmt::Display; use std::fmt::Display;
use std::future::Future; use std::future::Future;
@ -29,28 +35,54 @@ use crate::meta_flake::{META_REPO, MetaForge};
/// What a `Failed` outcome that is not a verdict on the config starts with. /// What a `Failed` outcome that is not a verdict on the config starts with.
pub(crate) const RETRY_PREFIX: &str = "not built, will be retried: "; pub(crate) const RETRY_PREFIX: &str = "not built, will be retried: ";
/// Whether a `Failed` node's `error` says the build gave no verdict. /// What the `Failed` outcome of a first `nix` error starts with.
pub(crate) const FIRST_FAILURE_PREFIX: &str = "build failed, will be built once more: ";
/// How many times `nix` may exit with an error before the build is red.
const NIX_ATTEMPTS: u32 = 2;
/// Whether a `Failed` node's `error` asks for another build.
pub(crate) fn is_retried(error: &str) -> bool { pub(crate) fn is_retried(error: &str) -> bool {
error.starts_with(RETRY_PREFIX) error.starts_with(RETRY_PREFIX) || error.starts_with(FIRST_FAILURE_PREFIX)
}
/// The `attempt` of the build that follows a `Failed` build of `attempt`
/// with `error`: one more after an error `nix` gave, the same after none.
pub(crate) fn next_attempt(attempt: u32, error: &str) -> u32 {
if error.starts_with(FIRST_FAILURE_PREFIX) {
attempt + 1
} else {
attempt
}
} }
fn not_built(why: impl Display) -> Outcome { fn not_built(why: impl Display) -> Outcome {
Outcome::Failed(format!("{RETRY_PREFIX}{why}")) Outcome::Failed(format!("{RETRY_PREFIX}{why}"))
} }
/// Where `internal/meta`'s head is read: the forge in production, a fake in /// Where `internal/meta` is read and shared with the builder: the forge in
/// the tests. /// production, a fake in the tests.
pub(crate) trait MetaHead { pub(crate) trait MetaRepo {
/// The head commit; `None` when the repo has none. /// The head commit; `None` when the repo has none.
fn head(&self) -> impl Future<Output = Result<Option<String>>> + Send; fn head(&self) -> impl Future<Output = Result<Option<String>>> + Send;
/// Make sure the builder's forge account can read the repo.
fn grant_builder_read(&self) -> impl Future<Output = Result<()>> + Send;
} }
impl MetaHead for forge::Client { impl MetaRepo for forge::Client {
async fn head(&self) -> Result<Option<String>> { async fn head(&self) -> Result<Option<String>> {
let read = self.head_files(SHARED_ORG, META_REPO, None, &[]); let read = self.head_files(SHARED_ORG, META_REPO, None, &[]);
let head = crate::jobq::forge_node("build_meta", read).await?; let head = crate::jobq::forge_node("build_meta", read).await?;
Ok(head.map(|h| h.commit)) Ok(head.map(|h| h.commit))
} }
async fn grant_builder_read(&self) -> Result<()> {
crate::jobq::forge_node(
"build_meta",
self.grant_meta_read(forge::builder_account::LOGIN),
)
.await
}
} }
/// Where a build is sent and its closure recorded: the queue in production, a /// Where a build is sent and its closure recorded: the queue in production, a
@ -104,10 +136,13 @@ impl MetaBuilds for async_nats::Client {
} }
/// Build `agent` from `internal/meta`'s head and record the closure. /// Build `agent` from `internal/meta`'s head and record the closure.
/// `attempt` is how many times `nix` has already exited with an error for this
/// deploy.
pub(crate) async fn run( pub(crate) async fn run(
head: Option<&impl MetaHead>, head: Option<&impl MetaRepo>,
queue: Option<&impl MetaBuilds>, queue: Option<&impl MetaBuilds>,
agent: String, agent: String,
attempt: u32,
) -> Outcome { ) -> Outcome {
let Some(head) = head else { let Some(head) = head else {
return not_built( return not_built(
@ -118,6 +153,12 @@ pub(crate) async fn run(
let Some(queue) = queue else { let Some(queue) = queue else {
return not_built("no swarm queue is configured on this host, so no build can be sent"); return not_built("no swarm queue is configured on this host, so no build can be sent");
}; };
// The repo is private, and the grant is otherwise made only by a meta update.
if let Err(e) = head.grant_builder_read().await {
return not_built(format!(
"the builder's read on internal/meta could not be granted: {e:#}"
));
}
let meta_rev = match head.head().await { let meta_rev = match head.head().await {
Ok(Some(rev)) => rev, Ok(Some(rev)) => rev,
Ok(None) => return not_built("the swarm has not published internal/meta yet"), Ok(None) => return not_built("the swarm has not published internal/meta yet"),
@ -133,6 +174,9 @@ pub(crate) async fn run(
match queue.build(request).await { match queue.build(request).await {
Err(why) => not_built(why), Err(why) => not_built(why),
Ok(FromMetaReply::Unavailable { reason }) => not_built(reason), Ok(FromMetaReply::Unavailable { reason }) => not_built(reason),
Ok(FromMetaReply::Failed { error_tail }) if attempt + 1 < NIX_ATTEMPTS => {
Outcome::Failed(format!("{FIRST_FAILURE_PREFIX}{error_tail}"))
}
Ok(FromMetaReply::Failed { error_tail }) => Outcome::Failed(error_tail), Ok(FromMetaReply::Failed { error_tail }) => Outcome::Failed(error_tail),
Ok(FromMetaReply::Built { out_path, system }) => { Ok(FromMetaReply::Built { out_path, system }) => {
let closure = AgentClosure { let closure = AgentClosure {
@ -157,15 +201,34 @@ mod tests {
const REV: &str = "0123456789abcdef0123456789abcdef01234567"; const REV: &str = "0123456789abcdef0123456789abcdef01234567";
struct FakeHead(Result<Option<&'static str>, &'static str>); /// `internal/meta` as the forge answers: the head read, and whether the
/// builder's read grant can be made.
struct FakeHead {
head: Result<Option<&'static str>, &'static str>,
grant_fails: bool,
}
impl MetaHead for FakeHead { impl FakeHead {
fn reading(head: Result<Option<&'static str>, &'static str>) -> Self {
Self {
head,
grant_fails: false,
}
}
}
impl MetaRepo for FakeHead {
async fn head(&self) -> Result<Option<String>> { async fn head(&self) -> Result<Option<String>> {
match self.0 { match self.head {
Ok(rev) => Ok(rev.map(str::to_owned)), Ok(rev) => Ok(rev.map(str::to_owned)),
Err(e) => Err(anyhow::anyhow!(e)), Err(e) => Err(anyhow::anyhow!(e)),
} }
} }
async fn grant_builder_read(&self) -> Result<()> {
anyhow::ensure!(!self.grant_fails, "the forge refused the collaborator");
Ok(())
}
} }
/// A queue that answers every build with `reply`, and keeps what it was /// A queue that answers every build with `reply`, and keeps what it was
@ -215,13 +278,17 @@ mod tests {
} }
async fn run_with(head: &FakeHead, queue: &FakeQueue) -> Outcome { async fn run_with(head: &FakeHead, queue: &FakeQueue) -> Outcome {
run(Some(head), Some(queue), "atlas".to_owned()).await run_at(head, queue, 0).await
}
async fn run_at(head: &FakeHead, queue: &FakeQueue, attempt: u32) -> Outcome {
run(Some(head), Some(queue), "atlas".to_owned(), attempt).await
} }
#[tokio::test] #[tokio::test]
async fn a_green_build_of_the_head_is_recorded_for_the_agent() { async fn a_green_build_of_the_head_is_recorded_for_the_agent() {
let queue = FakeQueue::built(); let queue = FakeQueue::built();
let outcome = run_with(&FakeHead(Ok(Some(REV))), &queue).await; let outcome = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await;
assert_eq!(outcome, Outcome::Done); assert_eq!(outcome, Outcome::Done);
assert_eq!( assert_eq!(
*queue.sent.lock().unwrap(), *queue.sent.lock().unwrap(),
@ -241,22 +308,69 @@ mod tests {
assert!(closure.built_unix > 0); assert!(closure.built_unix > 0);
} }
/// The builder said the build is red: the node fails with `nix`'s tail, fn nix_error() -> FakeQueue {
/// which is not a retried failure, and nothing is recorded. The green test FakeQueue::answering(Ok(FromMetaReply::Failed {
/// above is the control that a record is written when the build is green.
#[tokio::test]
async fn a_red_build_fails_with_its_tail_and_records_nothing() {
let queue = FakeQueue::answering(Ok(FromMetaReply::Failed {
error_tail: "error: builder for foo failed".to_owned(), error_tail: "error: builder for foo failed".to_owned(),
})); }))
let outcome = run_with(&FakeHead(Ok(Some(REV))), &queue).await; }
/// `nix` exiting with an error the first time is asked for once more: the
/// failure carries the tail and the retry marker, and the next build is the
/// second attempt. Nothing is recorded.
#[tokio::test]
async fn the_first_nix_error_is_built_once_more() {
let queue = nix_error();
let Outcome::Failed(error) = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await
else {
panic!("not failed")
};
assert!(is_retried(&error), "{error}");
assert!(error.ends_with("error: builder for foo failed"), "{error}");
assert_eq!(next_attempt(0, &error), 1);
assert!(queue.recorded.lock().unwrap().is_empty());
}
/// The second `nix` error is a red build: the tail alone, nothing retries
/// it, nothing is recorded. The green test above is the control that a
/// record is written when the build is green.
#[tokio::test]
async fn the_second_nix_error_is_red_and_records_nothing() {
let queue = nix_error();
let outcome = run_at(&FakeHead::reading(Ok(Some(REV))), &queue, 1).await;
assert_eq!( assert_eq!(
outcome, outcome,
Outcome::Failed("error: builder for foo failed".to_owned()) Outcome::Failed("error: builder for foo failed".to_owned())
); );
assert!(!is_retried("error: builder for foo failed"));
assert!(queue.recorded.lock().unwrap().is_empty()); assert!(queue.recorded.lock().unwrap().is_empty());
} }
/// A build with no verdict keeps its attempt, so a builder that is away for
/// hours does not use up the retry that a nix error is allowed.
#[test]
fn only_a_nix_error_uses_up_an_attempt() {
assert_eq!(next_attempt(0, &format!("{RETRY_PREFIX}no builder")), 0);
assert_eq!(next_attempt(1, &format!("{RETRY_PREFIX}no builder")), 1);
assert_eq!(next_attempt(0, &format!("{FIRST_FAILURE_PREFIX}tail")), 1);
}
/// A read grant the forge will not make is the controller's precondition,
/// not the config's: nothing is sent and the failure is retried.
#[tokio::test]
async fn a_read_grant_that_cannot_be_made_is_retried_before_anything_is_sent() {
let queue = FakeQueue::built();
let head = FakeHead {
grant_fails: true,
..FakeHead::reading(Ok(Some(REV)))
};
let Outcome::Failed(error) = run_with(&head, &queue).await else {
panic!("not failed")
};
assert!(is_retried(&error), "{error}");
assert!(error.contains("read on internal/meta"), "{error}");
assert!(queue.sent.lock().unwrap().is_empty());
}
#[tokio::test] #[tokio::test]
async fn a_builder_that_gave_no_verdict_is_a_retried_failure_and_records_nothing() { async fn a_builder_that_gave_no_verdict_is_a_retried_failure_and_records_nothing() {
for (name, reply) in [ for (name, reply) in [
@ -272,7 +386,7 @@ mod tests {
), ),
] { ] {
let queue = FakeQueue::answering(reply); let queue = FakeQueue::answering(reply);
let outcome = run_with(&FakeHead(Ok(Some(REV))), &queue).await; let outcome = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await;
let Outcome::Failed(error) = outcome else { let Outcome::Failed(error) = outcome else {
panic!("{name}: not failed") panic!("{name}: not failed")
}; };
@ -287,7 +401,8 @@ mod tests {
record_fails: true, record_fails: true,
..FakeQueue::built() ..FakeQueue::built()
}; };
let Outcome::Failed(error) = run_with(&FakeHead(Ok(Some(REV))), &queue).await else { let Outcome::Failed(error) = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await
else {
panic!("not failed") panic!("not failed")
}; };
assert!(is_retried(&error), "{error}"); assert!(is_retried(&error), "{error}");
@ -297,16 +412,21 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn without_a_head_or_a_queue_nothing_is_sent_and_the_failure_is_retried() { async fn without_a_head_or_a_queue_nothing_is_sent_and_the_failure_is_retried() {
for (name, head, with_queue) in [ for (name, head, with_queue) in [
("no repo yet", Some(FakeHead(Ok(None))), true), ("no repo yet", Some(FakeHead::reading(Ok(None))), true),
("forge error", Some(FakeHead(Err("forge down"))), true), (
"forge error",
Some(FakeHead::reading(Err("forge down"))),
true,
),
("no forge", None, true), ("no forge", None, true),
("no queue", Some(FakeHead(Ok(Some(REV)))), false), ("no queue", Some(FakeHead::reading(Ok(Some(REV)))), false),
] { ] {
let queue = FakeQueue::built(); let queue = FakeQueue::built();
let outcome = run( let outcome = run(
head.as_ref(), head.as_ref(),
with_queue.then_some(&queue), with_queue.then_some(&queue),
"atlas".to_owned(), "atlas".to_owned(),
0,
) )
.await; .await;
let Outcome::Failed(error) = outcome else { let Outcome::Failed(error) = outcome else {

View file

@ -142,12 +142,12 @@ pub enum FromMetaReply {
/// The toplevel built; `out_path` is its store path in the builder host's /// The toplevel built; `out_path` is its store path in the builder host's
/// store and `system` the platform the builder host builds for. /// store and `system` the platform the builder host builds for.
Built { out_path: String, system: String }, Built { out_path: String, system: String },
/// `nix` ran and the build did not finish: the last lines it printed, or /// `nix` ran and exited with an error: the last lines it printed. The
/// the deadline it hit. The controller holds the deploy back. /// controller builds once more, and holds the deploy back if that fails too.
Failed { error_tail: String }, Failed { error_tail: String },
/// The builder did not get to run `nix`: the build slot stayed busy or the /// `nix` gave no exit: the build slot stayed busy, the forge token could
/// forge token could not be read. Says nothing about the config, so the /// not be read, `nix` could not start, or it hit its deadline. Says nothing
/// controller tries again. /// about the config, so the controller tries again.
Unavailable { reason: String }, Unavailable { reason: String },
} }