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 pub lock_wait: Duration,
1013}
1014
1015#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1022#[error("the runner could not be started: {reason}")]
1023pub struct LaunchFailure {
1024 pub reason: FailureReason,
1025}
1026
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub struct ReplacementIntent {
1031 pub policy: PolicyId,
1032 pub previous_attempt: AttemptId,
1033 pub operation: &'static str,
1034}
1035
1036impl LaunchFailure {
1037 #[must_use]
1038 pub const fn new(reason: FailureReason) -> Self {
1039 Self { reason }
1040 }
1041}
1042
1043#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1046pub enum HostCapacityError {
1047 MissingHost,
1048 JournalUnreadable,
1049}
1050
1051impl HostCapacityError {
1052 #[must_use]
1053 pub const fn reason(self) -> &'static str {
1054 match self {
1055 Self::MissingHost => "host_missing",
1056 Self::JournalUnreadable => "host_journal_unreadable",
1057 }
1058 }
1059}
1060
1061#[derive(Debug)]
1062enum AllocationFailure {
1063 HostCapacity(HostCapacityError),
1064 Attempts(LaunchFailure),
1065}
1066
1067#[async_trait::async_trait]
1104pub trait RunnerLauncher: fmt::Debug + Send + Sync {
1105 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError>;
1109
1110 async fn supervise(
1115 &self,
1116 _policy: &ScalePolicy,
1117 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1118 Ok(Vec::new())
1119 }
1120
1121 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
1134
1135 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
1182
1183 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
1191}
1192
1193#[derive(Debug)]
1204pub struct FitnessGatedLauncher {
1205 inner: Arc<dyn RunnerLauncher>,
1206 unfit: Arc<AtomicBool>,
1207}
1208
1209impl FitnessGatedLauncher {
1210 #[must_use]
1211 pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
1212 Self { inner, unfit }
1213 }
1214}
1215
1216#[async_trait::async_trait]
1217impl RunnerLauncher for FitnessGatedLauncher {
1218 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
1219 self.inner.host_capacity(host).await
1220 }
1221
1222 async fn supervise(
1223 &self,
1224 policy: &ScalePolicy,
1225 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1226 self.inner.supervise(policy).await
1227 }
1228
1229 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
1230 self.inner.attempts().await
1231 }
1232
1233 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
1234 if self.unfit.load(Ordering::Acquire) {
1235 return Err(LaunchFailure::new(FailureReason::HostUnfit));
1236 }
1237 self.inner.launch(request).await
1238 }
1239
1240 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
1241 self.inner.clean(attempt).await
1242 }
1243}
1244
1245pub struct AllocationGuard {
1255 _held: Box<dyn std::any::Any + Send + Sync>,
1256}
1257
1258impl AllocationGuard {
1259 #[must_use]
1261 fn new<T: Send + Sync + 'static>(held: T) -> Self {
1262 Self {
1263 _held: Box::new(held),
1264 }
1265 }
1266}
1267
1268impl fmt::Debug for AllocationGuard {
1269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1270 f.write_str("AllocationGuard")
1271 }
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1276#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1277pub struct AllocationLockBusy;
1278
1279#[async_trait::async_trait]
1293pub trait AllocationLock: fmt::Debug + Send + Sync {
1294 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1300}
1301
1302#[derive(Debug, Default)]
1310pub struct InProcessAllocationLock {
1311 mutex: Arc<tokio::sync::Mutex<()>>,
1312}
1313
1314impl InProcessAllocationLock {
1315 #[must_use]
1316 pub fn new() -> Self {
1317 Self::default()
1318 }
1319}
1320
1321#[async_trait::async_trait]
1322impl AllocationLock for InProcessAllocationLock {
1323 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1324 let mutex = Arc::clone(&self.mutex);
1325 let guard = mutex.lock_owned().await;
1326 Ok(AllocationGuard::new(guard))
1327 }
1328}
1329
1330#[derive(Debug, Clone)]
1345pub struct FileAllocationLock {
1346 paths: Arc<runner_manager_platform::paths::AppPaths>,
1347 wait: Duration,
1348}
1349
1350impl FileAllocationLock {
1351 #[must_use]
1352 pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1353 Self {
1354 paths,
1355 wait: ALLOCATION_LOCK_WAIT,
1356 }
1357 }
1358
1359 #[must_use]
1360 pub const fn with_wait(mut self, wait: Duration) -> Self {
1361 self.wait = wait;
1362 self
1363 }
1364}
1365
1366#[async_trait::async_trait]
1367impl AllocationLock for FileAllocationLock {
1368 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1369 use runner_manager_platform::lock::{HostLock, LockKind};
1370
1371 let paths = Arc::clone(&self.paths);
1372 let wait = self.wait;
1373 let held = tokio::task::spawn_blocking(move || {
1374 HostLock::acquire(&paths, LockKind::Allocation, wait)
1375 })
1376 .await;
1377
1378 match held {
1379 Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1380 Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1384 }
1385 }
1386}
1387
1388#[derive(Debug)]
1412pub struct WslRecoveryAllocationLock {
1413 paths: Arc<runner_manager_platform::paths::AppPaths>,
1414 inner: Arc<dyn AllocationLock>,
1415 blocked_after_refusals: u32,
1416 blocked_after: Duration,
1417 streak_gap: Duration,
1419 in_flight: Arc<std::sync::atomic::AtomicUsize>,
1420 refusals: Mutex<Refusals>,
1421}
1422
1423#[derive(Debug, Default)]
1425struct Refusals {
1426 streak: Option<Streak>,
1429 recorded: bool,
1430}
1431
1432#[derive(Debug, Clone, Copy)]
1433struct Streak {
1434 since: chrono::DateTime<chrono::Utc>,
1435 latest: chrono::DateTime<chrono::Utc>,
1436 count: u32,
1437}
1438
1439struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
1441
1442impl InFlight {
1443 fn enter(count: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
1444 count.fetch_add(1, Ordering::SeqCst);
1445 Self(Arc::clone(count))
1446 }
1447}
1448
1449impl Drop for InFlight {
1450 fn drop(&mut self) {
1451 self.0.fetch_sub(1, Ordering::SeqCst);
1452 }
1453}
1454
1455struct LaunchFenceGuard(Option<runner_manager_platform::wsl::fence::GuestLaunchClaim>);
1459
1460impl Drop for LaunchFenceGuard {
1461 fn drop(&mut self) {
1462 if let Some(claim) = self.0.take()
1463 && let Err(error) = claim.release()
1464 {
1465 tracing::warn!(
1466 %error,
1467 "the WSL launch fence could not be released; the next launch reclaims it"
1468 );
1469 }
1470 }
1471}
1472
1473impl WslRecoveryAllocationLock {
1474 #[must_use]
1475 pub fn new(
1476 paths: Arc<runner_manager_platform::paths::AppPaths>,
1477 inner: Arc<dyn AllocationLock>,
1478 ) -> Self {
1479 use runner_manager_platform::launch_health::{BLOCKED_AFTER, BLOCKED_AFTER_DEFERRALS};
1480 Self {
1481 paths,
1482 inner,
1483 blocked_after_refusals: BLOCKED_AFTER_DEFERRALS,
1484 blocked_after: BLOCKED_AFTER,
1485 streak_gap: BLOCKED_AFTER,
1486 in_flight: Arc::default(),
1487 refusals: Mutex::default(),
1488 }
1489 }
1490
1491 #[cfg(test)]
1494 const fn with_blocked_after(mut self, refusals: u32, after: Duration) -> Self {
1495 self.blocked_after_refusals = refusals;
1496 self.blocked_after = after;
1497 self
1498 }
1499
1500 #[cfg(test)]
1502 const fn with_streak_gap(mut self, gap: Duration) -> Self {
1503 self.streak_gap = gap;
1504 self
1505 }
1506
1507 fn refusals(&self) -> std::sync::MutexGuard<'_, Refusals> {
1508 self.refusals
1509 .lock()
1510 .unwrap_or_else(std::sync::PoisonError::into_inner)
1511 }
1512
1513 fn note_granted(&self) {
1514 let mut refusals = self.refusals();
1515 refusals.streak = None;
1516 if refusals.recorded {
1517 match runner_manager_platform::launch_health::clear_launches_blocked(&self.paths) {
1518 Ok(()) => refusals.recorded = false,
1519 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be cleared"),
1521 }
1522 }
1523 }
1524
1525 fn note_refused(&self, cause: &runner_manager_platform::launch_health::BlockCause) {
1526 use runner_manager_platform::launch_health::{Viewpoint, record_launches_blocked};
1527 use runner_manager_platform::wsl::fence::elapsed_at_least;
1528
1529 if self.in_flight.load(Ordering::SeqCst) > 0 {
1530 return;
1531 }
1532 let now = chrono::Utc::now();
1533 let mut refusals = self.refusals();
1534 let streak = match refusals.streak {
1537 Some(streak) if !elapsed_at_least(streak.latest, now, self.streak_gap) => Streak {
1538 latest: now,
1539 count: streak.count.saturating_add(1),
1540 ..streak
1541 },
1542 _ => Streak {
1543 since: now,
1544 latest: now,
1545 count: 1,
1546 },
1547 };
1548 refusals.streak = Some(streak);
1549 if streak.count < self.blocked_after_refusals
1550 || !elapsed_at_least(streak.since, now, self.blocked_after)
1551 {
1552 return;
1553 }
1554 let blocked = cause.blocked_since(streak.since, Viewpoint::Guest);
1555 if !refusals.recorded {
1556 tracing::warn!(
1557 refusals = streak.count,
1558 "runner launches are blocked {blocked}: {}",
1559 blocked.remedy
1560 );
1561 }
1562 match record_launches_blocked(&self.paths, &blocked, now) {
1565 Ok(()) => refusals.recorded = true,
1566 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be written"),
1567 }
1568 }
1569}
1570
1571#[async_trait::async_trait]
1572impl AllocationLock for WslRecoveryAllocationLock {
1573 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1574 use runner_manager_platform::launch_health::BlockCause;
1575 use runner_manager_platform::wsl::fence::{
1576 DrainRequest, FENCE_DIRECTORY, FenceClaim, GuestClaimAttempt, GuestRecoveryConfig,
1577 try_claim_guest_launch,
1578 };
1579
1580 let paths = Arc::clone(&self.paths);
1581 let claim = tokio::task::spawn_blocking(move || {
1582 let unusable = |error: &dyn fmt::Display| BlockCause::FenceUnusable {
1583 detail: error.to_string(),
1584 };
1585 let config = match GuestRecoveryConfig::read(&paths) {
1586 Ok(Some(config)) => config,
1587 Ok(None) => return Ok(LaunchFenceGuard(None)),
1588 Err(error) => return Err(unusable(&error)),
1589 };
1590 let root = config.shared_root;
1591 let claim = match try_claim_guest_launch(&root) {
1592 Ok(GuestClaimAttempt::Claimed { claim, reclaimed }) => {
1593 if let Some(reclaimed) = reclaimed {
1594 tracing::warn!(
1595 shared_root = %root.display(),
1596 %reclaimed,
1597 "reclaimed a WSL launch fence claim whose owner is gone"
1598 );
1599 }
1600 LaunchFenceGuard(Some(claim))
1603 }
1604 Ok(GuestClaimAttempt::Busy) => {
1605 let directory = root.join(FENCE_DIRECTORY);
1606 return Err(match FenceClaim::owner(&root) {
1607 Ok(owner) => BlockCause::FenceHeld { directory, owner },
1608 Err(error) => BlockCause::FenceOwnerUnreadable {
1609 directory,
1610 detail: error.to_string(),
1611 },
1612 });
1613 }
1614 Err(error) => {
1615 return Err(match FenceClaim::owner(&root) {
1618 Err(owner_error) => BlockCause::FenceOwnerUnreadable {
1619 directory: root.join(FENCE_DIRECTORY),
1620 detail: owner_error.to_string(),
1621 },
1622 Ok(_) => unusable(&error),
1623 });
1624 }
1625 };
1626 match DrainRequest::read(&root) {
1627 Ok(None) => Ok(claim),
1628 Ok(Some(_)) => Err(BlockCause::DrainRequested { root }),
1629 Err(error) => Err(unusable(&error)),
1630 }
1631 })
1632 .await
1633 .map_err(|_| AllocationLockBusy)?;
1634
1635 let claim = match claim {
1636 Ok(claim) => claim,
1637 Err(cause) => {
1638 self.note_refused(&cause);
1639 return Err(AllocationLockBusy);
1640 }
1641 };
1642 let Ok(inner) = self.inner.acquire().await else {
1643 self.note_refused(&BlockCause::AllocationLockBusy {
1644 path: self
1645 .paths
1646 .state_dir()
1647 .join(runner_manager_platform::lock::LockKind::Allocation.file_name()),
1648 });
1649 return Err(AllocationLockBusy);
1650 };
1651 self.note_granted();
1652 let in_flight = InFlight::enter(&self.in_flight);
1653 Ok(AllocationGuard::new((claim, inner, in_flight)))
1654 }
1655}
1656
1657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1667pub enum OutcomeKind {
1668 CompletedJob,
1669 IdleExit,
1670 Failed,
1671 Orphaned,
1672}
1673
1674impl OutcomeKind {
1675 #[must_use]
1676 pub const fn of(outcome: &AttemptOutcome) -> Self {
1677 match outcome {
1678 AttemptOutcome::CompletedJob => Self::CompletedJob,
1679 AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1680 AttemptOutcome::Failed { .. } => Self::Failed,
1681 AttemptOutcome::Orphaned => Self::Orphaned,
1682 }
1683 }
1684
1685 #[must_use]
1686 pub const fn is_failure(&self) -> bool {
1687 matches!(self, Self::Failed | Self::Orphaned)
1688 }
1689
1690 #[must_use]
1691 pub const fn as_str(&self) -> &'static str {
1692 match self {
1693 Self::CompletedJob => "completed_job",
1694 Self::IdleExit => "exited_idle_without_work",
1695 Self::Failed => "failed",
1696 Self::Orphaned => "orphaned",
1697 }
1698 }
1699}
1700
1701impl fmt::Display for OutcomeKind {
1702 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1703 f.write_str(self.as_str())
1704 }
1705}
1706
1707#[must_use]
1716pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1717 match reason {
1718 FailureReason::JitRequestFailed => "jit_request_failed",
1719 FailureReason::JitExpired => "jit_expired",
1720 FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1721 FailureReason::RunnerVersionRejected => "runner_version_rejected",
1722 FailureReason::ProcessStartFailed => "process_start_failed",
1723 FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1724 FailureReason::RegistrationTimedOut => "registration_timed_out",
1725 FailureReason::TerminatedAfterRegistrationTimeout => {
1726 "terminated_after_registration_timeout"
1727 }
1728 FailureReason::IsolationProvider(category) => match category {
1729 IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1730 IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1731 IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1732 IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1733 "isolation_image_unavailable"
1734 }
1735 IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1736 IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1737 IsolationProviderFailure::RuntimeOperationFailed => {
1738 "isolation_runtime_operation_failed"
1739 }
1740 IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1741 IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1742 IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1743 },
1744 FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1745 FailureReason::HostUnfit => "host_unfit",
1746 FailureReason::Other(_) => "other",
1747 }
1748}
1749
1750#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1752pub enum RoutingRefusal {
1753 OverlappingProfiles,
1754}
1755
1756impl RoutingRefusal {
1757 #[must_use]
1758 pub const fn as_str(self) -> &'static str {
1759 match self {
1760 Self::OverlappingProfiles => "overlapping_profile_matches",
1761 }
1762 }
1763}
1764
1765impl fmt::Display for RoutingRefusal {
1766 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1767 f.write_str(match self {
1768 Self::OverlappingProfiles => {
1769 "repair overlapping profile labels or require a unique profile selector"
1770 }
1771 })
1772 }
1773}
1774
1775#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1785pub enum LifecycleEvent {
1786 DemandObserved {
1788 policy: PolicyId,
1789 demand: u32,
1791 not_matched: u32,
1797 unresolvable: u32,
1806 complete: bool,
1808 },
1809 RoutingRefused {
1811 policy: PolicyId,
1812 reason: RoutingRefusal,
1813 count: u32,
1814 },
1815 TargetUnreadable {
1817 policy: PolicyId,
1818 reason: &'static str,
1819 },
1820 Allocated {
1822 policy: PolicyId,
1823 demand: u32,
1824 desired: u16,
1825 active_owned: u16,
1826 headroom: u16,
1827 to_start: u16,
1828 limiting: LimitingFactor,
1829 },
1830 MonitorOnlySkipped { policy: PolicyId },
1833 RunnerStarted {
1835 policy: PolicyId,
1836 attempt: AttemptId,
1837 },
1838 RunnerStartFailed {
1840 policy: PolicyId,
1841 reason: &'static str,
1842 },
1843 AllocationDeferred { policy: PolicyId, count: u16 },
1846 AttemptsUnreadable { reason: &'static str },
1852 HostCapacityUnreadable { reason: HostCapacityError },
1854 AttemptCleaned {
1856 policy: PolicyId,
1857 attempt: AttemptId,
1858 outcome: OutcomeKind,
1859 },
1860 AttemptCleanFailed {
1863 policy: PolicyId,
1864 attempt: AttemptId,
1865 reason: &'static str,
1866 },
1867 ScaleDownRefused {
1869 policy: PolicyId,
1870 attempt: AttemptId,
1871 },
1872 PollScheduled { retry_in_ms: u64, pace: PollPace },
1874}
1875
1876impl LifecycleEvent {
1877 #[must_use]
1879 pub const fn name(&self) -> &'static str {
1880 match self {
1881 Self::DemandObserved { .. } => "demand_observed",
1882 Self::RoutingRefused { .. } => "routing_refused",
1883 Self::TargetUnreadable { .. } => "target_unreadable",
1884 Self::Allocated { .. } => "allocated",
1885 Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1886 Self::RunnerStarted { .. } => "runner_started",
1887 Self::RunnerStartFailed { .. } => "runner_start_failed",
1888 Self::AllocationDeferred { .. } => "allocation_deferred",
1889 Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1890 Self::HostCapacityUnreadable { .. } => "host_capacity_unreadable",
1891 Self::AttemptCleaned { .. } => "attempt_cleaned",
1892 Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1893 Self::ScaleDownRefused { .. } => "scale_down_refused",
1894 Self::PollScheduled { .. } => "poll_scheduled",
1895 }
1896 }
1897
1898 #[must_use]
1900 pub const fn policy(&self) -> Option<PolicyId> {
1901 match self {
1902 Self::DemandObserved { policy, .. }
1903 | Self::RoutingRefused { policy, .. }
1904 | Self::TargetUnreadable { policy, .. }
1905 | Self::Allocated { policy, .. }
1906 | Self::MonitorOnlySkipped { policy }
1907 | Self::RunnerStarted { policy, .. }
1908 | Self::RunnerStartFailed { policy, .. }
1909 | Self::AllocationDeferred { policy, .. }
1910 | Self::AttemptCleaned { policy, .. }
1911 | Self::AttemptCleanFailed { policy, .. }
1912 | Self::ScaleDownRefused { policy, .. } => Some(*policy),
1913 Self::PollScheduled { .. }
1914 | Self::AttemptsUnreadable { .. }
1915 | Self::HostCapacityUnreadable { .. } => None,
1916 }
1917 }
1918}
1919
1920impl fmt::Display for LifecycleEvent {
1921 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1922 match self {
1923 Self::DemandObserved {
1924 policy,
1925 demand,
1926 not_matched,
1927 unresolvable,
1928 complete,
1929 } => write!(
1930 f,
1931 "policy {policy}: {demand} queued jobs for this host{}{}{}",
1932 if *not_matched == 0 {
1933 String::new()
1934 } else {
1935 format!(", {not_matched} for other labels")
1936 },
1937 if *unresolvable == 0 {
1938 String::new()
1939 } else {
1940 format!(", {unresolvable} with an unresolvable `runs-on`")
1941 },
1942 if *complete {
1943 ""
1944 } else {
1945 " (a floor, not a total)"
1946 }
1947 ),
1948 Self::TargetUnreadable { policy, reason } => {
1949 write!(f, "policy {policy}: target unreadable ({reason})")
1950 }
1951 Self::RoutingRefused {
1952 policy,
1953 reason,
1954 count,
1955 } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
1956 Self::Allocated {
1957 policy,
1958 demand,
1959 desired,
1960 active_owned,
1961 headroom,
1962 to_start,
1963 limiting,
1964 } => write!(
1965 f,
1966 "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
1967 flight, {headroom} free on this host, starting {to_start} ({limiting})"
1968 ),
1969 Self::MonitorOnlySkipped { policy } => {
1970 write!(f, "policy {policy}: monitor-only, skipped")
1971 }
1972 Self::RunnerStarted { policy, attempt } => {
1973 write!(f, "policy {policy}: started attempt {attempt}")
1974 }
1975 Self::RunnerStartFailed { policy, reason } => {
1976 write!(f, "policy {policy}: could not start a runner ({reason})")
1977 }
1978 Self::AllocationDeferred { policy, count } => write!(
1979 f,
1980 "policy {policy}: the allocation lock was held; {count} granted runners \
1981 were not created"
1982 ),
1983 Self::AttemptsUnreadable { reason } => write!(
1984 f,
1985 "the host's attempt set could not be read ({reason}); nothing was started, \
1986 and this is not the same as the host being idle"
1987 ),
1988 Self::HostCapacityUnreadable { reason } => write!(
1989 f,
1990 "the host capacity could not be read ({}); new launches wait for readable host settings",
1991 reason.reason()
1992 ),
1993 Self::AttemptCleaned {
1994 policy,
1995 attempt,
1996 outcome,
1997 } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
1998 Self::AttemptCleanFailed {
1999 policy,
2000 attempt,
2001 reason,
2002 } => write!(
2003 f,
2004 "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
2005 will be retried"
2006 ),
2007 Self::ScaleDownRefused { policy, attempt } => write!(
2008 f,
2009 "policy {policy}: attempt {attempt} is executing a job and was not removed"
2010 ),
2011 Self::PollScheduled { retry_in_ms, pace } => {
2012 write!(f, "next poll in {retry_in_ms}ms ({pace})")
2013 }
2014 }
2015 }
2016}
2017
2018pub trait EventSink: fmt::Debug + Send + Sync {
2020 fn emit(&self, event: LifecycleEvent);
2021}
2022
2023#[derive(Debug, Clone, Copy, Default)]
2025pub struct NoEvents;
2026
2027impl EventSink for NoEvents {
2028 fn emit(&self, _event: LifecycleEvent) {}
2029}
2030
2031#[derive(Debug, Clone, Copy, Default)]
2039pub struct TracingEvents;
2040
2041impl EventSink for TracingEvents {
2042 fn emit(&self, event: LifecycleEvent) {
2043 let name = event.name();
2044 match event {
2045 LifecycleEvent::DemandObserved {
2046 policy,
2047 demand,
2048 not_matched,
2049 unresolvable,
2050 complete,
2051 } => {
2052 if demand == 0 && not_matched == 0 && unresolvable == 0 && complete {
2057 tracing::debug!(
2058 event = name,
2059 policy_id = %policy,
2060 demand,
2061 not_matched,
2062 unresolvable,
2063 count = u64::from(complete),
2064 );
2065 } else {
2066 tracing::info!(
2067 event = name,
2068 policy_id = %policy,
2069 demand,
2070 not_matched,
2071 unresolvable,
2072 count = u64::from(complete),
2073 );
2074 }
2075 }
2095 LifecycleEvent::TargetUnreadable { policy, reason } => {
2096 tracing::warn!(event = name, policy_id = %policy, reason);
2097 }
2098 LifecycleEvent::RoutingRefused {
2099 policy,
2100 reason,
2101 count,
2102 } => tracing::warn!(
2103 event = name,
2104 policy_id = %policy,
2105 reason = reason.as_str(),
2106 count,
2107 "{reason}"
2108 ),
2109 LifecycleEvent::Allocated {
2110 policy,
2111 demand,
2112 desired,
2113 active_owned,
2114 headroom,
2115 to_start,
2116 limiting,
2117 } if demand == 0 && to_start == 0 && active_owned == 0 => tracing::debug!(
2118 event = name,
2121 policy_id = %policy,
2122 demand,
2123 desired,
2124 capacity = active_owned,
2125 headroom,
2126 count = to_start,
2127 reason = %limiting,
2128 ),
2129 LifecycleEvent::Allocated {
2130 policy,
2131 demand,
2132 desired,
2133 active_owned,
2134 headroom,
2135 to_start,
2136 limiting,
2137 } => tracing::info!(
2138 event = name,
2139 policy_id = %policy,
2140 demand,
2141 desired,
2142 capacity = active_owned,
2143 headroom,
2144 count = to_start,
2145 reason = %limiting,
2146 ),
2147 LifecycleEvent::MonitorOnlySkipped { policy } => {
2148 tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
2149 }
2150 LifecycleEvent::RunnerStarted { policy, attempt } => {
2151 tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
2152 }
2153 LifecycleEvent::RunnerStartFailed { policy, reason } => {
2154 tracing::warn!(event = name, policy_id = %policy, reason);
2155 }
2156 LifecycleEvent::AllocationDeferred { policy, count } => {
2157 tracing::warn!(
2163 event = name,
2164 policy_id = %policy,
2165 reason = "allocation_lock_unavailable",
2166 count
2167 );
2168 }
2169 LifecycleEvent::AttemptsUnreadable { reason } => {
2170 tracing::warn!(event = name, reason);
2171 }
2172 LifecycleEvent::HostCapacityUnreadable { reason } => {
2173 tracing::warn!(event = name, reason = reason.reason());
2174 }
2175 LifecycleEvent::AttemptCleaned {
2176 policy,
2177 attempt,
2178 outcome,
2179 } => tracing::info!(
2180 event = name,
2181 policy_id = %policy,
2182 attempt_id = %attempt,
2183 outcome = outcome.as_str(),
2184 ),
2185 LifecycleEvent::AttemptCleanFailed {
2186 policy,
2187 attempt,
2188 reason,
2189 } => tracing::warn!(
2190 event = name,
2191 policy_id = %policy,
2192 attempt_id = %attempt,
2193 reason,
2194 ),
2195 LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
2196 event = name,
2197 policy_id = %policy,
2198 attempt_id = %attempt,
2199 attempt_state = "busy",
2200 ),
2201 LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
2202 if pace == PollPace::Idle {
2203 tracing::debug!(event = name, retry_in_ms, state = pace.as_str());
2204 } else {
2205 tracing::info!(event = name, retry_in_ms, state = pace.as_str());
2206 }
2207 }
2208 }
2209 }
2210}
2211
2212#[derive(Debug, Default)]
2216pub struct EventLog {
2217 events: Mutex<Vec<LifecycleEvent>>,
2218}
2219
2220impl EventLog {
2221 #[must_use]
2222 pub fn new() -> Self {
2223 Self::default()
2224 }
2225
2226 #[must_use]
2227 pub fn events(&self) -> Vec<LifecycleEvent> {
2228 self.events.lock().map(|e| e.clone()).unwrap_or_default()
2229 }
2230
2231 #[must_use]
2233 pub fn count_of(&self, name: &str) -> usize {
2234 self.events()
2235 .iter()
2236 .filter(|event| event.name() == name)
2237 .count()
2238 }
2239}
2240
2241impl EventSink for EventLog {
2242 fn emit(&self, event: LifecycleEvent) {
2243 if let Ok(mut events) = self.events.lock() {
2244 events.push(event);
2245 }
2246 }
2247}
2248
2249#[derive(Debug)]
2252pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
2253
2254impl EventSink for TeeEvents {
2255 fn emit(&self, event: LifecycleEvent) {
2256 self.0.emit(event);
2257 self.1.emit(event);
2258 }
2259}
2260
2261pub trait DemandDirector: fmt::Debug + Send + Sync {
2276 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32>;
2279
2280 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId);
2283}
2284
2285#[derive(Debug, Default, Clone, Copy)]
2287pub struct OwnDemand;
2288
2289impl DemandDirector for OwnDemand {
2290 fn directed_demand(&self, _policy: &ScalePolicy) -> Option<u32> {
2291 None
2292 }
2293
2294 fn launched(&self, _policy: &ScalePolicy, _attempt: AttemptId) {}
2295}
2296
2297pub struct ReconcilerPorts {
2303 pub demand: Arc<dyn DemandSource>,
2304 pub launcher: Arc<dyn RunnerLauncher>,
2305 pub lock: Arc<dyn AllocationLock>,
2306 pub directory: Arc<dyn RepositoryDirectory>,
2307 pub clock: Arc<dyn Clock>,
2308 pub jitter: Arc<dyn Jitter>,
2309 pub events: Arc<dyn EventSink>,
2310}
2311
2312impl fmt::Debug for ReconcilerPorts {
2313 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2314 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
2315 }
2316}
2317
2318#[derive(Debug, Clone, Default)]
2325pub struct ReconcileReport {
2326 pub allocations: Vec<Allocation>,
2328 pub monitor_only: Vec<PolicyId>,
2330 pub unreadable: Vec<PolicyId>,
2332 pub targets_read: u16,
2340 pub started: u16,
2342 pub replacement_intents: u16,
2345 pub cleaned: u16,
2347 pub idle_exits: u16,
2350 pub failures: u16,
2352 pub deferred: u16,
2361 pub attempts_unreadable: u16,
2380 pub host_capacity_unreadable: u16,
2382 pub clean_failures: u16,
2384 pub failure: Option<RefreshState>,
2386 pub offline: Option<OfflineState>,
2389 pub next_poll: NextPoll,
2391 pub demand_requests: u32,
2393}
2394
2395impl ReconcileReport {
2396 #[must_use]
2423 pub const fn reached_github(&self) -> bool {
2424 self.targets_read > 0
2425 }
2426}
2427
2428impl Default for NextPoll {
2429 fn default() -> Self {
2430 Self {
2431 delay: PollSchedule::floor(),
2432 pace: PollPace::Nominal,
2433 }
2434 }
2435}
2436
2437impl ReconcileReport {
2438 #[must_use]
2440 pub fn is_offline(&self) -> bool {
2441 matches!(self.failure, Some(RefreshState::Offline))
2442 }
2443
2444 #[must_use]
2446 pub const fn offline_state(&self) -> Option<&OfflineState> {
2447 self.offline.as_ref()
2448 }
2449
2450 #[must_use]
2452 pub const fn starts_nothing(&self) -> bool {
2453 self.started == 0
2454 }
2455}
2456
2457#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2459pub struct ScaleDownReport {
2460 pub removed: u16,
2462 pub refused_busy: u16,
2464 pub clean_failures: u16,
2466 pub retained: u16,
2469 pub attempts_unreadable: bool,
2479}
2480
2481impl ScaleDownReport {
2482 #[must_use]
2487 pub const fn is_conclusive(&self) -> bool {
2488 !self.attempts_unreadable
2489 }
2490}
2491
2492const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
2500
2501const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
2503
2504#[derive(Debug, Clone, Copy)]
2506struct CleanRetry {
2507 failures: u32,
2508 not_before: Timestamp,
2509}
2510
2511#[derive(Debug, Default)]
2515pub(crate) struct CleanBackoff {
2516 retries: BTreeMap<AttemptId, CleanRetry>,
2517}
2518
2519impl CleanBackoff {
2520 const SCHEDULE: crate::lifecycle::RetryPolicy =
2522 crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2523
2524 pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2526 self.retries
2527 .get(&attempt)
2528 .is_none_or(|retry| now >= retry.not_before)
2529 }
2530
2531 pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2533 let failures = self
2534 .retries
2535 .get(&attempt)
2536 .map_or(1, |retry| retry.failures.saturating_add(1));
2537 let delay = Self::SCHEDULE.delay(failures);
2538 self.retries.insert(
2539 attempt,
2540 CleanRetry {
2541 failures,
2542 not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2544 },
2545 );
2546 }
2547
2548 pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2550 self.retries.remove(&attempt);
2551 }
2552
2553 fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2556 self.retries.retain(|attempt, _| present.contains(attempt));
2557 }
2558}
2559
2560#[derive(Debug)]
2566pub struct Reconciler {
2567 host: Host,
2568 demand: Arc<dyn DemandSource>,
2569 launcher: Arc<dyn RunnerLauncher>,
2570 lock: Arc<dyn AllocationLock>,
2571 repositories: RepositoryCache,
2572 clock: Arc<dyn Clock>,
2573 jitter: Arc<dyn Jitter>,
2574 events: Arc<dyn EventSink>,
2575 schedule: PollSchedule,
2576 clean_backoff: CleanBackoff,
2577 director: Arc<dyn DemandDirector>,
2578}
2579
2580impl Reconciler {
2581 #[must_use]
2583 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2584 let interval = host.refresh_interval;
2585 let repositories = RepositoryCache::new(
2586 Arc::clone(&ports.directory),
2587 Arc::clone(&ports.clock),
2588 interval,
2589 );
2590 Self {
2591 host,
2592 demand: ports.demand,
2593 launcher: ports.launcher,
2594 lock: ports.lock,
2595 repositories,
2596 clock: ports.clock,
2597 jitter: ports.jitter,
2598 events: ports.events,
2599 schedule: PollSchedule::new(interval),
2600 clean_backoff: CleanBackoff::default(),
2601 director: Arc::new(OwnDemand),
2602 }
2603 }
2604
2605 #[must_use]
2608 pub fn with_director(mut self, director: Arc<dyn DemandDirector>) -> Self {
2609 self.director = director;
2610 self
2611 }
2612
2613 #[must_use]
2614 pub const fn host(&self) -> &Host {
2615 &self.host
2616 }
2617
2618 #[must_use]
2619 pub const fn schedule(&self) -> &PollSchedule {
2620 &self.schedule
2621 }
2622
2623 pub fn set_intervals(&mut self, active: RefreshInterval, idle: Duration) {
2625 self.host.refresh_interval = active;
2626 self.schedule.set_intervals(active, idle);
2627 }
2628
2629 #[must_use]
2631 pub const fn repositories(&self) -> &RepositoryCache {
2632 &self.repositories
2633 }
2634
2635 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2649 let mut report = ReconcileReport::default();
2650 let mut launched: Vec<RunnerAttempt> = Vec::new();
2654
2655 let mut pollable: Vec<&ScalePolicy> = Vec::new();
2657 let mut supervision_failed = BTreeSet::new();
2658 for policy in policies {
2659 if !policy.owns_runners() {
2660 report.monitor_only.push(policy.id);
2661 self.events
2662 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2663 continue;
2664 }
2665 if !policy.is_owned_by(self.host.id) {
2666 continue;
2670 }
2671 match self.launcher.supervise(policy).await {
2672 Ok(intents) => {
2673 report.replacement_intents = report
2674 .replacement_intents
2675 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
2676 }
2677 Err(failure) => {
2678 supervision_failed.insert(policy.id);
2679 self.report_unreadable_attempts(&mut report, &failure);
2680 continue;
2681 }
2682 }
2683 if !policy.may_start_runners() {
2684 continue;
2685 }
2686 pollable.push(policy);
2687 }
2688
2689 let readings = self.poll_targets(&pollable, &mut report).await;
2690 report.targets_read = u16::try_from(
2691 readings
2692 .values()
2693 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
2694 .count(),
2695 )
2696 .unwrap_or(u16::MAX);
2697 let mut routed = BTreeMap::new();
2698 for (target, outcome) in &readings {
2699 let PollOutcome::Ready(reading) = outcome else {
2700 continue;
2701 };
2702 let profiles: Vec<&ScalePolicy> = pollable
2703 .iter()
2704 .copied()
2705 .filter(|policy| &policy.target == target)
2706 .collect();
2707 let (tallies, refusals) = route_demand(policies, &profiles, reading);
2708 if let Some(first) = profiles.first() {
2709 for (reason, count) in refusals {
2710 self.events.emit(LifecycleEvent::RoutingRefused {
2711 policy: first.id,
2712 reason,
2713 count,
2714 });
2715 }
2716 }
2717 routed.insert(target.clone(), tallies);
2718 }
2719
2720 self.clean_terminal_attempts(&mut report).await;
2730
2731 for policy in policies {
2746 if !policy.owns_runners() {
2747 continue;
2753 }
2754 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2755 match self.allocate_only(policy, 0, &launched).await {
2759 Ok(allocation) => {
2760 self.emit_allocation(&allocation);
2761 report.allocations.push(allocation);
2762 }
2763 Err(failure) => self.report_allocation_failure(&mut report, &failure),
2764 }
2765 continue;
2766 }
2767 if supervision_failed.contains(&policy.id) {
2768 continue;
2769 }
2770 if let Some(directed) = self.director.directed_demand(policy) {
2774 self.start_runners(policy, directed, &mut report, &mut launched)
2775 .await;
2776 continue;
2777 }
2778 let Some(reading) = readings.get(&policy.target) else {
2779 debug_assert!(false, "a pollable policy's target has no reading");
2782 continue;
2783 };
2784 match reading {
2785 PollOutcome::Failed(state) => {
2786 report.unreadable.push(policy.id);
2787 self.events.emit(LifecycleEvent::TargetUnreadable {
2788 policy: policy.id,
2789 reason: unreadable_reason(state),
2790 });
2791 }
2792 PollOutcome::Ready(demand) => {
2793 let tally = routed
2794 .get(&policy.target)
2795 .and_then(|tallies| tallies.get(&policy.id))
2796 .expect("every pollable profile has a routed tally");
2797 let count = tally.demand();
2798 self.events.emit(LifecycleEvent::DemandObserved {
2799 policy: policy.id,
2800 demand: count,
2801 not_matched: tally.not_matched,
2802 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2803 complete: demand.is_complete(),
2804 });
2805 self.start_runners(policy, count, &mut report, &mut launched)
2806 .await;
2807 }
2808 }
2809 }
2810
2811 let failure = readings
2813 .values()
2814 .filter_map(PollOutcome::failure)
2815 .max_by_key(|state| severity(state))
2816 .cloned();
2817 let now = self.clock.now();
2818 let conditions = PollConditions {
2819 quiet: is_quiet(&readings, &report),
2820 requests_per_pass: report.demand_requests,
2821 quota: readings
2822 .values()
2823 .filter_map(PollOutcome::reading)
2824 .filter_map(QueuedDemand::rate_limit)
2825 .max_by_key(|quota| quota.observed_at)
2826 .copied(),
2827 };
2828 report.next_poll =
2829 self.schedule
2830 .next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
2831 report.failure = failure;
2832 if let PollPace::Offline { consecutive } = report.next_poll.pace {
2836 let state = OfflineState::new(consecutive, report.next_poll.delay);
2837 report.offline = Some(match self.schedule.offline_for(now) {
2838 Some(elapsed) => state.since(elapsed),
2839 None => state,
2840 });
2841 }
2842 self.events.emit(LifecycleEvent::PollScheduled {
2843 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2844 pace: report.next_poll.pace,
2845 });
2846
2847 report
2848 }
2849
2850 async fn poll_targets(
2858 &self,
2859 pollable: &[&ScalePolicy],
2860 report: &mut ReconcileReport,
2861 ) -> BTreeMap<ScaleTarget, PollOutcome> {
2862 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
2863
2864 let mut readings = BTreeMap::new();
2865 for target in targets {
2866 let outcome = match self.repositories.scope_for(&target).await {
2867 Ok(scope) => {
2868 report.demand_requests = report
2869 .demand_requests
2870 .saturating_add(demand_requests_per_poll(&scope));
2871 self.demand.poll(&scope).await
2872 }
2873 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
2879 };
2880 readings.insert(target, outcome);
2881 }
2882 readings
2883 }
2884
2885 async fn allocate_only(
2903 &self,
2904 policy: &ScalePolicy,
2905 demand: u32,
2906 launched: &[RunnerAttempt],
2907 ) -> Result<Allocation, AllocationFailure> {
2908 let mut host = self.host.clone();
2911 host.host_capacity = self
2912 .launcher
2913 .host_capacity(&self.host)
2914 .await
2915 .map_err(AllocationFailure::HostCapacity)?;
2916 let attempts = self
2917 .host_attempts(launched)
2918 .await
2919 .map_err(AllocationFailure::Attempts)?;
2920 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
2921 Ok(allocator.allocate(policy, demand))
2922 }
2923
2924 async fn start_runners(
2946 &self,
2947 policy: &ScalePolicy,
2948 demand: u32,
2949 report: &mut ReconcileReport,
2950 launched: &mut Vec<RunnerAttempt>,
2951 ) {
2952 let intent = match self.allocate_only(policy, demand, launched).await {
2964 Ok(intent) => intent,
2965 Err(failure) => {
2966 self.report_allocation_failure(report, &failure);
2967 return;
2968 }
2969 };
2970
2971 let mut decided: Option<Allocation> = None;
2975 let mut budget = intent.to_start;
2976
2977 while budget > 0 {
2978 let waiting_since = std::time::Instant::now();
2979 let guard = match self.lock.acquire().await {
2980 Ok(guard) => guard,
2981 Err(_) => {
2982 report.deferred = report.deferred.saturating_add(budget);
2985 self.events.emit(LifecycleEvent::AllocationDeferred {
2986 policy: policy.id,
2987 count: budget,
2988 });
2989 break;
2990 }
2991 };
2992 let lock_wait = waiting_since.elapsed();
2993
2994 let allocation = match self.allocate_only(policy, demand, launched).await {
3000 Ok(allocation) => allocation,
3001 Err(failure) => {
3002 drop(guard);
3003 self.report_allocation_failure(report, &failure);
3004 break;
3005 }
3006 };
3007
3008 if decided.is_none() {
3009 budget = budget.min(allocation.to_start);
3013 decided = Some(allocation.clone());
3014 }
3015
3016 if allocation.starts_nothing() || budget == 0 {
3019 drop(guard);
3020 break;
3021 }
3022
3023 let created = self
3024 .launcher
3025 .launch(LaunchRequest {
3026 host: &self.host,
3027 policy,
3028 allocation_guard: &guard,
3029 lock_wait,
3030 })
3031 .await;
3032 drop(guard);
3033
3034 match created {
3035 Ok(attempt) => {
3036 let id = attempt.id;
3037 launched.push(attempt);
3041 self.director.launched(policy, id);
3045 report.started = report.started.saturating_add(1);
3046 budget -= 1;
3047 self.events.emit(LifecycleEvent::RunnerStarted {
3048 policy: policy.id,
3049 attempt: id,
3050 });
3051 }
3052 Err(failure) => {
3053 self.events.emit(LifecycleEvent::RunnerStartFailed {
3054 policy: policy.id,
3055 reason: failure_reason_kind(&failure.reason),
3056 });
3057 break;
3058 }
3059 }
3060 }
3061
3062 let allocation = decided.unwrap_or(intent);
3063 self.emit_allocation(&allocation);
3064 report.allocations.push(allocation);
3065 }
3066
3067 async fn host_attempts(
3080 &self,
3081 launched: &[RunnerAttempt],
3082 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3083 let mut attempts = self.launcher.attempts().await?;
3084
3085 debug_assert!(
3092 launched
3093 .iter()
3094 .map(|attempt| attempt.id)
3095 .collect::<BTreeSet<AttemptId>>()
3096 .len()
3097 == launched.len(),
3098 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
3099 the host ceiling is enforced against a set keyed on that identifier, so a \
3100 duplicate is two runtimes counted as one"
3101 );
3102
3103 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
3104 attempts.extend(
3105 launched
3106 .iter()
3107 .filter(|attempt| !known.contains(&attempt.id))
3108 .cloned(),
3109 );
3110 Ok(attempts)
3111 }
3112
3113 fn report_allocation_failure(&self, report: &mut ReconcileReport, failure: &AllocationFailure) {
3114 match failure {
3115 AllocationFailure::HostCapacity(reason) => {
3116 report.host_capacity_unreadable = report.host_capacity_unreadable.saturating_add(1);
3117 self.events
3118 .emit(LifecycleEvent::HostCapacityUnreadable { reason: *reason });
3119 }
3120 AllocationFailure::Attempts(failure) => {
3121 self.report_unreadable_attempts(report, failure)
3122 }
3123 }
3124 }
3125
3126 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
3133 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
3134 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3141 reason: failure_reason_kind(&failure.reason),
3142 });
3143 }
3144
3145 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
3151 let attempts = match self.launcher.attempts().await {
3152 Ok(attempts) => attempts,
3153 Err(failure) => {
3154 self.report_unreadable_attempts(report, &failure);
3155 return;
3156 }
3157 };
3158 self.clean_backoff
3159 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
3160 let now = self.clock.now();
3161 for attempt in attempts {
3162 if !attempt.state().is_concluded() {
3163 continue;
3164 }
3165 let Some(outcome) = attempt.outcome() else {
3166 continue;
3167 };
3168 if !self.clean_backoff.is_due(attempt.id, now) {
3171 continue;
3172 }
3173 let kind = OutcomeKind::of(outcome);
3174 match self.launcher.clean(attempt.id).await {
3175 Ok(()) => {
3176 self.clean_backoff.succeeded(attempt.id);
3177 report.cleaned = report.cleaned.saturating_add(1);
3178 if kind.is_failure() {
3179 report.failures = report.failures.saturating_add(1);
3180 } else if kind == OutcomeKind::IdleExit {
3181 report.idle_exits = report.idle_exits.saturating_add(1);
3186 }
3187 self.events.emit(LifecycleEvent::AttemptCleaned {
3188 policy: attempt.policy_id,
3189 attempt: attempt.id,
3190 outcome: kind,
3191 });
3192 }
3193 Err(failure) => {
3203 self.clean_backoff.failed(attempt.id, now);
3204 report.clean_failures = report.clean_failures.saturating_add(1);
3205 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3206 policy: attempt.policy_id,
3207 attempt: attempt.id,
3208 reason: failure_reason_kind(&failure.reason),
3209 });
3210 }
3211 }
3212 }
3213 }
3214
3215 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
3223 let mut report = ScaleDownReport::default();
3224 let attempts = match self.launcher.attempts().await {
3225 Ok(attempts) => attempts,
3226 Err(failure) => {
3227 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3232 reason: failure_reason_kind(&failure.reason),
3233 });
3234 report.attempts_unreadable = true;
3235 return report;
3236 }
3237 };
3238 for attempt in attempts {
3239 if attempt.policy_id != policy.id {
3240 continue;
3241 }
3242 match attempt.state() {
3243 AttemptState::Busy => {
3244 report.refused_busy = report.refused_busy.saturating_add(1);
3245 self.events.emit(LifecycleEvent::ScaleDownRefused {
3246 policy: policy.id,
3247 attempt: attempt.id,
3248 });
3249 }
3250 state if state.is_concluded() => {
3251 let kind = attempt
3252 .outcome()
3253 .map_or(OutcomeKind::Failed, OutcomeKind::of);
3254 match self.launcher.clean(attempt.id).await {
3255 Ok(()) => {
3256 report.removed = report.removed.saturating_add(1);
3257 self.events.emit(LifecycleEvent::AttemptCleaned {
3258 policy: policy.id,
3259 attempt: attempt.id,
3260 outcome: kind,
3261 });
3262 }
3263 Err(failure) => {
3264 report.clean_failures = report.clean_failures.saturating_add(1);
3265 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3266 policy: policy.id,
3267 attempt: attempt.id,
3268 reason: failure_reason_kind(&failure.reason),
3269 });
3270 }
3271 }
3272 }
3273 AttemptState::Cleaned => {}
3274 _ => report.retained = report.retained.saturating_add(1),
3277 }
3278 }
3279 report
3280 }
3281
3282 fn emit_allocation(&self, allocation: &Allocation) {
3283 self.events.emit(LifecycleEvent::Allocated {
3284 policy: allocation.policy_id,
3285 demand: allocation.demand,
3286 desired: allocation.desired,
3287 active_owned: allocation.active_owned,
3288 headroom: allocation.headroom_before,
3289 to_start: allocation.to_start,
3290 limiting: allocation.limiting_factor,
3291 });
3292 }
3293}
3294
3295fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
3319 let Some(labels) = policy.routing_labels() else {
3320 debug_assert!(
3321 false,
3322 "a monitor-only policy is skipped before the demand poll (D19)"
3323 );
3324 return DemandTally::default();
3325 };
3326
3327 match &policy.target {
3328 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
3329 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
3330 }
3331}
3332
3333fn route_demand(
3337 all_policies: &[ScalePolicy],
3338 profiles: &[&ScalePolicy],
3339 reading: &QueuedDemand,
3340) -> (
3341 BTreeMap<PolicyId, DemandTally>,
3342 BTreeMap<RoutingRefusal, u32>,
3343) {
3344 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
3345 .iter()
3346 .map(|policy| (policy.id, demand_for(policy, reading)))
3347 .collect();
3348 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
3349 let Some(first) = profiles.first() else {
3350 return (tallies, refusals);
3351 };
3352 let owners: Vec<_> = all_policies
3353 .iter()
3354 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3355 .filter(|policy| policy.owns_runners())
3356 .collect();
3357 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3358 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3359 ScaleTarget::Organization(_) => reading.jobs().collect(),
3360 };
3361 for job in jobs {
3362 let Ok(_) = job.required_labels() else {
3363 continue;
3365 };
3366 let matched: Vec<PolicyId> = owners
3367 .iter()
3368 .filter(|policy| {
3369 policy
3370 .routing_labels()
3371 .is_some_and(|labels| labels.matches(job).is_match())
3372 })
3373 .map(|policy| policy.id)
3374 .collect();
3375 if matched.len() > 1 {
3376 *refusals
3377 .entry(RoutingRefusal::OverlappingProfiles)
3378 .or_default() += 1;
3379 for id in matched {
3380 if let Some(tally) = tallies.get_mut(&id) {
3381 tally.matched -= 1;
3382 tally.not_matched += 1;
3383 }
3384 }
3385 }
3386 }
3387 (tallies, refusals)
3388}
3389
3390const fn severity(state: &RefreshState) -> u8 {
3397 match state {
3398 RefreshState::Offline => 5,
3399 RefreshState::RateLimited(_) => 4,
3400 RefreshState::LockedOut { .. } => 3,
3401 RefreshState::Unauthorized => 2,
3402 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3403 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3404 }
3405}
3406
3407const fn unreadable_reason(state: &RefreshState) -> &'static str {
3418 match state {
3419 RefreshState::Ready(_) => "ready",
3420 RefreshState::Offline => "offline",
3421 RefreshState::RateLimited(_) => "rate_limited",
3422 RefreshState::LockedOut { .. } => "locked_out",
3423 RefreshState::Unauthorized => "unauthorized",
3424 RefreshState::Forbidden { .. } => "forbidden",
3425 RefreshState::Failed { .. } => "failed",
3426 RefreshState::Cancelled => "cancelled",
3427 }
3428}
3429
3430#[cfg(test)]
3431mod tests {
3432 use super::*;
3433
3434 #[test]
3442 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
3443 let mut report = ReconcileReport::default();
3444 assert!(
3445 !report.reached_github(),
3446 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
3447 monitor-only -- reached nobody. This is the case the old guard let through, and \
3448 the only one it ever let through."
3449 );
3450
3451 report.unreadable.push(PolicyId::from_u128(1));
3452 assert!(
3453 !report.reached_github(),
3454 "every target this pass tried was unreadable, so there is no contact to record"
3455 );
3456
3457 report.targets_read = 1;
3458 assert!(
3459 report.reached_github(),
3460 "one target answering is contact, whatever else failed alongside it"
3461 );
3462
3463 let mut unowned = ReconcileReport::default();
3467 unowned.unreadable.push(PolicyId::from_u128(2));
3468 unowned.allocations.push(Allocation {
3469 policy_id: PolicyId::from_u128(2),
3470 demand: 0,
3471 desired: 0,
3472 active_owned: 0,
3473 headroom_before: 0,
3474 to_start: 0,
3475 limiting_factor: LimitingFactor::Demand,
3476 });
3477 assert!(
3478 !unowned.reached_github(),
3479 "an allocation is not evidence that GitHub answered"
3480 );
3481 }
3482
3483 #[test]
3493 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
3494 assert!(
3495 severity(&RefreshState::Unauthorized) > 0,
3496 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
3497 or a pass where every target was refused would report no failure at all"
3498 );
3499 }
3500
3501 use std::sync::atomic::AtomicUsize;
3502
3503 use std::num::NonZeroU16;
3504
3505 use runner_manager_domain::attempt::PersistedAttempt;
3506 use runner_manager_domain::model::{CachePolicy, HostId};
3507 use runner_manager_domain::policy::PolicyMode;
3508 use runner_manager_domain::workspace::WorkspaceKind;
3509 use runner_manager_github::rest::RateLimited;
3510 use runner_manager_testkit::clock::FakeClock;
3511 use runner_manager_testkit::fixtures;
3512 use runner_manager_testkit::github::FakeGithub;
3513
3514 fn host_with(capacity: u16) -> Host {
3519 fixtures::host().capacity(capacity).build()
3520 }
3521
3522 fn repo(raw: &str) -> OwnerRepo {
3523 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
3524 }
3525
3526 use runner_manager_domain::policy::RunsOn;
3528
3529 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
3530 fixtures::policy()
3531 .id(PolicyId::from_u128(id))
3532 .repository(target)
3533 .autoscale("home", max)
3534 .active()
3535 .build()
3536 }
3537
3538 const HOST_LABEL: &str = "rm-home-win-x64";
3545
3546 fn jobs(n: usize) -> Vec<RunsOn> {
3552 fixtures::queued_jobs(&[HOST_LABEL], n)
3553 }
3554
3555 #[derive(Debug, Default)]
3557 struct FakeLauncher {
3558 capacity: Mutex<Option<NonZeroU16>>,
3559 capacity_unreadable: AtomicBool,
3560 capacity_after_launch: Mutex<Option<NonZeroU16>>,
3561 attempts: Mutex<Vec<RunnerAttempt>>,
3562 next_id: AtomicU64,
3563 launches: AtomicUsize,
3564 cleaned: Mutex<Vec<AttemptId>>,
3565 yields_before_recording: usize,
3568 forgetful: bool,
3572 fail_next: Mutex<Option<FailureReason>>,
3573 attempts_fail: Mutex<bool>,
3576 clean_fails: bool,
3579 replacements: Mutex<Vec<ReplacementIntent>>,
3580 }
3581
3582 impl FakeLauncher {
3583 fn new() -> Self {
3584 Self::default()
3585 }
3586
3587 fn with_yields(mut self, yields: usize) -> Self {
3588 self.yields_before_recording = yields;
3589 self
3590 }
3591
3592 fn forgetful() -> Self {
3593 Self {
3594 forgetful: true,
3595 ..Self::default()
3596 }
3597 }
3598
3599 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
3600 *self.attempts.lock().unwrap() = attempts;
3601 self
3602 }
3603
3604 fn launches(&self) -> usize {
3605 self.launches.load(Ordering::SeqCst)
3606 }
3607
3608 fn snapshot(&self) -> Vec<RunnerAttempt> {
3609 self.attempts.lock().unwrap().clone()
3610 }
3611
3612 fn live_count(&self) -> usize {
3613 self.snapshot()
3614 .iter()
3615 .filter(|a| a.counts_against_capacity())
3616 .count()
3617 }
3618
3619 fn fail_next(&self, reason: FailureReason) {
3620 *self.fail_next.lock().unwrap() = Some(reason);
3621 }
3622
3623 fn fail_attempts(&self, failing: bool) {
3624 *self.attempts_fail.lock().unwrap() = failing;
3625 }
3626
3627 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
3628 Self {
3629 clean_fails: true,
3630 ..Self::default()
3631 }
3632 .seeded(attempts)
3633 }
3634
3635 fn cleaned(&self) -> Vec<AttemptId> {
3636 self.cleaned.lock().unwrap().clone()
3637 }
3638
3639 fn replacing(self, intent: ReplacementIntent) -> Self {
3640 self.replacements.lock().unwrap().push(intent);
3641 self
3642 }
3643 }
3644
3645 #[async_trait::async_trait]
3646 impl RunnerLauncher for FakeLauncher {
3647 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
3648 if self.capacity_unreadable.load(Ordering::SeqCst) {
3649 return Err(HostCapacityError::JournalUnreadable);
3650 }
3651 Ok(self.capacity.lock().unwrap().unwrap_or(host.host_capacity))
3652 }
3653
3654 async fn supervise(
3655 &self,
3656 policy: &ScalePolicy,
3657 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3658 let mut replacements = self.replacements.lock().unwrap();
3659 let selected: Vec<_> = replacements
3660 .extract_if(.., |intent| intent.policy == policy.id)
3661 .collect();
3662 if !selected.is_empty() {
3663 let retired: BTreeSet<_> = selected
3664 .iter()
3665 .map(|intent| intent.previous_attempt)
3666 .collect();
3667 self.attempts
3668 .lock()
3669 .unwrap()
3670 .retain(|attempt| !retired.contains(&attempt.id));
3671 }
3672 Ok(selected)
3673 }
3674
3675 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3676 if *self.attempts_fail.lock().unwrap() {
3677 return Err(LaunchFailure::new(FailureReason::Other(
3678 "the journal could not be read".into(),
3679 )));
3680 }
3681 Ok(self.snapshot())
3682 }
3683
3684 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3685 if let Some(reason) = self.fail_next.lock().unwrap().take() {
3686 return Err(LaunchFailure::new(reason));
3687 }
3688 for _ in 0..self.yields_before_recording {
3690 tokio::task::yield_now().await;
3691 }
3692 let id =
3693 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
3694 let created = RunnerAttempt::allocate(
3695 id,
3696 request.policy.id,
3697 "runtime/p/a",
3698 request.host.created_at,
3699 );
3700 self.launches.fetch_add(1, Ordering::SeqCst);
3701 if let Some(capacity) = self.capacity_after_launch.lock().unwrap().take() {
3702 *self.capacity.lock().unwrap() = Some(capacity);
3703 }
3704 if !self.forgetful {
3705 self.attempts.lock().unwrap().push(created.clone());
3706 }
3707 Ok(created)
3708 }
3709
3710 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
3711 if self.clean_fails {
3712 return Err(LaunchFailure::new(FailureReason::Other(
3713 "the runtime directory is locked".into(),
3714 )));
3715 }
3716 self.cleaned.lock().unwrap().push(attempt);
3717 let mut attempts = self.attempts.lock().unwrap();
3718 attempts.retain(|a| a.id != attempt);
3719 Ok(())
3720 }
3721 }
3722
3723 #[derive(Debug, Default)]
3725 struct FakeDemand {
3726 outcome: Mutex<Option<PollOutcome>>,
3727 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
3729 scopes: Mutex<Vec<ActivityScope>>,
3730 }
3731
3732 impl FakeDemand {
3733 fn ready(count: u32, repository: &OwnerRepo) -> Self {
3734 let fake = Self::default();
3735 fake.set(PollOutcome::Ready(QueuedDemand::of(
3736 repository.clone(),
3737 jobs(count as usize),
3738 )));
3739 fake
3740 }
3741
3742 fn failing(state: RefreshState) -> Self {
3743 let fake = Self::default();
3744 fake.set(PollOutcome::Failed(state));
3745 fake
3746 }
3747
3748 fn set(&self, outcome: PollOutcome) {
3749 *self.outcome.lock().unwrap() = Some(outcome);
3750 }
3751
3752 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
3754 self.per_target
3755 .lock()
3756 .unwrap()
3757 .insert(target.clone(), outcome);
3758 }
3759
3760 fn polls(&self) -> Vec<ActivityScope> {
3761 self.scopes.lock().unwrap().clone()
3762 }
3763 }
3764
3765 #[async_trait::async_trait]
3766 impl DemandSource for FakeDemand {
3767 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
3768 self.scopes.lock().unwrap().push(scope.clone());
3769 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
3770 return outcome.clone();
3771 }
3772 self.outcome
3773 .lock()
3774 .unwrap()
3775 .clone()
3776 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
3777 }
3778 }
3779
3780 #[derive(Debug, Default)]
3781 struct FakeDirectory {
3782 repositories: Vec<OwnerRepo>,
3783 calls: AtomicUsize,
3784 }
3785
3786 impl FakeDirectory {
3787 fn of(repositories: Vec<OwnerRepo>) -> Self {
3788 Self {
3789 repositories,
3790 calls: AtomicUsize::new(0),
3791 }
3792 }
3793
3794 fn calls(&self) -> usize {
3795 self.calls.load(Ordering::SeqCst)
3796 }
3797 }
3798
3799 #[async_trait::async_trait]
3800 impl RepositoryDirectory for FakeDirectory {
3801 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
3802 self.calls.fetch_add(1, Ordering::SeqCst);
3803 Ok(self.repositories.clone())
3804 }
3805 }
3806
3807 #[derive(Debug)]
3813 struct CountingLock {
3814 inner: InProcessAllocationLock,
3815 concurrent: Arc<AtomicUsize>,
3816 peak: Arc<AtomicUsize>,
3817 acquisitions: Arc<AtomicUsize>,
3818 }
3819
3820 impl CountingLock {
3821 fn new() -> Self {
3822 Self {
3823 inner: InProcessAllocationLock::new(),
3824 concurrent: Arc::new(AtomicUsize::new(0)),
3825 peak: Arc::new(AtomicUsize::new(0)),
3826 acquisitions: Arc::new(AtomicUsize::new(0)),
3827 }
3828 }
3829
3830 fn peak(&self) -> usize {
3831 self.peak.load(Ordering::SeqCst)
3832 }
3833
3834 fn acquisitions(&self) -> usize {
3835 self.acquisitions.load(Ordering::SeqCst)
3836 }
3837 }
3838
3839 #[derive(Debug)]
3840 struct CountingGuard {
3841 _inner: AllocationGuard,
3842 concurrent: Arc<AtomicUsize>,
3843 }
3844
3845 impl Drop for CountingGuard {
3846 fn drop(&mut self) {
3847 self.concurrent.fetch_sub(1, Ordering::SeqCst);
3848 }
3849 }
3850
3851 #[async_trait::async_trait]
3852 impl AllocationLock for CountingLock {
3853 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3854 let inner = self.inner.acquire().await?;
3855 self.acquisitions.fetch_add(1, Ordering::SeqCst);
3856 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
3857 self.peak.fetch_max(now, Ordering::SeqCst);
3858 Ok(AllocationGuard::new(CountingGuard {
3859 _inner: inner,
3860 concurrent: Arc::clone(&self.concurrent),
3861 }))
3862 }
3863 }
3864
3865 #[derive(Debug, Default)]
3868 struct NoLock;
3869
3870 #[async_trait::async_trait]
3871 impl AllocationLock for NoLock {
3872 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3873 Ok(AllocationGuard::new(()))
3874 }
3875 }
3876
3877 #[tokio::test]
3878 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
3879 use runner_manager_platform::wsl::fence::DrainRequest;
3880
3881 let (_local, shared, paths) = guest_paths();
3882 DrainRequest::new(4, chrono::Utc::now())
3883 .write(shared.path())
3884 .unwrap();
3885 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3886
3887 assert!(lock.acquire().await.is_err());
3888 }
3889
3890 #[tokio::test]
3891 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
3892 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3893
3894 let (_local, shared, paths) = guest_paths();
3895 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3896 let guard = lock.acquire().await.unwrap();
3897 assert!(shared.path().join(FENCE_DIRECTORY).exists());
3898 drop(guard);
3899 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3900 }
3901
3902 #[derive(Clone, Default)]
3904 struct Logs(Arc<Mutex<Vec<u8>>>);
3905
3906 impl std::io::Write for Logs {
3907 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3908 self.0.lock().unwrap().extend_from_slice(buf);
3909 Ok(buf.len())
3910 }
3911 fn flush(&mut self) -> std::io::Result<()> {
3912 Ok(())
3913 }
3914 }
3915
3916 #[tokio::test]
3919 async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
3920 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3921
3922 let (_local, shared, paths) = guest_paths();
3923 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3924 let guard = lock.acquire().await.unwrap();
3925 let fence = shared.path().join(FENCE_DIRECTORY);
3928 std::fs::remove_dir_all(&fence).unwrap();
3929 std::fs::write(&fence, b"not a directory").unwrap();
3930
3931 let logs = Logs::default();
3932 let writer = logs.clone();
3933 let subscriber = tracing_subscriber::fmt()
3934 .with_writer(move || writer.clone())
3935 .with_ansi(false)
3936 .finish();
3937 tracing::subscriber::with_default(subscriber, || drop(guard));
3938
3939 let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
3940 assert!(text.contains("WARN"), "{text}");
3941 assert!(text.contains("could not be released"), "{text}");
3942 assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
3943 }
3944
3945 fn guest_paths() -> (
3946 tempfile::TempDir,
3947 tempfile::TempDir,
3948 Arc<runner_manager_platform::paths::AppPaths>,
3949 ) {
3950 use runner_manager_platform::paths::AppPaths;
3951 use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
3952
3953 let local = tempfile::tempdir().unwrap();
3954 let shared = tempfile::tempdir().unwrap();
3955 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3956 paths.create_all().unwrap();
3957 GuestRecoveryConfig::new(shared.path().to_path_buf())
3958 .write(&paths)
3959 .unwrap();
3960 (local, shared, paths)
3961 }
3962
3963 #[tokio::test]
3966 async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
3967 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
3968
3969 let (_local, shared, paths) = guest_paths();
3970 let fence = shared.path().join(FENCE_DIRECTORY);
3971 std::fs::create_dir_all(&fence).unwrap();
3972 let pid = std::process::id();
3973 std::fs::write(
3974 fence.join(OWNER_FILE),
3975 format!(
3976 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"}}}}"#
3977 ),
3978 )
3979 .unwrap();
3980 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3981
3982 let guard = lock.acquire().await.expect("the stale claim was reclaimed");
3983 drop(guard);
3984 assert!(!fence.exists());
3985 }
3986
3987 #[tokio::test]
3988 async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
3989 use runner_manager_platform::launch_health::recorded_launches_blocked;
3990 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
3991
3992 let (_local, shared, paths) = guest_paths();
3993 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
3994 .with_blocked_after(3, Duration::ZERO);
3995 let windows =
3997 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
3998 .unwrap()
3999 .unwrap();
4000
4001 for _ in 0..2 {
4002 assert!(lock.acquire().await.is_err());
4003 }
4004 assert_eq!(
4005 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4006 None,
4007 "not yet"
4008 );
4009 assert!(lock.acquire().await.is_err());
4010 let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
4011 .unwrap()
4012 .expect("three refusals in a row are recorded");
4013 assert!(
4014 blocked.reason.contains("Windows WSL recovery"),
4015 "{blocked:?}"
4016 );
4017
4018 windows.release().unwrap();
4019 let guard = lock.acquire().await.unwrap();
4020 assert_eq!(
4021 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4022 None
4023 );
4024 drop(guard);
4025 }
4026
4027 #[tokio::test]
4028 async fn refusals_while_this_process_is_launching_are_its_own_contention() {
4029 use runner_manager_platform::launch_health::recorded_launches_blocked;
4030
4031 let (_local, _shared, paths) = guest_paths();
4032 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4033 .with_blocked_after(1, Duration::ZERO);
4034 let launching = lock.acquire().await.unwrap();
4035 for _ in 0..3 {
4036 assert!(lock.acquire().await.is_err());
4037 }
4038 assert_eq!(
4039 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4040 None
4041 );
4042 drop(launching);
4043 }
4044
4045 #[tokio::test]
4048 async fn refusals_with_quiet_spells_between_them_never_add_up() {
4049 use runner_manager_platform::launch_health::recorded_launches_blocked;
4050 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4051
4052 let (_local, shared, paths) = guest_paths();
4053 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4054 .with_blocked_after(3, Duration::ZERO)
4055 .with_streak_gap(Duration::ZERO);
4056 let windows =
4057 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4058 .unwrap()
4059 .unwrap();
4060 for _ in 0..5 {
4061 assert!(lock.acquire().await.is_err());
4062 }
4063 assert_eq!(
4064 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4065 None
4066 );
4067 windows.release().unwrap();
4068 }
4069
4070 #[tokio::test]
4073 async fn a_claim_refused_by_a_drain_request_is_released() {
4074 use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
4075
4076 let (_local, shared, paths) = guest_paths();
4077 DrainRequest::new(4, chrono::Utc::now())
4078 .write(shared.path())
4079 .unwrap();
4080 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4081 assert!(lock.acquire().await.is_err());
4082 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4083 }
4084
4085 #[derive(Debug, Default)]
4087 struct HeldLock;
4088
4089 #[async_trait::async_trait]
4090 impl AllocationLock for HeldLock {
4091 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4092 Err(AllocationLockBusy)
4093 }
4094 }
4095
4096 struct Harness {
4098 launcher: Arc<FakeLauncher>,
4099 demand: Arc<FakeDemand>,
4100 events: Arc<EventLog>,
4101 clock: Arc<FakeClock>,
4102 reconciler: Reconciler,
4103 }
4104
4105 impl Harness {
4106 fn build(
4107 host: Host,
4108 launcher: Arc<FakeLauncher>,
4109 demand: Arc<FakeDemand>,
4110 lock: Arc<dyn AllocationLock>,
4111 ) -> Self {
4112 let events = Arc::new(EventLog::new());
4113 let clock = Arc::new(FakeClock::default());
4114 let reconciler = Reconciler::new(
4115 host,
4116 ReconcilerPorts {
4117 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4118 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4119 lock,
4120 directory: Arc::new(FakeDirectory::default()),
4121 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4122 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
4123 events: Arc::clone(&events) as Arc<dyn EventSink>,
4124 },
4125 );
4126 Self {
4127 launcher,
4128 demand,
4129 events,
4130 clock,
4131 reconciler,
4132 }
4133 }
4134
4135 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
4136 let launcher = Arc::new(FakeLauncher::new());
4137 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
4138 Self::build(
4139 host_with(capacity),
4140 launcher,
4141 demand,
4142 Arc::new(InProcessAllocationLock::new()),
4143 )
4144 }
4145 }
4146
4147 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
4148 let outcome = state.is_terminal().then(|| match state {
4149 AttemptState::Failed => {
4150 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
4151 }
4152 AttemptState::Orphaned => AttemptOutcome::Orphaned,
4153 _ => AttemptOutcome::CompletedJob,
4154 });
4155 RunnerAttempt::from_persisted(PersistedAttempt {
4156 id: AttemptId::from_u128(id),
4157 policy_id: PolicyId::from_u128(policy),
4158 github_runner_id: None,
4159 state,
4160 outcome,
4161 process_id: None,
4162 execution: runner_manager_domain::execution::AttemptExecution::Native {
4163 process_id: None,
4164 },
4165 runtime_path: "runtime/p/a".into(),
4166 workspace_kind: WorkspaceKind::Ephemeral,
4167 workspace_slot: None,
4168 created_at: fixtures::created_at(),
4169 terminal_at: state.is_terminal().then(fixtures::created_at),
4170 last_state_change_at: fixtures::created_at(),
4171 })
4172 .expect("a state/outcome pair the domain accepts")
4173 }
4174
4175 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
4177 RunnerAttempt::from_persisted(PersistedAttempt {
4178 id: AttemptId::from_u128(id),
4179 policy_id: PolicyId::from_u128(policy),
4180 github_runner_id: None,
4181 state: outcome.terminal_state(),
4182 outcome: Some(outcome),
4183 process_id: None,
4184 execution: runner_manager_domain::execution::AttemptExecution::Native {
4185 process_id: None,
4186 },
4187 runtime_path: "runtime/p/a".into(),
4188 workspace_kind: WorkspaceKind::Ephemeral,
4189 workspace_slot: None,
4190 created_at: fixtures::created_at(),
4191 terminal_at: Some(fixtures::created_at()),
4192 last_state_change_at: fixtures::created_at(),
4193 })
4194 .expect("a state/outcome pair the domain accepts")
4195 }
4196
4197 #[tokio::test]
4226 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
4227 let mut harness = Harness::simple(4, 1, "acme/app");
4228 let policy = policy(1, "acme/app", 4);
4229
4230 for poll in 1..=3 {
4231 let report = harness
4232 .reconciler
4233 .reconcile(std::slice::from_ref(&policy))
4234 .await;
4235 assert_eq!(
4236 harness.launcher.launches(),
4237 1,
4238 "poll {poll} started another runner for a job already being served; the \
4239 `- active_owned_runners` term reached `HostAllocator` as a set this host \
4240 does not hold"
4241 );
4242 assert_eq!(report.allocations.len(), 1);
4243 let allocation = &report.allocations[0];
4244 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
4245 if poll == 1 {
4246 assert_eq!(allocation.to_start, 1);
4247 assert_eq!(report.started, 1);
4248 } else {
4249 assert_eq!(allocation.active_owned, 1, "poll {poll}");
4250 assert_eq!(allocation.to_start, 0, "poll {poll}");
4251 assert_eq!(report.started, 0, "poll {poll}");
4252 }
4253 }
4254 assert_eq!(harness.launcher.live_count(), 1);
4255 }
4256
4257 #[tokio::test]
4258 async fn draining_parallel_workflow_starts_runners_for_the_remaining_queue() {
4259 let mut harness = Harness::simple(10, 8, "acme/app");
4260 let policy = policy(1, "acme/app", 10);
4261 let first = harness
4262 .reconciler
4263 .reconcile(std::slice::from_ref(&policy))
4264 .await;
4265 assert_eq!(first.started, 8);
4266
4267 let attempts = harness.launcher.snapshot();
4269 *harness.launcher.attempts.lock().unwrap() = attempts
4270 .iter()
4271 .take(3)
4272 .map(|attempt| attempt_in(AttemptState::Busy, attempt.id.as_uuid().as_u128(), 1))
4273 .collect();
4274 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4275 repo("acme/app"),
4276 jobs(2),
4277 )));
4278
4279 for poll in 0..3 {
4280 let report = harness
4281 .reconciler
4282 .reconcile(std::slice::from_ref(&policy))
4283 .await;
4284 assert_eq!(report.started, if poll == 0 { 2 } else { 0 });
4285 assert_eq!(harness.launcher.live_count(), 5);
4286 }
4287 }
4288
4289 #[tokio::test]
4300 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4301 let launcher = Arc::new(FakeLauncher::forgetful());
4302 let mut harness = Harness::build(
4303 host_with(64),
4304 Arc::clone(&launcher),
4305 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4306 Arc::new(InProcessAllocationLock::new()),
4307 );
4308
4309 let report = harness
4310 .reconciler
4311 .reconcile(&[policy(1, "acme/app", 8)])
4312 .await;
4313
4314 assert_eq!(
4315 report.started, 3,
4316 "the pass is bounded by the grant it was given, not by the attempt set catching \
4317 up with it"
4318 );
4319 assert_eq!(launcher.launches(), 3);
4320 assert!(
4321 launcher.snapshot().is_empty(),
4322 "the launcher never recorded anything, which is the whole point of the fixture"
4323 );
4324 }
4325
4326 #[tokio::test]
4338 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4339 let launcher = Arc::new(FakeLauncher::forgetful());
4340 let demand = Arc::new(FakeDemand::default());
4341 demand.set_for(
4342 &ScaleTarget::repository("acme/left").unwrap(),
4343 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4344 );
4345 demand.set_for(
4346 &ScaleTarget::repository("acme/right").unwrap(),
4347 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4348 );
4349 let mut harness = Harness::build(
4350 host_with(3),
4351 Arc::clone(&launcher),
4352 demand,
4353 Arc::new(InProcessAllocationLock::new()),
4354 );
4355
4356 let report = harness
4357 .reconciler
4358 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4359 .await;
4360
4361 assert_eq!(
4362 report.started, 3,
4363 "host_capacity is 3 and two policies each allowed 3 started {} runners \
4364 between them; the second policy's grant was computed from a set that did \
4365 not yet contain the first policy's launches",
4366 report.started
4367 );
4368 assert_eq!(launcher.launches(), 3);
4369 }
4370
4371 #[tokio::test]
4382 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
4383 let launcher = Arc::new(FakeLauncher::new());
4384 let mut harness = Harness::build(
4385 host_with(8),
4386 Arc::clone(&launcher),
4387 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
4388 Arc::new(InProcessAllocationLock::new()),
4389 );
4390 let policy = policy(1, "acme/app", 8);
4391
4392 launcher.fail_attempts(true);
4393 let unreadable = harness
4394 .reconciler
4395 .reconcile(std::slice::from_ref(&policy))
4396 .await;
4397
4398 assert_eq!(
4399 unreadable.started, 0,
4400 "nothing may be decided from a set that was not read"
4401 );
4402 assert_eq!(launcher.launches(), 0);
4403 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
4404 assert!(
4405 unreadable.allocations.is_empty(),
4406 "no allocation is reported either: there was no set to compute one from, and \
4407 an allocation of zero would claim a decision nobody made"
4408 );
4409 assert!(harness.events.count_of("attempts_unreadable") > 0);
4410
4411 launcher.fail_attempts(false);
4413 let readable = harness
4414 .reconciler
4415 .reconcile(std::slice::from_ref(&policy))
4416 .await;
4417 assert_eq!(
4418 readable.started, 4,
4419 "the difference between the two passes is only whether the set could be read"
4420 );
4421 assert_eq!(readable.attempts_unreadable, 0);
4422 }
4423
4424 #[tokio::test]
4434 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
4435 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4436 1,
4437 1,
4438 AttemptOutcome::ExitedIdleWithoutWork,
4439 )]));
4440 let mut harness = Harness::build(
4441 host_with(4),
4442 Arc::clone(&launcher),
4443 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4444 Arc::new(InProcessAllocationLock::new()),
4445 );
4446
4447 let report = harness
4448 .reconciler
4449 .reconcile(&[policy(1, "acme/app", 4)])
4450 .await;
4451
4452 assert_eq!(report.cleaned, 0);
4453 assert_eq!(report.clean_failures, 1);
4454 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
4455 assert_eq!(
4456 harness.events.count_of("attempt_cleaned"),
4457 0,
4458 "and it is not reported as cleaned"
4459 );
4460 assert_eq!(
4461 launcher.snapshot().len(),
4462 1,
4463 "the attempt is still there, so the retry is real -- what changed is that it \
4464 is no longer silent"
4465 );
4466
4467 let reasons: Vec<&'static str> = harness
4470 .events
4471 .events()
4472 .into_iter()
4473 .filter_map(|event| match event {
4474 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
4475 _ => None,
4476 })
4477 .collect();
4478 assert_eq!(reasons, vec!["other"]);
4479 }
4480
4481 #[tokio::test]
4484 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
4485 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4486 1,
4487 1,
4488 AttemptOutcome::ExitedIdleWithoutWork,
4489 )]));
4490 let mut harness = Harness::build(
4491 host_with(4),
4492 Arc::clone(&launcher),
4493 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4494 Arc::new(InProcessAllocationLock::new()),
4495 );
4496 let policies = [policy(1, "acme/app", 4)];
4497 let start = harness.clock.now();
4498 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
4499 let mut failures_at = async |offset: Duration| {
4500 harness.clock.set(at(offset));
4501 harness.reconciler.reconcile(&policies).await.clean_failures
4502 };
4503
4504 assert_eq!(failures_at(Duration::ZERO).await, 1);
4505 assert_eq!(
4507 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
4508 0,
4509 "a failed cleanup was retried on the very next pass"
4510 );
4511 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
4512 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
4514 assert_eq!(
4515 failures_at(second - Duration::from_secs(1)).await,
4516 0,
4517 "the delay did not double after a second failure"
4518 );
4519 assert_eq!(failures_at(second).await, 1);
4520 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
4521
4522 let mut backoff = CleanBackoff::default();
4524 let id = AttemptId::from_u128(9);
4525 for _ in 0..40 {
4526 backoff.failed(id, start);
4527 }
4528 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
4529 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
4530 backoff.succeeded(id);
4531 assert!(
4532 backoff.is_due(id, start),
4533 "a cleaned attempt kept its backoff"
4534 );
4535 }
4536
4537 #[tokio::test]
4550 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
4551 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4552 attempt_in(AttemptState::Busy, 1, 1),
4553 concluded(2, 1, AttemptOutcome::CompletedJob),
4554 ]));
4555 let harness = Harness::build(
4556 host_with(4),
4557 Arc::clone(&launcher),
4558 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4559 Arc::new(InProcessAllocationLock::new()),
4560 );
4561 let policy = policy(1, "acme/app", 4);
4562
4563 launcher.fail_attempts(true);
4564 let blind = harness.reconciler.scale_down(&policy).await;
4565
4566 assert!(!blind.is_conclusive(), "the machine was never read");
4567 assert_ne!(
4568 blind,
4569 ScaleDownReport::default(),
4570 "a scale-down that could not see the host must not be equal to one that saw \
4571 an idle host; that equality is the whole finding"
4572 );
4573 assert_eq!(blind.removed, 0);
4574 assert_eq!(
4575 blind.refused_busy, 0,
4576 "and this zero means `unknown`, not `none`"
4577 );
4578 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
4579
4580 launcher.fail_attempts(false);
4582 let seeing = harness.reconciler.scale_down(&policy).await;
4583
4584 assert!(seeing.is_conclusive());
4585 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
4586 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
4587 assert_ne!(
4588 seeing, blind,
4589 "the difference between the two is only whether the set could be read"
4590 );
4591 }
4592
4593 #[tokio::test]
4601 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
4602 let mut warm = ScalePolicy::new(
4603 PolicyId::from_u128(1),
4604 ScaleTarget::repository("acme/app").unwrap(),
4605 1,
4606 fixtures::HOST_ID,
4607 PolicyMode::autoscale(
4608 fixtures::routing_labels("home"),
4609 2,
4610 NonZeroU16::new(5).expect("non-zero"),
4611 )
4612 .expect("min <= max"),
4613 CachePolicy::default(),
4614 );
4615 warm.activate().expect("pending -> active");
4616
4617 let mut harness = Harness::simple(8, 0, "acme/app");
4618 let report = harness.reconciler.reconcile(&[warm]).await;
4619
4620 assert_eq!(
4621 report.allocations[0].demand, 0,
4622 "GitHub reported no queued runs"
4623 );
4624 assert_eq!(
4625 report.allocations[0].desired, 2,
4626 "min_capacity raised the target above demand"
4627 );
4628 assert_eq!(
4629 report.allocations[0].limiting_factor,
4630 LimitingFactor::MinCapacity
4631 );
4632 assert_eq!(report.started, 2, "and two runners were actually started");
4633 assert_eq!(harness.launcher.live_count(), 2);
4634 }
4635
4636 #[tokio::test]
4641 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
4642 let mut harness = Harness::simple(100, 10, "acme/app");
4643 let report = harness
4644 .reconciler
4645 .reconcile(&[policy(1, "acme/app", 3)])
4646 .await;
4647
4648 assert_eq!(report.allocations[0].demand, 10);
4649 assert_eq!(
4650 report.allocations[0].desired, 3,
4651 "max_capacity beats demand"
4652 );
4653 assert_eq!(report.started, 3);
4654 assert_eq!(
4655 report.allocations[0].limiting_factor,
4656 LimitingFactor::MaxCapacity
4657 );
4658 }
4659
4660 #[tokio::test]
4661 async fn demand_below_min_capacity_starts_nothing_in_v1() {
4662 let mut harness = Harness::simple(8, 0, "acme/app");
4665 let report = harness
4666 .reconciler
4667 .reconcile(&[policy(1, "acme/app", 4)])
4668 .await;
4669
4670 assert_eq!(report.allocations[0].desired, 0);
4671 assert_eq!(report.started, 0);
4672 assert!(report.starts_nothing());
4673 }
4674
4675 #[tokio::test]
4676 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
4677 let policy = policy(1, "octo/repo", 1);
4678 let previous = attempt_in(AttemptState::Starting, 41, 1);
4679 let intent = ReplacementIntent {
4680 policy: policy.id,
4681 previous_attempt: previous.id,
4682 operation: "exit_before_acceptance_replacement",
4683 };
4684 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
4685 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
4686 let mut harness = Harness::build(
4687 host_with(1),
4688 Arc::clone(&launcher),
4689 demand,
4690 Arc::new(InProcessAllocationLock::new()),
4691 );
4692
4693 let report = harness.reconciler.reconcile(&[policy]).await;
4694
4695 assert_eq!(report.replacement_intents, 1);
4696 assert_eq!(report.started, 1);
4697 assert_eq!(launcher.launches(), 1);
4698 assert_eq!(launcher.live_count(), 1);
4699 }
4700
4701 #[tokio::test]
4702 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
4703 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4704 attempt_in(AttemptState::Busy, 1, 1),
4705 attempt_in(AttemptState::Busy, 2, 1),
4706 ]));
4707 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
4708 let mut harness = Harness::build(
4709 host_with(2),
4710 launcher,
4711 demand,
4712 Arc::new(InProcessAllocationLock::new()),
4713 );
4714
4715 let report = harness
4716 .reconciler
4717 .reconcile(&[policy(1, "acme/app", 2)])
4718 .await;
4719 assert_eq!(report.started, 0);
4720 assert_eq!(report.allocations[0].headroom_before, 0);
4721 assert_eq!(harness.launcher.launches(), 0);
4722 }
4723
4724 #[tokio::test]
4725 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
4726 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4729 attempt_in(AttemptState::Busy, 1, 99),
4730 attempt_in(AttemptState::Busy, 2, 99),
4731 attempt_in(AttemptState::Idle, 3, 99),
4732 attempt_in(AttemptState::Starting, 4, 99),
4733 ]));
4734 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
4735 let mut harness = Harness::build(
4736 host_with(6),
4737 launcher,
4738 demand,
4739 Arc::new(InProcessAllocationLock::new()),
4740 );
4741
4742 let report = harness
4743 .reconciler
4744 .reconcile(&[policy(1, "acme/app", 5)])
4745 .await;
4746 assert_eq!(
4747 report.allocations[0].desired, 5,
4748 "its own ceiling allows five"
4749 );
4750 assert_eq!(report.started, 2, "the host has two slots free");
4751 assert_eq!(
4752 report.allocations[0].limiting_factor,
4753 LimitingFactor::HostCapacity
4754 );
4755 assert_eq!(harness.launcher.live_count(), 6);
4756 }
4757
4758 #[tokio::test]
4759 async fn the_idle_host_assertion_holds() {
4760 let mut harness = Harness::simple(8, 0, "acme/app");
4763 let report = harness
4764 .reconciler
4765 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
4766 .await;
4767
4768 assert_eq!(report.started, 0);
4769 assert_eq!(harness.launcher.launches(), 0);
4770 assert!(harness.launcher.snapshot().is_empty());
4771 assert_eq!(
4772 harness
4773 .launcher
4774 .snapshot()
4775 .iter()
4776 .filter(|a| !a.is_terminal())
4777 .count(),
4778 0
4779 );
4780 }
4781
4782 #[tokio::test]
4787 async fn live_capacity_increase_is_used_without_rebuilding_the_reconciler() {
4788 let mut harness = Harness::simple(1, 5, "acme/app");
4789 let policies = [policy(1, "acme/app", 5)];
4790 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
4791
4792 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(4);
4793 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 3);
4794 assert_eq!(harness.launcher.live_count(), 4);
4795 }
4796
4797 #[tokio::test]
4798 async fn live_capacity_decrease_keeps_busy_runners_and_waits_for_headroom() {
4799 let mut harness = Harness::simple(5, 5, "acme/app");
4800 let busy: Vec<_> = (1..=3)
4801 .map(|id| attempt_in(AttemptState::Busy, id, 1))
4802 .collect();
4803 *harness.launcher.attempts.lock().unwrap() = busy.clone();
4804 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
4805 let policies = [policy(1, "acme/app", 5)];
4806
4807 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 0);
4808 assert_eq!(harness.launcher.snapshot(), busy);
4809 assert!(harness.launcher.cleaned().is_empty());
4810
4811 harness.launcher.attempts.lock().unwrap().truncate(1);
4813 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
4814 assert_eq!(harness.launcher.live_count(), 2);
4815 }
4816
4817 #[tokio::test]
4818 async fn live_capacity_is_rechecked_between_launches_in_the_same_pass() {
4819 let mut harness = Harness::simple(5, 5, "acme/app");
4820 *harness.launcher.capacity_after_launch.lock().unwrap() = NonZeroU16::new(1);
4821
4822 let report = harness
4823 .reconciler
4824 .reconcile(&[policy(1, "acme/app", 5)])
4825 .await;
4826 assert_eq!(report.started, 1);
4827 assert_eq!(harness.launcher.live_count(), 1);
4828 }
4829
4830 #[tokio::test]
4831 async fn live_capacity_unreadable_blocks_launches_until_the_journal_recovers() {
4832 let mut harness = Harness::simple(5, 5, "acme/app");
4833 harness
4834 .launcher
4835 .capacity_unreadable
4836 .store(true, Ordering::SeqCst);
4837 let policies = [policy(1, "acme/app", 5)];
4838 let report = harness.reconciler.reconcile(&policies).await;
4839 assert_eq!(report.started, 0);
4840 assert_eq!(report.host_capacity_unreadable, 1);
4841 assert_eq!(report.attempts_unreadable, 0);
4842 assert_eq!(harness.events.count_of("attempts_unreadable"), 0);
4843 assert!(
4844 harness
4845 .events
4846 .events()
4847 .contains(&LifecycleEvent::HostCapacityUnreadable {
4848 reason: HostCapacityError::JournalUnreadable,
4849 })
4850 );
4851
4852 harness
4853 .launcher
4854 .capacity_unreadable
4855 .store(false, Ordering::SeqCst);
4856 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
4857 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 2);
4858 }
4859
4860 #[tokio::test]
4861 async fn unreadable_local_state_does_not_treat_a_busy_host_as_idle() {
4862 for host_failure in [true, false] {
4863 let mut harness = Harness::simple(5, 0, "acme/app");
4864 harness
4865 .reconciler
4866 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
4867 *harness.launcher.attempts.lock().unwrap() = vec![attempt_in(AttemptState::Busy, 1, 1)];
4868 harness
4869 .launcher
4870 .capacity_unreadable
4871 .store(host_failure, Ordering::SeqCst);
4872 harness.launcher.fail_attempts(!host_failure);
4873 let report = harness
4874 .reconciler
4875 .reconcile(&[policy(1, "acme/app", 5)])
4876 .await;
4877 assert_eq!(report.host_capacity_unreadable, u16::from(host_failure));
4878 assert_eq!(report.next_poll.pace, PollPace::Nominal);
4879 assert_eq!(harness.launcher.live_count(), 1);
4880 }
4881 }
4882
4883 #[derive(Debug)]
4884 struct HostEditOnLock {
4885 inner: InProcessAllocationLock,
4886 launcher: Arc<FakeLauncher>,
4887 unreadable: bool,
4888 }
4889
4890 #[async_trait::async_trait]
4891 impl AllocationLock for HostEditOnLock {
4892 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4893 let guard = self.inner.acquire().await?;
4894 *self.launcher.capacity.lock().unwrap() = NonZeroU16::new(1);
4895 self.launcher
4896 .capacity_unreadable
4897 .store(self.unreadable, Ordering::SeqCst);
4898 Ok(guard)
4899 }
4900 }
4901
4902 #[tokio::test]
4903 async fn live_capacity_edits_and_read_failures_while_waiting_for_the_lock_block_launches() {
4904 for unreadable in [false, true] {
4905 let launcher =
4906 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
4907 let lock = Arc::new(HostEditOnLock {
4908 inner: InProcessAllocationLock::new(),
4909 launcher: Arc::clone(&launcher),
4910 unreadable,
4911 });
4912 let mut harness = Harness::build(
4913 host_with(5),
4914 Arc::clone(&launcher),
4915 Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
4916 lock,
4917 );
4918 let report = harness
4919 .reconciler
4920 .reconcile(&[policy(1, "acme/app", 5)])
4921 .await;
4922 assert_eq!(report.started, 0);
4923 assert_eq!(launcher.live_count(), 1);
4924 assert!(launcher.cleaned().is_empty());
4925 assert_eq!(report.host_capacity_unreadable, u16::from(unreadable));
4926 assert_eq!(report.attempts_unreadable, 0);
4927 }
4928 }
4929
4930 #[tokio::test(flavor = "current_thread")]
4953 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
4954 let lock = Arc::new(CountingLock::new());
4955 let (launches, live) =
4956 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>, None).await;
4957
4958 assert_eq!(
4959 launches, 3,
4960 "the sum across policies must never exceed host_capacity, and each policy is \
4961 individually within its own max_capacity of 3"
4962 );
4963 assert_eq!(live, 3, "and no duplicate runner survived the race");
4964 assert_eq!(
4965 lock.peak(),
4966 1,
4967 "the allocation lock had one holder at a time; without that the read of the \
4968 headroom and the creation of the runtime are not atomic"
4969 );
4970 assert!(
4971 (3..=5).contains(&lock.acquisitions()),
4972 "the lock is taken before *each* runtime, not once per pass: three runtimes \
4973 means at least three holds, and at most one further hold per policy to \
4974 discover the host filled up underneath it. It was taken {} times",
4975 lock.acquisitions()
4976 );
4977 }
4978
4979 #[tokio::test(flavor = "current_thread")]
4986 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
4987 let (launches, _) =
4988 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>, None).await;
4989
4990 assert!(
4991 launches > 3,
4992 "with no serialisation both policies must be able to spend the same headroom; \
4993 they started {launches} runners on a host of 3. If this is ever 3, the \
4994 contention window closed and `two_policies_reconciling_concurrently_never_\
4995 exceed_host_capacity` has stopped proving anything"
4996 );
4997 }
4998
4999 async fn two_policies_concurrently(
5003 lock: Arc<dyn AllocationLock>,
5004 live_capacity: Option<NonZeroU16>,
5005 ) -> (usize, usize) {
5006 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
5007 *launcher.capacity.lock().unwrap() = live_capacity;
5008 let host = host_with(3);
5009
5010 let mut left = Harness::build(
5011 host.clone(),
5012 Arc::clone(&launcher),
5013 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
5014 Arc::clone(&lock),
5015 )
5016 .reconciler;
5017 let mut right = Harness::build(
5018 host,
5019 Arc::clone(&launcher),
5020 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
5021 Arc::clone(&lock),
5022 )
5023 .reconciler;
5024
5025 let a = policy(1, "acme/left", 3);
5026 let b = policy(2, "acme/right", 3);
5027
5028 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
5029 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
5030 let (_, _) = (left.await.unwrap(), right.await.unwrap());
5031
5032 (launcher.launches(), launcher.live_count())
5033 }
5034
5035 #[tokio::test(flavor = "current_thread")]
5036 async fn live_capacity_is_shared_by_concurrent_targets_with_stale_startup_hosts() {
5037 let (launches, live) =
5038 two_policies_concurrently(Arc::new(InProcessAllocationLock::new()), NonZeroU16::new(2))
5039 .await;
5040 assert_eq!(launches, 2);
5041 assert_eq!(live, 2);
5042 }
5043
5044 #[tokio::test]
5056 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
5057 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
5058 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
5059 let launcher = Arc::new(FakeLauncher::new());
5060 let events = Arc::new(EventLog::new());
5061
5062 let mut reconciler = Reconciler::new(
5063 host_with(10),
5064 ReconcilerPorts {
5065 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
5066 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5067 lock: Arc::new(InProcessAllocationLock::new()),
5068 directory: Arc::new(FakeDirectory::default()),
5069 clock: Arc::new(FakeClock::default()),
5070 jitter: Arc::new(NoJitter),
5071 events: Arc::clone(&events) as Arc<dyn EventSink>,
5072 },
5073 );
5074
5075 let monitor = fixtures::policy()
5076 .id(PolicyId::from_u128(1))
5077 .repository("acme/app")
5078 .monitor_only()
5079 .active()
5080 .build();
5081
5082 let report = reconciler.reconcile(&[monitor]).await;
5083
5084 assert_eq!(report.started, 0);
5085 assert_eq!(launcher.launches(), 0);
5086 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
5087 assert_eq!(
5088 report.demand_requests, 0,
5089 "a monitor-only policy spends nothing from the shared hourly ceiling"
5090 );
5091 assert!(
5092 gateway.gateway().calls().is_empty(),
5093 "a monitor-only policy issued a demand request: {:?}",
5094 gateway.gateway().calls()
5095 );
5096 assert_eq!(events.count_of("monitor_only_skipped"), 1);
5097 assert_eq!(
5098 events.count_of("demand_observed"),
5099 0,
5100 "and it contributed no demand"
5101 );
5102 }
5103
5104 #[tokio::test]
5122 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
5123 let lock = Arc::new(CountingLock::new());
5124 let launcher = Arc::new(FakeLauncher::new());
5125 let events = Arc::new(EventLog::new());
5126 let mut reconciler = Reconciler::new(
5127 host_with(4),
5128 ReconcilerPorts {
5129 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5130 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5131 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
5132 directory: Arc::new(FakeDirectory::default()),
5133 clock: Arc::new(FakeClock::default()),
5134 jitter: Arc::new(NoJitter),
5135 events: Arc::clone(&events) as Arc<dyn EventSink>,
5136 },
5137 );
5138
5139 let watcher = fixtures::policy()
5140 .id(PolicyId::from_u128(2))
5141 .repository("acme/app")
5142 .monitor_only()
5143 .active()
5144 .build();
5145
5146 let report = reconciler
5147 .reconcile(&[policy(1, "acme/app", 4), watcher])
5148 .await;
5149
5150 assert_eq!(report.started, 2, "the autoscale policy is served normally");
5151 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
5152 assert_eq!(
5153 events.count_of("demand_observed"),
5154 1,
5155 "the demand observation belongs to the autoscale policy alone"
5156 );
5157 assert!(
5158 report
5159 .allocations
5160 .iter()
5161 .all(|a| a.policy_id == PolicyId::from_u128(1)),
5162 "a monitor-only policy was allocated for: {:?}",
5163 report.allocations
5164 );
5165 assert_eq!(
5166 lock.acquisitions(),
5167 2,
5168 "one hold per runtime created, and none on behalf of the monitor-only policy. \
5169 It was three before the budget was checked at the top of the loop rather than \
5170 after the re-read, which cost every policy a surplus hold to discover there \
5171 was nothing left to grant"
5172 );
5173 }
5174
5175 #[tokio::test]
5176 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
5177 let monitor = fixtures::monitor_only_policy();
5182 assert!(!monitor.owns_runners());
5183 assert_eq!(monitor.max_capacity(), None);
5184
5185 let host = host_with(10);
5186 let attempts: Vec<RunnerAttempt> = Vec::new();
5187 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5188 let allocation = allocator.allocate(&monitor, 10_000);
5189 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
5190 assert_eq!(allocation.to_start, 0);
5191 assert_eq!(
5192 allocator.headroom(),
5193 10,
5194 "and it consumes no headroom, so an autoscale policy on the same host is \
5195 unaffected"
5196 );
5197 }
5198
5199 #[tokio::test]
5207 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
5208 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5209 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
5210 concluded(
5211 2,
5212 1,
5213 AttemptOutcome::failed(FailureReason::JitRequestFailed),
5214 ),
5215 concluded(3, 1, AttemptOutcome::CompletedJob),
5216 ]));
5217 let mut harness = Harness::build(
5218 host_with(4),
5219 Arc::clone(&launcher),
5220 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5221 Arc::new(InProcessAllocationLock::new()),
5222 );
5223
5224 let report = harness
5225 .reconciler
5226 .reconcile(&[policy(1, "acme/app", 4)])
5227 .await;
5228
5229 assert_eq!(report.cleaned, 3);
5230 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
5231 assert_eq!(
5232 report.failures, 1,
5233 "only the failed attempt is a failure; the idle exit and the completed job are \
5234 not"
5235 );
5236 assert_eq!(launcher.cleaned().len(), 3);
5237 assert!(launcher.snapshot().is_empty());
5238
5239 let cleaned: Vec<OutcomeKind> = harness
5240 .events
5241 .events()
5242 .into_iter()
5243 .filter_map(|event| match event {
5244 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
5245 _ => None,
5246 })
5247 .collect();
5248 assert!(cleaned.contains(&OutcomeKind::IdleExit));
5249 assert!(
5250 !OutcomeKind::IdleExit.is_failure(),
5251 "an idle exit rendered as a failure sends an operator hunting a fault that does \
5252 not exist"
5253 );
5254 }
5255
5256 #[tokio::test]
5259 async fn scale_down_removes_nothing_from_a_busy_attempt() {
5260 let busy = attempt_in(AttemptState::Busy, 1, 1);
5261 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5262 busy.clone(),
5263 attempt_in(AttemptState::Starting, 2, 1),
5264 concluded(3, 1, AttemptOutcome::CompletedJob),
5265 ]));
5266 let harness = Harness::build(
5267 host_with(4),
5268 Arc::clone(&launcher),
5269 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5270 Arc::new(InProcessAllocationLock::new()),
5271 );
5272
5273 let report = harness
5274 .reconciler
5275 .scale_down(&policy(1, "acme/app", 4))
5276 .await;
5277
5278 assert_eq!(report.refused_busy, 1);
5279 assert_eq!(
5280 report.retained, 1,
5281 "the `starting` attempt is not ended either"
5282 );
5283 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
5284
5285 let after = launcher.snapshot();
5286 let still_busy = after
5287 .iter()
5288 .find(|a| a.id == AttemptId::from_u128(1))
5289 .expect("the busy attempt is still there");
5290 assert_eq!(
5291 still_busy.state(),
5292 AttemptState::Busy,
5293 "scale-down removed nothing from a runner that is executing a job, and left it \
5294 busy"
5295 );
5296 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
5297
5298 let mut busy = busy;
5302 assert!(matches!(
5303 busy.clean(fixtures::created_at()),
5304 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
5305 ));
5306 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
5307 }
5308
5309 #[test]
5314 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
5315 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
5316 assert_eq!(RefreshInterval::MIN_SECS, 30);
5317 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
5318 assert!(
5319 RefreshInterval::from_secs(29).is_err(),
5320 "the floor is a rate-budget constraint, and a caller must not be able to write \
5321 a shorter interval at all"
5322 );
5323
5324 let mut schedule = PollSchedule::new(RefreshInterval::default());
5325 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
5326 assert_eq!(next.delay, Duration::from_secs(60));
5327 assert_eq!(next.pace, PollPace::Nominal);
5328
5329 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5330 assert_eq!(
5331 floored
5332 .next_poll(None, fixtures::created_at(), &NoJitter)
5333 .delay,
5334 Duration::from_secs(30)
5335 );
5336 }
5337
5338 #[test]
5342 fn a_rate_limit_increases_the_delay_and_names_itself() {
5343 let now = fixtures::created_at();
5344 let mut schedule = PollSchedule::new(RefreshInterval::default());
5345
5346 let limited = RefreshState::RateLimited(RateLimited {
5347 kind: RateLimitKind::Secondary,
5348 retry_after: Some(Duration::from_secs(300)),
5349 remaining: None,
5350 reset_unix_secs: None,
5351 });
5352 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5353
5354 assert_eq!(next.delay, Duration::from_secs(300));
5355 assert_eq!(
5356 next.pace,
5357 PollPace::RateLimited {
5358 kind: RateLimitKind::Secondary
5359 },
5360 "the increase is reported, never hidden"
5361 );
5362 assert!(next.pace.is_throttled());
5363 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
5364 }
5365
5366 #[test]
5369 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
5370 let now = fixtures::created_at();
5371 let mut schedule = PollSchedule::new(RefreshInterval::default());
5372
5373 let limited = RefreshState::RateLimited(RateLimited {
5374 kind: RateLimitKind::Primary,
5375 retry_after: Some(Duration::from_secs(300)),
5376 remaining: Some(0),
5377 reset_unix_secs: None,
5378 });
5379
5380 for _ in 0..5 {
5383 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5384 assert_eq!(
5385 next.delay,
5386 Duration::from_secs(300),
5387 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
5388 have compounded on every successive retry"
5389 );
5390 }
5391
5392 let brief = RefreshState::RateLimited(RateLimited {
5395 kind: RateLimitKind::Secondary,
5396 retry_after: Some(Duration::from_secs(5)),
5397 remaining: None,
5398 reset_unix_secs: None,
5399 });
5400 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
5401 assert_eq!(
5402 next.delay,
5403 Duration::from_secs(60),
5404 "a short `retry-after` may not drop the loop below its own interval"
5405 );
5406 assert!(next.delay >= PollSchedule::floor());
5407 }
5408
5409 #[test]
5410 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
5411 let now = fixtures::created_at();
5412 let states = [
5413 None,
5414 Some(RefreshState::Offline),
5415 Some(RefreshState::RateLimited(RateLimited {
5416 kind: RateLimitKind::Secondary,
5417 retry_after: Some(Duration::from_secs(1)),
5418 remaining: None,
5419 reset_unix_secs: None,
5420 })),
5421 Some(RefreshState::LockedOut {
5422 retry_after: Duration::from_secs(1),
5423 }),
5424 Some(RefreshState::Unauthorized),
5425 Some(RefreshState::Forbidden { message: None }),
5426 Some(RefreshState::Failed {
5427 status: Some(500),
5428 message: "server error".into(),
5429 }),
5430 Some(RefreshState::Cancelled),
5431 ];
5432
5433 for state in &states {
5434 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5435 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
5436 assert!(
5437 next.delay >= PollSchedule::floor(),
5438 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
5439 next.delay.as_millis()
5440 );
5441 }
5442 }
5443
5444 #[test]
5445 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
5446 let now = fixtures::created_at();
5447 let mut schedule = PollSchedule::new(RefreshInterval::default());
5448
5449 let mut previous = Duration::ZERO;
5451 for consecutive in 1..=6_u32 {
5452 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5453 assert_eq!(next.pace, PollPace::Offline { consecutive });
5454 assert!(
5455 next.delay >= previous,
5456 "the back-off must not shrink while the outage continues"
5457 );
5458 assert!(next.delay >= Duration::from_secs(60));
5459 previous = next.delay;
5460 }
5461 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
5462
5463 let mut jittered = PollSchedule::new(RefreshInterval::default());
5466 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5467 let mut jittered = PollSchedule::new(RefreshInterval::default());
5468 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
5469 assert!(full.delay > none.delay);
5470 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
5471
5472 assert_eq!(schedule.consecutive_offline(), 6);
5474 let recovered = schedule.next_poll(None, now, &NoJitter);
5475 assert_eq!(recovered.pace, PollPace::Nominal);
5476 assert_eq!(recovered.delay, Duration::from_secs(60));
5477 assert_eq!(schedule.consecutive_offline(), 0);
5478 }
5479
5480 #[test]
5481 fn the_offline_state_states_the_twenty_four_hour_bound() {
5482 assert_eq!(
5483 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
5484 Duration::from_secs(24 * 60 * 60)
5485 );
5486
5487 let brief = OfflineState::new(1, Duration::from_secs(120));
5488 let rendered = brief.to_string();
5489 assert!(rendered.contains("24 hours"), "{rendered}");
5490 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
5491 assert!(!brief.has_outlasted_the_queue());
5492
5493 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
5494 assert!(long.has_outlasted_the_queue());
5495 assert!(
5496 long.to_string().contains("queued work has been lost"),
5497 "{long}"
5498 );
5499
5500 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
5502 }
5503
5504 fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
5509 RateLimitSnapshot {
5510 limit: 5_000,
5511 remaining,
5512 used: Some(5_000 - remaining),
5513 reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
5514 observed_at: now,
5515 }
5516 }
5517
5518 fn quiet() -> PollConditions {
5519 PollConditions {
5520 quiet: true,
5521 quota: None,
5522 requests_per_pass: 0,
5523 }
5524 }
5525
5526 #[test]
5527 fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
5528 let now = fixtures::created_at();
5529 let mut schedule =
5530 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5531
5532 let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
5533 assert_eq!(idle.delay, Duration::from_secs(10));
5534 assert_eq!(idle.pace, PollPace::Idle);
5535 assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
5536
5537 let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
5538 assert_eq!(busy.delay, Duration::from_secs(60));
5539 assert_eq!(busy.pace, PollPace::Nominal);
5540 }
5541
5542 #[test]
5543 fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
5544 let now = fixtures::created_at();
5545 let mut schedule = PollSchedule::new(RefreshInterval::default());
5546 assert_eq!(
5547 schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
5548 Duration::from_secs(60)
5549 );
5550 }
5551
5552 #[test]
5553 fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
5554 let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
5555 .with_idle(Duration::from_secs(120));
5556 assert_eq!(
5557 schedule.idle(),
5558 Duration::from_secs(30),
5559 "an idle interval above the active one would make quiet targets slower to react"
5560 );
5561 let schedule =
5562 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
5563 assert_eq!(schedule.idle(), PollSchedule::idle_floor());
5564 }
5565
5566 #[test]
5569 fn a_low_quota_stretches_polling_and_wins_over_idle() {
5570 let now = fixtures::created_at();
5571 let mut schedule =
5572 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5573
5574 let healthy = schedule.next_poll_with(
5575 None,
5576 PollConditions {
5577 quiet: true,
5578 quota: Some(quota(1_000, 3_000, now)),
5579 requests_per_pass: 0,
5580 },
5581 now,
5582 &NoJitter,
5583 );
5584 assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
5585
5586 let low = schedule.next_poll_with(
5587 None,
5588 PollConditions {
5589 quiet: true,
5590 quota: Some(quota(999, 3_000, now)),
5591 requests_per_pass: 0,
5592 },
5593 now,
5594 &NoJitter,
5595 );
5596 assert_eq!(low.delay, Duration::from_secs(120));
5597 assert_eq!(
5598 low.pace,
5599 PollPace::Stretched {
5600 pressure: PrimaryPressure::Low
5601 }
5602 );
5603 assert!(low.pace.is_throttled());
5604 assert_eq!(low.pace.as_str(), "stretched_low_budget");
5605
5606 let critical = schedule.next_poll_with(
5607 None,
5608 PollConditions {
5609 quiet: false,
5610 quota: Some(quota(100, 3_000, now)),
5611 requests_per_pass: 0,
5612 },
5613 now,
5614 &NoJitter,
5615 );
5616 assert_eq!(critical.delay, Duration::from_secs(240));
5617 assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
5618 }
5619
5620 #[test]
5621 fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
5622 let now = fixtures::created_at();
5623 let mut schedule = PollSchedule::new(RefreshInterval::default());
5624
5625 let soon = schedule.next_poll_with(
5626 None,
5627 PollConditions {
5628 quiet: false,
5629 quota: Some(quota(10, 90, now)),
5630 requests_per_pass: 0,
5631 },
5632 now,
5633 &NoJitter,
5634 );
5635 assert_eq!(
5636 soon.delay,
5637 Duration::from_secs(90),
5638 "the bucket refills in 90 s; waiting 240 s buys nothing"
5639 );
5640
5641 let imminent = schedule.next_poll_with(
5642 None,
5643 PollConditions {
5644 quiet: false,
5645 quota: Some(quota(10, 5, now)),
5646 requests_per_pass: 0,
5647 },
5648 now,
5649 &NoJitter,
5650 );
5651 assert_eq!(imminent.delay, Duration::from_secs(60));
5652
5653 let expired = schedule.next_poll_with(
5654 None,
5655 PollConditions {
5656 quiet: true,
5657 quota: Some(quota(10, -5, now)),
5658 requests_per_pass: 0,
5659 },
5660 now,
5661 &NoJitter,
5662 );
5663 assert_eq!(
5664 expired.pace,
5665 PollPace::Nominal,
5666 "a reading from a window that has reset says nothing; the schedule here had no \
5667 idle interval, so it is the active one"
5668 );
5669 }
5670
5671 #[test]
5672 fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
5673 let now = fixtures::created_at();
5674 let mut schedule =
5675 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5676 let small = schedule.next_poll_with(
5677 None,
5678 PollConditions {
5679 quiet: true,
5680 quota: None,
5681 requests_per_pass: 40,
5682 },
5683 now,
5684 &NoJitter,
5685 );
5686 assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
5687 let organization = schedule.next_poll_with(
5688 None,
5689 PollConditions {
5690 quiet: true,
5691 quota: None,
5692 requests_per_pass: 400,
5693 },
5694 now,
5695 &NoJitter,
5696 );
5697 assert_eq!(
5698 organization.delay,
5699 Duration::from_secs(80),
5700 "400 requests a pass at 300 a minute is one pass every 80 s"
5701 );
5702 assert_eq!(organization.pace, PollPace::Idle);
5703 }
5704
5705 #[test]
5706 fn a_rate_limit_wait_is_jittered_upward_only() {
5707 let now = fixtures::created_at();
5708 let mut schedule = PollSchedule::new(RefreshInterval::default());
5709 let limited = RefreshState::RateLimited(RateLimited {
5710 kind: RateLimitKind::Secondary,
5711 retry_after: Some(Duration::from_secs(300)),
5712 remaining: None,
5713 reset_unix_secs: None,
5714 });
5715 assert_eq!(
5716 schedule
5717 .next_poll(Some(&limited), now, &FixedJitter(0.5))
5718 .delay,
5719 Duration::from_secs(315),
5720 "every loop shares one quiet period; without jitter they all wake together"
5721 );
5722 assert_eq!(
5723 schedule.next_poll(Some(&limited), now, &NoJitter).delay,
5724 Duration::from_secs(300),
5725 "never shorter than GitHub asked"
5726 );
5727 }
5728
5729 #[tokio::test]
5730 async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
5731 let launcher =
5734 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
5735 let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
5736 let mut harness = Harness::build(
5737 host_with(1),
5738 launcher,
5739 demand,
5740 Arc::new(InProcessAllocationLock::new()),
5741 );
5742 harness
5743 .reconciler
5744 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5745 let policy = policy(1, "acme/app", 4);
5746
5747 let report = harness
5748 .reconciler
5749 .reconcile(std::slice::from_ref(&policy))
5750 .await;
5751
5752 assert_eq!(report.started, 0, "the host is full");
5753 assert_eq!(
5754 report.next_poll.pace,
5755 PollPace::Nominal,
5756 "matched demand it could not serve is work waiting, not idle"
5757 );
5758 }
5759
5760 #[tokio::test]
5761 async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
5762 let mut harness = Harness::simple(4, 0, "acme/app");
5763 harness
5764 .reconciler
5765 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5766 let policy = policy(1, "acme/app", 4);
5767
5768 let report = harness
5769 .reconciler
5770 .reconcile(std::slice::from_ref(&policy))
5771 .await;
5772
5773 assert_eq!(report.next_poll.pace, PollPace::Idle);
5774 assert_eq!(report.next_poll.delay, Duration::from_secs(10));
5775 }
5776
5777 #[tokio::test]
5778 async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
5779 let mut harness = Harness::simple(4, 0, "acme/app");
5780 harness
5781 .reconciler
5782 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5783 harness.demand.set(PollOutcome::Ready(
5784 QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
5785 ));
5786 let policy = policy(1, "acme/app", 4);
5787
5788 let report = harness
5789 .reconciler
5790 .reconcile(std::slice::from_ref(&policy))
5791 .await;
5792
5793 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5794 assert_eq!(report.next_poll.delay, Duration::from_secs(60));
5795 }
5796
5797 #[tokio::test]
5798 async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
5799 let launcher =
5800 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5801 let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
5802 let mut harness = Harness::build(
5803 host_with(4),
5804 launcher,
5805 demand,
5806 Arc::new(InProcessAllocationLock::new()),
5807 );
5808 harness
5809 .reconciler
5810 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5811 let policy = policy(1, "acme/app", 4);
5812
5813 let report = harness
5814 .reconciler
5815 .reconcile(std::slice::from_ref(&policy))
5816 .await;
5817
5818 assert_eq!(
5819 report.next_poll.pace,
5820 PollPace::Nominal,
5821 "a job is running: its follow-on work is what the active interval watches for"
5822 );
5823 }
5824
5825 #[tokio::test]
5826 async fn a_pass_that_starts_a_runner_is_not_idle() {
5827 let mut harness = Harness::simple(4, 2, "acme/app");
5828 harness
5829 .reconciler
5830 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5831 let policy = policy(1, "acme/app", 4);
5832
5833 let report = harness
5834 .reconciler
5835 .reconcile(std::slice::from_ref(&policy))
5836 .await;
5837
5838 assert!(report.started > 0);
5839 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5840 }
5841
5842 #[tokio::test]
5843 async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
5844 let mut harness = Harness::simple(4, 0, "acme/app");
5845 harness
5846 .reconciler
5847 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5848 let now = harness.clock.now();
5849 harness.demand.set(PollOutcome::Ready(
5850 QueuedDemand::of(repo("acme/app"), Vec::new())
5851 .with_rate_limit(Some(quota(200, 1_800, now))),
5852 ));
5853 let policy = policy(1, "acme/app", 4);
5854
5855 let report = harness
5856 .reconciler
5857 .reconcile(std::slice::from_ref(&policy))
5858 .await;
5859
5860 assert_eq!(
5861 report.next_poll.pace,
5862 PollPace::Stretched {
5863 pressure: PrimaryPressure::Critical
5864 }
5865 );
5866 assert_eq!(report.next_poll.delay, Duration::from_secs(240));
5867 }
5868
5869 #[tokio::test]
5878 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
5879 let live = vec![
5880 attempt_in(AttemptState::Busy, 1, 1),
5881 attempt_in(AttemptState::Starting, 2, 1),
5882 ];
5883 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
5884 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5885 let mut harness = Harness::build(
5886 host_with(8),
5887 Arc::clone(&launcher),
5888 Arc::clone(&demand),
5889 Arc::new(InProcessAllocationLock::new()),
5890 );
5891 let policy = policy(1, "acme/app", 8);
5892
5893 let report = harness
5894 .reconciler
5895 .reconcile(std::slice::from_ref(&policy))
5896 .await;
5897
5898 assert!(report.is_offline());
5899 assert_eq!(report.started, 0, "no new runner during an outage");
5900 assert_eq!(launcher.launches(), 0);
5901 assert_eq!(
5902 launcher.snapshot(),
5903 live,
5904 "existing runner processes are retained, untouched"
5905 );
5906 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
5907 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5908 assert!(report.next_poll.delay >= Duration::from_secs(60));
5909 let offline = report.offline_state().expect("an offline state to display");
5910 assert!(offline.to_string().contains("24 hours"));
5911
5912 demand.set(PollOutcome::Ready(QueuedDemand::of(
5915 repo("acme/app"),
5916 jobs(2),
5917 )));
5918 let recovered = harness.reconciler.reconcile(&[policy]).await;
5919
5920 assert!(!recovered.is_offline());
5921 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
5922 assert_eq!(
5923 recovered.started, 1,
5924 "two queued jobs and one starting runner leave one job uncovered"
5925 );
5926 assert_eq!(recovered.allocations[0].active_owned, 2);
5927 assert_eq!(launcher.live_count(), 3);
5928 }
5929
5930 #[tokio::test]
5940 async fn one_offline_target_does_not_stop_a_reachable_one() {
5941 let mut harness = Harness::simple(8, 0, "acme/app");
5942 harness.demand.set_for(
5943 &ScaleTarget::repository("acme/app").unwrap(),
5944 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
5945 );
5946 harness.demand.set_for(
5947 &ScaleTarget::repository("acme/broken").unwrap(),
5948 PollOutcome::Failed(RefreshState::Offline),
5949 );
5950
5951 let report = harness
5952 .reconciler
5953 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
5954 .await;
5955
5956 assert_eq!(
5957 report.started, 2,
5958 "the reachable target was served; an unreachable sibling repository must not \
5959 idle the host"
5960 );
5961 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
5962 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
5963
5964 assert!(report.is_offline());
5966 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5967 }
5968
5969 #[tokio::test]
5984 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
5985 let clock = Arc::new(FakeClock::default());
5986 let launcher = Arc::new(FakeLauncher::new());
5987 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5988 let mut reconciler = Reconciler::new(
5989 host_with(4),
5990 ReconcilerPorts {
5991 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
5992 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5993 lock: Arc::new(InProcessAllocationLock::new()),
5994 directory: Arc::new(FakeDirectory::default()),
5995 clock: Arc::clone(&clock) as Arc<dyn Clock>,
5996 jitter: Arc::new(NoJitter),
5997 events: Arc::new(NoEvents),
5998 },
5999 );
6000 let policy = policy(1, "acme/app", 4);
6001
6002 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6004 let state = first.offline_state().expect("an offline state");
6005 assert!(!state.has_outlasted_the_queue());
6006 assert!(
6007 state
6008 .to_string()
6009 .contains("an outage longer than that loses"),
6010 "{state}"
6011 );
6012
6013 clock.advance_secs(24 * 60 * 60 + 60);
6015 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6016 let state = later.offline_state().expect("an offline state");
6017 assert!(state.has_outlasted_the_queue());
6018 assert!(
6019 state.to_string().contains("queued work has been lost"),
6020 "{state}"
6021 );
6022 assert_eq!(launcher.launches(), 0, "and still nothing was started");
6023
6024 demand.set(PollOutcome::Ready(QueuedDemand::of(
6027 repo("acme/app"),
6028 jobs(0),
6029 )));
6030 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6031 assert!(recovered.offline_state().is_none());
6032 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
6033
6034 demand.set(PollOutcome::Failed(RefreshState::Offline));
6035 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6036 assert!(
6037 !again
6038 .offline_state()
6039 .expect("an offline state")
6040 .has_outlasted_the_queue(),
6041 "a new outage must not inherit the age of the one before it"
6042 );
6043 }
6044
6045 #[tokio::test]
6061 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
6062 let root = tempfile::tempdir().expect("a temporary directory");
6063 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
6064 root.path(),
6065 ));
6066 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
6067
6068 let held = lock.acquire().await.expect("an uncontended lock is free");
6069 assert!(
6070 matches!(lock.acquire().await, Err(AllocationLockBusy)),
6071 "a second holder was admitted; on Unix the lock is per open file description \
6072 and on Windows the share mode denies write, so this must be refused even \
6073 from inside the same process"
6074 );
6075
6076 drop(held);
6077 let regained = lock.acquire().await;
6078 assert!(
6079 regained.is_ok(),
6080 "dropping the guard is the only release there is, so a guard that does not \
6081 hold the `HostLock` leaves it held forever"
6082 );
6083 }
6084
6085 #[test]
6086 fn tee_events_reaches_both_sinks() {
6087 let left = Arc::new(EventLog::new());
6091 let right = Arc::new(EventLog::new());
6092 let tee = TeeEvents(
6093 Arc::clone(&left) as Arc<dyn EventSink>,
6094 Arc::clone(&right) as Arc<dyn EventSink>,
6095 );
6096
6097 tee.emit(LifecycleEvent::MonitorOnlySkipped {
6098 policy: PolicyId::from_u128(1),
6099 });
6100
6101 assert_eq!(left.count_of("monitor_only_skipped"), 1);
6102 assert_eq!(right.count_of("monitor_only_skipped"), 1);
6103 }
6104
6105 #[tokio::test]
6110 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
6111 let clock = Arc::new(FakeClock::default());
6112 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
6113 let cache = RepositoryCache::new(
6114 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6115 Arc::clone(&clock) as Arc<dyn Clock>,
6116 RefreshInterval::default(),
6117 );
6118 let target = ScaleTarget::organization("acme").unwrap();
6119
6120 assert_eq!(
6121 cache.ttl(),
6122 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
6123 );
6124
6125 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
6127 let scope = cache.scope_for(&target).await.unwrap();
6128 assert_eq!(scope.repositories().len(), 2);
6129 clock.advance_secs(60);
6130 }
6131 assert_eq!(
6132 directory.calls(),
6133 1,
6134 "re-listing an organization at demand-poll frequency is what exhausts the \
6135 shared request budget"
6136 );
6137 assert_eq!(cache.lookups(), 1);
6138
6139 cache.scope_for(&target).await.unwrap();
6141 assert_eq!(directory.calls(), 2);
6142 }
6143
6144 #[tokio::test]
6145 async fn a_repository_target_never_consults_the_directory() {
6146 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
6147 let cache = RepositoryCache::new(
6148 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6149 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6150 RefreshInterval::default(),
6151 );
6152 let target = ScaleTarget::repository("acme/app").unwrap();
6153
6154 let scope = cache.scope_for(&target).await.unwrap();
6155 assert_eq!(scope.repositories(), &[repo("acme/app")]);
6156 assert_eq!(directory.calls(), 0);
6157 }
6158
6159 #[tokio::test]
6160 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
6161 let mut harness = Harness::simple(8, 4, "o/r");
6165 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6166 repo("o/r"),
6167 [
6168 fixtures::queued_job(&[HOST_LABEL]),
6169 fixtures::queued_job(&[HOST_LABEL]),
6170 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6171 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6172 ],
6173 )));
6174 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6175 isolated.activate().unwrap();
6176 let report = harness
6177 .reconciler
6178 .reconcile(&[policy(1, "o/r", 2), isolated])
6179 .await;
6180
6181 assert_eq!(harness.demand.polls().len(), 1);
6182 assert_eq!(report.targets_read, 1, "profiles share one target reading");
6183 assert_eq!(
6184 report.demand_requests,
6185 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
6186 "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"
6187 );
6188 assert_eq!(
6189 report.started, 4,
6190 "native and isolated profiles each get two"
6191 );
6192 }
6193
6194 #[tokio::test]
6195 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
6196 use runner_manager_domain::model::{Arch, HostLabel, Os};
6197 use runner_manager_domain::policy::RoutingLabels;
6198 let mut harness = Harness::build(
6199 fixtures::host()
6200 .os(Os::MacOs)
6201 .architecture(Arch::Arm64)
6202 .capacity(10)
6203 .build(),
6204 Arc::new(FakeLauncher::new()),
6205 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
6206 Arc::new(InProcessAllocationLock::new()),
6207 );
6208 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6209 repo("o/r"),
6210 [
6211 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
6212 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
6213 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
6214 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
6215 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
6216 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
6217 ],
6218 )));
6219 let mut local = fixtures::policy()
6220 .id(PolicyId::from_u128(1))
6221 .repository("o/r")
6222 .autoscale("macmini", 10)
6223 .routing_labels(RoutingLabels::derive(
6224 &HostLabel::new("macmini").unwrap(),
6225 Os::MacOs,
6226 Arch::Arm64,
6227 ))
6228 .active()
6229 .build();
6230 for label in ["self-hosted", "macos", "arm64", "extra"] {
6231 local.add_routing_label(fixtures::label(label)).unwrap();
6232 }
6233 let mut foreign = local.to_persisted();
6234 foreign.id = PolicyId::from_u128(2);
6235 foreign.host_id = HostId::from_u128(0xdead);
6236 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
6237 let report = harness.reconciler.reconcile(&[local, foreign]).await;
6238 assert_eq!(report.started, 2);
6239 assert_eq!(report.targets_read, 1);
6240 assert!(harness.events.events().iter().any(|event| matches!(
6241 event,
6242 LifecycleEvent::DemandObserved {
6243 demand: 2,
6244 not_matched: 4,
6245 ..
6246 }
6247 )));
6248 assert!(
6249 !harness
6250 .events
6251 .events()
6252 .iter()
6253 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6254 );
6255 }
6256
6257 #[tokio::test]
6258 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
6259 use runner_manager_domain::execution::{
6260 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6261 };
6262 let mut native = policy(1, "o/r", 2);
6263 native
6264 .add_routing_label(fixtures::label("self-hosted"))
6265 .unwrap();
6266 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6267 isolated
6268 .add_routing_label(fixtures::label("self-hosted"))
6269 .unwrap();
6270 isolated
6271 .set_execution_policy(ExecutionPolicy::Isolated {
6272 backend: Backend::Oci,
6273 image: ImageReference::new(format!(
6274 "registry.example/runner@sha256:{}",
6275 "a".repeat(64)
6276 ))
6277 .unwrap(),
6278 resources: ResourceLimits {
6279 cpu_millis: 1000,
6280 memory_mib: 512,
6281 disk_mib: 1024,
6282 },
6283 })
6284 .unwrap();
6285 assert!(!isolated.may_start_runners());
6286 let mut active_isolated = isolated.clone();
6287 active_isolated.activate().unwrap();
6288 for profiles in [
6289 [native.clone(), isolated.clone()],
6290 [isolated.clone(), native.clone()],
6291 [native.clone(), active_isolated.clone()],
6292 [active_isolated, native.clone()],
6293 ] {
6294 let mut harness = Harness::simple(4, 0, "o/r");
6295 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6296 repo("o/r"),
6297 [fixtures::queued_job(&["SELF-HOSTED"])],
6298 )));
6299 let report = harness.reconciler.reconcile(&profiles).await;
6300 assert_eq!(report.started, 0);
6301 assert!(harness.events.events().iter().any(|event| matches!(
6302 event,
6303 LifecycleEvent::RoutingRefused {
6304 reason: RoutingRefusal::OverlappingProfiles,
6305 count: 1,
6306 ..
6307 }
6308 )));
6309 }
6310 let mut harness = Harness::simple(4, 0, "o/r");
6313 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6314 repo("o/r"),
6315 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
6316 )));
6317 assert_eq!(
6318 harness
6319 .reconciler
6320 .reconcile(&[native, isolated])
6321 .await
6322 .started,
6323 0
6324 );
6325 }
6326
6327 #[tokio::test]
6328 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
6329 let mut harness = Harness::simple(2, 0, "o/r");
6330 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6331 repo("o/r"),
6332 [
6333 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
6334 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
6335 fixtures::queued_job(&["gpu"]),
6336 fixtures::unresolvable_job(),
6337 ],
6338 )));
6339 let mut native = policy(1, "o/r", 2);
6340 native.add_routing_label(fixtures::label("gpu")).unwrap();
6341 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6342 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
6343 isolated.activate().unwrap();
6344
6345 let report = harness.reconciler.reconcile(&[native, isolated]).await;
6346 assert_eq!(harness.demand.polls().len(), 1);
6347 assert_eq!(report.targets_read, 1);
6348 assert_eq!(report.started, 2);
6349 assert_eq!(report.allocations.len(), 2);
6350 assert!(
6351 report
6352 .allocations
6353 .iter()
6354 .all(|allocation| allocation.to_start == 1)
6355 );
6356 assert!(harness.events.events().iter().any(|event| matches!(
6357 event,
6358 LifecycleEvent::RoutingRefused {
6359 reason: RoutingRefusal::OverlappingProfiles,
6360 count: 1,
6361 ..
6362 }
6363 )));
6364 assert_eq!(
6365 harness
6366 .events
6367 .events()
6368 .iter()
6369 .filter(|event| matches!(
6370 event,
6371 LifecycleEvent::DemandObserved {
6372 unresolvable: 1,
6373 ..
6374 }
6375 ))
6376 .count(),
6377 2,
6378 "an expression stays unresolvable for both profiles"
6379 );
6380 }
6381
6382 #[tokio::test]
6383 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
6384 let mut harness = Harness::simple(4, 0, "o/r");
6385 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6386 repo("o/r"),
6387 [fixtures::queued_job(&[
6388 HOST_LABEL,
6389 "rm-home-win-x64-py-isolated",
6390 ])],
6391 )));
6392 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6393 isolated.activate().unwrap();
6394 let report = harness
6395 .reconciler
6396 .reconcile(&[policy(1, "o/r", 2), isolated])
6397 .await;
6398 assert_eq!(report.started, 0);
6399 assert_eq!(
6400 harness
6401 .events
6402 .events()
6403 .iter()
6404 .filter(|event| matches!(
6405 event,
6406 LifecycleEvent::DemandObserved {
6407 demand: 0,
6408 not_matched: 1,
6409 ..
6410 }
6411 ))
6412 .count(),
6413 2
6414 );
6415
6416 let mut superset = policy(1, "o/r", 2);
6420 assert!(
6421 superset
6422 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
6423 .unwrap()
6424 );
6425 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6426 sibling.activate().unwrap();
6427 let mut unique = Harness::simple(4, 0, "o/r");
6428 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
6429 repo("o/r"),
6430 [fixtures::queued_job(&[
6431 HOST_LABEL,
6432 "rm-home-win-x64-py-isolated",
6433 ])],
6434 )));
6435 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
6436 assert_eq!(report.started, 1);
6437 assert_eq!(
6438 report
6439 .allocations
6440 .iter()
6441 .find(|allocation| allocation.to_start == 1)
6442 .unwrap()
6443 .policy_id,
6444 PolicyId::from_u128(1)
6445 );
6446 assert!(
6447 !unique
6448 .events
6449 .events()
6450 .iter()
6451 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6452 );
6453
6454 let mut overlap = Harness::simple(4, 1, "o/r");
6455 let report = overlap
6456 .reconciler
6457 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
6458 .await;
6459 assert_eq!(report.started, 0);
6460 assert!(overlap.events.events().iter().any(|event| matches!(
6461 event,
6462 LifecycleEvent::RoutingRefused {
6463 reason: RoutingRefusal::OverlappingProfiles,
6464 count: 1,
6465 ..
6466 }
6467 )));
6468 assert!(
6469 RoutingRefusal::OverlappingProfiles
6470 .to_string()
6471 .contains("repair")
6472 );
6473 }
6474
6475 #[tokio::test]
6480 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
6481 let launcher = Arc::new(FakeLauncher::new());
6482 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
6483 let mut harness = Harness::build(
6484 host_with(4),
6485 Arc::clone(&launcher),
6486 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6487 Arc::new(InProcessAllocationLock::new()),
6488 );
6489
6490 let report = harness
6491 .reconciler
6492 .reconcile(&[policy(1, "acme/app", 4)])
6493 .await;
6494 assert_eq!(report.started, 0);
6495 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
6496
6497 let failures: Vec<&'static str> = harness
6498 .events
6499 .events()
6500 .into_iter()
6501 .filter_map(|event| match event {
6502 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6503 _ => None,
6504 })
6505 .collect();
6506 assert_eq!(failures, vec!["other"]);
6507 assert!(
6508 !failures[0].contains("ghp_"),
6509 "an event carried a `FailureReason::Other` detail verbatim"
6510 );
6511 }
6512
6513 #[tokio::test]
6518 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
6519 let launcher = Arc::new(FakeLauncher::new());
6520 let unfit = Arc::new(AtomicBool::new(true));
6521 let events = Arc::new(EventLog::new());
6522 let mut reconciler = Reconciler::new(
6523 host_with(4),
6524 ReconcilerPorts {
6525 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
6526 launcher: Arc::new(FitnessGatedLauncher::new(
6527 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6528 Arc::clone(&unfit),
6529 )),
6530 lock: Arc::new(InProcessAllocationLock::new()),
6531 directory: Arc::new(FakeDirectory::default()),
6532 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6533 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
6534 events: Arc::clone(&events) as Arc<dyn EventSink>,
6535 },
6536 );
6537 let policies = [policy(1, "acme/app", 4)];
6538
6539 let report = reconciler.reconcile(&policies).await;
6540 assert_eq!(report.started, 0);
6541 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
6542 let reasons: Vec<&'static str> = events
6543 .events()
6544 .into_iter()
6545 .filter_map(|event| match event {
6546 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6547 _ => None,
6548 })
6549 .collect();
6550 assert_eq!(reasons, ["host_unfit"]);
6551
6552 unfit.store(false, Ordering::Release);
6553 let report = reconciler.reconcile(&policies).await;
6554 assert_eq!(report.started, 2);
6555 assert_eq!(launcher.launches(), 2);
6556 }
6557
6558 #[tokio::test]
6559 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
6560 let launcher = Arc::new(FakeLauncher::new());
6561 let mut harness = Harness::build(
6562 host_with(4),
6563 Arc::clone(&launcher),
6564 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6565 Arc::new(HeldLock),
6566 );
6567
6568 let report = harness
6569 .reconciler
6570 .reconcile(&[policy(1, "acme/app", 4)])
6571 .await;
6572 assert_eq!(report.started, 0);
6573 assert_eq!(
6574 report.deferred, 3,
6575 "three runners were granted and none was created; `deferred` counts grants, \
6576 not policies -- it reported `1` when a policy that launched two of five and \
6577 then lost the lock had left three unstarted"
6578 );
6579 assert_eq!(launcher.launches(), 0);
6580 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
6581 assert!(
6582 harness
6583 .events
6584 .events()
6585 .iter()
6586 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
6587 "the event carries the same number the report does"
6588 );
6589 assert_eq!(
6590 report.allocations.len(),
6591 1,
6592 "the intent is still reported, so an operator staring at a queue sees why \
6593 nothing started"
6594 );
6595 }
6596
6597 #[tokio::test]
6598 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
6599 let mut harness = Harness::simple(8, 5, "acme/app");
6600
6601 let foreign = fixtures::policy()
6602 .id(PolicyId::from_u128(1))
6603 .repository("acme/app")
6604 .host(HostId::from_u128(0xdead))
6605 .autoscale("office", 4)
6606 .active()
6607 .build();
6608 let mut draining = policy(2, "acme/app", 4);
6609 draining.request_disable().unwrap();
6610
6611 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
6612
6613 assert_eq!(report.started, 0);
6614 assert_eq!(
6615 harness.demand.polls().len(),
6616 0,
6617 "neither can act on an answer"
6618 );
6619 let factors: Vec<LimitingFactor> = report
6620 .allocations
6621 .iter()
6622 .map(|a| a.limiting_factor)
6623 .collect();
6624 assert!(factors.contains(&LimitingFactor::ForeignHost));
6625 assert!(factors.contains(&LimitingFactor::NotReconciling));
6626 }
6627
6628 #[tokio::test]
6629 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
6630 #[derive(Debug)]
6633 struct BrokenDirectory;
6634
6635 #[async_trait::async_trait]
6636 impl RepositoryDirectory for BrokenDirectory {
6637 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
6638 Err(InventoryError::Cancelled)
6639 }
6640 }
6641
6642 let launcher = Arc::new(FakeLauncher::new());
6643 let events = Arc::new(EventLog::new());
6644 let mut reconciler = Reconciler::new(
6645 host_with(4),
6646 ReconcilerPorts {
6647 demand: Arc::new(FakeDemand::default()),
6648 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6649 lock: Arc::new(InProcessAllocationLock::new()),
6650 directory: Arc::new(BrokenDirectory),
6651 clock: Arc::new(FakeClock::default()),
6652 jitter: Arc::new(NoJitter),
6653 events: Arc::clone(&events) as Arc<dyn EventSink>,
6654 },
6655 );
6656
6657 let org_policy = fixtures::policy()
6658 .id(PolicyId::from_u128(1))
6659 .organization("acme")
6660 .autoscale("home", 4)
6661 .active()
6662 .build();
6663
6664 let report = reconciler.reconcile(&[org_policy]).await;
6665 assert_eq!(report.started, 0);
6666 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6667 assert_eq!(events.count_of("target_unreadable"), 1);
6668 }
6669
6670 fn every_event() -> Vec<LifecycleEvent> {
6682 let policy = PolicyId::from_u128(0xabcd_ef01);
6683 let attempt = AttemptId::from_u128(0x1234_5678);
6684 vec![
6685 LifecycleEvent::DemandObserved {
6686 policy,
6687 demand: u32::MAX,
6688 not_matched: u32::MAX,
6689 unresolvable: u32::MAX,
6690 complete: false,
6691 },
6692 LifecycleEvent::TargetUnreadable {
6693 policy,
6694 reason: unreadable_reason(&RefreshState::Failed {
6695 status: Some(500),
6696 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
6697 .into(),
6698 }),
6699 },
6700 LifecycleEvent::Allocated {
6701 policy,
6702 demand: u32::MAX,
6703 desired: u16::MAX,
6704 active_owned: 7,
6705 headroom: 9,
6706 to_start: 2,
6707 limiting: LimitingFactor::HostCapacity,
6708 },
6709 LifecycleEvent::MonitorOnlySkipped { policy },
6710 LifecycleEvent::RunnerStarted { policy, attempt },
6711 LifecycleEvent::RunnerStartFailed {
6712 policy,
6713 reason: failure_reason_kind(&FailureReason::Other(
6714 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6715 )),
6716 },
6717 LifecycleEvent::AllocationDeferred { policy, count: 4 },
6718 LifecycleEvent::AttemptsUnreadable {
6719 reason: failure_reason_kind(&FailureReason::Other(
6720 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6721 )),
6722 },
6723 LifecycleEvent::HostCapacityUnreadable {
6724 reason: HostCapacityError::MissingHost,
6725 },
6726 LifecycleEvent::HostCapacityUnreadable {
6727 reason: HostCapacityError::JournalUnreadable,
6728 },
6729 LifecycleEvent::AttemptCleanFailed {
6730 policy,
6731 attempt,
6732 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
6733 },
6734 LifecycleEvent::AttemptCleaned {
6735 policy,
6736 attempt,
6737 outcome: OutcomeKind::IdleExit,
6738 },
6739 LifecycleEvent::ScaleDownRefused { policy, attempt },
6740 LifecycleEvent::PollScheduled {
6741 retry_in_ms: 900_000,
6742 pace: PollPace::RateLimited {
6743 kind: RateLimitKind::Primary,
6744 },
6745 },
6746 ]
6747 }
6748
6749 #[test]
6758 fn no_emitted_event_can_carry_a_credential() {
6759 use runner_manager_platform::logging::redact;
6760
6761 for event in every_event() {
6762 let displayed = event.to_string();
6763 assert_eq!(
6764 redact(&displayed),
6765 displayed,
6766 "`{}` renders something `d1`'s sink would have to redact",
6767 event.name()
6768 );
6769
6770 let debugged = format!("{event:?}");
6771 assert_eq!(
6772 redact(&debugged),
6773 debugged,
6774 "`{}`'s Debug renders something `d1`'s sink would have to redact",
6775 event.name()
6776 );
6777 }
6778
6779 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
6782 assert_ne!(
6783 redact(secret),
6784 secret,
6785 "the scan above proves nothing if `redact` no longer recognises a credential"
6786 );
6787 }
6788
6789 #[test]
6790 fn every_field_name_this_sink_emits_is_one_d1_allows() {
6791 use runner_manager_platform::logging::is_field_allowed;
6792
6793 for field in [
6797 "event",
6798 "policy_id",
6799 "attempt_id",
6800 "attempt_state",
6801 "demand",
6802 "desired",
6803 "capacity",
6804 "headroom",
6805 "count",
6806 "reason",
6807 "outcome",
6808 "mode",
6809 "lock",
6810 "retry_in_ms",
6811 "state",
6812 ] {
6813 assert!(
6814 is_field_allowed(field),
6815 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
6816 `[redacted]` in its place and the line would lose its meaning"
6817 );
6818 }
6819 }
6820
6821 #[test]
6822 fn every_failure_reason_has_a_credential_free_kind() {
6823 for reason in FailureReason::ALL {
6824 let kind = failure_reason_kind(&reason);
6825 assert!(!kind.is_empty());
6826 assert!(
6827 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
6828 "`{kind}` is not a fixed identifier"
6829 );
6830 }
6831 assert_eq!(
6832 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
6833 "other",
6834 "the detail of an `Other` reason never reaches an event"
6835 );
6836 let categories = [
6837 IsolationProviderFailure::NotInstalled,
6838 IsolationProviderFailure::PermissionDenied,
6839 IsolationProviderFailure::ImageUnavailableOrIncompatible,
6840 IsolationProviderFailure::DiskQuotaUnavailable,
6841 IsolationProviderFailure::RuntimeOperationFailed,
6842 ];
6843 let mut kinds = std::collections::BTreeSet::new();
6844 for category in categories {
6845 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
6846 assert_ne!(kind, "other");
6847 assert!(!kind.contains("secret"));
6848 kinds.insert(kind);
6849 }
6850 assert_eq!(kinds.len(), categories.len());
6851 }
6852
6853 fn production_half_of(source: &str) -> String {
6868 let production = source
6869 .split_once("\n#[cfg(test)]")
6870 .map_or(source, |(production, _)| production);
6871 production
6872 .lines()
6873 .filter(|line| !line.trim_start().starts_with("//"))
6874 .collect::<Vec<_>>()
6875 .join("\n")
6876 }
6877
6878 fn this_file_above_its_tests_without_prose() -> String {
6880 production_half_of(include_str!("reconcile.rs"))
6881 }
6882
6883 fn normalise_for_scan(text: &str) -> String {
6902 text.to_ascii_lowercase().replace(['_', ' '], "")
6903 }
6904
6905 const FORBIDDEN: &[&str] = &[
6915 concat!("fn ", "acquire", "_job"),
6916 concat!("fn ", "claim", "_job"),
6917 concat!("fn ", "lease", "_job"),
6918 concat!("fn ", "reserve", "_job"),
6919 concat!("fn ", "ack", "nowledge"),
6920 concat!("struct ", "Job", "Lease"),
6921 concat!("struct ", "Job", "Claim"),
6922 concat!("struct ", "Job", "Reservation"),
6923 ];
6924
6925 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
6926 let haystack = normalise_for_scan(source);
6927 FORBIDDEN
6928 .iter()
6929 .copied()
6930 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
6931 }
6932
6933 #[test]
6967 fn nothing_in_this_crate_reserves_or_claims_a_job() {
6968 const SOURCES: &[(&str, &str)] = &[
6969 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
6970 ("docker_cleanup.rs", include_str!("docker_cleanup.rs")),
6971 (
6972 "docker_cleanup/http.rs",
6973 include_str!("docker_cleanup/http.rs"),
6974 ),
6975 (
6976 "docker_cleanup/proxy.rs",
6977 include_str!("docker_cleanup/proxy.rs"),
6978 ),
6979 (
6980 "docker_cleanup/tests.rs",
6981 include_str!("docker_cleanup/tests.rs"),
6982 ),
6983 ("lib.rs", include_str!("lib.rs")),
6984 ("lifecycle.rs", include_str!("lifecycle.rs")),
6985 (
6986 "lifecycle/windows_hyperv.rs",
6987 include_str!("lifecycle/windows_hyperv.rs"),
6988 ),
6989 ("oci.rs", include_str!("oci.rs")),
6990 ("package.rs", include_str!("package.rs")),
6991 ("reconcile.rs", include_str!("reconcile.rs")),
6992 ];
6993
6994 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
6995 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
6996 let entry = entry.expect("a readable directory entry");
6997 let name = entry.file_name().to_string_lossy().into_owned();
6998 let joined = if prefix.is_empty() {
7001 name.clone()
7002 } else {
7003 format!("{prefix}/{name}")
7004 };
7005 if entry.path().is_dir() {
7006 walk(&entry.path(), &joined, found);
7007 } else if name.ends_with(".rs") {
7008 found.push(joined);
7009 }
7010 }
7011 }
7012
7013 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
7014 listed.sort_unstable();
7015 let mut on_disk = Vec::new();
7016 walk(
7017 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
7018 "",
7019 &mut on_disk,
7020 );
7021 on_disk.sort_unstable();
7022 assert_eq!(
7023 listed, on_disk,
7024 "a source file was added or removed; this scan claims to cover the whole crate \
7025 and a stale list makes that claim false"
7026 );
7027
7028 for (name, source) in SOURCES {
7029 assert_eq!(
7030 forbidden_shape_in(&production_half_of(source)),
7031 None,
7032 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
7033 REST, and a local lease coordinates this host with itself and with nothing \
7034 else. If an owner decision restored one, that decision belongs in this \
7035 module's documentation and in this test before it belongs in the code"
7036 );
7037 }
7038
7039 assert!(
7042 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
7043 "the scan above proves nothing if the needles no longer match"
7044 );
7045 }
7046
7047 #[test]
7075 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
7076 let production = this_file_above_its_tests_without_prose();
7077
7078 assert!(
7079 production.contains("DemandTally"),
7080 "the reconciliation loop must tally queued jobs against this policy's routing \
7081 labels. A production half that named `DemandTally` nowhere would mean the \
7082 label filtering had been removed, and a repository whose jobs target \
7083 `ubuntu-latest` would drive its policy toward `max_capacity` again"
7084 );
7085
7086 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
7087 assert!(
7088 !production.contains(second_implementation),
7089 "the reconciliation loop names `{second_implementation}`, which is the \
7090 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
7091 has already decided it. This module applies the predicate and does not \
7092 re-implement it; if an owner decision changed that, it belongs in this \
7093 module's documentation and in this test before it belongs in the code"
7094 );
7095 }
7096 }
7097
7098 #[test]
7106 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
7107 let policy = policy(1, "acme/app", 10);
7108 let reading = QueuedDemand::of(
7109 repo("acme/app"),
7110 [
7111 fixtures::queued_job(&[HOST_LABEL]),
7112 fixtures::queued_job(&[HOST_LABEL]),
7113 fixtures::queued_job(&["ubuntu-latest"]),
7114 fixtures::unresolvable_job(),
7115 ],
7116 );
7117
7118 let tally = demand_for(&policy, &reading);
7119
7120 assert_eq!(
7121 tally.demand(),
7122 2,
7123 "only the jobs whose required labels this policy carries are demand"
7124 );
7125 assert_eq!(
7126 tally.not_matched, 1,
7127 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
7128 runner that idles until it times out"
7129 );
7130 assert_eq!(
7131 tally.unresolvable.len(),
7132 1,
7133 "an unresolvable `runs-on` is never demand and never discarded"
7134 );
7135 }
7136
7137 #[test]
7140 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
7141 let mut per_repository = BTreeMap::new();
7142 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
7143 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
7144 let reading = QueuedDemand::new(per_repository);
7145
7146 let repository_policy = policy(1, "acme/left", 10);
7147 assert_eq!(
7148 demand_for(&repository_policy, &reading).demand(),
7149 3,
7150 "a repository target reads its own repository's queue and not the aggregate"
7151 );
7152
7153 let org_policy = fixtures::policy()
7154 .id(PolicyId::from_u128(2))
7155 .organization("acme")
7156 .autoscale("home", 10)
7157 .active()
7158 .build();
7159 assert_eq!(
7160 demand_for(&org_policy, &reading).demand(),
7161 7,
7162 "an organization policy serves any repository in its scope, so its demand is \
7163 the whole aggregate's"
7164 );
7165 }
7166
7167 #[test]
7168 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
7169 let host = host_with(2);
7174 let policy = policy(1, "acme/app", 5);
7175 let attempts: Vec<RunnerAttempt> = Vec::new();
7176 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
7177
7178 let allocation = allocator.allocate(&policy, u32::MAX);
7179 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
7180 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
7181 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
7182 }
7183
7184 #[derive(Debug, Default)]
7187 struct FixedDirector {
7188 directed: Mutex<BTreeMap<PolicyId, u32>>,
7189 started: Mutex<Vec<(PolicyId, AttemptId)>>,
7190 }
7191
7192 impl DemandDirector for FixedDirector {
7193 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32> {
7194 self.directed.lock().unwrap().get(&policy.id).copied()
7195 }
7196
7197 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId) {
7198 self.started.lock().unwrap().push((policy.id, attempt));
7199 }
7200 }
7201
7202 #[tokio::test]
7206 async fn a_directed_policy_starts_only_what_it_was_directed_to_within_its_ceilings() {
7207 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
7208 let launcher = Arc::new(FakeLauncher::new());
7209 let director = Arc::new(FixedDirector::default());
7210 let mut reconciler = Reconciler::new(
7211 host_with(10),
7212 ReconcilerPorts {
7213 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
7214 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7215 lock: Arc::new(InProcessAllocationLock::new()),
7216 directory: Arc::new(FakeDirectory::default()),
7217 clock: Arc::new(FakeClock::default()),
7218 jitter: Arc::new(NoJitter),
7219 events: Arc::new(NoEvents),
7220 },
7221 )
7222 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7223 let pool = policy(1, "acme/app", 2);
7224
7225 director.directed.lock().unwrap().insert(pool.id, 0);
7227 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7228 assert_eq!(report.started, 0);
7229
7230 director.directed.lock().unwrap().insert(pool.id, 1);
7232 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7233 assert_eq!(report.started, 1);
7234 assert_eq!(director.started.lock().unwrap().len(), 1);
7235
7236 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7238 assert_eq!(report.started, 0);
7239
7240 director.directed.lock().unwrap().insert(pool.id, 3);
7242 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7243 assert_eq!(report.started, 1);
7244 assert_eq!(launcher.live_count(), 2);
7245
7246 let failing = Arc::new(FakeLauncher::new());
7248 let mut reconciler = Reconciler::new(
7249 host_with(10),
7250 ReconcilerPorts {
7251 demand: Arc::new(FakeDemand::failing(RefreshState::Offline)),
7252 launcher: Arc::clone(&failing) as Arc<dyn RunnerLauncher>,
7253 lock: Arc::new(InProcessAllocationLock::new()),
7254 directory: Arc::new(FakeDirectory::default()),
7255 clock: Arc::new(FakeClock::default()),
7256 jitter: Arc::new(NoJitter),
7257 events: Arc::new(NoEvents),
7258 },
7259 )
7260 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7261 director.directed.lock().unwrap().insert(pool.id, 1);
7262 assert_eq!(reconciler.reconcile(&[pool]).await.started, 1);
7263
7264 let own = Arc::new(FakeLauncher::new());
7266 let mut reconciler = Reconciler::new(
7267 host_with(10),
7268 ReconcilerPorts {
7269 demand: Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
7270 launcher: Arc::clone(&own) as Arc<dyn RunnerLauncher>,
7271 lock: Arc::new(InProcessAllocationLock::new()),
7272 directory: Arc::new(FakeDirectory::default()),
7273 clock: Arc::new(FakeClock::default()),
7274 jitter: Arc::new(NoJitter),
7275 events: Arc::new(NoEvents),
7276 },
7277 )
7278 .with_director(Arc::new(OwnDemand));
7279 assert_eq!(
7280 reconciler
7281 .reconcile(&[policy(1, "acme/app", 2)])
7282 .await
7283 .started,
7284 2
7285 );
7286 }
7287}