runner_manager_agent/reconcile.rs
1// owner: e1-reconciliation-capacity
2
3//! The loop that turns GitHub demand into a decision to start runners — and
4//! that refuses to start them when it should not.
5//!
6//! Every ceiling in this product is enforced from here, so the module is
7//! organised around the four things that can go wrong silently:
8//!
9//! * [`PollSchedule`] — the budget-aware interval. Demand shares one 5,000
10//! requests/hour ceiling with inventory and workflow counts, so this loop
11//! polls on a bounded interval (default 60 s, hard floor 30 s per target) and
12//! *increases* the delay under a rate-limit signal, never decreases it to
13//! catch up.
14//! * [`RepositoryCache`] — the per-organization repository list, refreshed on
15//! an interval materially slower than the demand poll. Re-listing an
16//! organization at demand-poll frequency is what exhausts the shared budget
17//! the paragraph above exists to protect.
18//! * [`Reconciler::reconcile`] — the allocation pass. It re-reads the attempt
19//! set **under the host-wide allocation lock, once per runtime created**, so
20//! two policies reconciling concurrently cannot both spend the same headroom.
21//! * [`LifecycleEvent`] — what `g2` and the local log sink see. Every field is
22//! an identifier, a count, an enumerated state or a duration; nothing free
23//! text, and nothing that came off the wire.
24//!
25//! # There is no acquisition step, and none may be added
26//!
27//! The scale-set model called `AcquireJobs` to reserve an assignment before
28//! scaling. The REST path has no equivalent (`01-current-architecture.md`, edge
29//! case 6), so demand is **advisory**. Two consequences are load-bearing here
30//! and neither is a defect:
31//!
32//! 1. **A surplus runner is an accepted outcome.** Another host serving the
33//! same labels may take the job first; this host's runner then finds no work
34//! and exits on its idle timeout, having cost one capacity slot and one cold
35//! start. That terminal outcome is
36//! [`AttemptOutcome::ExitedIdleWithoutWork`], is cleaned like any other, and
37//! is counted apart from a failure — see [`ReconcileReport::idle_exits`].
38//! 2. **The same job is still `queued` on the next poll** while its runner
39//! starts. The `- active_owned_runners` term in
40//! [`HostAllocator::allocate`] is what stops that from starting a second
41//! runner, and then a third. This module's only job in that arithmetic is to
42//! hand the allocator the attempt set the host actually holds — which is why
43//! [`RunnerLauncher`] supplies both the attempts and the launch, from one
44//! supply point, for the reason `b1` gives at
45//! [`HostAllocator::from_attempts`].
46//!
47//! `tests::nothing_in_this_module_reserves_or_claims_a_job` is a tripwire on the
48//! obvious shape of a reservation being added back.
49//!
50//! # Demand is measured in JOBS, filtered by this policy's routing labels
51//!
52//! `02-target-architecture.md` writes the formula as *"queued jobs whose
53//! `runs-on` matches this policy's routing labels"*, and that is now exactly
54//! what this module clamps. It was not always: an earlier owner decision priced
55//! the per-run job listing out and left this module clamping a count of
56//! **runs**, unfiltered. `crates/github/src/demand.rs` records that decision,
57//! why it was reversed, and what the reversal costs in requests.
58//!
59//! What the reversal means here is two changes to one line:
60//!
61//! * **A run of eight jobs is now eight units of demand, not one.** Under the
62//! run count a matrix filled one runner per poll while the rest of the matrix
63//! waited, so a host configured for ten concurrent runners served an
64//! eight-job matrix nearly serially. That was the defect that forced the
65//! decision back.
66//! * **A job this host cannot serve is no longer demand.** A repository whose
67//! jobs target `ubuntu-latest`, or another host's `rm-<host>-…` label, used to
68//! drive its policy toward `max_capacity` and start runners that idled until
69//! they timed out. The gateway now returns each queued job's `runs-on`, so
70//! `b1`'s predicate finally has its input.
71//!
72//! **The predicate is still `b1`'s and the input is still `c4`'s.** This module
73//! calls [`runner_manager_domain::policy::RoutingLabels`]'s `tally` and
74//! implements no label comparison of its own;
75//! `tests::the_label_predicate_is_b1s_and_this_module_only_applies_it` scans
76//! this file's own source and fails if a second implementation grows here, which
77//! is the same tripwire `c4` carries one layer down.
78//!
79//! # The filtering happens here rather than in the gateway, on purpose
80//!
81//! One target can be watched by more than one policy, each with its own routing
82//! labels, and [`Reconciler`]'s `poll_targets` deliberately polls a target **once**
83//! for all of them. A gateway that filtered would have to be told whose labels to
84//! filter by, which would make the poll per-policy and multiply its request cost
85//! by the number of policies sharing the target — the budget model prices a
86//! target, not a policy. So the gateway returns the jobs and each policy tallies
87//! them against its own labels.
88//!
89//! # What is still approximate
90//!
91//! The surplus-runner path above is narrowed by this change and not closed. A
92//! `runs-on: ${{ matrix.runner }}` cannot be resolved without evaluating the
93//! workflow, so `b1` reports it as unresolvable: never counted as demand, never
94//! silently dropped, and surfaced through
95//! [`LifecycleEvent::DemandObserved::unresolvable`] so that an operator can see
96//! a workflow this host will never serve sitting in the queue. And demand
97//! remains advisory — another host may still take a job this one started a
98//! runner for — which is what the two ceilings bound.
99//!
100//! # What is testable without a network, a filesystem, or a process
101//!
102//! All of it. [`DemandSource`], [`RunnerLauncher`], [`AllocationLock`],
103//! [`RepositoryDirectory`], [`Jitter`] and [`EventSink`] are ports;
104//! [`GatewayDemand`], [`FileAllocationLock`], [`RandomJitter`] and
105//! [`TracingEvents`] are the production adapters, and every one of them is a
106//! thin shell over a decision made in this file.
107
108use std::collections::{BTreeMap, BTreeSet};
109use std::fmt;
110use std::sync::atomic::{AtomicU64, Ordering};
111use std::sync::{Arc, Mutex};
112use std::time::Duration;
113
114use runner_manager_domain::attempt::{AttemptOutcome, AttemptState, FailureReason, RunnerAttempt};
115use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
116use runner_manager_domain::model::{
117 AttemptId, Clock, Host, Org, OwnerRepo, PolicyId, RefreshInterval, ScaleTarget, Timestamp,
118};
119use runner_manager_domain::policy::{DemandTally, ScalePolicy};
120use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
121use runner_manager_github::rest::{
122 ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
123};
124
125// ---------------------------------------------------------------------------
126// Constants
127// ---------------------------------------------------------------------------
128
129/// How much slower than the demand poll the per-organization repository list is
130/// refreshed.
131///
132/// There is no organization-wide workflow-runs endpoint, so an organization
133/// target costs one demand request **per repository the App is installed on**
134/// (`crates/github/src/demand.rs`). Discovering that repository list costs
135/// requests of its own, and it is the one input to a demand poll that changes on
136/// a human timescale: repositories are added to an installation by hand, not by
137/// a workflow starting.
138///
139/// Thirty polls is 30 minutes at the 60-second default and 15 at the 30-second
140/// floor — slow enough that the list is a rounding error against the demand
141/// requests it scopes, and fast enough that a repository added to the
142/// installation starts being served within one coffee break rather than at the
143/// next restart.
144pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
145
146/// The longest the *unjittered* offline back-off may grow to.
147///
148/// A back-off is a safety mechanism, and an unclamped one is an outage with
149/// extra steps. Fifteen minutes matches
150/// [`runner_manager_github::rest::MAX_RATE_LIMIT_BACKOFF`], which is the other
151/// place in this product where a delay is allowed to grow, and it is far inside
152/// the 24-hour bound at which GitHub cancels the queued jobs this loop exists to
153/// serve.
154pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
155
156/// The most the offline back-off is doubled, before the cap applies.
157///
158/// At the 60-second default this reaches [`MAX_OFFLINE_BACKOFF`] on the sixth
159/// consecutive failure, which is roughly half an hour of outage. Past that the
160/// cap holds it flat.
161const MAX_BACKOFF_DOUBLINGS: u32 = 5;
162
163/// How much of the computed back-off is jitter.
164///
165/// Jitter is **added** rather than subtracted, so a back-off never comes out
166/// shorter than the delay it was computed from. Subtractive jitter would let the
167/// first offline poll retry sooner than the nominal interval, which is the
168/// opposite of backing off; it is spelled out because "add jitter" reads as
169/// symmetric and is not.
170const JITTER_RATIO: f64 = 0.5;
171
172/// GitHub cancels a queued job after this long.
173///
174/// `01-current-architecture.md` records the measurement; `03-control-flows.md`
175/// flow 3.3 requires that the offline state **states** it, because an agent
176/// offline for longer than this has lost queued work and the operator cannot
177/// infer that from "offline". [`OfflineState`] is where it is said.
178pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
179
180/// How long [`FileAllocationLock`] waits for the host-wide allocation lock
181/// before reporting contention.
182///
183/// Contention here is expected rather than exceptional — it is two of this
184/// host's own policies creating runtimes at the same moment — and each hold
185/// lasts only as long as one runtime creation. Waiting a few seconds turns the
186/// common case into a short pause instead of a skipped runner.
187///
188/// # How many of these a poll actually costs
189///
190/// One per runtime created, none for a policy that is granted nothing, and at
191/// most one further hold per policy — the case where the pre-check proposed a
192/// grant and the under-lock re-read found the host had filled up underneath it,
193/// so that hold creates nothing and ends the loop. `(3..=5)` in
194/// `two_policies_reconciling_concurrently_never_exceed_host_capacity` is that
195/// bound with two policies and three runtimes; the deterministic single-policy
196/// case is pinned at exactly one per runtime.
197///
198/// That is worth stating because it did not used to be true and the
199/// difference only shows up here: the budget was checked *after* the lock had
200/// been taken and the attempt set re-read, so a policy granted N runners took
201/// N+1 holds, and `start_runners` ran for every readable autoscale policy
202/// including the zero-demand ones — so an idle host with P policies took P
203/// host-wide locks per poll for nothing. Free under
204/// [`InProcessAllocationLock`]; under [`FileAllocationLock`] each one is a
205/// `spawn_blocking` plus a filesystem lock, with this wait behind it.
206///
207/// [`Reconciler::start_runners`] now pre-checks lock-free and stops as soon as
208/// the budget is spent. The under-lock re-read still decides.
209pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
210
211// ---------------------------------------------------------------------------
212// What one demand poll produced
213// ---------------------------------------------------------------------------
214
215/// One target's demand poll, as a value this module can decide from.
216///
217/// The failure half is `c3`'s [`RefreshState`] rather than an
218/// [`InventoryError`], for the reason `c3` gives: `InventoryError` owns a
219/// `reqwest::Error` and a `serde_json::Error`, so it is neither `Clone` nor
220/// `PartialEq` and cannot be stored, compared, or rendered. Summarising at the
221/// gateway boundary — exactly once, in [`GatewayDemand`] — is what lets the
222/// whole schedule below be a pure function of values a test can construct.
223///
224/// [`RefreshState::Ready`] never appears in [`PollOutcome::Failed`]:
225/// [`RefreshState::from_error`] cannot produce it, and a demand poll returns a
226/// [`QueuedDemand`] rather than the runner inventory that variant carries.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum PollOutcome {
229 /// GitHub answered. The count may still be a floor — see
230 /// [`QueuedDemand::is_complete`].
231 Ready(QueuedDemand),
232 /// GitHub did not answer, or answered something this loop must slow down
233 /// for.
234 Failed(RefreshState),
235}
236
237impl PollOutcome {
238 /// The demand reading, when there is one.
239 #[must_use]
240 pub const fn reading(&self) -> Option<&QueuedDemand> {
241 match self {
242 Self::Ready(demand) => Some(demand),
243 Self::Failed(_) => None,
244 }
245 }
246
247 /// The failure, when there is one.
248 #[must_use]
249 pub const fn failure(&self) -> Option<&RefreshState> {
250 match self {
251 Self::Failed(state) => Some(state),
252 Self::Ready(_) => None,
253 }
254 }
255
256 /// Whether GitHub could not be reached at all, as opposed to answering
257 /// something unwelcome.
258 ///
259 /// The whole of flow 3.3 turns on this distinction: an outage retains
260 /// running runners and backs off, while a rejection is a configuration
261 /// problem that waiting does not fix.
262 #[must_use]
263 pub fn is_offline(&self) -> bool {
264 matches!(self, Self::Failed(RefreshState::Offline))
265 }
266}
267
268/// Where this loop gets its demand from.
269///
270/// A port rather than a direct [`DemandGateway`] dependency, because the two
271/// failures this loop must handle differently — unreachable and rate-limited —
272/// are distinguished by [`RefreshState`], and a test that wants to drive the
273/// offline path should not have to manufacture a `reqwest::Error` to do it.
274/// [`GatewayDemand`] is the one adapter that talks to `c4`.
275#[async_trait::async_trait]
276pub trait DemandSource: fmt::Debug + Send + Sync {
277 /// Queued runs across `scope`, or why there are none to report.
278 async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
279}
280
281/// [`DemandSource`] over `c4`'s [`DemandGateway`].
282///
283/// Holds the [`CancelToken`] so that a shutting-down daemon can withdraw a poll
284/// that is already blocked on a socket; `f3` keeps a clone and cancels it.
285#[derive(Debug)]
286pub struct GatewayDemand<G> {
287 gateway: G,
288 cancel: CancelToken,
289}
290
291impl<G: DemandGateway> GatewayDemand<G> {
292 #[must_use]
293 pub const fn new(gateway: G, cancel: CancelToken) -> Self {
294 Self { gateway, cancel }
295 }
296
297 #[must_use]
298 pub const fn gateway(&self) -> &G {
299 &self.gateway
300 }
301}
302
303#[async_trait::async_trait]
304impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
305 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
306 match self.gateway.queued_demand(scope, &self.cancel).await {
307 Ok(demand) => PollOutcome::Ready(demand),
308 // The one place an `InventoryError` is summarised. `c3` owns the
309 // mapping — including transport-to-`Offline`, which is what flow
310 // 3.3 branches on — so this loop never re-decides it.
311 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
312 }
313 }
314}
315
316// ---------------------------------------------------------------------------
317// The repository list, cached
318// ---------------------------------------------------------------------------
319
320/// Which repositories an organization installation reaches.
321///
322/// `f1` already holds this, from
323/// [`runner_manager_github::AuthenticatedClient::discover_installations`]. It is
324/// a port here so that [`RepositoryCache`] can be tested for the property that
325/// matters — how *often* it asks — without a network.
326#[async_trait::async_trait]
327pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
328 /// The repositories this credential reaches in `org`.
329 ///
330 /// # Errors
331 /// Anything the underlying gateway reports.
332 async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
333}
334
335#[derive(Debug, Clone)]
336struct CachedRepositories {
337 repositories: Vec<OwnerRepo>,
338 fetched_at: Timestamp,
339}
340
341/// The per-organization repository list, refreshed far more slowly than demand.
342///
343/// # Why this is not just "call the directory each poll"
344///
345/// An organization demand poll already costs one request per repository. Adding
346/// the installation listing to every poll makes the *scoping* of a poll cost
347/// requests on the same schedule as the poll itself, which is how a
348/// ten-repository organization at the 30-second floor stops fitting inside the
349/// half-of-5,000 allowance `f2` admits targets against. The repository list is
350/// also the one input that changes on a human timescale, so refreshing it
351/// [`REPOSITORY_LIST_REFRESH_MULTIPLE`] times more slowly costs nothing real.
352///
353/// # A repository target never consults the directory at all
354///
355/// Its scope is itself. That is not an optimisation; asking an installation
356/// listing which repositories a single named repository covers would be asking a
357/// question whose answer is already in the target.
358#[derive(Debug)]
359pub struct RepositoryCache {
360 directory: Arc<dyn RepositoryDirectory>,
361 clock: Arc<dyn Clock>,
362 ttl: Duration,
363 entries: Mutex<BTreeMap<Org, CachedRepositories>>,
364 lookups: AtomicU64,
365}
366
367impl RepositoryCache {
368 /// Build a cache whose refresh interval is `poll` slowed by
369 /// [`REPOSITORY_LIST_REFRESH_MULTIPLE`].
370 #[must_use]
371 pub fn new(
372 directory: Arc<dyn RepositoryDirectory>,
373 clock: Arc<dyn Clock>,
374 poll: RefreshInterval,
375 ) -> Self {
376 let ttl = Duration::from_secs(u64::from(poll.as_secs()))
377 .saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
378 Self {
379 directory,
380 clock,
381 ttl,
382 entries: Mutex::new(BTreeMap::new()),
383 lookups: AtomicU64::new(0),
384 }
385 }
386
387 /// How long a cached repository list is reused for.
388 #[must_use]
389 pub const fn ttl(&self) -> Duration {
390 self.ttl
391 }
392
393 /// How many times the underlying directory was actually asked.
394 ///
395 /// Measured rather than assumed, for the reason `c4` measures its own
396 /// request count: a budget nothing counts is a table in a document.
397 #[must_use]
398 pub fn lookups(&self) -> u64 {
399 self.lookups.load(Ordering::SeqCst)
400 }
401
402 /// The scope one demand poll of `target` covers.
403 ///
404 /// # Errors
405 /// Whatever the directory reported, for an organization target whose list is
406 /// stale or absent. A repository target cannot fail.
407 pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
408 match target {
409 ScaleTarget::Repository(repository) => {
410 Ok(ActivityScope::repository(repository.clone()))
411 }
412 ScaleTarget::Organization(org) => {
413 let repositories = self.repositories_of(org).await?;
414 Ok(ActivityScope::organization(org.clone(), repositories))
415 }
416 }
417 }
418
419 async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
420 let now = self.clock.now();
421 if let Some(fresh) = self.fresh_entry(org, now) {
422 return Ok(fresh);
423 }
424
425 // The directory call is deliberately made with no lock held. Two
426 // concurrent misses can therefore both ask, which costs one extra
427 // listing on the poll that follows a restart; holding a `std::sync`
428 // mutex across an `await` would cost a blocked executor thread and, on
429 // a current-thread runtime, a deadlock. The cheaper mistake is the one
430 // that spends a request.
431 let repositories = self.directory.repositories(org).await?;
432 self.lookups.fetch_add(1, Ordering::SeqCst);
433 self.store(org.clone(), repositories.clone(), now);
434 Ok(repositories)
435 }
436
437 fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
438 let entries = self.entries.lock().ok()?;
439 let entry = entries.get(org)?;
440 let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
441 (age < self.ttl).then(|| entry.repositories.clone())
442 }
443
444 fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
445 if let Ok(mut entries) = self.entries.lock() {
446 entries.insert(
447 org,
448 CachedRepositories {
449 repositories,
450 fetched_at,
451 },
452 );
453 }
454 }
455}
456
457// ---------------------------------------------------------------------------
458// Jitter
459// ---------------------------------------------------------------------------
460
461/// The randomness in the offline back-off, as a port.
462///
463/// Flow 3.3 requires jittered back-off, and a jittered delay is by construction
464/// not reproducible — so the source of the randomness is a port, and every test
465/// below asserts the *bounds* of the delay against a fixed fraction rather than
466/// asserting a number it could only have got by running the generator.
467pub trait Jitter: fmt::Debug + Send + Sync {
468 /// A fraction in `[0.0, 1.0)`. Values outside that range are clamped by the
469 /// caller, so an implementation cannot lengthen a back-off without bound.
470 fn fraction(&self) -> f64;
471}
472
473/// The production source.
474#[derive(Debug, Clone, Copy, Default)]
475pub struct RandomJitter;
476
477impl Jitter for RandomJitter {
478 fn fraction(&self) -> f64 {
479 rand::random::<f64>()
480 }
481}
482
483/// A fixed fraction, for tests and for the acceptance suite.
484#[derive(Debug, Clone, Copy)]
485pub struct FixedJitter(pub f64);
486
487impl Jitter for FixedJitter {
488 fn fraction(&self) -> f64 {
489 self.0
490 }
491}
492
493/// No jitter at all: the back-off is exactly what the schedule computed.
494#[derive(Debug, Clone, Copy, Default)]
495pub struct NoJitter;
496
497impl Jitter for NoJitter {
498 fn fraction(&self) -> f64 {
499 0.0
500 }
501}
502
503// ---------------------------------------------------------------------------
504// The schedule
505// ---------------------------------------------------------------------------
506
507/// Why the next poll is when it is.
508///
509/// Reported rather than inferred, because
510/// `04-subsystem-contracts.md` requires that rate limiting be *"displayed, never
511/// hidden"* — and a delay that grew for a reason the caller cannot name is
512/// hidden however visible the number is.
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum PollPace {
515 /// The configured interval. Nothing is throttling this loop.
516 Nominal,
517 /// GitHub's rate limit is exhausted. Resolves by waiting.
518 RateLimited { kind: RateLimitKind },
519 /// GitHub's temporary authentication lockout. The credential is fine.
520 LockedOut,
521 /// GitHub could not be reached. `consecutive` counts the unbroken run of
522 /// failures the back-off was computed from.
523 Offline { consecutive: u32 },
524 /// GitHub answered something no amount of waiting fixes — a rejected
525 /// credential, a permissions refusal, or an error status. The loop keeps
526 /// polling at its nominal interval so that a fix is noticed, and says that
527 /// it is blocked rather than pretending the poll succeeded.
528 Blocked,
529}
530
531impl PollPace {
532 /// Whether this pace is a slowdown the operator should be told about.
533 #[must_use]
534 pub const fn is_throttled(&self) -> bool {
535 !matches!(self, Self::Nominal)
536 }
537
538 /// A fixed, credential-free name for the log sink and for `g2`.
539 #[must_use]
540 pub const fn as_str(&self) -> &'static str {
541 match self {
542 Self::Nominal => "nominal",
543 Self::RateLimited {
544 kind: RateLimitKind::Primary,
545 } => "rate_limited_primary",
546 Self::RateLimited {
547 kind: RateLimitKind::Secondary,
548 } => "rate_limited_secondary",
549 Self::LockedOut => "locked_out",
550 Self::Offline { .. } => "offline",
551 Self::Blocked => "blocked",
552 }
553 }
554}
555
556impl fmt::Display for PollPace {
557 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558 f.write_str(self.as_str())
559 }
560}
561
562/// When to poll next, and why then.
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564pub struct NextPoll {
565 pub delay: Duration,
566 pub pace: PollPace,
567}
568
569/// The bounded, budget-aware poll interval.
570///
571/// # The floor is a rate-budget constraint, not a preference
572///
573/// [`RefreshInterval`] refuses anything under 30 seconds at construction, and
574/// every delay this type produces is at least that — including the ones it
575/// computes from a remote header. A rate limit may only ever make this loop
576/// *slower*.
577///
578/// # `retry_delay` is an absolute floor, not an addend
579///
580/// `c3` documents [`RefreshState::retry_delay`] as *"the earliest time a retry
581/// may occur"*: the scheduling rule is `next_attempt_at = now + retry_delay`,
582/// and **not** the ordinary interval plus it. Adding the two compounds on every
583/// successive retry — each new answer carries the remaining window, so an
584/// addend ratchets outward — and the symptom is a dashboard that stays dark
585/// long after GitHub said it could come back, which reads as a hang rather than
586/// as a rate limit. So the two are combined with `max`, which is what makes the
587/// floor a floor.
588#[derive(Debug, Clone)]
589pub struct PollSchedule {
590 interval: RefreshInterval,
591 consecutive_offline: u32,
592 offline_since: Option<Timestamp>,
593}
594
595impl PollSchedule {
596 #[must_use]
597 pub const fn new(interval: RefreshInterval) -> Self {
598 Self {
599 interval,
600 consecutive_offline: 0,
601 offline_since: None,
602 }
603 }
604
605 #[must_use]
606 pub const fn interval(&self) -> RefreshInterval {
607 self.interval
608 }
609
610 /// The nominal interval as a [`Duration`].
611 #[must_use]
612 pub const fn nominal(&self) -> Duration {
613 Duration::from_secs(self.interval.as_secs() as u64)
614 }
615
616 /// The unbroken run of offline polls this schedule has seen.
617 #[must_use]
618 pub const fn consecutive_offline(&self) -> u32 {
619 self.consecutive_offline
620 }
621
622 /// How long GitHub has been unreachable, or `None` when it is not.
623 ///
624 /// Measured from the first poll of the current run rather than inferred
625 /// from [`Self::consecutive_offline`] times the interval. The two diverge
626 /// as soon as the back-off starts doubling, and this is the number the
627 /// 24-hour queue-cancellation warning is compared against — an estimate
628 /// would make that warning fire early or late, and it is the one thing the
629 /// offline state exists to say.
630 #[must_use]
631 pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
632 let since = self.offline_since?;
633 now.signed_duration_since(since).to_std().ok()
634 }
635
636 /// The hard floor no computed delay may go below.
637 #[must_use]
638 pub const fn floor() -> Duration {
639 Duration::from_secs(RefreshInterval::MIN_SECS as u64)
640 }
641
642 /// Decide when to poll next, given how this pass ended.
643 ///
644 /// `failure` is the most severe failure across the targets polled this pass,
645 /// or `None` when every target answered. A pass that answered resets the
646 /// offline run, which is the whole of "recovery needs no bookkeeping":
647 /// demand is recomputed from the current queued-run set on every poll, so
648 /// there is nothing else to unwind.
649 pub fn next_poll(
650 &mut self,
651 failure: Option<&RefreshState>,
652 now: Timestamp,
653 jitter: &dyn Jitter,
654 ) -> NextPoll {
655 let nominal = self.nominal();
656
657 let next = match failure {
658 None => {
659 self.recovered();
660 NextPoll {
661 delay: nominal,
662 pace: PollPace::Nominal,
663 }
664 }
665 Some(RefreshState::Offline) => {
666 self.consecutive_offline = self.consecutive_offline.saturating_add(1);
667 // The instant the *run* began, not the instant of this poll.
668 self.offline_since.get_or_insert(now);
669 NextPoll {
670 delay: self.offline_delay(nominal, jitter),
671 pace: PollPace::Offline {
672 consecutive: self.consecutive_offline,
673 },
674 }
675 }
676 Some(state @ RefreshState::RateLimited(limit)) => {
677 self.recovered();
678 NextPoll {
679 // `max`, never `+`. See the type documentation.
680 delay: retry_floor(state, now).max(nominal),
681 pace: PollPace::RateLimited { kind: limit.kind },
682 }
683 }
684 Some(state @ RefreshState::LockedOut { .. }) => {
685 self.recovered();
686 NextPoll {
687 delay: retry_floor(state, now).max(nominal),
688 pace: PollPace::LockedOut,
689 }
690 }
691 // Unauthorized, Forbidden, Failed, Cancelled. `retry_delay` is
692 // `None` for all of them, and deliberately: no wait fixes a revoked
693 // credential or a missing grant. Polling stops being useful but
694 // does not stop, because the poll is also how a re-authentication
695 // is noticed.
696 Some(_) => {
697 // GitHub answered, so it is reachable: whatever is wrong, it is
698 // not an outage, and an outage run that was open must close.
699 self.recovered();
700 NextPoll {
701 delay: nominal,
702 pace: PollPace::Blocked,
703 }
704 }
705 };
706
707 debug_assert!(
708 next.delay >= Self::floor(),
709 "the 30-second floor is a rate-budget constraint and no branch may go below it"
710 );
711 next
712 }
713
714 /// GitHub answered something. Whatever it was, the outage run is over.
715 fn recovered(&mut self) {
716 self.consecutive_offline = 0;
717 self.offline_since = None;
718 }
719
720 fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
721 let doublings = self
722 .consecutive_offline
723 .saturating_sub(1)
724 .min(MAX_BACKOFF_DOUBLINGS);
725 let grown = nominal.saturating_mul(1_u32 << doublings);
726 let capped = grown.min(MAX_OFFLINE_BACKOFF);
727 // Additive, never subtractive: see `JITTER_RATIO`. The result may exceed
728 // `MAX_OFFLINE_BACKOFF` by up to the jitter ratio, which is the price of
729 // keeping a fleet of agents from retrying in lockstep at the plateau —
730 // a cap applied *after* jitter would collapse every agent onto the same
731 // instant precisely when the outage is longest.
732 let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
733 capped.saturating_add(spread).max(Self::floor())
734 }
735}
736
737/// `c3`'s retry floor, with the one fallback this loop needs.
738///
739/// [`RefreshState::retry_delay`] answers `None` for the states no wait fixes,
740/// and those never reach here — the caller matches them into
741/// [`PollPace::Blocked`] first. The fallback exists so that a future
742/// `RefreshState` variant added to the two arms above cannot silently schedule a
743/// zero-second retry against an endpoint that asked for quiet.
744fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
745 state.retry_delay(now).unwrap_or(PollSchedule::floor())
746}
747
748// ---------------------------------------------------------------------------
749// Offline
750// ---------------------------------------------------------------------------
751
752/// What an operator is told while GitHub is unreachable.
753///
754/// Flow 3.3 requires four things of an outage — start no new runner, retain
755/// existing runner processes, report `offline`, back off with jitter — and one
756/// thing of the *state*: that it says GitHub cancels queued jobs after 24 hours,
757/// so a prolonged outage loses queued work. That bound is stated here rather
758/// than left for a reader to infer, because an operator who does not know it has
759/// no reason to treat a long outage as urgent.
760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
761pub struct OfflineState {
762 /// The unbroken run of failed polls.
763 pub consecutive: u32,
764 /// How long until the next attempt.
765 pub retry_in: Duration,
766 /// How long this loop has been unable to reach GitHub, when it is known.
767 pub offline_for: Option<Duration>,
768}
769
770impl OfflineState {
771 #[must_use]
772 pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
773 Self {
774 consecutive,
775 retry_in,
776 offline_for: None,
777 }
778 }
779
780 #[must_use]
781 pub const fn since(mut self, offline_for: Duration) -> Self {
782 self.offline_for = Some(offline_for);
783 self
784 }
785
786 /// Whether the outage has already outlasted GitHub's queue.
787 ///
788 /// `false` when the duration is unknown: this reports a fact, and "we cannot
789 /// tell" is not the same fact as "not yet".
790 #[must_use]
791 pub fn has_outlasted_the_queue(&self) -> bool {
792 self.offline_for
793 .is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
794 }
795}
796
797impl fmt::Display for OfflineState {
798 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
799 write!(
800 f,
801 "GitHub is unreachable; no new runners are being started and running \
802 runners are left alone. Retrying in {}s",
803 self.retry_in.as_secs()
804 )?;
805 if self.has_outlasted_the_queue() {
806 f.write_str(
807 ". This outage has lasted more than 24 hours, and GitHub cancels a queued \
808 job after 24 hours, so queued work has been lost",
809 )
810 } else {
811 f.write_str(
812 ". GitHub cancels a queued job after 24 hours, so an outage longer than \
813 that loses queued work",
814 )
815 }
816 }
817}
818
819// ---------------------------------------------------------------------------
820// The launcher port
821// ---------------------------------------------------------------------------
822
823/// What this loop asks `e3` to create.
824#[derive(Debug, Clone, Copy)]
825pub struct LaunchRequest<'a> {
826 pub host: &'a Host,
827 pub policy: &'a ScalePolicy,
828 /// Proof that e1 still owns the host allocation lock for every package,
829 /// prune, and process-start effect performed by e3.
830 // Crate-visible so only this allocator can mint the request that reaches
831 // package pruning. A caller holding an unrelated public AllocationLock can
832 // no longer assemble a LaunchRequest and present that guard as authority.
833 pub(crate) allocation_guard: &'a AllocationGuard,
834}
835
836/// Why one runner could not be started.
837///
838/// Carries `b1`'s [`FailureReason`] rather than a taxonomy of this module's own:
839/// the reasons a runner fails to start are `e3`'s to know and `b1`'s to name,
840/// and a third vocabulary here would be a third answer to a question the
841/// operator asks once.
842#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
843#[error("the runner could not be started: {reason}")]
844pub struct LaunchFailure {
845 pub reason: FailureReason,
846}
847
848/// A lifecycle conclusion that must return through ordinary demand and
849/// capacity allocation before another runner may start.
850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
851pub struct ReplacementIntent {
852 pub policy: PolicyId,
853 pub previous_attempt: AttemptId,
854 pub operation: &'static str,
855}
856
857impl LaunchFailure {
858 #[must_use]
859 pub const fn new(reason: FailureReason) -> Self {
860 Self { reason }
861 }
862}
863
864/// The seam between the decision to start a runner and the act of starting one.
865///
866/// `e3` implements this; every test in this file fakes it, which is what makes
867/// the whole allocator path decidable with no process, no filesystem and no
868/// network.
869///
870/// # Why the attempt set comes through the same port as the launch
871///
872/// `b1` makes this argument at [`HostAllocator::from_attempts`] and it applies
873/// one layer up: the host-wide total (D9) and every per-policy count (D7) are
874/// two questions asked of **one** set, and a design that let the caller supply
875/// the set separately from the thing that creates its members is a design in
876/// which the two can disagree. Worse, it makes `&[]` expressible — and an empty
877/// attempt set is exactly the shape that drops the `- active_owned_runners`
878/// term, starts a second runner for a job already being served, and reports no
879/// error while doing it.
880///
881/// So the launcher is asked, under the allocation lock, immediately before each
882/// runtime is created. There is no second supply point and no cached copy.
883///
884/// # The two ways an implementer can say "I hold no attempts"
885///
886/// The argument above closes the hole for a *caller*. It stayed open one level
887/// down for the **implementer**, in two shapes that both oversubscribe the
888/// machine and neither of which reports anything:
889///
890/// * **By failing.** `attempts()` used to be infallible, which left `e3` — which
891/// reads a journal off a disk — a choice between panicking and answering
892/// `vec![]` on an I/O error. An empty set is indistinguishable from an idle
893/// host, so a transient read failure reads as "nothing is running" and the
894/// next pass allocates the whole machine for jobs already being served. It is
895/// fallible now, and [`Reconciler`] treats a failure the way it treats a lock
896/// it could not take: start nothing, say so, try again next pass.
897/// * **By lagging.** [`Self::launch`] returns the attempt it created rather than
898/// its identifier, so the caller can carry it. See that method for the
899/// measurement that made this necessary.
900#[async_trait::async_trait]
901pub trait RunnerLauncher: fmt::Debug + Send + Sync {
902 /// Reconcile this policy's existing processes before demand is read and
903 /// capacity is recomputed. A concluded pre-acceptance attempt thereby
904 /// becomes an ordinary allocation candidate in this same pass; replacement
905 /// never bypasses the allocator.
906 async fn supervise(
907 &self,
908 _policy: &ScalePolicy,
909 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
910 Ok(Vec::new())
911 }
912
913 /// Every attempt this host holds, across every policy, terminal ones
914 /// included.
915 ///
916 /// Terminal attempts are included rather than filtered out because the
917 /// caller needs both answers from one set:
918 /// [`AttemptState::counts_against_capacity`] decides the ceiling, and the
919 /// terminal ones are what [`RunnerLauncher::clean`] is for.
920 ///
921 /// # Errors
922 /// [`LaunchFailure`] when the set could not be read. **Never answer `Ok`
923 /// with an empty vector to signal a failure** — the caller cannot tell that
924 /// from an idle host, and the two lead to opposite actions.
925 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
926
927 /// Create exactly one runtime and start one runner, and return the attempt
928 /// that now exists.
929 ///
930 /// Called once per grant, with the host-wide allocation lock held.
931 ///
932 /// # The attempt is returned, not just its identifier
933 ///
934 /// The host ceiling is enforced against a host-wide total, and that total is
935 /// recomputed from [`Self::attempts`] on every hold. If a launch is not yet
936 /// visible there when the *next* policy is allocated for — a journal write
937 /// that has not landed, an asynchronous store, a cache — then that policy's
938 /// grant is computed from a set missing the previous policy's runners, and
939 /// it is too large.
940 ///
941 /// That is measured, not hypothetical. With a launcher whose attempts never
942 /// became visible, two policies on a host of **three** started **six**
943 /// runners, with the allocation lock held correctly throughout:
944 /// `host_capacity=3, started=6, launches=6`. Serialisation was never the
945 /// problem; the arithmetic under it was reading a stale set.
946 ///
947 /// So an implementer *should* make the new attempt visible to
948 /// [`Self::attempts`] before returning — and the caller does not depend on
949 /// it. [`Reconciler`] carries what this pass created and merges it, by
950 /// [`RunnerAttempt::id`], with whatever the launcher reports. A launcher
951 /// that honours the contract is not double-counted, and one that lags cannot
952 /// oversubscribe the host.
953 ///
954 /// # Every call must return a **fresh** [`RunnerAttempt::id`]
955 ///
956 /// This is a requirement, not a convention, because the merge above is what
957 /// carries the host ceiling and the merge is keyed on the identifier. Two
958 /// calls that answer with the same id are two runtimes that the host-wide
959 /// total counts once, and the machine is then allocated past
960 /// `host_capacity`: probed at `host_capacity = 3` with one slot already
961 /// busy and a launcher answering with a duplicate id, the pass started
962 /// **four** runners for five occupied slots.
963 ///
964 /// That is a narrower defect than the lagging launcher above — that one
965 /// needed no bug at all, this one needs a broken id generator — but `e3` is
966 /// the implementor and cannot honour a requirement nobody states.
967 /// [`Reconciler::host_attempts`] carries a `debug_assert` that fires on a
968 /// collision, so a development build finds it at the first duplicate rather
969 /// than through an oversubscribed host.
970 ///
971 /// # Errors
972 /// [`LaunchFailure`], carrying the [`FailureReason`] `e3` recorded.
973 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
974
975 /// Remove a terminal attempt's runtime and mark it `cleaned`.
976 ///
977 /// Never called for a non-terminal attempt: capacity is reclaimed when an
978 /// attempt reaches a terminal state and at no other time.
979 ///
980 /// # Errors
981 /// [`LaunchFailure`], carrying the [`FailureReason`] `e3` recorded.
982 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
983}
984
985// ---------------------------------------------------------------------------
986// The host-wide allocation lock
987// ---------------------------------------------------------------------------
988
989/// The lock is held for as long as this value lives.
990///
991/// Opaque on purpose: what is being held differs between the in-process and the
992/// file-backed implementation, and a caller that could see which one it has
993/// would eventually branch on it.
994pub struct AllocationGuard {
995 _held: Box<dyn std::any::Any + Send + Sync>,
996}
997
998impl AllocationGuard {
999 /// Wrap whatever the implementation holds. Dropping the guard drops it.
1000 #[must_use]
1001 fn new<T: Send + Sync + 'static>(held: T) -> Self {
1002 Self {
1003 _held: Box::new(held),
1004 }
1005 }
1006}
1007
1008impl fmt::Debug for AllocationGuard {
1009 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1010 f.write_str("AllocationGuard")
1011 }
1012}
1013
1014/// The host-wide allocation lock could not be taken.
1015#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1016#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1017pub struct AllocationLockBusy;
1018
1019/// Flow 2.4's *"takes the host-wide allocation lock before creating each local
1020/// runtime"*, as a port.
1021///
1022/// # Why a lock is needed at all, given the allocator already exists
1023///
1024/// [`HostAllocator`] enforces D9 across the policies of **one** pass. It cannot
1025/// enforce anything across two passes running at once, and `f3` runs one
1026/// demand-polling loop per target: without serialisation, two loops read the
1027/// same headroom, each finds it sufficient, and the host ends up with the sum of
1028/// two grants it only ever had room for one of. The lock is what makes the
1029/// read-decide-create sequence atomic, and it is taken once per runtime rather
1030/// than once per pass so that a slow package download in one policy does not
1031/// hold the whole host still.
1032#[async_trait::async_trait]
1033pub trait AllocationLock: fmt::Debug + Send + Sync {
1034 /// Take the lock, waiting briefly for it.
1035 ///
1036 /// # Errors
1037 /// [`AllocationLockBusy`] when it could not be taken. A refused grant is
1038 /// always safe: the next pass re-reads the headroom and tries again.
1039 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1040}
1041
1042/// The lock every task inside one agent process contends for.
1043///
1044/// This is the implementation that matters in practice, because the
1045/// single-instance lock (`d1`) already guarantees one agent per host: the
1046/// concurrency the allocation lock actually has to serialise is `f3`'s
1047/// per-target loops inside that one process. A `tokio` mutex rather than a
1048/// `std` one because it is held across the `await` that creates the runtime.
1049#[derive(Debug, Default)]
1050pub struct InProcessAllocationLock {
1051 mutex: Arc<tokio::sync::Mutex<()>>,
1052}
1053
1054impl InProcessAllocationLock {
1055 #[must_use]
1056 pub fn new() -> Self {
1057 Self::default()
1058 }
1059}
1060
1061#[async_trait::async_trait]
1062impl AllocationLock for InProcessAllocationLock {
1063 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1064 let mutex = Arc::clone(&self.mutex);
1065 let guard = mutex.lock_owned().await;
1066 Ok(AllocationGuard::new(guard))
1067 }
1068}
1069
1070/// `d1`'s file lock, which is host-wide across processes as well as across
1071/// tasks.
1072///
1073/// Defence in depth behind [`InProcessAllocationLock`], for the configuration
1074/// `d1` documents as the one where two agents can genuinely coexist: the
1075/// platform state directory is per-account, so a service-account daemon and an
1076/// interactive `daemon run` resolve different paths and do not contend for the
1077/// single-instance lock. They do contend here if they share a state directory.
1078///
1079/// [`runner_manager_platform::lock::HostLock::acquire`] blocks the calling
1080/// thread and its own documentation names this caller: *"Async callers must wrap
1081/// it in [`tokio::task::spawn_blocking`]"*. That is what this does, and the
1082/// returned `HostLock` lives inside the guard, because dropping it is the
1083/// release.
1084#[derive(Debug, Clone)]
1085pub struct FileAllocationLock {
1086 paths: Arc<runner_manager_platform::paths::AppPaths>,
1087 wait: Duration,
1088}
1089
1090impl FileAllocationLock {
1091 #[must_use]
1092 pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1093 Self {
1094 paths,
1095 wait: ALLOCATION_LOCK_WAIT,
1096 }
1097 }
1098
1099 #[must_use]
1100 pub const fn with_wait(mut self, wait: Duration) -> Self {
1101 self.wait = wait;
1102 self
1103 }
1104}
1105
1106#[async_trait::async_trait]
1107impl AllocationLock for FileAllocationLock {
1108 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1109 use runner_manager_platform::lock::{HostLock, LockKind};
1110
1111 let paths = Arc::clone(&self.paths);
1112 let wait = self.wait;
1113 let held = tokio::task::spawn_blocking(move || {
1114 HostLock::acquire(&paths, LockKind::Allocation, wait)
1115 })
1116 .await;
1117
1118 match held {
1119 Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1120 // A refused lock and a panicked blocking task are the same outcome
1121 // to this caller: no runtime was created and the next pass will
1122 // re-read the headroom. Neither is allowed to look like a grant.
1123 Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1124 }
1125 }
1126}
1127
1128/// Adds the Windows/WSL recovery fence to an ordinary allocation lock.
1129///
1130/// A Windows watchdog writes its drain request before attempting the same
1131/// atomic directory claim. Therefore either this guard owns the directory and
1132/// finishes the one launch already in progress, or Windows owns it and no new
1133/// launch can pass. A malformed configured fence fails closed.
1134#[derive(Debug)]
1135pub struct WslRecoveryAllocationLock {
1136 paths: Arc<runner_manager_platform::paths::AppPaths>,
1137 inner: Arc<dyn AllocationLock>,
1138}
1139
1140impl WslRecoveryAllocationLock {
1141 #[must_use]
1142 pub fn new(
1143 paths: Arc<runner_manager_platform::paths::AppPaths>,
1144 inner: Arc<dyn AllocationLock>,
1145 ) -> Self {
1146 Self { paths, inner }
1147 }
1148}
1149
1150#[async_trait::async_trait]
1151impl AllocationLock for WslRecoveryAllocationLock {
1152 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1153 use runner_manager_platform::wsl::fence::{
1154 DrainRequest, FenceClaim, FenceOwnerKind, GuestRecoveryConfig,
1155 };
1156
1157 let paths = Arc::clone(&self.paths);
1158 let claim = tokio::task::spawn_blocking(move || {
1159 let Some(config) = GuestRecoveryConfig::read(&paths).map_err(|_| ())? else {
1160 return Ok(None);
1161 };
1162 let Some(claim) =
1163 FenceClaim::try_claim(&config.shared_root, FenceOwnerKind::GuestLaunch, None)
1164 .map_err(|_| ())?
1165 else {
1166 return Err(());
1167 };
1168 match DrainRequest::read(&config.shared_root) {
1169 Ok(None) => Ok(Some(claim)),
1170 Ok(Some(_)) | Err(_) => Err(()),
1171 }
1172 })
1173 .await
1174 .map_err(|_| AllocationLockBusy)?
1175 .map_err(|()| AllocationLockBusy)?;
1176
1177 let inner = self.inner.acquire().await?;
1178 Ok(AllocationGuard::new((claim, inner)))
1179 }
1180}
1181
1182// ---------------------------------------------------------------------------
1183// Lifecycle events
1184// ---------------------------------------------------------------------------
1185
1186/// Which terminal thing happened, as a closed vocabulary.
1187///
1188/// The distinction `g2` renders: an idle exit is the accepted surplus case and
1189/// **not** a failure, and showing it as one sends an operator hunting a fault
1190/// that does not exist.
1191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1192pub enum OutcomeKind {
1193 CompletedJob,
1194 IdleExit,
1195 Failed,
1196 Orphaned,
1197}
1198
1199impl OutcomeKind {
1200 #[must_use]
1201 pub const fn of(outcome: &AttemptOutcome) -> Self {
1202 match outcome {
1203 AttemptOutcome::CompletedJob => Self::CompletedJob,
1204 AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1205 AttemptOutcome::Failed { .. } => Self::Failed,
1206 AttemptOutcome::Orphaned => Self::Orphaned,
1207 }
1208 }
1209
1210 #[must_use]
1211 pub const fn is_failure(&self) -> bool {
1212 matches!(self, Self::Failed | Self::Orphaned)
1213 }
1214
1215 #[must_use]
1216 pub const fn as_str(&self) -> &'static str {
1217 match self {
1218 Self::CompletedJob => "completed_job",
1219 Self::IdleExit => "exited_idle_without_work",
1220 Self::Failed => "failed",
1221 Self::Orphaned => "orphaned",
1222 }
1223 }
1224}
1225
1226impl fmt::Display for OutcomeKind {
1227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1228 f.write_str(self.as_str())
1229 }
1230}
1231
1232/// A [`FailureReason`]'s variant name, with no detail.
1233///
1234/// [`FailureReason::Other`] carries a `String` that `e3` fills in, and an event
1235/// is not the place for it: `07-security.md`'s log scan runs over everything
1236/// this loop emits, and free text is the one shape that can carry a credential
1237/// past a field allow-list. The operator-facing detail reaches the journal
1238/// through `b2` and the screen through `g2`; what reaches an *event* is the
1239/// variant.
1240#[must_use]
1241pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1242 match reason {
1243 FailureReason::JitRequestFailed => "jit_request_failed",
1244 FailureReason::JitExpired => "jit_expired",
1245 FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1246 FailureReason::RunnerVersionRejected => "runner_version_rejected",
1247 FailureReason::ProcessStartFailed => "process_start_failed",
1248 FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1249 FailureReason::RegistrationTimedOut => "registration_timed_out",
1250 FailureReason::TerminatedAfterRegistrationTimeout => {
1251 "terminated_after_registration_timeout"
1252 }
1253 FailureReason::Other(_) => "other",
1254 }
1255}
1256
1257/// What `g2`'s activity view and the local log sink see.
1258///
1259/// **Every field is an identifier, a count, a duration, or a `&'static str`
1260/// drawn from a closed set.** There is no `String` anywhere in this enum, which
1261/// is what makes "no emitted event contains a token, a JIT blob, or a credential
1262/// header" a property of the type rather than a discipline each call site has to
1263/// keep. `tests::no_emitted_event_can_carry_a_credential` renders every variant
1264/// through `d1`'s scrubber and asserts nothing changes, with a positive control
1265/// so the assertion cannot pass vacuously.
1266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1267pub enum LifecycleEvent {
1268 /// A demand poll answered for one target.
1269 DemandObserved {
1270 policy: PolicyId,
1271 /// Queued jobs this policy's routing labels match. The number clamped.
1272 demand: u32,
1273 /// Queued jobs whose required labels this policy does not carry.
1274 ///
1275 /// Never demand. Reported because the difference between this and
1276 /// `demand` is the whole value of the label filtering, and an operator
1277 /// wondering why a busy repository started no runners is owed it.
1278 not_matched: u32,
1279 /// Queued jobs whose `runs-on` could not be resolved statically.
1280 ///
1281 /// `b1` requires these be "reported as unresolvable rather than silently
1282 /// counted or silently dropped": counting one would start a runner for a
1283 /// job that may not be ours, and dropping it would hide a workflow this
1284 /// host can never serve. A count rather than the reasons themselves
1285 /// because this type is `Copy`, and `c4` logs the reasons where it
1286 /// builds them.
1287 unresolvable: u32,
1288 /// `false` when the count is a floor rather than a total.
1289 complete: bool,
1290 },
1291 /// A target could not be polled, so its policies start nothing this pass.
1292 TargetUnreadable {
1293 policy: PolicyId,
1294 reason: &'static str,
1295 },
1296 /// One policy's share of the pass.
1297 Allocated {
1298 policy: PolicyId,
1299 demand: u32,
1300 desired: u16,
1301 active_owned: u16,
1302 headroom: u16,
1303 to_start: u16,
1304 limiting: LimitingFactor,
1305 },
1306 /// A monitor-only policy was skipped entirely, before any demand request
1307 /// was issued for it (D19).
1308 MonitorOnlySkipped { policy: PolicyId },
1309 /// One runtime was created and one runner started.
1310 RunnerStarted {
1311 policy: PolicyId,
1312 attempt: AttemptId,
1313 },
1314 /// One runner could not be started.
1315 RunnerStartFailed {
1316 policy: PolicyId,
1317 reason: &'static str,
1318 },
1319 /// The allocation lock was not free, so `count` runners this policy was
1320 /// granted were not created this pass.
1321 AllocationDeferred { policy: PolicyId, count: u16 },
1322 /// The host's attempt set could not be read at all.
1323 ///
1324 /// Distinct from an empty set on purpose, and the whole reason
1325 /// [`RunnerLauncher::attempts`] is fallible: the two produce the same
1326 /// *number* and demand opposite actions.
1327 AttemptsUnreadable { reason: &'static str },
1328 /// A terminal attempt's runtime was removed.
1329 AttemptCleaned {
1330 policy: PolicyId,
1331 attempt: AttemptId,
1332 outcome: OutcomeKind,
1333 },
1334 /// A terminal attempt's runtime could not be removed. It will be retried on
1335 /// the next pass, and this is what keeps that retry from being silent.
1336 AttemptCleanFailed {
1337 policy: PolicyId,
1338 attempt: AttemptId,
1339 reason: &'static str,
1340 },
1341 /// Scale-down declined to remove a runner that is executing a job.
1342 ScaleDownRefused {
1343 policy: PolicyId,
1344 attempt: AttemptId,
1345 },
1346 /// When the next poll is, and why then.
1347 PollScheduled { retry_in_ms: u64, pace: PollPace },
1348}
1349
1350impl LifecycleEvent {
1351 /// A fixed name, for the `event` field `d1`'s sink allows verbatim.
1352 #[must_use]
1353 pub const fn name(&self) -> &'static str {
1354 match self {
1355 Self::DemandObserved { .. } => "demand_observed",
1356 Self::TargetUnreadable { .. } => "target_unreadable",
1357 Self::Allocated { .. } => "allocated",
1358 Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1359 Self::RunnerStarted { .. } => "runner_started",
1360 Self::RunnerStartFailed { .. } => "runner_start_failed",
1361 Self::AllocationDeferred { .. } => "allocation_deferred",
1362 Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1363 Self::AttemptCleaned { .. } => "attempt_cleaned",
1364 Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1365 Self::ScaleDownRefused { .. } => "scale_down_refused",
1366 Self::PollScheduled { .. } => "poll_scheduled",
1367 }
1368 }
1369
1370 /// Which policy this event is about.
1371 #[must_use]
1372 pub const fn policy(&self) -> Option<PolicyId> {
1373 match self {
1374 Self::DemandObserved { policy, .. }
1375 | Self::TargetUnreadable { policy, .. }
1376 | Self::Allocated { policy, .. }
1377 | Self::MonitorOnlySkipped { policy }
1378 | Self::RunnerStarted { policy, .. }
1379 | Self::RunnerStartFailed { policy, .. }
1380 | Self::AllocationDeferred { policy, .. }
1381 | Self::AttemptCleaned { policy, .. }
1382 | Self::AttemptCleanFailed { policy, .. }
1383 | Self::ScaleDownRefused { policy, .. } => Some(*policy),
1384 Self::PollScheduled { .. } | Self::AttemptsUnreadable { .. } => None,
1385 }
1386 }
1387}
1388
1389impl fmt::Display for LifecycleEvent {
1390 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1391 match self {
1392 Self::DemandObserved {
1393 policy,
1394 demand,
1395 not_matched,
1396 unresolvable,
1397 complete,
1398 } => write!(
1399 f,
1400 "policy {policy}: {demand} queued jobs for this host{}{}{}",
1401 if *not_matched == 0 {
1402 String::new()
1403 } else {
1404 format!(", {not_matched} for other labels")
1405 },
1406 if *unresolvable == 0 {
1407 String::new()
1408 } else {
1409 format!(", {unresolvable} with an unresolvable `runs-on`")
1410 },
1411 if *complete {
1412 ""
1413 } else {
1414 " (a floor, not a total)"
1415 }
1416 ),
1417 Self::TargetUnreadable { policy, reason } => {
1418 write!(f, "policy {policy}: target unreadable ({reason})")
1419 }
1420 Self::Allocated {
1421 policy,
1422 demand,
1423 desired,
1424 active_owned,
1425 headroom,
1426 to_start,
1427 limiting,
1428 } => write!(
1429 f,
1430 "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
1431 flight, {headroom} free on this host, starting {to_start} ({limiting})"
1432 ),
1433 Self::MonitorOnlySkipped { policy } => {
1434 write!(f, "policy {policy}: monitor-only, skipped")
1435 }
1436 Self::RunnerStarted { policy, attempt } => {
1437 write!(f, "policy {policy}: started attempt {attempt}")
1438 }
1439 Self::RunnerStartFailed { policy, reason } => {
1440 write!(f, "policy {policy}: could not start a runner ({reason})")
1441 }
1442 Self::AllocationDeferred { policy, count } => write!(
1443 f,
1444 "policy {policy}: the allocation lock was held; {count} granted runners \
1445 were not created"
1446 ),
1447 Self::AttemptsUnreadable { reason } => write!(
1448 f,
1449 "the host's attempt set could not be read ({reason}); nothing was started, \
1450 and this is not the same as the host being idle"
1451 ),
1452 Self::AttemptCleaned {
1453 policy,
1454 attempt,
1455 outcome,
1456 } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
1457 Self::AttemptCleanFailed {
1458 policy,
1459 attempt,
1460 reason,
1461 } => write!(
1462 f,
1463 "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
1464 will be retried"
1465 ),
1466 Self::ScaleDownRefused { policy, attempt } => write!(
1467 f,
1468 "policy {policy}: attempt {attempt} is executing a job and was not removed"
1469 ),
1470 Self::PollScheduled { retry_in_ms, pace } => {
1471 write!(f, "next poll in {retry_in_ms}ms ({pace})")
1472 }
1473 }
1474 }
1475}
1476
1477/// Where lifecycle events go.
1478pub trait EventSink: fmt::Debug + Send + Sync {
1479 fn emit(&self, event: LifecycleEvent);
1480}
1481
1482/// Discards everything. For callers that only want the report.
1483#[derive(Debug, Clone, Copy, Default)]
1484pub struct NoEvents;
1485
1486impl EventSink for NoEvents {
1487 fn emit(&self, _event: LifecycleEvent) {}
1488}
1489
1490/// The local log sink, through `d1`'s redacting layer.
1491///
1492/// Every field name below is on
1493/// [`runner_manager_platform::logging::ALLOWED_FIELDS`]; anything else would be
1494/// replaced with `[redacted]` and the line would lose its meaning rather than
1495/// its safety. `tests::every_field_name_this_sink_emits_is_one_d1_allows` keeps
1496/// that true.
1497#[derive(Debug, Clone, Copy, Default)]
1498pub struct TracingEvents;
1499
1500impl EventSink for TracingEvents {
1501 fn emit(&self, event: LifecycleEvent) {
1502 let name = event.name();
1503 match event {
1504 LifecycleEvent::DemandObserved {
1505 policy,
1506 demand,
1507 not_matched,
1508 unresolvable,
1509 complete,
1510 } => {
1511 tracing::info!(
1512 event = name,
1513 policy_id = %policy,
1514 demand,
1515 not_matched,
1516 unresolvable,
1517 count = u64::from(complete),
1518 );
1519 // There is deliberately no `warn!` here for the "demand is zero
1520 // but jobs were not matched" shape, though it is the one this
1521 // change introduced: before demand was filtered, a repository
1522 // with work in it always produced some, and now a policy whose
1523 // labels do not cover its jobs produces none.
1524 //
1525 // The reason is that the shape is indistinguishable from a
1526 // healthy one. A repository served by a Windows host and a macOS
1527 // host has the other host's jobs queued in it constantly, so
1528 // each agent would warn on every poll about work that is being
1529 // served correctly by the other machine. Telling the two apart
1530 // needs to know whether this policy has *ever* matched anything,
1531 // which is state across polls that this loop does not keep.
1532 //
1533 // What an operator gets instead is the `not_matched` count, on
1534 // this event and in its `Display`, which `g2` renders. "0 queued
1535 // jobs for this host, 5 for other labels" is the diagnosis; a
1536 // warning that fired on every healthy minute would be the kind
1537 // nobody reads.
1538 }
1539 LifecycleEvent::TargetUnreadable { policy, reason } => {
1540 tracing::warn!(event = name, policy_id = %policy, reason);
1541 }
1542 LifecycleEvent::Allocated {
1543 policy,
1544 demand,
1545 desired,
1546 active_owned,
1547 headroom,
1548 to_start,
1549 limiting,
1550 } => tracing::info!(
1551 event = name,
1552 policy_id = %policy,
1553 demand,
1554 desired,
1555 capacity = active_owned,
1556 headroom,
1557 count = to_start,
1558 reason = %limiting,
1559 ),
1560 LifecycleEvent::MonitorOnlySkipped { policy } => {
1561 tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
1562 }
1563 LifecycleEvent::RunnerStarted { policy, attempt } => {
1564 tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
1565 }
1566 LifecycleEvent::RunnerStartFailed { policy, reason } => {
1567 tracing::warn!(event = name, policy_id = %policy, reason);
1568 }
1569 LifecycleEvent::AllocationDeferred { policy, count } => {
1570 // Contention is normally brief, but a configured WSL recovery
1571 // fence whose shared root is unwritable reaches the same
1572 // fail-closed result forever. Demand was already observed and
1573 // capacity was already granted, so hiding this at DEBUG makes
1574 // a host claim healthy while it starts nothing.
1575 tracing::warn!(
1576 event = name,
1577 policy_id = %policy,
1578 reason = "allocation_lock_unavailable",
1579 count
1580 );
1581 }
1582 LifecycleEvent::AttemptsUnreadable { reason } => {
1583 tracing::warn!(event = name, reason);
1584 }
1585 LifecycleEvent::AttemptCleaned {
1586 policy,
1587 attempt,
1588 outcome,
1589 } => tracing::info!(
1590 event = name,
1591 policy_id = %policy,
1592 attempt_id = %attempt,
1593 outcome = outcome.as_str(),
1594 ),
1595 LifecycleEvent::AttemptCleanFailed {
1596 policy,
1597 attempt,
1598 reason,
1599 } => tracing::warn!(
1600 event = name,
1601 policy_id = %policy,
1602 attempt_id = %attempt,
1603 reason,
1604 ),
1605 LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
1606 event = name,
1607 policy_id = %policy,
1608 attempt_id = %attempt,
1609 attempt_state = "busy",
1610 ),
1611 LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
1612 tracing::info!(event = name, retry_in_ms, state = pace.as_str());
1613 }
1614 }
1615 }
1616}
1617
1618/// Keeps every event, in order.
1619///
1620/// `g2`'s activity view is a reader of this, and so is every test below.
1621#[derive(Debug, Default)]
1622pub struct EventLog {
1623 events: Mutex<Vec<LifecycleEvent>>,
1624}
1625
1626impl EventLog {
1627 #[must_use]
1628 pub fn new() -> Self {
1629 Self::default()
1630 }
1631
1632 #[must_use]
1633 pub fn events(&self) -> Vec<LifecycleEvent> {
1634 self.events.lock().map(|e| e.clone()).unwrap_or_default()
1635 }
1636
1637 /// How many events of one name were emitted.
1638 #[must_use]
1639 pub fn count_of(&self, name: &str) -> usize {
1640 self.events()
1641 .iter()
1642 .filter(|event| event.name() == name)
1643 .count()
1644 }
1645}
1646
1647impl EventSink for EventLog {
1648 fn emit(&self, event: LifecycleEvent) {
1649 if let Ok(mut events) = self.events.lock() {
1650 events.push(event);
1651 }
1652 }
1653}
1654
1655/// Both sinks at once: the log sink for the operator's file, the buffer for
1656/// `g2`'s screen.
1657#[derive(Debug)]
1658pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
1659
1660impl EventSink for TeeEvents {
1661 fn emit(&self, event: LifecycleEvent) {
1662 self.0.emit(event);
1663 self.1.emit(event);
1664 }
1665}
1666
1667// ---------------------------------------------------------------------------
1668// The reconciler
1669// ---------------------------------------------------------------------------
1670
1671/// Everything one reconciler needs, written down at the call site.
1672///
1673/// A struct rather than seven positional arguments, for the reason `b1` gives at
1674/// `PersistedAttempt`: several of these are `Arc<dyn …>` and transposing two of
1675/// them type-checks. Construct it with a struct literal so every port is named.
1676pub struct ReconcilerPorts {
1677 pub demand: Arc<dyn DemandSource>,
1678 pub launcher: Arc<dyn RunnerLauncher>,
1679 pub lock: Arc<dyn AllocationLock>,
1680 pub directory: Arc<dyn RepositoryDirectory>,
1681 pub clock: Arc<dyn Clock>,
1682 pub jitter: Arc<dyn Jitter>,
1683 pub events: Arc<dyn EventSink>,
1684}
1685
1686impl fmt::Debug for ReconcilerPorts {
1687 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1688 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
1689 }
1690}
1691
1692/// What one reconciliation pass did.
1693///
1694/// `started` and the allocations are reported separately on purpose: an
1695/// allocation is what the pass *decided* under the lock, and `started` is what
1696/// actually came up. They differ when a launch fails or when the lock was held,
1697/// and collapsing them would hide both.
1698#[derive(Debug, Clone, Default)]
1699pub struct ReconcileReport {
1700 /// One entry per policy that got as far as being allocated for.
1701 pub allocations: Vec<Allocation>,
1702 /// Policies skipped because they are monitor-only (D19).
1703 pub monitor_only: Vec<PolicyId>,
1704 /// Policies whose target could not be polled this pass.
1705 pub unreadable: Vec<PolicyId>,
1706 /// Policies whose target GitHub actually answered for this pass.
1707 ///
1708 /// The counterpart to [`Self::unreadable`], and the only honest evidence
1709 /// that this host reached GitHub at all. [`Self::allocations`] is not: a
1710 /// policy this host does not own is allocated for with no demand and
1711 /// without any target being polled, so a pass where every poll failed can
1712 /// still end with allocations in it.
1713 pub targets_read: u16,
1714 /// Runners actually started.
1715 pub started: u16,
1716 /// Pre-acceptance attempts routed back through this pass's ordinary
1717 /// demand/capacity decision.
1718 pub replacement_intents: u16,
1719 /// Terminal attempts whose runtime was removed.
1720 pub cleaned: u16,
1721 /// Of those, the surplus case: registered, got no job, exited on its idle
1722 /// timeout. **Not** a failure.
1723 pub idle_exits: u16,
1724 /// Of those, the ones an operator should look at.
1725 pub failures: u16,
1726 /// Runners this pass was granted but did not start because the allocation
1727 /// lock was held.
1728 ///
1729 /// **Grants, not policies.** It used to be incremented once per
1730 /// `start_runners` call that met a held lock, so a policy that launched two
1731 /// of five and then lost the lock reported `1` while three runners went
1732 /// unstarted -- a number that agreed with neither its own name nor its
1733 /// documentation.
1734 pub deferred: u16,
1735 /// Times the host's attempt set could not be read this pass.
1736 ///
1737 /// Non-zero means the pass decided less than it looks like it decided: a
1738 /// policy whose attempt set was unreadable started nothing and is *not* in
1739 /// [`Self::allocations`], because there was no set to compute an allocation
1740 /// from. It is not the same as the host being idle, which is the whole
1741 /// reason [`RunnerLauncher::attempts`] is fallible.
1742 ///
1743 /// **A count, where [`Self::unreadable`] is a `Vec<PolicyId>`, and that
1744 /// asymmetry is deliberate.** An unreadable *target* is a fact about one
1745 /// policy's GitHub target; an unreadable *attempt set* is a fact about this
1746 /// host's journal, which no policy owns — two of the three paths that reach
1747 /// it (`clean_terminal_attempts` and `scale_down`) have no policy in hand at
1748 /// all. Naming policies here would mean either inventing an owner for a
1749 /// host-wide failure or reporting a partial list, and both read as more
1750 /// precision than there is. The pass is distinguishable from an idle one,
1751 /// which is what the field exists for; the per-policy attribution is not
1752 /// available, and is recorded as missing rather than faked.
1753 pub attempts_unreadable: u16,
1754 /// Terminal attempts whose runtime could not be removed. Retried next pass.
1755 pub clean_failures: u16,
1756 /// The most severe failure across the targets polled, when there was one.
1757 pub failure: Option<RefreshState>,
1758 /// What to display while GitHub is unreachable, including how long the
1759 /// outage has run and therefore whether queued work has already been lost.
1760 pub offline: Option<OfflineState>,
1761 /// When to poll next, and why then.
1762 pub next_poll: NextPoll,
1763 /// Demand requests this pass projected against the shared hourly ceiling.
1764 pub demand_requests: u32,
1765}
1766
1767impl ReconcileReport {
1768 /// Whether this pass actually reached GitHub, which is the only thing that
1769 /// entitles it to write a `last GitHub contact`.
1770 ///
1771 /// # Positive evidence, because the absence of a failure is not evidence
1772 ///
1773 /// The record used to be written whenever [`Self::failure`] was `None`, on
1774 /// the belief that an unauthorized target lands in [`Self::unreadable`]
1775 /// rather than in `failure`. **That belief is wrong.** `unreadable` is
1776 /// pushed only from the `PollOutcome::Failed` arm, `failure` is the maximum
1777 /// over every `Failed` reading, and `RefreshState::Unauthorized` scores 2 —
1778 /// so a non-empty `unreadable` always implies `failure.is_some()`, and
1779 /// guarding on both would have changed nothing at all.
1780 ///
1781 /// The path that really writes a contact record without touching GitHub is
1782 /// a pass that polls **nothing**: every policy draining, owned by another
1783 /// host, or monitor-only. `pollable` is then empty, no reading exists, no
1784 /// failure is computed, and the old guard passed. That is how
1785 /// `service status` can answer `healthy` on a host doing nothing at all.
1786 ///
1787 /// So this asks for evidence rather than for the absence of a complaint. A
1788 /// pass with nothing to ask reaches nobody and records nothing, which is
1789 /// what `never` in `service status` is for.
1790 ///
1791 /// Conservative on purpose: `repositories.scope_for` is a real request that
1792 /// can succeed before a demand poll fails, and it is not counted. Contact
1793 /// that cannot be proven is not claimed.
1794 #[must_use]
1795 pub const fn reached_github(&self) -> bool {
1796 self.targets_read > 0
1797 }
1798}
1799
1800impl Default for NextPoll {
1801 fn default() -> Self {
1802 Self {
1803 delay: PollSchedule::floor(),
1804 pace: PollPace::Nominal,
1805 }
1806 }
1807}
1808
1809impl ReconcileReport {
1810 /// Whether GitHub was unreachable this pass.
1811 #[must_use]
1812 pub fn is_offline(&self) -> bool {
1813 matches!(self.failure, Some(RefreshState::Offline))
1814 }
1815
1816 /// The offline state to display, when this pass was one.
1817 #[must_use]
1818 pub const fn offline_state(&self) -> Option<&OfflineState> {
1819 self.offline.as_ref()
1820 }
1821
1822 /// Attempts this pass created. The idle-host assertion reads this.
1823 #[must_use]
1824 pub const fn starts_nothing(&self) -> bool {
1825 self.started == 0
1826 }
1827}
1828
1829/// What one scale-down request did.
1830#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1831pub struct ScaleDownReport {
1832 /// Terminal attempts whose runtime was removed.
1833 pub removed: u16,
1834 /// Attempts executing a job. **Removed nothing, left `busy`.**
1835 pub refused_busy: u16,
1836 /// Terminal attempts whose runtime could not be removed.
1837 pub clean_failures: u16,
1838 /// Live attempts that are not yet busy. Also removed nothing: capacity is
1839 /// reclaimed only when an attempt reaches a terminal state.
1840 pub retained: u16,
1841 /// The host's attempt set could not be read, so **every other field here is
1842 /// meaningless** rather than zero.
1843 ///
1844 /// This is the same distinction [`ReconcileReport::attempts_unreadable`]
1845 /// draws, and it is here for the same reason: a default
1846 /// [`ScaleDownReport`] and a scale-down that could not see the machine are
1847 /// both all-zeros, and they mean opposite things — "there was nothing to
1848 /// reclaim" against "we do not know what there was". Check
1849 /// [`Self::is_conclusive`] before reading a zero as an answer.
1850 pub attempts_unreadable: bool,
1851}
1852
1853impl ScaleDownReport {
1854 /// Whether the counts here describe the machine at all.
1855 ///
1856 /// `false` means the attempt set could not be read, so every zero is
1857 /// "unknown" rather than "none".
1858 #[must_use]
1859 pub const fn is_conclusive(&self) -> bool {
1860 !self.attempts_unreadable
1861 }
1862}
1863
1864/// The reconciliation loop.
1865///
1866/// One per target, as `f3` runs them; they share a [`RunnerLauncher`] and an
1867/// [`AllocationLock`], which is what keeps the host ceiling true across all of
1868/// them.
1869#[derive(Debug)]
1870pub struct Reconciler {
1871 host: Host,
1872 demand: Arc<dyn DemandSource>,
1873 launcher: Arc<dyn RunnerLauncher>,
1874 lock: Arc<dyn AllocationLock>,
1875 repositories: RepositoryCache,
1876 clock: Arc<dyn Clock>,
1877 jitter: Arc<dyn Jitter>,
1878 events: Arc<dyn EventSink>,
1879 schedule: PollSchedule,
1880}
1881
1882impl Reconciler {
1883 /// Build a reconciler polling at the host's configured interval.
1884 #[must_use]
1885 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
1886 let interval = host.refresh_interval;
1887 let repositories = RepositoryCache::new(
1888 Arc::clone(&ports.directory),
1889 Arc::clone(&ports.clock),
1890 interval,
1891 );
1892 Self {
1893 host,
1894 demand: ports.demand,
1895 launcher: ports.launcher,
1896 lock: ports.lock,
1897 repositories,
1898 clock: ports.clock,
1899 jitter: ports.jitter,
1900 events: ports.events,
1901 schedule: PollSchedule::new(interval),
1902 }
1903 }
1904
1905 #[must_use]
1906 pub const fn host(&self) -> &Host {
1907 &self.host
1908 }
1909
1910 #[must_use]
1911 pub const fn schedule(&self) -> &PollSchedule {
1912 &self.schedule
1913 }
1914
1915 /// The repository-list cache, so `f1` can report what it has spent.
1916 #[must_use]
1917 pub const fn repositories(&self) -> &RepositoryCache {
1918 &self.repositories
1919 }
1920
1921 /// One reconciliation pass over `policies`.
1922 ///
1923 /// The order of operations is `03-control-flows.md` flow 2, and the two
1924 /// steps most worth naming are the ones that are silent when they are wrong:
1925 ///
1926 /// * **Monitor-only policies are removed before the demand poll**, not
1927 /// after. D19 says such a policy "is skipped entirely by reconciliation",
1928 /// and a poll issued on its behalf would spend requests from the shared
1929 /// ceiling for a policy that can never act on the answer. This is asserted
1930 /// on [`ScalePolicy::owns_runners`] rather than deduced from
1931 /// `max_capacity` being absent.
1932 /// * **The attempt set is re-read under the lock, once per runtime.** See
1933 /// [`RunnerLauncher`] for why it comes from there and nowhere else.
1934 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
1935 let mut report = ReconcileReport::default();
1936 // Everything this pass has created, carried across policies so that the
1937 // host-wide total cannot be computed from a set that is missing it. See
1938 // `RunnerLauncher::launch`.
1939 let mut launched: Vec<RunnerAttempt> = Vec::new();
1940
1941 // --- Flow 2.1-2.2: who is even asking, and what did GitHub say -------
1942 let mut pollable: Vec<&ScalePolicy> = Vec::new();
1943 let mut supervision_failed = BTreeSet::new();
1944 for policy in policies {
1945 if !policy.owns_runners() {
1946 report.monitor_only.push(policy.id);
1947 self.events
1948 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
1949 continue;
1950 }
1951 if !policy.is_owned_by(self.host.id) {
1952 // Ownership rule 2 and precedence rule 4. The allocator reports
1953 // both by name below; polling on their behalf would spend
1954 // requests for an answer that cannot be acted on.
1955 continue;
1956 }
1957 match self.launcher.supervise(policy).await {
1958 Ok(intents) => {
1959 report.replacement_intents = report
1960 .replacement_intents
1961 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
1962 }
1963 Err(failure) => {
1964 supervision_failed.insert(policy.id);
1965 self.report_unreadable_attempts(&mut report, &failure);
1966 continue;
1967 }
1968 }
1969 if !policy.may_start_runners() {
1970 continue;
1971 }
1972 pollable.push(policy);
1973 }
1974
1975 let readings = self.poll_targets(&pollable, &mut report).await;
1976
1977 // --- Flow 2.8: terminal attempts, whatever else this pass does -------
1978 //
1979 // Run before the allocation phase so that a report's `cleaned` count
1980 // describes the same instant its allocations do. It does not change the
1981 // arithmetic: a terminal attempt already stopped counting against
1982 // capacity when it became terminal, which is `b1`'s
1983 // `counts_against_capacity`. It touches no live process, so it is also
1984 // safe during an outage — flow 3.3 requires that running runners be
1985 // retained, and nothing here can reach one.
1986 self.clean_terminal_attempts(&mut report).await;
1987
1988 // --- Flow 2.3-2.6: the allocation -----------------------------------
1989 //
1990 // The predicates are re-tested here rather than the reading being looked
1991 // up by target, and that is not redundancy. **Targets are shared.** A
1992 // monitor-only policy watching `acme/app` alongside an autoscale policy
1993 // on the *same* repository finds a reading in the map that the other
1994 // policy paid for, and a lookup-driven loop then serves it: it emits a
1995 // demand observation on its behalf and clamps a number it has no
1996 // business seeing.
1997 //
1998 // Nothing downstream goes wrong when that happens — `may_start_runners`
1999 // is false for a monitor-only policy, so `HostAllocator` refuses it and
2000 // `to_start` is zero. It simply is not *skipped*, and D19's word is
2001 // "entirely".
2002 for policy in policies {
2003 if !policy.owns_runners() {
2004 // Already recorded and reported above, before any demand request
2005 // was issued. It owns no routing labels, takes no part in
2006 // demand, and can never be the reason a runner starts. Asserted
2007 // on the mode rather than deduced from `max_capacity` being
2008 // absent, which is what the specification requires.
2009 continue;
2010 }
2011 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2012 // Ownership rule 2 and precedence rule 4. Allocated for with no
2013 // demand, so the refusal is reported by name rather than by
2014 // absence.
2015 match self.allocate_only(policy, 0, &launched).await {
2016 Ok(allocation) => {
2017 self.emit_allocation(&allocation);
2018 report.allocations.push(allocation);
2019 }
2020 Err(failure) => self.report_unreadable_attempts(&mut report, &failure),
2021 }
2022 continue;
2023 }
2024 if supervision_failed.contains(&policy.id) {
2025 continue;
2026 }
2027 let Some(reading) = readings.get(&policy.target) else {
2028 // Unreachable: every policy reaching here was in `pollable`, and
2029 // `poll_targets` inserts an outcome for each of their targets.
2030 debug_assert!(false, "a pollable policy's target has no reading");
2031 continue;
2032 };
2033 match reading {
2034 PollOutcome::Failed(state) => {
2035 report.unreadable.push(policy.id);
2036 self.events.emit(LifecycleEvent::TargetUnreadable {
2037 policy: policy.id,
2038 reason: unreadable_reason(state),
2039 });
2040 }
2041 PollOutcome::Ready(demand) => {
2042 report.targets_read = report.targets_read.saturating_add(1);
2043 let tally = demand_for(policy, demand);
2044 let count = tally.demand();
2045 self.events.emit(LifecycleEvent::DemandObserved {
2046 policy: policy.id,
2047 demand: count,
2048 not_matched: tally.not_matched,
2049 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2050 complete: demand.is_complete(),
2051 });
2052 self.start_runners(policy, count, &mut report, &mut launched)
2053 .await;
2054 }
2055 }
2056 }
2057
2058 // --- Flow 2.1 / 3.3: when to come back ------------------------------
2059 let failure = readings
2060 .values()
2061 .filter_map(PollOutcome::failure)
2062 .max_by_key(|state| severity(state))
2063 .cloned();
2064 let now = self.clock.now();
2065 report.next_poll = self
2066 .schedule
2067 .next_poll(failure.as_ref(), now, self.jitter.as_ref());
2068 report.failure = failure;
2069 // Flow 3.3's fourth obligation: the offline state carries the 24-hour
2070 // bound, and it can only say whether that bound has passed if it is
2071 // given the real elapsed time rather than an estimate from the interval.
2072 if let PollPace::Offline { consecutive } = report.next_poll.pace {
2073 let state = OfflineState::new(consecutive, report.next_poll.delay);
2074 report.offline = Some(match self.schedule.offline_for(now) {
2075 Some(elapsed) => state.since(elapsed),
2076 None => state,
2077 });
2078 }
2079 self.events.emit(LifecycleEvent::PollScheduled {
2080 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2081 pace: report.next_poll.pace,
2082 });
2083
2084 report
2085 }
2086
2087 /// Poll each distinct target once, however many policies share it.
2088 ///
2089 /// Two policies on one repository are one demand request, not two. That is
2090 /// not a micro-optimisation: the budget model in
2091 /// `04-subsystem-contracts.md` prices a *target*, and a loop that spent per
2092 /// policy would quietly exceed the projection `f2` admitted the
2093 /// configuration against.
2094 async fn poll_targets(
2095 &self,
2096 pollable: &[&ScalePolicy],
2097 report: &mut ReconcileReport,
2098 ) -> BTreeMap<ScaleTarget, PollOutcome> {
2099 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
2100
2101 let mut readings = BTreeMap::new();
2102 for target in targets {
2103 let outcome = match self.repositories.scope_for(&target).await {
2104 Ok(scope) => {
2105 report.demand_requests = report
2106 .demand_requests
2107 .saturating_add(demand_requests_per_poll(&scope));
2108 self.demand.poll(&scope).await
2109 }
2110 // The repository list could not be refreshed, so the scope of
2111 // the poll is unknown. Polling a stale or empty scope would
2112 // report a demand number for a set of repositories nobody
2113 // chose, which is worse than reporting that the target could
2114 // not be read.
2115 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
2116 };
2117 readings.insert(target, outcome);
2118 }
2119 readings
2120 }
2121
2122 /// Compute one policy's allocation without creating anything.
2123 ///
2124 /// # Why this is safe without the lock
2125 ///
2126 /// **Not** because it cannot grant — it can, and
2127 /// [`Reconciler::start_runners`] uses it as a pre-check precisely for the
2128 /// number it returns. That was the original reason and this function
2129 /// outgrew it; the reason now is that it *decides* nothing. Nothing is
2130 /// created here, the headroom it read is re-read under the lock before any
2131 /// runtime exists, and the under-lock allocation may only lower what this
2132 /// one proposed. So there is no read-decide-create sequence here to make
2133 /// atomic, and the worst this can be is optimistic — which the lock then
2134 /// corrects.
2135 ///
2136 /// # Errors
2137 /// Whatever [`RunnerLauncher::attempts`] reported. A failure is never the
2138 /// same answer as an empty set.
2139 async fn allocate_only(
2140 &self,
2141 policy: &ScalePolicy,
2142 demand: u32,
2143 launched: &[RunnerAttempt],
2144 ) -> Result<Allocation, LaunchFailure> {
2145 let attempts = self.host_attempts(launched).await?;
2146 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2147 Ok(allocator.allocate(policy, demand))
2148 }
2149
2150 /// Flow 2.4-2.6: start runners for one policy, one lock hold per runtime.
2151 ///
2152 /// # Two stopping conditions, and both are needed
2153 ///
2154 /// The loop re-reads the attempt set under every hold, so the obvious stop
2155 /// is "the allocator granted nothing". That condition **alone does not
2156 /// terminate**, and the failure is not hypothetical — it was measured.
2157 /// Handing the allocator a set that does not include the runners this loop
2158 /// just started (an empty one, a stale one, or a launcher whose journal
2159 /// write has not landed yet) makes every grant look like the first, and the
2160 /// pass starts runners until something outside it intervenes. With the set
2161 /// dropped entirely, the three-consecutive-polls test below does not report
2162 /// three attempts; it *never returns*.
2163 ///
2164 /// So the grant decided on the first hold is also a **budget**. A later hold
2165 /// may lower it — the host may have filled up meanwhile — and can never
2166 /// raise it, which bounds the pass at the number this policy was actually
2167 /// allocated. That is `c2`'s reasoning for `MAX_PAGES` one layer down: the
2168 /// reconciliation loop is the one place in this product that must not be
2169 /// able to wedge, so the bound is structural rather than a consequence of
2170 /// every input being well behaved.
2171 async fn start_runners(
2172 &self,
2173 policy: &ScalePolicy,
2174 demand: u32,
2175 report: &mut ReconcileReport,
2176 launched: &mut Vec<RunnerAttempt>,
2177 ) {
2178 // A lock-free pre-check, for one reason only: the host-wide lock should
2179 // not be taken by a policy that is going to be granted nothing. On an
2180 // idle host with P policies that was P lock acquisitions per poll --
2181 // free under `InProcessAllocationLock`, a `spawn_blocking` and a
2182 // filesystem lock apiece under `FileAllocationLock`.
2183 //
2184 // It is safe because it can only be optimistic. Anything it grants is
2185 // re-decided under the lock below and may be lowered there; the only
2186 // thing it can get wrong in the other direction is refusing a grant that
2187 // headroom freed a moment later would have allowed, which the next poll
2188 // picks up.
2189 let intent = match self.allocate_only(policy, demand, launched).await {
2190 Ok(intent) => intent,
2191 Err(failure) => {
2192 self.report_unreadable_attempts(report, &failure);
2193 return;
2194 }
2195 };
2196
2197 // The allocation that is *reported* is the one taken under the lock when
2198 // a lock was taken, because that is the one that decided anything. The
2199 // pre-check stands in only when no hold was ever obtained.
2200 let mut decided: Option<Allocation> = None;
2201 let mut budget = intent.to_start;
2202
2203 while budget > 0 {
2204 let guard = match self.lock.acquire().await {
2205 Ok(guard) => guard,
2206 Err(_) => {
2207 // Grants, not policies: this is what the policy was owed and
2208 // did not get.
2209 report.deferred = report.deferred.saturating_add(budget);
2210 self.events.emit(LifecycleEvent::AllocationDeferred {
2211 policy: policy.id,
2212 count: budget,
2213 });
2214 break;
2215 }
2216 };
2217
2218 // The read and the decision are both inside the hold, and so is the
2219 // creation below. Two concurrent passes therefore serialise on the
2220 // whole sequence rather than on the decision alone -- reading the
2221 // headroom outside the lock is the shape in which two policies both
2222 // find room for the last slot.
2223 let attempts = match self.host_attempts(launched).await {
2224 Ok(attempts) => attempts,
2225 Err(failure) => {
2226 drop(guard);
2227 self.report_unreadable_attempts(report, &failure);
2228 break;
2229 }
2230 };
2231 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2232 let allocation = allocator.allocate(policy, demand);
2233
2234 if decided.is_none() {
2235 // The under-lock decision may be smaller than the pre-check, and
2236 // never larger: `min` rather than assignment, so a later hold
2237 // cannot raise the bound either.
2238 budget = budget.min(allocation.to_start);
2239 decided = Some(allocation.clone());
2240 }
2241
2242 // Either stop is sufficient on its own in the well-behaved case;
2243 // neither is sufficient when the launcher lags. See the doc comment.
2244 if allocation.starts_nothing() || budget == 0 {
2245 drop(guard);
2246 break;
2247 }
2248
2249 let created = self
2250 .launcher
2251 .launch(LaunchRequest {
2252 host: &self.host,
2253 policy,
2254 allocation_guard: &guard,
2255 })
2256 .await;
2257 drop(guard);
2258
2259 match created {
2260 Ok(attempt) => {
2261 let id = attempt.id;
2262 // Carried across policies for the rest of this pass, so the
2263 // host-wide total cannot be computed from a set that is
2264 // missing it. See `RunnerLauncher::launch`.
2265 launched.push(attempt);
2266 report.started = report.started.saturating_add(1);
2267 budget -= 1;
2268 self.events.emit(LifecycleEvent::RunnerStarted {
2269 policy: policy.id,
2270 attempt: id,
2271 });
2272 }
2273 Err(failure) => {
2274 self.events.emit(LifecycleEvent::RunnerStartFailed {
2275 policy: policy.id,
2276 reason: failure_reason_kind(&failure.reason),
2277 });
2278 break;
2279 }
2280 }
2281 }
2282
2283 let allocation = decided.unwrap_or(intent);
2284 self.emit_allocation(&allocation);
2285 report.allocations.push(allocation);
2286 }
2287
2288 /// The attempt set the host holds, plus everything this pass has already
2289 /// created.
2290 ///
2291 /// The merge is by [`RunnerAttempt::id`], so a launcher that makes its
2292 /// launches visible before returning -- which
2293 /// [`RunnerLauncher::launch`] asks for -- contributes each attempt once, and
2294 /// one that lags still cannot hide a runner from the host-wide total. The
2295 /// ceiling therefore holds on the strength of this function rather than on
2296 /// the strength of an implementer honouring a comment.
2297 ///
2298 /// # Errors
2299 /// Whatever [`RunnerLauncher::attempts`] reported.
2300 async fn host_attempts(
2301 &self,
2302 launched: &[RunnerAttempt],
2303 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2304 let mut attempts = self.launcher.attempts().await?;
2305
2306 // Each `launch` creates one runtime, so each must answer with an
2307 // identifier no other attempt has. Two entries sharing one here are two
2308 // runtimes the host-wide total below counts once, which is the ceiling
2309 // failing silently -- so a development build stops at the first
2310 // duplicate instead. `RunnerLauncher::launch` states the requirement;
2311 // this is what makes it findable.
2312 debug_assert!(
2313 launched
2314 .iter()
2315 .map(|attempt| attempt.id)
2316 .collect::<BTreeSet<AttemptId>>()
2317 .len()
2318 == launched.len(),
2319 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
2320 the host ceiling is enforced against a set keyed on that identifier, so a \
2321 duplicate is two runtimes counted as one"
2322 );
2323
2324 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
2325 attempts.extend(
2326 launched
2327 .iter()
2328 .filter(|attempt| !known.contains(&attempt.id))
2329 .cloned(),
2330 );
2331 Ok(attempts)
2332 }
2333
2334 /// The attempt set could not be read, so nothing may be decided from it.
2335 ///
2336 /// Counted rather than swallowed for the reason the module documentation
2337 /// gives: an unreadable set and an idle host produce the same *number* and
2338 /// demand opposite actions, so the difference has to survive into the
2339 /// report.
2340 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
2341 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
2342 // The variant, never a literal and never the detail. A hand-written
2343 // `"attempts_unreadable"` said only what the event's own name already
2344 // said, and threw away the one thing the field is for -- *which* failure
2345 // it was. `FailureReason::Other` carries free text that must not reach
2346 // an event, which is what `failure_reason_kind` is for and what
2347 // `a_cleanup_that_cannot_succeed_...` pins for the sibling path.
2348 self.events.emit(LifecycleEvent::AttemptsUnreadable {
2349 reason: failure_reason_kind(&failure.reason),
2350 });
2351 }
2352
2353 /// Remove the runtimes of attempts that have already concluded.
2354 ///
2355 /// `is_concluded` and not `is_terminal`: `cleaned` is terminal and already
2356 /// done, and `busy` is not terminal at all. That is what makes it impossible
2357 /// for this path to reach a runner executing a job.
2358 async fn clean_terminal_attempts(&self, report: &mut ReconcileReport) {
2359 let attempts = match self.launcher.attempts().await {
2360 Ok(attempts) => attempts,
2361 Err(failure) => {
2362 self.report_unreadable_attempts(report, &failure);
2363 return;
2364 }
2365 };
2366 for attempt in attempts {
2367 if !attempt.state().is_concluded() {
2368 continue;
2369 }
2370 let Some(outcome) = attempt.outcome() else {
2371 continue;
2372 };
2373 let kind = OutcomeKind::of(outcome);
2374 match self.launcher.clean(attempt.id).await {
2375 Ok(()) => {
2376 report.cleaned = report.cleaned.saturating_add(1);
2377 if kind.is_failure() {
2378 report.failures = report.failures.saturating_add(1);
2379 } else if kind == OutcomeKind::IdleExit {
2380 // The surplus case. Counted apart from a failure because
2381 // `g2` renders it apart, and because an operator told
2382 // that a normal surplus exit is an error goes hunting a
2383 // fault that does not exist.
2384 report.idle_exits = report.idle_exits.saturating_add(1);
2385 }
2386 self.events.emit(LifecycleEvent::AttemptCleaned {
2387 policy: attempt.policy_id,
2388 attempt: attempt.id,
2389 outcome: kind,
2390 });
2391 }
2392 // A runtime directory that cannot be removed is retried on every
2393 // poll. Silently, before this arm existed: no event, no counter,
2394 // no report field, so a cleanup that can never succeed was an
2395 // invisible permanent loop. It wedges no capacity -- a terminal
2396 // attempt already stopped counting -- but this module's
2397 // organising principle is the things that go wrong silently, and
2398 // `clean` returns a `Result` precisely so the caller can say
2399 // something.
2400 Err(failure) => {
2401 report.clean_failures = report.clean_failures.saturating_add(1);
2402 self.events.emit(LifecycleEvent::AttemptCleanFailed {
2403 policy: attempt.policy_id,
2404 attempt: attempt.id,
2405 reason: failure_reason_kind(&failure.reason),
2406 });
2407 }
2408 }
2409 }
2410 }
2411
2412 /// Reclaim what can be reclaimed for one policy, and nothing else.
2413 ///
2414 /// **A busy attempt is never removed.** `04-subsystem-contracts.md`:
2415 /// *"`busy` cannot transition to cleanup due to a scale-down request"*.
2416 /// Capacity comes back when an attempt reaches a terminal state and at no
2417 /// other time, so a scale-down against a host full of busy runners removes
2418 /// nothing, changes nothing, and says so.
2419 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
2420 let mut report = ScaleDownReport::default();
2421 let attempts = match self.launcher.attempts().await {
2422 Ok(attempts) => attempts,
2423 Err(failure) => {
2424 // The same rule as everywhere else, and this was the one place
2425 // it was still broken: an unreadable set is not an empty one,
2426 // and a bare `default()` here reported all zeros -- byte for
2427 // byte an idle host with nothing to reclaim.
2428 self.events.emit(LifecycleEvent::AttemptsUnreadable {
2429 reason: failure_reason_kind(&failure.reason),
2430 });
2431 report.attempts_unreadable = true;
2432 return report;
2433 }
2434 };
2435 for attempt in attempts {
2436 if attempt.policy_id != policy.id {
2437 continue;
2438 }
2439 match attempt.state() {
2440 AttemptState::Busy => {
2441 report.refused_busy = report.refused_busy.saturating_add(1);
2442 self.events.emit(LifecycleEvent::ScaleDownRefused {
2443 policy: policy.id,
2444 attempt: attempt.id,
2445 });
2446 }
2447 state if state.is_concluded() => {
2448 let kind = attempt
2449 .outcome()
2450 .map_or(OutcomeKind::Failed, OutcomeKind::of);
2451 match self.launcher.clean(attempt.id).await {
2452 Ok(()) => {
2453 report.removed = report.removed.saturating_add(1);
2454 self.events.emit(LifecycleEvent::AttemptCleaned {
2455 policy: policy.id,
2456 attempt: attempt.id,
2457 outcome: kind,
2458 });
2459 }
2460 Err(failure) => {
2461 report.clean_failures = report.clean_failures.saturating_add(1);
2462 self.events.emit(LifecycleEvent::AttemptCleanFailed {
2463 policy: policy.id,
2464 attempt: attempt.id,
2465 reason: failure_reason_kind(&failure.reason),
2466 });
2467 }
2468 }
2469 }
2470 AttemptState::Cleaned => {}
2471 // `allocated`, `jit_received`, `starting`, `idle`: live, holding
2472 // a slot, and not this function's to end.
2473 _ => report.retained = report.retained.saturating_add(1),
2474 }
2475 }
2476 report
2477 }
2478
2479 fn emit_allocation(&self, allocation: &Allocation) {
2480 self.events.emit(LifecycleEvent::Allocated {
2481 policy: allocation.policy_id,
2482 demand: allocation.demand,
2483 desired: allocation.desired,
2484 active_owned: allocation.active_owned,
2485 headroom: allocation.headroom_before,
2486 to_start: allocation.to_start,
2487 limiting: allocation.limiting_factor,
2488 });
2489 }
2490}
2491
2492/// One policy's demand, from the reading its target answered with.
2493///
2494/// A repository target tallies its own repository's queued jobs; an organization
2495/// target tallies every repository its scope covered, because one policy watching
2496/// an organization serves any repository in it.
2497///
2498/// # Why this takes a whole policy rather than a target and a label set
2499///
2500/// Because both halves have to come from the same policy, and a signature that
2501/// took them separately made it possible for them not to. The predecessor took a
2502/// `&ScaleTarget` alone and could not filter at all; the obvious repair was to
2503/// add a `&RoutingLabels` beside it, and at three call sites — two of them in
2504/// tests — nothing would have caught passing one policy's target with another
2505/// policy's labels. It compiles, it runs, and it silently serves the wrong
2506/// repository's queue.
2507///
2508/// # A monitor-only policy has no labels, and cannot reach here
2509///
2510/// [`Reconciler::reconcile`] filters on [`ScalePolicy::owns_runners`] before any
2511/// demand request is issued (D19), so the `None` arm is unreachable rather than
2512/// merely unlikely. It returns an empty tally instead of unwrapping, because a
2513/// panic in the reconciliation loop would take the daemon down over a policy
2514/// that was only ever going to start nothing.
2515fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
2516 let Some(labels) = policy.routing_labels() else {
2517 debug_assert!(
2518 false,
2519 "a monitor-only policy is skipped before the demand poll (D19)"
2520 );
2521 return DemandTally::default();
2522 };
2523
2524 match &policy.target {
2525 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
2526 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
2527 }
2528}
2529
2530/// How urgently one failure should slow the loop down.
2531///
2532/// Ordering matters only for picking the worst of several targets: an outage
2533/// outranks a rate limit because backing off a socket that is not answering is
2534/// the safer error, and both outrank a per-target rejection that says nothing
2535/// about the credential as a whole.
2536const fn severity(state: &RefreshState) -> u8 {
2537 match state {
2538 RefreshState::Offline => 5,
2539 RefreshState::RateLimited(_) => 4,
2540 RefreshState::LockedOut { .. } => 3,
2541 RefreshState::Unauthorized => 2,
2542 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
2543 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
2544 }
2545}
2546
2547/// Why one target could not be read, as a fixed, credential-free name.
2548///
2549/// Deliberately not a [`PollPace`]: a pace describes the *schedule*, which is a
2550/// property of the whole pass, and stamping one onto a single target would have
2551/// meant inventing a `consecutive` count for a target that has none. What an
2552/// event needs here is the reason, and `c3`'s [`RefreshState`] already names it.
2553///
2554/// `RefreshState::Failed` carries GitHub's own message and
2555/// `RefreshState::Forbidden` may carry one too. Neither reaches the event: this
2556/// returns the variant, for the reason [`failure_reason_kind`] states.
2557const fn unreadable_reason(state: &RefreshState) -> &'static str {
2558 match state {
2559 RefreshState::Ready(_) => "ready",
2560 RefreshState::Offline => "offline",
2561 RefreshState::RateLimited(_) => "rate_limited",
2562 RefreshState::LockedOut { .. } => "locked_out",
2563 RefreshState::Unauthorized => "unauthorized",
2564 RefreshState::Forbidden { .. } => "forbidden",
2565 RefreshState::Failed { .. } => "failed",
2566 RefreshState::Cancelled => "cancelled",
2567 }
2568}
2569
2570#[cfg(test)]
2571mod tests {
2572 use super::*;
2573
2574 /// The reading that said `healthy` for 28 hours while nothing worked.
2575 ///
2576 /// A daemon every one of whose targets answered `401` kept writing a fresh
2577 /// `last GitHub contact`, because an unauthorized target is `unreadable`
2578 /// rather than a `failure`. Both that record and the `service status` built
2579 /// on it were used as evidence during the investigation, and both were
2580 /// wrong; see `docs/spikes/token-expiry-and-renewal.md`.
2581 #[test]
2582 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
2583 let mut report = ReconcileReport::default();
2584 assert!(
2585 !report.reached_github(),
2586 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
2587 monitor-only -- reached nobody. This is the case the old guard let through, and \
2588 the only one it ever let through."
2589 );
2590
2591 report.unreadable.push(PolicyId::from_u128(1));
2592 assert!(
2593 !report.reached_github(),
2594 "every target this pass tried was unreadable, so there is no contact to record"
2595 );
2596
2597 report.targets_read = 1;
2598 assert!(
2599 report.reached_github(),
2600 "one target answering is contact, whatever else failed alongside it"
2601 );
2602
2603 // `allocations` deliberately does not count: a policy this host does
2604 // not own is allocated for with no demand and without polling anything,
2605 // so a pass where every poll failed can still carry allocations.
2606 let mut unowned = ReconcileReport::default();
2607 unowned.unreadable.push(PolicyId::from_u128(2));
2608 unowned.allocations.push(Allocation {
2609 policy_id: PolicyId::from_u128(2),
2610 demand: 0,
2611 desired: 0,
2612 active_owned: 0,
2613 headroom_before: 0,
2614 to_start: 0,
2615 limiting_factor: LimitingFactor::Demand,
2616 });
2617 assert!(
2618 !unowned.reached_github(),
2619 "an allocation is not evidence that GitHub answered"
2620 );
2621 }
2622
2623 /// The claim the old guard rested on, checked rather than assumed.
2624 ///
2625 /// `report.failure.is_none()` was believed to be compatible with an
2626 /// all-unauthorized pass. It is not: `unreadable` is pushed only from the
2627 /// `Failed` arm and `failure` is the maximum over every `Failed` reading,
2628 /// so guarding on `failure.is_none() && reached_github()` would have been
2629 /// `failure.is_none()` with extra words. This pins the severity that makes
2630 /// it so, because a future `severity(Unauthorized) == 0` would quietly
2631 /// restore the belief.
2632 #[test]
2633 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
2634 assert!(
2635 severity(&RefreshState::Unauthorized) > 0,
2636 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
2637 or a pass where every target was refused would report no failure at all"
2638 );
2639 }
2640
2641 use std::sync::atomic::AtomicUsize;
2642
2643 use std::num::NonZeroU16;
2644
2645 use runner_manager_domain::attempt::PersistedAttempt;
2646 use runner_manager_domain::model::{CachePolicy, HostId};
2647 use runner_manager_domain::policy::PolicyMode;
2648 use runner_manager_domain::workspace::WorkspaceKind;
2649 use runner_manager_github::rest::RateLimited;
2650 use runner_manager_testkit::clock::FakeClock;
2651 use runner_manager_testkit::fixtures;
2652 use runner_manager_testkit::github::FakeGithub;
2653
2654 // =======================================================================
2655 // Fakes
2656 // =======================================================================
2657
2658 fn host_with(capacity: u16) -> Host {
2659 fixtures::host().capacity(capacity).build()
2660 }
2661
2662 fn repo(raw: &str) -> OwnerRepo {
2663 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
2664 }
2665
2666 /// An `active`, enabled autoscale policy on the fixture host.
2667 use runner_manager_domain::policy::RunsOn;
2668
2669 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
2670 fixtures::policy()
2671 .id(PolicyId::from_u128(id))
2672 .repository(target)
2673 .autoscale("home", max)
2674 .active()
2675 .build()
2676 }
2677
2678 /// The host label every policy in these tests carries.
2679 ///
2680 /// `policy` above builds through `fixtures::policy().autoscale("home", …)`,
2681 /// which derives `rm-home-win-x64`. A job fixture that did not carry it
2682 /// would be filtered out as another host's work, so the two are tied
2683 /// together here rather than repeated as a literal at each call site.
2684 const HOST_LABEL: &str = "rm-home-win-x64";
2685
2686 /// `n` queued jobs this host's policies match.
2687 ///
2688 /// The ordinary demand fixture. Since the reversal of the run-counting
2689 /// decision the unit `e1` clamps is a job, so a test wanting demand `n` asks
2690 /// for `n` jobs rather than for `n` runs.
2691 fn jobs(n: usize) -> Vec<RunsOn> {
2692 fixtures::queued_jobs(&[HOST_LABEL], n)
2693 }
2694
2695 /// `e3`, faked: an attempt table and a launch counter, no process anywhere.
2696 #[derive(Debug, Default)]
2697 struct FakeLauncher {
2698 attempts: Mutex<Vec<RunnerAttempt>>,
2699 next_id: AtomicU64,
2700 launches: AtomicUsize,
2701 cleaned: Mutex<Vec<AttemptId>>,
2702 /// Yields this many times between reading the attempt set and recording
2703 /// a new one, so an unserialised allocator has a window to be wrong in.
2704 yields_before_recording: usize,
2705 /// Reports success without the attempt ever becoming visible, which is
2706 /// the shape a slow journal write has. Every grant then looks like the
2707 /// first.
2708 forgetful: bool,
2709 fail_next: Mutex<Option<FailureReason>>,
2710 /// Reports that the attempt set cannot be read at all, which is the one
2711 /// answer a caller must never confuse with an idle host.
2712 attempts_fail: Mutex<bool>,
2713 /// Refuses every cleanup, so the silent-retry path has something to be
2714 /// loud about.
2715 clean_fails: bool,
2716 replacements: Mutex<Vec<ReplacementIntent>>,
2717 }
2718
2719 impl FakeLauncher {
2720 fn new() -> Self {
2721 Self::default()
2722 }
2723
2724 fn with_yields(mut self, yields: usize) -> Self {
2725 self.yields_before_recording = yields;
2726 self
2727 }
2728
2729 fn forgetful() -> Self {
2730 Self {
2731 forgetful: true,
2732 ..Self::default()
2733 }
2734 }
2735
2736 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
2737 *self.attempts.lock().unwrap() = attempts;
2738 self
2739 }
2740
2741 fn launches(&self) -> usize {
2742 self.launches.load(Ordering::SeqCst)
2743 }
2744
2745 fn snapshot(&self) -> Vec<RunnerAttempt> {
2746 self.attempts.lock().unwrap().clone()
2747 }
2748
2749 fn live_count(&self) -> usize {
2750 self.snapshot()
2751 .iter()
2752 .filter(|a| a.counts_against_capacity())
2753 .count()
2754 }
2755
2756 fn fail_next(&self, reason: FailureReason) {
2757 *self.fail_next.lock().unwrap() = Some(reason);
2758 }
2759
2760 fn fail_attempts(&self, failing: bool) {
2761 *self.attempts_fail.lock().unwrap() = failing;
2762 }
2763
2764 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
2765 Self {
2766 clean_fails: true,
2767 ..Self::default()
2768 }
2769 .seeded(attempts)
2770 }
2771
2772 fn cleaned(&self) -> Vec<AttemptId> {
2773 self.cleaned.lock().unwrap().clone()
2774 }
2775
2776 fn replacing(self, intent: ReplacementIntent) -> Self {
2777 self.replacements.lock().unwrap().push(intent);
2778 self
2779 }
2780 }
2781
2782 #[async_trait::async_trait]
2783 impl RunnerLauncher for FakeLauncher {
2784 async fn supervise(
2785 &self,
2786 policy: &ScalePolicy,
2787 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
2788 let mut replacements = self.replacements.lock().unwrap();
2789 let selected: Vec<_> = replacements
2790 .extract_if(.., |intent| intent.policy == policy.id)
2791 .collect();
2792 if !selected.is_empty() {
2793 let retired: BTreeSet<_> = selected
2794 .iter()
2795 .map(|intent| intent.previous_attempt)
2796 .collect();
2797 self.attempts
2798 .lock()
2799 .unwrap()
2800 .retain(|attempt| !retired.contains(&attempt.id));
2801 }
2802 Ok(selected)
2803 }
2804
2805 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2806 if *self.attempts_fail.lock().unwrap() {
2807 return Err(LaunchFailure::new(FailureReason::Other(
2808 "the journal could not be read".into(),
2809 )));
2810 }
2811 Ok(self.snapshot())
2812 }
2813
2814 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
2815 if let Some(reason) = self.fail_next.lock().unwrap().take() {
2816 return Err(LaunchFailure::new(reason));
2817 }
2818 // The window an unserialised caller would lose the race in.
2819 for _ in 0..self.yields_before_recording {
2820 tokio::task::yield_now().await;
2821 }
2822 let id =
2823 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
2824 let created = RunnerAttempt::allocate(
2825 id,
2826 request.policy.id,
2827 "runtime/p/a",
2828 request.host.created_at,
2829 );
2830 self.launches.fetch_add(1, Ordering::SeqCst);
2831 if !self.forgetful {
2832 self.attempts.lock().unwrap().push(created.clone());
2833 }
2834 Ok(created)
2835 }
2836
2837 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
2838 if self.clean_fails {
2839 return Err(LaunchFailure::new(FailureReason::Other(
2840 "the runtime directory is locked".into(),
2841 )));
2842 }
2843 self.cleaned.lock().unwrap().push(attempt);
2844 let mut attempts = self.attempts.lock().unwrap();
2845 attempts.retain(|a| a.id != attempt);
2846 Ok(())
2847 }
2848 }
2849
2850 /// A demand source a test programs directly, with no gateway underneath.
2851 #[derive(Debug, Default)]
2852 struct FakeDemand {
2853 outcome: Mutex<Option<PollOutcome>>,
2854 /// Answers programmed for one target, which beat the blanket one.
2855 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
2856 scopes: Mutex<Vec<ActivityScope>>,
2857 }
2858
2859 impl FakeDemand {
2860 fn ready(count: u32, repository: &OwnerRepo) -> Self {
2861 let fake = Self::default();
2862 fake.set(PollOutcome::Ready(QueuedDemand::of(
2863 repository.clone(),
2864 jobs(count as usize),
2865 )));
2866 fake
2867 }
2868
2869 fn failing(state: RefreshState) -> Self {
2870 let fake = Self::default();
2871 fake.set(PollOutcome::Failed(state));
2872 fake
2873 }
2874
2875 fn set(&self, outcome: PollOutcome) {
2876 *self.outcome.lock().unwrap() = Some(outcome);
2877 }
2878
2879 /// Program one target's answer, overriding the blanket one.
2880 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
2881 self.per_target
2882 .lock()
2883 .unwrap()
2884 .insert(target.clone(), outcome);
2885 }
2886
2887 fn polls(&self) -> Vec<ActivityScope> {
2888 self.scopes.lock().unwrap().clone()
2889 }
2890 }
2891
2892 #[async_trait::async_trait]
2893 impl DemandSource for FakeDemand {
2894 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
2895 self.scopes.lock().unwrap().push(scope.clone());
2896 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
2897 return outcome.clone();
2898 }
2899 self.outcome
2900 .lock()
2901 .unwrap()
2902 .clone()
2903 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
2904 }
2905 }
2906
2907 #[derive(Debug, Default)]
2908 struct FakeDirectory {
2909 repositories: Vec<OwnerRepo>,
2910 calls: AtomicUsize,
2911 }
2912
2913 impl FakeDirectory {
2914 fn of(repositories: Vec<OwnerRepo>) -> Self {
2915 Self {
2916 repositories,
2917 calls: AtomicUsize::new(0),
2918 }
2919 }
2920
2921 fn calls(&self) -> usize {
2922 self.calls.load(Ordering::SeqCst)
2923 }
2924 }
2925
2926 #[async_trait::async_trait]
2927 impl RepositoryDirectory for FakeDirectory {
2928 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
2929 self.calls.fetch_add(1, Ordering::SeqCst);
2930 Ok(self.repositories.clone())
2931 }
2932 }
2933
2934 /// A lock that grants everything and counts how many holders it had at once.
2935 ///
2936 /// The counter is the assertion: "under simulated lock contention" is only
2937 /// meaningful if something measures that the contention was actually
2938 /// serialised.
2939 #[derive(Debug)]
2940 struct CountingLock {
2941 inner: InProcessAllocationLock,
2942 concurrent: Arc<AtomicUsize>,
2943 peak: Arc<AtomicUsize>,
2944 acquisitions: Arc<AtomicUsize>,
2945 }
2946
2947 impl CountingLock {
2948 fn new() -> Self {
2949 Self {
2950 inner: InProcessAllocationLock::new(),
2951 concurrent: Arc::new(AtomicUsize::new(0)),
2952 peak: Arc::new(AtomicUsize::new(0)),
2953 acquisitions: Arc::new(AtomicUsize::new(0)),
2954 }
2955 }
2956
2957 fn peak(&self) -> usize {
2958 self.peak.load(Ordering::SeqCst)
2959 }
2960
2961 fn acquisitions(&self) -> usize {
2962 self.acquisitions.load(Ordering::SeqCst)
2963 }
2964 }
2965
2966 #[derive(Debug)]
2967 struct CountingGuard {
2968 _inner: AllocationGuard,
2969 concurrent: Arc<AtomicUsize>,
2970 }
2971
2972 impl Drop for CountingGuard {
2973 fn drop(&mut self) {
2974 self.concurrent.fetch_sub(1, Ordering::SeqCst);
2975 }
2976 }
2977
2978 #[async_trait::async_trait]
2979 impl AllocationLock for CountingLock {
2980 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
2981 let inner = self.inner.acquire().await?;
2982 self.acquisitions.fetch_add(1, Ordering::SeqCst);
2983 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
2984 self.peak.fetch_max(now, Ordering::SeqCst);
2985 Ok(AllocationGuard::new(CountingGuard {
2986 _inner: inner,
2987 concurrent: Arc::clone(&self.concurrent),
2988 }))
2989 }
2990 }
2991
2992 /// The lock that is not one: what the host looks like with the serialisation
2993 /// removed. Used only by the control half of the contention test.
2994 #[derive(Debug, Default)]
2995 struct NoLock;
2996
2997 #[async_trait::async_trait]
2998 impl AllocationLock for NoLock {
2999 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3000 Ok(AllocationGuard::new(()))
3001 }
3002 }
3003
3004 #[tokio::test]
3005 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
3006 use runner_manager_platform::paths::AppPaths;
3007 use runner_manager_platform::wsl::fence::{DrainRequest, GuestRecoveryConfig};
3008
3009 let local = tempfile::tempdir().unwrap();
3010 let shared = tempfile::tempdir().unwrap();
3011 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3012 paths.create_all().unwrap();
3013 GuestRecoveryConfig::new(shared.path().to_path_buf())
3014 .write(&paths)
3015 .unwrap();
3016 DrainRequest::new(4, chrono::Utc::now())
3017 .write(shared.path())
3018 .unwrap();
3019 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3020
3021 assert!(lock.acquire().await.is_err());
3022 }
3023
3024 #[tokio::test]
3025 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
3026 use runner_manager_platform::paths::AppPaths;
3027 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, GuestRecoveryConfig};
3028
3029 let local = tempfile::tempdir().unwrap();
3030 let shared = tempfile::tempdir().unwrap();
3031 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3032 paths.create_all().unwrap();
3033 GuestRecoveryConfig::new(shared.path().to_path_buf())
3034 .write(&paths)
3035 .unwrap();
3036 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3037 let guard = lock.acquire().await.unwrap();
3038 assert!(shared.path().join(FENCE_DIRECTORY).exists());
3039 drop(guard);
3040 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3041 }
3042
3043 /// A lock nobody can take.
3044 #[derive(Debug, Default)]
3045 struct HeldLock;
3046
3047 #[async_trait::async_trait]
3048 impl AllocationLock for HeldLock {
3049 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3050 Err(AllocationLockBusy)
3051 }
3052 }
3053
3054 /// Everything one test needs, wired together.
3055 struct Harness {
3056 launcher: Arc<FakeLauncher>,
3057 demand: Arc<FakeDemand>,
3058 events: Arc<EventLog>,
3059 reconciler: Reconciler,
3060 }
3061
3062 impl Harness {
3063 fn build(
3064 host: Host,
3065 launcher: Arc<FakeLauncher>,
3066 demand: Arc<FakeDemand>,
3067 lock: Arc<dyn AllocationLock>,
3068 ) -> Self {
3069 let events = Arc::new(EventLog::new());
3070 let reconciler = Reconciler::new(
3071 host,
3072 ReconcilerPorts {
3073 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
3074 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
3075 lock,
3076 directory: Arc::new(FakeDirectory::default()),
3077 clock: Arc::new(FakeClock::default()),
3078 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
3079 events: Arc::clone(&events) as Arc<dyn EventSink>,
3080 },
3081 );
3082 Self {
3083 launcher,
3084 demand,
3085 events,
3086 reconciler,
3087 }
3088 }
3089
3090 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
3091 let launcher = Arc::new(FakeLauncher::new());
3092 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
3093 Self::build(
3094 host_with(capacity),
3095 launcher,
3096 demand,
3097 Arc::new(InProcessAllocationLock::new()),
3098 )
3099 }
3100 }
3101
3102 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
3103 let outcome = state.is_terminal().then(|| match state {
3104 AttemptState::Failed => {
3105 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
3106 }
3107 AttemptState::Orphaned => AttemptOutcome::Orphaned,
3108 _ => AttemptOutcome::CompletedJob,
3109 });
3110 RunnerAttempt::from_persisted(PersistedAttempt {
3111 id: AttemptId::from_u128(id),
3112 policy_id: PolicyId::from_u128(policy),
3113 github_runner_id: None,
3114 state,
3115 outcome,
3116 process_id: None,
3117 runtime_path: "runtime/p/a".into(),
3118 workspace_kind: WorkspaceKind::Ephemeral,
3119 workspace_slot: None,
3120 created_at: fixtures::created_at(),
3121 terminal_at: state.is_terminal().then(fixtures::created_at),
3122 last_state_change_at: fixtures::created_at(),
3123 })
3124 .expect("a state/outcome pair the domain accepts")
3125 }
3126
3127 /// A concluded attempt carrying a specific outcome.
3128 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
3129 RunnerAttempt::from_persisted(PersistedAttempt {
3130 id: AttemptId::from_u128(id),
3131 policy_id: PolicyId::from_u128(policy),
3132 github_runner_id: None,
3133 state: outcome.terminal_state(),
3134 outcome: Some(outcome),
3135 process_id: None,
3136 runtime_path: "runtime/p/a".into(),
3137 workspace_kind: WorkspaceKind::Ephemeral,
3138 workspace_slot: None,
3139 created_at: fixtures::created_at(),
3140 terminal_at: Some(fixtures::created_at()),
3141 last_state_change_at: fixtures::created_at(),
3142 })
3143 .expect("a state/outcome pair the domain accepts")
3144 }
3145
3146 // =======================================================================
3147 // The in-flight term: the single most likely way this task goes wrong
3148 // =======================================================================
3149
3150 /// `e1`'s Definition of Done, verbatim: *"A job that remains `queued` across
3151 /// three consecutive polls while its attempt is `starting` yields exactly
3152 /// one attempt — the test fails if the in-flight term is dropped from the
3153 /// formula."*
3154 ///
3155 /// `b1` tests the arithmetic underneath this
3156 /// (`capacity::tests::the_same_queued_job_on_two_polls_yields_one_attempt_
3157 /// not_two`). What *this* test covers is the only way `e1` can drop the
3158 /// term without touching `b1` at all: handing the allocator an attempt set
3159 /// that is not the one the host holds.
3160 ///
3161 /// # This was measured, not assumed, and the first measurement was worse
3162 /// # than the failure it was looking for
3163 ///
3164 /// Replacing `self.launcher.attempts().await` in
3165 /// [`Reconciler::start_runners`] with `Vec::new()` compiles and runs. Before
3166 /// that function carried a budget, this test did not go red — it **never
3167 /// returned**: every grant looked like the first, so the pass started
3168 /// runners forever inside poll 1. That is the runaway-runner failure exactly
3169 /// as an operator would meet it, and it is why the budget exists.
3170 ///
3171 /// With the budget in place the same injection fails cleanly and says what
3172 /// happened: `poll 2 … left: 2, right: 1`. Both measurements were run
3173 /// before this assertion was written.
3174 #[tokio::test]
3175 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
3176 let mut harness = Harness::simple(4, 1, "acme/app");
3177 let policy = policy(1, "acme/app", 4);
3178
3179 for poll in 1..=3 {
3180 let report = harness
3181 .reconciler
3182 .reconcile(std::slice::from_ref(&policy))
3183 .await;
3184 assert_eq!(
3185 harness.launcher.launches(),
3186 1,
3187 "poll {poll} started another runner for a job already being served; the \
3188 `- active_owned_runners` term reached `HostAllocator` as a set this host \
3189 does not hold"
3190 );
3191 assert_eq!(report.allocations.len(), 1);
3192 let allocation = &report.allocations[0];
3193 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
3194 if poll == 1 {
3195 assert_eq!(allocation.to_start, 1);
3196 assert_eq!(report.started, 1);
3197 } else {
3198 assert_eq!(allocation.active_owned, 1, "poll {poll}");
3199 assert_eq!(allocation.to_start, 0, "poll {poll}");
3200 assert_eq!(report.started, 0, "poll {poll}");
3201 }
3202 }
3203 assert_eq!(harness.launcher.live_count(), 1);
3204 }
3205
3206 /// The other half of the measurement above: the loop must terminate even
3207 /// when the attempt set never catches up with it.
3208 ///
3209 /// Dropping the in-flight term made
3210 /// `three_polls_of_one_still_queued_run_yield_exactly_one_attempt` hang
3211 /// rather than fail — the loop had one stopping condition and it was the one
3212 /// the bug removed. A launcher whose journal write has not landed presents
3213 /// exactly the same shape without any bug at all, so the budget in
3214 /// [`Reconciler::start_runners`] bounds the pass structurally. This is what
3215 /// asserts the bound is really there.
3216 #[tokio::test]
3217 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
3218 let launcher = Arc::new(FakeLauncher::forgetful());
3219 let mut harness = Harness::build(
3220 host_with(64),
3221 Arc::clone(&launcher),
3222 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
3223 Arc::new(InProcessAllocationLock::new()),
3224 );
3225
3226 let report = harness
3227 .reconciler
3228 .reconcile(&[policy(1, "acme/app", 8)])
3229 .await;
3230
3231 assert_eq!(
3232 report.started, 3,
3233 "the pass is bounded by the grant it was given, not by the attempt set catching \
3234 up with it"
3235 );
3236 assert_eq!(launcher.launches(), 3);
3237 assert!(
3238 launcher.snapshot().is_empty(),
3239 "the launcher never recorded anything, which is the whole point of the fixture"
3240 );
3241 }
3242
3243 /// The host-wide ceiling must hold across policies even when the launcher
3244 /// lags, and the per-policy budget alone does not reach that case.
3245 ///
3246 /// Review found this, with this file's own `forgetful` fixture and one more
3247 /// policy: the budget bounds *each policy's* loop to its own first grant,
3248 /// but policy B's first grant is computed from a set that does not yet
3249 /// contain policy A's launches, so B's bound is itself too large. Two
3250 /// policies on a host of three started **six** runners --
3251 /// `host_capacity=3, started=6, launches=6` -- with the lock held correctly
3252 /// throughout. Serialisation was never the problem; the arithmetic under it
3253 /// was reading a stale set.
3254 #[tokio::test]
3255 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
3256 let launcher = Arc::new(FakeLauncher::forgetful());
3257 let demand = Arc::new(FakeDemand::default());
3258 demand.set_for(
3259 &ScaleTarget::repository("acme/left").unwrap(),
3260 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
3261 );
3262 demand.set_for(
3263 &ScaleTarget::repository("acme/right").unwrap(),
3264 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
3265 );
3266 let mut harness = Harness::build(
3267 host_with(3),
3268 Arc::clone(&launcher),
3269 demand,
3270 Arc::new(InProcessAllocationLock::new()),
3271 );
3272
3273 let report = harness
3274 .reconciler
3275 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
3276 .await;
3277
3278 assert_eq!(
3279 report.started, 3,
3280 "host_capacity is 3 and two policies each allowed 3 started {} runners \
3281 between them; the second policy's grant was computed from a set that did \
3282 not yet contain the first policy's launches",
3283 report.started
3284 );
3285 assert_eq!(launcher.launches(), 3);
3286 }
3287
3288 /// Finding 1: an attempt set that cannot be read is not an empty one.
3289 ///
3290 /// `attempts()` used to be infallible, which left `e3` — reading a journal
3291 /// off a disk — a choice between panicking and answering `vec![]`. The
3292 /// second is silent and catastrophic: an empty set is indistinguishable from
3293 /// an idle host, so a transient read failure reads as "nothing is running"
3294 /// and the pass allocates the whole machine for jobs already being served.
3295 ///
3296 /// The contrast is the assertion. Identical host, identical demand,
3297 /// identical policy; the only difference is whether the launcher can answer.
3298 #[tokio::test]
3299 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
3300 let launcher = Arc::new(FakeLauncher::new());
3301 let mut harness = Harness::build(
3302 host_with(8),
3303 Arc::clone(&launcher),
3304 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
3305 Arc::new(InProcessAllocationLock::new()),
3306 );
3307 let policy = policy(1, "acme/app", 8);
3308
3309 launcher.fail_attempts(true);
3310 let unreadable = harness
3311 .reconciler
3312 .reconcile(std::slice::from_ref(&policy))
3313 .await;
3314
3315 assert_eq!(
3316 unreadable.started, 0,
3317 "nothing may be decided from a set that was not read"
3318 );
3319 assert_eq!(launcher.launches(), 0);
3320 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
3321 assert!(
3322 unreadable.allocations.is_empty(),
3323 "no allocation is reported either: there was no set to compute one from, and \
3324 an allocation of zero would claim a decision nobody made"
3325 );
3326 assert!(harness.events.count_of("attempts_unreadable") > 0);
3327
3328 // The same everything, with a launcher that can answer.
3329 launcher.fail_attempts(false);
3330 let readable = harness
3331 .reconciler
3332 .reconcile(std::slice::from_ref(&policy))
3333 .await;
3334 assert_eq!(
3335 readable.started, 4,
3336 "the difference between the two passes is only whether the set could be read"
3337 );
3338 assert_eq!(readable.attempts_unreadable, 0);
3339 }
3340
3341 /// Finding 3: a cleanup that can never succeed was an invisible permanent
3342 /// loop.
3343 ///
3344 /// `if …clean(…).await.is_ok()` had no `else`, so a runtime directory that
3345 /// could not be removed was retried on every poll with no event, no counter
3346 /// and no report field. It wedges no capacity — a terminal attempt already
3347 /// stopped counting — but `clean` returns a `Result` precisely so the caller
3348 /// can say something, and this module's organising principle is the things
3349 /// that go wrong silently.
3350 #[tokio::test]
3351 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
3352 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
3353 1,
3354 1,
3355 AttemptOutcome::ExitedIdleWithoutWork,
3356 )]));
3357 let mut harness = Harness::build(
3358 host_with(4),
3359 Arc::clone(&launcher),
3360 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3361 Arc::new(InProcessAllocationLock::new()),
3362 );
3363
3364 let report = harness
3365 .reconciler
3366 .reconcile(&[policy(1, "acme/app", 4)])
3367 .await;
3368
3369 assert_eq!(report.cleaned, 0);
3370 assert_eq!(report.clean_failures, 1);
3371 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
3372 assert_eq!(
3373 harness.events.count_of("attempt_cleaned"),
3374 0,
3375 "and it is not reported as cleaned"
3376 );
3377 assert_eq!(
3378 launcher.snapshot().len(),
3379 1,
3380 "the attempt is still there, so the retry is real -- what changed is that it \
3381 is no longer silent"
3382 );
3383
3384 // The reason is the variant, never the detail: the fixture's failure
3385 // carries free text and none of it reaches the event.
3386 let reasons: Vec<&'static str> = harness
3387 .events
3388 .events()
3389 .into_iter()
3390 .filter_map(|event| match event {
3391 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
3392 _ => None,
3393 })
3394 .collect();
3395 assert_eq!(reasons, vec!["other"]);
3396 }
3397
3398 /// N2: an unreadable attempt set makes a scale-down inconclusive, not empty.
3399 ///
3400 /// Making `attempts()` fallible closed this everywhere the allocation path
3401 /// touches, and left it open in the one place that returns a different type:
3402 /// `scale_down` answered `ScaleDownReport::default()`, which is all zeros
3403 /// and byte-for-byte identical to an idle host with nothing to reclaim. The
3404 /// two mean opposite things — "there was nothing to remove" against "we
3405 /// cannot see what there was".
3406 ///
3407 /// Measured as the sibling test measures it: identical host, identical
3408 /// attempts, identical policy, and the only difference is whether the
3409 /// launcher can answer.
3410 #[tokio::test]
3411 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
3412 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3413 attempt_in(AttemptState::Busy, 1, 1),
3414 concluded(2, 1, AttemptOutcome::CompletedJob),
3415 ]));
3416 let harness = Harness::build(
3417 host_with(4),
3418 Arc::clone(&launcher),
3419 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3420 Arc::new(InProcessAllocationLock::new()),
3421 );
3422 let policy = policy(1, "acme/app", 4);
3423
3424 launcher.fail_attempts(true);
3425 let blind = harness.reconciler.scale_down(&policy).await;
3426
3427 assert!(!blind.is_conclusive(), "the machine was never read");
3428 assert_ne!(
3429 blind,
3430 ScaleDownReport::default(),
3431 "a scale-down that could not see the host must not be equal to one that saw \
3432 an idle host; that equality is the whole finding"
3433 );
3434 assert_eq!(blind.removed, 0);
3435 assert_eq!(
3436 blind.refused_busy, 0,
3437 "and this zero means `unknown`, not `none`"
3438 );
3439 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
3440
3441 // The same everything, with a launcher that can answer.
3442 launcher.fail_attempts(false);
3443 let seeing = harness.reconciler.scale_down(&policy).await;
3444
3445 assert!(seeing.is_conclusive());
3446 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
3447 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
3448 assert_ne!(
3449 seeing, blind,
3450 "the difference between the two is only whether the set could be read"
3451 );
3452 }
3453
3454 /// Finding 7: the lower arm of the clamp, driven through the reconciler.
3455 ///
3456 /// `demand_below_min_capacity_starts_nothing_in_v1` runs `demand = 0`
3457 /// against `min_capacity = 0`, which is *at* the floor and never raises
3458 /// `desired` — the assertion held for a reason unrelated to the boundary it
3459 /// named. D7 fixes `min` at 0 for v1, but `AutoscaleConfig::new` accepts
3460 /// `min > 0` today, so the path is representable and was undriven.
3461 #[tokio::test]
3462 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
3463 let mut warm = ScalePolicy::new(
3464 PolicyId::from_u128(1),
3465 ScaleTarget::repository("acme/app").unwrap(),
3466 1,
3467 fixtures::HOST_ID,
3468 PolicyMode::autoscale(
3469 fixtures::routing_labels("home"),
3470 2,
3471 NonZeroU16::new(5).expect("non-zero"),
3472 )
3473 .expect("min <= max"),
3474 CachePolicy::default(),
3475 );
3476 warm.activate().expect("pending -> active");
3477
3478 let mut harness = Harness::simple(8, 0, "acme/app");
3479 let report = harness.reconciler.reconcile(&[warm]).await;
3480
3481 assert_eq!(
3482 report.allocations[0].demand, 0,
3483 "GitHub reported no queued runs"
3484 );
3485 assert_eq!(
3486 report.allocations[0].desired, 2,
3487 "min_capacity raised the target above demand"
3488 );
3489 assert_eq!(
3490 report.allocations[0].limiting_factor,
3491 LimitingFactor::MinCapacity
3492 );
3493 assert_eq!(report.started, 2, "and two runners were actually started");
3494 assert_eq!(harness.launcher.live_count(), 2);
3495 }
3496
3497 // =======================================================================
3498 // Capacity, at the boundaries
3499 // =======================================================================
3500
3501 #[tokio::test]
3502 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
3503 let mut harness = Harness::simple(100, 10, "acme/app");
3504 let report = harness
3505 .reconciler
3506 .reconcile(&[policy(1, "acme/app", 3)])
3507 .await;
3508
3509 assert_eq!(report.allocations[0].demand, 10);
3510 assert_eq!(
3511 report.allocations[0].desired, 3,
3512 "max_capacity beats demand"
3513 );
3514 assert_eq!(report.started, 3);
3515 assert_eq!(
3516 report.allocations[0].limiting_factor,
3517 LimitingFactor::MaxCapacity
3518 );
3519 }
3520
3521 #[tokio::test]
3522 async fn demand_below_min_capacity_starts_nothing_in_v1() {
3523 // D7 fixes `min_capacity` at 0, so "below the floor" is "no demand", and
3524 // the product requirement it satisfies is "no idle runners when unused".
3525 let mut harness = Harness::simple(8, 0, "acme/app");
3526 let report = harness
3527 .reconciler
3528 .reconcile(&[policy(1, "acme/app", 4)])
3529 .await;
3530
3531 assert_eq!(report.allocations[0].desired, 0);
3532 assert_eq!(report.started, 0);
3533 assert!(report.starts_nothing());
3534 }
3535
3536 #[tokio::test]
3537 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
3538 let policy = policy(1, "octo/repo", 1);
3539 let previous = attempt_in(AttemptState::Starting, 41, 1);
3540 let intent = ReplacementIntent {
3541 policy: policy.id,
3542 previous_attempt: previous.id,
3543 operation: "exit_before_acceptance_replacement",
3544 };
3545 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
3546 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
3547 let mut harness = Harness::build(
3548 host_with(1),
3549 Arc::clone(&launcher),
3550 demand,
3551 Arc::new(InProcessAllocationLock::new()),
3552 );
3553
3554 let report = harness.reconciler.reconcile(&[policy]).await;
3555
3556 assert_eq!(report.replacement_intents, 1);
3557 assert_eq!(report.started, 1);
3558 assert_eq!(launcher.launches(), 1);
3559 assert_eq!(launcher.live_count(), 1);
3560 }
3561
3562 #[tokio::test]
3563 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
3564 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3565 attempt_in(AttemptState::Busy, 1, 1),
3566 attempt_in(AttemptState::Busy, 2, 1),
3567 ]));
3568 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
3569 let mut harness = Harness::build(
3570 host_with(2),
3571 launcher,
3572 demand,
3573 Arc::new(InProcessAllocationLock::new()),
3574 );
3575
3576 let report = harness
3577 .reconciler
3578 .reconcile(&[policy(1, "acme/app", 2)])
3579 .await;
3580 assert_eq!(report.started, 0);
3581 assert_eq!(report.allocations[0].headroom_before, 0);
3582 assert_eq!(harness.launcher.launches(), 0);
3583 }
3584
3585 #[tokio::test]
3586 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
3587 // Four slots held by *another* policy on a host of six: this policy is
3588 // allowed five and gets two.
3589 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3590 attempt_in(AttemptState::Busy, 1, 99),
3591 attempt_in(AttemptState::Busy, 2, 99),
3592 attempt_in(AttemptState::Idle, 3, 99),
3593 attempt_in(AttemptState::Starting, 4, 99),
3594 ]));
3595 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
3596 let mut harness = Harness::build(
3597 host_with(6),
3598 launcher,
3599 demand,
3600 Arc::new(InProcessAllocationLock::new()),
3601 );
3602
3603 let report = harness
3604 .reconciler
3605 .reconcile(&[policy(1, "acme/app", 5)])
3606 .await;
3607 assert_eq!(
3608 report.allocations[0].desired, 5,
3609 "its own ceiling allows five"
3610 );
3611 assert_eq!(report.started, 2, "the host has two slots free");
3612 assert_eq!(
3613 report.allocations[0].limiting_factor,
3614 LimitingFactor::HostCapacity
3615 );
3616 assert_eq!(harness.launcher.live_count(), 6);
3617 }
3618
3619 #[tokio::test]
3620 async fn the_idle_host_assertion_holds() {
3621 // "No demand means zero runner processes and zero attempts out of
3622 // terminal state."
3623 let mut harness = Harness::simple(8, 0, "acme/app");
3624 let report = harness
3625 .reconciler
3626 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
3627 .await;
3628
3629 assert_eq!(report.started, 0);
3630 assert_eq!(harness.launcher.launches(), 0);
3631 assert!(harness.launcher.snapshot().is_empty());
3632 assert_eq!(
3633 harness
3634 .launcher
3635 .snapshot()
3636 .iter()
3637 .filter(|a| !a.is_terminal())
3638 .count(),
3639 0
3640 );
3641 }
3642
3643 // =======================================================================
3644 // D9 under concurrency: the other silent failure
3645 // =======================================================================
3646
3647 /// `e1`'s Definition of Done: *"Two policies on one host with
3648 /// `host_capacity` smaller than the sum of their `max_capacity` values never
3649 /// exceed `host_capacity` under concurrent reconciliation — asserted under
3650 /// simulated lock contention, with no duplicate runners."*
3651 ///
3652 /// The contention is simulated by [`FakeLauncher::with_yields`], which puts
3653 /// executor yield points *between* the launcher reading the attempt set and
3654 /// recording the new one. Without serialisation both tasks read a headroom
3655 /// of three and both spend it.
3656 ///
3657 /// # Watched failing before it was made to pass
3658 ///
3659 /// Granting from this lock without taking the inner mutex — leaving every
3660 /// counter and every yield point exactly as they are — fails this assertion
3661 /// with `left: 4, right: 3`: four runners on a host of three, from two
3662 /// policies each individually inside their own `max_capacity`. The control
3663 /// test below keeps that measurement standing permanently by running the
3664 /// same body against [`NoLock`].
3665 #[tokio::test(flavor = "current_thread")]
3666 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
3667 let lock = Arc::new(CountingLock::new());
3668 let (launches, live) =
3669 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>).await;
3670
3671 assert_eq!(
3672 launches, 3,
3673 "the sum across policies must never exceed host_capacity, and each policy is \
3674 individually within its own max_capacity of 3"
3675 );
3676 assert_eq!(live, 3, "and no duplicate runner survived the race");
3677 assert_eq!(
3678 lock.peak(),
3679 1,
3680 "the allocation lock had one holder at a time; without that the read of the \
3681 headroom and the creation of the runtime are not atomic"
3682 );
3683 assert!(
3684 (3..=5).contains(&lock.acquisitions()),
3685 "the lock is taken before *each* runtime, not once per pass: three runtimes \
3686 means at least three holds, and at most one further hold per policy to \
3687 discover the host filled up underneath it. It was taken {} times",
3688 lock.acquisitions()
3689 );
3690 }
3691
3692 /// The control for the test above: the same body with the lock removed.
3693 ///
3694 /// It exists so that the assertion above cannot pass vacuously. If a future
3695 /// change makes the unserialised path safe by accident — a launcher that
3696 /// records synchronously, say — this test goes red and says so, rather than
3697 /// the other one silently proving nothing.
3698 #[tokio::test(flavor = "current_thread")]
3699 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
3700 let (launches, _) =
3701 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>).await;
3702
3703 assert!(
3704 launches > 3,
3705 "with no serialisation both policies must be able to spend the same headroom; \
3706 they started {launches} runners on a host of 3. If this is ever 3, the \
3707 contention window closed and `two_policies_reconciling_concurrently_never_\
3708 exceed_host_capacity` has stopped proving anything"
3709 );
3710 }
3711
3712 /// Two policies, one host of three, each allowed three, reconciled at once.
3713 ///
3714 /// Returns `(launches, live attempts)`.
3715 async fn two_policies_concurrently(lock: Arc<dyn AllocationLock>) -> (usize, usize) {
3716 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
3717 let host = host_with(3);
3718
3719 let mut left = Harness::build(
3720 host.clone(),
3721 Arc::clone(&launcher),
3722 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
3723 Arc::clone(&lock),
3724 )
3725 .reconciler;
3726 let mut right = Harness::build(
3727 host,
3728 Arc::clone(&launcher),
3729 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
3730 Arc::clone(&lock),
3731 )
3732 .reconciler;
3733
3734 let a = policy(1, "acme/left", 3);
3735 let b = policy(2, "acme/right", 3);
3736
3737 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
3738 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
3739 let (_, _) = (left.await.unwrap(), right.await.unwrap());
3740
3741 (launcher.launches(), launcher.live_count())
3742 }
3743
3744 // =======================================================================
3745 // D19: monitor-only
3746 // =======================================================================
3747
3748 /// `e1`'s Definition of Done: *"A `MonitorOnly` policy under maximum demand
3749 /// starts zero runners and issues no demand request."*
3750 ///
3751 /// Driven through `c4`'s real gateway fake so that "issued no demand
3752 /// request" is asserted against the thing that would have issued it, rather
3753 /// than against this module's own bookkeeping. `FakeGithub` records every
3754 /// call it is asked to make.
3755 #[tokio::test]
3756 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
3757 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
3758 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
3759 let launcher = Arc::new(FakeLauncher::new());
3760 let events = Arc::new(EventLog::new());
3761
3762 let mut reconciler = Reconciler::new(
3763 host_with(10),
3764 ReconcilerPorts {
3765 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
3766 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
3767 lock: Arc::new(InProcessAllocationLock::new()),
3768 directory: Arc::new(FakeDirectory::default()),
3769 clock: Arc::new(FakeClock::default()),
3770 jitter: Arc::new(NoJitter),
3771 events: Arc::clone(&events) as Arc<dyn EventSink>,
3772 },
3773 );
3774
3775 let monitor = fixtures::policy()
3776 .id(PolicyId::from_u128(1))
3777 .repository("acme/app")
3778 .monitor_only()
3779 .active()
3780 .build();
3781
3782 let report = reconciler.reconcile(&[monitor]).await;
3783
3784 assert_eq!(report.started, 0);
3785 assert_eq!(launcher.launches(), 0);
3786 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
3787 assert_eq!(
3788 report.demand_requests, 0,
3789 "a monitor-only policy spends nothing from the shared hourly ceiling"
3790 );
3791 assert!(
3792 gateway.gateway().calls().is_empty(),
3793 "a monitor-only policy issued a demand request: {:?}",
3794 gateway.gateway().calls()
3795 );
3796 assert_eq!(events.count_of("monitor_only_skipped"), 1);
3797 assert_eq!(
3798 events.count_of("demand_observed"),
3799 0,
3800 "and it contributed no demand"
3801 );
3802 }
3803
3804 /// D19 says a monitor-only policy is *"skipped entirely by
3805 /// reconciliation"*, and "entirely" is the load-bearing word once two
3806 /// policies share a target.
3807 ///
3808 /// This defect was found by review rather than by the test above, which
3809 /// cannot see it: there, the monitor-only policy is the *only* policy, so
3810 /// nobody polls its target and the lookup finds nothing. Give it a
3811 /// repository an autoscale policy already polls and the lookup succeeds —
3812 /// and the monitor-only policy was then allocated for and had a demand
3813 /// observation emitted on its behalf. It still started nothing, because
3814 /// `may_start_runners` is false for it and `HostAllocator` refuses it by
3815 /// name, so no ceiling was ever at risk. It simply was not skipped.
3816 ///
3817 /// Removing the `owns_runners` guard from the allocation loop was watched
3818 /// failing this test before it was restored:
3819 /// `a monitor-only policy was allocated for: [… limiting_factor:
3820 /// MonitorOnly]`.
3821 #[tokio::test]
3822 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
3823 let lock = Arc::new(CountingLock::new());
3824 let launcher = Arc::new(FakeLauncher::new());
3825 let events = Arc::new(EventLog::new());
3826 let mut reconciler = Reconciler::new(
3827 host_with(4),
3828 ReconcilerPorts {
3829 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
3830 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
3831 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
3832 directory: Arc::new(FakeDirectory::default()),
3833 clock: Arc::new(FakeClock::default()),
3834 jitter: Arc::new(NoJitter),
3835 events: Arc::clone(&events) as Arc<dyn EventSink>,
3836 },
3837 );
3838
3839 let watcher = fixtures::policy()
3840 .id(PolicyId::from_u128(2))
3841 .repository("acme/app")
3842 .monitor_only()
3843 .active()
3844 .build();
3845
3846 let report = reconciler
3847 .reconcile(&[policy(1, "acme/app", 4), watcher])
3848 .await;
3849
3850 assert_eq!(report.started, 2, "the autoscale policy is served normally");
3851 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
3852 assert_eq!(
3853 events.count_of("demand_observed"),
3854 1,
3855 "the demand observation belongs to the autoscale policy alone"
3856 );
3857 assert!(
3858 report
3859 .allocations
3860 .iter()
3861 .all(|a| a.policy_id == PolicyId::from_u128(1)),
3862 "a monitor-only policy was allocated for: {:?}",
3863 report.allocations
3864 );
3865 assert_eq!(
3866 lock.acquisitions(),
3867 2,
3868 "one hold per runtime created, and none on behalf of the monitor-only policy. \
3869 It was three before the budget was checked at the top of the loop rather than \
3870 after the re-read, which cost every policy a surplus hold to discover there \
3871 was nothing left to grant"
3872 );
3873 }
3874
3875 #[tokio::test]
3876 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
3877 // The specification requires this to be asserted rather than deduced
3878 // from `max_capacity` being absent. `HostAllocator` reports it by name,
3879 // and this loop reaches that arm through `owns_runners`, which is a
3880 // question about the mode.
3881 let monitor = fixtures::monitor_only_policy();
3882 assert!(!monitor.owns_runners());
3883 assert_eq!(monitor.max_capacity(), None);
3884
3885 let host = host_with(10);
3886 let attempts: Vec<RunnerAttempt> = Vec::new();
3887 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
3888 let allocation = allocator.allocate(&monitor, 10_000);
3889 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
3890 assert_eq!(allocation.to_start, 0);
3891 assert_eq!(
3892 allocator.headroom(),
3893 10,
3894 "and it consumes no headroom, so an autoscale policy on the same host is \
3895 unaffected"
3896 );
3897 }
3898
3899 // =======================================================================
3900 // The surplus runner, and busy protection
3901 // =======================================================================
3902
3903 /// `e1`'s Definition of Done: *"A surplus attempt that receives no job
3904 /// reaches a terminal state recorded as an idle exit, is cleaned, and is not
3905 /// reported as a failure."*
3906 #[tokio::test]
3907 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
3908 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3909 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
3910 concluded(
3911 2,
3912 1,
3913 AttemptOutcome::failed(FailureReason::JitRequestFailed),
3914 ),
3915 concluded(3, 1, AttemptOutcome::CompletedJob),
3916 ]));
3917 let mut harness = Harness::build(
3918 host_with(4),
3919 Arc::clone(&launcher),
3920 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3921 Arc::new(InProcessAllocationLock::new()),
3922 );
3923
3924 let report = harness
3925 .reconciler
3926 .reconcile(&[policy(1, "acme/app", 4)])
3927 .await;
3928
3929 assert_eq!(report.cleaned, 3);
3930 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
3931 assert_eq!(
3932 report.failures, 1,
3933 "only the failed attempt is a failure; the idle exit and the completed job are \
3934 not"
3935 );
3936 assert_eq!(launcher.cleaned().len(), 3);
3937 assert!(launcher.snapshot().is_empty());
3938
3939 let cleaned: Vec<OutcomeKind> = harness
3940 .events
3941 .events()
3942 .into_iter()
3943 .filter_map(|event| match event {
3944 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
3945 _ => None,
3946 })
3947 .collect();
3948 assert!(cleaned.contains(&OutcomeKind::IdleExit));
3949 assert!(
3950 !OutcomeKind::IdleExit.is_failure(),
3951 "an idle exit rendered as a failure sends an operator hunting a fault that does \
3952 not exist"
3953 );
3954 }
3955
3956 /// `e1`'s Definition of Done: *"A scale-down request with a busy attempt
3957 /// removes nothing and leaves the attempt `busy`."*
3958 #[tokio::test]
3959 async fn scale_down_removes_nothing_from_a_busy_attempt() {
3960 let busy = attempt_in(AttemptState::Busy, 1, 1);
3961 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3962 busy.clone(),
3963 attempt_in(AttemptState::Starting, 2, 1),
3964 concluded(3, 1, AttemptOutcome::CompletedJob),
3965 ]));
3966 let harness = Harness::build(
3967 host_with(4),
3968 Arc::clone(&launcher),
3969 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3970 Arc::new(InProcessAllocationLock::new()),
3971 );
3972
3973 let report = harness
3974 .reconciler
3975 .scale_down(&policy(1, "acme/app", 4))
3976 .await;
3977
3978 assert_eq!(report.refused_busy, 1);
3979 assert_eq!(
3980 report.retained, 1,
3981 "the `starting` attempt is not ended either"
3982 );
3983 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
3984
3985 let after = launcher.snapshot();
3986 let still_busy = after
3987 .iter()
3988 .find(|a| a.id == AttemptId::from_u128(1))
3989 .expect("the busy attempt is still there");
3990 assert_eq!(
3991 still_busy.state(),
3992 AttemptState::Busy,
3993 "scale-down removed nothing from a runner that is executing a job, and left it \
3994 busy"
3995 );
3996 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
3997
3998 // And the domain refuses it from the other side too, by name, so a
3999 // future caller that tried anyway would not get a generic transition
4000 // error.
4001 let mut busy = busy;
4002 assert!(matches!(
4003 busy.clean(fixtures::created_at()),
4004 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
4005 ));
4006 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
4007 }
4008
4009 // =======================================================================
4010 // The schedule
4011 // =======================================================================
4012
4013 #[test]
4014 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
4015 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
4016 assert_eq!(RefreshInterval::MIN_SECS, 30);
4017 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
4018 assert!(
4019 RefreshInterval::from_secs(29).is_err(),
4020 "the floor is a rate-budget constraint, and a caller must not be able to write \
4021 a shorter interval at all"
4022 );
4023
4024 let mut schedule = PollSchedule::new(RefreshInterval::default());
4025 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
4026 assert_eq!(next.delay, Duration::from_secs(60));
4027 assert_eq!(next.pace, PollPace::Nominal);
4028
4029 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4030 assert_eq!(
4031 floored
4032 .next_poll(None, fixtures::created_at(), &NoJitter)
4033 .delay,
4034 Duration::from_secs(30)
4035 );
4036 }
4037
4038 /// `e1`'s Definition of Done: *"The poll interval … increases under a
4039 /// rate-limit signal, and the increase is visible in emitted state rather
4040 /// than silent."*
4041 #[test]
4042 fn a_rate_limit_increases_the_delay_and_names_itself() {
4043 let now = fixtures::created_at();
4044 let mut schedule = PollSchedule::new(RefreshInterval::default());
4045
4046 let limited = RefreshState::RateLimited(RateLimited {
4047 kind: RateLimitKind::Secondary,
4048 retry_after: Some(Duration::from_secs(300)),
4049 remaining: None,
4050 reset_unix_secs: None,
4051 });
4052 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4053
4054 assert_eq!(next.delay, Duration::from_secs(300));
4055 assert_eq!(
4056 next.pace,
4057 PollPace::RateLimited {
4058 kind: RateLimitKind::Secondary
4059 },
4060 "the increase is reported, never hidden"
4061 );
4062 assert!(next.pace.is_throttled());
4063 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
4064 }
4065
4066 /// Constraint on this task: *"Read `RefreshState::retry_delay` as an
4067 /// absolute floor, not an addend."*
4068 #[test]
4069 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
4070 let now = fixtures::created_at();
4071 let mut schedule = PollSchedule::new(RefreshInterval::default());
4072
4073 let limited = RefreshState::RateLimited(RateLimited {
4074 kind: RateLimitKind::Primary,
4075 retry_after: Some(Duration::from_secs(300)),
4076 remaining: Some(0),
4077 reset_unix_secs: None,
4078 });
4079
4080 // Five successive answers, each carrying the window that is *left*.
4081 // An addend would compound: 360, 660, 960 … and look like a hang.
4082 for _ in 0..5 {
4083 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4084 assert_eq!(
4085 next.delay,
4086 Duration::from_secs(300),
4087 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
4088 have compounded on every successive retry"
4089 );
4090 }
4091
4092 // And when GitHub asks for less than the interval, the interval wins:
4093 // the floor is never crossed to catch up.
4094 let brief = RefreshState::RateLimited(RateLimited {
4095 kind: RateLimitKind::Secondary,
4096 retry_after: Some(Duration::from_secs(5)),
4097 remaining: None,
4098 reset_unix_secs: None,
4099 });
4100 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
4101 assert_eq!(
4102 next.delay,
4103 Duration::from_secs(60),
4104 "a short `retry-after` may not drop the loop below its own interval"
4105 );
4106 assert!(next.delay >= PollSchedule::floor());
4107 }
4108
4109 #[test]
4110 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
4111 let now = fixtures::created_at();
4112 let states = [
4113 None,
4114 Some(RefreshState::Offline),
4115 Some(RefreshState::RateLimited(RateLimited {
4116 kind: RateLimitKind::Secondary,
4117 retry_after: Some(Duration::from_secs(1)),
4118 remaining: None,
4119 reset_unix_secs: None,
4120 })),
4121 Some(RefreshState::LockedOut {
4122 retry_after: Duration::from_secs(1),
4123 }),
4124 Some(RefreshState::Unauthorized),
4125 Some(RefreshState::Forbidden { message: None }),
4126 Some(RefreshState::Failed {
4127 status: Some(500),
4128 message: "server error".into(),
4129 }),
4130 Some(RefreshState::Cancelled),
4131 ];
4132
4133 for state in &states {
4134 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4135 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
4136 assert!(
4137 next.delay >= PollSchedule::floor(),
4138 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
4139 next.delay.as_millis()
4140 );
4141 }
4142 }
4143
4144 #[test]
4145 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
4146 let now = fixtures::created_at();
4147 let mut schedule = PollSchedule::new(RefreshInterval::default());
4148
4149 // Doubling, from the nominal interval.
4150 let mut previous = Duration::ZERO;
4151 for consecutive in 1..=6_u32 {
4152 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4153 assert_eq!(next.pace, PollPace::Offline { consecutive });
4154 assert!(
4155 next.delay >= previous,
4156 "the back-off must not shrink while the outage continues"
4157 );
4158 assert!(next.delay >= Duration::from_secs(60));
4159 previous = next.delay;
4160 }
4161 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
4162
4163 // Jitter widens the delay rather than narrowing it, so a fleet of
4164 // agents does not retry in lockstep.
4165 let mut jittered = PollSchedule::new(RefreshInterval::default());
4166 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4167 let mut jittered = PollSchedule::new(RefreshInterval::default());
4168 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
4169 assert!(full.delay > none.delay);
4170 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
4171
4172 // Recovery resets the run with no bookkeeping of its own.
4173 assert_eq!(schedule.consecutive_offline(), 6);
4174 let recovered = schedule.next_poll(None, now, &NoJitter);
4175 assert_eq!(recovered.pace, PollPace::Nominal);
4176 assert_eq!(recovered.delay, Duration::from_secs(60));
4177 assert_eq!(schedule.consecutive_offline(), 0);
4178 }
4179
4180 #[test]
4181 fn the_offline_state_states_the_twenty_four_hour_bound() {
4182 assert_eq!(
4183 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
4184 Duration::from_secs(24 * 60 * 60)
4185 );
4186
4187 let brief = OfflineState::new(1, Duration::from_secs(120));
4188 let rendered = brief.to_string();
4189 assert!(rendered.contains("24 hours"), "{rendered}");
4190 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
4191 assert!(!brief.has_outlasted_the_queue());
4192
4193 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
4194 assert!(long.has_outlasted_the_queue());
4195 assert!(
4196 long.to_string().contains("queued work has been lost"),
4197 "{long}"
4198 );
4199
4200 // "We cannot tell" is not "not yet".
4201 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
4202 }
4203
4204 // =======================================================================
4205 // Offline, end to end
4206 // =======================================================================
4207
4208 /// `e1`'s Definition of Done: *"An unreachable GitHub yields `offline`, zero
4209 /// new runners, retained existing processes, and jittered backoff; recovery
4210 /// resumes polling and does not double-count a job that was already being
4211 /// served."*
4212 #[tokio::test]
4213 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
4214 let live = vec![
4215 attempt_in(AttemptState::Busy, 1, 1),
4216 attempt_in(AttemptState::Starting, 2, 1),
4217 ];
4218 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
4219 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
4220 let mut harness = Harness::build(
4221 host_with(8),
4222 Arc::clone(&launcher),
4223 Arc::clone(&demand),
4224 Arc::new(InProcessAllocationLock::new()),
4225 );
4226 let policy = policy(1, "acme/app", 8);
4227
4228 let report = harness
4229 .reconciler
4230 .reconcile(std::slice::from_ref(&policy))
4231 .await;
4232
4233 assert!(report.is_offline());
4234 assert_eq!(report.started, 0, "no new runner during an outage");
4235 assert_eq!(launcher.launches(), 0);
4236 assert_eq!(
4237 launcher.snapshot(),
4238 live,
4239 "existing runner processes are retained, untouched"
4240 );
4241 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
4242 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
4243 assert!(report.next_poll.delay >= Duration::from_secs(60));
4244 let offline = report.offline_state().expect("an offline state to display");
4245 assert!(offline.to_string().contains("24 hours"));
4246
4247 // Recovery: the same job is still queued, and one runner is already
4248 // serving it. Demand is recomputed from the current queued set rather
4249 // than accumulated, so the reconnect starts nothing new.
4250 demand.set(PollOutcome::Ready(QueuedDemand::of(
4251 repo("acme/app"),
4252 jobs(2),
4253 )));
4254 let recovered = harness.reconciler.reconcile(&[policy]).await;
4255
4256 assert!(!recovered.is_offline());
4257 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
4258 assert_eq!(
4259 recovered.started, 0,
4260 "two queued runs, two attempts already in flight: a reconnect cannot \
4261 double-count work"
4262 );
4263 assert_eq!(recovered.allocations[0].active_owned, 2);
4264 assert_eq!(launcher.live_count(), 2);
4265 }
4266
4267 /// One unreachable target must not idle a whole host.
4268 ///
4269 /// The failure that decides the *schedule* is the most severe across every
4270 /// target polled — backing the whole loop off during an outage is the safe
4271 /// error, and `f3` runs one reconciler per target anyway, so in production
4272 /// the two are usually the same thing. What must not follow from that is
4273 /// refusing to serve a policy whose own target answered perfectly well, and
4274 /// the two are easy to conflate because the offline reading is sitting in
4275 /// the same map.
4276 #[tokio::test]
4277 async fn one_offline_target_does_not_stop_a_reachable_one() {
4278 let mut harness = Harness::simple(8, 0, "acme/app");
4279 harness.demand.set_for(
4280 &ScaleTarget::repository("acme/app").unwrap(),
4281 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
4282 );
4283 harness.demand.set_for(
4284 &ScaleTarget::repository("acme/broken").unwrap(),
4285 PollOutcome::Failed(RefreshState::Offline),
4286 );
4287
4288 let report = harness
4289 .reconciler
4290 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
4291 .await;
4292
4293 assert_eq!(
4294 report.started, 2,
4295 "the reachable target was served; an unreachable sibling repository must not \
4296 idle the host"
4297 );
4298 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
4299 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
4300
4301 // And the schedule takes the worse of the two.
4302 assert!(report.is_offline());
4303 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
4304 }
4305
4306 /// The 24-hour bound has to be reachable in production, not only in a unit
4307 /// test of [`OfflineState`].
4308 ///
4309 /// This was a real gap: the reconciler built its offline state from the
4310 /// back-off count alone, so `offline_for` was always `None` and
4311 /// [`OfflineState::has_outlasted_the_queue`] could never be true outside a
4312 /// test that constructed the value by hand. An operator whose agent had been
4313 /// offline for two days would have been told that an outage longer than 24
4314 /// hours *would* lose queued work, in the future tense, having already lost
4315 /// it.
4316 ///
4317 /// The elapsed time is measured from the first poll of the run rather than
4318 /// derived from the interval, because the back-off doubles and the two
4319 /// diverge immediately.
4320 #[tokio::test]
4321 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
4322 let clock = Arc::new(FakeClock::default());
4323 let launcher = Arc::new(FakeLauncher::new());
4324 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
4325 let mut reconciler = Reconciler::new(
4326 host_with(4),
4327 ReconcilerPorts {
4328 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4329 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4330 lock: Arc::new(InProcessAllocationLock::new()),
4331 directory: Arc::new(FakeDirectory::default()),
4332 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4333 jitter: Arc::new(NoJitter),
4334 events: Arc::new(NoEvents),
4335 },
4336 );
4337 let policy = policy(1, "acme/app", 4);
4338
4339 // The outage begins.
4340 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4341 let state = first.offline_state().expect("an offline state");
4342 assert!(!state.has_outlasted_the_queue());
4343 assert!(
4344 state
4345 .to_string()
4346 .contains("an outage longer than that loses"),
4347 "{state}"
4348 );
4349
4350 // A day and a minute later, still unreachable.
4351 clock.advance_secs(24 * 60 * 60 + 60);
4352 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4353 let state = later.offline_state().expect("an offline state");
4354 assert!(state.has_outlasted_the_queue());
4355 assert!(
4356 state.to_string().contains("queued work has been lost"),
4357 "{state}"
4358 );
4359 assert_eq!(launcher.launches(), 0, "and still nothing was started");
4360
4361 // Recovery closes the run, so a *later* outage measures from itself
4362 // rather than from the first one.
4363 demand.set(PollOutcome::Ready(QueuedDemand::of(
4364 repo("acme/app"),
4365 jobs(0),
4366 )));
4367 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4368 assert!(recovered.offline_state().is_none());
4369 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
4370
4371 demand.set(PollOutcome::Failed(RefreshState::Offline));
4372 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4373 assert!(
4374 !again
4375 .offline_state()
4376 .expect("an offline state")
4377 .has_outlasted_the_queue(),
4378 "a new outage must not inherit the age of the one before it"
4379 );
4380 }
4381
4382 /// Finding 5: the adapter, not the lock underneath it.
4383 ///
4384 /// `d1` covers `LockKind::Allocation` including a contended `acquire_at`
4385 /// with a wait. What that does not reach is this adapter: the
4386 /// `spawn_blocking` wrapper, the collapse of both a refused lock and a
4387 /// panicked blocking task into `AllocationLockBusy`, and — the one that
4388 /// would be silent — whether [`AllocationGuard`] really holds the
4389 /// `HostLock`, since dropping it is the only release there is. A guard that
4390 /// dropped the lock on the way out would make every acquisition succeed and
4391 /// the ceiling would hold by luck.
4392 ///
4393 /// The original disclosure said this needed a real filesystem and was
4394 /// therefore expensive. `AppPaths::rooted_at` plus `tempfile` — already a
4395 /// non-dev dependency of this crate — makes it about fifteen lines, so the
4396 /// reason was weaker than stated.
4397 #[tokio::test]
4398 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
4399 let root = tempfile::tempdir().expect("a temporary directory");
4400 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
4401 root.path(),
4402 ));
4403 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
4404
4405 let held = lock.acquire().await.expect("an uncontended lock is free");
4406 assert!(
4407 matches!(lock.acquire().await, Err(AllocationLockBusy)),
4408 "a second holder was admitted; on Unix the lock is per open file description \
4409 and on Windows the share mode denies write, so this must be refused even \
4410 from inside the same process"
4411 );
4412
4413 drop(held);
4414 let regained = lock.acquire().await;
4415 assert!(
4416 regained.is_ok(),
4417 "dropping the guard is the only release there is, so a guard that does not \
4418 hold the `HostLock` leaves it held forever"
4419 );
4420 }
4421
4422 #[test]
4423 fn tee_events_reaches_both_sinks() {
4424 // `f3` wires the log sink and `g2`'s buffer at once, and an event that
4425 // reached only one of them would be an activity view missing lines the
4426 // log file has, or the reverse.
4427 let left = Arc::new(EventLog::new());
4428 let right = Arc::new(EventLog::new());
4429 let tee = TeeEvents(
4430 Arc::clone(&left) as Arc<dyn EventSink>,
4431 Arc::clone(&right) as Arc<dyn EventSink>,
4432 );
4433
4434 tee.emit(LifecycleEvent::MonitorOnlySkipped {
4435 policy: PolicyId::from_u128(1),
4436 });
4437
4438 assert_eq!(left.count_of("monitor_only_skipped"), 1);
4439 assert_eq!(right.count_of("monitor_only_skipped"), 1);
4440 }
4441
4442 // =======================================================================
4443 // Budget: the repository list, and the per-target poll
4444 // =======================================================================
4445
4446 #[tokio::test]
4447 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
4448 let clock = Arc::new(FakeClock::default());
4449 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
4450 let cache = RepositoryCache::new(
4451 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
4452 Arc::clone(&clock) as Arc<dyn Clock>,
4453 RefreshInterval::default(),
4454 );
4455 let target = ScaleTarget::organization("acme").unwrap();
4456
4457 assert_eq!(
4458 cache.ttl(),
4459 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
4460 );
4461
4462 // Every poll inside the window reuses the list.
4463 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
4464 let scope = cache.scope_for(&target).await.unwrap();
4465 assert_eq!(scope.repositories().len(), 2);
4466 clock.advance_secs(60);
4467 }
4468 assert_eq!(
4469 directory.calls(),
4470 1,
4471 "re-listing an organization at demand-poll frequency is what exhausts the \
4472 shared request budget"
4473 );
4474 assert_eq!(cache.lookups(), 1);
4475
4476 // Past it, exactly one more.
4477 cache.scope_for(&target).await.unwrap();
4478 assert_eq!(directory.calls(), 2);
4479 }
4480
4481 #[tokio::test]
4482 async fn a_repository_target_never_consults_the_directory() {
4483 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
4484 let cache = RepositoryCache::new(
4485 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
4486 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
4487 RefreshInterval::default(),
4488 );
4489 let target = ScaleTarget::repository("acme/app").unwrap();
4490
4491 let scope = cache.scope_for(&target).await.unwrap();
4492 assert_eq!(scope.repositories(), &[repo("acme/app")]);
4493 assert_eq!(directory.calls(), 0);
4494 }
4495
4496 #[tokio::test]
4497 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
4498 // `04-subsystem-contracts.md` prices a *target*. A loop that spent per
4499 // policy would exceed the projection `f2` admitted the configuration
4500 // against, silently.
4501 let mut harness = Harness::simple(8, 4, "acme/app");
4502 let report = harness
4503 .reconciler
4504 .reconcile(&[policy(1, "acme/app", 2), policy(2, "acme/app", 2)])
4505 .await;
4506
4507 assert_eq!(harness.demand.polls().len(), 1);
4508 assert_eq!(
4509 report.demand_requests,
4510 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
4511 "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"
4512 );
4513 assert_eq!(report.started, 4, "and both policies still get their share");
4514 }
4515
4516 // =======================================================================
4517 // Failure paths
4518 // =======================================================================
4519
4520 #[tokio::test]
4521 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
4522 let launcher = Arc::new(FakeLauncher::new());
4523 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
4524 let mut harness = Harness::build(
4525 host_with(4),
4526 Arc::clone(&launcher),
4527 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4528 Arc::new(InProcessAllocationLock::new()),
4529 );
4530
4531 let report = harness
4532 .reconciler
4533 .reconcile(&[policy(1, "acme/app", 4)])
4534 .await;
4535 assert_eq!(report.started, 0);
4536 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
4537
4538 let failures: Vec<&'static str> = harness
4539 .events
4540 .events()
4541 .into_iter()
4542 .filter_map(|event| match event {
4543 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
4544 _ => None,
4545 })
4546 .collect();
4547 assert_eq!(failures, vec!["other"]);
4548 assert!(
4549 !failures[0].contains("ghp_"),
4550 "an event carried a `FailureReason::Other` detail verbatim"
4551 );
4552 }
4553
4554 #[tokio::test]
4555 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
4556 let launcher = Arc::new(FakeLauncher::new());
4557 let mut harness = Harness::build(
4558 host_with(4),
4559 Arc::clone(&launcher),
4560 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4561 Arc::new(HeldLock),
4562 );
4563
4564 let report = harness
4565 .reconciler
4566 .reconcile(&[policy(1, "acme/app", 4)])
4567 .await;
4568 assert_eq!(report.started, 0);
4569 assert_eq!(
4570 report.deferred, 3,
4571 "three runners were granted and none was created; `deferred` counts grants, \
4572 not policies -- it reported `1` when a policy that launched two of five and \
4573 then lost the lock had left three unstarted"
4574 );
4575 assert_eq!(launcher.launches(), 0);
4576 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
4577 assert!(
4578 harness
4579 .events
4580 .events()
4581 .iter()
4582 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
4583 "the event carries the same number the report does"
4584 );
4585 assert_eq!(
4586 report.allocations.len(),
4587 1,
4588 "the intent is still reported, so an operator staring at a queue sees why \
4589 nothing started"
4590 );
4591 }
4592
4593 #[tokio::test]
4594 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
4595 let mut harness = Harness::simple(8, 5, "acme/app");
4596
4597 let foreign = fixtures::policy()
4598 .id(PolicyId::from_u128(1))
4599 .repository("acme/app")
4600 .host(HostId::from_u128(0xdead))
4601 .autoscale("office", 4)
4602 .active()
4603 .build();
4604 let mut draining = policy(2, "acme/app", 4);
4605 draining.request_disable().unwrap();
4606
4607 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
4608
4609 assert_eq!(report.started, 0);
4610 assert_eq!(
4611 harness.demand.polls().len(),
4612 0,
4613 "neither can act on an answer"
4614 );
4615 let factors: Vec<LimitingFactor> = report
4616 .allocations
4617 .iter()
4618 .map(|a| a.limiting_factor)
4619 .collect();
4620 assert!(factors.contains(&LimitingFactor::ForeignHost));
4621 assert!(factors.contains(&LimitingFactor::NotReconciling));
4622 }
4623
4624 #[tokio::test]
4625 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
4626 // Polling a scope nobody chose would report a demand number for the
4627 // wrong set of repositories, which is worse than reporting nothing.
4628 #[derive(Debug)]
4629 struct BrokenDirectory;
4630
4631 #[async_trait::async_trait]
4632 impl RepositoryDirectory for BrokenDirectory {
4633 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
4634 Err(InventoryError::Cancelled)
4635 }
4636 }
4637
4638 let launcher = Arc::new(FakeLauncher::new());
4639 let events = Arc::new(EventLog::new());
4640 let mut reconciler = Reconciler::new(
4641 host_with(4),
4642 ReconcilerPorts {
4643 demand: Arc::new(FakeDemand::default()),
4644 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4645 lock: Arc::new(InProcessAllocationLock::new()),
4646 directory: Arc::new(BrokenDirectory),
4647 clock: Arc::new(FakeClock::default()),
4648 jitter: Arc::new(NoJitter),
4649 events: Arc::clone(&events) as Arc<dyn EventSink>,
4650 },
4651 );
4652
4653 let org_policy = fixtures::policy()
4654 .id(PolicyId::from_u128(1))
4655 .organization("acme")
4656 .autoscale("home", 4)
4657 .active()
4658 .build();
4659
4660 let report = reconciler.reconcile(&[org_policy]).await;
4661 assert_eq!(report.started, 0);
4662 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
4663 assert_eq!(events.count_of("target_unreadable"), 1);
4664 }
4665
4666 // =======================================================================
4667 // What the events may carry
4668 // =======================================================================
4669
4670 /// One value of every [`LifecycleEvent`] variant.
4671 ///
4672 /// Hand-written, and what keeps it honest is the wildcard-free `match` in
4673 /// [`LifecycleEvent::name`]: adding a variant stops that compiling and puts
4674 /// the author here. The same residual `b1` records for `FailureReason::ALL`
4675 /// applies — an author who writes the `name` arm and forgets this list gets
4676 /// a green suite with the variant unscanned.
4677 fn every_event() -> Vec<LifecycleEvent> {
4678 let policy = PolicyId::from_u128(0xabcd_ef01);
4679 let attempt = AttemptId::from_u128(0x1234_5678);
4680 vec![
4681 LifecycleEvent::DemandObserved {
4682 policy,
4683 demand: u32::MAX,
4684 not_matched: u32::MAX,
4685 unresolvable: u32::MAX,
4686 complete: false,
4687 },
4688 LifecycleEvent::TargetUnreadable {
4689 policy,
4690 reason: unreadable_reason(&RefreshState::Failed {
4691 status: Some(500),
4692 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
4693 .into(),
4694 }),
4695 },
4696 LifecycleEvent::Allocated {
4697 policy,
4698 demand: u32::MAX,
4699 desired: u16::MAX,
4700 active_owned: 7,
4701 headroom: 9,
4702 to_start: 2,
4703 limiting: LimitingFactor::HostCapacity,
4704 },
4705 LifecycleEvent::MonitorOnlySkipped { policy },
4706 LifecycleEvent::RunnerStarted { policy, attempt },
4707 LifecycleEvent::RunnerStartFailed {
4708 policy,
4709 reason: failure_reason_kind(&FailureReason::Other(
4710 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
4711 )),
4712 },
4713 LifecycleEvent::AllocationDeferred { policy, count: 4 },
4714 LifecycleEvent::AttemptsUnreadable {
4715 reason: failure_reason_kind(&FailureReason::Other(
4716 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
4717 )),
4718 },
4719 LifecycleEvent::AttemptCleanFailed {
4720 policy,
4721 attempt,
4722 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
4723 },
4724 LifecycleEvent::AttemptCleaned {
4725 policy,
4726 attempt,
4727 outcome: OutcomeKind::IdleExit,
4728 },
4729 LifecycleEvent::ScaleDownRefused { policy, attempt },
4730 LifecycleEvent::PollScheduled {
4731 retry_in_ms: 900_000,
4732 pace: PollPace::RateLimited {
4733 kind: RateLimitKind::Primary,
4734 },
4735 },
4736 ]
4737 }
4738
4739 /// `e1`'s Definition of Done: *"No emitted event contains a token, a JIT
4740 /// blob, or a credential header."*
4741 ///
4742 /// Asserted by rendering every variant and putting the result through `d1`'s
4743 /// own scrubber: if any of it looked like a credential to the redactor that
4744 /// guards the log file, the round trip would not be the identity. The
4745 /// positive control at the bottom is what stops that assertion passing
4746 /// because the scrubber is asleep.
4747 #[test]
4748 fn no_emitted_event_can_carry_a_credential() {
4749 use runner_manager_platform::logging::redact;
4750
4751 for event in every_event() {
4752 let displayed = event.to_string();
4753 assert_eq!(
4754 redact(&displayed),
4755 displayed,
4756 "`{}` renders something `d1`'s sink would have to redact",
4757 event.name()
4758 );
4759
4760 let debugged = format!("{event:?}");
4761 assert_eq!(
4762 redact(&debugged),
4763 debugged,
4764 "`{}`'s Debug renders something `d1`'s sink would have to redact",
4765 event.name()
4766 );
4767 }
4768
4769 // The control: the scrubber is awake, and would have caught a credential
4770 // had one been there.
4771 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
4772 assert_ne!(
4773 redact(secret),
4774 secret,
4775 "the scan above proves nothing if `redact` no longer recognises a credential"
4776 );
4777 }
4778
4779 #[test]
4780 fn every_field_name_this_sink_emits_is_one_d1_allows() {
4781 use runner_manager_platform::logging::is_field_allowed;
4782
4783 // The names `TracingEvents` writes. Kept beside the sink rather than
4784 // derived from it, because a derived list would move with the code and
4785 // assert nothing.
4786 for field in [
4787 "event",
4788 "policy_id",
4789 "attempt_id",
4790 "attempt_state",
4791 "demand",
4792 "desired",
4793 "capacity",
4794 "headroom",
4795 "count",
4796 "reason",
4797 "outcome",
4798 "mode",
4799 "lock",
4800 "retry_in_ms",
4801 "state",
4802 ] {
4803 assert!(
4804 is_field_allowed(field),
4805 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
4806 `[redacted]` in its place and the line would lose its meaning"
4807 );
4808 }
4809 }
4810
4811 #[test]
4812 fn every_failure_reason_has_a_credential_free_kind() {
4813 for reason in FailureReason::ALL {
4814 let kind = failure_reason_kind(&reason);
4815 assert!(!kind.is_empty());
4816 assert!(
4817 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
4818 "`{kind}` is not a fixed identifier"
4819 );
4820 }
4821 assert_eq!(
4822 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
4823 "other",
4824 "the detail of an `Other` reason never reaches an event"
4825 );
4826 }
4827
4828 // =======================================================================
4829 // The two tripwires
4830 // =======================================================================
4831
4832 /// One source file's production half, with comment lines dropped.
4833 ///
4834 /// Both exclusions are `c4`'s, and load-bearing for the same reasons. The
4835 /// **test module** goes because the tests in it legitimately name the shapes
4836 /// they forbid — this module's own positive control is a literal
4837 /// `async fn acquire_jobs`, which would accuse the file of the thing it is
4838 /// proving it does not do. The **comments** go because this module's
4839 /// documentation explains the seam at length and has to name what does not
4840 /// exist in order to say why; a scan that forbade the explanation is a scan
4841 /// that gets the explanation deleted.
4842 fn production_half_of(source: &str) -> String {
4843 let production = source
4844 .split_once("\n#[cfg(test)]")
4845 .map_or(source, |(production, _)| production);
4846 production
4847 .lines()
4848 .filter(|line| !line.trim_start().starts_with("//"))
4849 .collect::<Vec<_>>()
4850 .join("\n")
4851 }
4852
4853 /// This file's own production half.
4854 fn this_file_above_its_tests_without_prose() -> String {
4855 production_half_of(include_str!("reconcile.rs"))
4856 }
4857
4858 /// The one normalisation both halves of the scan use.
4859 ///
4860 /// # This is a second copy of `crates/github/src/demand.rs`, deliberately
4861 ///
4862 /// `production_half_of`, this function, [`FORBIDDEN`] and
4863 /// `forbidden_shape_in` together duplicate `demand.rs:1530-1619`. Sharing
4864 /// them would mean putting them in `crates/testkit`, which `e1` does not
4865 /// own, so the copy was the only option available to this task.
4866 ///
4867 /// **It is worth consolidating later, and here is the specific hazard.**
4868 /// The last defect in `c4`'s copy was two spellings of "the same"
4869 /// normalisation drifting apart — the haystack lower-cased and the needle
4870 /// not — which made three of its seven assertions vacuously true from the
4871 /// day they were written. Two copies is the same hazard one level up. The
4872 /// mitigation inside *this* copy is that one function serves both the scan
4873 /// and its positive control, so a normaliser that stops matching fails the
4874 /// control loudly rather than passing the scan silently; what that cannot
4875 /// catch is this copy and `c4`'s diverging from each other.
4876 fn normalise_for_scan(text: &str) -> String {
4877 text.to_ascii_lowercase().replace(['_', ' '], "")
4878 }
4879
4880 /// The Actions-service call this design has no equivalent of, plus the
4881 /// shapes an implementer would invent in its place.
4882 ///
4883 /// Spelled in halves so that no needle ever appears whole in the text being
4884 /// scanned, and keyed to `fn`/`struct` so that the prose above may keep
4885 /// explaining why there is no reservation. `c4` records both trades at
4886 /// length; this list is its counterpart one layer up. Note that the
4887 /// allocation lock's own `fn acquire` is deliberately *not* matched: the
4888 /// needle is `acquire`-a-**job**, and a lock is not one.
4889 const FORBIDDEN: &[&str] = &[
4890 concat!("fn ", "acquire", "_job"),
4891 concat!("fn ", "claim", "_job"),
4892 concat!("fn ", "lease", "_job"),
4893 concat!("fn ", "reserve", "_job"),
4894 concat!("fn ", "ack", "nowledge"),
4895 concat!("struct ", "Job", "Lease"),
4896 concat!("struct ", "Job", "Claim"),
4897 concat!("struct ", "Job", "Reservation"),
4898 ];
4899
4900 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
4901 let haystack = normalise_for_scan(source);
4902 FORBIDDEN
4903 .iter()
4904 .copied()
4905 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
4906 }
4907
4908 /// `e1`'s Definition of Done: *"No reservation, claim, lease, or acquisition
4909 /// call exists in the crate; a test or review note records that this is
4910 /// deliberate rather than missing."*
4911 ///
4912 /// **Deliberate, not missing.** The scale-set model let a listener call
4913 /// `AcquireJobs` to claim an assignment before scaling; the REST path has no
4914 /// equivalent, so demand is advisory and two hosts serving the same labels
4915 /// can both start a runner for one queued run. Adding a local reservation
4916 /// table would not remove that — the other host cannot see it — it would
4917 /// only hide the surplus case from the tests that measure it. The three
4918 /// controls that actually bound it are host-scoped routing labels,
4919 /// `max_capacity`, and `host_capacity`, and the last two are enforced in
4920 /// this file.
4921 ///
4922 /// The scan is a tripwire on the obvious shape rather than a proof: a
4923 /// reservation reached through a trait method or a differently-named helper
4924 /// would walk past it. Review is the primary control, exactly as `c4` states
4925 /// for its own copy.
4926 ///
4927 /// # It scans the crate, because the bullet says "in the crate"
4928 ///
4929 /// It used to scan this file alone while quoting a crate-wide claim, which
4930 /// left `lifecycle.rs` — `e3`, the launcher, and by far the likeliest place
4931 /// for someone to "fix" the surplus-runner case with a local lease — covered
4932 /// by nothing. Reading another owner's file is not editing it, so ownership
4933 /// was never the obstacle.
4934 ///
4935 /// The walk below is `c4`'s, and it **recurses** for the reason `c4`
4936 /// records: a module directory (`src/reconcile/mod.rs`) arrives as an entry
4937 /// that does not end in `.rs`, so a flat filter drops it and takes every
4938 /// file underneath with it, leaving the scan passing over files it covers
4939 /// by nothing at all. The listed-versus-on-disk assertion is what stops
4940 /// `SOURCES` going stale the moment `e2` or `e3` adds a module.
4941 #[test]
4942 fn nothing_in_this_crate_reserves_or_claims_a_job() {
4943 const SOURCES: &[(&str, &str)] = &[
4944 ("lib.rs", include_str!("lib.rs")),
4945 ("lifecycle.rs", include_str!("lifecycle.rs")),
4946 ("package.rs", include_str!("package.rs")),
4947 ("reconcile.rs", include_str!("reconcile.rs")),
4948 ];
4949
4950 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
4951 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
4952 let entry = entry.expect("a readable directory entry");
4953 let name = entry.file_name().to_string_lossy().into_owned();
4954 // `/`-joined, which is what `include_str!` takes on every
4955 // platform, so the two sides compare directly.
4956 let joined = if prefix.is_empty() {
4957 name.clone()
4958 } else {
4959 format!("{prefix}/{name}")
4960 };
4961 if entry.path().is_dir() {
4962 walk(&entry.path(), &joined, found);
4963 } else if name.ends_with(".rs") {
4964 found.push(joined);
4965 }
4966 }
4967 }
4968
4969 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
4970 listed.sort_unstable();
4971 let mut on_disk = Vec::new();
4972 walk(
4973 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
4974 "",
4975 &mut on_disk,
4976 );
4977 on_disk.sort_unstable();
4978 assert_eq!(
4979 listed, on_disk,
4980 "a source file was added or removed; this scan claims to cover the whole crate \
4981 and a stale list makes that claim false"
4982 );
4983
4984 for (name, source) in SOURCES {
4985 assert_eq!(
4986 forbidden_shape_in(&production_half_of(source)),
4987 None,
4988 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
4989 REST, and a local lease coordinates this host with itself and with nothing \
4990 else. If an owner decision restored one, that decision belongs in this \
4991 module's documentation and in this test before it belongs in the code"
4992 );
4993 }
4994
4995 // The control: the scan can see a shape when there is one, through the
4996 // same matcher the loop above uses.
4997 assert!(
4998 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
4999 "the scan above proves nothing if the needles no longer match"
5000 );
5001 }
5002
5003 /// This module **applies** `b1`'s label predicate and implements none of it.
5004 ///
5005 /// The counterpart to `c4`'s scan over `crates/github/src/demand.rs`, and it
5006 /// checks the opposite thing, because the two modules sit on opposite sides
5007 /// of the same seam. `c4` builds a `RunsOn` per queued job and must name no
5008 /// `RoutingLabels`; this module holds the policy whose labels decide, so it
5009 /// must call `RoutingLabels::tally` and must not re-derive what that call
5010 /// answers.
5011 ///
5012 /// So the scan is in two halves:
5013 ///
5014 /// * **Present.** `DemandTally` has to appear, because [`demand_for`]
5015 /// returns one. A production half that named it nowhere would mean the
5016 /// filtering had been dropped and every queued job in a watched repository
5017 /// was driving this policy toward `max_capacity` again.
5018 /// * **Absent.** The vocabulary of a *second* implementation. `b1` names the
5019 /// three outcomes of matching one job; this module consumes the aggregate
5020 /// and never a single job's verdict, so naming `RunsOnMatch` or
5021 /// `UnresolvableRunsOn` here means a `match` on an outcome that
5022 /// `RoutingLabels::tally` has already decided — which is how two copies of
5023 /// a predicate start.
5024 ///
5025 /// Like the needles in `nothing_in_this_module_reserves_or_claims_a_job`,
5026 /// this is a tripwire on the obvious shape rather than a proof: a hand-rolled
5027 /// comparison of raw label strings that never names a `policy` type would
5028 /// walk past it. Stated rather than implied, for the same reason it is
5029 /// stated there.
5030 #[test]
5031 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
5032 let production = this_file_above_its_tests_without_prose();
5033
5034 assert!(
5035 production.contains("DemandTally"),
5036 "the reconciliation loop must tally queued jobs against this policy's routing \
5037 labels. A production half that named `DemandTally` nowhere would mean the \
5038 label filtering had been removed, and a repository whose jobs target \
5039 `ubuntu-latest` would drive its policy toward `max_capacity` again"
5040 );
5041
5042 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
5043 assert!(
5044 !production.contains(second_implementation),
5045 "the reconciliation loop names `{second_implementation}`, which is the \
5046 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
5047 has already decided it. This module applies the predicate and does not \
5048 re-implement it; if an owner decision changed that, it belongs in this \
5049 module's documentation and in this test before it belongs in the code"
5050 );
5051 }
5052 }
5053
5054 /// The demand this module clamps is the *matched* count, and a job this host
5055 /// cannot serve is not demand.
5056 ///
5057 /// The behaviour the whole reversal was for, asserted end to end through
5058 /// `demand_for` rather than through `b1`'s predicate in isolation: a policy
5059 /// carrying this host's labels, a reading holding some of its jobs and some
5060 /// of somebody else's, and the three counts kept apart.
5061 #[test]
5062 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
5063 let policy = policy(1, "acme/app", 10);
5064 let reading = QueuedDemand::of(
5065 repo("acme/app"),
5066 [
5067 fixtures::queued_job(&[HOST_LABEL]),
5068 fixtures::queued_job(&[HOST_LABEL]),
5069 fixtures::queued_job(&["ubuntu-latest"]),
5070 fixtures::unresolvable_job(),
5071 ],
5072 );
5073
5074 let tally = demand_for(&policy, &reading);
5075
5076 assert_eq!(
5077 tally.demand(),
5078 2,
5079 "only the jobs whose required labels this policy carries are demand"
5080 );
5081 assert_eq!(
5082 tally.not_matched, 1,
5083 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
5084 runner that idles until it times out"
5085 );
5086 assert_eq!(
5087 tally.unresolvable.len(),
5088 1,
5089 "an unresolvable `runs-on` is never demand and never discarded"
5090 );
5091 }
5092
5093 /// A repository target reads its own repository; an organization target
5094 /// reads the whole scope.
5095 #[test]
5096 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
5097 let mut per_repository = BTreeMap::new();
5098 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
5099 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
5100 let reading = QueuedDemand::new(per_repository);
5101
5102 let repository_policy = policy(1, "acme/left", 10);
5103 assert_eq!(
5104 demand_for(&repository_policy, &reading).demand(),
5105 3,
5106 "a repository target reads its own repository's queue and not the aggregate"
5107 );
5108
5109 let org_policy = fixtures::policy()
5110 .id(PolicyId::from_u128(2))
5111 .organization("acme")
5112 .autoscale("home", 10)
5113 .active()
5114 .build();
5115 assert_eq!(
5116 demand_for(&org_policy, &reading).demand(),
5117 7,
5118 "an organization policy serves any repository in its scope, so its demand is \
5119 the whole aggregate's"
5120 );
5121 }
5122
5123 #[test]
5124 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
5125 // The owner decision accepts that a repository whose jobs only target
5126 // `ubuntu-latest` still drives its policy toward `max_capacity`. What
5127 // stops that being unbounded is exactly what stops any other demand
5128 // being unbounded, which is asserted here rather than assumed.
5129 let host = host_with(2);
5130 let policy = policy(1, "acme/app", 5);
5131 let attempts: Vec<RunnerAttempt> = Vec::new();
5132 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5133
5134 let allocation = allocator.allocate(&policy, u32::MAX);
5135 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
5136 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
5137 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
5138 }
5139}