swarm: a deploy goes out only once its closure is cached and the hive has its cache facts
This commit is contained in:
parent
0e84fe8bc4
commit
79c1b6d81d
5 changed files with 432 additions and 92 deletions
|
|
@ -6,14 +6,19 @@
|
|||
//! [`RECONCILE_INTERVAL`] this writes each fact that is stored to every hive's
|
||||
//! `attic/` prefix, and leaves a copy that already holds the value alone.
|
||||
//!
|
||||
//! A deploy waits for the same copy: the `CopyAtticFacts` node ([`copy_node`])
|
||||
//! copies to the one hive the deploy goes to, through the same [`copy_hive`] as
|
||||
//! the pass, so a hive is told to deploy only once its facts are in its prefix.
|
||||
//!
|
||||
//! A fact that is not stored is not copied, and a copy whose source is gone
|
||||
//! stays: the pass never deletes. A swarm with no cache stores none, so the
|
||||
//! pass writes nothing.
|
||||
//! stays: neither the pass nor the node deletes. A swarm with no cache stores
|
||||
//! none, so both write nothing.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_jobq::scheduler::Outcome;
|
||||
use swarm_secret_client::SecretStore;
|
||||
use swarm_secret_client::attic::{Fact, Leaf, hive_path, service_path};
|
||||
|
||||
|
|
@ -94,6 +99,51 @@ async fn copy_hive(
|
|||
Ok(written)
|
||||
}
|
||||
|
||||
/// The stored facts copied to `hive`; how many copies were written. The one
|
||||
/// hive [`copy_node`] serves; [`copy_pass`] reads the sources once for all of
|
||||
/// them instead.
|
||||
///
|
||||
/// # Errors
|
||||
/// A source or a copy that cannot be read, or a copy that cannot be written.
|
||||
pub(crate) async fn ensure_copied(store: &impl FactStore, hive: &str) -> Result<usize> {
|
||||
copy_hive(store, hive, &sources(store).await?).await
|
||||
}
|
||||
|
||||
/// The `CopyAtticFacts` node's work: [`ensure_copied`] for `hive`, `Failed`
|
||||
/// with the cause when it errors. Written copies are logged; a hive whose
|
||||
/// copies are current is `Done` without a write.
|
||||
pub(crate) async fn copy_node(store: &impl FactStore, hive: &str) -> Outcome {
|
||||
match ensure_copied(store, hive).await {
|
||||
Ok(written) => {
|
||||
if written > 0 {
|
||||
tracing::info!(hive, written, "attic facts: copied for a deploy");
|
||||
}
|
||||
Outcome::Done
|
||||
}
|
||||
Err(e) => Outcome::Failed(format!(
|
||||
"the cache facts could not be copied to {hive}: {e:#}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`copy_node`] against the swarm's store, within [`PASS_DEADLINE`].
|
||||
pub(crate) async fn copy_node_now(hive: &str) -> Outcome {
|
||||
let work = async {
|
||||
match crate::store::connect().await {
|
||||
Ok(store) => copy_node(&store, hive).await,
|
||||
Err(e) => Outcome::Failed(format!("logging in to the swarm secret store: {e}")),
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PASS_DEADLINE, work)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Outcome::Failed(format!(
|
||||
"copying the cache facts to {hive} timed out after {}s",
|
||||
PASS_DEADLINE.as_secs()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// One pass over `hives`.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -305,6 +355,54 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The node and the pass copy through the same `copy_hive`: for one hive
|
||||
/// they leave the store holding the same values.
|
||||
#[tokio::test]
|
||||
async fn the_node_and_the_pass_leave_the_same_copies() {
|
||||
let by_pass = Memory::with(&SOURCES);
|
||||
copy_pass(&by_pass, &["alpha".to_owned()])
|
||||
.await
|
||||
.expect("pass");
|
||||
let by_node = Memory::with(&SOURCES);
|
||||
assert_eq!(copy_node(&by_node, "alpha").await, Outcome::Done);
|
||||
for leaf in Leaf::ALL {
|
||||
let path = hive_path("alpha", leaf).expect("a hive path");
|
||||
assert!(by_pass.get(&path).is_some(), "the pass wrote {path}");
|
||||
assert_eq!(by_node.get(&path), by_pass.get(&path), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The node copies for its hive only, and a hive already current is `Done`
|
||||
/// without a write.
|
||||
#[tokio::test]
|
||||
async fn the_node_copies_for_its_own_hive_and_leaves_a_current_copy_alone() {
|
||||
let store = Memory::with(&SOURCES);
|
||||
assert_eq!(copy_node(&store, "alpha").await, Outcome::Done);
|
||||
assert_eq!(
|
||||
store.get("swarm/hives/alpha/attic/pull-token").as_deref(),
|
||||
Some("tok")
|
||||
);
|
||||
assert!(store.get("swarm/hives/beta/attic/pull-token").is_none());
|
||||
|
||||
let writes = store.writes.lock().unwrap().len();
|
||||
assert_eq!(copy_node(&store, "alpha").await, Outcome::Done);
|
||||
assert_eq!(store.writes.lock().unwrap().len(), writes);
|
||||
}
|
||||
|
||||
/// A copy that cannot be written fails the node, naming the hive and the
|
||||
/// cause. Control: the same store copies for the hive it does not refuse.
|
||||
#[tokio::test]
|
||||
async fn a_copy_that_cannot_be_written_fails_the_node() {
|
||||
let mut store = Memory::with(&SOURCES);
|
||||
store.refuse = Some("swarm/hives/alpha/");
|
||||
let Outcome::Failed(error) = copy_node(&store, "alpha").await else {
|
||||
panic!("a refused write is not Done")
|
||||
};
|
||||
assert!(error.contains("alpha"), "{error}");
|
||||
assert!(error.contains("denied"), "{error}");
|
||||
assert_eq!(copy_node(&store, "beta").await, Outcome::Done);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_hive_failing_does_not_stop_the_others() {
|
||||
let mut store = Memory::with(&SOURCES);
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@
|
|||
//! refused token is "cannot tell" and rebuilds nothing.
|
||||
//!
|
||||
//! A record from the head with `cached` false (the builder's push failed, or the
|
||||
//! cache or its token was not up yet) is not a hive's to use, and nothing else
|
||||
//! pushes it before `internal/meta` moves. The pass sends its build without
|
||||
//! asking the cache, and the record is rewritten with `cached` true once the
|
||||
//! push lands.
|
||||
//! cache or its token was not up yet) is not a hive's to use. The meta timer
|
||||
//! builds its failed `BuildMeta` again, but only from the in-memory job graph, so
|
||||
//! a record that outlives a restart has no retry. The pass sends its build
|
||||
//! without asking the cache, and the push rewrites the record with `cached` true.
|
||||
//!
|
||||
//! A pass sends at most [`MAX_REBUILDS_PER_PASS`] builds, one after the other.
|
||||
//! An agent whose rebuild failed, or built without pushing, is left alone for
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
//! under one of two `reason` labels:
|
||||
//! - `failed`: `nix` failed, its timeout included.
|
||||
//! - `unavailable`: the build got no verdict (no builder, a forge error, no
|
||||
//! `internal/meta` head, a missing read grant).
|
||||
//! `internal/meta` head, a missing read grant, a closure the builder built but
|
||||
//! did not push to the cache).
|
||||
//!
|
||||
//! The ten-minute timer builds either again. A pending, running or finished
|
||||
//! build does not count, so the value falls to zero when a build succeeds.
|
||||
|
|
|
|||
|
|
@ -121,7 +121,8 @@ pub(crate) enum SwarmNodeKind {
|
|||
BuildAgent { agent: String, rev: String, pr: u64 },
|
||||
/// Build `agent` from `internal/meta`'s head on the swarm builder, and
|
||||
/// record the closure in `agent-closures`. The `TriggerDeploy` behind it
|
||||
/// runs only when the build is green. See `meta_build`.
|
||||
/// runs only when the build is green and the builder pushed the closure to
|
||||
/// the cache. See `meta_build`.
|
||||
///
|
||||
/// Carries no rev: it reads the head when it runs, which in most DAGs is
|
||||
/// after the `UpdateMeta` that commits it.
|
||||
|
|
@ -129,6 +130,12 @@ pub(crate) enum SwarmNodeKind {
|
|||
/// `pr` is the merged config PR the build belongs to, if any: a red `nix`
|
||||
/// build is reported on it once.
|
||||
BuildMeta { agent: String, pr: Option<u64> },
|
||||
/// Copy the swarm's cache facts to `hive`'s secret-store prefix, where the
|
||||
/// hive reads them to pull a closure. The `TriggerDeploy` to the same hive
|
||||
/// runs only when the copy is done. See `attic_copy`.
|
||||
///
|
||||
/// Carries no agent: the facts are the hive's, whatever it is told to deploy.
|
||||
CopyAtticFacts { hive: String },
|
||||
/// Re-mint `agent`'s queue secret if it is still stored and old enough.
|
||||
/// See `agent_renewal`.
|
||||
///
|
||||
|
|
@ -189,6 +196,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
|||
SwarmNodeKind::MintCiMetricsForgeToken => "mint_ci_metrics_forge_token".to_owned(),
|
||||
SwarmNodeKind::BuildAgent { .. } => "build_agent".to_owned(),
|
||||
SwarmNodeKind::BuildMeta { .. } => "build_meta".to_owned(),
|
||||
SwarmNodeKind::CopyAtticFacts { .. } => "copy_attic_facts".to_owned(),
|
||||
SwarmNodeKind::RenewAgentQueueCredential { .. } => {
|
||||
"renew_agent_queue_credential".to_owned()
|
||||
}
|
||||
|
|
@ -227,7 +235,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
|||
serde_json::json!({ "agent": agent, "archetype": archetype.name })
|
||||
}
|
||||
SwarmNodeKind::MintHiveSenderToken { hive }
|
||||
| SwarmNodeKind::MintHiveForgeToken { hive } => serde_json::json!({ "hive": hive }),
|
||||
| SwarmNodeKind::MintHiveForgeToken { hive }
|
||||
| SwarmNodeKind::CopyAtticFacts { hive } => serde_json::json!({ "hive": hive }),
|
||||
SwarmNodeKind::MintClassifierForgeToken
|
||||
| SwarmNodeKind::MintBuilderForgeToken
|
||||
| SwarmNodeKind::MintCiMetricsForgeToken
|
||||
|
|
@ -440,17 +449,14 @@ async fn run_node_work(
|
|||
build_agent(&deps, forge, agent, rev, pr).await
|
||||
}
|
||||
SwarmNodeKind::BuildMeta { agent, pr } => build_meta(&deps, forge, agent, pr).await,
|
||||
SwarmNodeKind::CopyAtticFacts { hive } => crate::attic_copy::copy_node_now(&hive).await,
|
||||
SwarmNodeKind::RenewAgentQueueCredential { agent } => renew_queue_credential(&agent).await,
|
||||
SwarmNodeKind::SetAgentWanted { hive, agent } => {
|
||||
set_agent_wanted(deps.wanted.as_deref(), &hive, &agent).await
|
||||
}
|
||||
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => match deps.queue {
|
||||
None => Outcome::Failed(
|
||||
"no swarm queue is configured on this host, so no hive can be told to deploy"
|
||||
.to_owned(),
|
||||
),
|
||||
Some(client) => publish_deploy(&client, &hive, &agent, rev).await,
|
||||
},
|
||||
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => {
|
||||
trigger_deploy(deps.queue.as_ref(), &hive, &agent, rev).await
|
||||
}
|
||||
SwarmNodeKind::UpdateMeta => finish_update_meta(update_meta(&deps, forge).await, &builder),
|
||||
SwarmNodeKind::RecordFanout { pin } => record_fanout(deps.queue.as_ref(), &pin).await,
|
||||
};
|
||||
|
|
@ -849,11 +855,13 @@ async fn record_fanout(
|
|||
}
|
||||
}
|
||||
|
||||
/// The build and the deploy every path declares for one agent: a `BuildMeta`
|
||||
/// of `agent`, then the `TriggerDeploy`, which runs only when the build is
|
||||
/// green (`after_ok`), so a red build leaves it `Skipped`.
|
||||
/// The build, the facts copy and the deploy every path declares for one agent:
|
||||
/// a `BuildMeta` of `agent` and a `CopyAtticFacts` of `hive`, then the
|
||||
/// `TriggerDeploy`, which runs only when both are done (`after_ok`), so a red or
|
||||
/// unpushed build, or a failed copy, leaves it `Skipped`. The copy is not tied
|
||||
/// to the build and waits on nothing.
|
||||
///
|
||||
/// `update` is the `UpdateMeta` the pair follows when it is not this pair's
|
||||
/// `update` is the `UpdateMeta` these nodes follow when it is not their
|
||||
/// 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.
|
||||
|
|
@ -874,25 +882,29 @@ fn gated_deploy<'a>(
|
|||
if let Some(update) = update {
|
||||
build = build.after_ok(update);
|
||||
}
|
||||
let copy = b.node(SwarmNodeKind::CopyAtticFacts {
|
||||
hive: hive.to_owned(),
|
||||
});
|
||||
let mut deploy = b
|
||||
.node(SwarmNodeKind::TriggerDeploy {
|
||||
hive: hive.to_owned(),
|
||||
agent: agent.to_owned(),
|
||||
rev,
|
||||
})
|
||||
.after_ok(build);
|
||||
.after_ok(build)
|
||||
.after_ok(copy);
|
||||
if let Some(update) = update {
|
||||
deploy = deploy.after_ok(update);
|
||||
}
|
||||
deploy
|
||||
}
|
||||
|
||||
/// 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 failing agent does
|
||||
/// Declare one build, facts copy and deploy 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 or copy
|
||||
/// gate 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.
|
||||
/// build or copy is re-declared by `queue_meta_update` on every round.
|
||||
fn declare_fan_out(
|
||||
grown: &hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
|
||||
fan_out: &FanOut,
|
||||
|
|
@ -1132,6 +1144,23 @@ async fn declare_new_agent(
|
|||
}
|
||||
}
|
||||
|
||||
/// The `TriggerDeploy` arm, lifted out for `clippy::too_many_lines`: fails when
|
||||
/// no swarm queue is configured, else [`publish_deploy`].
|
||||
async fn trigger_deploy(
|
||||
queue: Option<&async_nats::Client>,
|
||||
hive: &str,
|
||||
agent: &str,
|
||||
rev: Option<String>,
|
||||
) -> hive_jobq::scheduler::Outcome {
|
||||
match queue {
|
||||
None => hive_jobq::scheduler::Outcome::Failed(
|
||||
"no swarm queue is configured on this host, so no hive can be told to deploy"
|
||||
.to_owned(),
|
||||
),
|
||||
Some(client) => publish_deploy(client, hive, agent, rev).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish one deploy request and report whether it left this process.
|
||||
///
|
||||
/// The flush is not belt-and-braces: `publish` hands the message to the
|
||||
|
|
@ -1743,13 +1772,16 @@ pub(crate) fn spawn_meta_update_timer(
|
|||
});
|
||||
}
|
||||
|
||||
/// Why a deploy is `Skipped` and the timer should declare it again.
|
||||
/// The node whose failure left a deploy `Skipped`, which the timer declares
|
||||
/// again.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SkipCause {
|
||||
/// It waited on an `UpdateMeta` that `Failed`.
|
||||
FailedUpdate,
|
||||
/// It waited on a `BuildMeta` that `Failed`, whatever the reason.
|
||||
FailedBuild,
|
||||
/// The `UpdateMeta` it waited on.
|
||||
Update,
|
||||
/// The `BuildMeta` it waited on, whatever the reason.
|
||||
Build,
|
||||
/// The `CopyAtticFacts` it waited on.
|
||||
Copy,
|
||||
}
|
||||
|
||||
/// A deploy [`skipped_deploys`] found.
|
||||
|
|
@ -1764,12 +1796,12 @@ struct SkippedDeploy {
|
|||
}
|
||||
|
||||
/// The deploys the timer declares again: for each agent, its latest
|
||||
/// `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.
|
||||
/// `TriggerDeploy` when the scheduler skipped it because an `UpdateMeta`, a
|
||||
/// `BuildMeta` or a `CopyAtticFacts` it waited on `Failed`. A failed build or
|
||||
/// copy is declared 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> {
|
||||
|
|
@ -1801,9 +1833,11 @@ fn skipped_deploys(
|
|||
dep_nodes().any(|dep| is(&dep.payload) && dep.state == hive_jobq::State::Failed)
|
||||
};
|
||||
let cause = if failed(|p| matches!(p, SwarmNodeKind::UpdateMeta)) {
|
||||
SkipCause::FailedUpdate
|
||||
SkipCause::Update
|
||||
} else if failed(|p| matches!(p, SwarmNodeKind::BuildMeta { .. })) {
|
||||
SkipCause::FailedBuild
|
||||
SkipCause::Build
|
||||
} else if failed(|p| matches!(p, SwarmNodeKind::CopyAtticFacts { .. })) {
|
||||
SkipCause::Copy
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
|
@ -1829,9 +1863,9 @@ fn skipped_deploys(
|
|||
/// update was failing is deployed by the run that succeeds.
|
||||
///
|
||||
/// 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.
|
||||
/// or copy still gets a fresh build, copy and deploy, with no update, so one
|
||||
/// that failed is tried again on every round until it succeeds. That adds three
|
||||
/// 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> {
|
||||
|
|
@ -1857,7 +1891,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::FailedBuild)
|
||||
.filter(|s| update_due || s.cause != SkipCause::Update)
|
||||
.collect();
|
||||
if !update_due && skipped.is_empty() {
|
||||
return Ok(false);
|
||||
|
|
@ -2656,7 +2690,7 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Every `TriggerDeploy` in `graph` waits, strictly, on a `BuildMeta` of the
|
||||
/// same agent that needs the builder. Panics, naming `path`, when one does
|
||||
/// same agent that needs the builder, and on a `CopyAtticFacts` of its hive. Panics, naming `path`, when one does
|
||||
/// not; returns how many deploys it checked.
|
||||
fn assert_every_deploy_is_gated(
|
||||
graph: &hive_jobq::Graph<SwarmNodeKind, SwarmResourceKind>,
|
||||
|
|
@ -2664,7 +2698,7 @@ mod tests {
|
|||
) -> usize {
|
||||
let mut checked = 0;
|
||||
for deploy in graph.nodes() {
|
||||
let SwarmNodeKind::TriggerDeploy { agent, .. } = &deploy.payload else {
|
||||
let SwarmNodeKind::TriggerDeploy { hive, agent, .. } = &deploy.payload else {
|
||||
continue;
|
||||
};
|
||||
let gate = deploy.deps.iter().find_map(|d| {
|
||||
|
|
@ -2694,15 +2728,32 @@ mod tests {
|
|||
)),
|
||||
"{path}: {agent}'s build does not need the builder"
|
||||
);
|
||||
let copy = deploy.deps.iter().find_map(|d| {
|
||||
match d {
|
||||
hive_jobq::Dep::Node { id, when } => graph
|
||||
.node(*id)
|
||||
.filter(|n| {
|
||||
matches!(&n.payload, SwarmNodeKind::CopyAtticFacts { hive: h } if h == hive)
|
||||
})
|
||||
.map(|_| *when),
|
||||
hive_jobq::Dep::Resource { .. } => None,
|
||||
}
|
||||
});
|
||||
let when = copy
|
||||
.unwrap_or_else(|| panic!("{path}: {agent}'s deploy has no facts copy for {hive}"));
|
||||
assert!(
|
||||
!when.accepts(hive_jobq::TerminalState::Failed),
|
||||
"{path}: {agent}'s deploy runs after a failed facts copy"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
checked
|
||||
}
|
||||
|
||||
/// The four paths that send a deploy all declare the same build and deploy
|
||||
/// pair, so a path that forgets the gate fails here.
|
||||
/// The four paths that send a deploy all declare the same build, copy and
|
||||
/// deploy, so a path that forgets a gate fails here.
|
||||
#[tokio::test]
|
||||
async fn all_four_deploy_paths_declare_the_gated_pair() {
|
||||
async fn all_four_deploy_paths_declare_the_gated_deploy() {
|
||||
let graph_of = |declare: fn(&TestBuilder)| {
|
||||
let mut sched = hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
|
|
@ -2908,20 +2959,28 @@ mod tests {
|
|||
placed,
|
||||
failing_agent,
|
||||
crash_before_record,
|
||||
None,
|
||||
Failing::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`pass`], with the build of one agent failing with the given error when
|
||||
/// `failing_build` names it.
|
||||
/// What [`pass_with`] makes fail besides the deploy and the record.
|
||||
#[derive(Clone, Default)]
|
||||
struct Failing {
|
||||
/// The agent whose build fails, and with what.
|
||||
build: Option<(&'static str, String)>,
|
||||
/// The hive whose facts copy fails.
|
||||
copy: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// [`pass`], with what `failing` names failing.
|
||||
async fn pass_with(
|
||||
pins: &Arc<FakePins>,
|
||||
report: &meta_flake::Report,
|
||||
placed: &[(&str, &str)],
|
||||
failing_agent: Option<&'static str>,
|
||||
crash_before_record: bool,
|
||||
failing_build: Option<(&'static str, String)>,
|
||||
failing: Failing,
|
||||
) -> (FanOut, Arc<Mutex<TestScheduler>>) {
|
||||
use hive_jobq::scheduler::{Outcome, Scheduler};
|
||||
use meta_fanout::PinStore as _;
|
||||
|
|
@ -2947,8 +3006,7 @@ mod tests {
|
|||
.expect("the node just inserted is runnable");
|
||||
update.await.1.expect("the grown nodes insert");
|
||||
while let Some(runner) = Scheduler::claim_next(&sched, {
|
||||
let (pins, sched, failing_build) =
|
||||
(Arc::clone(pins), Arc::clone(&sched), failing_build.clone());
|
||||
let (pins, sched, failing) = (Arc::clone(pins), Arc::clone(&sched), failing.clone());
|
||||
move |_id, kind, builder| async move {
|
||||
let outcome = match kind {
|
||||
SwarmNodeKind::TriggerDeploy { agent, .. }
|
||||
|
|
@ -2957,9 +3015,14 @@ mod tests {
|
|||
Outcome::Failed("the hive did not take it".to_owned())
|
||||
}
|
||||
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"))
|
||||
}
|
||||
SwarmNodeKind::CopyAtticFacts { hive }
|
||||
if failing.copy == Some(hive.as_str()) =>
|
||||
{
|
||||
Outcome::Failed("the store refused the write".to_owned())
|
||||
}
|
||||
SwarmNodeKind::RecordFanout { .. } if crash_before_record => {
|
||||
Outcome::Failed("the controller died".to_owned())
|
||||
|
|
@ -3021,6 +3084,11 @@ mod tests {
|
|||
states_of(&sched, "trigger_deploy"),
|
||||
[hive_jobq::State::Done; 3]
|
||||
);
|
||||
assert_eq!(
|
||||
states_of(&sched, "copy_attic_facts"),
|
||||
[hive_jobq::State::Done; 3],
|
||||
"a copy is declared and run for every deploy"
|
||||
);
|
||||
assert_eq!(states_of(&sched, "record_fanout"), [hive_jobq::State::Done]);
|
||||
assert_eq!(pins.held().as_deref(), Some("p1"));
|
||||
}
|
||||
|
|
@ -3164,6 +3232,10 @@ mod tests {
|
|||
for error in [
|
||||
"error: builder for argus failed".to_owned(),
|
||||
"timed out: nix did not finish".to_owned(),
|
||||
format!(
|
||||
"{}built /nix/store/x-toplevel, but the builder did not push it to the cache",
|
||||
meta_build::NO_VERDICT_PREFIX
|
||||
),
|
||||
format!(
|
||||
"{}no swarm builder is listening",
|
||||
meta_build::NO_VERDICT_PREFIX
|
||||
|
|
@ -3176,7 +3248,10 @@ mod tests {
|
|||
&PLACED,
|
||||
None,
|
||||
false,
|
||||
Some(("argus", error.clone())),
|
||||
Failing {
|
||||
build: Some(("argus", error.clone())),
|
||||
..Failing::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut builds = states_of(&sched, "build_meta");
|
||||
|
|
@ -3214,6 +3289,68 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A failed facts copy to one hive skips the deploys to that hive only, and
|
||||
/// the pin is still recorded. The timer declares the copy, a build and the
|
||||
/// deploy again for the skipped deploy.
|
||||
#[tokio::test]
|
||||
async fn a_failed_copy_skips_its_deploy_and_the_timer_declares_it_again() {
|
||||
use hive_jobq::State;
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
||||
let pins = Arc::new(FakePins::holding("p0"));
|
||||
let (_, sched) = pass_with(
|
||||
&pins,
|
||||
&report("p1", true),
|
||||
&PLACED,
|
||||
None,
|
||||
false,
|
||||
Failing {
|
||||
copy: Some("sec0nd"),
|
||||
..Failing::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut copies = states_of(&sched, "copy_attic_facts");
|
||||
copies.sort_by_key(|s| format!("{s:?}"));
|
||||
assert_eq!(copies, [State::Done, State::Done, State::Failed]);
|
||||
let deploys: Vec<_> = {
|
||||
let guard = sched.lock().unwrap();
|
||||
guard
|
||||
.graph()
|
||||
.nodes()
|
||||
.filter_map(|n| match &n.payload {
|
||||
SwarmNodeKind::TriggerDeploy { agent, .. } => Some((agent.clone(), n.state)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let state_of = |who: &str| deploys.iter().find(|(a, _)| a == who).map(|(_, s)| *s);
|
||||
assert_eq!(state_of("iris"), Some(State::Skipped));
|
||||
assert_eq!(state_of("atlas"), Some(State::Done));
|
||||
assert_eq!(state_of("argus"), Some(State::Done));
|
||||
assert_eq!(states_of(&sched, "record_fanout"), [State::Done]);
|
||||
assert_eq!(pins.held().as_deref(), Some("p1"));
|
||||
|
||||
assert!(queue_meta_update(&sched).expect("tick"));
|
||||
let guard = sched.lock().unwrap();
|
||||
let pending = |label: &str| {
|
||||
guard
|
||||
.graph()
|
||||
.nodes()
|
||||
.filter(|n| n.payload.label() == label && n.state == State::Pending)
|
||||
.map(|n| match &n.payload {
|
||||
SwarmNodeKind::BuildMeta { agent, .. }
|
||||
| SwarmNodeKind::TriggerDeploy { agent, .. } => agent.clone(),
|
||||
SwarmNodeKind::CopyAtticFacts { hive } => hive.clone(),
|
||||
other => panic!("not a build, copy or deploy: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(pending("copy_attic_facts"), ["sec0nd"]);
|
||||
assert_eq!(pending("build_meta"), ["iris"]);
|
||||
assert_eq!(pending("trigger_deploy"), ["iris"]);
|
||||
}
|
||||
|
||||
/// With nothing placed there is nothing to wait on, and the pin is
|
||||
/// recorded all the same.
|
||||
#[tokio::test]
|
||||
|
|
@ -4297,6 +4434,65 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Run every runnable node; the facts copy to `hive` fails and everything
|
||||
/// else succeeds.
|
||||
async fn drain_failing_copy(sched: &SharedSched, hive: &'static str) {
|
||||
use hive_jobq::scheduler::{Outcome, Scheduler};
|
||||
|
||||
while let Some(runner) =
|
||||
Scheduler::claim_next(sched, move |_id, kind, builder| async move {
|
||||
let outcome = match kind {
|
||||
SwarmNodeKind::CopyAtticFacts { hive: h } if h == hive => {
|
||||
Outcome::Failed("the store refused the write".to_owned())
|
||||
}
|
||||
_ => Outcome::Done,
|
||||
};
|
||||
(builder, outcome)
|
||||
})
|
||||
{
|
||||
runner.await.1.expect("no growth declared");
|
||||
}
|
||||
}
|
||||
|
||||
/// A merged config whose facts copy fails, with the update `Done`: the
|
||||
/// deploy is skipped, and the timer declares a copy, a build and the same
|
||||
/// deploy again on every round, without an update, until a copy succeeds.
|
||||
/// Then it stops.
|
||||
#[tokio::test]
|
||||
async fn a_failed_copy_is_declared_again_every_round_until_it_succeeds() {
|
||||
use hive_jobq::State;
|
||||
|
||||
let sched = merged_atlas();
|
||||
drain_failing_copy(&sched, "pr1ma").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_copy(&sched, "pr1ma").await;
|
||||
assert_eq!(
|
||||
deploys_of(&sched, "atlas"),
|
||||
[State::Skipped, State::Skipped]
|
||||
);
|
||||
|
||||
// A success on a later round deploys, once, with the merged rev.
|
||||
assert!(queue_meta_update(&sched).expect("tick"));
|
||||
drain(&sched, false, false).await;
|
||||
assert_eq!(
|
||||
deploys_of(&sched, "atlas"),
|
||||
[State::Skipped, State::Skipped, State::Done]
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a merged config PR's build carries a PR: the creation DAG's does
|
||||
/// not, so its failure has nobody to comment on.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! The node ends one of two ways. `Done` is a green build. Every `Failed` skips
|
||||
//! the deploy behind it, and the Jobs page shows why:
|
||||
//! The node ends one of two ways. `Done` is a green build whose closure the
|
||||
//! builder pushed to the cache. Every `Failed` skips the deploy behind it, and
|
||||
//! the Jobs page shows why:
|
||||
//! - `nix` failed, its timeout included: `Failed` with `nix`'s tail, or `nix
|
||||
//! timed out`. The deploy stays held and counts as a failed build.
|
||||
//! - The build got no verdict (no builder, a forge error, no `internal/meta`
|
||||
//! head, a missing read grant, a failed closure write): `Failed` starting with
|
||||
//! [`NO_VERDICT_PREFIX`], which the held-deploys gauge reads to label the
|
||||
//! reason.
|
||||
//! head, a missing read grant, a failed closure write, a closure the builder
|
||||
//! built but did not push): `Failed` starting with [`NO_VERDICT_PREFIX`],
|
||||
//! which the held-deploys gauge reads to label the reason. A built but
|
||||
//! unpushed closure is still recorded, with `cached` false.
|
||||
//!
|
||||
//! Both are built again on every round of the meta timer
|
||||
//! (`jobq::queue_meta_update`) until a build succeeds.
|
||||
|
|
@ -384,6 +386,9 @@ pub(crate) async fn run(
|
|||
nix_builds,
|
||||
};
|
||||
match queue.record(&agent, &closure).await {
|
||||
Ok(()) if !cached => no_verdict(format!(
|
||||
"built {out_path}, but the builder did not push it to the cache"
|
||||
)),
|
||||
Ok(()) => {
|
||||
if let Some(pr) = pr {
|
||||
clear_failure(head, pr, &agent, &built_rev).await;
|
||||
|
|
@ -488,7 +493,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn built() -> Self {
|
||||
Self::built_cached(false)
|
||||
Self::built_cached(true)
|
||||
}
|
||||
|
||||
fn built_cached(cached: bool) -> Self {
|
||||
|
|
@ -615,16 +620,25 @@ mod tests {
|
|||
assert_eq!(closure.store_path, "/nix/store/x-toplevel");
|
||||
assert_eq!(closure.system, "x86_64-linux");
|
||||
assert!(closure.built_unix > 0);
|
||||
assert!(!closure.cached);
|
||||
assert!(closure.cached);
|
||||
}
|
||||
|
||||
/// The record says what the builder said about the cache, both ways.
|
||||
/// The record says what the builder said about the cache, both ways. Only a
|
||||
/// pushed closure ends the node `Done`: the deploy waits on that.
|
||||
#[tokio::test]
|
||||
async fn the_record_carries_whether_the_builder_cached_the_closure() {
|
||||
for cached in [true, false] {
|
||||
let queue = FakeQueue::built_cached(cached);
|
||||
let outcome = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await;
|
||||
assert_eq!(outcome, Outcome::Done);
|
||||
if cached {
|
||||
assert_eq!(outcome, Outcome::Done);
|
||||
} else {
|
||||
let Outcome::Failed(error) = outcome else {
|
||||
panic!("an uncached build is not Done: {outcome:?}")
|
||||
};
|
||||
assert!(is_no_verdict(&error), "{error}");
|
||||
assert!(error.contains("did not push"), "{error}");
|
||||
}
|
||||
let recorded = queue.recorded.lock().unwrap();
|
||||
let [(_, closure)] = recorded.as_slice() else {
|
||||
panic!("one record: {recorded:?}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue