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 start_directed(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2643 let mut report = ReconcileReport::default();
2644 let mut launched: Vec<RunnerAttempt> = Vec::new();
2645 for policy in policies {
2646 if !policy.owns_runners()
2647 || !policy.is_owned_by(self.host.id)
2648 || !policy.may_start_runners()
2649 {
2650 continue;
2651 }
2652 if let Some(directed) = self.director.directed_demand(policy) {
2653 self.start_runners(policy, directed, &mut report, &mut launched)
2654 .await;
2655 }
2656 }
2657 report
2658 }
2659
2660 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2674 let mut report = ReconcileReport::default();
2675 let mut launched: Vec<RunnerAttempt> = Vec::new();
2679
2680 let mut pollable: Vec<&ScalePolicy> = Vec::new();
2682 let mut supervision_failed = BTreeSet::new();
2683 for policy in policies {
2684 if !policy.owns_runners() {
2685 report.monitor_only.push(policy.id);
2686 self.events
2687 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2688 continue;
2689 }
2690 if !policy.is_owned_by(self.host.id) {
2691 continue;
2695 }
2696 match self.launcher.supervise(policy).await {
2697 Ok(intents) => {
2698 report.replacement_intents = report
2699 .replacement_intents
2700 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
2701 }
2702 Err(failure) => {
2703 supervision_failed.insert(policy.id);
2704 self.report_unreadable_attempts(&mut report, &failure);
2705 continue;
2706 }
2707 }
2708 if !policy.may_start_runners() {
2709 continue;
2710 }
2711 pollable.push(policy);
2712 }
2713
2714 let readings = self.poll_targets(&pollable, &mut report).await;
2715 report.targets_read = u16::try_from(
2716 readings
2717 .values()
2718 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
2719 .count(),
2720 )
2721 .unwrap_or(u16::MAX);
2722 let mut routed = BTreeMap::new();
2723 for (target, outcome) in &readings {
2724 let PollOutcome::Ready(reading) = outcome else {
2725 continue;
2726 };
2727 let profiles: Vec<&ScalePolicy> = pollable
2728 .iter()
2729 .copied()
2730 .filter(|policy| &policy.target == target)
2731 .collect();
2732 let (tallies, refusals) = route_demand(policies, &profiles, reading);
2733 if let Some(first) = profiles.first() {
2734 for (reason, count) in refusals {
2735 self.events.emit(LifecycleEvent::RoutingRefused {
2736 policy: first.id,
2737 reason,
2738 count,
2739 });
2740 }
2741 }
2742 routed.insert(target.clone(), tallies);
2743 }
2744
2745 self.clean_terminal_attempts(&mut report).await;
2755
2756 for policy in policies {
2771 if !policy.owns_runners() {
2772 continue;
2778 }
2779 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2780 match self.allocate_only(policy, 0, &launched).await {
2784 Ok(allocation) => {
2785 self.emit_allocation(&allocation);
2786 report.allocations.push(allocation);
2787 }
2788 Err(failure) => self.report_allocation_failure(&mut report, &failure),
2789 }
2790 continue;
2791 }
2792 if supervision_failed.contains(&policy.id) {
2793 continue;
2794 }
2795 if let Some(directed) = self.director.directed_demand(policy) {
2799 self.start_runners(policy, directed, &mut report, &mut launched)
2800 .await;
2801 continue;
2802 }
2803 let Some(reading) = readings.get(&policy.target) else {
2804 debug_assert!(false, "a pollable policy's target has no reading");
2807 continue;
2808 };
2809 match reading {
2810 PollOutcome::Failed(state) => {
2811 report.unreadable.push(policy.id);
2812 self.events.emit(LifecycleEvent::TargetUnreadable {
2813 policy: policy.id,
2814 reason: unreadable_reason(state),
2815 });
2816 }
2817 PollOutcome::Ready(demand) => {
2818 let tally = routed
2819 .get(&policy.target)
2820 .and_then(|tallies| tallies.get(&policy.id))
2821 .expect("every pollable profile has a routed tally");
2822 let count = tally.demand();
2823 self.events.emit(LifecycleEvent::DemandObserved {
2824 policy: policy.id,
2825 demand: count,
2826 not_matched: tally.not_matched,
2827 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2828 complete: demand.is_complete(),
2829 });
2830 self.start_runners(policy, count, &mut report, &mut launched)
2831 .await;
2832 }
2833 }
2834 }
2835
2836 let failure = readings
2838 .values()
2839 .filter_map(PollOutcome::failure)
2840 .max_by_key(|state| severity(state))
2841 .cloned();
2842 let now = self.clock.now();
2843 let conditions = PollConditions {
2844 quiet: is_quiet(&readings, &report),
2845 requests_per_pass: report.demand_requests,
2846 quota: readings
2847 .values()
2848 .filter_map(PollOutcome::reading)
2849 .filter_map(QueuedDemand::rate_limit)
2850 .max_by_key(|quota| quota.observed_at)
2851 .copied(),
2852 };
2853 report.next_poll =
2854 self.schedule
2855 .next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
2856 report.failure = failure;
2857 if let PollPace::Offline { consecutive } = report.next_poll.pace {
2861 let state = OfflineState::new(consecutive, report.next_poll.delay);
2862 report.offline = Some(match self.schedule.offline_for(now) {
2863 Some(elapsed) => state.since(elapsed),
2864 None => state,
2865 });
2866 }
2867 self.events.emit(LifecycleEvent::PollScheduled {
2868 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2869 pace: report.next_poll.pace,
2870 });
2871
2872 report
2873 }
2874
2875 async fn poll_targets(
2883 &self,
2884 pollable: &[&ScalePolicy],
2885 report: &mut ReconcileReport,
2886 ) -> BTreeMap<ScaleTarget, PollOutcome> {
2887 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
2888
2889 let mut readings = BTreeMap::new();
2890 for target in targets {
2891 let outcome = match self.repositories.scope_for(&target).await {
2892 Ok(scope) => {
2893 report.demand_requests = report
2894 .demand_requests
2895 .saturating_add(demand_requests_per_poll(&scope));
2896 self.demand.poll(&scope).await
2897 }
2898 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
2904 };
2905 readings.insert(target, outcome);
2906 }
2907 readings
2908 }
2909
2910 async fn allocate_only(
2928 &self,
2929 policy: &ScalePolicy,
2930 demand: u32,
2931 launched: &[RunnerAttempt],
2932 ) -> Result<Allocation, AllocationFailure> {
2933 let mut host = self.host.clone();
2936 host.host_capacity = self
2937 .launcher
2938 .host_capacity(&self.host)
2939 .await
2940 .map_err(AllocationFailure::HostCapacity)?;
2941 let attempts = self
2942 .host_attempts(launched)
2943 .await
2944 .map_err(AllocationFailure::Attempts)?;
2945 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
2946 Ok(allocator.allocate(policy, demand))
2947 }
2948
2949 async fn start_runners(
2971 &self,
2972 policy: &ScalePolicy,
2973 demand: u32,
2974 report: &mut ReconcileReport,
2975 launched: &mut Vec<RunnerAttempt>,
2976 ) {
2977 let intent = match self.allocate_only(policy, demand, launched).await {
2989 Ok(intent) => intent,
2990 Err(failure) => {
2991 self.report_allocation_failure(report, &failure);
2992 return;
2993 }
2994 };
2995
2996 let mut decided: Option<Allocation> = None;
3000 let mut budget = intent.to_start;
3001
3002 while budget > 0 {
3003 let waiting_since = std::time::Instant::now();
3004 let guard = match self.lock.acquire().await {
3005 Ok(guard) => guard,
3006 Err(_) => {
3007 report.deferred = report.deferred.saturating_add(budget);
3010 self.events.emit(LifecycleEvent::AllocationDeferred {
3011 policy: policy.id,
3012 count: budget,
3013 });
3014 break;
3015 }
3016 };
3017 let lock_wait = waiting_since.elapsed();
3018
3019 let allocation = match self.allocate_only(policy, demand, launched).await {
3025 Ok(allocation) => allocation,
3026 Err(failure) => {
3027 drop(guard);
3028 self.report_allocation_failure(report, &failure);
3029 break;
3030 }
3031 };
3032
3033 if decided.is_none() {
3034 budget = budget.min(allocation.to_start);
3038 decided = Some(allocation.clone());
3039 }
3040
3041 if allocation.starts_nothing() || budget == 0 {
3044 drop(guard);
3045 break;
3046 }
3047
3048 let created = self
3049 .launcher
3050 .launch(LaunchRequest {
3051 host: &self.host,
3052 policy,
3053 allocation_guard: &guard,
3054 lock_wait,
3055 })
3056 .await;
3057 drop(guard);
3058
3059 match created {
3060 Ok(attempt) => {
3061 let id = attempt.id;
3062 launched.push(attempt);
3066 self.director.launched(policy, id);
3070 report.started = report.started.saturating_add(1);
3071 budget -= 1;
3072 self.events.emit(LifecycleEvent::RunnerStarted {
3073 policy: policy.id,
3074 attempt: id,
3075 });
3076 }
3077 Err(failure) => {
3078 self.events.emit(LifecycleEvent::RunnerStartFailed {
3079 policy: policy.id,
3080 reason: failure_reason_kind(&failure.reason),
3081 });
3082 break;
3083 }
3084 }
3085 }
3086
3087 let allocation = decided.unwrap_or(intent);
3088 self.emit_allocation(&allocation);
3089 report.allocations.push(allocation);
3090 }
3091
3092 async fn host_attempts(
3105 &self,
3106 launched: &[RunnerAttempt],
3107 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3108 let mut attempts = self.launcher.attempts().await?;
3109
3110 debug_assert!(
3117 launched
3118 .iter()
3119 .map(|attempt| attempt.id)
3120 .collect::<BTreeSet<AttemptId>>()
3121 .len()
3122 == launched.len(),
3123 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
3124 the host ceiling is enforced against a set keyed on that identifier, so a \
3125 duplicate is two runtimes counted as one"
3126 );
3127
3128 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
3129 attempts.extend(
3130 launched
3131 .iter()
3132 .filter(|attempt| !known.contains(&attempt.id))
3133 .cloned(),
3134 );
3135 Ok(attempts)
3136 }
3137
3138 fn report_allocation_failure(&self, report: &mut ReconcileReport, failure: &AllocationFailure) {
3139 match failure {
3140 AllocationFailure::HostCapacity(reason) => {
3141 report.host_capacity_unreadable = report.host_capacity_unreadable.saturating_add(1);
3142 self.events
3143 .emit(LifecycleEvent::HostCapacityUnreadable { reason: *reason });
3144 }
3145 AllocationFailure::Attempts(failure) => {
3146 self.report_unreadable_attempts(report, failure)
3147 }
3148 }
3149 }
3150
3151 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
3158 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
3159 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3166 reason: failure_reason_kind(&failure.reason),
3167 });
3168 }
3169
3170 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
3176 let attempts = match self.launcher.attempts().await {
3177 Ok(attempts) => attempts,
3178 Err(failure) => {
3179 self.report_unreadable_attempts(report, &failure);
3180 return;
3181 }
3182 };
3183 self.clean_backoff
3184 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
3185 let now = self.clock.now();
3186 for attempt in attempts {
3187 if !attempt.state().is_concluded() {
3188 continue;
3189 }
3190 let Some(outcome) = attempt.outcome() else {
3191 continue;
3192 };
3193 if !self.clean_backoff.is_due(attempt.id, now) {
3196 continue;
3197 }
3198 let kind = OutcomeKind::of(outcome);
3199 match self.launcher.clean(attempt.id).await {
3200 Ok(()) => {
3201 self.clean_backoff.succeeded(attempt.id);
3202 report.cleaned = report.cleaned.saturating_add(1);
3203 if kind.is_failure() {
3204 report.failures = report.failures.saturating_add(1);
3205 } else if kind == OutcomeKind::IdleExit {
3206 report.idle_exits = report.idle_exits.saturating_add(1);
3211 }
3212 self.events.emit(LifecycleEvent::AttemptCleaned {
3213 policy: attempt.policy_id,
3214 attempt: attempt.id,
3215 outcome: kind,
3216 });
3217 }
3218 Err(failure) => {
3228 self.clean_backoff.failed(attempt.id, now);
3229 report.clean_failures = report.clean_failures.saturating_add(1);
3230 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3231 policy: attempt.policy_id,
3232 attempt: attempt.id,
3233 reason: failure_reason_kind(&failure.reason),
3234 });
3235 }
3236 }
3237 }
3238 }
3239
3240 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
3248 let mut report = ScaleDownReport::default();
3249 let attempts = match self.launcher.attempts().await {
3250 Ok(attempts) => attempts,
3251 Err(failure) => {
3252 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3257 reason: failure_reason_kind(&failure.reason),
3258 });
3259 report.attempts_unreadable = true;
3260 return report;
3261 }
3262 };
3263 for attempt in attempts {
3264 if attempt.policy_id != policy.id {
3265 continue;
3266 }
3267 match attempt.state() {
3268 AttemptState::Busy => {
3269 report.refused_busy = report.refused_busy.saturating_add(1);
3270 self.events.emit(LifecycleEvent::ScaleDownRefused {
3271 policy: policy.id,
3272 attempt: attempt.id,
3273 });
3274 }
3275 state if state.is_concluded() => {
3276 let kind = attempt
3277 .outcome()
3278 .map_or(OutcomeKind::Failed, OutcomeKind::of);
3279 match self.launcher.clean(attempt.id).await {
3280 Ok(()) => {
3281 report.removed = report.removed.saturating_add(1);
3282 self.events.emit(LifecycleEvent::AttemptCleaned {
3283 policy: policy.id,
3284 attempt: attempt.id,
3285 outcome: kind,
3286 });
3287 }
3288 Err(failure) => {
3289 report.clean_failures = report.clean_failures.saturating_add(1);
3290 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3291 policy: policy.id,
3292 attempt: attempt.id,
3293 reason: failure_reason_kind(&failure.reason),
3294 });
3295 }
3296 }
3297 }
3298 AttemptState::Cleaned => {}
3299 _ => report.retained = report.retained.saturating_add(1),
3302 }
3303 }
3304 report
3305 }
3306
3307 fn emit_allocation(&self, allocation: &Allocation) {
3308 self.events.emit(LifecycleEvent::Allocated {
3309 policy: allocation.policy_id,
3310 demand: allocation.demand,
3311 desired: allocation.desired,
3312 active_owned: allocation.active_owned,
3313 headroom: allocation.headroom_before,
3314 to_start: allocation.to_start,
3315 limiting: allocation.limiting_factor,
3316 });
3317 }
3318}
3319
3320fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
3344 let Some(labels) = policy.routing_labels() else {
3345 debug_assert!(
3346 false,
3347 "a monitor-only policy is skipped before the demand poll (D19)"
3348 );
3349 return DemandTally::default();
3350 };
3351
3352 match &policy.target {
3353 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
3354 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
3355 }
3356}
3357
3358fn route_demand(
3362 all_policies: &[ScalePolicy],
3363 profiles: &[&ScalePolicy],
3364 reading: &QueuedDemand,
3365) -> (
3366 BTreeMap<PolicyId, DemandTally>,
3367 BTreeMap<RoutingRefusal, u32>,
3368) {
3369 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
3370 .iter()
3371 .map(|policy| (policy.id, demand_for(policy, reading)))
3372 .collect();
3373 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
3374 let Some(first) = profiles.first() else {
3375 return (tallies, refusals);
3376 };
3377 let owners: Vec<_> = all_policies
3378 .iter()
3379 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3380 .filter(|policy| policy.owns_runners())
3381 .collect();
3382 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3383 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3384 ScaleTarget::Organization(_) => reading.jobs().collect(),
3385 };
3386 for job in jobs {
3387 let Ok(_) = job.required_labels() else {
3388 continue;
3390 };
3391 let matched: Vec<PolicyId> = owners
3392 .iter()
3393 .filter(|policy| {
3394 policy
3395 .routing_labels()
3396 .is_some_and(|labels| labels.matches(job).is_match())
3397 })
3398 .map(|policy| policy.id)
3399 .collect();
3400 if matched.len() > 1 {
3401 *refusals
3402 .entry(RoutingRefusal::OverlappingProfiles)
3403 .or_default() += 1;
3404 for id in matched {
3405 if let Some(tally) = tallies.get_mut(&id) {
3406 tally.matched -= 1;
3407 tally.not_matched += 1;
3408 }
3409 }
3410 }
3411 }
3412 (tallies, refusals)
3413}
3414
3415const fn severity(state: &RefreshState) -> u8 {
3422 match state {
3423 RefreshState::Offline => 5,
3424 RefreshState::RateLimited(_) => 4,
3425 RefreshState::LockedOut { .. } => 3,
3426 RefreshState::Unauthorized => 2,
3427 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3428 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3429 }
3430}
3431
3432const fn unreadable_reason(state: &RefreshState) -> &'static str {
3443 match state {
3444 RefreshState::Ready(_) => "ready",
3445 RefreshState::Offline => "offline",
3446 RefreshState::RateLimited(_) => "rate_limited",
3447 RefreshState::LockedOut { .. } => "locked_out",
3448 RefreshState::Unauthorized => "unauthorized",
3449 RefreshState::Forbidden { .. } => "forbidden",
3450 RefreshState::Failed { .. } => "failed",
3451 RefreshState::Cancelled => "cancelled",
3452 }
3453}
3454
3455#[cfg(test)]
3456mod tests {
3457 use super::*;
3458
3459 #[test]
3467 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
3468 let mut report = ReconcileReport::default();
3469 assert!(
3470 !report.reached_github(),
3471 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
3472 monitor-only -- reached nobody. This is the case the old guard let through, and \
3473 the only one it ever let through."
3474 );
3475
3476 report.unreadable.push(PolicyId::from_u128(1));
3477 assert!(
3478 !report.reached_github(),
3479 "every target this pass tried was unreadable, so there is no contact to record"
3480 );
3481
3482 report.targets_read = 1;
3483 assert!(
3484 report.reached_github(),
3485 "one target answering is contact, whatever else failed alongside it"
3486 );
3487
3488 let mut unowned = ReconcileReport::default();
3492 unowned.unreadable.push(PolicyId::from_u128(2));
3493 unowned.allocations.push(Allocation {
3494 policy_id: PolicyId::from_u128(2),
3495 demand: 0,
3496 desired: 0,
3497 active_owned: 0,
3498 headroom_before: 0,
3499 to_start: 0,
3500 limiting_factor: LimitingFactor::Demand,
3501 });
3502 assert!(
3503 !unowned.reached_github(),
3504 "an allocation is not evidence that GitHub answered"
3505 );
3506 }
3507
3508 #[test]
3518 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
3519 assert!(
3520 severity(&RefreshState::Unauthorized) > 0,
3521 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
3522 or a pass where every target was refused would report no failure at all"
3523 );
3524 }
3525
3526 use std::sync::atomic::AtomicUsize;
3527
3528 use std::num::NonZeroU16;
3529
3530 use runner_manager_domain::attempt::PersistedAttempt;
3531 use runner_manager_domain::model::{CachePolicy, HostId};
3532 use runner_manager_domain::policy::PolicyMode;
3533 use runner_manager_domain::workspace::WorkspaceKind;
3534 use runner_manager_github::rest::RateLimited;
3535 use runner_manager_testkit::clock::FakeClock;
3536 use runner_manager_testkit::fixtures;
3537 use runner_manager_testkit::github::FakeGithub;
3538
3539 fn host_with(capacity: u16) -> Host {
3544 fixtures::host().capacity(capacity).build()
3545 }
3546
3547 fn repo(raw: &str) -> OwnerRepo {
3548 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
3549 }
3550
3551 use runner_manager_domain::policy::RunsOn;
3553
3554 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
3555 fixtures::policy()
3556 .id(PolicyId::from_u128(id))
3557 .repository(target)
3558 .autoscale("home", max)
3559 .active()
3560 .build()
3561 }
3562
3563 const HOST_LABEL: &str = "rm-home-win-x64";
3570
3571 fn jobs(n: usize) -> Vec<RunsOn> {
3577 fixtures::queued_jobs(&[HOST_LABEL], n)
3578 }
3579
3580 #[derive(Debug, Default)]
3582 struct FakeLauncher {
3583 capacity: Mutex<Option<NonZeroU16>>,
3584 capacity_unreadable: AtomicBool,
3585 capacity_after_launch: Mutex<Option<NonZeroU16>>,
3586 attempts: Mutex<Vec<RunnerAttempt>>,
3587 next_id: AtomicU64,
3588 launches: AtomicUsize,
3589 cleaned: Mutex<Vec<AttemptId>>,
3590 yields_before_recording: usize,
3593 forgetful: bool,
3597 fail_next: Mutex<Option<FailureReason>>,
3598 attempts_fail: Mutex<bool>,
3601 clean_fails: bool,
3604 replacements: Mutex<Vec<ReplacementIntent>>,
3605 }
3606
3607 impl FakeLauncher {
3608 fn new() -> Self {
3609 Self::default()
3610 }
3611
3612 fn with_yields(mut self, yields: usize) -> Self {
3613 self.yields_before_recording = yields;
3614 self
3615 }
3616
3617 fn forgetful() -> Self {
3618 Self {
3619 forgetful: true,
3620 ..Self::default()
3621 }
3622 }
3623
3624 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
3625 *self.attempts.lock().unwrap() = attempts;
3626 self
3627 }
3628
3629 fn launches(&self) -> usize {
3630 self.launches.load(Ordering::SeqCst)
3631 }
3632
3633 fn snapshot(&self) -> Vec<RunnerAttempt> {
3634 self.attempts.lock().unwrap().clone()
3635 }
3636
3637 fn live_count(&self) -> usize {
3638 self.snapshot()
3639 .iter()
3640 .filter(|a| a.counts_against_capacity())
3641 .count()
3642 }
3643
3644 fn fail_next(&self, reason: FailureReason) {
3645 *self.fail_next.lock().unwrap() = Some(reason);
3646 }
3647
3648 fn fail_attempts(&self, failing: bool) {
3649 *self.attempts_fail.lock().unwrap() = failing;
3650 }
3651
3652 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
3653 Self {
3654 clean_fails: true,
3655 ..Self::default()
3656 }
3657 .seeded(attempts)
3658 }
3659
3660 fn cleaned(&self) -> Vec<AttemptId> {
3661 self.cleaned.lock().unwrap().clone()
3662 }
3663
3664 fn replacing(self, intent: ReplacementIntent) -> Self {
3665 self.replacements.lock().unwrap().push(intent);
3666 self
3667 }
3668 }
3669
3670 #[async_trait::async_trait]
3671 impl RunnerLauncher for FakeLauncher {
3672 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
3673 if self.capacity_unreadable.load(Ordering::SeqCst) {
3674 return Err(HostCapacityError::JournalUnreadable);
3675 }
3676 Ok(self.capacity.lock().unwrap().unwrap_or(host.host_capacity))
3677 }
3678
3679 async fn supervise(
3680 &self,
3681 policy: &ScalePolicy,
3682 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3683 let mut replacements = self.replacements.lock().unwrap();
3684 let selected: Vec<_> = replacements
3685 .extract_if(.., |intent| intent.policy == policy.id)
3686 .collect();
3687 if !selected.is_empty() {
3688 let retired: BTreeSet<_> = selected
3689 .iter()
3690 .map(|intent| intent.previous_attempt)
3691 .collect();
3692 self.attempts
3693 .lock()
3694 .unwrap()
3695 .retain(|attempt| !retired.contains(&attempt.id));
3696 }
3697 Ok(selected)
3698 }
3699
3700 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3701 if *self.attempts_fail.lock().unwrap() {
3702 return Err(LaunchFailure::new(FailureReason::Other(
3703 "the journal could not be read".into(),
3704 )));
3705 }
3706 Ok(self.snapshot())
3707 }
3708
3709 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3710 if let Some(reason) = self.fail_next.lock().unwrap().take() {
3711 return Err(LaunchFailure::new(reason));
3712 }
3713 for _ in 0..self.yields_before_recording {
3715 tokio::task::yield_now().await;
3716 }
3717 let id =
3718 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
3719 let created = RunnerAttempt::allocate(
3720 id,
3721 request.policy.id,
3722 "runtime/p/a",
3723 request.host.created_at,
3724 );
3725 self.launches.fetch_add(1, Ordering::SeqCst);
3726 if let Some(capacity) = self.capacity_after_launch.lock().unwrap().take() {
3727 *self.capacity.lock().unwrap() = Some(capacity);
3728 }
3729 if !self.forgetful {
3730 self.attempts.lock().unwrap().push(created.clone());
3731 }
3732 Ok(created)
3733 }
3734
3735 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
3736 if self.clean_fails {
3737 return Err(LaunchFailure::new(FailureReason::Other(
3738 "the runtime directory is locked".into(),
3739 )));
3740 }
3741 self.cleaned.lock().unwrap().push(attempt);
3742 let mut attempts = self.attempts.lock().unwrap();
3743 attempts.retain(|a| a.id != attempt);
3744 Ok(())
3745 }
3746 }
3747
3748 #[derive(Debug, Default)]
3750 struct FakeDemand {
3751 outcome: Mutex<Option<PollOutcome>>,
3752 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
3754 scopes: Mutex<Vec<ActivityScope>>,
3755 }
3756
3757 impl FakeDemand {
3758 fn ready(count: u32, repository: &OwnerRepo) -> Self {
3759 let fake = Self::default();
3760 fake.set(PollOutcome::Ready(QueuedDemand::of(
3761 repository.clone(),
3762 jobs(count as usize),
3763 )));
3764 fake
3765 }
3766
3767 fn failing(state: RefreshState) -> Self {
3768 let fake = Self::default();
3769 fake.set(PollOutcome::Failed(state));
3770 fake
3771 }
3772
3773 fn set(&self, outcome: PollOutcome) {
3774 *self.outcome.lock().unwrap() = Some(outcome);
3775 }
3776
3777 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
3779 self.per_target
3780 .lock()
3781 .unwrap()
3782 .insert(target.clone(), outcome);
3783 }
3784
3785 fn polls(&self) -> Vec<ActivityScope> {
3786 self.scopes.lock().unwrap().clone()
3787 }
3788 }
3789
3790 #[async_trait::async_trait]
3791 impl DemandSource for FakeDemand {
3792 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
3793 self.scopes.lock().unwrap().push(scope.clone());
3794 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
3795 return outcome.clone();
3796 }
3797 self.outcome
3798 .lock()
3799 .unwrap()
3800 .clone()
3801 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
3802 }
3803 }
3804
3805 #[derive(Debug, Default)]
3806 struct FakeDirectory {
3807 repositories: Vec<OwnerRepo>,
3808 calls: AtomicUsize,
3809 }
3810
3811 impl FakeDirectory {
3812 fn of(repositories: Vec<OwnerRepo>) -> Self {
3813 Self {
3814 repositories,
3815 calls: AtomicUsize::new(0),
3816 }
3817 }
3818
3819 fn calls(&self) -> usize {
3820 self.calls.load(Ordering::SeqCst)
3821 }
3822 }
3823
3824 #[async_trait::async_trait]
3825 impl RepositoryDirectory for FakeDirectory {
3826 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
3827 self.calls.fetch_add(1, Ordering::SeqCst);
3828 Ok(self.repositories.clone())
3829 }
3830 }
3831
3832 #[derive(Debug)]
3838 struct CountingLock {
3839 inner: InProcessAllocationLock,
3840 concurrent: Arc<AtomicUsize>,
3841 peak: Arc<AtomicUsize>,
3842 acquisitions: Arc<AtomicUsize>,
3843 }
3844
3845 impl CountingLock {
3846 fn new() -> Self {
3847 Self {
3848 inner: InProcessAllocationLock::new(),
3849 concurrent: Arc::new(AtomicUsize::new(0)),
3850 peak: Arc::new(AtomicUsize::new(0)),
3851 acquisitions: Arc::new(AtomicUsize::new(0)),
3852 }
3853 }
3854
3855 fn peak(&self) -> usize {
3856 self.peak.load(Ordering::SeqCst)
3857 }
3858
3859 fn acquisitions(&self) -> usize {
3860 self.acquisitions.load(Ordering::SeqCst)
3861 }
3862 }
3863
3864 #[derive(Debug)]
3865 struct CountingGuard {
3866 _inner: AllocationGuard,
3867 concurrent: Arc<AtomicUsize>,
3868 }
3869
3870 impl Drop for CountingGuard {
3871 fn drop(&mut self) {
3872 self.concurrent.fetch_sub(1, Ordering::SeqCst);
3873 }
3874 }
3875
3876 #[async_trait::async_trait]
3877 impl AllocationLock for CountingLock {
3878 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3879 let inner = self.inner.acquire().await?;
3880 self.acquisitions.fetch_add(1, Ordering::SeqCst);
3881 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
3882 self.peak.fetch_max(now, Ordering::SeqCst);
3883 Ok(AllocationGuard::new(CountingGuard {
3884 _inner: inner,
3885 concurrent: Arc::clone(&self.concurrent),
3886 }))
3887 }
3888 }
3889
3890 #[derive(Debug, Default)]
3893 struct NoLock;
3894
3895 #[async_trait::async_trait]
3896 impl AllocationLock for NoLock {
3897 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3898 Ok(AllocationGuard::new(()))
3899 }
3900 }
3901
3902 #[tokio::test]
3903 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
3904 use runner_manager_platform::wsl::fence::DrainRequest;
3905
3906 let (_local, shared, paths) = guest_paths();
3907 DrainRequest::new(4, chrono::Utc::now())
3908 .write(shared.path())
3909 .unwrap();
3910 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3911
3912 assert!(lock.acquire().await.is_err());
3913 }
3914
3915 #[tokio::test]
3916 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
3917 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3918
3919 let (_local, shared, paths) = guest_paths();
3920 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3921 let guard = lock.acquire().await.unwrap();
3922 assert!(shared.path().join(FENCE_DIRECTORY).exists());
3923 drop(guard);
3924 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3925 }
3926
3927 #[derive(Clone, Default)]
3929 struct Logs(Arc<Mutex<Vec<u8>>>);
3930
3931 impl std::io::Write for Logs {
3932 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3933 self.0.lock().unwrap().extend_from_slice(buf);
3934 Ok(buf.len())
3935 }
3936 fn flush(&mut self) -> std::io::Result<()> {
3937 Ok(())
3938 }
3939 }
3940
3941 #[tokio::test]
3944 async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
3945 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3946
3947 let (_local, shared, paths) = guest_paths();
3948 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3949 let guard = lock.acquire().await.unwrap();
3950 let fence = shared.path().join(FENCE_DIRECTORY);
3953 std::fs::remove_dir_all(&fence).unwrap();
3954 std::fs::write(&fence, b"not a directory").unwrap();
3955
3956 let logs = Logs::default();
3957 let writer = logs.clone();
3958 let subscriber = tracing_subscriber::fmt()
3959 .with_writer(move || writer.clone())
3960 .with_ansi(false)
3961 .finish();
3962 tracing::subscriber::with_default(subscriber, || drop(guard));
3963
3964 let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
3965 assert!(text.contains("WARN"), "{text}");
3966 assert!(text.contains("could not be released"), "{text}");
3967 assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
3968 }
3969
3970 fn guest_paths() -> (
3971 tempfile::TempDir,
3972 tempfile::TempDir,
3973 Arc<runner_manager_platform::paths::AppPaths>,
3974 ) {
3975 use runner_manager_platform::paths::AppPaths;
3976 use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
3977
3978 let local = tempfile::tempdir().unwrap();
3979 let shared = tempfile::tempdir().unwrap();
3980 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3981 paths.create_all().unwrap();
3982 GuestRecoveryConfig::new(shared.path().to_path_buf())
3983 .write(&paths)
3984 .unwrap();
3985 (local, shared, paths)
3986 }
3987
3988 #[tokio::test]
3991 async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
3992 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
3993
3994 let (_local, shared, paths) = guest_paths();
3995 let fence = shared.path().join(FENCE_DIRECTORY);
3996 std::fs::create_dir_all(&fence).unwrap();
3997 let pid = std::process::id();
3998 std::fs::write(
3999 fence.join(OWNER_FILE),
4000 format!(
4001 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"}}}}"#
4002 ),
4003 )
4004 .unwrap();
4005 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4006
4007 let guard = lock.acquire().await.expect("the stale claim was reclaimed");
4008 drop(guard);
4009 assert!(!fence.exists());
4010 }
4011
4012 #[tokio::test]
4013 async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
4014 use runner_manager_platform::launch_health::recorded_launches_blocked;
4015 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4016
4017 let (_local, shared, paths) = guest_paths();
4018 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4019 .with_blocked_after(3, Duration::ZERO);
4020 let windows =
4022 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4023 .unwrap()
4024 .unwrap();
4025
4026 for _ in 0..2 {
4027 assert!(lock.acquire().await.is_err());
4028 }
4029 assert_eq!(
4030 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4031 None,
4032 "not yet"
4033 );
4034 assert!(lock.acquire().await.is_err());
4035 let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
4036 .unwrap()
4037 .expect("three refusals in a row are recorded");
4038 assert!(
4039 blocked.reason.contains("Windows WSL recovery"),
4040 "{blocked:?}"
4041 );
4042
4043 windows.release().unwrap();
4044 let guard = lock.acquire().await.unwrap();
4045 assert_eq!(
4046 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4047 None
4048 );
4049 drop(guard);
4050 }
4051
4052 #[tokio::test]
4053 async fn refusals_while_this_process_is_launching_are_its_own_contention() {
4054 use runner_manager_platform::launch_health::recorded_launches_blocked;
4055
4056 let (_local, _shared, paths) = guest_paths();
4057 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4058 .with_blocked_after(1, Duration::ZERO);
4059 let launching = lock.acquire().await.unwrap();
4060 for _ in 0..3 {
4061 assert!(lock.acquire().await.is_err());
4062 }
4063 assert_eq!(
4064 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4065 None
4066 );
4067 drop(launching);
4068 }
4069
4070 #[tokio::test]
4073 async fn refusals_with_quiet_spells_between_them_never_add_up() {
4074 use runner_manager_platform::launch_health::recorded_launches_blocked;
4075 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4076
4077 let (_local, shared, paths) = guest_paths();
4078 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4079 .with_blocked_after(3, Duration::ZERO)
4080 .with_streak_gap(Duration::ZERO);
4081 let windows =
4082 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4083 .unwrap()
4084 .unwrap();
4085 for _ in 0..5 {
4086 assert!(lock.acquire().await.is_err());
4087 }
4088 assert_eq!(
4089 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4090 None
4091 );
4092 windows.release().unwrap();
4093 }
4094
4095 #[tokio::test]
4098 async fn a_claim_refused_by_a_drain_request_is_released() {
4099 use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
4100
4101 let (_local, shared, paths) = guest_paths();
4102 DrainRequest::new(4, chrono::Utc::now())
4103 .write(shared.path())
4104 .unwrap();
4105 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4106 assert!(lock.acquire().await.is_err());
4107 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4108 }
4109
4110 #[derive(Debug, Default)]
4112 struct HeldLock;
4113
4114 #[async_trait::async_trait]
4115 impl AllocationLock for HeldLock {
4116 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4117 Err(AllocationLockBusy)
4118 }
4119 }
4120
4121 struct Harness {
4123 launcher: Arc<FakeLauncher>,
4124 demand: Arc<FakeDemand>,
4125 events: Arc<EventLog>,
4126 clock: Arc<FakeClock>,
4127 reconciler: Reconciler,
4128 }
4129
4130 impl Harness {
4131 fn build(
4132 host: Host,
4133 launcher: Arc<FakeLauncher>,
4134 demand: Arc<FakeDemand>,
4135 lock: Arc<dyn AllocationLock>,
4136 ) -> Self {
4137 let events = Arc::new(EventLog::new());
4138 let clock = Arc::new(FakeClock::default());
4139 let reconciler = Reconciler::new(
4140 host,
4141 ReconcilerPorts {
4142 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4143 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4144 lock,
4145 directory: Arc::new(FakeDirectory::default()),
4146 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4147 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
4148 events: Arc::clone(&events) as Arc<dyn EventSink>,
4149 },
4150 );
4151 Self {
4152 launcher,
4153 demand,
4154 events,
4155 clock,
4156 reconciler,
4157 }
4158 }
4159
4160 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
4161 let launcher = Arc::new(FakeLauncher::new());
4162 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
4163 Self::build(
4164 host_with(capacity),
4165 launcher,
4166 demand,
4167 Arc::new(InProcessAllocationLock::new()),
4168 )
4169 }
4170 }
4171
4172 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
4173 let outcome = state.is_terminal().then(|| match state {
4174 AttemptState::Failed => {
4175 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
4176 }
4177 AttemptState::Orphaned => AttemptOutcome::Orphaned,
4178 _ => AttemptOutcome::CompletedJob,
4179 });
4180 RunnerAttempt::from_persisted(PersistedAttempt {
4181 id: AttemptId::from_u128(id),
4182 policy_id: PolicyId::from_u128(policy),
4183 github_runner_id: None,
4184 state,
4185 outcome,
4186 process_id: None,
4187 execution: runner_manager_domain::execution::AttemptExecution::Native {
4188 process_id: None,
4189 },
4190 runtime_path: "runtime/p/a".into(),
4191 workspace_kind: WorkspaceKind::Ephemeral,
4192 workspace_slot: None,
4193 created_at: fixtures::created_at(),
4194 terminal_at: state.is_terminal().then(fixtures::created_at),
4195 last_state_change_at: fixtures::created_at(),
4196 })
4197 .expect("a state/outcome pair the domain accepts")
4198 }
4199
4200 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
4202 RunnerAttempt::from_persisted(PersistedAttempt {
4203 id: AttemptId::from_u128(id),
4204 policy_id: PolicyId::from_u128(policy),
4205 github_runner_id: None,
4206 state: outcome.terminal_state(),
4207 outcome: Some(outcome),
4208 process_id: None,
4209 execution: runner_manager_domain::execution::AttemptExecution::Native {
4210 process_id: None,
4211 },
4212 runtime_path: "runtime/p/a".into(),
4213 workspace_kind: WorkspaceKind::Ephemeral,
4214 workspace_slot: None,
4215 created_at: fixtures::created_at(),
4216 terminal_at: Some(fixtures::created_at()),
4217 last_state_change_at: fixtures::created_at(),
4218 })
4219 .expect("a state/outcome pair the domain accepts")
4220 }
4221
4222 #[tokio::test]
4251 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
4252 let mut harness = Harness::simple(4, 1, "acme/app");
4253 let policy = policy(1, "acme/app", 4);
4254
4255 for poll in 1..=3 {
4256 let report = harness
4257 .reconciler
4258 .reconcile(std::slice::from_ref(&policy))
4259 .await;
4260 assert_eq!(
4261 harness.launcher.launches(),
4262 1,
4263 "poll {poll} started another runner for a job already being served; the \
4264 `- active_owned_runners` term reached `HostAllocator` as a set this host \
4265 does not hold"
4266 );
4267 assert_eq!(report.allocations.len(), 1);
4268 let allocation = &report.allocations[0];
4269 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
4270 if poll == 1 {
4271 assert_eq!(allocation.to_start, 1);
4272 assert_eq!(report.started, 1);
4273 } else {
4274 assert_eq!(allocation.active_owned, 1, "poll {poll}");
4275 assert_eq!(allocation.to_start, 0, "poll {poll}");
4276 assert_eq!(report.started, 0, "poll {poll}");
4277 }
4278 }
4279 assert_eq!(harness.launcher.live_count(), 1);
4280 }
4281
4282 #[tokio::test]
4283 async fn draining_parallel_workflow_starts_runners_for_the_remaining_queue() {
4284 let mut harness = Harness::simple(10, 8, "acme/app");
4285 let policy = policy(1, "acme/app", 10);
4286 let first = harness
4287 .reconciler
4288 .reconcile(std::slice::from_ref(&policy))
4289 .await;
4290 assert_eq!(first.started, 8);
4291
4292 let attempts = harness.launcher.snapshot();
4294 *harness.launcher.attempts.lock().unwrap() = attempts
4295 .iter()
4296 .take(3)
4297 .map(|attempt| attempt_in(AttemptState::Busy, attempt.id.as_uuid().as_u128(), 1))
4298 .collect();
4299 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4300 repo("acme/app"),
4301 jobs(2),
4302 )));
4303
4304 for poll in 0..3 {
4305 let report = harness
4306 .reconciler
4307 .reconcile(std::slice::from_ref(&policy))
4308 .await;
4309 assert_eq!(report.started, if poll == 0 { 2 } else { 0 });
4310 assert_eq!(harness.launcher.live_count(), 5);
4311 }
4312 }
4313
4314 #[tokio::test]
4325 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4326 let launcher = Arc::new(FakeLauncher::forgetful());
4327 let mut harness = Harness::build(
4328 host_with(64),
4329 Arc::clone(&launcher),
4330 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4331 Arc::new(InProcessAllocationLock::new()),
4332 );
4333
4334 let report = harness
4335 .reconciler
4336 .reconcile(&[policy(1, "acme/app", 8)])
4337 .await;
4338
4339 assert_eq!(
4340 report.started, 3,
4341 "the pass is bounded by the grant it was given, not by the attempt set catching \
4342 up with it"
4343 );
4344 assert_eq!(launcher.launches(), 3);
4345 assert!(
4346 launcher.snapshot().is_empty(),
4347 "the launcher never recorded anything, which is the whole point of the fixture"
4348 );
4349 }
4350
4351 #[tokio::test]
4363 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4364 let launcher = Arc::new(FakeLauncher::forgetful());
4365 let demand = Arc::new(FakeDemand::default());
4366 demand.set_for(
4367 &ScaleTarget::repository("acme/left").unwrap(),
4368 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4369 );
4370 demand.set_for(
4371 &ScaleTarget::repository("acme/right").unwrap(),
4372 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4373 );
4374 let mut harness = Harness::build(
4375 host_with(3),
4376 Arc::clone(&launcher),
4377 demand,
4378 Arc::new(InProcessAllocationLock::new()),
4379 );
4380
4381 let report = harness
4382 .reconciler
4383 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4384 .await;
4385
4386 assert_eq!(
4387 report.started, 3,
4388 "host_capacity is 3 and two policies each allowed 3 started {} runners \
4389 between them; the second policy's grant was computed from a set that did \
4390 not yet contain the first policy's launches",
4391 report.started
4392 );
4393 assert_eq!(launcher.launches(), 3);
4394 }
4395
4396 #[tokio::test]
4407 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
4408 let launcher = Arc::new(FakeLauncher::new());
4409 let mut harness = Harness::build(
4410 host_with(8),
4411 Arc::clone(&launcher),
4412 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
4413 Arc::new(InProcessAllocationLock::new()),
4414 );
4415 let policy = policy(1, "acme/app", 8);
4416
4417 launcher.fail_attempts(true);
4418 let unreadable = harness
4419 .reconciler
4420 .reconcile(std::slice::from_ref(&policy))
4421 .await;
4422
4423 assert_eq!(
4424 unreadable.started, 0,
4425 "nothing may be decided from a set that was not read"
4426 );
4427 assert_eq!(launcher.launches(), 0);
4428 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
4429 assert!(
4430 unreadable.allocations.is_empty(),
4431 "no allocation is reported either: there was no set to compute one from, and \
4432 an allocation of zero would claim a decision nobody made"
4433 );
4434 assert!(harness.events.count_of("attempts_unreadable") > 0);
4435
4436 launcher.fail_attempts(false);
4438 let readable = harness
4439 .reconciler
4440 .reconcile(std::slice::from_ref(&policy))
4441 .await;
4442 assert_eq!(
4443 readable.started, 4,
4444 "the difference between the two passes is only whether the set could be read"
4445 );
4446 assert_eq!(readable.attempts_unreadable, 0);
4447 }
4448
4449 #[tokio::test]
4459 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
4460 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4461 1,
4462 1,
4463 AttemptOutcome::ExitedIdleWithoutWork,
4464 )]));
4465 let mut harness = Harness::build(
4466 host_with(4),
4467 Arc::clone(&launcher),
4468 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4469 Arc::new(InProcessAllocationLock::new()),
4470 );
4471
4472 let report = harness
4473 .reconciler
4474 .reconcile(&[policy(1, "acme/app", 4)])
4475 .await;
4476
4477 assert_eq!(report.cleaned, 0);
4478 assert_eq!(report.clean_failures, 1);
4479 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
4480 assert_eq!(
4481 harness.events.count_of("attempt_cleaned"),
4482 0,
4483 "and it is not reported as cleaned"
4484 );
4485 assert_eq!(
4486 launcher.snapshot().len(),
4487 1,
4488 "the attempt is still there, so the retry is real -- what changed is that it \
4489 is no longer silent"
4490 );
4491
4492 let reasons: Vec<&'static str> = harness
4495 .events
4496 .events()
4497 .into_iter()
4498 .filter_map(|event| match event {
4499 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
4500 _ => None,
4501 })
4502 .collect();
4503 assert_eq!(reasons, vec!["other"]);
4504 }
4505
4506 #[tokio::test]
4509 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
4510 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4511 1,
4512 1,
4513 AttemptOutcome::ExitedIdleWithoutWork,
4514 )]));
4515 let mut harness = Harness::build(
4516 host_with(4),
4517 Arc::clone(&launcher),
4518 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4519 Arc::new(InProcessAllocationLock::new()),
4520 );
4521 let policies = [policy(1, "acme/app", 4)];
4522 let start = harness.clock.now();
4523 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
4524 let mut failures_at = async |offset: Duration| {
4525 harness.clock.set(at(offset));
4526 harness.reconciler.reconcile(&policies).await.clean_failures
4527 };
4528
4529 assert_eq!(failures_at(Duration::ZERO).await, 1);
4530 assert_eq!(
4532 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
4533 0,
4534 "a failed cleanup was retried on the very next pass"
4535 );
4536 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
4537 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
4539 assert_eq!(
4540 failures_at(second - Duration::from_secs(1)).await,
4541 0,
4542 "the delay did not double after a second failure"
4543 );
4544 assert_eq!(failures_at(second).await, 1);
4545 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
4546
4547 let mut backoff = CleanBackoff::default();
4549 let id = AttemptId::from_u128(9);
4550 for _ in 0..40 {
4551 backoff.failed(id, start);
4552 }
4553 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
4554 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
4555 backoff.succeeded(id);
4556 assert!(
4557 backoff.is_due(id, start),
4558 "a cleaned attempt kept its backoff"
4559 );
4560 }
4561
4562 #[tokio::test]
4575 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
4576 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4577 attempt_in(AttemptState::Busy, 1, 1),
4578 concluded(2, 1, AttemptOutcome::CompletedJob),
4579 ]));
4580 let harness = Harness::build(
4581 host_with(4),
4582 Arc::clone(&launcher),
4583 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4584 Arc::new(InProcessAllocationLock::new()),
4585 );
4586 let policy = policy(1, "acme/app", 4);
4587
4588 launcher.fail_attempts(true);
4589 let blind = harness.reconciler.scale_down(&policy).await;
4590
4591 assert!(!blind.is_conclusive(), "the machine was never read");
4592 assert_ne!(
4593 blind,
4594 ScaleDownReport::default(),
4595 "a scale-down that could not see the host must not be equal to one that saw \
4596 an idle host; that equality is the whole finding"
4597 );
4598 assert_eq!(blind.removed, 0);
4599 assert_eq!(
4600 blind.refused_busy, 0,
4601 "and this zero means `unknown`, not `none`"
4602 );
4603 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
4604
4605 launcher.fail_attempts(false);
4607 let seeing = harness.reconciler.scale_down(&policy).await;
4608
4609 assert!(seeing.is_conclusive());
4610 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
4611 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
4612 assert_ne!(
4613 seeing, blind,
4614 "the difference between the two is only whether the set could be read"
4615 );
4616 }
4617
4618 #[tokio::test]
4626 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
4627 let mut warm = ScalePolicy::new(
4628 PolicyId::from_u128(1),
4629 ScaleTarget::repository("acme/app").unwrap(),
4630 1,
4631 fixtures::HOST_ID,
4632 PolicyMode::autoscale(
4633 fixtures::routing_labels("home"),
4634 2,
4635 NonZeroU16::new(5).expect("non-zero"),
4636 )
4637 .expect("min <= max"),
4638 CachePolicy::default(),
4639 );
4640 warm.activate().expect("pending -> active");
4641
4642 let mut harness = Harness::simple(8, 0, "acme/app");
4643 let report = harness.reconciler.reconcile(&[warm]).await;
4644
4645 assert_eq!(
4646 report.allocations[0].demand, 0,
4647 "GitHub reported no queued runs"
4648 );
4649 assert_eq!(
4650 report.allocations[0].desired, 2,
4651 "min_capacity raised the target above demand"
4652 );
4653 assert_eq!(
4654 report.allocations[0].limiting_factor,
4655 LimitingFactor::MinCapacity
4656 );
4657 assert_eq!(report.started, 2, "and two runners were actually started");
4658 assert_eq!(harness.launcher.live_count(), 2);
4659 }
4660
4661 #[tokio::test]
4666 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
4667 let mut harness = Harness::simple(100, 10, "acme/app");
4668 let report = harness
4669 .reconciler
4670 .reconcile(&[policy(1, "acme/app", 3)])
4671 .await;
4672
4673 assert_eq!(report.allocations[0].demand, 10);
4674 assert_eq!(
4675 report.allocations[0].desired, 3,
4676 "max_capacity beats demand"
4677 );
4678 assert_eq!(report.started, 3);
4679 assert_eq!(
4680 report.allocations[0].limiting_factor,
4681 LimitingFactor::MaxCapacity
4682 );
4683 }
4684
4685 #[tokio::test]
4686 async fn demand_below_min_capacity_starts_nothing_in_v1() {
4687 let mut harness = Harness::simple(8, 0, "acme/app");
4690 let report = harness
4691 .reconciler
4692 .reconcile(&[policy(1, "acme/app", 4)])
4693 .await;
4694
4695 assert_eq!(report.allocations[0].desired, 0);
4696 assert_eq!(report.started, 0);
4697 assert!(report.starts_nothing());
4698 }
4699
4700 #[tokio::test]
4701 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
4702 let policy = policy(1, "octo/repo", 1);
4703 let previous = attempt_in(AttemptState::Starting, 41, 1);
4704 let intent = ReplacementIntent {
4705 policy: policy.id,
4706 previous_attempt: previous.id,
4707 operation: "exit_before_acceptance_replacement",
4708 };
4709 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
4710 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
4711 let mut harness = Harness::build(
4712 host_with(1),
4713 Arc::clone(&launcher),
4714 demand,
4715 Arc::new(InProcessAllocationLock::new()),
4716 );
4717
4718 let report = harness.reconciler.reconcile(&[policy]).await;
4719
4720 assert_eq!(report.replacement_intents, 1);
4721 assert_eq!(report.started, 1);
4722 assert_eq!(launcher.launches(), 1);
4723 assert_eq!(launcher.live_count(), 1);
4724 }
4725
4726 #[tokio::test]
4727 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
4728 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4729 attempt_in(AttemptState::Busy, 1, 1),
4730 attempt_in(AttemptState::Busy, 2, 1),
4731 ]));
4732 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
4733 let mut harness = Harness::build(
4734 host_with(2),
4735 launcher,
4736 demand,
4737 Arc::new(InProcessAllocationLock::new()),
4738 );
4739
4740 let report = harness
4741 .reconciler
4742 .reconcile(&[policy(1, "acme/app", 2)])
4743 .await;
4744 assert_eq!(report.started, 0);
4745 assert_eq!(report.allocations[0].headroom_before, 0);
4746 assert_eq!(harness.launcher.launches(), 0);
4747 }
4748
4749 #[tokio::test]
4750 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
4751 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4754 attempt_in(AttemptState::Busy, 1, 99),
4755 attempt_in(AttemptState::Busy, 2, 99),
4756 attempt_in(AttemptState::Idle, 3, 99),
4757 attempt_in(AttemptState::Starting, 4, 99),
4758 ]));
4759 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
4760 let mut harness = Harness::build(
4761 host_with(6),
4762 launcher,
4763 demand,
4764 Arc::new(InProcessAllocationLock::new()),
4765 );
4766
4767 let report = harness
4768 .reconciler
4769 .reconcile(&[policy(1, "acme/app", 5)])
4770 .await;
4771 assert_eq!(
4772 report.allocations[0].desired, 5,
4773 "its own ceiling allows five"
4774 );
4775 assert_eq!(report.started, 2, "the host has two slots free");
4776 assert_eq!(
4777 report.allocations[0].limiting_factor,
4778 LimitingFactor::HostCapacity
4779 );
4780 assert_eq!(harness.launcher.live_count(), 6);
4781 }
4782
4783 #[tokio::test]
4784 async fn the_idle_host_assertion_holds() {
4785 let mut harness = Harness::simple(8, 0, "acme/app");
4788 let report = harness
4789 .reconciler
4790 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
4791 .await;
4792
4793 assert_eq!(report.started, 0);
4794 assert_eq!(harness.launcher.launches(), 0);
4795 assert!(harness.launcher.snapshot().is_empty());
4796 assert_eq!(
4797 harness
4798 .launcher
4799 .snapshot()
4800 .iter()
4801 .filter(|a| !a.is_terminal())
4802 .count(),
4803 0
4804 );
4805 }
4806
4807 #[tokio::test]
4812 async fn live_capacity_increase_is_used_without_rebuilding_the_reconciler() {
4813 let mut harness = Harness::simple(1, 5, "acme/app");
4814 let policies = [policy(1, "acme/app", 5)];
4815 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
4816
4817 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(4);
4818 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 3);
4819 assert_eq!(harness.launcher.live_count(), 4);
4820 }
4821
4822 #[tokio::test]
4823 async fn live_capacity_decrease_keeps_busy_runners_and_waits_for_headroom() {
4824 let mut harness = Harness::simple(5, 5, "acme/app");
4825 let busy: Vec<_> = (1..=3)
4826 .map(|id| attempt_in(AttemptState::Busy, id, 1))
4827 .collect();
4828 *harness.launcher.attempts.lock().unwrap() = busy.clone();
4829 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
4830 let policies = [policy(1, "acme/app", 5)];
4831
4832 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 0);
4833 assert_eq!(harness.launcher.snapshot(), busy);
4834 assert!(harness.launcher.cleaned().is_empty());
4835
4836 harness.launcher.attempts.lock().unwrap().truncate(1);
4838 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
4839 assert_eq!(harness.launcher.live_count(), 2);
4840 }
4841
4842 #[tokio::test]
4843 async fn live_capacity_is_rechecked_between_launches_in_the_same_pass() {
4844 let mut harness = Harness::simple(5, 5, "acme/app");
4845 *harness.launcher.capacity_after_launch.lock().unwrap() = NonZeroU16::new(1);
4846
4847 let report = harness
4848 .reconciler
4849 .reconcile(&[policy(1, "acme/app", 5)])
4850 .await;
4851 assert_eq!(report.started, 1);
4852 assert_eq!(harness.launcher.live_count(), 1);
4853 }
4854
4855 #[tokio::test]
4856 async fn live_capacity_unreadable_blocks_launches_until_the_journal_recovers() {
4857 let mut harness = Harness::simple(5, 5, "acme/app");
4858 harness
4859 .launcher
4860 .capacity_unreadable
4861 .store(true, Ordering::SeqCst);
4862 let policies = [policy(1, "acme/app", 5)];
4863 let report = harness.reconciler.reconcile(&policies).await;
4864 assert_eq!(report.started, 0);
4865 assert_eq!(report.host_capacity_unreadable, 1);
4866 assert_eq!(report.attempts_unreadable, 0);
4867 assert_eq!(harness.events.count_of("attempts_unreadable"), 0);
4868 assert!(
4869 harness
4870 .events
4871 .events()
4872 .contains(&LifecycleEvent::HostCapacityUnreadable {
4873 reason: HostCapacityError::JournalUnreadable,
4874 })
4875 );
4876
4877 harness
4878 .launcher
4879 .capacity_unreadable
4880 .store(false, Ordering::SeqCst);
4881 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
4882 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 2);
4883 }
4884
4885 #[tokio::test]
4886 async fn unreadable_local_state_does_not_treat_a_busy_host_as_idle() {
4887 for host_failure in [true, false] {
4888 let mut harness = Harness::simple(5, 0, "acme/app");
4889 harness
4890 .reconciler
4891 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
4892 *harness.launcher.attempts.lock().unwrap() = vec![attempt_in(AttemptState::Busy, 1, 1)];
4893 harness
4894 .launcher
4895 .capacity_unreadable
4896 .store(host_failure, Ordering::SeqCst);
4897 harness.launcher.fail_attempts(!host_failure);
4898 let report = harness
4899 .reconciler
4900 .reconcile(&[policy(1, "acme/app", 5)])
4901 .await;
4902 assert_eq!(report.host_capacity_unreadable, u16::from(host_failure));
4903 assert_eq!(report.next_poll.pace, PollPace::Nominal);
4904 assert_eq!(harness.launcher.live_count(), 1);
4905 }
4906 }
4907
4908 #[derive(Debug)]
4909 struct HostEditOnLock {
4910 inner: InProcessAllocationLock,
4911 launcher: Arc<FakeLauncher>,
4912 unreadable: bool,
4913 }
4914
4915 #[async_trait::async_trait]
4916 impl AllocationLock for HostEditOnLock {
4917 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4918 let guard = self.inner.acquire().await?;
4919 *self.launcher.capacity.lock().unwrap() = NonZeroU16::new(1);
4920 self.launcher
4921 .capacity_unreadable
4922 .store(self.unreadable, Ordering::SeqCst);
4923 Ok(guard)
4924 }
4925 }
4926
4927 #[tokio::test]
4928 async fn live_capacity_edits_and_read_failures_while_waiting_for_the_lock_block_launches() {
4929 for unreadable in [false, true] {
4930 let launcher =
4931 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
4932 let lock = Arc::new(HostEditOnLock {
4933 inner: InProcessAllocationLock::new(),
4934 launcher: Arc::clone(&launcher),
4935 unreadable,
4936 });
4937 let mut harness = Harness::build(
4938 host_with(5),
4939 Arc::clone(&launcher),
4940 Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
4941 lock,
4942 );
4943 let report = harness
4944 .reconciler
4945 .reconcile(&[policy(1, "acme/app", 5)])
4946 .await;
4947 assert_eq!(report.started, 0);
4948 assert_eq!(launcher.live_count(), 1);
4949 assert!(launcher.cleaned().is_empty());
4950 assert_eq!(report.host_capacity_unreadable, u16::from(unreadable));
4951 assert_eq!(report.attempts_unreadable, 0);
4952 }
4953 }
4954
4955 #[tokio::test(flavor = "current_thread")]
4978 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
4979 let lock = Arc::new(CountingLock::new());
4980 let (launches, live) =
4981 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>, None).await;
4982
4983 assert_eq!(
4984 launches, 3,
4985 "the sum across policies must never exceed host_capacity, and each policy is \
4986 individually within its own max_capacity of 3"
4987 );
4988 assert_eq!(live, 3, "and no duplicate runner survived the race");
4989 assert_eq!(
4990 lock.peak(),
4991 1,
4992 "the allocation lock had one holder at a time; without that the read of the \
4993 headroom and the creation of the runtime are not atomic"
4994 );
4995 assert!(
4996 (3..=5).contains(&lock.acquisitions()),
4997 "the lock is taken before *each* runtime, not once per pass: three runtimes \
4998 means at least three holds, and at most one further hold per policy to \
4999 discover the host filled up underneath it. It was taken {} times",
5000 lock.acquisitions()
5001 );
5002 }
5003
5004 #[tokio::test(flavor = "current_thread")]
5011 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
5012 let (launches, _) =
5013 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>, None).await;
5014
5015 assert!(
5016 launches > 3,
5017 "with no serialisation both policies must be able to spend the same headroom; \
5018 they started {launches} runners on a host of 3. If this is ever 3, the \
5019 contention window closed and `two_policies_reconciling_concurrently_never_\
5020 exceed_host_capacity` has stopped proving anything"
5021 );
5022 }
5023
5024 async fn two_policies_concurrently(
5028 lock: Arc<dyn AllocationLock>,
5029 live_capacity: Option<NonZeroU16>,
5030 ) -> (usize, usize) {
5031 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
5032 *launcher.capacity.lock().unwrap() = live_capacity;
5033 let host = host_with(3);
5034
5035 let mut left = Harness::build(
5036 host.clone(),
5037 Arc::clone(&launcher),
5038 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
5039 Arc::clone(&lock),
5040 )
5041 .reconciler;
5042 let mut right = Harness::build(
5043 host,
5044 Arc::clone(&launcher),
5045 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
5046 Arc::clone(&lock),
5047 )
5048 .reconciler;
5049
5050 let a = policy(1, "acme/left", 3);
5051 let b = policy(2, "acme/right", 3);
5052
5053 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
5054 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
5055 let (_, _) = (left.await.unwrap(), right.await.unwrap());
5056
5057 (launcher.launches(), launcher.live_count())
5058 }
5059
5060 #[tokio::test(flavor = "current_thread")]
5061 async fn live_capacity_is_shared_by_concurrent_targets_with_stale_startup_hosts() {
5062 let (launches, live) =
5063 two_policies_concurrently(Arc::new(InProcessAllocationLock::new()), NonZeroU16::new(2))
5064 .await;
5065 assert_eq!(launches, 2);
5066 assert_eq!(live, 2);
5067 }
5068
5069 #[tokio::test]
5081 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
5082 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
5083 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
5084 let launcher = Arc::new(FakeLauncher::new());
5085 let events = Arc::new(EventLog::new());
5086
5087 let mut reconciler = Reconciler::new(
5088 host_with(10),
5089 ReconcilerPorts {
5090 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
5091 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5092 lock: Arc::new(InProcessAllocationLock::new()),
5093 directory: Arc::new(FakeDirectory::default()),
5094 clock: Arc::new(FakeClock::default()),
5095 jitter: Arc::new(NoJitter),
5096 events: Arc::clone(&events) as Arc<dyn EventSink>,
5097 },
5098 );
5099
5100 let monitor = fixtures::policy()
5101 .id(PolicyId::from_u128(1))
5102 .repository("acme/app")
5103 .monitor_only()
5104 .active()
5105 .build();
5106
5107 let report = reconciler.reconcile(&[monitor]).await;
5108
5109 assert_eq!(report.started, 0);
5110 assert_eq!(launcher.launches(), 0);
5111 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
5112 assert_eq!(
5113 report.demand_requests, 0,
5114 "a monitor-only policy spends nothing from the shared hourly ceiling"
5115 );
5116 assert!(
5117 gateway.gateway().calls().is_empty(),
5118 "a monitor-only policy issued a demand request: {:?}",
5119 gateway.gateway().calls()
5120 );
5121 assert_eq!(events.count_of("monitor_only_skipped"), 1);
5122 assert_eq!(
5123 events.count_of("demand_observed"),
5124 0,
5125 "and it contributed no demand"
5126 );
5127 }
5128
5129 #[tokio::test]
5147 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
5148 let lock = Arc::new(CountingLock::new());
5149 let launcher = Arc::new(FakeLauncher::new());
5150 let events = Arc::new(EventLog::new());
5151 let mut reconciler = Reconciler::new(
5152 host_with(4),
5153 ReconcilerPorts {
5154 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5155 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5156 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
5157 directory: Arc::new(FakeDirectory::default()),
5158 clock: Arc::new(FakeClock::default()),
5159 jitter: Arc::new(NoJitter),
5160 events: Arc::clone(&events) as Arc<dyn EventSink>,
5161 },
5162 );
5163
5164 let watcher = fixtures::policy()
5165 .id(PolicyId::from_u128(2))
5166 .repository("acme/app")
5167 .monitor_only()
5168 .active()
5169 .build();
5170
5171 let report = reconciler
5172 .reconcile(&[policy(1, "acme/app", 4), watcher])
5173 .await;
5174
5175 assert_eq!(report.started, 2, "the autoscale policy is served normally");
5176 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
5177 assert_eq!(
5178 events.count_of("demand_observed"),
5179 1,
5180 "the demand observation belongs to the autoscale policy alone"
5181 );
5182 assert!(
5183 report
5184 .allocations
5185 .iter()
5186 .all(|a| a.policy_id == PolicyId::from_u128(1)),
5187 "a monitor-only policy was allocated for: {:?}",
5188 report.allocations
5189 );
5190 assert_eq!(
5191 lock.acquisitions(),
5192 2,
5193 "one hold per runtime created, and none on behalf of the monitor-only policy. \
5194 It was three before the budget was checked at the top of the loop rather than \
5195 after the re-read, which cost every policy a surplus hold to discover there \
5196 was nothing left to grant"
5197 );
5198 }
5199
5200 #[tokio::test]
5201 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
5202 let monitor = fixtures::monitor_only_policy();
5207 assert!(!monitor.owns_runners());
5208 assert_eq!(monitor.max_capacity(), None);
5209
5210 let host = host_with(10);
5211 let attempts: Vec<RunnerAttempt> = Vec::new();
5212 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5213 let allocation = allocator.allocate(&monitor, 10_000);
5214 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
5215 assert_eq!(allocation.to_start, 0);
5216 assert_eq!(
5217 allocator.headroom(),
5218 10,
5219 "and it consumes no headroom, so an autoscale policy on the same host is \
5220 unaffected"
5221 );
5222 }
5223
5224 #[tokio::test]
5232 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
5233 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5234 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
5235 concluded(
5236 2,
5237 1,
5238 AttemptOutcome::failed(FailureReason::JitRequestFailed),
5239 ),
5240 concluded(3, 1, AttemptOutcome::CompletedJob),
5241 ]));
5242 let mut harness = Harness::build(
5243 host_with(4),
5244 Arc::clone(&launcher),
5245 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5246 Arc::new(InProcessAllocationLock::new()),
5247 );
5248
5249 let report = harness
5250 .reconciler
5251 .reconcile(&[policy(1, "acme/app", 4)])
5252 .await;
5253
5254 assert_eq!(report.cleaned, 3);
5255 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
5256 assert_eq!(
5257 report.failures, 1,
5258 "only the failed attempt is a failure; the idle exit and the completed job are \
5259 not"
5260 );
5261 assert_eq!(launcher.cleaned().len(), 3);
5262 assert!(launcher.snapshot().is_empty());
5263
5264 let cleaned: Vec<OutcomeKind> = harness
5265 .events
5266 .events()
5267 .into_iter()
5268 .filter_map(|event| match event {
5269 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
5270 _ => None,
5271 })
5272 .collect();
5273 assert!(cleaned.contains(&OutcomeKind::IdleExit));
5274 assert!(
5275 !OutcomeKind::IdleExit.is_failure(),
5276 "an idle exit rendered as a failure sends an operator hunting a fault that does \
5277 not exist"
5278 );
5279 }
5280
5281 #[tokio::test]
5284 async fn scale_down_removes_nothing_from_a_busy_attempt() {
5285 let busy = attempt_in(AttemptState::Busy, 1, 1);
5286 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5287 busy.clone(),
5288 attempt_in(AttemptState::Starting, 2, 1),
5289 concluded(3, 1, AttemptOutcome::CompletedJob),
5290 ]));
5291 let harness = Harness::build(
5292 host_with(4),
5293 Arc::clone(&launcher),
5294 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5295 Arc::new(InProcessAllocationLock::new()),
5296 );
5297
5298 let report = harness
5299 .reconciler
5300 .scale_down(&policy(1, "acme/app", 4))
5301 .await;
5302
5303 assert_eq!(report.refused_busy, 1);
5304 assert_eq!(
5305 report.retained, 1,
5306 "the `starting` attempt is not ended either"
5307 );
5308 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
5309
5310 let after = launcher.snapshot();
5311 let still_busy = after
5312 .iter()
5313 .find(|a| a.id == AttemptId::from_u128(1))
5314 .expect("the busy attempt is still there");
5315 assert_eq!(
5316 still_busy.state(),
5317 AttemptState::Busy,
5318 "scale-down removed nothing from a runner that is executing a job, and left it \
5319 busy"
5320 );
5321 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
5322
5323 let mut busy = busy;
5327 assert!(matches!(
5328 busy.clean(fixtures::created_at()),
5329 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
5330 ));
5331 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
5332 }
5333
5334 #[test]
5339 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
5340 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
5341 assert_eq!(RefreshInterval::MIN_SECS, 30);
5342 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
5343 assert!(
5344 RefreshInterval::from_secs(29).is_err(),
5345 "the floor is a rate-budget constraint, and a caller must not be able to write \
5346 a shorter interval at all"
5347 );
5348
5349 let mut schedule = PollSchedule::new(RefreshInterval::default());
5350 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
5351 assert_eq!(next.delay, Duration::from_secs(60));
5352 assert_eq!(next.pace, PollPace::Nominal);
5353
5354 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5355 assert_eq!(
5356 floored
5357 .next_poll(None, fixtures::created_at(), &NoJitter)
5358 .delay,
5359 Duration::from_secs(30)
5360 );
5361 }
5362
5363 #[test]
5367 fn a_rate_limit_increases_the_delay_and_names_itself() {
5368 let now = fixtures::created_at();
5369 let mut schedule = PollSchedule::new(RefreshInterval::default());
5370
5371 let limited = RefreshState::RateLimited(RateLimited {
5372 kind: RateLimitKind::Secondary,
5373 retry_after: Some(Duration::from_secs(300)),
5374 remaining: None,
5375 reset_unix_secs: None,
5376 });
5377 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5378
5379 assert_eq!(next.delay, Duration::from_secs(300));
5380 assert_eq!(
5381 next.pace,
5382 PollPace::RateLimited {
5383 kind: RateLimitKind::Secondary
5384 },
5385 "the increase is reported, never hidden"
5386 );
5387 assert!(next.pace.is_throttled());
5388 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
5389 }
5390
5391 #[test]
5394 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
5395 let now = fixtures::created_at();
5396 let mut schedule = PollSchedule::new(RefreshInterval::default());
5397
5398 let limited = RefreshState::RateLimited(RateLimited {
5399 kind: RateLimitKind::Primary,
5400 retry_after: Some(Duration::from_secs(300)),
5401 remaining: Some(0),
5402 reset_unix_secs: None,
5403 });
5404
5405 for _ in 0..5 {
5408 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5409 assert_eq!(
5410 next.delay,
5411 Duration::from_secs(300),
5412 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
5413 have compounded on every successive retry"
5414 );
5415 }
5416
5417 let brief = RefreshState::RateLimited(RateLimited {
5420 kind: RateLimitKind::Secondary,
5421 retry_after: Some(Duration::from_secs(5)),
5422 remaining: None,
5423 reset_unix_secs: None,
5424 });
5425 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
5426 assert_eq!(
5427 next.delay,
5428 Duration::from_secs(60),
5429 "a short `retry-after` may not drop the loop below its own interval"
5430 );
5431 assert!(next.delay >= PollSchedule::floor());
5432 }
5433
5434 #[test]
5435 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
5436 let now = fixtures::created_at();
5437 let states = [
5438 None,
5439 Some(RefreshState::Offline),
5440 Some(RefreshState::RateLimited(RateLimited {
5441 kind: RateLimitKind::Secondary,
5442 retry_after: Some(Duration::from_secs(1)),
5443 remaining: None,
5444 reset_unix_secs: None,
5445 })),
5446 Some(RefreshState::LockedOut {
5447 retry_after: Duration::from_secs(1),
5448 }),
5449 Some(RefreshState::Unauthorized),
5450 Some(RefreshState::Forbidden { message: None }),
5451 Some(RefreshState::Failed {
5452 status: Some(500),
5453 message: "server error".into(),
5454 }),
5455 Some(RefreshState::Cancelled),
5456 ];
5457
5458 for state in &states {
5459 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5460 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
5461 assert!(
5462 next.delay >= PollSchedule::floor(),
5463 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
5464 next.delay.as_millis()
5465 );
5466 }
5467 }
5468
5469 #[test]
5470 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
5471 let now = fixtures::created_at();
5472 let mut schedule = PollSchedule::new(RefreshInterval::default());
5473
5474 let mut previous = Duration::ZERO;
5476 for consecutive in 1..=6_u32 {
5477 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5478 assert_eq!(next.pace, PollPace::Offline { consecutive });
5479 assert!(
5480 next.delay >= previous,
5481 "the back-off must not shrink while the outage continues"
5482 );
5483 assert!(next.delay >= Duration::from_secs(60));
5484 previous = next.delay;
5485 }
5486 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
5487
5488 let mut jittered = PollSchedule::new(RefreshInterval::default());
5491 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5492 let mut jittered = PollSchedule::new(RefreshInterval::default());
5493 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
5494 assert!(full.delay > none.delay);
5495 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
5496
5497 assert_eq!(schedule.consecutive_offline(), 6);
5499 let recovered = schedule.next_poll(None, now, &NoJitter);
5500 assert_eq!(recovered.pace, PollPace::Nominal);
5501 assert_eq!(recovered.delay, Duration::from_secs(60));
5502 assert_eq!(schedule.consecutive_offline(), 0);
5503 }
5504
5505 #[test]
5506 fn the_offline_state_states_the_twenty_four_hour_bound() {
5507 assert_eq!(
5508 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
5509 Duration::from_secs(24 * 60 * 60)
5510 );
5511
5512 let brief = OfflineState::new(1, Duration::from_secs(120));
5513 let rendered = brief.to_string();
5514 assert!(rendered.contains("24 hours"), "{rendered}");
5515 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
5516 assert!(!brief.has_outlasted_the_queue());
5517
5518 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
5519 assert!(long.has_outlasted_the_queue());
5520 assert!(
5521 long.to_string().contains("queued work has been lost"),
5522 "{long}"
5523 );
5524
5525 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
5527 }
5528
5529 fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
5534 RateLimitSnapshot {
5535 limit: 5_000,
5536 remaining,
5537 used: Some(5_000 - remaining),
5538 reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
5539 observed_at: now,
5540 }
5541 }
5542
5543 fn quiet() -> PollConditions {
5544 PollConditions {
5545 quiet: true,
5546 quota: None,
5547 requests_per_pass: 0,
5548 }
5549 }
5550
5551 #[test]
5552 fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
5553 let now = fixtures::created_at();
5554 let mut schedule =
5555 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5556
5557 let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
5558 assert_eq!(idle.delay, Duration::from_secs(10));
5559 assert_eq!(idle.pace, PollPace::Idle);
5560 assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
5561
5562 let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
5563 assert_eq!(busy.delay, Duration::from_secs(60));
5564 assert_eq!(busy.pace, PollPace::Nominal);
5565 }
5566
5567 #[test]
5568 fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
5569 let now = fixtures::created_at();
5570 let mut schedule = PollSchedule::new(RefreshInterval::default());
5571 assert_eq!(
5572 schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
5573 Duration::from_secs(60)
5574 );
5575 }
5576
5577 #[test]
5578 fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
5579 let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
5580 .with_idle(Duration::from_secs(120));
5581 assert_eq!(
5582 schedule.idle(),
5583 Duration::from_secs(30),
5584 "an idle interval above the active one would make quiet targets slower to react"
5585 );
5586 let schedule =
5587 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
5588 assert_eq!(schedule.idle(), PollSchedule::idle_floor());
5589 }
5590
5591 #[test]
5594 fn a_low_quota_stretches_polling_and_wins_over_idle() {
5595 let now = fixtures::created_at();
5596 let mut schedule =
5597 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5598
5599 let healthy = schedule.next_poll_with(
5600 None,
5601 PollConditions {
5602 quiet: true,
5603 quota: Some(quota(1_000, 3_000, now)),
5604 requests_per_pass: 0,
5605 },
5606 now,
5607 &NoJitter,
5608 );
5609 assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
5610
5611 let low = schedule.next_poll_with(
5612 None,
5613 PollConditions {
5614 quiet: true,
5615 quota: Some(quota(999, 3_000, now)),
5616 requests_per_pass: 0,
5617 },
5618 now,
5619 &NoJitter,
5620 );
5621 assert_eq!(low.delay, Duration::from_secs(120));
5622 assert_eq!(
5623 low.pace,
5624 PollPace::Stretched {
5625 pressure: PrimaryPressure::Low
5626 }
5627 );
5628 assert!(low.pace.is_throttled());
5629 assert_eq!(low.pace.as_str(), "stretched_low_budget");
5630
5631 let critical = schedule.next_poll_with(
5632 None,
5633 PollConditions {
5634 quiet: false,
5635 quota: Some(quota(100, 3_000, now)),
5636 requests_per_pass: 0,
5637 },
5638 now,
5639 &NoJitter,
5640 );
5641 assert_eq!(critical.delay, Duration::from_secs(240));
5642 assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
5643 }
5644
5645 #[test]
5646 fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
5647 let now = fixtures::created_at();
5648 let mut schedule = PollSchedule::new(RefreshInterval::default());
5649
5650 let soon = schedule.next_poll_with(
5651 None,
5652 PollConditions {
5653 quiet: false,
5654 quota: Some(quota(10, 90, now)),
5655 requests_per_pass: 0,
5656 },
5657 now,
5658 &NoJitter,
5659 );
5660 assert_eq!(
5661 soon.delay,
5662 Duration::from_secs(90),
5663 "the bucket refills in 90 s; waiting 240 s buys nothing"
5664 );
5665
5666 let imminent = schedule.next_poll_with(
5667 None,
5668 PollConditions {
5669 quiet: false,
5670 quota: Some(quota(10, 5, now)),
5671 requests_per_pass: 0,
5672 },
5673 now,
5674 &NoJitter,
5675 );
5676 assert_eq!(imminent.delay, Duration::from_secs(60));
5677
5678 let expired = schedule.next_poll_with(
5679 None,
5680 PollConditions {
5681 quiet: true,
5682 quota: Some(quota(10, -5, now)),
5683 requests_per_pass: 0,
5684 },
5685 now,
5686 &NoJitter,
5687 );
5688 assert_eq!(
5689 expired.pace,
5690 PollPace::Nominal,
5691 "a reading from a window that has reset says nothing; the schedule here had no \
5692 idle interval, so it is the active one"
5693 );
5694 }
5695
5696 #[test]
5697 fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
5698 let now = fixtures::created_at();
5699 let mut schedule =
5700 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5701 let small = schedule.next_poll_with(
5702 None,
5703 PollConditions {
5704 quiet: true,
5705 quota: None,
5706 requests_per_pass: 40,
5707 },
5708 now,
5709 &NoJitter,
5710 );
5711 assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
5712 let organization = schedule.next_poll_with(
5713 None,
5714 PollConditions {
5715 quiet: true,
5716 quota: None,
5717 requests_per_pass: 400,
5718 },
5719 now,
5720 &NoJitter,
5721 );
5722 assert_eq!(
5723 organization.delay,
5724 Duration::from_secs(80),
5725 "400 requests a pass at 300 a minute is one pass every 80 s"
5726 );
5727 assert_eq!(organization.pace, PollPace::Idle);
5728 }
5729
5730 #[test]
5731 fn a_rate_limit_wait_is_jittered_upward_only() {
5732 let now = fixtures::created_at();
5733 let mut schedule = PollSchedule::new(RefreshInterval::default());
5734 let limited = RefreshState::RateLimited(RateLimited {
5735 kind: RateLimitKind::Secondary,
5736 retry_after: Some(Duration::from_secs(300)),
5737 remaining: None,
5738 reset_unix_secs: None,
5739 });
5740 assert_eq!(
5741 schedule
5742 .next_poll(Some(&limited), now, &FixedJitter(0.5))
5743 .delay,
5744 Duration::from_secs(315),
5745 "every loop shares one quiet period; without jitter they all wake together"
5746 );
5747 assert_eq!(
5748 schedule.next_poll(Some(&limited), now, &NoJitter).delay,
5749 Duration::from_secs(300),
5750 "never shorter than GitHub asked"
5751 );
5752 }
5753
5754 #[tokio::test]
5755 async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
5756 let launcher =
5759 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
5760 let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
5761 let mut harness = Harness::build(
5762 host_with(1),
5763 launcher,
5764 demand,
5765 Arc::new(InProcessAllocationLock::new()),
5766 );
5767 harness
5768 .reconciler
5769 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5770 let policy = policy(1, "acme/app", 4);
5771
5772 let report = harness
5773 .reconciler
5774 .reconcile(std::slice::from_ref(&policy))
5775 .await;
5776
5777 assert_eq!(report.started, 0, "the host is full");
5778 assert_eq!(
5779 report.next_poll.pace,
5780 PollPace::Nominal,
5781 "matched demand it could not serve is work waiting, not idle"
5782 );
5783 }
5784
5785 #[tokio::test]
5786 async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
5787 let mut harness = Harness::simple(4, 0, "acme/app");
5788 harness
5789 .reconciler
5790 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5791 let policy = policy(1, "acme/app", 4);
5792
5793 let report = harness
5794 .reconciler
5795 .reconcile(std::slice::from_ref(&policy))
5796 .await;
5797
5798 assert_eq!(report.next_poll.pace, PollPace::Idle);
5799 assert_eq!(report.next_poll.delay, Duration::from_secs(10));
5800 }
5801
5802 #[tokio::test]
5803 async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
5804 let mut harness = Harness::simple(4, 0, "acme/app");
5805 harness
5806 .reconciler
5807 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5808 harness.demand.set(PollOutcome::Ready(
5809 QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
5810 ));
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!(report.next_poll.pace, PollPace::Nominal);
5819 assert_eq!(report.next_poll.delay, Duration::from_secs(60));
5820 }
5821
5822 #[tokio::test]
5823 async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
5824 let launcher =
5825 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5826 let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
5827 let mut harness = Harness::build(
5828 host_with(4),
5829 launcher,
5830 demand,
5831 Arc::new(InProcessAllocationLock::new()),
5832 );
5833 harness
5834 .reconciler
5835 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5836 let policy = policy(1, "acme/app", 4);
5837
5838 let report = harness
5839 .reconciler
5840 .reconcile(std::slice::from_ref(&policy))
5841 .await;
5842
5843 assert_eq!(
5844 report.next_poll.pace,
5845 PollPace::Nominal,
5846 "a job is running: its follow-on work is what the active interval watches for"
5847 );
5848 }
5849
5850 #[tokio::test]
5851 async fn a_pass_that_starts_a_runner_is_not_idle() {
5852 let mut harness = Harness::simple(4, 2, "acme/app");
5853 harness
5854 .reconciler
5855 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5856 let policy = policy(1, "acme/app", 4);
5857
5858 let report = harness
5859 .reconciler
5860 .reconcile(std::slice::from_ref(&policy))
5861 .await;
5862
5863 assert!(report.started > 0);
5864 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5865 }
5866
5867 #[tokio::test]
5868 async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
5869 let mut harness = Harness::simple(4, 0, "acme/app");
5870 harness
5871 .reconciler
5872 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5873 let now = harness.clock.now();
5874 harness.demand.set(PollOutcome::Ready(
5875 QueuedDemand::of(repo("acme/app"), Vec::new())
5876 .with_rate_limit(Some(quota(200, 1_800, now))),
5877 ));
5878 let policy = policy(1, "acme/app", 4);
5879
5880 let report = harness
5881 .reconciler
5882 .reconcile(std::slice::from_ref(&policy))
5883 .await;
5884
5885 assert_eq!(
5886 report.next_poll.pace,
5887 PollPace::Stretched {
5888 pressure: PrimaryPressure::Critical
5889 }
5890 );
5891 assert_eq!(report.next_poll.delay, Duration::from_secs(240));
5892 }
5893
5894 #[tokio::test]
5903 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
5904 let live = vec![
5905 attempt_in(AttemptState::Busy, 1, 1),
5906 attempt_in(AttemptState::Starting, 2, 1),
5907 ];
5908 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
5909 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5910 let mut harness = Harness::build(
5911 host_with(8),
5912 Arc::clone(&launcher),
5913 Arc::clone(&demand),
5914 Arc::new(InProcessAllocationLock::new()),
5915 );
5916 let policy = policy(1, "acme/app", 8);
5917
5918 let report = harness
5919 .reconciler
5920 .reconcile(std::slice::from_ref(&policy))
5921 .await;
5922
5923 assert!(report.is_offline());
5924 assert_eq!(report.started, 0, "no new runner during an outage");
5925 assert_eq!(launcher.launches(), 0);
5926 assert_eq!(
5927 launcher.snapshot(),
5928 live,
5929 "existing runner processes are retained, untouched"
5930 );
5931 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
5932 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5933 assert!(report.next_poll.delay >= Duration::from_secs(60));
5934 let offline = report.offline_state().expect("an offline state to display");
5935 assert!(offline.to_string().contains("24 hours"));
5936
5937 demand.set(PollOutcome::Ready(QueuedDemand::of(
5940 repo("acme/app"),
5941 jobs(2),
5942 )));
5943 let recovered = harness.reconciler.reconcile(&[policy]).await;
5944
5945 assert!(!recovered.is_offline());
5946 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
5947 assert_eq!(
5948 recovered.started, 1,
5949 "two queued jobs and one starting runner leave one job uncovered"
5950 );
5951 assert_eq!(recovered.allocations[0].active_owned, 2);
5952 assert_eq!(launcher.live_count(), 3);
5953 }
5954
5955 #[tokio::test]
5965 async fn one_offline_target_does_not_stop_a_reachable_one() {
5966 let mut harness = Harness::simple(8, 0, "acme/app");
5967 harness.demand.set_for(
5968 &ScaleTarget::repository("acme/app").unwrap(),
5969 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
5970 );
5971 harness.demand.set_for(
5972 &ScaleTarget::repository("acme/broken").unwrap(),
5973 PollOutcome::Failed(RefreshState::Offline),
5974 );
5975
5976 let report = harness
5977 .reconciler
5978 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
5979 .await;
5980
5981 assert_eq!(
5982 report.started, 2,
5983 "the reachable target was served; an unreachable sibling repository must not \
5984 idle the host"
5985 );
5986 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
5987 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
5988
5989 assert!(report.is_offline());
5991 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5992 }
5993
5994 #[tokio::test]
6009 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
6010 let clock = Arc::new(FakeClock::default());
6011 let launcher = Arc::new(FakeLauncher::new());
6012 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6013 let mut reconciler = Reconciler::new(
6014 host_with(4),
6015 ReconcilerPorts {
6016 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
6017 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6018 lock: Arc::new(InProcessAllocationLock::new()),
6019 directory: Arc::new(FakeDirectory::default()),
6020 clock: Arc::clone(&clock) as Arc<dyn Clock>,
6021 jitter: Arc::new(NoJitter),
6022 events: Arc::new(NoEvents),
6023 },
6024 );
6025 let policy = policy(1, "acme/app", 4);
6026
6027 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6029 let state = first.offline_state().expect("an offline state");
6030 assert!(!state.has_outlasted_the_queue());
6031 assert!(
6032 state
6033 .to_string()
6034 .contains("an outage longer than that loses"),
6035 "{state}"
6036 );
6037
6038 clock.advance_secs(24 * 60 * 60 + 60);
6040 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6041 let state = later.offline_state().expect("an offline state");
6042 assert!(state.has_outlasted_the_queue());
6043 assert!(
6044 state.to_string().contains("queued work has been lost"),
6045 "{state}"
6046 );
6047 assert_eq!(launcher.launches(), 0, "and still nothing was started");
6048
6049 demand.set(PollOutcome::Ready(QueuedDemand::of(
6052 repo("acme/app"),
6053 jobs(0),
6054 )));
6055 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6056 assert!(recovered.offline_state().is_none());
6057 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
6058
6059 demand.set(PollOutcome::Failed(RefreshState::Offline));
6060 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6061 assert!(
6062 !again
6063 .offline_state()
6064 .expect("an offline state")
6065 .has_outlasted_the_queue(),
6066 "a new outage must not inherit the age of the one before it"
6067 );
6068 }
6069
6070 #[tokio::test]
6086 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
6087 let root = tempfile::tempdir().expect("a temporary directory");
6088 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
6089 root.path(),
6090 ));
6091 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
6092
6093 let held = lock.acquire().await.expect("an uncontended lock is free");
6094 assert!(
6095 matches!(lock.acquire().await, Err(AllocationLockBusy)),
6096 "a second holder was admitted; on Unix the lock is per open file description \
6097 and on Windows the share mode denies write, so this must be refused even \
6098 from inside the same process"
6099 );
6100
6101 drop(held);
6102 let regained = lock.acquire().await;
6103 assert!(
6104 regained.is_ok(),
6105 "dropping the guard is the only release there is, so a guard that does not \
6106 hold the `HostLock` leaves it held forever"
6107 );
6108 }
6109
6110 #[test]
6111 fn tee_events_reaches_both_sinks() {
6112 let left = Arc::new(EventLog::new());
6116 let right = Arc::new(EventLog::new());
6117 let tee = TeeEvents(
6118 Arc::clone(&left) as Arc<dyn EventSink>,
6119 Arc::clone(&right) as Arc<dyn EventSink>,
6120 );
6121
6122 tee.emit(LifecycleEvent::MonitorOnlySkipped {
6123 policy: PolicyId::from_u128(1),
6124 });
6125
6126 assert_eq!(left.count_of("monitor_only_skipped"), 1);
6127 assert_eq!(right.count_of("monitor_only_skipped"), 1);
6128 }
6129
6130 #[tokio::test]
6135 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
6136 let clock = Arc::new(FakeClock::default());
6137 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
6138 let cache = RepositoryCache::new(
6139 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6140 Arc::clone(&clock) as Arc<dyn Clock>,
6141 RefreshInterval::default(),
6142 );
6143 let target = ScaleTarget::organization("acme").unwrap();
6144
6145 assert_eq!(
6146 cache.ttl(),
6147 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
6148 );
6149
6150 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
6152 let scope = cache.scope_for(&target).await.unwrap();
6153 assert_eq!(scope.repositories().len(), 2);
6154 clock.advance_secs(60);
6155 }
6156 assert_eq!(
6157 directory.calls(),
6158 1,
6159 "re-listing an organization at demand-poll frequency is what exhausts the \
6160 shared request budget"
6161 );
6162 assert_eq!(cache.lookups(), 1);
6163
6164 cache.scope_for(&target).await.unwrap();
6166 assert_eq!(directory.calls(), 2);
6167 }
6168
6169 #[tokio::test]
6170 async fn a_repository_target_never_consults_the_directory() {
6171 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
6172 let cache = RepositoryCache::new(
6173 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6174 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6175 RefreshInterval::default(),
6176 );
6177 let target = ScaleTarget::repository("acme/app").unwrap();
6178
6179 let scope = cache.scope_for(&target).await.unwrap();
6180 assert_eq!(scope.repositories(), &[repo("acme/app")]);
6181 assert_eq!(directory.calls(), 0);
6182 }
6183
6184 #[tokio::test]
6185 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
6186 let mut harness = Harness::simple(8, 4, "o/r");
6190 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6191 repo("o/r"),
6192 [
6193 fixtures::queued_job(&[HOST_LABEL]),
6194 fixtures::queued_job(&[HOST_LABEL]),
6195 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6196 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6197 ],
6198 )));
6199 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6200 isolated.activate().unwrap();
6201 let report = harness
6202 .reconciler
6203 .reconcile(&[policy(1, "o/r", 2), isolated])
6204 .await;
6205
6206 assert_eq!(harness.demand.polls().len(), 1);
6207 assert_eq!(report.targets_read, 1, "profiles share one target reading");
6208 assert_eq!(
6209 report.demand_requests,
6210 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
6211 "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"
6212 );
6213 assert_eq!(
6214 report.started, 4,
6215 "native and isolated profiles each get two"
6216 );
6217 }
6218
6219 #[tokio::test]
6220 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
6221 use runner_manager_domain::model::{Arch, HostLabel, Os};
6222 use runner_manager_domain::policy::RoutingLabels;
6223 let mut harness = Harness::build(
6224 fixtures::host()
6225 .os(Os::MacOs)
6226 .architecture(Arch::Arm64)
6227 .capacity(10)
6228 .build(),
6229 Arc::new(FakeLauncher::new()),
6230 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
6231 Arc::new(InProcessAllocationLock::new()),
6232 );
6233 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6234 repo("o/r"),
6235 [
6236 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
6237 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
6238 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
6239 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
6240 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
6241 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
6242 ],
6243 )));
6244 let mut local = fixtures::policy()
6245 .id(PolicyId::from_u128(1))
6246 .repository("o/r")
6247 .autoscale("macmini", 10)
6248 .routing_labels(RoutingLabels::derive(
6249 &HostLabel::new("macmini").unwrap(),
6250 Os::MacOs,
6251 Arch::Arm64,
6252 ))
6253 .active()
6254 .build();
6255 for label in ["self-hosted", "macos", "arm64", "extra"] {
6256 local.add_routing_label(fixtures::label(label)).unwrap();
6257 }
6258 let mut foreign = local.to_persisted();
6259 foreign.id = PolicyId::from_u128(2);
6260 foreign.host_id = HostId::from_u128(0xdead);
6261 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
6262 let report = harness.reconciler.reconcile(&[local, foreign]).await;
6263 assert_eq!(report.started, 2);
6264 assert_eq!(report.targets_read, 1);
6265 assert!(harness.events.events().iter().any(|event| matches!(
6266 event,
6267 LifecycleEvent::DemandObserved {
6268 demand: 2,
6269 not_matched: 4,
6270 ..
6271 }
6272 )));
6273 assert!(
6274 !harness
6275 .events
6276 .events()
6277 .iter()
6278 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6279 );
6280 }
6281
6282 #[tokio::test]
6283 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
6284 use runner_manager_domain::execution::{
6285 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6286 };
6287 let mut native = policy(1, "o/r", 2);
6288 native
6289 .add_routing_label(fixtures::label("self-hosted"))
6290 .unwrap();
6291 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6292 isolated
6293 .add_routing_label(fixtures::label("self-hosted"))
6294 .unwrap();
6295 isolated
6296 .set_execution_policy(ExecutionPolicy::Isolated {
6297 backend: Backend::Oci,
6298 image: ImageReference::new(format!(
6299 "registry.example/runner@sha256:{}",
6300 "a".repeat(64)
6301 ))
6302 .unwrap(),
6303 resources: ResourceLimits {
6304 cpu_millis: 1000,
6305 memory_mib: 512,
6306 disk_mib: 1024,
6307 },
6308 })
6309 .unwrap();
6310 assert!(!isolated.may_start_runners());
6311 let mut active_isolated = isolated.clone();
6312 active_isolated.activate().unwrap();
6313 for profiles in [
6314 [native.clone(), isolated.clone()],
6315 [isolated.clone(), native.clone()],
6316 [native.clone(), active_isolated.clone()],
6317 [active_isolated, native.clone()],
6318 ] {
6319 let mut harness = Harness::simple(4, 0, "o/r");
6320 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6321 repo("o/r"),
6322 [fixtures::queued_job(&["SELF-HOSTED"])],
6323 )));
6324 let report = harness.reconciler.reconcile(&profiles).await;
6325 assert_eq!(report.started, 0);
6326 assert!(harness.events.events().iter().any(|event| matches!(
6327 event,
6328 LifecycleEvent::RoutingRefused {
6329 reason: RoutingRefusal::OverlappingProfiles,
6330 count: 1,
6331 ..
6332 }
6333 )));
6334 }
6335 let mut harness = Harness::simple(4, 0, "o/r");
6338 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6339 repo("o/r"),
6340 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
6341 )));
6342 assert_eq!(
6343 harness
6344 .reconciler
6345 .reconcile(&[native, isolated])
6346 .await
6347 .started,
6348 0
6349 );
6350 }
6351
6352 #[tokio::test]
6353 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
6354 let mut harness = Harness::simple(2, 0, "o/r");
6355 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6356 repo("o/r"),
6357 [
6358 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
6359 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
6360 fixtures::queued_job(&["gpu"]),
6361 fixtures::unresolvable_job(),
6362 ],
6363 )));
6364 let mut native = policy(1, "o/r", 2);
6365 native.add_routing_label(fixtures::label("gpu")).unwrap();
6366 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6367 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
6368 isolated.activate().unwrap();
6369
6370 let report = harness.reconciler.reconcile(&[native, isolated]).await;
6371 assert_eq!(harness.demand.polls().len(), 1);
6372 assert_eq!(report.targets_read, 1);
6373 assert_eq!(report.started, 2);
6374 assert_eq!(report.allocations.len(), 2);
6375 assert!(
6376 report
6377 .allocations
6378 .iter()
6379 .all(|allocation| allocation.to_start == 1)
6380 );
6381 assert!(harness.events.events().iter().any(|event| matches!(
6382 event,
6383 LifecycleEvent::RoutingRefused {
6384 reason: RoutingRefusal::OverlappingProfiles,
6385 count: 1,
6386 ..
6387 }
6388 )));
6389 assert_eq!(
6390 harness
6391 .events
6392 .events()
6393 .iter()
6394 .filter(|event| matches!(
6395 event,
6396 LifecycleEvent::DemandObserved {
6397 unresolvable: 1,
6398 ..
6399 }
6400 ))
6401 .count(),
6402 2,
6403 "an expression stays unresolvable for both profiles"
6404 );
6405 }
6406
6407 #[tokio::test]
6408 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
6409 let mut harness = Harness::simple(4, 0, "o/r");
6410 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6411 repo("o/r"),
6412 [fixtures::queued_job(&[
6413 HOST_LABEL,
6414 "rm-home-win-x64-py-isolated",
6415 ])],
6416 )));
6417 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6418 isolated.activate().unwrap();
6419 let report = harness
6420 .reconciler
6421 .reconcile(&[policy(1, "o/r", 2), isolated])
6422 .await;
6423 assert_eq!(report.started, 0);
6424 assert_eq!(
6425 harness
6426 .events
6427 .events()
6428 .iter()
6429 .filter(|event| matches!(
6430 event,
6431 LifecycleEvent::DemandObserved {
6432 demand: 0,
6433 not_matched: 1,
6434 ..
6435 }
6436 ))
6437 .count(),
6438 2
6439 );
6440
6441 let mut superset = policy(1, "o/r", 2);
6445 assert!(
6446 superset
6447 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
6448 .unwrap()
6449 );
6450 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6451 sibling.activate().unwrap();
6452 let mut unique = Harness::simple(4, 0, "o/r");
6453 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
6454 repo("o/r"),
6455 [fixtures::queued_job(&[
6456 HOST_LABEL,
6457 "rm-home-win-x64-py-isolated",
6458 ])],
6459 )));
6460 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
6461 assert_eq!(report.started, 1);
6462 assert_eq!(
6463 report
6464 .allocations
6465 .iter()
6466 .find(|allocation| allocation.to_start == 1)
6467 .unwrap()
6468 .policy_id,
6469 PolicyId::from_u128(1)
6470 );
6471 assert!(
6472 !unique
6473 .events
6474 .events()
6475 .iter()
6476 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6477 );
6478
6479 let mut overlap = Harness::simple(4, 1, "o/r");
6480 let report = overlap
6481 .reconciler
6482 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
6483 .await;
6484 assert_eq!(report.started, 0);
6485 assert!(overlap.events.events().iter().any(|event| matches!(
6486 event,
6487 LifecycleEvent::RoutingRefused {
6488 reason: RoutingRefusal::OverlappingProfiles,
6489 count: 1,
6490 ..
6491 }
6492 )));
6493 assert!(
6494 RoutingRefusal::OverlappingProfiles
6495 .to_string()
6496 .contains("repair")
6497 );
6498 }
6499
6500 #[tokio::test]
6505 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
6506 let launcher = Arc::new(FakeLauncher::new());
6507 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
6508 let mut harness = Harness::build(
6509 host_with(4),
6510 Arc::clone(&launcher),
6511 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6512 Arc::new(InProcessAllocationLock::new()),
6513 );
6514
6515 let report = harness
6516 .reconciler
6517 .reconcile(&[policy(1, "acme/app", 4)])
6518 .await;
6519 assert_eq!(report.started, 0);
6520 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
6521
6522 let failures: Vec<&'static str> = harness
6523 .events
6524 .events()
6525 .into_iter()
6526 .filter_map(|event| match event {
6527 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6528 _ => None,
6529 })
6530 .collect();
6531 assert_eq!(failures, vec!["other"]);
6532 assert!(
6533 !failures[0].contains("ghp_"),
6534 "an event carried a `FailureReason::Other` detail verbatim"
6535 );
6536 }
6537
6538 #[tokio::test]
6543 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
6544 let launcher = Arc::new(FakeLauncher::new());
6545 let unfit = Arc::new(AtomicBool::new(true));
6546 let events = Arc::new(EventLog::new());
6547 let mut reconciler = Reconciler::new(
6548 host_with(4),
6549 ReconcilerPorts {
6550 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
6551 launcher: Arc::new(FitnessGatedLauncher::new(
6552 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6553 Arc::clone(&unfit),
6554 )),
6555 lock: Arc::new(InProcessAllocationLock::new()),
6556 directory: Arc::new(FakeDirectory::default()),
6557 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6558 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
6559 events: Arc::clone(&events) as Arc<dyn EventSink>,
6560 },
6561 );
6562 let policies = [policy(1, "acme/app", 4)];
6563
6564 let report = reconciler.reconcile(&policies).await;
6565 assert_eq!(report.started, 0);
6566 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
6567 let reasons: Vec<&'static str> = events
6568 .events()
6569 .into_iter()
6570 .filter_map(|event| match event {
6571 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6572 _ => None,
6573 })
6574 .collect();
6575 assert_eq!(reasons, ["host_unfit"]);
6576
6577 unfit.store(false, Ordering::Release);
6578 let report = reconciler.reconcile(&policies).await;
6579 assert_eq!(report.started, 2);
6580 assert_eq!(launcher.launches(), 2);
6581 }
6582
6583 #[tokio::test]
6584 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
6585 let launcher = Arc::new(FakeLauncher::new());
6586 let mut harness = Harness::build(
6587 host_with(4),
6588 Arc::clone(&launcher),
6589 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6590 Arc::new(HeldLock),
6591 );
6592
6593 let report = harness
6594 .reconciler
6595 .reconcile(&[policy(1, "acme/app", 4)])
6596 .await;
6597 assert_eq!(report.started, 0);
6598 assert_eq!(
6599 report.deferred, 3,
6600 "three runners were granted and none was created; `deferred` counts grants, \
6601 not policies -- it reported `1` when a policy that launched two of five and \
6602 then lost the lock had left three unstarted"
6603 );
6604 assert_eq!(launcher.launches(), 0);
6605 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
6606 assert!(
6607 harness
6608 .events
6609 .events()
6610 .iter()
6611 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
6612 "the event carries the same number the report does"
6613 );
6614 assert_eq!(
6615 report.allocations.len(),
6616 1,
6617 "the intent is still reported, so an operator staring at a queue sees why \
6618 nothing started"
6619 );
6620 }
6621
6622 #[tokio::test]
6623 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
6624 let mut harness = Harness::simple(8, 5, "acme/app");
6625
6626 let foreign = fixtures::policy()
6627 .id(PolicyId::from_u128(1))
6628 .repository("acme/app")
6629 .host(HostId::from_u128(0xdead))
6630 .autoscale("office", 4)
6631 .active()
6632 .build();
6633 let mut draining = policy(2, "acme/app", 4);
6634 draining.request_disable().unwrap();
6635
6636 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
6637
6638 assert_eq!(report.started, 0);
6639 assert_eq!(
6640 harness.demand.polls().len(),
6641 0,
6642 "neither can act on an answer"
6643 );
6644 let factors: Vec<LimitingFactor> = report
6645 .allocations
6646 .iter()
6647 .map(|a| a.limiting_factor)
6648 .collect();
6649 assert!(factors.contains(&LimitingFactor::ForeignHost));
6650 assert!(factors.contains(&LimitingFactor::NotReconciling));
6651 }
6652
6653 #[tokio::test]
6654 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
6655 #[derive(Debug)]
6658 struct BrokenDirectory;
6659
6660 #[async_trait::async_trait]
6661 impl RepositoryDirectory for BrokenDirectory {
6662 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
6663 Err(InventoryError::Cancelled)
6664 }
6665 }
6666
6667 let launcher = Arc::new(FakeLauncher::new());
6668 let events = Arc::new(EventLog::new());
6669 let mut reconciler = Reconciler::new(
6670 host_with(4),
6671 ReconcilerPorts {
6672 demand: Arc::new(FakeDemand::default()),
6673 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6674 lock: Arc::new(InProcessAllocationLock::new()),
6675 directory: Arc::new(BrokenDirectory),
6676 clock: Arc::new(FakeClock::default()),
6677 jitter: Arc::new(NoJitter),
6678 events: Arc::clone(&events) as Arc<dyn EventSink>,
6679 },
6680 );
6681
6682 let org_policy = fixtures::policy()
6683 .id(PolicyId::from_u128(1))
6684 .organization("acme")
6685 .autoscale("home", 4)
6686 .active()
6687 .build();
6688
6689 let report = reconciler.reconcile(&[org_policy]).await;
6690 assert_eq!(report.started, 0);
6691 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6692 assert_eq!(events.count_of("target_unreadable"), 1);
6693 }
6694
6695 fn every_event() -> Vec<LifecycleEvent> {
6707 let policy = PolicyId::from_u128(0xabcd_ef01);
6708 let attempt = AttemptId::from_u128(0x1234_5678);
6709 vec![
6710 LifecycleEvent::DemandObserved {
6711 policy,
6712 demand: u32::MAX,
6713 not_matched: u32::MAX,
6714 unresolvable: u32::MAX,
6715 complete: false,
6716 },
6717 LifecycleEvent::TargetUnreadable {
6718 policy,
6719 reason: unreadable_reason(&RefreshState::Failed {
6720 status: Some(500),
6721 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
6722 .into(),
6723 }),
6724 },
6725 LifecycleEvent::Allocated {
6726 policy,
6727 demand: u32::MAX,
6728 desired: u16::MAX,
6729 active_owned: 7,
6730 headroom: 9,
6731 to_start: 2,
6732 limiting: LimitingFactor::HostCapacity,
6733 },
6734 LifecycleEvent::MonitorOnlySkipped { policy },
6735 LifecycleEvent::RunnerStarted { policy, attempt },
6736 LifecycleEvent::RunnerStartFailed {
6737 policy,
6738 reason: failure_reason_kind(&FailureReason::Other(
6739 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6740 )),
6741 },
6742 LifecycleEvent::AllocationDeferred { policy, count: 4 },
6743 LifecycleEvent::AttemptsUnreadable {
6744 reason: failure_reason_kind(&FailureReason::Other(
6745 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6746 )),
6747 },
6748 LifecycleEvent::HostCapacityUnreadable {
6749 reason: HostCapacityError::MissingHost,
6750 },
6751 LifecycleEvent::HostCapacityUnreadable {
6752 reason: HostCapacityError::JournalUnreadable,
6753 },
6754 LifecycleEvent::AttemptCleanFailed {
6755 policy,
6756 attempt,
6757 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
6758 },
6759 LifecycleEvent::AttemptCleaned {
6760 policy,
6761 attempt,
6762 outcome: OutcomeKind::IdleExit,
6763 },
6764 LifecycleEvent::ScaleDownRefused { policy, attempt },
6765 LifecycleEvent::PollScheduled {
6766 retry_in_ms: 900_000,
6767 pace: PollPace::RateLimited {
6768 kind: RateLimitKind::Primary,
6769 },
6770 },
6771 ]
6772 }
6773
6774 #[test]
6783 fn no_emitted_event_can_carry_a_credential() {
6784 use runner_manager_platform::logging::redact;
6785
6786 for event in every_event() {
6787 let displayed = event.to_string();
6788 assert_eq!(
6789 redact(&displayed),
6790 displayed,
6791 "`{}` renders something `d1`'s sink would have to redact",
6792 event.name()
6793 );
6794
6795 let debugged = format!("{event:?}");
6796 assert_eq!(
6797 redact(&debugged),
6798 debugged,
6799 "`{}`'s Debug renders something `d1`'s sink would have to redact",
6800 event.name()
6801 );
6802 }
6803
6804 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
6807 assert_ne!(
6808 redact(secret),
6809 secret,
6810 "the scan above proves nothing if `redact` no longer recognises a credential"
6811 );
6812 }
6813
6814 #[test]
6815 fn every_field_name_this_sink_emits_is_one_d1_allows() {
6816 use runner_manager_platform::logging::is_field_allowed;
6817
6818 for field in [
6822 "event",
6823 "policy_id",
6824 "attempt_id",
6825 "attempt_state",
6826 "demand",
6827 "desired",
6828 "capacity",
6829 "headroom",
6830 "count",
6831 "reason",
6832 "outcome",
6833 "mode",
6834 "lock",
6835 "retry_in_ms",
6836 "state",
6837 ] {
6838 assert!(
6839 is_field_allowed(field),
6840 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
6841 `[redacted]` in its place and the line would lose its meaning"
6842 );
6843 }
6844 }
6845
6846 #[test]
6847 fn every_failure_reason_has_a_credential_free_kind() {
6848 for reason in FailureReason::ALL {
6849 let kind = failure_reason_kind(&reason);
6850 assert!(!kind.is_empty());
6851 assert!(
6852 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
6853 "`{kind}` is not a fixed identifier"
6854 );
6855 }
6856 assert_eq!(
6857 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
6858 "other",
6859 "the detail of an `Other` reason never reaches an event"
6860 );
6861 let categories = [
6862 IsolationProviderFailure::NotInstalled,
6863 IsolationProviderFailure::PermissionDenied,
6864 IsolationProviderFailure::ImageUnavailableOrIncompatible,
6865 IsolationProviderFailure::DiskQuotaUnavailable,
6866 IsolationProviderFailure::RuntimeOperationFailed,
6867 ];
6868 let mut kinds = std::collections::BTreeSet::new();
6869 for category in categories {
6870 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
6871 assert_ne!(kind, "other");
6872 assert!(!kind.contains("secret"));
6873 kinds.insert(kind);
6874 }
6875 assert_eq!(kinds.len(), categories.len());
6876 }
6877
6878 fn production_half_of(source: &str) -> String {
6893 let production = source
6894 .split_once("\n#[cfg(test)]")
6895 .map_or(source, |(production, _)| production);
6896 production
6897 .lines()
6898 .filter(|line| !line.trim_start().starts_with("//"))
6899 .collect::<Vec<_>>()
6900 .join("\n")
6901 }
6902
6903 fn this_file_above_its_tests_without_prose() -> String {
6905 production_half_of(include_str!("reconcile.rs"))
6906 }
6907
6908 fn normalise_for_scan(text: &str) -> String {
6927 text.to_ascii_lowercase().replace(['_', ' '], "")
6928 }
6929
6930 const FORBIDDEN: &[&str] = &[
6940 concat!("fn ", "acquire", "_job"),
6941 concat!("fn ", "claim", "_job"),
6942 concat!("fn ", "lease", "_job"),
6943 concat!("fn ", "reserve", "_job"),
6944 concat!("fn ", "ack", "nowledge"),
6945 concat!("struct ", "Job", "Lease"),
6946 concat!("struct ", "Job", "Claim"),
6947 concat!("struct ", "Job", "Reservation"),
6948 ];
6949
6950 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
6951 let haystack = normalise_for_scan(source);
6952 FORBIDDEN
6953 .iter()
6954 .copied()
6955 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
6956 }
6957
6958 #[test]
6992 fn nothing_in_this_crate_reserves_or_claims_a_job() {
6993 const SOURCES: &[(&str, &str)] = &[
6994 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
6995 ("docker_cleanup.rs", include_str!("docker_cleanup.rs")),
6996 (
6997 "docker_cleanup/http.rs",
6998 include_str!("docker_cleanup/http.rs"),
6999 ),
7000 (
7001 "docker_cleanup/proxy.rs",
7002 include_str!("docker_cleanup/proxy.rs"),
7003 ),
7004 (
7005 "docker_cleanup/tests.rs",
7006 include_str!("docker_cleanup/tests.rs"),
7007 ),
7008 ("lib.rs", include_str!("lib.rs")),
7009 ("lifecycle.rs", include_str!("lifecycle.rs")),
7010 (
7011 "lifecycle/windows_hyperv.rs",
7012 include_str!("lifecycle/windows_hyperv.rs"),
7013 ),
7014 ("oci.rs", include_str!("oci.rs")),
7015 ("package.rs", include_str!("package.rs")),
7016 ("reconcile.rs", include_str!("reconcile.rs")),
7017 ];
7018
7019 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
7020 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
7021 let entry = entry.expect("a readable directory entry");
7022 let name = entry.file_name().to_string_lossy().into_owned();
7023 let joined = if prefix.is_empty() {
7026 name.clone()
7027 } else {
7028 format!("{prefix}/{name}")
7029 };
7030 if entry.path().is_dir() {
7031 walk(&entry.path(), &joined, found);
7032 } else if name.ends_with(".rs") {
7033 found.push(joined);
7034 }
7035 }
7036 }
7037
7038 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
7039 listed.sort_unstable();
7040 let mut on_disk = Vec::new();
7041 walk(
7042 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
7043 "",
7044 &mut on_disk,
7045 );
7046 on_disk.sort_unstable();
7047 assert_eq!(
7048 listed, on_disk,
7049 "a source file was added or removed; this scan claims to cover the whole crate \
7050 and a stale list makes that claim false"
7051 );
7052
7053 for (name, source) in SOURCES {
7054 assert_eq!(
7055 forbidden_shape_in(&production_half_of(source)),
7056 None,
7057 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
7058 REST, and a local lease coordinates this host with itself and with nothing \
7059 else. If an owner decision restored one, that decision belongs in this \
7060 module's documentation and in this test before it belongs in the code"
7061 );
7062 }
7063
7064 assert!(
7067 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
7068 "the scan above proves nothing if the needles no longer match"
7069 );
7070 }
7071
7072 #[test]
7100 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
7101 let production = this_file_above_its_tests_without_prose();
7102
7103 assert!(
7104 production.contains("DemandTally"),
7105 "the reconciliation loop must tally queued jobs against this policy's routing \
7106 labels. A production half that named `DemandTally` nowhere would mean the \
7107 label filtering had been removed, and a repository whose jobs target \
7108 `ubuntu-latest` would drive its policy toward `max_capacity` again"
7109 );
7110
7111 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
7112 assert!(
7113 !production.contains(second_implementation),
7114 "the reconciliation loop names `{second_implementation}`, which is the \
7115 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
7116 has already decided it. This module applies the predicate and does not \
7117 re-implement it; if an owner decision changed that, it belongs in this \
7118 module's documentation and in this test before it belongs in the code"
7119 );
7120 }
7121 }
7122
7123 #[test]
7131 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
7132 let policy = policy(1, "acme/app", 10);
7133 let reading = QueuedDemand::of(
7134 repo("acme/app"),
7135 [
7136 fixtures::queued_job(&[HOST_LABEL]),
7137 fixtures::queued_job(&[HOST_LABEL]),
7138 fixtures::queued_job(&["ubuntu-latest"]),
7139 fixtures::unresolvable_job(),
7140 ],
7141 );
7142
7143 let tally = demand_for(&policy, &reading);
7144
7145 assert_eq!(
7146 tally.demand(),
7147 2,
7148 "only the jobs whose required labels this policy carries are demand"
7149 );
7150 assert_eq!(
7151 tally.not_matched, 1,
7152 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
7153 runner that idles until it times out"
7154 );
7155 assert_eq!(
7156 tally.unresolvable.len(),
7157 1,
7158 "an unresolvable `runs-on` is never demand and never discarded"
7159 );
7160 }
7161
7162 #[test]
7165 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
7166 let mut per_repository = BTreeMap::new();
7167 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
7168 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
7169 let reading = QueuedDemand::new(per_repository);
7170
7171 let repository_policy = policy(1, "acme/left", 10);
7172 assert_eq!(
7173 demand_for(&repository_policy, &reading).demand(),
7174 3,
7175 "a repository target reads its own repository's queue and not the aggregate"
7176 );
7177
7178 let org_policy = fixtures::policy()
7179 .id(PolicyId::from_u128(2))
7180 .organization("acme")
7181 .autoscale("home", 10)
7182 .active()
7183 .build();
7184 assert_eq!(
7185 demand_for(&org_policy, &reading).demand(),
7186 7,
7187 "an organization policy serves any repository in its scope, so its demand is \
7188 the whole aggregate's"
7189 );
7190 }
7191
7192 #[test]
7193 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
7194 let host = host_with(2);
7199 let policy = policy(1, "acme/app", 5);
7200 let attempts: Vec<RunnerAttempt> = Vec::new();
7201 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
7202
7203 let allocation = allocator.allocate(&policy, u32::MAX);
7204 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
7205 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
7206 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
7207 }
7208
7209 #[derive(Debug, Default)]
7212 struct FixedDirector {
7213 directed: Mutex<BTreeMap<PolicyId, u32>>,
7214 started: Mutex<Vec<(PolicyId, AttemptId)>>,
7215 }
7216
7217 impl DemandDirector for FixedDirector {
7218 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32> {
7219 self.directed.lock().unwrap().get(&policy.id).copied()
7220 }
7221
7222 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId) {
7223 self.started.lock().unwrap().push((policy.id, attempt));
7224 }
7225 }
7226
7227 #[tokio::test]
7231 async fn a_directed_policy_starts_only_what_it_was_directed_to_within_its_ceilings() {
7232 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
7233 let launcher = Arc::new(FakeLauncher::new());
7234 let director = Arc::new(FixedDirector::default());
7235 let mut reconciler = Reconciler::new(
7236 host_with(10),
7237 ReconcilerPorts {
7238 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
7239 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7240 lock: Arc::new(InProcessAllocationLock::new()),
7241 directory: Arc::new(FakeDirectory::default()),
7242 clock: Arc::new(FakeClock::default()),
7243 jitter: Arc::new(NoJitter),
7244 events: Arc::new(NoEvents),
7245 },
7246 )
7247 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7248 let pool = policy(1, "acme/app", 2);
7249
7250 director.directed.lock().unwrap().insert(pool.id, 0);
7252 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7253 assert_eq!(report.started, 0);
7254
7255 director.directed.lock().unwrap().insert(pool.id, 1);
7257 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7258 assert_eq!(report.started, 1);
7259 assert_eq!(director.started.lock().unwrap().len(), 1);
7260
7261 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7263 assert_eq!(report.started, 0);
7264
7265 director.directed.lock().unwrap().insert(pool.id, 3);
7267 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
7268 assert_eq!(report.started, 1);
7269 assert_eq!(launcher.live_count(), 2);
7270
7271 let polls = demand.polls().len();
7273 director.directed.lock().unwrap().insert(pool.id, 0);
7274 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
7275 assert_eq!(report.started, 0);
7276 let fresh = Arc::new(FakeLauncher::new());
7277 let mut starter = Reconciler::new(
7278 host_with(10),
7279 ReconcilerPorts {
7280 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
7281 launcher: Arc::clone(&fresh) as Arc<dyn RunnerLauncher>,
7282 lock: Arc::new(InProcessAllocationLock::new()),
7283 directory: Arc::new(FakeDirectory::default()),
7284 clock: Arc::new(FakeClock::default()),
7285 jitter: Arc::new(NoJitter),
7286 events: Arc::new(NoEvents),
7287 },
7288 )
7289 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7290 let wide = policy(2, "acme/app", 6);
7291 director.directed.lock().unwrap().insert(wide.id, 6);
7292 let report = starter.start_directed(std::slice::from_ref(&wide)).await;
7293 assert_eq!(report.started, 6, "six launches, one call");
7294 assert_eq!(demand.polls().len(), polls, "GitHub was not polled");
7295 assert_eq!(
7297 starter
7298 .start_directed(&[policy(3, "acme/app", 2)])
7299 .await
7300 .started,
7301 0
7302 );
7303
7304 let failing = Arc::new(FakeLauncher::new());
7306 let mut reconciler = Reconciler::new(
7307 host_with(10),
7308 ReconcilerPorts {
7309 demand: Arc::new(FakeDemand::failing(RefreshState::Offline)),
7310 launcher: Arc::clone(&failing) as Arc<dyn RunnerLauncher>,
7311 lock: Arc::new(InProcessAllocationLock::new()),
7312 directory: Arc::new(FakeDirectory::default()),
7313 clock: Arc::new(FakeClock::default()),
7314 jitter: Arc::new(NoJitter),
7315 events: Arc::new(NoEvents),
7316 },
7317 )
7318 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
7319 director.directed.lock().unwrap().insert(pool.id, 1);
7320 assert_eq!(reconciler.reconcile(&[pool]).await.started, 1);
7321
7322 let own = Arc::new(FakeLauncher::new());
7324 let mut reconciler = Reconciler::new(
7325 host_with(10),
7326 ReconcilerPorts {
7327 demand: Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
7328 launcher: Arc::clone(&own) as Arc<dyn RunnerLauncher>,
7329 lock: Arc::new(InProcessAllocationLock::new()),
7330 directory: Arc::new(FakeDirectory::default()),
7331 clock: Arc::new(FakeClock::default()),
7332 jitter: Arc::new(NoJitter),
7333 events: Arc::new(NoEvents),
7334 },
7335 )
7336 .with_director(Arc::new(OwnDemand));
7337 assert_eq!(
7338 reconciler
7339 .reconcile(&[policy(1, "acme/app", 2)])
7340 .await
7341 .started,
7342 2
7343 );
7344 }
7345}