1use std::collections::{BTreeMap, BTreeSet};
113use std::fmt;
114use std::num::NonZeroU16;
115use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
116use std::sync::{Arc, Mutex};
117use std::time::Duration;
118
119use runner_manager_domain::attempt::{
120 AttemptOutcome, AttemptState, FailureReason, IsolationProviderFailure, RunnerAttempt,
121};
122use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
123use runner_manager_domain::model::{
124 AttemptId, Clock, Host, IdlePollInterval, Org, OwnerRepo, PolicyId, RefreshInterval,
125 ScaleTarget, Timestamp,
126};
127use runner_manager_domain::policy::{DemandTally, ScalePolicy};
128use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
129use runner_manager_github::rest::{
130 ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
131};
132use runner_manager_github::traffic::{PrimaryPressure, RateLimitSnapshot};
133
134pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
154
155pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
164
165const MAX_BACKOFF_DOUBLINGS: u32 = 5;
171
172const JITTER_RATIO: f64 = 0.5;
180
181const RATE_LIMIT_JITTER_RATIO: f64 = 0.1;
189
190pub const MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET: u32 = 300;
198
199pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
206
207pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
237
238#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum PollOutcome {
256 Ready(QueuedDemand),
259 Failed(RefreshState),
262}
263
264impl PollOutcome {
265 #[must_use]
267 pub const fn reading(&self) -> Option<&QueuedDemand> {
268 match self {
269 Self::Ready(demand) => Some(demand),
270 Self::Failed(_) => None,
271 }
272 }
273
274 #[must_use]
276 pub const fn failure(&self) -> Option<&RefreshState> {
277 match self {
278 Self::Failed(state) => Some(state),
279 Self::Ready(_) => None,
280 }
281 }
282
283 #[must_use]
290 pub fn is_offline(&self) -> bool {
291 matches!(self, Self::Failed(RefreshState::Offline))
292 }
293}
294
295#[async_trait::async_trait]
303pub trait DemandSource: fmt::Debug + Send + Sync {
304 async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
306}
307
308#[derive(Debug)]
313pub struct GatewayDemand<G> {
314 gateway: G,
315 cancel: CancelToken,
316}
317
318impl<G: DemandGateway> GatewayDemand<G> {
319 #[must_use]
320 pub const fn new(gateway: G, cancel: CancelToken) -> Self {
321 Self { gateway, cancel }
322 }
323
324 #[must_use]
325 pub const fn gateway(&self) -> &G {
326 &self.gateway
327 }
328}
329
330#[async_trait::async_trait]
331impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
332 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
333 match self.gateway.queued_demand(scope, &self.cancel).await {
334 Ok(demand) => PollOutcome::Ready(demand),
335 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
339 }
340 }
341}
342
343#[async_trait::async_trait]
354pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
355 async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
360}
361
362#[derive(Debug, Clone)]
363struct CachedRepositories {
364 repositories: Vec<OwnerRepo>,
365 fetched_at: Timestamp,
366}
367
368#[derive(Debug)]
386pub struct RepositoryCache {
387 directory: Arc<dyn RepositoryDirectory>,
388 clock: Arc<dyn Clock>,
389 ttl: Duration,
390 entries: Mutex<BTreeMap<Org, CachedRepositories>>,
391 lookups: AtomicU64,
392}
393
394impl RepositoryCache {
395 #[must_use]
398 pub fn new(
399 directory: Arc<dyn RepositoryDirectory>,
400 clock: Arc<dyn Clock>,
401 poll: RefreshInterval,
402 ) -> Self {
403 let ttl = Duration::from_secs(u64::from(poll.as_secs()))
404 .saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
405 Self {
406 directory,
407 clock,
408 ttl,
409 entries: Mutex::new(BTreeMap::new()),
410 lookups: AtomicU64::new(0),
411 }
412 }
413
414 #[must_use]
416 pub const fn ttl(&self) -> Duration {
417 self.ttl
418 }
419
420 #[must_use]
425 pub fn lookups(&self) -> u64 {
426 self.lookups.load(Ordering::SeqCst)
427 }
428
429 pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
435 match target {
436 ScaleTarget::Repository(repository) => {
437 Ok(ActivityScope::repository(repository.clone()))
438 }
439 ScaleTarget::Organization(org) => {
440 let repositories = self.repositories_of(org).await?;
441 Ok(ActivityScope::organization(org.clone(), repositories))
442 }
443 }
444 }
445
446 async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
447 let now = self.clock.now();
448 if let Some(fresh) = self.fresh_entry(org, now) {
449 return Ok(fresh);
450 }
451
452 let repositories = self.directory.repositories(org).await?;
459 self.lookups.fetch_add(1, Ordering::SeqCst);
460 self.store(org.clone(), repositories.clone(), now);
461 Ok(repositories)
462 }
463
464 fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
465 let entries = self.entries.lock().ok()?;
466 let entry = entries.get(org)?;
467 let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
468 (age < self.ttl).then(|| entry.repositories.clone())
469 }
470
471 fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
472 if let Ok(mut entries) = self.entries.lock() {
473 entries.insert(
474 org,
475 CachedRepositories {
476 repositories,
477 fetched_at,
478 },
479 );
480 }
481 }
482}
483
484pub trait Jitter: fmt::Debug + Send + Sync {
495 fn fraction(&self) -> f64;
498}
499
500#[derive(Debug, Clone, Copy, Default)]
502pub struct RandomJitter;
503
504impl Jitter for RandomJitter {
505 fn fraction(&self) -> f64 {
506 rand::random::<f64>()
507 }
508}
509
510#[derive(Debug, Clone, Copy)]
512pub struct FixedJitter(pub f64);
513
514impl Jitter for FixedJitter {
515 fn fraction(&self) -> f64 {
516 self.0
517 }
518}
519
520#[derive(Debug, Clone, Copy, Default)]
522pub struct NoJitter;
523
524impl Jitter for NoJitter {
525 fn fraction(&self) -> f64 {
526 0.0
527 }
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541pub enum PollPace {
542 Nominal,
544 Idle,
547 Stretched { pressure: PrimaryPressure },
552 RateLimited { kind: RateLimitKind },
554 LockedOut,
556 Offline { consecutive: u32 },
559 Blocked,
564}
565
566impl PollPace {
567 #[must_use]
569 pub const fn is_throttled(&self) -> bool {
570 !matches!(self, Self::Nominal | Self::Idle)
571 }
572
573 #[must_use]
575 pub const fn as_str(&self) -> &'static str {
576 match self {
577 Self::Nominal => "nominal",
578 Self::Idle => "idle",
579 Self::Stretched {
580 pressure: PrimaryPressure::Normal,
581 } => "nominal",
582 Self::Stretched {
583 pressure: PrimaryPressure::Low,
584 } => "stretched_low_budget",
585 Self::Stretched {
586 pressure: PrimaryPressure::Critical,
587 } => "stretched_critical_budget",
588 Self::RateLimited {
589 kind: RateLimitKind::Primary,
590 } => "rate_limited_primary",
591 Self::RateLimited {
592 kind: RateLimitKind::Secondary,
593 } => "rate_limited_secondary",
594 Self::LockedOut => "locked_out",
595 Self::Offline { .. } => "offline",
596 Self::Blocked => "blocked",
597 }
598 }
599}
600
601impl fmt::Display for PollPace {
602 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
603 f.write_str(self.as_str())
604 }
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub struct NextPoll {
610 pub delay: Duration,
611 pub pace: PollPace,
612}
613
614#[derive(Debug, Clone)]
634pub struct PollSchedule {
635 interval: RefreshInterval,
636 idle: Duration,
639 consecutive_offline: u32,
640 offline_since: Option<Timestamp>,
641}
642
643#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
645pub struct PollConditions {
646 pub quiet: bool,
649 pub quota: Option<RateLimitSnapshot>,
651 pub requests_per_pass: u32,
655}
656
657impl PollSchedule {
658 #[must_use]
659 pub const fn new(interval: RefreshInterval) -> Self {
660 Self {
661 interval,
662 idle: Duration::from_secs(interval.as_secs() as u64),
663 consecutive_offline: 0,
664 offline_since: None,
665 }
666 }
667
668 #[must_use]
673 pub fn with_idle(mut self, idle: Duration) -> Self {
674 self.set_intervals(self.interval, idle);
675 self
676 }
677
678 pub fn set_intervals(&mut self, interval: RefreshInterval, idle: Duration) {
680 self.interval = interval;
681 self.idle = idle.min(self.nominal()).max(Self::idle_floor());
682 }
683
684 #[must_use]
685 pub const fn interval(&self) -> RefreshInterval {
686 self.interval
687 }
688
689 #[must_use]
691 pub const fn idle(&self) -> Duration {
692 self.idle
693 }
694
695 #[must_use]
698 pub const fn idle_floor() -> Duration {
699 Duration::from_secs(IdlePollInterval::MIN_SECS as u64)
700 }
701
702 #[must_use]
704 pub const fn nominal(&self) -> Duration {
705 Duration::from_secs(self.interval.as_secs() as u64)
706 }
707
708 #[must_use]
710 pub const fn consecutive_offline(&self) -> u32 {
711 self.consecutive_offline
712 }
713
714 #[must_use]
723 pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
724 let since = self.offline_since?;
725 now.signed_duration_since(since).to_std().ok()
726 }
727
728 #[must_use]
730 pub const fn floor() -> Duration {
731 Duration::from_secs(RefreshInterval::MIN_SECS as u64)
732 }
733
734 pub fn next_poll(
742 &mut self,
743 failure: Option<&RefreshState>,
744 now: Timestamp,
745 jitter: &dyn Jitter,
746 ) -> NextPoll {
747 self.next_poll_with(failure, PollConditions::default(), now, jitter)
748 }
749
750 pub fn next_poll_with(
765 &mut self,
766 failure: Option<&RefreshState>,
767 conditions: PollConditions,
768 now: Timestamp,
769 jitter: &dyn Jitter,
770 ) -> NextPoll {
771 let nominal = self.nominal();
772
773 let next = match failure {
774 None => {
775 self.recovered();
776 let pressure = conditions
777 .quota
778 .map_or(PrimaryPressure::Normal, |quota| quota.pressure(now));
779 match (pressure, conditions.quota) {
780 (PrimaryPressure::Low | PrimaryPressure::Critical, Some(quota)) => NextPoll {
781 delay: nominal
782 .saturating_mul(pressure.stretch_factor())
783 .min(quota.reset_in(now).max(nominal)),
784 pace: PollPace::Stretched { pressure },
785 },
786 _ if conditions.quiet && self.idle < nominal => NextPoll {
789 delay: self.idle.max(idle_floor_for(conditions.requests_per_pass)),
790 pace: PollPace::Idle,
791 },
792 _ => NextPoll {
793 delay: nominal,
794 pace: PollPace::Nominal,
795 },
796 }
797 }
798 Some(RefreshState::Offline) => {
799 self.consecutive_offline = self.consecutive_offline.saturating_add(1);
800 self.offline_since.get_or_insert(now);
802 NextPoll {
803 delay: self.offline_delay(nominal, jitter),
804 pace: PollPace::Offline {
805 consecutive: self.consecutive_offline,
806 },
807 }
808 }
809 Some(state @ RefreshState::RateLimited(limit)) => {
810 self.recovered();
811 let wait = retry_floor(state, now).max(nominal);
814 NextPoll {
815 delay: wait.saturating_add(
816 wait.mul_f64(RATE_LIMIT_JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0)),
817 ),
818 pace: PollPace::RateLimited { kind: limit.kind },
819 }
820 }
821 Some(state @ RefreshState::LockedOut { .. }) => {
822 self.recovered();
823 NextPoll {
824 delay: retry_floor(state, now).max(nominal),
825 pace: PollPace::LockedOut,
826 }
827 }
828 Some(_) => {
834 self.recovered();
837 NextPoll {
838 delay: nominal,
839 pace: PollPace::Blocked,
840 }
841 }
842 };
843
844 debug_assert!(
845 next.delay >= Self::floor() || next.pace == PollPace::Idle,
846 "the 30-second floor is a rate-budget constraint and no charged branch may go below it"
847 );
848 debug_assert!(
849 next.delay >= Self::idle_floor(),
850 "the idle floor is a secondary-limit constraint and no branch may go below it"
851 );
852 next
853 }
854
855 fn recovered(&mut self) {
857 self.consecutive_offline = 0;
858 self.offline_since = None;
859 }
860
861 fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
862 let doublings = self
863 .consecutive_offline
864 .saturating_sub(1)
865 .min(MAX_BACKOFF_DOUBLINGS);
866 let grown = nominal.saturating_mul(1_u32 << doublings);
867 let capped = grown.min(MAX_OFFLINE_BACKOFF);
868 let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
874 capped.saturating_add(spread).max(Self::floor())
875 }
876}
877
878fn is_quiet(readings: &BTreeMap<ScaleTarget, PollOutcome>, report: &ReconcileReport) -> bool {
889 !readings.is_empty()
890 && readings
891 .values()
892 .all(|outcome| outcome.reading().is_some_and(QueuedDemand::is_quiet))
893 && report.started == 0
894 && report.host_capacity_unreadable == 0
897 && report.attempts_unreadable == 0
898 && report
902 .allocations
903 .iter()
904 .all(|allocation| allocation.active_owned == 0 && allocation.demand == 0)
905}
906
907fn idle_floor_for(requests_per_pass: u32) -> Duration {
909 Duration::from_secs(
910 u64::from(requests_per_pass) * 60 / u64::from(MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET),
911 )
912}
913
914fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
922 state.retry_delay(now).unwrap_or(PollSchedule::floor())
923}
924
925#[derive(Debug, Clone, Copy, PartialEq, Eq)]
938pub struct OfflineState {
939 pub consecutive: u32,
941 pub retry_in: Duration,
943 pub offline_for: Option<Duration>,
945}
946
947impl OfflineState {
948 #[must_use]
949 pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
950 Self {
951 consecutive,
952 retry_in,
953 offline_for: None,
954 }
955 }
956
957 #[must_use]
958 pub const fn since(mut self, offline_for: Duration) -> Self {
959 self.offline_for = Some(offline_for);
960 self
961 }
962
963 #[must_use]
968 pub fn has_outlasted_the_queue(&self) -> bool {
969 self.offline_for
970 .is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
971 }
972}
973
974impl fmt::Display for OfflineState {
975 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
976 write!(
977 f,
978 "GitHub is unreachable; no new runners are being started and running \
979 runners are left alone. Retrying in {}s",
980 self.retry_in.as_secs()
981 )?;
982 if self.has_outlasted_the_queue() {
983 f.write_str(
984 ". This outage has lasted more than 24 hours, and GitHub cancels a queued \
985 job after 24 hours, so queued work has been lost",
986 )
987 } else {
988 f.write_str(
989 ". GitHub cancels a queued job after 24 hours, so an outage longer than \
990 that loses queued work",
991 )
992 }
993 }
994}
995
996#[derive(Debug, Clone, Copy)]
1002pub struct LaunchRequest<'a> {
1003 pub host: &'a Host,
1004 pub policy: &'a ScalePolicy,
1005 pub(crate) allocation_guard: &'a AllocationGuard,
1011}
1012
1013#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1020#[error("the runner could not be started: {reason}")]
1021pub struct LaunchFailure {
1022 pub reason: FailureReason,
1023}
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028pub struct ReplacementIntent {
1029 pub policy: PolicyId,
1030 pub previous_attempt: AttemptId,
1031 pub operation: &'static str,
1032}
1033
1034impl LaunchFailure {
1035 #[must_use]
1036 pub const fn new(reason: FailureReason) -> Self {
1037 Self { reason }
1038 }
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1044pub enum HostCapacityError {
1045 MissingHost,
1046 JournalUnreadable,
1047}
1048
1049impl HostCapacityError {
1050 #[must_use]
1051 pub const fn reason(self) -> &'static str {
1052 match self {
1053 Self::MissingHost => "host_missing",
1054 Self::JournalUnreadable => "host_journal_unreadable",
1055 }
1056 }
1057}
1058
1059#[derive(Debug)]
1060enum AllocationFailure {
1061 HostCapacity(HostCapacityError),
1062 Attempts(LaunchFailure),
1063}
1064
1065#[async_trait::async_trait]
1102pub trait RunnerLauncher: fmt::Debug + Send + Sync {
1103 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError>;
1107
1108 async fn supervise(
1113 &self,
1114 _policy: &ScalePolicy,
1115 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1116 Ok(Vec::new())
1117 }
1118
1119 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
1132
1133 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
1184
1185 async fn finish_launch(
1197 &self,
1198 _policy: &ScalePolicy,
1199 attempt: RunnerAttempt,
1200 _lock_wait: Duration,
1201 ) -> Result<RunnerAttempt, LaunchFailure> {
1202 Ok(attempt)
1203 }
1204
1205 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
1213}
1214
1215#[derive(Debug)]
1226pub struct FitnessGatedLauncher {
1227 inner: Arc<dyn RunnerLauncher>,
1228 unfit: Arc<AtomicBool>,
1229}
1230
1231impl FitnessGatedLauncher {
1232 #[must_use]
1233 pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
1234 Self { inner, unfit }
1235 }
1236}
1237
1238#[async_trait::async_trait]
1239impl RunnerLauncher for FitnessGatedLauncher {
1240 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
1241 self.inner.host_capacity(host).await
1242 }
1243
1244 async fn supervise(
1245 &self,
1246 policy: &ScalePolicy,
1247 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1248 self.inner.supervise(policy).await
1249 }
1250
1251 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
1252 self.inner.attempts().await
1253 }
1254
1255 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
1256 if self.unfit.load(Ordering::Acquire) {
1257 return Err(LaunchFailure::new(FailureReason::HostUnfit));
1258 }
1259 self.inner.launch(request).await
1260 }
1261
1262 async fn finish_launch(
1263 &self,
1264 policy: &ScalePolicy,
1265 attempt: RunnerAttempt,
1266 lock_wait: Duration,
1267 ) -> Result<RunnerAttempt, LaunchFailure> {
1268 self.inner.finish_launch(policy, attempt, lock_wait).await
1269 }
1270
1271 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
1272 self.inner.clean(attempt).await
1273 }
1274}
1275
1276pub struct AllocationGuard {
1286 _held: Box<dyn std::any::Any + Send + Sync>,
1287 launch_hold: LaunchHold,
1288}
1289
1290impl AllocationGuard {
1291 #[must_use]
1293 fn new<T: Send + Sync + 'static>(held: T) -> Self {
1294 Self {
1295 _held: Box::new(held),
1296 launch_hold: LaunchHold::default(),
1297 }
1298 }
1299
1300 fn launch_hold(&self) -> LaunchHold {
1302 self.launch_hold.clone()
1303 }
1304}
1305
1306#[derive(Clone, Default)]
1311struct LaunchHold {
1312 _held: Option<Arc<dyn std::any::Any + Send + Sync>>,
1313}
1314
1315impl fmt::Debug for AllocationGuard {
1316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1317 f.write_str("AllocationGuard")
1318 }
1319}
1320
1321#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1323#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1324pub struct AllocationLockBusy;
1325
1326#[async_trait::async_trait]
1340pub trait AllocationLock: fmt::Debug + Send + Sync {
1341 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1347}
1348
1349#[derive(Debug, Default)]
1357pub struct InProcessAllocationLock {
1358 mutex: Arc<tokio::sync::Mutex<()>>,
1359}
1360
1361impl InProcessAllocationLock {
1362 #[must_use]
1363 pub fn new() -> Self {
1364 Self::default()
1365 }
1366}
1367
1368#[async_trait::async_trait]
1369impl AllocationLock for InProcessAllocationLock {
1370 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1371 let mutex = Arc::clone(&self.mutex);
1372 let guard = mutex.lock_owned().await;
1373 Ok(AllocationGuard::new(guard))
1374 }
1375}
1376
1377#[derive(Debug, Clone)]
1392pub struct FileAllocationLock {
1393 paths: Arc<runner_manager_platform::paths::AppPaths>,
1394 wait: Duration,
1395}
1396
1397impl FileAllocationLock {
1398 #[must_use]
1399 pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1400 Self {
1401 paths,
1402 wait: ALLOCATION_LOCK_WAIT,
1403 }
1404 }
1405
1406 #[must_use]
1407 pub const fn with_wait(mut self, wait: Duration) -> Self {
1408 self.wait = wait;
1409 self
1410 }
1411}
1412
1413#[async_trait::async_trait]
1414impl AllocationLock for FileAllocationLock {
1415 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1416 use runner_manager_platform::lock::{HostLock, LockKind};
1417
1418 let paths = Arc::clone(&self.paths);
1419 let wait = self.wait;
1420 let held = tokio::task::spawn_blocking(move || {
1421 HostLock::acquire(&paths, LockKind::Allocation, wait)
1422 })
1423 .await;
1424
1425 match held {
1426 Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1427 Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1431 }
1432 }
1433}
1434
1435#[derive(Debug)]
1463pub struct WslRecoveryAllocationLock {
1464 paths: Arc<runner_manager_platform::paths::AppPaths>,
1465 inner: Arc<dyn AllocationLock>,
1466 blocked_after_refusals: u32,
1467 blocked_after: Duration,
1468 streak_gap: Duration,
1470 in_flight: Arc<std::sync::atomic::AtomicUsize>,
1471 claim: tokio::sync::Mutex<std::sync::Weak<LaunchFenceGuard>>,
1473 refusals: Mutex<Refusals>,
1474}
1475
1476#[derive(Debug, Default)]
1478struct Refusals {
1479 streak: Option<Streak>,
1482 recorded: bool,
1483}
1484
1485#[derive(Debug, Clone, Copy)]
1486struct Streak {
1487 since: chrono::DateTime<chrono::Utc>,
1488 latest: chrono::DateTime<chrono::Utc>,
1489 count: u32,
1490}
1491
1492struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
1494
1495impl InFlight {
1496 fn enter(count: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
1497 count.fetch_add(1, Ordering::SeqCst);
1498 Self(Arc::clone(count))
1499 }
1500}
1501
1502impl Drop for InFlight {
1503 fn drop(&mut self) {
1504 self.0.fetch_sub(1, Ordering::SeqCst);
1505 }
1506}
1507
1508struct LaunchFenceGuard(Option<runner_manager_platform::wsl::fence::GuestLaunchClaim>);
1512
1513impl Drop for LaunchFenceGuard {
1514 fn drop(&mut self) {
1515 if let Some(claim) = self.0.take()
1516 && let Err(error) = claim.release()
1517 {
1518 tracing::warn!(
1519 %error,
1520 "the WSL launch fence could not be released; the next launch reclaims it"
1521 );
1522 }
1523 }
1524}
1525
1526impl WslRecoveryAllocationLock {
1527 #[must_use]
1528 pub fn new(
1529 paths: Arc<runner_manager_platform::paths::AppPaths>,
1530 inner: Arc<dyn AllocationLock>,
1531 ) -> Self {
1532 use runner_manager_platform::launch_health::{BLOCKED_AFTER, BLOCKED_AFTER_DEFERRALS};
1533 Self {
1534 paths,
1535 inner,
1536 blocked_after_refusals: BLOCKED_AFTER_DEFERRALS,
1537 blocked_after: BLOCKED_AFTER,
1538 streak_gap: BLOCKED_AFTER,
1539 in_flight: Arc::default(),
1540 claim: tokio::sync::Mutex::default(),
1541 refusals: Mutex::default(),
1542 }
1543 }
1544
1545 #[cfg(test)]
1548 const fn with_blocked_after(mut self, refusals: u32, after: Duration) -> Self {
1549 self.blocked_after_refusals = refusals;
1550 self.blocked_after = after;
1551 self
1552 }
1553
1554 #[cfg(test)]
1556 const fn with_streak_gap(mut self, gap: Duration) -> Self {
1557 self.streak_gap = gap;
1558 self
1559 }
1560
1561 fn refusals(&self) -> std::sync::MutexGuard<'_, Refusals> {
1562 self.refusals
1563 .lock()
1564 .unwrap_or_else(std::sync::PoisonError::into_inner)
1565 }
1566
1567 fn note_granted(&self) {
1568 let mut refusals = self.refusals();
1569 refusals.streak = None;
1570 if refusals.recorded {
1571 match runner_manager_platform::launch_health::clear_launches_blocked(&self.paths) {
1572 Ok(()) => refusals.recorded = false,
1573 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be cleared"),
1575 }
1576 }
1577 }
1578
1579 fn note_refused(&self, cause: &runner_manager_platform::launch_health::BlockCause) {
1580 use runner_manager_platform::launch_health::{Viewpoint, record_launches_blocked};
1581 use runner_manager_platform::wsl::fence::elapsed_at_least;
1582
1583 if self.in_flight.load(Ordering::SeqCst) > 0 {
1584 return;
1585 }
1586 let now = chrono::Utc::now();
1587 let mut refusals = self.refusals();
1588 let streak = match refusals.streak {
1591 Some(streak) if !elapsed_at_least(streak.latest, now, self.streak_gap) => Streak {
1592 latest: now,
1593 count: streak.count.saturating_add(1),
1594 ..streak
1595 },
1596 _ => Streak {
1597 since: now,
1598 latest: now,
1599 count: 1,
1600 },
1601 };
1602 refusals.streak = Some(streak);
1603 if streak.count < self.blocked_after_refusals
1604 || !elapsed_at_least(streak.since, now, self.blocked_after)
1605 {
1606 return;
1607 }
1608 let blocked = cause.blocked_since(streak.since, Viewpoint::Guest);
1609 if !refusals.recorded {
1610 tracing::warn!(
1611 refusals = streak.count,
1612 "runner launches are blocked {blocked}: {}",
1613 blocked.remedy
1614 );
1615 }
1616 match record_launches_blocked(&self.paths, &blocked, now) {
1619 Ok(()) => refusals.recorded = true,
1620 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be written"),
1621 }
1622 }
1623}
1624
1625fn claim_guest_fence(
1627 root: &std::path::Path,
1628) -> Result<Arc<LaunchFenceGuard>, runner_manager_platform::launch_health::BlockCause> {
1629 use runner_manager_platform::launch_health::BlockCause;
1630 use runner_manager_platform::wsl::fence::{
1631 FENCE_DIRECTORY, FenceClaim, GuestClaimAttempt, try_claim_guest_launch,
1632 };
1633
1634 match try_claim_guest_launch(root) {
1635 Ok(GuestClaimAttempt::Claimed { claim, reclaimed }) => {
1636 if let Some(reclaimed) = reclaimed {
1637 tracing::warn!(
1638 shared_root = %root.display(),
1639 %reclaimed,
1640 "reclaimed a WSL launch fence claim whose owner is gone"
1641 );
1642 }
1643 Ok(Arc::new(LaunchFenceGuard(Some(claim))))
1646 }
1647 Ok(GuestClaimAttempt::Busy) => {
1648 let directory = root.join(FENCE_DIRECTORY);
1649 Err(match FenceClaim::owner(root) {
1650 Ok(owner) => BlockCause::FenceHeld { directory, owner },
1651 Err(error) => BlockCause::FenceOwnerUnreadable {
1652 directory,
1653 detail: error.to_string(),
1654 },
1655 })
1656 }
1657 Err(error) => {
1658 Err(match FenceClaim::owner(root) {
1661 Err(owner_error) => BlockCause::FenceOwnerUnreadable {
1662 directory: root.join(FENCE_DIRECTORY),
1663 detail: owner_error.to_string(),
1664 },
1665 Ok(_) => BlockCause::FenceUnusable {
1666 detail: error.to_string(),
1667 },
1668 })
1669 }
1670 }
1671}
1672
1673#[async_trait::async_trait]
1674impl AllocationLock for WslRecoveryAllocationLock {
1675 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1676 use runner_manager_platform::launch_health::BlockCause;
1677 use runner_manager_platform::wsl::fence::{DrainRequest, GuestRecoveryConfig};
1678
1679 let paths = Arc::clone(&self.paths);
1680 let mut shared = self.claim.lock().await;
1681 let held = shared.upgrade();
1682 let claim = tokio::task::spawn_blocking(move || {
1683 let unusable = |error: &dyn fmt::Display| BlockCause::FenceUnusable {
1684 detail: error.to_string(),
1685 };
1686 let config = match GuestRecoveryConfig::read(&paths) {
1687 Ok(Some(config)) => config,
1688 Ok(None) => return Ok(Arc::new(LaunchFenceGuard(None))),
1689 Err(error) => return Err(unusable(&error)),
1690 };
1691 let root = config.shared_root;
1692 let claim = match held {
1693 Some(held) => {
1695 if let Some(Err(error)) = held.0.as_ref().map(|claim| claim.renew()) {
1696 tracing::warn!(%error, "the WSL launch fence's age could not be renewed");
1697 }
1698 held
1699 }
1700 None => claim_guest_fence(&root)?,
1701 };
1702 match DrainRequest::read(&root) {
1703 Ok(None) => Ok(claim),
1704 Ok(Some(_)) => Err(BlockCause::DrainRequested { root }),
1705 Err(error) => Err(unusable(&error)),
1706 }
1707 })
1708 .await
1709 .map_err(|_| AllocationLockBusy)?;
1710
1711 let claim = match claim {
1712 Ok(claim) => claim,
1713 Err(cause) => {
1714 drop(shared);
1715 self.note_refused(&cause);
1716 return Err(AllocationLockBusy);
1717 }
1718 };
1719 if claim.0.is_some() {
1720 *shared = Arc::downgrade(&claim);
1721 }
1722 drop(shared);
1723 let Ok(inner) = self.inner.acquire().await else {
1724 self.note_refused(&BlockCause::AllocationLockBusy {
1725 path: self
1726 .paths
1727 .state_dir()
1728 .join(runner_manager_platform::lock::LockKind::Allocation.file_name()),
1729 });
1730 return Err(AllocationLockBusy);
1731 };
1732 self.note_granted();
1733 let in_flight = InFlight::enter(&self.in_flight);
1734 let mut guard = AllocationGuard::new(inner);
1735 guard.launch_hold = LaunchHold {
1736 _held: Some(Arc::new((claim, in_flight))),
1737 };
1738 Ok(guard)
1739 }
1740}
1741
1742#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1752pub enum OutcomeKind {
1753 CompletedJob,
1754 IdleExit,
1755 Failed,
1756 Orphaned,
1757}
1758
1759impl OutcomeKind {
1760 #[must_use]
1761 pub const fn of(outcome: &AttemptOutcome) -> Self {
1762 match outcome {
1763 AttemptOutcome::CompletedJob => Self::CompletedJob,
1764 AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1765 AttemptOutcome::Failed { .. } => Self::Failed,
1766 AttemptOutcome::Orphaned => Self::Orphaned,
1767 }
1768 }
1769
1770 #[must_use]
1771 pub const fn is_failure(&self) -> bool {
1772 matches!(self, Self::Failed | Self::Orphaned)
1773 }
1774
1775 #[must_use]
1776 pub const fn as_str(&self) -> &'static str {
1777 match self {
1778 Self::CompletedJob => "completed_job",
1779 Self::IdleExit => "exited_idle_without_work",
1780 Self::Failed => "failed",
1781 Self::Orphaned => "orphaned",
1782 }
1783 }
1784}
1785
1786impl fmt::Display for OutcomeKind {
1787 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1788 f.write_str(self.as_str())
1789 }
1790}
1791
1792#[must_use]
1801pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1802 match reason {
1803 FailureReason::JitRequestFailed => "jit_request_failed",
1804 FailureReason::JitExpired => "jit_expired",
1805 FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1806 FailureReason::RunnerVersionRejected => "runner_version_rejected",
1807 FailureReason::ProcessStartFailed => "process_start_failed",
1808 FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1809 FailureReason::RegistrationTimedOut => "registration_timed_out",
1810 FailureReason::TerminatedAfterRegistrationTimeout => {
1811 "terminated_after_registration_timeout"
1812 }
1813 FailureReason::IsolationProvider(category) => match category {
1814 IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1815 IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1816 IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1817 IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1818 "isolation_image_unavailable"
1819 }
1820 IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1821 IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1822 IsolationProviderFailure::RuntimeOperationFailed => {
1823 "isolation_runtime_operation_failed"
1824 }
1825 IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1826 IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1827 IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1828 },
1829 FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1830 FailureReason::HostUnfit => "host_unfit",
1831 FailureReason::Other(_) => "other",
1832 }
1833}
1834
1835#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1837pub enum RoutingRefusal {
1838 OverlappingProfiles,
1839}
1840
1841impl RoutingRefusal {
1842 #[must_use]
1843 pub const fn as_str(self) -> &'static str {
1844 match self {
1845 Self::OverlappingProfiles => "overlapping_profile_matches",
1846 }
1847 }
1848}
1849
1850impl fmt::Display for RoutingRefusal {
1851 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1852 f.write_str(match self {
1853 Self::OverlappingProfiles => {
1854 "repair overlapping profile labels or require a unique profile selector"
1855 }
1856 })
1857 }
1858}
1859
1860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1870pub enum LifecycleEvent {
1871 DemandObserved {
1873 policy: PolicyId,
1874 demand: u32,
1876 not_matched: u32,
1882 unresolvable: u32,
1891 complete: bool,
1893 },
1894 RoutingRefused {
1896 policy: PolicyId,
1897 reason: RoutingRefusal,
1898 count: u32,
1899 },
1900 TargetUnreadable {
1902 policy: PolicyId,
1903 reason: &'static str,
1904 },
1905 Allocated {
1907 policy: PolicyId,
1908 demand: u32,
1909 desired: u16,
1910 active_owned: u16,
1911 headroom: u16,
1912 to_start: u16,
1913 limiting: LimitingFactor,
1914 },
1915 MonitorOnlySkipped { policy: PolicyId },
1918 RunnerStarted {
1920 policy: PolicyId,
1921 attempt: AttemptId,
1922 },
1923 RunnerStartFailed {
1925 policy: PolicyId,
1926 reason: &'static str,
1927 },
1928 AllocationDeferred { policy: PolicyId, count: u16 },
1931 AttemptsUnreadable { reason: &'static str },
1937 HostCapacityUnreadable { reason: HostCapacityError },
1939 AttemptCleaned {
1941 policy: PolicyId,
1942 attempt: AttemptId,
1943 outcome: OutcomeKind,
1944 },
1945 AttemptCleanFailed {
1948 policy: PolicyId,
1949 attempt: AttemptId,
1950 reason: &'static str,
1951 },
1952 ScaleDownRefused {
1954 policy: PolicyId,
1955 attempt: AttemptId,
1956 },
1957 PollScheduled { retry_in_ms: u64, pace: PollPace },
1959}
1960
1961impl LifecycleEvent {
1962 #[must_use]
1964 pub const fn name(&self) -> &'static str {
1965 match self {
1966 Self::DemandObserved { .. } => "demand_observed",
1967 Self::RoutingRefused { .. } => "routing_refused",
1968 Self::TargetUnreadable { .. } => "target_unreadable",
1969 Self::Allocated { .. } => "allocated",
1970 Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1971 Self::RunnerStarted { .. } => "runner_started",
1972 Self::RunnerStartFailed { .. } => "runner_start_failed",
1973 Self::AllocationDeferred { .. } => "allocation_deferred",
1974 Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1975 Self::HostCapacityUnreadable { .. } => "host_capacity_unreadable",
1976 Self::AttemptCleaned { .. } => "attempt_cleaned",
1977 Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1978 Self::ScaleDownRefused { .. } => "scale_down_refused",
1979 Self::PollScheduled { .. } => "poll_scheduled",
1980 }
1981 }
1982
1983 #[must_use]
1985 pub const fn policy(&self) -> Option<PolicyId> {
1986 match self {
1987 Self::DemandObserved { policy, .. }
1988 | Self::RoutingRefused { policy, .. }
1989 | Self::TargetUnreadable { policy, .. }
1990 | Self::Allocated { policy, .. }
1991 | Self::MonitorOnlySkipped { policy }
1992 | Self::RunnerStarted { policy, .. }
1993 | Self::RunnerStartFailed { policy, .. }
1994 | Self::AllocationDeferred { policy, .. }
1995 | Self::AttemptCleaned { policy, .. }
1996 | Self::AttemptCleanFailed { policy, .. }
1997 | Self::ScaleDownRefused { policy, .. } => Some(*policy),
1998 Self::PollScheduled { .. }
1999 | Self::AttemptsUnreadable { .. }
2000 | Self::HostCapacityUnreadable { .. } => None,
2001 }
2002 }
2003}
2004
2005impl fmt::Display for LifecycleEvent {
2006 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2007 match self {
2008 Self::DemandObserved {
2009 policy,
2010 demand,
2011 not_matched,
2012 unresolvable,
2013 complete,
2014 } => write!(
2015 f,
2016 "policy {policy}: {demand} queued jobs for this host{}{}{}",
2017 if *not_matched == 0 {
2018 String::new()
2019 } else {
2020 format!(", {not_matched} for other labels")
2021 },
2022 if *unresolvable == 0 {
2023 String::new()
2024 } else {
2025 format!(", {unresolvable} with an unresolvable `runs-on`")
2026 },
2027 if *complete {
2028 ""
2029 } else {
2030 " (a floor, not a total)"
2031 }
2032 ),
2033 Self::TargetUnreadable { policy, reason } => {
2034 write!(f, "policy {policy}: target unreadable ({reason})")
2035 }
2036 Self::RoutingRefused {
2037 policy,
2038 reason,
2039 count,
2040 } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
2041 Self::Allocated {
2042 policy,
2043 demand,
2044 desired,
2045 active_owned,
2046 headroom,
2047 to_start,
2048 limiting,
2049 } => write!(
2050 f,
2051 "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
2052 flight, {headroom} free on this host, starting {to_start} ({limiting})"
2053 ),
2054 Self::MonitorOnlySkipped { policy } => {
2055 write!(f, "policy {policy}: monitor-only, skipped")
2056 }
2057 Self::RunnerStarted { policy, attempt } => {
2058 write!(f, "policy {policy}: started attempt {attempt}")
2059 }
2060 Self::RunnerStartFailed { policy, reason } => {
2061 write!(f, "policy {policy}: could not start a runner ({reason})")
2062 }
2063 Self::AllocationDeferred { policy, count } => write!(
2064 f,
2065 "policy {policy}: the allocation lock was held; {count} granted runners \
2066 were not created"
2067 ),
2068 Self::AttemptsUnreadable { reason } => write!(
2069 f,
2070 "the host's attempt set could not be read ({reason}); nothing was started, \
2071 and this is not the same as the host being idle"
2072 ),
2073 Self::HostCapacityUnreadable { reason } => write!(
2074 f,
2075 "the host capacity could not be read ({}); new launches wait for readable host settings",
2076 reason.reason()
2077 ),
2078 Self::AttemptCleaned {
2079 policy,
2080 attempt,
2081 outcome,
2082 } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
2083 Self::AttemptCleanFailed {
2084 policy,
2085 attempt,
2086 reason,
2087 } => write!(
2088 f,
2089 "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
2090 will be retried"
2091 ),
2092 Self::ScaleDownRefused { policy, attempt } => write!(
2093 f,
2094 "policy {policy}: attempt {attempt} is executing a job and was not removed"
2095 ),
2096 Self::PollScheduled { retry_in_ms, pace } => {
2097 write!(f, "next poll in {retry_in_ms}ms ({pace})")
2098 }
2099 }
2100 }
2101}
2102
2103pub trait EventSink: fmt::Debug + Send + Sync {
2105 fn emit(&self, event: LifecycleEvent);
2106}
2107
2108#[derive(Debug, Clone, Copy, Default)]
2110pub struct NoEvents;
2111
2112impl EventSink for NoEvents {
2113 fn emit(&self, _event: LifecycleEvent) {}
2114}
2115
2116#[derive(Debug, Clone, Copy, Default)]
2124pub struct TracingEvents;
2125
2126impl EventSink for TracingEvents {
2127 fn emit(&self, event: LifecycleEvent) {
2128 let name = event.name();
2129 match event {
2130 LifecycleEvent::DemandObserved {
2131 policy,
2132 demand,
2133 not_matched,
2134 unresolvable,
2135 complete,
2136 } => {
2137 if demand == 0 && not_matched == 0 && unresolvable == 0 && complete {
2142 tracing::debug!(
2143 event = name,
2144 policy_id = %policy,
2145 demand,
2146 not_matched,
2147 unresolvable,
2148 count = u64::from(complete),
2149 );
2150 } else {
2151 tracing::info!(
2152 event = name,
2153 policy_id = %policy,
2154 demand,
2155 not_matched,
2156 unresolvable,
2157 count = u64::from(complete),
2158 );
2159 }
2160 }
2180 LifecycleEvent::TargetUnreadable { policy, reason } => {
2181 tracing::warn!(event = name, policy_id = %policy, reason);
2182 }
2183 LifecycleEvent::RoutingRefused {
2184 policy,
2185 reason,
2186 count,
2187 } => tracing::warn!(
2188 event = name,
2189 policy_id = %policy,
2190 reason = reason.as_str(),
2191 count,
2192 "{reason}"
2193 ),
2194 LifecycleEvent::Allocated {
2195 policy,
2196 demand,
2197 desired,
2198 active_owned,
2199 headroom,
2200 to_start,
2201 limiting,
2202 } if demand == 0 && to_start == 0 && active_owned == 0 => tracing::debug!(
2203 event = name,
2206 policy_id = %policy,
2207 demand,
2208 desired,
2209 capacity = active_owned,
2210 headroom,
2211 count = to_start,
2212 reason = %limiting,
2213 ),
2214 LifecycleEvent::Allocated {
2215 policy,
2216 demand,
2217 desired,
2218 active_owned,
2219 headroom,
2220 to_start,
2221 limiting,
2222 } => tracing::info!(
2223 event = name,
2224 policy_id = %policy,
2225 demand,
2226 desired,
2227 capacity = active_owned,
2228 headroom,
2229 count = to_start,
2230 reason = %limiting,
2231 ),
2232 LifecycleEvent::MonitorOnlySkipped { policy } => {
2233 tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
2234 }
2235 LifecycleEvent::RunnerStarted { policy, attempt } => {
2236 tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
2237 }
2238 LifecycleEvent::RunnerStartFailed { policy, reason } => {
2239 tracing::warn!(event = name, policy_id = %policy, reason);
2240 }
2241 LifecycleEvent::AllocationDeferred { policy, count } => {
2242 tracing::warn!(
2248 event = name,
2249 policy_id = %policy,
2250 reason = "allocation_lock_unavailable",
2251 count
2252 );
2253 }
2254 LifecycleEvent::AttemptsUnreadable { reason } => {
2255 tracing::warn!(event = name, reason);
2256 }
2257 LifecycleEvent::HostCapacityUnreadable { reason } => {
2258 tracing::warn!(event = name, reason = reason.reason());
2259 }
2260 LifecycleEvent::AttemptCleaned {
2261 policy,
2262 attempt,
2263 outcome,
2264 } => tracing::info!(
2265 event = name,
2266 policy_id = %policy,
2267 attempt_id = %attempt,
2268 outcome = outcome.as_str(),
2269 ),
2270 LifecycleEvent::AttemptCleanFailed {
2271 policy,
2272 attempt,
2273 reason,
2274 } => tracing::warn!(
2275 event = name,
2276 policy_id = %policy,
2277 attempt_id = %attempt,
2278 reason,
2279 ),
2280 LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
2281 event = name,
2282 policy_id = %policy,
2283 attempt_id = %attempt,
2284 attempt_state = "busy",
2285 ),
2286 LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
2287 if pace == PollPace::Idle {
2288 tracing::debug!(event = name, retry_in_ms, state = pace.as_str());
2289 } else {
2290 tracing::info!(event = name, retry_in_ms, state = pace.as_str());
2291 }
2292 }
2293 }
2294 }
2295}
2296
2297#[derive(Debug, Default)]
2301pub struct EventLog {
2302 events: Mutex<Vec<LifecycleEvent>>,
2303}
2304
2305impl EventLog {
2306 #[must_use]
2307 pub fn new() -> Self {
2308 Self::default()
2309 }
2310
2311 #[must_use]
2312 pub fn events(&self) -> Vec<LifecycleEvent> {
2313 self.events.lock().map(|e| e.clone()).unwrap_or_default()
2314 }
2315
2316 #[must_use]
2318 pub fn count_of(&self, name: &str) -> usize {
2319 self.events()
2320 .iter()
2321 .filter(|event| event.name() == name)
2322 .count()
2323 }
2324}
2325
2326impl EventSink for EventLog {
2327 fn emit(&self, event: LifecycleEvent) {
2328 if let Ok(mut events) = self.events.lock() {
2329 events.push(event);
2330 }
2331 }
2332}
2333
2334#[derive(Debug)]
2337pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
2338
2339impl EventSink for TeeEvents {
2340 fn emit(&self, event: LifecycleEvent) {
2341 self.0.emit(event);
2342 self.1.emit(event);
2343 }
2344}
2345
2346pub trait DemandDirector: fmt::Debug + Send + Sync {
2361 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32>;
2364
2365 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId);
2368
2369 fn start_failed(&self, _policy: &ScalePolicy, _attempt: Option<AttemptId>) {}
2374
2375 fn older_waiting(&self, _policy: &ScalePolicy) -> bool {
2378 false
2379 }
2380}
2381
2382const OLDER_LAUNCH_TURN: Duration = Duration::from_secs(2);
2384const OLDER_LAUNCH_POLL: Duration = Duration::from_millis(100);
2385
2386struct Reserved {
2388 policy: ScalePolicy,
2389 attempt: RunnerAttempt,
2390 lock_wait: Duration,
2391 reserved_at: std::time::Instant,
2392 _hold: LaunchHold,
2394}
2395
2396#[derive(Debug, Default, Clone, Copy)]
2398pub struct OwnDemand;
2399
2400impl DemandDirector for OwnDemand {
2401 fn directed_demand(&self, _policy: &ScalePolicy) -> Option<u32> {
2402 None
2403 }
2404
2405 fn launched(&self, _policy: &ScalePolicy, _attempt: AttemptId) {}
2406}
2407
2408pub struct ReconcilerPorts {
2414 pub demand: Arc<dyn DemandSource>,
2415 pub launcher: Arc<dyn RunnerLauncher>,
2416 pub lock: Arc<dyn AllocationLock>,
2417 pub directory: Arc<dyn RepositoryDirectory>,
2418 pub clock: Arc<dyn Clock>,
2419 pub jitter: Arc<dyn Jitter>,
2420 pub events: Arc<dyn EventSink>,
2421}
2422
2423impl fmt::Debug for ReconcilerPorts {
2424 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2425 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
2426 }
2427}
2428
2429#[derive(Debug, Clone, Default)]
2436pub struct ReconcileReport {
2437 pub allocations: Vec<Allocation>,
2439 pub monitor_only: Vec<PolicyId>,
2441 pub unreadable: Vec<PolicyId>,
2443 pub targets_read: u16,
2451 pub started: u16,
2453 pub replacement_intents: u16,
2456 pub cleaned: u16,
2458 pub idle_exits: u16,
2461 pub failures: u16,
2463 pub deferred: u16,
2472 pub attempts_unreadable: u16,
2491 pub host_capacity_unreadable: u16,
2493 pub clean_failures: u16,
2495 pub failure: Option<RefreshState>,
2497 pub offline: Option<OfflineState>,
2500 pub next_poll: NextPoll,
2502 pub demand_requests: u32,
2504}
2505
2506impl ReconcileReport {
2507 #[must_use]
2534 pub const fn reached_github(&self) -> bool {
2535 self.targets_read > 0
2536 }
2537}
2538
2539impl Default for NextPoll {
2540 fn default() -> Self {
2541 Self {
2542 delay: PollSchedule::floor(),
2543 pace: PollPace::Nominal,
2544 }
2545 }
2546}
2547
2548impl ReconcileReport {
2549 #[must_use]
2551 pub fn is_offline(&self) -> bool {
2552 matches!(self.failure, Some(RefreshState::Offline))
2553 }
2554
2555 #[must_use]
2557 pub const fn offline_state(&self) -> Option<&OfflineState> {
2558 self.offline.as_ref()
2559 }
2560
2561 #[must_use]
2563 pub const fn starts_nothing(&self) -> bool {
2564 self.started == 0
2565 }
2566}
2567
2568#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2570pub struct ScaleDownReport {
2571 pub removed: u16,
2573 pub refused_busy: u16,
2575 pub clean_failures: u16,
2577 pub retained: u16,
2580 pub attempts_unreadable: bool,
2590}
2591
2592impl ScaleDownReport {
2593 #[must_use]
2598 pub const fn is_conclusive(&self) -> bool {
2599 !self.attempts_unreadable
2600 }
2601}
2602
2603const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
2611
2612const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
2614
2615#[derive(Debug, Clone, Copy)]
2617struct CleanRetry {
2618 failures: u32,
2619 not_before: Timestamp,
2620}
2621
2622#[derive(Debug, Default)]
2626pub(crate) struct CleanBackoff {
2627 retries: BTreeMap<AttemptId, CleanRetry>,
2628}
2629
2630impl CleanBackoff {
2631 const SCHEDULE: crate::lifecycle::RetryPolicy =
2633 crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2634
2635 pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2637 self.retries
2638 .get(&attempt)
2639 .is_none_or(|retry| now >= retry.not_before)
2640 }
2641
2642 pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2644 let failures = self
2645 .retries
2646 .get(&attempt)
2647 .map_or(1, |retry| retry.failures.saturating_add(1));
2648 let delay = Self::SCHEDULE.delay(failures);
2649 self.retries.insert(
2650 attempt,
2651 CleanRetry {
2652 failures,
2653 not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2655 },
2656 );
2657 }
2658
2659 pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2661 self.retries.remove(&attempt);
2662 }
2663
2664 fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2667 self.retries.retain(|attempt, _| present.contains(attempt));
2668 }
2669}
2670
2671#[derive(Debug)]
2677pub struct Reconciler {
2678 host: Host,
2679 demand: Arc<dyn DemandSource>,
2680 launcher: Arc<dyn RunnerLauncher>,
2681 lock: Arc<dyn AllocationLock>,
2682 repositories: RepositoryCache,
2683 clock: Arc<dyn Clock>,
2684 jitter: Arc<dyn Jitter>,
2685 events: Arc<dyn EventSink>,
2686 schedule: PollSchedule,
2687 clean_backoff: CleanBackoff,
2688 director: Arc<dyn DemandDirector>,
2689}
2690
2691impl Reconciler {
2692 #[must_use]
2694 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2695 let interval = host.refresh_interval;
2696 let repositories = RepositoryCache::new(
2697 Arc::clone(&ports.directory),
2698 Arc::clone(&ports.clock),
2699 interval,
2700 );
2701 Self {
2702 host,
2703 demand: ports.demand,
2704 launcher: ports.launcher,
2705 lock: ports.lock,
2706 repositories,
2707 clock: ports.clock,
2708 jitter: ports.jitter,
2709 events: ports.events,
2710 schedule: PollSchedule::new(interval),
2711 clean_backoff: CleanBackoff::default(),
2712 director: Arc::new(OwnDemand),
2713 }
2714 }
2715
2716 #[must_use]
2719 pub fn with_director(mut self, director: Arc<dyn DemandDirector>) -> Self {
2720 self.director = director;
2721 self
2722 }
2723
2724 #[must_use]
2725 pub const fn host(&self) -> &Host {
2726 &self.host
2727 }
2728
2729 #[must_use]
2730 pub const fn schedule(&self) -> &PollSchedule {
2731 &self.schedule
2732 }
2733
2734 pub fn set_intervals(&mut self, active: RefreshInterval, idle: Duration) {
2736 self.host.refresh_interval = active;
2737 self.schedule.set_intervals(active, idle);
2738 }
2739
2740 #[must_use]
2742 pub const fn repositories(&self) -> &RepositoryCache {
2743 &self.repositories
2744 }
2745
2746 pub async fn start_directed(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2754 let mut report = ReconcileReport::default();
2755 let mut reserved: Vec<Reserved> = Vec::new();
2756 for policy in policies {
2757 if !policy.owns_runners()
2758 || !policy.is_owned_by(self.host.id)
2759 || !policy.may_start_runners()
2760 {
2761 continue;
2762 }
2763 if let Some(directed) = self.director.directed_demand(policy) {
2764 self.start_runners(policy, directed, &mut report, &mut reserved)
2765 .await;
2766 }
2767 }
2768 self.finish_reserved(reserved, &mut report).await;
2769 report
2770 }
2771
2772 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2786 let mut report = ReconcileReport::default();
2787 let mut reserved: Vec<Reserved> = Vec::new();
2791
2792 let mut pollable: Vec<&ScalePolicy> = Vec::new();
2794 let mut supervision_failed = BTreeSet::new();
2795 for policy in policies {
2796 if !policy.owns_runners() {
2797 report.monitor_only.push(policy.id);
2798 self.events
2799 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2800 continue;
2801 }
2802 if !policy.is_owned_by(self.host.id) {
2803 continue;
2807 }
2808 match self.launcher.supervise(policy).await {
2809 Ok(intents) => {
2810 report.replacement_intents = report
2811 .replacement_intents
2812 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
2813 }
2814 Err(failure) => {
2815 supervision_failed.insert(policy.id);
2816 self.report_unreadable_attempts(&mut report, &failure);
2817 continue;
2818 }
2819 }
2820 if !policy.may_start_runners() {
2821 continue;
2822 }
2823 pollable.push(policy);
2824 }
2825
2826 let readings = self.poll_targets(&pollable, &mut report).await;
2827 report.targets_read = u16::try_from(
2828 readings
2829 .values()
2830 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
2831 .count(),
2832 )
2833 .unwrap_or(u16::MAX);
2834 let mut routed = BTreeMap::new();
2835 for (target, outcome) in &readings {
2836 let PollOutcome::Ready(reading) = outcome else {
2837 continue;
2838 };
2839 let profiles: Vec<&ScalePolicy> = pollable
2840 .iter()
2841 .copied()
2842 .filter(|policy| &policy.target == target)
2843 .collect();
2844 let (tallies, refusals) = route_demand(policies, &profiles, reading);
2845 if let Some(first) = profiles.first() {
2846 for (reason, count) in refusals {
2847 self.events.emit(LifecycleEvent::RoutingRefused {
2848 policy: first.id,
2849 reason,
2850 count,
2851 });
2852 }
2853 }
2854 routed.insert(target.clone(), tallies);
2855 }
2856
2857 self.clean_terminal_attempts(&mut report).await;
2867
2868 for policy in policies {
2883 if !policy.owns_runners() {
2884 continue;
2890 }
2891 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2892 match self.allocate_only(policy, 0, &reserved).await {
2896 Ok(allocation) => {
2897 self.emit_allocation(&allocation);
2898 report.allocations.push(allocation);
2899 }
2900 Err(failure) => self.report_allocation_failure(&mut report, &failure),
2901 }
2902 continue;
2903 }
2904 if supervision_failed.contains(&policy.id) {
2905 continue;
2906 }
2907 if let Some(directed) = self.director.directed_demand(policy) {
2911 self.start_runners(policy, directed, &mut report, &mut reserved)
2912 .await;
2913 continue;
2914 }
2915 let Some(reading) = readings.get(&policy.target) else {
2916 debug_assert!(false, "a pollable policy's target has no reading");
2919 continue;
2920 };
2921 match reading {
2922 PollOutcome::Failed(state) => {
2923 report.unreadable.push(policy.id);
2924 self.events.emit(LifecycleEvent::TargetUnreadable {
2925 policy: policy.id,
2926 reason: unreadable_reason(state),
2927 });
2928 }
2929 PollOutcome::Ready(demand) => {
2930 let tally = routed
2931 .get(&policy.target)
2932 .and_then(|tallies| tallies.get(&policy.id))
2933 .expect("every pollable profile has a routed tally");
2934 let count = tally.demand();
2935 self.events.emit(LifecycleEvent::DemandObserved {
2936 policy: policy.id,
2937 demand: count,
2938 not_matched: tally.not_matched,
2939 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2940 complete: demand.is_complete(),
2941 });
2942 self.start_runners(policy, count, &mut report, &mut reserved)
2943 .await;
2944 }
2945 }
2946 }
2947 self.finish_reserved(reserved, &mut report).await;
2948
2949 let failure = readings
2951 .values()
2952 .filter_map(PollOutcome::failure)
2953 .max_by_key(|state| severity(state))
2954 .cloned();
2955 let now = self.clock.now();
2956 let conditions = PollConditions {
2957 quiet: is_quiet(&readings, &report),
2958 requests_per_pass: report.demand_requests,
2959 quota: readings
2960 .values()
2961 .filter_map(PollOutcome::reading)
2962 .filter_map(QueuedDemand::rate_limit)
2963 .max_by_key(|quota| quota.observed_at)
2964 .copied(),
2965 };
2966 report.next_poll =
2967 self.schedule
2968 .next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
2969 report.failure = failure;
2970 if let PollPace::Offline { consecutive } = report.next_poll.pace {
2974 let state = OfflineState::new(consecutive, report.next_poll.delay);
2975 report.offline = Some(match self.schedule.offline_for(now) {
2976 Some(elapsed) => state.since(elapsed),
2977 None => state,
2978 });
2979 }
2980 self.events.emit(LifecycleEvent::PollScheduled {
2981 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2982 pace: report.next_poll.pace,
2983 });
2984
2985 report
2986 }
2987
2988 async fn poll_targets(
2996 &self,
2997 pollable: &[&ScalePolicy],
2998 report: &mut ReconcileReport,
2999 ) -> BTreeMap<ScaleTarget, PollOutcome> {
3000 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
3001
3002 let mut readings = BTreeMap::new();
3003 for target in targets {
3004 let outcome = match self.repositories.scope_for(&target).await {
3005 Ok(scope) => {
3006 report.demand_requests = report
3007 .demand_requests
3008 .saturating_add(demand_requests_per_poll(&scope));
3009 self.demand.poll(&scope).await
3010 }
3011 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
3017 };
3018 readings.insert(target, outcome);
3019 }
3020 readings
3021 }
3022
3023 async fn allocate_only(
3041 &self,
3042 policy: &ScalePolicy,
3043 demand: u32,
3044 reserved: &[Reserved],
3045 ) -> Result<Allocation, AllocationFailure> {
3046 let mut host = self.host.clone();
3049 host.host_capacity = self
3050 .launcher
3051 .host_capacity(&self.host)
3052 .await
3053 .map_err(AllocationFailure::HostCapacity)?;
3054 let attempts = self
3055 .host_attempts(reserved)
3056 .await
3057 .map_err(AllocationFailure::Attempts)?;
3058 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
3059 Ok(allocator.allocate(policy, demand))
3060 }
3061
3062 async fn start_runners(
3084 &self,
3085 policy: &ScalePolicy,
3086 demand: u32,
3087 report: &mut ReconcileReport,
3088 reserved: &mut Vec<Reserved>,
3089 ) {
3090 let intent = match self.allocate_only(policy, demand, reserved).await {
3102 Ok(intent) => intent,
3103 Err(failure) => {
3104 self.report_allocation_failure(report, &failure);
3105 self.director.start_failed(policy, None);
3106 return;
3107 }
3108 };
3109
3110 let mut decided: Option<Allocation> = None;
3114 let mut budget = intent.to_start;
3115 let turn_limit = tokio::time::Instant::now() + OLDER_LAUNCH_TURN;
3116
3117 while budget > 0 {
3118 while self.director.older_waiting(policy) && tokio::time::Instant::now() < turn_limit {
3123 tokio::time::sleep(OLDER_LAUNCH_POLL).await;
3124 }
3125 let waiting_since = std::time::Instant::now();
3126 let guard = match self.lock.acquire().await {
3127 Ok(guard) => guard,
3128 Err(_) => {
3129 report.deferred = report.deferred.saturating_add(budget);
3132 self.events.emit(LifecycleEvent::AllocationDeferred {
3133 policy: policy.id,
3134 count: budget,
3135 });
3136 self.director.start_failed(policy, None);
3139 break;
3140 }
3141 };
3142 let lock_wait = waiting_since.elapsed();
3143
3144 let allocation = match self.allocate_only(policy, demand, reserved).await {
3150 Ok(allocation) => allocation,
3151 Err(failure) => {
3152 drop(guard);
3153 self.report_allocation_failure(report, &failure);
3154 self.director.start_failed(policy, None);
3155 break;
3156 }
3157 };
3158
3159 if decided.is_none() {
3160 budget = budget.min(allocation.to_start);
3164 decided = Some(allocation.clone());
3165 }
3166
3167 if allocation.starts_nothing() || budget == 0 {
3170 drop(guard);
3171 break;
3172 }
3173
3174 let created = self
3175 .launcher
3176 .launch(LaunchRequest {
3177 host: &self.host,
3178 policy,
3179 allocation_guard: &guard,
3180 })
3181 .await;
3182 let hold = guard.launch_hold();
3183 drop(guard);
3184
3185 match created {
3186 Ok(attempt) => {
3187 let id = attempt.id;
3188 self.director.launched(policy, id);
3191 budget -= 1;
3192 reserved.push(Reserved {
3196 policy: policy.clone(),
3197 attempt,
3198 lock_wait,
3199 reserved_at: std::time::Instant::now(),
3200 _hold: hold,
3201 });
3202 }
3203 Err(failure) => {
3204 self.note_start_failed(policy, None, &failure);
3205 break;
3206 }
3207 }
3208 }
3209
3210 let allocation = decided.unwrap_or(intent);
3211 self.emit_allocation(&allocation);
3212 report.allocations.push(allocation);
3213 }
3214
3215 async fn finish_reserved(&self, reserved: Vec<Reserved>, report: &mut ReconcileReport) {
3223 let finished = futures::future::join_all(reserved.into_iter().map(|reserved| async move {
3224 let id = reserved.attempt.id;
3225 let waited = reserved.lock_wait + reserved.reserved_at.elapsed();
3226 let result = self
3227 .launcher
3228 .finish_launch(&reserved.policy, reserved.attempt, waited)
3229 .await;
3230 match &result {
3233 Ok(_) => self.events.emit(LifecycleEvent::RunnerStarted {
3234 policy: reserved.policy.id,
3235 attempt: id,
3236 }),
3237 Err(failure) => self.note_start_failed(&reserved.policy, Some(id), failure),
3238 }
3239 result.is_ok()
3240 }))
3241 .await;
3242 let started = finished.into_iter().filter(|started| *started).count();
3243 report.started = report
3244 .started
3245 .saturating_add(u16::try_from(started).unwrap_or(u16::MAX));
3246 }
3247
3248 fn note_start_failed(
3249 &self,
3250 policy: &ScalePolicy,
3251 attempt: Option<AttemptId>,
3252 failure: &LaunchFailure,
3253 ) {
3254 self.events.emit(LifecycleEvent::RunnerStartFailed {
3255 policy: policy.id,
3256 reason: failure_reason_kind(&failure.reason),
3257 });
3258 self.director.start_failed(policy, attempt);
3259 }
3260
3261 async fn host_attempts(
3274 &self,
3275 reserved: &[Reserved],
3276 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3277 let mut attempts = self.launcher.attempts().await?;
3278
3279 debug_assert!(
3286 reserved
3287 .iter()
3288 .map(|reserved| reserved.attempt.id)
3289 .collect::<BTreeSet<AttemptId>>()
3290 .len()
3291 == reserved.len(),
3292 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
3293 the host ceiling is enforced against a set keyed on that identifier, so a \
3294 duplicate is two runtimes counted as one"
3295 );
3296
3297 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
3298 attempts.extend(
3299 reserved
3300 .iter()
3301 .filter(|reserved| !known.contains(&reserved.attempt.id))
3302 .map(|reserved| reserved.attempt.clone()),
3303 );
3304 Ok(attempts)
3305 }
3306
3307 fn report_allocation_failure(&self, report: &mut ReconcileReport, failure: &AllocationFailure) {
3308 match failure {
3309 AllocationFailure::HostCapacity(reason) => {
3310 report.host_capacity_unreadable = report.host_capacity_unreadable.saturating_add(1);
3311 self.events
3312 .emit(LifecycleEvent::HostCapacityUnreadable { reason: *reason });
3313 }
3314 AllocationFailure::Attempts(failure) => {
3315 self.report_unreadable_attempts(report, failure)
3316 }
3317 }
3318 }
3319
3320 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
3327 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
3328 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3335 reason: failure_reason_kind(&failure.reason),
3336 });
3337 }
3338
3339 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
3345 let attempts = match self.launcher.attempts().await {
3346 Ok(attempts) => attempts,
3347 Err(failure) => {
3348 self.report_unreadable_attempts(report, &failure);
3349 return;
3350 }
3351 };
3352 self.clean_backoff
3353 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
3354 let now = self.clock.now();
3355 for attempt in attempts {
3356 if !attempt.state().is_concluded() {
3357 continue;
3358 }
3359 let Some(outcome) = attempt.outcome() else {
3360 continue;
3361 };
3362 if !self.clean_backoff.is_due(attempt.id, now) {
3365 continue;
3366 }
3367 let kind = OutcomeKind::of(outcome);
3368 match self.launcher.clean(attempt.id).await {
3369 Ok(()) => {
3370 self.clean_backoff.succeeded(attempt.id);
3371 report.cleaned = report.cleaned.saturating_add(1);
3372 if kind.is_failure() {
3373 report.failures = report.failures.saturating_add(1);
3374 } else if kind == OutcomeKind::IdleExit {
3375 report.idle_exits = report.idle_exits.saturating_add(1);
3380 }
3381 self.events.emit(LifecycleEvent::AttemptCleaned {
3382 policy: attempt.policy_id,
3383 attempt: attempt.id,
3384 outcome: kind,
3385 });
3386 }
3387 Err(failure) => {
3397 self.clean_backoff.failed(attempt.id, now);
3398 report.clean_failures = report.clean_failures.saturating_add(1);
3399 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3400 policy: attempt.policy_id,
3401 attempt: attempt.id,
3402 reason: failure_reason_kind(&failure.reason),
3403 });
3404 }
3405 }
3406 }
3407 }
3408
3409 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
3417 let mut report = ScaleDownReport::default();
3418 let attempts = match self.launcher.attempts().await {
3419 Ok(attempts) => attempts,
3420 Err(failure) => {
3421 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3426 reason: failure_reason_kind(&failure.reason),
3427 });
3428 report.attempts_unreadable = true;
3429 return report;
3430 }
3431 };
3432 for attempt in attempts {
3433 if attempt.policy_id != policy.id {
3434 continue;
3435 }
3436 match attempt.state() {
3437 AttemptState::Busy => {
3438 report.refused_busy = report.refused_busy.saturating_add(1);
3439 self.events.emit(LifecycleEvent::ScaleDownRefused {
3440 policy: policy.id,
3441 attempt: attempt.id,
3442 });
3443 }
3444 state if state.is_concluded() => {
3445 let kind = attempt
3446 .outcome()
3447 .map_or(OutcomeKind::Failed, OutcomeKind::of);
3448 match self.launcher.clean(attempt.id).await {
3449 Ok(()) => {
3450 report.removed = report.removed.saturating_add(1);
3451 self.events.emit(LifecycleEvent::AttemptCleaned {
3452 policy: policy.id,
3453 attempt: attempt.id,
3454 outcome: kind,
3455 });
3456 }
3457 Err(failure) => {
3458 report.clean_failures = report.clean_failures.saturating_add(1);
3459 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3460 policy: policy.id,
3461 attempt: attempt.id,
3462 reason: failure_reason_kind(&failure.reason),
3463 });
3464 }
3465 }
3466 }
3467 AttemptState::Cleaned => {}
3468 _ => report.retained = report.retained.saturating_add(1),
3471 }
3472 }
3473 report
3474 }
3475
3476 fn emit_allocation(&self, allocation: &Allocation) {
3477 self.events.emit(LifecycleEvent::Allocated {
3478 policy: allocation.policy_id,
3479 demand: allocation.demand,
3480 desired: allocation.desired,
3481 active_owned: allocation.active_owned,
3482 headroom: allocation.headroom_before,
3483 to_start: allocation.to_start,
3484 limiting: allocation.limiting_factor,
3485 });
3486 }
3487}
3488
3489fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
3513 let Some(labels) = policy.routing_labels() else {
3514 debug_assert!(
3515 false,
3516 "a monitor-only policy is skipped before the demand poll (D19)"
3517 );
3518 return DemandTally::default();
3519 };
3520
3521 match &policy.target {
3522 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
3523 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
3524 }
3525}
3526
3527fn route_demand(
3531 all_policies: &[ScalePolicy],
3532 profiles: &[&ScalePolicy],
3533 reading: &QueuedDemand,
3534) -> (
3535 BTreeMap<PolicyId, DemandTally>,
3536 BTreeMap<RoutingRefusal, u32>,
3537) {
3538 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
3539 .iter()
3540 .map(|policy| (policy.id, demand_for(policy, reading)))
3541 .collect();
3542 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
3543 let Some(first) = profiles.first() else {
3544 return (tallies, refusals);
3545 };
3546 let owners: Vec<_> = all_policies
3547 .iter()
3548 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3549 .filter(|policy| policy.owns_runners())
3550 .collect();
3551 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3552 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3553 ScaleTarget::Organization(_) => reading.jobs().collect(),
3554 };
3555 for job in jobs {
3556 let Ok(_) = job.required_labels() else {
3557 continue;
3559 };
3560 let matched: Vec<PolicyId> = owners
3561 .iter()
3562 .filter(|policy| {
3563 policy
3564 .routing_labels()
3565 .is_some_and(|labels| labels.matches(job).is_match())
3566 })
3567 .map(|policy| policy.id)
3568 .collect();
3569 if matched.len() > 1 {
3570 *refusals
3571 .entry(RoutingRefusal::OverlappingProfiles)
3572 .or_default() += 1;
3573 for id in matched {
3574 if let Some(tally) = tallies.get_mut(&id) {
3575 tally.matched -= 1;
3576 tally.not_matched += 1;
3577 }
3578 }
3579 }
3580 }
3581 (tallies, refusals)
3582}
3583
3584const fn severity(state: &RefreshState) -> u8 {
3591 match state {
3592 RefreshState::Offline => 5,
3593 RefreshState::RateLimited(_) => 4,
3594 RefreshState::LockedOut { .. } => 3,
3595 RefreshState::Unauthorized => 2,
3596 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3597 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3598 }
3599}
3600
3601const fn unreadable_reason(state: &RefreshState) -> &'static str {
3612 match state {
3613 RefreshState::Ready(_) => "ready",
3614 RefreshState::Offline => "offline",
3615 RefreshState::RateLimited(_) => "rate_limited",
3616 RefreshState::LockedOut { .. } => "locked_out",
3617 RefreshState::Unauthorized => "unauthorized",
3618 RefreshState::Forbidden { .. } => "forbidden",
3619 RefreshState::Failed { .. } => "failed",
3620 RefreshState::Cancelled => "cancelled",
3621 }
3622}
3623
3624#[cfg(test)]
3625mod tests {
3626 use super::*;
3627
3628 #[test]
3636 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
3637 let mut report = ReconcileReport::default();
3638 assert!(
3639 !report.reached_github(),
3640 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
3641 monitor-only -- reached nobody. This is the case the old guard let through, and \
3642 the only one it ever let through."
3643 );
3644
3645 report.unreadable.push(PolicyId::from_u128(1));
3646 assert!(
3647 !report.reached_github(),
3648 "every target this pass tried was unreadable, so there is no contact to record"
3649 );
3650
3651 report.targets_read = 1;
3652 assert!(
3653 report.reached_github(),
3654 "one target answering is contact, whatever else failed alongside it"
3655 );
3656
3657 let mut unowned = ReconcileReport::default();
3661 unowned.unreadable.push(PolicyId::from_u128(2));
3662 unowned.allocations.push(Allocation {
3663 policy_id: PolicyId::from_u128(2),
3664 demand: 0,
3665 desired: 0,
3666 active_owned: 0,
3667 headroom_before: 0,
3668 to_start: 0,
3669 limiting_factor: LimitingFactor::Demand,
3670 });
3671 assert!(
3672 !unowned.reached_github(),
3673 "an allocation is not evidence that GitHub answered"
3674 );
3675 }
3676
3677 #[test]
3687 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
3688 assert!(
3689 severity(&RefreshState::Unauthorized) > 0,
3690 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
3691 or a pass where every target was refused would report no failure at all"
3692 );
3693 }
3694
3695 use std::sync::atomic::AtomicUsize;
3696
3697 use std::num::NonZeroU16;
3698
3699 use runner_manager_domain::attempt::PersistedAttempt;
3700 use runner_manager_domain::model::{CachePolicy, HostId};
3701 use runner_manager_domain::policy::PolicyMode;
3702 use runner_manager_domain::workspace::WorkspaceKind;
3703 use runner_manager_github::rest::RateLimited;
3704 use runner_manager_testkit::clock::FakeClock;
3705 use runner_manager_testkit::fixtures;
3706 use runner_manager_testkit::github::FakeGithub;
3707
3708 fn host_with(capacity: u16) -> Host {
3713 fixtures::host().capacity(capacity).build()
3714 }
3715
3716 fn repo(raw: &str) -> OwnerRepo {
3717 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
3718 }
3719
3720 use runner_manager_domain::policy::RunsOn;
3722
3723 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
3724 fixtures::policy()
3725 .id(PolicyId::from_u128(id))
3726 .repository(target)
3727 .autoscale("home", max)
3728 .active()
3729 .build()
3730 }
3731
3732 const HOST_LABEL: &str = "rm-home-win-x64";
3739
3740 fn jobs(n: usize) -> Vec<RunsOn> {
3746 fixtures::queued_jobs(&[HOST_LABEL], n)
3747 }
3748
3749 #[derive(Debug, Default)]
3751 struct FakeLauncher {
3752 capacity: Mutex<Option<NonZeroU16>>,
3753 capacity_unreadable: AtomicBool,
3754 capacity_after_launch: Mutex<Option<NonZeroU16>>,
3755 attempts: Mutex<Vec<RunnerAttempt>>,
3756 next_id: AtomicU64,
3757 launches: AtomicUsize,
3758 cleaned: Mutex<Vec<AttemptId>>,
3759 yields_before_recording: usize,
3762 forgetful: bool,
3766 fail_next: Mutex<Option<FailureReason>>,
3767 attempts_fail: Mutex<bool>,
3770 clean_fails: bool,
3773 replacements: Mutex<Vec<ReplacementIntent>>,
3774 }
3775
3776 impl FakeLauncher {
3777 fn new() -> Self {
3778 Self::default()
3779 }
3780
3781 fn with_yields(mut self, yields: usize) -> Self {
3782 self.yields_before_recording = yields;
3783 self
3784 }
3785
3786 fn forgetful() -> Self {
3787 Self {
3788 forgetful: true,
3789 ..Self::default()
3790 }
3791 }
3792
3793 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
3794 *self.attempts.lock().unwrap() = attempts;
3795 self
3796 }
3797
3798 fn launches(&self) -> usize {
3799 self.launches.load(Ordering::SeqCst)
3800 }
3801
3802 fn snapshot(&self) -> Vec<RunnerAttempt> {
3803 self.attempts.lock().unwrap().clone()
3804 }
3805
3806 fn live_count(&self) -> usize {
3807 self.snapshot()
3808 .iter()
3809 .filter(|a| a.counts_against_capacity())
3810 .count()
3811 }
3812
3813 fn fail_next(&self, reason: FailureReason) {
3814 *self.fail_next.lock().unwrap() = Some(reason);
3815 }
3816
3817 fn fail_attempts(&self, failing: bool) {
3818 *self.attempts_fail.lock().unwrap() = failing;
3819 }
3820
3821 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
3822 Self {
3823 clean_fails: true,
3824 ..Self::default()
3825 }
3826 .seeded(attempts)
3827 }
3828
3829 fn cleaned(&self) -> Vec<AttemptId> {
3830 self.cleaned.lock().unwrap().clone()
3831 }
3832
3833 fn replacing(self, intent: ReplacementIntent) -> Self {
3834 self.replacements.lock().unwrap().push(intent);
3835 self
3836 }
3837 }
3838
3839 #[async_trait::async_trait]
3840 impl RunnerLauncher for FakeLauncher {
3841 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
3842 if self.capacity_unreadable.load(Ordering::SeqCst) {
3843 return Err(HostCapacityError::JournalUnreadable);
3844 }
3845 Ok(self.capacity.lock().unwrap().unwrap_or(host.host_capacity))
3846 }
3847
3848 async fn supervise(
3849 &self,
3850 policy: &ScalePolicy,
3851 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3852 let mut replacements = self.replacements.lock().unwrap();
3853 let selected: Vec<_> = replacements
3854 .extract_if(.., |intent| intent.policy == policy.id)
3855 .collect();
3856 if !selected.is_empty() {
3857 let retired: BTreeSet<_> = selected
3858 .iter()
3859 .map(|intent| intent.previous_attempt)
3860 .collect();
3861 self.attempts
3862 .lock()
3863 .unwrap()
3864 .retain(|attempt| !retired.contains(&attempt.id));
3865 }
3866 Ok(selected)
3867 }
3868
3869 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3870 if *self.attempts_fail.lock().unwrap() {
3871 return Err(LaunchFailure::new(FailureReason::Other(
3872 "the journal could not be read".into(),
3873 )));
3874 }
3875 Ok(self.snapshot())
3876 }
3877
3878 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3879 if let Some(reason) = self.fail_next.lock().unwrap().take() {
3880 return Err(LaunchFailure::new(reason));
3881 }
3882 for _ in 0..self.yields_before_recording {
3884 tokio::task::yield_now().await;
3885 }
3886 let id =
3887 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
3888 let created = RunnerAttempt::allocate(
3889 id,
3890 request.policy.id,
3891 "runtime/p/a",
3892 request.host.created_at,
3893 );
3894 self.launches.fetch_add(1, Ordering::SeqCst);
3895 if let Some(capacity) = self.capacity_after_launch.lock().unwrap().take() {
3896 *self.capacity.lock().unwrap() = Some(capacity);
3897 }
3898 if !self.forgetful {
3899 self.attempts.lock().unwrap().push(created.clone());
3900 }
3901 Ok(created)
3902 }
3903
3904 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
3905 if self.clean_fails {
3906 return Err(LaunchFailure::new(FailureReason::Other(
3907 "the runtime directory is locked".into(),
3908 )));
3909 }
3910 self.cleaned.lock().unwrap().push(attempt);
3911 let mut attempts = self.attempts.lock().unwrap();
3912 attempts.retain(|a| a.id != attempt);
3913 Ok(())
3914 }
3915 }
3916
3917 #[derive(Debug, Default)]
3919 struct FakeDemand {
3920 outcome: Mutex<Option<PollOutcome>>,
3921 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
3923 scopes: Mutex<Vec<ActivityScope>>,
3924 }
3925
3926 impl FakeDemand {
3927 fn ready(count: u32, repository: &OwnerRepo) -> Self {
3928 let fake = Self::default();
3929 fake.set(PollOutcome::Ready(QueuedDemand::of(
3930 repository.clone(),
3931 jobs(count as usize),
3932 )));
3933 fake
3934 }
3935
3936 fn failing(state: RefreshState) -> Self {
3937 let fake = Self::default();
3938 fake.set(PollOutcome::Failed(state));
3939 fake
3940 }
3941
3942 fn set(&self, outcome: PollOutcome) {
3943 *self.outcome.lock().unwrap() = Some(outcome);
3944 }
3945
3946 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
3948 self.per_target
3949 .lock()
3950 .unwrap()
3951 .insert(target.clone(), outcome);
3952 }
3953
3954 fn polls(&self) -> Vec<ActivityScope> {
3955 self.scopes.lock().unwrap().clone()
3956 }
3957 }
3958
3959 #[async_trait::async_trait]
3960 impl DemandSource for FakeDemand {
3961 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
3962 self.scopes.lock().unwrap().push(scope.clone());
3963 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
3964 return outcome.clone();
3965 }
3966 self.outcome
3967 .lock()
3968 .unwrap()
3969 .clone()
3970 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
3971 }
3972 }
3973
3974 #[derive(Debug, Default)]
3975 struct FakeDirectory {
3976 repositories: Vec<OwnerRepo>,
3977 calls: AtomicUsize,
3978 }
3979
3980 impl FakeDirectory {
3981 fn of(repositories: Vec<OwnerRepo>) -> Self {
3982 Self {
3983 repositories,
3984 calls: AtomicUsize::new(0),
3985 }
3986 }
3987
3988 fn calls(&self) -> usize {
3989 self.calls.load(Ordering::SeqCst)
3990 }
3991 }
3992
3993 #[async_trait::async_trait]
3994 impl RepositoryDirectory for FakeDirectory {
3995 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
3996 self.calls.fetch_add(1, Ordering::SeqCst);
3997 Ok(self.repositories.clone())
3998 }
3999 }
4000
4001 #[derive(Debug)]
4007 struct CountingLock {
4008 inner: InProcessAllocationLock,
4009 concurrent: Arc<AtomicUsize>,
4010 peak: Arc<AtomicUsize>,
4011 acquisitions: Arc<AtomicUsize>,
4012 }
4013
4014 impl CountingLock {
4015 fn new() -> Self {
4016 Self {
4017 inner: InProcessAllocationLock::new(),
4018 concurrent: Arc::new(AtomicUsize::new(0)),
4019 peak: Arc::new(AtomicUsize::new(0)),
4020 acquisitions: Arc::new(AtomicUsize::new(0)),
4021 }
4022 }
4023
4024 fn peak(&self) -> usize {
4025 self.peak.load(Ordering::SeqCst)
4026 }
4027
4028 fn acquisitions(&self) -> usize {
4029 self.acquisitions.load(Ordering::SeqCst)
4030 }
4031 }
4032
4033 #[derive(Debug)]
4034 struct CountingGuard {
4035 _inner: AllocationGuard,
4036 concurrent: Arc<AtomicUsize>,
4037 }
4038
4039 impl Drop for CountingGuard {
4040 fn drop(&mut self) {
4041 self.concurrent.fetch_sub(1, Ordering::SeqCst);
4042 }
4043 }
4044
4045 #[async_trait::async_trait]
4046 impl AllocationLock for CountingLock {
4047 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4048 let inner = self.inner.acquire().await?;
4049 self.acquisitions.fetch_add(1, Ordering::SeqCst);
4050 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
4051 self.peak.fetch_max(now, Ordering::SeqCst);
4052 Ok(AllocationGuard::new(CountingGuard {
4053 _inner: inner,
4054 concurrent: Arc::clone(&self.concurrent),
4055 }))
4056 }
4057 }
4058
4059 #[derive(Debug, Default)]
4062 struct NoLock;
4063
4064 #[async_trait::async_trait]
4065 impl AllocationLock for NoLock {
4066 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4067 Ok(AllocationGuard::new(()))
4068 }
4069 }
4070
4071 #[tokio::test]
4072 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
4073 use runner_manager_platform::wsl::fence::DrainRequest;
4074
4075 let (_local, shared, paths) = guest_paths();
4076 DrainRequest::new(4, chrono::Utc::now())
4077 .write(shared.path())
4078 .unwrap();
4079 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4080
4081 assert!(lock.acquire().await.is_err());
4082 }
4083
4084 #[tokio::test]
4085 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
4086 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4087
4088 let (_local, shared, paths) = guest_paths();
4089 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4090 let guard = lock.acquire().await.unwrap();
4091 assert!(shared.path().join(FENCE_DIRECTORY).exists());
4092 drop(guard);
4093 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4094 }
4095
4096 #[derive(Clone, Default)]
4098 struct Logs(Arc<Mutex<Vec<u8>>>);
4099
4100 impl std::io::Write for Logs {
4101 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4102 self.0.lock().unwrap().extend_from_slice(buf);
4103 Ok(buf.len())
4104 }
4105 fn flush(&mut self) -> std::io::Result<()> {
4106 Ok(())
4107 }
4108 }
4109
4110 #[tokio::test]
4113 async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
4114 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4115
4116 let (_local, shared, paths) = guest_paths();
4117 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4118 let guard = lock.acquire().await.unwrap();
4119 let fence = shared.path().join(FENCE_DIRECTORY);
4122 std::fs::remove_dir_all(&fence).unwrap();
4123 std::fs::write(&fence, b"not a directory").unwrap();
4124
4125 let logs = Logs::default();
4126 let writer = logs.clone();
4127 let subscriber = tracing_subscriber::fmt()
4128 .with_writer(move || writer.clone())
4129 .with_ansi(false)
4130 .finish();
4131 tracing::subscriber::with_default(subscriber, || drop(guard));
4132
4133 let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
4134 assert!(text.contains("WARN"), "{text}");
4135 assert!(text.contains("could not be released"), "{text}");
4136 assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
4137 }
4138
4139 fn guest_paths() -> (
4140 tempfile::TempDir,
4141 tempfile::TempDir,
4142 Arc<runner_manager_platform::paths::AppPaths>,
4143 ) {
4144 use runner_manager_platform::paths::AppPaths;
4145 use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
4146
4147 let local = tempfile::tempdir().unwrap();
4148 let shared = tempfile::tempdir().unwrap();
4149 let paths = Arc::new(AppPaths::rooted_at(local.path()));
4150 paths.create_all().unwrap();
4151 GuestRecoveryConfig::new(shared.path().to_path_buf())
4152 .write(&paths)
4153 .unwrap();
4154 (local, shared, paths)
4155 }
4156
4157 #[tokio::test]
4160 async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
4161 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
4162
4163 let (_local, shared, paths) = guest_paths();
4164 let fence = shared.path().join(FENCE_DIRECTORY);
4165 std::fs::create_dir_all(&fence).unwrap();
4166 let pid = std::process::id();
4167 std::fs::write(
4168 fence.join(OWNER_FILE),
4169 format!(
4170 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"}}}}"#
4171 ),
4172 )
4173 .unwrap();
4174 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4175
4176 let guard = lock.acquire().await.expect("the stale claim was reclaimed");
4177 drop(guard);
4178 assert!(!fence.exists());
4179 }
4180
4181 #[tokio::test]
4182 async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
4183 use runner_manager_platform::launch_health::recorded_launches_blocked;
4184 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4185
4186 let (_local, shared, paths) = guest_paths();
4187 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4188 .with_blocked_after(3, Duration::ZERO);
4189 let windows =
4191 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4192 .unwrap()
4193 .unwrap();
4194
4195 for _ in 0..2 {
4196 assert!(lock.acquire().await.is_err());
4197 }
4198 assert_eq!(
4199 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4200 None,
4201 "not yet"
4202 );
4203 assert!(lock.acquire().await.is_err());
4204 let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
4205 .unwrap()
4206 .expect("three refusals in a row are recorded");
4207 assert!(
4208 blocked.reason.contains("Windows WSL recovery"),
4209 "{blocked:?}"
4210 );
4211
4212 windows.release().unwrap();
4213 let guard = lock.acquire().await.unwrap();
4214 assert_eq!(
4215 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4216 None
4217 );
4218 drop(guard);
4219 }
4220
4221 #[tokio::test]
4222 async fn refusals_while_this_process_is_launching_are_its_own_contention() {
4223 use runner_manager_platform::launch_health::recorded_launches_blocked;
4224
4225 use runner_manager_platform::wsl::fence::DrainRequest;
4226
4227 let (_local, shared, paths) = guest_paths();
4228 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4229 .with_blocked_after(1, Duration::ZERO);
4230 let launching = lock.acquire().await.unwrap();
4231 DrainRequest::new(1, chrono::Utc::now())
4232 .write(shared.path())
4233 .unwrap();
4234 for _ in 0..3 {
4235 assert!(lock.acquire().await.is_err());
4236 }
4237 assert_eq!(
4238 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4239 None
4240 );
4241 drop(launching);
4242 }
4243
4244 #[tokio::test]
4248 async fn launches_in_progress_share_the_fence_until_the_last_ends() {
4249 use runner_manager_platform::wsl::fence::{DrainRequest, FenceClaim, FenceOwnerKind};
4250
4251 let (_local, shared, paths) = guest_paths();
4252 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4253 let claimed = |root: &std::path::Path| FenceClaim::owner(root).unwrap().is_some();
4254
4255 let first = lock.acquire().await.unwrap();
4256 let first_runner = first.launch_hold();
4257 drop(first);
4258 assert!(claimed(shared.path()), "held while its runner comes up");
4259
4260 let claimed_at = FenceClaim::owner(shared.path())
4261 .unwrap()
4262 .unwrap()
4263 .acquired_at;
4264 tokio::time::sleep(Duration::from_millis(5)).await;
4265 let second = lock.acquire().await.expect("this process owns the fence");
4266 assert!(
4267 FenceClaim::owner(shared.path())
4268 .unwrap()
4269 .unwrap()
4270 .acquired_at
4271 > claimed_at,
4272 "a claim launches keep sharing is not one stuck for that long"
4273 );
4274 let second_runner = second.launch_hold();
4275 drop(second);
4276 drop(first_runner);
4277 assert!(claimed(shared.path()), "the other launch is still running");
4278
4279 DrainRequest::new(1, chrono::Utc::now())
4281 .write(shared.path())
4282 .unwrap();
4283 assert!(lock.acquire().await.is_err());
4284 assert!(claimed(shared.path()));
4285 assert!(
4286 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4287 .unwrap()
4288 .is_none()
4289 );
4290 drop(second_runner);
4291 assert!(!claimed(shared.path()), "the last one lets it go");
4292 assert!(
4293 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4294 .unwrap()
4295 .is_some()
4296 );
4297 }
4298
4299 #[tokio::test]
4302 async fn refusals_with_quiet_spells_between_them_never_add_up() {
4303 use runner_manager_platform::launch_health::recorded_launches_blocked;
4304 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4305
4306 let (_local, shared, paths) = guest_paths();
4307 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4308 .with_blocked_after(3, Duration::ZERO)
4309 .with_streak_gap(Duration::ZERO);
4310 let windows =
4311 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4312 .unwrap()
4313 .unwrap();
4314 for _ in 0..5 {
4315 assert!(lock.acquire().await.is_err());
4316 }
4317 assert_eq!(
4318 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4319 None
4320 );
4321 windows.release().unwrap();
4322 }
4323
4324 #[tokio::test]
4327 async fn a_claim_refused_by_a_drain_request_is_released() {
4328 use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
4329
4330 let (_local, shared, paths) = guest_paths();
4331 DrainRequest::new(4, chrono::Utc::now())
4332 .write(shared.path())
4333 .unwrap();
4334 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4335 assert!(lock.acquire().await.is_err());
4336 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4337 }
4338
4339 #[derive(Debug, Default)]
4341 struct HeldLock;
4342
4343 #[async_trait::async_trait]
4344 impl AllocationLock for HeldLock {
4345 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4346 Err(AllocationLockBusy)
4347 }
4348 }
4349
4350 struct Harness {
4352 launcher: Arc<FakeLauncher>,
4353 demand: Arc<FakeDemand>,
4354 events: Arc<EventLog>,
4355 clock: Arc<FakeClock>,
4356 reconciler: Reconciler,
4357 }
4358
4359 impl Harness {
4360 fn build(
4361 host: Host,
4362 launcher: Arc<FakeLauncher>,
4363 demand: Arc<FakeDemand>,
4364 lock: Arc<dyn AllocationLock>,
4365 ) -> Self {
4366 let events = Arc::new(EventLog::new());
4367 let clock = Arc::new(FakeClock::default());
4368 let reconciler = Reconciler::new(
4369 host,
4370 ReconcilerPorts {
4371 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4372 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4373 lock,
4374 directory: Arc::new(FakeDirectory::default()),
4375 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4376 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
4377 events: Arc::clone(&events) as Arc<dyn EventSink>,
4378 },
4379 );
4380 Self {
4381 launcher,
4382 demand,
4383 events,
4384 clock,
4385 reconciler,
4386 }
4387 }
4388
4389 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
4390 let launcher = Arc::new(FakeLauncher::new());
4391 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
4392 Self::build(
4393 host_with(capacity),
4394 launcher,
4395 demand,
4396 Arc::new(InProcessAllocationLock::new()),
4397 )
4398 }
4399 }
4400
4401 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
4402 let outcome = state.is_terminal().then(|| match state {
4403 AttemptState::Failed => {
4404 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
4405 }
4406 AttemptState::Orphaned => AttemptOutcome::Orphaned,
4407 _ => AttemptOutcome::CompletedJob,
4408 });
4409 RunnerAttempt::from_persisted(PersistedAttempt {
4410 id: AttemptId::from_u128(id),
4411 policy_id: PolicyId::from_u128(policy),
4412 github_runner_id: None,
4413 state,
4414 outcome,
4415 process_id: None,
4416 execution: runner_manager_domain::execution::AttemptExecution::Native {
4417 process_id: None,
4418 },
4419 runtime_path: "runtime/p/a".into(),
4420 workspace_kind: WorkspaceKind::Ephemeral,
4421 workspace_slot: None,
4422 created_at: fixtures::created_at(),
4423 terminal_at: state.is_terminal().then(fixtures::created_at),
4424 last_state_change_at: fixtures::created_at(),
4425 })
4426 .expect("a state/outcome pair the domain accepts")
4427 }
4428
4429 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
4431 RunnerAttempt::from_persisted(PersistedAttempt {
4432 id: AttemptId::from_u128(id),
4433 policy_id: PolicyId::from_u128(policy),
4434 github_runner_id: None,
4435 state: outcome.terminal_state(),
4436 outcome: Some(outcome),
4437 process_id: None,
4438 execution: runner_manager_domain::execution::AttemptExecution::Native {
4439 process_id: None,
4440 },
4441 runtime_path: "runtime/p/a".into(),
4442 workspace_kind: WorkspaceKind::Ephemeral,
4443 workspace_slot: None,
4444 created_at: fixtures::created_at(),
4445 terminal_at: Some(fixtures::created_at()),
4446 last_state_change_at: fixtures::created_at(),
4447 })
4448 .expect("a state/outcome pair the domain accepts")
4449 }
4450
4451 #[tokio::test]
4480 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
4481 let mut harness = Harness::simple(4, 1, "acme/app");
4482 let policy = policy(1, "acme/app", 4);
4483
4484 for poll in 1..=3 {
4485 let report = harness
4486 .reconciler
4487 .reconcile(std::slice::from_ref(&policy))
4488 .await;
4489 assert_eq!(
4490 harness.launcher.launches(),
4491 1,
4492 "poll {poll} started another runner for a job already being served; the \
4493 `- active_owned_runners` term reached `HostAllocator` as a set this host \
4494 does not hold"
4495 );
4496 assert_eq!(report.allocations.len(), 1);
4497 let allocation = &report.allocations[0];
4498 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
4499 if poll == 1 {
4500 assert_eq!(allocation.to_start, 1);
4501 assert_eq!(report.started, 1);
4502 } else {
4503 assert_eq!(allocation.active_owned, 1, "poll {poll}");
4504 assert_eq!(allocation.to_start, 0, "poll {poll}");
4505 assert_eq!(report.started, 0, "poll {poll}");
4506 }
4507 }
4508 assert_eq!(harness.launcher.live_count(), 1);
4509 }
4510
4511 #[tokio::test]
4512 async fn draining_parallel_workflow_starts_runners_for_the_remaining_queue() {
4513 let mut harness = Harness::simple(10, 8, "acme/app");
4514 let policy = policy(1, "acme/app", 10);
4515 let first = harness
4516 .reconciler
4517 .reconcile(std::slice::from_ref(&policy))
4518 .await;
4519 assert_eq!(first.started, 8);
4520
4521 let attempts = harness.launcher.snapshot();
4523 *harness.launcher.attempts.lock().unwrap() = attempts
4524 .iter()
4525 .take(3)
4526 .map(|attempt| attempt_in(AttemptState::Busy, attempt.id.as_uuid().as_u128(), 1))
4527 .collect();
4528 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4529 repo("acme/app"),
4530 jobs(2),
4531 )));
4532
4533 for poll in 0..3 {
4534 let report = harness
4535 .reconciler
4536 .reconcile(std::slice::from_ref(&policy))
4537 .await;
4538 assert_eq!(report.started, if poll == 0 { 2 } else { 0 });
4539 assert_eq!(harness.launcher.live_count(), 5);
4540 }
4541 }
4542
4543 #[tokio::test]
4554 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4555 let launcher = Arc::new(FakeLauncher::forgetful());
4556 let mut harness = Harness::build(
4557 host_with(64),
4558 Arc::clone(&launcher),
4559 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4560 Arc::new(InProcessAllocationLock::new()),
4561 );
4562
4563 let report = harness
4564 .reconciler
4565 .reconcile(&[policy(1, "acme/app", 8)])
4566 .await;
4567
4568 assert_eq!(
4569 report.started, 3,
4570 "the pass is bounded by the grant it was given, not by the attempt set catching \
4571 up with it"
4572 );
4573 assert_eq!(launcher.launches(), 3);
4574 assert!(
4575 launcher.snapshot().is_empty(),
4576 "the launcher never recorded anything, which is the whole point of the fixture"
4577 );
4578 }
4579
4580 #[tokio::test]
4592 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4593 let launcher = Arc::new(FakeLauncher::forgetful());
4594 let demand = Arc::new(FakeDemand::default());
4595 demand.set_for(
4596 &ScaleTarget::repository("acme/left").unwrap(),
4597 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4598 );
4599 demand.set_for(
4600 &ScaleTarget::repository("acme/right").unwrap(),
4601 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4602 );
4603 let mut harness = Harness::build(
4604 host_with(3),
4605 Arc::clone(&launcher),
4606 demand,
4607 Arc::new(InProcessAllocationLock::new()),
4608 );
4609
4610 let report = harness
4611 .reconciler
4612 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4613 .await;
4614
4615 assert_eq!(
4616 report.started, 3,
4617 "host_capacity is 3 and two policies each allowed 3 started {} runners \
4618 between them; the second policy's grant was computed from a set that did \
4619 not yet contain the first policy's launches",
4620 report.started
4621 );
4622 assert_eq!(launcher.launches(), 3);
4623 }
4624
4625 #[tokio::test]
4636 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
4637 let launcher = Arc::new(FakeLauncher::new());
4638 let mut harness = Harness::build(
4639 host_with(8),
4640 Arc::clone(&launcher),
4641 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
4642 Arc::new(InProcessAllocationLock::new()),
4643 );
4644 let policy = policy(1, "acme/app", 8);
4645
4646 launcher.fail_attempts(true);
4647 let unreadable = harness
4648 .reconciler
4649 .reconcile(std::slice::from_ref(&policy))
4650 .await;
4651
4652 assert_eq!(
4653 unreadable.started, 0,
4654 "nothing may be decided from a set that was not read"
4655 );
4656 assert_eq!(launcher.launches(), 0);
4657 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
4658 assert!(
4659 unreadable.allocations.is_empty(),
4660 "no allocation is reported either: there was no set to compute one from, and \
4661 an allocation of zero would claim a decision nobody made"
4662 );
4663 assert!(harness.events.count_of("attempts_unreadable") > 0);
4664
4665 launcher.fail_attempts(false);
4667 let readable = harness
4668 .reconciler
4669 .reconcile(std::slice::from_ref(&policy))
4670 .await;
4671 assert_eq!(
4672 readable.started, 4,
4673 "the difference between the two passes is only whether the set could be read"
4674 );
4675 assert_eq!(readable.attempts_unreadable, 0);
4676 }
4677
4678 #[tokio::test]
4688 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
4689 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4690 1,
4691 1,
4692 AttemptOutcome::ExitedIdleWithoutWork,
4693 )]));
4694 let mut harness = Harness::build(
4695 host_with(4),
4696 Arc::clone(&launcher),
4697 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4698 Arc::new(InProcessAllocationLock::new()),
4699 );
4700
4701 let report = harness
4702 .reconciler
4703 .reconcile(&[policy(1, "acme/app", 4)])
4704 .await;
4705
4706 assert_eq!(report.cleaned, 0);
4707 assert_eq!(report.clean_failures, 1);
4708 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
4709 assert_eq!(
4710 harness.events.count_of("attempt_cleaned"),
4711 0,
4712 "and it is not reported as cleaned"
4713 );
4714 assert_eq!(
4715 launcher.snapshot().len(),
4716 1,
4717 "the attempt is still there, so the retry is real -- what changed is that it \
4718 is no longer silent"
4719 );
4720
4721 let reasons: Vec<&'static str> = harness
4724 .events
4725 .events()
4726 .into_iter()
4727 .filter_map(|event| match event {
4728 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
4729 _ => None,
4730 })
4731 .collect();
4732 assert_eq!(reasons, vec!["other"]);
4733 }
4734
4735 #[tokio::test]
4738 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
4739 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4740 1,
4741 1,
4742 AttemptOutcome::ExitedIdleWithoutWork,
4743 )]));
4744 let mut harness = Harness::build(
4745 host_with(4),
4746 Arc::clone(&launcher),
4747 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4748 Arc::new(InProcessAllocationLock::new()),
4749 );
4750 let policies = [policy(1, "acme/app", 4)];
4751 let start = harness.clock.now();
4752 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
4753 let mut failures_at = async |offset: Duration| {
4754 harness.clock.set(at(offset));
4755 harness.reconciler.reconcile(&policies).await.clean_failures
4756 };
4757
4758 assert_eq!(failures_at(Duration::ZERO).await, 1);
4759 assert_eq!(
4761 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
4762 0,
4763 "a failed cleanup was retried on the very next pass"
4764 );
4765 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
4766 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
4768 assert_eq!(
4769 failures_at(second - Duration::from_secs(1)).await,
4770 0,
4771 "the delay did not double after a second failure"
4772 );
4773 assert_eq!(failures_at(second).await, 1);
4774 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
4775
4776 let mut backoff = CleanBackoff::default();
4778 let id = AttemptId::from_u128(9);
4779 for _ in 0..40 {
4780 backoff.failed(id, start);
4781 }
4782 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
4783 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
4784 backoff.succeeded(id);
4785 assert!(
4786 backoff.is_due(id, start),
4787 "a cleaned attempt kept its backoff"
4788 );
4789 }
4790
4791 #[tokio::test]
4804 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
4805 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4806 attempt_in(AttemptState::Busy, 1, 1),
4807 concluded(2, 1, AttemptOutcome::CompletedJob),
4808 ]));
4809 let harness = Harness::build(
4810 host_with(4),
4811 Arc::clone(&launcher),
4812 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4813 Arc::new(InProcessAllocationLock::new()),
4814 );
4815 let policy = policy(1, "acme/app", 4);
4816
4817 launcher.fail_attempts(true);
4818 let blind = harness.reconciler.scale_down(&policy).await;
4819
4820 assert!(!blind.is_conclusive(), "the machine was never read");
4821 assert_ne!(
4822 blind,
4823 ScaleDownReport::default(),
4824 "a scale-down that could not see the host must not be equal to one that saw \
4825 an idle host; that equality is the whole finding"
4826 );
4827 assert_eq!(blind.removed, 0);
4828 assert_eq!(
4829 blind.refused_busy, 0,
4830 "and this zero means `unknown`, not `none`"
4831 );
4832 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
4833
4834 launcher.fail_attempts(false);
4836 let seeing = harness.reconciler.scale_down(&policy).await;
4837
4838 assert!(seeing.is_conclusive());
4839 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
4840 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
4841 assert_ne!(
4842 seeing, blind,
4843 "the difference between the two is only whether the set could be read"
4844 );
4845 }
4846
4847 #[tokio::test]
4855 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
4856 let mut warm = ScalePolicy::new(
4857 PolicyId::from_u128(1),
4858 ScaleTarget::repository("acme/app").unwrap(),
4859 1,
4860 fixtures::HOST_ID,
4861 PolicyMode::autoscale(
4862 fixtures::routing_labels("home"),
4863 2,
4864 NonZeroU16::new(5).expect("non-zero"),
4865 )
4866 .expect("min <= max"),
4867 CachePolicy::default(),
4868 );
4869 warm.activate().expect("pending -> active");
4870
4871 let mut harness = Harness::simple(8, 0, "acme/app");
4872 let report = harness.reconciler.reconcile(&[warm]).await;
4873
4874 assert_eq!(
4875 report.allocations[0].demand, 0,
4876 "GitHub reported no queued runs"
4877 );
4878 assert_eq!(
4879 report.allocations[0].desired, 2,
4880 "min_capacity raised the target above demand"
4881 );
4882 assert_eq!(
4883 report.allocations[0].limiting_factor,
4884 LimitingFactor::MinCapacity
4885 );
4886 assert_eq!(report.started, 2, "and two runners were actually started");
4887 assert_eq!(harness.launcher.live_count(), 2);
4888 }
4889
4890 #[tokio::test]
4895 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
4896 let mut harness = Harness::simple(100, 10, "acme/app");
4897 let report = harness
4898 .reconciler
4899 .reconcile(&[policy(1, "acme/app", 3)])
4900 .await;
4901
4902 assert_eq!(report.allocations[0].demand, 10);
4903 assert_eq!(
4904 report.allocations[0].desired, 3,
4905 "max_capacity beats demand"
4906 );
4907 assert_eq!(report.started, 3);
4908 assert_eq!(
4909 report.allocations[0].limiting_factor,
4910 LimitingFactor::MaxCapacity
4911 );
4912 }
4913
4914 #[tokio::test]
4915 async fn demand_below_min_capacity_starts_nothing_in_v1() {
4916 let mut harness = Harness::simple(8, 0, "acme/app");
4919 let report = harness
4920 .reconciler
4921 .reconcile(&[policy(1, "acme/app", 4)])
4922 .await;
4923
4924 assert_eq!(report.allocations[0].desired, 0);
4925 assert_eq!(report.started, 0);
4926 assert!(report.starts_nothing());
4927 }
4928
4929 #[tokio::test]
4930 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
4931 let policy = policy(1, "octo/repo", 1);
4932 let previous = attempt_in(AttemptState::Starting, 41, 1);
4933 let intent = ReplacementIntent {
4934 policy: policy.id,
4935 previous_attempt: previous.id,
4936 operation: "exit_before_acceptance_replacement",
4937 };
4938 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
4939 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
4940 let mut harness = Harness::build(
4941 host_with(1),
4942 Arc::clone(&launcher),
4943 demand,
4944 Arc::new(InProcessAllocationLock::new()),
4945 );
4946
4947 let report = harness.reconciler.reconcile(&[policy]).await;
4948
4949 assert_eq!(report.replacement_intents, 1);
4950 assert_eq!(report.started, 1);
4951 assert_eq!(launcher.launches(), 1);
4952 assert_eq!(launcher.live_count(), 1);
4953 }
4954
4955 #[tokio::test]
4956 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
4957 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4958 attempt_in(AttemptState::Busy, 1, 1),
4959 attempt_in(AttemptState::Busy, 2, 1),
4960 ]));
4961 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
4962 let mut harness = Harness::build(
4963 host_with(2),
4964 launcher,
4965 demand,
4966 Arc::new(InProcessAllocationLock::new()),
4967 );
4968
4969 let report = harness
4970 .reconciler
4971 .reconcile(&[policy(1, "acme/app", 2)])
4972 .await;
4973 assert_eq!(report.started, 0);
4974 assert_eq!(report.allocations[0].headroom_before, 0);
4975 assert_eq!(harness.launcher.launches(), 0);
4976 }
4977
4978 #[tokio::test]
4979 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
4980 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4983 attempt_in(AttemptState::Busy, 1, 99),
4984 attempt_in(AttemptState::Busy, 2, 99),
4985 attempt_in(AttemptState::Idle, 3, 99),
4986 attempt_in(AttemptState::Starting, 4, 99),
4987 ]));
4988 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
4989 let mut harness = Harness::build(
4990 host_with(6),
4991 launcher,
4992 demand,
4993 Arc::new(InProcessAllocationLock::new()),
4994 );
4995
4996 let report = harness
4997 .reconciler
4998 .reconcile(&[policy(1, "acme/app", 5)])
4999 .await;
5000 assert_eq!(
5001 report.allocations[0].desired, 5,
5002 "its own ceiling allows five"
5003 );
5004 assert_eq!(report.started, 2, "the host has two slots free");
5005 assert_eq!(
5006 report.allocations[0].limiting_factor,
5007 LimitingFactor::HostCapacity
5008 );
5009 assert_eq!(harness.launcher.live_count(), 6);
5010 }
5011
5012 #[tokio::test]
5013 async fn the_idle_host_assertion_holds() {
5014 let mut harness = Harness::simple(8, 0, "acme/app");
5017 let report = harness
5018 .reconciler
5019 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
5020 .await;
5021
5022 assert_eq!(report.started, 0);
5023 assert_eq!(harness.launcher.launches(), 0);
5024 assert!(harness.launcher.snapshot().is_empty());
5025 assert_eq!(
5026 harness
5027 .launcher
5028 .snapshot()
5029 .iter()
5030 .filter(|a| !a.is_terminal())
5031 .count(),
5032 0
5033 );
5034 }
5035
5036 #[tokio::test]
5041 async fn live_capacity_increase_is_used_without_rebuilding_the_reconciler() {
5042 let mut harness = Harness::simple(1, 5, "acme/app");
5043 let policies = [policy(1, "acme/app", 5)];
5044 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5045
5046 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(4);
5047 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 3);
5048 assert_eq!(harness.launcher.live_count(), 4);
5049 }
5050
5051 #[tokio::test]
5052 async fn live_capacity_decrease_keeps_busy_runners_and_waits_for_headroom() {
5053 let mut harness = Harness::simple(5, 5, "acme/app");
5054 let busy: Vec<_> = (1..=3)
5055 .map(|id| attempt_in(AttemptState::Busy, id, 1))
5056 .collect();
5057 *harness.launcher.attempts.lock().unwrap() = busy.clone();
5058 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5059 let policies = [policy(1, "acme/app", 5)];
5060
5061 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 0);
5062 assert_eq!(harness.launcher.snapshot(), busy);
5063 assert!(harness.launcher.cleaned().is_empty());
5064
5065 harness.launcher.attempts.lock().unwrap().truncate(1);
5067 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5068 assert_eq!(harness.launcher.live_count(), 2);
5069 }
5070
5071 #[tokio::test]
5072 async fn live_capacity_is_rechecked_between_launches_in_the_same_pass() {
5073 let mut harness = Harness::simple(5, 5, "acme/app");
5074 *harness.launcher.capacity_after_launch.lock().unwrap() = NonZeroU16::new(1);
5075
5076 let report = harness
5077 .reconciler
5078 .reconcile(&[policy(1, "acme/app", 5)])
5079 .await;
5080 assert_eq!(report.started, 1);
5081 assert_eq!(harness.launcher.live_count(), 1);
5082 }
5083
5084 #[tokio::test]
5085 async fn live_capacity_unreadable_blocks_launches_until_the_journal_recovers() {
5086 let mut harness = Harness::simple(5, 5, "acme/app");
5087 harness
5088 .launcher
5089 .capacity_unreadable
5090 .store(true, Ordering::SeqCst);
5091 let policies = [policy(1, "acme/app", 5)];
5092 let report = harness.reconciler.reconcile(&policies).await;
5093 assert_eq!(report.started, 0);
5094 assert_eq!(report.host_capacity_unreadable, 1);
5095 assert_eq!(report.attempts_unreadable, 0);
5096 assert_eq!(harness.events.count_of("attempts_unreadable"), 0);
5097 assert!(
5098 harness
5099 .events
5100 .events()
5101 .contains(&LifecycleEvent::HostCapacityUnreadable {
5102 reason: HostCapacityError::JournalUnreadable,
5103 })
5104 );
5105
5106 harness
5107 .launcher
5108 .capacity_unreadable
5109 .store(false, Ordering::SeqCst);
5110 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5111 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 2);
5112 }
5113
5114 #[tokio::test]
5115 async fn unreadable_local_state_does_not_treat_a_busy_host_as_idle() {
5116 for host_failure in [true, false] {
5117 let mut harness = Harness::simple(5, 0, "acme/app");
5118 harness
5119 .reconciler
5120 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5121 *harness.launcher.attempts.lock().unwrap() = vec![attempt_in(AttemptState::Busy, 1, 1)];
5122 harness
5123 .launcher
5124 .capacity_unreadable
5125 .store(host_failure, Ordering::SeqCst);
5126 harness.launcher.fail_attempts(!host_failure);
5127 let report = harness
5128 .reconciler
5129 .reconcile(&[policy(1, "acme/app", 5)])
5130 .await;
5131 assert_eq!(report.host_capacity_unreadable, u16::from(host_failure));
5132 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5133 assert_eq!(harness.launcher.live_count(), 1);
5134 }
5135 }
5136
5137 #[derive(Debug)]
5138 struct HostEditOnLock {
5139 inner: InProcessAllocationLock,
5140 launcher: Arc<FakeLauncher>,
5141 unreadable: bool,
5142 }
5143
5144 #[async_trait::async_trait]
5145 impl AllocationLock for HostEditOnLock {
5146 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
5147 let guard = self.inner.acquire().await?;
5148 *self.launcher.capacity.lock().unwrap() = NonZeroU16::new(1);
5149 self.launcher
5150 .capacity_unreadable
5151 .store(self.unreadable, Ordering::SeqCst);
5152 Ok(guard)
5153 }
5154 }
5155
5156 #[tokio::test]
5157 async fn live_capacity_edits_and_read_failures_while_waiting_for_the_lock_block_launches() {
5158 for unreadable in [false, true] {
5159 let launcher =
5160 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5161 let lock = Arc::new(HostEditOnLock {
5162 inner: InProcessAllocationLock::new(),
5163 launcher: Arc::clone(&launcher),
5164 unreadable,
5165 });
5166 let mut harness = Harness::build(
5167 host_with(5),
5168 Arc::clone(&launcher),
5169 Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
5170 lock,
5171 );
5172 let report = harness
5173 .reconciler
5174 .reconcile(&[policy(1, "acme/app", 5)])
5175 .await;
5176 assert_eq!(report.started, 0);
5177 assert_eq!(launcher.live_count(), 1);
5178 assert!(launcher.cleaned().is_empty());
5179 assert_eq!(report.host_capacity_unreadable, u16::from(unreadable));
5180 assert_eq!(report.attempts_unreadable, 0);
5181 }
5182 }
5183
5184 #[tokio::test(flavor = "current_thread")]
5207 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
5208 let lock = Arc::new(CountingLock::new());
5209 let (launches, live) =
5210 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>, None).await;
5211
5212 assert_eq!(
5213 launches, 3,
5214 "the sum across policies must never exceed host_capacity, and each policy is \
5215 individually within its own max_capacity of 3"
5216 );
5217 assert_eq!(live, 3, "and no duplicate runner survived the race");
5218 assert_eq!(
5219 lock.peak(),
5220 1,
5221 "the allocation lock had one holder at a time; without that the read of the \
5222 headroom and the creation of the runtime are not atomic"
5223 );
5224 assert!(
5225 (3..=5).contains(&lock.acquisitions()),
5226 "the lock is taken before *each* runtime, not once per pass: three runtimes \
5227 means at least three holds, and at most one further hold per policy to \
5228 discover the host filled up underneath it. It was taken {} times",
5229 lock.acquisitions()
5230 );
5231 }
5232
5233 #[tokio::test(flavor = "current_thread")]
5240 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
5241 let (launches, _) =
5242 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>, None).await;
5243
5244 assert!(
5245 launches > 3,
5246 "with no serialisation both policies must be able to spend the same headroom; \
5247 they started {launches} runners on a host of 3. If this is ever 3, the \
5248 contention window closed and `two_policies_reconciling_concurrently_never_\
5249 exceed_host_capacity` has stopped proving anything"
5250 );
5251 }
5252
5253 async fn two_policies_concurrently(
5257 lock: Arc<dyn AllocationLock>,
5258 live_capacity: Option<NonZeroU16>,
5259 ) -> (usize, usize) {
5260 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
5261 *launcher.capacity.lock().unwrap() = live_capacity;
5262 let host = host_with(3);
5263
5264 let mut left = Harness::build(
5265 host.clone(),
5266 Arc::clone(&launcher),
5267 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
5268 Arc::clone(&lock),
5269 )
5270 .reconciler;
5271 let mut right = Harness::build(
5272 host,
5273 Arc::clone(&launcher),
5274 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
5275 Arc::clone(&lock),
5276 )
5277 .reconciler;
5278
5279 let a = policy(1, "acme/left", 3);
5280 let b = policy(2, "acme/right", 3);
5281
5282 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
5283 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
5284 let (_, _) = (left.await.unwrap(), right.await.unwrap());
5285
5286 (launcher.launches(), launcher.live_count())
5287 }
5288
5289 #[tokio::test(flavor = "current_thread")]
5290 async fn live_capacity_is_shared_by_concurrent_targets_with_stale_startup_hosts() {
5291 let (launches, live) =
5292 two_policies_concurrently(Arc::new(InProcessAllocationLock::new()), NonZeroU16::new(2))
5293 .await;
5294 assert_eq!(launches, 2);
5295 assert_eq!(live, 2);
5296 }
5297
5298 #[tokio::test]
5310 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
5311 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
5312 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
5313 let launcher = Arc::new(FakeLauncher::new());
5314 let events = Arc::new(EventLog::new());
5315
5316 let mut reconciler = Reconciler::new(
5317 host_with(10),
5318 ReconcilerPorts {
5319 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
5320 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5321 lock: Arc::new(InProcessAllocationLock::new()),
5322 directory: Arc::new(FakeDirectory::default()),
5323 clock: Arc::new(FakeClock::default()),
5324 jitter: Arc::new(NoJitter),
5325 events: Arc::clone(&events) as Arc<dyn EventSink>,
5326 },
5327 );
5328
5329 let monitor = fixtures::policy()
5330 .id(PolicyId::from_u128(1))
5331 .repository("acme/app")
5332 .monitor_only()
5333 .active()
5334 .build();
5335
5336 let report = reconciler.reconcile(&[monitor]).await;
5337
5338 assert_eq!(report.started, 0);
5339 assert_eq!(launcher.launches(), 0);
5340 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
5341 assert_eq!(
5342 report.demand_requests, 0,
5343 "a monitor-only policy spends nothing from the shared hourly ceiling"
5344 );
5345 assert!(
5346 gateway.gateway().calls().is_empty(),
5347 "a monitor-only policy issued a demand request: {:?}",
5348 gateway.gateway().calls()
5349 );
5350 assert_eq!(events.count_of("monitor_only_skipped"), 1);
5351 assert_eq!(
5352 events.count_of("demand_observed"),
5353 0,
5354 "and it contributed no demand"
5355 );
5356 }
5357
5358 #[tokio::test]
5376 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
5377 let lock = Arc::new(CountingLock::new());
5378 let launcher = Arc::new(FakeLauncher::new());
5379 let events = Arc::new(EventLog::new());
5380 let mut reconciler = Reconciler::new(
5381 host_with(4),
5382 ReconcilerPorts {
5383 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5384 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5385 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
5386 directory: Arc::new(FakeDirectory::default()),
5387 clock: Arc::new(FakeClock::default()),
5388 jitter: Arc::new(NoJitter),
5389 events: Arc::clone(&events) as Arc<dyn EventSink>,
5390 },
5391 );
5392
5393 let watcher = fixtures::policy()
5394 .id(PolicyId::from_u128(2))
5395 .repository("acme/app")
5396 .monitor_only()
5397 .active()
5398 .build();
5399
5400 let report = reconciler
5401 .reconcile(&[policy(1, "acme/app", 4), watcher])
5402 .await;
5403
5404 assert_eq!(report.started, 2, "the autoscale policy is served normally");
5405 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
5406 assert_eq!(
5407 events.count_of("demand_observed"),
5408 1,
5409 "the demand observation belongs to the autoscale policy alone"
5410 );
5411 assert!(
5412 report
5413 .allocations
5414 .iter()
5415 .all(|a| a.policy_id == PolicyId::from_u128(1)),
5416 "a monitor-only policy was allocated for: {:?}",
5417 report.allocations
5418 );
5419 assert_eq!(
5420 lock.acquisitions(),
5421 2,
5422 "one hold per runtime created, and none on behalf of the monitor-only policy. \
5423 It was three before the budget was checked at the top of the loop rather than \
5424 after the re-read, which cost every policy a surplus hold to discover there \
5425 was nothing left to grant"
5426 );
5427 }
5428
5429 #[tokio::test]
5430 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
5431 let monitor = fixtures::monitor_only_policy();
5436 assert!(!monitor.owns_runners());
5437 assert_eq!(monitor.max_capacity(), None);
5438
5439 let host = host_with(10);
5440 let attempts: Vec<RunnerAttempt> = Vec::new();
5441 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5442 let allocation = allocator.allocate(&monitor, 10_000);
5443 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
5444 assert_eq!(allocation.to_start, 0);
5445 assert_eq!(
5446 allocator.headroom(),
5447 10,
5448 "and it consumes no headroom, so an autoscale policy on the same host is \
5449 unaffected"
5450 );
5451 }
5452
5453 #[tokio::test]
5461 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
5462 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5463 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
5464 concluded(
5465 2,
5466 1,
5467 AttemptOutcome::failed(FailureReason::JitRequestFailed),
5468 ),
5469 concluded(3, 1, AttemptOutcome::CompletedJob),
5470 ]));
5471 let mut harness = Harness::build(
5472 host_with(4),
5473 Arc::clone(&launcher),
5474 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5475 Arc::new(InProcessAllocationLock::new()),
5476 );
5477
5478 let report = harness
5479 .reconciler
5480 .reconcile(&[policy(1, "acme/app", 4)])
5481 .await;
5482
5483 assert_eq!(report.cleaned, 3);
5484 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
5485 assert_eq!(
5486 report.failures, 1,
5487 "only the failed attempt is a failure; the idle exit and the completed job are \
5488 not"
5489 );
5490 assert_eq!(launcher.cleaned().len(), 3);
5491 assert!(launcher.snapshot().is_empty());
5492
5493 let cleaned: Vec<OutcomeKind> = harness
5494 .events
5495 .events()
5496 .into_iter()
5497 .filter_map(|event| match event {
5498 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
5499 _ => None,
5500 })
5501 .collect();
5502 assert!(cleaned.contains(&OutcomeKind::IdleExit));
5503 assert!(
5504 !OutcomeKind::IdleExit.is_failure(),
5505 "an idle exit rendered as a failure sends an operator hunting a fault that does \
5506 not exist"
5507 );
5508 }
5509
5510 #[tokio::test]
5513 async fn scale_down_removes_nothing_from_a_busy_attempt() {
5514 let busy = attempt_in(AttemptState::Busy, 1, 1);
5515 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5516 busy.clone(),
5517 attempt_in(AttemptState::Starting, 2, 1),
5518 concluded(3, 1, AttemptOutcome::CompletedJob),
5519 ]));
5520 let harness = Harness::build(
5521 host_with(4),
5522 Arc::clone(&launcher),
5523 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5524 Arc::new(InProcessAllocationLock::new()),
5525 );
5526
5527 let report = harness
5528 .reconciler
5529 .scale_down(&policy(1, "acme/app", 4))
5530 .await;
5531
5532 assert_eq!(report.refused_busy, 1);
5533 assert_eq!(
5534 report.retained, 1,
5535 "the `starting` attempt is not ended either"
5536 );
5537 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
5538
5539 let after = launcher.snapshot();
5540 let still_busy = after
5541 .iter()
5542 .find(|a| a.id == AttemptId::from_u128(1))
5543 .expect("the busy attempt is still there");
5544 assert_eq!(
5545 still_busy.state(),
5546 AttemptState::Busy,
5547 "scale-down removed nothing from a runner that is executing a job, and left it \
5548 busy"
5549 );
5550 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
5551
5552 let mut busy = busy;
5556 assert!(matches!(
5557 busy.clean(fixtures::created_at()),
5558 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
5559 ));
5560 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
5561 }
5562
5563 #[test]
5568 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
5569 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
5570 assert_eq!(RefreshInterval::MIN_SECS, 30);
5571 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
5572 assert!(
5573 RefreshInterval::from_secs(29).is_err(),
5574 "the floor is a rate-budget constraint, and a caller must not be able to write \
5575 a shorter interval at all"
5576 );
5577
5578 let mut schedule = PollSchedule::new(RefreshInterval::default());
5579 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
5580 assert_eq!(next.delay, Duration::from_secs(60));
5581 assert_eq!(next.pace, PollPace::Nominal);
5582
5583 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5584 assert_eq!(
5585 floored
5586 .next_poll(None, fixtures::created_at(), &NoJitter)
5587 .delay,
5588 Duration::from_secs(30)
5589 );
5590 }
5591
5592 #[test]
5596 fn a_rate_limit_increases_the_delay_and_names_itself() {
5597 let now = fixtures::created_at();
5598 let mut schedule = PollSchedule::new(RefreshInterval::default());
5599
5600 let limited = RefreshState::RateLimited(RateLimited {
5601 kind: RateLimitKind::Secondary,
5602 retry_after: Some(Duration::from_secs(300)),
5603 remaining: None,
5604 reset_unix_secs: None,
5605 });
5606 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5607
5608 assert_eq!(next.delay, Duration::from_secs(300));
5609 assert_eq!(
5610 next.pace,
5611 PollPace::RateLimited {
5612 kind: RateLimitKind::Secondary
5613 },
5614 "the increase is reported, never hidden"
5615 );
5616 assert!(next.pace.is_throttled());
5617 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
5618 }
5619
5620 #[test]
5623 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
5624 let now = fixtures::created_at();
5625 let mut schedule = PollSchedule::new(RefreshInterval::default());
5626
5627 let limited = RefreshState::RateLimited(RateLimited {
5628 kind: RateLimitKind::Primary,
5629 retry_after: Some(Duration::from_secs(300)),
5630 remaining: Some(0),
5631 reset_unix_secs: None,
5632 });
5633
5634 for _ in 0..5 {
5637 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5638 assert_eq!(
5639 next.delay,
5640 Duration::from_secs(300),
5641 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
5642 have compounded on every successive retry"
5643 );
5644 }
5645
5646 let brief = RefreshState::RateLimited(RateLimited {
5649 kind: RateLimitKind::Secondary,
5650 retry_after: Some(Duration::from_secs(5)),
5651 remaining: None,
5652 reset_unix_secs: None,
5653 });
5654 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
5655 assert_eq!(
5656 next.delay,
5657 Duration::from_secs(60),
5658 "a short `retry-after` may not drop the loop below its own interval"
5659 );
5660 assert!(next.delay >= PollSchedule::floor());
5661 }
5662
5663 #[test]
5664 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
5665 let now = fixtures::created_at();
5666 let states = [
5667 None,
5668 Some(RefreshState::Offline),
5669 Some(RefreshState::RateLimited(RateLimited {
5670 kind: RateLimitKind::Secondary,
5671 retry_after: Some(Duration::from_secs(1)),
5672 remaining: None,
5673 reset_unix_secs: None,
5674 })),
5675 Some(RefreshState::LockedOut {
5676 retry_after: Duration::from_secs(1),
5677 }),
5678 Some(RefreshState::Unauthorized),
5679 Some(RefreshState::Forbidden { message: None }),
5680 Some(RefreshState::Failed {
5681 status: Some(500),
5682 message: "server error".into(),
5683 }),
5684 Some(RefreshState::Cancelled),
5685 ];
5686
5687 for state in &states {
5688 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5689 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
5690 assert!(
5691 next.delay >= PollSchedule::floor(),
5692 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
5693 next.delay.as_millis()
5694 );
5695 }
5696 }
5697
5698 #[test]
5699 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
5700 let now = fixtures::created_at();
5701 let mut schedule = PollSchedule::new(RefreshInterval::default());
5702
5703 let mut previous = Duration::ZERO;
5705 for consecutive in 1..=6_u32 {
5706 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5707 assert_eq!(next.pace, PollPace::Offline { consecutive });
5708 assert!(
5709 next.delay >= previous,
5710 "the back-off must not shrink while the outage continues"
5711 );
5712 assert!(next.delay >= Duration::from_secs(60));
5713 previous = next.delay;
5714 }
5715 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
5716
5717 let mut jittered = PollSchedule::new(RefreshInterval::default());
5720 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5721 let mut jittered = PollSchedule::new(RefreshInterval::default());
5722 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
5723 assert!(full.delay > none.delay);
5724 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
5725
5726 assert_eq!(schedule.consecutive_offline(), 6);
5728 let recovered = schedule.next_poll(None, now, &NoJitter);
5729 assert_eq!(recovered.pace, PollPace::Nominal);
5730 assert_eq!(recovered.delay, Duration::from_secs(60));
5731 assert_eq!(schedule.consecutive_offline(), 0);
5732 }
5733
5734 #[test]
5735 fn the_offline_state_states_the_twenty_four_hour_bound() {
5736 assert_eq!(
5737 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
5738 Duration::from_secs(24 * 60 * 60)
5739 );
5740
5741 let brief = OfflineState::new(1, Duration::from_secs(120));
5742 let rendered = brief.to_string();
5743 assert!(rendered.contains("24 hours"), "{rendered}");
5744 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
5745 assert!(!brief.has_outlasted_the_queue());
5746
5747 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
5748 assert!(long.has_outlasted_the_queue());
5749 assert!(
5750 long.to_string().contains("queued work has been lost"),
5751 "{long}"
5752 );
5753
5754 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
5756 }
5757
5758 fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
5763 RateLimitSnapshot {
5764 limit: 5_000,
5765 remaining,
5766 used: Some(5_000 - remaining),
5767 reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
5768 observed_at: now,
5769 }
5770 }
5771
5772 fn quiet() -> PollConditions {
5773 PollConditions {
5774 quiet: true,
5775 quota: None,
5776 requests_per_pass: 0,
5777 }
5778 }
5779
5780 #[test]
5781 fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
5782 let now = fixtures::created_at();
5783 let mut schedule =
5784 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5785
5786 let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
5787 assert_eq!(idle.delay, Duration::from_secs(10));
5788 assert_eq!(idle.pace, PollPace::Idle);
5789 assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
5790
5791 let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
5792 assert_eq!(busy.delay, Duration::from_secs(60));
5793 assert_eq!(busy.pace, PollPace::Nominal);
5794 }
5795
5796 #[test]
5797 fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
5798 let now = fixtures::created_at();
5799 let mut schedule = PollSchedule::new(RefreshInterval::default());
5800 assert_eq!(
5801 schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
5802 Duration::from_secs(60)
5803 );
5804 }
5805
5806 #[test]
5807 fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
5808 let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
5809 .with_idle(Duration::from_secs(120));
5810 assert_eq!(
5811 schedule.idle(),
5812 Duration::from_secs(30),
5813 "an idle interval above the active one would make quiet targets slower to react"
5814 );
5815 let schedule =
5816 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
5817 assert_eq!(schedule.idle(), PollSchedule::idle_floor());
5818 }
5819
5820 #[test]
5823 fn a_low_quota_stretches_polling_and_wins_over_idle() {
5824 let now = fixtures::created_at();
5825 let mut schedule =
5826 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5827
5828 let healthy = schedule.next_poll_with(
5829 None,
5830 PollConditions {
5831 quiet: true,
5832 quota: Some(quota(1_000, 3_000, now)),
5833 requests_per_pass: 0,
5834 },
5835 now,
5836 &NoJitter,
5837 );
5838 assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
5839
5840 let low = schedule.next_poll_with(
5841 None,
5842 PollConditions {
5843 quiet: true,
5844 quota: Some(quota(999, 3_000, now)),
5845 requests_per_pass: 0,
5846 },
5847 now,
5848 &NoJitter,
5849 );
5850 assert_eq!(low.delay, Duration::from_secs(120));
5851 assert_eq!(
5852 low.pace,
5853 PollPace::Stretched {
5854 pressure: PrimaryPressure::Low
5855 }
5856 );
5857 assert!(low.pace.is_throttled());
5858 assert_eq!(low.pace.as_str(), "stretched_low_budget");
5859
5860 let critical = schedule.next_poll_with(
5861 None,
5862 PollConditions {
5863 quiet: false,
5864 quota: Some(quota(100, 3_000, now)),
5865 requests_per_pass: 0,
5866 },
5867 now,
5868 &NoJitter,
5869 );
5870 assert_eq!(critical.delay, Duration::from_secs(240));
5871 assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
5872 }
5873
5874 #[test]
5875 fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
5876 let now = fixtures::created_at();
5877 let mut schedule = PollSchedule::new(RefreshInterval::default());
5878
5879 let soon = schedule.next_poll_with(
5880 None,
5881 PollConditions {
5882 quiet: false,
5883 quota: Some(quota(10, 90, now)),
5884 requests_per_pass: 0,
5885 },
5886 now,
5887 &NoJitter,
5888 );
5889 assert_eq!(
5890 soon.delay,
5891 Duration::from_secs(90),
5892 "the bucket refills in 90 s; waiting 240 s buys nothing"
5893 );
5894
5895 let imminent = schedule.next_poll_with(
5896 None,
5897 PollConditions {
5898 quiet: false,
5899 quota: Some(quota(10, 5, now)),
5900 requests_per_pass: 0,
5901 },
5902 now,
5903 &NoJitter,
5904 );
5905 assert_eq!(imminent.delay, Duration::from_secs(60));
5906
5907 let expired = schedule.next_poll_with(
5908 None,
5909 PollConditions {
5910 quiet: true,
5911 quota: Some(quota(10, -5, now)),
5912 requests_per_pass: 0,
5913 },
5914 now,
5915 &NoJitter,
5916 );
5917 assert_eq!(
5918 expired.pace,
5919 PollPace::Nominal,
5920 "a reading from a window that has reset says nothing; the schedule here had no \
5921 idle interval, so it is the active one"
5922 );
5923 }
5924
5925 #[test]
5926 fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
5927 let now = fixtures::created_at();
5928 let mut schedule =
5929 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5930 let small = schedule.next_poll_with(
5931 None,
5932 PollConditions {
5933 quiet: true,
5934 quota: None,
5935 requests_per_pass: 40,
5936 },
5937 now,
5938 &NoJitter,
5939 );
5940 assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
5941 let organization = schedule.next_poll_with(
5942 None,
5943 PollConditions {
5944 quiet: true,
5945 quota: None,
5946 requests_per_pass: 400,
5947 },
5948 now,
5949 &NoJitter,
5950 );
5951 assert_eq!(
5952 organization.delay,
5953 Duration::from_secs(80),
5954 "400 requests a pass at 300 a minute is one pass every 80 s"
5955 );
5956 assert_eq!(organization.pace, PollPace::Idle);
5957 }
5958
5959 #[test]
5960 fn a_rate_limit_wait_is_jittered_upward_only() {
5961 let now = fixtures::created_at();
5962 let mut schedule = PollSchedule::new(RefreshInterval::default());
5963 let limited = RefreshState::RateLimited(RateLimited {
5964 kind: RateLimitKind::Secondary,
5965 retry_after: Some(Duration::from_secs(300)),
5966 remaining: None,
5967 reset_unix_secs: None,
5968 });
5969 assert_eq!(
5970 schedule
5971 .next_poll(Some(&limited), now, &FixedJitter(0.5))
5972 .delay,
5973 Duration::from_secs(315),
5974 "every loop shares one quiet period; without jitter they all wake together"
5975 );
5976 assert_eq!(
5977 schedule.next_poll(Some(&limited), now, &NoJitter).delay,
5978 Duration::from_secs(300),
5979 "never shorter than GitHub asked"
5980 );
5981 }
5982
5983 #[tokio::test]
5984 async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
5985 let launcher =
5988 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
5989 let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
5990 let mut harness = Harness::build(
5991 host_with(1),
5992 launcher,
5993 demand,
5994 Arc::new(InProcessAllocationLock::new()),
5995 );
5996 harness
5997 .reconciler
5998 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5999 let policy = policy(1, "acme/app", 4);
6000
6001 let report = harness
6002 .reconciler
6003 .reconcile(std::slice::from_ref(&policy))
6004 .await;
6005
6006 assert_eq!(report.started, 0, "the host is full");
6007 assert_eq!(
6008 report.next_poll.pace,
6009 PollPace::Nominal,
6010 "matched demand it could not serve is work waiting, not idle"
6011 );
6012 }
6013
6014 #[tokio::test]
6015 async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
6016 let mut harness = Harness::simple(4, 0, "acme/app");
6017 harness
6018 .reconciler
6019 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6020 let policy = policy(1, "acme/app", 4);
6021
6022 let report = harness
6023 .reconciler
6024 .reconcile(std::slice::from_ref(&policy))
6025 .await;
6026
6027 assert_eq!(report.next_poll.pace, PollPace::Idle);
6028 assert_eq!(report.next_poll.delay, Duration::from_secs(10));
6029 }
6030
6031 #[tokio::test]
6032 async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
6033 let mut harness = Harness::simple(4, 0, "acme/app");
6034 harness
6035 .reconciler
6036 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6037 harness.demand.set(PollOutcome::Ready(
6038 QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
6039 ));
6040 let policy = policy(1, "acme/app", 4);
6041
6042 let report = harness
6043 .reconciler
6044 .reconcile(std::slice::from_ref(&policy))
6045 .await;
6046
6047 assert_eq!(report.next_poll.pace, PollPace::Nominal);
6048 assert_eq!(report.next_poll.delay, Duration::from_secs(60));
6049 }
6050
6051 #[tokio::test]
6052 async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
6053 let launcher =
6054 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
6055 let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
6056 let mut harness = Harness::build(
6057 host_with(4),
6058 launcher,
6059 demand,
6060 Arc::new(InProcessAllocationLock::new()),
6061 );
6062 harness
6063 .reconciler
6064 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6065 let policy = policy(1, "acme/app", 4);
6066
6067 let report = harness
6068 .reconciler
6069 .reconcile(std::slice::from_ref(&policy))
6070 .await;
6071
6072 assert_eq!(
6073 report.next_poll.pace,
6074 PollPace::Nominal,
6075 "a job is running: its follow-on work is what the active interval watches for"
6076 );
6077 }
6078
6079 #[tokio::test]
6080 async fn a_pass_that_starts_a_runner_is_not_idle() {
6081 let mut harness = Harness::simple(4, 2, "acme/app");
6082 harness
6083 .reconciler
6084 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6085 let policy = policy(1, "acme/app", 4);
6086
6087 let report = harness
6088 .reconciler
6089 .reconcile(std::slice::from_ref(&policy))
6090 .await;
6091
6092 assert!(report.started > 0);
6093 assert_eq!(report.next_poll.pace, PollPace::Nominal);
6094 }
6095
6096 #[tokio::test]
6097 async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
6098 let mut harness = Harness::simple(4, 0, "acme/app");
6099 harness
6100 .reconciler
6101 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6102 let now = harness.clock.now();
6103 harness.demand.set(PollOutcome::Ready(
6104 QueuedDemand::of(repo("acme/app"), Vec::new())
6105 .with_rate_limit(Some(quota(200, 1_800, now))),
6106 ));
6107 let policy = policy(1, "acme/app", 4);
6108
6109 let report = harness
6110 .reconciler
6111 .reconcile(std::slice::from_ref(&policy))
6112 .await;
6113
6114 assert_eq!(
6115 report.next_poll.pace,
6116 PollPace::Stretched {
6117 pressure: PrimaryPressure::Critical
6118 }
6119 );
6120 assert_eq!(report.next_poll.delay, Duration::from_secs(240));
6121 }
6122
6123 #[tokio::test]
6132 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
6133 let live = vec![
6134 attempt_in(AttemptState::Busy, 1, 1),
6135 attempt_in(AttemptState::Starting, 2, 1),
6136 ];
6137 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
6138 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6139 let mut harness = Harness::build(
6140 host_with(8),
6141 Arc::clone(&launcher),
6142 Arc::clone(&demand),
6143 Arc::new(InProcessAllocationLock::new()),
6144 );
6145 let policy = policy(1, "acme/app", 8);
6146
6147 let report = harness
6148 .reconciler
6149 .reconcile(std::slice::from_ref(&policy))
6150 .await;
6151
6152 assert!(report.is_offline());
6153 assert_eq!(report.started, 0, "no new runner during an outage");
6154 assert_eq!(launcher.launches(), 0);
6155 assert_eq!(
6156 launcher.snapshot(),
6157 live,
6158 "existing runner processes are retained, untouched"
6159 );
6160 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6161 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6162 assert!(report.next_poll.delay >= Duration::from_secs(60));
6163 let offline = report.offline_state().expect("an offline state to display");
6164 assert!(offline.to_string().contains("24 hours"));
6165
6166 demand.set(PollOutcome::Ready(QueuedDemand::of(
6169 repo("acme/app"),
6170 jobs(2),
6171 )));
6172 let recovered = harness.reconciler.reconcile(&[policy]).await;
6173
6174 assert!(!recovered.is_offline());
6175 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
6176 assert_eq!(
6177 recovered.started, 1,
6178 "two queued jobs and one starting runner leave one job uncovered"
6179 );
6180 assert_eq!(recovered.allocations[0].active_owned, 2);
6181 assert_eq!(launcher.live_count(), 3);
6182 }
6183
6184 #[tokio::test]
6194 async fn one_offline_target_does_not_stop_a_reachable_one() {
6195 let mut harness = Harness::simple(8, 0, "acme/app");
6196 harness.demand.set_for(
6197 &ScaleTarget::repository("acme/app").unwrap(),
6198 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
6199 );
6200 harness.demand.set_for(
6201 &ScaleTarget::repository("acme/broken").unwrap(),
6202 PollOutcome::Failed(RefreshState::Offline),
6203 );
6204
6205 let report = harness
6206 .reconciler
6207 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
6208 .await;
6209
6210 assert_eq!(
6211 report.started, 2,
6212 "the reachable target was served; an unreachable sibling repository must not \
6213 idle the host"
6214 );
6215 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
6216 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
6217
6218 assert!(report.is_offline());
6220 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6221 }
6222
6223 #[tokio::test]
6238 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
6239 let clock = Arc::new(FakeClock::default());
6240 let launcher = Arc::new(FakeLauncher::new());
6241 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6242 let mut reconciler = Reconciler::new(
6243 host_with(4),
6244 ReconcilerPorts {
6245 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
6246 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6247 lock: Arc::new(InProcessAllocationLock::new()),
6248 directory: Arc::new(FakeDirectory::default()),
6249 clock: Arc::clone(&clock) as Arc<dyn Clock>,
6250 jitter: Arc::new(NoJitter),
6251 events: Arc::new(NoEvents),
6252 },
6253 );
6254 let policy = policy(1, "acme/app", 4);
6255
6256 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6258 let state = first.offline_state().expect("an offline state");
6259 assert!(!state.has_outlasted_the_queue());
6260 assert!(
6261 state
6262 .to_string()
6263 .contains("an outage longer than that loses"),
6264 "{state}"
6265 );
6266
6267 clock.advance_secs(24 * 60 * 60 + 60);
6269 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6270 let state = later.offline_state().expect("an offline state");
6271 assert!(state.has_outlasted_the_queue());
6272 assert!(
6273 state.to_string().contains("queued work has been lost"),
6274 "{state}"
6275 );
6276 assert_eq!(launcher.launches(), 0, "and still nothing was started");
6277
6278 demand.set(PollOutcome::Ready(QueuedDemand::of(
6281 repo("acme/app"),
6282 jobs(0),
6283 )));
6284 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6285 assert!(recovered.offline_state().is_none());
6286 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
6287
6288 demand.set(PollOutcome::Failed(RefreshState::Offline));
6289 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6290 assert!(
6291 !again
6292 .offline_state()
6293 .expect("an offline state")
6294 .has_outlasted_the_queue(),
6295 "a new outage must not inherit the age of the one before it"
6296 );
6297 }
6298
6299 #[tokio::test]
6315 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
6316 let root = tempfile::tempdir().expect("a temporary directory");
6317 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
6318 root.path(),
6319 ));
6320 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
6321
6322 let held = lock.acquire().await.expect("an uncontended lock is free");
6323 assert!(
6324 matches!(lock.acquire().await, Err(AllocationLockBusy)),
6325 "a second holder was admitted; on Unix the lock is per open file description \
6326 and on Windows the share mode denies write, so this must be refused even \
6327 from inside the same process"
6328 );
6329
6330 drop(held);
6331 let regained = lock.acquire().await;
6332 assert!(
6333 regained.is_ok(),
6334 "dropping the guard is the only release there is, so a guard that does not \
6335 hold the `HostLock` leaves it held forever"
6336 );
6337 }
6338
6339 #[test]
6340 fn tee_events_reaches_both_sinks() {
6341 let left = Arc::new(EventLog::new());
6345 let right = Arc::new(EventLog::new());
6346 let tee = TeeEvents(
6347 Arc::clone(&left) as Arc<dyn EventSink>,
6348 Arc::clone(&right) as Arc<dyn EventSink>,
6349 );
6350
6351 tee.emit(LifecycleEvent::MonitorOnlySkipped {
6352 policy: PolicyId::from_u128(1),
6353 });
6354
6355 assert_eq!(left.count_of("monitor_only_skipped"), 1);
6356 assert_eq!(right.count_of("monitor_only_skipped"), 1);
6357 }
6358
6359 #[tokio::test]
6364 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
6365 let clock = Arc::new(FakeClock::default());
6366 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
6367 let cache = RepositoryCache::new(
6368 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6369 Arc::clone(&clock) as Arc<dyn Clock>,
6370 RefreshInterval::default(),
6371 );
6372 let target = ScaleTarget::organization("acme").unwrap();
6373
6374 assert_eq!(
6375 cache.ttl(),
6376 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
6377 );
6378
6379 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
6381 let scope = cache.scope_for(&target).await.unwrap();
6382 assert_eq!(scope.repositories().len(), 2);
6383 clock.advance_secs(60);
6384 }
6385 assert_eq!(
6386 directory.calls(),
6387 1,
6388 "re-listing an organization at demand-poll frequency is what exhausts the \
6389 shared request budget"
6390 );
6391 assert_eq!(cache.lookups(), 1);
6392
6393 cache.scope_for(&target).await.unwrap();
6395 assert_eq!(directory.calls(), 2);
6396 }
6397
6398 #[tokio::test]
6399 async fn a_repository_target_never_consults_the_directory() {
6400 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
6401 let cache = RepositoryCache::new(
6402 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6403 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6404 RefreshInterval::default(),
6405 );
6406 let target = ScaleTarget::repository("acme/app").unwrap();
6407
6408 let scope = cache.scope_for(&target).await.unwrap();
6409 assert_eq!(scope.repositories(), &[repo("acme/app")]);
6410 assert_eq!(directory.calls(), 0);
6411 }
6412
6413 #[tokio::test]
6414 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
6415 let mut harness = Harness::simple(8, 4, "o/r");
6419 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6420 repo("o/r"),
6421 [
6422 fixtures::queued_job(&[HOST_LABEL]),
6423 fixtures::queued_job(&[HOST_LABEL]),
6424 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6425 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6426 ],
6427 )));
6428 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6429 isolated.activate().unwrap();
6430 let report = harness
6431 .reconciler
6432 .reconcile(&[policy(1, "o/r", 2), isolated])
6433 .await;
6434
6435 assert_eq!(harness.demand.polls().len(), 1);
6436 assert_eq!(report.targets_read, 1, "profiles share one target reading");
6437 assert_eq!(
6438 report.demand_requests,
6439 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
6440 "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"
6441 );
6442 assert_eq!(
6443 report.started, 4,
6444 "native and isolated profiles each get two"
6445 );
6446 }
6447
6448 #[tokio::test]
6449 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
6450 use runner_manager_domain::model::{Arch, HostLabel, Os};
6451 use runner_manager_domain::policy::RoutingLabels;
6452 let mut harness = Harness::build(
6453 fixtures::host()
6454 .os(Os::MacOs)
6455 .architecture(Arch::Arm64)
6456 .capacity(10)
6457 .build(),
6458 Arc::new(FakeLauncher::new()),
6459 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
6460 Arc::new(InProcessAllocationLock::new()),
6461 );
6462 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6463 repo("o/r"),
6464 [
6465 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
6466 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
6467 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
6468 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
6469 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
6470 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
6471 ],
6472 )));
6473 let mut local = fixtures::policy()
6474 .id(PolicyId::from_u128(1))
6475 .repository("o/r")
6476 .autoscale("macmini", 10)
6477 .routing_labels(RoutingLabels::derive(
6478 &HostLabel::new("macmini").unwrap(),
6479 Os::MacOs,
6480 Arch::Arm64,
6481 ))
6482 .active()
6483 .build();
6484 for label in ["self-hosted", "macos", "arm64", "extra"] {
6485 local.add_routing_label(fixtures::label(label)).unwrap();
6486 }
6487 let mut foreign = local.to_persisted();
6488 foreign.id = PolicyId::from_u128(2);
6489 foreign.host_id = HostId::from_u128(0xdead);
6490 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
6491 let report = harness.reconciler.reconcile(&[local, foreign]).await;
6492 assert_eq!(report.started, 2);
6493 assert_eq!(report.targets_read, 1);
6494 assert!(harness.events.events().iter().any(|event| matches!(
6495 event,
6496 LifecycleEvent::DemandObserved {
6497 demand: 2,
6498 not_matched: 4,
6499 ..
6500 }
6501 )));
6502 assert!(
6503 !harness
6504 .events
6505 .events()
6506 .iter()
6507 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6508 );
6509 }
6510
6511 #[tokio::test]
6512 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
6513 use runner_manager_domain::execution::{
6514 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6515 };
6516 let mut native = policy(1, "o/r", 2);
6517 native
6518 .add_routing_label(fixtures::label("self-hosted"))
6519 .unwrap();
6520 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6521 isolated
6522 .add_routing_label(fixtures::label("self-hosted"))
6523 .unwrap();
6524 isolated
6525 .set_execution_policy(ExecutionPolicy::Isolated {
6526 backend: Backend::Oci,
6527 image: ImageReference::new(format!(
6528 "registry.example/runner@sha256:{}",
6529 "a".repeat(64)
6530 ))
6531 .unwrap(),
6532 resources: ResourceLimits {
6533 cpu_millis: 1000,
6534 memory_mib: 512,
6535 disk_mib: 1024,
6536 },
6537 })
6538 .unwrap();
6539 assert!(!isolated.may_start_runners());
6540 let mut active_isolated = isolated.clone();
6541 active_isolated.activate().unwrap();
6542 for profiles in [
6543 [native.clone(), isolated.clone()],
6544 [isolated.clone(), native.clone()],
6545 [native.clone(), active_isolated.clone()],
6546 [active_isolated, native.clone()],
6547 ] {
6548 let mut harness = Harness::simple(4, 0, "o/r");
6549 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6550 repo("o/r"),
6551 [fixtures::queued_job(&["SELF-HOSTED"])],
6552 )));
6553 let report = harness.reconciler.reconcile(&profiles).await;
6554 assert_eq!(report.started, 0);
6555 assert!(harness.events.events().iter().any(|event| matches!(
6556 event,
6557 LifecycleEvent::RoutingRefused {
6558 reason: RoutingRefusal::OverlappingProfiles,
6559 count: 1,
6560 ..
6561 }
6562 )));
6563 }
6564 let mut harness = Harness::simple(4, 0, "o/r");
6567 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6568 repo("o/r"),
6569 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
6570 )));
6571 assert_eq!(
6572 harness
6573 .reconciler
6574 .reconcile(&[native, isolated])
6575 .await
6576 .started,
6577 0
6578 );
6579 }
6580
6581 #[tokio::test]
6582 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
6583 let mut harness = Harness::simple(2, 0, "o/r");
6584 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6585 repo("o/r"),
6586 [
6587 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
6588 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
6589 fixtures::queued_job(&["gpu"]),
6590 fixtures::unresolvable_job(),
6591 ],
6592 )));
6593 let mut native = policy(1, "o/r", 2);
6594 native.add_routing_label(fixtures::label("gpu")).unwrap();
6595 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6596 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
6597 isolated.activate().unwrap();
6598
6599 let report = harness.reconciler.reconcile(&[native, isolated]).await;
6600 assert_eq!(harness.demand.polls().len(), 1);
6601 assert_eq!(report.targets_read, 1);
6602 assert_eq!(report.started, 2);
6603 assert_eq!(report.allocations.len(), 2);
6604 assert!(
6605 report
6606 .allocations
6607 .iter()
6608 .all(|allocation| allocation.to_start == 1)
6609 );
6610 assert!(harness.events.events().iter().any(|event| matches!(
6611 event,
6612 LifecycleEvent::RoutingRefused {
6613 reason: RoutingRefusal::OverlappingProfiles,
6614 count: 1,
6615 ..
6616 }
6617 )));
6618 assert_eq!(
6619 harness
6620 .events
6621 .events()
6622 .iter()
6623 .filter(|event| matches!(
6624 event,
6625 LifecycleEvent::DemandObserved {
6626 unresolvable: 1,
6627 ..
6628 }
6629 ))
6630 .count(),
6631 2,
6632 "an expression stays unresolvable for both profiles"
6633 );
6634 }
6635
6636 #[tokio::test]
6637 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
6638 let mut harness = Harness::simple(4, 0, "o/r");
6639 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6640 repo("o/r"),
6641 [fixtures::queued_job(&[
6642 HOST_LABEL,
6643 "rm-home-win-x64-py-isolated",
6644 ])],
6645 )));
6646 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6647 isolated.activate().unwrap();
6648 let report = harness
6649 .reconciler
6650 .reconcile(&[policy(1, "o/r", 2), isolated])
6651 .await;
6652 assert_eq!(report.started, 0);
6653 assert_eq!(
6654 harness
6655 .events
6656 .events()
6657 .iter()
6658 .filter(|event| matches!(
6659 event,
6660 LifecycleEvent::DemandObserved {
6661 demand: 0,
6662 not_matched: 1,
6663 ..
6664 }
6665 ))
6666 .count(),
6667 2
6668 );
6669
6670 let mut superset = policy(1, "o/r", 2);
6674 assert!(
6675 superset
6676 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
6677 .unwrap()
6678 );
6679 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6680 sibling.activate().unwrap();
6681 let mut unique = Harness::simple(4, 0, "o/r");
6682 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
6683 repo("o/r"),
6684 [fixtures::queued_job(&[
6685 HOST_LABEL,
6686 "rm-home-win-x64-py-isolated",
6687 ])],
6688 )));
6689 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
6690 assert_eq!(report.started, 1);
6691 assert_eq!(
6692 report
6693 .allocations
6694 .iter()
6695 .find(|allocation| allocation.to_start == 1)
6696 .unwrap()
6697 .policy_id,
6698 PolicyId::from_u128(1)
6699 );
6700 assert!(
6701 !unique
6702 .events
6703 .events()
6704 .iter()
6705 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6706 );
6707
6708 let mut overlap = Harness::simple(4, 1, "o/r");
6709 let report = overlap
6710 .reconciler
6711 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
6712 .await;
6713 assert_eq!(report.started, 0);
6714 assert!(overlap.events.events().iter().any(|event| matches!(
6715 event,
6716 LifecycleEvent::RoutingRefused {
6717 reason: RoutingRefusal::OverlappingProfiles,
6718 count: 1,
6719 ..
6720 }
6721 )));
6722 assert!(
6723 RoutingRefusal::OverlappingProfiles
6724 .to_string()
6725 .contains("repair")
6726 );
6727 }
6728
6729 #[tokio::test]
6734 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
6735 let launcher = Arc::new(FakeLauncher::new());
6736 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
6737 let mut harness = Harness::build(
6738 host_with(4),
6739 Arc::clone(&launcher),
6740 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6741 Arc::new(InProcessAllocationLock::new()),
6742 );
6743
6744 let report = harness
6745 .reconciler
6746 .reconcile(&[policy(1, "acme/app", 4)])
6747 .await;
6748 assert_eq!(report.started, 0);
6749 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
6750
6751 let failures: Vec<&'static str> = harness
6752 .events
6753 .events()
6754 .into_iter()
6755 .filter_map(|event| match event {
6756 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6757 _ => None,
6758 })
6759 .collect();
6760 assert_eq!(failures, vec!["other"]);
6761 assert!(
6762 !failures[0].contains("ghp_"),
6763 "an event carried a `FailureReason::Other` detail verbatim"
6764 );
6765 }
6766
6767 #[tokio::test]
6772 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
6773 let launcher = Arc::new(FakeLauncher::new());
6774 let unfit = Arc::new(AtomicBool::new(true));
6775 let events = Arc::new(EventLog::new());
6776 let mut reconciler = Reconciler::new(
6777 host_with(4),
6778 ReconcilerPorts {
6779 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
6780 launcher: Arc::new(FitnessGatedLauncher::new(
6781 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6782 Arc::clone(&unfit),
6783 )),
6784 lock: Arc::new(InProcessAllocationLock::new()),
6785 directory: Arc::new(FakeDirectory::default()),
6786 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6787 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
6788 events: Arc::clone(&events) as Arc<dyn EventSink>,
6789 },
6790 );
6791 let policies = [policy(1, "acme/app", 4)];
6792
6793 let report = reconciler.reconcile(&policies).await;
6794 assert_eq!(report.started, 0);
6795 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
6796 let reasons: Vec<&'static str> = events
6797 .events()
6798 .into_iter()
6799 .filter_map(|event| match event {
6800 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6801 _ => None,
6802 })
6803 .collect();
6804 assert_eq!(reasons, ["host_unfit"]);
6805
6806 unfit.store(false, Ordering::Release);
6807 let report = reconciler.reconcile(&policies).await;
6808 assert_eq!(report.started, 2);
6809 assert_eq!(launcher.launches(), 2);
6810 }
6811
6812 #[tokio::test]
6813 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
6814 let launcher = Arc::new(FakeLauncher::new());
6815 let mut harness = Harness::build(
6816 host_with(4),
6817 Arc::clone(&launcher),
6818 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6819 Arc::new(HeldLock),
6820 );
6821
6822 let report = harness
6823 .reconciler
6824 .reconcile(&[policy(1, "acme/app", 4)])
6825 .await;
6826 assert_eq!(report.started, 0);
6827 assert_eq!(
6828 report.deferred, 3,
6829 "three runners were granted and none was created; `deferred` counts grants, \
6830 not policies -- it reported `1` when a policy that launched two of five and \
6831 then lost the lock had left three unstarted"
6832 );
6833 assert_eq!(launcher.launches(), 0);
6834 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
6835 assert!(
6836 harness
6837 .events
6838 .events()
6839 .iter()
6840 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
6841 "the event carries the same number the report does"
6842 );
6843 assert_eq!(
6844 report.allocations.len(),
6845 1,
6846 "the intent is still reported, so an operator staring at a queue sees why \
6847 nothing started"
6848 );
6849 }
6850
6851 #[tokio::test]
6852 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
6853 let mut harness = Harness::simple(8, 5, "acme/app");
6854
6855 let foreign = fixtures::policy()
6856 .id(PolicyId::from_u128(1))
6857 .repository("acme/app")
6858 .host(HostId::from_u128(0xdead))
6859 .autoscale("office", 4)
6860 .active()
6861 .build();
6862 let mut draining = policy(2, "acme/app", 4);
6863 draining.request_disable().unwrap();
6864
6865 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
6866
6867 assert_eq!(report.started, 0);
6868 assert_eq!(
6869 harness.demand.polls().len(),
6870 0,
6871 "neither can act on an answer"
6872 );
6873 let factors: Vec<LimitingFactor> = report
6874 .allocations
6875 .iter()
6876 .map(|a| a.limiting_factor)
6877 .collect();
6878 assert!(factors.contains(&LimitingFactor::ForeignHost));
6879 assert!(factors.contains(&LimitingFactor::NotReconciling));
6880 }
6881
6882 #[tokio::test]
6883 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
6884 #[derive(Debug)]
6887 struct BrokenDirectory;
6888
6889 #[async_trait::async_trait]
6890 impl RepositoryDirectory for BrokenDirectory {
6891 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
6892 Err(InventoryError::Cancelled)
6893 }
6894 }
6895
6896 let launcher = Arc::new(FakeLauncher::new());
6897 let events = Arc::new(EventLog::new());
6898 let mut reconciler = Reconciler::new(
6899 host_with(4),
6900 ReconcilerPorts {
6901 demand: Arc::new(FakeDemand::default()),
6902 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6903 lock: Arc::new(InProcessAllocationLock::new()),
6904 directory: Arc::new(BrokenDirectory),
6905 clock: Arc::new(FakeClock::default()),
6906 jitter: Arc::new(NoJitter),
6907 events: Arc::clone(&events) as Arc<dyn EventSink>,
6908 },
6909 );
6910
6911 let org_policy = fixtures::policy()
6912 .id(PolicyId::from_u128(1))
6913 .organization("acme")
6914 .autoscale("home", 4)
6915 .active()
6916 .build();
6917
6918 let report = reconciler.reconcile(&[org_policy]).await;
6919 assert_eq!(report.started, 0);
6920 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6921 assert_eq!(events.count_of("target_unreadable"), 1);
6922 }
6923
6924 fn every_event() -> Vec<LifecycleEvent> {
6936 let policy = PolicyId::from_u128(0xabcd_ef01);
6937 let attempt = AttemptId::from_u128(0x1234_5678);
6938 vec![
6939 LifecycleEvent::DemandObserved {
6940 policy,
6941 demand: u32::MAX,
6942 not_matched: u32::MAX,
6943 unresolvable: u32::MAX,
6944 complete: false,
6945 },
6946 LifecycleEvent::TargetUnreadable {
6947 policy,
6948 reason: unreadable_reason(&RefreshState::Failed {
6949 status: Some(500),
6950 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
6951 .into(),
6952 }),
6953 },
6954 LifecycleEvent::Allocated {
6955 policy,
6956 demand: u32::MAX,
6957 desired: u16::MAX,
6958 active_owned: 7,
6959 headroom: 9,
6960 to_start: 2,
6961 limiting: LimitingFactor::HostCapacity,
6962 },
6963 LifecycleEvent::MonitorOnlySkipped { policy },
6964 LifecycleEvent::RunnerStarted { policy, attempt },
6965 LifecycleEvent::RunnerStartFailed {
6966 policy,
6967 reason: failure_reason_kind(&FailureReason::Other(
6968 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6969 )),
6970 },
6971 LifecycleEvent::AllocationDeferred { policy, count: 4 },
6972 LifecycleEvent::AttemptsUnreadable {
6973 reason: failure_reason_kind(&FailureReason::Other(
6974 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6975 )),
6976 },
6977 LifecycleEvent::HostCapacityUnreadable {
6978 reason: HostCapacityError::MissingHost,
6979 },
6980 LifecycleEvent::HostCapacityUnreadable {
6981 reason: HostCapacityError::JournalUnreadable,
6982 },
6983 LifecycleEvent::AttemptCleanFailed {
6984 policy,
6985 attempt,
6986 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
6987 },
6988 LifecycleEvent::AttemptCleaned {
6989 policy,
6990 attempt,
6991 outcome: OutcomeKind::IdleExit,
6992 },
6993 LifecycleEvent::ScaleDownRefused { policy, attempt },
6994 LifecycleEvent::PollScheduled {
6995 retry_in_ms: 900_000,
6996 pace: PollPace::RateLimited {
6997 kind: RateLimitKind::Primary,
6998 },
6999 },
7000 ]
7001 }
7002
7003 #[test]
7012 fn no_emitted_event_can_carry_a_credential() {
7013 use runner_manager_platform::logging::redact;
7014
7015 for event in every_event() {
7016 let displayed = event.to_string();
7017 assert_eq!(
7018 redact(&displayed),
7019 displayed,
7020 "`{}` renders something `d1`'s sink would have to redact",
7021 event.name()
7022 );
7023
7024 let debugged = format!("{event:?}");
7025 assert_eq!(
7026 redact(&debugged),
7027 debugged,
7028 "`{}`'s Debug renders something `d1`'s sink would have to redact",
7029 event.name()
7030 );
7031 }
7032
7033 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
7036 assert_ne!(
7037 redact(secret),
7038 secret,
7039 "the scan above proves nothing if `redact` no longer recognises a credential"
7040 );
7041 }
7042
7043 #[test]
7044 fn every_field_name_this_sink_emits_is_one_d1_allows() {
7045 use runner_manager_platform::logging::is_field_allowed;
7046
7047 for field in [
7051 "event",
7052 "policy_id",
7053 "attempt_id",
7054 "attempt_state",
7055 "demand",
7056 "desired",
7057 "capacity",
7058 "headroom",
7059 "count",
7060 "reason",
7061 "outcome",
7062 "mode",
7063 "lock",
7064 "retry_in_ms",
7065 "state",
7066 ] {
7067 assert!(
7068 is_field_allowed(field),
7069 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
7070 `[redacted]` in its place and the line would lose its meaning"
7071 );
7072 }
7073 }
7074
7075 #[test]
7076 fn every_failure_reason_has_a_credential_free_kind() {
7077 for reason in FailureReason::ALL {
7078 let kind = failure_reason_kind(&reason);
7079 assert!(!kind.is_empty());
7080 assert!(
7081 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
7082 "`{kind}` is not a fixed identifier"
7083 );
7084 }
7085 assert_eq!(
7086 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
7087 "other",
7088 "the detail of an `Other` reason never reaches an event"
7089 );
7090 let categories = [
7091 IsolationProviderFailure::NotInstalled,
7092 IsolationProviderFailure::PermissionDenied,
7093 IsolationProviderFailure::ImageUnavailableOrIncompatible,
7094 IsolationProviderFailure::DiskQuotaUnavailable,
7095 IsolationProviderFailure::RuntimeOperationFailed,
7096 ];
7097 let mut kinds = std::collections::BTreeSet::new();
7098 for category in categories {
7099 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
7100 assert_ne!(kind, "other");
7101 assert!(!kind.contains("secret"));
7102 kinds.insert(kind);
7103 }
7104 assert_eq!(kinds.len(), categories.len());
7105 }
7106
7107 fn production_half_of(source: &str) -> String {
7122 let production = source
7123 .split_once("\n#[cfg(test)]")
7124 .map_or(source, |(production, _)| production);
7125 production
7126 .lines()
7127 .filter(|line| !line.trim_start().starts_with("//"))
7128 .collect::<Vec<_>>()
7129 .join("\n")
7130 }
7131
7132 fn this_file_above_its_tests_without_prose() -> String {
7134 production_half_of(include_str!("reconcile.rs"))
7135 }
7136
7137 fn normalise_for_scan(text: &str) -> String {
7156 text.to_ascii_lowercase().replace(['_', ' '], "")
7157 }
7158
7159 const FORBIDDEN: &[&str] = &[
7169 concat!("fn ", "acquire", "_job"),
7170 concat!("fn ", "claim", "_job"),
7171 concat!("fn ", "lease", "_job"),
7172 concat!("fn ", "reserve", "_job"),
7173 concat!("fn ", "ack", "nowledge"),
7174 concat!("struct ", "Job", "Lease"),
7175 concat!("struct ", "Job", "Claim"),
7176 concat!("struct ", "Job", "Reservation"),
7177 ];
7178
7179 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
7180 let haystack = normalise_for_scan(source);
7181 FORBIDDEN
7182 .iter()
7183 .copied()
7184 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
7185 }
7186
7187 #[test]
7221 fn nothing_in_this_crate_reserves_or_claims_a_job() {
7222 const SOURCES: &[(&str, &str)] = &[
7223 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
7224 ("docker_cleanup.rs", include_str!("docker_cleanup.rs")),
7225 (
7226 "docker_cleanup/http.rs",
7227 include_str!("docker_cleanup/http.rs"),
7228 ),
7229 (
7230 "docker_cleanup/proxy.rs",
7231 include_str!("docker_cleanup/proxy.rs"),
7232 ),
7233 (
7234 "docker_cleanup/tests.rs",
7235 include_str!("docker_cleanup/tests.rs"),
7236 ),
7237 ("lib.rs", include_str!("lib.rs")),
7238 ("lifecycle.rs", include_str!("lifecycle.rs")),
7239 (
7240 "lifecycle/windows_hyperv.rs",
7241 include_str!("lifecycle/windows_hyperv.rs"),
7242 ),
7243 ("oci.rs", include_str!("oci.rs")),
7244 ("package.rs", include_str!("package.rs")),
7245 ("reconcile.rs", include_str!("reconcile.rs")),
7246 ];
7247
7248 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
7249 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
7250 let entry = entry.expect("a readable directory entry");
7251 let name = entry.file_name().to_string_lossy().into_owned();
7252 let joined = if prefix.is_empty() {
7255 name.clone()
7256 } else {
7257 format!("{prefix}/{name}")
7258 };
7259 if entry.path().is_dir() {
7260 walk(&entry.path(), &joined, found);
7261 } else if name.ends_with(".rs") {
7262 found.push(joined);
7263 }
7264 }
7265 }
7266
7267 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
7268 listed.sort_unstable();
7269 let mut on_disk = Vec::new();
7270 walk(
7271 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
7272 "",
7273 &mut on_disk,
7274 );
7275 on_disk.sort_unstable();
7276 assert_eq!(
7277 listed, on_disk,
7278 "a source file was added or removed; this scan claims to cover the whole crate \
7279 and a stale list makes that claim false"
7280 );
7281
7282 for (name, source) in SOURCES {
7283 assert_eq!(
7284 forbidden_shape_in(&production_half_of(source)),
7285 None,
7286 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
7287 REST, and a local lease coordinates this host with itself and with nothing \
7288 else. If an owner decision restored one, that decision belongs in this \
7289 module's documentation and in this test before it belongs in the code"
7290 );
7291 }
7292
7293 assert!(
7296 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
7297 "the scan above proves nothing if the needles no longer match"
7298 );
7299 }
7300
7301 #[test]
7329 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
7330 let production = this_file_above_its_tests_without_prose();
7331
7332 assert!(
7333 production.contains("DemandTally"),
7334 "the reconciliation loop must tally queued jobs against this policy's routing \
7335 labels. A production half that named `DemandTally` nowhere would mean the \
7336 label filtering had been removed, and a repository whose jobs target \
7337 `ubuntu-latest` would drive its policy toward `max_capacity` again"
7338 );
7339
7340 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
7341 assert!(
7342 !production.contains(second_implementation),
7343 "the reconciliation loop names `{second_implementation}`, which is the \
7344 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
7345 has already decided it. This module applies the predicate and does not \
7346 re-implement it; if an owner decision changed that, it belongs in this \
7347 module's documentation and in this test before it belongs in the code"
7348 );
7349 }
7350 }
7351
7352 #[test]
7360 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
7361 let policy = policy(1, "acme/app", 10);
7362 let reading = QueuedDemand::of(
7363 repo("acme/app"),
7364 [
7365 fixtures::queued_job(&[HOST_LABEL]),
7366 fixtures::queued_job(&[HOST_LABEL]),
7367 fixtures::queued_job(&["ubuntu-latest"]),
7368 fixtures::unresolvable_job(),
7369 ],
7370 );
7371
7372 let tally = demand_for(&policy, &reading);
7373
7374 assert_eq!(
7375 tally.demand(),
7376 2,
7377 "only the jobs whose required labels this policy carries are demand"
7378 );
7379 assert_eq!(
7380 tally.not_matched, 1,
7381 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
7382 runner that idles until it times out"
7383 );
7384 assert_eq!(
7385 tally.unresolvable.len(),
7386 1,
7387 "an unresolvable `runs-on` is never demand and never discarded"
7388 );
7389 }
7390
7391 #[test]
7394 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
7395 let mut per_repository = BTreeMap::new();
7396 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
7397 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
7398 let reading = QueuedDemand::new(per_repository);
7399
7400 let repository_policy = policy(1, "acme/left", 10);
7401 assert_eq!(
7402 demand_for(&repository_policy, &reading).demand(),
7403 3,
7404 "a repository target reads its own repository's queue and not the aggregate"
7405 );
7406
7407 let org_policy = fixtures::policy()
7408 .id(PolicyId::from_u128(2))
7409 .organization("acme")
7410 .autoscale("home", 10)
7411 .active()
7412 .build();
7413 assert_eq!(
7414 demand_for(&org_policy, &reading).demand(),
7415 7,
7416 "an organization policy serves any repository in its scope, so its demand is \
7417 the whole aggregate's"
7418 );
7419 }
7420
7421 #[test]
7422 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
7423 let host = host_with(2);
7428 let policy = policy(1, "acme/app", 5);
7429 let attempts: Vec<RunnerAttempt> = Vec::new();
7430 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
7431
7432 let allocation = allocator.allocate(&policy, u32::MAX);
7433 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
7434 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
7435 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
7436 }
7437
7438 #[derive(Debug, Default)]
7441 struct FixedDirector {
7442 directed: Mutex<BTreeMap<PolicyId, u32>>,
7443 started: Mutex<Vec<(PolicyId, AttemptId)>>,
7444 failed: Mutex<Vec<(PolicyId, Option<AttemptId>)>>,
7445 older: AtomicUsize,
7447 }
7448
7449 impl DemandDirector for FixedDirector {
7450 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32> {
7451 self.directed.lock().unwrap().get(&policy.id).copied()
7452 }
7453
7454 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId) {
7455 self.started.lock().unwrap().push((policy.id, attempt));
7456 }
7457
7458 fn start_failed(&self, policy: &ScalePolicy, attempt: Option<AttemptId>) {
7459 self.failed.lock().unwrap().push((policy.id, attempt));
7460 }
7461
7462 fn older_waiting(&self, _policy: &ScalePolicy) -> bool {
7463 self.older
7464 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
7465 left.checked_sub(1)
7466 })
7467 .is_ok()
7468 }
7469 }
7470
7471 #[derive(Debug)]
7474 struct HeldBringUp {
7475 inner: Arc<FakeLauncher>,
7476 barrier: Arc<tokio::sync::Barrier>,
7477 fail: Option<FailureReason>,
7478 }
7479
7480 #[async_trait::async_trait]
7481 impl RunnerLauncher for HeldBringUp {
7482 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
7483 self.inner.host_capacity(host).await
7484 }
7485
7486 async fn supervise(
7487 &self,
7488 policy: &ScalePolicy,
7489 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
7490 self.inner.supervise(policy).await
7491 }
7492
7493 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
7494 self.inner.attempts().await
7495 }
7496
7497 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
7498 self.inner.launch(request).await
7499 }
7500
7501 async fn finish_launch(
7502 &self,
7503 _policy: &ScalePolicy,
7504 attempt: RunnerAttempt,
7505 _lock_wait: Duration,
7506 ) -> Result<RunnerAttempt, LaunchFailure> {
7507 self.barrier.wait().await;
7508 match &self.fail {
7509 Some(reason) => Err(LaunchFailure::new(reason.clone())),
7510 None => Ok(attempt),
7511 }
7512 }
7513
7514 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
7515 self.inner.clean(attempt).await
7516 }
7517 }
7518
7519 fn directed_reconciler(
7520 capacity: u16,
7521 launcher: Arc<dyn RunnerLauncher>,
7522 lock: Arc<dyn AllocationLock>,
7523 director: Arc<FixedDirector>,
7524 ) -> Reconciler {
7525 Reconciler::new(
7526 host_with(capacity),
7527 ReconcilerPorts {
7528 demand: Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
7529 launcher,
7530 lock,
7531 directory: Arc::new(FakeDirectory::default()),
7532 clock: Arc::new(FakeClock::default()),
7533 jitter: Arc::new(NoJitter),
7534 events: Arc::new(NoEvents),
7535 },
7536 )
7537 .with_director(director as Arc<dyn DemandDirector>)
7538 }
7539
7540 #[tokio::test]
7543 async fn two_targets_bring_up_their_runners_at_once() {
7544 let fake = Arc::new(FakeLauncher::new());
7545 let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
7546 let barrier = Arc::new(tokio::sync::Barrier::new(2));
7547 let held = Arc::new(HeldBringUp {
7548 inner: Arc::clone(&fake),
7549 barrier,
7550 fail: None,
7551 });
7552 let director = Arc::new(FixedDirector::default());
7553 let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
7554 director.directed.lock().unwrap().insert(one.id, 1);
7555 director.directed.lock().unwrap().insert(two.id, 1);
7556 let mut first = directed_reconciler(
7557 2,
7558 Arc::clone(&held) as _,
7559 Arc::clone(&lock),
7560 Arc::clone(&director),
7561 );
7562 let mut second = directed_reconciler(2, held as _, lock, director);
7563
7564 let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
7565 tokio::join!(
7566 first.start_directed(std::slice::from_ref(&one)),
7567 second.start_directed(std::slice::from_ref(&two)),
7568 )
7569 })
7570 .await
7571 .expect("neither waits for the other's bring-up");
7572 assert_eq!((a.started, b.started), (1, 1));
7573 }
7574
7575 #[tokio::test]
7578 async fn a_runner_coming_up_holds_its_slot_against_another_target() {
7579 let fake = Arc::new(FakeLauncher::new());
7580 let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
7581 let barrier = Arc::new(tokio::sync::Barrier::new(2));
7582 let held = Arc::new(HeldBringUp {
7583 inner: Arc::clone(&fake),
7584 barrier: Arc::clone(&barrier),
7585 fail: None,
7586 });
7587 let director = Arc::new(FixedDirector::default());
7588 let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
7589 director.directed.lock().unwrap().insert(one.id, 1);
7590 director.directed.lock().unwrap().insert(two.id, 1);
7591 let mut first = directed_reconciler(
7592 1,
7593 Arc::clone(&held) as _,
7594 Arc::clone(&lock),
7595 Arc::clone(&director),
7596 );
7597 let mut second = directed_reconciler(1, held as _, lock, director);
7598
7599 let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
7600 tokio::join!(first.start_directed(std::slice::from_ref(&one)), async {
7601 while fake.live_count() == 0 {
7602 tokio::task::yield_now().await;
7603 }
7604 let report = second.start_directed(std::slice::from_ref(&two)).await;
7605 barrier.wait().await;
7606 report
7607 })
7608 })
7609 .await
7610 .expect("the first is let go");
7611 assert_eq!((a.started, b.started), (1, 0));
7612 assert_eq!(fake.live_count(), 1);
7613 }
7614
7615 #[tokio::test]
7618 async fn a_failed_bring_up_is_a_failed_start() {
7619 let fake = Arc::new(FakeLauncher::new());
7620 let held = Arc::new(HeldBringUp {
7621 inner: fake,
7622 barrier: Arc::new(tokio::sync::Barrier::new(1)),
7623 fail: Some(FailureReason::JitRequestFailed),
7624 });
7625 let director = Arc::new(FixedDirector::default());
7626 let pool = policy(1, "acme/app", 2);
7627 director.directed.lock().unwrap().insert(pool.id, 1);
7628 let mut reconciler = directed_reconciler(
7629 2,
7630 held as _,
7631 Arc::new(InProcessAllocationLock::new()),
7632 Arc::clone(&director),
7633 );
7634
7635 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
7636
7637 assert_eq!(report.started, 0);
7638 let started = director.started.lock().unwrap()[0].1;
7639 assert_eq!(
7640 *director.failed.lock().unwrap(),
7641 vec![(pool.id, Some(started))]
7642 );
7643 }
7644
7645 #[tokio::test(start_paused = true)]
7648 async fn a_younger_launch_lets_an_older_one_reserve_first_for_a_bounded_time() {
7649 let fake = Arc::new(FakeLauncher::new());
7650 let director = Arc::new(FixedDirector::default());
7651 let pool = policy(1, "acme/app", 3);
7652 director.directed.lock().unwrap().insert(pool.id, 1);
7653 let mut reconciler = directed_reconciler(
7654 5,
7655 Arc::clone(&fake) as _,
7656 Arc::new(InProcessAllocationLock::new()),
7657 Arc::clone(&director),
7658 );
7659
7660 director.older.store(3, Ordering::SeqCst);
7662 let began = tokio::time::Instant::now();
7663 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
7664 assert_eq!(report.started, 1);
7665 assert_eq!(began.elapsed(), OLDER_LAUNCH_POLL * 3);
7666
7667 director.directed.lock().unwrap().insert(pool.id, 2);
7669 director.older.store(usize::MAX, Ordering::SeqCst);
7670 let began = tokio::time::Instant::now();
7671 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
7672 assert_eq!(report.started, 1, "started anyway");
7673 assert!(began.elapsed() >= OLDER_LAUNCH_TURN);
7674 assert!(began.elapsed() < OLDER_LAUNCH_TURN + Duration::from_secs(1));
7675 }
7676
7677 #[tokio::test]
7681 async fn a_directed_policy_starts_only_what_it_was_directed_to_within_its_ceilings() {
7682 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
7683 let launcher = Arc::new(FakeLauncher::new());
7684 let director = Arc::new(FixedDirector::default());
7685 let mut reconciler = Reconciler::new(
7686 host_with(10),
7687 ReconcilerPorts {
7688 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
7689 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7690 lock: Arc::new(InProcessAllocationLock::new()),
7691 directory: Arc::new(FakeDirectory::default()),
7692 clock: Arc::new(FakeClock::default()),
7693 jitter: Arc::new(NoJitter),
7694 events: Arc::new(NoEvents),
7695 },
7696 )
7697 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7698 let pool = policy(1, "acme/app", 2);
7699
7700 director.directed.lock().unwrap().insert(pool.id, 0);
7702 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7703 assert_eq!(report.started, 0);
7704
7705 director.directed.lock().unwrap().insert(pool.id, 1);
7707 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7708 assert_eq!(report.started, 1);
7709 assert_eq!(director.started.lock().unwrap().len(), 1);
7710
7711 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7713 assert_eq!(report.started, 0);
7714
7715 director.directed.lock().unwrap().insert(pool.id, 3);
7717 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7718 assert_eq!(report.started, 1);
7719 assert_eq!(launcher.live_count(), 2);
7720
7721 let polls = demand.polls().len();
7723 director.directed.lock().unwrap().insert(pool.id, 0);
7724 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
7725 assert_eq!(report.started, 0);
7726 let fresh = Arc::new(FakeLauncher::new());
7727 let mut starter = Reconciler::new(
7728 host_with(10),
7729 ReconcilerPorts {
7730 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
7731 launcher: Arc::clone(&fresh) as Arc<dyn RunnerLauncher>,
7732 lock: Arc::new(InProcessAllocationLock::new()),
7733 directory: Arc::new(FakeDirectory::default()),
7734 clock: Arc::new(FakeClock::default()),
7735 jitter: Arc::new(NoJitter),
7736 events: Arc::new(NoEvents),
7737 },
7738 )
7739 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7740 let wide = policy(2, "acme/app", 6);
7741 director.directed.lock().unwrap().insert(wide.id, 6);
7742 let report = starter.start_directed(std::slice::from_ref(&wide)).await;
7743 assert_eq!(report.started, 6, "six launches, one call");
7744 assert_eq!(demand.polls().len(), polls, "GitHub was not polled");
7745 assert_eq!(
7747 starter
7748 .start_directed(&[policy(3, "acme/app", 2)])
7749 .await
7750 .started,
7751 0
7752 );
7753
7754 let failing = Arc::new(FakeLauncher::new());
7756 let mut reconciler = Reconciler::new(
7757 host_with(10),
7758 ReconcilerPorts {
7759 demand: Arc::new(FakeDemand::failing(RefreshState::Offline)),
7760 launcher: Arc::clone(&failing) as Arc<dyn RunnerLauncher>,
7761 lock: Arc::new(InProcessAllocationLock::new()),
7762 directory: Arc::new(FakeDirectory::default()),
7763 clock: Arc::new(FakeClock::default()),
7764 jitter: Arc::new(NoJitter),
7765 events: Arc::new(NoEvents),
7766 },
7767 )
7768 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7769 director.directed.lock().unwrap().insert(pool.id, 1);
7770 assert_eq!(reconciler.reconcile(&[pool]).await.started, 1);
7771
7772 let own = Arc::new(FakeLauncher::new());
7774 let mut reconciler = Reconciler::new(
7775 host_with(10),
7776 ReconcilerPorts {
7777 demand: Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
7778 launcher: Arc::clone(&own) as Arc<dyn RunnerLauncher>,
7779 lock: Arc::new(InProcessAllocationLock::new()),
7780 directory: Arc::new(FakeDirectory::default()),
7781 clock: Arc::new(FakeClock::default()),
7782 jitter: Arc::new(NoJitter),
7783 events: Arc::new(NoEvents),
7784 },
7785 )
7786 .with_director(Arc::new(OwnDemand));
7787 assert_eq!(
7788 reconciler
7789 .reconcile(&[policy(1, "acme/app", 2)])
7790 .await
7791 .started,
7792 2
7793 );
7794 }
7795}