use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use runner_manager_domain::attempt::{
AttemptOutcome, AttemptState, FailureReason, IsolationProviderFailure, RunnerAttempt,
};
use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
use runner_manager_domain::model::{
AttemptId, Clock, Host, IdlePollInterval, Org, OwnerRepo, PolicyId, RefreshInterval,
ScaleTarget, Timestamp,
};
use runner_manager_domain::policy::{DemandTally, ScalePolicy};
use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
use runner_manager_github::rest::{
ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
};
use runner_manager_github::traffic::{PrimaryPressure, RateLimitSnapshot};
pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
const MAX_BACKOFF_DOUBLINGS: u32 = 5;
const JITTER_RATIO: f64 = 0.5;
const RATE_LIMIT_JITTER_RATIO: f64 = 0.1;
pub const MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET: u32 = 300;
pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollOutcome {
Ready(QueuedDemand),
Failed(RefreshState),
}
impl PollOutcome {
#[must_use]
pub const fn reading(&self) -> Option<&QueuedDemand> {
match self {
Self::Ready(demand) => Some(demand),
Self::Failed(_) => None,
}
}
#[must_use]
pub const fn failure(&self) -> Option<&RefreshState> {
match self {
Self::Failed(state) => Some(state),
Self::Ready(_) => None,
}
}
#[must_use]
pub fn is_offline(&self) -> bool {
matches!(self, Self::Failed(RefreshState::Offline))
}
}
#[async_trait::async_trait]
pub trait DemandSource: fmt::Debug + Send + Sync {
async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
}
#[derive(Debug)]
pub struct GatewayDemand<G> {
gateway: G,
cancel: CancelToken,
}
impl<G: DemandGateway> GatewayDemand<G> {
#[must_use]
pub const fn new(gateway: G, cancel: CancelToken) -> Self {
Self { gateway, cancel }
}
#[must_use]
pub const fn gateway(&self) -> &G {
&self.gateway
}
}
#[async_trait::async_trait]
impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
match self.gateway.queued_demand(scope, &self.cancel).await {
Ok(demand) => PollOutcome::Ready(demand),
Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
}
}
}
#[async_trait::async_trait]
pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
}
#[derive(Debug, Clone)]
struct CachedRepositories {
repositories: Vec<OwnerRepo>,
fetched_at: Timestamp,
}
#[derive(Debug)]
pub struct RepositoryCache {
directory: Arc<dyn RepositoryDirectory>,
clock: Arc<dyn Clock>,
ttl: Duration,
entries: Mutex<BTreeMap<Org, CachedRepositories>>,
lookups: AtomicU64,
}
impl RepositoryCache {
#[must_use]
pub fn new(
directory: Arc<dyn RepositoryDirectory>,
clock: Arc<dyn Clock>,
poll: RefreshInterval,
) -> Self {
let ttl = Duration::from_secs(u64::from(poll.as_secs()))
.saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
Self {
directory,
clock,
ttl,
entries: Mutex::new(BTreeMap::new()),
lookups: AtomicU64::new(0),
}
}
#[must_use]
pub const fn ttl(&self) -> Duration {
self.ttl
}
#[must_use]
pub fn lookups(&self) -> u64 {
self.lookups.load(Ordering::SeqCst)
}
pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
match target {
ScaleTarget::Repository(repository) => {
Ok(ActivityScope::repository(repository.clone()))
}
ScaleTarget::Organization(org) => {
let repositories = self.repositories_of(org).await?;
Ok(ActivityScope::organization(org.clone(), repositories))
}
}
}
async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
let now = self.clock.now();
if let Some(fresh) = self.fresh_entry(org, now) {
return Ok(fresh);
}
let repositories = self.directory.repositories(org).await?;
self.lookups.fetch_add(1, Ordering::SeqCst);
self.store(org.clone(), repositories.clone(), now);
Ok(repositories)
}
fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
let entries = self.entries.lock().ok()?;
let entry = entries.get(org)?;
let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
(age < self.ttl).then(|| entry.repositories.clone())
}
fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
if let Ok(mut entries) = self.entries.lock() {
entries.insert(
org,
CachedRepositories {
repositories,
fetched_at,
},
);
}
}
}
pub trait Jitter: fmt::Debug + Send + Sync {
fn fraction(&self) -> f64;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RandomJitter;
impl Jitter for RandomJitter {
fn fraction(&self) -> f64 {
rand::random::<f64>()
}
}
#[derive(Debug, Clone, Copy)]
pub struct FixedJitter(pub f64);
impl Jitter for FixedJitter {
fn fraction(&self) -> f64 {
self.0
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoJitter;
impl Jitter for NoJitter {
fn fraction(&self) -> f64 {
0.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PollPace {
Nominal,
Idle,
Stretched { pressure: PrimaryPressure },
RateLimited { kind: RateLimitKind },
LockedOut,
Offline { consecutive: u32 },
Blocked,
}
impl PollPace {
#[must_use]
pub const fn is_throttled(&self) -> bool {
!matches!(self, Self::Nominal | Self::Idle)
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Nominal => "nominal",
Self::Idle => "idle",
Self::Stretched {
pressure: PrimaryPressure::Normal,
} => "nominal",
Self::Stretched {
pressure: PrimaryPressure::Low,
} => "stretched_low_budget",
Self::Stretched {
pressure: PrimaryPressure::Critical,
} => "stretched_critical_budget",
Self::RateLimited {
kind: RateLimitKind::Primary,
} => "rate_limited_primary",
Self::RateLimited {
kind: RateLimitKind::Secondary,
} => "rate_limited_secondary",
Self::LockedOut => "locked_out",
Self::Offline { .. } => "offline",
Self::Blocked => "blocked",
}
}
}
impl fmt::Display for PollPace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NextPoll {
pub delay: Duration,
pub pace: PollPace,
}
#[derive(Debug, Clone)]
pub struct PollSchedule {
interval: RefreshInterval,
idle: Duration,
consecutive_offline: u32,
offline_since: Option<Timestamp>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PollConditions {
pub quiet: bool,
pub quota: Option<RateLimitSnapshot>,
pub requests_per_pass: u32,
}
impl PollSchedule {
#[must_use]
pub const fn new(interval: RefreshInterval) -> Self {
Self {
interval,
idle: Duration::from_secs(interval.as_secs() as u64),
consecutive_offline: 0,
offline_since: None,
}
}
#[must_use]
pub fn with_idle(mut self, idle: Duration) -> Self {
self.set_intervals(self.interval, idle);
self
}
pub fn set_intervals(&mut self, interval: RefreshInterval, idle: Duration) {
self.interval = interval;
self.idle = idle.min(self.nominal()).max(Self::idle_floor());
}
#[must_use]
pub const fn interval(&self) -> RefreshInterval {
self.interval
}
#[must_use]
pub const fn idle(&self) -> Duration {
self.idle
}
#[must_use]
pub const fn idle_floor() -> Duration {
Duration::from_secs(IdlePollInterval::MIN_SECS as u64)
}
#[must_use]
pub const fn nominal(&self) -> Duration {
Duration::from_secs(self.interval.as_secs() as u64)
}
#[must_use]
pub const fn consecutive_offline(&self) -> u32 {
self.consecutive_offline
}
#[must_use]
pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
let since = self.offline_since?;
now.signed_duration_since(since).to_std().ok()
}
#[must_use]
pub const fn floor() -> Duration {
Duration::from_secs(RefreshInterval::MIN_SECS as u64)
}
pub fn next_poll(
&mut self,
failure: Option<&RefreshState>,
now: Timestamp,
jitter: &dyn Jitter,
) -> NextPoll {
self.next_poll_with(failure, PollConditions::default(), now, jitter)
}
pub fn next_poll_with(
&mut self,
failure: Option<&RefreshState>,
conditions: PollConditions,
now: Timestamp,
jitter: &dyn Jitter,
) -> NextPoll {
let nominal = self.nominal();
let next = match failure {
None => {
self.recovered();
let pressure = conditions
.quota
.map_or(PrimaryPressure::Normal, |quota| quota.pressure(now));
match (pressure, conditions.quota) {
(PrimaryPressure::Low | PrimaryPressure::Critical, Some(quota)) => NextPoll {
delay: nominal
.saturating_mul(pressure.stretch_factor())
.min(quota.reset_in(now).max(nominal)),
pace: PollPace::Stretched { pressure },
},
_ if conditions.quiet && self.idle < nominal => NextPoll {
delay: self.idle.max(idle_floor_for(conditions.requests_per_pass)),
pace: PollPace::Idle,
},
_ => NextPoll {
delay: nominal,
pace: PollPace::Nominal,
},
}
}
Some(RefreshState::Offline) => {
self.consecutive_offline = self.consecutive_offline.saturating_add(1);
self.offline_since.get_or_insert(now);
NextPoll {
delay: self.offline_delay(nominal, jitter),
pace: PollPace::Offline {
consecutive: self.consecutive_offline,
},
}
}
Some(state @ RefreshState::RateLimited(limit)) => {
self.recovered();
let wait = retry_floor(state, now).max(nominal);
NextPoll {
delay: wait.saturating_add(
wait.mul_f64(RATE_LIMIT_JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0)),
),
pace: PollPace::RateLimited { kind: limit.kind },
}
}
Some(state @ RefreshState::LockedOut { .. }) => {
self.recovered();
NextPoll {
delay: retry_floor(state, now).max(nominal),
pace: PollPace::LockedOut,
}
}
Some(_) => {
self.recovered();
NextPoll {
delay: nominal,
pace: PollPace::Blocked,
}
}
};
debug_assert!(
next.delay >= Self::floor() || next.pace == PollPace::Idle,
"the 30-second floor is a rate-budget constraint and no charged branch may go below it"
);
debug_assert!(
next.delay >= Self::idle_floor(),
"the idle floor is a secondary-limit constraint and no branch may go below it"
);
next
}
fn recovered(&mut self) {
self.consecutive_offline = 0;
self.offline_since = None;
}
fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
let doublings = self
.consecutive_offline
.saturating_sub(1)
.min(MAX_BACKOFF_DOUBLINGS);
let grown = nominal.saturating_mul(1_u32 << doublings);
let capped = grown.min(MAX_OFFLINE_BACKOFF);
let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
capped.saturating_add(spread).max(Self::floor())
}
}
fn is_quiet(readings: &BTreeMap<ScaleTarget, PollOutcome>, report: &ReconcileReport) -> bool {
!readings.is_empty()
&& readings
.values()
.all(|outcome| outcome.reading().is_some_and(QueuedDemand::is_quiet))
&& report.started == 0
&& report
.allocations
.iter()
.all(|allocation| allocation.active_owned == 0 && allocation.demand == 0)
}
fn idle_floor_for(requests_per_pass: u32) -> Duration {
Duration::from_secs(
u64::from(requests_per_pass) * 60 / u64::from(MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET),
)
}
fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
state.retry_delay(now).unwrap_or(PollSchedule::floor())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OfflineState {
pub consecutive: u32,
pub retry_in: Duration,
pub offline_for: Option<Duration>,
}
impl OfflineState {
#[must_use]
pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
Self {
consecutive,
retry_in,
offline_for: None,
}
}
#[must_use]
pub const fn since(mut self, offline_for: Duration) -> Self {
self.offline_for = Some(offline_for);
self
}
#[must_use]
pub fn has_outlasted_the_queue(&self) -> bool {
self.offline_for
.is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
}
}
impl fmt::Display for OfflineState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"GitHub is unreachable; no new runners are being started and running \
runners are left alone. Retrying in {}s",
self.retry_in.as_secs()
)?;
if self.has_outlasted_the_queue() {
f.write_str(
". This outage has lasted more than 24 hours, and GitHub cancels a queued \
job after 24 hours, so queued work has been lost",
)
} else {
f.write_str(
". GitHub cancels a queued job after 24 hours, so an outage longer than \
that loses queued work",
)
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct LaunchRequest<'a> {
pub host: &'a Host,
pub policy: &'a ScalePolicy,
pub(crate) allocation_guard: &'a AllocationGuard,
pub lock_wait: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the runner could not be started: {reason}")]
pub struct LaunchFailure {
pub reason: FailureReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplacementIntent {
pub policy: PolicyId,
pub previous_attempt: AttemptId,
pub operation: &'static str,
}
impl LaunchFailure {
#[must_use]
pub const fn new(reason: FailureReason) -> Self {
Self { reason }
}
}
#[async_trait::async_trait]
pub trait RunnerLauncher: fmt::Debug + Send + Sync {
async fn supervise(
&self,
_policy: &ScalePolicy,
) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
Ok(Vec::new())
}
async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
}
#[derive(Debug)]
pub struct FitnessGatedLauncher {
inner: Arc<dyn RunnerLauncher>,
unfit: Arc<AtomicBool>,
}
impl FitnessGatedLauncher {
#[must_use]
pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
Self { inner, unfit }
}
}
#[async_trait::async_trait]
impl RunnerLauncher for FitnessGatedLauncher {
async fn supervise(
&self,
policy: &ScalePolicy,
) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
self.inner.supervise(policy).await
}
async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
self.inner.attempts().await
}
async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
if self.unfit.load(Ordering::Acquire) {
return Err(LaunchFailure::new(FailureReason::HostUnfit));
}
self.inner.launch(request).await
}
async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
self.inner.clean(attempt).await
}
}
pub struct AllocationGuard {
_held: Box<dyn std::any::Any + Send + Sync>,
}
impl AllocationGuard {
#[must_use]
fn new<T: Send + Sync + 'static>(held: T) -> Self {
Self {
_held: Box::new(held),
}
}
}
impl fmt::Debug for AllocationGuard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("AllocationGuard")
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
pub struct AllocationLockBusy;
#[async_trait::async_trait]
pub trait AllocationLock: fmt::Debug + Send + Sync {
async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
}
#[derive(Debug, Default)]
pub struct InProcessAllocationLock {
mutex: Arc<tokio::sync::Mutex<()>>,
}
impl InProcessAllocationLock {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[async_trait::async_trait]
impl AllocationLock for InProcessAllocationLock {
async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
let mutex = Arc::clone(&self.mutex);
let guard = mutex.lock_owned().await;
Ok(AllocationGuard::new(guard))
}
}
#[derive(Debug, Clone)]
pub struct FileAllocationLock {
paths: Arc<runner_manager_platform::paths::AppPaths>,
wait: Duration,
}
impl FileAllocationLock {
#[must_use]
pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
Self {
paths,
wait: ALLOCATION_LOCK_WAIT,
}
}
#[must_use]
pub const fn with_wait(mut self, wait: Duration) -> Self {
self.wait = wait;
self
}
}
#[async_trait::async_trait]
impl AllocationLock for FileAllocationLock {
async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
use runner_manager_platform::lock::{HostLock, LockKind};
let paths = Arc::clone(&self.paths);
let wait = self.wait;
let held = tokio::task::spawn_blocking(move || {
HostLock::acquire(&paths, LockKind::Allocation, wait)
})
.await;
match held {
Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
}
}
}
#[derive(Debug)]
pub struct WslRecoveryAllocationLock {
paths: Arc<runner_manager_platform::paths::AppPaths>,
inner: Arc<dyn AllocationLock>,
blocked_after_refusals: u32,
blocked_after: Duration,
streak_gap: Duration,
in_flight: Arc<std::sync::atomic::AtomicUsize>,
refusals: Mutex<Refusals>,
}
#[derive(Debug, Default)]
struct Refusals {
streak: Option<Streak>,
recorded: bool,
}
#[derive(Debug, Clone, Copy)]
struct Streak {
since: chrono::DateTime<chrono::Utc>,
latest: chrono::DateTime<chrono::Utc>,
count: u32,
}
struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
impl InFlight {
fn enter(count: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
count.fetch_add(1, Ordering::SeqCst);
Self(Arc::clone(count))
}
}
impl Drop for InFlight {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
struct LaunchFenceGuard(Option<runner_manager_platform::wsl::fence::GuestLaunchClaim>);
impl Drop for LaunchFenceGuard {
fn drop(&mut self) {
if let Some(claim) = self.0.take()
&& let Err(error) = claim.release()
{
tracing::warn!(
%error,
"the WSL launch fence could not be released; the next launch reclaims it"
);
}
}
}
impl WslRecoveryAllocationLock {
#[must_use]
pub fn new(
paths: Arc<runner_manager_platform::paths::AppPaths>,
inner: Arc<dyn AllocationLock>,
) -> Self {
use runner_manager_platform::launch_health::{BLOCKED_AFTER, BLOCKED_AFTER_DEFERRALS};
Self {
paths,
inner,
blocked_after_refusals: BLOCKED_AFTER_DEFERRALS,
blocked_after: BLOCKED_AFTER,
streak_gap: BLOCKED_AFTER,
in_flight: Arc::default(),
refusals: Mutex::default(),
}
}
#[cfg(test)]
const fn with_blocked_after(mut self, refusals: u32, after: Duration) -> Self {
self.blocked_after_refusals = refusals;
self.blocked_after = after;
self
}
#[cfg(test)]
const fn with_streak_gap(mut self, gap: Duration) -> Self {
self.streak_gap = gap;
self
}
fn refusals(&self) -> std::sync::MutexGuard<'_, Refusals> {
self.refusals
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn note_granted(&self) {
let mut refusals = self.refusals();
refusals.streak = None;
if refusals.recorded {
match runner_manager_platform::launch_health::clear_launches_blocked(&self.paths) {
Ok(()) => refusals.recorded = false,
Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be cleared"),
}
}
}
fn note_refused(&self, cause: &runner_manager_platform::launch_health::BlockCause) {
use runner_manager_platform::launch_health::{Viewpoint, record_launches_blocked};
use runner_manager_platform::wsl::fence::elapsed_at_least;
if self.in_flight.load(Ordering::SeqCst) > 0 {
return;
}
let now = chrono::Utc::now();
let mut refusals = self.refusals();
let streak = match refusals.streak {
Some(streak) if !elapsed_at_least(streak.latest, now, self.streak_gap) => Streak {
latest: now,
count: streak.count.saturating_add(1),
..streak
},
_ => Streak {
since: now,
latest: now,
count: 1,
},
};
refusals.streak = Some(streak);
if streak.count < self.blocked_after_refusals
|| !elapsed_at_least(streak.since, now, self.blocked_after)
{
return;
}
let blocked = cause.blocked_since(streak.since, Viewpoint::Guest);
if !refusals.recorded {
tracing::warn!(
refusals = streak.count,
"runner launches are blocked {blocked}: {}",
blocked.remedy
);
}
match record_launches_blocked(&self.paths, &blocked, now) {
Ok(()) => refusals.recorded = true,
Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be written"),
}
}
}
#[async_trait::async_trait]
impl AllocationLock for WslRecoveryAllocationLock {
async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
use runner_manager_platform::launch_health::BlockCause;
use runner_manager_platform::wsl::fence::{
DrainRequest, FENCE_DIRECTORY, FenceClaim, GuestClaimAttempt, GuestRecoveryConfig,
try_claim_guest_launch,
};
let paths = Arc::clone(&self.paths);
let claim = tokio::task::spawn_blocking(move || {
let unusable = |error: &dyn fmt::Display| BlockCause::FenceUnusable {
detail: error.to_string(),
};
let config = match GuestRecoveryConfig::read(&paths) {
Ok(Some(config)) => config,
Ok(None) => return Ok(LaunchFenceGuard(None)),
Err(error) => return Err(unusable(&error)),
};
let root = config.shared_root;
let claim = match try_claim_guest_launch(&root) {
Ok(GuestClaimAttempt::Claimed { claim, reclaimed }) => {
if let Some(reclaimed) = reclaimed {
tracing::warn!(
shared_root = %root.display(),
%reclaimed,
"reclaimed a WSL launch fence claim whose owner is gone"
);
}
LaunchFenceGuard(Some(claim))
}
Ok(GuestClaimAttempt::Busy) => {
let directory = root.join(FENCE_DIRECTORY);
return Err(match FenceClaim::owner(&root) {
Ok(owner) => BlockCause::FenceHeld { directory, owner },
Err(error) => BlockCause::FenceOwnerUnreadable {
directory,
detail: error.to_string(),
},
});
}
Err(error) => {
return Err(match FenceClaim::owner(&root) {
Err(owner_error) => BlockCause::FenceOwnerUnreadable {
directory: root.join(FENCE_DIRECTORY),
detail: owner_error.to_string(),
},
Ok(_) => unusable(&error),
});
}
};
match DrainRequest::read(&root) {
Ok(None) => Ok(claim),
Ok(Some(_)) => Err(BlockCause::DrainRequested { root }),
Err(error) => Err(unusable(&error)),
}
})
.await
.map_err(|_| AllocationLockBusy)?;
let claim = match claim {
Ok(claim) => claim,
Err(cause) => {
self.note_refused(&cause);
return Err(AllocationLockBusy);
}
};
let Ok(inner) = self.inner.acquire().await else {
self.note_refused(&BlockCause::AllocationLockBusy {
path: self
.paths
.state_dir()
.join(runner_manager_platform::lock::LockKind::Allocation.file_name()),
});
return Err(AllocationLockBusy);
};
self.note_granted();
let in_flight = InFlight::enter(&self.in_flight);
Ok(AllocationGuard::new((claim, inner, in_flight)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutcomeKind {
CompletedJob,
IdleExit,
Failed,
Orphaned,
}
impl OutcomeKind {
#[must_use]
pub const fn of(outcome: &AttemptOutcome) -> Self {
match outcome {
AttemptOutcome::CompletedJob => Self::CompletedJob,
AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
AttemptOutcome::Failed { .. } => Self::Failed,
AttemptOutcome::Orphaned => Self::Orphaned,
}
}
#[must_use]
pub const fn is_failure(&self) -> bool {
matches!(self, Self::Failed | Self::Orphaned)
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::CompletedJob => "completed_job",
Self::IdleExit => "exited_idle_without_work",
Self::Failed => "failed",
Self::Orphaned => "orphaned",
}
}
}
impl fmt::Display for OutcomeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
match reason {
FailureReason::JitRequestFailed => "jit_request_failed",
FailureReason::JitExpired => "jit_expired",
FailureReason::RunnerPackageUnverified => "runner_package_unverified",
FailureReason::RunnerVersionRejected => "runner_version_rejected",
FailureReason::ProcessStartFailed => "process_start_failed",
FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
FailureReason::RegistrationTimedOut => "registration_timed_out",
FailureReason::TerminatedAfterRegistrationTimeout => {
"terminated_after_registration_timeout"
}
FailureReason::IsolationProvider(category) => match category {
IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
IsolationProviderFailure::ImageUnavailableOrIncompatible => {
"isolation_image_unavailable"
}
IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
IsolationProviderFailure::Degraded => "isolation_provider_degraded",
IsolationProviderFailure::RuntimeOperationFailed => {
"isolation_runtime_operation_failed"
}
IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
},
FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
FailureReason::HostUnfit => "host_unfit",
FailureReason::Other(_) => "other",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RoutingRefusal {
OverlappingProfiles,
}
impl RoutingRefusal {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::OverlappingProfiles => "overlapping_profile_matches",
}
}
}
impl fmt::Display for RoutingRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::OverlappingProfiles => {
"repair overlapping profile labels or require a unique profile selector"
}
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleEvent {
DemandObserved {
policy: PolicyId,
demand: u32,
not_matched: u32,
unresolvable: u32,
complete: bool,
},
RoutingRefused {
policy: PolicyId,
reason: RoutingRefusal,
count: u32,
},
TargetUnreadable {
policy: PolicyId,
reason: &'static str,
},
Allocated {
policy: PolicyId,
demand: u32,
desired: u16,
active_owned: u16,
headroom: u16,
to_start: u16,
limiting: LimitingFactor,
},
MonitorOnlySkipped { policy: PolicyId },
RunnerStarted {
policy: PolicyId,
attempt: AttemptId,
},
RunnerStartFailed {
policy: PolicyId,
reason: &'static str,
},
AllocationDeferred { policy: PolicyId, count: u16 },
AttemptsUnreadable { reason: &'static str },
AttemptCleaned {
policy: PolicyId,
attempt: AttemptId,
outcome: OutcomeKind,
},
AttemptCleanFailed {
policy: PolicyId,
attempt: AttemptId,
reason: &'static str,
},
ScaleDownRefused {
policy: PolicyId,
attempt: AttemptId,
},
PollScheduled { retry_in_ms: u64, pace: PollPace },
}
impl LifecycleEvent {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::DemandObserved { .. } => "demand_observed",
Self::RoutingRefused { .. } => "routing_refused",
Self::TargetUnreadable { .. } => "target_unreadable",
Self::Allocated { .. } => "allocated",
Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
Self::RunnerStarted { .. } => "runner_started",
Self::RunnerStartFailed { .. } => "runner_start_failed",
Self::AllocationDeferred { .. } => "allocation_deferred",
Self::AttemptsUnreadable { .. } => "attempts_unreadable",
Self::AttemptCleaned { .. } => "attempt_cleaned",
Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
Self::ScaleDownRefused { .. } => "scale_down_refused",
Self::PollScheduled { .. } => "poll_scheduled",
}
}
#[must_use]
pub const fn policy(&self) -> Option<PolicyId> {
match self {
Self::DemandObserved { policy, .. }
| Self::RoutingRefused { policy, .. }
| Self::TargetUnreadable { policy, .. }
| Self::Allocated { policy, .. }
| Self::MonitorOnlySkipped { policy }
| Self::RunnerStarted { policy, .. }
| Self::RunnerStartFailed { policy, .. }
| Self::AllocationDeferred { policy, .. }
| Self::AttemptCleaned { policy, .. }
| Self::AttemptCleanFailed { policy, .. }
| Self::ScaleDownRefused { policy, .. } => Some(*policy),
Self::PollScheduled { .. } | Self::AttemptsUnreadable { .. } => None,
}
}
}
impl fmt::Display for LifecycleEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DemandObserved {
policy,
demand,
not_matched,
unresolvable,
complete,
} => write!(
f,
"policy {policy}: {demand} queued jobs for this host{}{}{}",
if *not_matched == 0 {
String::new()
} else {
format!(", {not_matched} for other labels")
},
if *unresolvable == 0 {
String::new()
} else {
format!(", {unresolvable} with an unresolvable `runs-on`")
},
if *complete {
""
} else {
" (a floor, not a total)"
}
),
Self::TargetUnreadable { policy, reason } => {
write!(f, "policy {policy}: target unreadable ({reason})")
}
Self::RoutingRefused {
policy,
reason,
count,
} => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
Self::Allocated {
policy,
demand,
desired,
active_owned,
headroom,
to_start,
limiting,
} => write!(
f,
"policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
flight, {headroom} free on this host, starting {to_start} ({limiting})"
),
Self::MonitorOnlySkipped { policy } => {
write!(f, "policy {policy}: monitor-only, skipped")
}
Self::RunnerStarted { policy, attempt } => {
write!(f, "policy {policy}: started attempt {attempt}")
}
Self::RunnerStartFailed { policy, reason } => {
write!(f, "policy {policy}: could not start a runner ({reason})")
}
Self::AllocationDeferred { policy, count } => write!(
f,
"policy {policy}: the allocation lock was held; {count} granted runners \
were not created"
),
Self::AttemptsUnreadable { reason } => write!(
f,
"the host's attempt set could not be read ({reason}); nothing was started, \
and this is not the same as the host being idle"
),
Self::AttemptCleaned {
policy,
attempt,
outcome,
} => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
Self::AttemptCleanFailed {
policy,
attempt,
reason,
} => write!(
f,
"policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
will be retried"
),
Self::ScaleDownRefused { policy, attempt } => write!(
f,
"policy {policy}: attempt {attempt} is executing a job and was not removed"
),
Self::PollScheduled { retry_in_ms, pace } => {
write!(f, "next poll in {retry_in_ms}ms ({pace})")
}
}
}
}
pub trait EventSink: fmt::Debug + Send + Sync {
fn emit(&self, event: LifecycleEvent);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoEvents;
impl EventSink for NoEvents {
fn emit(&self, _event: LifecycleEvent) {}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TracingEvents;
impl EventSink for TracingEvents {
fn emit(&self, event: LifecycleEvent) {
let name = event.name();
match event {
LifecycleEvent::DemandObserved {
policy,
demand,
not_matched,
unresolvable,
complete,
} => {
if demand == 0 && not_matched == 0 && unresolvable == 0 && complete {
tracing::debug!(
event = name,
policy_id = %policy,
demand,
not_matched,
unresolvable,
count = u64::from(complete),
);
} else {
tracing::info!(
event = name,
policy_id = %policy,
demand,
not_matched,
unresolvable,
count = u64::from(complete),
);
}
}
LifecycleEvent::TargetUnreadable { policy, reason } => {
tracing::warn!(event = name, policy_id = %policy, reason);
}
LifecycleEvent::RoutingRefused {
policy,
reason,
count,
} => tracing::warn!(
event = name,
policy_id = %policy,
reason = reason.as_str(),
count,
"{reason}"
),
LifecycleEvent::Allocated {
policy,
demand,
desired,
active_owned,
headroom,
to_start,
limiting,
} if demand == 0 && to_start == 0 && active_owned == 0 => tracing::debug!(
event = name,
policy_id = %policy,
demand,
desired,
capacity = active_owned,
headroom,
count = to_start,
reason = %limiting,
),
LifecycleEvent::Allocated {
policy,
demand,
desired,
active_owned,
headroom,
to_start,
limiting,
} => tracing::info!(
event = name,
policy_id = %policy,
demand,
desired,
capacity = active_owned,
headroom,
count = to_start,
reason = %limiting,
),
LifecycleEvent::MonitorOnlySkipped { policy } => {
tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
}
LifecycleEvent::RunnerStarted { policy, attempt } => {
tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
}
LifecycleEvent::RunnerStartFailed { policy, reason } => {
tracing::warn!(event = name, policy_id = %policy, reason);
}
LifecycleEvent::AllocationDeferred { policy, count } => {
tracing::warn!(
event = name,
policy_id = %policy,
reason = "allocation_lock_unavailable",
count
);
}
LifecycleEvent::AttemptsUnreadable { reason } => {
tracing::warn!(event = name, reason);
}
LifecycleEvent::AttemptCleaned {
policy,
attempt,
outcome,
} => tracing::info!(
event = name,
policy_id = %policy,
attempt_id = %attempt,
outcome = outcome.as_str(),
),
LifecycleEvent::AttemptCleanFailed {
policy,
attempt,
reason,
} => tracing::warn!(
event = name,
policy_id = %policy,
attempt_id = %attempt,
reason,
),
LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
event = name,
policy_id = %policy,
attempt_id = %attempt,
attempt_state = "busy",
),
LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
if pace == PollPace::Idle {
tracing::debug!(event = name, retry_in_ms, state = pace.as_str());
} else {
tracing::info!(event = name, retry_in_ms, state = pace.as_str());
}
}
}
}
}
#[derive(Debug, Default)]
pub struct EventLog {
events: Mutex<Vec<LifecycleEvent>>,
}
impl EventLog {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn events(&self) -> Vec<LifecycleEvent> {
self.events.lock().map(|e| e.clone()).unwrap_or_default()
}
#[must_use]
pub fn count_of(&self, name: &str) -> usize {
self.events()
.iter()
.filter(|event| event.name() == name)
.count()
}
}
impl EventSink for EventLog {
fn emit(&self, event: LifecycleEvent) {
if let Ok(mut events) = self.events.lock() {
events.push(event);
}
}
}
#[derive(Debug)]
pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
impl EventSink for TeeEvents {
fn emit(&self, event: LifecycleEvent) {
self.0.emit(event);
self.1.emit(event);
}
}
pub struct ReconcilerPorts {
pub demand: Arc<dyn DemandSource>,
pub launcher: Arc<dyn RunnerLauncher>,
pub lock: Arc<dyn AllocationLock>,
pub directory: Arc<dyn RepositoryDirectory>,
pub clock: Arc<dyn Clock>,
pub jitter: Arc<dyn Jitter>,
pub events: Arc<dyn EventSink>,
}
impl fmt::Debug for ReconcilerPorts {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Default)]
pub struct ReconcileReport {
pub allocations: Vec<Allocation>,
pub monitor_only: Vec<PolicyId>,
pub unreadable: Vec<PolicyId>,
pub targets_read: u16,
pub started: u16,
pub replacement_intents: u16,
pub cleaned: u16,
pub idle_exits: u16,
pub failures: u16,
pub deferred: u16,
pub attempts_unreadable: u16,
pub clean_failures: u16,
pub failure: Option<RefreshState>,
pub offline: Option<OfflineState>,
pub next_poll: NextPoll,
pub demand_requests: u32,
}
impl ReconcileReport {
#[must_use]
pub const fn reached_github(&self) -> bool {
self.targets_read > 0
}
}
impl Default for NextPoll {
fn default() -> Self {
Self {
delay: PollSchedule::floor(),
pace: PollPace::Nominal,
}
}
}
impl ReconcileReport {
#[must_use]
pub fn is_offline(&self) -> bool {
matches!(self.failure, Some(RefreshState::Offline))
}
#[must_use]
pub const fn offline_state(&self) -> Option<&OfflineState> {
self.offline.as_ref()
}
#[must_use]
pub const fn starts_nothing(&self) -> bool {
self.started == 0
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ScaleDownReport {
pub removed: u16,
pub refused_busy: u16,
pub clean_failures: u16,
pub retained: u16,
pub attempts_unreadable: bool,
}
impl ScaleDownReport {
#[must_use]
pub const fn is_conclusive(&self) -> bool {
!self.attempts_unreadable
}
}
const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Clone, Copy)]
struct CleanRetry {
failures: u32,
not_before: Timestamp,
}
#[derive(Debug, Default)]
pub(crate) struct CleanBackoff {
retries: BTreeMap<AttemptId, CleanRetry>,
}
impl CleanBackoff {
const SCHEDULE: crate::lifecycle::RetryPolicy =
crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
self.retries
.get(&attempt)
.is_none_or(|retry| now >= retry.not_before)
}
pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
let failures = self
.retries
.get(&attempt)
.map_or(1, |retry| retry.failures.saturating_add(1));
let delay = Self::SCHEDULE.delay(failures);
self.retries.insert(
attempt,
CleanRetry {
failures,
not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
},
);
}
pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
self.retries.remove(&attempt);
}
fn retain(&mut self, present: &BTreeSet<AttemptId>) {
self.retries.retain(|attempt, _| present.contains(attempt));
}
}
#[derive(Debug)]
pub struct Reconciler {
host: Host,
demand: Arc<dyn DemandSource>,
launcher: Arc<dyn RunnerLauncher>,
lock: Arc<dyn AllocationLock>,
repositories: RepositoryCache,
clock: Arc<dyn Clock>,
jitter: Arc<dyn Jitter>,
events: Arc<dyn EventSink>,
schedule: PollSchedule,
clean_backoff: CleanBackoff,
}
impl Reconciler {
#[must_use]
pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
let interval = host.refresh_interval;
let repositories = RepositoryCache::new(
Arc::clone(&ports.directory),
Arc::clone(&ports.clock),
interval,
);
Self {
host,
demand: ports.demand,
launcher: ports.launcher,
lock: ports.lock,
repositories,
clock: ports.clock,
jitter: ports.jitter,
events: ports.events,
schedule: PollSchedule::new(interval),
clean_backoff: CleanBackoff::default(),
}
}
#[must_use]
pub const fn host(&self) -> &Host {
&self.host
}
#[must_use]
pub const fn schedule(&self) -> &PollSchedule {
&self.schedule
}
pub fn set_intervals(&mut self, active: RefreshInterval, idle: Duration) {
self.host.refresh_interval = active;
self.schedule.set_intervals(active, idle);
}
#[must_use]
pub const fn repositories(&self) -> &RepositoryCache {
&self.repositories
}
pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
let mut report = ReconcileReport::default();
let mut launched: Vec<RunnerAttempt> = Vec::new();
let mut pollable: Vec<&ScalePolicy> = Vec::new();
let mut supervision_failed = BTreeSet::new();
for policy in policies {
if !policy.owns_runners() {
report.monitor_only.push(policy.id);
self.events
.emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
continue;
}
if !policy.is_owned_by(self.host.id) {
continue;
}
match self.launcher.supervise(policy).await {
Ok(intents) => {
report.replacement_intents = report
.replacement_intents
.saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
}
Err(failure) => {
supervision_failed.insert(policy.id);
self.report_unreadable_attempts(&mut report, &failure);
continue;
}
}
if !policy.may_start_runners() {
continue;
}
pollable.push(policy);
}
let readings = self.poll_targets(&pollable, &mut report).await;
report.targets_read = u16::try_from(
readings
.values()
.filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
.count(),
)
.unwrap_or(u16::MAX);
let mut routed = BTreeMap::new();
for (target, outcome) in &readings {
let PollOutcome::Ready(reading) = outcome else {
continue;
};
let profiles: Vec<&ScalePolicy> = pollable
.iter()
.copied()
.filter(|policy| &policy.target == target)
.collect();
let (tallies, refusals) = route_demand(policies, &profiles, reading);
if let Some(first) = profiles.first() {
for (reason, count) in refusals {
self.events.emit(LifecycleEvent::RoutingRefused {
policy: first.id,
reason,
count,
});
}
}
routed.insert(target.clone(), tallies);
}
self.clean_terminal_attempts(&mut report).await;
for policy in policies {
if !policy.owns_runners() {
continue;
}
if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
match self.allocate_only(policy, 0, &launched).await {
Ok(allocation) => {
self.emit_allocation(&allocation);
report.allocations.push(allocation);
}
Err(failure) => self.report_unreadable_attempts(&mut report, &failure),
}
continue;
}
if supervision_failed.contains(&policy.id) {
continue;
}
let Some(reading) = readings.get(&policy.target) else {
debug_assert!(false, "a pollable policy's target has no reading");
continue;
};
match reading {
PollOutcome::Failed(state) => {
report.unreadable.push(policy.id);
self.events.emit(LifecycleEvent::TargetUnreadable {
policy: policy.id,
reason: unreadable_reason(state),
});
}
PollOutcome::Ready(demand) => {
let tally = routed
.get(&policy.target)
.and_then(|tallies| tallies.get(&policy.id))
.expect("every pollable profile has a routed tally");
let count = tally.demand();
self.events.emit(LifecycleEvent::DemandObserved {
policy: policy.id,
demand: count,
not_matched: tally.not_matched,
unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
complete: demand.is_complete(),
});
self.start_runners(policy, count, &mut report, &mut launched)
.await;
}
}
}
let failure = readings
.values()
.filter_map(PollOutcome::failure)
.max_by_key(|state| severity(state))
.cloned();
let now = self.clock.now();
let conditions = PollConditions {
quiet: is_quiet(&readings, &report),
requests_per_pass: report.demand_requests,
quota: readings
.values()
.filter_map(PollOutcome::reading)
.filter_map(QueuedDemand::rate_limit)
.max_by_key(|quota| quota.observed_at)
.copied(),
};
report.next_poll =
self.schedule
.next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
report.failure = failure;
if let PollPace::Offline { consecutive } = report.next_poll.pace {
let state = OfflineState::new(consecutive, report.next_poll.delay);
report.offline = Some(match self.schedule.offline_for(now) {
Some(elapsed) => state.since(elapsed),
None => state,
});
}
self.events.emit(LifecycleEvent::PollScheduled {
retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
pace: report.next_poll.pace,
});
report
}
async fn poll_targets(
&self,
pollable: &[&ScalePolicy],
report: &mut ReconcileReport,
) -> BTreeMap<ScaleTarget, PollOutcome> {
let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
let mut readings = BTreeMap::new();
for target in targets {
let outcome = match self.repositories.scope_for(&target).await {
Ok(scope) => {
report.demand_requests = report
.demand_requests
.saturating_add(demand_requests_per_poll(&scope));
self.demand.poll(&scope).await
}
Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
};
readings.insert(target, outcome);
}
readings
}
async fn allocate_only(
&self,
policy: &ScalePolicy,
demand: u32,
launched: &[RunnerAttempt],
) -> Result<Allocation, LaunchFailure> {
let attempts = self.host_attempts(launched).await?;
let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
Ok(allocator.allocate(policy, demand))
}
async fn start_runners(
&self,
policy: &ScalePolicy,
demand: u32,
report: &mut ReconcileReport,
launched: &mut Vec<RunnerAttempt>,
) {
let intent = match self.allocate_only(policy, demand, launched).await {
Ok(intent) => intent,
Err(failure) => {
self.report_unreadable_attempts(report, &failure);
return;
}
};
let mut decided: Option<Allocation> = None;
let mut budget = intent.to_start;
while budget > 0 {
let waiting_since = std::time::Instant::now();
let guard = match self.lock.acquire().await {
Ok(guard) => guard,
Err(_) => {
report.deferred = report.deferred.saturating_add(budget);
self.events.emit(LifecycleEvent::AllocationDeferred {
policy: policy.id,
count: budget,
});
break;
}
};
let lock_wait = waiting_since.elapsed();
let attempts = match self.host_attempts(launched).await {
Ok(attempts) => attempts,
Err(failure) => {
drop(guard);
self.report_unreadable_attempts(report, &failure);
break;
}
};
let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
let allocation = allocator.allocate(policy, demand);
if decided.is_none() {
budget = budget.min(allocation.to_start);
decided = Some(allocation.clone());
}
if allocation.starts_nothing() || budget == 0 {
drop(guard);
break;
}
let created = self
.launcher
.launch(LaunchRequest {
host: &self.host,
policy,
allocation_guard: &guard,
lock_wait,
})
.await;
drop(guard);
match created {
Ok(attempt) => {
let id = attempt.id;
launched.push(attempt);
report.started = report.started.saturating_add(1);
budget -= 1;
self.events.emit(LifecycleEvent::RunnerStarted {
policy: policy.id,
attempt: id,
});
}
Err(failure) => {
self.events.emit(LifecycleEvent::RunnerStartFailed {
policy: policy.id,
reason: failure_reason_kind(&failure.reason),
});
break;
}
}
}
let allocation = decided.unwrap_or(intent);
self.emit_allocation(&allocation);
report.allocations.push(allocation);
}
async fn host_attempts(
&self,
launched: &[RunnerAttempt],
) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
let mut attempts = self.launcher.attempts().await?;
debug_assert!(
launched
.iter()
.map(|attempt| attempt.id)
.collect::<BTreeSet<AttemptId>>()
.len()
== launched.len(),
"`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
the host ceiling is enforced against a set keyed on that identifier, so a \
duplicate is two runtimes counted as one"
);
let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
attempts.extend(
launched
.iter()
.filter(|attempt| !known.contains(&attempt.id))
.cloned(),
);
Ok(attempts)
}
fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
self.events.emit(LifecycleEvent::AttemptsUnreadable {
reason: failure_reason_kind(&failure.reason),
});
}
async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
let attempts = match self.launcher.attempts().await {
Ok(attempts) => attempts,
Err(failure) => {
self.report_unreadable_attempts(report, &failure);
return;
}
};
self.clean_backoff
.retain(&attempts.iter().map(|attempt| attempt.id).collect());
let now = self.clock.now();
for attempt in attempts {
if !attempt.state().is_concluded() {
continue;
}
let Some(outcome) = attempt.outcome() else {
continue;
};
if !self.clean_backoff.is_due(attempt.id, now) {
continue;
}
let kind = OutcomeKind::of(outcome);
match self.launcher.clean(attempt.id).await {
Ok(()) => {
self.clean_backoff.succeeded(attempt.id);
report.cleaned = report.cleaned.saturating_add(1);
if kind.is_failure() {
report.failures = report.failures.saturating_add(1);
} else if kind == OutcomeKind::IdleExit {
report.idle_exits = report.idle_exits.saturating_add(1);
}
self.events.emit(LifecycleEvent::AttemptCleaned {
policy: attempt.policy_id,
attempt: attempt.id,
outcome: kind,
});
}
Err(failure) => {
self.clean_backoff.failed(attempt.id, now);
report.clean_failures = report.clean_failures.saturating_add(1);
self.events.emit(LifecycleEvent::AttemptCleanFailed {
policy: attempt.policy_id,
attempt: attempt.id,
reason: failure_reason_kind(&failure.reason),
});
}
}
}
}
pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
let mut report = ScaleDownReport::default();
let attempts = match self.launcher.attempts().await {
Ok(attempts) => attempts,
Err(failure) => {
self.events.emit(LifecycleEvent::AttemptsUnreadable {
reason: failure_reason_kind(&failure.reason),
});
report.attempts_unreadable = true;
return report;
}
};
for attempt in attempts {
if attempt.policy_id != policy.id {
continue;
}
match attempt.state() {
AttemptState::Busy => {
report.refused_busy = report.refused_busy.saturating_add(1);
self.events.emit(LifecycleEvent::ScaleDownRefused {
policy: policy.id,
attempt: attempt.id,
});
}
state if state.is_concluded() => {
let kind = attempt
.outcome()
.map_or(OutcomeKind::Failed, OutcomeKind::of);
match self.launcher.clean(attempt.id).await {
Ok(()) => {
report.removed = report.removed.saturating_add(1);
self.events.emit(LifecycleEvent::AttemptCleaned {
policy: policy.id,
attempt: attempt.id,
outcome: kind,
});
}
Err(failure) => {
report.clean_failures = report.clean_failures.saturating_add(1);
self.events.emit(LifecycleEvent::AttemptCleanFailed {
policy: policy.id,
attempt: attempt.id,
reason: failure_reason_kind(&failure.reason),
});
}
}
}
AttemptState::Cleaned => {}
_ => report.retained = report.retained.saturating_add(1),
}
}
report
}
fn emit_allocation(&self, allocation: &Allocation) {
self.events.emit(LifecycleEvent::Allocated {
policy: allocation.policy_id,
demand: allocation.demand,
desired: allocation.desired,
active_owned: allocation.active_owned,
headroom: allocation.headroom_before,
to_start: allocation.to_start,
limiting: allocation.limiting_factor,
});
}
}
fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
let Some(labels) = policy.routing_labels() else {
debug_assert!(
false,
"a monitor-only policy is skipped before the demand poll (D19)"
);
return DemandTally::default();
};
match &policy.target {
ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
}
}
fn route_demand(
all_policies: &[ScalePolicy],
profiles: &[&ScalePolicy],
reading: &QueuedDemand,
) -> (
BTreeMap<PolicyId, DemandTally>,
BTreeMap<RoutingRefusal, u32>,
) {
let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
.iter()
.map(|policy| (policy.id, demand_for(policy, reading)))
.collect();
let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
let Some(first) = profiles.first() else {
return (tallies, refusals);
};
let owners: Vec<_> = all_policies
.iter()
.filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
.filter(|policy| policy.owns_runners())
.collect();
let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
ScaleTarget::Organization(_) => reading.jobs().collect(),
};
for job in jobs {
let Ok(_) = job.required_labels() else {
continue;
};
let matched: Vec<PolicyId> = owners
.iter()
.filter(|policy| {
policy
.routing_labels()
.is_some_and(|labels| labels.matches(job).is_match())
})
.map(|policy| policy.id)
.collect();
if matched.len() > 1 {
*refusals
.entry(RoutingRefusal::OverlappingProfiles)
.or_default() += 1;
for id in matched {
if let Some(tally) = tallies.get_mut(&id) {
tally.matched -= 1;
tally.not_matched += 1;
}
}
}
}
(tallies, refusals)
}
const fn severity(state: &RefreshState) -> u8 {
match state {
RefreshState::Offline => 5,
RefreshState::RateLimited(_) => 4,
RefreshState::LockedOut { .. } => 3,
RefreshState::Unauthorized => 2,
RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
RefreshState::Cancelled | RefreshState::Ready(_) => 0,
}
}
const fn unreadable_reason(state: &RefreshState) -> &'static str {
match state {
RefreshState::Ready(_) => "ready",
RefreshState::Offline => "offline",
RefreshState::RateLimited(_) => "rate_limited",
RefreshState::LockedOut { .. } => "locked_out",
RefreshState::Unauthorized => "unauthorized",
RefreshState::Forbidden { .. } => "forbidden",
RefreshState::Failed { .. } => "failed",
RefreshState::Cancelled => "cancelled",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
let mut report = ReconcileReport::default();
assert!(
!report.reached_github(),
"a pass that polled nothing -- every policy draining, owned elsewhere, or \
monitor-only -- reached nobody. This is the case the old guard let through, and \
the only one it ever let through."
);
report.unreadable.push(PolicyId::from_u128(1));
assert!(
!report.reached_github(),
"every target this pass tried was unreadable, so there is no contact to record"
);
report.targets_read = 1;
assert!(
report.reached_github(),
"one target answering is contact, whatever else failed alongside it"
);
let mut unowned = ReconcileReport::default();
unowned.unreadable.push(PolicyId::from_u128(2));
unowned.allocations.push(Allocation {
policy_id: PolicyId::from_u128(2),
demand: 0,
desired: 0,
active_owned: 0,
headroom_before: 0,
to_start: 0,
limiting_factor: LimitingFactor::Demand,
});
assert!(
!unowned.reached_github(),
"an allocation is not evidence that GitHub answered"
);
}
#[test]
fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
assert!(
severity(&RefreshState::Unauthorized) > 0,
"an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
or a pass where every target was refused would report no failure at all"
);
}
use std::sync::atomic::AtomicUsize;
use std::num::NonZeroU16;
use runner_manager_domain::attempt::PersistedAttempt;
use runner_manager_domain::model::{CachePolicy, HostId};
use runner_manager_domain::policy::PolicyMode;
use runner_manager_domain::workspace::WorkspaceKind;
use runner_manager_github::rest::RateLimited;
use runner_manager_testkit::clock::FakeClock;
use runner_manager_testkit::fixtures;
use runner_manager_testkit::github::FakeGithub;
fn host_with(capacity: u16) -> Host {
fixtures::host().capacity(capacity).build()
}
fn repo(raw: &str) -> OwnerRepo {
OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
}
use runner_manager_domain::policy::RunsOn;
fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
fixtures::policy()
.id(PolicyId::from_u128(id))
.repository(target)
.autoscale("home", max)
.active()
.build()
}
const HOST_LABEL: &str = "rm-home-win-x64";
fn jobs(n: usize) -> Vec<RunsOn> {
fixtures::queued_jobs(&[HOST_LABEL], n)
}
#[derive(Debug, Default)]
struct FakeLauncher {
attempts: Mutex<Vec<RunnerAttempt>>,
next_id: AtomicU64,
launches: AtomicUsize,
cleaned: Mutex<Vec<AttemptId>>,
yields_before_recording: usize,
forgetful: bool,
fail_next: Mutex<Option<FailureReason>>,
attempts_fail: Mutex<bool>,
clean_fails: bool,
replacements: Mutex<Vec<ReplacementIntent>>,
}
impl FakeLauncher {
fn new() -> Self {
Self::default()
}
fn with_yields(mut self, yields: usize) -> Self {
self.yields_before_recording = yields;
self
}
fn forgetful() -> Self {
Self {
forgetful: true,
..Self::default()
}
}
fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
*self.attempts.lock().unwrap() = attempts;
self
}
fn launches(&self) -> usize {
self.launches.load(Ordering::SeqCst)
}
fn snapshot(&self) -> Vec<RunnerAttempt> {
self.attempts.lock().unwrap().clone()
}
fn live_count(&self) -> usize {
self.snapshot()
.iter()
.filter(|a| a.counts_against_capacity())
.count()
}
fn fail_next(&self, reason: FailureReason) {
*self.fail_next.lock().unwrap() = Some(reason);
}
fn fail_attempts(&self, failing: bool) {
*self.attempts_fail.lock().unwrap() = failing;
}
fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
Self {
clean_fails: true,
..Self::default()
}
.seeded(attempts)
}
fn cleaned(&self) -> Vec<AttemptId> {
self.cleaned.lock().unwrap().clone()
}
fn replacing(self, intent: ReplacementIntent) -> Self {
self.replacements.lock().unwrap().push(intent);
self
}
}
#[async_trait::async_trait]
impl RunnerLauncher for FakeLauncher {
async fn supervise(
&self,
policy: &ScalePolicy,
) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
let mut replacements = self.replacements.lock().unwrap();
let selected: Vec<_> = replacements
.extract_if(.., |intent| intent.policy == policy.id)
.collect();
if !selected.is_empty() {
let retired: BTreeSet<_> = selected
.iter()
.map(|intent| intent.previous_attempt)
.collect();
self.attempts
.lock()
.unwrap()
.retain(|attempt| !retired.contains(&attempt.id));
}
Ok(selected)
}
async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
if *self.attempts_fail.lock().unwrap() {
return Err(LaunchFailure::new(FailureReason::Other(
"the journal could not be read".into(),
)));
}
Ok(self.snapshot())
}
async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
if let Some(reason) = self.fail_next.lock().unwrap().take() {
return Err(LaunchFailure::new(reason));
}
for _ in 0..self.yields_before_recording {
tokio::task::yield_now().await;
}
let id =
AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
let created = RunnerAttempt::allocate(
id,
request.policy.id,
"runtime/p/a",
request.host.created_at,
);
self.launches.fetch_add(1, Ordering::SeqCst);
if !self.forgetful {
self.attempts.lock().unwrap().push(created.clone());
}
Ok(created)
}
async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
if self.clean_fails {
return Err(LaunchFailure::new(FailureReason::Other(
"the runtime directory is locked".into(),
)));
}
self.cleaned.lock().unwrap().push(attempt);
let mut attempts = self.attempts.lock().unwrap();
attempts.retain(|a| a.id != attempt);
Ok(())
}
}
#[derive(Debug, Default)]
struct FakeDemand {
outcome: Mutex<Option<PollOutcome>>,
per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
scopes: Mutex<Vec<ActivityScope>>,
}
impl FakeDemand {
fn ready(count: u32, repository: &OwnerRepo) -> Self {
let fake = Self::default();
fake.set(PollOutcome::Ready(QueuedDemand::of(
repository.clone(),
jobs(count as usize),
)));
fake
}
fn failing(state: RefreshState) -> Self {
let fake = Self::default();
fake.set(PollOutcome::Failed(state));
fake
}
fn set(&self, outcome: PollOutcome) {
*self.outcome.lock().unwrap() = Some(outcome);
}
fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
self.per_target
.lock()
.unwrap()
.insert(target.clone(), outcome);
}
fn polls(&self) -> Vec<ActivityScope> {
self.scopes.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl DemandSource for FakeDemand {
async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
self.scopes.lock().unwrap().push(scope.clone());
if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
return outcome.clone();
}
self.outcome
.lock()
.unwrap()
.clone()
.unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
}
}
#[derive(Debug, Default)]
struct FakeDirectory {
repositories: Vec<OwnerRepo>,
calls: AtomicUsize,
}
impl FakeDirectory {
fn of(repositories: Vec<OwnerRepo>) -> Self {
Self {
repositories,
calls: AtomicUsize::new(0),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl RepositoryDirectory for FakeDirectory {
async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.repositories.clone())
}
}
#[derive(Debug)]
struct CountingLock {
inner: InProcessAllocationLock,
concurrent: Arc<AtomicUsize>,
peak: Arc<AtomicUsize>,
acquisitions: Arc<AtomicUsize>,
}
impl CountingLock {
fn new() -> Self {
Self {
inner: InProcessAllocationLock::new(),
concurrent: Arc::new(AtomicUsize::new(0)),
peak: Arc::new(AtomicUsize::new(0)),
acquisitions: Arc::new(AtomicUsize::new(0)),
}
}
fn peak(&self) -> usize {
self.peak.load(Ordering::SeqCst)
}
fn acquisitions(&self) -> usize {
self.acquisitions.load(Ordering::SeqCst)
}
}
#[derive(Debug)]
struct CountingGuard {
_inner: AllocationGuard,
concurrent: Arc<AtomicUsize>,
}
impl Drop for CountingGuard {
fn drop(&mut self) {
self.concurrent.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait::async_trait]
impl AllocationLock for CountingLock {
async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
let inner = self.inner.acquire().await?;
self.acquisitions.fetch_add(1, Ordering::SeqCst);
let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
self.peak.fetch_max(now, Ordering::SeqCst);
Ok(AllocationGuard::new(CountingGuard {
_inner: inner,
concurrent: Arc::clone(&self.concurrent),
}))
}
}
#[derive(Debug, Default)]
struct NoLock;
#[async_trait::async_trait]
impl AllocationLock for NoLock {
async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
Ok(AllocationGuard::new(()))
}
}
#[tokio::test]
async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
use runner_manager_platform::wsl::fence::DrainRequest;
let (_local, shared, paths) = guest_paths();
DrainRequest::new(4, chrono::Utc::now())
.write(shared.path())
.unwrap();
let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
assert!(lock.acquire().await.is_err());
}
#[tokio::test]
async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
let (_local, shared, paths) = guest_paths();
let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
let guard = lock.acquire().await.unwrap();
assert!(shared.path().join(FENCE_DIRECTORY).exists());
drop(guard);
assert!(!shared.path().join(FENCE_DIRECTORY).exists());
}
#[derive(Clone, Default)]
struct Logs(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Logs {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
let (_local, shared, paths) = guest_paths();
let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
let guard = lock.acquire().await.unwrap();
let fence = shared.path().join(FENCE_DIRECTORY);
std::fs::remove_dir_all(&fence).unwrap();
std::fs::write(&fence, b"not a directory").unwrap();
let logs = Logs::default();
let writer = logs.clone();
let subscriber = tracing_subscriber::fmt()
.with_writer(move || writer.clone())
.with_ansi(false)
.finish();
tracing::subscriber::with_default(subscriber, || drop(guard));
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
assert!(text.contains("WARN"), "{text}");
assert!(text.contains("could not be released"), "{text}");
assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
}
fn guest_paths() -> (
tempfile::TempDir,
tempfile::TempDir,
Arc<runner_manager_platform::paths::AppPaths>,
) {
use runner_manager_platform::paths::AppPaths;
use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
let local = tempfile::tempdir().unwrap();
let shared = tempfile::tempdir().unwrap();
let paths = Arc::new(AppPaths::rooted_at(local.path()));
paths.create_all().unwrap();
GuestRecoveryConfig::new(shared.path().to_path_buf())
.write(&paths)
.unwrap();
(local, shared, paths)
}
#[tokio::test]
async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
let (_local, shared, paths) = guest_paths();
let fence = shared.path().join(FENCE_DIRECTORY);
std::fs::create_dir_all(&fence).unwrap();
let pid = std::process::id();
std::fs::write(
fence.join(OWNER_FILE),
format!(
r#"{{"schema_version":1,"kind":"guest_launch","generation":null,"process_id":{pid},"acquired_at":"2026-09-24T00:47:18.724Z","identity":{{"pid":{pid},"start_token":"linux:earlier-boot:1"}}}}"#
),
)
.unwrap();
let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
let guard = lock.acquire().await.expect("the stale claim was reclaimed");
drop(guard);
assert!(!fence.exists());
}
#[tokio::test]
async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
use runner_manager_platform::launch_health::recorded_launches_blocked;
use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
let (_local, shared, paths) = guest_paths();
let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
.with_blocked_after(3, Duration::ZERO);
let windows =
FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
.unwrap()
.unwrap();
for _ in 0..2 {
assert!(lock.acquire().await.is_err());
}
assert_eq!(
recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
None,
"not yet"
);
assert!(lock.acquire().await.is_err());
let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
.unwrap()
.expect("three refusals in a row are recorded");
assert!(
blocked.reason.contains("Windows WSL recovery"),
"{blocked:?}"
);
windows.release().unwrap();
let guard = lock.acquire().await.unwrap();
assert_eq!(
recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
None
);
drop(guard);
}
#[tokio::test]
async fn refusals_while_this_process_is_launching_are_its_own_contention() {
use runner_manager_platform::launch_health::recorded_launches_blocked;
let (_local, _shared, paths) = guest_paths();
let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
.with_blocked_after(1, Duration::ZERO);
let launching = lock.acquire().await.unwrap();
for _ in 0..3 {
assert!(lock.acquire().await.is_err());
}
assert_eq!(
recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
None
);
drop(launching);
}
#[tokio::test]
async fn refusals_with_quiet_spells_between_them_never_add_up() {
use runner_manager_platform::launch_health::recorded_launches_blocked;
use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
let (_local, shared, paths) = guest_paths();
let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
.with_blocked_after(3, Duration::ZERO)
.with_streak_gap(Duration::ZERO);
let windows =
FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
.unwrap()
.unwrap();
for _ in 0..5 {
assert!(lock.acquire().await.is_err());
}
assert_eq!(
recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
None
);
windows.release().unwrap();
}
#[tokio::test]
async fn a_claim_refused_by_a_drain_request_is_released() {
use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
let (_local, shared, paths) = guest_paths();
DrainRequest::new(4, chrono::Utc::now())
.write(shared.path())
.unwrap();
let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
assert!(lock.acquire().await.is_err());
assert!(!shared.path().join(FENCE_DIRECTORY).exists());
}
#[derive(Debug, Default)]
struct HeldLock;
#[async_trait::async_trait]
impl AllocationLock for HeldLock {
async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
Err(AllocationLockBusy)
}
}
struct Harness {
launcher: Arc<FakeLauncher>,
demand: Arc<FakeDemand>,
events: Arc<EventLog>,
clock: Arc<FakeClock>,
reconciler: Reconciler,
}
impl Harness {
fn build(
host: Host,
launcher: Arc<FakeLauncher>,
demand: Arc<FakeDemand>,
lock: Arc<dyn AllocationLock>,
) -> Self {
let events = Arc::new(EventLog::new());
let clock = Arc::new(FakeClock::default());
let reconciler = Reconciler::new(
host,
ReconcilerPorts {
demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
lock,
directory: Arc::new(FakeDirectory::default()),
clock: Arc::clone(&clock) as Arc<dyn Clock>,
jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
events: Arc::clone(&events) as Arc<dyn EventSink>,
},
);
Self {
launcher,
demand,
events,
clock,
reconciler,
}
}
fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
let launcher = Arc::new(FakeLauncher::new());
let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
Self::build(
host_with(capacity),
launcher,
demand,
Arc::new(InProcessAllocationLock::new()),
)
}
}
fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
let outcome = state.is_terminal().then(|| match state {
AttemptState::Failed => {
AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
}
AttemptState::Orphaned => AttemptOutcome::Orphaned,
_ => AttemptOutcome::CompletedJob,
});
RunnerAttempt::from_persisted(PersistedAttempt {
id: AttemptId::from_u128(id),
policy_id: PolicyId::from_u128(policy),
github_runner_id: None,
state,
outcome,
process_id: None,
execution: runner_manager_domain::execution::AttemptExecution::Native {
process_id: None,
},
runtime_path: "runtime/p/a".into(),
workspace_kind: WorkspaceKind::Ephemeral,
workspace_slot: None,
created_at: fixtures::created_at(),
terminal_at: state.is_terminal().then(fixtures::created_at),
last_state_change_at: fixtures::created_at(),
})
.expect("a state/outcome pair the domain accepts")
}
fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
RunnerAttempt::from_persisted(PersistedAttempt {
id: AttemptId::from_u128(id),
policy_id: PolicyId::from_u128(policy),
github_runner_id: None,
state: outcome.terminal_state(),
outcome: Some(outcome),
process_id: None,
execution: runner_manager_domain::execution::AttemptExecution::Native {
process_id: None,
},
runtime_path: "runtime/p/a".into(),
workspace_kind: WorkspaceKind::Ephemeral,
workspace_slot: None,
created_at: fixtures::created_at(),
terminal_at: Some(fixtures::created_at()),
last_state_change_at: fixtures::created_at(),
})
.expect("a state/outcome pair the domain accepts")
}
#[tokio::test]
async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
let mut harness = Harness::simple(4, 1, "acme/app");
let policy = policy(1, "acme/app", 4);
for poll in 1..=3 {
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(
harness.launcher.launches(),
1,
"poll {poll} started another runner for a job already being served; the \
`- active_owned_runners` term reached `HostAllocator` as a set this host \
does not hold"
);
assert_eq!(report.allocations.len(), 1);
let allocation = &report.allocations[0];
assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
if poll == 1 {
assert_eq!(allocation.to_start, 1);
assert_eq!(report.started, 1);
} else {
assert_eq!(allocation.active_owned, 1, "poll {poll}");
assert_eq!(allocation.to_start, 0, "poll {poll}");
assert_eq!(report.started, 0, "poll {poll}");
}
}
assert_eq!(harness.launcher.live_count(), 1);
}
#[tokio::test]
async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
let launcher = Arc::new(FakeLauncher::forgetful());
let mut harness = Harness::build(
host_with(64),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 8)])
.await;
assert_eq!(
report.started, 3,
"the pass is bounded by the grant it was given, not by the attempt set catching \
up with it"
);
assert_eq!(launcher.launches(), 3);
assert!(
launcher.snapshot().is_empty(),
"the launcher never recorded anything, which is the whole point of the fixture"
);
}
#[tokio::test]
async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
let launcher = Arc::new(FakeLauncher::forgetful());
let demand = Arc::new(FakeDemand::default());
demand.set_for(
&ScaleTarget::repository("acme/left").unwrap(),
PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
);
demand.set_for(
&ScaleTarget::repository("acme/right").unwrap(),
PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
);
let mut harness = Harness::build(
host_with(3),
Arc::clone(&launcher),
demand,
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
.await;
assert_eq!(
report.started, 3,
"host_capacity is 3 and two policies each allowed 3 started {} runners \
between them; the second policy's grant was computed from a set that did \
not yet contain the first policy's launches",
report.started
);
assert_eq!(launcher.launches(), 3);
}
#[tokio::test]
async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
let launcher = Arc::new(FakeLauncher::new());
let mut harness = Harness::build(
host_with(8),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let policy = policy(1, "acme/app", 8);
launcher.fail_attempts(true);
let unreadable = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(
unreadable.started, 0,
"nothing may be decided from a set that was not read"
);
assert_eq!(launcher.launches(), 0);
assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
assert!(
unreadable.allocations.is_empty(),
"no allocation is reported either: there was no set to compute one from, and \
an allocation of zero would claim a decision nobody made"
);
assert!(harness.events.count_of("attempts_unreadable") > 0);
launcher.fail_attempts(false);
let readable = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(
readable.started, 4,
"the difference between the two passes is only whether the set could be read"
);
assert_eq!(readable.attempts_unreadable, 0);
}
#[tokio::test]
async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
1,
1,
AttemptOutcome::ExitedIdleWithoutWork,
)]));
let mut harness = Harness::build(
host_with(4),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 4)])
.await;
assert_eq!(report.cleaned, 0);
assert_eq!(report.clean_failures, 1);
assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
assert_eq!(
harness.events.count_of("attempt_cleaned"),
0,
"and it is not reported as cleaned"
);
assert_eq!(
launcher.snapshot().len(),
1,
"the attempt is still there, so the retry is real -- what changed is that it \
is no longer silent"
);
let reasons: Vec<&'static str> = harness
.events
.events()
.into_iter()
.filter_map(|event| match event {
LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
_ => None,
})
.collect();
assert_eq!(reasons, vec!["other"]);
}
#[tokio::test]
async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
1,
1,
AttemptOutcome::ExitedIdleWithoutWork,
)]));
let mut harness = Harness::build(
host_with(4),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let policies = [policy(1, "acme/app", 4)];
let start = harness.clock.now();
let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
let mut failures_at = async |offset: Duration| {
harness.clock.set(at(offset));
harness.reconciler.reconcile(&policies).await.clean_failures
};
assert_eq!(failures_at(Duration::ZERO).await, 1);
assert_eq!(
failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
0,
"a failed cleanup was retried on the very next pass"
);
assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
assert_eq!(
failures_at(second - Duration::from_secs(1)).await,
0,
"the delay did not double after a second failure"
);
assert_eq!(failures_at(second).await, 1);
assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
let mut backoff = CleanBackoff::default();
let id = AttemptId::from_u128(9);
for _ in 0..40 {
backoff.failed(id, start);
}
assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
backoff.succeeded(id);
assert!(
backoff.is_due(id, start),
"a cleaned attempt kept its backoff"
);
}
#[tokio::test]
async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
let launcher = Arc::new(FakeLauncher::new().seeded(vec![
attempt_in(AttemptState::Busy, 1, 1),
concluded(2, 1, AttemptOutcome::CompletedJob),
]));
let harness = Harness::build(
host_with(4),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let policy = policy(1, "acme/app", 4);
launcher.fail_attempts(true);
let blind = harness.reconciler.scale_down(&policy).await;
assert!(!blind.is_conclusive(), "the machine was never read");
assert_ne!(
blind,
ScaleDownReport::default(),
"a scale-down that could not see the host must not be equal to one that saw \
an idle host; that equality is the whole finding"
);
assert_eq!(blind.removed, 0);
assert_eq!(
blind.refused_busy, 0,
"and this zero means `unknown`, not `none`"
);
assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
launcher.fail_attempts(false);
let seeing = harness.reconciler.scale_down(&policy).await;
assert!(seeing.is_conclusive());
assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
assert_ne!(
seeing, blind,
"the difference between the two is only whether the set could be read"
);
}
#[tokio::test]
async fn demand_below_min_capacity_is_raised_to_min_capacity() {
let mut warm = ScalePolicy::new(
PolicyId::from_u128(1),
ScaleTarget::repository("acme/app").unwrap(),
1,
fixtures::HOST_ID,
PolicyMode::autoscale(
fixtures::routing_labels("home"),
2,
NonZeroU16::new(5).expect("non-zero"),
)
.expect("min <= max"),
CachePolicy::default(),
);
warm.activate().expect("pending -> active");
let mut harness = Harness::simple(8, 0, "acme/app");
let report = harness.reconciler.reconcile(&[warm]).await;
assert_eq!(
report.allocations[0].demand, 0,
"GitHub reported no queued runs"
);
assert_eq!(
report.allocations[0].desired, 2,
"min_capacity raised the target above demand"
);
assert_eq!(
report.allocations[0].limiting_factor,
LimitingFactor::MinCapacity
);
assert_eq!(report.started, 2, "and two runners were actually started");
assert_eq!(harness.launcher.live_count(), 2);
}
#[tokio::test]
async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
let mut harness = Harness::simple(100, 10, "acme/app");
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 3)])
.await;
assert_eq!(report.allocations[0].demand, 10);
assert_eq!(
report.allocations[0].desired, 3,
"max_capacity beats demand"
);
assert_eq!(report.started, 3);
assert_eq!(
report.allocations[0].limiting_factor,
LimitingFactor::MaxCapacity
);
}
#[tokio::test]
async fn demand_below_min_capacity_starts_nothing_in_v1() {
let mut harness = Harness::simple(8, 0, "acme/app");
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 4)])
.await;
assert_eq!(report.allocations[0].desired, 0);
assert_eq!(report.started, 0);
assert!(report.starts_nothing());
}
#[tokio::test]
async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
let policy = policy(1, "octo/repo", 1);
let previous = attempt_in(AttemptState::Starting, 41, 1);
let intent = ReplacementIntent {
policy: policy.id,
previous_attempt: previous.id,
operation: "exit_before_acceptance_replacement",
};
let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
let mut harness = Harness::build(
host_with(1),
Arc::clone(&launcher),
demand,
Arc::new(InProcessAllocationLock::new()),
);
let report = harness.reconciler.reconcile(&[policy]).await;
assert_eq!(report.replacement_intents, 1);
assert_eq!(report.started, 1);
assert_eq!(launcher.launches(), 1);
assert_eq!(launcher.live_count(), 1);
}
#[tokio::test]
async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
let launcher = Arc::new(FakeLauncher::new().seeded(vec![
attempt_in(AttemptState::Busy, 1, 1),
attempt_in(AttemptState::Busy, 2, 1),
]));
let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
let mut harness = Harness::build(
host_with(2),
launcher,
demand,
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 2)])
.await;
assert_eq!(report.started, 0);
assert_eq!(report.allocations[0].headroom_before, 0);
assert_eq!(harness.launcher.launches(), 0);
}
#[tokio::test]
async fn headroom_smaller_than_the_per_policy_allowance_wins() {
let launcher = Arc::new(FakeLauncher::new().seeded(vec![
attempt_in(AttemptState::Busy, 1, 99),
attempt_in(AttemptState::Busy, 2, 99),
attempt_in(AttemptState::Idle, 3, 99),
attempt_in(AttemptState::Starting, 4, 99),
]));
let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
let mut harness = Harness::build(
host_with(6),
launcher,
demand,
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 5)])
.await;
assert_eq!(
report.allocations[0].desired, 5,
"its own ceiling allows five"
);
assert_eq!(report.started, 2, "the host has two slots free");
assert_eq!(
report.allocations[0].limiting_factor,
LimitingFactor::HostCapacity
);
assert_eq!(harness.launcher.live_count(), 6);
}
#[tokio::test]
async fn the_idle_host_assertion_holds() {
let mut harness = Harness::simple(8, 0, "acme/app");
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
.await;
assert_eq!(report.started, 0);
assert_eq!(harness.launcher.launches(), 0);
assert!(harness.launcher.snapshot().is_empty());
assert_eq!(
harness
.launcher
.snapshot()
.iter()
.filter(|a| !a.is_terminal())
.count(),
0
);
}
#[tokio::test(flavor = "current_thread")]
async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
let lock = Arc::new(CountingLock::new());
let (launches, live) =
two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>).await;
assert_eq!(
launches, 3,
"the sum across policies must never exceed host_capacity, and each policy is \
individually within its own max_capacity of 3"
);
assert_eq!(live, 3, "and no duplicate runner survived the race");
assert_eq!(
lock.peak(),
1,
"the allocation lock had one holder at a time; without that the read of the \
headroom and the creation of the runtime are not atomic"
);
assert!(
(3..=5).contains(&lock.acquisitions()),
"the lock is taken before *each* runtime, not once per pass: three runtimes \
means at least three holds, and at most one further hold per policy to \
discover the host filled up underneath it. It was taken {} times",
lock.acquisitions()
);
}
#[tokio::test(flavor = "current_thread")]
async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
let (launches, _) =
two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>).await;
assert!(
launches > 3,
"with no serialisation both policies must be able to spend the same headroom; \
they started {launches} runners on a host of 3. If this is ever 3, the \
contention window closed and `two_policies_reconciling_concurrently_never_\
exceed_host_capacity` has stopped proving anything"
);
}
async fn two_policies_concurrently(lock: Arc<dyn AllocationLock>) -> (usize, usize) {
let launcher = Arc::new(FakeLauncher::new().with_yields(4));
let host = host_with(3);
let mut left = Harness::build(
host.clone(),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
Arc::clone(&lock),
)
.reconciler;
let mut right = Harness::build(
host,
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
Arc::clone(&lock),
)
.reconciler;
let a = policy(1, "acme/left", 3);
let b = policy(2, "acme/right", 3);
let left = tokio::spawn(async move { left.reconcile(&[a]).await });
let right = tokio::spawn(async move { right.reconcile(&[b]).await });
let (_, _) = (left.await.unwrap(), right.await.unwrap());
(launcher.launches(), launcher.live_count())
}
#[tokio::test]
async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
let launcher = Arc::new(FakeLauncher::new());
let events = Arc::new(EventLog::new());
let mut reconciler = Reconciler::new(
host_with(10),
ReconcilerPorts {
demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
lock: Arc::new(InProcessAllocationLock::new()),
directory: Arc::new(FakeDirectory::default()),
clock: Arc::new(FakeClock::default()),
jitter: Arc::new(NoJitter),
events: Arc::clone(&events) as Arc<dyn EventSink>,
},
);
let monitor = fixtures::policy()
.id(PolicyId::from_u128(1))
.repository("acme/app")
.monitor_only()
.active()
.build();
let report = reconciler.reconcile(&[monitor]).await;
assert_eq!(report.started, 0);
assert_eq!(launcher.launches(), 0);
assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
assert_eq!(
report.demand_requests, 0,
"a monitor-only policy spends nothing from the shared hourly ceiling"
);
assert!(
gateway.gateway().calls().is_empty(),
"a monitor-only policy issued a demand request: {:?}",
gateway.gateway().calls()
);
assert_eq!(events.count_of("monitor_only_skipped"), 1);
assert_eq!(
events.count_of("demand_observed"),
0,
"and it contributed no demand"
);
}
#[tokio::test]
async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
let lock = Arc::new(CountingLock::new());
let launcher = Arc::new(FakeLauncher::new());
let events = Arc::new(EventLog::new());
let mut reconciler = Reconciler::new(
host_with(4),
ReconcilerPorts {
demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
directory: Arc::new(FakeDirectory::default()),
clock: Arc::new(FakeClock::default()),
jitter: Arc::new(NoJitter),
events: Arc::clone(&events) as Arc<dyn EventSink>,
},
);
let watcher = fixtures::policy()
.id(PolicyId::from_u128(2))
.repository("acme/app")
.monitor_only()
.active()
.build();
let report = reconciler
.reconcile(&[policy(1, "acme/app", 4), watcher])
.await;
assert_eq!(report.started, 2, "the autoscale policy is served normally");
assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
assert_eq!(
events.count_of("demand_observed"),
1,
"the demand observation belongs to the autoscale policy alone"
);
assert!(
report
.allocations
.iter()
.all(|a| a.policy_id == PolicyId::from_u128(1)),
"a monitor-only policy was allocated for: {:?}",
report.allocations
);
assert_eq!(
lock.acquisitions(),
2,
"one hold per runtime created, and none on behalf of the monitor-only policy. \
It was three before the budget was checked at the top of the loop rather than \
after the re-read, which cost every policy a surplus hold to discover there \
was nothing left to grant"
);
}
#[tokio::test]
async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
let monitor = fixtures::monitor_only_policy();
assert!(!monitor.owns_runners());
assert_eq!(monitor.max_capacity(), None);
let host = host_with(10);
let attempts: Vec<RunnerAttempt> = Vec::new();
let mut allocator = HostAllocator::from_attempts(&host, &attempts);
let allocation = allocator.allocate(&monitor, 10_000);
assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
assert_eq!(allocation.to_start, 0);
assert_eq!(
allocator.headroom(),
10,
"and it consumes no headroom, so an autoscale policy on the same host is \
unaffected"
);
}
#[tokio::test]
async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
let launcher = Arc::new(FakeLauncher::new().seeded(vec![
concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
concluded(
2,
1,
AttemptOutcome::failed(FailureReason::JitRequestFailed),
),
concluded(3, 1, AttemptOutcome::CompletedJob),
]));
let mut harness = Harness::build(
host_with(4),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 4)])
.await;
assert_eq!(report.cleaned, 3);
assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
assert_eq!(
report.failures, 1,
"only the failed attempt is a failure; the idle exit and the completed job are \
not"
);
assert_eq!(launcher.cleaned().len(), 3);
assert!(launcher.snapshot().is_empty());
let cleaned: Vec<OutcomeKind> = harness
.events
.events()
.into_iter()
.filter_map(|event| match event {
LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
_ => None,
})
.collect();
assert!(cleaned.contains(&OutcomeKind::IdleExit));
assert!(
!OutcomeKind::IdleExit.is_failure(),
"an idle exit rendered as a failure sends an operator hunting a fault that does \
not exist"
);
}
#[tokio::test]
async fn scale_down_removes_nothing_from_a_busy_attempt() {
let busy = attempt_in(AttemptState::Busy, 1, 1);
let launcher = Arc::new(FakeLauncher::new().seeded(vec![
busy.clone(),
attempt_in(AttemptState::Starting, 2, 1),
concluded(3, 1, AttemptOutcome::CompletedJob),
]));
let harness = Harness::build(
host_with(4),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.scale_down(&policy(1, "acme/app", 4))
.await;
assert_eq!(report.refused_busy, 1);
assert_eq!(
report.retained, 1,
"the `starting` attempt is not ended either"
);
assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
let after = launcher.snapshot();
let still_busy = after
.iter()
.find(|a| a.id == AttemptId::from_u128(1))
.expect("the busy attempt is still there");
assert_eq!(
still_busy.state(),
AttemptState::Busy,
"scale-down removed nothing from a runner that is executing a job, and left it \
busy"
);
assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
let mut busy = busy;
assert!(matches!(
busy.clean(fixtures::created_at()),
Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
));
assert_eq!(harness.events.count_of("scale_down_refused"), 1);
}
#[test]
fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
assert_eq!(RefreshInterval::MIN_SECS, 30);
assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
assert!(
RefreshInterval::from_secs(29).is_err(),
"the floor is a rate-budget constraint, and a caller must not be able to write \
a shorter interval at all"
);
let mut schedule = PollSchedule::new(RefreshInterval::default());
let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
assert_eq!(next.delay, Duration::from_secs(60));
assert_eq!(next.pace, PollPace::Nominal);
let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
assert_eq!(
floored
.next_poll(None, fixtures::created_at(), &NoJitter)
.delay,
Duration::from_secs(30)
);
}
#[test]
fn a_rate_limit_increases_the_delay_and_names_itself() {
let now = fixtures::created_at();
let mut schedule = PollSchedule::new(RefreshInterval::default());
let limited = RefreshState::RateLimited(RateLimited {
kind: RateLimitKind::Secondary,
retry_after: Some(Duration::from_secs(300)),
remaining: None,
reset_unix_secs: None,
});
let next = schedule.next_poll(Some(&limited), now, &NoJitter);
assert_eq!(next.delay, Duration::from_secs(300));
assert_eq!(
next.pace,
PollPace::RateLimited {
kind: RateLimitKind::Secondary
},
"the increase is reported, never hidden"
);
assert!(next.pace.is_throttled());
assert_eq!(next.pace.as_str(), "rate_limited_secondary");
}
#[test]
fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
let now = fixtures::created_at();
let mut schedule = PollSchedule::new(RefreshInterval::default());
let limited = RefreshState::RateLimited(RateLimited {
kind: RateLimitKind::Primary,
retry_after: Some(Duration::from_secs(300)),
remaining: Some(0),
reset_unix_secs: None,
});
for _ in 0..5 {
let next = schedule.next_poll(Some(&limited), now, &NoJitter);
assert_eq!(
next.delay,
Duration::from_secs(300),
"the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
have compounded on every successive retry"
);
}
let brief = RefreshState::RateLimited(RateLimited {
kind: RateLimitKind::Secondary,
retry_after: Some(Duration::from_secs(5)),
remaining: None,
reset_unix_secs: None,
});
let next = schedule.next_poll(Some(&brief), now, &NoJitter);
assert_eq!(
next.delay,
Duration::from_secs(60),
"a short `retry-after` may not drop the loop below its own interval"
);
assert!(next.delay >= PollSchedule::floor());
}
#[test]
fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
let now = fixtures::created_at();
let states = [
None,
Some(RefreshState::Offline),
Some(RefreshState::RateLimited(RateLimited {
kind: RateLimitKind::Secondary,
retry_after: Some(Duration::from_secs(1)),
remaining: None,
reset_unix_secs: None,
})),
Some(RefreshState::LockedOut {
retry_after: Duration::from_secs(1),
}),
Some(RefreshState::Unauthorized),
Some(RefreshState::Forbidden { message: None }),
Some(RefreshState::Failed {
status: Some(500),
message: "server error".into(),
}),
Some(RefreshState::Cancelled),
];
for state in &states {
let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
assert!(
next.delay >= PollSchedule::floor(),
"{state:?} scheduled a poll {}ms away, under the 30-second floor",
next.delay.as_millis()
);
}
}
#[test]
fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
let now = fixtures::created_at();
let mut schedule = PollSchedule::new(RefreshInterval::default());
let mut previous = Duration::ZERO;
for consecutive in 1..=6_u32 {
let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
assert_eq!(next.pace, PollPace::Offline { consecutive });
assert!(
next.delay >= previous,
"the back-off must not shrink while the outage continues"
);
assert!(next.delay >= Duration::from_secs(60));
previous = next.delay;
}
assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
let mut jittered = PollSchedule::new(RefreshInterval::default());
let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
let mut jittered = PollSchedule::new(RefreshInterval::default());
let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
assert!(full.delay > none.delay);
assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
assert_eq!(schedule.consecutive_offline(), 6);
let recovered = schedule.next_poll(None, now, &NoJitter);
assert_eq!(recovered.pace, PollPace::Nominal);
assert_eq!(recovered.delay, Duration::from_secs(60));
assert_eq!(schedule.consecutive_offline(), 0);
}
#[test]
fn the_offline_state_states_the_twenty_four_hour_bound() {
assert_eq!(
GITHUB_CANCELS_QUEUED_JOBS_AFTER,
Duration::from_secs(24 * 60 * 60)
);
let brief = OfflineState::new(1, Duration::from_secs(120));
let rendered = brief.to_string();
assert!(rendered.contains("24 hours"), "{rendered}");
assert!(rendered.contains("Retrying in 120s"), "{rendered}");
assert!(!brief.has_outlasted_the_queue());
let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
assert!(long.has_outlasted_the_queue());
assert!(
long.to_string().contains("queued work has been lost"),
"{long}"
);
assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
}
fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
RateLimitSnapshot {
limit: 5_000,
remaining,
used: Some(5_000 - remaining),
reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
observed_at: now,
}
}
fn quiet() -> PollConditions {
PollConditions {
quiet: true,
quota: None,
requests_per_pass: 0,
}
}
#[test]
fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
let now = fixtures::created_at();
let mut schedule =
PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
assert_eq!(idle.delay, Duration::from_secs(10));
assert_eq!(idle.pace, PollPace::Idle);
assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
assert_eq!(busy.delay, Duration::from_secs(60));
assert_eq!(busy.pace, PollPace::Nominal);
}
#[test]
fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
let now = fixtures::created_at();
let mut schedule = PollSchedule::new(RefreshInterval::default());
assert_eq!(
schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
Duration::from_secs(60)
);
}
#[test]
fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
.with_idle(Duration::from_secs(120));
assert_eq!(
schedule.idle(),
Duration::from_secs(30),
"an idle interval above the active one would make quiet targets slower to react"
);
let schedule =
PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
assert_eq!(schedule.idle(), PollSchedule::idle_floor());
}
#[test]
fn a_low_quota_stretches_polling_and_wins_over_idle() {
let now = fixtures::created_at();
let mut schedule =
PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
let healthy = schedule.next_poll_with(
None,
PollConditions {
quiet: true,
quota: Some(quota(1_000, 3_000, now)),
requests_per_pass: 0,
},
now,
&NoJitter,
);
assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
let low = schedule.next_poll_with(
None,
PollConditions {
quiet: true,
quota: Some(quota(999, 3_000, now)),
requests_per_pass: 0,
},
now,
&NoJitter,
);
assert_eq!(low.delay, Duration::from_secs(120));
assert_eq!(
low.pace,
PollPace::Stretched {
pressure: PrimaryPressure::Low
}
);
assert!(low.pace.is_throttled());
assert_eq!(low.pace.as_str(), "stretched_low_budget");
let critical = schedule.next_poll_with(
None,
PollConditions {
quiet: false,
quota: Some(quota(100, 3_000, now)),
requests_per_pass: 0,
},
now,
&NoJitter,
);
assert_eq!(critical.delay, Duration::from_secs(240));
assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
}
#[test]
fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
let now = fixtures::created_at();
let mut schedule = PollSchedule::new(RefreshInterval::default());
let soon = schedule.next_poll_with(
None,
PollConditions {
quiet: false,
quota: Some(quota(10, 90, now)),
requests_per_pass: 0,
},
now,
&NoJitter,
);
assert_eq!(
soon.delay,
Duration::from_secs(90),
"the bucket refills in 90 s; waiting 240 s buys nothing"
);
let imminent = schedule.next_poll_with(
None,
PollConditions {
quiet: false,
quota: Some(quota(10, 5, now)),
requests_per_pass: 0,
},
now,
&NoJitter,
);
assert_eq!(imminent.delay, Duration::from_secs(60));
let expired = schedule.next_poll_with(
None,
PollConditions {
quiet: true,
quota: Some(quota(10, -5, now)),
requests_per_pass: 0,
},
now,
&NoJitter,
);
assert_eq!(
expired.pace,
PollPace::Nominal,
"a reading from a window that has reset says nothing; the schedule here had no \
idle interval, so it is the active one"
);
}
#[test]
fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
let now = fixtures::created_at();
let mut schedule =
PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
let small = schedule.next_poll_with(
None,
PollConditions {
quiet: true,
quota: None,
requests_per_pass: 40,
},
now,
&NoJitter,
);
assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
let organization = schedule.next_poll_with(
None,
PollConditions {
quiet: true,
quota: None,
requests_per_pass: 400,
},
now,
&NoJitter,
);
assert_eq!(
organization.delay,
Duration::from_secs(80),
"400 requests a pass at 300 a minute is one pass every 80 s"
);
assert_eq!(organization.pace, PollPace::Idle);
}
#[test]
fn a_rate_limit_wait_is_jittered_upward_only() {
let now = fixtures::created_at();
let mut schedule = PollSchedule::new(RefreshInterval::default());
let limited = RefreshState::RateLimited(RateLimited {
kind: RateLimitKind::Secondary,
retry_after: Some(Duration::from_secs(300)),
remaining: None,
reset_unix_secs: None,
});
assert_eq!(
schedule
.next_poll(Some(&limited), now, &FixedJitter(0.5))
.delay,
Duration::from_secs(315),
"every loop shares one quiet period; without jitter they all wake together"
);
assert_eq!(
schedule.next_poll(Some(&limited), now, &NoJitter).delay,
Duration::from_secs(300),
"never shorter than GitHub asked"
);
}
#[tokio::test]
async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
let launcher =
Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
let mut harness = Harness::build(
host_with(1),
launcher,
demand,
Arc::new(InProcessAllocationLock::new()),
);
harness
.reconciler
.set_intervals(RefreshInterval::default(), Duration::from_secs(10));
let policy = policy(1, "acme/app", 4);
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(report.started, 0, "the host is full");
assert_eq!(
report.next_poll.pace,
PollPace::Nominal,
"matched demand it could not serve is work waiting, not idle"
);
}
#[tokio::test]
async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
let mut harness = Harness::simple(4, 0, "acme/app");
harness
.reconciler
.set_intervals(RefreshInterval::default(), Duration::from_secs(10));
let policy = policy(1, "acme/app", 4);
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(report.next_poll.pace, PollPace::Idle);
assert_eq!(report.next_poll.delay, Duration::from_secs(10));
}
#[tokio::test]
async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
let mut harness = Harness::simple(4, 0, "acme/app");
harness
.reconciler
.set_intervals(RefreshInterval::default(), Duration::from_secs(10));
harness.demand.set(PollOutcome::Ready(
QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
));
let policy = policy(1, "acme/app", 4);
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(report.next_poll.pace, PollPace::Nominal);
assert_eq!(report.next_poll.delay, Duration::from_secs(60));
}
#[tokio::test]
async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
let launcher =
Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
let mut harness = Harness::build(
host_with(4),
launcher,
demand,
Arc::new(InProcessAllocationLock::new()),
);
harness
.reconciler
.set_intervals(RefreshInterval::default(), Duration::from_secs(10));
let policy = policy(1, "acme/app", 4);
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(
report.next_poll.pace,
PollPace::Nominal,
"a job is running: its follow-on work is what the active interval watches for"
);
}
#[tokio::test]
async fn a_pass_that_starts_a_runner_is_not_idle() {
let mut harness = Harness::simple(4, 2, "acme/app");
harness
.reconciler
.set_intervals(RefreshInterval::default(), Duration::from_secs(10));
let policy = policy(1, "acme/app", 4);
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert!(report.started > 0);
assert_eq!(report.next_poll.pace, PollPace::Nominal);
}
#[tokio::test]
async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
let mut harness = Harness::simple(4, 0, "acme/app");
harness
.reconciler
.set_intervals(RefreshInterval::default(), Duration::from_secs(10));
let now = harness.clock.now();
harness.demand.set(PollOutcome::Ready(
QueuedDemand::of(repo("acme/app"), Vec::new())
.with_rate_limit(Some(quota(200, 1_800, now))),
));
let policy = policy(1, "acme/app", 4);
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert_eq!(
report.next_poll.pace,
PollPace::Stretched {
pressure: PrimaryPressure::Critical
}
);
assert_eq!(report.next_poll.delay, Duration::from_secs(240));
}
#[tokio::test]
async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
let live = vec![
attempt_in(AttemptState::Busy, 1, 1),
attempt_in(AttemptState::Starting, 2, 1),
];
let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
let mut harness = Harness::build(
host_with(8),
Arc::clone(&launcher),
Arc::clone(&demand),
Arc::new(InProcessAllocationLock::new()),
);
let policy = policy(1, "acme/app", 8);
let report = harness
.reconciler
.reconcile(std::slice::from_ref(&policy))
.await;
assert!(report.is_offline());
assert_eq!(report.started, 0, "no new runner during an outage");
assert_eq!(launcher.launches(), 0);
assert_eq!(
launcher.snapshot(),
live,
"existing runner processes are retained, untouched"
);
assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
assert!(report.next_poll.delay >= Duration::from_secs(60));
let offline = report.offline_state().expect("an offline state to display");
assert!(offline.to_string().contains("24 hours"));
demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("acme/app"),
jobs(2),
)));
let recovered = harness.reconciler.reconcile(&[policy]).await;
assert!(!recovered.is_offline());
assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
assert_eq!(
recovered.started, 0,
"two queued runs, two attempts already in flight: a reconnect cannot \
double-count work"
);
assert_eq!(recovered.allocations[0].active_owned, 2);
assert_eq!(launcher.live_count(), 2);
}
#[tokio::test]
async fn one_offline_target_does_not_stop_a_reachable_one() {
let mut harness = Harness::simple(8, 0, "acme/app");
harness.demand.set_for(
&ScaleTarget::repository("acme/app").unwrap(),
PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
);
harness.demand.set_for(
&ScaleTarget::repository("acme/broken").unwrap(),
PollOutcome::Failed(RefreshState::Offline),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
.await;
assert_eq!(
report.started, 2,
"the reachable target was served; an unreachable sibling repository must not \
idle the host"
);
assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
assert!(report.is_offline());
assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
}
#[tokio::test]
async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
let clock = Arc::new(FakeClock::default());
let launcher = Arc::new(FakeLauncher::new());
let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
let mut reconciler = Reconciler::new(
host_with(4),
ReconcilerPorts {
demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
lock: Arc::new(InProcessAllocationLock::new()),
directory: Arc::new(FakeDirectory::default()),
clock: Arc::clone(&clock) as Arc<dyn Clock>,
jitter: Arc::new(NoJitter),
events: Arc::new(NoEvents),
},
);
let policy = policy(1, "acme/app", 4);
let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
let state = first.offline_state().expect("an offline state");
assert!(!state.has_outlasted_the_queue());
assert!(
state
.to_string()
.contains("an outage longer than that loses"),
"{state}"
);
clock.advance_secs(24 * 60 * 60 + 60);
let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
let state = later.offline_state().expect("an offline state");
assert!(state.has_outlasted_the_queue());
assert!(
state.to_string().contains("queued work has been lost"),
"{state}"
);
assert_eq!(launcher.launches(), 0, "and still nothing was started");
demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("acme/app"),
jobs(0),
)));
let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
assert!(recovered.offline_state().is_none());
assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
demand.set(PollOutcome::Failed(RefreshState::Offline));
let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
assert!(
!again
.offline_state()
.expect("an offline state")
.has_outlasted_the_queue(),
"a new outage must not inherit the age of the one before it"
);
}
#[tokio::test]
async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
let root = tempfile::tempdir().expect("a temporary directory");
let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
root.path(),
));
let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
let held = lock.acquire().await.expect("an uncontended lock is free");
assert!(
matches!(lock.acquire().await, Err(AllocationLockBusy)),
"a second holder was admitted; on Unix the lock is per open file description \
and on Windows the share mode denies write, so this must be refused even \
from inside the same process"
);
drop(held);
let regained = lock.acquire().await;
assert!(
regained.is_ok(),
"dropping the guard is the only release there is, so a guard that does not \
hold the `HostLock` leaves it held forever"
);
}
#[test]
fn tee_events_reaches_both_sinks() {
let left = Arc::new(EventLog::new());
let right = Arc::new(EventLog::new());
let tee = TeeEvents(
Arc::clone(&left) as Arc<dyn EventSink>,
Arc::clone(&right) as Arc<dyn EventSink>,
);
tee.emit(LifecycleEvent::MonitorOnlySkipped {
policy: PolicyId::from_u128(1),
});
assert_eq!(left.count_of("monitor_only_skipped"), 1);
assert_eq!(right.count_of("monitor_only_skipped"), 1);
}
#[tokio::test]
async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
let clock = Arc::new(FakeClock::default());
let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
let cache = RepositoryCache::new(
Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
Arc::clone(&clock) as Arc<dyn Clock>,
RefreshInterval::default(),
);
let target = ScaleTarget::organization("acme").unwrap();
assert_eq!(
cache.ttl(),
Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
);
for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
let scope = cache.scope_for(&target).await.unwrap();
assert_eq!(scope.repositories().len(), 2);
clock.advance_secs(60);
}
assert_eq!(
directory.calls(),
1,
"re-listing an organization at demand-poll frequency is what exhausts the \
shared request budget"
);
assert_eq!(cache.lookups(), 1);
cache.scope_for(&target).await.unwrap();
assert_eq!(directory.calls(), 2);
}
#[tokio::test]
async fn a_repository_target_never_consults_the_directory() {
let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
let cache = RepositoryCache::new(
Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
Arc::new(FakeClock::default()) as Arc<dyn Clock>,
RefreshInterval::default(),
);
let target = ScaleTarget::repository("acme/app").unwrap();
let scope = cache.scope_for(&target).await.unwrap();
assert_eq!(scope.repositories(), &[repo("acme/app")]);
assert_eq!(directory.calls(), 0);
}
#[tokio::test]
async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
let mut harness = Harness::simple(8, 4, "o/r");
harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("o/r"),
[
fixtures::queued_job(&[HOST_LABEL]),
fixtures::queued_job(&[HOST_LABEL]),
fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
],
)));
let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
isolated.activate().unwrap();
let report = harness
.reconciler
.reconcile(&[policy(1, "o/r", 2), isolated])
.await;
assert_eq!(harness.demand.polls().len(), 1);
assert_eq!(report.targets_read, 1, "profiles share one target reading");
assert_eq!(
report.demand_requests,
runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
"one repository's worth of demand requests, not two policies' worth. Read from the constant rather than written as a literal so that repricing the poll cannot silently turn this into an assertion about the wrong thing"
);
assert_eq!(
report.started, 4,
"native and isolated profiles each get two"
);
}
#[tokio::test]
async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
use runner_manager_domain::model::{Arch, HostLabel, Os};
use runner_manager_domain::policy::RoutingLabels;
let mut harness = Harness::build(
fixtures::host()
.os(Os::MacOs)
.architecture(Arch::Arm64)
.capacity(10)
.build(),
Arc::new(FakeLauncher::new()),
Arc::new(FakeDemand::ready(0, &repo("o/r"))),
Arc::new(InProcessAllocationLock::new()),
);
harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("o/r"),
[
fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
],
)));
let mut local = fixtures::policy()
.id(PolicyId::from_u128(1))
.repository("o/r")
.autoscale("macmini", 10)
.routing_labels(RoutingLabels::derive(
&HostLabel::new("macmini").unwrap(),
Os::MacOs,
Arch::Arm64,
))
.active()
.build();
for label in ["self-hosted", "macos", "arm64", "extra"] {
local.add_routing_label(fixtures::label(label)).unwrap();
}
let mut foreign = local.to_persisted();
foreign.id = PolicyId::from_u128(2);
foreign.host_id = HostId::from_u128(0xdead);
let foreign = ScalePolicy::from_persisted(foreign).unwrap();
let report = harness.reconciler.reconcile(&[local, foreign]).await;
assert_eq!(report.started, 2);
assert_eq!(report.targets_read, 1);
assert!(harness.events.events().iter().any(|event| matches!(
event,
LifecycleEvent::DemandObserved {
demand: 2,
not_matched: 4,
..
}
)));
assert!(
!harness
.events
.events()
.iter()
.any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
);
}
#[tokio::test]
async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
use runner_manager_domain::execution::{
Backend, ExecutionPolicy, ImageReference, ResourceLimits,
};
let mut native = policy(1, "o/r", 2);
native
.add_routing_label(fixtures::label("self-hosted"))
.unwrap();
let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
isolated
.add_routing_label(fixtures::label("self-hosted"))
.unwrap();
isolated
.set_execution_policy(ExecutionPolicy::Isolated {
backend: Backend::Oci,
image: ImageReference::new(format!(
"registry.example/runner@sha256:{}",
"a".repeat(64)
))
.unwrap(),
resources: ResourceLimits {
cpu_millis: 1000,
memory_mib: 512,
disk_mib: 1024,
},
})
.unwrap();
assert!(!isolated.may_start_runners());
let mut active_isolated = isolated.clone();
active_isolated.activate().unwrap();
for profiles in [
[native.clone(), isolated.clone()],
[isolated.clone(), native.clone()],
[native.clone(), active_isolated.clone()],
[active_isolated, native.clone()],
] {
let mut harness = Harness::simple(4, 0, "o/r");
harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("o/r"),
[fixtures::queued_job(&["SELF-HOSTED"])],
)));
let report = harness.reconciler.reconcile(&profiles).await;
assert_eq!(report.started, 0);
assert!(harness.events.events().iter().any(|event| matches!(
event,
LifecycleEvent::RoutingRefused {
reason: RoutingRefusal::OverlappingProfiles,
count: 1,
..
}
)));
}
let mut harness = Harness::simple(4, 0, "o/r");
harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("o/r"),
[fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
)));
assert_eq!(
harness
.reconciler
.reconcile(&[native, isolated])
.await
.started,
0
);
}
#[tokio::test]
async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
let mut harness = Harness::simple(2, 0, "o/r");
harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("o/r"),
[
fixtures::queued_job(&[HOST_LABEL, "gpu"]),
fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
fixtures::queued_job(&["gpu"]),
fixtures::unresolvable_job(),
],
)));
let mut native = policy(1, "o/r", 2);
native.add_routing_label(fixtures::label("gpu")).unwrap();
let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
isolated.add_routing_label(fixtures::label("gpu")).unwrap();
isolated.activate().unwrap();
let report = harness.reconciler.reconcile(&[native, isolated]).await;
assert_eq!(harness.demand.polls().len(), 1);
assert_eq!(report.targets_read, 1);
assert_eq!(report.started, 2);
assert_eq!(report.allocations.len(), 2);
assert!(
report
.allocations
.iter()
.all(|allocation| allocation.to_start == 1)
);
assert!(harness.events.events().iter().any(|event| matches!(
event,
LifecycleEvent::RoutingRefused {
reason: RoutingRefusal::OverlappingProfiles,
count: 1,
..
}
)));
assert_eq!(
harness
.events
.events()
.iter()
.filter(|event| matches!(
event,
LifecycleEvent::DemandObserved {
unresolvable: 1,
..
}
))
.count(),
2,
"an expression stays unresolvable for both profiles"
);
}
#[tokio::test]
async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
let mut harness = Harness::simple(4, 0, "o/r");
harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("o/r"),
[fixtures::queued_job(&[
HOST_LABEL,
"rm-home-win-x64-py-isolated",
])],
)));
let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
isolated.activate().unwrap();
let report = harness
.reconciler
.reconcile(&[policy(1, "o/r", 2), isolated])
.await;
assert_eq!(report.started, 0);
assert_eq!(
harness
.events
.events()
.iter()
.filter(|event| matches!(
event,
LifecycleEvent::DemandObserved {
demand: 0,
not_matched: 1,
..
}
))
.count(),
2
);
let mut superset = policy(1, "o/r", 2);
assert!(
superset
.add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
.unwrap()
);
let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
sibling.activate().unwrap();
let mut unique = Harness::simple(4, 0, "o/r");
unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
repo("o/r"),
[fixtures::queued_job(&[
HOST_LABEL,
"rm-home-win-x64-py-isolated",
])],
)));
let report = unique.reconciler.reconcile(&[superset, sibling]).await;
assert_eq!(report.started, 1);
assert_eq!(
report
.allocations
.iter()
.find(|allocation| allocation.to_start == 1)
.unwrap()
.policy_id,
PolicyId::from_u128(1)
);
assert!(
!unique
.events
.events()
.iter()
.any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
);
let mut overlap = Harness::simple(4, 1, "o/r");
let report = overlap
.reconciler
.reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
.await;
assert_eq!(report.started, 0);
assert!(overlap.events.events().iter().any(|event| matches!(
event,
LifecycleEvent::RoutingRefused {
reason: RoutingRefusal::OverlappingProfiles,
count: 1,
..
}
)));
assert!(
RoutingRefusal::OverlappingProfiles
.to_string()
.contains("repair")
);
}
#[tokio::test]
async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
let launcher = Arc::new(FakeLauncher::new());
launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
let mut harness = Harness::build(
host_with(4),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
Arc::new(InProcessAllocationLock::new()),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 4)])
.await;
assert_eq!(report.started, 0);
assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
let failures: Vec<&'static str> = harness
.events
.events()
.into_iter()
.filter_map(|event| match event {
LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
_ => None,
})
.collect();
assert_eq!(failures, vec!["other"]);
assert!(
!failures[0].contains("ghp_"),
"an event carried a `FailureReason::Other` detail verbatim"
);
}
#[tokio::test]
async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
let launcher = Arc::new(FakeLauncher::new());
let unfit = Arc::new(AtomicBool::new(true));
let events = Arc::new(EventLog::new());
let mut reconciler = Reconciler::new(
host_with(4),
ReconcilerPorts {
demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
launcher: Arc::new(FitnessGatedLauncher::new(
Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
Arc::clone(&unfit),
)),
lock: Arc::new(InProcessAllocationLock::new()),
directory: Arc::new(FakeDirectory::default()),
clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
events: Arc::clone(&events) as Arc<dyn EventSink>,
},
);
let policies = [policy(1, "acme/app", 4)];
let report = reconciler.reconcile(&policies).await;
assert_eq!(report.started, 0);
assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
let reasons: Vec<&'static str> = events
.events()
.into_iter()
.filter_map(|event| match event {
LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
_ => None,
})
.collect();
assert_eq!(reasons, ["host_unfit"]);
unfit.store(false, Ordering::Release);
let report = reconciler.reconcile(&policies).await;
assert_eq!(report.started, 2);
assert_eq!(launcher.launches(), 2);
}
#[tokio::test]
async fn a_held_allocation_lock_starts_nothing_and_says_so() {
let launcher = Arc::new(FakeLauncher::new());
let mut harness = Harness::build(
host_with(4),
Arc::clone(&launcher),
Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
Arc::new(HeldLock),
);
let report = harness
.reconciler
.reconcile(&[policy(1, "acme/app", 4)])
.await;
assert_eq!(report.started, 0);
assert_eq!(
report.deferred, 3,
"three runners were granted and none was created; `deferred` counts grants, \
not policies -- it reported `1` when a policy that launched two of five and \
then lost the lock had left three unstarted"
);
assert_eq!(launcher.launches(), 0);
assert_eq!(harness.events.count_of("allocation_deferred"), 1);
assert!(
harness
.events
.events()
.iter()
.any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
"the event carries the same number the report does"
);
assert_eq!(
report.allocations.len(),
1,
"the intent is still reported, so an operator staring at a queue sees why \
nothing started"
);
}
#[tokio::test]
async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
let mut harness = Harness::simple(8, 5, "acme/app");
let foreign = fixtures::policy()
.id(PolicyId::from_u128(1))
.repository("acme/app")
.host(HostId::from_u128(0xdead))
.autoscale("office", 4)
.active()
.build();
let mut draining = policy(2, "acme/app", 4);
draining.request_disable().unwrap();
let report = harness.reconciler.reconcile(&[foreign, draining]).await;
assert_eq!(report.started, 0);
assert_eq!(
harness.demand.polls().len(),
0,
"neither can act on an answer"
);
let factors: Vec<LimitingFactor> = report
.allocations
.iter()
.map(|a| a.limiting_factor)
.collect();
assert!(factors.contains(&LimitingFactor::ForeignHost));
assert!(factors.contains(&LimitingFactor::NotReconciling));
}
#[tokio::test]
async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
#[derive(Debug)]
struct BrokenDirectory;
#[async_trait::async_trait]
impl RepositoryDirectory for BrokenDirectory {
async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
Err(InventoryError::Cancelled)
}
}
let launcher = Arc::new(FakeLauncher::new());
let events = Arc::new(EventLog::new());
let mut reconciler = Reconciler::new(
host_with(4),
ReconcilerPorts {
demand: Arc::new(FakeDemand::default()),
launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
lock: Arc::new(InProcessAllocationLock::new()),
directory: Arc::new(BrokenDirectory),
clock: Arc::new(FakeClock::default()),
jitter: Arc::new(NoJitter),
events: Arc::clone(&events) as Arc<dyn EventSink>,
},
);
let org_policy = fixtures::policy()
.id(PolicyId::from_u128(1))
.organization("acme")
.autoscale("home", 4)
.active()
.build();
let report = reconciler.reconcile(&[org_policy]).await;
assert_eq!(report.started, 0);
assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
assert_eq!(events.count_of("target_unreadable"), 1);
}
fn every_event() -> Vec<LifecycleEvent> {
let policy = PolicyId::from_u128(0xabcd_ef01);
let attempt = AttemptId::from_u128(0x1234_5678);
vec![
LifecycleEvent::DemandObserved {
policy,
demand: u32::MAX,
not_matched: u32::MAX,
unresolvable: u32::MAX,
complete: false,
},
LifecycleEvent::TargetUnreadable {
policy,
reason: unreadable_reason(&RefreshState::Failed {
status: Some(500),
message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
.into(),
}),
},
LifecycleEvent::Allocated {
policy,
demand: u32::MAX,
desired: u16::MAX,
active_owned: 7,
headroom: 9,
to_start: 2,
limiting: LimitingFactor::HostCapacity,
},
LifecycleEvent::MonitorOnlySkipped { policy },
LifecycleEvent::RunnerStarted { policy, attempt },
LifecycleEvent::RunnerStartFailed {
policy,
reason: failure_reason_kind(&FailureReason::Other(
"Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
)),
},
LifecycleEvent::AllocationDeferred { policy, count: 4 },
LifecycleEvent::AttemptsUnreadable {
reason: failure_reason_kind(&FailureReason::Other(
"x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
)),
},
LifecycleEvent::AttemptCleanFailed {
policy,
attempt,
reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
},
LifecycleEvent::AttemptCleaned {
policy,
attempt,
outcome: OutcomeKind::IdleExit,
},
LifecycleEvent::ScaleDownRefused { policy, attempt },
LifecycleEvent::PollScheduled {
retry_in_ms: 900_000,
pace: PollPace::RateLimited {
kind: RateLimitKind::Primary,
},
},
]
}
#[test]
fn no_emitted_event_can_carry_a_credential() {
use runner_manager_platform::logging::redact;
for event in every_event() {
let displayed = event.to_string();
assert_eq!(
redact(&displayed),
displayed,
"`{}` renders something `d1`'s sink would have to redact",
event.name()
);
let debugged = format!("{event:?}");
assert_eq!(
redact(&debugged),
debugged,
"`{}`'s Debug renders something `d1`'s sink would have to redact",
event.name()
);
}
let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
assert_ne!(
redact(secret),
secret,
"the scan above proves nothing if `redact` no longer recognises a credential"
);
}
#[test]
fn every_field_name_this_sink_emits_is_one_d1_allows() {
use runner_manager_platform::logging::is_field_allowed;
for field in [
"event",
"policy_id",
"attempt_id",
"attempt_state",
"demand",
"desired",
"capacity",
"headroom",
"count",
"reason",
"outcome",
"mode",
"lock",
"retry_in_ms",
"state",
] {
assert!(
is_field_allowed(field),
"`{field}` is not on `d1`'s allow-list, so this sink would emit \
`[redacted]` in its place and the line would lose its meaning"
);
}
}
#[test]
fn every_failure_reason_has_a_credential_free_kind() {
for reason in FailureReason::ALL {
let kind = failure_reason_kind(&reason);
assert!(!kind.is_empty());
assert!(
kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
"`{kind}` is not a fixed identifier"
);
}
assert_eq!(
failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
"other",
"the detail of an `Other` reason never reaches an event"
);
let categories = [
IsolationProviderFailure::NotInstalled,
IsolationProviderFailure::PermissionDenied,
IsolationProviderFailure::ImageUnavailableOrIncompatible,
IsolationProviderFailure::DiskQuotaUnavailable,
IsolationProviderFailure::RuntimeOperationFailed,
];
let mut kinds = std::collections::BTreeSet::new();
for category in categories {
let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
assert_ne!(kind, "other");
assert!(!kind.contains("secret"));
kinds.insert(kind);
}
assert_eq!(kinds.len(), categories.len());
}
fn production_half_of(source: &str) -> String {
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(production, _)| production);
production
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
fn this_file_above_its_tests_without_prose() -> String {
production_half_of(include_str!("reconcile.rs"))
}
fn normalise_for_scan(text: &str) -> String {
text.to_ascii_lowercase().replace(['_', ' '], "")
}
const FORBIDDEN: &[&str] = &[
concat!("fn ", "acquire", "_job"),
concat!("fn ", "claim", "_job"),
concat!("fn ", "lease", "_job"),
concat!("fn ", "reserve", "_job"),
concat!("fn ", "ack", "nowledge"),
concat!("struct ", "Job", "Lease"),
concat!("struct ", "Job", "Claim"),
concat!("struct ", "Job", "Reservation"),
];
fn forbidden_shape_in(source: &str) -> Option<&'static str> {
let haystack = normalise_for_scan(source);
FORBIDDEN
.iter()
.copied()
.find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
}
#[test]
fn nothing_in_this_crate_reserves_or_claims_a_job() {
const SOURCES: &[(&str, &str)] = &[
("dependency_caches.rs", include_str!("dependency_caches.rs")),
("lib.rs", include_str!("lib.rs")),
("lifecycle.rs", include_str!("lifecycle.rs")),
(
"lifecycle/windows_hyperv.rs",
include_str!("lifecycle/windows_hyperv.rs"),
),
("oci.rs", include_str!("oci.rs")),
("package.rs", include_str!("package.rs")),
("reconcile.rs", include_str!("reconcile.rs")),
];
fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
let entry = entry.expect("a readable directory entry");
let name = entry.file_name().to_string_lossy().into_owned();
let joined = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
if entry.path().is_dir() {
walk(&entry.path(), &joined, found);
} else if name.ends_with(".rs") {
found.push(joined);
}
}
}
let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
listed.sort_unstable();
let mut on_disk = Vec::new();
walk(
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
"",
&mut on_disk,
);
on_disk.sort_unstable();
assert_eq!(
listed, on_disk,
"a source file was added or removed; this scan claims to cover the whole crate \
and a stale list makes that claim false"
);
for (name, source) in SOURCES {
assert_eq!(
forbidden_shape_in(&production_half_of(source)),
None,
"{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
REST, and a local lease coordinates this host with itself and with nothing \
else. If an owner decision restored one, that decision belongs in this \
module's documentation and in this test before it belongs in the code"
);
}
assert!(
forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
"the scan above proves nothing if the needles no longer match"
);
}
#[test]
fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
let production = this_file_above_its_tests_without_prose();
assert!(
production.contains("DemandTally"),
"the reconciliation loop must tally queued jobs against this policy's routing \
labels. A production half that named `DemandTally` nowhere would mean the \
label filtering had been removed, and a repository whose jobs target \
`ubuntu-latest` would drive its policy toward `max_capacity` again"
);
for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
assert!(
!production.contains(second_implementation),
"the reconciliation loop names `{second_implementation}`, which is the \
vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
has already decided it. This module applies the predicate and does not \
re-implement it; if an owner decision changed that, it belongs in this \
module's documentation and in this test before it belongs in the code"
);
}
}
#[test]
fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
let policy = policy(1, "acme/app", 10);
let reading = QueuedDemand::of(
repo("acme/app"),
[
fixtures::queued_job(&[HOST_LABEL]),
fixtures::queued_job(&[HOST_LABEL]),
fixtures::queued_job(&["ubuntu-latest"]),
fixtures::unresolvable_job(),
],
);
let tally = demand_for(&policy, &reading);
assert_eq!(
tally.demand(),
2,
"only the jobs whose required labels this policy carries are demand"
);
assert_eq!(
tally.not_matched, 1,
"a `ubuntu-latest` job is somebody else's work; counting it would start a \
runner that idles until it times out"
);
assert_eq!(
tally.unresolvable.len(),
1,
"an unresolvable `runs-on` is never demand and never discarded"
);
}
#[test]
fn an_organization_policy_tallies_every_repository_its_scope_covers() {
let mut per_repository = BTreeMap::new();
per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
let reading = QueuedDemand::new(per_repository);
let repository_policy = policy(1, "acme/left", 10);
assert_eq!(
demand_for(&repository_policy, &reading).demand(),
3,
"a repository target reads its own repository's queue and not the aggregate"
);
let org_policy = fixtures::policy()
.id(PolicyId::from_u128(2))
.organization("acme")
.autoscale("home", 10)
.active()
.build();
assert_eq!(
demand_for(&org_policy, &reading).demand(),
7,
"an organization policy serves any repository in its scope, so its demand is \
the whole aggregate's"
);
}
#[test]
fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
let host = host_with(2);
let policy = policy(1, "acme/app", 5);
let attempts: Vec<RunnerAttempt> = Vec::new();
let mut allocator = HostAllocator::from_attempts(&host, &attempts);
let allocation = allocator.allocate(&policy, u32::MAX);
assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
}
}