1use std::collections::{BTreeMap, BTreeSet};
113use std::fmt;
114use std::num::NonZeroU16;
115use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
116use std::sync::{Arc, Mutex};
117use std::time::Duration;
118
119use runner_manager_domain::attempt::{
120 AttemptOutcome, AttemptState, FailureReason, IsolationProviderFailure, RunnerAttempt,
121};
122use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
123use runner_manager_domain::memory::{MemoryFigures, MemoryVerdict};
124use runner_manager_domain::model::{
125 AttemptId, Clock, Host, IdlePollInterval, Org, OwnerRepo, PolicyId, RefreshInterval,
126 ScaleTarget, Timestamp,
127};
128use runner_manager_domain::policy::{DemandTally, ScalePolicy};
129use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
130use runner_manager_github::rest::{
131 ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
132};
133use runner_manager_github::traffic::{PrimaryPressure, RateLimitSnapshot};
134
135pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
155
156pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
165
166const MAX_BACKOFF_DOUBLINGS: u32 = 5;
172
173const JITTER_RATIO: f64 = 0.5;
181
182const RATE_LIMIT_JITTER_RATIO: f64 = 0.1;
190
191pub const MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET: u32 = 300;
199
200pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
207
208pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
238
239#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum PollOutcome {
257 Ready(QueuedDemand),
260 Failed(RefreshState),
263}
264
265impl PollOutcome {
266 #[must_use]
268 pub const fn reading(&self) -> Option<&QueuedDemand> {
269 match self {
270 Self::Ready(demand) => Some(demand),
271 Self::Failed(_) => None,
272 }
273 }
274
275 #[must_use]
277 pub const fn failure(&self) -> Option<&RefreshState> {
278 match self {
279 Self::Failed(state) => Some(state),
280 Self::Ready(_) => None,
281 }
282 }
283
284 #[must_use]
291 pub fn is_offline(&self) -> bool {
292 matches!(self, Self::Failed(RefreshState::Offline))
293 }
294}
295
296#[async_trait::async_trait]
304pub trait DemandSource: fmt::Debug + Send + Sync {
305 async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
307}
308
309#[derive(Debug)]
314pub struct GatewayDemand<G> {
315 gateway: G,
316 cancel: CancelToken,
317}
318
319impl<G: DemandGateway> GatewayDemand<G> {
320 #[must_use]
321 pub const fn new(gateway: G, cancel: CancelToken) -> Self {
322 Self { gateway, cancel }
323 }
324
325 #[must_use]
326 pub const fn gateway(&self) -> &G {
327 &self.gateway
328 }
329}
330
331#[async_trait::async_trait]
332impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
333 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
334 match self.gateway.queued_demand(scope, &self.cancel).await {
335 Ok(demand) => PollOutcome::Ready(demand),
336 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
340 }
341 }
342}
343
344#[async_trait::async_trait]
355pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
356 async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
361}
362
363#[derive(Debug, Clone)]
364struct CachedRepositories {
365 repositories: Vec<OwnerRepo>,
366 fetched_at: Timestamp,
367}
368
369#[derive(Debug)]
387pub struct RepositoryCache {
388 directory: Arc<dyn RepositoryDirectory>,
389 clock: Arc<dyn Clock>,
390 ttl: Duration,
391 entries: Mutex<BTreeMap<Org, CachedRepositories>>,
392 lookups: AtomicU64,
393}
394
395impl RepositoryCache {
396 #[must_use]
399 pub fn new(
400 directory: Arc<dyn RepositoryDirectory>,
401 clock: Arc<dyn Clock>,
402 poll: RefreshInterval,
403 ) -> Self {
404 let ttl = Duration::from_secs(u64::from(poll.as_secs()))
405 .saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
406 Self {
407 directory,
408 clock,
409 ttl,
410 entries: Mutex::new(BTreeMap::new()),
411 lookups: AtomicU64::new(0),
412 }
413 }
414
415 #[must_use]
417 pub const fn ttl(&self) -> Duration {
418 self.ttl
419 }
420
421 #[must_use]
426 pub fn lookups(&self) -> u64 {
427 self.lookups.load(Ordering::SeqCst)
428 }
429
430 pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
436 match target {
437 ScaleTarget::Repository(repository) => {
438 Ok(ActivityScope::repository(repository.clone()))
439 }
440 ScaleTarget::Organization(org) => {
441 let repositories = self.repositories_of(org).await?;
442 Ok(ActivityScope::organization(org.clone(), repositories))
443 }
444 }
445 }
446
447 async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
448 let now = self.clock.now();
449 if let Some(fresh) = self.fresh_entry(org, now) {
450 return Ok(fresh);
451 }
452
453 let repositories = self.directory.repositories(org).await?;
460 self.lookups.fetch_add(1, Ordering::SeqCst);
461 self.store(org.clone(), repositories.clone(), now);
462 Ok(repositories)
463 }
464
465 fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
466 let entries = self.entries.lock().ok()?;
467 let entry = entries.get(org)?;
468 let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
469 (age < self.ttl).then(|| entry.repositories.clone())
470 }
471
472 fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
473 if let Ok(mut entries) = self.entries.lock() {
474 entries.insert(
475 org,
476 CachedRepositories {
477 repositories,
478 fetched_at,
479 },
480 );
481 }
482 }
483}
484
485pub trait Jitter: fmt::Debug + Send + Sync {
496 fn fraction(&self) -> f64;
499}
500
501#[derive(Debug, Clone, Copy, Default)]
503pub struct RandomJitter;
504
505impl Jitter for RandomJitter {
506 fn fraction(&self) -> f64 {
507 rand::random::<f64>()
508 }
509}
510
511#[derive(Debug, Clone, Copy)]
513pub struct FixedJitter(pub f64);
514
515impl Jitter for FixedJitter {
516 fn fraction(&self) -> f64 {
517 self.0
518 }
519}
520
521#[derive(Debug, Clone, Copy, Default)]
523pub struct NoJitter;
524
525impl Jitter for NoJitter {
526 fn fraction(&self) -> f64 {
527 0.0
528 }
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum PollPace {
543 Nominal,
545 Idle,
548 Stretched { pressure: PrimaryPressure },
553 RateLimited { kind: RateLimitKind },
555 LockedOut,
557 Offline { consecutive: u32 },
560 Blocked,
565}
566
567impl PollPace {
568 #[must_use]
570 pub const fn is_throttled(&self) -> bool {
571 !matches!(self, Self::Nominal | Self::Idle)
572 }
573
574 #[must_use]
576 pub const fn as_str(&self) -> &'static str {
577 match self {
578 Self::Nominal => "nominal",
579 Self::Idle => "idle",
580 Self::Stretched {
581 pressure: PrimaryPressure::Normal,
582 } => "nominal",
583 Self::Stretched {
584 pressure: PrimaryPressure::Low,
585 } => "stretched_low_budget",
586 Self::Stretched {
587 pressure: PrimaryPressure::Critical,
588 } => "stretched_critical_budget",
589 Self::RateLimited {
590 kind: RateLimitKind::Primary,
591 } => "rate_limited_primary",
592 Self::RateLimited {
593 kind: RateLimitKind::Secondary,
594 } => "rate_limited_secondary",
595 Self::LockedOut => "locked_out",
596 Self::Offline { .. } => "offline",
597 Self::Blocked => "blocked",
598 }
599 }
600}
601
602impl fmt::Display for PollPace {
603 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
604 f.write_str(self.as_str())
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610pub struct NextPoll {
611 pub delay: Duration,
612 pub pace: PollPace,
613}
614
615#[derive(Debug, Clone)]
635pub struct PollSchedule {
636 interval: RefreshInterval,
637 idle: Duration,
640 consecutive_offline: u32,
641 offline_since: Option<Timestamp>,
642}
643
644#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
646pub struct PollConditions {
647 pub quiet: bool,
650 pub quota: Option<RateLimitSnapshot>,
652 pub requests_per_pass: u32,
656}
657
658impl PollSchedule {
659 #[must_use]
660 pub const fn new(interval: RefreshInterval) -> Self {
661 Self {
662 interval,
663 idle: Duration::from_secs(interval.as_secs() as u64),
664 consecutive_offline: 0,
665 offline_since: None,
666 }
667 }
668
669 #[must_use]
674 pub fn with_idle(mut self, idle: Duration) -> Self {
675 self.set_intervals(self.interval, idle);
676 self
677 }
678
679 pub fn set_intervals(&mut self, interval: RefreshInterval, idle: Duration) {
681 self.interval = interval;
682 self.idle = idle.min(self.nominal()).max(Self::idle_floor());
683 }
684
685 #[must_use]
686 pub const fn interval(&self) -> RefreshInterval {
687 self.interval
688 }
689
690 #[must_use]
692 pub const fn idle(&self) -> Duration {
693 self.idle
694 }
695
696 #[must_use]
699 pub const fn idle_floor() -> Duration {
700 Duration::from_secs(IdlePollInterval::MIN_SECS as u64)
701 }
702
703 #[must_use]
705 pub const fn nominal(&self) -> Duration {
706 Duration::from_secs(self.interval.as_secs() as u64)
707 }
708
709 #[must_use]
711 pub const fn consecutive_offline(&self) -> u32 {
712 self.consecutive_offline
713 }
714
715 #[must_use]
724 pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
725 let since = self.offline_since?;
726 now.signed_duration_since(since).to_std().ok()
727 }
728
729 #[must_use]
731 pub const fn floor() -> Duration {
732 Duration::from_secs(RefreshInterval::MIN_SECS as u64)
733 }
734
735 pub fn next_poll(
743 &mut self,
744 failure: Option<&RefreshState>,
745 now: Timestamp,
746 jitter: &dyn Jitter,
747 ) -> NextPoll {
748 self.next_poll_with(failure, PollConditions::default(), now, jitter)
749 }
750
751 pub fn next_poll_with(
766 &mut self,
767 failure: Option<&RefreshState>,
768 conditions: PollConditions,
769 now: Timestamp,
770 jitter: &dyn Jitter,
771 ) -> NextPoll {
772 let nominal = self.nominal();
773
774 let next = match failure {
775 None => {
776 self.recovered();
777 let pressure = conditions
778 .quota
779 .map_or(PrimaryPressure::Normal, |quota| quota.pressure(now));
780 match (pressure, conditions.quota) {
781 (PrimaryPressure::Low | PrimaryPressure::Critical, Some(quota)) => NextPoll {
782 delay: nominal
783 .saturating_mul(pressure.stretch_factor())
784 .min(quota.reset_in(now).max(nominal)),
785 pace: PollPace::Stretched { pressure },
786 },
787 _ if conditions.quiet && self.idle < nominal => NextPoll {
790 delay: self.idle.max(idle_floor_for(conditions.requests_per_pass)),
791 pace: PollPace::Idle,
792 },
793 _ => NextPoll {
794 delay: nominal,
795 pace: PollPace::Nominal,
796 },
797 }
798 }
799 Some(RefreshState::Offline) => {
800 self.consecutive_offline = self.consecutive_offline.saturating_add(1);
801 self.offline_since.get_or_insert(now);
803 NextPoll {
804 delay: self.offline_delay(nominal, jitter),
805 pace: PollPace::Offline {
806 consecutive: self.consecutive_offline,
807 },
808 }
809 }
810 Some(state @ RefreshState::RateLimited(limit)) => {
811 self.recovered();
812 let wait = retry_floor(state, now).max(nominal);
815 NextPoll {
816 delay: wait.saturating_add(
817 wait.mul_f64(RATE_LIMIT_JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0)),
818 ),
819 pace: PollPace::RateLimited { kind: limit.kind },
820 }
821 }
822 Some(state @ RefreshState::LockedOut { .. }) => {
823 self.recovered();
824 NextPoll {
825 delay: retry_floor(state, now).max(nominal),
826 pace: PollPace::LockedOut,
827 }
828 }
829 Some(_) => {
835 self.recovered();
838 NextPoll {
839 delay: nominal,
840 pace: PollPace::Blocked,
841 }
842 }
843 };
844
845 debug_assert!(
846 next.delay >= Self::floor() || next.pace == PollPace::Idle,
847 "the 30-second floor is a rate-budget constraint and no charged branch may go below it"
848 );
849 debug_assert!(
850 next.delay >= Self::idle_floor(),
851 "the idle floor is a secondary-limit constraint and no branch may go below it"
852 );
853 next
854 }
855
856 fn recovered(&mut self) {
858 self.consecutive_offline = 0;
859 self.offline_since = None;
860 }
861
862 fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
863 let doublings = self
864 .consecutive_offline
865 .saturating_sub(1)
866 .min(MAX_BACKOFF_DOUBLINGS);
867 let grown = nominal.saturating_mul(1_u32 << doublings);
868 let capped = grown.min(MAX_OFFLINE_BACKOFF);
869 let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
875 capped.saturating_add(spread).max(Self::floor())
876 }
877}
878
879fn is_quiet(readings: &BTreeMap<ScaleTarget, PollOutcome>, report: &ReconcileReport) -> bool {
890 !readings.is_empty()
891 && readings
892 .values()
893 .all(|outcome| outcome.reading().is_some_and(QueuedDemand::is_quiet))
894 && report.started == 0
895 && report.host_capacity_unreadable == 0
898 && report.attempts_unreadable == 0
899 && report
903 .allocations
904 .iter()
905 .all(|allocation| allocation.active_owned == 0 && allocation.demand == 0)
906}
907
908fn idle_floor_for(requests_per_pass: u32) -> Duration {
910 Duration::from_secs(
911 u64::from(requests_per_pass) * 60 / u64::from(MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET),
912 )
913}
914
915fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
923 state.retry_delay(now).unwrap_or(PollSchedule::floor())
924}
925
926#[derive(Debug, Clone, Copy, PartialEq, Eq)]
939pub struct OfflineState {
940 pub consecutive: u32,
942 pub retry_in: Duration,
944 pub offline_for: Option<Duration>,
946}
947
948impl OfflineState {
949 #[must_use]
950 pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
951 Self {
952 consecutive,
953 retry_in,
954 offline_for: None,
955 }
956 }
957
958 #[must_use]
959 pub const fn since(mut self, offline_for: Duration) -> Self {
960 self.offline_for = Some(offline_for);
961 self
962 }
963
964 #[must_use]
969 pub fn has_outlasted_the_queue(&self) -> bool {
970 self.offline_for
971 .is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
972 }
973}
974
975impl fmt::Display for OfflineState {
976 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
977 write!(
978 f,
979 "GitHub is unreachable; no new runners are being started and running \
980 runners are left alone. Retrying in {}s",
981 self.retry_in.as_secs()
982 )?;
983 if self.has_outlasted_the_queue() {
984 f.write_str(
985 ". This outage has lasted more than 24 hours, and GitHub cancels a queued \
986 job after 24 hours, so queued work has been lost",
987 )
988 } else {
989 f.write_str(
990 ". GitHub cancels a queued job after 24 hours, so an outage longer than \
991 that loses queued work",
992 )
993 }
994 }
995}
996
997#[derive(Debug, Clone, Copy)]
1003pub struct LaunchRequest<'a> {
1004 pub host: &'a Host,
1005 pub policy: &'a ScalePolicy,
1006 pub(crate) allocation_guard: &'a AllocationGuard,
1012}
1013
1014#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1021#[error("the runner could not be started: {reason}")]
1022pub struct LaunchFailure {
1023 pub reason: FailureReason,
1024}
1025
1026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub struct ReplacementIntent {
1030 pub policy: PolicyId,
1031 pub previous_attempt: AttemptId,
1032 pub operation: &'static str,
1033}
1034
1035impl LaunchFailure {
1036 #[must_use]
1037 pub const fn new(reason: FailureReason) -> Self {
1038 Self { reason }
1039 }
1040}
1041
1042#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum HostCapacityError {
1046 MissingHost,
1047 JournalUnreadable,
1048}
1049
1050impl HostCapacityError {
1051 #[must_use]
1052 pub const fn reason(self) -> &'static str {
1053 match self {
1054 Self::MissingHost => "host_missing",
1055 Self::JournalUnreadable => "host_journal_unreadable",
1056 }
1057 }
1058}
1059
1060#[derive(Debug)]
1061enum AllocationFailure {
1062 HostCapacity(HostCapacityError),
1063 Attempts(LaunchFailure),
1064}
1065
1066#[async_trait::async_trait]
1103pub trait RunnerLauncher: fmt::Debug + Send + Sync {
1104 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError>;
1108
1109 async fn supervise(
1114 &self,
1115 _policy: &ScalePolicy,
1116 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1117 Ok(Vec::new())
1118 }
1119
1120 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
1133
1134 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
1185
1186 async fn finish_launch(
1198 &self,
1199 _policy: &ScalePolicy,
1200 attempt: RunnerAttempt,
1201 _waits: ReservationWaits,
1202 ) -> Result<RunnerAttempt, LaunchFailure> {
1203 Ok(attempt)
1204 }
1205
1206 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
1214}
1215
1216#[derive(Debug)]
1227pub struct FitnessGatedLauncher {
1228 inner: Arc<dyn RunnerLauncher>,
1229 unfit: Arc<AtomicBool>,
1230}
1231
1232impl FitnessGatedLauncher {
1233 #[must_use]
1234 pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
1235 Self { inner, unfit }
1236 }
1237}
1238
1239#[async_trait::async_trait]
1240impl RunnerLauncher for FitnessGatedLauncher {
1241 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
1242 self.inner.host_capacity(host).await
1243 }
1244
1245 async fn supervise(
1246 &self,
1247 policy: &ScalePolicy,
1248 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1249 self.inner.supervise(policy).await
1250 }
1251
1252 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
1253 self.inner.attempts().await
1254 }
1255
1256 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
1257 if self.unfit.load(Ordering::Acquire) {
1258 return Err(LaunchFailure::new(FailureReason::HostUnfit));
1259 }
1260 self.inner.launch(request).await
1261 }
1262
1263 async fn finish_launch(
1264 &self,
1265 policy: &ScalePolicy,
1266 attempt: RunnerAttempt,
1267 waits: ReservationWaits,
1268 ) -> Result<RunnerAttempt, LaunchFailure> {
1269 self.inner.finish_launch(policy, attempt, waits).await
1270 }
1271
1272 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
1273 self.inner.clean(attempt).await
1274 }
1275}
1276
1277pub struct AllocationGuard {
1287 _held: Box<dyn std::any::Any + Send + Sync>,
1288 launch_hold: LaunchHold,
1289}
1290
1291impl AllocationGuard {
1292 #[must_use]
1294 fn new<T: Send + Sync + 'static>(held: T) -> Self {
1295 Self {
1296 _held: Box::new(held),
1297 launch_hold: LaunchHold::default(),
1298 }
1299 }
1300
1301 fn launch_hold(&self) -> LaunchHold {
1303 self.launch_hold.clone()
1304 }
1305}
1306
1307#[derive(Clone, Default)]
1312struct LaunchHold {
1313 _held: Option<Arc<dyn std::any::Any + Send + Sync>>,
1314}
1315
1316impl fmt::Debug for AllocationGuard {
1317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1318 f.write_str("AllocationGuard")
1319 }
1320}
1321
1322#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1324#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1325pub struct AllocationLockBusy;
1326
1327#[async_trait::async_trait]
1341pub trait AllocationLock: fmt::Debug + Send + Sync {
1342 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1348}
1349
1350#[derive(Debug, Default)]
1358pub struct InProcessAllocationLock {
1359 mutex: Arc<tokio::sync::Mutex<()>>,
1360}
1361
1362impl InProcessAllocationLock {
1363 #[must_use]
1364 pub fn new() -> Self {
1365 Self::default()
1366 }
1367}
1368
1369#[async_trait::async_trait]
1370impl AllocationLock for InProcessAllocationLock {
1371 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1372 let mutex = Arc::clone(&self.mutex);
1373 let guard = mutex.lock_owned().await;
1374 Ok(AllocationGuard::new(guard))
1375 }
1376}
1377
1378#[derive(Debug, Clone)]
1393pub struct FileAllocationLock {
1394 paths: Arc<runner_manager_platform::paths::AppPaths>,
1395 wait: Duration,
1396}
1397
1398impl FileAllocationLock {
1399 #[must_use]
1400 pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1401 Self {
1402 paths,
1403 wait: ALLOCATION_LOCK_WAIT,
1404 }
1405 }
1406
1407 #[must_use]
1408 pub const fn with_wait(mut self, wait: Duration) -> Self {
1409 self.wait = wait;
1410 self
1411 }
1412}
1413
1414#[async_trait::async_trait]
1415impl AllocationLock for FileAllocationLock {
1416 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1417 use runner_manager_platform::lock::{HostLock, LockKind};
1418
1419 let paths = Arc::clone(&self.paths);
1420 let wait = self.wait;
1421 let held = tokio::task::spawn_blocking(move || {
1422 HostLock::acquire(&paths, LockKind::Allocation, wait)
1423 })
1424 .await;
1425
1426 match held {
1427 Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1428 Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1432 }
1433 }
1434}
1435
1436#[derive(Debug)]
1464pub struct WslRecoveryAllocationLock {
1465 paths: Arc<runner_manager_platform::paths::AppPaths>,
1466 inner: Arc<dyn AllocationLock>,
1467 blocked_after_refusals: u32,
1468 blocked_after: Duration,
1469 streak_gap: Duration,
1471 in_flight: Arc<std::sync::atomic::AtomicUsize>,
1472 claim: tokio::sync::Mutex<std::sync::Weak<LaunchFenceGuard>>,
1474 refusals: Mutex<Refusals>,
1475}
1476
1477#[derive(Debug, Default)]
1479struct Refusals {
1480 streak: Option<Streak>,
1483 recorded: bool,
1484}
1485
1486#[derive(Debug, Clone, Copy)]
1487struct Streak {
1488 since: chrono::DateTime<chrono::Utc>,
1489 latest: chrono::DateTime<chrono::Utc>,
1490 count: u32,
1491}
1492
1493struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
1495
1496impl InFlight {
1497 fn enter(count: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
1498 count.fetch_add(1, Ordering::SeqCst);
1499 Self(Arc::clone(count))
1500 }
1501}
1502
1503impl Drop for InFlight {
1504 fn drop(&mut self) {
1505 self.0.fetch_sub(1, Ordering::SeqCst);
1506 }
1507}
1508
1509struct LaunchFenceGuard(Option<runner_manager_platform::wsl::fence::GuestLaunchClaim>);
1513
1514impl Drop for LaunchFenceGuard {
1515 fn drop(&mut self) {
1516 if let Some(claim) = self.0.take()
1517 && let Err(error) = claim.release()
1518 {
1519 tracing::warn!(
1520 %error,
1521 "the WSL launch fence could not be released; the next launch reclaims it"
1522 );
1523 }
1524 }
1525}
1526
1527impl WslRecoveryAllocationLock {
1528 #[must_use]
1529 pub fn new(
1530 paths: Arc<runner_manager_platform::paths::AppPaths>,
1531 inner: Arc<dyn AllocationLock>,
1532 ) -> Self {
1533 use runner_manager_platform::launch_health::{BLOCKED_AFTER, BLOCKED_AFTER_DEFERRALS};
1534 Self {
1535 paths,
1536 inner,
1537 blocked_after_refusals: BLOCKED_AFTER_DEFERRALS,
1538 blocked_after: BLOCKED_AFTER,
1539 streak_gap: BLOCKED_AFTER,
1540 in_flight: Arc::default(),
1541 claim: tokio::sync::Mutex::default(),
1542 refusals: Mutex::default(),
1543 }
1544 }
1545
1546 #[cfg(test)]
1549 const fn with_blocked_after(mut self, refusals: u32, after: Duration) -> Self {
1550 self.blocked_after_refusals = refusals;
1551 self.blocked_after = after;
1552 self
1553 }
1554
1555 #[cfg(test)]
1557 const fn with_streak_gap(mut self, gap: Duration) -> Self {
1558 self.streak_gap = gap;
1559 self
1560 }
1561
1562 fn refusals(&self) -> std::sync::MutexGuard<'_, Refusals> {
1563 self.refusals
1564 .lock()
1565 .unwrap_or_else(std::sync::PoisonError::into_inner)
1566 }
1567
1568 fn note_granted(&self) {
1569 let mut refusals = self.refusals();
1570 refusals.streak = None;
1571 if refusals.recorded {
1572 match runner_manager_platform::launch_health::clear_launches_blocked(&self.paths) {
1573 Ok(()) => refusals.recorded = false,
1574 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be cleared"),
1576 }
1577 }
1578 }
1579
1580 fn note_refused(&self, cause: &runner_manager_platform::launch_health::BlockCause) {
1581 use runner_manager_platform::launch_health::{Viewpoint, record_launches_blocked};
1582 use runner_manager_platform::wsl::fence::elapsed_at_least;
1583
1584 if self.in_flight.load(Ordering::SeqCst) > 0 {
1585 return;
1586 }
1587 let now = chrono::Utc::now();
1588 let mut refusals = self.refusals();
1589 let streak = match refusals.streak {
1592 Some(streak) if !elapsed_at_least(streak.latest, now, self.streak_gap) => Streak {
1593 latest: now,
1594 count: streak.count.saturating_add(1),
1595 ..streak
1596 },
1597 _ => Streak {
1598 since: now,
1599 latest: now,
1600 count: 1,
1601 },
1602 };
1603 refusals.streak = Some(streak);
1604 if streak.count < self.blocked_after_refusals
1605 || !elapsed_at_least(streak.since, now, self.blocked_after)
1606 {
1607 return;
1608 }
1609 let blocked = cause.blocked_since(streak.since, Viewpoint::Guest);
1610 if !refusals.recorded {
1611 tracing::warn!(
1612 refusals = streak.count,
1613 "runner launches are blocked {blocked}: {}",
1614 blocked.remedy
1615 );
1616 }
1617 match record_launches_blocked(&self.paths, &blocked, now) {
1620 Ok(()) => refusals.recorded = true,
1621 Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be written"),
1622 }
1623 }
1624}
1625
1626fn claim_guest_fence(
1628 root: &std::path::Path,
1629) -> Result<Arc<LaunchFenceGuard>, runner_manager_platform::launch_health::BlockCause> {
1630 use runner_manager_platform::launch_health::BlockCause;
1631 use runner_manager_platform::wsl::fence::{
1632 FENCE_DIRECTORY, FenceClaim, GuestClaimAttempt, try_claim_guest_launch,
1633 };
1634
1635 match try_claim_guest_launch(root) {
1636 Ok(GuestClaimAttempt::Claimed { claim, reclaimed }) => {
1637 if let Some(reclaimed) = reclaimed {
1638 tracing::warn!(
1639 shared_root = %root.display(),
1640 %reclaimed,
1641 "reclaimed a WSL launch fence claim whose owner is gone"
1642 );
1643 }
1644 Ok(Arc::new(LaunchFenceGuard(Some(claim))))
1647 }
1648 Ok(GuestClaimAttempt::Busy) => {
1649 let directory = root.join(FENCE_DIRECTORY);
1650 Err(match FenceClaim::owner(root) {
1651 Ok(owner) => BlockCause::FenceHeld { directory, owner },
1652 Err(error) => BlockCause::FenceOwnerUnreadable {
1653 directory,
1654 detail: error.to_string(),
1655 },
1656 })
1657 }
1658 Err(error) => {
1659 Err(match FenceClaim::owner(root) {
1662 Err(owner_error) => BlockCause::FenceOwnerUnreadable {
1663 directory: root.join(FENCE_DIRECTORY),
1664 detail: owner_error.to_string(),
1665 },
1666 Ok(_) => BlockCause::FenceUnusable {
1667 detail: error.to_string(),
1668 },
1669 })
1670 }
1671 }
1672}
1673
1674#[async_trait::async_trait]
1675impl AllocationLock for WslRecoveryAllocationLock {
1676 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1677 use runner_manager_platform::launch_health::BlockCause;
1678 use runner_manager_platform::wsl::fence::{DrainRequest, GuestRecoveryConfig};
1679
1680 let paths = Arc::clone(&self.paths);
1681 let mut shared = self.claim.lock().await;
1682 let held = shared.upgrade();
1683 let claim = tokio::task::spawn_blocking(move || {
1684 let unusable = |error: &dyn fmt::Display| BlockCause::FenceUnusable {
1685 detail: error.to_string(),
1686 };
1687 let config = match GuestRecoveryConfig::read(&paths) {
1688 Ok(Some(config)) => config,
1689 Ok(None) => return Ok(Arc::new(LaunchFenceGuard(None))),
1690 Err(error) => return Err(unusable(&error)),
1691 };
1692 let root = config.shared_root;
1693 let claim = match held {
1694 Some(held) => {
1696 if let Some(Err(error)) = held.0.as_ref().map(|claim| claim.renew()) {
1697 tracing::warn!(%error, "the WSL launch fence's age could not be renewed");
1698 }
1699 held
1700 }
1701 None => claim_guest_fence(&root)?,
1702 };
1703 match DrainRequest::read(&root) {
1704 Ok(None) => Ok(claim),
1705 Ok(Some(_)) => Err(BlockCause::DrainRequested { root }),
1706 Err(error) => Err(unusable(&error)),
1707 }
1708 })
1709 .await
1710 .map_err(|_| AllocationLockBusy)?;
1711
1712 let claim = match claim {
1713 Ok(claim) => claim,
1714 Err(cause) => {
1715 drop(shared);
1716 self.note_refused(&cause);
1717 return Err(AllocationLockBusy);
1718 }
1719 };
1720 if claim.0.is_some() {
1721 *shared = Arc::downgrade(&claim);
1722 }
1723 drop(shared);
1724 let Ok(inner) = self.inner.acquire().await else {
1725 self.note_refused(&BlockCause::AllocationLockBusy {
1726 path: self
1727 .paths
1728 .state_dir()
1729 .join(runner_manager_platform::lock::LockKind::Allocation.file_name()),
1730 });
1731 return Err(AllocationLockBusy);
1732 };
1733 self.note_granted();
1734 let in_flight = InFlight::enter(&self.in_flight);
1735 let mut guard = AllocationGuard::new(inner);
1736 guard.launch_hold = LaunchHold {
1737 _held: Some(Arc::new((claim, in_flight))),
1738 };
1739 Ok(guard)
1740 }
1741}
1742
1743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1753pub enum OutcomeKind {
1754 CompletedJob,
1755 IdleExit,
1756 Failed,
1757 Orphaned,
1758}
1759
1760impl OutcomeKind {
1761 #[must_use]
1762 pub const fn of(outcome: &AttemptOutcome) -> Self {
1763 match outcome {
1764 AttemptOutcome::CompletedJob => Self::CompletedJob,
1765 AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1766 AttemptOutcome::Failed { .. } => Self::Failed,
1767 AttemptOutcome::Orphaned => Self::Orphaned,
1768 }
1769 }
1770
1771 #[must_use]
1772 pub const fn is_failure(&self) -> bool {
1773 matches!(self, Self::Failed | Self::Orphaned)
1774 }
1775
1776 #[must_use]
1777 pub const fn as_str(&self) -> &'static str {
1778 match self {
1779 Self::CompletedJob => "completed_job",
1780 Self::IdleExit => "exited_idle_without_work",
1781 Self::Failed => "failed",
1782 Self::Orphaned => "orphaned",
1783 }
1784 }
1785}
1786
1787impl fmt::Display for OutcomeKind {
1788 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1789 f.write_str(self.as_str())
1790 }
1791}
1792
1793#[must_use]
1802pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1803 match reason {
1804 FailureReason::JitRequestFailed => "jit_request_failed",
1805 FailureReason::JitExpired => "jit_expired",
1806 FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1807 FailureReason::RunnerVersionRejected => "runner_version_rejected",
1808 FailureReason::ProcessStartFailed => "process_start_failed",
1809 FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1810 FailureReason::RegistrationTimedOut => "registration_timed_out",
1811 FailureReason::TerminatedAfterRegistrationTimeout => {
1812 "terminated_after_registration_timeout"
1813 }
1814 FailureReason::IsolationProvider(category) => match category {
1815 IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1816 IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1817 IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1818 IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1819 "isolation_image_unavailable"
1820 }
1821 IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1822 IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1823 IsolationProviderFailure::RuntimeOperationFailed => {
1824 "isolation_runtime_operation_failed"
1825 }
1826 IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1827 IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1828 IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1829 },
1830 FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1831 FailureReason::HostUnfit => "host_unfit",
1832 FailureReason::Other(_) => "other",
1833 }
1834}
1835
1836#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1838pub enum RoutingRefusal {
1839 OverlappingProfiles,
1840}
1841
1842impl RoutingRefusal {
1843 #[must_use]
1844 pub const fn as_str(self) -> &'static str {
1845 match self {
1846 Self::OverlappingProfiles => "overlapping_profile_matches",
1847 }
1848 }
1849}
1850
1851impl fmt::Display for RoutingRefusal {
1852 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1853 f.write_str(match self {
1854 Self::OverlappingProfiles => {
1855 "repair overlapping profile labels or require a unique profile selector"
1856 }
1857 })
1858 }
1859}
1860
1861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1871pub enum LifecycleEvent {
1872 DemandObserved {
1874 policy: PolicyId,
1875 demand: u32,
1877 not_matched: u32,
1883 unresolvable: u32,
1892 complete: bool,
1894 },
1895 RoutingRefused {
1897 policy: PolicyId,
1898 reason: RoutingRefusal,
1899 count: u32,
1900 },
1901 TargetUnreadable {
1903 policy: PolicyId,
1904 reason: &'static str,
1905 },
1906 Allocated {
1908 policy: PolicyId,
1909 demand: u32,
1910 desired: u16,
1911 active_owned: u16,
1912 headroom: u16,
1913 to_start: u16,
1914 limiting: LimitingFactor,
1915 },
1916 MonitorOnlySkipped { policy: PolicyId },
1919 RunnerStarted {
1921 policy: PolicyId,
1922 attempt: AttemptId,
1923 },
1924 RunnerStartFailed {
1926 policy: PolicyId,
1927 reason: &'static str,
1928 },
1929 AllocationDeferred { policy: PolicyId, count: u16 },
1932 MemoryShort {
1938 policy: PolicyId,
1939 figures: MemoryFigures,
1940 started: bool,
1941 },
1942 AttemptsUnreadable { reason: &'static str },
1948 HostCapacityUnreadable { reason: HostCapacityError },
1950 AttemptCleaned {
1952 policy: PolicyId,
1953 attempt: AttemptId,
1954 outcome: OutcomeKind,
1955 },
1956 AttemptCleanFailed {
1959 policy: PolicyId,
1960 attempt: AttemptId,
1961 reason: &'static str,
1962 },
1963 ScaleDownRefused {
1965 policy: PolicyId,
1966 attempt: AttemptId,
1967 },
1968 PollScheduled { retry_in_ms: u64, pace: PollPace },
1970}
1971
1972impl LifecycleEvent {
1973 #[must_use]
1975 pub const fn name(&self) -> &'static str {
1976 match self {
1977 Self::DemandObserved { .. } => "demand_observed",
1978 Self::RoutingRefused { .. } => "routing_refused",
1979 Self::TargetUnreadable { .. } => "target_unreadable",
1980 Self::Allocated { .. } => "allocated",
1981 Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1982 Self::RunnerStarted { .. } => "runner_started",
1983 Self::RunnerStartFailed { .. } => "runner_start_failed",
1984 Self::AllocationDeferred { .. } => "allocation_deferred",
1985 Self::MemoryShort { started: false, .. } => "memory_deferred",
1986 Self::MemoryShort { started: true, .. } => "memory_admitted_alone",
1987 Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1988 Self::HostCapacityUnreadable { .. } => "host_capacity_unreadable",
1989 Self::AttemptCleaned { .. } => "attempt_cleaned",
1990 Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1991 Self::ScaleDownRefused { .. } => "scale_down_refused",
1992 Self::PollScheduled { .. } => "poll_scheduled",
1993 }
1994 }
1995
1996 #[must_use]
1998 pub const fn policy(&self) -> Option<PolicyId> {
1999 match self {
2000 Self::DemandObserved { policy, .. }
2001 | Self::RoutingRefused { policy, .. }
2002 | Self::TargetUnreadable { policy, .. }
2003 | Self::Allocated { policy, .. }
2004 | Self::MonitorOnlySkipped { policy }
2005 | Self::RunnerStarted { policy, .. }
2006 | Self::RunnerStartFailed { policy, .. }
2007 | Self::AllocationDeferred { policy, .. }
2008 | Self::MemoryShort { policy, .. }
2009 | Self::AttemptCleaned { policy, .. }
2010 | Self::AttemptCleanFailed { policy, .. }
2011 | Self::ScaleDownRefused { policy, .. } => Some(*policy),
2012 Self::PollScheduled { .. }
2013 | Self::AttemptsUnreadable { .. }
2014 | Self::HostCapacityUnreadable { .. } => None,
2015 }
2016 }
2017}
2018
2019impl fmt::Display for LifecycleEvent {
2020 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2021 match self {
2022 Self::DemandObserved {
2023 policy,
2024 demand,
2025 not_matched,
2026 unresolvable,
2027 complete,
2028 } => write!(
2029 f,
2030 "policy {policy}: {demand} queued jobs for this host{}{}{}",
2031 if *not_matched == 0 {
2032 String::new()
2033 } else {
2034 format!(", {not_matched} for other labels")
2035 },
2036 if *unresolvable == 0 {
2037 String::new()
2038 } else {
2039 format!(", {unresolvable} with an unresolvable `runs-on`")
2040 },
2041 if *complete {
2042 ""
2043 } else {
2044 " (a floor, not a total)"
2045 }
2046 ),
2047 Self::TargetUnreadable { policy, reason } => {
2048 write!(f, "policy {policy}: target unreadable ({reason})")
2049 }
2050 Self::RoutingRefused {
2051 policy,
2052 reason,
2053 count,
2054 } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
2055 Self::Allocated {
2056 policy,
2057 demand,
2058 desired,
2059 active_owned,
2060 headroom,
2061 to_start,
2062 limiting,
2063 } => write!(
2064 f,
2065 "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
2066 flight, {headroom} free on this host, starting {to_start} ({limiting})"
2067 ),
2068 Self::MonitorOnlySkipped { policy } => {
2069 write!(f, "policy {policy}: monitor-only, skipped")
2070 }
2071 Self::RunnerStarted { policy, attempt } => {
2072 write!(f, "policy {policy}: started attempt {attempt}")
2073 }
2074 Self::RunnerStartFailed { policy, reason } => {
2075 write!(f, "policy {policy}: could not start a runner ({reason})")
2076 }
2077 Self::AllocationDeferred { policy, count } => write!(
2078 f,
2079 "policy {policy}: the allocation lock was held; {count} granted runners \
2080 were not created"
2081 ),
2082 Self::MemoryShort {
2083 policy,
2084 figures,
2085 started: false,
2086 } => write!(f, "policy {policy}: waiting for memory: {figures}"),
2087 Self::MemoryShort {
2088 policy,
2089 figures,
2090 started: true,
2091 } => write!(
2092 f,
2093 "policy {policy}: started although the host lacks the memory, because \
2094 nothing else runs here: {figures}"
2095 ),
2096 Self::AttemptsUnreadable { reason } => write!(
2097 f,
2098 "the host's attempt set could not be read ({reason}); nothing was started, \
2099 and this is not the same as the host being idle"
2100 ),
2101 Self::HostCapacityUnreadable { reason } => write!(
2102 f,
2103 "the host capacity could not be read ({}); new launches wait for readable host settings",
2104 reason.reason()
2105 ),
2106 Self::AttemptCleaned {
2107 policy,
2108 attempt,
2109 outcome,
2110 } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
2111 Self::AttemptCleanFailed {
2112 policy,
2113 attempt,
2114 reason,
2115 } => write!(
2116 f,
2117 "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
2118 will be retried"
2119 ),
2120 Self::ScaleDownRefused { policy, attempt } => write!(
2121 f,
2122 "policy {policy}: attempt {attempt} is executing a job and was not removed"
2123 ),
2124 Self::PollScheduled { retry_in_ms, pace } => {
2125 write!(f, "next poll in {retry_in_ms}ms ({pace})")
2126 }
2127 }
2128 }
2129}
2130
2131pub trait EventSink: fmt::Debug + Send + Sync {
2133 fn emit(&self, event: LifecycleEvent);
2134}
2135
2136#[derive(Debug, Clone, Copy, Default)]
2138pub struct NoEvents;
2139
2140impl EventSink for NoEvents {
2141 fn emit(&self, _event: LifecycleEvent) {}
2142}
2143
2144#[derive(Debug, Clone, Copy, Default)]
2152pub struct TracingEvents;
2153
2154impl EventSink for TracingEvents {
2155 fn emit(&self, event: LifecycleEvent) {
2156 let name = event.name();
2157 match event {
2158 LifecycleEvent::DemandObserved {
2159 policy,
2160 demand,
2161 not_matched,
2162 unresolvable,
2163 complete,
2164 } => {
2165 if demand == 0 && not_matched == 0 && unresolvable == 0 && complete {
2170 tracing::debug!(
2171 event = name,
2172 policy_id = %policy,
2173 demand,
2174 not_matched,
2175 unresolvable,
2176 count = u64::from(complete),
2177 );
2178 } else {
2179 tracing::info!(
2180 event = name,
2181 policy_id = %policy,
2182 demand,
2183 not_matched,
2184 unresolvable,
2185 count = u64::from(complete),
2186 );
2187 }
2188 }
2208 LifecycleEvent::TargetUnreadable { policy, reason } => {
2209 tracing::warn!(event = name, policy_id = %policy, reason);
2210 }
2211 LifecycleEvent::RoutingRefused {
2212 policy,
2213 reason,
2214 count,
2215 } => tracing::warn!(
2216 event = name,
2217 policy_id = %policy,
2218 reason = reason.as_str(),
2219 count,
2220 "{reason}"
2221 ),
2222 LifecycleEvent::Allocated {
2223 policy,
2224 demand,
2225 desired,
2226 active_owned,
2227 headroom,
2228 to_start,
2229 limiting,
2230 } if demand == 0 && to_start == 0 && active_owned == 0 => tracing::debug!(
2231 event = name,
2234 policy_id = %policy,
2235 demand,
2236 desired,
2237 capacity = active_owned,
2238 headroom,
2239 count = to_start,
2240 reason = %limiting,
2241 ),
2242 LifecycleEvent::Allocated {
2243 policy,
2244 demand,
2245 desired,
2246 active_owned,
2247 headroom,
2248 to_start,
2249 limiting,
2250 } => tracing::info!(
2251 event = name,
2252 policy_id = %policy,
2253 demand,
2254 desired,
2255 capacity = active_owned,
2256 headroom,
2257 count = to_start,
2258 reason = %limiting,
2259 ),
2260 LifecycleEvent::MonitorOnlySkipped { policy } => {
2261 tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
2262 }
2263 LifecycleEvent::RunnerStarted { policy, attempt } => {
2264 tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
2265 }
2266 LifecycleEvent::RunnerStartFailed { policy, reason } => {
2267 tracing::warn!(event = name, policy_id = %policy, reason);
2268 }
2269 LifecycleEvent::AllocationDeferred { policy, count } => {
2270 tracing::warn!(
2276 event = name,
2277 policy_id = %policy,
2278 reason = "allocation_lock_unavailable",
2279 count
2280 );
2281 }
2282 LifecycleEvent::MemoryShort {
2283 policy,
2284 figures,
2285 started,
2286 } => {
2287 let reason = if started {
2288 "larger_than_free_memory_nothing_else_running"
2289 } else {
2290 "insufficient_memory"
2291 };
2292 tracing::warn!(
2293 event = name,
2294 policy_id = %policy,
2295 reason,
2296 free_bytes = figures.free,
2297 reserved_bytes = figures.reserved,
2298 estimate_bytes = figures.estimate.bytes,
2299 estimate_source = figures.estimate.source.as_str(),
2300 margin_bytes = figures.margin,
2301 );
2302 }
2303 LifecycleEvent::AttemptsUnreadable { reason } => {
2304 tracing::warn!(event = name, reason);
2305 }
2306 LifecycleEvent::HostCapacityUnreadable { reason } => {
2307 tracing::warn!(event = name, reason = reason.reason());
2308 }
2309 LifecycleEvent::AttemptCleaned {
2310 policy,
2311 attempt,
2312 outcome,
2313 } => tracing::info!(
2314 event = name,
2315 policy_id = %policy,
2316 attempt_id = %attempt,
2317 outcome = outcome.as_str(),
2318 ),
2319 LifecycleEvent::AttemptCleanFailed {
2320 policy,
2321 attempt,
2322 reason,
2323 } => tracing::warn!(
2324 event = name,
2325 policy_id = %policy,
2326 attempt_id = %attempt,
2327 reason,
2328 ),
2329 LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
2330 event = name,
2331 policy_id = %policy,
2332 attempt_id = %attempt,
2333 attempt_state = "busy",
2334 ),
2335 LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
2336 if pace == PollPace::Idle {
2337 tracing::debug!(event = name, retry_in_ms, state = pace.as_str());
2338 } else {
2339 tracing::info!(event = name, retry_in_ms, state = pace.as_str());
2340 }
2341 }
2342 }
2343 }
2344}
2345
2346#[derive(Debug, Default)]
2350pub struct EventLog {
2351 events: Mutex<Vec<LifecycleEvent>>,
2352}
2353
2354impl EventLog {
2355 #[must_use]
2356 pub fn new() -> Self {
2357 Self::default()
2358 }
2359
2360 #[must_use]
2361 pub fn events(&self) -> Vec<LifecycleEvent> {
2362 self.events.lock().map(|e| e.clone()).unwrap_or_default()
2363 }
2364
2365 #[must_use]
2367 pub fn count_of(&self, name: &str) -> usize {
2368 self.events()
2369 .iter()
2370 .filter(|event| event.name() == name)
2371 .count()
2372 }
2373}
2374
2375impl EventSink for EventLog {
2376 fn emit(&self, event: LifecycleEvent) {
2377 if let Ok(mut events) = self.events.lock() {
2378 events.push(event);
2379 }
2380 }
2381}
2382
2383#[derive(Debug)]
2386pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
2387
2388impl EventSink for TeeEvents {
2389 fn emit(&self, event: LifecycleEvent) {
2390 self.0.emit(event);
2391 self.1.emit(event);
2392 }
2393}
2394
2395pub trait DemandDirector: fmt::Debug + Send + Sync {
2410 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32>;
2413
2414 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId);
2417
2418 fn start_failed(&self, _policy: &ScalePolicy, _attempt: Option<AttemptId>) {}
2423
2424 fn next_waiting(&self, _policy: &ScalePolicy) -> Option<u64> {
2427 None
2428 }
2429
2430 fn older_waiting(&self, _policy: &ScalePolicy) -> usize {
2434 0
2435 }
2436
2437 fn pass_over_older(&self, _policy: &ScalePolicy) {}
2440}
2441
2442const OLDER_LAUNCH_TURN: Duration = Duration::from_secs(2);
2449const OLDER_LAUNCH_POLL: Duration = Duration::from_millis(10);
2450
2451const LAUNCH_RETRY: Duration = Duration::from_secs(30);
2453
2454#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2456pub struct ReservationWaits {
2457 pub order: Duration,
2459 pub lock: Duration,
2461}
2462
2463#[derive(Default)]
2465struct ReservationRound {
2466 reserved: BTreeMap<PolicyId, (u32, u32)>,
2469 exhausted: BTreeSet<PolicyId>,
2470}
2471
2472type Reserving<'a> = std::pin::Pin<
2474 Box<
2475 dyn std::future::Future<Output = (Option<Reserved>, ReservationRound, Vec<ScalePolicy>)>
2476 + Send
2477 + 'a,
2478 >,
2479>;
2480
2481struct Reserved {
2483 policy: ScalePolicy,
2484 attempt: RunnerAttempt,
2485 waits: ReservationWaits,
2486 reserved_at: std::time::Instant,
2487 _hold: LaunchHold,
2489}
2490
2491pub trait MemoryGate: fmt::Debug + Send + Sync {
2496 fn admit(&self, policy: &ScalePolicy, attempts: &[RunnerAttempt]) -> MemoryVerdict;
2499
2500 fn admitted(&self, _policy: &ScalePolicy) {}
2503}
2504
2505#[derive(Debug, Default, Clone, Copy)]
2507pub struct NoMemoryGate;
2508
2509impl MemoryGate for NoMemoryGate {
2510 fn admit(&self, _policy: &ScalePolicy, _attempts: &[RunnerAttempt]) -> MemoryVerdict {
2511 MemoryVerdict::Unchecked
2512 }
2513}
2514
2515#[derive(Debug, Default, Clone, Copy)]
2517pub struct OwnDemand;
2518
2519impl DemandDirector for OwnDemand {
2520 fn directed_demand(&self, _policy: &ScalePolicy) -> Option<u32> {
2521 None
2522 }
2523
2524 fn launched(&self, _policy: &ScalePolicy, _attempt: AttemptId) {}
2525}
2526
2527#[derive(Clone)]
2533pub struct ReconcilerPorts {
2534 pub demand: Arc<dyn DemandSource>,
2535 pub launcher: Arc<dyn RunnerLauncher>,
2536 pub lock: Arc<dyn AllocationLock>,
2537 pub directory: Arc<dyn RepositoryDirectory>,
2538 pub clock: Arc<dyn Clock>,
2539 pub jitter: Arc<dyn Jitter>,
2540 pub events: Arc<dyn EventSink>,
2541}
2542
2543impl fmt::Debug for ReconcilerPorts {
2544 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2545 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
2546 }
2547}
2548
2549#[derive(Debug, Clone, Default)]
2556pub struct ReconcileReport {
2557 pub allocations: Vec<Allocation>,
2559 pub monitor_only: Vec<PolicyId>,
2561 pub unreadable: Vec<PolicyId>,
2563 pub targets_read: u16,
2571 pub started: u16,
2573 pub replacement_intents: u16,
2576 pub cleaned: u16,
2578 pub idle_exits: u16,
2581 pub failures: u16,
2583 pub deferred: u16,
2592 pub memory_deferred: u16,
2595 pub attempts_unreadable: u16,
2614 pub host_capacity_unreadable: u16,
2616 pub clean_failures: u16,
2618 pub failure: Option<RefreshState>,
2620 pub offline: Option<OfflineState>,
2623 pub next_poll: NextPoll,
2625 pub demand_requests: u32,
2627}
2628
2629impl ReconcileReport {
2630 #[must_use]
2657 pub const fn reached_github(&self) -> bool {
2658 self.targets_read > 0
2659 }
2660}
2661
2662impl Default for NextPoll {
2663 fn default() -> Self {
2664 Self {
2665 delay: PollSchedule::floor(),
2666 pace: PollPace::Nominal,
2667 }
2668 }
2669}
2670
2671impl ReconcileReport {
2672 #[must_use]
2674 pub fn is_offline(&self) -> bool {
2675 matches!(self.failure, Some(RefreshState::Offline))
2676 }
2677
2678 #[must_use]
2680 pub const fn offline_state(&self) -> Option<&OfflineState> {
2681 self.offline.as_ref()
2682 }
2683
2684 #[must_use]
2686 pub const fn starts_nothing(&self) -> bool {
2687 self.started == 0
2688 }
2689}
2690
2691#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2693pub struct ScaleDownReport {
2694 pub removed: u16,
2696 pub refused_busy: u16,
2698 pub clean_failures: u16,
2700 pub retained: u16,
2703 pub attempts_unreadable: bool,
2713}
2714
2715impl ScaleDownReport {
2716 #[must_use]
2721 pub const fn is_conclusive(&self) -> bool {
2722 !self.attempts_unreadable
2723 }
2724}
2725
2726const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
2734
2735const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
2737
2738#[derive(Debug, Clone, Copy)]
2740struct CleanRetry {
2741 failures: u32,
2742 not_before: Timestamp,
2743}
2744
2745#[derive(Debug, Default)]
2749pub(crate) struct CleanBackoff {
2750 retries: BTreeMap<AttemptId, CleanRetry>,
2751}
2752
2753impl CleanBackoff {
2754 const SCHEDULE: crate::lifecycle::RetryPolicy =
2756 crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2757
2758 pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2760 self.retries
2761 .get(&attempt)
2762 .is_none_or(|retry| now >= retry.not_before)
2763 }
2764
2765 pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2767 let failures = self
2768 .retries
2769 .get(&attempt)
2770 .map_or(1, |retry| retry.failures.saturating_add(1));
2771 let delay = Self::SCHEDULE.delay(failures);
2772 self.retries.insert(
2773 attempt,
2774 CleanRetry {
2775 failures,
2776 not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2778 },
2779 );
2780 }
2781
2782 pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2784 self.retries.remove(&attempt);
2785 }
2786
2787 fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2790 self.retries.retain(|attempt, _| present.contains(attempt));
2791 }
2792}
2793
2794#[derive(Debug)]
2800pub struct Reconciler {
2801 host: Host,
2802 demand: Arc<dyn DemandSource>,
2803 launcher: Arc<dyn RunnerLauncher>,
2804 lock: Arc<dyn AllocationLock>,
2805 repositories: RepositoryCache,
2806 clock: Arc<dyn Clock>,
2807 jitter: Arc<dyn Jitter>,
2808 events: Arc<dyn EventSink>,
2809 schedule: PollSchedule,
2810 clean_backoff: CleanBackoff,
2811 director: Arc<dyn DemandDirector>,
2812 launch_side: Option<Arc<tokio::sync::Notify>>,
2815 memory: Arc<dyn MemoryGate>,
2816}
2817
2818impl Reconciler {
2819 #[must_use]
2821 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2822 let interval = host.refresh_interval;
2823 let repositories = RepositoryCache::new(
2824 Arc::clone(&ports.directory),
2825 Arc::clone(&ports.clock),
2826 interval,
2827 );
2828 Self {
2829 host,
2830 demand: ports.demand,
2831 launcher: ports.launcher,
2832 lock: ports.lock,
2833 repositories,
2834 clock: ports.clock,
2835 jitter: ports.jitter,
2836 events: ports.events,
2837 schedule: PollSchedule::new(interval),
2838 clean_backoff: CleanBackoff::default(),
2839 director: Arc::new(OwnDemand),
2840 launch_side: None,
2841 memory: Arc::new(NoMemoryGate),
2842 }
2843 }
2844
2845 #[must_use]
2847 pub fn with_memory_gate(mut self, memory: Arc<dyn MemoryGate>) -> Self {
2848 self.memory = memory;
2849 self
2850 }
2851
2852 #[must_use]
2857 pub fn with_launch_side(mut self, signal: Arc<tokio::sync::Notify>) -> Self {
2858 self.launch_side = Some(signal);
2859 self
2860 }
2861
2862 #[must_use]
2865 pub fn with_director(mut self, director: Arc<dyn DemandDirector>) -> Self {
2866 self.director = director;
2867 self
2868 }
2869
2870 #[must_use]
2871 pub const fn host(&self) -> &Host {
2872 &self.host
2873 }
2874
2875 #[must_use]
2876 pub const fn schedule(&self) -> &PollSchedule {
2877 &self.schedule
2878 }
2879
2880 pub fn set_intervals(&mut self, active: RefreshInterval, idle: Duration) {
2882 self.host.refresh_interval = active;
2883 self.schedule.set_intervals(active, idle);
2884 }
2885
2886 #[must_use]
2888 pub const fn repositories(&self) -> &RepositoryCache {
2889 &self.repositories
2890 }
2891
2892 #[cfg(test)]
2896 pub async fn start_directed(&self, policies: &[ScalePolicy]) -> ReconcileReport {
2897 let mut report = ReconcileReport::default();
2898 let mut round = ReservationRound::default();
2899 let mut reserved = Vec::new();
2900 while let Some(next) = self
2901 .reserve_next_launch(policies, &mut round, &mut report)
2902 .await
2903 {
2904 reserved.push(next);
2905 }
2906 self.finish_reserved(reserved, &mut report).await;
2907 report
2908 }
2909
2910 pub async fn serve_launches(
2926 &self,
2927 policies: &(dyn Fn() -> Vec<ScalePolicy> + Send + Sync),
2928 signal: &tokio::sync::Notify,
2929 mut stop: tokio::sync::watch::Receiver<bool>,
2930 ) {
2931 use futures::StreamExt as _;
2932
2933 let mut bringing_up = futures::stream::FuturesUnordered::new();
2934 let mut reserving: Option<Reserving<'_>> = None;
2935 let mut retry_at: Option<tokio::time::Instant> = None;
2936 let mut known: Vec<ScalePolicy> = Vec::new();
2937 let mut stopping = *stop.borrow();
2938 loop {
2939 if stopping && reserving.is_none() && bringing_up.is_empty() {
2940 return;
2941 }
2942 tokio::select! {
2943 () = signal.notified(), if !stopping && reserving.is_none() => {
2944 reserving = Some(self.reserve_step(policies(), ReservationRound::default()));
2945 }
2946 () = async {
2947 match retry_at {
2948 Some(at) => tokio::time::sleep_until(at).await,
2949 None => std::future::pending().await,
2950 }
2951 }, if !stopping && reserving.is_none() => {
2952 retry_at = None;
2953 reserving = Some(self.reserve_step(policies(), ReservationRound::default()));
2954 }
2955 (reserved, round, read) = async {
2956 match reserving.as_mut() {
2957 Some(reserving) => reserving.await,
2958 None => std::future::pending().await,
2959 }
2960 } => {
2961 reserving = None;
2962 match reserved {
2963 Some(reserved) => {
2964 bringing_up.push(self.bring_up(reserved));
2965 known = read;
2966 if !stopping {
2967 reserving = Some(self.reserve_step(policies(), round));
2968 }
2969 }
2970 None => known = read,
2971 }
2972 }
2973 Some(_) = bringing_up.next(), if !bringing_up.is_empty() => {}
2974 changed = stop.changed(), if !stopping => {
2975 stopping = changed.is_err() || *stop.borrow();
2976 }
2977 }
2978 if reserving.is_none()
2981 && retry_at.is_none()
2982 && known
2983 .iter()
2984 .any(|policy| self.director.next_waiting(policy).is_some())
2985 {
2986 retry_at = Some(tokio::time::Instant::now() + LAUNCH_RETRY);
2987 }
2988 }
2989 }
2990
2991 fn reserve_step(
2993 &self,
2994 policies: Vec<ScalePolicy>,
2995 mut round: ReservationRound,
2996 ) -> Reserving<'_> {
2997 Box::pin(async move {
2998 let mut report = ReconcileReport::default();
2999 let reserved = self
3000 .reserve_next_launch(&policies, &mut round, &mut report)
3001 .await;
3002 (reserved, round, policies)
3003 })
3004 }
3005
3006 async fn reserve_next_launch(
3011 &self,
3012 policies: &[ScalePolicy],
3013 round: &mut ReservationRound,
3014 report: &mut ReconcileReport,
3015 ) -> Option<Reserved> {
3016 loop {
3017 let policy = policies
3018 .iter()
3019 .filter(|policy| {
3020 policy.owns_runners()
3021 && policy.is_owned_by(self.host.id)
3022 && policy.may_start_runners()
3023 && !round.exhausted.contains(&policy.id)
3024 })
3025 .min_by_key(|policy| self.director.next_waiting(policy).unwrap_or(u64::MAX))?;
3026 let (served, most) = round.reserved.entry(policy.id).or_default();
3027 if let Some(directed) = self.director.directed_demand(policy)
3029 && *served < directed.max(*most)
3030 {
3031 *most = directed.max(*most);
3032 let mut reserved = Vec::new();
3033 self.start_runners(policy, directed, 1, report, &mut reserved)
3034 .await;
3035 if let Some(next) = reserved.pop() {
3036 *served += 1;
3037 return Some(next);
3038 }
3039 }
3040 round.exhausted.insert(policy.id);
3041 }
3042 }
3043
3044 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
3058 let mut report = ReconcileReport::default();
3059 let mut reserved: Vec<Reserved> = Vec::new();
3063
3064 let mut pollable: Vec<&ScalePolicy> = Vec::new();
3066 let mut supervision_failed = BTreeSet::new();
3067 for policy in policies {
3068 if !policy.owns_runners() {
3069 report.monitor_only.push(policy.id);
3070 self.events
3071 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
3072 continue;
3073 }
3074 if !policy.is_owned_by(self.host.id) {
3075 continue;
3079 }
3080 match self.launcher.supervise(policy).await {
3081 Ok(intents) => {
3082 report.replacement_intents = report
3083 .replacement_intents
3084 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
3085 }
3086 Err(failure) => {
3087 supervision_failed.insert(policy.id);
3088 self.report_unreadable_attempts(&mut report, &failure);
3089 continue;
3090 }
3091 }
3092 if !policy.may_start_runners() {
3093 continue;
3094 }
3095 pollable.push(policy);
3096 }
3097
3098 let readings = self.poll_targets(&pollable, &mut report).await;
3099 report.targets_read = u16::try_from(
3100 readings
3101 .values()
3102 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
3103 .count(),
3104 )
3105 .unwrap_or(u16::MAX);
3106 let mut routed = BTreeMap::new();
3107 for (target, outcome) in &readings {
3108 let PollOutcome::Ready(reading) = outcome else {
3109 continue;
3110 };
3111 let profiles: Vec<&ScalePolicy> = pollable
3112 .iter()
3113 .copied()
3114 .filter(|policy| &policy.target == target)
3115 .collect();
3116 let (tallies, refusals) = route_demand(policies, &profiles, reading);
3117 if let Some(first) = profiles.first() {
3118 for (reason, count) in refusals {
3119 self.events.emit(LifecycleEvent::RoutingRefused {
3120 policy: first.id,
3121 reason,
3122 count,
3123 });
3124 }
3125 }
3126 routed.insert(target.clone(), tallies);
3127 }
3128
3129 self.clean_terminal_attempts(&mut report).await;
3139
3140 for policy in policies {
3155 if !policy.owns_runners() {
3156 continue;
3162 }
3163 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
3164 match self.allocate_only(policy, 0, &reserved).await {
3168 Ok(allocation) => {
3169 self.emit_allocation(&allocation);
3170 report.allocations.push(allocation);
3171 }
3172 Err(failure) => self.report_allocation_failure(&mut report, &failure),
3173 }
3174 continue;
3175 }
3176 if supervision_failed.contains(&policy.id) {
3177 continue;
3178 }
3179 if let Some(directed) = self.director.directed_demand(policy) {
3183 let Some(launch_side) = &self.launch_side else {
3184 self.start_runners(policy, directed, u16::MAX, &mut report, &mut reserved)
3185 .await;
3186 continue;
3187 };
3188 match self.allocate_only(policy, directed, &reserved).await {
3189 Ok(allocation) => {
3190 if !allocation.starts_nothing() {
3191 launch_side.notify_one();
3192 }
3193 report.allocations.push(allocation);
3194 }
3195 Err(failure) => self.report_allocation_failure(&mut report, &failure),
3196 }
3197 continue;
3198 }
3199 let Some(reading) = readings.get(&policy.target) else {
3200 debug_assert!(false, "a pollable policy's target has no reading");
3203 continue;
3204 };
3205 match reading {
3206 PollOutcome::Failed(state) => {
3207 report.unreadable.push(policy.id);
3208 self.events.emit(LifecycleEvent::TargetUnreadable {
3209 policy: policy.id,
3210 reason: unreadable_reason(state),
3211 });
3212 }
3213 PollOutcome::Ready(demand) => {
3214 let tally = routed
3215 .get(&policy.target)
3216 .and_then(|tallies| tallies.get(&policy.id))
3217 .expect("every pollable profile has a routed tally");
3218 let count = tally.demand();
3219 self.events.emit(LifecycleEvent::DemandObserved {
3220 policy: policy.id,
3221 demand: count,
3222 not_matched: tally.not_matched,
3223 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
3224 complete: demand.is_complete(),
3225 });
3226 self.start_runners(policy, count, u16::MAX, &mut report, &mut reserved)
3227 .await;
3228 }
3229 }
3230 }
3231 self.finish_reserved(reserved, &mut report).await;
3232
3233 let failure = readings
3235 .values()
3236 .filter_map(PollOutcome::failure)
3237 .max_by_key(|state| severity(state))
3238 .cloned();
3239 let now = self.clock.now();
3240 let conditions = PollConditions {
3241 quiet: is_quiet(&readings, &report),
3242 requests_per_pass: report.demand_requests,
3243 quota: readings
3244 .values()
3245 .filter_map(PollOutcome::reading)
3246 .filter_map(QueuedDemand::rate_limit)
3247 .max_by_key(|quota| quota.observed_at)
3248 .copied(),
3249 };
3250 report.next_poll =
3251 self.schedule
3252 .next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
3253 report.failure = failure;
3254 if let PollPace::Offline { consecutive } = report.next_poll.pace {
3258 let state = OfflineState::new(consecutive, report.next_poll.delay);
3259 report.offline = Some(match self.schedule.offline_for(now) {
3260 Some(elapsed) => state.since(elapsed),
3261 None => state,
3262 });
3263 }
3264 self.events.emit(LifecycleEvent::PollScheduled {
3265 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
3266 pace: report.next_poll.pace,
3267 });
3268
3269 report
3270 }
3271
3272 async fn poll_targets(
3280 &self,
3281 pollable: &[&ScalePolicy],
3282 report: &mut ReconcileReport,
3283 ) -> BTreeMap<ScaleTarget, PollOutcome> {
3284 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
3285
3286 let mut readings = BTreeMap::new();
3287 for target in targets {
3288 let outcome = match self.repositories.scope_for(&target).await {
3289 Ok(scope) => {
3290 report.demand_requests = report
3291 .demand_requests
3292 .saturating_add(demand_requests_per_poll(&scope));
3293 self.demand.poll(&scope).await
3294 }
3295 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
3301 };
3302 readings.insert(target, outcome);
3303 }
3304 readings
3305 }
3306
3307 async fn allocate_only(
3325 &self,
3326 policy: &ScalePolicy,
3327 demand: u32,
3328 reserved: &[Reserved],
3329 ) -> Result<Allocation, AllocationFailure> {
3330 self.allocate_over(policy, demand, reserved)
3331 .await
3332 .map(|(allocation, _)| allocation)
3333 }
3334
3335 async fn allocate_over(
3337 &self,
3338 policy: &ScalePolicy,
3339 demand: u32,
3340 reserved: &[Reserved],
3341 ) -> Result<(Allocation, Vec<RunnerAttempt>), AllocationFailure> {
3342 let mut host = self.host.clone();
3345 host.host_capacity = self
3346 .launcher
3347 .host_capacity(&self.host)
3348 .await
3349 .map_err(AllocationFailure::HostCapacity)?;
3350 let attempts = self
3351 .host_attempts(reserved)
3352 .await
3353 .map_err(AllocationFailure::Attempts)?;
3354 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
3355 let allocation = allocator.allocate(policy, demand);
3356 Ok((allocation, attempts))
3357 }
3358
3359 async fn start_runners(
3383 &self,
3384 policy: &ScalePolicy,
3385 demand: u32,
3386 limit: u16,
3387 report: &mut ReconcileReport,
3388 reserved: &mut Vec<Reserved>,
3389 ) {
3390 let intent = match self.allocate_only(policy, demand, reserved).await {
3402 Ok(intent) => intent,
3403 Err(failure) => {
3404 self.report_allocation_failure(report, &failure);
3405 self.director.start_failed(policy, None);
3406 return;
3407 }
3408 };
3409
3410 let mut decided: Option<Allocation> = None;
3414 let mut budget = intent.to_start.min(limit);
3415
3416 while budget > 0 {
3417 let order_wait = self.let_older_launches_reserve(policy).await;
3418 let demand = if order_wait.is_zero() {
3420 demand
3421 } else {
3422 self.director.directed_demand(policy).unwrap_or(demand)
3423 };
3424 let waiting_since = std::time::Instant::now();
3425 let guard = match self.lock.acquire().await {
3426 Ok(guard) => guard,
3427 Err(_) => {
3428 report.deferred = report.deferred.saturating_add(budget);
3431 self.events.emit(LifecycleEvent::AllocationDeferred {
3432 policy: policy.id,
3433 count: budget,
3434 });
3435 self.director.start_failed(policy, None);
3438 break;
3439 }
3440 };
3441 let lock_wait = waiting_since.elapsed();
3442
3443 let (allocation, attempts) = match self.allocate_over(policy, demand, reserved).await {
3449 Ok(decided) => decided,
3450 Err(failure) => {
3451 drop(guard);
3452 self.report_allocation_failure(report, &failure);
3453 self.director.start_failed(policy, None);
3454 break;
3455 }
3456 };
3457
3458 if decided.is_none() {
3459 budget = budget.min(allocation.to_start);
3463 decided = Some(allocation.clone());
3464 }
3465
3466 if allocation.starts_nothing() || budget == 0 {
3469 drop(guard);
3470 break;
3471 }
3472
3473 match self.memory.admit(policy, &attempts) {
3477 MemoryVerdict::Defer(figures) => {
3478 drop(guard);
3479 report.memory_deferred = report.memory_deferred.saturating_add(budget);
3480 self.events.emit(LifecycleEvent::MemoryShort {
3481 policy: policy.id,
3482 figures,
3483 started: false,
3484 });
3485 self.director.start_failed(policy, None);
3489 break;
3490 }
3491 MemoryVerdict::AdmitAlone(figures) => {
3492 self.events.emit(LifecycleEvent::MemoryShort {
3493 policy: policy.id,
3494 figures,
3495 started: true,
3496 });
3497 self.memory.admitted(policy);
3498 }
3499 MemoryVerdict::Admit(_) | MemoryVerdict::Unchecked => self.memory.admitted(policy),
3500 }
3501
3502 let created = self
3503 .launcher
3504 .launch(LaunchRequest {
3505 host: &self.host,
3506 policy,
3507 allocation_guard: &guard,
3508 })
3509 .await;
3510 let hold = guard.launch_hold();
3511 drop(guard);
3512
3513 match created {
3514 Ok(attempt) => {
3515 let id = attempt.id;
3516 self.director.launched(policy, id);
3519 budget -= 1;
3520 reserved.push(Reserved {
3524 policy: policy.clone(),
3525 attempt,
3526 waits: ReservationWaits {
3527 order: order_wait,
3528 lock: lock_wait,
3529 },
3530 reserved_at: std::time::Instant::now(),
3531 _hold: hold,
3532 });
3533 }
3534 Err(failure) => {
3535 self.note_start_failed(policy, None, &failure);
3536 break;
3537 }
3538 }
3539 }
3540
3541 let allocation = decided.unwrap_or(intent);
3542 self.emit_allocation(&allocation);
3543 report.allocations.push(allocation);
3544 }
3545
3546 async fn let_older_launches_reserve(&self, policy: &ScalePolicy) -> Duration {
3552 let began = tokio::time::Instant::now();
3553 let mut older = self.director.older_waiting(policy);
3554 let mut limit = began + OLDER_LAUNCH_TURN;
3555 while older > 0 {
3556 if tokio::time::Instant::now() >= limit {
3557 tracing::warn!(
3558 event = "older_launch_passed_over",
3559 policy_id = %policy.id,
3560 count = older,
3561 );
3562 self.director.pass_over_older(policy);
3563 break;
3564 }
3565 tokio::time::sleep(OLDER_LAUNCH_POLL).await;
3566 let still = self.director.older_waiting(policy);
3567 if still < older {
3568 limit = tokio::time::Instant::now() + OLDER_LAUNCH_TURN;
3569 }
3570 older = still;
3571 }
3572 began.elapsed()
3573 }
3574
3575 async fn finish_reserved(&self, reserved: Vec<Reserved>, report: &mut ReconcileReport) {
3583 let finished =
3584 futures::future::join_all(reserved.into_iter().map(|reserved| self.bring_up(reserved)))
3585 .await;
3586 let started = finished.into_iter().filter(|started| *started).count();
3587 report.started = report
3588 .started
3589 .saturating_add(u16::try_from(started).unwrap_or(u16::MAX));
3590 }
3591
3592 async fn bring_up(&self, reserved: Reserved) -> bool {
3594 let id = reserved.attempt.id;
3595 let mut waits = reserved.waits;
3596 waits.lock += reserved.reserved_at.elapsed();
3597 let result = self
3598 .launcher
3599 .finish_launch(&reserved.policy, reserved.attempt, waits)
3600 .await;
3601 match &result {
3604 Ok(_) => self.events.emit(LifecycleEvent::RunnerStarted {
3605 policy: reserved.policy.id,
3606 attempt: id,
3607 }),
3608 Err(failure) => self.note_start_failed(&reserved.policy, Some(id), failure),
3609 }
3610 result.is_ok()
3611 }
3612
3613 fn note_start_failed(
3614 &self,
3615 policy: &ScalePolicy,
3616 attempt: Option<AttemptId>,
3617 failure: &LaunchFailure,
3618 ) {
3619 self.events.emit(LifecycleEvent::RunnerStartFailed {
3620 policy: policy.id,
3621 reason: failure_reason_kind(&failure.reason),
3622 });
3623 self.director.start_failed(policy, attempt);
3624 }
3625
3626 async fn host_attempts(
3639 &self,
3640 reserved: &[Reserved],
3641 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3642 let mut attempts = self.launcher.attempts().await?;
3643
3644 debug_assert!(
3651 reserved
3652 .iter()
3653 .map(|reserved| reserved.attempt.id)
3654 .collect::<BTreeSet<AttemptId>>()
3655 .len()
3656 == reserved.len(),
3657 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
3658 the host ceiling is enforced against a set keyed on that identifier, so a \
3659 duplicate is two runtimes counted as one"
3660 );
3661
3662 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
3663 attempts.extend(
3664 reserved
3665 .iter()
3666 .filter(|reserved| !known.contains(&reserved.attempt.id))
3667 .map(|reserved| reserved.attempt.clone()),
3668 );
3669 Ok(attempts)
3670 }
3671
3672 fn report_allocation_failure(&self, report: &mut ReconcileReport, failure: &AllocationFailure) {
3673 match failure {
3674 AllocationFailure::HostCapacity(reason) => {
3675 report.host_capacity_unreadable = report.host_capacity_unreadable.saturating_add(1);
3676 self.events
3677 .emit(LifecycleEvent::HostCapacityUnreadable { reason: *reason });
3678 }
3679 AllocationFailure::Attempts(failure) => {
3680 self.report_unreadable_attempts(report, failure)
3681 }
3682 }
3683 }
3684
3685 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
3692 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
3693 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3700 reason: failure_reason_kind(&failure.reason),
3701 });
3702 }
3703
3704 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
3710 let attempts = match self.launcher.attempts().await {
3711 Ok(attempts) => attempts,
3712 Err(failure) => {
3713 self.report_unreadable_attempts(report, &failure);
3714 return;
3715 }
3716 };
3717 self.clean_backoff
3718 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
3719 let now = self.clock.now();
3720 for attempt in attempts {
3721 if !attempt.state().is_concluded() {
3722 continue;
3723 }
3724 let Some(outcome) = attempt.outcome() else {
3725 continue;
3726 };
3727 if !self.clean_backoff.is_due(attempt.id, now) {
3730 continue;
3731 }
3732 let kind = OutcomeKind::of(outcome);
3733 match self.launcher.clean(attempt.id).await {
3734 Ok(()) => {
3735 self.clean_backoff.succeeded(attempt.id);
3736 report.cleaned = report.cleaned.saturating_add(1);
3737 if kind.is_failure() {
3738 report.failures = report.failures.saturating_add(1);
3739 } else if kind == OutcomeKind::IdleExit {
3740 report.idle_exits = report.idle_exits.saturating_add(1);
3745 }
3746 self.events.emit(LifecycleEvent::AttemptCleaned {
3747 policy: attempt.policy_id,
3748 attempt: attempt.id,
3749 outcome: kind,
3750 });
3751 }
3752 Err(failure) => {
3762 self.clean_backoff.failed(attempt.id, now);
3763 report.clean_failures = report.clean_failures.saturating_add(1);
3764 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3765 policy: attempt.policy_id,
3766 attempt: attempt.id,
3767 reason: failure_reason_kind(&failure.reason),
3768 });
3769 }
3770 }
3771 }
3772 }
3773
3774 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
3782 let mut report = ScaleDownReport::default();
3783 let attempts = match self.launcher.attempts().await {
3784 Ok(attempts) => attempts,
3785 Err(failure) => {
3786 self.events.emit(LifecycleEvent::AttemptsUnreadable {
3791 reason: failure_reason_kind(&failure.reason),
3792 });
3793 report.attempts_unreadable = true;
3794 return report;
3795 }
3796 };
3797 for attempt in attempts {
3798 if attempt.policy_id != policy.id {
3799 continue;
3800 }
3801 match attempt.state() {
3802 AttemptState::Busy => {
3803 report.refused_busy = report.refused_busy.saturating_add(1);
3804 self.events.emit(LifecycleEvent::ScaleDownRefused {
3805 policy: policy.id,
3806 attempt: attempt.id,
3807 });
3808 }
3809 state if state.is_concluded() => {
3810 let kind = attempt
3811 .outcome()
3812 .map_or(OutcomeKind::Failed, OutcomeKind::of);
3813 match self.launcher.clean(attempt.id).await {
3814 Ok(()) => {
3815 report.removed = report.removed.saturating_add(1);
3816 self.events.emit(LifecycleEvent::AttemptCleaned {
3817 policy: policy.id,
3818 attempt: attempt.id,
3819 outcome: kind,
3820 });
3821 }
3822 Err(failure) => {
3823 report.clean_failures = report.clean_failures.saturating_add(1);
3824 self.events.emit(LifecycleEvent::AttemptCleanFailed {
3825 policy: policy.id,
3826 attempt: attempt.id,
3827 reason: failure_reason_kind(&failure.reason),
3828 });
3829 }
3830 }
3831 }
3832 AttemptState::Cleaned => {}
3833 _ => report.retained = report.retained.saturating_add(1),
3836 }
3837 }
3838 report
3839 }
3840
3841 fn emit_allocation(&self, allocation: &Allocation) {
3842 self.events.emit(LifecycleEvent::Allocated {
3843 policy: allocation.policy_id,
3844 demand: allocation.demand,
3845 desired: allocation.desired,
3846 active_owned: allocation.active_owned,
3847 headroom: allocation.headroom_before,
3848 to_start: allocation.to_start,
3849 limiting: allocation.limiting_factor,
3850 });
3851 }
3852}
3853
3854fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
3878 let Some(labels) = policy.routing_labels() else {
3879 debug_assert!(
3880 false,
3881 "a monitor-only policy is skipped before the demand poll (D19)"
3882 );
3883 return DemandTally::default();
3884 };
3885
3886 match &policy.target {
3887 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
3888 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
3889 }
3890}
3891
3892fn route_demand(
3896 all_policies: &[ScalePolicy],
3897 profiles: &[&ScalePolicy],
3898 reading: &QueuedDemand,
3899) -> (
3900 BTreeMap<PolicyId, DemandTally>,
3901 BTreeMap<RoutingRefusal, u32>,
3902) {
3903 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
3904 .iter()
3905 .map(|policy| (policy.id, demand_for(policy, reading)))
3906 .collect();
3907 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
3908 let Some(first) = profiles.first() else {
3909 return (tallies, refusals);
3910 };
3911 let owners: Vec<_> = all_policies
3912 .iter()
3913 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3914 .filter(|policy| policy.owns_runners())
3915 .collect();
3916 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3917 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3918 ScaleTarget::Organization(_) => reading.jobs().collect(),
3919 };
3920 for job in jobs {
3921 let Ok(_) = job.required_labels() else {
3922 continue;
3924 };
3925 let matched: Vec<PolicyId> = owners
3926 .iter()
3927 .filter(|policy| {
3928 policy
3929 .routing_labels()
3930 .is_some_and(|labels| labels.matches(job).is_match())
3931 })
3932 .map(|policy| policy.id)
3933 .collect();
3934 if matched.len() > 1 {
3935 *refusals
3936 .entry(RoutingRefusal::OverlappingProfiles)
3937 .or_default() += 1;
3938 for id in matched {
3939 if let Some(tally) = tallies.get_mut(&id) {
3940 tally.matched -= 1;
3941 tally.not_matched += 1;
3942 }
3943 }
3944 }
3945 }
3946 (tallies, refusals)
3947}
3948
3949const fn severity(state: &RefreshState) -> u8 {
3956 match state {
3957 RefreshState::Offline => 5,
3958 RefreshState::RateLimited(_) => 4,
3959 RefreshState::LockedOut { .. } => 3,
3960 RefreshState::Unauthorized => 2,
3961 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3962 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3963 }
3964}
3965
3966const fn unreadable_reason(state: &RefreshState) -> &'static str {
3977 match state {
3978 RefreshState::Ready(_) => "ready",
3979 RefreshState::Offline => "offline",
3980 RefreshState::RateLimited(_) => "rate_limited",
3981 RefreshState::LockedOut { .. } => "locked_out",
3982 RefreshState::Unauthorized => "unauthorized",
3983 RefreshState::Forbidden { .. } => "forbidden",
3984 RefreshState::Failed { .. } => "failed",
3985 RefreshState::Cancelled => "cancelled",
3986 }
3987}
3988
3989#[cfg(test)]
3990mod tests {
3991 use super::*;
3992
3993 #[test]
4001 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
4002 let mut report = ReconcileReport::default();
4003 assert!(
4004 !report.reached_github(),
4005 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
4006 monitor-only -- reached nobody. This is the case the old guard let through, and \
4007 the only one it ever let through."
4008 );
4009
4010 report.unreadable.push(PolicyId::from_u128(1));
4011 assert!(
4012 !report.reached_github(),
4013 "every target this pass tried was unreadable, so there is no contact to record"
4014 );
4015
4016 report.targets_read = 1;
4017 assert!(
4018 report.reached_github(),
4019 "one target answering is contact, whatever else failed alongside it"
4020 );
4021
4022 let mut unowned = ReconcileReport::default();
4026 unowned.unreadable.push(PolicyId::from_u128(2));
4027 unowned.allocations.push(Allocation {
4028 policy_id: PolicyId::from_u128(2),
4029 demand: 0,
4030 desired: 0,
4031 active_owned: 0,
4032 headroom_before: 0,
4033 to_start: 0,
4034 limiting_factor: LimitingFactor::Demand,
4035 });
4036 assert!(
4037 !unowned.reached_github(),
4038 "an allocation is not evidence that GitHub answered"
4039 );
4040 }
4041
4042 #[test]
4052 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
4053 assert!(
4054 severity(&RefreshState::Unauthorized) > 0,
4055 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
4056 or a pass where every target was refused would report no failure at all"
4057 );
4058 }
4059
4060 use std::sync::atomic::AtomicUsize;
4061
4062 use std::num::NonZeroU16;
4063
4064 use runner_manager_domain::attempt::PersistedAttempt;
4065 use runner_manager_domain::model::{CachePolicy, HostId};
4066 use runner_manager_domain::policy::PolicyMode;
4067 use runner_manager_domain::workspace::WorkspaceKind;
4068 use runner_manager_github::rest::RateLimited;
4069 use runner_manager_testkit::clock::FakeClock;
4070 use runner_manager_testkit::fixtures;
4071 use runner_manager_testkit::github::FakeGithub;
4072
4073 fn host_with(capacity: u16) -> Host {
4078 fixtures::host().capacity(capacity).build()
4079 }
4080
4081 fn repo(raw: &str) -> OwnerRepo {
4082 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
4083 }
4084
4085 use runner_manager_domain::policy::RunsOn;
4087
4088 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
4089 fixtures::policy()
4090 .id(PolicyId::from_u128(id))
4091 .repository(target)
4092 .autoscale("home", max)
4093 .active()
4094 .build()
4095 }
4096
4097 const HOST_LABEL: &str = "rm-home-win-x64";
4104
4105 fn jobs(n: usize) -> Vec<RunsOn> {
4111 fixtures::queued_jobs(&[HOST_LABEL], n)
4112 }
4113
4114 #[derive(Debug, Default)]
4116 struct FakeLauncher {
4117 capacity: Mutex<Option<NonZeroU16>>,
4118 capacity_unreadable: AtomicBool,
4119 capacity_after_launch: Mutex<Option<NonZeroU16>>,
4120 attempts: Mutex<Vec<RunnerAttempt>>,
4121 next_id: AtomicU64,
4122 launches: AtomicUsize,
4123 cleaned: Mutex<Vec<AttemptId>>,
4124 yields_before_recording: usize,
4127 forgetful: bool,
4131 fail_next: Mutex<Option<FailureReason>>,
4132 attempts_fail: Mutex<bool>,
4135 clean_fails: bool,
4138 replacements: Mutex<Vec<ReplacementIntent>>,
4139 }
4140
4141 impl FakeLauncher {
4142 fn new() -> Self {
4143 Self::default()
4144 }
4145
4146 fn with_yields(mut self, yields: usize) -> Self {
4147 self.yields_before_recording = yields;
4148 self
4149 }
4150
4151 fn forgetful() -> Self {
4152 Self {
4153 forgetful: true,
4154 ..Self::default()
4155 }
4156 }
4157
4158 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
4159 *self.attempts.lock().unwrap() = attempts;
4160 self
4161 }
4162
4163 fn launches(&self) -> usize {
4164 self.launches.load(Ordering::SeqCst)
4165 }
4166
4167 fn snapshot(&self) -> Vec<RunnerAttempt> {
4168 self.attempts.lock().unwrap().clone()
4169 }
4170
4171 fn live_count(&self) -> usize {
4172 self.snapshot()
4173 .iter()
4174 .filter(|a| a.counts_against_capacity())
4175 .count()
4176 }
4177
4178 fn fail_next(&self, reason: FailureReason) {
4179 *self.fail_next.lock().unwrap() = Some(reason);
4180 }
4181
4182 fn fail_attempts(&self, failing: bool) {
4183 *self.attempts_fail.lock().unwrap() = failing;
4184 }
4185
4186 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
4187 Self {
4188 clean_fails: true,
4189 ..Self::default()
4190 }
4191 .seeded(attempts)
4192 }
4193
4194 fn cleaned(&self) -> Vec<AttemptId> {
4195 self.cleaned.lock().unwrap().clone()
4196 }
4197
4198 fn replacing(self, intent: ReplacementIntent) -> Self {
4199 self.replacements.lock().unwrap().push(intent);
4200 self
4201 }
4202 }
4203
4204 #[async_trait::async_trait]
4205 impl RunnerLauncher for FakeLauncher {
4206 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
4207 if self.capacity_unreadable.load(Ordering::SeqCst) {
4208 return Err(HostCapacityError::JournalUnreadable);
4209 }
4210 Ok(self.capacity.lock().unwrap().unwrap_or(host.host_capacity))
4211 }
4212
4213 async fn supervise(
4214 &self,
4215 policy: &ScalePolicy,
4216 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4217 let mut replacements = self.replacements.lock().unwrap();
4218 let selected: Vec<_> = replacements
4219 .extract_if(.., |intent| intent.policy == policy.id)
4220 .collect();
4221 if !selected.is_empty() {
4222 let retired: BTreeSet<_> = selected
4223 .iter()
4224 .map(|intent| intent.previous_attempt)
4225 .collect();
4226 self.attempts
4227 .lock()
4228 .unwrap()
4229 .retain(|attempt| !retired.contains(&attempt.id));
4230 }
4231 Ok(selected)
4232 }
4233
4234 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4235 if *self.attempts_fail.lock().unwrap() {
4236 return Err(LaunchFailure::new(FailureReason::Other(
4237 "the journal could not be read".into(),
4238 )));
4239 }
4240 Ok(self.snapshot())
4241 }
4242
4243 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4244 if let Some(reason) = self.fail_next.lock().unwrap().take() {
4245 return Err(LaunchFailure::new(reason));
4246 }
4247 for _ in 0..self.yields_before_recording {
4249 tokio::task::yield_now().await;
4250 }
4251 let id =
4252 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
4253 let created = RunnerAttempt::allocate(
4254 id,
4255 request.policy.id,
4256 "runtime/p/a",
4257 request.host.created_at,
4258 );
4259 self.launches.fetch_add(1, Ordering::SeqCst);
4260 if let Some(capacity) = self.capacity_after_launch.lock().unwrap().take() {
4261 *self.capacity.lock().unwrap() = Some(capacity);
4262 }
4263 if !self.forgetful {
4264 self.attempts.lock().unwrap().push(created.clone());
4265 }
4266 Ok(created)
4267 }
4268
4269 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
4270 if self.clean_fails {
4271 return Err(LaunchFailure::new(FailureReason::Other(
4272 "the runtime directory is locked".into(),
4273 )));
4274 }
4275 self.cleaned.lock().unwrap().push(attempt);
4276 let mut attempts = self.attempts.lock().unwrap();
4277 attempts.retain(|a| a.id != attempt);
4278 Ok(())
4279 }
4280 }
4281
4282 #[derive(Debug, Default)]
4284 struct FakeDemand {
4285 outcome: Mutex<Option<PollOutcome>>,
4286 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
4288 scopes: Mutex<Vec<ActivityScope>>,
4289 }
4290
4291 impl FakeDemand {
4292 fn ready(count: u32, repository: &OwnerRepo) -> Self {
4293 let fake = Self::default();
4294 fake.set(PollOutcome::Ready(QueuedDemand::of(
4295 repository.clone(),
4296 jobs(count as usize),
4297 )));
4298 fake
4299 }
4300
4301 fn failing(state: RefreshState) -> Self {
4302 let fake = Self::default();
4303 fake.set(PollOutcome::Failed(state));
4304 fake
4305 }
4306
4307 fn set(&self, outcome: PollOutcome) {
4308 *self.outcome.lock().unwrap() = Some(outcome);
4309 }
4310
4311 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
4313 self.per_target
4314 .lock()
4315 .unwrap()
4316 .insert(target.clone(), outcome);
4317 }
4318
4319 fn polls(&self) -> Vec<ActivityScope> {
4320 self.scopes.lock().unwrap().clone()
4321 }
4322 }
4323
4324 #[async_trait::async_trait]
4325 impl DemandSource for FakeDemand {
4326 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
4327 self.scopes.lock().unwrap().push(scope.clone());
4328 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
4329 return outcome.clone();
4330 }
4331 self.outcome
4332 .lock()
4333 .unwrap()
4334 .clone()
4335 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
4336 }
4337 }
4338
4339 #[derive(Debug, Default)]
4340 struct FakeDirectory {
4341 repositories: Vec<OwnerRepo>,
4342 calls: AtomicUsize,
4343 }
4344
4345 impl FakeDirectory {
4346 fn of(repositories: Vec<OwnerRepo>) -> Self {
4347 Self {
4348 repositories,
4349 calls: AtomicUsize::new(0),
4350 }
4351 }
4352
4353 fn calls(&self) -> usize {
4354 self.calls.load(Ordering::SeqCst)
4355 }
4356 }
4357
4358 #[async_trait::async_trait]
4359 impl RepositoryDirectory for FakeDirectory {
4360 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
4361 self.calls.fetch_add(1, Ordering::SeqCst);
4362 Ok(self.repositories.clone())
4363 }
4364 }
4365
4366 #[derive(Debug)]
4372 struct CountingLock {
4373 inner: InProcessAllocationLock,
4374 concurrent: Arc<AtomicUsize>,
4375 peak: Arc<AtomicUsize>,
4376 acquisitions: Arc<AtomicUsize>,
4377 }
4378
4379 impl CountingLock {
4380 fn new() -> Self {
4381 Self {
4382 inner: InProcessAllocationLock::new(),
4383 concurrent: Arc::new(AtomicUsize::new(0)),
4384 peak: Arc::new(AtomicUsize::new(0)),
4385 acquisitions: Arc::new(AtomicUsize::new(0)),
4386 }
4387 }
4388
4389 fn peak(&self) -> usize {
4390 self.peak.load(Ordering::SeqCst)
4391 }
4392
4393 fn acquisitions(&self) -> usize {
4394 self.acquisitions.load(Ordering::SeqCst)
4395 }
4396 }
4397
4398 #[derive(Debug)]
4399 struct CountingGuard {
4400 _inner: AllocationGuard,
4401 concurrent: Arc<AtomicUsize>,
4402 }
4403
4404 impl Drop for CountingGuard {
4405 fn drop(&mut self) {
4406 self.concurrent.fetch_sub(1, Ordering::SeqCst);
4407 }
4408 }
4409
4410 #[async_trait::async_trait]
4411 impl AllocationLock for CountingLock {
4412 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4413 let inner = self.inner.acquire().await?;
4414 self.acquisitions.fetch_add(1, Ordering::SeqCst);
4415 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
4416 self.peak.fetch_max(now, Ordering::SeqCst);
4417 Ok(AllocationGuard::new(CountingGuard {
4418 _inner: inner,
4419 concurrent: Arc::clone(&self.concurrent),
4420 }))
4421 }
4422 }
4423
4424 #[derive(Debug, Default)]
4427 struct NoLock;
4428
4429 #[async_trait::async_trait]
4430 impl AllocationLock for NoLock {
4431 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4432 Ok(AllocationGuard::new(()))
4433 }
4434 }
4435
4436 #[tokio::test]
4437 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
4438 use runner_manager_platform::wsl::fence::DrainRequest;
4439
4440 let (_local, shared, paths) = guest_paths();
4441 DrainRequest::new(4, chrono::Utc::now())
4442 .write(shared.path())
4443 .unwrap();
4444 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4445
4446 assert!(lock.acquire().await.is_err());
4447 }
4448
4449 #[tokio::test]
4450 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
4451 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4452
4453 let (_local, shared, paths) = guest_paths();
4454 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4455 let guard = lock.acquire().await.unwrap();
4456 assert!(shared.path().join(FENCE_DIRECTORY).exists());
4457 drop(guard);
4458 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4459 }
4460
4461 #[derive(Clone, Default)]
4463 struct Logs(Arc<Mutex<Vec<u8>>>);
4464
4465 impl std::io::Write for Logs {
4466 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4467 self.0.lock().unwrap().extend_from_slice(buf);
4468 Ok(buf.len())
4469 }
4470 fn flush(&mut self) -> std::io::Result<()> {
4471 Ok(())
4472 }
4473 }
4474
4475 #[tokio::test]
4478 async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
4479 use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4480
4481 let (_local, shared, paths) = guest_paths();
4482 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4483 let guard = lock.acquire().await.unwrap();
4484 let fence = shared.path().join(FENCE_DIRECTORY);
4487 std::fs::remove_dir_all(&fence).unwrap();
4488 std::fs::write(&fence, b"not a directory").unwrap();
4489
4490 let logs = Logs::default();
4491 let writer = logs.clone();
4492 let subscriber = tracing_subscriber::fmt()
4493 .with_writer(move || writer.clone())
4494 .with_ansi(false)
4495 .finish();
4496 tracing::subscriber::with_default(subscriber, || drop(guard));
4497
4498 let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
4499 assert!(text.contains("WARN"), "{text}");
4500 assert!(text.contains("could not be released"), "{text}");
4501 assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
4502 }
4503
4504 fn guest_paths() -> (
4505 tempfile::TempDir,
4506 tempfile::TempDir,
4507 Arc<runner_manager_platform::paths::AppPaths>,
4508 ) {
4509 use runner_manager_platform::paths::AppPaths;
4510 use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
4511
4512 let local = tempfile::tempdir().unwrap();
4513 let shared = tempfile::tempdir().unwrap();
4514 let paths = Arc::new(AppPaths::rooted_at(local.path()));
4515 paths.create_all().unwrap();
4516 GuestRecoveryConfig::new(shared.path().to_path_buf())
4517 .write(&paths)
4518 .unwrap();
4519 (local, shared, paths)
4520 }
4521
4522 #[tokio::test]
4525 async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
4526 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
4527
4528 let (_local, shared, paths) = guest_paths();
4529 let fence = shared.path().join(FENCE_DIRECTORY);
4530 std::fs::create_dir_all(&fence).unwrap();
4531 let pid = std::process::id();
4532 std::fs::write(
4533 fence.join(OWNER_FILE),
4534 format!(
4535 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"}}}}"#
4536 ),
4537 )
4538 .unwrap();
4539 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4540
4541 let guard = lock.acquire().await.expect("the stale claim was reclaimed");
4542 drop(guard);
4543 assert!(!fence.exists());
4544 }
4545
4546 #[tokio::test]
4547 async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
4548 use runner_manager_platform::launch_health::recorded_launches_blocked;
4549 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4550
4551 let (_local, shared, paths) = guest_paths();
4552 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4553 .with_blocked_after(3, Duration::ZERO);
4554 let windows =
4556 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4557 .unwrap()
4558 .unwrap();
4559
4560 for _ in 0..2 {
4561 assert!(lock.acquire().await.is_err());
4562 }
4563 assert_eq!(
4564 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4565 None,
4566 "not yet"
4567 );
4568 assert!(lock.acquire().await.is_err());
4569 let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
4570 .unwrap()
4571 .expect("three refusals in a row are recorded");
4572 assert!(
4573 blocked.reason.contains("Windows WSL recovery"),
4574 "{blocked:?}"
4575 );
4576
4577 windows.release().unwrap();
4578 let guard = lock.acquire().await.unwrap();
4579 assert_eq!(
4580 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4581 None
4582 );
4583 drop(guard);
4584 }
4585
4586 #[tokio::test]
4587 async fn refusals_while_this_process_is_launching_are_its_own_contention() {
4588 use runner_manager_platform::launch_health::recorded_launches_blocked;
4589
4590 use runner_manager_platform::wsl::fence::DrainRequest;
4591
4592 let (_local, shared, paths) = guest_paths();
4593 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4594 .with_blocked_after(1, Duration::ZERO);
4595 let launching = lock.acquire().await.unwrap();
4596 DrainRequest::new(1, chrono::Utc::now())
4597 .write(shared.path())
4598 .unwrap();
4599 for _ in 0..3 {
4600 assert!(lock.acquire().await.is_err());
4601 }
4602 assert_eq!(
4603 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4604 None
4605 );
4606 drop(launching);
4607 }
4608
4609 #[tokio::test]
4613 async fn launches_in_progress_share_the_fence_until_the_last_ends() {
4614 use runner_manager_platform::wsl::fence::{DrainRequest, FenceClaim, FenceOwnerKind};
4615
4616 let (_local, shared, paths) = guest_paths();
4617 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4618 let claimed = |root: &std::path::Path| FenceClaim::owner(root).unwrap().is_some();
4619
4620 let first = lock.acquire().await.unwrap();
4621 let first_runner = first.launch_hold();
4622 drop(first);
4623 assert!(claimed(shared.path()), "held while its runner comes up");
4624
4625 let claimed_at = FenceClaim::owner(shared.path())
4626 .unwrap()
4627 .unwrap()
4628 .acquired_at;
4629 tokio::time::sleep(Duration::from_millis(5)).await;
4630 let second = lock.acquire().await.expect("this process owns the fence");
4631 assert!(
4632 FenceClaim::owner(shared.path())
4633 .unwrap()
4634 .unwrap()
4635 .acquired_at
4636 > claimed_at,
4637 "a claim launches keep sharing is not one stuck for that long"
4638 );
4639 let second_runner = second.launch_hold();
4640 drop(second);
4641 drop(first_runner);
4642 assert!(claimed(shared.path()), "the other launch is still running");
4643
4644 DrainRequest::new(1, chrono::Utc::now())
4646 .write(shared.path())
4647 .unwrap();
4648 assert!(lock.acquire().await.is_err());
4649 assert!(claimed(shared.path()));
4650 assert!(
4651 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4652 .unwrap()
4653 .is_none()
4654 );
4655 drop(second_runner);
4656 assert!(!claimed(shared.path()), "the last one lets it go");
4657 assert!(
4658 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4659 .unwrap()
4660 .is_some()
4661 );
4662 }
4663
4664 #[tokio::test]
4667 async fn refusals_with_quiet_spells_between_them_never_add_up() {
4668 use runner_manager_platform::launch_health::recorded_launches_blocked;
4669 use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4670
4671 let (_local, shared, paths) = guest_paths();
4672 let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4673 .with_blocked_after(3, Duration::ZERO)
4674 .with_streak_gap(Duration::ZERO);
4675 let windows =
4676 FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4677 .unwrap()
4678 .unwrap();
4679 for _ in 0..5 {
4680 assert!(lock.acquire().await.is_err());
4681 }
4682 assert_eq!(
4683 recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4684 None
4685 );
4686 windows.release().unwrap();
4687 }
4688
4689 #[tokio::test]
4692 async fn a_claim_refused_by_a_drain_request_is_released() {
4693 use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
4694
4695 let (_local, shared, paths) = guest_paths();
4696 DrainRequest::new(4, chrono::Utc::now())
4697 .write(shared.path())
4698 .unwrap();
4699 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4700 assert!(lock.acquire().await.is_err());
4701 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4702 }
4703
4704 #[derive(Debug, Default)]
4706 struct HeldLock;
4707
4708 #[async_trait::async_trait]
4709 impl AllocationLock for HeldLock {
4710 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4711 Err(AllocationLockBusy)
4712 }
4713 }
4714
4715 struct Harness {
4717 launcher: Arc<FakeLauncher>,
4718 demand: Arc<FakeDemand>,
4719 events: Arc<EventLog>,
4720 clock: Arc<FakeClock>,
4721 reconciler: Reconciler,
4722 }
4723
4724 impl Harness {
4725 fn build(
4726 host: Host,
4727 launcher: Arc<FakeLauncher>,
4728 demand: Arc<FakeDemand>,
4729 lock: Arc<dyn AllocationLock>,
4730 ) -> Self {
4731 let events = Arc::new(EventLog::new());
4732 let clock = Arc::new(FakeClock::default());
4733 let reconciler = Reconciler::new(
4734 host,
4735 ReconcilerPorts {
4736 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4737 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4738 lock,
4739 directory: Arc::new(FakeDirectory::default()),
4740 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4741 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
4742 events: Arc::clone(&events) as Arc<dyn EventSink>,
4743 },
4744 );
4745 Self {
4746 launcher,
4747 demand,
4748 events,
4749 clock,
4750 reconciler,
4751 }
4752 }
4753
4754 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
4755 let launcher = Arc::new(FakeLauncher::new());
4756 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
4757 Self::build(
4758 host_with(capacity),
4759 launcher,
4760 demand,
4761 Arc::new(InProcessAllocationLock::new()),
4762 )
4763 }
4764 }
4765
4766 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
4767 let outcome = state.is_terminal().then(|| match state {
4768 AttemptState::Failed => {
4769 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
4770 }
4771 AttemptState::Orphaned => AttemptOutcome::Orphaned,
4772 _ => AttemptOutcome::CompletedJob,
4773 });
4774 RunnerAttempt::from_persisted(PersistedAttempt {
4775 id: AttemptId::from_u128(id),
4776 policy_id: PolicyId::from_u128(policy),
4777 github_runner_id: None,
4778 state,
4779 outcome,
4780 process_id: None,
4781 execution: runner_manager_domain::execution::AttemptExecution::Native {
4782 process_id: None,
4783 },
4784 runtime_path: "runtime/p/a".into(),
4785 workspace_kind: WorkspaceKind::Ephemeral,
4786 workspace_slot: None,
4787 created_at: fixtures::created_at(),
4788 terminal_at: state.is_terminal().then(fixtures::created_at),
4789 last_state_change_at: fixtures::created_at(),
4790 })
4791 .expect("a state/outcome pair the domain accepts")
4792 }
4793
4794 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
4796 RunnerAttempt::from_persisted(PersistedAttempt {
4797 id: AttemptId::from_u128(id),
4798 policy_id: PolicyId::from_u128(policy),
4799 github_runner_id: None,
4800 state: outcome.terminal_state(),
4801 outcome: Some(outcome),
4802 process_id: None,
4803 execution: runner_manager_domain::execution::AttemptExecution::Native {
4804 process_id: None,
4805 },
4806 runtime_path: "runtime/p/a".into(),
4807 workspace_kind: WorkspaceKind::Ephemeral,
4808 workspace_slot: None,
4809 created_at: fixtures::created_at(),
4810 terminal_at: Some(fixtures::created_at()),
4811 last_state_change_at: fixtures::created_at(),
4812 })
4813 .expect("a state/outcome pair the domain accepts")
4814 }
4815
4816 #[tokio::test]
4845 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
4846 let mut harness = Harness::simple(4, 1, "acme/app");
4847 let policy = policy(1, "acme/app", 4);
4848
4849 for poll in 1..=3 {
4850 let report = harness
4851 .reconciler
4852 .reconcile(std::slice::from_ref(&policy))
4853 .await;
4854 assert_eq!(
4855 harness.launcher.launches(),
4856 1,
4857 "poll {poll} started another runner for a job already being served; the \
4858 `- active_owned_runners` term reached `HostAllocator` as a set this host \
4859 does not hold"
4860 );
4861 assert_eq!(report.allocations.len(), 1);
4862 let allocation = &report.allocations[0];
4863 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
4864 if poll == 1 {
4865 assert_eq!(allocation.to_start, 1);
4866 assert_eq!(report.started, 1);
4867 } else {
4868 assert_eq!(allocation.active_owned, 1, "poll {poll}");
4869 assert_eq!(allocation.to_start, 0, "poll {poll}");
4870 assert_eq!(report.started, 0, "poll {poll}");
4871 }
4872 }
4873 assert_eq!(harness.launcher.live_count(), 1);
4874 }
4875
4876 #[tokio::test]
4877 async fn draining_parallel_workflow_starts_runners_for_the_remaining_queue() {
4878 let mut harness = Harness::simple(10, 8, "acme/app");
4879 let policy = policy(1, "acme/app", 10);
4880 let first = harness
4881 .reconciler
4882 .reconcile(std::slice::from_ref(&policy))
4883 .await;
4884 assert_eq!(first.started, 8);
4885
4886 let attempts = harness.launcher.snapshot();
4888 *harness.launcher.attempts.lock().unwrap() = attempts
4889 .iter()
4890 .take(3)
4891 .map(|attempt| attempt_in(AttemptState::Busy, attempt.id.as_uuid().as_u128(), 1))
4892 .collect();
4893 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4894 repo("acme/app"),
4895 jobs(2),
4896 )));
4897
4898 for poll in 0..3 {
4899 let report = harness
4900 .reconciler
4901 .reconcile(std::slice::from_ref(&policy))
4902 .await;
4903 assert_eq!(report.started, if poll == 0 { 2 } else { 0 });
4904 assert_eq!(harness.launcher.live_count(), 5);
4905 }
4906 }
4907
4908 #[tokio::test]
4919 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4920 let launcher = Arc::new(FakeLauncher::forgetful());
4921 let mut harness = Harness::build(
4922 host_with(64),
4923 Arc::clone(&launcher),
4924 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4925 Arc::new(InProcessAllocationLock::new()),
4926 );
4927
4928 let report = harness
4929 .reconciler
4930 .reconcile(&[policy(1, "acme/app", 8)])
4931 .await;
4932
4933 assert_eq!(
4934 report.started, 3,
4935 "the pass is bounded by the grant it was given, not by the attempt set catching \
4936 up with it"
4937 );
4938 assert_eq!(launcher.launches(), 3);
4939 assert!(
4940 launcher.snapshot().is_empty(),
4941 "the launcher never recorded anything, which is the whole point of the fixture"
4942 );
4943 }
4944
4945 #[tokio::test]
4957 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4958 let launcher = Arc::new(FakeLauncher::forgetful());
4959 let demand = Arc::new(FakeDemand::default());
4960 demand.set_for(
4961 &ScaleTarget::repository("acme/left").unwrap(),
4962 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4963 );
4964 demand.set_for(
4965 &ScaleTarget::repository("acme/right").unwrap(),
4966 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4967 );
4968 let mut harness = Harness::build(
4969 host_with(3),
4970 Arc::clone(&launcher),
4971 demand,
4972 Arc::new(InProcessAllocationLock::new()),
4973 );
4974
4975 let report = harness
4976 .reconciler
4977 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4978 .await;
4979
4980 assert_eq!(
4981 report.started, 3,
4982 "host_capacity is 3 and two policies each allowed 3 started {} runners \
4983 between them; the second policy's grant was computed from a set that did \
4984 not yet contain the first policy's launches",
4985 report.started
4986 );
4987 assert_eq!(launcher.launches(), 3);
4988 }
4989
4990 #[tokio::test]
5001 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
5002 let launcher = Arc::new(FakeLauncher::new());
5003 let mut harness = Harness::build(
5004 host_with(8),
5005 Arc::clone(&launcher),
5006 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
5007 Arc::new(InProcessAllocationLock::new()),
5008 );
5009 let policy = policy(1, "acme/app", 8);
5010
5011 launcher.fail_attempts(true);
5012 let unreadable = harness
5013 .reconciler
5014 .reconcile(std::slice::from_ref(&policy))
5015 .await;
5016
5017 assert_eq!(
5018 unreadable.started, 0,
5019 "nothing may be decided from a set that was not read"
5020 );
5021 assert_eq!(launcher.launches(), 0);
5022 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
5023 assert!(
5024 unreadable.allocations.is_empty(),
5025 "no allocation is reported either: there was no set to compute one from, and \
5026 an allocation of zero would claim a decision nobody made"
5027 );
5028 assert!(harness.events.count_of("attempts_unreadable") > 0);
5029
5030 launcher.fail_attempts(false);
5032 let readable = harness
5033 .reconciler
5034 .reconcile(std::slice::from_ref(&policy))
5035 .await;
5036 assert_eq!(
5037 readable.started, 4,
5038 "the difference between the two passes is only whether the set could be read"
5039 );
5040 assert_eq!(readable.attempts_unreadable, 0);
5041 }
5042
5043 #[tokio::test]
5053 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
5054 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
5055 1,
5056 1,
5057 AttemptOutcome::ExitedIdleWithoutWork,
5058 )]));
5059 let mut harness = Harness::build(
5060 host_with(4),
5061 Arc::clone(&launcher),
5062 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5063 Arc::new(InProcessAllocationLock::new()),
5064 );
5065
5066 let report = harness
5067 .reconciler
5068 .reconcile(&[policy(1, "acme/app", 4)])
5069 .await;
5070
5071 assert_eq!(report.cleaned, 0);
5072 assert_eq!(report.clean_failures, 1);
5073 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
5074 assert_eq!(
5075 harness.events.count_of("attempt_cleaned"),
5076 0,
5077 "and it is not reported as cleaned"
5078 );
5079 assert_eq!(
5080 launcher.snapshot().len(),
5081 1,
5082 "the attempt is still there, so the retry is real -- what changed is that it \
5083 is no longer silent"
5084 );
5085
5086 let reasons: Vec<&'static str> = harness
5089 .events
5090 .events()
5091 .into_iter()
5092 .filter_map(|event| match event {
5093 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
5094 _ => None,
5095 })
5096 .collect();
5097 assert_eq!(reasons, vec!["other"]);
5098 }
5099
5100 #[tokio::test]
5103 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
5104 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
5105 1,
5106 1,
5107 AttemptOutcome::ExitedIdleWithoutWork,
5108 )]));
5109 let mut harness = Harness::build(
5110 host_with(4),
5111 Arc::clone(&launcher),
5112 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5113 Arc::new(InProcessAllocationLock::new()),
5114 );
5115 let policies = [policy(1, "acme/app", 4)];
5116 let start = harness.clock.now();
5117 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
5118 let mut failures_at = async |offset: Duration| {
5119 harness.clock.set(at(offset));
5120 harness.reconciler.reconcile(&policies).await.clean_failures
5121 };
5122
5123 assert_eq!(failures_at(Duration::ZERO).await, 1);
5124 assert_eq!(
5126 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
5127 0,
5128 "a failed cleanup was retried on the very next pass"
5129 );
5130 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
5131 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
5133 assert_eq!(
5134 failures_at(second - Duration::from_secs(1)).await,
5135 0,
5136 "the delay did not double after a second failure"
5137 );
5138 assert_eq!(failures_at(second).await, 1);
5139 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
5140
5141 let mut backoff = CleanBackoff::default();
5143 let id = AttemptId::from_u128(9);
5144 for _ in 0..40 {
5145 backoff.failed(id, start);
5146 }
5147 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
5148 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
5149 backoff.succeeded(id);
5150 assert!(
5151 backoff.is_due(id, start),
5152 "a cleaned attempt kept its backoff"
5153 );
5154 }
5155
5156 #[tokio::test]
5169 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
5170 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5171 attempt_in(AttemptState::Busy, 1, 1),
5172 concluded(2, 1, AttemptOutcome::CompletedJob),
5173 ]));
5174 let harness = Harness::build(
5175 host_with(4),
5176 Arc::clone(&launcher),
5177 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5178 Arc::new(InProcessAllocationLock::new()),
5179 );
5180 let policy = policy(1, "acme/app", 4);
5181
5182 launcher.fail_attempts(true);
5183 let blind = harness.reconciler.scale_down(&policy).await;
5184
5185 assert!(!blind.is_conclusive(), "the machine was never read");
5186 assert_ne!(
5187 blind,
5188 ScaleDownReport::default(),
5189 "a scale-down that could not see the host must not be equal to one that saw \
5190 an idle host; that equality is the whole finding"
5191 );
5192 assert_eq!(blind.removed, 0);
5193 assert_eq!(
5194 blind.refused_busy, 0,
5195 "and this zero means `unknown`, not `none`"
5196 );
5197 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
5198
5199 launcher.fail_attempts(false);
5201 let seeing = harness.reconciler.scale_down(&policy).await;
5202
5203 assert!(seeing.is_conclusive());
5204 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
5205 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
5206 assert_ne!(
5207 seeing, blind,
5208 "the difference between the two is only whether the set could be read"
5209 );
5210 }
5211
5212 #[tokio::test]
5220 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
5221 let mut warm = ScalePolicy::new(
5222 PolicyId::from_u128(1),
5223 ScaleTarget::repository("acme/app").unwrap(),
5224 1,
5225 fixtures::HOST_ID,
5226 PolicyMode::autoscale(
5227 fixtures::routing_labels("home"),
5228 2,
5229 NonZeroU16::new(5).expect("non-zero"),
5230 )
5231 .expect("min <= max"),
5232 CachePolicy::default(),
5233 );
5234 warm.activate().expect("pending -> active");
5235
5236 let mut harness = Harness::simple(8, 0, "acme/app");
5237 let report = harness.reconciler.reconcile(&[warm]).await;
5238
5239 assert_eq!(
5240 report.allocations[0].demand, 0,
5241 "GitHub reported no queued runs"
5242 );
5243 assert_eq!(
5244 report.allocations[0].desired, 2,
5245 "min_capacity raised the target above demand"
5246 );
5247 assert_eq!(
5248 report.allocations[0].limiting_factor,
5249 LimitingFactor::MinCapacity
5250 );
5251 assert_eq!(report.started, 2, "and two runners were actually started");
5252 assert_eq!(harness.launcher.live_count(), 2);
5253 }
5254
5255 #[derive(Debug)]
5262 struct FixedMemory {
5263 free: u64,
5264 estimate: u64,
5265 admitted: std::sync::atomic::AtomicU32,
5266 }
5267
5268 impl MemoryGate for FixedMemory {
5269 fn admit(&self, _policy: &ScalePolicy, attempts: &[RunnerAttempt]) -> MemoryVerdict {
5270 let running: Vec<runner_manager_domain::memory::Running> = attempts
5271 .iter()
5272 .filter(|attempt| attempt.counts_against_capacity())
5273 .map(|_| runner_manager_domain::memory::Running {
5274 estimate: self.estimate,
5275 current_rss: None,
5276 })
5277 .collect();
5278 runner_manager_domain::memory::admit(
5279 Some(self.free),
5280 &running,
5281 runner_manager_domain::memory::Estimate::choose(None, Some(self.estimate)),
5282 GIB,
5283 )
5284 }
5285
5286 fn admitted(&self, _policy: &ScalePolicy) {
5287 self.admitted
5288 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5289 }
5290 }
5291
5292 const GIB: u64 = 1 << 30;
5293
5294 fn with_memory(harness: Harness, free: u64, estimate: u64) -> (Harness, Arc<FixedMemory>) {
5295 let gate = Arc::new(FixedMemory {
5296 free,
5297 estimate,
5298 admitted: std::sync::atomic::AtomicU32::new(0),
5299 });
5300 let Harness {
5301 launcher,
5302 demand,
5303 events,
5304 clock,
5305 reconciler,
5306 } = harness;
5307 let reconciler = reconciler.with_memory_gate(Arc::clone(&gate) as Arc<dyn MemoryGate>);
5308 (
5309 Harness {
5310 launcher,
5311 demand,
5312 events,
5313 clock,
5314 reconciler,
5315 },
5316 gate,
5317 )
5318 }
5319
5320 #[tokio::test]
5321 async fn a_start_the_memory_cannot_hold_is_deferred_and_the_demand_kept() {
5322 let (mut harness, gate) = with_memory(Harness::simple(8, 5, "acme/app"), 10 * GIB, 4 * GIB);
5326 let report = harness
5327 .reconciler
5328 .reconcile(&[policy(1, "acme/app", 5)])
5329 .await;
5330
5331 assert_eq!(
5332 report.allocations[0].to_start, 5,
5333 "the ceilings granted five"
5334 );
5335 assert_eq!(report.started, 2);
5336 assert_eq!(report.memory_deferred, 3, "deferred, not failed");
5337 assert_eq!(report.failures, 0);
5338 assert_eq!(gate.admitted.load(std::sync::atomic::Ordering::Relaxed), 2);
5339 let deferred: Vec<MemoryFigures> = harness
5340 .events
5341 .events()
5342 .into_iter()
5343 .filter_map(|event| match event {
5344 LifecycleEvent::MemoryShort {
5345 figures,
5346 started: false,
5347 ..
5348 } => Some(figures),
5349 _ => None,
5350 })
5351 .collect();
5352 assert_eq!(deferred.len(), 1);
5353 assert_eq!(deferred[0].free, 10 * GIB);
5354 assert_eq!(deferred[0].reserved, 8 * GIB);
5355 assert_eq!(deferred[0].needed(), 5 * GIB);
5356
5357 let again = harness
5360 .reconciler
5361 .reconcile(&[policy(1, "acme/app", 5)])
5362 .await;
5363 assert_eq!(again.started, 0);
5364 assert!(again.memory_deferred > 0);
5365 assert_eq!(harness.launcher.live_count(), 2);
5366 }
5367
5368 #[tokio::test]
5369 async fn a_runner_bigger_than_the_free_memory_starts_when_nothing_else_runs() {
5370 let (mut harness, _) = with_memory(Harness::simple(8, 2, "acme/app"), GIB, 4 * GIB);
5371 let report = harness
5372 .reconciler
5373 .reconcile(&[policy(1, "acme/app", 5)])
5374 .await;
5375
5376 assert_eq!(report.started, 1, "no starvation, but only one");
5377 assert_eq!(report.memory_deferred, 1);
5378 let names: Vec<&str> = harness
5379 .events
5380 .events()
5381 .iter()
5382 .map(LifecycleEvent::name)
5383 .filter(|name| name.starts_with("memory_"))
5384 .collect();
5385 assert_eq!(names, ["memory_admitted_alone", "memory_deferred"]);
5386 }
5387
5388 #[tokio::test]
5389 async fn without_a_memory_gate_every_granted_runner_starts() {
5390 let mut harness = Harness::simple(8, 5, "acme/app");
5391 let report = harness
5392 .reconciler
5393 .reconcile(&[policy(1, "acme/app", 5)])
5394 .await;
5395 assert_eq!(report.started, 5);
5396 assert_eq!(report.memory_deferred, 0);
5397 }
5398
5399 #[tokio::test]
5404 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
5405 let mut harness = Harness::simple(100, 10, "acme/app");
5406 let report = harness
5407 .reconciler
5408 .reconcile(&[policy(1, "acme/app", 3)])
5409 .await;
5410
5411 assert_eq!(report.allocations[0].demand, 10);
5412 assert_eq!(
5413 report.allocations[0].desired, 3,
5414 "max_capacity beats demand"
5415 );
5416 assert_eq!(report.started, 3);
5417 assert_eq!(
5418 report.allocations[0].limiting_factor,
5419 LimitingFactor::MaxCapacity
5420 );
5421 }
5422
5423 #[tokio::test]
5424 async fn demand_below_min_capacity_starts_nothing_in_v1() {
5425 let mut harness = Harness::simple(8, 0, "acme/app");
5428 let report = harness
5429 .reconciler
5430 .reconcile(&[policy(1, "acme/app", 4)])
5431 .await;
5432
5433 assert_eq!(report.allocations[0].desired, 0);
5434 assert_eq!(report.started, 0);
5435 assert!(report.starts_nothing());
5436 }
5437
5438 #[tokio::test]
5439 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
5440 let policy = policy(1, "octo/repo", 1);
5441 let previous = attempt_in(AttemptState::Starting, 41, 1);
5442 let intent = ReplacementIntent {
5443 policy: policy.id,
5444 previous_attempt: previous.id,
5445 operation: "exit_before_acceptance_replacement",
5446 };
5447 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
5448 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
5449 let mut harness = Harness::build(
5450 host_with(1),
5451 Arc::clone(&launcher),
5452 demand,
5453 Arc::new(InProcessAllocationLock::new()),
5454 );
5455
5456 let report = harness.reconciler.reconcile(&[policy]).await;
5457
5458 assert_eq!(report.replacement_intents, 1);
5459 assert_eq!(report.started, 1);
5460 assert_eq!(launcher.launches(), 1);
5461 assert_eq!(launcher.live_count(), 1);
5462 }
5463
5464 #[tokio::test]
5465 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
5466 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5467 attempt_in(AttemptState::Busy, 1, 1),
5468 attempt_in(AttemptState::Busy, 2, 1),
5469 ]));
5470 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
5471 let mut harness = Harness::build(
5472 host_with(2),
5473 launcher,
5474 demand,
5475 Arc::new(InProcessAllocationLock::new()),
5476 );
5477
5478 let report = harness
5479 .reconciler
5480 .reconcile(&[policy(1, "acme/app", 2)])
5481 .await;
5482 assert_eq!(report.started, 0);
5483 assert_eq!(report.allocations[0].headroom_before, 0);
5484 assert_eq!(harness.launcher.launches(), 0);
5485 }
5486
5487 #[tokio::test]
5488 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
5489 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5492 attempt_in(AttemptState::Busy, 1, 99),
5493 attempt_in(AttemptState::Busy, 2, 99),
5494 attempt_in(AttemptState::Idle, 3, 99),
5495 attempt_in(AttemptState::Starting, 4, 99),
5496 ]));
5497 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
5498 let mut harness = Harness::build(
5499 host_with(6),
5500 launcher,
5501 demand,
5502 Arc::new(InProcessAllocationLock::new()),
5503 );
5504
5505 let report = harness
5506 .reconciler
5507 .reconcile(&[policy(1, "acme/app", 5)])
5508 .await;
5509 assert_eq!(
5510 report.allocations[0].desired, 5,
5511 "its own ceiling allows five"
5512 );
5513 assert_eq!(report.started, 2, "the host has two slots free");
5514 assert_eq!(
5515 report.allocations[0].limiting_factor,
5516 LimitingFactor::HostCapacity
5517 );
5518 assert_eq!(harness.launcher.live_count(), 6);
5519 }
5520
5521 #[tokio::test]
5522 async fn the_idle_host_assertion_holds() {
5523 let mut harness = Harness::simple(8, 0, "acme/app");
5526 let report = harness
5527 .reconciler
5528 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
5529 .await;
5530
5531 assert_eq!(report.started, 0);
5532 assert_eq!(harness.launcher.launches(), 0);
5533 assert!(harness.launcher.snapshot().is_empty());
5534 assert_eq!(
5535 harness
5536 .launcher
5537 .snapshot()
5538 .iter()
5539 .filter(|a| !a.is_terminal())
5540 .count(),
5541 0
5542 );
5543 }
5544
5545 #[tokio::test]
5550 async fn live_capacity_increase_is_used_without_rebuilding_the_reconciler() {
5551 let mut harness = Harness::simple(1, 5, "acme/app");
5552 let policies = [policy(1, "acme/app", 5)];
5553 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5554
5555 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(4);
5556 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 3);
5557 assert_eq!(harness.launcher.live_count(), 4);
5558 }
5559
5560 #[tokio::test]
5561 async fn live_capacity_decrease_keeps_busy_runners_and_waits_for_headroom() {
5562 let mut harness = Harness::simple(5, 5, "acme/app");
5563 let busy: Vec<_> = (1..=3)
5564 .map(|id| attempt_in(AttemptState::Busy, id, 1))
5565 .collect();
5566 *harness.launcher.attempts.lock().unwrap() = busy.clone();
5567 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5568 let policies = [policy(1, "acme/app", 5)];
5569
5570 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 0);
5571 assert_eq!(harness.launcher.snapshot(), busy);
5572 assert!(harness.launcher.cleaned().is_empty());
5573
5574 harness.launcher.attempts.lock().unwrap().truncate(1);
5576 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5577 assert_eq!(harness.launcher.live_count(), 2);
5578 }
5579
5580 #[tokio::test]
5581 async fn live_capacity_is_rechecked_between_launches_in_the_same_pass() {
5582 let mut harness = Harness::simple(5, 5, "acme/app");
5583 *harness.launcher.capacity_after_launch.lock().unwrap() = NonZeroU16::new(1);
5584
5585 let report = harness
5586 .reconciler
5587 .reconcile(&[policy(1, "acme/app", 5)])
5588 .await;
5589 assert_eq!(report.started, 1);
5590 assert_eq!(harness.launcher.live_count(), 1);
5591 }
5592
5593 #[tokio::test]
5594 async fn live_capacity_unreadable_blocks_launches_until_the_journal_recovers() {
5595 let mut harness = Harness::simple(5, 5, "acme/app");
5596 harness
5597 .launcher
5598 .capacity_unreadable
5599 .store(true, Ordering::SeqCst);
5600 let policies = [policy(1, "acme/app", 5)];
5601 let report = harness.reconciler.reconcile(&policies).await;
5602 assert_eq!(report.started, 0);
5603 assert_eq!(report.host_capacity_unreadable, 1);
5604 assert_eq!(report.attempts_unreadable, 0);
5605 assert_eq!(harness.events.count_of("attempts_unreadable"), 0);
5606 assert!(
5607 harness
5608 .events
5609 .events()
5610 .contains(&LifecycleEvent::HostCapacityUnreadable {
5611 reason: HostCapacityError::JournalUnreadable,
5612 })
5613 );
5614
5615 harness
5616 .launcher
5617 .capacity_unreadable
5618 .store(false, Ordering::SeqCst);
5619 *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5620 assert_eq!(harness.reconciler.reconcile(&policies).await.started, 2);
5621 }
5622
5623 #[tokio::test]
5624 async fn unreadable_local_state_does_not_treat_a_busy_host_as_idle() {
5625 for host_failure in [true, false] {
5626 let mut harness = Harness::simple(5, 0, "acme/app");
5627 harness
5628 .reconciler
5629 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5630 *harness.launcher.attempts.lock().unwrap() = vec![attempt_in(AttemptState::Busy, 1, 1)];
5631 harness
5632 .launcher
5633 .capacity_unreadable
5634 .store(host_failure, Ordering::SeqCst);
5635 harness.launcher.fail_attempts(!host_failure);
5636 let report = harness
5637 .reconciler
5638 .reconcile(&[policy(1, "acme/app", 5)])
5639 .await;
5640 assert_eq!(report.host_capacity_unreadable, u16::from(host_failure));
5641 assert_eq!(report.next_poll.pace, PollPace::Nominal);
5642 assert_eq!(harness.launcher.live_count(), 1);
5643 }
5644 }
5645
5646 #[derive(Debug)]
5647 struct HostEditOnLock {
5648 inner: InProcessAllocationLock,
5649 launcher: Arc<FakeLauncher>,
5650 unreadable: bool,
5651 }
5652
5653 #[async_trait::async_trait]
5654 impl AllocationLock for HostEditOnLock {
5655 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
5656 let guard = self.inner.acquire().await?;
5657 *self.launcher.capacity.lock().unwrap() = NonZeroU16::new(1);
5658 self.launcher
5659 .capacity_unreadable
5660 .store(self.unreadable, Ordering::SeqCst);
5661 Ok(guard)
5662 }
5663 }
5664
5665 #[tokio::test]
5666 async fn live_capacity_edits_and_read_failures_while_waiting_for_the_lock_block_launches() {
5667 for unreadable in [false, true] {
5668 let launcher =
5669 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5670 let lock = Arc::new(HostEditOnLock {
5671 inner: InProcessAllocationLock::new(),
5672 launcher: Arc::clone(&launcher),
5673 unreadable,
5674 });
5675 let mut harness = Harness::build(
5676 host_with(5),
5677 Arc::clone(&launcher),
5678 Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
5679 lock,
5680 );
5681 let report = harness
5682 .reconciler
5683 .reconcile(&[policy(1, "acme/app", 5)])
5684 .await;
5685 assert_eq!(report.started, 0);
5686 assert_eq!(launcher.live_count(), 1);
5687 assert!(launcher.cleaned().is_empty());
5688 assert_eq!(report.host_capacity_unreadable, u16::from(unreadable));
5689 assert_eq!(report.attempts_unreadable, 0);
5690 }
5691 }
5692
5693 #[tokio::test(flavor = "current_thread")]
5716 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
5717 let lock = Arc::new(CountingLock::new());
5718 let (launches, live) =
5719 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>, None).await;
5720
5721 assert_eq!(
5722 launches, 3,
5723 "the sum across policies must never exceed host_capacity, and each policy is \
5724 individually within its own max_capacity of 3"
5725 );
5726 assert_eq!(live, 3, "and no duplicate runner survived the race");
5727 assert_eq!(
5728 lock.peak(),
5729 1,
5730 "the allocation lock had one holder at a time; without that the read of the \
5731 headroom and the creation of the runtime are not atomic"
5732 );
5733 assert!(
5734 (3..=5).contains(&lock.acquisitions()),
5735 "the lock is taken before *each* runtime, not once per pass: three runtimes \
5736 means at least three holds, and at most one further hold per policy to \
5737 discover the host filled up underneath it. It was taken {} times",
5738 lock.acquisitions()
5739 );
5740 }
5741
5742 #[tokio::test(flavor = "current_thread")]
5749 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
5750 let (launches, _) =
5751 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>, None).await;
5752
5753 assert!(
5754 launches > 3,
5755 "with no serialisation both policies must be able to spend the same headroom; \
5756 they started {launches} runners on a host of 3. If this is ever 3, the \
5757 contention window closed and `two_policies_reconciling_concurrently_never_\
5758 exceed_host_capacity` has stopped proving anything"
5759 );
5760 }
5761
5762 async fn two_policies_concurrently(
5766 lock: Arc<dyn AllocationLock>,
5767 live_capacity: Option<NonZeroU16>,
5768 ) -> (usize, usize) {
5769 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
5770 *launcher.capacity.lock().unwrap() = live_capacity;
5771 let host = host_with(3);
5772
5773 let mut left = Harness::build(
5774 host.clone(),
5775 Arc::clone(&launcher),
5776 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
5777 Arc::clone(&lock),
5778 )
5779 .reconciler;
5780 let mut right = Harness::build(
5781 host,
5782 Arc::clone(&launcher),
5783 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
5784 Arc::clone(&lock),
5785 )
5786 .reconciler;
5787
5788 let a = policy(1, "acme/left", 3);
5789 let b = policy(2, "acme/right", 3);
5790
5791 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
5792 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
5793 let (_, _) = (left.await.unwrap(), right.await.unwrap());
5794
5795 (launcher.launches(), launcher.live_count())
5796 }
5797
5798 #[tokio::test(flavor = "current_thread")]
5799 async fn live_capacity_is_shared_by_concurrent_targets_with_stale_startup_hosts() {
5800 let (launches, live) =
5801 two_policies_concurrently(Arc::new(InProcessAllocationLock::new()), NonZeroU16::new(2))
5802 .await;
5803 assert_eq!(launches, 2);
5804 assert_eq!(live, 2);
5805 }
5806
5807 #[tokio::test]
5819 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
5820 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
5821 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
5822 let launcher = Arc::new(FakeLauncher::new());
5823 let events = Arc::new(EventLog::new());
5824
5825 let mut reconciler = Reconciler::new(
5826 host_with(10),
5827 ReconcilerPorts {
5828 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
5829 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5830 lock: Arc::new(InProcessAllocationLock::new()),
5831 directory: Arc::new(FakeDirectory::default()),
5832 clock: Arc::new(FakeClock::default()),
5833 jitter: Arc::new(NoJitter),
5834 events: Arc::clone(&events) as Arc<dyn EventSink>,
5835 },
5836 );
5837
5838 let monitor = fixtures::policy()
5839 .id(PolicyId::from_u128(1))
5840 .repository("acme/app")
5841 .monitor_only()
5842 .active()
5843 .build();
5844
5845 let report = reconciler.reconcile(&[monitor]).await;
5846
5847 assert_eq!(report.started, 0);
5848 assert_eq!(launcher.launches(), 0);
5849 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
5850 assert_eq!(
5851 report.demand_requests, 0,
5852 "a monitor-only policy spends nothing from the shared hourly ceiling"
5853 );
5854 assert!(
5855 gateway.gateway().calls().is_empty(),
5856 "a monitor-only policy issued a demand request: {:?}",
5857 gateway.gateway().calls()
5858 );
5859 assert_eq!(events.count_of("monitor_only_skipped"), 1);
5860 assert_eq!(
5861 events.count_of("demand_observed"),
5862 0,
5863 "and it contributed no demand"
5864 );
5865 }
5866
5867 #[tokio::test]
5885 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
5886 let lock = Arc::new(CountingLock::new());
5887 let launcher = Arc::new(FakeLauncher::new());
5888 let events = Arc::new(EventLog::new());
5889 let mut reconciler = Reconciler::new(
5890 host_with(4),
5891 ReconcilerPorts {
5892 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5893 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5894 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
5895 directory: Arc::new(FakeDirectory::default()),
5896 clock: Arc::new(FakeClock::default()),
5897 jitter: Arc::new(NoJitter),
5898 events: Arc::clone(&events) as Arc<dyn EventSink>,
5899 },
5900 );
5901
5902 let watcher = fixtures::policy()
5903 .id(PolicyId::from_u128(2))
5904 .repository("acme/app")
5905 .monitor_only()
5906 .active()
5907 .build();
5908
5909 let report = reconciler
5910 .reconcile(&[policy(1, "acme/app", 4), watcher])
5911 .await;
5912
5913 assert_eq!(report.started, 2, "the autoscale policy is served normally");
5914 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
5915 assert_eq!(
5916 events.count_of("demand_observed"),
5917 1,
5918 "the demand observation belongs to the autoscale policy alone"
5919 );
5920 assert!(
5921 report
5922 .allocations
5923 .iter()
5924 .all(|a| a.policy_id == PolicyId::from_u128(1)),
5925 "a monitor-only policy was allocated for: {:?}",
5926 report.allocations
5927 );
5928 assert_eq!(
5929 lock.acquisitions(),
5930 2,
5931 "one hold per runtime created, and none on behalf of the monitor-only policy. \
5932 It was three before the budget was checked at the top of the loop rather than \
5933 after the re-read, which cost every policy a surplus hold to discover there \
5934 was nothing left to grant"
5935 );
5936 }
5937
5938 #[tokio::test]
5939 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
5940 let monitor = fixtures::monitor_only_policy();
5945 assert!(!monitor.owns_runners());
5946 assert_eq!(monitor.max_capacity(), None);
5947
5948 let host = host_with(10);
5949 let attempts: Vec<RunnerAttempt> = Vec::new();
5950 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5951 let allocation = allocator.allocate(&monitor, 10_000);
5952 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
5953 assert_eq!(allocation.to_start, 0);
5954 assert_eq!(
5955 allocator.headroom(),
5956 10,
5957 "and it consumes no headroom, so an autoscale policy on the same host is \
5958 unaffected"
5959 );
5960 }
5961
5962 #[tokio::test]
5970 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
5971 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5972 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
5973 concluded(
5974 2,
5975 1,
5976 AttemptOutcome::failed(FailureReason::JitRequestFailed),
5977 ),
5978 concluded(3, 1, AttemptOutcome::CompletedJob),
5979 ]));
5980 let mut harness = Harness::build(
5981 host_with(4),
5982 Arc::clone(&launcher),
5983 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5984 Arc::new(InProcessAllocationLock::new()),
5985 );
5986
5987 let report = harness
5988 .reconciler
5989 .reconcile(&[policy(1, "acme/app", 4)])
5990 .await;
5991
5992 assert_eq!(report.cleaned, 3);
5993 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
5994 assert_eq!(
5995 report.failures, 1,
5996 "only the failed attempt is a failure; the idle exit and the completed job are \
5997 not"
5998 );
5999 assert_eq!(launcher.cleaned().len(), 3);
6000 assert!(launcher.snapshot().is_empty());
6001
6002 let cleaned: Vec<OutcomeKind> = harness
6003 .events
6004 .events()
6005 .into_iter()
6006 .filter_map(|event| match event {
6007 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
6008 _ => None,
6009 })
6010 .collect();
6011 assert!(cleaned.contains(&OutcomeKind::IdleExit));
6012 assert!(
6013 !OutcomeKind::IdleExit.is_failure(),
6014 "an idle exit rendered as a failure sends an operator hunting a fault that does \
6015 not exist"
6016 );
6017 }
6018
6019 #[tokio::test]
6022 async fn scale_down_removes_nothing_from_a_busy_attempt() {
6023 let busy = attempt_in(AttemptState::Busy, 1, 1);
6024 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
6025 busy.clone(),
6026 attempt_in(AttemptState::Starting, 2, 1),
6027 concluded(3, 1, AttemptOutcome::CompletedJob),
6028 ]));
6029 let harness = Harness::build(
6030 host_with(4),
6031 Arc::clone(&launcher),
6032 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
6033 Arc::new(InProcessAllocationLock::new()),
6034 );
6035
6036 let report = harness
6037 .reconciler
6038 .scale_down(&policy(1, "acme/app", 4))
6039 .await;
6040
6041 assert_eq!(report.refused_busy, 1);
6042 assert_eq!(
6043 report.retained, 1,
6044 "the `starting` attempt is not ended either"
6045 );
6046 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
6047
6048 let after = launcher.snapshot();
6049 let still_busy = after
6050 .iter()
6051 .find(|a| a.id == AttemptId::from_u128(1))
6052 .expect("the busy attempt is still there");
6053 assert_eq!(
6054 still_busy.state(),
6055 AttemptState::Busy,
6056 "scale-down removed nothing from a runner that is executing a job, and left it \
6057 busy"
6058 );
6059 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
6060
6061 let mut busy = busy;
6065 assert!(matches!(
6066 busy.clean(fixtures::created_at()),
6067 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
6068 ));
6069 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
6070 }
6071
6072 #[test]
6077 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
6078 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
6079 assert_eq!(RefreshInterval::MIN_SECS, 30);
6080 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
6081 assert!(
6082 RefreshInterval::from_secs(29).is_err(),
6083 "the floor is a rate-budget constraint, and a caller must not be able to write \
6084 a shorter interval at all"
6085 );
6086
6087 let mut schedule = PollSchedule::new(RefreshInterval::default());
6088 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
6089 assert_eq!(next.delay, Duration::from_secs(60));
6090 assert_eq!(next.pace, PollPace::Nominal);
6091
6092 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
6093 assert_eq!(
6094 floored
6095 .next_poll(None, fixtures::created_at(), &NoJitter)
6096 .delay,
6097 Duration::from_secs(30)
6098 );
6099 }
6100
6101 #[test]
6105 fn a_rate_limit_increases_the_delay_and_names_itself() {
6106 let now = fixtures::created_at();
6107 let mut schedule = PollSchedule::new(RefreshInterval::default());
6108
6109 let limited = RefreshState::RateLimited(RateLimited {
6110 kind: RateLimitKind::Secondary,
6111 retry_after: Some(Duration::from_secs(300)),
6112 remaining: None,
6113 reset_unix_secs: None,
6114 });
6115 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
6116
6117 assert_eq!(next.delay, Duration::from_secs(300));
6118 assert_eq!(
6119 next.pace,
6120 PollPace::RateLimited {
6121 kind: RateLimitKind::Secondary
6122 },
6123 "the increase is reported, never hidden"
6124 );
6125 assert!(next.pace.is_throttled());
6126 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
6127 }
6128
6129 #[test]
6132 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
6133 let now = fixtures::created_at();
6134 let mut schedule = PollSchedule::new(RefreshInterval::default());
6135
6136 let limited = RefreshState::RateLimited(RateLimited {
6137 kind: RateLimitKind::Primary,
6138 retry_after: Some(Duration::from_secs(300)),
6139 remaining: Some(0),
6140 reset_unix_secs: None,
6141 });
6142
6143 for _ in 0..5 {
6146 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
6147 assert_eq!(
6148 next.delay,
6149 Duration::from_secs(300),
6150 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
6151 have compounded on every successive retry"
6152 );
6153 }
6154
6155 let brief = RefreshState::RateLimited(RateLimited {
6158 kind: RateLimitKind::Secondary,
6159 retry_after: Some(Duration::from_secs(5)),
6160 remaining: None,
6161 reset_unix_secs: None,
6162 });
6163 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
6164 assert_eq!(
6165 next.delay,
6166 Duration::from_secs(60),
6167 "a short `retry-after` may not drop the loop below its own interval"
6168 );
6169 assert!(next.delay >= PollSchedule::floor());
6170 }
6171
6172 #[test]
6173 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
6174 let now = fixtures::created_at();
6175 let states = [
6176 None,
6177 Some(RefreshState::Offline),
6178 Some(RefreshState::RateLimited(RateLimited {
6179 kind: RateLimitKind::Secondary,
6180 retry_after: Some(Duration::from_secs(1)),
6181 remaining: None,
6182 reset_unix_secs: None,
6183 })),
6184 Some(RefreshState::LockedOut {
6185 retry_after: Duration::from_secs(1),
6186 }),
6187 Some(RefreshState::Unauthorized),
6188 Some(RefreshState::Forbidden { message: None }),
6189 Some(RefreshState::Failed {
6190 status: Some(500),
6191 message: "server error".into(),
6192 }),
6193 Some(RefreshState::Cancelled),
6194 ];
6195
6196 for state in &states {
6197 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
6198 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
6199 assert!(
6200 next.delay >= PollSchedule::floor(),
6201 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
6202 next.delay.as_millis()
6203 );
6204 }
6205 }
6206
6207 #[test]
6208 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
6209 let now = fixtures::created_at();
6210 let mut schedule = PollSchedule::new(RefreshInterval::default());
6211
6212 let mut previous = Duration::ZERO;
6214 for consecutive in 1..=6_u32 {
6215 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
6216 assert_eq!(next.pace, PollPace::Offline { consecutive });
6217 assert!(
6218 next.delay >= previous,
6219 "the back-off must not shrink while the outage continues"
6220 );
6221 assert!(next.delay >= Duration::from_secs(60));
6222 previous = next.delay;
6223 }
6224 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
6225
6226 let mut jittered = PollSchedule::new(RefreshInterval::default());
6229 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
6230 let mut jittered = PollSchedule::new(RefreshInterval::default());
6231 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
6232 assert!(full.delay > none.delay);
6233 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
6234
6235 assert_eq!(schedule.consecutive_offline(), 6);
6237 let recovered = schedule.next_poll(None, now, &NoJitter);
6238 assert_eq!(recovered.pace, PollPace::Nominal);
6239 assert_eq!(recovered.delay, Duration::from_secs(60));
6240 assert_eq!(schedule.consecutive_offline(), 0);
6241 }
6242
6243 #[test]
6244 fn the_offline_state_states_the_twenty_four_hour_bound() {
6245 assert_eq!(
6246 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
6247 Duration::from_secs(24 * 60 * 60)
6248 );
6249
6250 let brief = OfflineState::new(1, Duration::from_secs(120));
6251 let rendered = brief.to_string();
6252 assert!(rendered.contains("24 hours"), "{rendered}");
6253 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
6254 assert!(!brief.has_outlasted_the_queue());
6255
6256 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
6257 assert!(long.has_outlasted_the_queue());
6258 assert!(
6259 long.to_string().contains("queued work has been lost"),
6260 "{long}"
6261 );
6262
6263 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
6265 }
6266
6267 fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
6272 RateLimitSnapshot {
6273 limit: 5_000,
6274 remaining,
6275 used: Some(5_000 - remaining),
6276 reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
6277 observed_at: now,
6278 }
6279 }
6280
6281 fn quiet() -> PollConditions {
6282 PollConditions {
6283 quiet: true,
6284 quota: None,
6285 requests_per_pass: 0,
6286 }
6287 }
6288
6289 #[test]
6290 fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
6291 let now = fixtures::created_at();
6292 let mut schedule =
6293 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6294
6295 let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
6296 assert_eq!(idle.delay, Duration::from_secs(10));
6297 assert_eq!(idle.pace, PollPace::Idle);
6298 assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
6299
6300 let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
6301 assert_eq!(busy.delay, Duration::from_secs(60));
6302 assert_eq!(busy.pace, PollPace::Nominal);
6303 }
6304
6305 #[test]
6306 fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
6307 let now = fixtures::created_at();
6308 let mut schedule = PollSchedule::new(RefreshInterval::default());
6309 assert_eq!(
6310 schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
6311 Duration::from_secs(60)
6312 );
6313 }
6314
6315 #[test]
6316 fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
6317 let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
6318 .with_idle(Duration::from_secs(120));
6319 assert_eq!(
6320 schedule.idle(),
6321 Duration::from_secs(30),
6322 "an idle interval above the active one would make quiet targets slower to react"
6323 );
6324 let schedule =
6325 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
6326 assert_eq!(schedule.idle(), PollSchedule::idle_floor());
6327 }
6328
6329 #[test]
6332 fn a_low_quota_stretches_polling_and_wins_over_idle() {
6333 let now = fixtures::created_at();
6334 let mut schedule =
6335 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6336
6337 let healthy = schedule.next_poll_with(
6338 None,
6339 PollConditions {
6340 quiet: true,
6341 quota: Some(quota(1_000, 3_000, now)),
6342 requests_per_pass: 0,
6343 },
6344 now,
6345 &NoJitter,
6346 );
6347 assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
6348
6349 let low = schedule.next_poll_with(
6350 None,
6351 PollConditions {
6352 quiet: true,
6353 quota: Some(quota(999, 3_000, now)),
6354 requests_per_pass: 0,
6355 },
6356 now,
6357 &NoJitter,
6358 );
6359 assert_eq!(low.delay, Duration::from_secs(120));
6360 assert_eq!(
6361 low.pace,
6362 PollPace::Stretched {
6363 pressure: PrimaryPressure::Low
6364 }
6365 );
6366 assert!(low.pace.is_throttled());
6367 assert_eq!(low.pace.as_str(), "stretched_low_budget");
6368
6369 let critical = schedule.next_poll_with(
6370 None,
6371 PollConditions {
6372 quiet: false,
6373 quota: Some(quota(100, 3_000, now)),
6374 requests_per_pass: 0,
6375 },
6376 now,
6377 &NoJitter,
6378 );
6379 assert_eq!(critical.delay, Duration::from_secs(240));
6380 assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
6381 }
6382
6383 #[test]
6384 fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
6385 let now = fixtures::created_at();
6386 let mut schedule = PollSchedule::new(RefreshInterval::default());
6387
6388 let soon = schedule.next_poll_with(
6389 None,
6390 PollConditions {
6391 quiet: false,
6392 quota: Some(quota(10, 90, now)),
6393 requests_per_pass: 0,
6394 },
6395 now,
6396 &NoJitter,
6397 );
6398 assert_eq!(
6399 soon.delay,
6400 Duration::from_secs(90),
6401 "the bucket refills in 90 s; waiting 240 s buys nothing"
6402 );
6403
6404 let imminent = schedule.next_poll_with(
6405 None,
6406 PollConditions {
6407 quiet: false,
6408 quota: Some(quota(10, 5, now)),
6409 requests_per_pass: 0,
6410 },
6411 now,
6412 &NoJitter,
6413 );
6414 assert_eq!(imminent.delay, Duration::from_secs(60));
6415
6416 let expired = schedule.next_poll_with(
6417 None,
6418 PollConditions {
6419 quiet: true,
6420 quota: Some(quota(10, -5, now)),
6421 requests_per_pass: 0,
6422 },
6423 now,
6424 &NoJitter,
6425 );
6426 assert_eq!(
6427 expired.pace,
6428 PollPace::Nominal,
6429 "a reading from a window that has reset says nothing; the schedule here had no \
6430 idle interval, so it is the active one"
6431 );
6432 }
6433
6434 #[test]
6435 fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
6436 let now = fixtures::created_at();
6437 let mut schedule =
6438 PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6439 let small = schedule.next_poll_with(
6440 None,
6441 PollConditions {
6442 quiet: true,
6443 quota: None,
6444 requests_per_pass: 40,
6445 },
6446 now,
6447 &NoJitter,
6448 );
6449 assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
6450 let organization = schedule.next_poll_with(
6451 None,
6452 PollConditions {
6453 quiet: true,
6454 quota: None,
6455 requests_per_pass: 400,
6456 },
6457 now,
6458 &NoJitter,
6459 );
6460 assert_eq!(
6461 organization.delay,
6462 Duration::from_secs(80),
6463 "400 requests a pass at 300 a minute is one pass every 80 s"
6464 );
6465 assert_eq!(organization.pace, PollPace::Idle);
6466 }
6467
6468 #[test]
6469 fn a_rate_limit_wait_is_jittered_upward_only() {
6470 let now = fixtures::created_at();
6471 let mut schedule = PollSchedule::new(RefreshInterval::default());
6472 let limited = RefreshState::RateLimited(RateLimited {
6473 kind: RateLimitKind::Secondary,
6474 retry_after: Some(Duration::from_secs(300)),
6475 remaining: None,
6476 reset_unix_secs: None,
6477 });
6478 assert_eq!(
6479 schedule
6480 .next_poll(Some(&limited), now, &FixedJitter(0.5))
6481 .delay,
6482 Duration::from_secs(315),
6483 "every loop shares one quiet period; without jitter they all wake together"
6484 );
6485 assert_eq!(
6486 schedule.next_poll(Some(&limited), now, &NoJitter).delay,
6487 Duration::from_secs(300),
6488 "never shorter than GitHub asked"
6489 );
6490 }
6491
6492 #[tokio::test]
6493 async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
6494 let launcher =
6497 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
6498 let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
6499 let mut harness = Harness::build(
6500 host_with(1),
6501 launcher,
6502 demand,
6503 Arc::new(InProcessAllocationLock::new()),
6504 );
6505 harness
6506 .reconciler
6507 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6508 let policy = policy(1, "acme/app", 4);
6509
6510 let report = harness
6511 .reconciler
6512 .reconcile(std::slice::from_ref(&policy))
6513 .await;
6514
6515 assert_eq!(report.started, 0, "the host is full");
6516 assert_eq!(
6517 report.next_poll.pace,
6518 PollPace::Nominal,
6519 "matched demand it could not serve is work waiting, not idle"
6520 );
6521 }
6522
6523 #[tokio::test]
6524 async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
6525 let mut harness = Harness::simple(4, 0, "acme/app");
6526 harness
6527 .reconciler
6528 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6529 let policy = policy(1, "acme/app", 4);
6530
6531 let report = harness
6532 .reconciler
6533 .reconcile(std::slice::from_ref(&policy))
6534 .await;
6535
6536 assert_eq!(report.next_poll.pace, PollPace::Idle);
6537 assert_eq!(report.next_poll.delay, Duration::from_secs(10));
6538 }
6539
6540 #[tokio::test]
6541 async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
6542 let mut harness = Harness::simple(4, 0, "acme/app");
6543 harness
6544 .reconciler
6545 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6546 harness.demand.set(PollOutcome::Ready(
6547 QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
6548 ));
6549 let policy = policy(1, "acme/app", 4);
6550
6551 let report = harness
6552 .reconciler
6553 .reconcile(std::slice::from_ref(&policy))
6554 .await;
6555
6556 assert_eq!(report.next_poll.pace, PollPace::Nominal);
6557 assert_eq!(report.next_poll.delay, Duration::from_secs(60));
6558 }
6559
6560 #[tokio::test]
6561 async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
6562 let launcher =
6563 Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
6564 let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
6565 let mut harness = Harness::build(
6566 host_with(4),
6567 launcher,
6568 demand,
6569 Arc::new(InProcessAllocationLock::new()),
6570 );
6571 harness
6572 .reconciler
6573 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6574 let policy = policy(1, "acme/app", 4);
6575
6576 let report = harness
6577 .reconciler
6578 .reconcile(std::slice::from_ref(&policy))
6579 .await;
6580
6581 assert_eq!(
6582 report.next_poll.pace,
6583 PollPace::Nominal,
6584 "a job is running: its follow-on work is what the active interval watches for"
6585 );
6586 }
6587
6588 #[tokio::test]
6589 async fn a_pass_that_starts_a_runner_is_not_idle() {
6590 let mut harness = Harness::simple(4, 2, "acme/app");
6591 harness
6592 .reconciler
6593 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6594 let policy = policy(1, "acme/app", 4);
6595
6596 let report = harness
6597 .reconciler
6598 .reconcile(std::slice::from_ref(&policy))
6599 .await;
6600
6601 assert!(report.started > 0);
6602 assert_eq!(report.next_poll.pace, PollPace::Nominal);
6603 }
6604
6605 #[tokio::test]
6606 async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
6607 let mut harness = Harness::simple(4, 0, "acme/app");
6608 harness
6609 .reconciler
6610 .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6611 let now = harness.clock.now();
6612 harness.demand.set(PollOutcome::Ready(
6613 QueuedDemand::of(repo("acme/app"), Vec::new())
6614 .with_rate_limit(Some(quota(200, 1_800, now))),
6615 ));
6616 let policy = policy(1, "acme/app", 4);
6617
6618 let report = harness
6619 .reconciler
6620 .reconcile(std::slice::from_ref(&policy))
6621 .await;
6622
6623 assert_eq!(
6624 report.next_poll.pace,
6625 PollPace::Stretched {
6626 pressure: PrimaryPressure::Critical
6627 }
6628 );
6629 assert_eq!(report.next_poll.delay, Duration::from_secs(240));
6630 }
6631
6632 #[tokio::test]
6641 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
6642 let live = vec![
6643 attempt_in(AttemptState::Busy, 1, 1),
6644 attempt_in(AttemptState::Starting, 2, 1),
6645 ];
6646 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
6647 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6648 let mut harness = Harness::build(
6649 host_with(8),
6650 Arc::clone(&launcher),
6651 Arc::clone(&demand),
6652 Arc::new(InProcessAllocationLock::new()),
6653 );
6654 let policy = policy(1, "acme/app", 8);
6655
6656 let report = harness
6657 .reconciler
6658 .reconcile(std::slice::from_ref(&policy))
6659 .await;
6660
6661 assert!(report.is_offline());
6662 assert_eq!(report.started, 0, "no new runner during an outage");
6663 assert_eq!(launcher.launches(), 0);
6664 assert_eq!(
6665 launcher.snapshot(),
6666 live,
6667 "existing runner processes are retained, untouched"
6668 );
6669 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6670 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6671 assert!(report.next_poll.delay >= Duration::from_secs(60));
6672 let offline = report.offline_state().expect("an offline state to display");
6673 assert!(offline.to_string().contains("24 hours"));
6674
6675 demand.set(PollOutcome::Ready(QueuedDemand::of(
6678 repo("acme/app"),
6679 jobs(2),
6680 )));
6681 let recovered = harness.reconciler.reconcile(&[policy]).await;
6682
6683 assert!(!recovered.is_offline());
6684 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
6685 assert_eq!(
6686 recovered.started, 1,
6687 "two queued jobs and one starting runner leave one job uncovered"
6688 );
6689 assert_eq!(recovered.allocations[0].active_owned, 2);
6690 assert_eq!(launcher.live_count(), 3);
6691 }
6692
6693 #[tokio::test]
6703 async fn one_offline_target_does_not_stop_a_reachable_one() {
6704 let mut harness = Harness::simple(8, 0, "acme/app");
6705 harness.demand.set_for(
6706 &ScaleTarget::repository("acme/app").unwrap(),
6707 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
6708 );
6709 harness.demand.set_for(
6710 &ScaleTarget::repository("acme/broken").unwrap(),
6711 PollOutcome::Failed(RefreshState::Offline),
6712 );
6713
6714 let report = harness
6715 .reconciler
6716 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
6717 .await;
6718
6719 assert_eq!(
6720 report.started, 2,
6721 "the reachable target was served; an unreachable sibling repository must not \
6722 idle the host"
6723 );
6724 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
6725 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
6726
6727 assert!(report.is_offline());
6729 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6730 }
6731
6732 #[tokio::test]
6747 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
6748 let clock = Arc::new(FakeClock::default());
6749 let launcher = Arc::new(FakeLauncher::new());
6750 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6751 let mut reconciler = Reconciler::new(
6752 host_with(4),
6753 ReconcilerPorts {
6754 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
6755 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6756 lock: Arc::new(InProcessAllocationLock::new()),
6757 directory: Arc::new(FakeDirectory::default()),
6758 clock: Arc::clone(&clock) as Arc<dyn Clock>,
6759 jitter: Arc::new(NoJitter),
6760 events: Arc::new(NoEvents),
6761 },
6762 );
6763 let policy = policy(1, "acme/app", 4);
6764
6765 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6767 let state = first.offline_state().expect("an offline state");
6768 assert!(!state.has_outlasted_the_queue());
6769 assert!(
6770 state
6771 .to_string()
6772 .contains("an outage longer than that loses"),
6773 "{state}"
6774 );
6775
6776 clock.advance_secs(24 * 60 * 60 + 60);
6778 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6779 let state = later.offline_state().expect("an offline state");
6780 assert!(state.has_outlasted_the_queue());
6781 assert!(
6782 state.to_string().contains("queued work has been lost"),
6783 "{state}"
6784 );
6785 assert_eq!(launcher.launches(), 0, "and still nothing was started");
6786
6787 demand.set(PollOutcome::Ready(QueuedDemand::of(
6790 repo("acme/app"),
6791 jobs(0),
6792 )));
6793 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6794 assert!(recovered.offline_state().is_none());
6795 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
6796
6797 demand.set(PollOutcome::Failed(RefreshState::Offline));
6798 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6799 assert!(
6800 !again
6801 .offline_state()
6802 .expect("an offline state")
6803 .has_outlasted_the_queue(),
6804 "a new outage must not inherit the age of the one before it"
6805 );
6806 }
6807
6808 #[tokio::test]
6824 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
6825 let root = tempfile::tempdir().expect("a temporary directory");
6826 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
6827 root.path(),
6828 ));
6829 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
6830
6831 let held = lock.acquire().await.expect("an uncontended lock is free");
6832 assert!(
6833 matches!(lock.acquire().await, Err(AllocationLockBusy)),
6834 "a second holder was admitted; on Unix the lock is per open file description \
6835 and on Windows the share mode denies write, so this must be refused even \
6836 from inside the same process"
6837 );
6838
6839 drop(held);
6840 let regained = lock.acquire().await;
6841 assert!(
6842 regained.is_ok(),
6843 "dropping the guard is the only release there is, so a guard that does not \
6844 hold the `HostLock` leaves it held forever"
6845 );
6846 }
6847
6848 #[test]
6849 fn tee_events_reaches_both_sinks() {
6850 let left = Arc::new(EventLog::new());
6854 let right = Arc::new(EventLog::new());
6855 let tee = TeeEvents(
6856 Arc::clone(&left) as Arc<dyn EventSink>,
6857 Arc::clone(&right) as Arc<dyn EventSink>,
6858 );
6859
6860 tee.emit(LifecycleEvent::MonitorOnlySkipped {
6861 policy: PolicyId::from_u128(1),
6862 });
6863
6864 assert_eq!(left.count_of("monitor_only_skipped"), 1);
6865 assert_eq!(right.count_of("monitor_only_skipped"), 1);
6866 }
6867
6868 #[tokio::test]
6873 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
6874 let clock = Arc::new(FakeClock::default());
6875 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
6876 let cache = RepositoryCache::new(
6877 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6878 Arc::clone(&clock) as Arc<dyn Clock>,
6879 RefreshInterval::default(),
6880 );
6881 let target = ScaleTarget::organization("acme").unwrap();
6882
6883 assert_eq!(
6884 cache.ttl(),
6885 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
6886 );
6887
6888 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
6890 let scope = cache.scope_for(&target).await.unwrap();
6891 assert_eq!(scope.repositories().len(), 2);
6892 clock.advance_secs(60);
6893 }
6894 assert_eq!(
6895 directory.calls(),
6896 1,
6897 "re-listing an organization at demand-poll frequency is what exhausts the \
6898 shared request budget"
6899 );
6900 assert_eq!(cache.lookups(), 1);
6901
6902 cache.scope_for(&target).await.unwrap();
6904 assert_eq!(directory.calls(), 2);
6905 }
6906
6907 #[tokio::test]
6908 async fn a_repository_target_never_consults_the_directory() {
6909 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
6910 let cache = RepositoryCache::new(
6911 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6912 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6913 RefreshInterval::default(),
6914 );
6915 let target = ScaleTarget::repository("acme/app").unwrap();
6916
6917 let scope = cache.scope_for(&target).await.unwrap();
6918 assert_eq!(scope.repositories(), &[repo("acme/app")]);
6919 assert_eq!(directory.calls(), 0);
6920 }
6921
6922 #[tokio::test]
6923 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
6924 let mut harness = Harness::simple(8, 4, "o/r");
6928 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6929 repo("o/r"),
6930 [
6931 fixtures::queued_job(&[HOST_LABEL]),
6932 fixtures::queued_job(&[HOST_LABEL]),
6933 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6934 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6935 ],
6936 )));
6937 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6938 isolated.activate().unwrap();
6939 let report = harness
6940 .reconciler
6941 .reconcile(&[policy(1, "o/r", 2), isolated])
6942 .await;
6943
6944 assert_eq!(harness.demand.polls().len(), 1);
6945 assert_eq!(report.targets_read, 1, "profiles share one target reading");
6946 assert_eq!(
6947 report.demand_requests,
6948 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
6949 "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"
6950 );
6951 assert_eq!(
6952 report.started, 4,
6953 "native and isolated profiles each get two"
6954 );
6955 }
6956
6957 #[tokio::test]
6958 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
6959 use runner_manager_domain::model::{Arch, HostLabel, Os};
6960 use runner_manager_domain::policy::RoutingLabels;
6961 let mut harness = Harness::build(
6962 fixtures::host()
6963 .os(Os::MacOs)
6964 .architecture(Arch::Arm64)
6965 .capacity(10)
6966 .build(),
6967 Arc::new(FakeLauncher::new()),
6968 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
6969 Arc::new(InProcessAllocationLock::new()),
6970 );
6971 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6972 repo("o/r"),
6973 [
6974 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
6975 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
6976 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
6977 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
6978 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
6979 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
6980 ],
6981 )));
6982 let mut local = fixtures::policy()
6983 .id(PolicyId::from_u128(1))
6984 .repository("o/r")
6985 .autoscale("macmini", 10)
6986 .routing_labels(RoutingLabels::derive(
6987 &HostLabel::new("macmini").unwrap(),
6988 Os::MacOs,
6989 Arch::Arm64,
6990 ))
6991 .active()
6992 .build();
6993 for label in ["self-hosted", "macos", "arm64", "extra"] {
6994 local.add_routing_label(fixtures::label(label)).unwrap();
6995 }
6996 let mut foreign = local.to_persisted();
6997 foreign.id = PolicyId::from_u128(2);
6998 foreign.host_id = HostId::from_u128(0xdead);
6999 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
7000 let report = harness.reconciler.reconcile(&[local, foreign]).await;
7001 assert_eq!(report.started, 2);
7002 assert_eq!(report.targets_read, 1);
7003 assert!(harness.events.events().iter().any(|event| matches!(
7004 event,
7005 LifecycleEvent::DemandObserved {
7006 demand: 2,
7007 not_matched: 4,
7008 ..
7009 }
7010 )));
7011 assert!(
7012 !harness
7013 .events
7014 .events()
7015 .iter()
7016 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
7017 );
7018 }
7019
7020 #[tokio::test]
7021 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
7022 use runner_manager_domain::execution::{
7023 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
7024 };
7025 let mut native = policy(1, "o/r", 2);
7026 native
7027 .add_routing_label(fixtures::label("self-hosted"))
7028 .unwrap();
7029 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7030 isolated
7031 .add_routing_label(fixtures::label("self-hosted"))
7032 .unwrap();
7033 isolated
7034 .set_execution_policy(ExecutionPolicy::Isolated {
7035 backend: Backend::Oci,
7036 image: ImageReference::new(format!(
7037 "registry.example/runner@sha256:{}",
7038 "a".repeat(64)
7039 ))
7040 .unwrap(),
7041 resources: ResourceLimits {
7042 cpu_millis: 1000,
7043 memory_mib: 512,
7044 disk_mib: 1024,
7045 },
7046 })
7047 .unwrap();
7048 assert!(!isolated.may_start_runners());
7049 let mut active_isolated = isolated.clone();
7050 active_isolated.activate().unwrap();
7051 for profiles in [
7052 [native.clone(), isolated.clone()],
7053 [isolated.clone(), native.clone()],
7054 [native.clone(), active_isolated.clone()],
7055 [active_isolated, native.clone()],
7056 ] {
7057 let mut harness = Harness::simple(4, 0, "o/r");
7058 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7059 repo("o/r"),
7060 [fixtures::queued_job(&["SELF-HOSTED"])],
7061 )));
7062 let report = harness.reconciler.reconcile(&profiles).await;
7063 assert_eq!(report.started, 0);
7064 assert!(harness.events.events().iter().any(|event| matches!(
7065 event,
7066 LifecycleEvent::RoutingRefused {
7067 reason: RoutingRefusal::OverlappingProfiles,
7068 count: 1,
7069 ..
7070 }
7071 )));
7072 }
7073 let mut harness = Harness::simple(4, 0, "o/r");
7076 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7077 repo("o/r"),
7078 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
7079 )));
7080 assert_eq!(
7081 harness
7082 .reconciler
7083 .reconcile(&[native, isolated])
7084 .await
7085 .started,
7086 0
7087 );
7088 }
7089
7090 #[tokio::test]
7091 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
7092 let mut harness = Harness::simple(2, 0, "o/r");
7093 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7094 repo("o/r"),
7095 [
7096 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
7097 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
7098 fixtures::queued_job(&["gpu"]),
7099 fixtures::unresolvable_job(),
7100 ],
7101 )));
7102 let mut native = policy(1, "o/r", 2);
7103 native.add_routing_label(fixtures::label("gpu")).unwrap();
7104 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7105 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
7106 isolated.activate().unwrap();
7107
7108 let report = harness.reconciler.reconcile(&[native, isolated]).await;
7109 assert_eq!(harness.demand.polls().len(), 1);
7110 assert_eq!(report.targets_read, 1);
7111 assert_eq!(report.started, 2);
7112 assert_eq!(report.allocations.len(), 2);
7113 assert!(
7114 report
7115 .allocations
7116 .iter()
7117 .all(|allocation| allocation.to_start == 1)
7118 );
7119 assert!(harness.events.events().iter().any(|event| matches!(
7120 event,
7121 LifecycleEvent::RoutingRefused {
7122 reason: RoutingRefusal::OverlappingProfiles,
7123 count: 1,
7124 ..
7125 }
7126 )));
7127 assert_eq!(
7128 harness
7129 .events
7130 .events()
7131 .iter()
7132 .filter(|event| matches!(
7133 event,
7134 LifecycleEvent::DemandObserved {
7135 unresolvable: 1,
7136 ..
7137 }
7138 ))
7139 .count(),
7140 2,
7141 "an expression stays unresolvable for both profiles"
7142 );
7143 }
7144
7145 #[tokio::test]
7146 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
7147 let mut harness = Harness::simple(4, 0, "o/r");
7148 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7149 repo("o/r"),
7150 [fixtures::queued_job(&[
7151 HOST_LABEL,
7152 "rm-home-win-x64-py-isolated",
7153 ])],
7154 )));
7155 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7156 isolated.activate().unwrap();
7157 let report = harness
7158 .reconciler
7159 .reconcile(&[policy(1, "o/r", 2), isolated])
7160 .await;
7161 assert_eq!(report.started, 0);
7162 assert_eq!(
7163 harness
7164 .events
7165 .events()
7166 .iter()
7167 .filter(|event| matches!(
7168 event,
7169 LifecycleEvent::DemandObserved {
7170 demand: 0,
7171 not_matched: 1,
7172 ..
7173 }
7174 ))
7175 .count(),
7176 2
7177 );
7178
7179 let mut superset = policy(1, "o/r", 2);
7183 assert!(
7184 superset
7185 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
7186 .unwrap()
7187 );
7188 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7189 sibling.activate().unwrap();
7190 let mut unique = Harness::simple(4, 0, "o/r");
7191 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
7192 repo("o/r"),
7193 [fixtures::queued_job(&[
7194 HOST_LABEL,
7195 "rm-home-win-x64-py-isolated",
7196 ])],
7197 )));
7198 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
7199 assert_eq!(report.started, 1);
7200 assert_eq!(
7201 report
7202 .allocations
7203 .iter()
7204 .find(|allocation| allocation.to_start == 1)
7205 .unwrap()
7206 .policy_id,
7207 PolicyId::from_u128(1)
7208 );
7209 assert!(
7210 !unique
7211 .events
7212 .events()
7213 .iter()
7214 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
7215 );
7216
7217 let mut overlap = Harness::simple(4, 1, "o/r");
7218 let report = overlap
7219 .reconciler
7220 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
7221 .await;
7222 assert_eq!(report.started, 0);
7223 assert!(overlap.events.events().iter().any(|event| matches!(
7224 event,
7225 LifecycleEvent::RoutingRefused {
7226 reason: RoutingRefusal::OverlappingProfiles,
7227 count: 1,
7228 ..
7229 }
7230 )));
7231 assert!(
7232 RoutingRefusal::OverlappingProfiles
7233 .to_string()
7234 .contains("repair")
7235 );
7236 }
7237
7238 #[tokio::test]
7243 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
7244 let launcher = Arc::new(FakeLauncher::new());
7245 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
7246 let mut harness = Harness::build(
7247 host_with(4),
7248 Arc::clone(&launcher),
7249 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
7250 Arc::new(InProcessAllocationLock::new()),
7251 );
7252
7253 let report = harness
7254 .reconciler
7255 .reconcile(&[policy(1, "acme/app", 4)])
7256 .await;
7257 assert_eq!(report.started, 0);
7258 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
7259
7260 let failures: Vec<&'static str> = harness
7261 .events
7262 .events()
7263 .into_iter()
7264 .filter_map(|event| match event {
7265 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
7266 _ => None,
7267 })
7268 .collect();
7269 assert_eq!(failures, vec!["other"]);
7270 assert!(
7271 !failures[0].contains("ghp_"),
7272 "an event carried a `FailureReason::Other` detail verbatim"
7273 );
7274 }
7275
7276 #[tokio::test]
7281 async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
7282 let launcher = Arc::new(FakeLauncher::new());
7283 let unfit = Arc::new(AtomicBool::new(true));
7284 let events = Arc::new(EventLog::new());
7285 let mut reconciler = Reconciler::new(
7286 host_with(4),
7287 ReconcilerPorts {
7288 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
7289 launcher: Arc::new(FitnessGatedLauncher::new(
7290 Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7291 Arc::clone(&unfit),
7292 )),
7293 lock: Arc::new(InProcessAllocationLock::new()),
7294 directory: Arc::new(FakeDirectory::default()),
7295 clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
7296 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
7297 events: Arc::clone(&events) as Arc<dyn EventSink>,
7298 },
7299 );
7300 let policies = [policy(1, "acme/app", 4)];
7301
7302 let report = reconciler.reconcile(&policies).await;
7303 assert_eq!(report.started, 0);
7304 assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
7305 let reasons: Vec<&'static str> = events
7306 .events()
7307 .into_iter()
7308 .filter_map(|event| match event {
7309 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
7310 _ => None,
7311 })
7312 .collect();
7313 assert_eq!(reasons, ["host_unfit"]);
7314
7315 unfit.store(false, Ordering::Release);
7316 let report = reconciler.reconcile(&policies).await;
7317 assert_eq!(report.started, 2);
7318 assert_eq!(launcher.launches(), 2);
7319 }
7320
7321 #[tokio::test]
7322 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
7323 let launcher = Arc::new(FakeLauncher::new());
7324 let mut harness = Harness::build(
7325 host_with(4),
7326 Arc::clone(&launcher),
7327 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
7328 Arc::new(HeldLock),
7329 );
7330
7331 let report = harness
7332 .reconciler
7333 .reconcile(&[policy(1, "acme/app", 4)])
7334 .await;
7335 assert_eq!(report.started, 0);
7336 assert_eq!(
7337 report.deferred, 3,
7338 "three runners were granted and none was created; `deferred` counts grants, \
7339 not policies -- it reported `1` when a policy that launched two of five and \
7340 then lost the lock had left three unstarted"
7341 );
7342 assert_eq!(launcher.launches(), 0);
7343 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
7344 assert!(
7345 harness
7346 .events
7347 .events()
7348 .iter()
7349 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
7350 "the event carries the same number the report does"
7351 );
7352 assert_eq!(
7353 report.allocations.len(),
7354 1,
7355 "the intent is still reported, so an operator staring at a queue sees why \
7356 nothing started"
7357 );
7358 }
7359
7360 #[tokio::test]
7361 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
7362 let mut harness = Harness::simple(8, 5, "acme/app");
7363
7364 let foreign = fixtures::policy()
7365 .id(PolicyId::from_u128(1))
7366 .repository("acme/app")
7367 .host(HostId::from_u128(0xdead))
7368 .autoscale("office", 4)
7369 .active()
7370 .build();
7371 let mut draining = policy(2, "acme/app", 4);
7372 draining.request_disable().unwrap();
7373
7374 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
7375
7376 assert_eq!(report.started, 0);
7377 assert_eq!(
7378 harness.demand.polls().len(),
7379 0,
7380 "neither can act on an answer"
7381 );
7382 let factors: Vec<LimitingFactor> = report
7383 .allocations
7384 .iter()
7385 .map(|a| a.limiting_factor)
7386 .collect();
7387 assert!(factors.contains(&LimitingFactor::ForeignHost));
7388 assert!(factors.contains(&LimitingFactor::NotReconciling));
7389 }
7390
7391 #[tokio::test]
7392 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
7393 #[derive(Debug)]
7396 struct BrokenDirectory;
7397
7398 #[async_trait::async_trait]
7399 impl RepositoryDirectory for BrokenDirectory {
7400 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
7401 Err(InventoryError::Cancelled)
7402 }
7403 }
7404
7405 let launcher = Arc::new(FakeLauncher::new());
7406 let events = Arc::new(EventLog::new());
7407 let mut reconciler = Reconciler::new(
7408 host_with(4),
7409 ReconcilerPorts {
7410 demand: Arc::new(FakeDemand::default()),
7411 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7412 lock: Arc::new(InProcessAllocationLock::new()),
7413 directory: Arc::new(BrokenDirectory),
7414 clock: Arc::new(FakeClock::default()),
7415 jitter: Arc::new(NoJitter),
7416 events: Arc::clone(&events) as Arc<dyn EventSink>,
7417 },
7418 );
7419
7420 let org_policy = fixtures::policy()
7421 .id(PolicyId::from_u128(1))
7422 .organization("acme")
7423 .autoscale("home", 4)
7424 .active()
7425 .build();
7426
7427 let report = reconciler.reconcile(&[org_policy]).await;
7428 assert_eq!(report.started, 0);
7429 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
7430 assert_eq!(events.count_of("target_unreadable"), 1);
7431 }
7432
7433 fn every_event() -> Vec<LifecycleEvent> {
7445 let policy = PolicyId::from_u128(0xabcd_ef01);
7446 let attempt = AttemptId::from_u128(0x1234_5678);
7447 vec![
7448 LifecycleEvent::DemandObserved {
7449 policy,
7450 demand: u32::MAX,
7451 not_matched: u32::MAX,
7452 unresolvable: u32::MAX,
7453 complete: false,
7454 },
7455 LifecycleEvent::TargetUnreadable {
7456 policy,
7457 reason: unreadable_reason(&RefreshState::Failed {
7458 status: Some(500),
7459 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
7460 .into(),
7461 }),
7462 },
7463 LifecycleEvent::Allocated {
7464 policy,
7465 demand: u32::MAX,
7466 desired: u16::MAX,
7467 active_owned: 7,
7468 headroom: 9,
7469 to_start: 2,
7470 limiting: LimitingFactor::HostCapacity,
7471 },
7472 LifecycleEvent::MonitorOnlySkipped { policy },
7473 LifecycleEvent::RunnerStarted { policy, attempt },
7474 LifecycleEvent::RunnerStartFailed {
7475 policy,
7476 reason: failure_reason_kind(&FailureReason::Other(
7477 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
7478 )),
7479 },
7480 LifecycleEvent::AllocationDeferred { policy, count: 4 },
7481 LifecycleEvent::AttemptsUnreadable {
7482 reason: failure_reason_kind(&FailureReason::Other(
7483 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
7484 )),
7485 },
7486 LifecycleEvent::HostCapacityUnreadable {
7487 reason: HostCapacityError::MissingHost,
7488 },
7489 LifecycleEvent::HostCapacityUnreadable {
7490 reason: HostCapacityError::JournalUnreadable,
7491 },
7492 LifecycleEvent::AttemptCleanFailed {
7493 policy,
7494 attempt,
7495 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
7496 },
7497 LifecycleEvent::AttemptCleaned {
7498 policy,
7499 attempt,
7500 outcome: OutcomeKind::IdleExit,
7501 },
7502 LifecycleEvent::ScaleDownRefused { policy, attempt },
7503 LifecycleEvent::PollScheduled {
7504 retry_in_ms: 900_000,
7505 pace: PollPace::RateLimited {
7506 kind: RateLimitKind::Primary,
7507 },
7508 },
7509 ]
7510 }
7511
7512 #[test]
7521 fn no_emitted_event_can_carry_a_credential() {
7522 use runner_manager_platform::logging::redact;
7523
7524 for event in every_event() {
7525 let displayed = event.to_string();
7526 assert_eq!(
7527 redact(&displayed),
7528 displayed,
7529 "`{}` renders something `d1`'s sink would have to redact",
7530 event.name()
7531 );
7532
7533 let debugged = format!("{event:?}");
7534 assert_eq!(
7535 redact(&debugged),
7536 debugged,
7537 "`{}`'s Debug renders something `d1`'s sink would have to redact",
7538 event.name()
7539 );
7540 }
7541
7542 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
7545 assert_ne!(
7546 redact(secret),
7547 secret,
7548 "the scan above proves nothing if `redact` no longer recognises a credential"
7549 );
7550 }
7551
7552 #[test]
7553 fn every_field_name_this_sink_emits_is_one_d1_allows() {
7554 use runner_manager_platform::logging::is_field_allowed;
7555
7556 for field in [
7560 "event",
7561 "policy_id",
7562 "attempt_id",
7563 "attempt_state",
7564 "demand",
7565 "desired",
7566 "capacity",
7567 "headroom",
7568 "count",
7569 "reason",
7570 "outcome",
7571 "mode",
7572 "lock",
7573 "retry_in_ms",
7574 "state",
7575 ] {
7576 assert!(
7577 is_field_allowed(field),
7578 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
7579 `[redacted]` in its place and the line would lose its meaning"
7580 );
7581 }
7582 }
7583
7584 #[test]
7585 fn every_failure_reason_has_a_credential_free_kind() {
7586 for reason in FailureReason::ALL {
7587 let kind = failure_reason_kind(&reason);
7588 assert!(!kind.is_empty());
7589 assert!(
7590 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
7591 "`{kind}` is not a fixed identifier"
7592 );
7593 }
7594 assert_eq!(
7595 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
7596 "other",
7597 "the detail of an `Other` reason never reaches an event"
7598 );
7599 let categories = [
7600 IsolationProviderFailure::NotInstalled,
7601 IsolationProviderFailure::PermissionDenied,
7602 IsolationProviderFailure::ImageUnavailableOrIncompatible,
7603 IsolationProviderFailure::DiskQuotaUnavailable,
7604 IsolationProviderFailure::RuntimeOperationFailed,
7605 ];
7606 let mut kinds = std::collections::BTreeSet::new();
7607 for category in categories {
7608 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
7609 assert_ne!(kind, "other");
7610 assert!(!kind.contains("secret"));
7611 kinds.insert(kind);
7612 }
7613 assert_eq!(kinds.len(), categories.len());
7614 }
7615
7616 fn production_half_of(source: &str) -> String {
7631 let production = source
7632 .split_once("\n#[cfg(test)]")
7633 .map_or(source, |(production, _)| production);
7634 production
7635 .lines()
7636 .filter(|line| !line.trim_start().starts_with("//"))
7637 .collect::<Vec<_>>()
7638 .join("\n")
7639 }
7640
7641 fn this_file_above_its_tests_without_prose() -> String {
7643 production_half_of(include_str!("reconcile.rs"))
7644 }
7645
7646 fn normalise_for_scan(text: &str) -> String {
7665 text.to_ascii_lowercase().replace(['_', ' '], "")
7666 }
7667
7668 const FORBIDDEN: &[&str] = &[
7678 concat!("fn ", "acquire", "_job"),
7679 concat!("fn ", "claim", "_job"),
7680 concat!("fn ", "lease", "_job"),
7681 concat!("fn ", "reserve", "_job"),
7682 concat!("fn ", "ack", "nowledge"),
7683 concat!("struct ", "Job", "Lease"),
7684 concat!("struct ", "Job", "Claim"),
7685 concat!("struct ", "Job", "Reservation"),
7686 ];
7687
7688 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
7689 let haystack = normalise_for_scan(source);
7690 FORBIDDEN
7691 .iter()
7692 .copied()
7693 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
7694 }
7695
7696 #[test]
7730 fn nothing_in_this_crate_reserves_or_claims_a_job() {
7731 const SOURCES: &[(&str, &str)] = &[
7732 ("dependency_caches.rs", include_str!("dependency_caches.rs")),
7733 ("docker_cleanup.rs", include_str!("docker_cleanup.rs")),
7734 (
7735 "docker_cleanup/http.rs",
7736 include_str!("docker_cleanup/http.rs"),
7737 ),
7738 (
7739 "docker_cleanup/policy.rs",
7740 include_str!("docker_cleanup/policy.rs"),
7741 ),
7742 (
7743 "docker_cleanup/proxy.rs",
7744 include_str!("docker_cleanup/proxy.rs"),
7745 ),
7746 (
7747 "docker_cleanup/tests.rs",
7748 include_str!("docker_cleanup/tests.rs"),
7749 ),
7750 ("lib.rs", include_str!("lib.rs")),
7751 ("lifecycle.rs", include_str!("lifecycle.rs")),
7752 (
7753 "lifecycle/windows_hyperv.rs",
7754 include_str!("lifecycle/windows_hyperv.rs"),
7755 ),
7756 ("oci.rs", include_str!("oci.rs")),
7757 ("package.rs", include_str!("package.rs")),
7758 ("reconcile.rs", include_str!("reconcile.rs")),
7759 ];
7760
7761 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
7762 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
7763 let entry = entry.expect("a readable directory entry");
7764 let name = entry.file_name().to_string_lossy().into_owned();
7765 let joined = if prefix.is_empty() {
7768 name.clone()
7769 } else {
7770 format!("{prefix}/{name}")
7771 };
7772 if entry.path().is_dir() {
7773 walk(&entry.path(), &joined, found);
7774 } else if name.ends_with(".rs") {
7775 found.push(joined);
7776 }
7777 }
7778 }
7779
7780 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
7781 listed.sort_unstable();
7782 let mut on_disk = Vec::new();
7783 walk(
7784 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
7785 "",
7786 &mut on_disk,
7787 );
7788 on_disk.sort_unstable();
7789 assert_eq!(
7790 listed, on_disk,
7791 "a source file was added or removed; this scan claims to cover the whole crate \
7792 and a stale list makes that claim false"
7793 );
7794
7795 for (name, source) in SOURCES {
7796 assert_eq!(
7797 forbidden_shape_in(&production_half_of(source)),
7798 None,
7799 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
7800 REST, and a local lease coordinates this host with itself and with nothing \
7801 else. If an owner decision restored one, that decision belongs in this \
7802 module's documentation and in this test before it belongs in the code"
7803 );
7804 }
7805
7806 assert!(
7809 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
7810 "the scan above proves nothing if the needles no longer match"
7811 );
7812 }
7813
7814 #[test]
7842 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
7843 let production = this_file_above_its_tests_without_prose();
7844
7845 assert!(
7846 production.contains("DemandTally"),
7847 "the reconciliation loop must tally queued jobs against this policy's routing \
7848 labels. A production half that named `DemandTally` nowhere would mean the \
7849 label filtering had been removed, and a repository whose jobs target \
7850 `ubuntu-latest` would drive its policy toward `max_capacity` again"
7851 );
7852
7853 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
7854 assert!(
7855 !production.contains(second_implementation),
7856 "the reconciliation loop names `{second_implementation}`, which is the \
7857 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
7858 has already decided it. This module applies the predicate and does not \
7859 re-implement it; if an owner decision changed that, it belongs in this \
7860 module's documentation and in this test before it belongs in the code"
7861 );
7862 }
7863 }
7864
7865 #[test]
7873 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
7874 let policy = policy(1, "acme/app", 10);
7875 let reading = QueuedDemand::of(
7876 repo("acme/app"),
7877 [
7878 fixtures::queued_job(&[HOST_LABEL]),
7879 fixtures::queued_job(&[HOST_LABEL]),
7880 fixtures::queued_job(&["ubuntu-latest"]),
7881 fixtures::unresolvable_job(),
7882 ],
7883 );
7884
7885 let tally = demand_for(&policy, &reading);
7886
7887 assert_eq!(
7888 tally.demand(),
7889 2,
7890 "only the jobs whose required labels this policy carries are demand"
7891 );
7892 assert_eq!(
7893 tally.not_matched, 1,
7894 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
7895 runner that idles until it times out"
7896 );
7897 assert_eq!(
7898 tally.unresolvable.len(),
7899 1,
7900 "an unresolvable `runs-on` is never demand and never discarded"
7901 );
7902 }
7903
7904 #[test]
7907 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
7908 let mut per_repository = BTreeMap::new();
7909 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
7910 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
7911 let reading = QueuedDemand::new(per_repository);
7912
7913 let repository_policy = policy(1, "acme/left", 10);
7914 assert_eq!(
7915 demand_for(&repository_policy, &reading).demand(),
7916 3,
7917 "a repository target reads its own repository's queue and not the aggregate"
7918 );
7919
7920 let org_policy = fixtures::policy()
7921 .id(PolicyId::from_u128(2))
7922 .organization("acme")
7923 .autoscale("home", 10)
7924 .active()
7925 .build();
7926 assert_eq!(
7927 demand_for(&org_policy, &reading).demand(),
7928 7,
7929 "an organization policy serves any repository in its scope, so its demand is \
7930 the whole aggregate's"
7931 );
7932 }
7933
7934 #[test]
7935 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
7936 let host = host_with(2);
7941 let policy = policy(1, "acme/app", 5);
7942 let attempts: Vec<RunnerAttempt> = Vec::new();
7943 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
7944
7945 let allocation = allocator.allocate(&policy, u32::MAX);
7946 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
7947 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
7948 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
7949 }
7950
7951 #[derive(Debug, Default)]
7954 struct FixedDirector {
7955 directed: Mutex<BTreeMap<PolicyId, u32>>,
7956 started: Mutex<Vec<(PolicyId, AttemptId)>>,
7957 failed: Mutex<Vec<(PolicyId, Option<AttemptId>)>>,
7958 older: Mutex<std::collections::VecDeque<usize>>,
7960 passed_over: AtomicUsize,
7961 orders: Mutex<BTreeMap<PolicyId, u64>>,
7963 }
7964
7965 impl DemandDirector for FixedDirector {
7966 fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32> {
7967 self.directed.lock().unwrap().get(&policy.id).copied()
7968 }
7969
7970 fn launched(&self, policy: &ScalePolicy, attempt: AttemptId) {
7971 self.started.lock().unwrap().push((policy.id, attempt));
7972 }
7973
7974 fn start_failed(&self, policy: &ScalePolicy, attempt: Option<AttemptId>) {
7975 self.failed.lock().unwrap().push((policy.id, attempt));
7976 }
7977
7978 fn next_waiting(&self, policy: &ScalePolicy) -> Option<u64> {
7979 self.orders.lock().unwrap().get(&policy.id).copied()
7980 }
7981
7982 fn older_waiting(&self, _policy: &ScalePolicy) -> usize {
7983 let mut older = self.older.lock().unwrap();
7984 if older.len() > 1 {
7985 older.pop_front().unwrap_or(0)
7986 } else {
7987 older.front().copied().unwrap_or(0)
7988 }
7989 }
7990
7991 fn pass_over_older(&self, _policy: &ScalePolicy) {
7992 self.passed_over.fetch_add(1, Ordering::SeqCst);
7993 self.older.lock().unwrap().clear();
7994 }
7995 }
7996
7997 #[derive(Debug)]
8000 struct HeldBringUp {
8001 inner: Arc<FakeLauncher>,
8002 barrier: Arc<tokio::sync::Barrier>,
8003 fail: Option<FailureReason>,
8004 waits: Mutex<Vec<ReservationWaits>>,
8005 }
8006
8007 #[async_trait::async_trait]
8008 impl RunnerLauncher for HeldBringUp {
8009 async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
8010 self.inner.host_capacity(host).await
8011 }
8012
8013 async fn supervise(
8014 &self,
8015 policy: &ScalePolicy,
8016 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
8017 self.inner.supervise(policy).await
8018 }
8019
8020 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
8021 self.inner.attempts().await
8022 }
8023
8024 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
8025 self.inner.launch(request).await
8026 }
8027
8028 async fn finish_launch(
8029 &self,
8030 _policy: &ScalePolicy,
8031 attempt: RunnerAttempt,
8032 waits: ReservationWaits,
8033 ) -> Result<RunnerAttempt, LaunchFailure> {
8034 self.waits.lock().unwrap().push(waits);
8035 self.barrier.wait().await;
8036 match &self.fail {
8037 Some(reason) => Err(LaunchFailure::new(reason.clone())),
8038 None => Ok(attempt),
8039 }
8040 }
8041
8042 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
8043 self.inner.clean(attempt).await
8044 }
8045 }
8046
8047 fn directed_reconciler(
8048 capacity: u16,
8049 launcher: Arc<dyn RunnerLauncher>,
8050 lock: Arc<dyn AllocationLock>,
8051 director: Arc<FixedDirector>,
8052 ) -> Reconciler {
8053 Reconciler::new(
8054 host_with(capacity),
8055 ReconcilerPorts {
8056 demand: Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
8057 launcher,
8058 lock,
8059 directory: Arc::new(FakeDirectory::default()),
8060 clock: Arc::new(FakeClock::default()),
8061 jitter: Arc::new(NoJitter),
8062 events: Arc::new(NoEvents),
8063 },
8064 )
8065 .with_director(director as Arc<dyn DemandDirector>)
8066 }
8067
8068 #[tokio::test]
8071 async fn two_targets_bring_up_their_runners_at_once() {
8072 let fake = Arc::new(FakeLauncher::new());
8073 let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
8074 let barrier = Arc::new(tokio::sync::Barrier::new(2));
8075 let held = Arc::new(HeldBringUp {
8076 inner: Arc::clone(&fake),
8077 barrier,
8078 fail: None,
8079 waits: Mutex::default(),
8080 });
8081 let director = Arc::new(FixedDirector::default());
8082 let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
8083 director.directed.lock().unwrap().insert(one.id, 1);
8084 director.directed.lock().unwrap().insert(two.id, 1);
8085 let first = directed_reconciler(
8086 2,
8087 Arc::clone(&held) as _,
8088 Arc::clone(&lock),
8089 Arc::clone(&director),
8090 );
8091 let second = directed_reconciler(2, held as _, lock, director);
8092
8093 let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
8094 tokio::join!(
8095 first.start_directed(std::slice::from_ref(&one)),
8096 second.start_directed(std::slice::from_ref(&two)),
8097 )
8098 })
8099 .await
8100 .expect("neither waits for the other's bring-up");
8101 assert_eq!((a.started, b.started), (1, 1));
8102 }
8103
8104 #[tokio::test]
8107 async fn a_runner_coming_up_holds_its_slot_against_another_target() {
8108 let fake = Arc::new(FakeLauncher::new());
8109 let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
8110 let barrier = Arc::new(tokio::sync::Barrier::new(2));
8111 let held = Arc::new(HeldBringUp {
8112 inner: Arc::clone(&fake),
8113 barrier: Arc::clone(&barrier),
8114 fail: None,
8115 waits: Mutex::default(),
8116 });
8117 let director = Arc::new(FixedDirector::default());
8118 let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
8119 director.directed.lock().unwrap().insert(one.id, 1);
8120 director.directed.lock().unwrap().insert(two.id, 1);
8121 let first = directed_reconciler(
8122 1,
8123 Arc::clone(&held) as _,
8124 Arc::clone(&lock),
8125 Arc::clone(&director),
8126 );
8127 let second = directed_reconciler(1, held as _, lock, director);
8128
8129 let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
8130 tokio::join!(first.start_directed(std::slice::from_ref(&one)), async {
8131 while fake.live_count() == 0 {
8132 tokio::task::yield_now().await;
8133 }
8134 let report = second.start_directed(std::slice::from_ref(&two)).await;
8135 barrier.wait().await;
8136 report
8137 })
8138 })
8139 .await
8140 .expect("the first is let go");
8141 assert_eq!((a.started, b.started), (1, 0));
8142 assert_eq!(fake.live_count(), 1);
8143 }
8144
8145 #[tokio::test]
8148 async fn a_failed_bring_up_is_a_failed_start() {
8149 let fake = Arc::new(FakeLauncher::new());
8150 let held = Arc::new(HeldBringUp {
8151 inner: fake,
8152 barrier: Arc::new(tokio::sync::Barrier::new(1)),
8153 fail: Some(FailureReason::JitRequestFailed),
8154 waits: Mutex::default(),
8155 });
8156 let director = Arc::new(FixedDirector::default());
8157 let pool = policy(1, "acme/app", 2);
8158 director.directed.lock().unwrap().insert(pool.id, 1);
8159 let reconciler = directed_reconciler(
8160 2,
8161 held as _,
8162 Arc::new(InProcessAllocationLock::new()),
8163 Arc::clone(&director),
8164 );
8165
8166 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8167
8168 assert_eq!(report.started, 0);
8169 let started = director.started.lock().unwrap()[0].1;
8170 assert_eq!(
8171 *director.failed.lock().unwrap(),
8172 vec![(pool.id, Some(started))]
8173 );
8174 }
8175
8176 fn scripted_older(director: &FixedDirector, looks: &[(usize, usize)]) {
8177 *director.older.lock().unwrap() = looks
8178 .iter()
8179 .flat_map(|(value, count)| std::iter::repeat_n(*value, *count))
8180 .collect();
8181 }
8182
8183 #[tokio::test(start_paused = true)]
8187 async fn a_younger_launch_reserves_after_older_ones_while_they_make_progress() {
8188 let held = Arc::new(HeldBringUp {
8189 inner: Arc::new(FakeLauncher::new()),
8190 barrier: Arc::new(tokio::sync::Barrier::new(1)),
8191 fail: None,
8192 waits: Mutex::default(),
8193 });
8194 let director = Arc::new(FixedDirector::default());
8195 let pool = policy(1, "acme/app", 3);
8196 director.directed.lock().unwrap().insert(pool.id, 1);
8197 let reconciler = directed_reconciler(
8198 5,
8199 Arc::clone(&held) as _,
8200 Arc::new(InProcessAllocationLock::new()),
8201 Arc::clone(&director),
8202 );
8203
8204 let looks =
8207 (OLDER_LAUNCH_TURN.as_millis() * 3 / 4 / OLDER_LAUNCH_POLL.as_millis()) as usize;
8208 scripted_older(&director, &[(3, looks), (2, looks), (1, looks), (0, 1)]);
8209 let began = tokio::time::Instant::now();
8210 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8211 assert_eq!(report.started, 1);
8212 assert_eq!(
8213 began.elapsed(),
8214 OLDER_LAUNCH_POLL * u32::try_from(looks * 3).unwrap()
8215 );
8216 assert_eq!(director.passed_over.load(Ordering::SeqCst), 0);
8217 assert_eq!(
8218 held.waits.lock().unwrap()[0].order,
8219 OLDER_LAUNCH_POLL * u32::try_from(looks * 3).unwrap(),
8220 "the wait is in the launch's timings"
8221 );
8222 }
8223
8224 #[tokio::test]
8227 async fn a_pass_hands_a_directed_launch_to_the_launch_side() {
8228 let fake = Arc::new(FakeLauncher::new());
8229 let director = Arc::new(FixedDirector::default());
8230 let pool = policy(1, "acme/app", 3);
8231 director.directed.lock().unwrap().insert(pool.id, 1);
8232 let signal = Arc::new(tokio::sync::Notify::new());
8233 let mut reconciler = directed_reconciler(
8234 5,
8235 Arc::clone(&fake) as _,
8236 Arc::new(InProcessAllocationLock::new()),
8237 Arc::clone(&director),
8238 )
8239 .with_launch_side(Arc::clone(&signal));
8240
8241 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8242
8243 assert_eq!(report.started, 0);
8244 assert_eq!(fake.live_count(), 0);
8245 assert_eq!(report.allocations[0].to_start, 1);
8246 tokio::time::timeout(Duration::from_secs(1), signal.notified())
8247 .await
8248 .expect("the launch side is told");
8249
8250 director.directed.lock().unwrap().insert(pool.id, 0);
8252 reconciler.reconcile(std::slice::from_ref(&pool)).await;
8253 assert!(
8254 tokio::time::timeout(Duration::from_millis(50), signal.notified())
8255 .await
8256 .is_err()
8257 );
8258 }
8259
8260 #[tokio::test(start_paused = true)]
8263 async fn an_older_launch_that_cannot_reserve_is_passed_over_once() {
8264 let fake = Arc::new(FakeLauncher::new());
8265 let director = Arc::new(FixedDirector::default());
8266 let pool = policy(1, "acme/app", 3);
8267 director.directed.lock().unwrap().insert(pool.id, 1);
8268 let reconciler = directed_reconciler(
8269 5,
8270 Arc::clone(&fake) as _,
8271 Arc::new(InProcessAllocationLock::new()),
8272 Arc::clone(&director),
8273 );
8274
8275 scripted_older(&director, &[(1, 1)]);
8276 let began = tokio::time::Instant::now();
8277 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8278 assert_eq!(report.started, 1, "started anyway");
8279 assert!(began.elapsed() >= OLDER_LAUNCH_TURN);
8280 assert!(began.elapsed() < OLDER_LAUNCH_TURN + Duration::from_secs(1));
8281 assert_eq!(director.passed_over.load(Ordering::SeqCst), 1);
8282
8283 director.directed.lock().unwrap().insert(pool.id, 2);
8284 let began = tokio::time::Instant::now();
8285 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8286 assert_eq!(report.started, 1);
8287 assert_eq!(began.elapsed(), Duration::ZERO);
8288 }
8289
8290 #[tokio::test]
8293 async fn a_targets_profiles_reserve_oldest_launch_first() {
8294 let director = Arc::new(FixedDirector::default());
8295 let (younger, older) = (policy(1, "acme/app", 3), policy(2, "acme/app", 3));
8296 director.directed.lock().unwrap().insert(younger.id, 1);
8297 director.directed.lock().unwrap().insert(older.id, 1);
8298 director.orders.lock().unwrap().insert(younger.id, 9);
8299 director.orders.lock().unwrap().insert(older.id, 4);
8300 let reconciler = directed_reconciler(
8301 5,
8302 Arc::new(FakeLauncher::new()) as _,
8303 Arc::new(InProcessAllocationLock::new()),
8304 Arc::clone(&director),
8305 );
8306
8307 let report = reconciler
8308 .start_directed(&[younger.clone(), older.clone()])
8309 .await;
8310
8311 assert_eq!(report.started, 2);
8312 let order: Vec<PolicyId> = director
8313 .started
8314 .lock()
8315 .unwrap()
8316 .iter()
8317 .map(|(policy, _)| *policy)
8318 .collect();
8319 assert_eq!(order, vec![older.id, younger.id]);
8320 }
8321
8322 fn serving<'a>(
8324 reconciler: &'a Reconciler,
8325 pool: &ScalePolicy,
8326 signal: &'a tokio::sync::Notify,
8327 stop: tokio::sync::watch::Receiver<bool>,
8328 ) -> impl std::future::Future<Output = ()> + 'a {
8329 let pool = pool.clone();
8330 async move {
8331 let policies = move || vec![pool.clone()];
8332 reconciler.serve_launches(&policies, signal, stop).await;
8333 }
8334 }
8335
8336 #[tokio::test]
8340 async fn a_launch_accepted_during_a_batch_starts_without_waiting_for_it() {
8341 let fake = Arc::new(FakeLauncher::new());
8342 let held = Arc::new(HeldBringUp {
8343 inner: Arc::clone(&fake),
8344 barrier: Arc::new(tokio::sync::Barrier::new(2)),
8345 fail: None,
8346 waits: Mutex::default(),
8347 });
8348 let director = Arc::new(FixedDirector::default());
8349 let pool = policy(1, "acme/app", 3);
8350 director.directed.lock().unwrap().insert(pool.id, 1);
8351 let reconciler = directed_reconciler(
8352 5,
8353 held as _,
8354 Arc::new(InProcessAllocationLock::new()),
8355 Arc::clone(&director),
8356 );
8357 let signal = tokio::sync::Notify::new();
8358 let (stop, stopped) = tokio::sync::watch::channel(false);
8359
8360 tokio::time::timeout(Duration::from_secs(10), async {
8361 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8362 signal.notify_one();
8363 while director.started.lock().unwrap().is_empty() {
8364 tokio::task::yield_now().await;
8365 }
8366 director.directed.lock().unwrap().insert(pool.id, 2);
8367 signal.notify_one();
8368 while director.started.lock().unwrap().len() < 2 {
8369 tokio::task::yield_now().await;
8370 }
8371 stop.send_replace(true);
8372 })
8373 })
8374 .await
8375 .expect("the second came up beside the first, and stopping ends it");
8376 assert_eq!(fake.live_count(), 2);
8377 }
8378
8379 #[tokio::test]
8382 async fn a_launch_during_a_batch_takes_no_slot_that_batch_holds() {
8383 let fake = Arc::new(FakeLauncher::new());
8384 let held = Arc::new(HeldBringUp {
8385 inner: Arc::clone(&fake),
8386 barrier: Arc::new(tokio::sync::Barrier::new(2)),
8387 fail: None,
8388 waits: Mutex::default(),
8389 });
8390 let barrier = Arc::clone(&held.barrier);
8391 let director = Arc::new(FixedDirector::default());
8392 let pool = policy(1, "acme/app", 3);
8393 director.directed.lock().unwrap().insert(pool.id, 1);
8394 let reconciler = directed_reconciler(
8395 1,
8396 held as _,
8397 Arc::new(InProcessAllocationLock::new()),
8398 Arc::clone(&director),
8399 );
8400 let signal = tokio::sync::Notify::new();
8401 let (stop, stopped) = tokio::sync::watch::channel(false);
8402
8403 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8404 signal.notify_one();
8405 while director.started.lock().unwrap().is_empty() {
8406 tokio::task::yield_now().await;
8407 }
8408 director.directed.lock().unwrap().insert(pool.id, 2);
8409 signal.notify_one();
8410 for _ in 0..100 {
8411 tokio::task::yield_now().await;
8412 }
8413 assert_eq!(
8414 director.started.lock().unwrap().len(),
8415 1,
8416 "the host is full"
8417 );
8418 assert_eq!(fake.live_count(), 1);
8419 stop.send_replace(true);
8420 barrier.wait().await;
8421 });
8422 }
8423
8424 #[tokio::test(start_paused = true)]
8428 async fn a_reserved_runner_comes_up_before_the_next_reservation_ends() {
8429 let held = Arc::new(HeldBringUp {
8430 inner: Arc::new(FakeLauncher::new()),
8431 barrier: Arc::new(tokio::sync::Barrier::new(1)),
8432 fail: None,
8433 waits: Mutex::default(),
8434 });
8435 let director = Arc::new(FixedDirector::default());
8436 let pool = policy(1, "acme/app", 3);
8437 director.directed.lock().unwrap().insert(pool.id, 2);
8438 let reconciler = directed_reconciler(
8439 5,
8440 Arc::clone(&held) as _,
8441 Arc::new(InProcessAllocationLock::new()),
8442 Arc::clone(&director),
8443 );
8444 let signal = tokio::sync::Notify::new();
8445 let (stop, stopped) = tokio::sync::watch::channel(false);
8446
8447 scripted_older(&director, &[(0, 1), (1, 1)]);
8449 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8450 signal.notify_one();
8451 tokio::time::sleep(Duration::from_millis(1)).await;
8452 assert_eq!(
8453 held.waits.lock().unwrap().len(),
8454 1,
8455 "the first is coming up"
8456 );
8457 tokio::time::sleep(OLDER_LAUNCH_TURN + Duration::from_secs(1)).await;
8458 assert_eq!(held.waits.lock().unwrap().len(), 2);
8459 stop.send_replace(true);
8460 });
8461 }
8462
8463 #[tokio::test(start_paused = true)]
8466 async fn a_launch_that_did_not_start_is_retried_and_a_stop_ends_it() {
8467 let held = Arc::new(HeldBringUp {
8468 inner: Arc::new(FakeLauncher::new()),
8469 barrier: Arc::new(tokio::sync::Barrier::new(1)),
8470 fail: Some(FailureReason::JitRequestFailed),
8471 waits: Mutex::default(),
8472 });
8473 let director = Arc::new(FixedDirector::default());
8474 let pool = policy(1, "acme/app", 3);
8475 director.directed.lock().unwrap().insert(pool.id, 1);
8476 director.orders.lock().unwrap().insert(pool.id, 1);
8477 let reconciler = directed_reconciler(
8478 5,
8479 held as _,
8480 Arc::new(InProcessAllocationLock::new()),
8481 Arc::clone(&director),
8482 );
8483 let signal = tokio::sync::Notify::new();
8484 let (stop, stopped) = tokio::sync::watch::channel(false);
8485
8486 tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8487 signal.notify_one();
8488 tokio::time::sleep(Duration::from_millis(1)).await;
8489 assert_eq!(director.failed.lock().unwrap().len(), 1);
8490 director.directed.lock().unwrap().insert(pool.id, 2);
8492 tokio::time::sleep(LAUNCH_RETRY - Duration::from_secs(1)).await;
8493 assert_eq!(director.failed.lock().unwrap().len(), 1, "not yet");
8494 tokio::time::sleep(Duration::from_secs(2)).await;
8495 assert_eq!(director.failed.lock().unwrap().len(), 2, "retried");
8496
8497 stop.send_replace(true);
8498 signal.notify_one();
8499 });
8500 assert_eq!(director.failed.lock().unwrap().len(), 2);
8501 }
8502
8503 #[tokio::test]
8507 async fn a_directed_policy_starts_only_what_it_was_directed_to_within_its_ceilings() {
8508 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
8509 let launcher = Arc::new(FakeLauncher::new());
8510 let director = Arc::new(FixedDirector::default());
8511 let mut reconciler = Reconciler::new(
8512 host_with(10),
8513 ReconcilerPorts {
8514 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
8515 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
8516 lock: Arc::new(InProcessAllocationLock::new()),
8517 directory: Arc::new(FakeDirectory::default()),
8518 clock: Arc::new(FakeClock::default()),
8519 jitter: Arc::new(NoJitter),
8520 events: Arc::new(NoEvents),
8521 },
8522 )
8523 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8524 let pool = policy(1, "acme/app", 2);
8525
8526 director.directed.lock().unwrap().insert(pool.id, 0);
8528 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8529 assert_eq!(report.started, 0);
8530
8531 director.directed.lock().unwrap().insert(pool.id, 1);
8533 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8534 assert_eq!(report.started, 1);
8535 assert_eq!(director.started.lock().unwrap().len(), 1);
8536
8537 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8539 assert_eq!(report.started, 0);
8540
8541 director.directed.lock().unwrap().insert(pool.id, 3);
8543 let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8544 assert_eq!(report.started, 1);
8545 assert_eq!(launcher.live_count(), 2);
8546
8547 let polls = demand.polls().len();
8549 director.directed.lock().unwrap().insert(pool.id, 0);
8550 let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8551 assert_eq!(report.started, 0);
8552 let fresh = Arc::new(FakeLauncher::new());
8553 let starter = Reconciler::new(
8554 host_with(10),
8555 ReconcilerPorts {
8556 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
8557 launcher: Arc::clone(&fresh) as Arc<dyn RunnerLauncher>,
8558 lock: Arc::new(InProcessAllocationLock::new()),
8559 directory: Arc::new(FakeDirectory::default()),
8560 clock: Arc::new(FakeClock::default()),
8561 jitter: Arc::new(NoJitter),
8562 events: Arc::new(NoEvents),
8563 },
8564 )
8565 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8566 let wide = policy(2, "acme/app", 6);
8567 director.directed.lock().unwrap().insert(wide.id, 6);
8568 let report = starter.start_directed(std::slice::from_ref(&wide)).await;
8569 assert_eq!(report.started, 6, "six launches, one call");
8570 assert_eq!(demand.polls().len(), polls, "GitHub was not polled");
8571 assert_eq!(
8573 starter
8574 .start_directed(&[policy(3, "acme/app", 2)])
8575 .await
8576 .started,
8577 0
8578 );
8579
8580 let failing = Arc::new(FakeLauncher::new());
8582 let mut reconciler = Reconciler::new(
8583 host_with(10),
8584 ReconcilerPorts {
8585 demand: Arc::new(FakeDemand::failing(RefreshState::Offline)),
8586 launcher: Arc::clone(&failing) as Arc<dyn RunnerLauncher>,
8587 lock: Arc::new(InProcessAllocationLock::new()),
8588 directory: Arc::new(FakeDirectory::default()),
8589 clock: Arc::new(FakeClock::default()),
8590 jitter: Arc::new(NoJitter),
8591 events: Arc::new(NoEvents),
8592 },
8593 )
8594 .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8595 director.directed.lock().unwrap().insert(pool.id, 1);
8596 assert_eq!(reconciler.reconcile(&[pool]).await.started, 1);
8597
8598 let own = Arc::new(FakeLauncher::new());
8600 let mut reconciler = Reconciler::new(
8601 host_with(10),
8602 ReconcilerPorts {
8603 demand: Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
8604 launcher: Arc::clone(&own) as Arc<dyn RunnerLauncher>,
8605 lock: Arc::new(InProcessAllocationLock::new()),
8606 directory: Arc::new(FakeDirectory::default()),
8607 clock: Arc::new(FakeClock::default()),
8608 jitter: Arc::new(NoJitter),
8609 events: Arc::new(NoEvents),
8610 },
8611 )
8612 .with_director(Arc::new(OwnDemand));
8613 assert_eq!(
8614 reconciler
8615 .reconcile(&[policy(1, "acme/app", 2)])
8616 .await
8617 .started,
8618 2
8619 );
8620 }
8621}