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 _waits: ReservationWaits,
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 waits: ReservationWaits,
1267 ) -> Result<RunnerAttempt, LaunchFailure> {
1268 self.inner.finish_launch(policy, attempt, waits).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 next_waiting(&self, _policy: &ScalePolicy) -> Option<u64> {
2378 None
2379 }
2380
2381 fn older_waiting(&self, _policy: &ScalePolicy) -> usize {
2385 0
2386 }
2387
2388 fn pass_over_older(&self, _policy: &ScalePolicy) {}
2391}
2392
2393const OLDER_LAUNCH_TURN: Duration = Duration::from_secs(2);
2400const OLDER_LAUNCH_POLL: Duration = Duration::from_millis(10);
2401
2402const LAUNCH_RETRY: Duration = Duration::from_secs(30);
2404
2405#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2407pub struct ReservationWaits {
2408 pub order: Duration,
2410 pub lock: Duration,
2412}
2413
2414#[derive(Default)]
2416struct ReservationRound {
2417 reserved: BTreeMap<PolicyId, (u32, u32)>,
2420 exhausted: BTreeSet<PolicyId>,
2421}
2422
2423type Reserving<'a> = std::pin::Pin<
2425 Box<
2426 dyn std::future::Future<Output = (Option<Reserved>, ReservationRound, Vec<ScalePolicy>)>
2427 + Send
2428 + 'a,
2429 >,
2430>;
2431
2432struct Reserved {
2434 policy: ScalePolicy,
2435 attempt: RunnerAttempt,
2436 waits: ReservationWaits,
2437 reserved_at: std::time::Instant,
2438 _hold: LaunchHold,
2440}
2441
2442#[derive(Debug, Default, Clone, Copy)]
2444pub struct OwnDemand;
2445
2446impl DemandDirector for OwnDemand {
2447 fn directed_demand(&self, _policy: &ScalePolicy) -> Option<u32> {
2448 None
2449 }
2450
2451 fn launched(&self, _policy: &ScalePolicy, _attempt: AttemptId) {}
2452}
2453
2454#[derive(Clone)]
2460pub struct ReconcilerPorts {
2461 pub demand: Arc<dyn DemandSource>,
2462 pub launcher: Arc<dyn RunnerLauncher>,
2463 pub lock: Arc<dyn AllocationLock>,
2464 pub directory: Arc<dyn RepositoryDirectory>,
2465 pub clock: Arc<dyn Clock>,
2466 pub jitter: Arc<dyn Jitter>,
2467 pub events: Arc<dyn EventSink>,
2468}
2469
2470impl fmt::Debug for ReconcilerPorts {
2471 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2472 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
2473 }
2474}
2475
2476#[derive(Debug, Clone, Default)]
2483pub struct ReconcileReport {
2484 pub allocations: Vec<Allocation>,
2486 pub monitor_only: Vec<PolicyId>,
2488 pub unreadable: Vec<PolicyId>,
2490 pub targets_read: u16,
2498 pub started: u16,
2500 pub replacement_intents: u16,
2503 pub cleaned: u16,
2505 pub idle_exits: u16,
2508 pub failures: u16,
2510 pub deferred: u16,
2519 pub attempts_unreadable: u16,
2538 pub host_capacity_unreadable: u16,
2540 pub clean_failures: u16,
2542 pub failure: Option<RefreshState>,
2544 pub offline: Option<OfflineState>,
2547 pub next_poll: NextPoll,
2549 pub demand_requests: u32,
2551}
2552
2553impl ReconcileReport {
2554 #[must_use]
2581 pub const fn reached_github(&self) -> bool {
2582 self.targets_read > 0
2583 }
2584}
2585
2586impl Default for NextPoll {
2587 fn default() -> Self {
2588 Self {
2589 delay: PollSchedule::floor(),
2590 pace: PollPace::Nominal,
2591 }
2592 }
2593}
2594
2595impl ReconcileReport {
2596 #[must_use]
2598 pub fn is_offline(&self) -> bool {
2599 matches!(self.failure, Some(RefreshState::Offline))
2600 }
2601
2602 #[must_use]
2604 pub const fn offline_state(&self) -> Option<&OfflineState> {
2605 self.offline.as_ref()
2606 }
2607
2608 #[must_use]
2610 pub const fn starts_nothing(&self) -> bool {
2611 self.started == 0
2612 }
2613}
2614
2615#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2617pub struct ScaleDownReport {
2618 pub removed: u16,
2620 pub refused_busy: u16,
2622 pub clean_failures: u16,
2624 pub retained: u16,
2627 pub attempts_unreadable: bool,
2637}
2638
2639impl ScaleDownReport {
2640 #[must_use]
2645 pub const fn is_conclusive(&self) -> bool {
2646 !self.attempts_unreadable
2647 }
2648}
2649
2650const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
2658
2659const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
2661
2662#[derive(Debug, Clone, Copy)]
2664struct CleanRetry {
2665 failures: u32,
2666 not_before: Timestamp,
2667}
2668
2669#[derive(Debug, Default)]
2673pub(crate) struct CleanBackoff {
2674 retries: BTreeMap<AttemptId, CleanRetry>,
2675}
2676
2677impl CleanBackoff {
2678 const SCHEDULE: crate::lifecycle::RetryPolicy =
2680 crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2681
2682 pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2684 self.retries
2685 .get(&attempt)
2686 .is_none_or(|retry| now >= retry.not_before)
2687 }
2688
2689 pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2691 let failures = self
2692 .retries
2693 .get(&attempt)
2694 .map_or(1, |retry| retry.failures.saturating_add(1));
2695 let delay = Self::SCHEDULE.delay(failures);
2696 self.retries.insert(
2697 attempt,
2698 CleanRetry {
2699 failures,
2700 not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2702 },
2703 );
2704 }
2705
2706 pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2708 self.retries.remove(&attempt);
2709 }
2710
2711 fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2714 self.retries.retain(|attempt, _| present.contains(attempt));
2715 }
2716}
2717
2718#[derive(Debug)]
2724pub struct Reconciler {
2725 host: Host,
2726 demand: Arc<dyn DemandSource>,
2727 launcher: Arc<dyn RunnerLauncher>,
2728 lock: Arc<dyn AllocationLock>,
2729 repositories: RepositoryCache,
2730 clock: Arc<dyn Clock>,
2731 jitter: Arc<dyn Jitter>,
2732 events: Arc<dyn EventSink>,
2733 schedule: PollSchedule,
2734 clean_backoff: CleanBackoff,
2735 director: Arc<dyn DemandDirector>,
2736 launch_side: Option<Arc<tokio::sync::Notify>>,
2739}
2740
2741impl Reconciler {
2742 #[must_use]
2744 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2745 let interval = host.refresh_interval;
2746 let repositories = RepositoryCache::new(
2747 Arc::clone(&ports.directory),
2748 Arc::clone(&ports.clock),
2749 interval,
2750 );
2751 Self {
2752 host,
2753 demand: ports.demand,
2754 launcher: ports.launcher,
2755 lock: ports.lock,
2756 repositories,
2757 clock: ports.clock,
2758 jitter: ports.jitter,
2759 events: ports.events,
2760 schedule: PollSchedule::new(interval),
2761 clean_backoff: CleanBackoff::default(),
2762 director: Arc::new(OwnDemand),
2763 launch_side: None,
2764 }
2765 }
2766
2767 #[must_use]
2772 pub fn with_launch_side(mut self, signal: Arc<tokio::sync::Notify>) -> Self {
2773 self.launch_side = Some(signal);
2774 self
2775 }
2776
2777 #[must_use]
2780 pub fn with_director(mut self, director: Arc<dyn DemandDirector>) -> Self {
2781 self.director = director;
2782 self
2783 }
2784
2785 #[must_use]
2786 pub const fn host(&self) -> &Host {
2787 &self.host
2788 }
2789
2790 #[must_use]
2791 pub const fn schedule(&self) -> &PollSchedule {
2792 &self.schedule
2793 }
2794
2795 pub fn set_intervals(&mut self, active: RefreshInterval, idle: Duration) {
2797 self.host.refresh_interval = active;
2798 self.schedule.set_intervals(active, idle);
2799 }
2800
2801 #[must_use]
2803 pub const fn repositories(&self) -> &RepositoryCache {
2804 &self.repositories
2805 }
2806
2807 #[cfg(test)]
2811 pub async fn start_directed(&self, policies: &[ScalePolicy]) -> ReconcileReport {
2812 let mut report = ReconcileReport::default();
2813 let mut round = ReservationRound::default();
2814 let mut reserved = Vec::new();
2815 while let Some(next) = self
2816 .reserve_next_launch(policies, &mut round, &mut report)
2817 .await
2818 {
2819 reserved.push(next);
2820 }
2821 self.finish_reserved(reserved, &mut report).await;
2822 report
2823 }
2824
2825 pub async fn serve_launches(
2841 &self,
2842 policies: &(dyn Fn() -> Vec<ScalePolicy> + Send + Sync),
2843 signal: &tokio::sync::Notify,
2844 mut stop: tokio::sync::watch::Receiver<bool>,
2845 ) {
2846 use futures::StreamExt as _;
2847
2848 let mut bringing_up = futures::stream::FuturesUnordered::new();
2849 let mut reserving: Option<Reserving<'_>> = None;
2850 let mut retry_at: Option<tokio::time::Instant> = None;
2851 let mut known: Vec<ScalePolicy> = Vec::new();
2852 let mut stopping = *stop.borrow();
2853 loop {
2854 if stopping && reserving.is_none() && bringing_up.is_empty() {
2855 return;
2856 }
2857 tokio::select! {
2858 () = signal.notified(), if !stopping && reserving.is_none() => {
2859 reserving = Some(self.reserve_step(policies(), ReservationRound::default()));
2860 }
2861 () = async {
2862 match retry_at {
2863 Some(at) => tokio::time::sleep_until(at).await,
2864 None => std::future::pending().await,
2865 }
2866 }, if !stopping && reserving.is_none() => {
2867 retry_at = None;
2868 reserving = Some(self.reserve_step(policies(), ReservationRound::default()));
2869 }
2870 (reserved, round, read) = async {
2871 match reserving.as_mut() {
2872 Some(reserving) => reserving.await,
2873 None => std::future::pending().await,
2874 }
2875 } => {
2876 reserving = None;
2877 match reserved {
2878 Some(reserved) => {
2879 bringing_up.push(self.bring_up(reserved));
2880 known = read;
2881 if !stopping {
2882 reserving = Some(self.reserve_step(policies(), round));
2883 }
2884 }
2885 None => known = read,
2886 }
2887 }
2888 Some(_) = bringing_up.next(), if !bringing_up.is_empty() => {}
2889 changed = stop.changed(), if !stopping => {
2890 stopping = changed.is_err() || *stop.borrow();
2891 }
2892 }
2893 if reserving.is_none()
2896 && retry_at.is_none()
2897 && known
2898 .iter()
2899 .any(|policy| self.director.next_waiting(policy).is_some())
2900 {
2901 retry_at = Some(tokio::time::Instant::now() + LAUNCH_RETRY);
2902 }
2903 }
2904 }
2905
2906 fn reserve_step(
2908 &self,
2909 policies: Vec<ScalePolicy>,
2910 mut round: ReservationRound,
2911 ) -> Reserving<'_> {
2912 Box::pin(async move {
2913 let mut report = ReconcileReport::default();
2914 let reserved = self
2915 .reserve_next_launch(&policies, &mut round, &mut report)
2916 .await;
2917 (reserved, round, policies)
2918 })
2919 }
2920
2921 async fn reserve_next_launch(
2926 &self,
2927 policies: &[ScalePolicy],
2928 round: &mut ReservationRound,
2929 report: &mut ReconcileReport,
2930 ) -> Option<Reserved> {
2931 loop {
2932 let policy = policies
2933 .iter()
2934 .filter(|policy| {
2935 policy.owns_runners()
2936 && policy.is_owned_by(self.host.id)
2937 && policy.may_start_runners()
2938 && !round.exhausted.contains(&policy.id)
2939 })
2940 .min_by_key(|policy| self.director.next_waiting(policy).unwrap_or(u64::MAX))?;
2941 let (served, most) = round.reserved.entry(policy.id).or_default();
2942 if let Some(directed) = self.director.directed_demand(policy)
2944 && *served < directed.max(*most)
2945 {
2946 *most = directed.max(*most);
2947 let mut reserved = Vec::new();
2948 self.start_runners(policy, directed, 1, report, &mut reserved)
2949 .await;
2950 if let Some(next) = reserved.pop() {
2951 *served += 1;
2952 return Some(next);
2953 }
2954 }
2955 round.exhausted.insert(policy.id);
2956 }
2957 }
2958
2959 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2973 let mut report = ReconcileReport::default();
2974 let mut reserved: Vec<Reserved> = Vec::new();
2978
2979 let mut pollable: Vec<&ScalePolicy> = Vec::new();
2981 let mut supervision_failed = BTreeSet::new();
2982 for policy in policies {
2983 if !policy.owns_runners() {
2984 report.monitor_only.push(policy.id);
2985 self.events
2986 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2987 continue;
2988 }
2989 if !policy.is_owned_by(self.host.id) {
2990 continue;
2994 }
2995 match self.launcher.supervise(policy).await {
2996 Ok(intents) => {
2997 report.replacement_intents = report
2998 .replacement_intents
2999 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
3000 }
3001 Err(failure) => {
3002 supervision_failed.insert(policy.id);
3003 self.report_unreadable_attempts(&mut report, &failure);
3004 continue;
3005 }
3006 }
3007 if !policy.may_start_runners() {
3008 continue;
3009 }
3010 pollable.push(policy);
3011 }
3012
3013 let readings = self.poll_targets(&pollable, &mut report).await;
3014 report.targets_read = u16::try_from(
3015 readings
3016 .values()
3017 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
3018 .count(),
3019 )
3020 .unwrap_or(u16::MAX);
3021 let mut routed = BTreeMap::new();
3022 for (target, outcome) in &readings {
3023 let PollOutcome::Ready(reading) = outcome else {
3024 continue;
3025 };
3026 let profiles: Vec<&ScalePolicy> = pollable
3027 .iter()
3028 .copied()
3029 .filter(|policy| &policy.target == target)
3030 .collect();
3031 let (tallies, refusals) = route_demand(policies, &profiles, reading);
3032 if let Some(first) = profiles.first() {
3033 for (reason, count) in refusals {
3034 self.events.emit(LifecycleEvent::RoutingRefused {
3035 policy: first.id,
3036 reason,
3037 count,
3038 });
3039 }
3040 }
3041 routed.insert(target.clone(), tallies);
3042 }
3043
3044 self.clean_terminal_attempts(&mut report).await;
3054
3055 for policy in policies {
3070 if !policy.owns_runners() {
3071 continue;
3077 }
3078 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
3079 match self.allocate_only(policy, 0, &reserved).await {
3083 Ok(allocation) => {
3084 self.emit_allocation(&allocation);
3085 report.allocations.push(allocation);
3086 }
3087 Err(failure) => self.report_allocation_failure(&mut report, &failure),
3088 }
3089 continue;
3090 }
3091 if supervision_failed.contains(&policy.id) {
3092 continue;
3093 }
3094 if let Some(directed) = self.director.directed_demand(policy) {
3098 let Some(launch_side) = &self.launch_side else {
3099 self.start_runners(policy, directed, u16::MAX, &mut report, &mut reserved)
3100 .await;
3101 continue;
3102 };
3103 match self.allocate_only(policy, directed, &reserved).await {
3104 Ok(allocation) => {
3105 if !allocation.starts_nothing() {
3106 launch_side.notify_one();
3107 }
3108 report.allocations.push(allocation);
3109 }
3110 Err(failure) => self.report_allocation_failure(&mut report, &failure),
3111 }
3112 continue;
3113 }
3114 let Some(reading) = readings.get(&policy.target) else {
3115 debug_assert!(false, "a pollable policy's target has no reading");
3118 continue;
3119 };
3120 match reading {
3121 PollOutcome::Failed(state) => {
3122 report.unreadable.push(policy.id);
3123 self.events.emit(LifecycleEvent::TargetUnreadable {
3124 policy: policy.id,
3125 reason: unreadable_reason(state),
3126 });
3127 }
3128 PollOutcome::Ready(demand) => {
3129 let tally = routed
3130 .get(&policy.target)
3131 .and_then(|tallies| tallies.get(&policy.id))
3132 .expect("every pollable profile has a routed tally");
3133 let count = tally.demand();
3134 self.events.emit(LifecycleEvent::DemandObserved {
3135 policy: policy.id,
3136 demand: count,
3137 not_matched: tally.not_matched,
3138 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
3139 complete: demand.is_complete(),
3140 });
3141 self.start_runners(policy, count, u16::MAX, &mut report, &mut reserved)
3142 .await;
3143 }
3144 }
3145 }
3146 self.finish_reserved(reserved, &mut report).await;
3147
3148 let failure = readings
3150 .values()
3151 .filter_map(PollOutcome::failure)
3152 .max_by_key(|state| severity(state))
3153 .cloned();
3154 let now = self.clock.now();
3155 let conditions = PollConditions {
3156 quiet: is_quiet(&readings, &report),
3157 requests_per_pass: report.demand_requests,
3158 quota: readings
3159 .values()
3160 .filter_map(PollOutcome::reading)
3161 .filter_map(QueuedDemand::rate_limit)
3162 .max_by_key(|quota| quota.observed_at)
3163 .copied(),
3164 };
3165 report.next_poll =
3166 self.schedule
3167 .next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
3168 report.failure = failure;
3169 if let PollPace::Offline { consecutive } = report.next_poll.pace {
3173 let state = OfflineState::new(consecutive, report.next_poll.delay);
3174 report.offline = Some(match self.schedule.offline_for(now) {
3175 Some(elapsed) => state.since(elapsed),
3176 None => state,
3177 });
3178 }
3179 self.events.emit(LifecycleEvent::PollScheduled {
3180 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
3181 pace: report.next_poll.pace,
3182 });
3183
3184 report
3185 }
3186
3187 async fn poll_targets(
3195 &self,
3196 pollable: &[&ScalePolicy],
3197 report: &mut ReconcileReport,
3198 ) -> BTreeMap<ScaleTarget, PollOutcome> {
3199 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
3200
3201 let mut readings = BTreeMap::new();
3202 for target in targets {
3203 let outcome = match self.repositories.scope_for(&target).await {
3204 Ok(scope) => {
3205 report.demand_requests = report
3206 .demand_requests
3207 .saturating_add(demand_requests_per_poll(&scope));
3208 self.demand.poll(&scope).await
3209 }
3210 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
3216 };
3217 readings.insert(target, outcome);
3218 }
3219 readings
3220 }
3221
3222 async fn allocate_only(
3240 &self,
3241 policy: &ScalePolicy,
3242 demand: u32,
3243 reserved: &[Reserved],
3244 ) -> Result<Allocation, AllocationFailure> {
3245 let mut host = self.host.clone();
3248 host.host_capacity = self
3249 .launcher
3250 .host_capacity(&self.host)
3251 .await
3252 .map_err(AllocationFailure::HostCapacity)?;
3253 let attempts = self
3254 .host_attempts(reserved)
3255 .await
3256 .map_err(AllocationFailure::Attempts)?;
3257 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
3258 Ok(allocator.allocate(policy, demand))
3259 }
3260
3261 async fn start_runners(
3285 &self,
3286 policy: &ScalePolicy,
3287 demand: u32,
3288 limit: u16,
3289 report: &mut ReconcileReport,
3290 reserved: &mut Vec<Reserved>,
3291 ) {
3292 let intent = match self.allocate_only(policy, demand, reserved).await {
3304 Ok(intent) => intent,
3305 Err(failure) => {
3306 self.report_allocation_failure(report, &failure);
3307 self.director.start_failed(policy, None);
3308 return;
3309 }
3310 };
3311
3312 let mut decided: Option<Allocation> = None;
3316 let mut budget = intent.to_start.min(limit);
3317
3318 while budget > 0 {
3319 let order_wait = self.let_older_launches_reserve(policy).await;
3320 let demand = if order_wait.is_zero() {
3322 demand
3323 } else {
3324 self.director.directed_demand(policy).unwrap_or(demand)
3325 };
3326 let waiting_since = std::time::Instant::now();
3327 let guard = match self.lock.acquire().await {
3328 Ok(guard) => guard,
3329 Err(_) => {
3330 report.deferred = report.deferred.saturating_add(budget);
3333 self.events.emit(LifecycleEvent::AllocationDeferred {
3334 policy: policy.id,
3335 count: budget,
3336 });
3337 self.director.start_failed(policy, None);
3340 break;
3341 }
3342 };
3343 let lock_wait = waiting_since.elapsed();
3344
3345 let allocation = match self.allocate_only(policy, demand, reserved).await {
3351 Ok(allocation) => allocation,
3352 Err(failure) => {
3353 drop(guard);
3354 self.report_allocation_failure(report, &failure);
3355 self.director.start_failed(policy, None);
3356 break;
3357 }
3358 };
3359
3360 if decided.is_none() {
3361 budget = budget.min(allocation.to_start);
3365 decided = Some(allocation.clone());
3366 }
3367
3368 if allocation.starts_nothing() || budget == 0 {
3371 drop(guard);
3372 break;
3373 }
3374
3375 let created = self
3376 .launcher
3377 .launch(LaunchRequest {
3378 host: &self.host,
3379 policy,
3380 allocation_guard: &guard,
3381 })
3382 .await;
3383 let hold = guard.launch_hold();
3384 drop(guard);
3385
3386 match created {
3387 Ok(attempt) => {
3388 let id = attempt.id;
3389 self.director.launched(policy, id);
3392 budget -= 1;
3393 reserved.push(Reserved {
3397 policy: policy.clone(),
3398 attempt,
3399 waits: ReservationWaits {
3400 order: order_wait,
3401 lock: lock_wait,
3402 },
3403 reserved_at: std::time::Instant::now(),
3404 _hold: hold,
3405 });
3406 }
3407 Err(failure) => {
3408 self.note_start_failed(policy, None, &failure);
3409 break;
3410 }
3411 }
3412 }
3413
3414 let allocation = decided.unwrap_or(intent);
3415 self.emit_allocation(&allocation);
3416 report.allocations.push(allocation);
3417 }
3418
3419 async fn let_older_launches_reserve(&self, policy: &ScalePolicy) -> Duration {
3425 let began = tokio::time::Instant::now();
3426 let mut older = self.director.older_waiting(policy);
3427 let mut limit = began + OLDER_LAUNCH_TURN;
3428 while older > 0 {
3429 if tokio::time::Instant::now() >= limit {
3430 tracing::warn!(
3431 event = "older_launch_passed_over",
3432 policy_id = %policy.id,
3433 count = older,
3434 );
3435 self.director.pass_over_older(policy);
3436 break;
3437 }
3438 tokio::time::sleep(OLDER_LAUNCH_POLL).await;
3439 let still = self.director.older_waiting(policy);
3440 if still < older {
3441 limit = tokio::time::Instant::now() + OLDER_LAUNCH_TURN;
3442 }
3443 older = still;
3444 }
3445 began.elapsed()
3446 }
3447
3448 async fn finish_reserved(&self, reserved: Vec<Reserved>, report: &mut ReconcileReport) {
3456 let finished =
3457 futures::future::join_all(reserved.into_iter().map(|reserved| self.bring_up(reserved)))
3458 .await;
3459 let started = finished.into_iter().filter(|started| *started).count();
3460 report.started = report
3461 .started
3462 .saturating_add(u16::try_from(started).unwrap_or(u16::MAX));
3463 }
3464
3465 async fn bring_up(&self, reserved: Reserved) -> bool {
3467 let id = reserved.attempt.id;
3468 let mut waits = reserved.waits;
3469 waits.lock += reserved.reserved_at.elapsed();
3470 let result = self
3471 .launcher
3472 .finish_launch(&reserved.policy, reserved.attempt, waits)
3473 .await;
3474 match &result {
3477 Ok(_) => self.events.emit(LifecycleEvent::RunnerStarted {
3478 policy: reserved.policy.id,
3479 attempt: id,
3480 }),
3481 Err(failure) => self.note_start_failed(&reserved.policy, Some(id), failure),
3482 }
3483 result.is_ok()
3484 }
3485
3486 fn note_start_failed(
3487 &self,
3488 policy: &ScalePolicy,
3489 attempt: Option<AttemptId>,
3490 failure: &LaunchFailure,
3491 ) {
3492 self.events.emit(LifecycleEvent::RunnerStartFailed {
3493 policy: policy.id,
3494 reason: failure_reason_kind(&failure.reason),
3495 });
3496 self.director.start_failed(policy, attempt);
3497 }
3498
3499 async fn host_attempts(
3512 &self,
3513 reserved: &[Reserved],
3514 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3515 let mut attempts = self.launcher.attempts().await?;
3516
3517 debug_assert!(
3524 reserved
3525 .iter()
3526 .map(|reserved| reserved.attempt.id)
3527 .collect::<BTreeSet<AttemptId>>()
3528 .len()
3529 == reserved.len(),
3530 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
3531 the host ceiling is enforced against a set keyed on that identifier, so a \
3532 duplicate is two runtimes counted as one"
3533 );
3534
3535 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
3536 attempts.extend(
3537 reserved
3538 .iter()
3539 .filter(|reserved| !known.contains(&reserved.attempt.id))
3540 .map(|reserved| reserved.attempt.clone()),
3541 );
3542 Ok(attempts)
3543 }
3544
3545 fn report_allocation_failure(&self, report: &mut ReconcileReport, failure: &AllocationFailure) {
3546 match failure {
3547 AllocationFailure::HostCapacity(reason) => {
3548 report.host_capacity_unreadable = report.host_capacity_unreadable.saturating_add(1);
3549 self.events
3550 .emit(LifecycleEvent::HostCapacityUnreadable { reason: *reason });
3551 }
3552 AllocationFailure::Attempts(failure) => {
3553 self.report_unreadable_attempts(report, failure)
3554 }
3555 }
3556 }
3557
3558 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
3565 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
3566 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3573 reason: failure_reason_kind(&failure.reason),
3574 });
3575 }
3576
3577 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
3583 let attempts = match self.launcher.attempts().await {
3584 Ok(attempts) => attempts,
3585 Err(failure) => {
3586 self.report_unreadable_attempts(report, &failure);
3587 return;
3588 }
3589 };
3590 self.clean_backoff
3591 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
3592 let now = self.clock.now();
3593 for attempt in attempts {
3594 if !attempt.state().is_concluded() {
3595 continue;
3596 }
3597 let Some(outcome) = attempt.outcome() else {
3598 continue;
3599 };
3600 if !self.clean_backoff.is_due(attempt.id, now) {
3603 continue;
3604 }
3605 let kind = OutcomeKind::of(outcome);
3606 match self.launcher.clean(attempt.id).await {
3607 Ok(()) => {
3608 self.clean_backoff.succeeded(attempt.id);
3609 report.cleaned = report.cleaned.saturating_add(1);
3610 if kind.is_failure() {
3611 report.failures = report.failures.saturating_add(1);
3612 } else if kind == OutcomeKind::IdleExit {
3613 report.idle_exits = report.idle_exits.saturating_add(1);
3618 }
3619 self.events.emit(LifecycleEvent::AttemptCleaned {
3620 policy: attempt.policy_id,
3621 attempt: attempt.id,
3622 outcome: kind,
3623 });
3624 }
3625 Err(failure) => {
3635 self.clean_backoff.failed(attempt.id, now);
3636 report.clean_failures = report.clean_failures.saturating_add(1);
3637 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3638 policy: attempt.policy_id,
3639 attempt: attempt.id,
3640 reason: failure_reason_kind(&failure.reason),
3641 });
3642 }
3643 }
3644 }
3645 }
3646
3647 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
3655 let mut report = ScaleDownReport::default();
3656 let attempts = match self.launcher.attempts().await {
3657 Ok(attempts) => attempts,
3658 Err(failure) => {
3659 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3664 reason: failure_reason_kind(&failure.reason),
3665 });
3666 report.attempts_unreadable = true;
3667 return report;
3668 }
3669 };
3670 for attempt in attempts {
3671 if attempt.policy_id != policy.id {
3672 continue;
3673 }
3674 match attempt.state() {
3675 AttemptState::Busy => {
3676 report.refused_busy = report.refused_busy.saturating_add(1);
3677 self.events.emit(LifecycleEvent::ScaleDownRefused {
3678 policy: policy.id,
3679 attempt: attempt.id,
3680 });
3681 }
3682 state if state.is_concluded() => {
3683 let kind = attempt
3684 .outcome()
3685 .map_or(OutcomeKind::Failed, OutcomeKind::of);
3686 match self.launcher.clean(attempt.id).await {
3687 Ok(()) => {
3688 report.removed = report.removed.saturating_add(1);
3689 self.events.emit(LifecycleEvent::AttemptCleaned {
3690 policy: policy.id,
3691 attempt: attempt.id,
3692 outcome: kind,
3693 });
3694 }
3695 Err(failure) => {
3696 report.clean_failures = report.clean_failures.saturating_add(1);
3697 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3698 policy: policy.id,
3699 attempt: attempt.id,
3700 reason: failure_reason_kind(&failure.reason),
3701 });
3702 }
3703 }
3704 }
3705 AttemptState::Cleaned => {}
3706 _ => report.retained = report.retained.saturating_add(1),
3709 }
3710 }
3711 report
3712 }
3713
3714 fn emit_allocation(&self, allocation: &Allocation) {
3715 self.events.emit(LifecycleEvent::Allocated {
3716 policy: allocation.policy_id,
3717 demand: allocation.demand,
3718 desired: allocation.desired,
3719 active_owned: allocation.active_owned,
3720 headroom: allocation.headroom_before,
3721 to_start: allocation.to_start,
3722 limiting: allocation.limiting_factor,
3723 });
3724 }
3725}
3726
3727fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
3751 let Some(labels) = policy.routing_labels() else {
3752 debug_assert!(
3753 false,
3754 "a monitor-only policy is skipped before the demand poll (D19)"
3755 );
3756 return DemandTally::default();
3757 };
3758
3759 match &policy.target {
3760 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
3761 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
3762 }
3763}
3764
3765fn route_demand(
3769 all_policies: &[ScalePolicy],
3770 profiles: &[&ScalePolicy],
3771 reading: &QueuedDemand,
3772) -> (
3773 BTreeMap<PolicyId, DemandTally>,
3774 BTreeMap<RoutingRefusal, u32>,
3775) {
3776 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
3777 .iter()
3778 .map(|policy| (policy.id, demand_for(policy, reading)))
3779 .collect();
3780 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
3781 let Some(first) = profiles.first() else {
3782 return (tallies, refusals);
3783 };
3784 let owners: Vec<_> = all_policies
3785 .iter()
3786 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3787 .filter(|policy| policy.owns_runners())
3788 .collect();
3789 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3790 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3791 ScaleTarget::Organization(_) => reading.jobs().collect(),
3792 };
3793 for job in jobs {
3794 let Ok(_) = job.required_labels() else {
3795 continue;
3797 };
3798 let matched: Vec<PolicyId> = owners
3799 .iter()
3800 .filter(|policy| {
3801 policy
3802 .routing_labels()
3803 .is_some_and(|labels| labels.matches(job).is_match())
3804 })
3805 .map(|policy| policy.id)
3806 .collect();
3807 if matched.len() > 1 {
3808 *refusals
3809 .entry(RoutingRefusal::OverlappingProfiles)
3810 .or_default() += 1;
3811 for id in matched {
3812 if let Some(tally) = tallies.get_mut(&id) {
3813 tally.matched -= 1;
3814 tally.not_matched += 1;
3815 }
3816 }
3817 }
3818 }
3819 (tallies, refusals)
3820}
3821
3822const fn severity(state: &RefreshState) -> u8 {
3829 match state {
3830 RefreshState::Offline => 5,
3831 RefreshState::RateLimited(_) => 4,
3832 RefreshState::LockedOut { .. } => 3,
3833 RefreshState::Unauthorized => 2,
3834 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3835 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3836 }
3837}
3838
3839const fn unreadable_reason(state: &RefreshState) -> &'static str {
3850 match state {
3851 RefreshState::Ready(_) => "ready",
3852 RefreshState::Offline => "offline",
3853 RefreshState::RateLimited(_) => "rate_limited",
3854 RefreshState::LockedOut { .. } => "locked_out",
3855 RefreshState::Unauthorized => "unauthorized",
3856 RefreshState::Forbidden { .. } => "forbidden",
3857 RefreshState::Failed { .. } => "failed",
3858 RefreshState::Cancelled => "cancelled",
3859 }
3860}
3861
3862#[cfg(test)]
3863mod tests {
3864 use super::*;
3865
3866 #[test]
3874 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
3875 let mut report = ReconcileReport::default();
3876 assert!(
3877 !report.reached_github(),
3878 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
3879 monitor-only -- reached nobody. This is the case the old guard let through, and \
3880 the only one it ever let through."
3881 );
3882
3883 report.unreadable.push(PolicyId::from_u128(1));
3884 assert!(
3885 !report.reached_github(),
3886 "every target this pass tried was unreadable, so there is no contact to record"
3887 );
3888
3889 report.targets_read = 1;
3890 assert!(
3891 report.reached_github(),
3892 "one target answering is contact, whatever else failed alongside it"
3893 );
3894
3895 let mut unowned = ReconcileReport::default();
3899 unowned.unreadable.push(PolicyId::from_u128(2));
3900 unowned.allocations.push(Allocation {
3901 policy_id: PolicyId::from_u128(2),
3902 demand: 0,
3903 desired: 0,
3904 active_owned: 0,
3905 headroom_before: 0,
3906 to_start: 0,
3907 limiting_factor: LimitingFactor::Demand,
3908 });
3909 assert!(
3910 !unowned.reached_github(),
3911 "an allocation is not evidence that GitHub answered"
3912 );
3913 }
3914
3915 #[test]
3925 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
3926 assert!(
3927 severity(&RefreshState::Unauthorized) > 0,
3928 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
3929 or a pass where every target was refused would report no failure at all"
3930 );
3931 }
3932
3933 use std::sync::atomic::AtomicUsize;
3934
3935 use std::num::NonZeroU16;
3936
3937 use runner_manager_domain::attempt::PersistedAttempt;
3938 use runner_manager_domain::model::{CachePolicy, HostId};
3939 use runner_manager_domain::policy::PolicyMode;
3940 use runner_manager_domain::workspace::WorkspaceKind;
3941 use runner_manager_github::rest::RateLimited;
3942 use runner_manager_testkit::clock::FakeClock;
3943 use runner_manager_testkit::fixtures;
3944 use runner_manager_testkit::github::FakeGithub;
3945
3946 fn host_with(capacity: u16) -> Host {
3951 fixtures::host().capacity(capacity).build()
3952 }
3953
3954 fn repo(raw: &str) -> OwnerRepo {
3955 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
3956 }
3957
3958 use runner_manager_domain::policy::RunsOn;
3960
3961 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
3962 fixtures::policy()
3963 .id(PolicyId::from_u128(id))
3964 .repository(target)
3965 .autoscale("home", max)
3966 .active()
3967 .build()
3968 }
3969
3970 const HOST_LABEL: &str = "rm-home-win-x64";
3977
3978 fn jobs(n: usize) -> Vec<RunsOn> {
3984 fixtures::queued_jobs(&[HOST_LABEL], n)
3985 }
3986
3987 #[derive(Debug, Default)]
3989 struct FakeLauncher {
3990 capacity: Mutex<Option<NonZeroU16>>,
3991 capacity_unreadable: AtomicBool,
3992 capacity_after_launch: Mutex<Option<NonZeroU16>>,
3993 attempts: Mutex<Vec<RunnerAttempt>>,
3994 next_id: AtomicU64,
3995 launches: AtomicUsize,
3996 cleaned: Mutex<Vec<AttemptId>>,
3997 yields_before_recording: usize,
4000 forgetful: bool,
4004 fail_next: Mutex<Option<FailureReason>>,
4005 attempts_fail: Mutex<bool>,
4008 clean_fails: bool,
4011 replacements: Mutex<Vec<ReplacementIntent>>,
4012 }
4013
4014 impl FakeLauncher {
4015 fn new() -> Self {
4016 Self::default()
4017 }
4018
4019 fn with_yields(mut self, yields: usize) -> Self {
4020 self.yields_before_recording = yields;
4021 self
4022 }
4023
4024 fn forgetful() -> Self {
4025 Self {
4026 forgetful: true,
4027 ..Self::default()
4028 }
4029 }
4030
4031 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
4032 *self.attempts.lock().unwrap() = attempts;
4033 self
4034 }
4035
4036 fn launches(&self) -> usize {
4037 self.launches.load(Ordering::SeqCst)
4038 }
4039
4040 fn snapshot(&self) -> Vec<RunnerAttempt> {
4041 self.attempts.lock().unwrap().clone()
4042 }
4043
4044 fn live_count(&self) -> usize {
4045 self.snapshot()
4046 .iter()
4047 .filter(|a| a.counts_against_capacity())
4048 .count()
4049 }
4050
4051 fn fail_next(&self, reason: FailureReason) {
4052 *self.fail_next.lock().unwrap() = Some(reason);
4053 }
4054
4055 fn fail_attempts(&self, failing: bool) {
4056 *self.attempts_fail.lock().unwrap() = failing;
4057 }
4058
4059 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
4060 Self {
4061 clean_fails: true,
4062 ..Self::default()
4063 }
4064 .seeded(attempts)
4065 }
4066
4067 fn cleaned(&self) -> Vec<AttemptId> {
4068 self.cleaned.lock().unwrap().clone()
4069 }
4070
4071 fn replacing(self, intent: ReplacementIntent) -> Self {
4072 self.replacements.lock().unwrap().push(intent);
4073 self
4074 }
4075 }
4076
4077 #[async_trait::async_trait]
4078 impl RunnerLauncher for FakeLauncher {
4079 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
4080 if self.capacity_unreadable.load(Ordering::SeqCst) {
4081 return Err(HostCapacityError::JournalUnreadable);
4082 }
4083 Ok(self.capacity.lock().unwrap().unwrap_or(host.host_capacity))
4084 }
4085
4086 async fn supervise(
4087 &self,
4088 policy: &ScalePolicy,
4089 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4090 let mut replacements = self.replacements.lock().unwrap();
4091 let selected: Vec<_> = replacements
4092 .extract_if(.., |intent| intent.policy == policy.id)
4093 .collect();
4094 if !selected.is_empty() {
4095 let retired: BTreeSet<_> = selected
4096 .iter()
4097 .map(|intent| intent.previous_attempt)
4098 .collect();
4099 self.attempts
4100 .lock()
4101 .unwrap()
4102 .retain(|attempt| !retired.contains(&attempt.id));
4103 }
4104 Ok(selected)
4105 }
4106
4107 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4108 if *self.attempts_fail.lock().unwrap() {
4109 return Err(LaunchFailure::new(FailureReason::Other(
4110 "the journal could not be read".into(),
4111 )));
4112 }
4113 Ok(self.snapshot())
4114 }
4115
4116 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4117 if let Some(reason) = self.fail_next.lock().unwrap().take() {
4118 return Err(LaunchFailure::new(reason));
4119 }
4120 for _ in 0..self.yields_before_recording {
4122 tokio::task::yield_now().await;
4123 }
4124 let id =
4125 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
4126 let created = RunnerAttempt::allocate(
4127 id,
4128 request.policy.id,
4129 "runtime/p/a",
4130 request.host.created_at,
4131 );
4132 self.launches.fetch_add(1, Ordering::SeqCst);
4133 if let Some(capacity) = self.capacity_after_launch.lock().unwrap().take() {
4134 *self.capacity.lock().unwrap() = Some(capacity);
4135 }
4136 if !self.forgetful {
4137 self.attempts.lock().unwrap().push(created.clone());
4138 }
4139 Ok(created)
4140 }
4141
4142 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
4143 if self.clean_fails {
4144 return Err(LaunchFailure::new(FailureReason::Other(
4145 "the runtime directory is locked".into(),
4146 )));
4147 }
4148 self.cleaned.lock().unwrap().push(attempt);
4149 let mut attempts = self.attempts.lock().unwrap();
4150 attempts.retain(|a| a.id != attempt);
4151 Ok(())
4152 }
4153 }
4154
4155 #[derive(Debug, Default)]
4157 struct FakeDemand {
4158 outcome: Mutex<Option<PollOutcome>>,
4159 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
4161 scopes: Mutex<Vec<ActivityScope>>,
4162 }
4163
4164 impl FakeDemand {
4165 fn ready(count: u32, repository: &OwnerRepo) -> Self {
4166 let fake = Self::default();
4167 fake.set(PollOutcome::Ready(QueuedDemand::of(
4168 repository.clone(),
4169 jobs(count as usize),
4170 )));
4171 fake
4172 }
4173
4174 fn failing(state: RefreshState) -> Self {
4175 let fake = Self::default();
4176 fake.set(PollOutcome::Failed(state));
4177 fake
4178 }
4179
4180 fn set(&self, outcome: PollOutcome) {
4181 *self.outcome.lock().unwrap() = Some(outcome);
4182 }
4183
4184 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
4186 self.per_target
4187 .lock()
4188 .unwrap()
4189 .insert(target.clone(), outcome);
4190 }
4191
4192 fn polls(&self) -> Vec<ActivityScope> {
4193 self.scopes.lock().unwrap().clone()
4194 }
4195 }
4196
4197 #[async_trait::async_trait]
4198 impl DemandSource for FakeDemand {
4199 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
4200 self.scopes.lock().unwrap().push(scope.clone());
4201 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
4202 return outcome.clone();
4203 }
4204 self.outcome
4205 .lock()
4206 .unwrap()
4207 .clone()
4208 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
4209 }
4210 }
4211
4212 #[derive(Debug, Default)]
4213 struct FakeDirectory {
4214 repositories: Vec<OwnerRepo>,
4215 calls: AtomicUsize,
4216 }
4217
4218 impl FakeDirectory {
4219 fn of(repositories: Vec<OwnerRepo>) -> Self {
4220 Self {
4221 repositories,
4222 calls: AtomicUsize::new(0),
4223 }
4224 }
4225
4226 fn calls(&self) -> usize {
4227 self.calls.load(Ordering::SeqCst)
4228 }
4229 }
4230
4231 #[async_trait::async_trait]
4232 impl RepositoryDirectory for FakeDirectory {
4233 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
4234 self.calls.fetch_add(1, Ordering::SeqCst);
4235 Ok(self.repositories.clone())
4236 }
4237 }
4238
4239 #[derive(Debug)]
4245 struct CountingLock {
4246 inner: InProcessAllocationLock,
4247 concurrent: Arc<AtomicUsize>,
4248 peak: Arc<AtomicUsize>,
4249 acquisitions: Arc<AtomicUsize>,
4250 }
4251
4252 impl CountingLock {
4253 fn new() -> Self {
4254 Self {
4255 inner: InProcessAllocationLock::new(),
4256 concurrent: Arc::new(AtomicUsize::new(0)),
4257 peak: Arc::new(AtomicUsize::new(0)),
4258 acquisitions: Arc::new(AtomicUsize::new(0)),
4259 }
4260 }
4261
4262 fn peak(&self) -> usize {
4263 self.peak.load(Ordering::SeqCst)
4264 }
4265
4266 fn acquisitions(&self) -> usize {
4267 self.acquisitions.load(Ordering::SeqCst)
4268 }
4269 }
4270
4271 #[derive(Debug)]
4272 struct CountingGuard {
4273 _inner: AllocationGuard,
4274 concurrent: Arc<AtomicUsize>,
4275 }
4276
4277 impl Drop for CountingGuard {
4278 fn drop(&mut self) {
4279 self.concurrent.fetch_sub(1, Ordering::SeqCst);
4280 }
4281 }
4282
4283 #[async_trait::async_trait]
4284 impl AllocationLock for CountingLock {
4285 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4286 let inner = self.inner.acquire().await?;
4287 self.acquisitions.fetch_add(1, Ordering::SeqCst);
4288 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
4289 self.peak.fetch_max(now, Ordering::SeqCst);
4290 Ok(AllocationGuard::new(CountingGuard {
4291 _inner: inner,
4292 concurrent: Arc::clone(&self.concurrent),
4293 }))
4294 }
4295 }
4296
4297 #[derive(Debug, Default)]
4300 struct NoLock;
4301
4302 #[async_trait::async_trait]
4303 impl AllocationLock for NoLock {
4304 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4305 Ok(AllocationGuard::new(()))
4306 }
4307 }
4308
4309 #[tokio::test]
4310 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
4311 use runner_manager_platform::wsl::fence::DrainRequest;
4312
4313 let (_local, shared, paths) = guest_paths();
4314 DrainRequest::new(4, chrono::Utc::now())
4315 .write(shared.path())
4316 .unwrap();
4317 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4318
4319 assert!(lock.acquire().await.is_err());
4320 }
4321
4322 #[tokio::test]
4323 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
4324 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4325
4326 let (_local, shared, paths) = guest_paths();
4327 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4328 let guard = lock.acquire().await.unwrap();
4329 assert!(shared.path().join(FENCE_DIRECTORY).exists());
4330 drop(guard);
4331 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4332 }
4333
4334 #[derive(Clone, Default)]
4336 struct Logs(Arc<Mutex<Vec<u8>>>);
4337
4338 impl std::io::Write for Logs {
4339 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4340 self.0.lock().unwrap().extend_from_slice(buf);
4341 Ok(buf.len())
4342 }
4343 fn flush(&mut self) -> std::io::Result<()> {
4344 Ok(())
4345 }
4346 }
4347
4348 #[tokio::test]
4351 async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
4352 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4353
4354 let (_local, shared, paths) = guest_paths();
4355 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4356 let guard = lock.acquire().await.unwrap();
4357 let fence = shared.path().join(FENCE_DIRECTORY);
4360 std::fs::remove_dir_all(&fence).unwrap();
4361 std::fs::write(&fence, b"not a directory").unwrap();
4362
4363 let logs = Logs::default();
4364 let writer = logs.clone();
4365 let subscriber = tracing_subscriber::fmt()
4366 .with_writer(move || writer.clone())
4367 .with_ansi(false)
4368 .finish();
4369 tracing::subscriber::with_default(subscriber, || drop(guard));
4370
4371 let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
4372 assert!(text.contains("WARN"), "{text}");
4373 assert!(text.contains("could not be released"), "{text}");
4374 assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
4375 }
4376
4377 fn guest_paths() -> (
4378 tempfile::TempDir,
4379 tempfile::TempDir,
4380 Arc<runner_manager_platform::paths::AppPaths>,
4381 ) {
4382 use runner_manager_platform::paths::AppPaths;
4383 use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
4384
4385 let local = tempfile::tempdir().unwrap();
4386 let shared = tempfile::tempdir().unwrap();
4387 let paths = Arc::new(AppPaths::rooted_at(local.path()));
4388 paths.create_all().unwrap();
4389 GuestRecoveryConfig::new(shared.path().to_path_buf())
4390 .write(&paths)
4391 .unwrap();
4392 (local, shared, paths)
4393 }
4394
4395 #[tokio::test]
4398 async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
4399 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
4400
4401 let (_local, shared, paths) = guest_paths();
4402 let fence = shared.path().join(FENCE_DIRECTORY);
4403 std::fs::create_dir_all(&fence).unwrap();
4404 let pid = std::process::id();
4405 std::fs::write(
4406 fence.join(OWNER_FILE),
4407 format!(
4408 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"}}}}"#
4409 ),
4410 )
4411 .unwrap();
4412 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4413
4414 let guard = lock.acquire().await.expect("the stale claim was reclaimed");
4415 drop(guard);
4416 assert!(!fence.exists());
4417 }
4418
4419 #[tokio::test]
4420 async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
4421 use runner_manager_platform::launch_health::recorded_launches_blocked;
4422 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4423
4424 let (_local, shared, paths) = guest_paths();
4425 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4426 .with_blocked_after(3, Duration::ZERO);
4427 let windows =
4429 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4430 .unwrap()
4431 .unwrap();
4432
4433 for _ in 0..2 {
4434 assert!(lock.acquire().await.is_err());
4435 }
4436 assert_eq!(
4437 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4438 None,
4439 "not yet"
4440 );
4441 assert!(lock.acquire().await.is_err());
4442 let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
4443 .unwrap()
4444 .expect("three refusals in a row are recorded");
4445 assert!(
4446 blocked.reason.contains("Windows WSL recovery"),
4447 "{blocked:?}"
4448 );
4449
4450 windows.release().unwrap();
4451 let guard = lock.acquire().await.unwrap();
4452 assert_eq!(
4453 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4454 None
4455 );
4456 drop(guard);
4457 }
4458
4459 #[tokio::test]
4460 async fn refusals_while_this_process_is_launching_are_its_own_contention() {
4461 use runner_manager_platform::launch_health::recorded_launches_blocked;
4462
4463 use runner_manager_platform::wsl::fence::DrainRequest;
4464
4465 let (_local, shared, paths) = guest_paths();
4466 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4467 .with_blocked_after(1, Duration::ZERO);
4468 let launching = lock.acquire().await.unwrap();
4469 DrainRequest::new(1, chrono::Utc::now())
4470 .write(shared.path())
4471 .unwrap();
4472 for _ in 0..3 {
4473 assert!(lock.acquire().await.is_err());
4474 }
4475 assert_eq!(
4476 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4477 None
4478 );
4479 drop(launching);
4480 }
4481
4482 #[tokio::test]
4486 async fn launches_in_progress_share_the_fence_until_the_last_ends() {
4487 use runner_manager_platform::wsl::fence::{DrainRequest, FenceClaim, FenceOwnerKind};
4488
4489 let (_local, shared, paths) = guest_paths();
4490 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4491 let claimed = |root: &std::path::Path| FenceClaim::owner(root).unwrap().is_some();
4492
4493 let first = lock.acquire().await.unwrap();
4494 let first_runner = first.launch_hold();
4495 drop(first);
4496 assert!(claimed(shared.path()), "held while its runner comes up");
4497
4498 let claimed_at = FenceClaim::owner(shared.path())
4499 .unwrap()
4500 .unwrap()
4501 .acquired_at;
4502 tokio::time::sleep(Duration::from_millis(5)).await;
4503 let second = lock.acquire().await.expect("this process owns the fence");
4504 assert!(
4505 FenceClaim::owner(shared.path())
4506 .unwrap()
4507 .unwrap()
4508 .acquired_at
4509 > claimed_at,
4510 "a claim launches keep sharing is not one stuck for that long"
4511 );
4512 let second_runner = second.launch_hold();
4513 drop(second);
4514 drop(first_runner);
4515 assert!(claimed(shared.path()), "the other launch is still running");
4516
4517 DrainRequest::new(1, chrono::Utc::now())
4519 .write(shared.path())
4520 .unwrap();
4521 assert!(lock.acquire().await.is_err());
4522 assert!(claimed(shared.path()));
4523 assert!(
4524 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4525 .unwrap()
4526 .is_none()
4527 );
4528 drop(second_runner);
4529 assert!(!claimed(shared.path()), "the last one lets it go");
4530 assert!(
4531 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4532 .unwrap()
4533 .is_some()
4534 );
4535 }
4536
4537 #[tokio::test]
4540 async fn refusals_with_quiet_spells_between_them_never_add_up() {
4541 use runner_manager_platform::launch_health::recorded_launches_blocked;
4542 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4543
4544 let (_local, shared, paths) = guest_paths();
4545 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4546 .with_blocked_after(3, Duration::ZERO)
4547 .with_streak_gap(Duration::ZERO);
4548 let windows =
4549 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4550 .unwrap()
4551 .unwrap();
4552 for _ in 0..5 {
4553 assert!(lock.acquire().await.is_err());
4554 }
4555 assert_eq!(
4556 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4557 None
4558 );
4559 windows.release().unwrap();
4560 }
4561
4562 #[tokio::test]
4565 async fn a_claim_refused_by_a_drain_request_is_released() {
4566 use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
4567
4568 let (_local, shared, paths) = guest_paths();
4569 DrainRequest::new(4, chrono::Utc::now())
4570 .write(shared.path())
4571 .unwrap();
4572 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4573 assert!(lock.acquire().await.is_err());
4574 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4575 }
4576
4577 #[derive(Debug, Default)]
4579 struct HeldLock;
4580
4581 #[async_trait::async_trait]
4582 impl AllocationLock for HeldLock {
4583 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4584 Err(AllocationLockBusy)
4585 }
4586 }
4587
4588 struct Harness {
4590 launcher: Arc<FakeLauncher>,
4591 demand: Arc<FakeDemand>,
4592 events: Arc<EventLog>,
4593 clock: Arc<FakeClock>,
4594 reconciler: Reconciler,
4595 }
4596
4597 impl Harness {
4598 fn build(
4599 host: Host,
4600 launcher: Arc<FakeLauncher>,
4601 demand: Arc<FakeDemand>,
4602 lock: Arc<dyn AllocationLock>,
4603 ) -> Self {
4604 let events = Arc::new(EventLog::new());
4605 let clock = Arc::new(FakeClock::default());
4606 let reconciler = Reconciler::new(
4607 host,
4608 ReconcilerPorts {
4609 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4610 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4611 lock,
4612 directory: Arc::new(FakeDirectory::default()),
4613 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4614 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
4615 events: Arc::clone(&events) as Arc<dyn EventSink>,
4616 },
4617 );
4618 Self {
4619 launcher,
4620 demand,
4621 events,
4622 clock,
4623 reconciler,
4624 }
4625 }
4626
4627 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
4628 let launcher = Arc::new(FakeLauncher::new());
4629 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
4630 Self::build(
4631 host_with(capacity),
4632 launcher,
4633 demand,
4634 Arc::new(InProcessAllocationLock::new()),
4635 )
4636 }
4637 }
4638
4639 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
4640 let outcome = state.is_terminal().then(|| match state {
4641 AttemptState::Failed => {
4642 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
4643 }
4644 AttemptState::Orphaned => AttemptOutcome::Orphaned,
4645 _ => AttemptOutcome::CompletedJob,
4646 });
4647 RunnerAttempt::from_persisted(PersistedAttempt {
4648 id: AttemptId::from_u128(id),
4649 policy_id: PolicyId::from_u128(policy),
4650 github_runner_id: None,
4651 state,
4652 outcome,
4653 process_id: None,
4654 execution: runner_manager_domain::execution::AttemptExecution::Native {
4655 process_id: None,
4656 },
4657 runtime_path: "runtime/p/a".into(),
4658 workspace_kind: WorkspaceKind::Ephemeral,
4659 workspace_slot: None,
4660 created_at: fixtures::created_at(),
4661 terminal_at: state.is_terminal().then(fixtures::created_at),
4662 last_state_change_at: fixtures::created_at(),
4663 })
4664 .expect("a state/outcome pair the domain accepts")
4665 }
4666
4667 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
4669 RunnerAttempt::from_persisted(PersistedAttempt {
4670 id: AttemptId::from_u128(id),
4671 policy_id: PolicyId::from_u128(policy),
4672 github_runner_id: None,
4673 state: outcome.terminal_state(),
4674 outcome: Some(outcome),
4675 process_id: None,
4676 execution: runner_manager_domain::execution::AttemptExecution::Native {
4677 process_id: None,
4678 },
4679 runtime_path: "runtime/p/a".into(),
4680 workspace_kind: WorkspaceKind::Ephemeral,
4681 workspace_slot: None,
4682 created_at: fixtures::created_at(),
4683 terminal_at: Some(fixtures::created_at()),
4684 last_state_change_at: fixtures::created_at(),
4685 })
4686 .expect("a state/outcome pair the domain accepts")
4687 }
4688
4689 #[tokio::test]
4718 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
4719 let mut harness = Harness::simple(4, 1, "acme/app");
4720 let policy = policy(1, "acme/app", 4);
4721
4722 for poll in 1..=3 {
4723 let report = harness
4724 .reconciler
4725 .reconcile(std::slice::from_ref(&policy))
4726 .await;
4727 assert_eq!(
4728 harness.launcher.launches(),
4729 1,
4730 "poll {poll} started another runner for a job already being served; the \
4731 `- active_owned_runners` term reached `HostAllocator` as a set this host \
4732 does not hold"
4733 );
4734 assert_eq!(report.allocations.len(), 1);
4735 let allocation = &report.allocations[0];
4736 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
4737 if poll == 1 {
4738 assert_eq!(allocation.to_start, 1);
4739 assert_eq!(report.started, 1);
4740 } else {
4741 assert_eq!(allocation.active_owned, 1, "poll {poll}");
4742 assert_eq!(allocation.to_start, 0, "poll {poll}");
4743 assert_eq!(report.started, 0, "poll {poll}");
4744 }
4745 }
4746 assert_eq!(harness.launcher.live_count(), 1);
4747 }
4748
4749 #[tokio::test]
4750 async fn draining_parallel_workflow_starts_runners_for_the_remaining_queue() {
4751 let mut harness = Harness::simple(10, 8, "acme/app");
4752 let policy = policy(1, "acme/app", 10);
4753 let first = harness
4754 .reconciler
4755 .reconcile(std::slice::from_ref(&policy))
4756 .await;
4757 assert_eq!(first.started, 8);
4758
4759 let attempts = harness.launcher.snapshot();
4761 *harness.launcher.attempts.lock().unwrap() = attempts
4762 .iter()
4763 .take(3)
4764 .map(|attempt| attempt_in(AttemptState::Busy, attempt.id.as_uuid().as_u128(), 1))
4765 .collect();
4766 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4767 repo("acme/app"),
4768 jobs(2),
4769 )));
4770
4771 for poll in 0..3 {
4772 let report = harness
4773 .reconciler
4774 .reconcile(std::slice::from_ref(&policy))
4775 .await;
4776 assert_eq!(report.started, if poll == 0 { 2 } else { 0 });
4777 assert_eq!(harness.launcher.live_count(), 5);
4778 }
4779 }
4780
4781 #[tokio::test]
4792 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4793 let launcher = Arc::new(FakeLauncher::forgetful());
4794 let mut harness = Harness::build(
4795 host_with(64),
4796 Arc::clone(&launcher),
4797 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4798 Arc::new(InProcessAllocationLock::new()),
4799 );
4800
4801 let report = harness
4802 .reconciler
4803 .reconcile(&[policy(1, "acme/app", 8)])
4804 .await;
4805
4806 assert_eq!(
4807 report.started, 3,
4808 "the pass is bounded by the grant it was given, not by the attempt set catching \
4809 up with it"
4810 );
4811 assert_eq!(launcher.launches(), 3);
4812 assert!(
4813 launcher.snapshot().is_empty(),
4814 "the launcher never recorded anything, which is the whole point of the fixture"
4815 );
4816 }
4817
4818 #[tokio::test]
4830 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4831 let launcher = Arc::new(FakeLauncher::forgetful());
4832 let demand = Arc::new(FakeDemand::default());
4833 demand.set_for(
4834 &ScaleTarget::repository("acme/left").unwrap(),
4835 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4836 );
4837 demand.set_for(
4838 &ScaleTarget::repository("acme/right").unwrap(),
4839 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4840 );
4841 let mut harness = Harness::build(
4842 host_with(3),
4843 Arc::clone(&launcher),
4844 demand,
4845 Arc::new(InProcessAllocationLock::new()),
4846 );
4847
4848 let report = harness
4849 .reconciler
4850 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4851 .await;
4852
4853 assert_eq!(
4854 report.started, 3,
4855 "host_capacity is 3 and two policies each allowed 3 started {} runners \
4856 between them; the second policy's grant was computed from a set that did \
4857 not yet contain the first policy's launches",
4858 report.started
4859 );
4860 assert_eq!(launcher.launches(), 3);
4861 }
4862
4863 #[tokio::test]
4874 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
4875 let launcher = Arc::new(FakeLauncher::new());
4876 let mut harness = Harness::build(
4877 host_with(8),
4878 Arc::clone(&launcher),
4879 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
4880 Arc::new(InProcessAllocationLock::new()),
4881 );
4882 let policy = policy(1, "acme/app", 8);
4883
4884 launcher.fail_attempts(true);
4885 let unreadable = harness
4886 .reconciler
4887 .reconcile(std::slice::from_ref(&policy))
4888 .await;
4889
4890 assert_eq!(
4891 unreadable.started, 0,
4892 "nothing may be decided from a set that was not read"
4893 );
4894 assert_eq!(launcher.launches(), 0);
4895 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
4896 assert!(
4897 unreadable.allocations.is_empty(),
4898 "no allocation is reported either: there was no set to compute one from, and \
4899 an allocation of zero would claim a decision nobody made"
4900 );
4901 assert!(harness.events.count_of("attempts_unreadable") > 0);
4902
4903 launcher.fail_attempts(false);
4905 let readable = harness
4906 .reconciler
4907 .reconcile(std::slice::from_ref(&policy))
4908 .await;
4909 assert_eq!(
4910 readable.started, 4,
4911 "the difference between the two passes is only whether the set could be read"
4912 );
4913 assert_eq!(readable.attempts_unreadable, 0);
4914 }
4915
4916 #[tokio::test]
4926 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
4927 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4928 1,
4929 1,
4930 AttemptOutcome::ExitedIdleWithoutWork,
4931 )]));
4932 let mut harness = Harness::build(
4933 host_with(4),
4934 Arc::clone(&launcher),
4935 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4936 Arc::new(InProcessAllocationLock::new()),
4937 );
4938
4939 let report = harness
4940 .reconciler
4941 .reconcile(&[policy(1, "acme/app", 4)])
4942 .await;
4943
4944 assert_eq!(report.cleaned, 0);
4945 assert_eq!(report.clean_failures, 1);
4946 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
4947 assert_eq!(
4948 harness.events.count_of("attempt_cleaned"),
4949 0,
4950 "and it is not reported as cleaned"
4951 );
4952 assert_eq!(
4953 launcher.snapshot().len(),
4954 1,
4955 "the attempt is still there, so the retry is real -- what changed is that it \
4956 is no longer silent"
4957 );
4958
4959 let reasons: Vec<&'static str> = harness
4962 .events
4963 .events()
4964 .into_iter()
4965 .filter_map(|event| match event {
4966 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
4967 _ => None,
4968 })
4969 .collect();
4970 assert_eq!(reasons, vec!["other"]);
4971 }
4972
4973 #[tokio::test]
4976 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
4977 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4978 1,
4979 1,
4980 AttemptOutcome::ExitedIdleWithoutWork,
4981 )]));
4982 let mut harness = Harness::build(
4983 host_with(4),
4984 Arc::clone(&launcher),
4985 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4986 Arc::new(InProcessAllocationLock::new()),
4987 );
4988 let policies = [policy(1, "acme/app", 4)];
4989 let start = harness.clock.now();
4990 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
4991 let mut failures_at = async |offset: Duration| {
4992 harness.clock.set(at(offset));
4993 harness.reconciler.reconcile(&policies).await.clean_failures
4994 };
4995
4996 assert_eq!(failures_at(Duration::ZERO).await, 1);
4997 assert_eq!(
4999 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
5000 0,
5001 "a failed cleanup was retried on the very next pass"
5002 );
5003 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
5004 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
5006 assert_eq!(
5007 failures_at(second - Duration::from_secs(1)).await,
5008 0,
5009 "the delay did not double after a second failure"
5010 );
5011 assert_eq!(failures_at(second).await, 1);
5012 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
5013
5014 let mut backoff = CleanBackoff::default();
5016 let id = AttemptId::from_u128(9);
5017 for _ in 0..40 {
5018 backoff.failed(id, start);
5019 }
5020 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
5021 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
5022 backoff.succeeded(id);
5023 assert!(
5024 backoff.is_due(id, start),
5025 "a cleaned attempt kept its backoff"
5026 );
5027 }
5028
5029 #[tokio::test]
5042 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
5043 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5044 attempt_in(AttemptState::Busy, 1, 1),
5045 concluded(2, 1, AttemptOutcome::CompletedJob),
5046 ]));
5047 let harness = Harness::build(
5048 host_with(4),
5049 Arc::clone(&launcher),
5050 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5051 Arc::new(InProcessAllocationLock::new()),
5052 );
5053 let policy = policy(1, "acme/app", 4);
5054
5055 launcher.fail_attempts(true);
5056 let blind = harness.reconciler.scale_down(&policy).await;
5057
5058 assert!(!blind.is_conclusive(), "the machine was never read");
5059 assert_ne!(
5060 blind,
5061 ScaleDownReport::default(),
5062 "a scale-down that could not see the host must not be equal to one that saw \
5063 an idle host; that equality is the whole finding"
5064 );
5065 assert_eq!(blind.removed, 0);
5066 assert_eq!(
5067 blind.refused_busy, 0,
5068 "and this zero means `unknown`, not `none`"
5069 );
5070 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
5071
5072 launcher.fail_attempts(false);
5074 let seeing = harness.reconciler.scale_down(&policy).await;
5075
5076 assert!(seeing.is_conclusive());
5077 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
5078 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
5079 assert_ne!(
5080 seeing, blind,
5081 "the difference between the two is only whether the set could be read"
5082 );
5083 }
5084
5085 #[tokio::test]
5093 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
5094 let mut warm = ScalePolicy::new(
5095 PolicyId::from_u128(1),
5096 ScaleTarget::repository("acme/app").unwrap(),
5097 1,
5098 fixtures::HOST_ID,
5099 PolicyMode::autoscale(
5100 fixtures::routing_labels("home"),
5101 2,
5102 NonZeroU16::new(5).expect("non-zero"),
5103 )
5104 .expect("min <= max"),
5105 CachePolicy::default(),
5106 );
5107 warm.activate().expect("pending -> active");
5108
5109 let mut harness = Harness::simple(8, 0, "acme/app");
5110 let report = harness.reconciler.reconcile(&[warm]).await;
5111
5112 assert_eq!(
5113 report.allocations[0].demand, 0,
5114 "GitHub reported no queued runs"
5115 );
5116 assert_eq!(
5117 report.allocations[0].desired, 2,
5118 "min_capacity raised the target above demand"
5119 );
5120 assert_eq!(
5121 report.allocations[0].limiting_factor,
5122 LimitingFactor::MinCapacity
5123 );
5124 assert_eq!(report.started, 2, "and two runners were actually started");
5125 assert_eq!(harness.launcher.live_count(), 2);
5126 }
5127
5128 #[tokio::test]
5133 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
5134 let mut harness = Harness::simple(100, 10, "acme/app");
5135 let report = harness
5136 .reconciler
5137 .reconcile(&[policy(1, "acme/app", 3)])
5138 .await;
5139
5140 assert_eq!(report.allocations[0].demand, 10);
5141 assert_eq!(
5142 report.allocations[0].desired, 3,
5143 "max_capacity beats demand"
5144 );
5145 assert_eq!(report.started, 3);
5146 assert_eq!(
5147 report.allocations[0].limiting_factor,
5148 LimitingFactor::MaxCapacity
5149 );
5150 }
5151
5152 #[tokio::test]
5153 async fn demand_below_min_capacity_starts_nothing_in_v1() {
5154 let mut harness = Harness::simple(8, 0, "acme/app");
5157 let report = harness
5158 .reconciler
5159 .reconcile(&[policy(1, "acme/app", 4)])
5160 .await;
5161
5162 assert_eq!(report.allocations[0].desired, 0);
5163 assert_eq!(report.started, 0);
5164 assert!(report.starts_nothing());
5165 }
5166
5167 #[tokio::test]
5168 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
5169 let policy = policy(1, "octo/repo", 1);
5170 let previous = attempt_in(AttemptState::Starting, 41, 1);
5171 let intent = ReplacementIntent {
5172 policy: policy.id,
5173 previous_attempt: previous.id,
5174 operation: "exit_before_acceptance_replacement",
5175 };
5176 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
5177 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
5178 let mut harness = Harness::build(
5179 host_with(1),
5180 Arc::clone(&launcher),
5181 demand,
5182 Arc::new(InProcessAllocationLock::new()),
5183 );
5184
5185 let report = harness.reconciler.reconcile(&[policy]).await;
5186
5187 assert_eq!(report.replacement_intents, 1);
5188 assert_eq!(report.started, 1);
5189 assert_eq!(launcher.launches(), 1);
5190 assert_eq!(launcher.live_count(), 1);
5191 }
5192
5193 #[tokio::test]
5194 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
5195 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5196 attempt_in(AttemptState::Busy, 1, 1),
5197 attempt_in(AttemptState::Busy, 2, 1),
5198 ]));
5199 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
5200 let mut harness = Harness::build(
5201 host_with(2),
5202 launcher,
5203 demand,
5204 Arc::new(InProcessAllocationLock::new()),
5205 );
5206
5207 let report = harness
5208 .reconciler
5209 .reconcile(&[policy(1, "acme/app", 2)])
5210 .await;
5211 assert_eq!(report.started, 0);
5212 assert_eq!(report.allocations[0].headroom_before, 0);
5213 assert_eq!(harness.launcher.launches(), 0);
5214 }
5215
5216 #[tokio::test]
5217 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
5218 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5221 attempt_in(AttemptState::Busy, 1, 99),
5222 attempt_in(AttemptState::Busy, 2, 99),
5223 attempt_in(AttemptState::Idle, 3, 99),
5224 attempt_in(AttemptState::Starting, 4, 99),
5225 ]));
5226 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
5227 let mut harness = Harness::build(
5228 host_with(6),
5229 launcher,
5230 demand,
5231 Arc::new(InProcessAllocationLock::new()),
5232 );
5233
5234 let report = harness
5235 .reconciler
5236 .reconcile(&[policy(1, "acme/app", 5)])
5237 .await;
5238 assert_eq!(
5239 report.allocations[0].desired, 5,
5240 "its own ceiling allows five"
5241 );
5242 assert_eq!(report.started, 2, "the host has two slots free");
5243 assert_eq!(
5244 report.allocations[0].limiting_factor,
5245 LimitingFactor::HostCapacity
5246 );
5247 assert_eq!(harness.launcher.live_count(), 6);
5248 }
5249
5250 #[tokio::test]
5251 async fn the_idle_host_assertion_holds() {
5252 let mut harness = Harness::simple(8, 0, "acme/app");
5255 let report = harness
5256 .reconciler
5257 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
5258 .await;
5259
5260 assert_eq!(report.started, 0);
5261 assert_eq!(harness.launcher.launches(), 0);
5262 assert!(harness.launcher.snapshot().is_empty());
5263 assert_eq!(
5264 harness
5265 .launcher
5266 .snapshot()
5267 .iter()
5268 .filter(|a| !a.is_terminal())
5269 .count(),
5270 0
5271 );
5272 }
5273
5274 #[tokio::test]
5279 async fn live_capacity_increase_is_used_without_rebuilding_the_reconciler() {
5280 let mut harness = Harness::simple(1, 5, "acme/app");
5281 let policies = [policy(1, "acme/app", 5)];
5282 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5283
5284 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(4);
5285 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 3);
5286 assert_eq!(harness.launcher.live_count(), 4);
5287 }
5288
5289 #[tokio::test]
5290 async fn live_capacity_decrease_keeps_busy_runners_and_waits_for_headroom() {
5291 let mut harness = Harness::simple(5, 5, "acme/app");
5292 let busy: Vec<_> = (1..=3)
5293 .map(|id| attempt_in(AttemptState::Busy, id, 1))
5294 .collect();
5295 *harness.launcher.attempts.lock().unwrap() = busy.clone();
5296 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5297 let policies = [policy(1, "acme/app", 5)];
5298
5299 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 0);
5300 assert_eq!(harness.launcher.snapshot(), busy);
5301 assert!(harness.launcher.cleaned().is_empty());
5302
5303 harness.launcher.attempts.lock().unwrap().truncate(1);
5305 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5306 assert_eq!(harness.launcher.live_count(), 2);
5307 }
5308
5309 #[tokio::test]
5310 async fn live_capacity_is_rechecked_between_launches_in_the_same_pass() {
5311 let mut harness = Harness::simple(5, 5, "acme/app");
5312 *harness.launcher.capacity_after_launch.lock().unwrap() = NonZeroU16::new(1);
5313
5314 let report = harness
5315 .reconciler
5316 .reconcile(&[policy(1, "acme/app", 5)])
5317 .await;
5318 assert_eq!(report.started, 1);
5319 assert_eq!(harness.launcher.live_count(), 1);
5320 }
5321
5322 #[tokio::test]
5323 async fn live_capacity_unreadable_blocks_launches_until_the_journal_recovers() {
5324 let mut harness = Harness::simple(5, 5, "acme/app");
5325 harness
5326 .launcher
5327 .capacity_unreadable
5328 .store(true, Ordering::SeqCst);
5329 let policies = [policy(1, "acme/app", 5)];
5330 let report = harness.reconciler.reconcile(&policies).await;
5331 assert_eq!(report.started, 0);
5332 assert_eq!(report.host_capacity_unreadable, 1);
5333 assert_eq!(report.attempts_unreadable, 0);
5334 assert_eq!(harness.events.count_of("attempts_unreadable"), 0);
5335 assert!(
5336 harness
5337 .events
5338 .events()
5339 .contains(&LifecycleEvent::HostCapacityUnreadable {
5340 reason: HostCapacityError::JournalUnreadable,
5341 })
5342 );
5343
5344 harness
5345 .launcher
5346 .capacity_unreadable
5347 .store(false, Ordering::SeqCst);
5348 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5349 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 2);
5350 }
5351
5352 #[tokio::test]
5353 async fn unreadable_local_state_does_not_treat_a_busy_host_as_idle() {
5354 for host_failure in [true, false] {
5355 let mut harness = Harness::simple(5, 0, "acme/app");
5356 harness
5357 .reconciler
5358 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5359 *harness.launcher.attempts.lock().unwrap() = vec![attempt_in(AttemptState::Busy, 1, 1)];
5360 harness
5361 .launcher
5362 .capacity_unreadable
5363 .store(host_failure, Ordering::SeqCst);
5364 harness.launcher.fail_attempts(!host_failure);
5365 let report = harness
5366 .reconciler
5367 .reconcile(&[policy(1, "acme/app", 5)])
5368 .await;
5369 assert_eq!(report.host_capacity_unreadable, u16::from(host_failure));
5370 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5371 assert_eq!(harness.launcher.live_count(), 1);
5372 }
5373 }
5374
5375 #[derive(Debug)]
5376 struct HostEditOnLock {
5377 inner: InProcessAllocationLock,
5378 launcher: Arc<FakeLauncher>,
5379 unreadable: bool,
5380 }
5381
5382 #[async_trait::async_trait]
5383 impl AllocationLock for HostEditOnLock {
5384 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
5385 let guard = self.inner.acquire().await?;
5386 *self.launcher.capacity.lock().unwrap() = NonZeroU16::new(1);
5387 self.launcher
5388 .capacity_unreadable
5389 .store(self.unreadable, Ordering::SeqCst);
5390 Ok(guard)
5391 }
5392 }
5393
5394 #[tokio::test]
5395 async fn live_capacity_edits_and_read_failures_while_waiting_for_the_lock_block_launches() {
5396 for unreadable in [false, true] {
5397 let launcher =
5398 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5399 let lock = Arc::new(HostEditOnLock {
5400 inner: InProcessAllocationLock::new(),
5401 launcher: Arc::clone(&launcher),
5402 unreadable,
5403 });
5404 let mut harness = Harness::build(
5405 host_with(5),
5406 Arc::clone(&launcher),
5407 Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
5408 lock,
5409 );
5410 let report = harness
5411 .reconciler
5412 .reconcile(&[policy(1, "acme/app", 5)])
5413 .await;
5414 assert_eq!(report.started, 0);
5415 assert_eq!(launcher.live_count(), 1);
5416 assert!(launcher.cleaned().is_empty());
5417 assert_eq!(report.host_capacity_unreadable, u16::from(unreadable));
5418 assert_eq!(report.attempts_unreadable, 0);
5419 }
5420 }
5421
5422 #[tokio::test(flavor = "current_thread")]
5445 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
5446 let lock = Arc::new(CountingLock::new());
5447 let (launches, live) =
5448 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>, None).await;
5449
5450 assert_eq!(
5451 launches, 3,
5452 "the sum across policies must never exceed host_capacity, and each policy is \
5453 individually within its own max_capacity of 3"
5454 );
5455 assert_eq!(live, 3, "and no duplicate runner survived the race");
5456 assert_eq!(
5457 lock.peak(),
5458 1,
5459 "the allocation lock had one holder at a time; without that the read of the \
5460 headroom and the creation of the runtime are not atomic"
5461 );
5462 assert!(
5463 (3..=5).contains(&lock.acquisitions()),
5464 "the lock is taken before *each* runtime, not once per pass: three runtimes \
5465 means at least three holds, and at most one further hold per policy to \
5466 discover the host filled up underneath it. It was taken {} times",
5467 lock.acquisitions()
5468 );
5469 }
5470
5471 #[tokio::test(flavor = "current_thread")]
5478 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
5479 let (launches, _) =
5480 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>, None).await;
5481
5482 assert!(
5483 launches > 3,
5484 "with no serialisation both policies must be able to spend the same headroom; \
5485 they started {launches} runners on a host of 3. If this is ever 3, the \
5486 contention window closed and `two_policies_reconciling_concurrently_never_\
5487 exceed_host_capacity` has stopped proving anything"
5488 );
5489 }
5490
5491 async fn two_policies_concurrently(
5495 lock: Arc<dyn AllocationLock>,
5496 live_capacity: Option<NonZeroU16>,
5497 ) -> (usize, usize) {
5498 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
5499 *launcher.capacity.lock().unwrap() = live_capacity;
5500 let host = host_with(3);
5501
5502 let mut left = Harness::build(
5503 host.clone(),
5504 Arc::clone(&launcher),
5505 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
5506 Arc::clone(&lock),
5507 )
5508 .reconciler;
5509 let mut right = Harness::build(
5510 host,
5511 Arc::clone(&launcher),
5512 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
5513 Arc::clone(&lock),
5514 )
5515 .reconciler;
5516
5517 let a = policy(1, "acme/left", 3);
5518 let b = policy(2, "acme/right", 3);
5519
5520 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
5521 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
5522 let (_, _) = (left.await.unwrap(), right.await.unwrap());
5523
5524 (launcher.launches(), launcher.live_count())
5525 }
5526
5527 #[tokio::test(flavor = "current_thread")]
5528 async fn live_capacity_is_shared_by_concurrent_targets_with_stale_startup_hosts() {
5529 let (launches, live) =
5530 two_policies_concurrently(Arc::new(InProcessAllocationLock::new()), NonZeroU16::new(2))
5531 .await;
5532 assert_eq!(launches, 2);
5533 assert_eq!(live, 2);
5534 }
5535
5536 #[tokio::test]
5548 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
5549 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
5550 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
5551 let launcher = Arc::new(FakeLauncher::new());
5552 let events = Arc::new(EventLog::new());
5553
5554 let mut reconciler = Reconciler::new(
5555 host_with(10),
5556 ReconcilerPorts {
5557 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
5558 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5559 lock: Arc::new(InProcessAllocationLock::new()),
5560 directory: Arc::new(FakeDirectory::default()),
5561 clock: Arc::new(FakeClock::default()),
5562 jitter: Arc::new(NoJitter),
5563 events: Arc::clone(&events) as Arc<dyn EventSink>,
5564 },
5565 );
5566
5567 let monitor = fixtures::policy()
5568 .id(PolicyId::from_u128(1))
5569 .repository("acme/app")
5570 .monitor_only()
5571 .active()
5572 .build();
5573
5574 let report = reconciler.reconcile(&[monitor]).await;
5575
5576 assert_eq!(report.started, 0);
5577 assert_eq!(launcher.launches(), 0);
5578 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
5579 assert_eq!(
5580 report.demand_requests, 0,
5581 "a monitor-only policy spends nothing from the shared hourly ceiling"
5582 );
5583 assert!(
5584 gateway.gateway().calls().is_empty(),
5585 "a monitor-only policy issued a demand request: {:?}",
5586 gateway.gateway().calls()
5587 );
5588 assert_eq!(events.count_of("monitor_only_skipped"), 1);
5589 assert_eq!(
5590 events.count_of("demand_observed"),
5591 0,
5592 "and it contributed no demand"
5593 );
5594 }
5595
5596 #[tokio::test]
5614 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
5615 let lock = Arc::new(CountingLock::new());
5616 let launcher = Arc::new(FakeLauncher::new());
5617 let events = Arc::new(EventLog::new());
5618 let mut reconciler = Reconciler::new(
5619 host_with(4),
5620 ReconcilerPorts {
5621 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5622 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5623 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
5624 directory: Arc::new(FakeDirectory::default()),
5625 clock: Arc::new(FakeClock::default()),
5626 jitter: Arc::new(NoJitter),
5627 events: Arc::clone(&events) as Arc<dyn EventSink>,
5628 },
5629 );
5630
5631 let watcher = fixtures::policy()
5632 .id(PolicyId::from_u128(2))
5633 .repository("acme/app")
5634 .monitor_only()
5635 .active()
5636 .build();
5637
5638 let report = reconciler
5639 .reconcile(&[policy(1, "acme/app", 4), watcher])
5640 .await;
5641
5642 assert_eq!(report.started, 2, "the autoscale policy is served normally");
5643 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
5644 assert_eq!(
5645 events.count_of("demand_observed"),
5646 1,
5647 "the demand observation belongs to the autoscale policy alone"
5648 );
5649 assert!(
5650 report
5651 .allocations
5652 .iter()
5653 .all(|a| a.policy_id == PolicyId::from_u128(1)),
5654 "a monitor-only policy was allocated for: {:?}",
5655 report.allocations
5656 );
5657 assert_eq!(
5658 lock.acquisitions(),
5659 2,
5660 "one hold per runtime created, and none on behalf of the monitor-only policy. \
5661 It was three before the budget was checked at the top of the loop rather than \
5662 after the re-read, which cost every policy a surplus hold to discover there \
5663 was nothing left to grant"
5664 );
5665 }
5666
5667 #[tokio::test]
5668 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
5669 let monitor = fixtures::monitor_only_policy();
5674 assert!(!monitor.owns_runners());
5675 assert_eq!(monitor.max_capacity(), None);
5676
5677 let host = host_with(10);
5678 let attempts: Vec<RunnerAttempt> = Vec::new();
5679 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5680 let allocation = allocator.allocate(&monitor, 10_000);
5681 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
5682 assert_eq!(allocation.to_start, 0);
5683 assert_eq!(
5684 allocator.headroom(),
5685 10,
5686 "and it consumes no headroom, so an autoscale policy on the same host is \
5687 unaffected"
5688 );
5689 }
5690
5691 #[tokio::test]
5699 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
5700 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5701 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
5702 concluded(
5703 2,
5704 1,
5705 AttemptOutcome::failed(FailureReason::JitRequestFailed),
5706 ),
5707 concluded(3, 1, AttemptOutcome::CompletedJob),
5708 ]));
5709 let mut harness = Harness::build(
5710 host_with(4),
5711 Arc::clone(&launcher),
5712 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5713 Arc::new(InProcessAllocationLock::new()),
5714 );
5715
5716 let report = harness
5717 .reconciler
5718 .reconcile(&[policy(1, "acme/app", 4)])
5719 .await;
5720
5721 assert_eq!(report.cleaned, 3);
5722 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
5723 assert_eq!(
5724 report.failures, 1,
5725 "only the failed attempt is a failure; the idle exit and the completed job are \
5726 not"
5727 );
5728 assert_eq!(launcher.cleaned().len(), 3);
5729 assert!(launcher.snapshot().is_empty());
5730
5731 let cleaned: Vec<OutcomeKind> = harness
5732 .events
5733 .events()
5734 .into_iter()
5735 .filter_map(|event| match event {
5736 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
5737 _ => None,
5738 })
5739 .collect();
5740 assert!(cleaned.contains(&OutcomeKind::IdleExit));
5741 assert!(
5742 !OutcomeKind::IdleExit.is_failure(),
5743 "an idle exit rendered as a failure sends an operator hunting a fault that does \
5744 not exist"
5745 );
5746 }
5747
5748 #[tokio::test]
5751 async fn scale_down_removes_nothing_from_a_busy_attempt() {
5752 let busy = attempt_in(AttemptState::Busy, 1, 1);
5753 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5754 busy.clone(),
5755 attempt_in(AttemptState::Starting, 2, 1),
5756 concluded(3, 1, AttemptOutcome::CompletedJob),
5757 ]));
5758 let harness = Harness::build(
5759 host_with(4),
5760 Arc::clone(&launcher),
5761 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5762 Arc::new(InProcessAllocationLock::new()),
5763 );
5764
5765 let report = harness
5766 .reconciler
5767 .scale_down(&policy(1, "acme/app", 4))
5768 .await;
5769
5770 assert_eq!(report.refused_busy, 1);
5771 assert_eq!(
5772 report.retained, 1,
5773 "the `starting` attempt is not ended either"
5774 );
5775 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
5776
5777 let after = launcher.snapshot();
5778 let still_busy = after
5779 .iter()
5780 .find(|a| a.id == AttemptId::from_u128(1))
5781 .expect("the busy attempt is still there");
5782 assert_eq!(
5783 still_busy.state(),
5784 AttemptState::Busy,
5785 "scale-down removed nothing from a runner that is executing a job, and left it \
5786 busy"
5787 );
5788 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
5789
5790 let mut busy = busy;
5794 assert!(matches!(
5795 busy.clean(fixtures::created_at()),
5796 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
5797 ));
5798 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
5799 }
5800
5801 #[test]
5806 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
5807 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
5808 assert_eq!(RefreshInterval::MIN_SECS, 30);
5809 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
5810 assert!(
5811 RefreshInterval::from_secs(29).is_err(),
5812 "the floor is a rate-budget constraint, and a caller must not be able to write \
5813 a shorter interval at all"
5814 );
5815
5816 let mut schedule = PollSchedule::new(RefreshInterval::default());
5817 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
5818 assert_eq!(next.delay, Duration::from_secs(60));
5819 assert_eq!(next.pace, PollPace::Nominal);
5820
5821 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5822 assert_eq!(
5823 floored
5824 .next_poll(None, fixtures::created_at(), &NoJitter)
5825 .delay,
5826 Duration::from_secs(30)
5827 );
5828 }
5829
5830 #[test]
5834 fn a_rate_limit_increases_the_delay_and_names_itself() {
5835 let now = fixtures::created_at();
5836 let mut schedule = PollSchedule::new(RefreshInterval::default());
5837
5838 let limited = RefreshState::RateLimited(RateLimited {
5839 kind: RateLimitKind::Secondary,
5840 retry_after: Some(Duration::from_secs(300)),
5841 remaining: None,
5842 reset_unix_secs: None,
5843 });
5844 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5845
5846 assert_eq!(next.delay, Duration::from_secs(300));
5847 assert_eq!(
5848 next.pace,
5849 PollPace::RateLimited {
5850 kind: RateLimitKind::Secondary
5851 },
5852 "the increase is reported, never hidden"
5853 );
5854 assert!(next.pace.is_throttled());
5855 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
5856 }
5857
5858 #[test]
5861 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
5862 let now = fixtures::created_at();
5863 let mut schedule = PollSchedule::new(RefreshInterval::default());
5864
5865 let limited = RefreshState::RateLimited(RateLimited {
5866 kind: RateLimitKind::Primary,
5867 retry_after: Some(Duration::from_secs(300)),
5868 remaining: Some(0),
5869 reset_unix_secs: None,
5870 });
5871
5872 for _ in 0..5 {
5875 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5876 assert_eq!(
5877 next.delay,
5878 Duration::from_secs(300),
5879 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
5880 have compounded on every successive retry"
5881 );
5882 }
5883
5884 let brief = RefreshState::RateLimited(RateLimited {
5887 kind: RateLimitKind::Secondary,
5888 retry_after: Some(Duration::from_secs(5)),
5889 remaining: None,
5890 reset_unix_secs: None,
5891 });
5892 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
5893 assert_eq!(
5894 next.delay,
5895 Duration::from_secs(60),
5896 "a short `retry-after` may not drop the loop below its own interval"
5897 );
5898 assert!(next.delay >= PollSchedule::floor());
5899 }
5900
5901 #[test]
5902 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
5903 let now = fixtures::created_at();
5904 let states = [
5905 None,
5906 Some(RefreshState::Offline),
5907 Some(RefreshState::RateLimited(RateLimited {
5908 kind: RateLimitKind::Secondary,
5909 retry_after: Some(Duration::from_secs(1)),
5910 remaining: None,
5911 reset_unix_secs: None,
5912 })),
5913 Some(RefreshState::LockedOut {
5914 retry_after: Duration::from_secs(1),
5915 }),
5916 Some(RefreshState::Unauthorized),
5917 Some(RefreshState::Forbidden { message: None }),
5918 Some(RefreshState::Failed {
5919 status: Some(500),
5920 message: "server error".into(),
5921 }),
5922 Some(RefreshState::Cancelled),
5923 ];
5924
5925 for state in &states {
5926 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5927 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
5928 assert!(
5929 next.delay >= PollSchedule::floor(),
5930 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
5931 next.delay.as_millis()
5932 );
5933 }
5934 }
5935
5936 #[test]
5937 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
5938 let now = fixtures::created_at();
5939 let mut schedule = PollSchedule::new(RefreshInterval::default());
5940
5941 let mut previous = Duration::ZERO;
5943 for consecutive in 1..=6_u32 {
5944 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5945 assert_eq!(next.pace, PollPace::Offline { consecutive });
5946 assert!(
5947 next.delay >= previous,
5948 "the back-off must not shrink while the outage continues"
5949 );
5950 assert!(next.delay >= Duration::from_secs(60));
5951 previous = next.delay;
5952 }
5953 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
5954
5955 let mut jittered = PollSchedule::new(RefreshInterval::default());
5958 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5959 let mut jittered = PollSchedule::new(RefreshInterval::default());
5960 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
5961 assert!(full.delay > none.delay);
5962 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
5963
5964 assert_eq!(schedule.consecutive_offline(), 6);
5966 let recovered = schedule.next_poll(None, now, &NoJitter);
5967 assert_eq!(recovered.pace, PollPace::Nominal);
5968 assert_eq!(recovered.delay, Duration::from_secs(60));
5969 assert_eq!(schedule.consecutive_offline(), 0);
5970 }
5971
5972 #[test]
5973 fn the_offline_state_states_the_twenty_four_hour_bound() {
5974 assert_eq!(
5975 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
5976 Duration::from_secs(24 * 60 * 60)
5977 );
5978
5979 let brief = OfflineState::new(1, Duration::from_secs(120));
5980 let rendered = brief.to_string();
5981 assert!(rendered.contains("24 hours"), "{rendered}");
5982 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
5983 assert!(!brief.has_outlasted_the_queue());
5984
5985 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
5986 assert!(long.has_outlasted_the_queue());
5987 assert!(
5988 long.to_string().contains("queued work has been lost"),
5989 "{long}"
5990 );
5991
5992 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
5994 }
5995
5996 fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
6001 RateLimitSnapshot {
6002 limit: 5_000,
6003 remaining,
6004 used: Some(5_000 - remaining),
6005 reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
6006 observed_at: now,
6007 }
6008 }
6009
6010 fn quiet() -> PollConditions {
6011 PollConditions {
6012 quiet: true,
6013 quota: None,
6014 requests_per_pass: 0,
6015 }
6016 }
6017
6018 #[test]
6019 fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
6020 let now = fixtures::created_at();
6021 let mut schedule =
6022 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6023
6024 let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
6025 assert_eq!(idle.delay, Duration::from_secs(10));
6026 assert_eq!(idle.pace, PollPace::Idle);
6027 assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
6028
6029 let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
6030 assert_eq!(busy.delay, Duration::from_secs(60));
6031 assert_eq!(busy.pace, PollPace::Nominal);
6032 }
6033
6034 #[test]
6035 fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
6036 let now = fixtures::created_at();
6037 let mut schedule = PollSchedule::new(RefreshInterval::default());
6038 assert_eq!(
6039 schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
6040 Duration::from_secs(60)
6041 );
6042 }
6043
6044 #[test]
6045 fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
6046 let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
6047 .with_idle(Duration::from_secs(120));
6048 assert_eq!(
6049 schedule.idle(),
6050 Duration::from_secs(30),
6051 "an idle interval above the active one would make quiet targets slower to react"
6052 );
6053 let schedule =
6054 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
6055 assert_eq!(schedule.idle(), PollSchedule::idle_floor());
6056 }
6057
6058 #[test]
6061 fn a_low_quota_stretches_polling_and_wins_over_idle() {
6062 let now = fixtures::created_at();
6063 let mut schedule =
6064 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6065
6066 let healthy = schedule.next_poll_with(
6067 None,
6068 PollConditions {
6069 quiet: true,
6070 quota: Some(quota(1_000, 3_000, now)),
6071 requests_per_pass: 0,
6072 },
6073 now,
6074 &NoJitter,
6075 );
6076 assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
6077
6078 let low = schedule.next_poll_with(
6079 None,
6080 PollConditions {
6081 quiet: true,
6082 quota: Some(quota(999, 3_000, now)),
6083 requests_per_pass: 0,
6084 },
6085 now,
6086 &NoJitter,
6087 );
6088 assert_eq!(low.delay, Duration::from_secs(120));
6089 assert_eq!(
6090 low.pace,
6091 PollPace::Stretched {
6092 pressure: PrimaryPressure::Low
6093 }
6094 );
6095 assert!(low.pace.is_throttled());
6096 assert_eq!(low.pace.as_str(), "stretched_low_budget");
6097
6098 let critical = schedule.next_poll_with(
6099 None,
6100 PollConditions {
6101 quiet: false,
6102 quota: Some(quota(100, 3_000, now)),
6103 requests_per_pass: 0,
6104 },
6105 now,
6106 &NoJitter,
6107 );
6108 assert_eq!(critical.delay, Duration::from_secs(240));
6109 assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
6110 }
6111
6112 #[test]
6113 fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
6114 let now = fixtures::created_at();
6115 let mut schedule = PollSchedule::new(RefreshInterval::default());
6116
6117 let soon = schedule.next_poll_with(
6118 None,
6119 PollConditions {
6120 quiet: false,
6121 quota: Some(quota(10, 90, now)),
6122 requests_per_pass: 0,
6123 },
6124 now,
6125 &NoJitter,
6126 );
6127 assert_eq!(
6128 soon.delay,
6129 Duration::from_secs(90),
6130 "the bucket refills in 90 s; waiting 240 s buys nothing"
6131 );
6132
6133 let imminent = schedule.next_poll_with(
6134 None,
6135 PollConditions {
6136 quiet: false,
6137 quota: Some(quota(10, 5, now)),
6138 requests_per_pass: 0,
6139 },
6140 now,
6141 &NoJitter,
6142 );
6143 assert_eq!(imminent.delay, Duration::from_secs(60));
6144
6145 let expired = schedule.next_poll_with(
6146 None,
6147 PollConditions {
6148 quiet: true,
6149 quota: Some(quota(10, -5, now)),
6150 requests_per_pass: 0,
6151 },
6152 now,
6153 &NoJitter,
6154 );
6155 assert_eq!(
6156 expired.pace,
6157 PollPace::Nominal,
6158 "a reading from a window that has reset says nothing; the schedule here had no \
6159 idle interval, so it is the active one"
6160 );
6161 }
6162
6163 #[test]
6164 fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
6165 let now = fixtures::created_at();
6166 let mut schedule =
6167 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6168 let small = schedule.next_poll_with(
6169 None,
6170 PollConditions {
6171 quiet: true,
6172 quota: None,
6173 requests_per_pass: 40,
6174 },
6175 now,
6176 &NoJitter,
6177 );
6178 assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
6179 let organization = schedule.next_poll_with(
6180 None,
6181 PollConditions {
6182 quiet: true,
6183 quota: None,
6184 requests_per_pass: 400,
6185 },
6186 now,
6187 &NoJitter,
6188 );
6189 assert_eq!(
6190 organization.delay,
6191 Duration::from_secs(80),
6192 "400 requests a pass at 300 a minute is one pass every 80 s"
6193 );
6194 assert_eq!(organization.pace, PollPace::Idle);
6195 }
6196
6197 #[test]
6198 fn a_rate_limit_wait_is_jittered_upward_only() {
6199 let now = fixtures::created_at();
6200 let mut schedule = PollSchedule::new(RefreshInterval::default());
6201 let limited = RefreshState::RateLimited(RateLimited {
6202 kind: RateLimitKind::Secondary,
6203 retry_after: Some(Duration::from_secs(300)),
6204 remaining: None,
6205 reset_unix_secs: None,
6206 });
6207 assert_eq!(
6208 schedule
6209 .next_poll(Some(&limited), now, &FixedJitter(0.5))
6210 .delay,
6211 Duration::from_secs(315),
6212 "every loop shares one quiet period; without jitter they all wake together"
6213 );
6214 assert_eq!(
6215 schedule.next_poll(Some(&limited), now, &NoJitter).delay,
6216 Duration::from_secs(300),
6217 "never shorter than GitHub asked"
6218 );
6219 }
6220
6221 #[tokio::test]
6222 async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
6223 let launcher =
6226 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
6227 let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
6228 let mut harness = Harness::build(
6229 host_with(1),
6230 launcher,
6231 demand,
6232 Arc::new(InProcessAllocationLock::new()),
6233 );
6234 harness
6235 .reconciler
6236 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6237 let policy = policy(1, "acme/app", 4);
6238
6239 let report = harness
6240 .reconciler
6241 .reconcile(std::slice::from_ref(&policy))
6242 .await;
6243
6244 assert_eq!(report.started, 0, "the host is full");
6245 assert_eq!(
6246 report.next_poll.pace,
6247 PollPace::Nominal,
6248 "matched demand it could not serve is work waiting, not idle"
6249 );
6250 }
6251
6252 #[tokio::test]
6253 async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
6254 let mut harness = Harness::simple(4, 0, "acme/app");
6255 harness
6256 .reconciler
6257 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6258 let policy = policy(1, "acme/app", 4);
6259
6260 let report = harness
6261 .reconciler
6262 .reconcile(std::slice::from_ref(&policy))
6263 .await;
6264
6265 assert_eq!(report.next_poll.pace, PollPace::Idle);
6266 assert_eq!(report.next_poll.delay, Duration::from_secs(10));
6267 }
6268
6269 #[tokio::test]
6270 async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
6271 let mut harness = Harness::simple(4, 0, "acme/app");
6272 harness
6273 .reconciler
6274 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6275 harness.demand.set(PollOutcome::Ready(
6276 QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
6277 ));
6278 let policy = policy(1, "acme/app", 4);
6279
6280 let report = harness
6281 .reconciler
6282 .reconcile(std::slice::from_ref(&policy))
6283 .await;
6284
6285 assert_eq!(report.next_poll.pace, PollPace::Nominal);
6286 assert_eq!(report.next_poll.delay, Duration::from_secs(60));
6287 }
6288
6289 #[tokio::test]
6290 async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
6291 let launcher =
6292 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
6293 let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
6294 let mut harness = Harness::build(
6295 host_with(4),
6296 launcher,
6297 demand,
6298 Arc::new(InProcessAllocationLock::new()),
6299 );
6300 harness
6301 .reconciler
6302 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6303 let policy = policy(1, "acme/app", 4);
6304
6305 let report = harness
6306 .reconciler
6307 .reconcile(std::slice::from_ref(&policy))
6308 .await;
6309
6310 assert_eq!(
6311 report.next_poll.pace,
6312 PollPace::Nominal,
6313 "a job is running: its follow-on work is what the active interval watches for"
6314 );
6315 }
6316
6317 #[tokio::test]
6318 async fn a_pass_that_starts_a_runner_is_not_idle() {
6319 let mut harness = Harness::simple(4, 2, "acme/app");
6320 harness
6321 .reconciler
6322 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6323 let policy = policy(1, "acme/app", 4);
6324
6325 let report = harness
6326 .reconciler
6327 .reconcile(std::slice::from_ref(&policy))
6328 .await;
6329
6330 assert!(report.started > 0);
6331 assert_eq!(report.next_poll.pace, PollPace::Nominal);
6332 }
6333
6334 #[tokio::test]
6335 async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
6336 let mut harness = Harness::simple(4, 0, "acme/app");
6337 harness
6338 .reconciler
6339 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6340 let now = harness.clock.now();
6341 harness.demand.set(PollOutcome::Ready(
6342 QueuedDemand::of(repo("acme/app"), Vec::new())
6343 .with_rate_limit(Some(quota(200, 1_800, now))),
6344 ));
6345 let policy = policy(1, "acme/app", 4);
6346
6347 let report = harness
6348 .reconciler
6349 .reconcile(std::slice::from_ref(&policy))
6350 .await;
6351
6352 assert_eq!(
6353 report.next_poll.pace,
6354 PollPace::Stretched {
6355 pressure: PrimaryPressure::Critical
6356 }
6357 );
6358 assert_eq!(report.next_poll.delay, Duration::from_secs(240));
6359 }
6360
6361 #[tokio::test]
6370 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
6371 let live = vec![
6372 attempt_in(AttemptState::Busy, 1, 1),
6373 attempt_in(AttemptState::Starting, 2, 1),
6374 ];
6375 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
6376 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6377 let mut harness = Harness::build(
6378 host_with(8),
6379 Arc::clone(&launcher),
6380 Arc::clone(&demand),
6381 Arc::new(InProcessAllocationLock::new()),
6382 );
6383 let policy = policy(1, "acme/app", 8);
6384
6385 let report = harness
6386 .reconciler
6387 .reconcile(std::slice::from_ref(&policy))
6388 .await;
6389
6390 assert!(report.is_offline());
6391 assert_eq!(report.started, 0, "no new runner during an outage");
6392 assert_eq!(launcher.launches(), 0);
6393 assert_eq!(
6394 launcher.snapshot(),
6395 live,
6396 "existing runner processes are retained, untouched"
6397 );
6398 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6399 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6400 assert!(report.next_poll.delay >= Duration::from_secs(60));
6401 let offline = report.offline_state().expect("an offline state to display");
6402 assert!(offline.to_string().contains("24 hours"));
6403
6404 demand.set(PollOutcome::Ready(QueuedDemand::of(
6407 repo("acme/app"),
6408 jobs(2),
6409 )));
6410 let recovered = harness.reconciler.reconcile(&[policy]).await;
6411
6412 assert!(!recovered.is_offline());
6413 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
6414 assert_eq!(
6415 recovered.started, 1,
6416 "two queued jobs and one starting runner leave one job uncovered"
6417 );
6418 assert_eq!(recovered.allocations[0].active_owned, 2);
6419 assert_eq!(launcher.live_count(), 3);
6420 }
6421
6422 #[tokio::test]
6432 async fn one_offline_target_does_not_stop_a_reachable_one() {
6433 let mut harness = Harness::simple(8, 0, "acme/app");
6434 harness.demand.set_for(
6435 &ScaleTarget::repository("acme/app").unwrap(),
6436 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
6437 );
6438 harness.demand.set_for(
6439 &ScaleTarget::repository("acme/broken").unwrap(),
6440 PollOutcome::Failed(RefreshState::Offline),
6441 );
6442
6443 let report = harness
6444 .reconciler
6445 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
6446 .await;
6447
6448 assert_eq!(
6449 report.started, 2,
6450 "the reachable target was served; an unreachable sibling repository must not \
6451 idle the host"
6452 );
6453 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
6454 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
6455
6456 assert!(report.is_offline());
6458 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6459 }
6460
6461 #[tokio::test]
6476 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
6477 let clock = Arc::new(FakeClock::default());
6478 let launcher = Arc::new(FakeLauncher::new());
6479 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6480 let mut reconciler = Reconciler::new(
6481 host_with(4),
6482 ReconcilerPorts {
6483 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
6484 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6485 lock: Arc::new(InProcessAllocationLock::new()),
6486 directory: Arc::new(FakeDirectory::default()),
6487 clock: Arc::clone(&clock) as Arc<dyn Clock>,
6488 jitter: Arc::new(NoJitter),
6489 events: Arc::new(NoEvents),
6490 },
6491 );
6492 let policy = policy(1, "acme/app", 4);
6493
6494 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6496 let state = first.offline_state().expect("an offline state");
6497 assert!(!state.has_outlasted_the_queue());
6498 assert!(
6499 state
6500 .to_string()
6501 .contains("an outage longer than that loses"),
6502 "{state}"
6503 );
6504
6505 clock.advance_secs(24 * 60 * 60 + 60);
6507 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6508 let state = later.offline_state().expect("an offline state");
6509 assert!(state.has_outlasted_the_queue());
6510 assert!(
6511 state.to_string().contains("queued work has been lost"),
6512 "{state}"
6513 );
6514 assert_eq!(launcher.launches(), 0, "and still nothing was started");
6515
6516 demand.set(PollOutcome::Ready(QueuedDemand::of(
6519 repo("acme/app"),
6520 jobs(0),
6521 )));
6522 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6523 assert!(recovered.offline_state().is_none());
6524 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
6525
6526 demand.set(PollOutcome::Failed(RefreshState::Offline));
6527 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6528 assert!(
6529 !again
6530 .offline_state()
6531 .expect("an offline state")
6532 .has_outlasted_the_queue(),
6533 "a new outage must not inherit the age of the one before it"
6534 );
6535 }
6536
6537 #[tokio::test]
6553 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
6554 let root = tempfile::tempdir().expect("a temporary directory");
6555 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
6556 root.path(),
6557 ));
6558 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
6559
6560 let held = lock.acquire().await.expect("an uncontended lock is free");
6561 assert!(
6562 matches!(lock.acquire().await, Err(AllocationLockBusy)),
6563 "a second holder was admitted; on Unix the lock is per open file description \
6564 and on Windows the share mode denies write, so this must be refused even \
6565 from inside the same process"
6566 );
6567
6568 drop(held);
6569 let regained = lock.acquire().await;
6570 assert!(
6571 regained.is_ok(),
6572 "dropping the guard is the only release there is, so a guard that does not \
6573 hold the `HostLock` leaves it held forever"
6574 );
6575 }
6576
6577 #[test]
6578 fn tee_events_reaches_both_sinks() {
6579 let left = Arc::new(EventLog::new());
6583 let right = Arc::new(EventLog::new());
6584 let tee = TeeEvents(
6585 Arc::clone(&left) as Arc<dyn EventSink>,
6586 Arc::clone(&right) as Arc<dyn EventSink>,
6587 );
6588
6589 tee.emit(LifecycleEvent::MonitorOnlySkipped {
6590 policy: PolicyId::from_u128(1),
6591 });
6592
6593 assert_eq!(left.count_of("monitor_only_skipped"), 1);
6594 assert_eq!(right.count_of("monitor_only_skipped"), 1);
6595 }
6596
6597 #[tokio::test]
6602 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
6603 let clock = Arc::new(FakeClock::default());
6604 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
6605 let cache = RepositoryCache::new(
6606 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6607 Arc::clone(&clock) as Arc<dyn Clock>,
6608 RefreshInterval::default(),
6609 );
6610 let target = ScaleTarget::organization("acme").unwrap();
6611
6612 assert_eq!(
6613 cache.ttl(),
6614 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
6615 );
6616
6617 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
6619 let scope = cache.scope_for(&target).await.unwrap();
6620 assert_eq!(scope.repositories().len(), 2);
6621 clock.advance_secs(60);
6622 }
6623 assert_eq!(
6624 directory.calls(),
6625 1,
6626 "re-listing an organization at demand-poll frequency is what exhausts the \
6627 shared request budget"
6628 );
6629 assert_eq!(cache.lookups(), 1);
6630
6631 cache.scope_for(&target).await.unwrap();
6633 assert_eq!(directory.calls(), 2);
6634 }
6635
6636 #[tokio::test]
6637 async fn a_repository_target_never_consults_the_directory() {
6638 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
6639 let cache = RepositoryCache::new(
6640 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6641 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6642 RefreshInterval::default(),
6643 );
6644 let target = ScaleTarget::repository("acme/app").unwrap();
6645
6646 let scope = cache.scope_for(&target).await.unwrap();
6647 assert_eq!(scope.repositories(), &[repo("acme/app")]);
6648 assert_eq!(directory.calls(), 0);
6649 }
6650
6651 #[tokio::test]
6652 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
6653 let mut harness = Harness::simple(8, 4, "o/r");
6657 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6658 repo("o/r"),
6659 [
6660 fixtures::queued_job(&[HOST_LABEL]),
6661 fixtures::queued_job(&[HOST_LABEL]),
6662 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6663 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6664 ],
6665 )));
6666 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6667 isolated.activate().unwrap();
6668 let report = harness
6669 .reconciler
6670 .reconcile(&[policy(1, "o/r", 2), isolated])
6671 .await;
6672
6673 assert_eq!(harness.demand.polls().len(), 1);
6674 assert_eq!(report.targets_read, 1, "profiles share one target reading");
6675 assert_eq!(
6676 report.demand_requests,
6677 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
6678 "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"
6679 );
6680 assert_eq!(
6681 report.started, 4,
6682 "native and isolated profiles each get two"
6683 );
6684 }
6685
6686 #[tokio::test]
6687 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
6688 use runner_manager_domain::model::{Arch, HostLabel, Os};
6689 use runner_manager_domain::policy::RoutingLabels;
6690 let mut harness = Harness::build(
6691 fixtures::host()
6692 .os(Os::MacOs)
6693 .architecture(Arch::Arm64)
6694 .capacity(10)
6695 .build(),
6696 Arc::new(FakeLauncher::new()),
6697 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
6698 Arc::new(InProcessAllocationLock::new()),
6699 );
6700 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6701 repo("o/r"),
6702 [
6703 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
6704 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
6705 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
6706 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
6707 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
6708 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
6709 ],
6710 )));
6711 let mut local = fixtures::policy()
6712 .id(PolicyId::from_u128(1))
6713 .repository("o/r")
6714 .autoscale("macmini", 10)
6715 .routing_labels(RoutingLabels::derive(
6716 &HostLabel::new("macmini").unwrap(),
6717 Os::MacOs,
6718 Arch::Arm64,
6719 ))
6720 .active()
6721 .build();
6722 for label in ["self-hosted", "macos", "arm64", "extra"] {
6723 local.add_routing_label(fixtures::label(label)).unwrap();
6724 }
6725 let mut foreign = local.to_persisted();
6726 foreign.id = PolicyId::from_u128(2);
6727 foreign.host_id = HostId::from_u128(0xdead);
6728 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
6729 let report = harness.reconciler.reconcile(&[local, foreign]).await;
6730 assert_eq!(report.started, 2);
6731 assert_eq!(report.targets_read, 1);
6732 assert!(harness.events.events().iter().any(|event| matches!(
6733 event,
6734 LifecycleEvent::DemandObserved {
6735 demand: 2,
6736 not_matched: 4,
6737 ..
6738 }
6739 )));
6740 assert!(
6741 !harness
6742 .events
6743 .events()
6744 .iter()
6745 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6746 );
6747 }
6748
6749 #[tokio::test]
6750 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
6751 use runner_manager_domain::execution::{
6752 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6753 };
6754 let mut native = policy(1, "o/r", 2);
6755 native
6756 .add_routing_label(fixtures::label("self-hosted"))
6757 .unwrap();
6758 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6759 isolated
6760 .add_routing_label(fixtures::label("self-hosted"))
6761 .unwrap();
6762 isolated
6763 .set_execution_policy(ExecutionPolicy::Isolated {
6764 backend: Backend::Oci,
6765 image: ImageReference::new(format!(
6766 "registry.example/runner@sha256:{}",
6767 "a".repeat(64)
6768 ))
6769 .unwrap(),
6770 resources: ResourceLimits {
6771 cpu_millis: 1000,
6772 memory_mib: 512,
6773 disk_mib: 1024,
6774 },
6775 })
6776 .unwrap();
6777 assert!(!isolated.may_start_runners());
6778 let mut active_isolated = isolated.clone();
6779 active_isolated.activate().unwrap();
6780 for profiles in [
6781 [native.clone(), isolated.clone()],
6782 [isolated.clone(), native.clone()],
6783 [native.clone(), active_isolated.clone()],
6784 [active_isolated, native.clone()],
6785 ] {
6786 let mut harness = Harness::simple(4, 0, "o/r");
6787 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6788 repo("o/r"),
6789 [fixtures::queued_job(&["SELF-HOSTED"])],
6790 )));
6791 let report = harness.reconciler.reconcile(&profiles).await;
6792 assert_eq!(report.started, 0);
6793 assert!(harness.events.events().iter().any(|event| matches!(
6794 event,
6795 LifecycleEvent::RoutingRefused {
6796 reason: RoutingRefusal::OverlappingProfiles,
6797 count: 1,
6798 ..
6799 }
6800 )));
6801 }
6802 let mut harness = Harness::simple(4, 0, "o/r");
6805 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6806 repo("o/r"),
6807 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
6808 )));
6809 assert_eq!(
6810 harness
6811 .reconciler
6812 .reconcile(&[native, isolated])
6813 .await
6814 .started,
6815 0
6816 );
6817 }
6818
6819 #[tokio::test]
6820 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
6821 let mut harness = Harness::simple(2, 0, "o/r");
6822 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6823 repo("o/r"),
6824 [
6825 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
6826 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
6827 fixtures::queued_job(&["gpu"]),
6828 fixtures::unresolvable_job(),
6829 ],
6830 )));
6831 let mut native = policy(1, "o/r", 2);
6832 native.add_routing_label(fixtures::label("gpu")).unwrap();
6833 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6834 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
6835 isolated.activate().unwrap();
6836
6837 let report = harness.reconciler.reconcile(&[native, isolated]).await;
6838 assert_eq!(harness.demand.polls().len(), 1);
6839 assert_eq!(report.targets_read, 1);
6840 assert_eq!(report.started, 2);
6841 assert_eq!(report.allocations.len(), 2);
6842 assert!(
6843 report
6844 .allocations
6845 .iter()
6846 .all(|allocation| allocation.to_start == 1)
6847 );
6848 assert!(harness.events.events().iter().any(|event| matches!(
6849 event,
6850 LifecycleEvent::RoutingRefused {
6851 reason: RoutingRefusal::OverlappingProfiles,
6852 count: 1,
6853 ..
6854 }
6855 )));
6856 assert_eq!(
6857 harness
6858 .events
6859 .events()
6860 .iter()
6861 .filter(|event| matches!(
6862 event,
6863 LifecycleEvent::DemandObserved {
6864 unresolvable: 1,
6865 ..
6866 }
6867 ))
6868 .count(),
6869 2,
6870 "an expression stays unresolvable for both profiles"
6871 );
6872 }
6873
6874 #[tokio::test]
6875 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
6876 let mut harness = Harness::simple(4, 0, "o/r");
6877 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6878 repo("o/r"),
6879 [fixtures::queued_job(&[
6880 HOST_LABEL,
6881 "rm-home-win-x64-py-isolated",
6882 ])],
6883 )));
6884 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6885 isolated.activate().unwrap();
6886 let report = harness
6887 .reconciler
6888 .reconcile(&[policy(1, "o/r", 2), isolated])
6889 .await;
6890 assert_eq!(report.started, 0);
6891 assert_eq!(
6892 harness
6893 .events
6894 .events()
6895 .iter()
6896 .filter(|event| matches!(
6897 event,
6898 LifecycleEvent::DemandObserved {
6899 demand: 0,
6900 not_matched: 1,
6901 ..
6902 }
6903 ))
6904 .count(),
6905 2
6906 );
6907
6908 let mut superset = policy(1, "o/r", 2);
6912 assert!(
6913 superset
6914 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
6915 .unwrap()
6916 );
6917 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6918 sibling.activate().unwrap();
6919 let mut unique = Harness::simple(4, 0, "o/r");
6920 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
6921 repo("o/r"),
6922 [fixtures::queued_job(&[
6923 HOST_LABEL,
6924 "rm-home-win-x64-py-isolated",
6925 ])],
6926 )));
6927 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
6928 assert_eq!(report.started, 1);
6929 assert_eq!(
6930 report
6931 .allocations
6932 .iter()
6933 .find(|allocation| allocation.to_start == 1)
6934 .unwrap()
6935 .policy_id,
6936 PolicyId::from_u128(1)
6937 );
6938 assert!(
6939 !unique
6940 .events
6941 .events()
6942 .iter()
6943 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6944 );
6945
6946 let mut overlap = Harness::simple(4, 1, "o/r");
6947 let report = overlap
6948 .reconciler
6949 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
6950 .await;
6951 assert_eq!(report.started, 0);
6952 assert!(overlap.events.events().iter().any(|event| matches!(
6953 event,
6954 LifecycleEvent::RoutingRefused {
6955 reason: RoutingRefusal::OverlappingProfiles,
6956 count: 1,
6957 ..
6958 }
6959 )));
6960 assert!(
6961 RoutingRefusal::OverlappingProfiles
6962 .to_string()
6963 .contains("repair")
6964 );
6965 }
6966
6967 #[tokio::test]
6972 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
6973 let launcher = Arc::new(FakeLauncher::new());
6974 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
6975 let mut harness = Harness::build(
6976 host_with(4),
6977 Arc::clone(&launcher),
6978 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6979 Arc::new(InProcessAllocationLock::new()),
6980 );
6981
6982 let report = harness
6983 .reconciler
6984 .reconcile(&[policy(1, "acme/app", 4)])
6985 .await;
6986 assert_eq!(report.started, 0);
6987 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
6988
6989 let failures: Vec<&'static str> = harness
6990 .events
6991 .events()
6992 .into_iter()
6993 .filter_map(|event| match event {
6994 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6995 _ => None,
6996 })
6997 .collect();
6998 assert_eq!(failures, vec!["other"]);
6999 assert!(
7000 !failures[0].contains("ghp_"),
7001 "an event carried a `FailureReason::Other` detail verbatim"
7002 );
7003 }
7004
7005 #[tokio::test]
7010 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
7011 let launcher = Arc::new(FakeLauncher::new());
7012 let unfit = Arc::new(AtomicBool::new(true));
7013 let events = Arc::new(EventLog::new());
7014 let mut reconciler = Reconciler::new(
7015 host_with(4),
7016 ReconcilerPorts {
7017 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
7018 launcher: Arc::new(FitnessGatedLauncher::new(
7019 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7020 Arc::clone(&unfit),
7021 )),
7022 lock: Arc::new(InProcessAllocationLock::new()),
7023 directory: Arc::new(FakeDirectory::default()),
7024 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
7025 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
7026 events: Arc::clone(&events) as Arc<dyn EventSink>,
7027 },
7028 );
7029 let policies = [policy(1, "acme/app", 4)];
7030
7031 let report = reconciler.reconcile(&policies).await;
7032 assert_eq!(report.started, 0);
7033 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
7034 let reasons: Vec<&'static str> = events
7035 .events()
7036 .into_iter()
7037 .filter_map(|event| match event {
7038 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
7039 _ => None,
7040 })
7041 .collect();
7042 assert_eq!(reasons, ["host_unfit"]);
7043
7044 unfit.store(false, Ordering::Release);
7045 let report = reconciler.reconcile(&policies).await;
7046 assert_eq!(report.started, 2);
7047 assert_eq!(launcher.launches(), 2);
7048 }
7049
7050 #[tokio::test]
7051 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
7052 let launcher = Arc::new(FakeLauncher::new());
7053 let mut harness = Harness::build(
7054 host_with(4),
7055 Arc::clone(&launcher),
7056 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
7057 Arc::new(HeldLock),
7058 );
7059
7060 let report = harness
7061 .reconciler
7062 .reconcile(&[policy(1, "acme/app", 4)])
7063 .await;
7064 assert_eq!(report.started, 0);
7065 assert_eq!(
7066 report.deferred, 3,
7067 "three runners were granted and none was created; `deferred` counts grants, \
7068 not policies -- it reported `1` when a policy that launched two of five and \
7069 then lost the lock had left three unstarted"
7070 );
7071 assert_eq!(launcher.launches(), 0);
7072 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
7073 assert!(
7074 harness
7075 .events
7076 .events()
7077 .iter()
7078 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
7079 "the event carries the same number the report does"
7080 );
7081 assert_eq!(
7082 report.allocations.len(),
7083 1,
7084 "the intent is still reported, so an operator staring at a queue sees why \
7085 nothing started"
7086 );
7087 }
7088
7089 #[tokio::test]
7090 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
7091 let mut harness = Harness::simple(8, 5, "acme/app");
7092
7093 let foreign = fixtures::policy()
7094 .id(PolicyId::from_u128(1))
7095 .repository("acme/app")
7096 .host(HostId::from_u128(0xdead))
7097 .autoscale("office", 4)
7098 .active()
7099 .build();
7100 let mut draining = policy(2, "acme/app", 4);
7101 draining.request_disable().unwrap();
7102
7103 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
7104
7105 assert_eq!(report.started, 0);
7106 assert_eq!(
7107 harness.demand.polls().len(),
7108 0,
7109 "neither can act on an answer"
7110 );
7111 let factors: Vec<LimitingFactor> = report
7112 .allocations
7113 .iter()
7114 .map(|a| a.limiting_factor)
7115 .collect();
7116 assert!(factors.contains(&LimitingFactor::ForeignHost));
7117 assert!(factors.contains(&LimitingFactor::NotReconciling));
7118 }
7119
7120 #[tokio::test]
7121 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
7122 #[derive(Debug)]
7125 struct BrokenDirectory;
7126
7127 #[async_trait::async_trait]
7128 impl RepositoryDirectory for BrokenDirectory {
7129 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
7130 Err(InventoryError::Cancelled)
7131 }
7132 }
7133
7134 let launcher = Arc::new(FakeLauncher::new());
7135 let events = Arc::new(EventLog::new());
7136 let mut reconciler = Reconciler::new(
7137 host_with(4),
7138 ReconcilerPorts {
7139 demand: Arc::new(FakeDemand::default()),
7140 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7141 lock: Arc::new(InProcessAllocationLock::new()),
7142 directory: Arc::new(BrokenDirectory),
7143 clock: Arc::new(FakeClock::default()),
7144 jitter: Arc::new(NoJitter),
7145 events: Arc::clone(&events) as Arc<dyn EventSink>,
7146 },
7147 );
7148
7149 let org_policy = fixtures::policy()
7150 .id(PolicyId::from_u128(1))
7151 .organization("acme")
7152 .autoscale("home", 4)
7153 .active()
7154 .build();
7155
7156 let report = reconciler.reconcile(&[org_policy]).await;
7157 assert_eq!(report.started, 0);
7158 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
7159 assert_eq!(events.count_of("target_unreadable"), 1);
7160 }
7161
7162 fn every_event() -> Vec<LifecycleEvent> {
7174 let policy = PolicyId::from_u128(0xabcd_ef01);
7175 let attempt = AttemptId::from_u128(0x1234_5678);
7176 vec![
7177 LifecycleEvent::DemandObserved {
7178 policy,
7179 demand: u32::MAX,
7180 not_matched: u32::MAX,
7181 unresolvable: u32::MAX,
7182 complete: false,
7183 },
7184 LifecycleEvent::TargetUnreadable {
7185 policy,
7186 reason: unreadable_reason(&RefreshState::Failed {
7187 status: Some(500),
7188 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
7189 .into(),
7190 }),
7191 },
7192 LifecycleEvent::Allocated {
7193 policy,
7194 demand: u32::MAX,
7195 desired: u16::MAX,
7196 active_owned: 7,
7197 headroom: 9,
7198 to_start: 2,
7199 limiting: LimitingFactor::HostCapacity,
7200 },
7201 LifecycleEvent::MonitorOnlySkipped { policy },
7202 LifecycleEvent::RunnerStarted { policy, attempt },
7203 LifecycleEvent::RunnerStartFailed {
7204 policy,
7205 reason: failure_reason_kind(&FailureReason::Other(
7206 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
7207 )),
7208 },
7209 LifecycleEvent::AllocationDeferred { policy, count: 4 },
7210 LifecycleEvent::AttemptsUnreadable {
7211 reason: failure_reason_kind(&FailureReason::Other(
7212 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
7213 )),
7214 },
7215 LifecycleEvent::HostCapacityUnreadable {
7216 reason: HostCapacityError::MissingHost,
7217 },
7218 LifecycleEvent::HostCapacityUnreadable {
7219 reason: HostCapacityError::JournalUnreadable,
7220 },
7221 LifecycleEvent::AttemptCleanFailed {
7222 policy,
7223 attempt,
7224 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
7225 },
7226 LifecycleEvent::AttemptCleaned {
7227 policy,
7228 attempt,
7229 outcome: OutcomeKind::IdleExit,
7230 },
7231 LifecycleEvent::ScaleDownRefused { policy, attempt },
7232 LifecycleEvent::PollScheduled {
7233 retry_in_ms: 900_000,
7234 pace: PollPace::RateLimited {
7235 kind: RateLimitKind::Primary,
7236 },
7237 },
7238 ]
7239 }
7240
7241 #[test]
7250 fn no_emitted_event_can_carry_a_credential() {
7251 use runner_manager_platform::logging::redact;
7252
7253 for event in every_event() {
7254 let displayed = event.to_string();
7255 assert_eq!(
7256 redact(&displayed),
7257 displayed,
7258 "`{}` renders something `d1`'s sink would have to redact",
7259 event.name()
7260 );
7261
7262 let debugged = format!("{event:?}");
7263 assert_eq!(
7264 redact(&debugged),
7265 debugged,
7266 "`{}`'s Debug renders something `d1`'s sink would have to redact",
7267 event.name()
7268 );
7269 }
7270
7271 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
7274 assert_ne!(
7275 redact(secret),
7276 secret,
7277 "the scan above proves nothing if `redact` no longer recognises a credential"
7278 );
7279 }
7280
7281 #[test]
7282 fn every_field_name_this_sink_emits_is_one_d1_allows() {
7283 use runner_manager_platform::logging::is_field_allowed;
7284
7285 for field in [
7289 "event",
7290 "policy_id",
7291 "attempt_id",
7292 "attempt_state",
7293 "demand",
7294 "desired",
7295 "capacity",
7296 "headroom",
7297 "count",
7298 "reason",
7299 "outcome",
7300 "mode",
7301 "lock",
7302 "retry_in_ms",
7303 "state",
7304 ] {
7305 assert!(
7306 is_field_allowed(field),
7307 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
7308 `[redacted]` in its place and the line would lose its meaning"
7309 );
7310 }
7311 }
7312
7313 #[test]
7314 fn every_failure_reason_has_a_credential_free_kind() {
7315 for reason in FailureReason::ALL {
7316 let kind = failure_reason_kind(&reason);
7317 assert!(!kind.is_empty());
7318 assert!(
7319 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
7320 "`{kind}` is not a fixed identifier"
7321 );
7322 }
7323 assert_eq!(
7324 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
7325 "other",
7326 "the detail of an `Other` reason never reaches an event"
7327 );
7328 let categories = [
7329 IsolationProviderFailure::NotInstalled,
7330 IsolationProviderFailure::PermissionDenied,
7331 IsolationProviderFailure::ImageUnavailableOrIncompatible,
7332 IsolationProviderFailure::DiskQuotaUnavailable,
7333 IsolationProviderFailure::RuntimeOperationFailed,
7334 ];
7335 let mut kinds = std::collections::BTreeSet::new();
7336 for category in categories {
7337 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
7338 assert_ne!(kind, "other");
7339 assert!(!kind.contains("secret"));
7340 kinds.insert(kind);
7341 }
7342 assert_eq!(kinds.len(), categories.len());
7343 }
7344
7345 fn production_half_of(source: &str) -> String {
7360 let production = source
7361 .split_once("\n#[cfg(test)]")
7362 .map_or(source, |(production, _)| production);
7363 production
7364 .lines()
7365 .filter(|line| !line.trim_start().starts_with("//"))
7366 .collect::<Vec<_>>()
7367 .join("\n")
7368 }
7369
7370 fn this_file_above_its_tests_without_prose() -> String {
7372 production_half_of(include_str!("reconcile.rs"))
7373 }
7374
7375 fn normalise_for_scan(text: &str) -> String {
7394 text.to_ascii_lowercase().replace(['_', ' '], "")
7395 }
7396
7397 const FORBIDDEN: &[&str] = &[
7407 concat!("fn ", "acquire", "_job"),
7408 concat!("fn ", "claim", "_job"),
7409 concat!("fn ", "lease", "_job"),
7410 concat!("fn ", "reserve", "_job"),
7411 concat!("fn ", "ack", "nowledge"),
7412 concat!("struct ", "Job", "Lease"),
7413 concat!("struct ", "Job", "Claim"),
7414 concat!("struct ", "Job", "Reservation"),
7415 ];
7416
7417 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
7418 let haystack = normalise_for_scan(source);
7419 FORBIDDEN
7420 .iter()
7421 .copied()
7422 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
7423 }
7424
7425 #[test]
7459 fn nothing_in_this_crate_reserves_or_claims_a_job() {
7460 const SOURCES: &[(&str, &str)] = &[
7461 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
7462 ("docker_cleanup.rs", include_str!("docker_cleanup.rs")),
7463 (
7464 "docker_cleanup/http.rs",
7465 include_str!("docker_cleanup/http.rs"),
7466 ),
7467 (
7468 "docker_cleanup/proxy.rs",
7469 include_str!("docker_cleanup/proxy.rs"),
7470 ),
7471 (
7472 "docker_cleanup/tests.rs",
7473 include_str!("docker_cleanup/tests.rs"),
7474 ),
7475 ("lib.rs", include_str!("lib.rs")),
7476 ("lifecycle.rs", include_str!("lifecycle.rs")),
7477 (
7478 "lifecycle/windows_hyperv.rs",
7479 include_str!("lifecycle/windows_hyperv.rs"),
7480 ),
7481 ("oci.rs", include_str!("oci.rs")),
7482 ("package.rs", include_str!("package.rs")),
7483 ("reconcile.rs", include_str!("reconcile.rs")),
7484 ];
7485
7486 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
7487 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
7488 let entry = entry.expect("a readable directory entry");
7489 let name = entry.file_name().to_string_lossy().into_owned();
7490 let joined = if prefix.is_empty() {
7493 name.clone()
7494 } else {
7495 format!("{prefix}/{name}")
7496 };
7497 if entry.path().is_dir() {
7498 walk(&entry.path(), &joined, found);
7499 } else if name.ends_with(".rs") {
7500 found.push(joined);
7501 }
7502 }
7503 }
7504
7505 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
7506 listed.sort_unstable();
7507 let mut on_disk = Vec::new();
7508 walk(
7509 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
7510 "",
7511 &mut on_disk,
7512 );
7513 on_disk.sort_unstable();
7514 assert_eq!(
7515 listed, on_disk,
7516 "a source file was added or removed; this scan claims to cover the whole crate \
7517 and a stale list makes that claim false"
7518 );
7519
7520 for (name, source) in SOURCES {
7521 assert_eq!(
7522 forbidden_shape_in(&production_half_of(source)),
7523 None,
7524 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
7525 REST, and a local lease coordinates this host with itself and with nothing \
7526 else. If an owner decision restored one, that decision belongs in this \
7527 module's documentation and in this test before it belongs in the code"
7528 );
7529 }
7530
7531 assert!(
7534 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
7535 "the scan above proves nothing if the needles no longer match"
7536 );
7537 }
7538
7539 #[test]
7567 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
7568 let production = this_file_above_its_tests_without_prose();
7569
7570 assert!(
7571 production.contains("DemandTally"),
7572 "the reconciliation loop must tally queued jobs against this policy's routing \
7573 labels. A production half that named `DemandTally` nowhere would mean the \
7574 label filtering had been removed, and a repository whose jobs target \
7575 `ubuntu-latest` would drive its policy toward `max_capacity` again"
7576 );
7577
7578 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
7579 assert!(
7580 !production.contains(second_implementation),
7581 "the reconciliation loop names `{second_implementation}`, which is the \
7582 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
7583 has already decided it. This module applies the predicate and does not \
7584 re-implement it; if an owner decision changed that, it belongs in this \
7585 module's documentation and in this test before it belongs in the code"
7586 );
7587 }
7588 }
7589
7590 #[test]
7598 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
7599 let policy = policy(1, "acme/app", 10);
7600 let reading = QueuedDemand::of(
7601 repo("acme/app"),
7602 [
7603 fixtures::queued_job(&[HOST_LABEL]),
7604 fixtures::queued_job(&[HOST_LABEL]),
7605 fixtures::queued_job(&["ubuntu-latest"]),
7606 fixtures::unresolvable_job(),
7607 ],
7608 );
7609
7610 let tally = demand_for(&policy, &reading);
7611
7612 assert_eq!(
7613 tally.demand(),
7614 2,
7615 "only the jobs whose required labels this policy carries are demand"
7616 );
7617 assert_eq!(
7618 tally.not_matched, 1,
7619 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
7620 runner that idles until it times out"
7621 );
7622 assert_eq!(
7623 tally.unresolvable.len(),
7624 1,
7625 "an unresolvable `runs-on` is never demand and never discarded"
7626 );
7627 }
7628
7629 #[test]
7632 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
7633 let mut per_repository = BTreeMap::new();
7634 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
7635 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
7636 let reading = QueuedDemand::new(per_repository);
7637
7638 let repository_policy = policy(1, "acme/left", 10);
7639 assert_eq!(
7640 demand_for(&repository_policy, &reading).demand(),
7641 3,
7642 "a repository target reads its own repository's queue and not the aggregate"
7643 );
7644
7645 let org_policy = fixtures::policy()
7646 .id(PolicyId::from_u128(2))
7647 .organization("acme")
7648 .autoscale("home", 10)
7649 .active()
7650 .build();
7651 assert_eq!(
7652 demand_for(&org_policy, &reading).demand(),
7653 7,
7654 "an organization policy serves any repository in its scope, so its demand is \
7655 the whole aggregate's"
7656 );
7657 }
7658
7659 #[test]
7660 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
7661 let host = host_with(2);
7666 let policy = policy(1, "acme/app", 5);
7667 let attempts: Vec<RunnerAttempt> = Vec::new();
7668 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
7669
7670 let allocation = allocator.allocate(&policy, u32::MAX);
7671 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
7672 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
7673 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
7674 }
7675
7676 #[derive(Debug, Default)]
7679 struct FixedDirector {
7680 directed: Mutex<BTreeMap<PolicyId, u32>>,
7681 started: Mutex<Vec<(PolicyId, AttemptId)>>,
7682 failed: Mutex<Vec<(PolicyId, Option<AttemptId>)>>,
7683 older: Mutex<std::collections::VecDeque<usize>>,
7685 passed_over: AtomicUsize,
7686 orders: Mutex<BTreeMap<PolicyId, u64>>,
7688 }
7689
7690 impl DemandDirector for FixedDirector {
7691 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32> {
7692 self.directed.lock().unwrap().get(&policy.id).copied()
7693 }
7694
7695 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId) {
7696 self.started.lock().unwrap().push((policy.id, attempt));
7697 }
7698
7699 fn start_failed(&self, policy: &ScalePolicy, attempt: Option<AttemptId>) {
7700 self.failed.lock().unwrap().push((policy.id, attempt));
7701 }
7702
7703 fn next_waiting(&self, policy: &ScalePolicy) -> Option<u64> {
7704 self.orders.lock().unwrap().get(&policy.id).copied()
7705 }
7706
7707 fn older_waiting(&self, _policy: &ScalePolicy) -> usize {
7708 let mut older = self.older.lock().unwrap();
7709 if older.len() > 1 {
7710 older.pop_front().unwrap_or(0)
7711 } else {
7712 older.front().copied().unwrap_or(0)
7713 }
7714 }
7715
7716 fn pass_over_older(&self, _policy: &ScalePolicy) {
7717 self.passed_over.fetch_add(1, Ordering::SeqCst);
7718 self.older.lock().unwrap().clear();
7719 }
7720 }
7721
7722 #[derive(Debug)]
7725 struct HeldBringUp {
7726 inner: Arc<FakeLauncher>,
7727 barrier: Arc<tokio::sync::Barrier>,
7728 fail: Option<FailureReason>,
7729 waits: Mutex<Vec<ReservationWaits>>,
7730 }
7731
7732 #[async_trait::async_trait]
7733 impl RunnerLauncher for HeldBringUp {
7734 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
7735 self.inner.host_capacity(host).await
7736 }
7737
7738 async fn supervise(
7739 &self,
7740 policy: &ScalePolicy,
7741 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
7742 self.inner.supervise(policy).await
7743 }
7744
7745 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
7746 self.inner.attempts().await
7747 }
7748
7749 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
7750 self.inner.launch(request).await
7751 }
7752
7753 async fn finish_launch(
7754 &self,
7755 _policy: &ScalePolicy,
7756 attempt: RunnerAttempt,
7757 waits: ReservationWaits,
7758 ) -> Result<RunnerAttempt, LaunchFailure> {
7759 self.waits.lock().unwrap().push(waits);
7760 self.barrier.wait().await;
7761 match &self.fail {
7762 Some(reason) => Err(LaunchFailure::new(reason.clone())),
7763 None => Ok(attempt),
7764 }
7765 }
7766
7767 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
7768 self.inner.clean(attempt).await
7769 }
7770 }
7771
7772 fn directed_reconciler(
7773 capacity: u16,
7774 launcher: Arc<dyn RunnerLauncher>,
7775 lock: Arc<dyn AllocationLock>,
7776 director: Arc<FixedDirector>,
7777 ) -> Reconciler {
7778 Reconciler::new(
7779 host_with(capacity),
7780 ReconcilerPorts {
7781 demand: Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
7782 launcher,
7783 lock,
7784 directory: Arc::new(FakeDirectory::default()),
7785 clock: Arc::new(FakeClock::default()),
7786 jitter: Arc::new(NoJitter),
7787 events: Arc::new(NoEvents),
7788 },
7789 )
7790 .with_director(director as Arc<dyn DemandDirector>)
7791 }
7792
7793 #[tokio::test]
7796 async fn two_targets_bring_up_their_runners_at_once() {
7797 let fake = Arc::new(FakeLauncher::new());
7798 let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
7799 let barrier = Arc::new(tokio::sync::Barrier::new(2));
7800 let held = Arc::new(HeldBringUp {
7801 inner: Arc::clone(&fake),
7802 barrier,
7803 fail: None,
7804 waits: Mutex::default(),
7805 });
7806 let director = Arc::new(FixedDirector::default());
7807 let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
7808 director.directed.lock().unwrap().insert(one.id, 1);
7809 director.directed.lock().unwrap().insert(two.id, 1);
7810 let first = directed_reconciler(
7811 2,
7812 Arc::clone(&held) as _,
7813 Arc::clone(&lock),
7814 Arc::clone(&director),
7815 );
7816 let second = directed_reconciler(2, held as _, lock, director);
7817
7818 let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
7819 tokio::join!(
7820 first.start_directed(std::slice::from_ref(&one)),
7821 second.start_directed(std::slice::from_ref(&two)),
7822 )
7823 })
7824 .await
7825 .expect("neither waits for the other's bring-up");
7826 assert_eq!((a.started, b.started), (1, 1));
7827 }
7828
7829 #[tokio::test]
7832 async fn a_runner_coming_up_holds_its_slot_against_another_target() {
7833 let fake = Arc::new(FakeLauncher::new());
7834 let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
7835 let barrier = Arc::new(tokio::sync::Barrier::new(2));
7836 let held = Arc::new(HeldBringUp {
7837 inner: Arc::clone(&fake),
7838 barrier: Arc::clone(&barrier),
7839 fail: None,
7840 waits: Mutex::default(),
7841 });
7842 let director = Arc::new(FixedDirector::default());
7843 let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
7844 director.directed.lock().unwrap().insert(one.id, 1);
7845 director.directed.lock().unwrap().insert(two.id, 1);
7846 let first = directed_reconciler(
7847 1,
7848 Arc::clone(&held) as _,
7849 Arc::clone(&lock),
7850 Arc::clone(&director),
7851 );
7852 let second = directed_reconciler(1, held as _, lock, director);
7853
7854 let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
7855 tokio::join!(first.start_directed(std::slice::from_ref(&one)), async {
7856 while fake.live_count() == 0 {
7857 tokio::task::yield_now().await;
7858 }
7859 let report = second.start_directed(std::slice::from_ref(&two)).await;
7860 barrier.wait().await;
7861 report
7862 })
7863 })
7864 .await
7865 .expect("the first is let go");
7866 assert_eq!((a.started, b.started), (1, 0));
7867 assert_eq!(fake.live_count(), 1);
7868 }
7869
7870 #[tokio::test]
7873 async fn a_failed_bring_up_is_a_failed_start() {
7874 let fake = Arc::new(FakeLauncher::new());
7875 let held = Arc::new(HeldBringUp {
7876 inner: fake,
7877 barrier: Arc::new(tokio::sync::Barrier::new(1)),
7878 fail: Some(FailureReason::JitRequestFailed),
7879 waits: Mutex::default(),
7880 });
7881 let director = Arc::new(FixedDirector::default());
7882 let pool = policy(1, "acme/app", 2);
7883 director.directed.lock().unwrap().insert(pool.id, 1);
7884 let reconciler = directed_reconciler(
7885 2,
7886 held as _,
7887 Arc::new(InProcessAllocationLock::new()),
7888 Arc::clone(&director),
7889 );
7890
7891 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
7892
7893 assert_eq!(report.started, 0);
7894 let started = director.started.lock().unwrap()[0].1;
7895 assert_eq!(
7896 *director.failed.lock().unwrap(),
7897 vec![(pool.id, Some(started))]
7898 );
7899 }
7900
7901 fn scripted_older(director: &FixedDirector, looks: &[(usize, usize)]) {
7902 *director.older.lock().unwrap() = looks
7903 .iter()
7904 .flat_map(|(value, count)| std::iter::repeat_n(*value, *count))
7905 .collect();
7906 }
7907
7908 #[tokio::test(start_paused = true)]
7912 async fn a_younger_launch_reserves_after_older_ones_while_they_make_progress() {
7913 let held = Arc::new(HeldBringUp {
7914 inner: Arc::new(FakeLauncher::new()),
7915 barrier: Arc::new(tokio::sync::Barrier::new(1)),
7916 fail: None,
7917 waits: Mutex::default(),
7918 });
7919 let director = Arc::new(FixedDirector::default());
7920 let pool = policy(1, "acme/app", 3);
7921 director.directed.lock().unwrap().insert(pool.id, 1);
7922 let reconciler = directed_reconciler(
7923 5,
7924 Arc::clone(&held) as _,
7925 Arc::new(InProcessAllocationLock::new()),
7926 Arc::clone(&director),
7927 );
7928
7929 let looks =
7932 (OLDER_LAUNCH_TURN.as_millis() * 3 / 4 / OLDER_LAUNCH_POLL.as_millis()) as usize;
7933 scripted_older(&director, &[(3, looks), (2, looks), (1, looks), (0, 1)]);
7934 let began = tokio::time::Instant::now();
7935 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
7936 assert_eq!(report.started, 1);
7937 assert_eq!(
7938 began.elapsed(),
7939 OLDER_LAUNCH_POLL * u32::try_from(looks * 3).unwrap()
7940 );
7941 assert_eq!(director.passed_over.load(Ordering::SeqCst), 0);
7942 assert_eq!(
7943 held.waits.lock().unwrap()[0].order,
7944 OLDER_LAUNCH_POLL * u32::try_from(looks * 3).unwrap(),
7945 "the wait is in the launch's timings"
7946 );
7947 }
7948
7949 #[tokio::test]
7952 async fn a_pass_hands_a_directed_launch_to_the_launch_side() {
7953 let fake = Arc::new(FakeLauncher::new());
7954 let director = Arc::new(FixedDirector::default());
7955 let pool = policy(1, "acme/app", 3);
7956 director.directed.lock().unwrap().insert(pool.id, 1);
7957 let signal = Arc::new(tokio::sync::Notify::new());
7958 let mut reconciler = directed_reconciler(
7959 5,
7960 Arc::clone(&fake) as _,
7961 Arc::new(InProcessAllocationLock::new()),
7962 Arc::clone(&director),
7963 )
7964 .with_launch_side(Arc::clone(&signal));
7965
7966 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7967
7968 assert_eq!(report.started, 0);
7969 assert_eq!(fake.live_count(), 0);
7970 assert_eq!(report.allocations[0].to_start, 1);
7971 tokio::time::timeout(Duration::from_secs(1), signal.notified())
7972 .await
7973 .expect("the launch side is told");
7974
7975 director.directed.lock().unwrap().insert(pool.id, 0);
7977 reconciler.reconcile(std::slice::from_ref(&pool)).await;
7978 assert!(
7979 tokio::time::timeout(Duration::from_millis(50), signal.notified())
7980 .await
7981 .is_err()
7982 );
7983 }
7984
7985 #[tokio::test(start_paused = true)]
7988 async fn an_older_launch_that_cannot_reserve_is_passed_over_once() {
7989 let fake = Arc::new(FakeLauncher::new());
7990 let director = Arc::new(FixedDirector::default());
7991 let pool = policy(1, "acme/app", 3);
7992 director.directed.lock().unwrap().insert(pool.id, 1);
7993 let reconciler = directed_reconciler(
7994 5,
7995 Arc::clone(&fake) as _,
7996 Arc::new(InProcessAllocationLock::new()),
7997 Arc::clone(&director),
7998 );
7999
8000 scripted_older(&director, &[(1, 1)]);
8001 let began = tokio::time::Instant::now();
8002 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8003 assert_eq!(report.started, 1, "started anyway");
8004 assert!(began.elapsed() >= OLDER_LAUNCH_TURN);
8005 assert!(began.elapsed() < OLDER_LAUNCH_TURN + Duration::from_secs(1));
8006 assert_eq!(director.passed_over.load(Ordering::SeqCst), 1);
8007
8008 director.directed.lock().unwrap().insert(pool.id, 2);
8009 let began = tokio::time::Instant::now();
8010 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8011 assert_eq!(report.started, 1);
8012 assert_eq!(began.elapsed(), Duration::ZERO);
8013 }
8014
8015 #[tokio::test]
8018 async fn a_targets_profiles_reserve_oldest_launch_first() {
8019 let director = Arc::new(FixedDirector::default());
8020 let (younger, older) = (policy(1, "acme/app", 3), policy(2, "acme/app", 3));
8021 director.directed.lock().unwrap().insert(younger.id, 1);
8022 director.directed.lock().unwrap().insert(older.id, 1);
8023 director.orders.lock().unwrap().insert(younger.id, 9);
8024 director.orders.lock().unwrap().insert(older.id, 4);
8025 let reconciler = directed_reconciler(
8026 5,
8027 Arc::new(FakeLauncher::new()) as _,
8028 Arc::new(InProcessAllocationLock::new()),
8029 Arc::clone(&director),
8030 );
8031
8032 let report = reconciler
8033 .start_directed(&[younger.clone(), older.clone()])
8034 .await;
8035
8036 assert_eq!(report.started, 2);
8037 let order: Vec<PolicyId> = director
8038 .started
8039 .lock()
8040 .unwrap()
8041 .iter()
8042 .map(|(policy, _)| *policy)
8043 .collect();
8044 assert_eq!(order, vec![older.id, younger.id]);
8045 }
8046
8047 fn serving<'a>(
8049 reconciler: &'a Reconciler,
8050 pool: &ScalePolicy,
8051 signal: &'a tokio::sync::Notify,
8052 stop: tokio::sync::watch::Receiver<bool>,
8053 ) -> impl std::future::Future<Output = ()> + 'a {
8054 let pool = pool.clone();
8055 async move {
8056 let policies = move || vec![pool.clone()];
8057 reconciler.serve_launches(&policies, signal, stop).await;
8058 }
8059 }
8060
8061 #[tokio::test]
8065 async fn a_launch_accepted_during_a_batch_starts_without_waiting_for_it() {
8066 let fake = Arc::new(FakeLauncher::new());
8067 let held = Arc::new(HeldBringUp {
8068 inner: Arc::clone(&fake),
8069 barrier: Arc::new(tokio::sync::Barrier::new(2)),
8070 fail: None,
8071 waits: Mutex::default(),
8072 });
8073 let director = Arc::new(FixedDirector::default());
8074 let pool = policy(1, "acme/app", 3);
8075 director.directed.lock().unwrap().insert(pool.id, 1);
8076 let reconciler = directed_reconciler(
8077 5,
8078 held as _,
8079 Arc::new(InProcessAllocationLock::new()),
8080 Arc::clone(&director),
8081 );
8082 let signal = tokio::sync::Notify::new();
8083 let (stop, stopped) = tokio::sync::watch::channel(false);
8084
8085 tokio::time::timeout(Duration::from_secs(10), async {
8086 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8087 signal.notify_one();
8088 while director.started.lock().unwrap().is_empty() {
8089 tokio::task::yield_now().await;
8090 }
8091 director.directed.lock().unwrap().insert(pool.id, 2);
8092 signal.notify_one();
8093 while director.started.lock().unwrap().len() < 2 {
8094 tokio::task::yield_now().await;
8095 }
8096 stop.send_replace(true);
8097 })
8098 })
8099 .await
8100 .expect("the second came up beside the first, and stopping ends it");
8101 assert_eq!(fake.live_count(), 2);
8102 }
8103
8104 #[tokio::test]
8107 async fn a_launch_during_a_batch_takes_no_slot_that_batch_holds() {
8108 let fake = Arc::new(FakeLauncher::new());
8109 let held = Arc::new(HeldBringUp {
8110 inner: Arc::clone(&fake),
8111 barrier: Arc::new(tokio::sync::Barrier::new(2)),
8112 fail: None,
8113 waits: Mutex::default(),
8114 });
8115 let barrier = Arc::clone(&held.barrier);
8116 let director = Arc::new(FixedDirector::default());
8117 let pool = policy(1, "acme/app", 3);
8118 director.directed.lock().unwrap().insert(pool.id, 1);
8119 let reconciler = directed_reconciler(
8120 1,
8121 held as _,
8122 Arc::new(InProcessAllocationLock::new()),
8123 Arc::clone(&director),
8124 );
8125 let signal = tokio::sync::Notify::new();
8126 let (stop, stopped) = tokio::sync::watch::channel(false);
8127
8128 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8129 signal.notify_one();
8130 while director.started.lock().unwrap().is_empty() {
8131 tokio::task::yield_now().await;
8132 }
8133 director.directed.lock().unwrap().insert(pool.id, 2);
8134 signal.notify_one();
8135 for _ in 0..100 {
8136 tokio::task::yield_now().await;
8137 }
8138 assert_eq!(
8139 director.started.lock().unwrap().len(),
8140 1,
8141 "the host is full"
8142 );
8143 assert_eq!(fake.live_count(), 1);
8144 stop.send_replace(true);
8145 barrier.wait().await;
8146 });
8147 }
8148
8149 #[tokio::test(start_paused = true)]
8153 async fn a_reserved_runner_comes_up_before_the_next_reservation_ends() {
8154 let held = Arc::new(HeldBringUp {
8155 inner: Arc::new(FakeLauncher::new()),
8156 barrier: Arc::new(tokio::sync::Barrier::new(1)),
8157 fail: None,
8158 waits: Mutex::default(),
8159 });
8160 let director = Arc::new(FixedDirector::default());
8161 let pool = policy(1, "acme/app", 3);
8162 director.directed.lock().unwrap().insert(pool.id, 2);
8163 let reconciler = directed_reconciler(
8164 5,
8165 Arc::clone(&held) as _,
8166 Arc::new(InProcessAllocationLock::new()),
8167 Arc::clone(&director),
8168 );
8169 let signal = tokio::sync::Notify::new();
8170 let (stop, stopped) = tokio::sync::watch::channel(false);
8171
8172 scripted_older(&director, &[(0, 1), (1, 1)]);
8174 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8175 signal.notify_one();
8176 tokio::time::sleep(Duration::from_millis(1)).await;
8177 assert_eq!(
8178 held.waits.lock().unwrap().len(),
8179 1,
8180 "the first is coming up"
8181 );
8182 tokio::time::sleep(OLDER_LAUNCH_TURN + Duration::from_secs(1)).await;
8183 assert_eq!(held.waits.lock().unwrap().len(), 2);
8184 stop.send_replace(true);
8185 });
8186 }
8187
8188 #[tokio::test(start_paused = true)]
8191 async fn a_launch_that_did_not_start_is_retried_and_a_stop_ends_it() {
8192 let held = Arc::new(HeldBringUp {
8193 inner: Arc::new(FakeLauncher::new()),
8194 barrier: Arc::new(tokio::sync::Barrier::new(1)),
8195 fail: Some(FailureReason::JitRequestFailed),
8196 waits: Mutex::default(),
8197 });
8198 let director = Arc::new(FixedDirector::default());
8199 let pool = policy(1, "acme/app", 3);
8200 director.directed.lock().unwrap().insert(pool.id, 1);
8201 director.orders.lock().unwrap().insert(pool.id, 1);
8202 let reconciler = directed_reconciler(
8203 5,
8204 held as _,
8205 Arc::new(InProcessAllocationLock::new()),
8206 Arc::clone(&director),
8207 );
8208 let signal = tokio::sync::Notify::new();
8209 let (stop, stopped) = tokio::sync::watch::channel(false);
8210
8211 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8212 signal.notify_one();
8213 tokio::time::sleep(Duration::from_millis(1)).await;
8214 assert_eq!(director.failed.lock().unwrap().len(), 1);
8215 director.directed.lock().unwrap().insert(pool.id, 2);
8217 tokio::time::sleep(LAUNCH_RETRY - Duration::from_secs(1)).await;
8218 assert_eq!(director.failed.lock().unwrap().len(), 1, "not yet");
8219 tokio::time::sleep(Duration::from_secs(2)).await;
8220 assert_eq!(director.failed.lock().unwrap().len(), 2, "retried");
8221
8222 stop.send_replace(true);
8223 signal.notify_one();
8224 });
8225 assert_eq!(director.failed.lock().unwrap().len(), 2);
8226 }
8227
8228 #[tokio::test]
8232 async fn a_directed_policy_starts_only_what_it_was_directed_to_within_its_ceilings() {
8233 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
8234 let launcher = Arc::new(FakeLauncher::new());
8235 let director = Arc::new(FixedDirector::default());
8236 let mut reconciler = Reconciler::new(
8237 host_with(10),
8238 ReconcilerPorts {
8239 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
8240 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
8241 lock: Arc::new(InProcessAllocationLock::new()),
8242 directory: Arc::new(FakeDirectory::default()),
8243 clock: Arc::new(FakeClock::default()),
8244 jitter: Arc::new(NoJitter),
8245 events: Arc::new(NoEvents),
8246 },
8247 )
8248 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8249 let pool = policy(1, "acme/app", 2);
8250
8251 director.directed.lock().unwrap().insert(pool.id, 0);
8253 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8254 assert_eq!(report.started, 0);
8255
8256 director.directed.lock().unwrap().insert(pool.id, 1);
8258 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8259 assert_eq!(report.started, 1);
8260 assert_eq!(director.started.lock().unwrap().len(), 1);
8261
8262 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8264 assert_eq!(report.started, 0);
8265
8266 director.directed.lock().unwrap().insert(pool.id, 3);
8268 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8269 assert_eq!(report.started, 1);
8270 assert_eq!(launcher.live_count(), 2);
8271
8272 let polls = demand.polls().len();
8274 director.directed.lock().unwrap().insert(pool.id, 0);
8275 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8276 assert_eq!(report.started, 0);
8277 let fresh = Arc::new(FakeLauncher::new());
8278 let starter = Reconciler::new(
8279 host_with(10),
8280 ReconcilerPorts {
8281 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
8282 launcher: Arc::clone(&fresh) as Arc<dyn RunnerLauncher>,
8283 lock: Arc::new(InProcessAllocationLock::new()),
8284 directory: Arc::new(FakeDirectory::default()),
8285 clock: Arc::new(FakeClock::default()),
8286 jitter: Arc::new(NoJitter),
8287 events: Arc::new(NoEvents),
8288 },
8289 )
8290 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8291 let wide = policy(2, "acme/app", 6);
8292 director.directed.lock().unwrap().insert(wide.id, 6);
8293 let report = starter.start_directed(std::slice::from_ref(&wide)).await;
8294 assert_eq!(report.started, 6, "six launches, one call");
8295 assert_eq!(demand.polls().len(), polls, "GitHub was not polled");
8296 assert_eq!(
8298 starter
8299 .start_directed(&[policy(3, "acme/app", 2)])
8300 .await
8301 .started,
8302 0
8303 );
8304
8305 let failing = Arc::new(FakeLauncher::new());
8307 let mut reconciler = Reconciler::new(
8308 host_with(10),
8309 ReconcilerPorts {
8310 demand: Arc::new(FakeDemand::failing(RefreshState::Offline)),
8311 launcher: Arc::clone(&failing) as Arc<dyn RunnerLauncher>,
8312 lock: Arc::new(InProcessAllocationLock::new()),
8313 directory: Arc::new(FakeDirectory::default()),
8314 clock: Arc::new(FakeClock::default()),
8315 jitter: Arc::new(NoJitter),
8316 events: Arc::new(NoEvents),
8317 },
8318 )
8319 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8320 director.directed.lock().unwrap().insert(pool.id, 1);
8321 assert_eq!(reconciler.reconcile(&[pool]).await.started, 1);
8322
8323 let own = Arc::new(FakeLauncher::new());
8325 let mut reconciler = Reconciler::new(
8326 host_with(10),
8327 ReconcilerPorts {
8328 demand: Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
8329 launcher: Arc::clone(&own) as Arc<dyn RunnerLauncher>,
8330 lock: Arc::new(InProcessAllocationLock::new()),
8331 directory: Arc::new(FakeDirectory::default()),
8332 clock: Arc::new(FakeClock::default()),
8333 jitter: Arc::new(NoJitter),
8334 events: Arc::new(NoEvents),
8335 },
8336 )
8337 .with_director(Arc::new(OwnDemand));
8338 assert_eq!(
8339 reconciler
8340 .reconcile(&[policy(1, "acme/app", 2)])
8341 .await
8342 .started,
8343 2
8344 );
8345 }
8346}