swarm-builder: refuse a build that waited too long for the slot
A request that waits behind another build, e.g. one a controller restart orphaned, could start a full build after most of the controller's 61-minute reply deadline was gone. The builder now waits at most REPLY_DEADLINE - BUILD_DEADLINE - 30 s for the slot and answers Failed, without building, past that. The 30 s covers the forge token read, now bounded to 20 s, and the reply's trip back. Fixes #5437
This commit is contained in:
parent
6630a51022
commit
bf848ba65f
4 changed files with 180 additions and 22 deletions
|
|
@ -20,7 +20,11 @@ Where it runs and how it's enabled: [`services.md`](services.md#builder).
|
||||||
finishes and writes its status on its own, older commit.
|
finishes and writes its status on its own, older commit.
|
||||||
2. The node sets the status to `pending`, then sends the build as a request on
|
2. The node sets the status to `pending`, then sends the build as a request on
|
||||||
the queue subject `$SWARM.builder.build` and waits up to 61 minutes for the
|
the queue subject `$SWARM.builder.build` and waits up to 61 minutes for the
|
||||||
reply.
|
reply. The builder runs one build at a time and refuses a request that has
|
||||||
|
waited 30 seconds for its turn, such as one behind a build a controller
|
||||||
|
restart orphaned: a build started later could still run when the 61
|
||||||
|
minutes end. Reading the forge token before a build has 20 seconds, and
|
||||||
|
the rest of the minute covers the reply's trip back.
|
||||||
3. The builder runs `nix build` of `nix/builder/check-flake`'s `check` output
|
3. The builder runs `nix build` of `nix/builder/check-flake`'s `check` output
|
||||||
with every input overridden:
|
with every input overridden:
|
||||||
- `hyperhive` and `nixpkgs`: the sources the builder's own host was
|
- `hyperhive` and `nixpkgs`: the sources the builder's own host was
|
||||||
|
|
@ -39,6 +43,7 @@ Where it runs and how it's enabled: [`services.md`](services.md#builder).
|
||||||
| ----------------------------------------- | --------- | --------------------------- | ------ |
|
| ----------------------------------------- | --------- | --------------------------- | ------ |
|
||||||
| built | `success` | the store path | done |
|
| built | `success` | the store path | done |
|
||||||
| `nix` failed or timed out | `failure` | the last line `nix` printed | failed |
|
| `nix` failed or timed out | `failure` | the last line `nix` printed | failed |
|
||||||
|
| the builder busy for 30 seconds | `failure` | why | failed |
|
||||||
| no builder listening, or no reply in time | `error` | why | failed |
|
| no builder listening, or no reply in time | `error` | why | failed |
|
||||||
|
|
||||||
The full tail is in the failed node's error on the Jobs page and in the
|
The full tail is in the failed node's error on the Jobs page and in the
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,10 @@ swarm-secret-client.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
||||||
|
# `test-util` for `#[tokio::test(start_paused = true)]`: the slot tests wait
|
||||||
|
# out the build slot's wait limit on paused time.
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,16 @@
|
||||||
//! secret is read at start and again when authelia refuses it, the forge token
|
//! secret is read at start and again when authelia refuses it, the forge token
|
||||||
//! once per build, both from the store under that identity.
|
//! once per build, both from the store under that identity.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use futures_util::StreamExt as _;
|
use futures_util::StreamExt as _;
|
||||||
use swarm_queue_client::builder::{
|
use swarm_queue_client::builder::{
|
||||||
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, BUILD_DEADLINE, BuildAgent, QUEUE_GROUP, Reply,
|
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, BUILD_DEADLINE, BuildAgent, QUEUE_GROUP,
|
||||||
SUBJECT,
|
REPLY_DEADLINE, Reply, SUBJECT,
|
||||||
};
|
};
|
||||||
use swarm_queue_client::stored_secret::StoredSecret;
|
use swarm_queue_client::stored_secret::StoredSecret;
|
||||||
use swarm_queue_client::{ClientSecret, QueueConfig};
|
use swarm_queue_client::{ClientSecret, QueueConfig};
|
||||||
|
|
@ -35,6 +37,23 @@ const CERT_ROLE: &str = "swarm-builder";
|
||||||
/// The environment prefix `swarm-builder.nix` sets.
|
/// The environment prefix `swarm-builder.nix` sets.
|
||||||
const PREFIX: &str = "SWARM_BUILDER";
|
const PREFIX: &str = "SWARM_BUILDER";
|
||||||
|
|
||||||
|
/// The longest the store login and forge token read may take before a build.
|
||||||
|
/// Each store request alone may take 30 s, so without this bound the two could
|
||||||
|
/// spend the minute `REPLY_DEADLINE` leaves after `BUILD_DEADLINE`.
|
||||||
|
const TOKEN_DEADLINE: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// What is held back from the minute `REPLY_DEADLINE` leaves after
|
||||||
|
/// `BUILD_DEADLINE`: [`TOKEN_DEADLINE`], then 10 s to publish the reply and for
|
||||||
|
/// it to reach the controller. Both come after the slot wait.
|
||||||
|
const SLOT_MARGIN: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// The longest a request may wait for the build slot. A build that gets the
|
||||||
|
/// slot later could still be running when the controller stops waiting for the
|
||||||
|
/// reply.
|
||||||
|
const MAX_SLOT_WAIT: Duration = REPLY_DEADLINE
|
||||||
|
.saturating_sub(BUILD_DEADLINE)
|
||||||
|
.saturating_sub(SLOT_MARGIN);
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
hive_log::init();
|
hive_log::init();
|
||||||
|
|
@ -93,10 +112,12 @@ async fn main() -> Result<()> {
|
||||||
tokio::spawn(answer_evaluations(evaluations, queue.clone(), evaluator));
|
tokio::spawn(answer_evaluations(evaluations, queue.clone(), evaluator));
|
||||||
|
|
||||||
// One build at a time. swarm-controller already sends one at a time; this
|
// One build at a time. swarm-controller already sends one at a time; this
|
||||||
// holds the bound for any request it did not serialise.
|
// holds the bound for any request it did not serialise, and `in_slot`
|
||||||
|
// refuses one that waited too long behind another.
|
||||||
let slot = Arc::new(tokio::sync::Semaphore::new(1));
|
let slot = Arc::new(tokio::sync::Semaphore::new(1));
|
||||||
|
|
||||||
while let Some(msg) = requests.next().await {
|
while let Some(msg) = requests.next().await {
|
||||||
|
let received = tokio::time::Instant::now();
|
||||||
let Some(reply_to) = msg.reply.clone() else {
|
let Some(reply_to) = msg.reply.clone() else {
|
||||||
tracing::warn!("a build arrived with no reply subject; dropping it");
|
tracing::warn!("a build arrived with no reply subject; dropping it");
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -105,10 +126,21 @@ async fn main() -> Result<()> {
|
||||||
let slot = Arc::clone(&slot);
|
let slot = Arc::clone(&slot);
|
||||||
let builder = Arc::clone(&builder);
|
let builder = Arc::clone(&builder);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let Ok(_permit) = slot.acquire().await else {
|
let reply = match serde_json::from_slice::<BuildAgent>(&msg.payload) {
|
||||||
return;
|
Ok(job) => {
|
||||||
|
let Some(reply) = in_slot(&slot, received, &job, handle(&job, &builder)).await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "an undecodable build; answering with the error");
|
||||||
|
Reply::Failed {
|
||||||
|
error_tail: format!("the builder could not decode the request: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let reply = handle(&msg.payload, &builder).await;
|
|
||||||
match serde_json::to_vec(&reply) {
|
match serde_json::to_vec(&reply) {
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
||||||
|
|
@ -155,27 +187,60 @@ async fn answer_evaluations(
|
||||||
tracing::error!("the subscription to {ARCHETYPES_SUBJECT} ended");
|
tracing::error!("the subscription to {ARCHETYPES_SUBJECT} ended");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode one request, build it and log the build.
|
/// `build`'s reply once `job` holds the build slot, or a refusal, without
|
||||||
async fn handle(payload: &[u8], builder: &nix_build::Builder) -> Reply {
|
/// running `build`, once it has waited [`MAX_SLOT_WAIT`] since `received`.
|
||||||
let job: BuildAgent = match serde_json::from_slice(payload) {
|
/// `None` only when the slot is closed.
|
||||||
Ok(job) => job,
|
async fn in_slot(
|
||||||
Err(e) => {
|
slot: &tokio::sync::Semaphore,
|
||||||
tracing::warn!(error = %e, "an undecodable build; answering with the error");
|
received: tokio::time::Instant,
|
||||||
return Reply::Failed {
|
job: &BuildAgent,
|
||||||
error_tail: format!("the builder could not decode the request: {e}"),
|
build: impl Future<Output = Reply>,
|
||||||
};
|
) -> Option<Reply> {
|
||||||
}
|
let Ok(permit) = tokio::time::timeout_at(received + MAX_SLOT_WAIT, slot.acquire()).await else {
|
||||||
|
tracing::warn!(
|
||||||
|
agent = %job.agent,
|
||||||
|
rev = %job.rev,
|
||||||
|
pr = job.pr,
|
||||||
|
waited_secs = MAX_SLOT_WAIT.as_secs(),
|
||||||
|
"build refused: another build held the slot too long for this one to finish \
|
||||||
|
before swarm-controller stops waiting"
|
||||||
|
);
|
||||||
|
return Some(Reply::Failed {
|
||||||
|
error_tail: format!(
|
||||||
|
"the swarm builder was busy with another build for {} s, too long for this one \
|
||||||
|
to finish before swarm-controller stops waiting for it",
|
||||||
|
MAX_SLOT_WAIT.as_secs()
|
||||||
|
),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
let reply = match forge_token().await {
|
let _permit = permit.ok()?;
|
||||||
Ok(token) => builder.build(&job, &token).await,
|
Some(build.await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one request and log the build.
|
||||||
|
async fn handle(job: &BuildAgent, builder: &nix_build::Builder) -> Reply {
|
||||||
|
let reply = match within(TOKEN_DEADLINE, forge_token()).await {
|
||||||
|
Ok(token) => builder.build(job, &token).await,
|
||||||
Err(e) => Reply::Failed {
|
Err(e) => Reply::Failed {
|
||||||
error_tail: format!("{e:#}"),
|
error_tail: format!("{e:#}"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
log_build(&job, &reply);
|
log_build(job, &reply);
|
||||||
reply
|
reply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `read`'s result, or an error once it has run for `deadline`.
|
||||||
|
async fn within<T>(deadline: Duration, read: impl Future<Output = Result<T>>) -> Result<T> {
|
||||||
|
tokio::time::timeout(deadline, read)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
Err(anyhow!(
|
||||||
|
"reading the forge token took more than {} s",
|
||||||
|
deadline.as_secs()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The one journal line a build leaves.
|
/// The one journal line a build leaves.
|
||||||
fn log_build(job: &BuildAgent, reply: &Reply) {
|
fn log_build(job: &BuildAgent, reply: &Reply) {
|
||||||
match reply {
|
match reply {
|
||||||
|
|
@ -244,3 +309,84 @@ fn forge_url() -> Result<String> {
|
||||||
}
|
}
|
||||||
Ok(url.to_owned())
|
Ok(url.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn job() -> BuildAgent {
|
||||||
|
BuildAgent {
|
||||||
|
agent: "atlas".to_owned(),
|
||||||
|
rev: "abc".to_owned(),
|
||||||
|
pr: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request behind a build that holds the slot for `held`: its reply,
|
||||||
|
/// whether its own build ran, and how long after arriving it was answered.
|
||||||
|
async fn behind_a_build_of(held: Duration) -> (Option<Reply>, bool, Duration) {
|
||||||
|
let slot = Arc::new(Semaphore::new(1));
|
||||||
|
let running = Arc::clone(&slot).acquire_owned().await.expect("open");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(held).await;
|
||||||
|
drop(running);
|
||||||
|
});
|
||||||
|
let received = Instant::now();
|
||||||
|
let ran = AtomicBool::new(false);
|
||||||
|
let build = async {
|
||||||
|
ran.store(true, Ordering::SeqCst);
|
||||||
|
Reply::Built {
|
||||||
|
out_path: "/nix/store/x".to_owned(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let reply = in_slot(&slot, received, &job(), build).await;
|
||||||
|
(reply, ran.load(Ordering::SeqCst), received.elapsed())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_request_that_waited_past_the_slot_limit_is_refused_without_building() {
|
||||||
|
let (reply, built, answered_after) =
|
||||||
|
behind_a_build_of(MAX_SLOT_WAIT + Duration::from_mins(1)).await;
|
||||||
|
assert!(
|
||||||
|
matches!(&reply, Some(Reply::Failed { error_tail }) if error_tail.contains("busy")),
|
||||||
|
"{reply:?}"
|
||||||
|
);
|
||||||
|
assert!(!built, "a refused request must not build");
|
||||||
|
assert_eq!(answered_after, MAX_SLOT_WAIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_request_that_gets_the_slot_in_time_builds() {
|
||||||
|
let (reply, built, _) =
|
||||||
|
behind_a_build_of(MAX_SLOT_WAIT.saturating_sub(Duration::from_secs(1))).await;
|
||||||
|
assert!(matches!(reply, Some(Reply::Built { .. })), "{reply:?}");
|
||||||
|
assert!(built);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_token_read_past_its_deadline_is_an_error_and_a_prompt_one_is_kept() {
|
||||||
|
let started = Instant::now();
|
||||||
|
let stuck = within(TOKEN_DEADLINE, std::future::pending::<Result<String>>()).await;
|
||||||
|
assert_eq!(started.elapsed(), TOKEN_DEADLINE);
|
||||||
|
assert!(
|
||||||
|
matches!(&stuck, Err(e) if e.to_string().contains("took more than 20 s")),
|
||||||
|
"{stuck:?}"
|
||||||
|
);
|
||||||
|
let prompt = within(TOKEN_DEADLINE, async { Ok("t".to_owned()) }).await;
|
||||||
|
assert_eq!(prompt.expect("read"), "t");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_build_started_at_the_slot_limit_ends_inside_the_reply_deadline() {
|
||||||
|
assert!(MAX_SLOT_WAIT > Duration::ZERO);
|
||||||
|
assert!(MAX_SLOT_WAIT + BUILD_DEADLINE < REPLY_DEADLINE);
|
||||||
|
assert!(SLOT_MARGIN >= Duration::from_secs(30));
|
||||||
|
assert!(SLOT_MARGIN >= TOKEN_DEADLINE + Duration::from_secs(10));
|
||||||
|
assert!(MAX_SLOT_WAIT + SLOT_MARGIN + BUILD_DEADLINE <= REPLY_DEADLINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ pub const QUEUE_GROUP: &str = "builder";
|
||||||
pub const BUILD_DEADLINE: Duration = Duration::from_hours(1);
|
pub const BUILD_DEADLINE: Duration = Duration::from_hours(1);
|
||||||
|
|
||||||
/// How long the controller waits for the reply, and the queue lets the
|
/// How long the controller waits for the reply, and the queue lets the
|
||||||
/// builder answer: the build at its deadline, plus a minute for the rest.
|
/// builder answer: the build at its deadline, plus a minute the builder spends
|
||||||
|
/// on a request's wait for the build slot, the forge token read before the
|
||||||
|
/// build and the reply's trip back.
|
||||||
pub const REPLY_DEADLINE: Duration = Duration::from_mins(61);
|
pub const REPLY_DEADLINE: Duration = Duration::from_mins(61);
|
||||||
|
|
||||||
/// One build: an agent's config repo at one commit, on the builder's own
|
/// One build: an agent's config repo at one commit, on the builder's own
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue