Hive Hive
Sign in

fix(infra): fix the macOS runner queue bottleneck (image-roll pacing + tart-kubelet self-heal/observability)

GitHub issue · Closed

Metadata
Source
tuist/tuist #11362
Updated
Jun 24, 2026
Domains
Compute
Details

TL;DR

Fixes the macOS runner queue clog. Empirical investigation (a spare Mac mini + live prod telemetry) showed the bottleneck is not VM boot time — it’s the image-pull wave on a digest roll (every node tart pulls the new ~tens-of-GB image at once, collapsing the warm pool), plus a secondary tart-kubelet stall failure mode. This PR addresses both, reverts the disproven boot-time hardening, and adds the observability to see it.

How we know

  • On a spare Mac mini running the real tuist-runner:macos-26-5 image: clone→IP ~9s, boot→runner-polling ~32s. Boot is fast, and Spotlight is already disabled in the base, so the original mdutil hardening was a no-op (reverted).
  • Live prod: pods sit Pending for minutes on Ready, free, no-pressure nodes with zero events between Scheduled and Running — and a 0.6.0 → 0.7.0 digest roll was in flight. The tart_kubelet_vm_boot_duration metric only covers tart run→IP, so pull/clone time was invisible. The “stalls for minutes, then recovers” signature is a finite pull completing, not boot.

What’s in this PR

1. Cap concurrent image rolls (the primary fix) — server + runners-controller + CRD

The old server drain was time-staggered (8 slots × 30s) but open-loop; 30s ≪ a multi-minute pull, so the whole fleet pulled at once. Replaced with a feedback-driven, controller-owned cap:

  • CRD spec.rollout.maxConcurrentPercent (default 5, min 1, max 100); cap = max(1, floor(pct/100 × replicas)).
  • The controller marks stale Pods tuist.dev/drain-eligible only up to the cap (counting in-flight = drain-eligible stale + current-image not-Ready), marking more only as rollers reach Ready. Controller-owned to avoid a server-side thundering-herd race.
  • The server 410s a stale Pod only when it carries the label.
  • New metrics: tuist_runners_pool_rolling_pods, tuist_runners_pool_stale_pods, tuist_runners_pool_roll_concurrency_cap — so roll progress and a stuck roll (stale flat > 0 with rolling pinned) are visible.

2. tart-kubelet self-heal + observability (secondary stall failure mode)

  • Per-op timeouts on tart pull/clone/set/stop/delete/get/list — a hung op is killed and retried instead of wedging the node’s reconcile.
  • Explicit requeue after the finalizer-add so a missed watch event can’t strand a Pod Pending.
  • tart_kubelet_pod_provision_delay_seconds (pod-created → provisioning-start) + a CreatingVM event so the Scheduled→Running gap is no longer invisible.

3. Reverted + kept

  • Reverted the first-boot image hardening (disproven).
  • Kept the Runners dashboard changes (macOS ready/cold-booting split, utilisation-denominator fix, VM-boot panel).

Validation

go build/vet/test green incl. new unit tests (cap/readiness, watchdog); server compiles --warnings-as-errors and formats clean; the drain tests are rewritten to the label protocol; packer fmt clean. controller-gen was unavailable, so the CRD deepcopy + schema were hand-edited (mirroring autoscaling) — a maintainer should run make generate to confirm they match. Cannot integration-test the controller/node-agent off-cluster — needs canary validation through a real digest roll (watch tuist_runners_pool_rolling_pods stay ≤ ..._roll_concurrency_cap).

Follow-ups

  • A dashboard panel + alert on the new roll metrics (PromQL: rolling_pods > roll_concurrency_cap = cap bug; stale_pods > 0 flat = stuck roll). Metrics are exported now; the panel is cosmetic.
  • Pre-pulling the new image fleet-wide before rolling pods (eliminate the pull from the critical path entirely) is the deeper optimization beyond capping concurrency.

🤖 Generated with Claude Code

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments
F
fortmarek Jun 19, 2026

Thanks for the focused repros — all four addressed in d301ce6:

  1. Pending Pods bypassed the cap. Confirmed: stale Pending Pods were reaped en masse before the cap logic. They now retire under the same roll budget as the macOS Running→410 path (reap idle Pending directly, mark idle Running drain-eligible). The throttle moved ahead of the gap-fill so a reaped Pod’s current-image replacement is created the same reconcile. Regression test added: 5 stale Pending + 40% cap → exactly 2 retired/tick, 3 keep serving the old image.
  2. Roll gauges leaked for deleted static pools. metrics.Clear now also runs on the controller’s NotFound (pool-gone) path, not just the autoscaler path.
  3. rollout knob not rendered. runner-pool.yaml now renders spec.rollout from each pool/override/shape across all four RunnerPool blocks. Verified with helm template: a legacy pool with rollout.maxConcurrentPercent=20 and a 26.5 xcodeOverride with 7 both render; pools without an override render none (controller default 5 applies).
  4. provision_delay double-counted on retries. Moved the observe to the success path (right before tart run), recorded once per Pod that reaches Run. It now spans pull+clone — the segment the boot histogram (which starts at tart run) can’t see — and the Help text reflects that.

go build/vet/test green on both modules incl. the new throttle test; helm template renders the knob.

TA
tuist-atlas[bot] Jun 20, 2026

The fix for the macOS runner queue bottleneck (image-roll pacing plus tart-kubelet self-heal/observability) is now available in the xcresult-processor-image@0.26.4 release. Update your Tart image to ghcr.io/tuist/tuist-xcresult-processor:0.26.4 to pick up these changes.