1use std::collections::{BTreeMap, BTreeSet};
109use std::fmt;
110use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
111use std::sync::{Arc, Mutex};
112use std::time::Duration;
113
114use runner_manager_domain::attempt::{
115 AttemptOutcome, AttemptState, FailureReason, IsolationProviderFailure, RunnerAttempt,
116};
117use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
118use runner_manager_domain::model::{
119 AttemptId, Clock, Host, Org, OwnerRepo, PolicyId, RefreshInterval, ScaleTarget, Timestamp,
120};
121use runner_manager_domain::policy::{DemandTally, ScalePolicy};
122use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
123use runner_manager_github::rest::{
124 ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
125};
126
127pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
147
148pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
157
158const MAX_BACKOFF_DOUBLINGS: u32 = 5;
164
165const JITTER_RATIO: f64 = 0.5;
173
174pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
181
182pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
212
213#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum PollOutcome {
231 Ready(QueuedDemand),
234 Failed(RefreshState),
237}
238
239impl PollOutcome {
240 #[must_use]
242 pub const fn reading(&self) -> Option<&QueuedDemand> {
243 match self {
244 Self::Ready(demand) => Some(demand),
245 Self::Failed(_) => None,
246 }
247 }
248
249 #[must_use]
251 pub const fn failure(&self) -> Option<&RefreshState> {
252 match self {
253 Self::Failed(state) => Some(state),
254 Self::Ready(_) => None,
255 }
256 }
257
258 #[must_use]
265 pub fn is_offline(&self) -> bool {
266 matches!(self, Self::Failed(RefreshState::Offline))
267 }
268}
269
270#[async_trait::async_trait]
278pub trait DemandSource: fmt::Debug + Send + Sync {
279 async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
281}
282
283#[derive(Debug)]
288pub struct GatewayDemand<G> {
289 gateway: G,
290 cancel: CancelToken,
291}
292
293impl<G: DemandGateway> GatewayDemand<G> {
294 #[must_use]
295 pub const fn new(gateway: G, cancel: CancelToken) -> Self {
296 Self { gateway, cancel }
297 }
298
299 #[must_use]
300 pub const fn gateway(&self) -> &G {
301 &self.gateway
302 }
303}
304
305#[async_trait::async_trait]
306impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
307 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
308 match self.gateway.queued_demand(scope, &self.cancel).await {
309 Ok(demand) => PollOutcome::Ready(demand),
310 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
314 }
315 }
316}
317
318#[async_trait::async_trait]
329pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
330 async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
335}
336
337#[derive(Debug, Clone)]
338struct CachedRepositories {
339 repositories: Vec<OwnerRepo>,
340 fetched_at: Timestamp,
341}
342
343#[derive(Debug)]
361pub struct RepositoryCache {
362 directory: Arc<dyn RepositoryDirectory>,
363 clock: Arc<dyn Clock>,
364 ttl: Duration,
365 entries: Mutex<BTreeMap<Org, CachedRepositories>>,
366 lookups: AtomicU64,
367}
368
369impl RepositoryCache {
370 #[must_use]
373 pub fn new(
374 directory: Arc<dyn RepositoryDirectory>,
375 clock: Arc<dyn Clock>,
376 poll: RefreshInterval,
377 ) -> Self {
378 let ttl = Duration::from_secs(u64::from(poll.as_secs()))
379 .saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
380 Self {
381 directory,
382 clock,
383 ttl,
384 entries: Mutex::new(BTreeMap::new()),
385 lookups: AtomicU64::new(0),
386 }
387 }
388
389 #[must_use]
391 pub const fn ttl(&self) -> Duration {
392 self.ttl
393 }
394
395 #[must_use]
400 pub fn lookups(&self) -> u64 {
401 self.lookups.load(Ordering::SeqCst)
402 }
403
404 pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
410 match target {
411 ScaleTarget::Repository(repository) => {
412 Ok(ActivityScope::repository(repository.clone()))
413 }
414 ScaleTarget::Organization(org) => {
415 let repositories = self.repositories_of(org).await?;
416 Ok(ActivityScope::organization(org.clone(), repositories))
417 }
418 }
419 }
420
421 async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
422 let now = self.clock.now();
423 if let Some(fresh) = self.fresh_entry(org, now) {
424 return Ok(fresh);
425 }
426
427 let repositories = self.directory.repositories(org).await?;
434 self.lookups.fetch_add(1, Ordering::SeqCst);
435 self.store(org.clone(), repositories.clone(), now);
436 Ok(repositories)
437 }
438
439 fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
440 let entries = self.entries.lock().ok()?;
441 let entry = entries.get(org)?;
442 let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
443 (age < self.ttl).then(|| entry.repositories.clone())
444 }
445
446 fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
447 if let Ok(mut entries) = self.entries.lock() {
448 entries.insert(
449 org,
450 CachedRepositories {
451 repositories,
452 fetched_at,
453 },
454 );
455 }
456 }
457}
458
459pub trait Jitter: fmt::Debug + Send + Sync {
470 fn fraction(&self) -> f64;
473}
474
475#[derive(Debug, Clone, Copy, Default)]
477pub struct RandomJitter;
478
479impl Jitter for RandomJitter {
480 fn fraction(&self) -> f64 {
481 rand::random::<f64>()
482 }
483}
484
485#[derive(Debug, Clone, Copy)]
487pub struct FixedJitter(pub f64);
488
489impl Jitter for FixedJitter {
490 fn fraction(&self) -> f64 {
491 self.0
492 }
493}
494
495#[derive(Debug, Clone, Copy, Default)]
497pub struct NoJitter;
498
499impl Jitter for NoJitter {
500 fn fraction(&self) -> f64 {
501 0.0
502 }
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub enum PollPace {
517 Nominal,
519 RateLimited { kind: RateLimitKind },
521 LockedOut,
523 Offline { consecutive: u32 },
526 Blocked,
531}
532
533impl PollPace {
534 #[must_use]
536 pub const fn is_throttled(&self) -> bool {
537 !matches!(self, Self::Nominal)
538 }
539
540 #[must_use]
542 pub const fn as_str(&self) -> &'static str {
543 match self {
544 Self::Nominal => "nominal",
545 Self::RateLimited {
546 kind: RateLimitKind::Primary,
547 } => "rate_limited_primary",
548 Self::RateLimited {
549 kind: RateLimitKind::Secondary,
550 } => "rate_limited_secondary",
551 Self::LockedOut => "locked_out",
552 Self::Offline { .. } => "offline",
553 Self::Blocked => "blocked",
554 }
555 }
556}
557
558impl fmt::Display for PollPace {
559 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560 f.write_str(self.as_str())
561 }
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub struct NextPoll {
567 pub delay: Duration,
568 pub pace: PollPace,
569}
570
571#[derive(Debug, Clone)]
591pub struct PollSchedule {
592 interval: RefreshInterval,
593 consecutive_offline: u32,
594 offline_since: Option<Timestamp>,
595}
596
597impl PollSchedule {
598 #[must_use]
599 pub const fn new(interval: RefreshInterval) -> Self {
600 Self {
601 interval,
602 consecutive_offline: 0,
603 offline_since: None,
604 }
605 }
606
607 #[must_use]
608 pub const fn interval(&self) -> RefreshInterval {
609 self.interval
610 }
611
612 #[must_use]
614 pub const fn nominal(&self) -> Duration {
615 Duration::from_secs(self.interval.as_secs() as u64)
616 }
617
618 #[must_use]
620 pub const fn consecutive_offline(&self) -> u32 {
621 self.consecutive_offline
622 }
623
624 #[must_use]
633 pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
634 let since = self.offline_since?;
635 now.signed_duration_since(since).to_std().ok()
636 }
637
638 #[must_use]
640 pub const fn floor() -> Duration {
641 Duration::from_secs(RefreshInterval::MIN_SECS as u64)
642 }
643
644 pub fn next_poll(
652 &mut self,
653 failure: Option<&RefreshState>,
654 now: Timestamp,
655 jitter: &dyn Jitter,
656 ) -> NextPoll {
657 let nominal = self.nominal();
658
659 let next = match failure {
660 None => {
661 self.recovered();
662 NextPoll {
663 delay: nominal,
664 pace: PollPace::Nominal,
665 }
666 }
667 Some(RefreshState::Offline) => {
668 self.consecutive_offline = self.consecutive_offline.saturating_add(1);
669 self.offline_since.get_or_insert(now);
671 NextPoll {
672 delay: self.offline_delay(nominal, jitter),
673 pace: PollPace::Offline {
674 consecutive: self.consecutive_offline,
675 },
676 }
677 }
678 Some(state @ RefreshState::RateLimited(limit)) => {
679 self.recovered();
680 NextPoll {
681 delay: retry_floor(state, now).max(nominal),
683 pace: PollPace::RateLimited { kind: limit.kind },
684 }
685 }
686 Some(state @ RefreshState::LockedOut { .. }) => {
687 self.recovered();
688 NextPoll {
689 delay: retry_floor(state, now).max(nominal),
690 pace: PollPace::LockedOut,
691 }
692 }
693 Some(_) => {
699 self.recovered();
702 NextPoll {
703 delay: nominal,
704 pace: PollPace::Blocked,
705 }
706 }
707 };
708
709 debug_assert!(
710 next.delay >= Self::floor(),
711 "the 30-second floor is a rate-budget constraint and no branch may go below it"
712 );
713 next
714 }
715
716 fn recovered(&mut self) {
718 self.consecutive_offline = 0;
719 self.offline_since = None;
720 }
721
722 fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
723 let doublings = self
724 .consecutive_offline
725 .saturating_sub(1)
726 .min(MAX_BACKOFF_DOUBLINGS);
727 let grown = nominal.saturating_mul(1_u32 << doublings);
728 let capped = grown.min(MAX_OFFLINE_BACKOFF);
729 let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
735 capped.saturating_add(spread).max(Self::floor())
736 }
737}
738
739fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
747 state.retry_delay(now).unwrap_or(PollSchedule::floor())
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763pub struct OfflineState {
764 pub consecutive: u32,
766 pub retry_in: Duration,
768 pub offline_for: Option<Duration>,
770}
771
772impl OfflineState {
773 #[must_use]
774 pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
775 Self {
776 consecutive,
777 retry_in,
778 offline_for: None,
779 }
780 }
781
782 #[must_use]
783 pub const fn since(mut self, offline_for: Duration) -> Self {
784 self.offline_for = Some(offline_for);
785 self
786 }
787
788 #[must_use]
793 pub fn has_outlasted_the_queue(&self) -> bool {
794 self.offline_for
795 .is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
796 }
797}
798
799impl fmt::Display for OfflineState {
800 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801 write!(
802 f,
803 "GitHub is unreachable; no new runners are being started and running \
804 runners are left alone. Retrying in {}s",
805 self.retry_in.as_secs()
806 )?;
807 if self.has_outlasted_the_queue() {
808 f.write_str(
809 ". This outage has lasted more than 24 hours, and GitHub cancels a queued \
810 job after 24 hours, so queued work has been lost",
811 )
812 } else {
813 f.write_str(
814 ". GitHub cancels a queued job after 24 hours, so an outage longer than \
815 that loses queued work",
816 )
817 }
818 }
819}
820
821#[derive(Debug, Clone, Copy)]
827pub struct LaunchRequest<'a> {
828 pub host: &'a Host,
829 pub policy: &'a ScalePolicy,
830 pub(crate) allocation_guard: &'a AllocationGuard,
836 pub lock_wait: Duration,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
847#[error("the runner could not be started: {reason}")]
848pub struct LaunchFailure {
849 pub reason: FailureReason,
850}
851
852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
855pub struct ReplacementIntent {
856 pub policy: PolicyId,
857 pub previous_attempt: AttemptId,
858 pub operation: &'static str,
859}
860
861impl LaunchFailure {
862 #[must_use]
863 pub const fn new(reason: FailureReason) -> Self {
864 Self { reason }
865 }
866}
867
868#[async_trait::async_trait]
905pub trait RunnerLauncher: fmt::Debug + Send + Sync {
906 async fn supervise(
911 &self,
912 _policy: &ScalePolicy,
913 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
914 Ok(Vec::new())
915 }
916
917 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
930
931 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
978
979 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
987}
988
989#[derive(Debug)]
1000pub struct FitnessGatedLauncher {
1001 inner: Arc<dyn RunnerLauncher>,
1002 unfit: Arc<AtomicBool>,
1003}
1004
1005impl FitnessGatedLauncher {
1006 #[must_use]
1007 pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
1008 Self { inner, unfit }
1009 }
1010}
1011
1012#[async_trait::async_trait]
1013impl RunnerLauncher for FitnessGatedLauncher {
1014 async fn supervise(
1015 &self,
1016 policy: &ScalePolicy,
1017 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1018 self.inner.supervise(policy).await
1019 }
1020
1021 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
1022 self.inner.attempts().await
1023 }
1024
1025 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
1026 if self.unfit.load(Ordering::Acquire) {
1027 return Err(LaunchFailure::new(FailureReason::HostUnfit));
1028 }
1029 self.inner.launch(request).await
1030 }
1031
1032 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
1033 self.inner.clean(attempt).await
1034 }
1035}
1036
1037pub struct AllocationGuard {
1047 _held: Box<dyn std::any::Any + Send + Sync>,
1048}
1049
1050impl AllocationGuard {
1051 #[must_use]
1053 fn new<T: Send + Sync + 'static>(held: T) -> Self {
1054 Self {
1055 _held: Box::new(held),
1056 }
1057 }
1058}
1059
1060impl fmt::Debug for AllocationGuard {
1061 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1062 f.write_str("AllocationGuard")
1063 }
1064}
1065
1066#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1068#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1069pub struct AllocationLockBusy;
1070
1071#[async_trait::async_trait]
1085pub trait AllocationLock: fmt::Debug + Send + Sync {
1086 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1092}
1093
1094#[derive(Debug, Default)]
1102pub struct InProcessAllocationLock {
1103 mutex: Arc<tokio::sync::Mutex<()>>,
1104}
1105
1106impl InProcessAllocationLock {
1107 #[must_use]
1108 pub fn new() -> Self {
1109 Self::default()
1110 }
1111}
1112
1113#[async_trait::async_trait]
1114impl AllocationLock for InProcessAllocationLock {
1115 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1116 let mutex = Arc::clone(&self.mutex);
1117 let guard = mutex.lock_owned().await;
1118 Ok(AllocationGuard::new(guard))
1119 }
1120}
1121
1122#[derive(Debug, Clone)]
1137pub struct FileAllocationLock {
1138 paths: Arc<runner_manager_platform::paths::AppPaths>,
1139 wait: Duration,
1140}
1141
1142impl FileAllocationLock {
1143 #[must_use]
1144 pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1145 Self {
1146 paths,
1147 wait: ALLOCATION_LOCK_WAIT,
1148 }
1149 }
1150
1151 #[must_use]
1152 pub const fn with_wait(mut self, wait: Duration) -> Self {
1153 self.wait = wait;
1154 self
1155 }
1156}
1157
1158#[async_trait::async_trait]
1159impl AllocationLock for FileAllocationLock {
1160 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1161 use runner_manager_platform::lock::{HostLock, LockKind};
1162
1163 let paths = Arc::clone(&self.paths);
1164 let wait = self.wait;
1165 let held = tokio::task::spawn_blocking(move || {
1166 HostLock::acquire(&paths, LockKind::Allocation, wait)
1167 })
1168 .await;
1169
1170 match held {
1171 Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1172 Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1176 }
1177 }
1178}
1179
1180#[derive(Debug)]
1204pub struct WslRecoveryAllocationLock {
1205 paths: Arc<runner_manager_platform::paths::AppPaths>,
1206 inner: Arc<dyn AllocationLock>,
1207 blocked_after_refusals: u32,
1208 blocked_after: Duration,
1209 streak_gap: Duration,
1211 in_flight: Arc<std::sync::atomic::AtomicUsize>,
1212 refusals: Mutex<Refusals>,
1213}
1214
1215#[derive(Debug, Default)]
1217struct Refusals {
1218 streak: Option<Streak>,
1221 recorded: bool,
1222}
1223
1224#[derive(Debug, Clone, Copy)]
1225struct Streak {
1226 since: chrono::DateTime<chrono::Utc>,
1227 latest: chrono::DateTime<chrono::Utc>,
1228 count: u32,
1229}
1230
1231struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
1233
1234impl InFlight {
1235 fn enter(count: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
1236 count.fetch_add(1, Ordering::SeqCst);
1237 Self(Arc::clone(count))
1238 }
1239}
1240
1241impl Drop for InFlight {
1242 fn drop(&mut self) {
1243 self.0.fetch_sub(1, Ordering::SeqCst);
1244 }
1245}
1246
1247struct LaunchFenceGuard(Option<runner_manager_platform::wsl::fence::GuestLaunchClaim>);
1251
1252impl Drop for LaunchFenceGuard {
1253 fn drop(&mut self) {
1254 if let Some(claim) = self.0.take()
1255 && let Err(error) = claim.release()
1256 {
1257 tracing::warn!(
1258 %error,
1259 "the WSL launch fence could not be released; the next launch reclaims it"
1260 );
1261 }
1262 }
1263}
1264
1265impl WslRecoveryAllocationLock {
1266 #[must_use]
1267 pub fn new(
1268 paths: Arc<runner_manager_platform::paths::AppPaths>,
1269 inner: Arc<dyn AllocationLock>,
1270 ) -> Self {
1271 use runner_manager_platform::launch_health::{BLOCKED_AFTER, BLOCKED_AFTER_DEFERRALS};
1272 Self {
1273 paths,
1274 inner,
1275 blocked_after_refusals: BLOCKED_AFTER_DEFERRALS,
1276 blocked_after: BLOCKED_AFTER,
1277 streak_gap: BLOCKED_AFTER,
1278 in_flight: Arc::default(),
1279 refusals: Mutex::default(),
1280 }
1281 }
1282
1283 #[cfg(test)]
1286 const fn with_blocked_after(mut self, refusals: u32, after: Duration) -> Self {
1287 self.blocked_after_refusals = refusals;
1288 self.blocked_after = after;
1289 self
1290 }
1291
1292 #[cfg(test)]
1294 const fn with_streak_gap(mut self, gap: Duration) -> Self {
1295 self.streak_gap = gap;
1296 self
1297 }
1298
1299 fn refusals(&self) -> std::sync::MutexGuard<'_, Refusals> {
1300 self.refusals
1301 .lock()
1302 .unwrap_or_else(std::sync::PoisonError::into_inner)
1303 }
1304
1305 fn note_granted(&self) {
1306 let mut refusals = self.refusals();
1307 refusals.streak = None;
1308 if refusals.recorded {
1309 match runner_manager_platform::launch_health::clear_launches_blocked(&self.paths) {
1310 Ok(()) => refusals.recorded = false,
1311 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be cleared"),
1313 }
1314 }
1315 }
1316
1317 fn note_refused(&self, cause: &runner_manager_platform::launch_health::BlockCause) {
1318 use runner_manager_platform::launch_health::{Viewpoint, record_launches_blocked};
1319 use runner_manager_platform::wsl::fence::elapsed_at_least;
1320
1321 if self.in_flight.load(Ordering::SeqCst) > 0 {
1322 return;
1323 }
1324 let now = chrono::Utc::now();
1325 let mut refusals = self.refusals();
1326 let streak = match refusals.streak {
1329 Some(streak) if !elapsed_at_least(streak.latest, now, self.streak_gap) => Streak {
1330 latest: now,
1331 count: streak.count.saturating_add(1),
1332 ..streak
1333 },
1334 _ => Streak {
1335 since: now,
1336 latest: now,
1337 count: 1,
1338 },
1339 };
1340 refusals.streak = Some(streak);
1341 if streak.count < self.blocked_after_refusals
1342 || !elapsed_at_least(streak.since, now, self.blocked_after)
1343 {
1344 return;
1345 }
1346 let blocked = cause.blocked_since(streak.since, Viewpoint::Guest);
1347 if !refusals.recorded {
1348 tracing::warn!(
1349 refusals = streak.count,
1350 "runner launches are blocked {blocked}: {}",
1351 blocked.remedy
1352 );
1353 }
1354 match record_launches_blocked(&self.paths, &blocked, now) {
1357 Ok(()) => refusals.recorded = true,
1358 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be written"),
1359 }
1360 }
1361}
1362
1363#[async_trait::async_trait]
1364impl AllocationLock for WslRecoveryAllocationLock {
1365 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1366 use runner_manager_platform::launch_health::BlockCause;
1367 use runner_manager_platform::wsl::fence::{
1368 DrainRequest, FENCE_DIRECTORY, FenceClaim, GuestClaimAttempt, GuestRecoveryConfig,
1369 try_claim_guest_launch,
1370 };
1371
1372 let paths = Arc::clone(&self.paths);
1373 let claim = tokio::task::spawn_blocking(move || {
1374 let unusable = |error: &dyn fmt::Display| BlockCause::FenceUnusable {
1375 detail: error.to_string(),
1376 };
1377 let config = match GuestRecoveryConfig::read(&paths) {
1378 Ok(Some(config)) => config,
1379 Ok(None) => return Ok(LaunchFenceGuard(None)),
1380 Err(error) => return Err(unusable(&error)),
1381 };
1382 let root = config.shared_root;
1383 let claim = match try_claim_guest_launch(&root) {
1384 Ok(GuestClaimAttempt::Claimed { claim, reclaimed }) => {
1385 if let Some(reclaimed) = reclaimed {
1386 tracing::warn!(
1387 shared_root = %root.display(),
1388 %reclaimed,
1389 "reclaimed a WSL launch fence claim whose owner is gone"
1390 );
1391 }
1392 LaunchFenceGuard(Some(claim))
1395 }
1396 Ok(GuestClaimAttempt::Busy) => {
1397 let directory = root.join(FENCE_DIRECTORY);
1398 return Err(match FenceClaim::owner(&root) {
1399 Ok(owner) => BlockCause::FenceHeld { directory, owner },
1400 Err(error) => BlockCause::FenceOwnerUnreadable {
1401 directory,
1402 detail: error.to_string(),
1403 },
1404 });
1405 }
1406 Err(error) => {
1407 return Err(match FenceClaim::owner(&root) {
1410 Err(owner_error) => BlockCause::FenceOwnerUnreadable {
1411 directory: root.join(FENCE_DIRECTORY),
1412 detail: owner_error.to_string(),
1413 },
1414 Ok(_) => unusable(&error),
1415 });
1416 }
1417 };
1418 match DrainRequest::read(&root) {
1419 Ok(None) => Ok(claim),
1420 Ok(Some(_)) => Err(BlockCause::DrainRequested { root }),
1421 Err(error) => Err(unusable(&error)),
1422 }
1423 })
1424 .await
1425 .map_err(|_| AllocationLockBusy)?;
1426
1427 let claim = match claim {
1428 Ok(claim) => claim,
1429 Err(cause) => {
1430 self.note_refused(&cause);
1431 return Err(AllocationLockBusy);
1432 }
1433 };
1434 let Ok(inner) = self.inner.acquire().await else {
1435 self.note_refused(&BlockCause::AllocationLockBusy {
1436 path: self
1437 .paths
1438 .state_dir()
1439 .join(runner_manager_platform::lock::LockKind::Allocation.file_name()),
1440 });
1441 return Err(AllocationLockBusy);
1442 };
1443 self.note_granted();
1444 let in_flight = InFlight::enter(&self.in_flight);
1445 Ok(AllocationGuard::new((claim, inner, in_flight)))
1446 }
1447}
1448
1449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1459pub enum OutcomeKind {
1460 CompletedJob,
1461 IdleExit,
1462 Failed,
1463 Orphaned,
1464}
1465
1466impl OutcomeKind {
1467 #[must_use]
1468 pub const fn of(outcome: &AttemptOutcome) -> Self {
1469 match outcome {
1470 AttemptOutcome::CompletedJob => Self::CompletedJob,
1471 AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1472 AttemptOutcome::Failed { .. } => Self::Failed,
1473 AttemptOutcome::Orphaned => Self::Orphaned,
1474 }
1475 }
1476
1477 #[must_use]
1478 pub const fn is_failure(&self) -> bool {
1479 matches!(self, Self::Failed | Self::Orphaned)
1480 }
1481
1482 #[must_use]
1483 pub const fn as_str(&self) -> &'static str {
1484 match self {
1485 Self::CompletedJob => "completed_job",
1486 Self::IdleExit => "exited_idle_without_work",
1487 Self::Failed => "failed",
1488 Self::Orphaned => "orphaned",
1489 }
1490 }
1491}
1492
1493impl fmt::Display for OutcomeKind {
1494 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1495 f.write_str(self.as_str())
1496 }
1497}
1498
1499#[must_use]
1508pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1509 match reason {
1510 FailureReason::JitRequestFailed => "jit_request_failed",
1511 FailureReason::JitExpired => "jit_expired",
1512 FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1513 FailureReason::RunnerVersionRejected => "runner_version_rejected",
1514 FailureReason::ProcessStartFailed => "process_start_failed",
1515 FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1516 FailureReason::RegistrationTimedOut => "registration_timed_out",
1517 FailureReason::TerminatedAfterRegistrationTimeout => {
1518 "terminated_after_registration_timeout"
1519 }
1520 FailureReason::IsolationProvider(category) => match category {
1521 IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1522 IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1523 IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1524 IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1525 "isolation_image_unavailable"
1526 }
1527 IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1528 IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1529 IsolationProviderFailure::RuntimeOperationFailed => {
1530 "isolation_runtime_operation_failed"
1531 }
1532 IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1533 IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1534 IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1535 },
1536 FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1537 FailureReason::HostUnfit => "host_unfit",
1538 FailureReason::Other(_) => "other",
1539 }
1540}
1541
1542#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1544pub enum RoutingRefusal {
1545 OverlappingProfiles,
1546}
1547
1548impl RoutingRefusal {
1549 #[must_use]
1550 pub const fn as_str(self) -> &'static str {
1551 match self {
1552 Self::OverlappingProfiles => "overlapping_profile_matches",
1553 }
1554 }
1555}
1556
1557impl fmt::Display for RoutingRefusal {
1558 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1559 f.write_str(match self {
1560 Self::OverlappingProfiles => {
1561 "repair overlapping profile labels or require a unique profile selector"
1562 }
1563 })
1564 }
1565}
1566
1567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1577pub enum LifecycleEvent {
1578 DemandObserved {
1580 policy: PolicyId,
1581 demand: u32,
1583 not_matched: u32,
1589 unresolvable: u32,
1598 complete: bool,
1600 },
1601 RoutingRefused {
1603 policy: PolicyId,
1604 reason: RoutingRefusal,
1605 count: u32,
1606 },
1607 TargetUnreadable {
1609 policy: PolicyId,
1610 reason: &'static str,
1611 },
1612 Allocated {
1614 policy: PolicyId,
1615 demand: u32,
1616 desired: u16,
1617 active_owned: u16,
1618 headroom: u16,
1619 to_start: u16,
1620 limiting: LimitingFactor,
1621 },
1622 MonitorOnlySkipped { policy: PolicyId },
1625 RunnerStarted {
1627 policy: PolicyId,
1628 attempt: AttemptId,
1629 },
1630 RunnerStartFailed {
1632 policy: PolicyId,
1633 reason: &'static str,
1634 },
1635 AllocationDeferred { policy: PolicyId, count: u16 },
1638 AttemptsUnreadable { reason: &'static str },
1644 AttemptCleaned {
1646 policy: PolicyId,
1647 attempt: AttemptId,
1648 outcome: OutcomeKind,
1649 },
1650 AttemptCleanFailed {
1653 policy: PolicyId,
1654 attempt: AttemptId,
1655 reason: &'static str,
1656 },
1657 ScaleDownRefused {
1659 policy: PolicyId,
1660 attempt: AttemptId,
1661 },
1662 PollScheduled { retry_in_ms: u64, pace: PollPace },
1664}
1665
1666impl LifecycleEvent {
1667 #[must_use]
1669 pub const fn name(&self) -> &'static str {
1670 match self {
1671 Self::DemandObserved { .. } => "demand_observed",
1672 Self::RoutingRefused { .. } => "routing_refused",
1673 Self::TargetUnreadable { .. } => "target_unreadable",
1674 Self::Allocated { .. } => "allocated",
1675 Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1676 Self::RunnerStarted { .. } => "runner_started",
1677 Self::RunnerStartFailed { .. } => "runner_start_failed",
1678 Self::AllocationDeferred { .. } => "allocation_deferred",
1679 Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1680 Self::AttemptCleaned { .. } => "attempt_cleaned",
1681 Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1682 Self::ScaleDownRefused { .. } => "scale_down_refused",
1683 Self::PollScheduled { .. } => "poll_scheduled",
1684 }
1685 }
1686
1687 #[must_use]
1689 pub const fn policy(&self) -> Option<PolicyId> {
1690 match self {
1691 Self::DemandObserved { policy, .. }
1692 | Self::RoutingRefused { policy, .. }
1693 | Self::TargetUnreadable { policy, .. }
1694 | Self::Allocated { policy, .. }
1695 | Self::MonitorOnlySkipped { policy }
1696 | Self::RunnerStarted { policy, .. }
1697 | Self::RunnerStartFailed { policy, .. }
1698 | Self::AllocationDeferred { policy, .. }
1699 | Self::AttemptCleaned { policy, .. }
1700 | Self::AttemptCleanFailed { policy, .. }
1701 | Self::ScaleDownRefused { policy, .. } => Some(*policy),
1702 Self::PollScheduled { .. } | Self::AttemptsUnreadable { .. } => None,
1703 }
1704 }
1705}
1706
1707impl fmt::Display for LifecycleEvent {
1708 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1709 match self {
1710 Self::DemandObserved {
1711 policy,
1712 demand,
1713 not_matched,
1714 unresolvable,
1715 complete,
1716 } => write!(
1717 f,
1718 "policy {policy}: {demand} queued jobs for this host{}{}{}",
1719 if *not_matched == 0 {
1720 String::new()
1721 } else {
1722 format!(", {not_matched} for other labels")
1723 },
1724 if *unresolvable == 0 {
1725 String::new()
1726 } else {
1727 format!(", {unresolvable} with an unresolvable `runs-on`")
1728 },
1729 if *complete {
1730 ""
1731 } else {
1732 " (a floor, not a total)"
1733 }
1734 ),
1735 Self::TargetUnreadable { policy, reason } => {
1736 write!(f, "policy {policy}: target unreadable ({reason})")
1737 }
1738 Self::RoutingRefused {
1739 policy,
1740 reason,
1741 count,
1742 } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
1743 Self::Allocated {
1744 policy,
1745 demand,
1746 desired,
1747 active_owned,
1748 headroom,
1749 to_start,
1750 limiting,
1751 } => write!(
1752 f,
1753 "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
1754 flight, {headroom} free on this host, starting {to_start} ({limiting})"
1755 ),
1756 Self::MonitorOnlySkipped { policy } => {
1757 write!(f, "policy {policy}: monitor-only, skipped")
1758 }
1759 Self::RunnerStarted { policy, attempt } => {
1760 write!(f, "policy {policy}: started attempt {attempt}")
1761 }
1762 Self::RunnerStartFailed { policy, reason } => {
1763 write!(f, "policy {policy}: could not start a runner ({reason})")
1764 }
1765 Self::AllocationDeferred { policy, count } => write!(
1766 f,
1767 "policy {policy}: the allocation lock was held; {count} granted runners \
1768 were not created"
1769 ),
1770 Self::AttemptsUnreadable { reason } => write!(
1771 f,
1772 "the host's attempt set could not be read ({reason}); nothing was started, \
1773 and this is not the same as the host being idle"
1774 ),
1775 Self::AttemptCleaned {
1776 policy,
1777 attempt,
1778 outcome,
1779 } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
1780 Self::AttemptCleanFailed {
1781 policy,
1782 attempt,
1783 reason,
1784 } => write!(
1785 f,
1786 "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
1787 will be retried"
1788 ),
1789 Self::ScaleDownRefused { policy, attempt } => write!(
1790 f,
1791 "policy {policy}: attempt {attempt} is executing a job and was not removed"
1792 ),
1793 Self::PollScheduled { retry_in_ms, pace } => {
1794 write!(f, "next poll in {retry_in_ms}ms ({pace})")
1795 }
1796 }
1797 }
1798}
1799
1800pub trait EventSink: fmt::Debug + Send + Sync {
1802 fn emit(&self, event: LifecycleEvent);
1803}
1804
1805#[derive(Debug, Clone, Copy, Default)]
1807pub struct NoEvents;
1808
1809impl EventSink for NoEvents {
1810 fn emit(&self, _event: LifecycleEvent) {}
1811}
1812
1813#[derive(Debug, Clone, Copy, Default)]
1821pub struct TracingEvents;
1822
1823impl EventSink for TracingEvents {
1824 fn emit(&self, event: LifecycleEvent) {
1825 let name = event.name();
1826 match event {
1827 LifecycleEvent::DemandObserved {
1828 policy,
1829 demand,
1830 not_matched,
1831 unresolvable,
1832 complete,
1833 } => {
1834 tracing::info!(
1835 event = name,
1836 policy_id = %policy,
1837 demand,
1838 not_matched,
1839 unresolvable,
1840 count = u64::from(complete),
1841 );
1842 }
1862 LifecycleEvent::TargetUnreadable { policy, reason } => {
1863 tracing::warn!(event = name, policy_id = %policy, reason);
1864 }
1865 LifecycleEvent::RoutingRefused {
1866 policy,
1867 reason,
1868 count,
1869 } => tracing::warn!(
1870 event = name,
1871 policy_id = %policy,
1872 reason = reason.as_str(),
1873 count,
1874 "{reason}"
1875 ),
1876 LifecycleEvent::Allocated {
1877 policy,
1878 demand,
1879 desired,
1880 active_owned,
1881 headroom,
1882 to_start,
1883 limiting,
1884 } => tracing::info!(
1885 event = name,
1886 policy_id = %policy,
1887 demand,
1888 desired,
1889 capacity = active_owned,
1890 headroom,
1891 count = to_start,
1892 reason = %limiting,
1893 ),
1894 LifecycleEvent::MonitorOnlySkipped { policy } => {
1895 tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
1896 }
1897 LifecycleEvent::RunnerStarted { policy, attempt } => {
1898 tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
1899 }
1900 LifecycleEvent::RunnerStartFailed { policy, reason } => {
1901 tracing::warn!(event = name, policy_id = %policy, reason);
1902 }
1903 LifecycleEvent::AllocationDeferred { policy, count } => {
1904 tracing::warn!(
1910 event = name,
1911 policy_id = %policy,
1912 reason = "allocation_lock_unavailable",
1913 count
1914 );
1915 }
1916 LifecycleEvent::AttemptsUnreadable { reason } => {
1917 tracing::warn!(event = name, reason);
1918 }
1919 LifecycleEvent::AttemptCleaned {
1920 policy,
1921 attempt,
1922 outcome,
1923 } => tracing::info!(
1924 event = name,
1925 policy_id = %policy,
1926 attempt_id = %attempt,
1927 outcome = outcome.as_str(),
1928 ),
1929 LifecycleEvent::AttemptCleanFailed {
1930 policy,
1931 attempt,
1932 reason,
1933 } => tracing::warn!(
1934 event = name,
1935 policy_id = %policy,
1936 attempt_id = %attempt,
1937 reason,
1938 ),
1939 LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
1940 event = name,
1941 policy_id = %policy,
1942 attempt_id = %attempt,
1943 attempt_state = "busy",
1944 ),
1945 LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
1946 tracing::info!(event = name, retry_in_ms, state = pace.as_str());
1947 }
1948 }
1949 }
1950}
1951
1952#[derive(Debug, Default)]
1956pub struct EventLog {
1957 events: Mutex<Vec<LifecycleEvent>>,
1958}
1959
1960impl EventLog {
1961 #[must_use]
1962 pub fn new() -> Self {
1963 Self::default()
1964 }
1965
1966 #[must_use]
1967 pub fn events(&self) -> Vec<LifecycleEvent> {
1968 self.events.lock().map(|e| e.clone()).unwrap_or_default()
1969 }
1970
1971 #[must_use]
1973 pub fn count_of(&self, name: &str) -> usize {
1974 self.events()
1975 .iter()
1976 .filter(|event| event.name() == name)
1977 .count()
1978 }
1979}
1980
1981impl EventSink for EventLog {
1982 fn emit(&self, event: LifecycleEvent) {
1983 if let Ok(mut events) = self.events.lock() {
1984 events.push(event);
1985 }
1986 }
1987}
1988
1989#[derive(Debug)]
1992pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
1993
1994impl EventSink for TeeEvents {
1995 fn emit(&self, event: LifecycleEvent) {
1996 self.0.emit(event);
1997 self.1.emit(event);
1998 }
1999}
2000
2001pub struct ReconcilerPorts {
2011 pub demand: Arc<dyn DemandSource>,
2012 pub launcher: Arc<dyn RunnerLauncher>,
2013 pub lock: Arc<dyn AllocationLock>,
2014 pub directory: Arc<dyn RepositoryDirectory>,
2015 pub clock: Arc<dyn Clock>,
2016 pub jitter: Arc<dyn Jitter>,
2017 pub events: Arc<dyn EventSink>,
2018}
2019
2020impl fmt::Debug for ReconcilerPorts {
2021 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2022 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
2023 }
2024}
2025
2026#[derive(Debug, Clone, Default)]
2033pub struct ReconcileReport {
2034 pub allocations: Vec<Allocation>,
2036 pub monitor_only: Vec<PolicyId>,
2038 pub unreadable: Vec<PolicyId>,
2040 pub targets_read: u16,
2048 pub started: u16,
2050 pub replacement_intents: u16,
2053 pub cleaned: u16,
2055 pub idle_exits: u16,
2058 pub failures: u16,
2060 pub deferred: u16,
2069 pub attempts_unreadable: u16,
2088 pub clean_failures: u16,
2090 pub failure: Option<RefreshState>,
2092 pub offline: Option<OfflineState>,
2095 pub next_poll: NextPoll,
2097 pub demand_requests: u32,
2099}
2100
2101impl ReconcileReport {
2102 #[must_use]
2129 pub const fn reached_github(&self) -> bool {
2130 self.targets_read > 0
2131 }
2132}
2133
2134impl Default for NextPoll {
2135 fn default() -> Self {
2136 Self {
2137 delay: PollSchedule::floor(),
2138 pace: PollPace::Nominal,
2139 }
2140 }
2141}
2142
2143impl ReconcileReport {
2144 #[must_use]
2146 pub fn is_offline(&self) -> bool {
2147 matches!(self.failure, Some(RefreshState::Offline))
2148 }
2149
2150 #[must_use]
2152 pub const fn offline_state(&self) -> Option<&OfflineState> {
2153 self.offline.as_ref()
2154 }
2155
2156 #[must_use]
2158 pub const fn starts_nothing(&self) -> bool {
2159 self.started == 0
2160 }
2161}
2162
2163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2165pub struct ScaleDownReport {
2166 pub removed: u16,
2168 pub refused_busy: u16,
2170 pub clean_failures: u16,
2172 pub retained: u16,
2175 pub attempts_unreadable: bool,
2185}
2186
2187impl ScaleDownReport {
2188 #[must_use]
2193 pub const fn is_conclusive(&self) -> bool {
2194 !self.attempts_unreadable
2195 }
2196}
2197
2198const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
2206
2207const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
2209
2210#[derive(Debug, Clone, Copy)]
2212struct CleanRetry {
2213 failures: u32,
2214 not_before: Timestamp,
2215}
2216
2217#[derive(Debug, Default)]
2221pub(crate) struct CleanBackoff {
2222 retries: BTreeMap<AttemptId, CleanRetry>,
2223}
2224
2225impl CleanBackoff {
2226 const SCHEDULE: crate::lifecycle::RetryPolicy =
2228 crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2229
2230 pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2232 self.retries
2233 .get(&attempt)
2234 .is_none_or(|retry| now >= retry.not_before)
2235 }
2236
2237 pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2239 let failures = self
2240 .retries
2241 .get(&attempt)
2242 .map_or(1, |retry| retry.failures.saturating_add(1));
2243 let delay = Self::SCHEDULE.delay(failures);
2244 self.retries.insert(
2245 attempt,
2246 CleanRetry {
2247 failures,
2248 not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2250 },
2251 );
2252 }
2253
2254 pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2256 self.retries.remove(&attempt);
2257 }
2258
2259 fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2262 self.retries.retain(|attempt, _| present.contains(attempt));
2263 }
2264}
2265
2266#[derive(Debug)]
2272pub struct Reconciler {
2273 host: Host,
2274 demand: Arc<dyn DemandSource>,
2275 launcher: Arc<dyn RunnerLauncher>,
2276 lock: Arc<dyn AllocationLock>,
2277 repositories: RepositoryCache,
2278 clock: Arc<dyn Clock>,
2279 jitter: Arc<dyn Jitter>,
2280 events: Arc<dyn EventSink>,
2281 schedule: PollSchedule,
2282 clean_backoff: CleanBackoff,
2283}
2284
2285impl Reconciler {
2286 #[must_use]
2288 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2289 let interval = host.refresh_interval;
2290 let repositories = RepositoryCache::new(
2291 Arc::clone(&ports.directory),
2292 Arc::clone(&ports.clock),
2293 interval,
2294 );
2295 Self {
2296 host,
2297 demand: ports.demand,
2298 launcher: ports.launcher,
2299 lock: ports.lock,
2300 repositories,
2301 clock: ports.clock,
2302 jitter: ports.jitter,
2303 events: ports.events,
2304 schedule: PollSchedule::new(interval),
2305 clean_backoff: CleanBackoff::default(),
2306 }
2307 }
2308
2309 #[must_use]
2310 pub const fn host(&self) -> &Host {
2311 &self.host
2312 }
2313
2314 #[must_use]
2315 pub const fn schedule(&self) -> &PollSchedule {
2316 &self.schedule
2317 }
2318
2319 #[must_use]
2321 pub const fn repositories(&self) -> &RepositoryCache {
2322 &self.repositories
2323 }
2324
2325 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2339 let mut report = ReconcileReport::default();
2340 let mut launched: Vec<RunnerAttempt> = Vec::new();
2344
2345 let mut pollable: Vec<&ScalePolicy> = Vec::new();
2347 let mut supervision_failed = BTreeSet::new();
2348 for policy in policies {
2349 if !policy.owns_runners() {
2350 report.monitor_only.push(policy.id);
2351 self.events
2352 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2353 continue;
2354 }
2355 if !policy.is_owned_by(self.host.id) {
2356 continue;
2360 }
2361 match self.launcher.supervise(policy).await {
2362 Ok(intents) => {
2363 report.replacement_intents = report
2364 .replacement_intents
2365 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
2366 }
2367 Err(failure) => {
2368 supervision_failed.insert(policy.id);
2369 self.report_unreadable_attempts(&mut report, &failure);
2370 continue;
2371 }
2372 }
2373 if !policy.may_start_runners() {
2374 continue;
2375 }
2376 pollable.push(policy);
2377 }
2378
2379 let readings = self.poll_targets(&pollable, &mut report).await;
2380 report.targets_read = u16::try_from(
2381 readings
2382 .values()
2383 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
2384 .count(),
2385 )
2386 .unwrap_or(u16::MAX);
2387 let mut routed = BTreeMap::new();
2388 for (target, outcome) in &readings {
2389 let PollOutcome::Ready(reading) = outcome else {
2390 continue;
2391 };
2392 let profiles: Vec<&ScalePolicy> = pollable
2393 .iter()
2394 .copied()
2395 .filter(|policy| &policy.target == target)
2396 .collect();
2397 let (tallies, refusals) = route_demand(policies, &profiles, reading);
2398 if let Some(first) = profiles.first() {
2399 for (reason, count) in refusals {
2400 self.events.emit(LifecycleEvent::RoutingRefused {
2401 policy: first.id,
2402 reason,
2403 count,
2404 });
2405 }
2406 }
2407 routed.insert(target.clone(), tallies);
2408 }
2409
2410 self.clean_terminal_attempts(&mut report).await;
2420
2421 for policy in policies {
2436 if !policy.owns_runners() {
2437 continue;
2443 }
2444 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2445 match self.allocate_only(policy, 0, &launched).await {
2449 Ok(allocation) => {
2450 self.emit_allocation(&allocation);
2451 report.allocations.push(allocation);
2452 }
2453 Err(failure) => self.report_unreadable_attempts(&mut report, &failure),
2454 }
2455 continue;
2456 }
2457 if supervision_failed.contains(&policy.id) {
2458 continue;
2459 }
2460 let Some(reading) = readings.get(&policy.target) else {
2461 debug_assert!(false, "a pollable policy's target has no reading");
2464 continue;
2465 };
2466 match reading {
2467 PollOutcome::Failed(state) => {
2468 report.unreadable.push(policy.id);
2469 self.events.emit(LifecycleEvent::TargetUnreadable {
2470 policy: policy.id,
2471 reason: unreadable_reason(state),
2472 });
2473 }
2474 PollOutcome::Ready(demand) => {
2475 let tally = routed
2476 .get(&policy.target)
2477 .and_then(|tallies| tallies.get(&policy.id))
2478 .expect("every pollable profile has a routed tally");
2479 let count = tally.demand();
2480 self.events.emit(LifecycleEvent::DemandObserved {
2481 policy: policy.id,
2482 demand: count,
2483 not_matched: tally.not_matched,
2484 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2485 complete: demand.is_complete(),
2486 });
2487 self.start_runners(policy, count, &mut report, &mut launched)
2488 .await;
2489 }
2490 }
2491 }
2492
2493 let failure = readings
2495 .values()
2496 .filter_map(PollOutcome::failure)
2497 .max_by_key(|state| severity(state))
2498 .cloned();
2499 let now = self.clock.now();
2500 report.next_poll = self
2501 .schedule
2502 .next_poll(failure.as_ref(), now, self.jitter.as_ref());
2503 report.failure = failure;
2504 if let PollPace::Offline { consecutive } = report.next_poll.pace {
2508 let state = OfflineState::new(consecutive, report.next_poll.delay);
2509 report.offline = Some(match self.schedule.offline_for(now) {
2510 Some(elapsed) => state.since(elapsed),
2511 None => state,
2512 });
2513 }
2514 self.events.emit(LifecycleEvent::PollScheduled {
2515 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2516 pace: report.next_poll.pace,
2517 });
2518
2519 report
2520 }
2521
2522 async fn poll_targets(
2530 &self,
2531 pollable: &[&ScalePolicy],
2532 report: &mut ReconcileReport,
2533 ) -> BTreeMap<ScaleTarget, PollOutcome> {
2534 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
2535
2536 let mut readings = BTreeMap::new();
2537 for target in targets {
2538 let outcome = match self.repositories.scope_for(&target).await {
2539 Ok(scope) => {
2540 report.demand_requests = report
2541 .demand_requests
2542 .saturating_add(demand_requests_per_poll(&scope));
2543 self.demand.poll(&scope).await
2544 }
2545 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
2551 };
2552 readings.insert(target, outcome);
2553 }
2554 readings
2555 }
2556
2557 async fn allocate_only(
2575 &self,
2576 policy: &ScalePolicy,
2577 demand: u32,
2578 launched: &[RunnerAttempt],
2579 ) -> Result<Allocation, LaunchFailure> {
2580 let attempts = self.host_attempts(launched).await?;
2581 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2582 Ok(allocator.allocate(policy, demand))
2583 }
2584
2585 async fn start_runners(
2607 &self,
2608 policy: &ScalePolicy,
2609 demand: u32,
2610 report: &mut ReconcileReport,
2611 launched: &mut Vec<RunnerAttempt>,
2612 ) {
2613 let intent = match self.allocate_only(policy, demand, launched).await {
2625 Ok(intent) => intent,
2626 Err(failure) => {
2627 self.report_unreadable_attempts(report, &failure);
2628 return;
2629 }
2630 };
2631
2632 let mut decided: Option<Allocation> = None;
2636 let mut budget = intent.to_start;
2637
2638 while budget > 0 {
2639 let waiting_since = std::time::Instant::now();
2640 let guard = match self.lock.acquire().await {
2641 Ok(guard) => guard,
2642 Err(_) => {
2643 report.deferred = report.deferred.saturating_add(budget);
2646 self.events.emit(LifecycleEvent::AllocationDeferred {
2647 policy: policy.id,
2648 count: budget,
2649 });
2650 break;
2651 }
2652 };
2653 let lock_wait = waiting_since.elapsed();
2654
2655 let attempts = match self.host_attempts(launched).await {
2661 Ok(attempts) => attempts,
2662 Err(failure) => {
2663 drop(guard);
2664 self.report_unreadable_attempts(report, &failure);
2665 break;
2666 }
2667 };
2668 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2669 let allocation = allocator.allocate(policy, demand);
2670
2671 if decided.is_none() {
2672 budget = budget.min(allocation.to_start);
2676 decided = Some(allocation.clone());
2677 }
2678
2679 if allocation.starts_nothing() || budget == 0 {
2682 drop(guard);
2683 break;
2684 }
2685
2686 let created = self
2687 .launcher
2688 .launch(LaunchRequest {
2689 host: &self.host,
2690 policy,
2691 allocation_guard: &guard,
2692 lock_wait,
2693 })
2694 .await;
2695 drop(guard);
2696
2697 match created {
2698 Ok(attempt) => {
2699 let id = attempt.id;
2700 launched.push(attempt);
2704 report.started = report.started.saturating_add(1);
2705 budget -= 1;
2706 self.events.emit(LifecycleEvent::RunnerStarted {
2707 policy: policy.id,
2708 attempt: id,
2709 });
2710 }
2711 Err(failure) => {
2712 self.events.emit(LifecycleEvent::RunnerStartFailed {
2713 policy: policy.id,
2714 reason: failure_reason_kind(&failure.reason),
2715 });
2716 break;
2717 }
2718 }
2719 }
2720
2721 let allocation = decided.unwrap_or(intent);
2722 self.emit_allocation(&allocation);
2723 report.allocations.push(allocation);
2724 }
2725
2726 async fn host_attempts(
2739 &self,
2740 launched: &[RunnerAttempt],
2741 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2742 let mut attempts = self.launcher.attempts().await?;
2743
2744 debug_assert!(
2751 launched
2752 .iter()
2753 .map(|attempt| attempt.id)
2754 .collect::<BTreeSet<AttemptId>>()
2755 .len()
2756 == launched.len(),
2757 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
2758 the host ceiling is enforced against a set keyed on that identifier, so a \
2759 duplicate is two runtimes counted as one"
2760 );
2761
2762 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
2763 attempts.extend(
2764 launched
2765 .iter()
2766 .filter(|attempt| !known.contains(&attempt.id))
2767 .cloned(),
2768 );
2769 Ok(attempts)
2770 }
2771
2772 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
2779 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
2780 self.events.emit(LifecycleEvent::AttemptsUnreadable {
2787 reason: failure_reason_kind(&failure.reason),
2788 });
2789 }
2790
2791 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
2797 let attempts = match self.launcher.attempts().await {
2798 Ok(attempts) => attempts,
2799 Err(failure) => {
2800 self.report_unreadable_attempts(report, &failure);
2801 return;
2802 }
2803 };
2804 self.clean_backoff
2805 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
2806 let now = self.clock.now();
2807 for attempt in attempts {
2808 if !attempt.state().is_concluded() {
2809 continue;
2810 }
2811 let Some(outcome) = attempt.outcome() else {
2812 continue;
2813 };
2814 if !self.clean_backoff.is_due(attempt.id, now) {
2817 continue;
2818 }
2819 let kind = OutcomeKind::of(outcome);
2820 match self.launcher.clean(attempt.id).await {
2821 Ok(()) => {
2822 self.clean_backoff.succeeded(attempt.id);
2823 report.cleaned = report.cleaned.saturating_add(1);
2824 if kind.is_failure() {
2825 report.failures = report.failures.saturating_add(1);
2826 } else if kind == OutcomeKind::IdleExit {
2827 report.idle_exits = report.idle_exits.saturating_add(1);
2832 }
2833 self.events.emit(LifecycleEvent::AttemptCleaned {
2834 policy: attempt.policy_id,
2835 attempt: attempt.id,
2836 outcome: kind,
2837 });
2838 }
2839 Err(failure) => {
2849 self.clean_backoff.failed(attempt.id, now);
2850 report.clean_failures = report.clean_failures.saturating_add(1);
2851 self.events.emit(LifecycleEvent::AttemptCleanFailed {
2852 policy: attempt.policy_id,
2853 attempt: attempt.id,
2854 reason: failure_reason_kind(&failure.reason),
2855 });
2856 }
2857 }
2858 }
2859 }
2860
2861 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
2869 let mut report = ScaleDownReport::default();
2870 let attempts = match self.launcher.attempts().await {
2871 Ok(attempts) => attempts,
2872 Err(failure) => {
2873 self.events.emit(LifecycleEvent::AttemptsUnreadable {
2878 reason: failure_reason_kind(&failure.reason),
2879 });
2880 report.attempts_unreadable = true;
2881 return report;
2882 }
2883 };
2884 for attempt in attempts {
2885 if attempt.policy_id != policy.id {
2886 continue;
2887 }
2888 match attempt.state() {
2889 AttemptState::Busy => {
2890 report.refused_busy = report.refused_busy.saturating_add(1);
2891 self.events.emit(LifecycleEvent::ScaleDownRefused {
2892 policy: policy.id,
2893 attempt: attempt.id,
2894 });
2895 }
2896 state if state.is_concluded() => {
2897 let kind = attempt
2898 .outcome()
2899 .map_or(OutcomeKind::Failed, OutcomeKind::of);
2900 match self.launcher.clean(attempt.id).await {
2901 Ok(()) => {
2902 report.removed = report.removed.saturating_add(1);
2903 self.events.emit(LifecycleEvent::AttemptCleaned {
2904 policy: policy.id,
2905 attempt: attempt.id,
2906 outcome: kind,
2907 });
2908 }
2909 Err(failure) => {
2910 report.clean_failures = report.clean_failures.saturating_add(1);
2911 self.events.emit(LifecycleEvent::AttemptCleanFailed {
2912 policy: policy.id,
2913 attempt: attempt.id,
2914 reason: failure_reason_kind(&failure.reason),
2915 });
2916 }
2917 }
2918 }
2919 AttemptState::Cleaned => {}
2920 _ => report.retained = report.retained.saturating_add(1),
2923 }
2924 }
2925 report
2926 }
2927
2928 fn emit_allocation(&self, allocation: &Allocation) {
2929 self.events.emit(LifecycleEvent::Allocated {
2930 policy: allocation.policy_id,
2931 demand: allocation.demand,
2932 desired: allocation.desired,
2933 active_owned: allocation.active_owned,
2934 headroom: allocation.headroom_before,
2935 to_start: allocation.to_start,
2936 limiting: allocation.limiting_factor,
2937 });
2938 }
2939}
2940
2941fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
2965 let Some(labels) = policy.routing_labels() else {
2966 debug_assert!(
2967 false,
2968 "a monitor-only policy is skipped before the demand poll (D19)"
2969 );
2970 return DemandTally::default();
2971 };
2972
2973 match &policy.target {
2974 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
2975 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
2976 }
2977}
2978
2979fn route_demand(
2983 all_policies: &[ScalePolicy],
2984 profiles: &[&ScalePolicy],
2985 reading: &QueuedDemand,
2986) -> (
2987 BTreeMap<PolicyId, DemandTally>,
2988 BTreeMap<RoutingRefusal, u32>,
2989) {
2990 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
2991 .iter()
2992 .map(|policy| (policy.id, demand_for(policy, reading)))
2993 .collect();
2994 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
2995 let Some(first) = profiles.first() else {
2996 return (tallies, refusals);
2997 };
2998 let owners: Vec<_> = all_policies
2999 .iter()
3000 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3001 .filter(|policy| policy.owns_runners())
3002 .collect();
3003 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3004 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3005 ScaleTarget::Organization(_) => reading.jobs().collect(),
3006 };
3007 for job in jobs {
3008 let Ok(_) = job.required_labels() else {
3009 continue;
3011 };
3012 let matched: Vec<PolicyId> = owners
3013 .iter()
3014 .filter(|policy| {
3015 policy
3016 .routing_labels()
3017 .is_some_and(|labels| labels.matches(job).is_match())
3018 })
3019 .map(|policy| policy.id)
3020 .collect();
3021 if matched.len() > 1 {
3022 *refusals
3023 .entry(RoutingRefusal::OverlappingProfiles)
3024 .or_default() += 1;
3025 for id in matched {
3026 if let Some(tally) = tallies.get_mut(&id) {
3027 tally.matched -= 1;
3028 tally.not_matched += 1;
3029 }
3030 }
3031 }
3032 }
3033 (tallies, refusals)
3034}
3035
3036const fn severity(state: &RefreshState) -> u8 {
3043 match state {
3044 RefreshState::Offline => 5,
3045 RefreshState::RateLimited(_) => 4,
3046 RefreshState::LockedOut { .. } => 3,
3047 RefreshState::Unauthorized => 2,
3048 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3049 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3050 }
3051}
3052
3053const fn unreadable_reason(state: &RefreshState) -> &'static str {
3064 match state {
3065 RefreshState::Ready(_) => "ready",
3066 RefreshState::Offline => "offline",
3067 RefreshState::RateLimited(_) => "rate_limited",
3068 RefreshState::LockedOut { .. } => "locked_out",
3069 RefreshState::Unauthorized => "unauthorized",
3070 RefreshState::Forbidden { .. } => "forbidden",
3071 RefreshState::Failed { .. } => "failed",
3072 RefreshState::Cancelled => "cancelled",
3073 }
3074}
3075
3076#[cfg(test)]
3077mod tests {
3078 use super::*;
3079
3080 #[test]
3088 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
3089 let mut report = ReconcileReport::default();
3090 assert!(
3091 !report.reached_github(),
3092 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
3093 monitor-only -- reached nobody. This is the case the old guard let through, and \
3094 the only one it ever let through."
3095 );
3096
3097 report.unreadable.push(PolicyId::from_u128(1));
3098 assert!(
3099 !report.reached_github(),
3100 "every target this pass tried was unreadable, so there is no contact to record"
3101 );
3102
3103 report.targets_read = 1;
3104 assert!(
3105 report.reached_github(),
3106 "one target answering is contact, whatever else failed alongside it"
3107 );
3108
3109 let mut unowned = ReconcileReport::default();
3113 unowned.unreadable.push(PolicyId::from_u128(2));
3114 unowned.allocations.push(Allocation {
3115 policy_id: PolicyId::from_u128(2),
3116 demand: 0,
3117 desired: 0,
3118 active_owned: 0,
3119 headroom_before: 0,
3120 to_start: 0,
3121 limiting_factor: LimitingFactor::Demand,
3122 });
3123 assert!(
3124 !unowned.reached_github(),
3125 "an allocation is not evidence that GitHub answered"
3126 );
3127 }
3128
3129 #[test]
3139 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
3140 assert!(
3141 severity(&RefreshState::Unauthorized) > 0,
3142 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
3143 or a pass where every target was refused would report no failure at all"
3144 );
3145 }
3146
3147 use std::sync::atomic::AtomicUsize;
3148
3149 use std::num::NonZeroU16;
3150
3151 use runner_manager_domain::attempt::PersistedAttempt;
3152 use runner_manager_domain::model::{CachePolicy, HostId};
3153 use runner_manager_domain::policy::PolicyMode;
3154 use runner_manager_domain::workspace::WorkspaceKind;
3155 use runner_manager_github::rest::RateLimited;
3156 use runner_manager_testkit::clock::FakeClock;
3157 use runner_manager_testkit::fixtures;
3158 use runner_manager_testkit::github::FakeGithub;
3159
3160 fn host_with(capacity: u16) -> Host {
3165 fixtures::host().capacity(capacity).build()
3166 }
3167
3168 fn repo(raw: &str) -> OwnerRepo {
3169 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
3170 }
3171
3172 use runner_manager_domain::policy::RunsOn;
3174
3175 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
3176 fixtures::policy()
3177 .id(PolicyId::from_u128(id))
3178 .repository(target)
3179 .autoscale("home", max)
3180 .active()
3181 .build()
3182 }
3183
3184 const HOST_LABEL: &str = "rm-home-win-x64";
3191
3192 fn jobs(n: usize) -> Vec<RunsOn> {
3198 fixtures::queued_jobs(&[HOST_LABEL], n)
3199 }
3200
3201 #[derive(Debug, Default)]
3203 struct FakeLauncher {
3204 attempts: Mutex<Vec<RunnerAttempt>>,
3205 next_id: AtomicU64,
3206 launches: AtomicUsize,
3207 cleaned: Mutex<Vec<AttemptId>>,
3208 yields_before_recording: usize,
3211 forgetful: bool,
3215 fail_next: Mutex<Option<FailureReason>>,
3216 attempts_fail: Mutex<bool>,
3219 clean_fails: bool,
3222 replacements: Mutex<Vec<ReplacementIntent>>,
3223 }
3224
3225 impl FakeLauncher {
3226 fn new() -> Self {
3227 Self::default()
3228 }
3229
3230 fn with_yields(mut self, yields: usize) -> Self {
3231 self.yields_before_recording = yields;
3232 self
3233 }
3234
3235 fn forgetful() -> Self {
3236 Self {
3237 forgetful: true,
3238 ..Self::default()
3239 }
3240 }
3241
3242 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
3243 *self.attempts.lock().unwrap() = attempts;
3244 self
3245 }
3246
3247 fn launches(&self) -> usize {
3248 self.launches.load(Ordering::SeqCst)
3249 }
3250
3251 fn snapshot(&self) -> Vec<RunnerAttempt> {
3252 self.attempts.lock().unwrap().clone()
3253 }
3254
3255 fn live_count(&self) -> usize {
3256 self.snapshot()
3257 .iter()
3258 .filter(|a| a.counts_against_capacity())
3259 .count()
3260 }
3261
3262 fn fail_next(&self, reason: FailureReason) {
3263 *self.fail_next.lock().unwrap() = Some(reason);
3264 }
3265
3266 fn fail_attempts(&self, failing: bool) {
3267 *self.attempts_fail.lock().unwrap() = failing;
3268 }
3269
3270 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
3271 Self {
3272 clean_fails: true,
3273 ..Self::default()
3274 }
3275 .seeded(attempts)
3276 }
3277
3278 fn cleaned(&self) -> Vec<AttemptId> {
3279 self.cleaned.lock().unwrap().clone()
3280 }
3281
3282 fn replacing(self, intent: ReplacementIntent) -> Self {
3283 self.replacements.lock().unwrap().push(intent);
3284 self
3285 }
3286 }
3287
3288 #[async_trait::async_trait]
3289 impl RunnerLauncher for FakeLauncher {
3290 async fn supervise(
3291 &self,
3292 policy: &ScalePolicy,
3293 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3294 let mut replacements = self.replacements.lock().unwrap();
3295 let selected: Vec<_> = replacements
3296 .extract_if(.., |intent| intent.policy == policy.id)
3297 .collect();
3298 if !selected.is_empty() {
3299 let retired: BTreeSet<_> = selected
3300 .iter()
3301 .map(|intent| intent.previous_attempt)
3302 .collect();
3303 self.attempts
3304 .lock()
3305 .unwrap()
3306 .retain(|attempt| !retired.contains(&attempt.id));
3307 }
3308 Ok(selected)
3309 }
3310
3311 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3312 if *self.attempts_fail.lock().unwrap() {
3313 return Err(LaunchFailure::new(FailureReason::Other(
3314 "the journal could not be read".into(),
3315 )));
3316 }
3317 Ok(self.snapshot())
3318 }
3319
3320 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3321 if let Some(reason) = self.fail_next.lock().unwrap().take() {
3322 return Err(LaunchFailure::new(reason));
3323 }
3324 for _ in 0..self.yields_before_recording {
3326 tokio::task::yield_now().await;
3327 }
3328 let id =
3329 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
3330 let created = RunnerAttempt::allocate(
3331 id,
3332 request.policy.id,
3333 "runtime/p/a",
3334 request.host.created_at,
3335 );
3336 self.launches.fetch_add(1, Ordering::SeqCst);
3337 if !self.forgetful {
3338 self.attempts.lock().unwrap().push(created.clone());
3339 }
3340 Ok(created)
3341 }
3342
3343 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
3344 if self.clean_fails {
3345 return Err(LaunchFailure::new(FailureReason::Other(
3346 "the runtime directory is locked".into(),
3347 )));
3348 }
3349 self.cleaned.lock().unwrap().push(attempt);
3350 let mut attempts = self.attempts.lock().unwrap();
3351 attempts.retain(|a| a.id != attempt);
3352 Ok(())
3353 }
3354 }
3355
3356 #[derive(Debug, Default)]
3358 struct FakeDemand {
3359 outcome: Mutex<Option<PollOutcome>>,
3360 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
3362 scopes: Mutex<Vec<ActivityScope>>,
3363 }
3364
3365 impl FakeDemand {
3366 fn ready(count: u32, repository: &OwnerRepo) -> Self {
3367 let fake = Self::default();
3368 fake.set(PollOutcome::Ready(QueuedDemand::of(
3369 repository.clone(),
3370 jobs(count as usize),
3371 )));
3372 fake
3373 }
3374
3375 fn failing(state: RefreshState) -> Self {
3376 let fake = Self::default();
3377 fake.set(PollOutcome::Failed(state));
3378 fake
3379 }
3380
3381 fn set(&self, outcome: PollOutcome) {
3382 *self.outcome.lock().unwrap() = Some(outcome);
3383 }
3384
3385 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
3387 self.per_target
3388 .lock()
3389 .unwrap()
3390 .insert(target.clone(), outcome);
3391 }
3392
3393 fn polls(&self) -> Vec<ActivityScope> {
3394 self.scopes.lock().unwrap().clone()
3395 }
3396 }
3397
3398 #[async_trait::async_trait]
3399 impl DemandSource for FakeDemand {
3400 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
3401 self.scopes.lock().unwrap().push(scope.clone());
3402 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
3403 return outcome.clone();
3404 }
3405 self.outcome
3406 .lock()
3407 .unwrap()
3408 .clone()
3409 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
3410 }
3411 }
3412
3413 #[derive(Debug, Default)]
3414 struct FakeDirectory {
3415 repositories: Vec<OwnerRepo>,
3416 calls: AtomicUsize,
3417 }
3418
3419 impl FakeDirectory {
3420 fn of(repositories: Vec<OwnerRepo>) -> Self {
3421 Self {
3422 repositories,
3423 calls: AtomicUsize::new(0),
3424 }
3425 }
3426
3427 fn calls(&self) -> usize {
3428 self.calls.load(Ordering::SeqCst)
3429 }
3430 }
3431
3432 #[async_trait::async_trait]
3433 impl RepositoryDirectory for FakeDirectory {
3434 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
3435 self.calls.fetch_add(1, Ordering::SeqCst);
3436 Ok(self.repositories.clone())
3437 }
3438 }
3439
3440 #[derive(Debug)]
3446 struct CountingLock {
3447 inner: InProcessAllocationLock,
3448 concurrent: Arc<AtomicUsize>,
3449 peak: Arc<AtomicUsize>,
3450 acquisitions: Arc<AtomicUsize>,
3451 }
3452
3453 impl CountingLock {
3454 fn new() -> Self {
3455 Self {
3456 inner: InProcessAllocationLock::new(),
3457 concurrent: Arc::new(AtomicUsize::new(0)),
3458 peak: Arc::new(AtomicUsize::new(0)),
3459 acquisitions: Arc::new(AtomicUsize::new(0)),
3460 }
3461 }
3462
3463 fn peak(&self) -> usize {
3464 self.peak.load(Ordering::SeqCst)
3465 }
3466
3467 fn acquisitions(&self) -> usize {
3468 self.acquisitions.load(Ordering::SeqCst)
3469 }
3470 }
3471
3472 #[derive(Debug)]
3473 struct CountingGuard {
3474 _inner: AllocationGuard,
3475 concurrent: Arc<AtomicUsize>,
3476 }
3477
3478 impl Drop for CountingGuard {
3479 fn drop(&mut self) {
3480 self.concurrent.fetch_sub(1, Ordering::SeqCst);
3481 }
3482 }
3483
3484 #[async_trait::async_trait]
3485 impl AllocationLock for CountingLock {
3486 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3487 let inner = self.inner.acquire().await?;
3488 self.acquisitions.fetch_add(1, Ordering::SeqCst);
3489 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
3490 self.peak.fetch_max(now, Ordering::SeqCst);
3491 Ok(AllocationGuard::new(CountingGuard {
3492 _inner: inner,
3493 concurrent: Arc::clone(&self.concurrent),
3494 }))
3495 }
3496 }
3497
3498 #[derive(Debug, Default)]
3501 struct NoLock;
3502
3503 #[async_trait::async_trait]
3504 impl AllocationLock for NoLock {
3505 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3506 Ok(AllocationGuard::new(()))
3507 }
3508 }
3509
3510 #[tokio::test]
3511 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
3512 use runner_manager_platform::wsl::fence::DrainRequest;
3513
3514 let (_local, shared, paths) = guest_paths();
3515 DrainRequest::new(4, chrono::Utc::now())
3516 .write(shared.path())
3517 .unwrap();
3518 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3519
3520 assert!(lock.acquire().await.is_err());
3521 }
3522
3523 #[tokio::test]
3524 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
3525 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3526
3527 let (_local, shared, paths) = guest_paths();
3528 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3529 let guard = lock.acquire().await.unwrap();
3530 assert!(shared.path().join(FENCE_DIRECTORY).exists());
3531 drop(guard);
3532 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3533 }
3534
3535 #[derive(Clone, Default)]
3537 struct Logs(Arc<Mutex<Vec<u8>>>);
3538
3539 impl std::io::Write for Logs {
3540 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3541 self.0.lock().unwrap().extend_from_slice(buf);
3542 Ok(buf.len())
3543 }
3544 fn flush(&mut self) -> std::io::Result<()> {
3545 Ok(())
3546 }
3547 }
3548
3549 #[tokio::test]
3552 async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
3553 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3554
3555 let (_local, shared, paths) = guest_paths();
3556 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3557 let guard = lock.acquire().await.unwrap();
3558 let fence = shared.path().join(FENCE_DIRECTORY);
3561 std::fs::remove_dir_all(&fence).unwrap();
3562 std::fs::write(&fence, b"not a directory").unwrap();
3563
3564 let logs = Logs::default();
3565 let writer = logs.clone();
3566 let subscriber = tracing_subscriber::fmt()
3567 .with_writer(move || writer.clone())
3568 .with_ansi(false)
3569 .finish();
3570 tracing::subscriber::with_default(subscriber, || drop(guard));
3571
3572 let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
3573 assert!(text.contains("WARN"), "{text}");
3574 assert!(text.contains("could not be released"), "{text}");
3575 assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
3576 }
3577
3578 fn guest_paths() -> (
3579 tempfile::TempDir,
3580 tempfile::TempDir,
3581 Arc<runner_manager_platform::paths::AppPaths>,
3582 ) {
3583 use runner_manager_platform::paths::AppPaths;
3584 use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
3585
3586 let local = tempfile::tempdir().unwrap();
3587 let shared = tempfile::tempdir().unwrap();
3588 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3589 paths.create_all().unwrap();
3590 GuestRecoveryConfig::new(shared.path().to_path_buf())
3591 .write(&paths)
3592 .unwrap();
3593 (local, shared, paths)
3594 }
3595
3596 #[tokio::test]
3599 async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
3600 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
3601
3602 let (_local, shared, paths) = guest_paths();
3603 let fence = shared.path().join(FENCE_DIRECTORY);
3604 std::fs::create_dir_all(&fence).unwrap();
3605 let pid = std::process::id();
3606 std::fs::write(
3607 fence.join(OWNER_FILE),
3608 format!(
3609 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"}}}}"#
3610 ),
3611 )
3612 .unwrap();
3613 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3614
3615 let guard = lock.acquire().await.expect("the stale claim was reclaimed");
3616 drop(guard);
3617 assert!(!fence.exists());
3618 }
3619
3620 #[tokio::test]
3621 async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
3622 use runner_manager_platform::launch_health::recorded_launches_blocked;
3623 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
3624
3625 let (_local, shared, paths) = guest_paths();
3626 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
3627 .with_blocked_after(3, Duration::ZERO);
3628 let windows =
3630 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
3631 .unwrap()
3632 .unwrap();
3633
3634 for _ in 0..2 {
3635 assert!(lock.acquire().await.is_err());
3636 }
3637 assert_eq!(
3638 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3639 None,
3640 "not yet"
3641 );
3642 assert!(lock.acquire().await.is_err());
3643 let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
3644 .unwrap()
3645 .expect("three refusals in a row are recorded");
3646 assert!(
3647 blocked.reason.contains("Windows WSL recovery"),
3648 "{blocked:?}"
3649 );
3650
3651 windows.release().unwrap();
3652 let guard = lock.acquire().await.unwrap();
3653 assert_eq!(
3654 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3655 None
3656 );
3657 drop(guard);
3658 }
3659
3660 #[tokio::test]
3661 async fn refusals_while_this_process_is_launching_are_its_own_contention() {
3662 use runner_manager_platform::launch_health::recorded_launches_blocked;
3663
3664 let (_local, _shared, paths) = guest_paths();
3665 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
3666 .with_blocked_after(1, Duration::ZERO);
3667 let launching = lock.acquire().await.unwrap();
3668 for _ in 0..3 {
3669 assert!(lock.acquire().await.is_err());
3670 }
3671 assert_eq!(
3672 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3673 None
3674 );
3675 drop(launching);
3676 }
3677
3678 #[tokio::test]
3681 async fn refusals_with_quiet_spells_between_them_never_add_up() {
3682 use runner_manager_platform::launch_health::recorded_launches_blocked;
3683 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
3684
3685 let (_local, shared, paths) = guest_paths();
3686 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
3687 .with_blocked_after(3, Duration::ZERO)
3688 .with_streak_gap(Duration::ZERO);
3689 let windows =
3690 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
3691 .unwrap()
3692 .unwrap();
3693 for _ in 0..5 {
3694 assert!(lock.acquire().await.is_err());
3695 }
3696 assert_eq!(
3697 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3698 None
3699 );
3700 windows.release().unwrap();
3701 }
3702
3703 #[tokio::test]
3706 async fn a_claim_refused_by_a_drain_request_is_released() {
3707 use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
3708
3709 let (_local, shared, paths) = guest_paths();
3710 DrainRequest::new(4, chrono::Utc::now())
3711 .write(shared.path())
3712 .unwrap();
3713 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3714 assert!(lock.acquire().await.is_err());
3715 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3716 }
3717
3718 #[derive(Debug, Default)]
3720 struct HeldLock;
3721
3722 #[async_trait::async_trait]
3723 impl AllocationLock for HeldLock {
3724 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3725 Err(AllocationLockBusy)
3726 }
3727 }
3728
3729 struct Harness {
3731 launcher: Arc<FakeLauncher>,
3732 demand: Arc<FakeDemand>,
3733 events: Arc<EventLog>,
3734 clock: Arc<FakeClock>,
3735 reconciler: Reconciler,
3736 }
3737
3738 impl Harness {
3739 fn build(
3740 host: Host,
3741 launcher: Arc<FakeLauncher>,
3742 demand: Arc<FakeDemand>,
3743 lock: Arc<dyn AllocationLock>,
3744 ) -> Self {
3745 let events = Arc::new(EventLog::new());
3746 let clock = Arc::new(FakeClock::default());
3747 let reconciler = Reconciler::new(
3748 host,
3749 ReconcilerPorts {
3750 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
3751 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
3752 lock,
3753 directory: Arc::new(FakeDirectory::default()),
3754 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3755 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
3756 events: Arc::clone(&events) as Arc<dyn EventSink>,
3757 },
3758 );
3759 Self {
3760 launcher,
3761 demand,
3762 events,
3763 clock,
3764 reconciler,
3765 }
3766 }
3767
3768 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
3769 let launcher = Arc::new(FakeLauncher::new());
3770 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
3771 Self::build(
3772 host_with(capacity),
3773 launcher,
3774 demand,
3775 Arc::new(InProcessAllocationLock::new()),
3776 )
3777 }
3778 }
3779
3780 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
3781 let outcome = state.is_terminal().then(|| match state {
3782 AttemptState::Failed => {
3783 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
3784 }
3785 AttemptState::Orphaned => AttemptOutcome::Orphaned,
3786 _ => AttemptOutcome::CompletedJob,
3787 });
3788 RunnerAttempt::from_persisted(PersistedAttempt {
3789 id: AttemptId::from_u128(id),
3790 policy_id: PolicyId::from_u128(policy),
3791 github_runner_id: None,
3792 state,
3793 outcome,
3794 process_id: None,
3795 execution: runner_manager_domain::execution::AttemptExecution::Native {
3796 process_id: None,
3797 },
3798 runtime_path: "runtime/p/a".into(),
3799 workspace_kind: WorkspaceKind::Ephemeral,
3800 workspace_slot: None,
3801 created_at: fixtures::created_at(),
3802 terminal_at: state.is_terminal().then(fixtures::created_at),
3803 last_state_change_at: fixtures::created_at(),
3804 })
3805 .expect("a state/outcome pair the domain accepts")
3806 }
3807
3808 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
3810 RunnerAttempt::from_persisted(PersistedAttempt {
3811 id: AttemptId::from_u128(id),
3812 policy_id: PolicyId::from_u128(policy),
3813 github_runner_id: None,
3814 state: outcome.terminal_state(),
3815 outcome: Some(outcome),
3816 process_id: None,
3817 execution: runner_manager_domain::execution::AttemptExecution::Native {
3818 process_id: None,
3819 },
3820 runtime_path: "runtime/p/a".into(),
3821 workspace_kind: WorkspaceKind::Ephemeral,
3822 workspace_slot: None,
3823 created_at: fixtures::created_at(),
3824 terminal_at: Some(fixtures::created_at()),
3825 last_state_change_at: fixtures::created_at(),
3826 })
3827 .expect("a state/outcome pair the domain accepts")
3828 }
3829
3830 #[tokio::test]
3859 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
3860 let mut harness = Harness::simple(4, 1, "acme/app");
3861 let policy = policy(1, "acme/app", 4);
3862
3863 for poll in 1..=3 {
3864 let report = harness
3865 .reconciler
3866 .reconcile(std::slice::from_ref(&policy))
3867 .await;
3868 assert_eq!(
3869 harness.launcher.launches(),
3870 1,
3871 "poll {poll} started another runner for a job already being served; the \
3872 `- active_owned_runners` term reached `HostAllocator` as a set this host \
3873 does not hold"
3874 );
3875 assert_eq!(report.allocations.len(), 1);
3876 let allocation = &report.allocations[0];
3877 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
3878 if poll == 1 {
3879 assert_eq!(allocation.to_start, 1);
3880 assert_eq!(report.started, 1);
3881 } else {
3882 assert_eq!(allocation.active_owned, 1, "poll {poll}");
3883 assert_eq!(allocation.to_start, 0, "poll {poll}");
3884 assert_eq!(report.started, 0, "poll {poll}");
3885 }
3886 }
3887 assert_eq!(harness.launcher.live_count(), 1);
3888 }
3889
3890 #[tokio::test]
3901 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
3902 let launcher = Arc::new(FakeLauncher::forgetful());
3903 let mut harness = Harness::build(
3904 host_with(64),
3905 Arc::clone(&launcher),
3906 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
3907 Arc::new(InProcessAllocationLock::new()),
3908 );
3909
3910 let report = harness
3911 .reconciler
3912 .reconcile(&[policy(1, "acme/app", 8)])
3913 .await;
3914
3915 assert_eq!(
3916 report.started, 3,
3917 "the pass is bounded by the grant it was given, not by the attempt set catching \
3918 up with it"
3919 );
3920 assert_eq!(launcher.launches(), 3);
3921 assert!(
3922 launcher.snapshot().is_empty(),
3923 "the launcher never recorded anything, which is the whole point of the fixture"
3924 );
3925 }
3926
3927 #[tokio::test]
3939 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
3940 let launcher = Arc::new(FakeLauncher::forgetful());
3941 let demand = Arc::new(FakeDemand::default());
3942 demand.set_for(
3943 &ScaleTarget::repository("acme/left").unwrap(),
3944 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
3945 );
3946 demand.set_for(
3947 &ScaleTarget::repository("acme/right").unwrap(),
3948 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
3949 );
3950 let mut harness = Harness::build(
3951 host_with(3),
3952 Arc::clone(&launcher),
3953 demand,
3954 Arc::new(InProcessAllocationLock::new()),
3955 );
3956
3957 let report = harness
3958 .reconciler
3959 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
3960 .await;
3961
3962 assert_eq!(
3963 report.started, 3,
3964 "host_capacity is 3 and two policies each allowed 3 started {} runners \
3965 between them; the second policy's grant was computed from a set that did \
3966 not yet contain the first policy's launches",
3967 report.started
3968 );
3969 assert_eq!(launcher.launches(), 3);
3970 }
3971
3972 #[tokio::test]
3983 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
3984 let launcher = Arc::new(FakeLauncher::new());
3985 let mut harness = Harness::build(
3986 host_with(8),
3987 Arc::clone(&launcher),
3988 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
3989 Arc::new(InProcessAllocationLock::new()),
3990 );
3991 let policy = policy(1, "acme/app", 8);
3992
3993 launcher.fail_attempts(true);
3994 let unreadable = harness
3995 .reconciler
3996 .reconcile(std::slice::from_ref(&policy))
3997 .await;
3998
3999 assert_eq!(
4000 unreadable.started, 0,
4001 "nothing may be decided from a set that was not read"
4002 );
4003 assert_eq!(launcher.launches(), 0);
4004 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
4005 assert!(
4006 unreadable.allocations.is_empty(),
4007 "no allocation is reported either: there was no set to compute one from, and \
4008 an allocation of zero would claim a decision nobody made"
4009 );
4010 assert!(harness.events.count_of("attempts_unreadable") > 0);
4011
4012 launcher.fail_attempts(false);
4014 let readable = harness
4015 .reconciler
4016 .reconcile(std::slice::from_ref(&policy))
4017 .await;
4018 assert_eq!(
4019 readable.started, 4,
4020 "the difference between the two passes is only whether the set could be read"
4021 );
4022 assert_eq!(readable.attempts_unreadable, 0);
4023 }
4024
4025 #[tokio::test]
4035 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
4036 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4037 1,
4038 1,
4039 AttemptOutcome::ExitedIdleWithoutWork,
4040 )]));
4041 let mut harness = Harness::build(
4042 host_with(4),
4043 Arc::clone(&launcher),
4044 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4045 Arc::new(InProcessAllocationLock::new()),
4046 );
4047
4048 let report = harness
4049 .reconciler
4050 .reconcile(&[policy(1, "acme/app", 4)])
4051 .await;
4052
4053 assert_eq!(report.cleaned, 0);
4054 assert_eq!(report.clean_failures, 1);
4055 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
4056 assert_eq!(
4057 harness.events.count_of("attempt_cleaned"),
4058 0,
4059 "and it is not reported as cleaned"
4060 );
4061 assert_eq!(
4062 launcher.snapshot().len(),
4063 1,
4064 "the attempt is still there, so the retry is real -- what changed is that it \
4065 is no longer silent"
4066 );
4067
4068 let reasons: Vec<&'static str> = harness
4071 .events
4072 .events()
4073 .into_iter()
4074 .filter_map(|event| match event {
4075 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
4076 _ => None,
4077 })
4078 .collect();
4079 assert_eq!(reasons, vec!["other"]);
4080 }
4081
4082 #[tokio::test]
4085 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
4086 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4087 1,
4088 1,
4089 AttemptOutcome::ExitedIdleWithoutWork,
4090 )]));
4091 let mut harness = Harness::build(
4092 host_with(4),
4093 Arc::clone(&launcher),
4094 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4095 Arc::new(InProcessAllocationLock::new()),
4096 );
4097 let policies = [policy(1, "acme/app", 4)];
4098 let start = harness.clock.now();
4099 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
4100 let mut failures_at = async |offset: Duration| {
4101 harness.clock.set(at(offset));
4102 harness.reconciler.reconcile(&policies).await.clean_failures
4103 };
4104
4105 assert_eq!(failures_at(Duration::ZERO).await, 1);
4106 assert_eq!(
4108 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
4109 0,
4110 "a failed cleanup was retried on the very next pass"
4111 );
4112 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
4113 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
4115 assert_eq!(
4116 failures_at(second - Duration::from_secs(1)).await,
4117 0,
4118 "the delay did not double after a second failure"
4119 );
4120 assert_eq!(failures_at(second).await, 1);
4121 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
4122
4123 let mut backoff = CleanBackoff::default();
4125 let id = AttemptId::from_u128(9);
4126 for _ in 0..40 {
4127 backoff.failed(id, start);
4128 }
4129 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
4130 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
4131 backoff.succeeded(id);
4132 assert!(
4133 backoff.is_due(id, start),
4134 "a cleaned attempt kept its backoff"
4135 );
4136 }
4137
4138 #[tokio::test]
4151 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
4152 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4153 attempt_in(AttemptState::Busy, 1, 1),
4154 concluded(2, 1, AttemptOutcome::CompletedJob),
4155 ]));
4156 let harness = Harness::build(
4157 host_with(4),
4158 Arc::clone(&launcher),
4159 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4160 Arc::new(InProcessAllocationLock::new()),
4161 );
4162 let policy = policy(1, "acme/app", 4);
4163
4164 launcher.fail_attempts(true);
4165 let blind = harness.reconciler.scale_down(&policy).await;
4166
4167 assert!(!blind.is_conclusive(), "the machine was never read");
4168 assert_ne!(
4169 blind,
4170 ScaleDownReport::default(),
4171 "a scale-down that could not see the host must not be equal to one that saw \
4172 an idle host; that equality is the whole finding"
4173 );
4174 assert_eq!(blind.removed, 0);
4175 assert_eq!(
4176 blind.refused_busy, 0,
4177 "and this zero means `unknown`, not `none`"
4178 );
4179 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
4180
4181 launcher.fail_attempts(false);
4183 let seeing = harness.reconciler.scale_down(&policy).await;
4184
4185 assert!(seeing.is_conclusive());
4186 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
4187 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
4188 assert_ne!(
4189 seeing, blind,
4190 "the difference between the two is only whether the set could be read"
4191 );
4192 }
4193
4194 #[tokio::test]
4202 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
4203 let mut warm = ScalePolicy::new(
4204 PolicyId::from_u128(1),
4205 ScaleTarget::repository("acme/app").unwrap(),
4206 1,
4207 fixtures::HOST_ID,
4208 PolicyMode::autoscale(
4209 fixtures::routing_labels("home"),
4210 2,
4211 NonZeroU16::new(5).expect("non-zero"),
4212 )
4213 .expect("min <= max"),
4214 CachePolicy::default(),
4215 );
4216 warm.activate().expect("pending -> active");
4217
4218 let mut harness = Harness::simple(8, 0, "acme/app");
4219 let report = harness.reconciler.reconcile(&[warm]).await;
4220
4221 assert_eq!(
4222 report.allocations[0].demand, 0,
4223 "GitHub reported no queued runs"
4224 );
4225 assert_eq!(
4226 report.allocations[0].desired, 2,
4227 "min_capacity raised the target above demand"
4228 );
4229 assert_eq!(
4230 report.allocations[0].limiting_factor,
4231 LimitingFactor::MinCapacity
4232 );
4233 assert_eq!(report.started, 2, "and two runners were actually started");
4234 assert_eq!(harness.launcher.live_count(), 2);
4235 }
4236
4237 #[tokio::test]
4242 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
4243 let mut harness = Harness::simple(100, 10, "acme/app");
4244 let report = harness
4245 .reconciler
4246 .reconcile(&[policy(1, "acme/app", 3)])
4247 .await;
4248
4249 assert_eq!(report.allocations[0].demand, 10);
4250 assert_eq!(
4251 report.allocations[0].desired, 3,
4252 "max_capacity beats demand"
4253 );
4254 assert_eq!(report.started, 3);
4255 assert_eq!(
4256 report.allocations[0].limiting_factor,
4257 LimitingFactor::MaxCapacity
4258 );
4259 }
4260
4261 #[tokio::test]
4262 async fn demand_below_min_capacity_starts_nothing_in_v1() {
4263 let mut harness = Harness::simple(8, 0, "acme/app");
4266 let report = harness
4267 .reconciler
4268 .reconcile(&[policy(1, "acme/app", 4)])
4269 .await;
4270
4271 assert_eq!(report.allocations[0].desired, 0);
4272 assert_eq!(report.started, 0);
4273 assert!(report.starts_nothing());
4274 }
4275
4276 #[tokio::test]
4277 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
4278 let policy = policy(1, "octo/repo", 1);
4279 let previous = attempt_in(AttemptState::Starting, 41, 1);
4280 let intent = ReplacementIntent {
4281 policy: policy.id,
4282 previous_attempt: previous.id,
4283 operation: "exit_before_acceptance_replacement",
4284 };
4285 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
4286 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
4287 let mut harness = Harness::build(
4288 host_with(1),
4289 Arc::clone(&launcher),
4290 demand,
4291 Arc::new(InProcessAllocationLock::new()),
4292 );
4293
4294 let report = harness.reconciler.reconcile(&[policy]).await;
4295
4296 assert_eq!(report.replacement_intents, 1);
4297 assert_eq!(report.started, 1);
4298 assert_eq!(launcher.launches(), 1);
4299 assert_eq!(launcher.live_count(), 1);
4300 }
4301
4302 #[tokio::test]
4303 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
4304 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4305 attempt_in(AttemptState::Busy, 1, 1),
4306 attempt_in(AttemptState::Busy, 2, 1),
4307 ]));
4308 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
4309 let mut harness = Harness::build(
4310 host_with(2),
4311 launcher,
4312 demand,
4313 Arc::new(InProcessAllocationLock::new()),
4314 );
4315
4316 let report = harness
4317 .reconciler
4318 .reconcile(&[policy(1, "acme/app", 2)])
4319 .await;
4320 assert_eq!(report.started, 0);
4321 assert_eq!(report.allocations[0].headroom_before, 0);
4322 assert_eq!(harness.launcher.launches(), 0);
4323 }
4324
4325 #[tokio::test]
4326 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
4327 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4330 attempt_in(AttemptState::Busy, 1, 99),
4331 attempt_in(AttemptState::Busy, 2, 99),
4332 attempt_in(AttemptState::Idle, 3, 99),
4333 attempt_in(AttemptState::Starting, 4, 99),
4334 ]));
4335 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
4336 let mut harness = Harness::build(
4337 host_with(6),
4338 launcher,
4339 demand,
4340 Arc::new(InProcessAllocationLock::new()),
4341 );
4342
4343 let report = harness
4344 .reconciler
4345 .reconcile(&[policy(1, "acme/app", 5)])
4346 .await;
4347 assert_eq!(
4348 report.allocations[0].desired, 5,
4349 "its own ceiling allows five"
4350 );
4351 assert_eq!(report.started, 2, "the host has two slots free");
4352 assert_eq!(
4353 report.allocations[0].limiting_factor,
4354 LimitingFactor::HostCapacity
4355 );
4356 assert_eq!(harness.launcher.live_count(), 6);
4357 }
4358
4359 #[tokio::test]
4360 async fn the_idle_host_assertion_holds() {
4361 let mut harness = Harness::simple(8, 0, "acme/app");
4364 let report = harness
4365 .reconciler
4366 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
4367 .await;
4368
4369 assert_eq!(report.started, 0);
4370 assert_eq!(harness.launcher.launches(), 0);
4371 assert!(harness.launcher.snapshot().is_empty());
4372 assert_eq!(
4373 harness
4374 .launcher
4375 .snapshot()
4376 .iter()
4377 .filter(|a| !a.is_terminal())
4378 .count(),
4379 0
4380 );
4381 }
4382
4383 #[tokio::test(flavor = "current_thread")]
4406 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
4407 let lock = Arc::new(CountingLock::new());
4408 let (launches, live) =
4409 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>).await;
4410
4411 assert_eq!(
4412 launches, 3,
4413 "the sum across policies must never exceed host_capacity, and each policy is \
4414 individually within its own max_capacity of 3"
4415 );
4416 assert_eq!(live, 3, "and no duplicate runner survived the race");
4417 assert_eq!(
4418 lock.peak(),
4419 1,
4420 "the allocation lock had one holder at a time; without that the read of the \
4421 headroom and the creation of the runtime are not atomic"
4422 );
4423 assert!(
4424 (3..=5).contains(&lock.acquisitions()),
4425 "the lock is taken before *each* runtime, not once per pass: three runtimes \
4426 means at least three holds, and at most one further hold per policy to \
4427 discover the host filled up underneath it. It was taken {} times",
4428 lock.acquisitions()
4429 );
4430 }
4431
4432 #[tokio::test(flavor = "current_thread")]
4439 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
4440 let (launches, _) =
4441 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>).await;
4442
4443 assert!(
4444 launches > 3,
4445 "with no serialisation both policies must be able to spend the same headroom; \
4446 they started {launches} runners on a host of 3. If this is ever 3, the \
4447 contention window closed and `two_policies_reconciling_concurrently_never_\
4448 exceed_host_capacity` has stopped proving anything"
4449 );
4450 }
4451
4452 async fn two_policies_concurrently(lock: Arc<dyn AllocationLock>) -> (usize, usize) {
4456 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
4457 let host = host_with(3);
4458
4459 let mut left = Harness::build(
4460 host.clone(),
4461 Arc::clone(&launcher),
4462 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
4463 Arc::clone(&lock),
4464 )
4465 .reconciler;
4466 let mut right = Harness::build(
4467 host,
4468 Arc::clone(&launcher),
4469 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
4470 Arc::clone(&lock),
4471 )
4472 .reconciler;
4473
4474 let a = policy(1, "acme/left", 3);
4475 let b = policy(2, "acme/right", 3);
4476
4477 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
4478 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
4479 let (_, _) = (left.await.unwrap(), right.await.unwrap());
4480
4481 (launcher.launches(), launcher.live_count())
4482 }
4483
4484 #[tokio::test]
4496 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
4497 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
4498 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
4499 let launcher = Arc::new(FakeLauncher::new());
4500 let events = Arc::new(EventLog::new());
4501
4502 let mut reconciler = Reconciler::new(
4503 host_with(10),
4504 ReconcilerPorts {
4505 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
4506 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4507 lock: Arc::new(InProcessAllocationLock::new()),
4508 directory: Arc::new(FakeDirectory::default()),
4509 clock: Arc::new(FakeClock::default()),
4510 jitter: Arc::new(NoJitter),
4511 events: Arc::clone(&events) as Arc<dyn EventSink>,
4512 },
4513 );
4514
4515 let monitor = fixtures::policy()
4516 .id(PolicyId::from_u128(1))
4517 .repository("acme/app")
4518 .monitor_only()
4519 .active()
4520 .build();
4521
4522 let report = reconciler.reconcile(&[monitor]).await;
4523
4524 assert_eq!(report.started, 0);
4525 assert_eq!(launcher.launches(), 0);
4526 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
4527 assert_eq!(
4528 report.demand_requests, 0,
4529 "a monitor-only policy spends nothing from the shared hourly ceiling"
4530 );
4531 assert!(
4532 gateway.gateway().calls().is_empty(),
4533 "a monitor-only policy issued a demand request: {:?}",
4534 gateway.gateway().calls()
4535 );
4536 assert_eq!(events.count_of("monitor_only_skipped"), 1);
4537 assert_eq!(
4538 events.count_of("demand_observed"),
4539 0,
4540 "and it contributed no demand"
4541 );
4542 }
4543
4544 #[tokio::test]
4562 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
4563 let lock = Arc::new(CountingLock::new());
4564 let launcher = Arc::new(FakeLauncher::new());
4565 let events = Arc::new(EventLog::new());
4566 let mut reconciler = Reconciler::new(
4567 host_with(4),
4568 ReconcilerPorts {
4569 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
4570 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4571 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
4572 directory: Arc::new(FakeDirectory::default()),
4573 clock: Arc::new(FakeClock::default()),
4574 jitter: Arc::new(NoJitter),
4575 events: Arc::clone(&events) as Arc<dyn EventSink>,
4576 },
4577 );
4578
4579 let watcher = fixtures::policy()
4580 .id(PolicyId::from_u128(2))
4581 .repository("acme/app")
4582 .monitor_only()
4583 .active()
4584 .build();
4585
4586 let report = reconciler
4587 .reconcile(&[policy(1, "acme/app", 4), watcher])
4588 .await;
4589
4590 assert_eq!(report.started, 2, "the autoscale policy is served normally");
4591 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
4592 assert_eq!(
4593 events.count_of("demand_observed"),
4594 1,
4595 "the demand observation belongs to the autoscale policy alone"
4596 );
4597 assert!(
4598 report
4599 .allocations
4600 .iter()
4601 .all(|a| a.policy_id == PolicyId::from_u128(1)),
4602 "a monitor-only policy was allocated for: {:?}",
4603 report.allocations
4604 );
4605 assert_eq!(
4606 lock.acquisitions(),
4607 2,
4608 "one hold per runtime created, and none on behalf of the monitor-only policy. \
4609 It was three before the budget was checked at the top of the loop rather than \
4610 after the re-read, which cost every policy a surplus hold to discover there \
4611 was nothing left to grant"
4612 );
4613 }
4614
4615 #[tokio::test]
4616 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
4617 let monitor = fixtures::monitor_only_policy();
4622 assert!(!monitor.owns_runners());
4623 assert_eq!(monitor.max_capacity(), None);
4624
4625 let host = host_with(10);
4626 let attempts: Vec<RunnerAttempt> = Vec::new();
4627 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
4628 let allocation = allocator.allocate(&monitor, 10_000);
4629 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
4630 assert_eq!(allocation.to_start, 0);
4631 assert_eq!(
4632 allocator.headroom(),
4633 10,
4634 "and it consumes no headroom, so an autoscale policy on the same host is \
4635 unaffected"
4636 );
4637 }
4638
4639 #[tokio::test]
4647 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
4648 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4649 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
4650 concluded(
4651 2,
4652 1,
4653 AttemptOutcome::failed(FailureReason::JitRequestFailed),
4654 ),
4655 concluded(3, 1, AttemptOutcome::CompletedJob),
4656 ]));
4657 let mut harness = Harness::build(
4658 host_with(4),
4659 Arc::clone(&launcher),
4660 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4661 Arc::new(InProcessAllocationLock::new()),
4662 );
4663
4664 let report = harness
4665 .reconciler
4666 .reconcile(&[policy(1, "acme/app", 4)])
4667 .await;
4668
4669 assert_eq!(report.cleaned, 3);
4670 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
4671 assert_eq!(
4672 report.failures, 1,
4673 "only the failed attempt is a failure; the idle exit and the completed job are \
4674 not"
4675 );
4676 assert_eq!(launcher.cleaned().len(), 3);
4677 assert!(launcher.snapshot().is_empty());
4678
4679 let cleaned: Vec<OutcomeKind> = harness
4680 .events
4681 .events()
4682 .into_iter()
4683 .filter_map(|event| match event {
4684 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
4685 _ => None,
4686 })
4687 .collect();
4688 assert!(cleaned.contains(&OutcomeKind::IdleExit));
4689 assert!(
4690 !OutcomeKind::IdleExit.is_failure(),
4691 "an idle exit rendered as a failure sends an operator hunting a fault that does \
4692 not exist"
4693 );
4694 }
4695
4696 #[tokio::test]
4699 async fn scale_down_removes_nothing_from_a_busy_attempt() {
4700 let busy = attempt_in(AttemptState::Busy, 1, 1);
4701 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4702 busy.clone(),
4703 attempt_in(AttemptState::Starting, 2, 1),
4704 concluded(3, 1, AttemptOutcome::CompletedJob),
4705 ]));
4706 let harness = Harness::build(
4707 host_with(4),
4708 Arc::clone(&launcher),
4709 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4710 Arc::new(InProcessAllocationLock::new()),
4711 );
4712
4713 let report = harness
4714 .reconciler
4715 .scale_down(&policy(1, "acme/app", 4))
4716 .await;
4717
4718 assert_eq!(report.refused_busy, 1);
4719 assert_eq!(
4720 report.retained, 1,
4721 "the `starting` attempt is not ended either"
4722 );
4723 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
4724
4725 let after = launcher.snapshot();
4726 let still_busy = after
4727 .iter()
4728 .find(|a| a.id == AttemptId::from_u128(1))
4729 .expect("the busy attempt is still there");
4730 assert_eq!(
4731 still_busy.state(),
4732 AttemptState::Busy,
4733 "scale-down removed nothing from a runner that is executing a job, and left it \
4734 busy"
4735 );
4736 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
4737
4738 let mut busy = busy;
4742 assert!(matches!(
4743 busy.clean(fixtures::created_at()),
4744 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
4745 ));
4746 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
4747 }
4748
4749 #[test]
4754 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
4755 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
4756 assert_eq!(RefreshInterval::MIN_SECS, 30);
4757 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
4758 assert!(
4759 RefreshInterval::from_secs(29).is_err(),
4760 "the floor is a rate-budget constraint, and a caller must not be able to write \
4761 a shorter interval at all"
4762 );
4763
4764 let mut schedule = PollSchedule::new(RefreshInterval::default());
4765 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
4766 assert_eq!(next.delay, Duration::from_secs(60));
4767 assert_eq!(next.pace, PollPace::Nominal);
4768
4769 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4770 assert_eq!(
4771 floored
4772 .next_poll(None, fixtures::created_at(), &NoJitter)
4773 .delay,
4774 Duration::from_secs(30)
4775 );
4776 }
4777
4778 #[test]
4782 fn a_rate_limit_increases_the_delay_and_names_itself() {
4783 let now = fixtures::created_at();
4784 let mut schedule = PollSchedule::new(RefreshInterval::default());
4785
4786 let limited = RefreshState::RateLimited(RateLimited {
4787 kind: RateLimitKind::Secondary,
4788 retry_after: Some(Duration::from_secs(300)),
4789 remaining: None,
4790 reset_unix_secs: None,
4791 });
4792 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4793
4794 assert_eq!(next.delay, Duration::from_secs(300));
4795 assert_eq!(
4796 next.pace,
4797 PollPace::RateLimited {
4798 kind: RateLimitKind::Secondary
4799 },
4800 "the increase is reported, never hidden"
4801 );
4802 assert!(next.pace.is_throttled());
4803 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
4804 }
4805
4806 #[test]
4809 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
4810 let now = fixtures::created_at();
4811 let mut schedule = PollSchedule::new(RefreshInterval::default());
4812
4813 let limited = RefreshState::RateLimited(RateLimited {
4814 kind: RateLimitKind::Primary,
4815 retry_after: Some(Duration::from_secs(300)),
4816 remaining: Some(0),
4817 reset_unix_secs: None,
4818 });
4819
4820 for _ in 0..5 {
4823 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4824 assert_eq!(
4825 next.delay,
4826 Duration::from_secs(300),
4827 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
4828 have compounded on every successive retry"
4829 );
4830 }
4831
4832 let brief = RefreshState::RateLimited(RateLimited {
4835 kind: RateLimitKind::Secondary,
4836 retry_after: Some(Duration::from_secs(5)),
4837 remaining: None,
4838 reset_unix_secs: None,
4839 });
4840 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
4841 assert_eq!(
4842 next.delay,
4843 Duration::from_secs(60),
4844 "a short `retry-after` may not drop the loop below its own interval"
4845 );
4846 assert!(next.delay >= PollSchedule::floor());
4847 }
4848
4849 #[test]
4850 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
4851 let now = fixtures::created_at();
4852 let states = [
4853 None,
4854 Some(RefreshState::Offline),
4855 Some(RefreshState::RateLimited(RateLimited {
4856 kind: RateLimitKind::Secondary,
4857 retry_after: Some(Duration::from_secs(1)),
4858 remaining: None,
4859 reset_unix_secs: None,
4860 })),
4861 Some(RefreshState::LockedOut {
4862 retry_after: Duration::from_secs(1),
4863 }),
4864 Some(RefreshState::Unauthorized),
4865 Some(RefreshState::Forbidden { message: None }),
4866 Some(RefreshState::Failed {
4867 status: Some(500),
4868 message: "server error".into(),
4869 }),
4870 Some(RefreshState::Cancelled),
4871 ];
4872
4873 for state in &states {
4874 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4875 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
4876 assert!(
4877 next.delay >= PollSchedule::floor(),
4878 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
4879 next.delay.as_millis()
4880 );
4881 }
4882 }
4883
4884 #[test]
4885 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
4886 let now = fixtures::created_at();
4887 let mut schedule = PollSchedule::new(RefreshInterval::default());
4888
4889 let mut previous = Duration::ZERO;
4891 for consecutive in 1..=6_u32 {
4892 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4893 assert_eq!(next.pace, PollPace::Offline { consecutive });
4894 assert!(
4895 next.delay >= previous,
4896 "the back-off must not shrink while the outage continues"
4897 );
4898 assert!(next.delay >= Duration::from_secs(60));
4899 previous = next.delay;
4900 }
4901 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
4902
4903 let mut jittered = PollSchedule::new(RefreshInterval::default());
4906 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4907 let mut jittered = PollSchedule::new(RefreshInterval::default());
4908 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
4909 assert!(full.delay > none.delay);
4910 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
4911
4912 assert_eq!(schedule.consecutive_offline(), 6);
4914 let recovered = schedule.next_poll(None, now, &NoJitter);
4915 assert_eq!(recovered.pace, PollPace::Nominal);
4916 assert_eq!(recovered.delay, Duration::from_secs(60));
4917 assert_eq!(schedule.consecutive_offline(), 0);
4918 }
4919
4920 #[test]
4921 fn the_offline_state_states_the_twenty_four_hour_bound() {
4922 assert_eq!(
4923 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
4924 Duration::from_secs(24 * 60 * 60)
4925 );
4926
4927 let brief = OfflineState::new(1, Duration::from_secs(120));
4928 let rendered = brief.to_string();
4929 assert!(rendered.contains("24 hours"), "{rendered}");
4930 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
4931 assert!(!brief.has_outlasted_the_queue());
4932
4933 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
4934 assert!(long.has_outlasted_the_queue());
4935 assert!(
4936 long.to_string().contains("queued work has been lost"),
4937 "{long}"
4938 );
4939
4940 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
4942 }
4943
4944 #[tokio::test]
4953 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
4954 let live = vec![
4955 attempt_in(AttemptState::Busy, 1, 1),
4956 attempt_in(AttemptState::Starting, 2, 1),
4957 ];
4958 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
4959 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
4960 let mut harness = Harness::build(
4961 host_with(8),
4962 Arc::clone(&launcher),
4963 Arc::clone(&demand),
4964 Arc::new(InProcessAllocationLock::new()),
4965 );
4966 let policy = policy(1, "acme/app", 8);
4967
4968 let report = harness
4969 .reconciler
4970 .reconcile(std::slice::from_ref(&policy))
4971 .await;
4972
4973 assert!(report.is_offline());
4974 assert_eq!(report.started, 0, "no new runner during an outage");
4975 assert_eq!(launcher.launches(), 0);
4976 assert_eq!(
4977 launcher.snapshot(),
4978 live,
4979 "existing runner processes are retained, untouched"
4980 );
4981 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
4982 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
4983 assert!(report.next_poll.delay >= Duration::from_secs(60));
4984 let offline = report.offline_state().expect("an offline state to display");
4985 assert!(offline.to_string().contains("24 hours"));
4986
4987 demand.set(PollOutcome::Ready(QueuedDemand::of(
4991 repo("acme/app"),
4992 jobs(2),
4993 )));
4994 let recovered = harness.reconciler.reconcile(&[policy]).await;
4995
4996 assert!(!recovered.is_offline());
4997 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
4998 assert_eq!(
4999 recovered.started, 0,
5000 "two queued runs, two attempts already in flight: a reconnect cannot \
5001 double-count work"
5002 );
5003 assert_eq!(recovered.allocations[0].active_owned, 2);
5004 assert_eq!(launcher.live_count(), 2);
5005 }
5006
5007 #[tokio::test]
5017 async fn one_offline_target_does_not_stop_a_reachable_one() {
5018 let mut harness = Harness::simple(8, 0, "acme/app");
5019 harness.demand.set_for(
5020 &ScaleTarget::repository("acme/app").unwrap(),
5021 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
5022 );
5023 harness.demand.set_for(
5024 &ScaleTarget::repository("acme/broken").unwrap(),
5025 PollOutcome::Failed(RefreshState::Offline),
5026 );
5027
5028 let report = harness
5029 .reconciler
5030 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
5031 .await;
5032
5033 assert_eq!(
5034 report.started, 2,
5035 "the reachable target was served; an unreachable sibling repository must not \
5036 idle the host"
5037 );
5038 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
5039 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
5040
5041 assert!(report.is_offline());
5043 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5044 }
5045
5046 #[tokio::test]
5061 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
5062 let clock = Arc::new(FakeClock::default());
5063 let launcher = Arc::new(FakeLauncher::new());
5064 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5065 let mut reconciler = Reconciler::new(
5066 host_with(4),
5067 ReconcilerPorts {
5068 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
5069 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5070 lock: Arc::new(InProcessAllocationLock::new()),
5071 directory: Arc::new(FakeDirectory::default()),
5072 clock: Arc::clone(&clock) as Arc<dyn Clock>,
5073 jitter: Arc::new(NoJitter),
5074 events: Arc::new(NoEvents),
5075 },
5076 );
5077 let policy = policy(1, "acme/app", 4);
5078
5079 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5081 let state = first.offline_state().expect("an offline state");
5082 assert!(!state.has_outlasted_the_queue());
5083 assert!(
5084 state
5085 .to_string()
5086 .contains("an outage longer than that loses"),
5087 "{state}"
5088 );
5089
5090 clock.advance_secs(24 * 60 * 60 + 60);
5092 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5093 let state = later.offline_state().expect("an offline state");
5094 assert!(state.has_outlasted_the_queue());
5095 assert!(
5096 state.to_string().contains("queued work has been lost"),
5097 "{state}"
5098 );
5099 assert_eq!(launcher.launches(), 0, "and still nothing was started");
5100
5101 demand.set(PollOutcome::Ready(QueuedDemand::of(
5104 repo("acme/app"),
5105 jobs(0),
5106 )));
5107 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5108 assert!(recovered.offline_state().is_none());
5109 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
5110
5111 demand.set(PollOutcome::Failed(RefreshState::Offline));
5112 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5113 assert!(
5114 !again
5115 .offline_state()
5116 .expect("an offline state")
5117 .has_outlasted_the_queue(),
5118 "a new outage must not inherit the age of the one before it"
5119 );
5120 }
5121
5122 #[tokio::test]
5138 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
5139 let root = tempfile::tempdir().expect("a temporary directory");
5140 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
5141 root.path(),
5142 ));
5143 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
5144
5145 let held = lock.acquire().await.expect("an uncontended lock is free");
5146 assert!(
5147 matches!(lock.acquire().await, Err(AllocationLockBusy)),
5148 "a second holder was admitted; on Unix the lock is per open file description \
5149 and on Windows the share mode denies write, so this must be refused even \
5150 from inside the same process"
5151 );
5152
5153 drop(held);
5154 let regained = lock.acquire().await;
5155 assert!(
5156 regained.is_ok(),
5157 "dropping the guard is the only release there is, so a guard that does not \
5158 hold the `HostLock` leaves it held forever"
5159 );
5160 }
5161
5162 #[test]
5163 fn tee_events_reaches_both_sinks() {
5164 let left = Arc::new(EventLog::new());
5168 let right = Arc::new(EventLog::new());
5169 let tee = TeeEvents(
5170 Arc::clone(&left) as Arc<dyn EventSink>,
5171 Arc::clone(&right) as Arc<dyn EventSink>,
5172 );
5173
5174 tee.emit(LifecycleEvent::MonitorOnlySkipped {
5175 policy: PolicyId::from_u128(1),
5176 });
5177
5178 assert_eq!(left.count_of("monitor_only_skipped"), 1);
5179 assert_eq!(right.count_of("monitor_only_skipped"), 1);
5180 }
5181
5182 #[tokio::test]
5187 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
5188 let clock = Arc::new(FakeClock::default());
5189 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
5190 let cache = RepositoryCache::new(
5191 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
5192 Arc::clone(&clock) as Arc<dyn Clock>,
5193 RefreshInterval::default(),
5194 );
5195 let target = ScaleTarget::organization("acme").unwrap();
5196
5197 assert_eq!(
5198 cache.ttl(),
5199 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
5200 );
5201
5202 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
5204 let scope = cache.scope_for(&target).await.unwrap();
5205 assert_eq!(scope.repositories().len(), 2);
5206 clock.advance_secs(60);
5207 }
5208 assert_eq!(
5209 directory.calls(),
5210 1,
5211 "re-listing an organization at demand-poll frequency is what exhausts the \
5212 shared request budget"
5213 );
5214 assert_eq!(cache.lookups(), 1);
5215
5216 cache.scope_for(&target).await.unwrap();
5218 assert_eq!(directory.calls(), 2);
5219 }
5220
5221 #[tokio::test]
5222 async fn a_repository_target_never_consults_the_directory() {
5223 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
5224 let cache = RepositoryCache::new(
5225 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
5226 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
5227 RefreshInterval::default(),
5228 );
5229 let target = ScaleTarget::repository("acme/app").unwrap();
5230
5231 let scope = cache.scope_for(&target).await.unwrap();
5232 assert_eq!(scope.repositories(), &[repo("acme/app")]);
5233 assert_eq!(directory.calls(), 0);
5234 }
5235
5236 #[tokio::test]
5237 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
5238 let mut harness = Harness::simple(8, 4, "o/r");
5242 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5243 repo("o/r"),
5244 [
5245 fixtures::queued_job(&[HOST_LABEL]),
5246 fixtures::queued_job(&[HOST_LABEL]),
5247 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
5248 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
5249 ],
5250 )));
5251 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5252 isolated.activate().unwrap();
5253 let report = harness
5254 .reconciler
5255 .reconcile(&[policy(1, "o/r", 2), isolated])
5256 .await;
5257
5258 assert_eq!(harness.demand.polls().len(), 1);
5259 assert_eq!(report.targets_read, 1, "profiles share one target reading");
5260 assert_eq!(
5261 report.demand_requests,
5262 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
5263 "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"
5264 );
5265 assert_eq!(
5266 report.started, 4,
5267 "native and isolated profiles each get two"
5268 );
5269 }
5270
5271 #[tokio::test]
5272 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
5273 use runner_manager_domain::model::{Arch, HostLabel, Os};
5274 use runner_manager_domain::policy::RoutingLabels;
5275 let mut harness = Harness::build(
5276 fixtures::host()
5277 .os(Os::MacOs)
5278 .architecture(Arch::Arm64)
5279 .capacity(10)
5280 .build(),
5281 Arc::new(FakeLauncher::new()),
5282 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
5283 Arc::new(InProcessAllocationLock::new()),
5284 );
5285 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5286 repo("o/r"),
5287 [
5288 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
5289 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
5290 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
5291 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
5292 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
5293 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
5294 ],
5295 )));
5296 let mut local = fixtures::policy()
5297 .id(PolicyId::from_u128(1))
5298 .repository("o/r")
5299 .autoscale("macmini", 10)
5300 .routing_labels(RoutingLabels::derive(
5301 &HostLabel::new("macmini").unwrap(),
5302 Os::MacOs,
5303 Arch::Arm64,
5304 ))
5305 .active()
5306 .build();
5307 for label in ["self-hosted", "macos", "arm64", "extra"] {
5308 local.add_routing_label(fixtures::label(label)).unwrap();
5309 }
5310 let mut foreign = local.to_persisted();
5311 foreign.id = PolicyId::from_u128(2);
5312 foreign.host_id = HostId::from_u128(0xdead);
5313 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
5314 let report = harness.reconciler.reconcile(&[local, foreign]).await;
5315 assert_eq!(report.started, 2);
5316 assert_eq!(report.targets_read, 1);
5317 assert!(harness.events.events().iter().any(|event| matches!(
5318 event,
5319 LifecycleEvent::DemandObserved {
5320 demand: 2,
5321 not_matched: 4,
5322 ..
5323 }
5324 )));
5325 assert!(
5326 !harness
5327 .events
5328 .events()
5329 .iter()
5330 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
5331 );
5332 }
5333
5334 #[tokio::test]
5335 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
5336 use runner_manager_domain::execution::{
5337 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
5338 };
5339 let mut native = policy(1, "o/r", 2);
5340 native
5341 .add_routing_label(fixtures::label("self-hosted"))
5342 .unwrap();
5343 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5344 isolated
5345 .add_routing_label(fixtures::label("self-hosted"))
5346 .unwrap();
5347 isolated
5348 .set_execution_policy(ExecutionPolicy::Isolated {
5349 backend: Backend::Oci,
5350 image: ImageReference::new(format!(
5351 "registry.example/runner@sha256:{}",
5352 "a".repeat(64)
5353 ))
5354 .unwrap(),
5355 resources: ResourceLimits {
5356 cpu_millis: 1000,
5357 memory_mib: 512,
5358 disk_mib: 1024,
5359 },
5360 })
5361 .unwrap();
5362 assert!(!isolated.may_start_runners());
5363 let mut active_isolated = isolated.clone();
5364 active_isolated.activate().unwrap();
5365 for profiles in [
5366 [native.clone(), isolated.clone()],
5367 [isolated.clone(), native.clone()],
5368 [native.clone(), active_isolated.clone()],
5369 [active_isolated, native.clone()],
5370 ] {
5371 let mut harness = Harness::simple(4, 0, "o/r");
5372 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5373 repo("o/r"),
5374 [fixtures::queued_job(&["SELF-HOSTED"])],
5375 )));
5376 let report = harness.reconciler.reconcile(&profiles).await;
5377 assert_eq!(report.started, 0);
5378 assert!(harness.events.events().iter().any(|event| matches!(
5379 event,
5380 LifecycleEvent::RoutingRefused {
5381 reason: RoutingRefusal::OverlappingProfiles,
5382 count: 1,
5383 ..
5384 }
5385 )));
5386 }
5387 let mut harness = Harness::simple(4, 0, "o/r");
5390 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5391 repo("o/r"),
5392 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
5393 )));
5394 assert_eq!(
5395 harness
5396 .reconciler
5397 .reconcile(&[native, isolated])
5398 .await
5399 .started,
5400 0
5401 );
5402 }
5403
5404 #[tokio::test]
5405 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
5406 let mut harness = Harness::simple(2, 0, "o/r");
5407 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5408 repo("o/r"),
5409 [
5410 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
5411 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
5412 fixtures::queued_job(&["gpu"]),
5413 fixtures::unresolvable_job(),
5414 ],
5415 )));
5416 let mut native = policy(1, "o/r", 2);
5417 native.add_routing_label(fixtures::label("gpu")).unwrap();
5418 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5419 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
5420 isolated.activate().unwrap();
5421
5422 let report = harness.reconciler.reconcile(&[native, isolated]).await;
5423 assert_eq!(harness.demand.polls().len(), 1);
5424 assert_eq!(report.targets_read, 1);
5425 assert_eq!(report.started, 2);
5426 assert_eq!(report.allocations.len(), 2);
5427 assert!(
5428 report
5429 .allocations
5430 .iter()
5431 .all(|allocation| allocation.to_start == 1)
5432 );
5433 assert!(harness.events.events().iter().any(|event| matches!(
5434 event,
5435 LifecycleEvent::RoutingRefused {
5436 reason: RoutingRefusal::OverlappingProfiles,
5437 count: 1,
5438 ..
5439 }
5440 )));
5441 assert_eq!(
5442 harness
5443 .events
5444 .events()
5445 .iter()
5446 .filter(|event| matches!(
5447 event,
5448 LifecycleEvent::DemandObserved {
5449 unresolvable: 1,
5450 ..
5451 }
5452 ))
5453 .count(),
5454 2,
5455 "an expression stays unresolvable for both profiles"
5456 );
5457 }
5458
5459 #[tokio::test]
5460 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
5461 let mut harness = Harness::simple(4, 0, "o/r");
5462 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5463 repo("o/r"),
5464 [fixtures::queued_job(&[
5465 HOST_LABEL,
5466 "rm-home-win-x64-py-isolated",
5467 ])],
5468 )));
5469 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5470 isolated.activate().unwrap();
5471 let report = harness
5472 .reconciler
5473 .reconcile(&[policy(1, "o/r", 2), isolated])
5474 .await;
5475 assert_eq!(report.started, 0);
5476 assert_eq!(
5477 harness
5478 .events
5479 .events()
5480 .iter()
5481 .filter(|event| matches!(
5482 event,
5483 LifecycleEvent::DemandObserved {
5484 demand: 0,
5485 not_matched: 1,
5486 ..
5487 }
5488 ))
5489 .count(),
5490 2
5491 );
5492
5493 let mut superset = policy(1, "o/r", 2);
5497 assert!(
5498 superset
5499 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
5500 .unwrap()
5501 );
5502 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5503 sibling.activate().unwrap();
5504 let mut unique = Harness::simple(4, 0, "o/r");
5505 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
5506 repo("o/r"),
5507 [fixtures::queued_job(&[
5508 HOST_LABEL,
5509 "rm-home-win-x64-py-isolated",
5510 ])],
5511 )));
5512 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
5513 assert_eq!(report.started, 1);
5514 assert_eq!(
5515 report
5516 .allocations
5517 .iter()
5518 .find(|allocation| allocation.to_start == 1)
5519 .unwrap()
5520 .policy_id,
5521 PolicyId::from_u128(1)
5522 );
5523 assert!(
5524 !unique
5525 .events
5526 .events()
5527 .iter()
5528 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
5529 );
5530
5531 let mut overlap = Harness::simple(4, 1, "o/r");
5532 let report = overlap
5533 .reconciler
5534 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
5535 .await;
5536 assert_eq!(report.started, 0);
5537 assert!(overlap.events.events().iter().any(|event| matches!(
5538 event,
5539 LifecycleEvent::RoutingRefused {
5540 reason: RoutingRefusal::OverlappingProfiles,
5541 count: 1,
5542 ..
5543 }
5544 )));
5545 assert!(
5546 RoutingRefusal::OverlappingProfiles
5547 .to_string()
5548 .contains("repair")
5549 );
5550 }
5551
5552 #[tokio::test]
5557 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
5558 let launcher = Arc::new(FakeLauncher::new());
5559 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
5560 let mut harness = Harness::build(
5561 host_with(4),
5562 Arc::clone(&launcher),
5563 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
5564 Arc::new(InProcessAllocationLock::new()),
5565 );
5566
5567 let report = harness
5568 .reconciler
5569 .reconcile(&[policy(1, "acme/app", 4)])
5570 .await;
5571 assert_eq!(report.started, 0);
5572 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
5573
5574 let failures: Vec<&'static str> = harness
5575 .events
5576 .events()
5577 .into_iter()
5578 .filter_map(|event| match event {
5579 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
5580 _ => None,
5581 })
5582 .collect();
5583 assert_eq!(failures, vec!["other"]);
5584 assert!(
5585 !failures[0].contains("ghp_"),
5586 "an event carried a `FailureReason::Other` detail verbatim"
5587 );
5588 }
5589
5590 #[tokio::test]
5595 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
5596 let launcher = Arc::new(FakeLauncher::new());
5597 let unfit = Arc::new(AtomicBool::new(true));
5598 let events = Arc::new(EventLog::new());
5599 let mut reconciler = Reconciler::new(
5600 host_with(4),
5601 ReconcilerPorts {
5602 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5603 launcher: Arc::new(FitnessGatedLauncher::new(
5604 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5605 Arc::clone(&unfit),
5606 )),
5607 lock: Arc::new(InProcessAllocationLock::new()),
5608 directory: Arc::new(FakeDirectory::default()),
5609 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
5610 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
5611 events: Arc::clone(&events) as Arc<dyn EventSink>,
5612 },
5613 );
5614 let policies = [policy(1, "acme/app", 4)];
5615
5616 let report = reconciler.reconcile(&policies).await;
5617 assert_eq!(report.started, 0);
5618 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
5619 let reasons: Vec<&'static str> = events
5620 .events()
5621 .into_iter()
5622 .filter_map(|event| match event {
5623 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
5624 _ => None,
5625 })
5626 .collect();
5627 assert_eq!(reasons, ["host_unfit"]);
5628
5629 unfit.store(false, Ordering::Release);
5630 let report = reconciler.reconcile(&policies).await;
5631 assert_eq!(report.started, 2);
5632 assert_eq!(launcher.launches(), 2);
5633 }
5634
5635 #[tokio::test]
5636 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
5637 let launcher = Arc::new(FakeLauncher::new());
5638 let mut harness = Harness::build(
5639 host_with(4),
5640 Arc::clone(&launcher),
5641 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
5642 Arc::new(HeldLock),
5643 );
5644
5645 let report = harness
5646 .reconciler
5647 .reconcile(&[policy(1, "acme/app", 4)])
5648 .await;
5649 assert_eq!(report.started, 0);
5650 assert_eq!(
5651 report.deferred, 3,
5652 "three runners were granted and none was created; `deferred` counts grants, \
5653 not policies -- it reported `1` when a policy that launched two of five and \
5654 then lost the lock had left three unstarted"
5655 );
5656 assert_eq!(launcher.launches(), 0);
5657 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
5658 assert!(
5659 harness
5660 .events
5661 .events()
5662 .iter()
5663 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
5664 "the event carries the same number the report does"
5665 );
5666 assert_eq!(
5667 report.allocations.len(),
5668 1,
5669 "the intent is still reported, so an operator staring at a queue sees why \
5670 nothing started"
5671 );
5672 }
5673
5674 #[tokio::test]
5675 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
5676 let mut harness = Harness::simple(8, 5, "acme/app");
5677
5678 let foreign = fixtures::policy()
5679 .id(PolicyId::from_u128(1))
5680 .repository("acme/app")
5681 .host(HostId::from_u128(0xdead))
5682 .autoscale("office", 4)
5683 .active()
5684 .build();
5685 let mut draining = policy(2, "acme/app", 4);
5686 draining.request_disable().unwrap();
5687
5688 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
5689
5690 assert_eq!(report.started, 0);
5691 assert_eq!(
5692 harness.demand.polls().len(),
5693 0,
5694 "neither can act on an answer"
5695 );
5696 let factors: Vec<LimitingFactor> = report
5697 .allocations
5698 .iter()
5699 .map(|a| a.limiting_factor)
5700 .collect();
5701 assert!(factors.contains(&LimitingFactor::ForeignHost));
5702 assert!(factors.contains(&LimitingFactor::NotReconciling));
5703 }
5704
5705 #[tokio::test]
5706 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
5707 #[derive(Debug)]
5710 struct BrokenDirectory;
5711
5712 #[async_trait::async_trait]
5713 impl RepositoryDirectory for BrokenDirectory {
5714 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
5715 Err(InventoryError::Cancelled)
5716 }
5717 }
5718
5719 let launcher = Arc::new(FakeLauncher::new());
5720 let events = Arc::new(EventLog::new());
5721 let mut reconciler = Reconciler::new(
5722 host_with(4),
5723 ReconcilerPorts {
5724 demand: Arc::new(FakeDemand::default()),
5725 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5726 lock: Arc::new(InProcessAllocationLock::new()),
5727 directory: Arc::new(BrokenDirectory),
5728 clock: Arc::new(FakeClock::default()),
5729 jitter: Arc::new(NoJitter),
5730 events: Arc::clone(&events) as Arc<dyn EventSink>,
5731 },
5732 );
5733
5734 let org_policy = fixtures::policy()
5735 .id(PolicyId::from_u128(1))
5736 .organization("acme")
5737 .autoscale("home", 4)
5738 .active()
5739 .build();
5740
5741 let report = reconciler.reconcile(&[org_policy]).await;
5742 assert_eq!(report.started, 0);
5743 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
5744 assert_eq!(events.count_of("target_unreadable"), 1);
5745 }
5746
5747 fn every_event() -> Vec<LifecycleEvent> {
5759 let policy = PolicyId::from_u128(0xabcd_ef01);
5760 let attempt = AttemptId::from_u128(0x1234_5678);
5761 vec![
5762 LifecycleEvent::DemandObserved {
5763 policy,
5764 demand: u32::MAX,
5765 not_matched: u32::MAX,
5766 unresolvable: u32::MAX,
5767 complete: false,
5768 },
5769 LifecycleEvent::TargetUnreadable {
5770 policy,
5771 reason: unreadable_reason(&RefreshState::Failed {
5772 status: Some(500),
5773 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
5774 .into(),
5775 }),
5776 },
5777 LifecycleEvent::Allocated {
5778 policy,
5779 demand: u32::MAX,
5780 desired: u16::MAX,
5781 active_owned: 7,
5782 headroom: 9,
5783 to_start: 2,
5784 limiting: LimitingFactor::HostCapacity,
5785 },
5786 LifecycleEvent::MonitorOnlySkipped { policy },
5787 LifecycleEvent::RunnerStarted { policy, attempt },
5788 LifecycleEvent::RunnerStartFailed {
5789 policy,
5790 reason: failure_reason_kind(&FailureReason::Other(
5791 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
5792 )),
5793 },
5794 LifecycleEvent::AllocationDeferred { policy, count: 4 },
5795 LifecycleEvent::AttemptsUnreadable {
5796 reason: failure_reason_kind(&FailureReason::Other(
5797 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
5798 )),
5799 },
5800 LifecycleEvent::AttemptCleanFailed {
5801 policy,
5802 attempt,
5803 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
5804 },
5805 LifecycleEvent::AttemptCleaned {
5806 policy,
5807 attempt,
5808 outcome: OutcomeKind::IdleExit,
5809 },
5810 LifecycleEvent::ScaleDownRefused { policy, attempt },
5811 LifecycleEvent::PollScheduled {
5812 retry_in_ms: 900_000,
5813 pace: PollPace::RateLimited {
5814 kind: RateLimitKind::Primary,
5815 },
5816 },
5817 ]
5818 }
5819
5820 #[test]
5829 fn no_emitted_event_can_carry_a_credential() {
5830 use runner_manager_platform::logging::redact;
5831
5832 for event in every_event() {
5833 let displayed = event.to_string();
5834 assert_eq!(
5835 redact(&displayed),
5836 displayed,
5837 "`{}` renders something `d1`'s sink would have to redact",
5838 event.name()
5839 );
5840
5841 let debugged = format!("{event:?}");
5842 assert_eq!(
5843 redact(&debugged),
5844 debugged,
5845 "`{}`'s Debug renders something `d1`'s sink would have to redact",
5846 event.name()
5847 );
5848 }
5849
5850 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
5853 assert_ne!(
5854 redact(secret),
5855 secret,
5856 "the scan above proves nothing if `redact` no longer recognises a credential"
5857 );
5858 }
5859
5860 #[test]
5861 fn every_field_name_this_sink_emits_is_one_d1_allows() {
5862 use runner_manager_platform::logging::is_field_allowed;
5863
5864 for field in [
5868 "event",
5869 "policy_id",
5870 "attempt_id",
5871 "attempt_state",
5872 "demand",
5873 "desired",
5874 "capacity",
5875 "headroom",
5876 "count",
5877 "reason",
5878 "outcome",
5879 "mode",
5880 "lock",
5881 "retry_in_ms",
5882 "state",
5883 ] {
5884 assert!(
5885 is_field_allowed(field),
5886 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
5887 `[redacted]` in its place and the line would lose its meaning"
5888 );
5889 }
5890 }
5891
5892 #[test]
5893 fn every_failure_reason_has_a_credential_free_kind() {
5894 for reason in FailureReason::ALL {
5895 let kind = failure_reason_kind(&reason);
5896 assert!(!kind.is_empty());
5897 assert!(
5898 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
5899 "`{kind}` is not a fixed identifier"
5900 );
5901 }
5902 assert_eq!(
5903 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
5904 "other",
5905 "the detail of an `Other` reason never reaches an event"
5906 );
5907 let categories = [
5908 IsolationProviderFailure::NotInstalled,
5909 IsolationProviderFailure::PermissionDenied,
5910 IsolationProviderFailure::ImageUnavailableOrIncompatible,
5911 IsolationProviderFailure::DiskQuotaUnavailable,
5912 IsolationProviderFailure::RuntimeOperationFailed,
5913 ];
5914 let mut kinds = std::collections::BTreeSet::new();
5915 for category in categories {
5916 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
5917 assert_ne!(kind, "other");
5918 assert!(!kind.contains("secret"));
5919 kinds.insert(kind);
5920 }
5921 assert_eq!(kinds.len(), categories.len());
5922 }
5923
5924 fn production_half_of(source: &str) -> String {
5939 let production = source
5940 .split_once("\n#[cfg(test)]")
5941 .map_or(source, |(production, _)| production);
5942 production
5943 .lines()
5944 .filter(|line| !line.trim_start().starts_with("//"))
5945 .collect::<Vec<_>>()
5946 .join("\n")
5947 }
5948
5949 fn this_file_above_its_tests_without_prose() -> String {
5951 production_half_of(include_str!("reconcile.rs"))
5952 }
5953
5954 fn normalise_for_scan(text: &str) -> String {
5973 text.to_ascii_lowercase().replace(['_', ' '], "")
5974 }
5975
5976 const FORBIDDEN: &[&str] = &[
5986 concat!("fn ", "acquire", "_job"),
5987 concat!("fn ", "claim", "_job"),
5988 concat!("fn ", "lease", "_job"),
5989 concat!("fn ", "reserve", "_job"),
5990 concat!("fn ", "ack", "nowledge"),
5991 concat!("struct ", "Job", "Lease"),
5992 concat!("struct ", "Job", "Claim"),
5993 concat!("struct ", "Job", "Reservation"),
5994 ];
5995
5996 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
5997 let haystack = normalise_for_scan(source);
5998 FORBIDDEN
5999 .iter()
6000 .copied()
6001 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
6002 }
6003
6004 #[test]
6038 fn nothing_in_this_crate_reserves_or_claims_a_job() {
6039 const SOURCES: &[(&str, &str)] = &[
6040 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
6041 ("lib.rs", include_str!("lib.rs")),
6042 ("lifecycle.rs", include_str!("lifecycle.rs")),
6043 (
6044 "lifecycle/windows_hyperv.rs",
6045 include_str!("lifecycle/windows_hyperv.rs"),
6046 ),
6047 ("oci.rs", include_str!("oci.rs")),
6048 ("package.rs", include_str!("package.rs")),
6049 ("reconcile.rs", include_str!("reconcile.rs")),
6050 ];
6051
6052 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
6053 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
6054 let entry = entry.expect("a readable directory entry");
6055 let name = entry.file_name().to_string_lossy().into_owned();
6056 let joined = if prefix.is_empty() {
6059 name.clone()
6060 } else {
6061 format!("{prefix}/{name}")
6062 };
6063 if entry.path().is_dir() {
6064 walk(&entry.path(), &joined, found);
6065 } else if name.ends_with(".rs") {
6066 found.push(joined);
6067 }
6068 }
6069 }
6070
6071 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
6072 listed.sort_unstable();
6073 let mut on_disk = Vec::new();
6074 walk(
6075 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
6076 "",
6077 &mut on_disk,
6078 );
6079 on_disk.sort_unstable();
6080 assert_eq!(
6081 listed, on_disk,
6082 "a source file was added or removed; this scan claims to cover the whole crate \
6083 and a stale list makes that claim false"
6084 );
6085
6086 for (name, source) in SOURCES {
6087 assert_eq!(
6088 forbidden_shape_in(&production_half_of(source)),
6089 None,
6090 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
6091 REST, and a local lease coordinates this host with itself and with nothing \
6092 else. If an owner decision restored one, that decision belongs in this \
6093 module's documentation and in this test before it belongs in the code"
6094 );
6095 }
6096
6097 assert!(
6100 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
6101 "the scan above proves nothing if the needles no longer match"
6102 );
6103 }
6104
6105 #[test]
6133 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
6134 let production = this_file_above_its_tests_without_prose();
6135
6136 assert!(
6137 production.contains("DemandTally"),
6138 "the reconciliation loop must tally queued jobs against this policy's routing \
6139 labels. A production half that named `DemandTally` nowhere would mean the \
6140 label filtering had been removed, and a repository whose jobs target \
6141 `ubuntu-latest` would drive its policy toward `max_capacity` again"
6142 );
6143
6144 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
6145 assert!(
6146 !production.contains(second_implementation),
6147 "the reconciliation loop names `{second_implementation}`, which is the \
6148 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
6149 has already decided it. This module applies the predicate and does not \
6150 re-implement it; if an owner decision changed that, it belongs in this \
6151 module's documentation and in this test before it belongs in the code"
6152 );
6153 }
6154 }
6155
6156 #[test]
6164 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
6165 let policy = policy(1, "acme/app", 10);
6166 let reading = QueuedDemand::of(
6167 repo("acme/app"),
6168 [
6169 fixtures::queued_job(&[HOST_LABEL]),
6170 fixtures::queued_job(&[HOST_LABEL]),
6171 fixtures::queued_job(&["ubuntu-latest"]),
6172 fixtures::unresolvable_job(),
6173 ],
6174 );
6175
6176 let tally = demand_for(&policy, &reading);
6177
6178 assert_eq!(
6179 tally.demand(),
6180 2,
6181 "only the jobs whose required labels this policy carries are demand"
6182 );
6183 assert_eq!(
6184 tally.not_matched, 1,
6185 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
6186 runner that idles until it times out"
6187 );
6188 assert_eq!(
6189 tally.unresolvable.len(),
6190 1,
6191 "an unresolvable `runs-on` is never demand and never discarded"
6192 );
6193 }
6194
6195 #[test]
6198 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
6199 let mut per_repository = BTreeMap::new();
6200 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
6201 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
6202 let reading = QueuedDemand::new(per_repository);
6203
6204 let repository_policy = policy(1, "acme/left", 10);
6205 assert_eq!(
6206 demand_for(&repository_policy, &reading).demand(),
6207 3,
6208 "a repository target reads its own repository's queue and not the aggregate"
6209 );
6210
6211 let org_policy = fixtures::policy()
6212 .id(PolicyId::from_u128(2))
6213 .organization("acme")
6214 .autoscale("home", 10)
6215 .active()
6216 .build();
6217 assert_eq!(
6218 demand_for(&org_policy, &reading).demand(),
6219 7,
6220 "an organization policy serves any repository in its scope, so its demand is \
6221 the whole aggregate's"
6222 );
6223 }
6224
6225 #[test]
6226 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
6227 let host = host_with(2);
6232 let policy = policy(1, "acme/app", 5);
6233 let attempts: Vec<RunnerAttempt> = Vec::new();
6234 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
6235
6236 let allocation = allocator.allocate(&policy, u32::MAX);
6237 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
6238 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
6239 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
6240 }
6241}