1use std::collections::{BTreeMap, BTreeSet};
113use std::fmt;
114use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
115use std::sync::{Arc, Mutex};
116use std::time::Duration;
117
118use runner_manager_domain::attempt::{
119 AttemptOutcome, AttemptState, FailureReason, IsolationProviderFailure, RunnerAttempt,
120};
121use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
122use runner_manager_domain::model::{
123 AttemptId, Clock, Host, IdlePollInterval, Org, OwnerRepo, PolicyId, RefreshInterval,
124 ScaleTarget, Timestamp,
125};
126use runner_manager_domain::policy::{DemandTally, ScalePolicy};
127use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
128use runner_manager_github::rest::{
129 ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
130};
131use runner_manager_github::traffic::{PrimaryPressure, RateLimitSnapshot};
132
133pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
153
154pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
163
164const MAX_BACKOFF_DOUBLINGS: u32 = 5;
170
171const JITTER_RATIO: f64 = 0.5;
179
180const RATE_LIMIT_JITTER_RATIO: f64 = 0.1;
188
189pub const MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET: u32 = 300;
197
198pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
205
206pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
236
237#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum PollOutcome {
255 Ready(QueuedDemand),
258 Failed(RefreshState),
261}
262
263impl PollOutcome {
264 #[must_use]
266 pub const fn reading(&self) -> Option<&QueuedDemand> {
267 match self {
268 Self::Ready(demand) => Some(demand),
269 Self::Failed(_) => None,
270 }
271 }
272
273 #[must_use]
275 pub const fn failure(&self) -> Option<&RefreshState> {
276 match self {
277 Self::Failed(state) => Some(state),
278 Self::Ready(_) => None,
279 }
280 }
281
282 #[must_use]
289 pub fn is_offline(&self) -> bool {
290 matches!(self, Self::Failed(RefreshState::Offline))
291 }
292}
293
294#[async_trait::async_trait]
302pub trait DemandSource: fmt::Debug + Send + Sync {
303 async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
305}
306
307#[derive(Debug)]
312pub struct GatewayDemand<G> {
313 gateway: G,
314 cancel: CancelToken,
315}
316
317impl<G: DemandGateway> GatewayDemand<G> {
318 #[must_use]
319 pub const fn new(gateway: G, cancel: CancelToken) -> Self {
320 Self { gateway, cancel }
321 }
322
323 #[must_use]
324 pub const fn gateway(&self) -> &G {
325 &self.gateway
326 }
327}
328
329#[async_trait::async_trait]
330impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
331 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
332 match self.gateway.queued_demand(scope, &self.cancel).await {
333 Ok(demand) => PollOutcome::Ready(demand),
334 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
338 }
339 }
340}
341
342#[async_trait::async_trait]
353pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
354 async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
359}
360
361#[derive(Debug, Clone)]
362struct CachedRepositories {
363 repositories: Vec<OwnerRepo>,
364 fetched_at: Timestamp,
365}
366
367#[derive(Debug)]
385pub struct RepositoryCache {
386 directory: Arc<dyn RepositoryDirectory>,
387 clock: Arc<dyn Clock>,
388 ttl: Duration,
389 entries: Mutex<BTreeMap<Org, CachedRepositories>>,
390 lookups: AtomicU64,
391}
392
393impl RepositoryCache {
394 #[must_use]
397 pub fn new(
398 directory: Arc<dyn RepositoryDirectory>,
399 clock: Arc<dyn Clock>,
400 poll: RefreshInterval,
401 ) -> Self {
402 let ttl = Duration::from_secs(u64::from(poll.as_secs()))
403 .saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
404 Self {
405 directory,
406 clock,
407 ttl,
408 entries: Mutex::new(BTreeMap::new()),
409 lookups: AtomicU64::new(0),
410 }
411 }
412
413 #[must_use]
415 pub const fn ttl(&self) -> Duration {
416 self.ttl
417 }
418
419 #[must_use]
424 pub fn lookups(&self) -> u64 {
425 self.lookups.load(Ordering::SeqCst)
426 }
427
428 pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
434 match target {
435 ScaleTarget::Repository(repository) => {
436 Ok(ActivityScope::repository(repository.clone()))
437 }
438 ScaleTarget::Organization(org) => {
439 let repositories = self.repositories_of(org).await?;
440 Ok(ActivityScope::organization(org.clone(), repositories))
441 }
442 }
443 }
444
445 async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
446 let now = self.clock.now();
447 if let Some(fresh) = self.fresh_entry(org, now) {
448 return Ok(fresh);
449 }
450
451 let repositories = self.directory.repositories(org).await?;
458 self.lookups.fetch_add(1, Ordering::SeqCst);
459 self.store(org.clone(), repositories.clone(), now);
460 Ok(repositories)
461 }
462
463 fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
464 let entries = self.entries.lock().ok()?;
465 let entry = entries.get(org)?;
466 let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
467 (age < self.ttl).then(|| entry.repositories.clone())
468 }
469
470 fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
471 if let Ok(mut entries) = self.entries.lock() {
472 entries.insert(
473 org,
474 CachedRepositories {
475 repositories,
476 fetched_at,
477 },
478 );
479 }
480 }
481}
482
483pub trait Jitter: fmt::Debug + Send + Sync {
494 fn fraction(&self) -> f64;
497}
498
499#[derive(Debug, Clone, Copy, Default)]
501pub struct RandomJitter;
502
503impl Jitter for RandomJitter {
504 fn fraction(&self) -> f64 {
505 rand::random::<f64>()
506 }
507}
508
509#[derive(Debug, Clone, Copy)]
511pub struct FixedJitter(pub f64);
512
513impl Jitter for FixedJitter {
514 fn fraction(&self) -> f64 {
515 self.0
516 }
517}
518
519#[derive(Debug, Clone, Copy, Default)]
521pub struct NoJitter;
522
523impl Jitter for NoJitter {
524 fn fraction(&self) -> f64 {
525 0.0
526 }
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum PollPace {
541 Nominal,
543 Idle,
546 Stretched { pressure: PrimaryPressure },
551 RateLimited { kind: RateLimitKind },
553 LockedOut,
555 Offline { consecutive: u32 },
558 Blocked,
563}
564
565impl PollPace {
566 #[must_use]
568 pub const fn is_throttled(&self) -> bool {
569 !matches!(self, Self::Nominal | Self::Idle)
570 }
571
572 #[must_use]
574 pub const fn as_str(&self) -> &'static str {
575 match self {
576 Self::Nominal => "nominal",
577 Self::Idle => "idle",
578 Self::Stretched {
579 pressure: PrimaryPressure::Normal,
580 } => "nominal",
581 Self::Stretched {
582 pressure: PrimaryPressure::Low,
583 } => "stretched_low_budget",
584 Self::Stretched {
585 pressure: PrimaryPressure::Critical,
586 } => "stretched_critical_budget",
587 Self::RateLimited {
588 kind: RateLimitKind::Primary,
589 } => "rate_limited_primary",
590 Self::RateLimited {
591 kind: RateLimitKind::Secondary,
592 } => "rate_limited_secondary",
593 Self::LockedOut => "locked_out",
594 Self::Offline { .. } => "offline",
595 Self::Blocked => "blocked",
596 }
597 }
598}
599
600impl fmt::Display for PollPace {
601 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
602 f.write_str(self.as_str())
603 }
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub struct NextPoll {
609 pub delay: Duration,
610 pub pace: PollPace,
611}
612
613#[derive(Debug, Clone)]
633pub struct PollSchedule {
634 interval: RefreshInterval,
635 idle: Duration,
638 consecutive_offline: u32,
639 offline_since: Option<Timestamp>,
640}
641
642#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
644pub struct PollConditions {
645 pub quiet: bool,
648 pub quota: Option<RateLimitSnapshot>,
650 pub requests_per_pass: u32,
654}
655
656impl PollSchedule {
657 #[must_use]
658 pub const fn new(interval: RefreshInterval) -> Self {
659 Self {
660 interval,
661 idle: Duration::from_secs(interval.as_secs() as u64),
662 consecutive_offline: 0,
663 offline_since: None,
664 }
665 }
666
667 #[must_use]
672 pub fn with_idle(mut self, idle: Duration) -> Self {
673 self.set_intervals(self.interval, idle);
674 self
675 }
676
677 pub fn set_intervals(&mut self, interval: RefreshInterval, idle: Duration) {
679 self.interval = interval;
680 self.idle = idle.min(self.nominal()).max(Self::idle_floor());
681 }
682
683 #[must_use]
684 pub const fn interval(&self) -> RefreshInterval {
685 self.interval
686 }
687
688 #[must_use]
690 pub const fn idle(&self) -> Duration {
691 self.idle
692 }
693
694 #[must_use]
697 pub const fn idle_floor() -> Duration {
698 Duration::from_secs(IdlePollInterval::MIN_SECS as u64)
699 }
700
701 #[must_use]
703 pub const fn nominal(&self) -> Duration {
704 Duration::from_secs(self.interval.as_secs() as u64)
705 }
706
707 #[must_use]
709 pub const fn consecutive_offline(&self) -> u32 {
710 self.consecutive_offline
711 }
712
713 #[must_use]
722 pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
723 let since = self.offline_since?;
724 now.signed_duration_since(since).to_std().ok()
725 }
726
727 #[must_use]
729 pub const fn floor() -> Duration {
730 Duration::from_secs(RefreshInterval::MIN_SECS as u64)
731 }
732
733 pub fn next_poll(
741 &mut self,
742 failure: Option<&RefreshState>,
743 now: Timestamp,
744 jitter: &dyn Jitter,
745 ) -> NextPoll {
746 self.next_poll_with(failure, PollConditions::default(), now, jitter)
747 }
748
749 pub fn next_poll_with(
764 &mut self,
765 failure: Option<&RefreshState>,
766 conditions: PollConditions,
767 now: Timestamp,
768 jitter: &dyn Jitter,
769 ) -> NextPoll {
770 let nominal = self.nominal();
771
772 let next = match failure {
773 None => {
774 self.recovered();
775 let pressure = conditions
776 .quota
777 .map_or(PrimaryPressure::Normal, |quota| quota.pressure(now));
778 match (pressure, conditions.quota) {
779 (PrimaryPressure::Low | PrimaryPressure::Critical, Some(quota)) => NextPoll {
780 delay: nominal
781 .saturating_mul(pressure.stretch_factor())
782 .min(quota.reset_in(now).max(nominal)),
783 pace: PollPace::Stretched { pressure },
784 },
785 _ if conditions.quiet && self.idle < nominal => NextPoll {
788 delay: self.idle.max(idle_floor_for(conditions.requests_per_pass)),
789 pace: PollPace::Idle,
790 },
791 _ => NextPoll {
792 delay: nominal,
793 pace: PollPace::Nominal,
794 },
795 }
796 }
797 Some(RefreshState::Offline) => {
798 self.consecutive_offline = self.consecutive_offline.saturating_add(1);
799 self.offline_since.get_or_insert(now);
801 NextPoll {
802 delay: self.offline_delay(nominal, jitter),
803 pace: PollPace::Offline {
804 consecutive: self.consecutive_offline,
805 },
806 }
807 }
808 Some(state @ RefreshState::RateLimited(limit)) => {
809 self.recovered();
810 let wait = retry_floor(state, now).max(nominal);
813 NextPoll {
814 delay: wait.saturating_add(
815 wait.mul_f64(RATE_LIMIT_JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0)),
816 ),
817 pace: PollPace::RateLimited { kind: limit.kind },
818 }
819 }
820 Some(state @ RefreshState::LockedOut { .. }) => {
821 self.recovered();
822 NextPoll {
823 delay: retry_floor(state, now).max(nominal),
824 pace: PollPace::LockedOut,
825 }
826 }
827 Some(_) => {
833 self.recovered();
836 NextPoll {
837 delay: nominal,
838 pace: PollPace::Blocked,
839 }
840 }
841 };
842
843 debug_assert!(
844 next.delay >= Self::floor() || next.pace == PollPace::Idle,
845 "the 30-second floor is a rate-budget constraint and no charged branch may go below it"
846 );
847 debug_assert!(
848 next.delay >= Self::idle_floor(),
849 "the idle floor is a secondary-limit constraint and no branch may go below it"
850 );
851 next
852 }
853
854 fn recovered(&mut self) {
856 self.consecutive_offline = 0;
857 self.offline_since = None;
858 }
859
860 fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
861 let doublings = self
862 .consecutive_offline
863 .saturating_sub(1)
864 .min(MAX_BACKOFF_DOUBLINGS);
865 let grown = nominal.saturating_mul(1_u32 << doublings);
866 let capped = grown.min(MAX_OFFLINE_BACKOFF);
867 let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
873 capped.saturating_add(spread).max(Self::floor())
874 }
875}
876
877fn is_quiet(readings: &BTreeMap<ScaleTarget, PollOutcome>, report: &ReconcileReport) -> bool {
888 !readings.is_empty()
889 && readings
890 .values()
891 .all(|outcome| outcome.reading().is_some_and(QueuedDemand::is_quiet))
892 && report.started == 0
893 && report
897 .allocations
898 .iter()
899 .all(|allocation| allocation.active_owned == 0 && allocation.demand == 0)
900}
901
902fn idle_floor_for(requests_per_pass: u32) -> Duration {
904 Duration::from_secs(
905 u64::from(requests_per_pass) * 60 / u64::from(MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET),
906 )
907}
908
909fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
917 state.retry_delay(now).unwrap_or(PollSchedule::floor())
918}
919
920#[derive(Debug, Clone, Copy, PartialEq, Eq)]
933pub struct OfflineState {
934 pub consecutive: u32,
936 pub retry_in: Duration,
938 pub offline_for: Option<Duration>,
940}
941
942impl OfflineState {
943 #[must_use]
944 pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
945 Self {
946 consecutive,
947 retry_in,
948 offline_for: None,
949 }
950 }
951
952 #[must_use]
953 pub const fn since(mut self, offline_for: Duration) -> Self {
954 self.offline_for = Some(offline_for);
955 self
956 }
957
958 #[must_use]
963 pub fn has_outlasted_the_queue(&self) -> bool {
964 self.offline_for
965 .is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
966 }
967}
968
969impl fmt::Display for OfflineState {
970 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
971 write!(
972 f,
973 "GitHub is unreachable; no new runners are being started and running \
974 runners are left alone. Retrying in {}s",
975 self.retry_in.as_secs()
976 )?;
977 if self.has_outlasted_the_queue() {
978 f.write_str(
979 ". This outage has lasted more than 24 hours, and GitHub cancels a queued \
980 job after 24 hours, so queued work has been lost",
981 )
982 } else {
983 f.write_str(
984 ". GitHub cancels a queued job after 24 hours, so an outage longer than \
985 that loses queued work",
986 )
987 }
988 }
989}
990
991#[derive(Debug, Clone, Copy)]
997pub struct LaunchRequest<'a> {
998 pub host: &'a Host,
999 pub policy: &'a ScalePolicy,
1000 pub(crate) allocation_guard: &'a AllocationGuard,
1006 pub lock_wait: Duration,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1017#[error("the runner could not be started: {reason}")]
1018pub struct LaunchFailure {
1019 pub reason: FailureReason,
1020}
1021
1022#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1025pub struct ReplacementIntent {
1026 pub policy: PolicyId,
1027 pub previous_attempt: AttemptId,
1028 pub operation: &'static str,
1029}
1030
1031impl LaunchFailure {
1032 #[must_use]
1033 pub const fn new(reason: FailureReason) -> Self {
1034 Self { reason }
1035 }
1036}
1037
1038#[async_trait::async_trait]
1075pub trait RunnerLauncher: fmt::Debug + Send + Sync {
1076 async fn supervise(
1081 &self,
1082 _policy: &ScalePolicy,
1083 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1084 Ok(Vec::new())
1085 }
1086
1087 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
1100
1101 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
1148
1149 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
1157}
1158
1159#[derive(Debug)]
1170pub struct FitnessGatedLauncher {
1171 inner: Arc<dyn RunnerLauncher>,
1172 unfit: Arc<AtomicBool>,
1173}
1174
1175impl FitnessGatedLauncher {
1176 #[must_use]
1177 pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
1178 Self { inner, unfit }
1179 }
1180}
1181
1182#[async_trait::async_trait]
1183impl RunnerLauncher for FitnessGatedLauncher {
1184 async fn supervise(
1185 &self,
1186 policy: &ScalePolicy,
1187 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1188 self.inner.supervise(policy).await
1189 }
1190
1191 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
1192 self.inner.attempts().await
1193 }
1194
1195 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
1196 if self.unfit.load(Ordering::Acquire) {
1197 return Err(LaunchFailure::new(FailureReason::HostUnfit));
1198 }
1199 self.inner.launch(request).await
1200 }
1201
1202 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
1203 self.inner.clean(attempt).await
1204 }
1205}
1206
1207pub struct AllocationGuard {
1217 _held: Box<dyn std::any::Any + Send + Sync>,
1218}
1219
1220impl AllocationGuard {
1221 #[must_use]
1223 fn new<T: Send + Sync + 'static>(held: T) -> Self {
1224 Self {
1225 _held: Box::new(held),
1226 }
1227 }
1228}
1229
1230impl fmt::Debug for AllocationGuard {
1231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1232 f.write_str("AllocationGuard")
1233 }
1234}
1235
1236#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1238#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1239pub struct AllocationLockBusy;
1240
1241#[async_trait::async_trait]
1255pub trait AllocationLock: fmt::Debug + Send + Sync {
1256 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1262}
1263
1264#[derive(Debug, Default)]
1272pub struct InProcessAllocationLock {
1273 mutex: Arc<tokio::sync::Mutex<()>>,
1274}
1275
1276impl InProcessAllocationLock {
1277 #[must_use]
1278 pub fn new() -> Self {
1279 Self::default()
1280 }
1281}
1282
1283#[async_trait::async_trait]
1284impl AllocationLock for InProcessAllocationLock {
1285 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1286 let mutex = Arc::clone(&self.mutex);
1287 let guard = mutex.lock_owned().await;
1288 Ok(AllocationGuard::new(guard))
1289 }
1290}
1291
1292#[derive(Debug, Clone)]
1307pub struct FileAllocationLock {
1308 paths: Arc<runner_manager_platform::paths::AppPaths>,
1309 wait: Duration,
1310}
1311
1312impl FileAllocationLock {
1313 #[must_use]
1314 pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1315 Self {
1316 paths,
1317 wait: ALLOCATION_LOCK_WAIT,
1318 }
1319 }
1320
1321 #[must_use]
1322 pub const fn with_wait(mut self, wait: Duration) -> Self {
1323 self.wait = wait;
1324 self
1325 }
1326}
1327
1328#[async_trait::async_trait]
1329impl AllocationLock for FileAllocationLock {
1330 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1331 use runner_manager_platform::lock::{HostLock, LockKind};
1332
1333 let paths = Arc::clone(&self.paths);
1334 let wait = self.wait;
1335 let held = tokio::task::spawn_blocking(move || {
1336 HostLock::acquire(&paths, LockKind::Allocation, wait)
1337 })
1338 .await;
1339
1340 match held {
1341 Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1342 Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1346 }
1347 }
1348}
1349
1350#[derive(Debug)]
1374pub struct WslRecoveryAllocationLock {
1375 paths: Arc<runner_manager_platform::paths::AppPaths>,
1376 inner: Arc<dyn AllocationLock>,
1377 blocked_after_refusals: u32,
1378 blocked_after: Duration,
1379 streak_gap: Duration,
1381 in_flight: Arc<std::sync::atomic::AtomicUsize>,
1382 refusals: Mutex<Refusals>,
1383}
1384
1385#[derive(Debug, Default)]
1387struct Refusals {
1388 streak: Option<Streak>,
1391 recorded: bool,
1392}
1393
1394#[derive(Debug, Clone, Copy)]
1395struct Streak {
1396 since: chrono::DateTime<chrono::Utc>,
1397 latest: chrono::DateTime<chrono::Utc>,
1398 count: u32,
1399}
1400
1401struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
1403
1404impl InFlight {
1405 fn enter(count: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
1406 count.fetch_add(1, Ordering::SeqCst);
1407 Self(Arc::clone(count))
1408 }
1409}
1410
1411impl Drop for InFlight {
1412 fn drop(&mut self) {
1413 self.0.fetch_sub(1, Ordering::SeqCst);
1414 }
1415}
1416
1417struct LaunchFenceGuard(Option<runner_manager_platform::wsl::fence::GuestLaunchClaim>);
1421
1422impl Drop for LaunchFenceGuard {
1423 fn drop(&mut self) {
1424 if let Some(claim) = self.0.take()
1425 && let Err(error) = claim.release()
1426 {
1427 tracing::warn!(
1428 %error,
1429 "the WSL launch fence could not be released; the next launch reclaims it"
1430 );
1431 }
1432 }
1433}
1434
1435impl WslRecoveryAllocationLock {
1436 #[must_use]
1437 pub fn new(
1438 paths: Arc<runner_manager_platform::paths::AppPaths>,
1439 inner: Arc<dyn AllocationLock>,
1440 ) -> Self {
1441 use runner_manager_platform::launch_health::{BLOCKED_AFTER, BLOCKED_AFTER_DEFERRALS};
1442 Self {
1443 paths,
1444 inner,
1445 blocked_after_refusals: BLOCKED_AFTER_DEFERRALS,
1446 blocked_after: BLOCKED_AFTER,
1447 streak_gap: BLOCKED_AFTER,
1448 in_flight: Arc::default(),
1449 refusals: Mutex::default(),
1450 }
1451 }
1452
1453 #[cfg(test)]
1456 const fn with_blocked_after(mut self, refusals: u32, after: Duration) -> Self {
1457 self.blocked_after_refusals = refusals;
1458 self.blocked_after = after;
1459 self
1460 }
1461
1462 #[cfg(test)]
1464 const fn with_streak_gap(mut self, gap: Duration) -> Self {
1465 self.streak_gap = gap;
1466 self
1467 }
1468
1469 fn refusals(&self) -> std::sync::MutexGuard<'_, Refusals> {
1470 self.refusals
1471 .lock()
1472 .unwrap_or_else(std::sync::PoisonError::into_inner)
1473 }
1474
1475 fn note_granted(&self) {
1476 let mut refusals = self.refusals();
1477 refusals.streak = None;
1478 if refusals.recorded {
1479 match runner_manager_platform::launch_health::clear_launches_blocked(&self.paths) {
1480 Ok(()) => refusals.recorded = false,
1481 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be cleared"),
1483 }
1484 }
1485 }
1486
1487 fn note_refused(&self, cause: &runner_manager_platform::launch_health::BlockCause) {
1488 use runner_manager_platform::launch_health::{Viewpoint, record_launches_blocked};
1489 use runner_manager_platform::wsl::fence::elapsed_at_least;
1490
1491 if self.in_flight.load(Ordering::SeqCst) > 0 {
1492 return;
1493 }
1494 let now = chrono::Utc::now();
1495 let mut refusals = self.refusals();
1496 let streak = match refusals.streak {
1499 Some(streak) if !elapsed_at_least(streak.latest, now, self.streak_gap) => Streak {
1500 latest: now,
1501 count: streak.count.saturating_add(1),
1502 ..streak
1503 },
1504 _ => Streak {
1505 since: now,
1506 latest: now,
1507 count: 1,
1508 },
1509 };
1510 refusals.streak = Some(streak);
1511 if streak.count < self.blocked_after_refusals
1512 || !elapsed_at_least(streak.since, now, self.blocked_after)
1513 {
1514 return;
1515 }
1516 let blocked = cause.blocked_since(streak.since, Viewpoint::Guest);
1517 if !refusals.recorded {
1518 tracing::warn!(
1519 refusals = streak.count,
1520 "runner launches are blocked {blocked}: {}",
1521 blocked.remedy
1522 );
1523 }
1524 match record_launches_blocked(&self.paths, &blocked, now) {
1527 Ok(()) => refusals.recorded = true,
1528 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be written"),
1529 }
1530 }
1531}
1532
1533#[async_trait::async_trait]
1534impl AllocationLock for WslRecoveryAllocationLock {
1535 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1536 use runner_manager_platform::launch_health::BlockCause;
1537 use runner_manager_platform::wsl::fence::{
1538 DrainRequest, FENCE_DIRECTORY, FenceClaim, GuestClaimAttempt, GuestRecoveryConfig,
1539 try_claim_guest_launch,
1540 };
1541
1542 let paths = Arc::clone(&self.paths);
1543 let claim = tokio::task::spawn_blocking(move || {
1544 let unusable = |error: &dyn fmt::Display| BlockCause::FenceUnusable {
1545 detail: error.to_string(),
1546 };
1547 let config = match GuestRecoveryConfig::read(&paths) {
1548 Ok(Some(config)) => config,
1549 Ok(None) => return Ok(LaunchFenceGuard(None)),
1550 Err(error) => return Err(unusable(&error)),
1551 };
1552 let root = config.shared_root;
1553 let claim = match try_claim_guest_launch(&root) {
1554 Ok(GuestClaimAttempt::Claimed { claim, reclaimed }) => {
1555 if let Some(reclaimed) = reclaimed {
1556 tracing::warn!(
1557 shared_root = %root.display(),
1558 %reclaimed,
1559 "reclaimed a WSL launch fence claim whose owner is gone"
1560 );
1561 }
1562 LaunchFenceGuard(Some(claim))
1565 }
1566 Ok(GuestClaimAttempt::Busy) => {
1567 let directory = root.join(FENCE_DIRECTORY);
1568 return Err(match FenceClaim::owner(&root) {
1569 Ok(owner) => BlockCause::FenceHeld { directory, owner },
1570 Err(error) => BlockCause::FenceOwnerUnreadable {
1571 directory,
1572 detail: error.to_string(),
1573 },
1574 });
1575 }
1576 Err(error) => {
1577 return Err(match FenceClaim::owner(&root) {
1580 Err(owner_error) => BlockCause::FenceOwnerUnreadable {
1581 directory: root.join(FENCE_DIRECTORY),
1582 detail: owner_error.to_string(),
1583 },
1584 Ok(_) => unusable(&error),
1585 });
1586 }
1587 };
1588 match DrainRequest::read(&root) {
1589 Ok(None) => Ok(claim),
1590 Ok(Some(_)) => Err(BlockCause::DrainRequested { root }),
1591 Err(error) => Err(unusable(&error)),
1592 }
1593 })
1594 .await
1595 .map_err(|_| AllocationLockBusy)?;
1596
1597 let claim = match claim {
1598 Ok(claim) => claim,
1599 Err(cause) => {
1600 self.note_refused(&cause);
1601 return Err(AllocationLockBusy);
1602 }
1603 };
1604 let Ok(inner) = self.inner.acquire().await else {
1605 self.note_refused(&BlockCause::AllocationLockBusy {
1606 path: self
1607 .paths
1608 .state_dir()
1609 .join(runner_manager_platform::lock::LockKind::Allocation.file_name()),
1610 });
1611 return Err(AllocationLockBusy);
1612 };
1613 self.note_granted();
1614 let in_flight = InFlight::enter(&self.in_flight);
1615 Ok(AllocationGuard::new((claim, inner, in_flight)))
1616 }
1617}
1618
1619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1629pub enum OutcomeKind {
1630 CompletedJob,
1631 IdleExit,
1632 Failed,
1633 Orphaned,
1634}
1635
1636impl OutcomeKind {
1637 #[must_use]
1638 pub const fn of(outcome: &AttemptOutcome) -> Self {
1639 match outcome {
1640 AttemptOutcome::CompletedJob => Self::CompletedJob,
1641 AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1642 AttemptOutcome::Failed { .. } => Self::Failed,
1643 AttemptOutcome::Orphaned => Self::Orphaned,
1644 }
1645 }
1646
1647 #[must_use]
1648 pub const fn is_failure(&self) -> bool {
1649 matches!(self, Self::Failed | Self::Orphaned)
1650 }
1651
1652 #[must_use]
1653 pub const fn as_str(&self) -> &'static str {
1654 match self {
1655 Self::CompletedJob => "completed_job",
1656 Self::IdleExit => "exited_idle_without_work",
1657 Self::Failed => "failed",
1658 Self::Orphaned => "orphaned",
1659 }
1660 }
1661}
1662
1663impl fmt::Display for OutcomeKind {
1664 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1665 f.write_str(self.as_str())
1666 }
1667}
1668
1669#[must_use]
1678pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1679 match reason {
1680 FailureReason::JitRequestFailed => "jit_request_failed",
1681 FailureReason::JitExpired => "jit_expired",
1682 FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1683 FailureReason::RunnerVersionRejected => "runner_version_rejected",
1684 FailureReason::ProcessStartFailed => "process_start_failed",
1685 FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1686 FailureReason::RegistrationTimedOut => "registration_timed_out",
1687 FailureReason::TerminatedAfterRegistrationTimeout => {
1688 "terminated_after_registration_timeout"
1689 }
1690 FailureReason::IsolationProvider(category) => match category {
1691 IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1692 IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1693 IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1694 IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1695 "isolation_image_unavailable"
1696 }
1697 IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1698 IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1699 IsolationProviderFailure::RuntimeOperationFailed => {
1700 "isolation_runtime_operation_failed"
1701 }
1702 IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1703 IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1704 IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1705 },
1706 FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1707 FailureReason::HostUnfit => "host_unfit",
1708 FailureReason::Other(_) => "other",
1709 }
1710}
1711
1712#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1714pub enum RoutingRefusal {
1715 OverlappingProfiles,
1716}
1717
1718impl RoutingRefusal {
1719 #[must_use]
1720 pub const fn as_str(self) -> &'static str {
1721 match self {
1722 Self::OverlappingProfiles => "overlapping_profile_matches",
1723 }
1724 }
1725}
1726
1727impl fmt::Display for RoutingRefusal {
1728 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1729 f.write_str(match self {
1730 Self::OverlappingProfiles => {
1731 "repair overlapping profile labels or require a unique profile selector"
1732 }
1733 })
1734 }
1735}
1736
1737#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1747pub enum LifecycleEvent {
1748 DemandObserved {
1750 policy: PolicyId,
1751 demand: u32,
1753 not_matched: u32,
1759 unresolvable: u32,
1768 complete: bool,
1770 },
1771 RoutingRefused {
1773 policy: PolicyId,
1774 reason: RoutingRefusal,
1775 count: u32,
1776 },
1777 TargetUnreadable {
1779 policy: PolicyId,
1780 reason: &'static str,
1781 },
1782 Allocated {
1784 policy: PolicyId,
1785 demand: u32,
1786 desired: u16,
1787 active_owned: u16,
1788 headroom: u16,
1789 to_start: u16,
1790 limiting: LimitingFactor,
1791 },
1792 MonitorOnlySkipped { policy: PolicyId },
1795 RunnerStarted {
1797 policy: PolicyId,
1798 attempt: AttemptId,
1799 },
1800 RunnerStartFailed {
1802 policy: PolicyId,
1803 reason: &'static str,
1804 },
1805 AllocationDeferred { policy: PolicyId, count: u16 },
1808 AttemptsUnreadable { reason: &'static str },
1814 AttemptCleaned {
1816 policy: PolicyId,
1817 attempt: AttemptId,
1818 outcome: OutcomeKind,
1819 },
1820 AttemptCleanFailed {
1823 policy: PolicyId,
1824 attempt: AttemptId,
1825 reason: &'static str,
1826 },
1827 ScaleDownRefused {
1829 policy: PolicyId,
1830 attempt: AttemptId,
1831 },
1832 PollScheduled { retry_in_ms: u64, pace: PollPace },
1834}
1835
1836impl LifecycleEvent {
1837 #[must_use]
1839 pub const fn name(&self) -> &'static str {
1840 match self {
1841 Self::DemandObserved { .. } => "demand_observed",
1842 Self::RoutingRefused { .. } => "routing_refused",
1843 Self::TargetUnreadable { .. } => "target_unreadable",
1844 Self::Allocated { .. } => "allocated",
1845 Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1846 Self::RunnerStarted { .. } => "runner_started",
1847 Self::RunnerStartFailed { .. } => "runner_start_failed",
1848 Self::AllocationDeferred { .. } => "allocation_deferred",
1849 Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1850 Self::AttemptCleaned { .. } => "attempt_cleaned",
1851 Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1852 Self::ScaleDownRefused { .. } => "scale_down_refused",
1853 Self::PollScheduled { .. } => "poll_scheduled",
1854 }
1855 }
1856
1857 #[must_use]
1859 pub const fn policy(&self) -> Option<PolicyId> {
1860 match self {
1861 Self::DemandObserved { policy, .. }
1862 | Self::RoutingRefused { policy, .. }
1863 | Self::TargetUnreadable { policy, .. }
1864 | Self::Allocated { policy, .. }
1865 | Self::MonitorOnlySkipped { policy }
1866 | Self::RunnerStarted { policy, .. }
1867 | Self::RunnerStartFailed { policy, .. }
1868 | Self::AllocationDeferred { policy, .. }
1869 | Self::AttemptCleaned { policy, .. }
1870 | Self::AttemptCleanFailed { policy, .. }
1871 | Self::ScaleDownRefused { policy, .. } => Some(*policy),
1872 Self::PollScheduled { .. } | Self::AttemptsUnreadable { .. } => None,
1873 }
1874 }
1875}
1876
1877impl fmt::Display for LifecycleEvent {
1878 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1879 match self {
1880 Self::DemandObserved {
1881 policy,
1882 demand,
1883 not_matched,
1884 unresolvable,
1885 complete,
1886 } => write!(
1887 f,
1888 "policy {policy}: {demand} queued jobs for this host{}{}{}",
1889 if *not_matched == 0 {
1890 String::new()
1891 } else {
1892 format!(", {not_matched} for other labels")
1893 },
1894 if *unresolvable == 0 {
1895 String::new()
1896 } else {
1897 format!(", {unresolvable} with an unresolvable `runs-on`")
1898 },
1899 if *complete {
1900 ""
1901 } else {
1902 " (a floor, not a total)"
1903 }
1904 ),
1905 Self::TargetUnreadable { policy, reason } => {
1906 write!(f, "policy {policy}: target unreadable ({reason})")
1907 }
1908 Self::RoutingRefused {
1909 policy,
1910 reason,
1911 count,
1912 } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
1913 Self::Allocated {
1914 policy,
1915 demand,
1916 desired,
1917 active_owned,
1918 headroom,
1919 to_start,
1920 limiting,
1921 } => write!(
1922 f,
1923 "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
1924 flight, {headroom} free on this host, starting {to_start} ({limiting})"
1925 ),
1926 Self::MonitorOnlySkipped { policy } => {
1927 write!(f, "policy {policy}: monitor-only, skipped")
1928 }
1929 Self::RunnerStarted { policy, attempt } => {
1930 write!(f, "policy {policy}: started attempt {attempt}")
1931 }
1932 Self::RunnerStartFailed { policy, reason } => {
1933 write!(f, "policy {policy}: could not start a runner ({reason})")
1934 }
1935 Self::AllocationDeferred { policy, count } => write!(
1936 f,
1937 "policy {policy}: the allocation lock was held; {count} granted runners \
1938 were not created"
1939 ),
1940 Self::AttemptsUnreadable { reason } => write!(
1941 f,
1942 "the host's attempt set could not be read ({reason}); nothing was started, \
1943 and this is not the same as the host being idle"
1944 ),
1945 Self::AttemptCleaned {
1946 policy,
1947 attempt,
1948 outcome,
1949 } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
1950 Self::AttemptCleanFailed {
1951 policy,
1952 attempt,
1953 reason,
1954 } => write!(
1955 f,
1956 "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
1957 will be retried"
1958 ),
1959 Self::ScaleDownRefused { policy, attempt } => write!(
1960 f,
1961 "policy {policy}: attempt {attempt} is executing a job and was not removed"
1962 ),
1963 Self::PollScheduled { retry_in_ms, pace } => {
1964 write!(f, "next poll in {retry_in_ms}ms ({pace})")
1965 }
1966 }
1967 }
1968}
1969
1970pub trait EventSink: fmt::Debug + Send + Sync {
1972 fn emit(&self, event: LifecycleEvent);
1973}
1974
1975#[derive(Debug, Clone, Copy, Default)]
1977pub struct NoEvents;
1978
1979impl EventSink for NoEvents {
1980 fn emit(&self, _event: LifecycleEvent) {}
1981}
1982
1983#[derive(Debug, Clone, Copy, Default)]
1991pub struct TracingEvents;
1992
1993impl EventSink for TracingEvents {
1994 fn emit(&self, event: LifecycleEvent) {
1995 let name = event.name();
1996 match event {
1997 LifecycleEvent::DemandObserved {
1998 policy,
1999 demand,
2000 not_matched,
2001 unresolvable,
2002 complete,
2003 } => {
2004 if demand == 0 && not_matched == 0 && unresolvable == 0 && complete {
2009 tracing::debug!(
2010 event = name,
2011 policy_id = %policy,
2012 demand,
2013 not_matched,
2014 unresolvable,
2015 count = u64::from(complete),
2016 );
2017 } else {
2018 tracing::info!(
2019 event = name,
2020 policy_id = %policy,
2021 demand,
2022 not_matched,
2023 unresolvable,
2024 count = u64::from(complete),
2025 );
2026 }
2027 }
2047 LifecycleEvent::TargetUnreadable { policy, reason } => {
2048 tracing::warn!(event = name, policy_id = %policy, reason);
2049 }
2050 LifecycleEvent::RoutingRefused {
2051 policy,
2052 reason,
2053 count,
2054 } => tracing::warn!(
2055 event = name,
2056 policy_id = %policy,
2057 reason = reason.as_str(),
2058 count,
2059 "{reason}"
2060 ),
2061 LifecycleEvent::Allocated {
2062 policy,
2063 demand,
2064 desired,
2065 active_owned,
2066 headroom,
2067 to_start,
2068 limiting,
2069 } if demand == 0 && to_start == 0 && active_owned == 0 => tracing::debug!(
2070 event = name,
2073 policy_id = %policy,
2074 demand,
2075 desired,
2076 capacity = active_owned,
2077 headroom,
2078 count = to_start,
2079 reason = %limiting,
2080 ),
2081 LifecycleEvent::Allocated {
2082 policy,
2083 demand,
2084 desired,
2085 active_owned,
2086 headroom,
2087 to_start,
2088 limiting,
2089 } => tracing::info!(
2090 event = name,
2091 policy_id = %policy,
2092 demand,
2093 desired,
2094 capacity = active_owned,
2095 headroom,
2096 count = to_start,
2097 reason = %limiting,
2098 ),
2099 LifecycleEvent::MonitorOnlySkipped { policy } => {
2100 tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
2101 }
2102 LifecycleEvent::RunnerStarted { policy, attempt } => {
2103 tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
2104 }
2105 LifecycleEvent::RunnerStartFailed { policy, reason } => {
2106 tracing::warn!(event = name, policy_id = %policy, reason);
2107 }
2108 LifecycleEvent::AllocationDeferred { policy, count } => {
2109 tracing::warn!(
2115 event = name,
2116 policy_id = %policy,
2117 reason = "allocation_lock_unavailable",
2118 count
2119 );
2120 }
2121 LifecycleEvent::AttemptsUnreadable { reason } => {
2122 tracing::warn!(event = name, reason);
2123 }
2124 LifecycleEvent::AttemptCleaned {
2125 policy,
2126 attempt,
2127 outcome,
2128 } => tracing::info!(
2129 event = name,
2130 policy_id = %policy,
2131 attempt_id = %attempt,
2132 outcome = outcome.as_str(),
2133 ),
2134 LifecycleEvent::AttemptCleanFailed {
2135 policy,
2136 attempt,
2137 reason,
2138 } => tracing::warn!(
2139 event = name,
2140 policy_id = %policy,
2141 attempt_id = %attempt,
2142 reason,
2143 ),
2144 LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
2145 event = name,
2146 policy_id = %policy,
2147 attempt_id = %attempt,
2148 attempt_state = "busy",
2149 ),
2150 LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
2151 if pace == PollPace::Idle {
2152 tracing::debug!(event = name, retry_in_ms, state = pace.as_str());
2153 } else {
2154 tracing::info!(event = name, retry_in_ms, state = pace.as_str());
2155 }
2156 }
2157 }
2158 }
2159}
2160
2161#[derive(Debug, Default)]
2165pub struct EventLog {
2166 events: Mutex<Vec<LifecycleEvent>>,
2167}
2168
2169impl EventLog {
2170 #[must_use]
2171 pub fn new() -> Self {
2172 Self::default()
2173 }
2174
2175 #[must_use]
2176 pub fn events(&self) -> Vec<LifecycleEvent> {
2177 self.events.lock().map(|e| e.clone()).unwrap_or_default()
2178 }
2179
2180 #[must_use]
2182 pub fn count_of(&self, name: &str) -> usize {
2183 self.events()
2184 .iter()
2185 .filter(|event| event.name() == name)
2186 .count()
2187 }
2188}
2189
2190impl EventSink for EventLog {
2191 fn emit(&self, event: LifecycleEvent) {
2192 if let Ok(mut events) = self.events.lock() {
2193 events.push(event);
2194 }
2195 }
2196}
2197
2198#[derive(Debug)]
2201pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
2202
2203impl EventSink for TeeEvents {
2204 fn emit(&self, event: LifecycleEvent) {
2205 self.0.emit(event);
2206 self.1.emit(event);
2207 }
2208}
2209
2210pub struct ReconcilerPorts {
2220 pub demand: Arc<dyn DemandSource>,
2221 pub launcher: Arc<dyn RunnerLauncher>,
2222 pub lock: Arc<dyn AllocationLock>,
2223 pub directory: Arc<dyn RepositoryDirectory>,
2224 pub clock: Arc<dyn Clock>,
2225 pub jitter: Arc<dyn Jitter>,
2226 pub events: Arc<dyn EventSink>,
2227}
2228
2229impl fmt::Debug for ReconcilerPorts {
2230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2231 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
2232 }
2233}
2234
2235#[derive(Debug, Clone, Default)]
2242pub struct ReconcileReport {
2243 pub allocations: Vec<Allocation>,
2245 pub monitor_only: Vec<PolicyId>,
2247 pub unreadable: Vec<PolicyId>,
2249 pub targets_read: u16,
2257 pub started: u16,
2259 pub replacement_intents: u16,
2262 pub cleaned: u16,
2264 pub idle_exits: u16,
2267 pub failures: u16,
2269 pub deferred: u16,
2278 pub attempts_unreadable: u16,
2297 pub clean_failures: u16,
2299 pub failure: Option<RefreshState>,
2301 pub offline: Option<OfflineState>,
2304 pub next_poll: NextPoll,
2306 pub demand_requests: u32,
2308}
2309
2310impl ReconcileReport {
2311 #[must_use]
2338 pub const fn reached_github(&self) -> bool {
2339 self.targets_read > 0
2340 }
2341}
2342
2343impl Default for NextPoll {
2344 fn default() -> Self {
2345 Self {
2346 delay: PollSchedule::floor(),
2347 pace: PollPace::Nominal,
2348 }
2349 }
2350}
2351
2352impl ReconcileReport {
2353 #[must_use]
2355 pub fn is_offline(&self) -> bool {
2356 matches!(self.failure, Some(RefreshState::Offline))
2357 }
2358
2359 #[must_use]
2361 pub const fn offline_state(&self) -> Option<&OfflineState> {
2362 self.offline.as_ref()
2363 }
2364
2365 #[must_use]
2367 pub const fn starts_nothing(&self) -> bool {
2368 self.started == 0
2369 }
2370}
2371
2372#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2374pub struct ScaleDownReport {
2375 pub removed: u16,
2377 pub refused_busy: u16,
2379 pub clean_failures: u16,
2381 pub retained: u16,
2384 pub attempts_unreadable: bool,
2394}
2395
2396impl ScaleDownReport {
2397 #[must_use]
2402 pub const fn is_conclusive(&self) -> bool {
2403 !self.attempts_unreadable
2404 }
2405}
2406
2407const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
2415
2416const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
2418
2419#[derive(Debug, Clone, Copy)]
2421struct CleanRetry {
2422 failures: u32,
2423 not_before: Timestamp,
2424}
2425
2426#[derive(Debug, Default)]
2430pub(crate) struct CleanBackoff {
2431 retries: BTreeMap<AttemptId, CleanRetry>,
2432}
2433
2434impl CleanBackoff {
2435 const SCHEDULE: crate::lifecycle::RetryPolicy =
2437 crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2438
2439 pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2441 self.retries
2442 .get(&attempt)
2443 .is_none_or(|retry| now >= retry.not_before)
2444 }
2445
2446 pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2448 let failures = self
2449 .retries
2450 .get(&attempt)
2451 .map_or(1, |retry| retry.failures.saturating_add(1));
2452 let delay = Self::SCHEDULE.delay(failures);
2453 self.retries.insert(
2454 attempt,
2455 CleanRetry {
2456 failures,
2457 not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2459 },
2460 );
2461 }
2462
2463 pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2465 self.retries.remove(&attempt);
2466 }
2467
2468 fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2471 self.retries.retain(|attempt, _| present.contains(attempt));
2472 }
2473}
2474
2475#[derive(Debug)]
2481pub struct Reconciler {
2482 host: Host,
2483 demand: Arc<dyn DemandSource>,
2484 launcher: Arc<dyn RunnerLauncher>,
2485 lock: Arc<dyn AllocationLock>,
2486 repositories: RepositoryCache,
2487 clock: Arc<dyn Clock>,
2488 jitter: Arc<dyn Jitter>,
2489 events: Arc<dyn EventSink>,
2490 schedule: PollSchedule,
2491 clean_backoff: CleanBackoff,
2492}
2493
2494impl Reconciler {
2495 #[must_use]
2497 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2498 let interval = host.refresh_interval;
2499 let repositories = RepositoryCache::new(
2500 Arc::clone(&ports.directory),
2501 Arc::clone(&ports.clock),
2502 interval,
2503 );
2504 Self {
2505 host,
2506 demand: ports.demand,
2507 launcher: ports.launcher,
2508 lock: ports.lock,
2509 repositories,
2510 clock: ports.clock,
2511 jitter: ports.jitter,
2512 events: ports.events,
2513 schedule: PollSchedule::new(interval),
2514 clean_backoff: CleanBackoff::default(),
2515 }
2516 }
2517
2518 #[must_use]
2519 pub const fn host(&self) -> &Host {
2520 &self.host
2521 }
2522
2523 #[must_use]
2524 pub const fn schedule(&self) -> &PollSchedule {
2525 &self.schedule
2526 }
2527
2528 pub fn set_intervals(&mut self, active: RefreshInterval, idle: Duration) {
2530 self.host.refresh_interval = active;
2531 self.schedule.set_intervals(active, idle);
2532 }
2533
2534 #[must_use]
2536 pub const fn repositories(&self) -> &RepositoryCache {
2537 &self.repositories
2538 }
2539
2540 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2554 let mut report = ReconcileReport::default();
2555 let mut launched: Vec<RunnerAttempt> = Vec::new();
2559
2560 let mut pollable: Vec<&ScalePolicy> = Vec::new();
2562 let mut supervision_failed = BTreeSet::new();
2563 for policy in policies {
2564 if !policy.owns_runners() {
2565 report.monitor_only.push(policy.id);
2566 self.events
2567 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2568 continue;
2569 }
2570 if !policy.is_owned_by(self.host.id) {
2571 continue;
2575 }
2576 match self.launcher.supervise(policy).await {
2577 Ok(intents) => {
2578 report.replacement_intents = report
2579 .replacement_intents
2580 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
2581 }
2582 Err(failure) => {
2583 supervision_failed.insert(policy.id);
2584 self.report_unreadable_attempts(&mut report, &failure);
2585 continue;
2586 }
2587 }
2588 if !policy.may_start_runners() {
2589 continue;
2590 }
2591 pollable.push(policy);
2592 }
2593
2594 let readings = self.poll_targets(&pollable, &mut report).await;
2595 report.targets_read = u16::try_from(
2596 readings
2597 .values()
2598 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
2599 .count(),
2600 )
2601 .unwrap_or(u16::MAX);
2602 let mut routed = BTreeMap::new();
2603 for (target, outcome) in &readings {
2604 let PollOutcome::Ready(reading) = outcome else {
2605 continue;
2606 };
2607 let profiles: Vec<&ScalePolicy> = pollable
2608 .iter()
2609 .copied()
2610 .filter(|policy| &policy.target == target)
2611 .collect();
2612 let (tallies, refusals) = route_demand(policies, &profiles, reading);
2613 if let Some(first) = profiles.first() {
2614 for (reason, count) in refusals {
2615 self.events.emit(LifecycleEvent::RoutingRefused {
2616 policy: first.id,
2617 reason,
2618 count,
2619 });
2620 }
2621 }
2622 routed.insert(target.clone(), tallies);
2623 }
2624
2625 self.clean_terminal_attempts(&mut report).await;
2635
2636 for policy in policies {
2651 if !policy.owns_runners() {
2652 continue;
2658 }
2659 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2660 match self.allocate_only(policy, 0, &launched).await {
2664 Ok(allocation) => {
2665 self.emit_allocation(&allocation);
2666 report.allocations.push(allocation);
2667 }
2668 Err(failure) => self.report_unreadable_attempts(&mut report, &failure),
2669 }
2670 continue;
2671 }
2672 if supervision_failed.contains(&policy.id) {
2673 continue;
2674 }
2675 let Some(reading) = readings.get(&policy.target) else {
2676 debug_assert!(false, "a pollable policy's target has no reading");
2679 continue;
2680 };
2681 match reading {
2682 PollOutcome::Failed(state) => {
2683 report.unreadable.push(policy.id);
2684 self.events.emit(LifecycleEvent::TargetUnreadable {
2685 policy: policy.id,
2686 reason: unreadable_reason(state),
2687 });
2688 }
2689 PollOutcome::Ready(demand) => {
2690 let tally = routed
2691 .get(&policy.target)
2692 .and_then(|tallies| tallies.get(&policy.id))
2693 .expect("every pollable profile has a routed tally");
2694 let count = tally.demand();
2695 self.events.emit(LifecycleEvent::DemandObserved {
2696 policy: policy.id,
2697 demand: count,
2698 not_matched: tally.not_matched,
2699 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2700 complete: demand.is_complete(),
2701 });
2702 self.start_runners(policy, count, &mut report, &mut launched)
2703 .await;
2704 }
2705 }
2706 }
2707
2708 let failure = readings
2710 .values()
2711 .filter_map(PollOutcome::failure)
2712 .max_by_key(|state| severity(state))
2713 .cloned();
2714 let now = self.clock.now();
2715 let conditions = PollConditions {
2716 quiet: is_quiet(&readings, &report),
2717 requests_per_pass: report.demand_requests,
2718 quota: readings
2719 .values()
2720 .filter_map(PollOutcome::reading)
2721 .filter_map(QueuedDemand::rate_limit)
2722 .max_by_key(|quota| quota.observed_at)
2723 .copied(),
2724 };
2725 report.next_poll =
2726 self.schedule
2727 .next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
2728 report.failure = failure;
2729 if let PollPace::Offline { consecutive } = report.next_poll.pace {
2733 let state = OfflineState::new(consecutive, report.next_poll.delay);
2734 report.offline = Some(match self.schedule.offline_for(now) {
2735 Some(elapsed) => state.since(elapsed),
2736 None => state,
2737 });
2738 }
2739 self.events.emit(LifecycleEvent::PollScheduled {
2740 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2741 pace: report.next_poll.pace,
2742 });
2743
2744 report
2745 }
2746
2747 async fn poll_targets(
2755 &self,
2756 pollable: &[&ScalePolicy],
2757 report: &mut ReconcileReport,
2758 ) -> BTreeMap<ScaleTarget, PollOutcome> {
2759 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
2760
2761 let mut readings = BTreeMap::new();
2762 for target in targets {
2763 let outcome = match self.repositories.scope_for(&target).await {
2764 Ok(scope) => {
2765 report.demand_requests = report
2766 .demand_requests
2767 .saturating_add(demand_requests_per_poll(&scope));
2768 self.demand.poll(&scope).await
2769 }
2770 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
2776 };
2777 readings.insert(target, outcome);
2778 }
2779 readings
2780 }
2781
2782 async fn allocate_only(
2800 &self,
2801 policy: &ScalePolicy,
2802 demand: u32,
2803 launched: &[RunnerAttempt],
2804 ) -> Result<Allocation, LaunchFailure> {
2805 let attempts = self.host_attempts(launched).await?;
2806 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2807 Ok(allocator.allocate(policy, demand))
2808 }
2809
2810 async fn start_runners(
2832 &self,
2833 policy: &ScalePolicy,
2834 demand: u32,
2835 report: &mut ReconcileReport,
2836 launched: &mut Vec<RunnerAttempt>,
2837 ) {
2838 let intent = match self.allocate_only(policy, demand, launched).await {
2850 Ok(intent) => intent,
2851 Err(failure) => {
2852 self.report_unreadable_attempts(report, &failure);
2853 return;
2854 }
2855 };
2856
2857 let mut decided: Option<Allocation> = None;
2861 let mut budget = intent.to_start;
2862
2863 while budget > 0 {
2864 let waiting_since = std::time::Instant::now();
2865 let guard = match self.lock.acquire().await {
2866 Ok(guard) => guard,
2867 Err(_) => {
2868 report.deferred = report.deferred.saturating_add(budget);
2871 self.events.emit(LifecycleEvent::AllocationDeferred {
2872 policy: policy.id,
2873 count: budget,
2874 });
2875 break;
2876 }
2877 };
2878 let lock_wait = waiting_since.elapsed();
2879
2880 let attempts = match self.host_attempts(launched).await {
2886 Ok(attempts) => attempts,
2887 Err(failure) => {
2888 drop(guard);
2889 self.report_unreadable_attempts(report, &failure);
2890 break;
2891 }
2892 };
2893 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2894 let allocation = allocator.allocate(policy, demand);
2895
2896 if decided.is_none() {
2897 budget = budget.min(allocation.to_start);
2901 decided = Some(allocation.clone());
2902 }
2903
2904 if allocation.starts_nothing() || budget == 0 {
2907 drop(guard);
2908 break;
2909 }
2910
2911 let created = self
2912 .launcher
2913 .launch(LaunchRequest {
2914 host: &self.host,
2915 policy,
2916 allocation_guard: &guard,
2917 lock_wait,
2918 })
2919 .await;
2920 drop(guard);
2921
2922 match created {
2923 Ok(attempt) => {
2924 let id = attempt.id;
2925 launched.push(attempt);
2929 report.started = report.started.saturating_add(1);
2930 budget -= 1;
2931 self.events.emit(LifecycleEvent::RunnerStarted {
2932 policy: policy.id,
2933 attempt: id,
2934 });
2935 }
2936 Err(failure) => {
2937 self.events.emit(LifecycleEvent::RunnerStartFailed {
2938 policy: policy.id,
2939 reason: failure_reason_kind(&failure.reason),
2940 });
2941 break;
2942 }
2943 }
2944 }
2945
2946 let allocation = decided.unwrap_or(intent);
2947 self.emit_allocation(&allocation);
2948 report.allocations.push(allocation);
2949 }
2950
2951 async fn host_attempts(
2964 &self,
2965 launched: &[RunnerAttempt],
2966 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2967 let mut attempts = self.launcher.attempts().await?;
2968
2969 debug_assert!(
2976 launched
2977 .iter()
2978 .map(|attempt| attempt.id)
2979 .collect::<BTreeSet<AttemptId>>()
2980 .len()
2981 == launched.len(),
2982 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
2983 the host ceiling is enforced against a set keyed on that identifier, so a \
2984 duplicate is two runtimes counted as one"
2985 );
2986
2987 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
2988 attempts.extend(
2989 launched
2990 .iter()
2991 .filter(|attempt| !known.contains(&attempt.id))
2992 .cloned(),
2993 );
2994 Ok(attempts)
2995 }
2996
2997 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
3004 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
3005 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3012 reason: failure_reason_kind(&failure.reason),
3013 });
3014 }
3015
3016 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
3022 let attempts = match self.launcher.attempts().await {
3023 Ok(attempts) => attempts,
3024 Err(failure) => {
3025 self.report_unreadable_attempts(report, &failure);
3026 return;
3027 }
3028 };
3029 self.clean_backoff
3030 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
3031 let now = self.clock.now();
3032 for attempt in attempts {
3033 if !attempt.state().is_concluded() {
3034 continue;
3035 }
3036 let Some(outcome) = attempt.outcome() else {
3037 continue;
3038 };
3039 if !self.clean_backoff.is_due(attempt.id, now) {
3042 continue;
3043 }
3044 let kind = OutcomeKind::of(outcome);
3045 match self.launcher.clean(attempt.id).await {
3046 Ok(()) => {
3047 self.clean_backoff.succeeded(attempt.id);
3048 report.cleaned = report.cleaned.saturating_add(1);
3049 if kind.is_failure() {
3050 report.failures = report.failures.saturating_add(1);
3051 } else if kind == OutcomeKind::IdleExit {
3052 report.idle_exits = report.idle_exits.saturating_add(1);
3057 }
3058 self.events.emit(LifecycleEvent::AttemptCleaned {
3059 policy: attempt.policy_id,
3060 attempt: attempt.id,
3061 outcome: kind,
3062 });
3063 }
3064 Err(failure) => {
3074 self.clean_backoff.failed(attempt.id, now);
3075 report.clean_failures = report.clean_failures.saturating_add(1);
3076 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3077 policy: attempt.policy_id,
3078 attempt: attempt.id,
3079 reason: failure_reason_kind(&failure.reason),
3080 });
3081 }
3082 }
3083 }
3084 }
3085
3086 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
3094 let mut report = ScaleDownReport::default();
3095 let attempts = match self.launcher.attempts().await {
3096 Ok(attempts) => attempts,
3097 Err(failure) => {
3098 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3103 reason: failure_reason_kind(&failure.reason),
3104 });
3105 report.attempts_unreadable = true;
3106 return report;
3107 }
3108 };
3109 for attempt in attempts {
3110 if attempt.policy_id != policy.id {
3111 continue;
3112 }
3113 match attempt.state() {
3114 AttemptState::Busy => {
3115 report.refused_busy = report.refused_busy.saturating_add(1);
3116 self.events.emit(LifecycleEvent::ScaleDownRefused {
3117 policy: policy.id,
3118 attempt: attempt.id,
3119 });
3120 }
3121 state if state.is_concluded() => {
3122 let kind = attempt
3123 .outcome()
3124 .map_or(OutcomeKind::Failed, OutcomeKind::of);
3125 match self.launcher.clean(attempt.id).await {
3126 Ok(()) => {
3127 report.removed = report.removed.saturating_add(1);
3128 self.events.emit(LifecycleEvent::AttemptCleaned {
3129 policy: policy.id,
3130 attempt: attempt.id,
3131 outcome: kind,
3132 });
3133 }
3134 Err(failure) => {
3135 report.clean_failures = report.clean_failures.saturating_add(1);
3136 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3137 policy: policy.id,
3138 attempt: attempt.id,
3139 reason: failure_reason_kind(&failure.reason),
3140 });
3141 }
3142 }
3143 }
3144 AttemptState::Cleaned => {}
3145 _ => report.retained = report.retained.saturating_add(1),
3148 }
3149 }
3150 report
3151 }
3152
3153 fn emit_allocation(&self, allocation: &Allocation) {
3154 self.events.emit(LifecycleEvent::Allocated {
3155 policy: allocation.policy_id,
3156 demand: allocation.demand,
3157 desired: allocation.desired,
3158 active_owned: allocation.active_owned,
3159 headroom: allocation.headroom_before,
3160 to_start: allocation.to_start,
3161 limiting: allocation.limiting_factor,
3162 });
3163 }
3164}
3165
3166fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
3190 let Some(labels) = policy.routing_labels() else {
3191 debug_assert!(
3192 false,
3193 "a monitor-only policy is skipped before the demand poll (D19)"
3194 );
3195 return DemandTally::default();
3196 };
3197
3198 match &policy.target {
3199 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
3200 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
3201 }
3202}
3203
3204fn route_demand(
3208 all_policies: &[ScalePolicy],
3209 profiles: &[&ScalePolicy],
3210 reading: &QueuedDemand,
3211) -> (
3212 BTreeMap<PolicyId, DemandTally>,
3213 BTreeMap<RoutingRefusal, u32>,
3214) {
3215 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
3216 .iter()
3217 .map(|policy| (policy.id, demand_for(policy, reading)))
3218 .collect();
3219 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
3220 let Some(first) = profiles.first() else {
3221 return (tallies, refusals);
3222 };
3223 let owners: Vec<_> = all_policies
3224 .iter()
3225 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3226 .filter(|policy| policy.owns_runners())
3227 .collect();
3228 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3229 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3230 ScaleTarget::Organization(_) => reading.jobs().collect(),
3231 };
3232 for job in jobs {
3233 let Ok(_) = job.required_labels() else {
3234 continue;
3236 };
3237 let matched: Vec<PolicyId> = owners
3238 .iter()
3239 .filter(|policy| {
3240 policy
3241 .routing_labels()
3242 .is_some_and(|labels| labels.matches(job).is_match())
3243 })
3244 .map(|policy| policy.id)
3245 .collect();
3246 if matched.len() > 1 {
3247 *refusals
3248 .entry(RoutingRefusal::OverlappingProfiles)
3249 .or_default() += 1;
3250 for id in matched {
3251 if let Some(tally) = tallies.get_mut(&id) {
3252 tally.matched -= 1;
3253 tally.not_matched += 1;
3254 }
3255 }
3256 }
3257 }
3258 (tallies, refusals)
3259}
3260
3261const fn severity(state: &RefreshState) -> u8 {
3268 match state {
3269 RefreshState::Offline => 5,
3270 RefreshState::RateLimited(_) => 4,
3271 RefreshState::LockedOut { .. } => 3,
3272 RefreshState::Unauthorized => 2,
3273 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3274 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3275 }
3276}
3277
3278const fn unreadable_reason(state: &RefreshState) -> &'static str {
3289 match state {
3290 RefreshState::Ready(_) => "ready",
3291 RefreshState::Offline => "offline",
3292 RefreshState::RateLimited(_) => "rate_limited",
3293 RefreshState::LockedOut { .. } => "locked_out",
3294 RefreshState::Unauthorized => "unauthorized",
3295 RefreshState::Forbidden { .. } => "forbidden",
3296 RefreshState::Failed { .. } => "failed",
3297 RefreshState::Cancelled => "cancelled",
3298 }
3299}
3300
3301#[cfg(test)]
3302mod tests {
3303 use super::*;
3304
3305 #[test]
3313 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
3314 let mut report = ReconcileReport::default();
3315 assert!(
3316 !report.reached_github(),
3317 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
3318 monitor-only -- reached nobody. This is the case the old guard let through, and \
3319 the only one it ever let through."
3320 );
3321
3322 report.unreadable.push(PolicyId::from_u128(1));
3323 assert!(
3324 !report.reached_github(),
3325 "every target this pass tried was unreadable, so there is no contact to record"
3326 );
3327
3328 report.targets_read = 1;
3329 assert!(
3330 report.reached_github(),
3331 "one target answering is contact, whatever else failed alongside it"
3332 );
3333
3334 let mut unowned = ReconcileReport::default();
3338 unowned.unreadable.push(PolicyId::from_u128(2));
3339 unowned.allocations.push(Allocation {
3340 policy_id: PolicyId::from_u128(2),
3341 demand: 0,
3342 desired: 0,
3343 active_owned: 0,
3344 headroom_before: 0,
3345 to_start: 0,
3346 limiting_factor: LimitingFactor::Demand,
3347 });
3348 assert!(
3349 !unowned.reached_github(),
3350 "an allocation is not evidence that GitHub answered"
3351 );
3352 }
3353
3354 #[test]
3364 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
3365 assert!(
3366 severity(&RefreshState::Unauthorized) > 0,
3367 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
3368 or a pass where every target was refused would report no failure at all"
3369 );
3370 }
3371
3372 use std::sync::atomic::AtomicUsize;
3373
3374 use std::num::NonZeroU16;
3375
3376 use runner_manager_domain::attempt::PersistedAttempt;
3377 use runner_manager_domain::model::{CachePolicy, HostId};
3378 use runner_manager_domain::policy::PolicyMode;
3379 use runner_manager_domain::workspace::WorkspaceKind;
3380 use runner_manager_github::rest::RateLimited;
3381 use runner_manager_testkit::clock::FakeClock;
3382 use runner_manager_testkit::fixtures;
3383 use runner_manager_testkit::github::FakeGithub;
3384
3385 fn host_with(capacity: u16) -> Host {
3390 fixtures::host().capacity(capacity).build()
3391 }
3392
3393 fn repo(raw: &str) -> OwnerRepo {
3394 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
3395 }
3396
3397 use runner_manager_domain::policy::RunsOn;
3399
3400 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
3401 fixtures::policy()
3402 .id(PolicyId::from_u128(id))
3403 .repository(target)
3404 .autoscale("home", max)
3405 .active()
3406 .build()
3407 }
3408
3409 const HOST_LABEL: &str = "rm-home-win-x64";
3416
3417 fn jobs(n: usize) -> Vec<RunsOn> {
3423 fixtures::queued_jobs(&[HOST_LABEL], n)
3424 }
3425
3426 #[derive(Debug, Default)]
3428 struct FakeLauncher {
3429 attempts: Mutex<Vec<RunnerAttempt>>,
3430 next_id: AtomicU64,
3431 launches: AtomicUsize,
3432 cleaned: Mutex<Vec<AttemptId>>,
3433 yields_before_recording: usize,
3436 forgetful: bool,
3440 fail_next: Mutex<Option<FailureReason>>,
3441 attempts_fail: Mutex<bool>,
3444 clean_fails: bool,
3447 replacements: Mutex<Vec<ReplacementIntent>>,
3448 }
3449
3450 impl FakeLauncher {
3451 fn new() -> Self {
3452 Self::default()
3453 }
3454
3455 fn with_yields(mut self, yields: usize) -> Self {
3456 self.yields_before_recording = yields;
3457 self
3458 }
3459
3460 fn forgetful() -> Self {
3461 Self {
3462 forgetful: true,
3463 ..Self::default()
3464 }
3465 }
3466
3467 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
3468 *self.attempts.lock().unwrap() = attempts;
3469 self
3470 }
3471
3472 fn launches(&self) -> usize {
3473 self.launches.load(Ordering::SeqCst)
3474 }
3475
3476 fn snapshot(&self) -> Vec<RunnerAttempt> {
3477 self.attempts.lock().unwrap().clone()
3478 }
3479
3480 fn live_count(&self) -> usize {
3481 self.snapshot()
3482 .iter()
3483 .filter(|a| a.counts_against_capacity())
3484 .count()
3485 }
3486
3487 fn fail_next(&self, reason: FailureReason) {
3488 *self.fail_next.lock().unwrap() = Some(reason);
3489 }
3490
3491 fn fail_attempts(&self, failing: bool) {
3492 *self.attempts_fail.lock().unwrap() = failing;
3493 }
3494
3495 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
3496 Self {
3497 clean_fails: true,
3498 ..Self::default()
3499 }
3500 .seeded(attempts)
3501 }
3502
3503 fn cleaned(&self) -> Vec<AttemptId> {
3504 self.cleaned.lock().unwrap().clone()
3505 }
3506
3507 fn replacing(self, intent: ReplacementIntent) -> Self {
3508 self.replacements.lock().unwrap().push(intent);
3509 self
3510 }
3511 }
3512
3513 #[async_trait::async_trait]
3514 impl RunnerLauncher for FakeLauncher {
3515 async fn supervise(
3516 &self,
3517 policy: &ScalePolicy,
3518 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3519 let mut replacements = self.replacements.lock().unwrap();
3520 let selected: Vec<_> = replacements
3521 .extract_if(.., |intent| intent.policy == policy.id)
3522 .collect();
3523 if !selected.is_empty() {
3524 let retired: BTreeSet<_> = selected
3525 .iter()
3526 .map(|intent| intent.previous_attempt)
3527 .collect();
3528 self.attempts
3529 .lock()
3530 .unwrap()
3531 .retain(|attempt| !retired.contains(&attempt.id));
3532 }
3533 Ok(selected)
3534 }
3535
3536 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3537 if *self.attempts_fail.lock().unwrap() {
3538 return Err(LaunchFailure::new(FailureReason::Other(
3539 "the journal could not be read".into(),
3540 )));
3541 }
3542 Ok(self.snapshot())
3543 }
3544
3545 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3546 if let Some(reason) = self.fail_next.lock().unwrap().take() {
3547 return Err(LaunchFailure::new(reason));
3548 }
3549 for _ in 0..self.yields_before_recording {
3551 tokio::task::yield_now().await;
3552 }
3553 let id =
3554 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
3555 let created = RunnerAttempt::allocate(
3556 id,
3557 request.policy.id,
3558 "runtime/p/a",
3559 request.host.created_at,
3560 );
3561 self.launches.fetch_add(1, Ordering::SeqCst);
3562 if !self.forgetful {
3563 self.attempts.lock().unwrap().push(created.clone());
3564 }
3565 Ok(created)
3566 }
3567
3568 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
3569 if self.clean_fails {
3570 return Err(LaunchFailure::new(FailureReason::Other(
3571 "the runtime directory is locked".into(),
3572 )));
3573 }
3574 self.cleaned.lock().unwrap().push(attempt);
3575 let mut attempts = self.attempts.lock().unwrap();
3576 attempts.retain(|a| a.id != attempt);
3577 Ok(())
3578 }
3579 }
3580
3581 #[derive(Debug, Default)]
3583 struct FakeDemand {
3584 outcome: Mutex<Option<PollOutcome>>,
3585 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
3587 scopes: Mutex<Vec<ActivityScope>>,
3588 }
3589
3590 impl FakeDemand {
3591 fn ready(count: u32, repository: &OwnerRepo) -> Self {
3592 let fake = Self::default();
3593 fake.set(PollOutcome::Ready(QueuedDemand::of(
3594 repository.clone(),
3595 jobs(count as usize),
3596 )));
3597 fake
3598 }
3599
3600 fn failing(state: RefreshState) -> Self {
3601 let fake = Self::default();
3602 fake.set(PollOutcome::Failed(state));
3603 fake
3604 }
3605
3606 fn set(&self, outcome: PollOutcome) {
3607 *self.outcome.lock().unwrap() = Some(outcome);
3608 }
3609
3610 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
3612 self.per_target
3613 .lock()
3614 .unwrap()
3615 .insert(target.clone(), outcome);
3616 }
3617
3618 fn polls(&self) -> Vec<ActivityScope> {
3619 self.scopes.lock().unwrap().clone()
3620 }
3621 }
3622
3623 #[async_trait::async_trait]
3624 impl DemandSource for FakeDemand {
3625 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
3626 self.scopes.lock().unwrap().push(scope.clone());
3627 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
3628 return outcome.clone();
3629 }
3630 self.outcome
3631 .lock()
3632 .unwrap()
3633 .clone()
3634 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
3635 }
3636 }
3637
3638 #[derive(Debug, Default)]
3639 struct FakeDirectory {
3640 repositories: Vec<OwnerRepo>,
3641 calls: AtomicUsize,
3642 }
3643
3644 impl FakeDirectory {
3645 fn of(repositories: Vec<OwnerRepo>) -> Self {
3646 Self {
3647 repositories,
3648 calls: AtomicUsize::new(0),
3649 }
3650 }
3651
3652 fn calls(&self) -> usize {
3653 self.calls.load(Ordering::SeqCst)
3654 }
3655 }
3656
3657 #[async_trait::async_trait]
3658 impl RepositoryDirectory for FakeDirectory {
3659 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
3660 self.calls.fetch_add(1, Ordering::SeqCst);
3661 Ok(self.repositories.clone())
3662 }
3663 }
3664
3665 #[derive(Debug)]
3671 struct CountingLock {
3672 inner: InProcessAllocationLock,
3673 concurrent: Arc<AtomicUsize>,
3674 peak: Arc<AtomicUsize>,
3675 acquisitions: Arc<AtomicUsize>,
3676 }
3677
3678 impl CountingLock {
3679 fn new() -> Self {
3680 Self {
3681 inner: InProcessAllocationLock::new(),
3682 concurrent: Arc::new(AtomicUsize::new(0)),
3683 peak: Arc::new(AtomicUsize::new(0)),
3684 acquisitions: Arc::new(AtomicUsize::new(0)),
3685 }
3686 }
3687
3688 fn peak(&self) -> usize {
3689 self.peak.load(Ordering::SeqCst)
3690 }
3691
3692 fn acquisitions(&self) -> usize {
3693 self.acquisitions.load(Ordering::SeqCst)
3694 }
3695 }
3696
3697 #[derive(Debug)]
3698 struct CountingGuard {
3699 _inner: AllocationGuard,
3700 concurrent: Arc<AtomicUsize>,
3701 }
3702
3703 impl Drop for CountingGuard {
3704 fn drop(&mut self) {
3705 self.concurrent.fetch_sub(1, Ordering::SeqCst);
3706 }
3707 }
3708
3709 #[async_trait::async_trait]
3710 impl AllocationLock for CountingLock {
3711 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3712 let inner = self.inner.acquire().await?;
3713 self.acquisitions.fetch_add(1, Ordering::SeqCst);
3714 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
3715 self.peak.fetch_max(now, Ordering::SeqCst);
3716 Ok(AllocationGuard::new(CountingGuard {
3717 _inner: inner,
3718 concurrent: Arc::clone(&self.concurrent),
3719 }))
3720 }
3721 }
3722
3723 #[derive(Debug, Default)]
3726 struct NoLock;
3727
3728 #[async_trait::async_trait]
3729 impl AllocationLock for NoLock {
3730 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3731 Ok(AllocationGuard::new(()))
3732 }
3733 }
3734
3735 #[tokio::test]
3736 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
3737 use runner_manager_platform::wsl::fence::DrainRequest;
3738
3739 let (_local, shared, paths) = guest_paths();
3740 DrainRequest::new(4, chrono::Utc::now())
3741 .write(shared.path())
3742 .unwrap();
3743 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3744
3745 assert!(lock.acquire().await.is_err());
3746 }
3747
3748 #[tokio::test]
3749 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
3750 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3751
3752 let (_local, shared, paths) = guest_paths();
3753 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3754 let guard = lock.acquire().await.unwrap();
3755 assert!(shared.path().join(FENCE_DIRECTORY).exists());
3756 drop(guard);
3757 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3758 }
3759
3760 #[derive(Clone, Default)]
3762 struct Logs(Arc<Mutex<Vec<u8>>>);
3763
3764 impl std::io::Write for Logs {
3765 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3766 self.0.lock().unwrap().extend_from_slice(buf);
3767 Ok(buf.len())
3768 }
3769 fn flush(&mut self) -> std::io::Result<()> {
3770 Ok(())
3771 }
3772 }
3773
3774 #[tokio::test]
3777 async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
3778 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
3779
3780 let (_local, shared, paths) = guest_paths();
3781 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3782 let guard = lock.acquire().await.unwrap();
3783 let fence = shared.path().join(FENCE_DIRECTORY);
3786 std::fs::remove_dir_all(&fence).unwrap();
3787 std::fs::write(&fence, b"not a directory").unwrap();
3788
3789 let logs = Logs::default();
3790 let writer = logs.clone();
3791 let subscriber = tracing_subscriber::fmt()
3792 .with_writer(move || writer.clone())
3793 .with_ansi(false)
3794 .finish();
3795 tracing::subscriber::with_default(subscriber, || drop(guard));
3796
3797 let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
3798 assert!(text.contains("WARN"), "{text}");
3799 assert!(text.contains("could not be released"), "{text}");
3800 assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
3801 }
3802
3803 fn guest_paths() -> (
3804 tempfile::TempDir,
3805 tempfile::TempDir,
3806 Arc<runner_manager_platform::paths::AppPaths>,
3807 ) {
3808 use runner_manager_platform::paths::AppPaths;
3809 use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
3810
3811 let local = tempfile::tempdir().unwrap();
3812 let shared = tempfile::tempdir().unwrap();
3813 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3814 paths.create_all().unwrap();
3815 GuestRecoveryConfig::new(shared.path().to_path_buf())
3816 .write(&paths)
3817 .unwrap();
3818 (local, shared, paths)
3819 }
3820
3821 #[tokio::test]
3824 async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
3825 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
3826
3827 let (_local, shared, paths) = guest_paths();
3828 let fence = shared.path().join(FENCE_DIRECTORY);
3829 std::fs::create_dir_all(&fence).unwrap();
3830 let pid = std::process::id();
3831 std::fs::write(
3832 fence.join(OWNER_FILE),
3833 format!(
3834 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"}}}}"#
3835 ),
3836 )
3837 .unwrap();
3838 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3839
3840 let guard = lock.acquire().await.expect("the stale claim was reclaimed");
3841 drop(guard);
3842 assert!(!fence.exists());
3843 }
3844
3845 #[tokio::test]
3846 async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
3847 use runner_manager_platform::launch_health::recorded_launches_blocked;
3848 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
3849
3850 let (_local, shared, paths) = guest_paths();
3851 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
3852 .with_blocked_after(3, Duration::ZERO);
3853 let windows =
3855 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
3856 .unwrap()
3857 .unwrap();
3858
3859 for _ in 0..2 {
3860 assert!(lock.acquire().await.is_err());
3861 }
3862 assert_eq!(
3863 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3864 None,
3865 "not yet"
3866 );
3867 assert!(lock.acquire().await.is_err());
3868 let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
3869 .unwrap()
3870 .expect("three refusals in a row are recorded");
3871 assert!(
3872 blocked.reason.contains("Windows WSL recovery"),
3873 "{blocked:?}"
3874 );
3875
3876 windows.release().unwrap();
3877 let guard = lock.acquire().await.unwrap();
3878 assert_eq!(
3879 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3880 None
3881 );
3882 drop(guard);
3883 }
3884
3885 #[tokio::test]
3886 async fn refusals_while_this_process_is_launching_are_its_own_contention() {
3887 use runner_manager_platform::launch_health::recorded_launches_blocked;
3888
3889 let (_local, _shared, paths) = guest_paths();
3890 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
3891 .with_blocked_after(1, Duration::ZERO);
3892 let launching = lock.acquire().await.unwrap();
3893 for _ in 0..3 {
3894 assert!(lock.acquire().await.is_err());
3895 }
3896 assert_eq!(
3897 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3898 None
3899 );
3900 drop(launching);
3901 }
3902
3903 #[tokio::test]
3906 async fn refusals_with_quiet_spells_between_them_never_add_up() {
3907 use runner_manager_platform::launch_health::recorded_launches_blocked;
3908 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
3909
3910 let (_local, shared, paths) = guest_paths();
3911 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
3912 .with_blocked_after(3, Duration::ZERO)
3913 .with_streak_gap(Duration::ZERO);
3914 let windows =
3915 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
3916 .unwrap()
3917 .unwrap();
3918 for _ in 0..5 {
3919 assert!(lock.acquire().await.is_err());
3920 }
3921 assert_eq!(
3922 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
3923 None
3924 );
3925 windows.release().unwrap();
3926 }
3927
3928 #[tokio::test]
3931 async fn a_claim_refused_by_a_drain_request_is_released() {
3932 use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
3933
3934 let (_local, shared, paths) = guest_paths();
3935 DrainRequest::new(4, chrono::Utc::now())
3936 .write(shared.path())
3937 .unwrap();
3938 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3939 assert!(lock.acquire().await.is_err());
3940 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3941 }
3942
3943 #[derive(Debug, Default)]
3945 struct HeldLock;
3946
3947 #[async_trait::async_trait]
3948 impl AllocationLock for HeldLock {
3949 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3950 Err(AllocationLockBusy)
3951 }
3952 }
3953
3954 struct Harness {
3956 launcher: Arc<FakeLauncher>,
3957 demand: Arc<FakeDemand>,
3958 events: Arc<EventLog>,
3959 clock: Arc<FakeClock>,
3960 reconciler: Reconciler,
3961 }
3962
3963 impl Harness {
3964 fn build(
3965 host: Host,
3966 launcher: Arc<FakeLauncher>,
3967 demand: Arc<FakeDemand>,
3968 lock: Arc<dyn AllocationLock>,
3969 ) -> Self {
3970 let events = Arc::new(EventLog::new());
3971 let clock = Arc::new(FakeClock::default());
3972 let reconciler = Reconciler::new(
3973 host,
3974 ReconcilerPorts {
3975 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
3976 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
3977 lock,
3978 directory: Arc::new(FakeDirectory::default()),
3979 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3980 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
3981 events: Arc::clone(&events) as Arc<dyn EventSink>,
3982 },
3983 );
3984 Self {
3985 launcher,
3986 demand,
3987 events,
3988 clock,
3989 reconciler,
3990 }
3991 }
3992
3993 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
3994 let launcher = Arc::new(FakeLauncher::new());
3995 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
3996 Self::build(
3997 host_with(capacity),
3998 launcher,
3999 demand,
4000 Arc::new(InProcessAllocationLock::new()),
4001 )
4002 }
4003 }
4004
4005 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
4006 let outcome = state.is_terminal().then(|| match state {
4007 AttemptState::Failed => {
4008 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
4009 }
4010 AttemptState::Orphaned => AttemptOutcome::Orphaned,
4011 _ => AttemptOutcome::CompletedJob,
4012 });
4013 RunnerAttempt::from_persisted(PersistedAttempt {
4014 id: AttemptId::from_u128(id),
4015 policy_id: PolicyId::from_u128(policy),
4016 github_runner_id: None,
4017 state,
4018 outcome,
4019 process_id: None,
4020 execution: runner_manager_domain::execution::AttemptExecution::Native {
4021 process_id: None,
4022 },
4023 runtime_path: "runtime/p/a".into(),
4024 workspace_kind: WorkspaceKind::Ephemeral,
4025 workspace_slot: None,
4026 created_at: fixtures::created_at(),
4027 terminal_at: state.is_terminal().then(fixtures::created_at),
4028 last_state_change_at: fixtures::created_at(),
4029 })
4030 .expect("a state/outcome pair the domain accepts")
4031 }
4032
4033 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
4035 RunnerAttempt::from_persisted(PersistedAttempt {
4036 id: AttemptId::from_u128(id),
4037 policy_id: PolicyId::from_u128(policy),
4038 github_runner_id: None,
4039 state: outcome.terminal_state(),
4040 outcome: Some(outcome),
4041 process_id: None,
4042 execution: runner_manager_domain::execution::AttemptExecution::Native {
4043 process_id: None,
4044 },
4045 runtime_path: "runtime/p/a".into(),
4046 workspace_kind: WorkspaceKind::Ephemeral,
4047 workspace_slot: None,
4048 created_at: fixtures::created_at(),
4049 terminal_at: Some(fixtures::created_at()),
4050 last_state_change_at: fixtures::created_at(),
4051 })
4052 .expect("a state/outcome pair the domain accepts")
4053 }
4054
4055 #[tokio::test]
4084 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
4085 let mut harness = Harness::simple(4, 1, "acme/app");
4086 let policy = policy(1, "acme/app", 4);
4087
4088 for poll in 1..=3 {
4089 let report = harness
4090 .reconciler
4091 .reconcile(std::slice::from_ref(&policy))
4092 .await;
4093 assert_eq!(
4094 harness.launcher.launches(),
4095 1,
4096 "poll {poll} started another runner for a job already being served; the \
4097 `- active_owned_runners` term reached `HostAllocator` as a set this host \
4098 does not hold"
4099 );
4100 assert_eq!(report.allocations.len(), 1);
4101 let allocation = &report.allocations[0];
4102 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
4103 if poll == 1 {
4104 assert_eq!(allocation.to_start, 1);
4105 assert_eq!(report.started, 1);
4106 } else {
4107 assert_eq!(allocation.active_owned, 1, "poll {poll}");
4108 assert_eq!(allocation.to_start, 0, "poll {poll}");
4109 assert_eq!(report.started, 0, "poll {poll}");
4110 }
4111 }
4112 assert_eq!(harness.launcher.live_count(), 1);
4113 }
4114
4115 #[tokio::test]
4126 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4127 let launcher = Arc::new(FakeLauncher::forgetful());
4128 let mut harness = Harness::build(
4129 host_with(64),
4130 Arc::clone(&launcher),
4131 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4132 Arc::new(InProcessAllocationLock::new()),
4133 );
4134
4135 let report = harness
4136 .reconciler
4137 .reconcile(&[policy(1, "acme/app", 8)])
4138 .await;
4139
4140 assert_eq!(
4141 report.started, 3,
4142 "the pass is bounded by the grant it was given, not by the attempt set catching \
4143 up with it"
4144 );
4145 assert_eq!(launcher.launches(), 3);
4146 assert!(
4147 launcher.snapshot().is_empty(),
4148 "the launcher never recorded anything, which is the whole point of the fixture"
4149 );
4150 }
4151
4152 #[tokio::test]
4164 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4165 let launcher = Arc::new(FakeLauncher::forgetful());
4166 let demand = Arc::new(FakeDemand::default());
4167 demand.set_for(
4168 &ScaleTarget::repository("acme/left").unwrap(),
4169 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4170 );
4171 demand.set_for(
4172 &ScaleTarget::repository("acme/right").unwrap(),
4173 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4174 );
4175 let mut harness = Harness::build(
4176 host_with(3),
4177 Arc::clone(&launcher),
4178 demand,
4179 Arc::new(InProcessAllocationLock::new()),
4180 );
4181
4182 let report = harness
4183 .reconciler
4184 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4185 .await;
4186
4187 assert_eq!(
4188 report.started, 3,
4189 "host_capacity is 3 and two policies each allowed 3 started {} runners \
4190 between them; the second policy's grant was computed from a set that did \
4191 not yet contain the first policy's launches",
4192 report.started
4193 );
4194 assert_eq!(launcher.launches(), 3);
4195 }
4196
4197 #[tokio::test]
4208 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
4209 let launcher = Arc::new(FakeLauncher::new());
4210 let mut harness = Harness::build(
4211 host_with(8),
4212 Arc::clone(&launcher),
4213 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
4214 Arc::new(InProcessAllocationLock::new()),
4215 );
4216 let policy = policy(1, "acme/app", 8);
4217
4218 launcher.fail_attempts(true);
4219 let unreadable = harness
4220 .reconciler
4221 .reconcile(std::slice::from_ref(&policy))
4222 .await;
4223
4224 assert_eq!(
4225 unreadable.started, 0,
4226 "nothing may be decided from a set that was not read"
4227 );
4228 assert_eq!(launcher.launches(), 0);
4229 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
4230 assert!(
4231 unreadable.allocations.is_empty(),
4232 "no allocation is reported either: there was no set to compute one from, and \
4233 an allocation of zero would claim a decision nobody made"
4234 );
4235 assert!(harness.events.count_of("attempts_unreadable") > 0);
4236
4237 launcher.fail_attempts(false);
4239 let readable = harness
4240 .reconciler
4241 .reconcile(std::slice::from_ref(&policy))
4242 .await;
4243 assert_eq!(
4244 readable.started, 4,
4245 "the difference between the two passes is only whether the set could be read"
4246 );
4247 assert_eq!(readable.attempts_unreadable, 0);
4248 }
4249
4250 #[tokio::test]
4260 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
4261 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4262 1,
4263 1,
4264 AttemptOutcome::ExitedIdleWithoutWork,
4265 )]));
4266 let mut harness = Harness::build(
4267 host_with(4),
4268 Arc::clone(&launcher),
4269 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4270 Arc::new(InProcessAllocationLock::new()),
4271 );
4272
4273 let report = harness
4274 .reconciler
4275 .reconcile(&[policy(1, "acme/app", 4)])
4276 .await;
4277
4278 assert_eq!(report.cleaned, 0);
4279 assert_eq!(report.clean_failures, 1);
4280 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
4281 assert_eq!(
4282 harness.events.count_of("attempt_cleaned"),
4283 0,
4284 "and it is not reported as cleaned"
4285 );
4286 assert_eq!(
4287 launcher.snapshot().len(),
4288 1,
4289 "the attempt is still there, so the retry is real -- what changed is that it \
4290 is no longer silent"
4291 );
4292
4293 let reasons: Vec<&'static str> = harness
4296 .events
4297 .events()
4298 .into_iter()
4299 .filter_map(|event| match event {
4300 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
4301 _ => None,
4302 })
4303 .collect();
4304 assert_eq!(reasons, vec!["other"]);
4305 }
4306
4307 #[tokio::test]
4310 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
4311 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4312 1,
4313 1,
4314 AttemptOutcome::ExitedIdleWithoutWork,
4315 )]));
4316 let mut harness = Harness::build(
4317 host_with(4),
4318 Arc::clone(&launcher),
4319 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4320 Arc::new(InProcessAllocationLock::new()),
4321 );
4322 let policies = [policy(1, "acme/app", 4)];
4323 let start = harness.clock.now();
4324 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
4325 let mut failures_at = async |offset: Duration| {
4326 harness.clock.set(at(offset));
4327 harness.reconciler.reconcile(&policies).await.clean_failures
4328 };
4329
4330 assert_eq!(failures_at(Duration::ZERO).await, 1);
4331 assert_eq!(
4333 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
4334 0,
4335 "a failed cleanup was retried on the very next pass"
4336 );
4337 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
4338 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
4340 assert_eq!(
4341 failures_at(second - Duration::from_secs(1)).await,
4342 0,
4343 "the delay did not double after a second failure"
4344 );
4345 assert_eq!(failures_at(second).await, 1);
4346 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
4347
4348 let mut backoff = CleanBackoff::default();
4350 let id = AttemptId::from_u128(9);
4351 for _ in 0..40 {
4352 backoff.failed(id, start);
4353 }
4354 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
4355 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
4356 backoff.succeeded(id);
4357 assert!(
4358 backoff.is_due(id, start),
4359 "a cleaned attempt kept its backoff"
4360 );
4361 }
4362
4363 #[tokio::test]
4376 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
4377 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4378 attempt_in(AttemptState::Busy, 1, 1),
4379 concluded(2, 1, AttemptOutcome::CompletedJob),
4380 ]));
4381 let harness = Harness::build(
4382 host_with(4),
4383 Arc::clone(&launcher),
4384 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4385 Arc::new(InProcessAllocationLock::new()),
4386 );
4387 let policy = policy(1, "acme/app", 4);
4388
4389 launcher.fail_attempts(true);
4390 let blind = harness.reconciler.scale_down(&policy).await;
4391
4392 assert!(!blind.is_conclusive(), "the machine was never read");
4393 assert_ne!(
4394 blind,
4395 ScaleDownReport::default(),
4396 "a scale-down that could not see the host must not be equal to one that saw \
4397 an idle host; that equality is the whole finding"
4398 );
4399 assert_eq!(blind.removed, 0);
4400 assert_eq!(
4401 blind.refused_busy, 0,
4402 "and this zero means `unknown`, not `none`"
4403 );
4404 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
4405
4406 launcher.fail_attempts(false);
4408 let seeing = harness.reconciler.scale_down(&policy).await;
4409
4410 assert!(seeing.is_conclusive());
4411 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
4412 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
4413 assert_ne!(
4414 seeing, blind,
4415 "the difference between the two is only whether the set could be read"
4416 );
4417 }
4418
4419 #[tokio::test]
4427 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
4428 let mut warm = ScalePolicy::new(
4429 PolicyId::from_u128(1),
4430 ScaleTarget::repository("acme/app").unwrap(),
4431 1,
4432 fixtures::HOST_ID,
4433 PolicyMode::autoscale(
4434 fixtures::routing_labels("home"),
4435 2,
4436 NonZeroU16::new(5).expect("non-zero"),
4437 )
4438 .expect("min <= max"),
4439 CachePolicy::default(),
4440 );
4441 warm.activate().expect("pending -> active");
4442
4443 let mut harness = Harness::simple(8, 0, "acme/app");
4444 let report = harness.reconciler.reconcile(&[warm]).await;
4445
4446 assert_eq!(
4447 report.allocations[0].demand, 0,
4448 "GitHub reported no queued runs"
4449 );
4450 assert_eq!(
4451 report.allocations[0].desired, 2,
4452 "min_capacity raised the target above demand"
4453 );
4454 assert_eq!(
4455 report.allocations[0].limiting_factor,
4456 LimitingFactor::MinCapacity
4457 );
4458 assert_eq!(report.started, 2, "and two runners were actually started");
4459 assert_eq!(harness.launcher.live_count(), 2);
4460 }
4461
4462 #[tokio::test]
4467 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
4468 let mut harness = Harness::simple(100, 10, "acme/app");
4469 let report = harness
4470 .reconciler
4471 .reconcile(&[policy(1, "acme/app", 3)])
4472 .await;
4473
4474 assert_eq!(report.allocations[0].demand, 10);
4475 assert_eq!(
4476 report.allocations[0].desired, 3,
4477 "max_capacity beats demand"
4478 );
4479 assert_eq!(report.started, 3);
4480 assert_eq!(
4481 report.allocations[0].limiting_factor,
4482 LimitingFactor::MaxCapacity
4483 );
4484 }
4485
4486 #[tokio::test]
4487 async fn demand_below_min_capacity_starts_nothing_in_v1() {
4488 let mut harness = Harness::simple(8, 0, "acme/app");
4491 let report = harness
4492 .reconciler
4493 .reconcile(&[policy(1, "acme/app", 4)])
4494 .await;
4495
4496 assert_eq!(report.allocations[0].desired, 0);
4497 assert_eq!(report.started, 0);
4498 assert!(report.starts_nothing());
4499 }
4500
4501 #[tokio::test]
4502 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
4503 let policy = policy(1, "octo/repo", 1);
4504 let previous = attempt_in(AttemptState::Starting, 41, 1);
4505 let intent = ReplacementIntent {
4506 policy: policy.id,
4507 previous_attempt: previous.id,
4508 operation: "exit_before_acceptance_replacement",
4509 };
4510 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
4511 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
4512 let mut harness = Harness::build(
4513 host_with(1),
4514 Arc::clone(&launcher),
4515 demand,
4516 Arc::new(InProcessAllocationLock::new()),
4517 );
4518
4519 let report = harness.reconciler.reconcile(&[policy]).await;
4520
4521 assert_eq!(report.replacement_intents, 1);
4522 assert_eq!(report.started, 1);
4523 assert_eq!(launcher.launches(), 1);
4524 assert_eq!(launcher.live_count(), 1);
4525 }
4526
4527 #[tokio::test]
4528 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
4529 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4530 attempt_in(AttemptState::Busy, 1, 1),
4531 attempt_in(AttemptState::Busy, 2, 1),
4532 ]));
4533 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
4534 let mut harness = Harness::build(
4535 host_with(2),
4536 launcher,
4537 demand,
4538 Arc::new(InProcessAllocationLock::new()),
4539 );
4540
4541 let report = harness
4542 .reconciler
4543 .reconcile(&[policy(1, "acme/app", 2)])
4544 .await;
4545 assert_eq!(report.started, 0);
4546 assert_eq!(report.allocations[0].headroom_before, 0);
4547 assert_eq!(harness.launcher.launches(), 0);
4548 }
4549
4550 #[tokio::test]
4551 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
4552 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4555 attempt_in(AttemptState::Busy, 1, 99),
4556 attempt_in(AttemptState::Busy, 2, 99),
4557 attempt_in(AttemptState::Idle, 3, 99),
4558 attempt_in(AttemptState::Starting, 4, 99),
4559 ]));
4560 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
4561 let mut harness = Harness::build(
4562 host_with(6),
4563 launcher,
4564 demand,
4565 Arc::new(InProcessAllocationLock::new()),
4566 );
4567
4568 let report = harness
4569 .reconciler
4570 .reconcile(&[policy(1, "acme/app", 5)])
4571 .await;
4572 assert_eq!(
4573 report.allocations[0].desired, 5,
4574 "its own ceiling allows five"
4575 );
4576 assert_eq!(report.started, 2, "the host has two slots free");
4577 assert_eq!(
4578 report.allocations[0].limiting_factor,
4579 LimitingFactor::HostCapacity
4580 );
4581 assert_eq!(harness.launcher.live_count(), 6);
4582 }
4583
4584 #[tokio::test]
4585 async fn the_idle_host_assertion_holds() {
4586 let mut harness = Harness::simple(8, 0, "acme/app");
4589 let report = harness
4590 .reconciler
4591 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
4592 .await;
4593
4594 assert_eq!(report.started, 0);
4595 assert_eq!(harness.launcher.launches(), 0);
4596 assert!(harness.launcher.snapshot().is_empty());
4597 assert_eq!(
4598 harness
4599 .launcher
4600 .snapshot()
4601 .iter()
4602 .filter(|a| !a.is_terminal())
4603 .count(),
4604 0
4605 );
4606 }
4607
4608 #[tokio::test(flavor = "current_thread")]
4631 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
4632 let lock = Arc::new(CountingLock::new());
4633 let (launches, live) =
4634 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>).await;
4635
4636 assert_eq!(
4637 launches, 3,
4638 "the sum across policies must never exceed host_capacity, and each policy is \
4639 individually within its own max_capacity of 3"
4640 );
4641 assert_eq!(live, 3, "and no duplicate runner survived the race");
4642 assert_eq!(
4643 lock.peak(),
4644 1,
4645 "the allocation lock had one holder at a time; without that the read of the \
4646 headroom and the creation of the runtime are not atomic"
4647 );
4648 assert!(
4649 (3..=5).contains(&lock.acquisitions()),
4650 "the lock is taken before *each* runtime, not once per pass: three runtimes \
4651 means at least three holds, and at most one further hold per policy to \
4652 discover the host filled up underneath it. It was taken {} times",
4653 lock.acquisitions()
4654 );
4655 }
4656
4657 #[tokio::test(flavor = "current_thread")]
4664 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
4665 let (launches, _) =
4666 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>).await;
4667
4668 assert!(
4669 launches > 3,
4670 "with no serialisation both policies must be able to spend the same headroom; \
4671 they started {launches} runners on a host of 3. If this is ever 3, the \
4672 contention window closed and `two_policies_reconciling_concurrently_never_\
4673 exceed_host_capacity` has stopped proving anything"
4674 );
4675 }
4676
4677 async fn two_policies_concurrently(lock: Arc<dyn AllocationLock>) -> (usize, usize) {
4681 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
4682 let host = host_with(3);
4683
4684 let mut left = Harness::build(
4685 host.clone(),
4686 Arc::clone(&launcher),
4687 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
4688 Arc::clone(&lock),
4689 )
4690 .reconciler;
4691 let mut right = Harness::build(
4692 host,
4693 Arc::clone(&launcher),
4694 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
4695 Arc::clone(&lock),
4696 )
4697 .reconciler;
4698
4699 let a = policy(1, "acme/left", 3);
4700 let b = policy(2, "acme/right", 3);
4701
4702 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
4703 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
4704 let (_, _) = (left.await.unwrap(), right.await.unwrap());
4705
4706 (launcher.launches(), launcher.live_count())
4707 }
4708
4709 #[tokio::test]
4721 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
4722 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
4723 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
4724 let launcher = Arc::new(FakeLauncher::new());
4725 let events = Arc::new(EventLog::new());
4726
4727 let mut reconciler = Reconciler::new(
4728 host_with(10),
4729 ReconcilerPorts {
4730 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
4731 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4732 lock: Arc::new(InProcessAllocationLock::new()),
4733 directory: Arc::new(FakeDirectory::default()),
4734 clock: Arc::new(FakeClock::default()),
4735 jitter: Arc::new(NoJitter),
4736 events: Arc::clone(&events) as Arc<dyn EventSink>,
4737 },
4738 );
4739
4740 let monitor = fixtures::policy()
4741 .id(PolicyId::from_u128(1))
4742 .repository("acme/app")
4743 .monitor_only()
4744 .active()
4745 .build();
4746
4747 let report = reconciler.reconcile(&[monitor]).await;
4748
4749 assert_eq!(report.started, 0);
4750 assert_eq!(launcher.launches(), 0);
4751 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
4752 assert_eq!(
4753 report.demand_requests, 0,
4754 "a monitor-only policy spends nothing from the shared hourly ceiling"
4755 );
4756 assert!(
4757 gateway.gateway().calls().is_empty(),
4758 "a monitor-only policy issued a demand request: {:?}",
4759 gateway.gateway().calls()
4760 );
4761 assert_eq!(events.count_of("monitor_only_skipped"), 1);
4762 assert_eq!(
4763 events.count_of("demand_observed"),
4764 0,
4765 "and it contributed no demand"
4766 );
4767 }
4768
4769 #[tokio::test]
4787 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
4788 let lock = Arc::new(CountingLock::new());
4789 let launcher = Arc::new(FakeLauncher::new());
4790 let events = Arc::new(EventLog::new());
4791 let mut reconciler = Reconciler::new(
4792 host_with(4),
4793 ReconcilerPorts {
4794 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
4795 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4796 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
4797 directory: Arc::new(FakeDirectory::default()),
4798 clock: Arc::new(FakeClock::default()),
4799 jitter: Arc::new(NoJitter),
4800 events: Arc::clone(&events) as Arc<dyn EventSink>,
4801 },
4802 );
4803
4804 let watcher = fixtures::policy()
4805 .id(PolicyId::from_u128(2))
4806 .repository("acme/app")
4807 .monitor_only()
4808 .active()
4809 .build();
4810
4811 let report = reconciler
4812 .reconcile(&[policy(1, "acme/app", 4), watcher])
4813 .await;
4814
4815 assert_eq!(report.started, 2, "the autoscale policy is served normally");
4816 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
4817 assert_eq!(
4818 events.count_of("demand_observed"),
4819 1,
4820 "the demand observation belongs to the autoscale policy alone"
4821 );
4822 assert!(
4823 report
4824 .allocations
4825 .iter()
4826 .all(|a| a.policy_id == PolicyId::from_u128(1)),
4827 "a monitor-only policy was allocated for: {:?}",
4828 report.allocations
4829 );
4830 assert_eq!(
4831 lock.acquisitions(),
4832 2,
4833 "one hold per runtime created, and none on behalf of the monitor-only policy. \
4834 It was three before the budget was checked at the top of the loop rather than \
4835 after the re-read, which cost every policy a surplus hold to discover there \
4836 was nothing left to grant"
4837 );
4838 }
4839
4840 #[tokio::test]
4841 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
4842 let monitor = fixtures::monitor_only_policy();
4847 assert!(!monitor.owns_runners());
4848 assert_eq!(monitor.max_capacity(), None);
4849
4850 let host = host_with(10);
4851 let attempts: Vec<RunnerAttempt> = Vec::new();
4852 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
4853 let allocation = allocator.allocate(&monitor, 10_000);
4854 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
4855 assert_eq!(allocation.to_start, 0);
4856 assert_eq!(
4857 allocator.headroom(),
4858 10,
4859 "and it consumes no headroom, so an autoscale policy on the same host is \
4860 unaffected"
4861 );
4862 }
4863
4864 #[tokio::test]
4872 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
4873 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4874 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
4875 concluded(
4876 2,
4877 1,
4878 AttemptOutcome::failed(FailureReason::JitRequestFailed),
4879 ),
4880 concluded(3, 1, AttemptOutcome::CompletedJob),
4881 ]));
4882 let mut harness = Harness::build(
4883 host_with(4),
4884 Arc::clone(&launcher),
4885 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4886 Arc::new(InProcessAllocationLock::new()),
4887 );
4888
4889 let report = harness
4890 .reconciler
4891 .reconcile(&[policy(1, "acme/app", 4)])
4892 .await;
4893
4894 assert_eq!(report.cleaned, 3);
4895 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
4896 assert_eq!(
4897 report.failures, 1,
4898 "only the failed attempt is a failure; the idle exit and the completed job are \
4899 not"
4900 );
4901 assert_eq!(launcher.cleaned().len(), 3);
4902 assert!(launcher.snapshot().is_empty());
4903
4904 let cleaned: Vec<OutcomeKind> = harness
4905 .events
4906 .events()
4907 .into_iter()
4908 .filter_map(|event| match event {
4909 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
4910 _ => None,
4911 })
4912 .collect();
4913 assert!(cleaned.contains(&OutcomeKind::IdleExit));
4914 assert!(
4915 !OutcomeKind::IdleExit.is_failure(),
4916 "an idle exit rendered as a failure sends an operator hunting a fault that does \
4917 not exist"
4918 );
4919 }
4920
4921 #[tokio::test]
4924 async fn scale_down_removes_nothing_from_a_busy_attempt() {
4925 let busy = attempt_in(AttemptState::Busy, 1, 1);
4926 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4927 busy.clone(),
4928 attempt_in(AttemptState::Starting, 2, 1),
4929 concluded(3, 1, AttemptOutcome::CompletedJob),
4930 ]));
4931 let harness = Harness::build(
4932 host_with(4),
4933 Arc::clone(&launcher),
4934 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4935 Arc::new(InProcessAllocationLock::new()),
4936 );
4937
4938 let report = harness
4939 .reconciler
4940 .scale_down(&policy(1, "acme/app", 4))
4941 .await;
4942
4943 assert_eq!(report.refused_busy, 1);
4944 assert_eq!(
4945 report.retained, 1,
4946 "the `starting` attempt is not ended either"
4947 );
4948 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
4949
4950 let after = launcher.snapshot();
4951 let still_busy = after
4952 .iter()
4953 .find(|a| a.id == AttemptId::from_u128(1))
4954 .expect("the busy attempt is still there");
4955 assert_eq!(
4956 still_busy.state(),
4957 AttemptState::Busy,
4958 "scale-down removed nothing from a runner that is executing a job, and left it \
4959 busy"
4960 );
4961 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
4962
4963 let mut busy = busy;
4967 assert!(matches!(
4968 busy.clean(fixtures::created_at()),
4969 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
4970 ));
4971 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
4972 }
4973
4974 #[test]
4979 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
4980 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
4981 assert_eq!(RefreshInterval::MIN_SECS, 30);
4982 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
4983 assert!(
4984 RefreshInterval::from_secs(29).is_err(),
4985 "the floor is a rate-budget constraint, and a caller must not be able to write \
4986 a shorter interval at all"
4987 );
4988
4989 let mut schedule = PollSchedule::new(RefreshInterval::default());
4990 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
4991 assert_eq!(next.delay, Duration::from_secs(60));
4992 assert_eq!(next.pace, PollPace::Nominal);
4993
4994 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4995 assert_eq!(
4996 floored
4997 .next_poll(None, fixtures::created_at(), &NoJitter)
4998 .delay,
4999 Duration::from_secs(30)
5000 );
5001 }
5002
5003 #[test]
5007 fn a_rate_limit_increases_the_delay_and_names_itself() {
5008 let now = fixtures::created_at();
5009 let mut schedule = PollSchedule::new(RefreshInterval::default());
5010
5011 let limited = RefreshState::RateLimited(RateLimited {
5012 kind: RateLimitKind::Secondary,
5013 retry_after: Some(Duration::from_secs(300)),
5014 remaining: None,
5015 reset_unix_secs: None,
5016 });
5017 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5018
5019 assert_eq!(next.delay, Duration::from_secs(300));
5020 assert_eq!(
5021 next.pace,
5022 PollPace::RateLimited {
5023 kind: RateLimitKind::Secondary
5024 },
5025 "the increase is reported, never hidden"
5026 );
5027 assert!(next.pace.is_throttled());
5028 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
5029 }
5030
5031 #[test]
5034 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
5035 let now = fixtures::created_at();
5036 let mut schedule = PollSchedule::new(RefreshInterval::default());
5037
5038 let limited = RefreshState::RateLimited(RateLimited {
5039 kind: RateLimitKind::Primary,
5040 retry_after: Some(Duration::from_secs(300)),
5041 remaining: Some(0),
5042 reset_unix_secs: None,
5043 });
5044
5045 for _ in 0..5 {
5048 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5049 assert_eq!(
5050 next.delay,
5051 Duration::from_secs(300),
5052 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
5053 have compounded on every successive retry"
5054 );
5055 }
5056
5057 let brief = RefreshState::RateLimited(RateLimited {
5060 kind: RateLimitKind::Secondary,
5061 retry_after: Some(Duration::from_secs(5)),
5062 remaining: None,
5063 reset_unix_secs: None,
5064 });
5065 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
5066 assert_eq!(
5067 next.delay,
5068 Duration::from_secs(60),
5069 "a short `retry-after` may not drop the loop below its own interval"
5070 );
5071 assert!(next.delay >= PollSchedule::floor());
5072 }
5073
5074 #[test]
5075 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
5076 let now = fixtures::created_at();
5077 let states = [
5078 None,
5079 Some(RefreshState::Offline),
5080 Some(RefreshState::RateLimited(RateLimited {
5081 kind: RateLimitKind::Secondary,
5082 retry_after: Some(Duration::from_secs(1)),
5083 remaining: None,
5084 reset_unix_secs: None,
5085 })),
5086 Some(RefreshState::LockedOut {
5087 retry_after: Duration::from_secs(1),
5088 }),
5089 Some(RefreshState::Unauthorized),
5090 Some(RefreshState::Forbidden { message: None }),
5091 Some(RefreshState::Failed {
5092 status: Some(500),
5093 message: "server error".into(),
5094 }),
5095 Some(RefreshState::Cancelled),
5096 ];
5097
5098 for state in &states {
5099 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5100 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
5101 assert!(
5102 next.delay >= PollSchedule::floor(),
5103 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
5104 next.delay.as_millis()
5105 );
5106 }
5107 }
5108
5109 #[test]
5110 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
5111 let now = fixtures::created_at();
5112 let mut schedule = PollSchedule::new(RefreshInterval::default());
5113
5114 let mut previous = Duration::ZERO;
5116 for consecutive in 1..=6_u32 {
5117 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5118 assert_eq!(next.pace, PollPace::Offline { consecutive });
5119 assert!(
5120 next.delay >= previous,
5121 "the back-off must not shrink while the outage continues"
5122 );
5123 assert!(next.delay >= Duration::from_secs(60));
5124 previous = next.delay;
5125 }
5126 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
5127
5128 let mut jittered = PollSchedule::new(RefreshInterval::default());
5131 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5132 let mut jittered = PollSchedule::new(RefreshInterval::default());
5133 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
5134 assert!(full.delay > none.delay);
5135 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
5136
5137 assert_eq!(schedule.consecutive_offline(), 6);
5139 let recovered = schedule.next_poll(None, now, &NoJitter);
5140 assert_eq!(recovered.pace, PollPace::Nominal);
5141 assert_eq!(recovered.delay, Duration::from_secs(60));
5142 assert_eq!(schedule.consecutive_offline(), 0);
5143 }
5144
5145 #[test]
5146 fn the_offline_state_states_the_twenty_four_hour_bound() {
5147 assert_eq!(
5148 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
5149 Duration::from_secs(24 * 60 * 60)
5150 );
5151
5152 let brief = OfflineState::new(1, Duration::from_secs(120));
5153 let rendered = brief.to_string();
5154 assert!(rendered.contains("24 hours"), "{rendered}");
5155 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
5156 assert!(!brief.has_outlasted_the_queue());
5157
5158 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
5159 assert!(long.has_outlasted_the_queue());
5160 assert!(
5161 long.to_string().contains("queued work has been lost"),
5162 "{long}"
5163 );
5164
5165 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
5167 }
5168
5169 fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
5174 RateLimitSnapshot {
5175 limit: 5_000,
5176 remaining,
5177 used: Some(5_000 - remaining),
5178 reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
5179 observed_at: now,
5180 }
5181 }
5182
5183 fn quiet() -> PollConditions {
5184 PollConditions {
5185 quiet: true,
5186 quota: None,
5187 requests_per_pass: 0,
5188 }
5189 }
5190
5191 #[test]
5192 fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
5193 let now = fixtures::created_at();
5194 let mut schedule =
5195 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5196
5197 let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
5198 assert_eq!(idle.delay, Duration::from_secs(10));
5199 assert_eq!(idle.pace, PollPace::Idle);
5200 assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
5201
5202 let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
5203 assert_eq!(busy.delay, Duration::from_secs(60));
5204 assert_eq!(busy.pace, PollPace::Nominal);
5205 }
5206
5207 #[test]
5208 fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
5209 let now = fixtures::created_at();
5210 let mut schedule = PollSchedule::new(RefreshInterval::default());
5211 assert_eq!(
5212 schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
5213 Duration::from_secs(60)
5214 );
5215 }
5216
5217 #[test]
5218 fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
5219 let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
5220 .with_idle(Duration::from_secs(120));
5221 assert_eq!(
5222 schedule.idle(),
5223 Duration::from_secs(30),
5224 "an idle interval above the active one would make quiet targets slower to react"
5225 );
5226 let schedule =
5227 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
5228 assert_eq!(schedule.idle(), PollSchedule::idle_floor());
5229 }
5230
5231 #[test]
5234 fn a_low_quota_stretches_polling_and_wins_over_idle() {
5235 let now = fixtures::created_at();
5236 let mut schedule =
5237 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5238
5239 let healthy = schedule.next_poll_with(
5240 None,
5241 PollConditions {
5242 quiet: true,
5243 quota: Some(quota(1_000, 3_000, now)),
5244 requests_per_pass: 0,
5245 },
5246 now,
5247 &NoJitter,
5248 );
5249 assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
5250
5251 let low = schedule.next_poll_with(
5252 None,
5253 PollConditions {
5254 quiet: true,
5255 quota: Some(quota(999, 3_000, now)),
5256 requests_per_pass: 0,
5257 },
5258 now,
5259 &NoJitter,
5260 );
5261 assert_eq!(low.delay, Duration::from_secs(120));
5262 assert_eq!(
5263 low.pace,
5264 PollPace::Stretched {
5265 pressure: PrimaryPressure::Low
5266 }
5267 );
5268 assert!(low.pace.is_throttled());
5269 assert_eq!(low.pace.as_str(), "stretched_low_budget");
5270
5271 let critical = schedule.next_poll_with(
5272 None,
5273 PollConditions {
5274 quiet: false,
5275 quota: Some(quota(100, 3_000, now)),
5276 requests_per_pass: 0,
5277 },
5278 now,
5279 &NoJitter,
5280 );
5281 assert_eq!(critical.delay, Duration::from_secs(240));
5282 assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
5283 }
5284
5285 #[test]
5286 fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
5287 let now = fixtures::created_at();
5288 let mut schedule = PollSchedule::new(RefreshInterval::default());
5289
5290 let soon = schedule.next_poll_with(
5291 None,
5292 PollConditions {
5293 quiet: false,
5294 quota: Some(quota(10, 90, now)),
5295 requests_per_pass: 0,
5296 },
5297 now,
5298 &NoJitter,
5299 );
5300 assert_eq!(
5301 soon.delay,
5302 Duration::from_secs(90),
5303 "the bucket refills in 90 s; waiting 240 s buys nothing"
5304 );
5305
5306 let imminent = schedule.next_poll_with(
5307 None,
5308 PollConditions {
5309 quiet: false,
5310 quota: Some(quota(10, 5, now)),
5311 requests_per_pass: 0,
5312 },
5313 now,
5314 &NoJitter,
5315 );
5316 assert_eq!(imminent.delay, Duration::from_secs(60));
5317
5318 let expired = schedule.next_poll_with(
5319 None,
5320 PollConditions {
5321 quiet: true,
5322 quota: Some(quota(10, -5, now)),
5323 requests_per_pass: 0,
5324 },
5325 now,
5326 &NoJitter,
5327 );
5328 assert_eq!(
5329 expired.pace,
5330 PollPace::Nominal,
5331 "a reading from a window that has reset says nothing; the schedule here had no \
5332 idle interval, so it is the active one"
5333 );
5334 }
5335
5336 #[test]
5337 fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
5338 let now = fixtures::created_at();
5339 let mut schedule =
5340 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5341 let small = schedule.next_poll_with(
5342 None,
5343 PollConditions {
5344 quiet: true,
5345 quota: None,
5346 requests_per_pass: 40,
5347 },
5348 now,
5349 &NoJitter,
5350 );
5351 assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
5352 let organization = schedule.next_poll_with(
5353 None,
5354 PollConditions {
5355 quiet: true,
5356 quota: None,
5357 requests_per_pass: 400,
5358 },
5359 now,
5360 &NoJitter,
5361 );
5362 assert_eq!(
5363 organization.delay,
5364 Duration::from_secs(80),
5365 "400 requests a pass at 300 a minute is one pass every 80 s"
5366 );
5367 assert_eq!(organization.pace, PollPace::Idle);
5368 }
5369
5370 #[test]
5371 fn a_rate_limit_wait_is_jittered_upward_only() {
5372 let now = fixtures::created_at();
5373 let mut schedule = PollSchedule::new(RefreshInterval::default());
5374 let limited = RefreshState::RateLimited(RateLimited {
5375 kind: RateLimitKind::Secondary,
5376 retry_after: Some(Duration::from_secs(300)),
5377 remaining: None,
5378 reset_unix_secs: None,
5379 });
5380 assert_eq!(
5381 schedule
5382 .next_poll(Some(&limited), now, &FixedJitter(0.5))
5383 .delay,
5384 Duration::from_secs(315),
5385 "every loop shares one quiet period; without jitter they all wake together"
5386 );
5387 assert_eq!(
5388 schedule.next_poll(Some(&limited), now, &NoJitter).delay,
5389 Duration::from_secs(300),
5390 "never shorter than GitHub asked"
5391 );
5392 }
5393
5394 #[tokio::test]
5395 async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
5396 let launcher =
5399 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
5400 let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
5401 let mut harness = Harness::build(
5402 host_with(1),
5403 launcher,
5404 demand,
5405 Arc::new(InProcessAllocationLock::new()),
5406 );
5407 harness
5408 .reconciler
5409 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5410 let policy = policy(1, "acme/app", 4);
5411
5412 let report = harness
5413 .reconciler
5414 .reconcile(std::slice::from_ref(&policy))
5415 .await;
5416
5417 assert_eq!(report.started, 0, "the host is full");
5418 assert_eq!(
5419 report.next_poll.pace,
5420 PollPace::Nominal,
5421 "matched demand it could not serve is work waiting, not idle"
5422 );
5423 }
5424
5425 #[tokio::test]
5426 async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
5427 let mut harness = Harness::simple(4, 0, "acme/app");
5428 harness
5429 .reconciler
5430 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5431 let policy = policy(1, "acme/app", 4);
5432
5433 let report = harness
5434 .reconciler
5435 .reconcile(std::slice::from_ref(&policy))
5436 .await;
5437
5438 assert_eq!(report.next_poll.pace, PollPace::Idle);
5439 assert_eq!(report.next_poll.delay, Duration::from_secs(10));
5440 }
5441
5442 #[tokio::test]
5443 async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
5444 let mut harness = Harness::simple(4, 0, "acme/app");
5445 harness
5446 .reconciler
5447 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5448 harness.demand.set(PollOutcome::Ready(
5449 QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
5450 ));
5451 let policy = policy(1, "acme/app", 4);
5452
5453 let report = harness
5454 .reconciler
5455 .reconcile(std::slice::from_ref(&policy))
5456 .await;
5457
5458 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5459 assert_eq!(report.next_poll.delay, Duration::from_secs(60));
5460 }
5461
5462 #[tokio::test]
5463 async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
5464 let launcher =
5465 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5466 let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
5467 let mut harness = Harness::build(
5468 host_with(4),
5469 launcher,
5470 demand,
5471 Arc::new(InProcessAllocationLock::new()),
5472 );
5473 harness
5474 .reconciler
5475 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5476 let policy = policy(1, "acme/app", 4);
5477
5478 let report = harness
5479 .reconciler
5480 .reconcile(std::slice::from_ref(&policy))
5481 .await;
5482
5483 assert_eq!(
5484 report.next_poll.pace,
5485 PollPace::Nominal,
5486 "a job is running: its follow-on work is what the active interval watches for"
5487 );
5488 }
5489
5490 #[tokio::test]
5491 async fn a_pass_that_starts_a_runner_is_not_idle() {
5492 let mut harness = Harness::simple(4, 2, "acme/app");
5493 harness
5494 .reconciler
5495 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5496 let policy = policy(1, "acme/app", 4);
5497
5498 let report = harness
5499 .reconciler
5500 .reconcile(std::slice::from_ref(&policy))
5501 .await;
5502
5503 assert!(report.started > 0);
5504 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5505 }
5506
5507 #[tokio::test]
5508 async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
5509 let mut harness = Harness::simple(4, 0, "acme/app");
5510 harness
5511 .reconciler
5512 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5513 let now = harness.clock.now();
5514 harness.demand.set(PollOutcome::Ready(
5515 QueuedDemand::of(repo("acme/app"), Vec::new())
5516 .with_rate_limit(Some(quota(200, 1_800, now))),
5517 ));
5518 let policy = policy(1, "acme/app", 4);
5519
5520 let report = harness
5521 .reconciler
5522 .reconcile(std::slice::from_ref(&policy))
5523 .await;
5524
5525 assert_eq!(
5526 report.next_poll.pace,
5527 PollPace::Stretched {
5528 pressure: PrimaryPressure::Critical
5529 }
5530 );
5531 assert_eq!(report.next_poll.delay, Duration::from_secs(240));
5532 }
5533
5534 #[tokio::test]
5543 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
5544 let live = vec![
5545 attempt_in(AttemptState::Busy, 1, 1),
5546 attempt_in(AttemptState::Starting, 2, 1),
5547 ];
5548 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
5549 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5550 let mut harness = Harness::build(
5551 host_with(8),
5552 Arc::clone(&launcher),
5553 Arc::clone(&demand),
5554 Arc::new(InProcessAllocationLock::new()),
5555 );
5556 let policy = policy(1, "acme/app", 8);
5557
5558 let report = harness
5559 .reconciler
5560 .reconcile(std::slice::from_ref(&policy))
5561 .await;
5562
5563 assert!(report.is_offline());
5564 assert_eq!(report.started, 0, "no new runner during an outage");
5565 assert_eq!(launcher.launches(), 0);
5566 assert_eq!(
5567 launcher.snapshot(),
5568 live,
5569 "existing runner processes are retained, untouched"
5570 );
5571 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
5572 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5573 assert!(report.next_poll.delay >= Duration::from_secs(60));
5574 let offline = report.offline_state().expect("an offline state to display");
5575 assert!(offline.to_string().contains("24 hours"));
5576
5577 demand.set(PollOutcome::Ready(QueuedDemand::of(
5581 repo("acme/app"),
5582 jobs(2),
5583 )));
5584 let recovered = harness.reconciler.reconcile(&[policy]).await;
5585
5586 assert!(!recovered.is_offline());
5587 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
5588 assert_eq!(
5589 recovered.started, 0,
5590 "two queued runs, two attempts already in flight: a reconnect cannot \
5591 double-count work"
5592 );
5593 assert_eq!(recovered.allocations[0].active_owned, 2);
5594 assert_eq!(launcher.live_count(), 2);
5595 }
5596
5597 #[tokio::test]
5607 async fn one_offline_target_does_not_stop_a_reachable_one() {
5608 let mut harness = Harness::simple(8, 0, "acme/app");
5609 harness.demand.set_for(
5610 &ScaleTarget::repository("acme/app").unwrap(),
5611 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
5612 );
5613 harness.demand.set_for(
5614 &ScaleTarget::repository("acme/broken").unwrap(),
5615 PollOutcome::Failed(RefreshState::Offline),
5616 );
5617
5618 let report = harness
5619 .reconciler
5620 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
5621 .await;
5622
5623 assert_eq!(
5624 report.started, 2,
5625 "the reachable target was served; an unreachable sibling repository must not \
5626 idle the host"
5627 );
5628 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
5629 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
5630
5631 assert!(report.is_offline());
5633 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5634 }
5635
5636 #[tokio::test]
5651 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
5652 let clock = Arc::new(FakeClock::default());
5653 let launcher = Arc::new(FakeLauncher::new());
5654 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5655 let mut reconciler = Reconciler::new(
5656 host_with(4),
5657 ReconcilerPorts {
5658 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
5659 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5660 lock: Arc::new(InProcessAllocationLock::new()),
5661 directory: Arc::new(FakeDirectory::default()),
5662 clock: Arc::clone(&clock) as Arc<dyn Clock>,
5663 jitter: Arc::new(NoJitter),
5664 events: Arc::new(NoEvents),
5665 },
5666 );
5667 let policy = policy(1, "acme/app", 4);
5668
5669 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5671 let state = first.offline_state().expect("an offline state");
5672 assert!(!state.has_outlasted_the_queue());
5673 assert!(
5674 state
5675 .to_string()
5676 .contains("an outage longer than that loses"),
5677 "{state}"
5678 );
5679
5680 clock.advance_secs(24 * 60 * 60 + 60);
5682 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5683 let state = later.offline_state().expect("an offline state");
5684 assert!(state.has_outlasted_the_queue());
5685 assert!(
5686 state.to_string().contains("queued work has been lost"),
5687 "{state}"
5688 );
5689 assert_eq!(launcher.launches(), 0, "and still nothing was started");
5690
5691 demand.set(PollOutcome::Ready(QueuedDemand::of(
5694 repo("acme/app"),
5695 jobs(0),
5696 )));
5697 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5698 assert!(recovered.offline_state().is_none());
5699 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
5700
5701 demand.set(PollOutcome::Failed(RefreshState::Offline));
5702 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5703 assert!(
5704 !again
5705 .offline_state()
5706 .expect("an offline state")
5707 .has_outlasted_the_queue(),
5708 "a new outage must not inherit the age of the one before it"
5709 );
5710 }
5711
5712 #[tokio::test]
5728 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
5729 let root = tempfile::tempdir().expect("a temporary directory");
5730 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
5731 root.path(),
5732 ));
5733 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
5734
5735 let held = lock.acquire().await.expect("an uncontended lock is free");
5736 assert!(
5737 matches!(lock.acquire().await, Err(AllocationLockBusy)),
5738 "a second holder was admitted; on Unix the lock is per open file description \
5739 and on Windows the share mode denies write, so this must be refused even \
5740 from inside the same process"
5741 );
5742
5743 drop(held);
5744 let regained = lock.acquire().await;
5745 assert!(
5746 regained.is_ok(),
5747 "dropping the guard is the only release there is, so a guard that does not \
5748 hold the `HostLock` leaves it held forever"
5749 );
5750 }
5751
5752 #[test]
5753 fn tee_events_reaches_both_sinks() {
5754 let left = Arc::new(EventLog::new());
5758 let right = Arc::new(EventLog::new());
5759 let tee = TeeEvents(
5760 Arc::clone(&left) as Arc<dyn EventSink>,
5761 Arc::clone(&right) as Arc<dyn EventSink>,
5762 );
5763
5764 tee.emit(LifecycleEvent::MonitorOnlySkipped {
5765 policy: PolicyId::from_u128(1),
5766 });
5767
5768 assert_eq!(left.count_of("monitor_only_skipped"), 1);
5769 assert_eq!(right.count_of("monitor_only_skipped"), 1);
5770 }
5771
5772 #[tokio::test]
5777 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
5778 let clock = Arc::new(FakeClock::default());
5779 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
5780 let cache = RepositoryCache::new(
5781 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
5782 Arc::clone(&clock) as Arc<dyn Clock>,
5783 RefreshInterval::default(),
5784 );
5785 let target = ScaleTarget::organization("acme").unwrap();
5786
5787 assert_eq!(
5788 cache.ttl(),
5789 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
5790 );
5791
5792 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
5794 let scope = cache.scope_for(&target).await.unwrap();
5795 assert_eq!(scope.repositories().len(), 2);
5796 clock.advance_secs(60);
5797 }
5798 assert_eq!(
5799 directory.calls(),
5800 1,
5801 "re-listing an organization at demand-poll frequency is what exhausts the \
5802 shared request budget"
5803 );
5804 assert_eq!(cache.lookups(), 1);
5805
5806 cache.scope_for(&target).await.unwrap();
5808 assert_eq!(directory.calls(), 2);
5809 }
5810
5811 #[tokio::test]
5812 async fn a_repository_target_never_consults_the_directory() {
5813 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
5814 let cache = RepositoryCache::new(
5815 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
5816 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
5817 RefreshInterval::default(),
5818 );
5819 let target = ScaleTarget::repository("acme/app").unwrap();
5820
5821 let scope = cache.scope_for(&target).await.unwrap();
5822 assert_eq!(scope.repositories(), &[repo("acme/app")]);
5823 assert_eq!(directory.calls(), 0);
5824 }
5825
5826 #[tokio::test]
5827 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
5828 let mut harness = Harness::simple(8, 4, "o/r");
5832 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5833 repo("o/r"),
5834 [
5835 fixtures::queued_job(&[HOST_LABEL]),
5836 fixtures::queued_job(&[HOST_LABEL]),
5837 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
5838 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
5839 ],
5840 )));
5841 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5842 isolated.activate().unwrap();
5843 let report = harness
5844 .reconciler
5845 .reconcile(&[policy(1, "o/r", 2), isolated])
5846 .await;
5847
5848 assert_eq!(harness.demand.polls().len(), 1);
5849 assert_eq!(report.targets_read, 1, "profiles share one target reading");
5850 assert_eq!(
5851 report.demand_requests,
5852 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
5853 "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"
5854 );
5855 assert_eq!(
5856 report.started, 4,
5857 "native and isolated profiles each get two"
5858 );
5859 }
5860
5861 #[tokio::test]
5862 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
5863 use runner_manager_domain::model::{Arch, HostLabel, Os};
5864 use runner_manager_domain::policy::RoutingLabels;
5865 let mut harness = Harness::build(
5866 fixtures::host()
5867 .os(Os::MacOs)
5868 .architecture(Arch::Arm64)
5869 .capacity(10)
5870 .build(),
5871 Arc::new(FakeLauncher::new()),
5872 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
5873 Arc::new(InProcessAllocationLock::new()),
5874 );
5875 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5876 repo("o/r"),
5877 [
5878 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
5879 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
5880 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
5881 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
5882 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
5883 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
5884 ],
5885 )));
5886 let mut local = fixtures::policy()
5887 .id(PolicyId::from_u128(1))
5888 .repository("o/r")
5889 .autoscale("macmini", 10)
5890 .routing_labels(RoutingLabels::derive(
5891 &HostLabel::new("macmini").unwrap(),
5892 Os::MacOs,
5893 Arch::Arm64,
5894 ))
5895 .active()
5896 .build();
5897 for label in ["self-hosted", "macos", "arm64", "extra"] {
5898 local.add_routing_label(fixtures::label(label)).unwrap();
5899 }
5900 let mut foreign = local.to_persisted();
5901 foreign.id = PolicyId::from_u128(2);
5902 foreign.host_id = HostId::from_u128(0xdead);
5903 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
5904 let report = harness.reconciler.reconcile(&[local, foreign]).await;
5905 assert_eq!(report.started, 2);
5906 assert_eq!(report.targets_read, 1);
5907 assert!(harness.events.events().iter().any(|event| matches!(
5908 event,
5909 LifecycleEvent::DemandObserved {
5910 demand: 2,
5911 not_matched: 4,
5912 ..
5913 }
5914 )));
5915 assert!(
5916 !harness
5917 .events
5918 .events()
5919 .iter()
5920 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
5921 );
5922 }
5923
5924 #[tokio::test]
5925 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
5926 use runner_manager_domain::execution::{
5927 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
5928 };
5929 let mut native = policy(1, "o/r", 2);
5930 native
5931 .add_routing_label(fixtures::label("self-hosted"))
5932 .unwrap();
5933 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5934 isolated
5935 .add_routing_label(fixtures::label("self-hosted"))
5936 .unwrap();
5937 isolated
5938 .set_execution_policy(ExecutionPolicy::Isolated {
5939 backend: Backend::Oci,
5940 image: ImageReference::new(format!(
5941 "registry.example/runner@sha256:{}",
5942 "a".repeat(64)
5943 ))
5944 .unwrap(),
5945 resources: ResourceLimits {
5946 cpu_millis: 1000,
5947 memory_mib: 512,
5948 disk_mib: 1024,
5949 },
5950 })
5951 .unwrap();
5952 assert!(!isolated.may_start_runners());
5953 let mut active_isolated = isolated.clone();
5954 active_isolated.activate().unwrap();
5955 for profiles in [
5956 [native.clone(), isolated.clone()],
5957 [isolated.clone(), native.clone()],
5958 [native.clone(), active_isolated.clone()],
5959 [active_isolated, native.clone()],
5960 ] {
5961 let mut harness = Harness::simple(4, 0, "o/r");
5962 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5963 repo("o/r"),
5964 [fixtures::queued_job(&["SELF-HOSTED"])],
5965 )));
5966 let report = harness.reconciler.reconcile(&profiles).await;
5967 assert_eq!(report.started, 0);
5968 assert!(harness.events.events().iter().any(|event| matches!(
5969 event,
5970 LifecycleEvent::RoutingRefused {
5971 reason: RoutingRefusal::OverlappingProfiles,
5972 count: 1,
5973 ..
5974 }
5975 )));
5976 }
5977 let mut harness = Harness::simple(4, 0, "o/r");
5980 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5981 repo("o/r"),
5982 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
5983 )));
5984 assert_eq!(
5985 harness
5986 .reconciler
5987 .reconcile(&[native, isolated])
5988 .await
5989 .started,
5990 0
5991 );
5992 }
5993
5994 #[tokio::test]
5995 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
5996 let mut harness = Harness::simple(2, 0, "o/r");
5997 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5998 repo("o/r"),
5999 [
6000 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
6001 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
6002 fixtures::queued_job(&["gpu"]),
6003 fixtures::unresolvable_job(),
6004 ],
6005 )));
6006 let mut native = policy(1, "o/r", 2);
6007 native.add_routing_label(fixtures::label("gpu")).unwrap();
6008 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6009 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
6010 isolated.activate().unwrap();
6011
6012 let report = harness.reconciler.reconcile(&[native, isolated]).await;
6013 assert_eq!(harness.demand.polls().len(), 1);
6014 assert_eq!(report.targets_read, 1);
6015 assert_eq!(report.started, 2);
6016 assert_eq!(report.allocations.len(), 2);
6017 assert!(
6018 report
6019 .allocations
6020 .iter()
6021 .all(|allocation| allocation.to_start == 1)
6022 );
6023 assert!(harness.events.events().iter().any(|event| matches!(
6024 event,
6025 LifecycleEvent::RoutingRefused {
6026 reason: RoutingRefusal::OverlappingProfiles,
6027 count: 1,
6028 ..
6029 }
6030 )));
6031 assert_eq!(
6032 harness
6033 .events
6034 .events()
6035 .iter()
6036 .filter(|event| matches!(
6037 event,
6038 LifecycleEvent::DemandObserved {
6039 unresolvable: 1,
6040 ..
6041 }
6042 ))
6043 .count(),
6044 2,
6045 "an expression stays unresolvable for both profiles"
6046 );
6047 }
6048
6049 #[tokio::test]
6050 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
6051 let mut harness = Harness::simple(4, 0, "o/r");
6052 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6053 repo("o/r"),
6054 [fixtures::queued_job(&[
6055 HOST_LABEL,
6056 "rm-home-win-x64-py-isolated",
6057 ])],
6058 )));
6059 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6060 isolated.activate().unwrap();
6061 let report = harness
6062 .reconciler
6063 .reconcile(&[policy(1, "o/r", 2), isolated])
6064 .await;
6065 assert_eq!(report.started, 0);
6066 assert_eq!(
6067 harness
6068 .events
6069 .events()
6070 .iter()
6071 .filter(|event| matches!(
6072 event,
6073 LifecycleEvent::DemandObserved {
6074 demand: 0,
6075 not_matched: 1,
6076 ..
6077 }
6078 ))
6079 .count(),
6080 2
6081 );
6082
6083 let mut superset = policy(1, "o/r", 2);
6087 assert!(
6088 superset
6089 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
6090 .unwrap()
6091 );
6092 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6093 sibling.activate().unwrap();
6094 let mut unique = Harness::simple(4, 0, "o/r");
6095 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
6096 repo("o/r"),
6097 [fixtures::queued_job(&[
6098 HOST_LABEL,
6099 "rm-home-win-x64-py-isolated",
6100 ])],
6101 )));
6102 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
6103 assert_eq!(report.started, 1);
6104 assert_eq!(
6105 report
6106 .allocations
6107 .iter()
6108 .find(|allocation| allocation.to_start == 1)
6109 .unwrap()
6110 .policy_id,
6111 PolicyId::from_u128(1)
6112 );
6113 assert!(
6114 !unique
6115 .events
6116 .events()
6117 .iter()
6118 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6119 );
6120
6121 let mut overlap = Harness::simple(4, 1, "o/r");
6122 let report = overlap
6123 .reconciler
6124 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
6125 .await;
6126 assert_eq!(report.started, 0);
6127 assert!(overlap.events.events().iter().any(|event| matches!(
6128 event,
6129 LifecycleEvent::RoutingRefused {
6130 reason: RoutingRefusal::OverlappingProfiles,
6131 count: 1,
6132 ..
6133 }
6134 )));
6135 assert!(
6136 RoutingRefusal::OverlappingProfiles
6137 .to_string()
6138 .contains("repair")
6139 );
6140 }
6141
6142 #[tokio::test]
6147 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
6148 let launcher = Arc::new(FakeLauncher::new());
6149 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
6150 let mut harness = Harness::build(
6151 host_with(4),
6152 Arc::clone(&launcher),
6153 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6154 Arc::new(InProcessAllocationLock::new()),
6155 );
6156
6157 let report = harness
6158 .reconciler
6159 .reconcile(&[policy(1, "acme/app", 4)])
6160 .await;
6161 assert_eq!(report.started, 0);
6162 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
6163
6164 let failures: Vec<&'static str> = harness
6165 .events
6166 .events()
6167 .into_iter()
6168 .filter_map(|event| match event {
6169 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6170 _ => None,
6171 })
6172 .collect();
6173 assert_eq!(failures, vec!["other"]);
6174 assert!(
6175 !failures[0].contains("ghp_"),
6176 "an event carried a `FailureReason::Other` detail verbatim"
6177 );
6178 }
6179
6180 #[tokio::test]
6185 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
6186 let launcher = Arc::new(FakeLauncher::new());
6187 let unfit = Arc::new(AtomicBool::new(true));
6188 let events = Arc::new(EventLog::new());
6189 let mut reconciler = Reconciler::new(
6190 host_with(4),
6191 ReconcilerPorts {
6192 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
6193 launcher: Arc::new(FitnessGatedLauncher::new(
6194 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6195 Arc::clone(&unfit),
6196 )),
6197 lock: Arc::new(InProcessAllocationLock::new()),
6198 directory: Arc::new(FakeDirectory::default()),
6199 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6200 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
6201 events: Arc::clone(&events) as Arc<dyn EventSink>,
6202 },
6203 );
6204 let policies = [policy(1, "acme/app", 4)];
6205
6206 let report = reconciler.reconcile(&policies).await;
6207 assert_eq!(report.started, 0);
6208 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
6209 let reasons: Vec<&'static str> = events
6210 .events()
6211 .into_iter()
6212 .filter_map(|event| match event {
6213 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6214 _ => None,
6215 })
6216 .collect();
6217 assert_eq!(reasons, ["host_unfit"]);
6218
6219 unfit.store(false, Ordering::Release);
6220 let report = reconciler.reconcile(&policies).await;
6221 assert_eq!(report.started, 2);
6222 assert_eq!(launcher.launches(), 2);
6223 }
6224
6225 #[tokio::test]
6226 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
6227 let launcher = Arc::new(FakeLauncher::new());
6228 let mut harness = Harness::build(
6229 host_with(4),
6230 Arc::clone(&launcher),
6231 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6232 Arc::new(HeldLock),
6233 );
6234
6235 let report = harness
6236 .reconciler
6237 .reconcile(&[policy(1, "acme/app", 4)])
6238 .await;
6239 assert_eq!(report.started, 0);
6240 assert_eq!(
6241 report.deferred, 3,
6242 "three runners were granted and none was created; `deferred` counts grants, \
6243 not policies -- it reported `1` when a policy that launched two of five and \
6244 then lost the lock had left three unstarted"
6245 );
6246 assert_eq!(launcher.launches(), 0);
6247 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
6248 assert!(
6249 harness
6250 .events
6251 .events()
6252 .iter()
6253 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
6254 "the event carries the same number the report does"
6255 );
6256 assert_eq!(
6257 report.allocations.len(),
6258 1,
6259 "the intent is still reported, so an operator staring at a queue sees why \
6260 nothing started"
6261 );
6262 }
6263
6264 #[tokio::test]
6265 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
6266 let mut harness = Harness::simple(8, 5, "acme/app");
6267
6268 let foreign = fixtures::policy()
6269 .id(PolicyId::from_u128(1))
6270 .repository("acme/app")
6271 .host(HostId::from_u128(0xdead))
6272 .autoscale("office", 4)
6273 .active()
6274 .build();
6275 let mut draining = policy(2, "acme/app", 4);
6276 draining.request_disable().unwrap();
6277
6278 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
6279
6280 assert_eq!(report.started, 0);
6281 assert_eq!(
6282 harness.demand.polls().len(),
6283 0,
6284 "neither can act on an answer"
6285 );
6286 let factors: Vec<LimitingFactor> = report
6287 .allocations
6288 .iter()
6289 .map(|a| a.limiting_factor)
6290 .collect();
6291 assert!(factors.contains(&LimitingFactor::ForeignHost));
6292 assert!(factors.contains(&LimitingFactor::NotReconciling));
6293 }
6294
6295 #[tokio::test]
6296 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
6297 #[derive(Debug)]
6300 struct BrokenDirectory;
6301
6302 #[async_trait::async_trait]
6303 impl RepositoryDirectory for BrokenDirectory {
6304 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
6305 Err(InventoryError::Cancelled)
6306 }
6307 }
6308
6309 let launcher = Arc::new(FakeLauncher::new());
6310 let events = Arc::new(EventLog::new());
6311 let mut reconciler = Reconciler::new(
6312 host_with(4),
6313 ReconcilerPorts {
6314 demand: Arc::new(FakeDemand::default()),
6315 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6316 lock: Arc::new(InProcessAllocationLock::new()),
6317 directory: Arc::new(BrokenDirectory),
6318 clock: Arc::new(FakeClock::default()),
6319 jitter: Arc::new(NoJitter),
6320 events: Arc::clone(&events) as Arc<dyn EventSink>,
6321 },
6322 );
6323
6324 let org_policy = fixtures::policy()
6325 .id(PolicyId::from_u128(1))
6326 .organization("acme")
6327 .autoscale("home", 4)
6328 .active()
6329 .build();
6330
6331 let report = reconciler.reconcile(&[org_policy]).await;
6332 assert_eq!(report.started, 0);
6333 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6334 assert_eq!(events.count_of("target_unreadable"), 1);
6335 }
6336
6337 fn every_event() -> Vec<LifecycleEvent> {
6349 let policy = PolicyId::from_u128(0xabcd_ef01);
6350 let attempt = AttemptId::from_u128(0x1234_5678);
6351 vec![
6352 LifecycleEvent::DemandObserved {
6353 policy,
6354 demand: u32::MAX,
6355 not_matched: u32::MAX,
6356 unresolvable: u32::MAX,
6357 complete: false,
6358 },
6359 LifecycleEvent::TargetUnreadable {
6360 policy,
6361 reason: unreadable_reason(&RefreshState::Failed {
6362 status: Some(500),
6363 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
6364 .into(),
6365 }),
6366 },
6367 LifecycleEvent::Allocated {
6368 policy,
6369 demand: u32::MAX,
6370 desired: u16::MAX,
6371 active_owned: 7,
6372 headroom: 9,
6373 to_start: 2,
6374 limiting: LimitingFactor::HostCapacity,
6375 },
6376 LifecycleEvent::MonitorOnlySkipped { policy },
6377 LifecycleEvent::RunnerStarted { policy, attempt },
6378 LifecycleEvent::RunnerStartFailed {
6379 policy,
6380 reason: failure_reason_kind(&FailureReason::Other(
6381 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6382 )),
6383 },
6384 LifecycleEvent::AllocationDeferred { policy, count: 4 },
6385 LifecycleEvent::AttemptsUnreadable {
6386 reason: failure_reason_kind(&FailureReason::Other(
6387 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6388 )),
6389 },
6390 LifecycleEvent::AttemptCleanFailed {
6391 policy,
6392 attempt,
6393 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
6394 },
6395 LifecycleEvent::AttemptCleaned {
6396 policy,
6397 attempt,
6398 outcome: OutcomeKind::IdleExit,
6399 },
6400 LifecycleEvent::ScaleDownRefused { policy, attempt },
6401 LifecycleEvent::PollScheduled {
6402 retry_in_ms: 900_000,
6403 pace: PollPace::RateLimited {
6404 kind: RateLimitKind::Primary,
6405 },
6406 },
6407 ]
6408 }
6409
6410 #[test]
6419 fn no_emitted_event_can_carry_a_credential() {
6420 use runner_manager_platform::logging::redact;
6421
6422 for event in every_event() {
6423 let displayed = event.to_string();
6424 assert_eq!(
6425 redact(&displayed),
6426 displayed,
6427 "`{}` renders something `d1`'s sink would have to redact",
6428 event.name()
6429 );
6430
6431 let debugged = format!("{event:?}");
6432 assert_eq!(
6433 redact(&debugged),
6434 debugged,
6435 "`{}`'s Debug renders something `d1`'s sink would have to redact",
6436 event.name()
6437 );
6438 }
6439
6440 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
6443 assert_ne!(
6444 redact(secret),
6445 secret,
6446 "the scan above proves nothing if `redact` no longer recognises a credential"
6447 );
6448 }
6449
6450 #[test]
6451 fn every_field_name_this_sink_emits_is_one_d1_allows() {
6452 use runner_manager_platform::logging::is_field_allowed;
6453
6454 for field in [
6458 "event",
6459 "policy_id",
6460 "attempt_id",
6461 "attempt_state",
6462 "demand",
6463 "desired",
6464 "capacity",
6465 "headroom",
6466 "count",
6467 "reason",
6468 "outcome",
6469 "mode",
6470 "lock",
6471 "retry_in_ms",
6472 "state",
6473 ] {
6474 assert!(
6475 is_field_allowed(field),
6476 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
6477 `[redacted]` in its place and the line would lose its meaning"
6478 );
6479 }
6480 }
6481
6482 #[test]
6483 fn every_failure_reason_has_a_credential_free_kind() {
6484 for reason in FailureReason::ALL {
6485 let kind = failure_reason_kind(&reason);
6486 assert!(!kind.is_empty());
6487 assert!(
6488 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
6489 "`{kind}` is not a fixed identifier"
6490 );
6491 }
6492 assert_eq!(
6493 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
6494 "other",
6495 "the detail of an `Other` reason never reaches an event"
6496 );
6497 let categories = [
6498 IsolationProviderFailure::NotInstalled,
6499 IsolationProviderFailure::PermissionDenied,
6500 IsolationProviderFailure::ImageUnavailableOrIncompatible,
6501 IsolationProviderFailure::DiskQuotaUnavailable,
6502 IsolationProviderFailure::RuntimeOperationFailed,
6503 ];
6504 let mut kinds = std::collections::BTreeSet::new();
6505 for category in categories {
6506 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
6507 assert_ne!(kind, "other");
6508 assert!(!kind.contains("secret"));
6509 kinds.insert(kind);
6510 }
6511 assert_eq!(kinds.len(), categories.len());
6512 }
6513
6514 fn production_half_of(source: &str) -> String {
6529 let production = source
6530 .split_once("\n#[cfg(test)]")
6531 .map_or(source, |(production, _)| production);
6532 production
6533 .lines()
6534 .filter(|line| !line.trim_start().starts_with("//"))
6535 .collect::<Vec<_>>()
6536 .join("\n")
6537 }
6538
6539 fn this_file_above_its_tests_without_prose() -> String {
6541 production_half_of(include_str!("reconcile.rs"))
6542 }
6543
6544 fn normalise_for_scan(text: &str) -> String {
6563 text.to_ascii_lowercase().replace(['_', ' '], "")
6564 }
6565
6566 const FORBIDDEN: &[&str] = &[
6576 concat!("fn ", "acquire", "_job"),
6577 concat!("fn ", "claim", "_job"),
6578 concat!("fn ", "lease", "_job"),
6579 concat!("fn ", "reserve", "_job"),
6580 concat!("fn ", "ack", "nowledge"),
6581 concat!("struct ", "Job", "Lease"),
6582 concat!("struct ", "Job", "Claim"),
6583 concat!("struct ", "Job", "Reservation"),
6584 ];
6585
6586 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
6587 let haystack = normalise_for_scan(source);
6588 FORBIDDEN
6589 .iter()
6590 .copied()
6591 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
6592 }
6593
6594 #[test]
6628 fn nothing_in_this_crate_reserves_or_claims_a_job() {
6629 const SOURCES: &[(&str, &str)] = &[
6630 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
6631 ("lib.rs", include_str!("lib.rs")),
6632 ("lifecycle.rs", include_str!("lifecycle.rs")),
6633 (
6634 "lifecycle/windows_hyperv.rs",
6635 include_str!("lifecycle/windows_hyperv.rs"),
6636 ),
6637 ("oci.rs", include_str!("oci.rs")),
6638 ("package.rs", include_str!("package.rs")),
6639 ("reconcile.rs", include_str!("reconcile.rs")),
6640 ];
6641
6642 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
6643 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
6644 let entry = entry.expect("a readable directory entry");
6645 let name = entry.file_name().to_string_lossy().into_owned();
6646 let joined = if prefix.is_empty() {
6649 name.clone()
6650 } else {
6651 format!("{prefix}/{name}")
6652 };
6653 if entry.path().is_dir() {
6654 walk(&entry.path(), &joined, found);
6655 } else if name.ends_with(".rs") {
6656 found.push(joined);
6657 }
6658 }
6659 }
6660
6661 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
6662 listed.sort_unstable();
6663 let mut on_disk = Vec::new();
6664 walk(
6665 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
6666 "",
6667 &mut on_disk,
6668 );
6669 on_disk.sort_unstable();
6670 assert_eq!(
6671 listed, on_disk,
6672 "a source file was added or removed; this scan claims to cover the whole crate \
6673 and a stale list makes that claim false"
6674 );
6675
6676 for (name, source) in SOURCES {
6677 assert_eq!(
6678 forbidden_shape_in(&production_half_of(source)),
6679 None,
6680 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
6681 REST, and a local lease coordinates this host with itself and with nothing \
6682 else. If an owner decision restored one, that decision belongs in this \
6683 module's documentation and in this test before it belongs in the code"
6684 );
6685 }
6686
6687 assert!(
6690 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
6691 "the scan above proves nothing if the needles no longer match"
6692 );
6693 }
6694
6695 #[test]
6723 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
6724 let production = this_file_above_its_tests_without_prose();
6725
6726 assert!(
6727 production.contains("DemandTally"),
6728 "the reconciliation loop must tally queued jobs against this policy's routing \
6729 labels. A production half that named `DemandTally` nowhere would mean the \
6730 label filtering had been removed, and a repository whose jobs target \
6731 `ubuntu-latest` would drive its policy toward `max_capacity` again"
6732 );
6733
6734 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
6735 assert!(
6736 !production.contains(second_implementation),
6737 "the reconciliation loop names `{second_implementation}`, which is the \
6738 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
6739 has already decided it. This module applies the predicate and does not \
6740 re-implement it; if an owner decision changed that, it belongs in this \
6741 module's documentation and in this test before it belongs in the code"
6742 );
6743 }
6744 }
6745
6746 #[test]
6754 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
6755 let policy = policy(1, "acme/app", 10);
6756 let reading = QueuedDemand::of(
6757 repo("acme/app"),
6758 [
6759 fixtures::queued_job(&[HOST_LABEL]),
6760 fixtures::queued_job(&[HOST_LABEL]),
6761 fixtures::queued_job(&["ubuntu-latest"]),
6762 fixtures::unresolvable_job(),
6763 ],
6764 );
6765
6766 let tally = demand_for(&policy, &reading);
6767
6768 assert_eq!(
6769 tally.demand(),
6770 2,
6771 "only the jobs whose required labels this policy carries are demand"
6772 );
6773 assert_eq!(
6774 tally.not_matched, 1,
6775 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
6776 runner that idles until it times out"
6777 );
6778 assert_eq!(
6779 tally.unresolvable.len(),
6780 1,
6781 "an unresolvable `runs-on` is never demand and never discarded"
6782 );
6783 }
6784
6785 #[test]
6788 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
6789 let mut per_repository = BTreeMap::new();
6790 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
6791 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
6792 let reading = QueuedDemand::new(per_repository);
6793
6794 let repository_policy = policy(1, "acme/left", 10);
6795 assert_eq!(
6796 demand_for(&repository_policy, &reading).demand(),
6797 3,
6798 "a repository target reads its own repository's queue and not the aggregate"
6799 );
6800
6801 let org_policy = fixtures::policy()
6802 .id(PolicyId::from_u128(2))
6803 .organization("acme")
6804 .autoscale("home", 10)
6805 .active()
6806 .build();
6807 assert_eq!(
6808 demand_for(&org_policy, &reading).demand(),
6809 7,
6810 "an organization policy serves any repository in its scope, so its demand is \
6811 the whole aggregate's"
6812 );
6813 }
6814
6815 #[test]
6816 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
6817 let host = host_with(2);
6822 let policy = policy(1, "acme/app", 5);
6823 let attempts: Vec<RunnerAttempt> = Vec::new();
6824 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
6825
6826 let allocation = allocator.allocate(&policy, u32::MAX);
6827 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
6828 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
6829 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
6830 }
6831}