Watch
0
0
Fork
You've already forked hyperhive
0

rebuild an agent's closure when the binary cache lost it

This commit is contained in:
atlas 2026-10-28 13:19:36 +01:00 • committed by mara
commit cbe41bea54
7 changed files with 1049 additions and 6 deletions

View file

@ -297,6 +297,42 @@ Mixed versions:
A record written before the field existed also reads as `false`.
### Rebuild on a cache miss
The cache drops a closure nobody fetched for its retention period, but the
record in `agent-closures` stays. Every ten minutes `swarm-controller` checks
each agent a hive places: when the record is from `internal/meta`'s head and
says the builder pushed it, the controller fetches the narinfo from
`<url>/<cache>/<hash>.narinfo` with the pull token it reads from
`secret/swarm/services/attic/` (the same four leaves it
[copies to the hives](#what-hives-do-with-the-cache); with any missing, or a URL
that isn't `https`, the pass does nothing). A 404 is a miss, and the controller
sends the builder `build-meta` for that agent and the record's own commit. The
builder builds it and pushes as for any meta build. Any other answer or a failed
request isn't a miss, and rebuilds nothing. The next ordinary build handles a record
from an older commit, or one with `cached` false.
A pass sends at most three builds, one after the other. After an agent's rebuild
failed, got no answer or built without pushing, the pass skips it for 30 minutes, then
for twice as long after each further failure, up to 12 hours. The wait is in memory,
so a controller restart tries every agent again. The first pass runs ten
minutes after the controller starts.
The record keeps naming what hives deploy. A rebuild that lands on the record's
path writes nothing. A rebuild that lands on another path means the build isn't
reproducible: the controller logs a warning, and replaces the record only if it
still holds the same commit and no write came since the controller read it. The
record otherwise stays as it was.
The controller counts each rebuild in `hyperhive.builder.cache_miss_rebuilds` with
the label `outcome`: `rebuilt`, `path_changed` (the record now names the new path),
`path_changed_kept` (the record stayed), `not_pushed`, `failed` (`nix` failed) or
`unavailable` (the builder was busy, absent or gave no answer).
Mixed versions: a builder needs no change, since a meta build already names
its commit. An old controller does nothing about a miss, as before. A hive
sees nothing new.
### The swarm's own cache
`services.hyperhive.deploy.attic.enable` runs an attic server in a `swarm-attic`
@ -333,8 +369,8 @@ host pushes to it.
Storage is local disk in the container's state (`/var/lib/swarm-attic`). A store
path nobody added or fetched for 14 days goes at the next garbage collection,
which runs every 12 hours (`deploy.attic.retentionPeriod`). The next build of
that agent pushes a closure the cache lost.
which runs every 12 hours (`deploy.attic.retentionPeriod`). The controller
[rebuilds a closure the cache lost](#rebuild-on-a-cache-miss).
The gateway terminates TLS with a swarm services certificate and forwards to
`127.0.0.1:8910` (`deploy.attic.port`) with no login in front: attic checks its

View file

@ -49,7 +49,7 @@ the rest of the cell says how.
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every review |
| `swarm/services/builder/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-builder`, `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every build |
| `swarm/services/attic/push-token` | `swarm-bao-attic-tokens` on the cache's host, when none is stored; with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the token on stdin | `swarm-builder` | ❌ write a new token; a token the cache refuses fails the push, and the build stays green with `cached` false | ✅ read on every push; with both `swarm.attic` options null, nothing reads it |
| `swarm/services/attic/pull-token` | `swarm-bao-attic-tokens` on the cache's host, when none is stored; read-only, ten years; with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin | `swarm-controller`, which copies it to each hive | ❌ write a new token | ✅ copied to the hives every 5 minutes |
| `swarm/services/attic/pull-token` | `swarm-bao-attic-tokens` on the cache's host, when none is stored; read-only, ten years; with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin | `swarm-controller`, to copy it and to check narinfos | ❌ write a new token | ✅ copied to the hives every 5 minutes |
| `swarm/services/attic/url` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (`https://<swarm.attic.domain>`); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin (scheme included, no trailing slash) | `swarm-controller`, which copies it to each hive | ❌ delete it and restart `swarm-bao-attic-tokens`, or write the new value | ✅ copied to the hives every 5 minutes |
| `swarm/services/attic/cache` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (`swarm.attic.cache`); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin | `swarm-controller`, which copies it to each hive | ❌ as the URL | ✅ copied to the hives every 5 minutes |
| `swarm/services/attic/public-key` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (the key `attic cache info` prints); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin (`<name>:<base64>`) | `swarm-controller`, which copies it to each hive | ❌ as the URL. A cache recreated with a new key leaves the stored one stale: every hive then reports `bad-signature` | ✅ copied to the hives every 5 minutes |

View file

@ -166,7 +166,8 @@ in
description = ''
How long a store path stays in the cache after it was added or last
fetched. A garbage collection every twelve hours removes what is older;
a build the cache no longer holds is rebuilt on the next miss.
swarm-controller rebuilds a closure the cache no longer holds and
the builder pushes it again.
'';
};
};

View file

@ -0,0 +1,997 @@
//! Rebuild an agent's closure when the binary cache no longer holds it.
//!
//! The cache drops a path after its retention period, but the agent's record in
//! [`swarm_wire::agent_closures`] stays, so a hive that rebuilds an idle agent
//! finds a record that names a closure nobody can download. Every
//! [`PASS_INTERVAL`] this asks the cache for the narinfo of each placed agent's
//! record and, on a 404, sends the builder the same `internal/meta` commit
//! again. The builder builds and pushes as for any meta build.
//!
//! Only a record a hive would use is checked: the one built from `internal/meta`'s
//! head, which says the builder pushed it. Only a 404 is a miss; an unreachable
//! cache or a refused token is "cannot tell" and rebuilds nothing.
//!
//! 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
//! [`BACKOFF_BASE`], doubling per failure up to [`BACKOFF_MAX`]. The backoff
//! lives in memory, so a restart tries every agent once more.
//!
//! The record names what hives deploy, so the pass changes it as little as it
//! can. A rebuild that lands on the record's own path writes nothing. One that
//! lands on another path (a build that is not reproducible) is logged at warn,
//! and replaces the record only if the record still holds the same commit and
//! no other write came in since it was read.
use std::collections::BTreeMap;
use std::future::Future;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result};
use async_nats::jetstream::kv::Store;
use opentelemetry::KeyValue;
use opentelemetry::metrics::{Counter, MeterProvider as _};
use opentelemetry_sdk::metrics::SdkMeterProvider;
use swarm_secret_client::attic::{Leaf, service_path};
use swarm_wire::agent_closures::AgentClosure;
use swarm_wire::builder::{BuildFromMeta, FromMetaReply};
use crate::agent_renewal::unix_now;
use crate::forge;
use crate::meta_build::{MetaBuilds as _, MetaRepo as _};
use crate::wanted::WantedWriter;
/// How long the loop waits between passes, which is also the wait before the
/// first.
const PASS_INTERVAL: Duration = Duration::from_mins(10);
/// The most builds one pass sends, whether or not they succeed.
const MAX_REBUILDS_PER_PASS: usize = 3;
/// How long an agent is left alone after its first failed rebuild.
const BACKOFF_BASE: Duration = Duration::from_mins(30);
/// The longest an agent is left alone.
const BACKOFF_MAX: Duration = Duration::from_hours(12);
/// How long one narinfo request may take.
const FETCH_TIMEOUT: Duration = Duration::from_secs(20);
/// A record with the KV revision it was read at.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Stored {
closure: AgentClosure,
revision: u64,
}
/// What a pass reads and does outside the process, so a test can stand in for it.
trait World {
/// The agents a hive places.
fn placed_agents(&self) -> impl Future<Output = Result<Vec<String>>> + Send;
/// `internal/meta`'s head, with the builder's read on it granted; `None`
/// when the repo has no commit.
fn meta_head(&self) -> impl Future<Output = Result<Option<String>>> + Send;
/// `agent`'s record; `None` when it has none.
fn record(&self, agent: &str) -> impl Future<Output = Result<Option<Stored>>> + Send;
/// Whether the cache holds the closure at `store_path`. `Err` when it can't
/// say.
fn holds(&self, store_path: &str) -> impl Future<Output = Result<bool>> + Send;
/// Ask the builder to build `agent` at `meta_rev` and push the closure.
fn rebuild(
&self,
agent: &str,
meta_rev: &str,
) -> impl Future<Output = Result<FromMetaReply, String>> + Send;
/// Replace `agent`'s record if it is still at `revision`. `Ok(false)` when
/// another write came first.
fn replace(
&self,
agent: &str,
closure: &AgentClosure,
revision: u64,
) -> impl Future<Output = Result<bool>> + Send;
}
/// What became of one rebuild: the `outcome` label of the counter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
/// Built and pushed, at the path the record names.
Rebuilt,
/// Built and pushed at another path, and the record now names it.
PathChanged,
/// Built and pushed at another path, and the record was left as it was.
PathChangedKept,
/// Built, but the builder's push failed or it has no cache.
NotPushed,
/// `nix` failed.
Failed,
/// The builder gave no verdict: busy, not listening, no reply.
Unavailable,
}
impl Outcome {
const fn label(self) -> &'static str {
match self {
Self::Rebuilt => "rebuilt",
Self::PathChanged => "path_changed",
Self::PathChangedKept => "path_changed_kept",
Self::NotPushed => "not_pushed",
Self::Failed => "failed",
Self::Unavailable => "unavailable",
}
}
/// Whether the agent is left alone for a while.
const fn backs_off(self) -> bool {
matches!(self, Self::NotPushed | Self::Failed | Self::Unavailable)
}
}
/// Which agents are left alone until when, and after how many failures.
#[derive(Debug, Default)]
struct Backoff(BTreeMap<String, (u32, Instant)>);
impl Backoff {
fn waiting(&self, agent: &str, now: Instant) -> bool {
self.0.get(agent).is_some_and(|(_, until)| *until > now)
}
fn failed(&mut self, agent: &str, now: Instant) {
let failures = self.0.get(agent).map_or(1, |(n, _)| n.saturating_add(1));
let delay = BACKOFF_BASE
.saturating_mul(2u32.saturating_pow(failures - 1))
.min(BACKOFF_MAX);
self.0.insert(agent.to_owned(), (failures, now + delay));
}
fn cleared(&mut self, agent: &str) {
self.0.remove(agent);
}
}
/// The counter `hyperhive.builder.cache_miss_rebuilds` (`outcome`), or nothing
/// where no collector is configured.
#[derive(Clone)]
struct Metrics(Option<Arc<Instruments>>);
struct Instruments {
/// Exports only while it lives.
_provider: SdkMeterProvider,
rebuilds: Counter<u64>,
}
impl Instruments {
fn new(provider: &SdkMeterProvider) -> Self {
Self {
_provider: provider.clone(),
rebuilds: provider
.meter("hyperhive.builder")
.u64_counter("hyperhive.builder.cache_miss_rebuilds")
.with_description(
"rebuilds the controller sent because the binary cache lost a closure, by outcome",
)
.build(),
}
}
}
static GLOBAL: OnceLock<Option<Arc<Instruments>>> = OnceLock::new();
impl Metrics {
/// The process's counter, building its exporter on the first call.
fn global() -> Self {
Self(
GLOBAL
.get_or_init(|| {
let endpoint = crate::vcs_metrics::endpoint()?;
match crate::vcs_metrics::build_provider(crate::vcs_metrics::EXPORT_INTERVAL) {
Ok(provider) => {
tracing::info!(%endpoint, "otel cache-miss metrics: exporter enabled");
Some(Arc::new(Instruments::new(&provider)))
}
Err(e) => {
tracing::warn!(error = ?e, "otel cache-miss metrics: exporter init failed");
None
}
}
})
.clone(),
)
}
fn count(&self, outcome: Outcome) {
if let Some(inst) = &self.0 {
inst.rebuilds
.add(1, &[KeyValue::new("outcome", outcome.label())]);
}
}
}
/// One pass over the placed agents. The returned outcomes are the rebuilds it
/// sent, in order.
///
/// # Errors
/// A read that failed before any agent was looked at (the placements, the
/// head). A failure on one agent is logged and the pass goes on.
async fn pass(
world: &impl World,
backoff: &mut Backoff,
metrics: &Metrics,
now: Instant,
) -> Result<Vec<(String, Outcome)>> {
let Some(head) = world.meta_head().await.context("reading internal/meta")? else {
return Ok(Vec::new());
};
let agents = world
.placed_agents()
.await
.context("reading where the hives place agents")?;
let mut sent = Vec::new();
for agent in agents {
if backoff.waiting(&agent, now) {
continue;
}
let stored = match world.record(&agent).await {
Ok(Some(stored)) => stored,
Ok(None) => continue,
Err(e) => {
tracing::warn!(%agent, error = %format!("{e:#}"), "cache miss: reading the record failed");
continue;
}
};
let record = &stored.closure;
if !record.cached || record.meta_rev != head {
continue;
}
match world.holds(&record.store_path).await {
Ok(true) => {
backoff.cleared(&agent);
continue;
}
Ok(false) => {}
Err(e) => {
tracing::warn!(%agent, error = %format!("{e:#}"), "cache miss: asking the cache failed");
continue;
}
}
if sent.len() >= MAX_REBUILDS_PER_PASS {
tracing::info!(
limit = MAX_REBUILDS_PER_PASS,
"cache miss: pass limit reached; the rest wait for the next pass"
);
break;
}
tracing::warn!(
%agent, meta_rev = %record.meta_rev, store_path = %record.store_path,
"cache miss: the cache lost the agent's closure; rebuilding"
);
let outcome = rebuild(world, &agent, stored).await;
metrics.count(outcome);
if outcome.backs_off() {
backoff.failed(&agent, now);
} else {
backoff.cleared(&agent);
}
sent.push((agent, outcome));
}
Ok(sent)
}
/// Send the builder the record's own commit, and apply the rule for the record
/// to what it answers.
async fn rebuild(world: &impl World, agent: &str, stored: Stored) -> Outcome {
let meta_rev = &stored.closure.meta_rev;
let reply = match world.rebuild(agent, meta_rev).await {
Ok(reply) => reply,
Err(why) => {
tracing::warn!(%agent, %meta_rev, %why, "cache miss: the rebuild got no answer");
return Outcome::Unavailable;
}
};
let (out_path, system, cached) = match reply {
FromMetaReply::Built {
out_path,
system,
cached,
} => (out_path, system, cached),
FromMetaReply::Failed { error_tail } => {
tracing::warn!(%agent, %meta_rev, %error_tail, "cache miss: the rebuild failed");
return Outcome::Failed;
}
FromMetaReply::Unavailable { reason } => {
tracing::warn!(%agent, %meta_rev, %reason, "cache miss: the rebuild did not run");
return Outcome::Unavailable;
}
};
if !cached {
tracing::warn!(%agent, %meta_rev, %out_path, "cache miss: rebuilt, but the builder did not push it");
return Outcome::NotPushed;
}
if out_path == stored.closure.store_path {
tracing::info!(%agent, %meta_rev, %out_path, "cache miss: rebuilt and pushed");
return Outcome::Rebuilt;
}
tracing::warn!(
%agent, %meta_rev, recorded = %stored.closure.store_path, %out_path,
"cache miss: the rebuild is at another path than the record; the build is not reproducible"
);
let now = AgentClosure {
meta_rev: meta_rev.clone(),
store_path: out_path,
system,
built_unix: unix_now(),
cached: true,
};
match replace_if_current(world, agent, &now).await {
Ok(true) => Outcome::PathChanged,
Ok(false) => {
tracing::warn!(%agent, %meta_rev, "cache miss: the record changed meanwhile and is left as it is");
Outcome::PathChangedKept
}
Err(e) => {
tracing::warn!(%agent, %meta_rev, error = %format!("{e:#}"), "cache miss: the record could not be replaced");
Outcome::PathChangedKept
}
}
}
/// Replace `agent`'s record with `now`, read again for the write: only when it
/// still holds `now`'s commit and no write has come since.
async fn replace_if_current(world: &impl World, agent: &str, now: &AgentClosure) -> Result<bool> {
let Some(current) = world.record(agent).await? else {
return Ok(false);
};
if current.closure.meta_rev != now.meta_rev {
return Ok(false);
}
world.replace(agent, now, current.revision).await
}
/// The cache's address and pull token, read from the store.
struct Facts {
url: String,
cache: String,
token: String,
}
impl Facts {
/// The facts if all three are stored, non-blank, and the URL is `https`:
/// the token goes out as a basic-auth password.
fn from_values(
token: Option<String>,
url: Option<String>,
cache: Option<String>,
) -> Option<Self> {
let clean = |v: Option<String>| v.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty());
let url = clean(url)?.trim_end_matches('/').to_owned();
url.starts_with("https://").then_some(())?;
Some(Self {
url,
cache: clean(cache)?,
token: clean(token)?,
})
}
async fn read(store: &impl crate::attic_copy::FactStore) -> Result<Option<Self>> {
let read = async |leaf| {
let path = service_path(leaf)?;
store
.read(&path)
.await
.with_context(|| format!("reading {path}"))
};
Ok(Self::from_values(
read(Leaf::PullToken).await?,
read(Leaf::Url).await?,
read(Leaf::Cache).await?,
))
}
}
/// The hash `/nix/store/<hash>-<name>` is named by in the cache.
fn path_hash(store_path: &str) -> Result<&str> {
let base = store_path.rsplit('/').next().unwrap_or_default();
match base.split_once('-') {
Some((hash, name)) if hash.len() == 32 && !name.is_empty() => Ok(hash),
_ => anyhow::bail!("{store_path:?} is not a store path"),
}
}
/// The real world of one pass.
struct Live {
queue: async_nats::Client,
closures: Store,
wanted: Arc<WantedWriter>,
hives: Vec<String>,
forge: Arc<forge::Client>,
http: reqwest::Client,
facts: Facts,
}
impl World for Live {
async fn placed_agents(&self) -> Result<Vec<String>> {
let placed = crate::jobq::placements(&self.wanted, &self.hives).await?;
let agents: std::collections::BTreeSet<_> =
placed.into_iter().map(|(_, agent)| agent).collect();
Ok(agents.into_iter().collect())
}
async fn meta_head(&self) -> Result<Option<String>> {
self.forge.grant_builder_read().await?;
self.forge.head().await
}
async fn record(&self, agent: &str) -> Result<Option<Stored>> {
let (closure, revision) = crate::schedules::read_with(&self.closures, agent, |bytes| {
Ok(Some(serde_json::from_slice::<AgentClosure>(bytes)?))
})
.await?;
Ok(closure
.zip(revision)
.map(|(closure, revision)| Stored { closure, revision }))
}
async fn holds(&self, store_path: &str) -> Result<bool> {
let url = format!(
"{}/{}/{}.narinfo",
self.facts.url,
self.facts.cache,
path_hash(store_path)?
);
let response = self
.http
.get(&url)
.basic_auth("swarm-controller", Some(&self.facts.token))
.send()
.await
.map_err(|e| anyhow::anyhow!("GET {url}: {}", e.without_url()))?;
match response.status() {
reqwest::StatusCode::OK => Ok(true),
reqwest::StatusCode::NOT_FOUND => Ok(false),
status => anyhow::bail!("GET {url}: {status}"),
}
}
async fn rebuild(&self, agent: &str, meta_rev: &str) -> Result<FromMetaReply, String> {
let request = serde_json::to_vec(&BuildFromMeta {
agent: agent.to_owned(),
meta_rev: meta_rev.to_owned(),
})
.map_err(|e| format!("encoding the build request: {e}"))?;
self.queue.build(request).await
}
async fn replace(&self, agent: &str, closure: &AgentClosure, revision: u64) -> Result<bool> {
crate::schedules::write(&self.closures, agent, closure, Some(revision)).await
}
}
/// Check the placed agents every [`PASS_INTERVAL`], starting one interval from
/// now. A pass that fails is logged and the next tick tries again; it never stops
/// the daemon.
pub fn spawn(
queue: async_nats::Client,
wanted: Arc<WantedWriter>,
hives: Vec<String>,
forge: Arc<forge::Client>,
) {
tokio::spawn(async move {
let metrics = Metrics::global();
let mut backoff = Backoff::default();
let mut ticker =
tokio::time::interval_at(tokio::time::Instant::now() + PASS_INTERVAL, PASS_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let world = match live(&queue, &wanted, &hives, &forge).await {
Ok(Some(world)) => world,
Ok(None) => {
tracing::debug!("cache miss: the swarm has no usable cache facts");
continue;
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "cache miss: setting up the pass failed");
continue;
}
};
match pass(&world, &mut backoff, &metrics, Instant::now()).await {
Ok(sent) => tracing::debug!(rebuilds = sent.len(), "cache miss: pass done"),
Err(e) => tracing::warn!(error = %format!("{e:#}"), "cache miss: pass failed"),
}
}
});
}
/// The world for one pass, or `None` when the store holds no usable cache facts.
async fn live(
queue: &async_nats::Client,
wanted: &Arc<WantedWriter>,
hives: &[String],
forge: &Arc<forge::Client>,
) -> Result<Option<Live>> {
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let Some(facts) = Facts::read(&store).await? else {
return Ok(None);
};
Ok(Some(Live {
queue: queue.clone(),
closures: swarm_queue_client::agent_closures::open_or_create(queue).await?,
wanted: Arc::clone(wanted),
hives: hives.to_vec(),
forge: Arc::clone(forge),
http: reqwest::Client::builder()
.timeout(FETCH_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()?,
facts,
}))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::sync::Mutex;
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader};
use tracing_subscriber::layer::SubscriberExt as _;
use super::*;
const HEAD: &str = "0123456789abcdef0123456789abcdef01234567";
const OLD: &str = "fedcba9876543210fedcba9876543210fedcba98";
fn path(name: &str) -> String {
format!("/nix/store/{:a<32}-{name}", "")
}
fn record(rev: &str, name: &str, cached: bool) -> AgentClosure {
AgentClosure {
meta_rev: rev.to_owned(),
store_path: path(name),
system: "x86_64-linux".to_owned(),
built_unix: 7,
cached,
}
}
/// The swarm as a pass sees it: records, what the cache holds, and what the
/// builder answers. Every build sent and every record write is kept.
struct Fake {
head: Option<String>,
records: Mutex<BTreeMap<String, Stored>>,
cache: Mutex<BTreeSet<String>>,
cache_down: bool,
/// What a build answers, per agent; a missing agent builds to its
/// record's path and pushes it.
answers: Mutex<BTreeMap<String, Result<FromMetaReply, String>>>,
/// A record write that lands between the build and the replace.
racing: Mutex<Option<(String, AgentClosure)>>,
/// Whether another write beats the replace.
replace_loses: std::sync::atomic::AtomicBool,
builds: Mutex<Vec<(String, String)>>,
writes: Mutex<Vec<(String, AgentClosure)>>,
}
impl Fake {
/// Every agent has a cached record at `HEAD`, and the cache holds none
/// of the closures.
fn missing(agents: &[&str]) -> Self {
let records = agents
.iter()
.map(|a| {
let stored = Stored {
closure: record(HEAD, a, true),
revision: 4,
};
((*a).to_owned(), stored)
})
.collect();
Self {
head: Some(HEAD.to_owned()),
records: Mutex::new(records),
cache: Mutex::default(),
cache_down: false,
answers: Mutex::default(),
racing: Mutex::default(),
replace_loses: std::sync::atomic::AtomicBool::default(),
builds: Mutex::default(),
writes: Mutex::default(),
}
}
fn holding(self, agent: &str) -> Self {
self.cache.lock().unwrap().insert(path(agent));
self
}
fn answering(self, agent: &str, answer: FromMetaReply) -> Self {
self.answers
.lock()
.unwrap()
.insert(agent.to_owned(), Ok(answer));
self
}
fn with_record(self, agent: &str, closure: AgentClosure) -> Self {
self.records.lock().unwrap().insert(
agent.to_owned(),
Stored {
closure,
revision: 4,
},
);
self
}
fn builds(&self) -> Vec<(String, String)> {
self.builds.lock().unwrap().clone()
}
fn writes(&self) -> Vec<(String, AgentClosure)> {
self.writes.lock().unwrap().clone()
}
}
fn built(out_path: &str, cached: bool) -> FromMetaReply {
FromMetaReply::Built {
out_path: out_path.to_owned(),
system: "x86_64-linux".to_owned(),
cached,
}
}
impl World for Fake {
async fn placed_agents(&self) -> Result<Vec<String>> {
Ok(self.records.lock().unwrap().keys().cloned().collect())
}
async fn meta_head(&self) -> Result<Option<String>> {
Ok(self.head.clone())
}
async fn record(&self, agent: &str) -> Result<Option<Stored>> {
Ok(self.records.lock().unwrap().get(agent).cloned())
}
async fn holds(&self, store_path: &str) -> Result<bool> {
anyhow::ensure!(!self.cache_down, "the cache answered 503");
Ok(self.cache.lock().unwrap().contains(store_path))
}
async fn rebuild(&self, agent: &str, meta_rev: &str) -> Result<FromMetaReply, String> {
self.builds
.lock()
.unwrap()
.push((agent.to_owned(), meta_rev.to_owned()));
let answer = self.answers.lock().unwrap().get(agent).cloned();
let answer = answer.unwrap_or_else(|| Ok(built(&path(agent), true)));
if let Ok(FromMetaReply::Built {
out_path,
cached: true,
..
}) = &answer
{
self.cache.lock().unwrap().insert(out_path.clone());
}
if let Some((racer, closure)) = self.racing.lock().unwrap().take() {
let mut records = self.records.lock().unwrap();
records.insert(
racer,
Stored {
closure,
revision: 5,
},
);
}
answer
}
async fn replace(
&self,
agent: &str,
closure: &AgentClosure,
revision: u64,
) -> Result<bool> {
let mut records = self.records.lock().unwrap();
if self.replace_loses.load(std::sync::atomic::Ordering::SeqCst)
|| records[agent].revision != revision
{
return Ok(false);
}
records.insert(
agent.to_owned(),
Stored {
closure: closure.clone(),
revision: revision + 1,
},
);
self.writes
.lock()
.unwrap()
.push((agent.to_owned(), closure.clone()));
Ok(true)
}
}
async fn pass_at(world: &Fake, backoff: &mut Backoff, now: Instant) -> Vec<(String, Outcome)> {
pass(world, backoff, &Metrics(None), now)
.await
.expect("the pass reads")
}
fn rebuilt(agent: &str) -> (String, Outcome) {
(agent.to_owned(), Outcome::Rebuilt)
}
#[tokio::test]
async fn a_missing_closure_is_rebuilt_once_at_the_records_commit() {
let world = Fake::missing(&["iris"]);
let mut backoff = Backoff::default();
let now = Instant::now();
assert_eq!(pass_at(&world, &mut backoff, now).await, [rebuilt("iris")]);
assert_eq!(world.builds(), [("iris".to_owned(), HEAD.to_owned())]);
// The build pushed it, so the next pass finds it.
assert_eq!(pass_at(&world, &mut backoff, now).await, []);
assert_eq!(world.builds().len(), 1);
assert!(world.writes().is_empty(), "the record already names it");
}
#[tokio::test]
async fn a_closure_the_cache_holds_is_left_alone() {
let world = Fake::missing(&["iris", "argus"]).holding("iris");
let sent = pass_at(&world, &mut Backoff::default(), Instant::now()).await;
// Control: the other agent's miss is rebuilt in the same pass.
assert_eq!(sent, [rebuilt("argus")]);
assert_eq!(world.builds(), [("argus".to_owned(), HEAD.to_owned())]);
}
#[tokio::test]
async fn only_the_record_a_hive_would_use_is_checked() {
let world = Fake::missing(&["iris", "argus", "atlas", "muede"])
.with_record("iris", record(OLD, "iris", true))
.with_record("argus", record(HEAD, "argus", false));
let sent = pass_at(&world, &mut Backoff::default(), Instant::now()).await;
// `iris` is from another commit, `argus` was never pushed.
assert_eq!(sent, [rebuilt("atlas"), rebuilt("muede")]);
}
#[tokio::test]
async fn a_cache_that_cannot_answer_is_not_a_miss() {
let mut world = Fake::missing(&["iris"]);
world.cache_down = true;
let sent = pass_at(&world, &mut Backoff::default(), Instant::now()).await;
assert_eq!(sent, []);
assert!(world.builds().is_empty());
}
#[tokio::test]
async fn no_head_means_no_pass() {
let mut world = Fake::missing(&["iris"]);
world.head = None;
assert_eq!(
pass_at(&world, &mut Backoff::default(), Instant::now()).await,
[]
);
}
#[tokio::test]
async fn a_pass_sends_at_most_its_limit_and_the_next_takes_the_rest() {
let agents = ["a", "b", "c", "d", "e"];
let world = Fake::missing(&agents);
let mut backoff = Backoff::default();
let now = Instant::now();
assert_eq!(MAX_REBUILDS_PER_PASS, 3);
let first = pass_at(&world, &mut backoff, now).await;
assert_eq!(first, [rebuilt("a"), rebuilt("b"), rebuilt("c")]);
let second = pass_at(&world, &mut backoff, now).await;
assert_eq!(second, [rebuilt("d"), rebuilt("e")]);
assert_eq!(world.builds().len(), 5);
}
#[tokio::test]
async fn a_failed_rebuild_waits_and_the_wait_doubles() {
let world = Fake::missing(&["iris"]);
world
.answers
.lock()
.unwrap()
.insert("iris".to_owned(), Err("no builder".to_owned()));
let mut backoff = Backoff::default();
let t0 = Instant::now();
let unavailable = [("iris".to_owned(), Outcome::Unavailable)];
assert_eq!(pass_at(&world, &mut backoff, t0).await, unavailable);
// No tight loop: every pass inside the wait sends nothing.
for minutes in [1, 10, 29] {
let at = t0 + Duration::from_mins(minutes);
assert_eq!(pass_at(&world, &mut backoff, at).await, []);
}
assert_eq!(world.builds().len(), 1);
// After the wait it tries again, and the next wait is twice as long.
let t1 = t0 + BACKOFF_BASE;
assert_eq!(pass_at(&world, &mut backoff, t1).await, unavailable);
assert_eq!(pass_at(&world, &mut backoff, t1 + BACKOFF_BASE).await, []);
assert_eq!(world.builds().len(), 2);
assert_eq!(
pass_at(&world, &mut backoff, t1 + BACKOFF_BASE * 2).await,
unavailable
);
assert_eq!(world.builds().len(), 3);
}
#[tokio::test]
async fn the_wait_stops_growing_at_its_cap() {
let mut backoff = Backoff::default();
let t0 = Instant::now();
for _ in 0..40 {
backoff.failed("iris", t0);
}
assert!(backoff.waiting(
"iris",
t0 + BACKOFF_MAX.saturating_sub(Duration::from_secs(1))
));
assert!(!backoff.waiting("iris", t0 + BACKOFF_MAX));
}
#[tokio::test]
async fn a_build_that_failed_or_did_not_push_backs_off_and_writes_nothing() {
let nix_failed = FromMetaReply::Failed {
error_tail: "error".to_owned(),
};
let world = Fake::missing(&["iris", "argus"])
.answering("iris", nix_failed)
.answering("argus", built(&path("argus"), false));
let mut backoff = Backoff::default();
let now = Instant::now();
let sent = pass_at(&world, &mut backoff, now).await;
assert_eq!(
sent,
[
("argus".to_owned(), Outcome::NotPushed),
("iris".to_owned(), Outcome::Failed)
]
);
assert!(backoff.waiting("iris", now) && backoff.waiting("argus", now));
assert!(world.writes().is_empty());
}
#[tokio::test]
async fn another_path_is_logged_and_replaces_the_record_it_matches() {
let other = path("iris-other");
let world = Fake::missing(&["iris"]).answering("iris", built(&other, true));
let events = Events::default();
let sent = {
let _guard = tracing::subscriber::set_default(
tracing_subscriber::registry().with(events.clone()),
);
pass_at(&world, &mut Backoff::default(), Instant::now()).await
};
assert_eq!(sent, [("iris".to_owned(), Outcome::PathChanged)]);
let writes = world.writes();
assert_eq!(writes.len(), 1);
assert_eq!(writes[0].1.store_path, other);
assert_eq!(writes[0].1.meta_rev, HEAD);
assert!(writes[0].1.cached);
assert!(
events
.warnings()
.iter()
.any(|m| m.contains("not reproducible")),
"{:?}",
events.warnings()
);
}
#[tokio::test]
async fn another_path_leaves_a_record_that_moved_on() {
let world = Fake::missing(&["iris"]).answering("iris", built(&path("iris-other"), true));
// A deploy's build records a newer commit while this one builds.
*world.racing.lock().unwrap() = Some(("iris".to_owned(), record(OLD, "iris-newer", true)));
let sent = pass_at(&world, &mut Backoff::default(), Instant::now()).await;
assert_eq!(sent, [("iris".to_owned(), Outcome::PathChangedKept)]);
assert!(world.writes().is_empty());
let kept = world.records.lock().unwrap()["iris"].clone();
assert_eq!(kept.closure, record(OLD, "iris-newer", true));
}
#[tokio::test]
async fn another_path_leaves_a_record_that_a_write_beat_the_replace_to() {
let world = Fake::missing(&["iris"]).answering("iris", built(&path("iris-other"), true));
world
.replace_loses
.store(true, std::sync::atomic::Ordering::SeqCst);
let sent = pass_at(&world, &mut Backoff::default(), Instant::now()).await;
assert_eq!(sent, [("iris".to_owned(), Outcome::PathChangedKept)]);
assert!(world.writes().is_empty());
}
#[test]
fn the_cache_facts_need_all_three_values_and_an_https_url() {
let some = |v: &str| Some(v.to_owned());
let facts = Facts::from_values(some("tok"), some("https://cache.example/"), some("swarm"))
.expect("complete");
assert_eq!(facts.url, "https://cache.example");
assert!(Facts::from_values(None, some("https://c"), some("swarm")).is_none());
assert!(Facts::from_values(some("tok"), some(" \n"), some("swarm")).is_none());
assert!(Facts::from_values(some("tok"), some("https://c"), None).is_none());
assert!(Facts::from_values(some("tok"), some("http://c"), some("swarm")).is_none());
}
#[test]
fn the_narinfo_is_named_by_the_stores_hash() {
let p = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system";
assert_eq!(path_hash(p).unwrap(), "0123456789abcdfghijklmnpqrsvwxyz");
assert!(path_hash("/nix/store/short-x").is_err());
}
#[test]
fn the_counter_counts_by_outcome() {
let exporter = InMemoryMetricExporter::default();
let provider = SdkMeterProvider::builder()
.with_reader(PeriodicReader::builder(exporter.clone()).build())
.build();
let metrics = Metrics(Some(Arc::new(Instruments::new(&provider))));
metrics.count(Outcome::Rebuilt);
metrics.count(Outcome::Rebuilt);
metrics.count(Outcome::Failed);
provider.force_flush().expect("flush");
let all = exporter.get_finished_metrics().expect("metrics");
let mut seen = BTreeMap::new();
for metric in all
.last()
.into_iter()
.flat_map(opentelemetry_sdk::metrics::data::ResourceMetrics::scope_metrics)
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
{
assert_eq!(metric.name(), "hyperhive.builder.cache_miss_rebuilds");
let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data() else {
panic!("not a u64 sum");
};
for point in sum.data_points() {
let outcome = point
.attributes()
.find(|kv| kv.key.as_str() == "outcome")
.map(|kv| kv.value.to_string())
.expect("labelled");
seen.insert(outcome, point.value());
}
}
assert_eq!(
seen,
BTreeMap::from([("failed".to_owned(), 1), ("rebuilt".to_owned(), 2)])
);
}
/// The messages of the warn events a test emitted.
#[derive(Clone, Default)]
struct Events(Arc<Mutex<Vec<String>>>);
impl Events {
fn warnings(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Events {
fn on_event(
&self,
event: &tracing::Event<'_>,
_: tracing_subscriber::layer::Context<'_, S>,
) {
if *event.metadata().level() != tracing::Level::WARN {
return;
}
let mut message = String::new();
event.record(
&mut |field: &tracing::field::Field, value: &dyn std::fmt::Debug| {
if field.name() == "message" {
message = format!("{value:?}");
}
},
);
self.0.lock().unwrap().push(message);
}
}
}

View file

@ -810,7 +810,7 @@ fn declare_fan_out(
}
/// Every `(hive, agent)` a hive in `hives` places.
async fn placements(
pub(crate) async fn placements(
wanted: &wanted::WantedWriter,
hives: &[String],
) -> Result<std::collections::BTreeSet<(String, String)>> {

View file

@ -41,6 +41,7 @@ mod attic_copy;
mod auth;
mod backend_env;
mod builder;
mod cache_miss;
mod claude_login;
mod config_pr;
mod forge;

View file

@ -291,10 +291,18 @@ fn spawn_forge_workers(
// pin when that is newer. The timer's first tick is this boot run.
crate::jobq::spawn_meta_update_timer(Arc::clone(jobq));
forge::objects::spawn(Arc::clone(&client));
if let (Some(bridge), Some(queue)) = (auth, queue) {
if let (Some(bridge), Some(queue)) = (auth, queue.clone()) {
forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue);
}
forge::runner_token::spawn(Arc::clone(&client));
if let Some(queue) = queue {
crate::cache_miss::spawn(
queue.clone(),
Arc::new(wanted::WantedWriter::new(queue)),
hives.iter().map(|h| h.name.clone()).collect(),
Arc::clone(&client),
);
}
let sched = Arc::clone(jobq);
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
forge::hive_token::spawn(Arc::clone(&client), hive_names, move |hives| {