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]
4116 async fn draining_parallel_workflow_starts_runners_for_the_remaining_queue() {
4117 let mut harness = Harness::simple(10, 8, "acme/app");
4118 let policy = policy(1, "acme/app", 10);
4119 let first = harness
4120 .reconciler
4121 .reconcile(std::slice::from_ref(&policy))
4122 .await;
4123 assert_eq!(first.started, 8);
4124
4125 let attempts = harness.launcher.snapshot();
4127 *harness.launcher.attempts.lock().unwrap() = attempts
4128 .iter()
4129 .take(3)
4130 .map(|attempt| attempt_in(AttemptState::Busy, attempt.id.as_uuid().as_u128(), 1))
4131 .collect();
4132 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4133 repo("acme/app"),
4134 jobs(2),
4135 )));
4136
4137 for poll in 0..3 {
4138 let report = harness
4139 .reconciler
4140 .reconcile(std::slice::from_ref(&policy))
4141 .await;
4142 assert_eq!(report.started, if poll == 0 { 2 } else { 0 });
4143 assert_eq!(harness.launcher.live_count(), 5);
4144 }
4145 }
4146
4147 #[tokio::test]
4158 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4159 let launcher = Arc::new(FakeLauncher::forgetful());
4160 let mut harness = Harness::build(
4161 host_with(64),
4162 Arc::clone(&launcher),
4163 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4164 Arc::new(InProcessAllocationLock::new()),
4165 );
4166
4167 let report = harness
4168 .reconciler
4169 .reconcile(&[policy(1, "acme/app", 8)])
4170 .await;
4171
4172 assert_eq!(
4173 report.started, 3,
4174 "the pass is bounded by the grant it was given, not by the attempt set catching \
4175 up with it"
4176 );
4177 assert_eq!(launcher.launches(), 3);
4178 assert!(
4179 launcher.snapshot().is_empty(),
4180 "the launcher never recorded anything, which is the whole point of the fixture"
4181 );
4182 }
4183
4184 #[tokio::test]
4196 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4197 let launcher = Arc::new(FakeLauncher::forgetful());
4198 let demand = Arc::new(FakeDemand::default());
4199 demand.set_for(
4200 &ScaleTarget::repository("acme/left").unwrap(),
4201 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4202 );
4203 demand.set_for(
4204 &ScaleTarget::repository("acme/right").unwrap(),
4205 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4206 );
4207 let mut harness = Harness::build(
4208 host_with(3),
4209 Arc::clone(&launcher),
4210 demand,
4211 Arc::new(InProcessAllocationLock::new()),
4212 );
4213
4214 let report = harness
4215 .reconciler
4216 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4217 .await;
4218
4219 assert_eq!(
4220 report.started, 3,
4221 "host_capacity is 3 and two policies each allowed 3 started {} runners \
4222 between them; the second policy's grant was computed from a set that did \
4223 not yet contain the first policy's launches",
4224 report.started
4225 );
4226 assert_eq!(launcher.launches(), 3);
4227 }
4228
4229 #[tokio::test]
4240 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
4241 let launcher = Arc::new(FakeLauncher::new());
4242 let mut harness = Harness::build(
4243 host_with(8),
4244 Arc::clone(&launcher),
4245 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
4246 Arc::new(InProcessAllocationLock::new()),
4247 );
4248 let policy = policy(1, "acme/app", 8);
4249
4250 launcher.fail_attempts(true);
4251 let unreadable = harness
4252 .reconciler
4253 .reconcile(std::slice::from_ref(&policy))
4254 .await;
4255
4256 assert_eq!(
4257 unreadable.started, 0,
4258 "nothing may be decided from a set that was not read"
4259 );
4260 assert_eq!(launcher.launches(), 0);
4261 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
4262 assert!(
4263 unreadable.allocations.is_empty(),
4264 "no allocation is reported either: there was no set to compute one from, and \
4265 an allocation of zero would claim a decision nobody made"
4266 );
4267 assert!(harness.events.count_of("attempts_unreadable") > 0);
4268
4269 launcher.fail_attempts(false);
4271 let readable = harness
4272 .reconciler
4273 .reconcile(std::slice::from_ref(&policy))
4274 .await;
4275 assert_eq!(
4276 readable.started, 4,
4277 "the difference between the two passes is only whether the set could be read"
4278 );
4279 assert_eq!(readable.attempts_unreadable, 0);
4280 }
4281
4282 #[tokio::test]
4292 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
4293 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4294 1,
4295 1,
4296 AttemptOutcome::ExitedIdleWithoutWork,
4297 )]));
4298 let mut harness = Harness::build(
4299 host_with(4),
4300 Arc::clone(&launcher),
4301 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4302 Arc::new(InProcessAllocationLock::new()),
4303 );
4304
4305 let report = harness
4306 .reconciler
4307 .reconcile(&[policy(1, "acme/app", 4)])
4308 .await;
4309
4310 assert_eq!(report.cleaned, 0);
4311 assert_eq!(report.clean_failures, 1);
4312 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
4313 assert_eq!(
4314 harness.events.count_of("attempt_cleaned"),
4315 0,
4316 "and it is not reported as cleaned"
4317 );
4318 assert_eq!(
4319 launcher.snapshot().len(),
4320 1,
4321 "the attempt is still there, so the retry is real -- what changed is that it \
4322 is no longer silent"
4323 );
4324
4325 let reasons: Vec<&'static str> = harness
4328 .events
4329 .events()
4330 .into_iter()
4331 .filter_map(|event| match event {
4332 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
4333 _ => None,
4334 })
4335 .collect();
4336 assert_eq!(reasons, vec!["other"]);
4337 }
4338
4339 #[tokio::test]
4342 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
4343 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
4344 1,
4345 1,
4346 AttemptOutcome::ExitedIdleWithoutWork,
4347 )]));
4348 let mut harness = Harness::build(
4349 host_with(4),
4350 Arc::clone(&launcher),
4351 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4352 Arc::new(InProcessAllocationLock::new()),
4353 );
4354 let policies = [policy(1, "acme/app", 4)];
4355 let start = harness.clock.now();
4356 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
4357 let mut failures_at = async |offset: Duration| {
4358 harness.clock.set(at(offset));
4359 harness.reconciler.reconcile(&policies).await.clean_failures
4360 };
4361
4362 assert_eq!(failures_at(Duration::ZERO).await, 1);
4363 assert_eq!(
4365 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
4366 0,
4367 "a failed cleanup was retried on the very next pass"
4368 );
4369 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
4370 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
4372 assert_eq!(
4373 failures_at(second - Duration::from_secs(1)).await,
4374 0,
4375 "the delay did not double after a second failure"
4376 );
4377 assert_eq!(failures_at(second).await, 1);
4378 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
4379
4380 let mut backoff = CleanBackoff::default();
4382 let id = AttemptId::from_u128(9);
4383 for _ in 0..40 {
4384 backoff.failed(id, start);
4385 }
4386 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
4387 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
4388 backoff.succeeded(id);
4389 assert!(
4390 backoff.is_due(id, start),
4391 "a cleaned attempt kept its backoff"
4392 );
4393 }
4394
4395 #[tokio::test]
4408 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
4409 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4410 attempt_in(AttemptState::Busy, 1, 1),
4411 concluded(2, 1, AttemptOutcome::CompletedJob),
4412 ]));
4413 let harness = Harness::build(
4414 host_with(4),
4415 Arc::clone(&launcher),
4416 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4417 Arc::new(InProcessAllocationLock::new()),
4418 );
4419 let policy = policy(1, "acme/app", 4);
4420
4421 launcher.fail_attempts(true);
4422 let blind = harness.reconciler.scale_down(&policy).await;
4423
4424 assert!(!blind.is_conclusive(), "the machine was never read");
4425 assert_ne!(
4426 blind,
4427 ScaleDownReport::default(),
4428 "a scale-down that could not see the host must not be equal to one that saw \
4429 an idle host; that equality is the whole finding"
4430 );
4431 assert_eq!(blind.removed, 0);
4432 assert_eq!(
4433 blind.refused_busy, 0,
4434 "and this zero means `unknown`, not `none`"
4435 );
4436 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
4437
4438 launcher.fail_attempts(false);
4440 let seeing = harness.reconciler.scale_down(&policy).await;
4441
4442 assert!(seeing.is_conclusive());
4443 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
4444 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
4445 assert_ne!(
4446 seeing, blind,
4447 "the difference between the two is only whether the set could be read"
4448 );
4449 }
4450
4451 #[tokio::test]
4459 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
4460 let mut warm = ScalePolicy::new(
4461 PolicyId::from_u128(1),
4462 ScaleTarget::repository("acme/app").unwrap(),
4463 1,
4464 fixtures::HOST_ID,
4465 PolicyMode::autoscale(
4466 fixtures::routing_labels("home"),
4467 2,
4468 NonZeroU16::new(5).expect("non-zero"),
4469 )
4470 .expect("min <= max"),
4471 CachePolicy::default(),
4472 );
4473 warm.activate().expect("pending -> active");
4474
4475 let mut harness = Harness::simple(8, 0, "acme/app");
4476 let report = harness.reconciler.reconcile(&[warm]).await;
4477
4478 assert_eq!(
4479 report.allocations[0].demand, 0,
4480 "GitHub reported no queued runs"
4481 );
4482 assert_eq!(
4483 report.allocations[0].desired, 2,
4484 "min_capacity raised the target above demand"
4485 );
4486 assert_eq!(
4487 report.allocations[0].limiting_factor,
4488 LimitingFactor::MinCapacity
4489 );
4490 assert_eq!(report.started, 2, "and two runners were actually started");
4491 assert_eq!(harness.launcher.live_count(), 2);
4492 }
4493
4494 #[tokio::test]
4499 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
4500 let mut harness = Harness::simple(100, 10, "acme/app");
4501 let report = harness
4502 .reconciler
4503 .reconcile(&[policy(1, "acme/app", 3)])
4504 .await;
4505
4506 assert_eq!(report.allocations[0].demand, 10);
4507 assert_eq!(
4508 report.allocations[0].desired, 3,
4509 "max_capacity beats demand"
4510 );
4511 assert_eq!(report.started, 3);
4512 assert_eq!(
4513 report.allocations[0].limiting_factor,
4514 LimitingFactor::MaxCapacity
4515 );
4516 }
4517
4518 #[tokio::test]
4519 async fn demand_below_min_capacity_starts_nothing_in_v1() {
4520 let mut harness = Harness::simple(8, 0, "acme/app");
4523 let report = harness
4524 .reconciler
4525 .reconcile(&[policy(1, "acme/app", 4)])
4526 .await;
4527
4528 assert_eq!(report.allocations[0].desired, 0);
4529 assert_eq!(report.started, 0);
4530 assert!(report.starts_nothing());
4531 }
4532
4533 #[tokio::test]
4534 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
4535 let policy = policy(1, "octo/repo", 1);
4536 let previous = attempt_in(AttemptState::Starting, 41, 1);
4537 let intent = ReplacementIntent {
4538 policy: policy.id,
4539 previous_attempt: previous.id,
4540 operation: "exit_before_acceptance_replacement",
4541 };
4542 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
4543 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
4544 let mut harness = Harness::build(
4545 host_with(1),
4546 Arc::clone(&launcher),
4547 demand,
4548 Arc::new(InProcessAllocationLock::new()),
4549 );
4550
4551 let report = harness.reconciler.reconcile(&[policy]).await;
4552
4553 assert_eq!(report.replacement_intents, 1);
4554 assert_eq!(report.started, 1);
4555 assert_eq!(launcher.launches(), 1);
4556 assert_eq!(launcher.live_count(), 1);
4557 }
4558
4559 #[tokio::test]
4560 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
4561 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4562 attempt_in(AttemptState::Busy, 1, 1),
4563 attempt_in(AttemptState::Busy, 2, 1),
4564 ]));
4565 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
4566 let mut harness = Harness::build(
4567 host_with(2),
4568 launcher,
4569 demand,
4570 Arc::new(InProcessAllocationLock::new()),
4571 );
4572
4573 let report = harness
4574 .reconciler
4575 .reconcile(&[policy(1, "acme/app", 2)])
4576 .await;
4577 assert_eq!(report.started, 0);
4578 assert_eq!(report.allocations[0].headroom_before, 0);
4579 assert_eq!(harness.launcher.launches(), 0);
4580 }
4581
4582 #[tokio::test]
4583 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
4584 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4587 attempt_in(AttemptState::Busy, 1, 99),
4588 attempt_in(AttemptState::Busy, 2, 99),
4589 attempt_in(AttemptState::Idle, 3, 99),
4590 attempt_in(AttemptState::Starting, 4, 99),
4591 ]));
4592 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
4593 let mut harness = Harness::build(
4594 host_with(6),
4595 launcher,
4596 demand,
4597 Arc::new(InProcessAllocationLock::new()),
4598 );
4599
4600 let report = harness
4601 .reconciler
4602 .reconcile(&[policy(1, "acme/app", 5)])
4603 .await;
4604 assert_eq!(
4605 report.allocations[0].desired, 5,
4606 "its own ceiling allows five"
4607 );
4608 assert_eq!(report.started, 2, "the host has two slots free");
4609 assert_eq!(
4610 report.allocations[0].limiting_factor,
4611 LimitingFactor::HostCapacity
4612 );
4613 assert_eq!(harness.launcher.live_count(), 6);
4614 }
4615
4616 #[tokio::test]
4617 async fn the_idle_host_assertion_holds() {
4618 let mut harness = Harness::simple(8, 0, "acme/app");
4621 let report = harness
4622 .reconciler
4623 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
4624 .await;
4625
4626 assert_eq!(report.started, 0);
4627 assert_eq!(harness.launcher.launches(), 0);
4628 assert!(harness.launcher.snapshot().is_empty());
4629 assert_eq!(
4630 harness
4631 .launcher
4632 .snapshot()
4633 .iter()
4634 .filter(|a| !a.is_terminal())
4635 .count(),
4636 0
4637 );
4638 }
4639
4640 #[tokio::test(flavor = "current_thread")]
4663 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
4664 let lock = Arc::new(CountingLock::new());
4665 let (launches, live) =
4666 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>).await;
4667
4668 assert_eq!(
4669 launches, 3,
4670 "the sum across policies must never exceed host_capacity, and each policy is \
4671 individually within its own max_capacity of 3"
4672 );
4673 assert_eq!(live, 3, "and no duplicate runner survived the race");
4674 assert_eq!(
4675 lock.peak(),
4676 1,
4677 "the allocation lock had one holder at a time; without that the read of the \
4678 headroom and the creation of the runtime are not atomic"
4679 );
4680 assert!(
4681 (3..=5).contains(&lock.acquisitions()),
4682 "the lock is taken before *each* runtime, not once per pass: three runtimes \
4683 means at least three holds, and at most one further hold per policy to \
4684 discover the host filled up underneath it. It was taken {} times",
4685 lock.acquisitions()
4686 );
4687 }
4688
4689 #[tokio::test(flavor = "current_thread")]
4696 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
4697 let (launches, _) =
4698 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>).await;
4699
4700 assert!(
4701 launches > 3,
4702 "with no serialisation both policies must be able to spend the same headroom; \
4703 they started {launches} runners on a host of 3. If this is ever 3, the \
4704 contention window closed and `two_policies_reconciling_concurrently_never_\
4705 exceed_host_capacity` has stopped proving anything"
4706 );
4707 }
4708
4709 async fn two_policies_concurrently(lock: Arc<dyn AllocationLock>) -> (usize, usize) {
4713 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
4714 let host = host_with(3);
4715
4716 let mut left = Harness::build(
4717 host.clone(),
4718 Arc::clone(&launcher),
4719 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
4720 Arc::clone(&lock),
4721 )
4722 .reconciler;
4723 let mut right = Harness::build(
4724 host,
4725 Arc::clone(&launcher),
4726 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
4727 Arc::clone(&lock),
4728 )
4729 .reconciler;
4730
4731 let a = policy(1, "acme/left", 3);
4732 let b = policy(2, "acme/right", 3);
4733
4734 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
4735 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
4736 let (_, _) = (left.await.unwrap(), right.await.unwrap());
4737
4738 (launcher.launches(), launcher.live_count())
4739 }
4740
4741 #[tokio::test]
4753 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
4754 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
4755 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
4756 let launcher = Arc::new(FakeLauncher::new());
4757 let events = Arc::new(EventLog::new());
4758
4759 let mut reconciler = Reconciler::new(
4760 host_with(10),
4761 ReconcilerPorts {
4762 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
4763 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4764 lock: Arc::new(InProcessAllocationLock::new()),
4765 directory: Arc::new(FakeDirectory::default()),
4766 clock: Arc::new(FakeClock::default()),
4767 jitter: Arc::new(NoJitter),
4768 events: Arc::clone(&events) as Arc<dyn EventSink>,
4769 },
4770 );
4771
4772 let monitor = fixtures::policy()
4773 .id(PolicyId::from_u128(1))
4774 .repository("acme/app")
4775 .monitor_only()
4776 .active()
4777 .build();
4778
4779 let report = reconciler.reconcile(&[monitor]).await;
4780
4781 assert_eq!(report.started, 0);
4782 assert_eq!(launcher.launches(), 0);
4783 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
4784 assert_eq!(
4785 report.demand_requests, 0,
4786 "a monitor-only policy spends nothing from the shared hourly ceiling"
4787 );
4788 assert!(
4789 gateway.gateway().calls().is_empty(),
4790 "a monitor-only policy issued a demand request: {:?}",
4791 gateway.gateway().calls()
4792 );
4793 assert_eq!(events.count_of("monitor_only_skipped"), 1);
4794 assert_eq!(
4795 events.count_of("demand_observed"),
4796 0,
4797 "and it contributed no demand"
4798 );
4799 }
4800
4801 #[tokio::test]
4819 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
4820 let lock = Arc::new(CountingLock::new());
4821 let launcher = Arc::new(FakeLauncher::new());
4822 let events = Arc::new(EventLog::new());
4823 let mut reconciler = Reconciler::new(
4824 host_with(4),
4825 ReconcilerPorts {
4826 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
4827 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4828 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
4829 directory: Arc::new(FakeDirectory::default()),
4830 clock: Arc::new(FakeClock::default()),
4831 jitter: Arc::new(NoJitter),
4832 events: Arc::clone(&events) as Arc<dyn EventSink>,
4833 },
4834 );
4835
4836 let watcher = fixtures::policy()
4837 .id(PolicyId::from_u128(2))
4838 .repository("acme/app")
4839 .monitor_only()
4840 .active()
4841 .build();
4842
4843 let report = reconciler
4844 .reconcile(&[policy(1, "acme/app", 4), watcher])
4845 .await;
4846
4847 assert_eq!(report.started, 2, "the autoscale policy is served normally");
4848 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
4849 assert_eq!(
4850 events.count_of("demand_observed"),
4851 1,
4852 "the demand observation belongs to the autoscale policy alone"
4853 );
4854 assert!(
4855 report
4856 .allocations
4857 .iter()
4858 .all(|a| a.policy_id == PolicyId::from_u128(1)),
4859 "a monitor-only policy was allocated for: {:?}",
4860 report.allocations
4861 );
4862 assert_eq!(
4863 lock.acquisitions(),
4864 2,
4865 "one hold per runtime created, and none on behalf of the monitor-only policy. \
4866 It was three before the budget was checked at the top of the loop rather than \
4867 after the re-read, which cost every policy a surplus hold to discover there \
4868 was nothing left to grant"
4869 );
4870 }
4871
4872 #[tokio::test]
4873 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
4874 let monitor = fixtures::monitor_only_policy();
4879 assert!(!monitor.owns_runners());
4880 assert_eq!(monitor.max_capacity(), None);
4881
4882 let host = host_with(10);
4883 let attempts: Vec<RunnerAttempt> = Vec::new();
4884 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
4885 let allocation = allocator.allocate(&monitor, 10_000);
4886 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
4887 assert_eq!(allocation.to_start, 0);
4888 assert_eq!(
4889 allocator.headroom(),
4890 10,
4891 "and it consumes no headroom, so an autoscale policy on the same host is \
4892 unaffected"
4893 );
4894 }
4895
4896 #[tokio::test]
4904 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
4905 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4906 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
4907 concluded(
4908 2,
4909 1,
4910 AttemptOutcome::failed(FailureReason::JitRequestFailed),
4911 ),
4912 concluded(3, 1, AttemptOutcome::CompletedJob),
4913 ]));
4914 let mut harness = Harness::build(
4915 host_with(4),
4916 Arc::clone(&launcher),
4917 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4918 Arc::new(InProcessAllocationLock::new()),
4919 );
4920
4921 let report = harness
4922 .reconciler
4923 .reconcile(&[policy(1, "acme/app", 4)])
4924 .await;
4925
4926 assert_eq!(report.cleaned, 3);
4927 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
4928 assert_eq!(
4929 report.failures, 1,
4930 "only the failed attempt is a failure; the idle exit and the completed job are \
4931 not"
4932 );
4933 assert_eq!(launcher.cleaned().len(), 3);
4934 assert!(launcher.snapshot().is_empty());
4935
4936 let cleaned: Vec<OutcomeKind> = harness
4937 .events
4938 .events()
4939 .into_iter()
4940 .filter_map(|event| match event {
4941 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
4942 _ => None,
4943 })
4944 .collect();
4945 assert!(cleaned.contains(&OutcomeKind::IdleExit));
4946 assert!(
4947 !OutcomeKind::IdleExit.is_failure(),
4948 "an idle exit rendered as a failure sends an operator hunting a fault that does \
4949 not exist"
4950 );
4951 }
4952
4953 #[tokio::test]
4956 async fn scale_down_removes_nothing_from_a_busy_attempt() {
4957 let busy = attempt_in(AttemptState::Busy, 1, 1);
4958 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4959 busy.clone(),
4960 attempt_in(AttemptState::Starting, 2, 1),
4961 concluded(3, 1, AttemptOutcome::CompletedJob),
4962 ]));
4963 let harness = Harness::build(
4964 host_with(4),
4965 Arc::clone(&launcher),
4966 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4967 Arc::new(InProcessAllocationLock::new()),
4968 );
4969
4970 let report = harness
4971 .reconciler
4972 .scale_down(&policy(1, "acme/app", 4))
4973 .await;
4974
4975 assert_eq!(report.refused_busy, 1);
4976 assert_eq!(
4977 report.retained, 1,
4978 "the `starting` attempt is not ended either"
4979 );
4980 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
4981
4982 let after = launcher.snapshot();
4983 let still_busy = after
4984 .iter()
4985 .find(|a| a.id == AttemptId::from_u128(1))
4986 .expect("the busy attempt is still there");
4987 assert_eq!(
4988 still_busy.state(),
4989 AttemptState::Busy,
4990 "scale-down removed nothing from a runner that is executing a job, and left it \
4991 busy"
4992 );
4993 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
4994
4995 let mut busy = busy;
4999 assert!(matches!(
5000 busy.clean(fixtures::created_at()),
5001 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
5002 ));
5003 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
5004 }
5005
5006 #[test]
5011 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
5012 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
5013 assert_eq!(RefreshInterval::MIN_SECS, 30);
5014 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
5015 assert!(
5016 RefreshInterval::from_secs(29).is_err(),
5017 "the floor is a rate-budget constraint, and a caller must not be able to write \
5018 a shorter interval at all"
5019 );
5020
5021 let mut schedule = PollSchedule::new(RefreshInterval::default());
5022 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
5023 assert_eq!(next.delay, Duration::from_secs(60));
5024 assert_eq!(next.pace, PollPace::Nominal);
5025
5026 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5027 assert_eq!(
5028 floored
5029 .next_poll(None, fixtures::created_at(), &NoJitter)
5030 .delay,
5031 Duration::from_secs(30)
5032 );
5033 }
5034
5035 #[test]
5039 fn a_rate_limit_increases_the_delay_and_names_itself() {
5040 let now = fixtures::created_at();
5041 let mut schedule = PollSchedule::new(RefreshInterval::default());
5042
5043 let limited = RefreshState::RateLimited(RateLimited {
5044 kind: RateLimitKind::Secondary,
5045 retry_after: Some(Duration::from_secs(300)),
5046 remaining: None,
5047 reset_unix_secs: None,
5048 });
5049 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5050
5051 assert_eq!(next.delay, Duration::from_secs(300));
5052 assert_eq!(
5053 next.pace,
5054 PollPace::RateLimited {
5055 kind: RateLimitKind::Secondary
5056 },
5057 "the increase is reported, never hidden"
5058 );
5059 assert!(next.pace.is_throttled());
5060 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
5061 }
5062
5063 #[test]
5066 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
5067 let now = fixtures::created_at();
5068 let mut schedule = PollSchedule::new(RefreshInterval::default());
5069
5070 let limited = RefreshState::RateLimited(RateLimited {
5071 kind: RateLimitKind::Primary,
5072 retry_after: Some(Duration::from_secs(300)),
5073 remaining: Some(0),
5074 reset_unix_secs: None,
5075 });
5076
5077 for _ in 0..5 {
5080 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
5081 assert_eq!(
5082 next.delay,
5083 Duration::from_secs(300),
5084 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
5085 have compounded on every successive retry"
5086 );
5087 }
5088
5089 let brief = RefreshState::RateLimited(RateLimited {
5092 kind: RateLimitKind::Secondary,
5093 retry_after: Some(Duration::from_secs(5)),
5094 remaining: None,
5095 reset_unix_secs: None,
5096 });
5097 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
5098 assert_eq!(
5099 next.delay,
5100 Duration::from_secs(60),
5101 "a short `retry-after` may not drop the loop below its own interval"
5102 );
5103 assert!(next.delay >= PollSchedule::floor());
5104 }
5105
5106 #[test]
5107 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
5108 let now = fixtures::created_at();
5109 let states = [
5110 None,
5111 Some(RefreshState::Offline),
5112 Some(RefreshState::RateLimited(RateLimited {
5113 kind: RateLimitKind::Secondary,
5114 retry_after: Some(Duration::from_secs(1)),
5115 remaining: None,
5116 reset_unix_secs: None,
5117 })),
5118 Some(RefreshState::LockedOut {
5119 retry_after: Duration::from_secs(1),
5120 }),
5121 Some(RefreshState::Unauthorized),
5122 Some(RefreshState::Forbidden { message: None }),
5123 Some(RefreshState::Failed {
5124 status: Some(500),
5125 message: "server error".into(),
5126 }),
5127 Some(RefreshState::Cancelled),
5128 ];
5129
5130 for state in &states {
5131 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
5132 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
5133 assert!(
5134 next.delay >= PollSchedule::floor(),
5135 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
5136 next.delay.as_millis()
5137 );
5138 }
5139 }
5140
5141 #[test]
5142 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
5143 let now = fixtures::created_at();
5144 let mut schedule = PollSchedule::new(RefreshInterval::default());
5145
5146 let mut previous = Duration::ZERO;
5148 for consecutive in 1..=6_u32 {
5149 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5150 assert_eq!(next.pace, PollPace::Offline { consecutive });
5151 assert!(
5152 next.delay >= previous,
5153 "the back-off must not shrink while the outage continues"
5154 );
5155 assert!(next.delay >= Duration::from_secs(60));
5156 previous = next.delay;
5157 }
5158 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
5159
5160 let mut jittered = PollSchedule::new(RefreshInterval::default());
5163 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
5164 let mut jittered = PollSchedule::new(RefreshInterval::default());
5165 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
5166 assert!(full.delay > none.delay);
5167 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
5168
5169 assert_eq!(schedule.consecutive_offline(), 6);
5171 let recovered = schedule.next_poll(None, now, &NoJitter);
5172 assert_eq!(recovered.pace, PollPace::Nominal);
5173 assert_eq!(recovered.delay, Duration::from_secs(60));
5174 assert_eq!(schedule.consecutive_offline(), 0);
5175 }
5176
5177 #[test]
5178 fn the_offline_state_states_the_twenty_four_hour_bound() {
5179 assert_eq!(
5180 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
5181 Duration::from_secs(24 * 60 * 60)
5182 );
5183
5184 let brief = OfflineState::new(1, Duration::from_secs(120));
5185 let rendered = brief.to_string();
5186 assert!(rendered.contains("24 hours"), "{rendered}");
5187 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
5188 assert!(!brief.has_outlasted_the_queue());
5189
5190 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
5191 assert!(long.has_outlasted_the_queue());
5192 assert!(
5193 long.to_string().contains("queued work has been lost"),
5194 "{long}"
5195 );
5196
5197 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
5199 }
5200
5201 fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
5206 RateLimitSnapshot {
5207 limit: 5_000,
5208 remaining,
5209 used: Some(5_000 - remaining),
5210 reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
5211 observed_at: now,
5212 }
5213 }
5214
5215 fn quiet() -> PollConditions {
5216 PollConditions {
5217 quiet: true,
5218 quota: None,
5219 requests_per_pass: 0,
5220 }
5221 }
5222
5223 #[test]
5224 fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
5225 let now = fixtures::created_at();
5226 let mut schedule =
5227 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5228
5229 let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
5230 assert_eq!(idle.delay, Duration::from_secs(10));
5231 assert_eq!(idle.pace, PollPace::Idle);
5232 assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
5233
5234 let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
5235 assert_eq!(busy.delay, Duration::from_secs(60));
5236 assert_eq!(busy.pace, PollPace::Nominal);
5237 }
5238
5239 #[test]
5240 fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
5241 let now = fixtures::created_at();
5242 let mut schedule = PollSchedule::new(RefreshInterval::default());
5243 assert_eq!(
5244 schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
5245 Duration::from_secs(60)
5246 );
5247 }
5248
5249 #[test]
5250 fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
5251 let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
5252 .with_idle(Duration::from_secs(120));
5253 assert_eq!(
5254 schedule.idle(),
5255 Duration::from_secs(30),
5256 "an idle interval above the active one would make quiet targets slower to react"
5257 );
5258 let schedule =
5259 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
5260 assert_eq!(schedule.idle(), PollSchedule::idle_floor());
5261 }
5262
5263 #[test]
5266 fn a_low_quota_stretches_polling_and_wins_over_idle() {
5267 let now = fixtures::created_at();
5268 let mut schedule =
5269 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5270
5271 let healthy = schedule.next_poll_with(
5272 None,
5273 PollConditions {
5274 quiet: true,
5275 quota: Some(quota(1_000, 3_000, now)),
5276 requests_per_pass: 0,
5277 },
5278 now,
5279 &NoJitter,
5280 );
5281 assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
5282
5283 let low = schedule.next_poll_with(
5284 None,
5285 PollConditions {
5286 quiet: true,
5287 quota: Some(quota(999, 3_000, now)),
5288 requests_per_pass: 0,
5289 },
5290 now,
5291 &NoJitter,
5292 );
5293 assert_eq!(low.delay, Duration::from_secs(120));
5294 assert_eq!(
5295 low.pace,
5296 PollPace::Stretched {
5297 pressure: PrimaryPressure::Low
5298 }
5299 );
5300 assert!(low.pace.is_throttled());
5301 assert_eq!(low.pace.as_str(), "stretched_low_budget");
5302
5303 let critical = schedule.next_poll_with(
5304 None,
5305 PollConditions {
5306 quiet: false,
5307 quota: Some(quota(100, 3_000, now)),
5308 requests_per_pass: 0,
5309 },
5310 now,
5311 &NoJitter,
5312 );
5313 assert_eq!(critical.delay, Duration::from_secs(240));
5314 assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
5315 }
5316
5317 #[test]
5318 fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
5319 let now = fixtures::created_at();
5320 let mut schedule = PollSchedule::new(RefreshInterval::default());
5321
5322 let soon = schedule.next_poll_with(
5323 None,
5324 PollConditions {
5325 quiet: false,
5326 quota: Some(quota(10, 90, now)),
5327 requests_per_pass: 0,
5328 },
5329 now,
5330 &NoJitter,
5331 );
5332 assert_eq!(
5333 soon.delay,
5334 Duration::from_secs(90),
5335 "the bucket refills in 90 s; waiting 240 s buys nothing"
5336 );
5337
5338 let imminent = schedule.next_poll_with(
5339 None,
5340 PollConditions {
5341 quiet: false,
5342 quota: Some(quota(10, 5, now)),
5343 requests_per_pass: 0,
5344 },
5345 now,
5346 &NoJitter,
5347 );
5348 assert_eq!(imminent.delay, Duration::from_secs(60));
5349
5350 let expired = schedule.next_poll_with(
5351 None,
5352 PollConditions {
5353 quiet: true,
5354 quota: Some(quota(10, -5, now)),
5355 requests_per_pass: 0,
5356 },
5357 now,
5358 &NoJitter,
5359 );
5360 assert_eq!(
5361 expired.pace,
5362 PollPace::Nominal,
5363 "a reading from a window that has reset says nothing; the schedule here had no \
5364 idle interval, so it is the active one"
5365 );
5366 }
5367
5368 #[test]
5369 fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
5370 let now = fixtures::created_at();
5371 let mut schedule =
5372 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
5373 let small = schedule.next_poll_with(
5374 None,
5375 PollConditions {
5376 quiet: true,
5377 quota: None,
5378 requests_per_pass: 40,
5379 },
5380 now,
5381 &NoJitter,
5382 );
5383 assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
5384 let organization = schedule.next_poll_with(
5385 None,
5386 PollConditions {
5387 quiet: true,
5388 quota: None,
5389 requests_per_pass: 400,
5390 },
5391 now,
5392 &NoJitter,
5393 );
5394 assert_eq!(
5395 organization.delay,
5396 Duration::from_secs(80),
5397 "400 requests a pass at 300 a minute is one pass every 80 s"
5398 );
5399 assert_eq!(organization.pace, PollPace::Idle);
5400 }
5401
5402 #[test]
5403 fn a_rate_limit_wait_is_jittered_upward_only() {
5404 let now = fixtures::created_at();
5405 let mut schedule = PollSchedule::new(RefreshInterval::default());
5406 let limited = RefreshState::RateLimited(RateLimited {
5407 kind: RateLimitKind::Secondary,
5408 retry_after: Some(Duration::from_secs(300)),
5409 remaining: None,
5410 reset_unix_secs: None,
5411 });
5412 assert_eq!(
5413 schedule
5414 .next_poll(Some(&limited), now, &FixedJitter(0.5))
5415 .delay,
5416 Duration::from_secs(315),
5417 "every loop shares one quiet period; without jitter they all wake together"
5418 );
5419 assert_eq!(
5420 schedule.next_poll(Some(&limited), now, &NoJitter).delay,
5421 Duration::from_secs(300),
5422 "never shorter than GitHub asked"
5423 );
5424 }
5425
5426 #[tokio::test]
5427 async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
5428 let launcher =
5431 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
5432 let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
5433 let mut harness = Harness::build(
5434 host_with(1),
5435 launcher,
5436 demand,
5437 Arc::new(InProcessAllocationLock::new()),
5438 );
5439 harness
5440 .reconciler
5441 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5442 let policy = policy(1, "acme/app", 4);
5443
5444 let report = harness
5445 .reconciler
5446 .reconcile(std::slice::from_ref(&policy))
5447 .await;
5448
5449 assert_eq!(report.started, 0, "the host is full");
5450 assert_eq!(
5451 report.next_poll.pace,
5452 PollPace::Nominal,
5453 "matched demand it could not serve is work waiting, not idle"
5454 );
5455 }
5456
5457 #[tokio::test]
5458 async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
5459 let mut harness = Harness::simple(4, 0, "acme/app");
5460 harness
5461 .reconciler
5462 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5463 let policy = policy(1, "acme/app", 4);
5464
5465 let report = harness
5466 .reconciler
5467 .reconcile(std::slice::from_ref(&policy))
5468 .await;
5469
5470 assert_eq!(report.next_poll.pace, PollPace::Idle);
5471 assert_eq!(report.next_poll.delay, Duration::from_secs(10));
5472 }
5473
5474 #[tokio::test]
5475 async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
5476 let mut harness = Harness::simple(4, 0, "acme/app");
5477 harness
5478 .reconciler
5479 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5480 harness.demand.set(PollOutcome::Ready(
5481 QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
5482 ));
5483 let policy = policy(1, "acme/app", 4);
5484
5485 let report = harness
5486 .reconciler
5487 .reconcile(std::slice::from_ref(&policy))
5488 .await;
5489
5490 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5491 assert_eq!(report.next_poll.delay, Duration::from_secs(60));
5492 }
5493
5494 #[tokio::test]
5495 async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
5496 let launcher =
5497 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5498 let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
5499 let mut harness = Harness::build(
5500 host_with(4),
5501 launcher,
5502 demand,
5503 Arc::new(InProcessAllocationLock::new()),
5504 );
5505 harness
5506 .reconciler
5507 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5508 let policy = policy(1, "acme/app", 4);
5509
5510 let report = harness
5511 .reconciler
5512 .reconcile(std::slice::from_ref(&policy))
5513 .await;
5514
5515 assert_eq!(
5516 report.next_poll.pace,
5517 PollPace::Nominal,
5518 "a job is running: its follow-on work is what the active interval watches for"
5519 );
5520 }
5521
5522 #[tokio::test]
5523 async fn a_pass_that_starts_a_runner_is_not_idle() {
5524 let mut harness = Harness::simple(4, 2, "acme/app");
5525 harness
5526 .reconciler
5527 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5528 let policy = policy(1, "acme/app", 4);
5529
5530 let report = harness
5531 .reconciler
5532 .reconcile(std::slice::from_ref(&policy))
5533 .await;
5534
5535 assert!(report.started > 0);
5536 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5537 }
5538
5539 #[tokio::test]
5540 async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
5541 let mut harness = Harness::simple(4, 0, "acme/app");
5542 harness
5543 .reconciler
5544 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5545 let now = harness.clock.now();
5546 harness.demand.set(PollOutcome::Ready(
5547 QueuedDemand::of(repo("acme/app"), Vec::new())
5548 .with_rate_limit(Some(quota(200, 1_800, now))),
5549 ));
5550 let policy = policy(1, "acme/app", 4);
5551
5552 let report = harness
5553 .reconciler
5554 .reconcile(std::slice::from_ref(&policy))
5555 .await;
5556
5557 assert_eq!(
5558 report.next_poll.pace,
5559 PollPace::Stretched {
5560 pressure: PrimaryPressure::Critical
5561 }
5562 );
5563 assert_eq!(report.next_poll.delay, Duration::from_secs(240));
5564 }
5565
5566 #[tokio::test]
5575 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
5576 let live = vec![
5577 attempt_in(AttemptState::Busy, 1, 1),
5578 attempt_in(AttemptState::Starting, 2, 1),
5579 ];
5580 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
5581 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5582 let mut harness = Harness::build(
5583 host_with(8),
5584 Arc::clone(&launcher),
5585 Arc::clone(&demand),
5586 Arc::new(InProcessAllocationLock::new()),
5587 );
5588 let policy = policy(1, "acme/app", 8);
5589
5590 let report = harness
5591 .reconciler
5592 .reconcile(std::slice::from_ref(&policy))
5593 .await;
5594
5595 assert!(report.is_offline());
5596 assert_eq!(report.started, 0, "no new runner during an outage");
5597 assert_eq!(launcher.launches(), 0);
5598 assert_eq!(
5599 launcher.snapshot(),
5600 live,
5601 "existing runner processes are retained, untouched"
5602 );
5603 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
5604 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5605 assert!(report.next_poll.delay >= Duration::from_secs(60));
5606 let offline = report.offline_state().expect("an offline state to display");
5607 assert!(offline.to_string().contains("24 hours"));
5608
5609 demand.set(PollOutcome::Ready(QueuedDemand::of(
5612 repo("acme/app"),
5613 jobs(2),
5614 )));
5615 let recovered = harness.reconciler.reconcile(&[policy]).await;
5616
5617 assert!(!recovered.is_offline());
5618 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
5619 assert_eq!(
5620 recovered.started, 1,
5621 "two queued jobs and one starting runner leave one job uncovered"
5622 );
5623 assert_eq!(recovered.allocations[0].active_owned, 2);
5624 assert_eq!(launcher.live_count(), 3);
5625 }
5626
5627 #[tokio::test]
5637 async fn one_offline_target_does_not_stop_a_reachable_one() {
5638 let mut harness = Harness::simple(8, 0, "acme/app");
5639 harness.demand.set_for(
5640 &ScaleTarget::repository("acme/app").unwrap(),
5641 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
5642 );
5643 harness.demand.set_for(
5644 &ScaleTarget::repository("acme/broken").unwrap(),
5645 PollOutcome::Failed(RefreshState::Offline),
5646 );
5647
5648 let report = harness
5649 .reconciler
5650 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
5651 .await;
5652
5653 assert_eq!(
5654 report.started, 2,
5655 "the reachable target was served; an unreachable sibling repository must not \
5656 idle the host"
5657 );
5658 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
5659 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
5660
5661 assert!(report.is_offline());
5663 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
5664 }
5665
5666 #[tokio::test]
5681 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
5682 let clock = Arc::new(FakeClock::default());
5683 let launcher = Arc::new(FakeLauncher::new());
5684 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
5685 let mut reconciler = Reconciler::new(
5686 host_with(4),
5687 ReconcilerPorts {
5688 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
5689 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5690 lock: Arc::new(InProcessAllocationLock::new()),
5691 directory: Arc::new(FakeDirectory::default()),
5692 clock: Arc::clone(&clock) as Arc<dyn Clock>,
5693 jitter: Arc::new(NoJitter),
5694 events: Arc::new(NoEvents),
5695 },
5696 );
5697 let policy = policy(1, "acme/app", 4);
5698
5699 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5701 let state = first.offline_state().expect("an offline state");
5702 assert!(!state.has_outlasted_the_queue());
5703 assert!(
5704 state
5705 .to_string()
5706 .contains("an outage longer than that loses"),
5707 "{state}"
5708 );
5709
5710 clock.advance_secs(24 * 60 * 60 + 60);
5712 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5713 let state = later.offline_state().expect("an offline state");
5714 assert!(state.has_outlasted_the_queue());
5715 assert!(
5716 state.to_string().contains("queued work has been lost"),
5717 "{state}"
5718 );
5719 assert_eq!(launcher.launches(), 0, "and still nothing was started");
5720
5721 demand.set(PollOutcome::Ready(QueuedDemand::of(
5724 repo("acme/app"),
5725 jobs(0),
5726 )));
5727 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5728 assert!(recovered.offline_state().is_none());
5729 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
5730
5731 demand.set(PollOutcome::Failed(RefreshState::Offline));
5732 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
5733 assert!(
5734 !again
5735 .offline_state()
5736 .expect("an offline state")
5737 .has_outlasted_the_queue(),
5738 "a new outage must not inherit the age of the one before it"
5739 );
5740 }
5741
5742 #[tokio::test]
5758 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
5759 let root = tempfile::tempdir().expect("a temporary directory");
5760 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
5761 root.path(),
5762 ));
5763 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
5764
5765 let held = lock.acquire().await.expect("an uncontended lock is free");
5766 assert!(
5767 matches!(lock.acquire().await, Err(AllocationLockBusy)),
5768 "a second holder was admitted; on Unix the lock is per open file description \
5769 and on Windows the share mode denies write, so this must be refused even \
5770 from inside the same process"
5771 );
5772
5773 drop(held);
5774 let regained = lock.acquire().await;
5775 assert!(
5776 regained.is_ok(),
5777 "dropping the guard is the only release there is, so a guard that does not \
5778 hold the `HostLock` leaves it held forever"
5779 );
5780 }
5781
5782 #[test]
5783 fn tee_events_reaches_both_sinks() {
5784 let left = Arc::new(EventLog::new());
5788 let right = Arc::new(EventLog::new());
5789 let tee = TeeEvents(
5790 Arc::clone(&left) as Arc<dyn EventSink>,
5791 Arc::clone(&right) as Arc<dyn EventSink>,
5792 );
5793
5794 tee.emit(LifecycleEvent::MonitorOnlySkipped {
5795 policy: PolicyId::from_u128(1),
5796 });
5797
5798 assert_eq!(left.count_of("monitor_only_skipped"), 1);
5799 assert_eq!(right.count_of("monitor_only_skipped"), 1);
5800 }
5801
5802 #[tokio::test]
5807 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
5808 let clock = Arc::new(FakeClock::default());
5809 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
5810 let cache = RepositoryCache::new(
5811 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
5812 Arc::clone(&clock) as Arc<dyn Clock>,
5813 RefreshInterval::default(),
5814 );
5815 let target = ScaleTarget::organization("acme").unwrap();
5816
5817 assert_eq!(
5818 cache.ttl(),
5819 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
5820 );
5821
5822 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
5824 let scope = cache.scope_for(&target).await.unwrap();
5825 assert_eq!(scope.repositories().len(), 2);
5826 clock.advance_secs(60);
5827 }
5828 assert_eq!(
5829 directory.calls(),
5830 1,
5831 "re-listing an organization at demand-poll frequency is what exhausts the \
5832 shared request budget"
5833 );
5834 assert_eq!(cache.lookups(), 1);
5835
5836 cache.scope_for(&target).await.unwrap();
5838 assert_eq!(directory.calls(), 2);
5839 }
5840
5841 #[tokio::test]
5842 async fn a_repository_target_never_consults_the_directory() {
5843 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
5844 let cache = RepositoryCache::new(
5845 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
5846 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
5847 RefreshInterval::default(),
5848 );
5849 let target = ScaleTarget::repository("acme/app").unwrap();
5850
5851 let scope = cache.scope_for(&target).await.unwrap();
5852 assert_eq!(scope.repositories(), &[repo("acme/app")]);
5853 assert_eq!(directory.calls(), 0);
5854 }
5855
5856 #[tokio::test]
5857 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
5858 let mut harness = Harness::simple(8, 4, "o/r");
5862 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5863 repo("o/r"),
5864 [
5865 fixtures::queued_job(&[HOST_LABEL]),
5866 fixtures::queued_job(&[HOST_LABEL]),
5867 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
5868 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
5869 ],
5870 )));
5871 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5872 isolated.activate().unwrap();
5873 let report = harness
5874 .reconciler
5875 .reconcile(&[policy(1, "o/r", 2), isolated])
5876 .await;
5877
5878 assert_eq!(harness.demand.polls().len(), 1);
5879 assert_eq!(report.targets_read, 1, "profiles share one target reading");
5880 assert_eq!(
5881 report.demand_requests,
5882 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
5883 "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"
5884 );
5885 assert_eq!(
5886 report.started, 4,
5887 "native and isolated profiles each get two"
5888 );
5889 }
5890
5891 #[tokio::test]
5892 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
5893 use runner_manager_domain::model::{Arch, HostLabel, Os};
5894 use runner_manager_domain::policy::RoutingLabels;
5895 let mut harness = Harness::build(
5896 fixtures::host()
5897 .os(Os::MacOs)
5898 .architecture(Arch::Arm64)
5899 .capacity(10)
5900 .build(),
5901 Arc::new(FakeLauncher::new()),
5902 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
5903 Arc::new(InProcessAllocationLock::new()),
5904 );
5905 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5906 repo("o/r"),
5907 [
5908 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
5909 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
5910 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
5911 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
5912 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
5913 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
5914 ],
5915 )));
5916 let mut local = fixtures::policy()
5917 .id(PolicyId::from_u128(1))
5918 .repository("o/r")
5919 .autoscale("macmini", 10)
5920 .routing_labels(RoutingLabels::derive(
5921 &HostLabel::new("macmini").unwrap(),
5922 Os::MacOs,
5923 Arch::Arm64,
5924 ))
5925 .active()
5926 .build();
5927 for label in ["self-hosted", "macos", "arm64", "extra"] {
5928 local.add_routing_label(fixtures::label(label)).unwrap();
5929 }
5930 let mut foreign = local.to_persisted();
5931 foreign.id = PolicyId::from_u128(2);
5932 foreign.host_id = HostId::from_u128(0xdead);
5933 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
5934 let report = harness.reconciler.reconcile(&[local, foreign]).await;
5935 assert_eq!(report.started, 2);
5936 assert_eq!(report.targets_read, 1);
5937 assert!(harness.events.events().iter().any(|event| matches!(
5938 event,
5939 LifecycleEvent::DemandObserved {
5940 demand: 2,
5941 not_matched: 4,
5942 ..
5943 }
5944 )));
5945 assert!(
5946 !harness
5947 .events
5948 .events()
5949 .iter()
5950 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
5951 );
5952 }
5953
5954 #[tokio::test]
5955 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
5956 use runner_manager_domain::execution::{
5957 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
5958 };
5959 let mut native = policy(1, "o/r", 2);
5960 native
5961 .add_routing_label(fixtures::label("self-hosted"))
5962 .unwrap();
5963 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5964 isolated
5965 .add_routing_label(fixtures::label("self-hosted"))
5966 .unwrap();
5967 isolated
5968 .set_execution_policy(ExecutionPolicy::Isolated {
5969 backend: Backend::Oci,
5970 image: ImageReference::new(format!(
5971 "registry.example/runner@sha256:{}",
5972 "a".repeat(64)
5973 ))
5974 .unwrap(),
5975 resources: ResourceLimits {
5976 cpu_millis: 1000,
5977 memory_mib: 512,
5978 disk_mib: 1024,
5979 },
5980 })
5981 .unwrap();
5982 assert!(!isolated.may_start_runners());
5983 let mut active_isolated = isolated.clone();
5984 active_isolated.activate().unwrap();
5985 for profiles in [
5986 [native.clone(), isolated.clone()],
5987 [isolated.clone(), native.clone()],
5988 [native.clone(), active_isolated.clone()],
5989 [active_isolated, native.clone()],
5990 ] {
5991 let mut harness = Harness::simple(4, 0, "o/r");
5992 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5993 repo("o/r"),
5994 [fixtures::queued_job(&["SELF-HOSTED"])],
5995 )));
5996 let report = harness.reconciler.reconcile(&profiles).await;
5997 assert_eq!(report.started, 0);
5998 assert!(harness.events.events().iter().any(|event| matches!(
5999 event,
6000 LifecycleEvent::RoutingRefused {
6001 reason: RoutingRefusal::OverlappingProfiles,
6002 count: 1,
6003 ..
6004 }
6005 )));
6006 }
6007 let mut harness = Harness::simple(4, 0, "o/r");
6010 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6011 repo("o/r"),
6012 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
6013 )));
6014 assert_eq!(
6015 harness
6016 .reconciler
6017 .reconcile(&[native, isolated])
6018 .await
6019 .started,
6020 0
6021 );
6022 }
6023
6024 #[tokio::test]
6025 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
6026 let mut harness = Harness::simple(2, 0, "o/r");
6027 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6028 repo("o/r"),
6029 [
6030 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
6031 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
6032 fixtures::queued_job(&["gpu"]),
6033 fixtures::unresolvable_job(),
6034 ],
6035 )));
6036 let mut native = policy(1, "o/r", 2);
6037 native.add_routing_label(fixtures::label("gpu")).unwrap();
6038 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6039 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
6040 isolated.activate().unwrap();
6041
6042 let report = harness.reconciler.reconcile(&[native, isolated]).await;
6043 assert_eq!(harness.demand.polls().len(), 1);
6044 assert_eq!(report.targets_read, 1);
6045 assert_eq!(report.started, 2);
6046 assert_eq!(report.allocations.len(), 2);
6047 assert!(
6048 report
6049 .allocations
6050 .iter()
6051 .all(|allocation| allocation.to_start == 1)
6052 );
6053 assert!(harness.events.events().iter().any(|event| matches!(
6054 event,
6055 LifecycleEvent::RoutingRefused {
6056 reason: RoutingRefusal::OverlappingProfiles,
6057 count: 1,
6058 ..
6059 }
6060 )));
6061 assert_eq!(
6062 harness
6063 .events
6064 .events()
6065 .iter()
6066 .filter(|event| matches!(
6067 event,
6068 LifecycleEvent::DemandObserved {
6069 unresolvable: 1,
6070 ..
6071 }
6072 ))
6073 .count(),
6074 2,
6075 "an expression stays unresolvable for both profiles"
6076 );
6077 }
6078
6079 #[tokio::test]
6080 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
6081 let mut harness = Harness::simple(4, 0, "o/r");
6082 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6083 repo("o/r"),
6084 [fixtures::queued_job(&[
6085 HOST_LABEL,
6086 "rm-home-win-x64-py-isolated",
6087 ])],
6088 )));
6089 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6090 isolated.activate().unwrap();
6091 let report = harness
6092 .reconciler
6093 .reconcile(&[policy(1, "o/r", 2), isolated])
6094 .await;
6095 assert_eq!(report.started, 0);
6096 assert_eq!(
6097 harness
6098 .events
6099 .events()
6100 .iter()
6101 .filter(|event| matches!(
6102 event,
6103 LifecycleEvent::DemandObserved {
6104 demand: 0,
6105 not_matched: 1,
6106 ..
6107 }
6108 ))
6109 .count(),
6110 2
6111 );
6112
6113 let mut superset = policy(1, "o/r", 2);
6117 assert!(
6118 superset
6119 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
6120 .unwrap()
6121 );
6122 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6123 sibling.activate().unwrap();
6124 let mut unique = Harness::simple(4, 0, "o/r");
6125 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
6126 repo("o/r"),
6127 [fixtures::queued_job(&[
6128 HOST_LABEL,
6129 "rm-home-win-x64-py-isolated",
6130 ])],
6131 )));
6132 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
6133 assert_eq!(report.started, 1);
6134 assert_eq!(
6135 report
6136 .allocations
6137 .iter()
6138 .find(|allocation| allocation.to_start == 1)
6139 .unwrap()
6140 .policy_id,
6141 PolicyId::from_u128(1)
6142 );
6143 assert!(
6144 !unique
6145 .events
6146 .events()
6147 .iter()
6148 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
6149 );
6150
6151 let mut overlap = Harness::simple(4, 1, "o/r");
6152 let report = overlap
6153 .reconciler
6154 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
6155 .await;
6156 assert_eq!(report.started, 0);
6157 assert!(overlap.events.events().iter().any(|event| matches!(
6158 event,
6159 LifecycleEvent::RoutingRefused {
6160 reason: RoutingRefusal::OverlappingProfiles,
6161 count: 1,
6162 ..
6163 }
6164 )));
6165 assert!(
6166 RoutingRefusal::OverlappingProfiles
6167 .to_string()
6168 .contains("repair")
6169 );
6170 }
6171
6172 #[tokio::test]
6177 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
6178 let launcher = Arc::new(FakeLauncher::new());
6179 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
6180 let mut harness = Harness::build(
6181 host_with(4),
6182 Arc::clone(&launcher),
6183 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6184 Arc::new(InProcessAllocationLock::new()),
6185 );
6186
6187 let report = harness
6188 .reconciler
6189 .reconcile(&[policy(1, "acme/app", 4)])
6190 .await;
6191 assert_eq!(report.started, 0);
6192 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
6193
6194 let failures: Vec<&'static str> = harness
6195 .events
6196 .events()
6197 .into_iter()
6198 .filter_map(|event| match event {
6199 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6200 _ => None,
6201 })
6202 .collect();
6203 assert_eq!(failures, vec!["other"]);
6204 assert!(
6205 !failures[0].contains("ghp_"),
6206 "an event carried a `FailureReason::Other` detail verbatim"
6207 );
6208 }
6209
6210 #[tokio::test]
6215 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
6216 let launcher = Arc::new(FakeLauncher::new());
6217 let unfit = Arc::new(AtomicBool::new(true));
6218 let events = Arc::new(EventLog::new());
6219 let mut reconciler = Reconciler::new(
6220 host_with(4),
6221 ReconcilerPorts {
6222 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
6223 launcher: Arc::new(FitnessGatedLauncher::new(
6224 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6225 Arc::clone(&unfit),
6226 )),
6227 lock: Arc::new(InProcessAllocationLock::new()),
6228 directory: Arc::new(FakeDirectory::default()),
6229 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6230 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
6231 events: Arc::clone(&events) as Arc<dyn EventSink>,
6232 },
6233 );
6234 let policies = [policy(1, "acme/app", 4)];
6235
6236 let report = reconciler.reconcile(&policies).await;
6237 assert_eq!(report.started, 0);
6238 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
6239 let reasons: Vec<&'static str> = events
6240 .events()
6241 .into_iter()
6242 .filter_map(|event| match event {
6243 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
6244 _ => None,
6245 })
6246 .collect();
6247 assert_eq!(reasons, ["host_unfit"]);
6248
6249 unfit.store(false, Ordering::Release);
6250 let report = reconciler.reconcile(&policies).await;
6251 assert_eq!(report.started, 2);
6252 assert_eq!(launcher.launches(), 2);
6253 }
6254
6255 #[tokio::test]
6256 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
6257 let launcher = Arc::new(FakeLauncher::new());
6258 let mut harness = Harness::build(
6259 host_with(4),
6260 Arc::clone(&launcher),
6261 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
6262 Arc::new(HeldLock),
6263 );
6264
6265 let report = harness
6266 .reconciler
6267 .reconcile(&[policy(1, "acme/app", 4)])
6268 .await;
6269 assert_eq!(report.started, 0);
6270 assert_eq!(
6271 report.deferred, 3,
6272 "three runners were granted and none was created; `deferred` counts grants, \
6273 not policies -- it reported `1` when a policy that launched two of five and \
6274 then lost the lock had left three unstarted"
6275 );
6276 assert_eq!(launcher.launches(), 0);
6277 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
6278 assert!(
6279 harness
6280 .events
6281 .events()
6282 .iter()
6283 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
6284 "the event carries the same number the report does"
6285 );
6286 assert_eq!(
6287 report.allocations.len(),
6288 1,
6289 "the intent is still reported, so an operator staring at a queue sees why \
6290 nothing started"
6291 );
6292 }
6293
6294 #[tokio::test]
6295 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
6296 let mut harness = Harness::simple(8, 5, "acme/app");
6297
6298 let foreign = fixtures::policy()
6299 .id(PolicyId::from_u128(1))
6300 .repository("acme/app")
6301 .host(HostId::from_u128(0xdead))
6302 .autoscale("office", 4)
6303 .active()
6304 .build();
6305 let mut draining = policy(2, "acme/app", 4);
6306 draining.request_disable().unwrap();
6307
6308 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
6309
6310 assert_eq!(report.started, 0);
6311 assert_eq!(
6312 harness.demand.polls().len(),
6313 0,
6314 "neither can act on an answer"
6315 );
6316 let factors: Vec<LimitingFactor> = report
6317 .allocations
6318 .iter()
6319 .map(|a| a.limiting_factor)
6320 .collect();
6321 assert!(factors.contains(&LimitingFactor::ForeignHost));
6322 assert!(factors.contains(&LimitingFactor::NotReconciling));
6323 }
6324
6325 #[tokio::test]
6326 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
6327 #[derive(Debug)]
6330 struct BrokenDirectory;
6331
6332 #[async_trait::async_trait]
6333 impl RepositoryDirectory for BrokenDirectory {
6334 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
6335 Err(InventoryError::Cancelled)
6336 }
6337 }
6338
6339 let launcher = Arc::new(FakeLauncher::new());
6340 let events = Arc::new(EventLog::new());
6341 let mut reconciler = Reconciler::new(
6342 host_with(4),
6343 ReconcilerPorts {
6344 demand: Arc::new(FakeDemand::default()),
6345 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6346 lock: Arc::new(InProcessAllocationLock::new()),
6347 directory: Arc::new(BrokenDirectory),
6348 clock: Arc::new(FakeClock::default()),
6349 jitter: Arc::new(NoJitter),
6350 events: Arc::clone(&events) as Arc<dyn EventSink>,
6351 },
6352 );
6353
6354 let org_policy = fixtures::policy()
6355 .id(PolicyId::from_u128(1))
6356 .organization("acme")
6357 .autoscale("home", 4)
6358 .active()
6359 .build();
6360
6361 let report = reconciler.reconcile(&[org_policy]).await;
6362 assert_eq!(report.started, 0);
6363 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6364 assert_eq!(events.count_of("target_unreadable"), 1);
6365 }
6366
6367 fn every_event() -> Vec<LifecycleEvent> {
6379 let policy = PolicyId::from_u128(0xabcd_ef01);
6380 let attempt = AttemptId::from_u128(0x1234_5678);
6381 vec![
6382 LifecycleEvent::DemandObserved {
6383 policy,
6384 demand: u32::MAX,
6385 not_matched: u32::MAX,
6386 unresolvable: u32::MAX,
6387 complete: false,
6388 },
6389 LifecycleEvent::TargetUnreadable {
6390 policy,
6391 reason: unreadable_reason(&RefreshState::Failed {
6392 status: Some(500),
6393 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
6394 .into(),
6395 }),
6396 },
6397 LifecycleEvent::Allocated {
6398 policy,
6399 demand: u32::MAX,
6400 desired: u16::MAX,
6401 active_owned: 7,
6402 headroom: 9,
6403 to_start: 2,
6404 limiting: LimitingFactor::HostCapacity,
6405 },
6406 LifecycleEvent::MonitorOnlySkipped { policy },
6407 LifecycleEvent::RunnerStarted { policy, attempt },
6408 LifecycleEvent::RunnerStartFailed {
6409 policy,
6410 reason: failure_reason_kind(&FailureReason::Other(
6411 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6412 )),
6413 },
6414 LifecycleEvent::AllocationDeferred { policy, count: 4 },
6415 LifecycleEvent::AttemptsUnreadable {
6416 reason: failure_reason_kind(&FailureReason::Other(
6417 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
6418 )),
6419 },
6420 LifecycleEvent::AttemptCleanFailed {
6421 policy,
6422 attempt,
6423 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
6424 },
6425 LifecycleEvent::AttemptCleaned {
6426 policy,
6427 attempt,
6428 outcome: OutcomeKind::IdleExit,
6429 },
6430 LifecycleEvent::ScaleDownRefused { policy, attempt },
6431 LifecycleEvent::PollScheduled {
6432 retry_in_ms: 900_000,
6433 pace: PollPace::RateLimited {
6434 kind: RateLimitKind::Primary,
6435 },
6436 },
6437 ]
6438 }
6439
6440 #[test]
6449 fn no_emitted_event_can_carry_a_credential() {
6450 use runner_manager_platform::logging::redact;
6451
6452 for event in every_event() {
6453 let displayed = event.to_string();
6454 assert_eq!(
6455 redact(&displayed),
6456 displayed,
6457 "`{}` renders something `d1`'s sink would have to redact",
6458 event.name()
6459 );
6460
6461 let debugged = format!("{event:?}");
6462 assert_eq!(
6463 redact(&debugged),
6464 debugged,
6465 "`{}`'s Debug renders something `d1`'s sink would have to redact",
6466 event.name()
6467 );
6468 }
6469
6470 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
6473 assert_ne!(
6474 redact(secret),
6475 secret,
6476 "the scan above proves nothing if `redact` no longer recognises a credential"
6477 );
6478 }
6479
6480 #[test]
6481 fn every_field_name_this_sink_emits_is_one_d1_allows() {
6482 use runner_manager_platform::logging::is_field_allowed;
6483
6484 for field in [
6488 "event",
6489 "policy_id",
6490 "attempt_id",
6491 "attempt_state",
6492 "demand",
6493 "desired",
6494 "capacity",
6495 "headroom",
6496 "count",
6497 "reason",
6498 "outcome",
6499 "mode",
6500 "lock",
6501 "retry_in_ms",
6502 "state",
6503 ] {
6504 assert!(
6505 is_field_allowed(field),
6506 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
6507 `[redacted]` in its place and the line would lose its meaning"
6508 );
6509 }
6510 }
6511
6512 #[test]
6513 fn every_failure_reason_has_a_credential_free_kind() {
6514 for reason in FailureReason::ALL {
6515 let kind = failure_reason_kind(&reason);
6516 assert!(!kind.is_empty());
6517 assert!(
6518 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
6519 "`{kind}` is not a fixed identifier"
6520 );
6521 }
6522 assert_eq!(
6523 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
6524 "other",
6525 "the detail of an `Other` reason never reaches an event"
6526 );
6527 let categories = [
6528 IsolationProviderFailure::NotInstalled,
6529 IsolationProviderFailure::PermissionDenied,
6530 IsolationProviderFailure::ImageUnavailableOrIncompatible,
6531 IsolationProviderFailure::DiskQuotaUnavailable,
6532 IsolationProviderFailure::RuntimeOperationFailed,
6533 ];
6534 let mut kinds = std::collections::BTreeSet::new();
6535 for category in categories {
6536 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
6537 assert_ne!(kind, "other");
6538 assert!(!kind.contains("secret"));
6539 kinds.insert(kind);
6540 }
6541 assert_eq!(kinds.len(), categories.len());
6542 }
6543
6544 fn production_half_of(source: &str) -> String {
6559 let production = source
6560 .split_once("\n#[cfg(test)]")
6561 .map_or(source, |(production, _)| production);
6562 production
6563 .lines()
6564 .filter(|line| !line.trim_start().starts_with("//"))
6565 .collect::<Vec<_>>()
6566 .join("\n")
6567 }
6568
6569 fn this_file_above_its_tests_without_prose() -> String {
6571 production_half_of(include_str!("reconcile.rs"))
6572 }
6573
6574 fn normalise_for_scan(text: &str) -> String {
6593 text.to_ascii_lowercase().replace(['_', ' '], "")
6594 }
6595
6596 const FORBIDDEN: &[&str] = &[
6606 concat!("fn ", "acquire", "_job"),
6607 concat!("fn ", "claim", "_job"),
6608 concat!("fn ", "lease", "_job"),
6609 concat!("fn ", "reserve", "_job"),
6610 concat!("fn ", "ack", "nowledge"),
6611 concat!("struct ", "Job", "Lease"),
6612 concat!("struct ", "Job", "Claim"),
6613 concat!("struct ", "Job", "Reservation"),
6614 ];
6615
6616 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
6617 let haystack = normalise_for_scan(source);
6618 FORBIDDEN
6619 .iter()
6620 .copied()
6621 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
6622 }
6623
6624 #[test]
6658 fn nothing_in_this_crate_reserves_or_claims_a_job() {
6659 const SOURCES: &[(&str, &str)] = &[
6660 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
6661 ("docker_cleanup.rs", include_str!("docker_cleanup.rs")),
6662 (
6663 "docker_cleanup/http.rs",
6664 include_str!("docker_cleanup/http.rs"),
6665 ),
6666 (
6667 "docker_cleanup/proxy.rs",
6668 include_str!("docker_cleanup/proxy.rs"),
6669 ),
6670 (
6671 "docker_cleanup/tests.rs",
6672 include_str!("docker_cleanup/tests.rs"),
6673 ),
6674 ("lib.rs", include_str!("lib.rs")),
6675 ("lifecycle.rs", include_str!("lifecycle.rs")),
6676 (
6677 "lifecycle/windows_hyperv.rs",
6678 include_str!("lifecycle/windows_hyperv.rs"),
6679 ),
6680 ("oci.rs", include_str!("oci.rs")),
6681 ("package.rs", include_str!("package.rs")),
6682 ("reconcile.rs", include_str!("reconcile.rs")),
6683 ];
6684
6685 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
6686 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
6687 let entry = entry.expect("a readable directory entry");
6688 let name = entry.file_name().to_string_lossy().into_owned();
6689 let joined = if prefix.is_empty() {
6692 name.clone()
6693 } else {
6694 format!("{prefix}/{name}")
6695 };
6696 if entry.path().is_dir() {
6697 walk(&entry.path(), &joined, found);
6698 } else if name.ends_with(".rs") {
6699 found.push(joined);
6700 }
6701 }
6702 }
6703
6704 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
6705 listed.sort_unstable();
6706 let mut on_disk = Vec::new();
6707 walk(
6708 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
6709 "",
6710 &mut on_disk,
6711 );
6712 on_disk.sort_unstable();
6713 assert_eq!(
6714 listed, on_disk,
6715 "a source file was added or removed; this scan claims to cover the whole crate \
6716 and a stale list makes that claim false"
6717 );
6718
6719 for (name, source) in SOURCES {
6720 assert_eq!(
6721 forbidden_shape_in(&production_half_of(source)),
6722 None,
6723 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
6724 REST, and a local lease coordinates this host with itself and with nothing \
6725 else. If an owner decision restored one, that decision belongs in this \
6726 module's documentation and in this test before it belongs in the code"
6727 );
6728 }
6729
6730 assert!(
6733 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
6734 "the scan above proves nothing if the needles no longer match"
6735 );
6736 }
6737
6738 #[test]
6766 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
6767 let production = this_file_above_its_tests_without_prose();
6768
6769 assert!(
6770 production.contains("DemandTally"),
6771 "the reconciliation loop must tally queued jobs against this policy's routing \
6772 labels. A production half that named `DemandTally` nowhere would mean the \
6773 label filtering had been removed, and a repository whose jobs target \
6774 `ubuntu-latest` would drive its policy toward `max_capacity` again"
6775 );
6776
6777 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
6778 assert!(
6779 !production.contains(second_implementation),
6780 "the reconciliation loop names `{second_implementation}`, which is the \
6781 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
6782 has already decided it. This module applies the predicate and does not \
6783 re-implement it; if an owner decision changed that, it belongs in this \
6784 module's documentation and in this test before it belongs in the code"
6785 );
6786 }
6787 }
6788
6789 #[test]
6797 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
6798 let policy = policy(1, "acme/app", 10);
6799 let reading = QueuedDemand::of(
6800 repo("acme/app"),
6801 [
6802 fixtures::queued_job(&[HOST_LABEL]),
6803 fixtures::queued_job(&[HOST_LABEL]),
6804 fixtures::queued_job(&["ubuntu-latest"]),
6805 fixtures::unresolvable_job(),
6806 ],
6807 );
6808
6809 let tally = demand_for(&policy, &reading);
6810
6811 assert_eq!(
6812 tally.demand(),
6813 2,
6814 "only the jobs whose required labels this policy carries are demand"
6815 );
6816 assert_eq!(
6817 tally.not_matched, 1,
6818 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
6819 runner that idles until it times out"
6820 );
6821 assert_eq!(
6822 tally.unresolvable.len(),
6823 1,
6824 "an unresolvable `runs-on` is never demand and never discarded"
6825 );
6826 }
6827
6828 #[test]
6831 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
6832 let mut per_repository = BTreeMap::new();
6833 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
6834 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
6835 let reading = QueuedDemand::new(per_repository);
6836
6837 let repository_policy = policy(1, "acme/left", 10);
6838 assert_eq!(
6839 demand_for(&repository_policy, &reading).demand(),
6840 3,
6841 "a repository target reads its own repository's queue and not the aggregate"
6842 );
6843
6844 let org_policy = fixtures::policy()
6845 .id(PolicyId::from_u128(2))
6846 .organization("acme")
6847 .autoscale("home", 10)
6848 .active()
6849 .build();
6850 assert_eq!(
6851 demand_for(&org_policy, &reading).demand(),
6852 7,
6853 "an organization policy serves any repository in its scope, so its demand is \
6854 the whole aggregate's"
6855 );
6856 }
6857
6858 #[test]
6859 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
6860 let host = host_with(2);
6865 let policy = policy(1, "acme/app", 5);
6866 let attempts: Vec<RunnerAttempt> = Vec::new();
6867 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
6868
6869 let allocation = allocator.allocate(&policy, u32::MAX);
6870 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
6871 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
6872 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
6873 }
6874}