ruby: Measure worker slack and let a sample of workers exit idle - #101
ruby: Measure worker slack and let a sample of workers exit idle#101spiliopoulos wants to merge 1 commit into
Conversation
A worker that has drained the queue does not exit. `exhausted?` is `queue_initialized? && size == 0`, and `size` counts pending plus running across the whole build, so every worker stays online until the slowest one finishes. That is deliberate, so a test that times out or gets requeued still has somewhere to run. It is not free. On a Figma permissions build of 20 workers over 141 files, two workers ran 105 and 34 tests and stopped 62 milliseconds apart. Wall-time profiles put roughly 22% of main thread time in the poll backoff. Two changes. Measurement. The worker records when its last test finished and exposes `slack_duration`, the gap between that and leaving the queue. The minitest adapter emits it as `minitests.queue.worker.slack`, tagged with whether the worker held requeue duty. Emitted from the adapter, not the queue, because ci/queue stays framework agnostic and Statsd lives under minitest/. Control. At init each worker draws a number and compares it to CI_QUEUE_IDLE_EXIT_PROBABILITY. Workers above the threshold keep today's behaviour. The rest leave once they have been idle for CI_QUEUE_IDLE_EXIT_GRACE, after the existing sleep and backoff. Defaults are 0.0 and 30, so nothing changes until the knob is set. The draw uses Random.new rather than Kernel#rand. The global RNG is seeded from --seed, which is identical across workers, so every worker would otherwise draw the same number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
Reviewed by Cursor Bugbot for commit 86365bb. Configure here.
| else | ||
| idle_since ||= CI::Queue.time_now | ||
| if CI::Queue.time_now - idle_since > 120 && !idle_state_printed | ||
| @idle_since ||= CI::Queue.time_now |
There was a problem hiding this comment.
Idle workers spam logs and Redis
Medium Severity
Writing idle_since on the instance makes idle? true while polling, which activates a previously dead branch in try_to_reserve_lost_test. That path prints the full running set and issues a Redis zrange on every idle reserve (every 0.5–2s), including when idle_exit_probability is 0.0.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 86365bb. Configure here.
| known_flaky_tests: load_known_flaky_tests(env['CI_QUEUE_KNOWN_FLAKY_TESTS']), | ||
| branch: env['BUILDKITE_BRANCH'], | ||
| idle_exit_probability: env['CI_QUEUE_IDLE_EXIT_PROBABILITY']&.to_f || 0.0, | ||
| idle_exit_grace: env['CI_QUEUE_IDLE_EXIT_GRACE']&.to_f || 30.0, |
There was a problem hiding this comment.
Zero idle grace is ignored
Low Severity
from_env uses || 30.0 after to_f, so CI_QUEUE_IDLE_EXIT_GRACE=0 becomes 0.0 and falls back to 30.0. A zero grace is a valid “leave as soon as idle” setting and is already used that way on the constructor path.
Reviewed by Cursor Bugbot for commit 86365bb. Configure here.


Describe
A ci-queue worker that has drained the queue does not exit.
exhausted?isqueue_initialized? && size == 0, andsizecounts pending plus running across the whole build:So every worker stays online until the slowest one finishes. That is deliberate — the comment on the idle branch says why:
It is not free. On a Figma permissions build with 20 workers over 141 files:
worker.rb:poll→sleepThis PR measures that slack, and makes the number of workers that hold requeue duty tunable.
Measurement
The worker records when its last test finished and exposes the gap between that and leaving the queue:
The minitest adapter emits it after
queue.pollreturns asminitests.queue.worker.slack(amstimer), taggedwaits_for_requeues:true|falseand the existingslug:.Emitted from the adapter rather than the queue because
ci/queuestays framework agnostic — there is an rspec adapter too — andMinitest::Queue::Statsdlives underminitest/. It reuses the existingCI_QUEUE_STATSD_ADDRclient, so no new dependency.Control
At init each worker draws a number and compares it to a threshold. Workers above it keep today's behaviour; the rest leave once they have been idle for a grace period, after the existing sleep and backoff.
added to the existing loop guard:
CI_QUEUE_IDLE_EXIT_PROBABILITY0.0CI_QUEUE_IDLE_EXIT_GRACE30Defaults preserve current behaviour exactly. At
0.0every draw is above the threshold, so every worker waits, as today.One non-obvious detail, called out in a code comment: the draw uses
Random.newrather thanKernel#rand. The global RNG is seeded from--seed, which is identical across workers, so every worker would otherwise draw the same number and the sample would be all-or-nothing.pollpreviously keptidle_sinceas a local while the class already had anattr_accessor :idle_sinceand anidle?reading the ivar. The local is now the ivar, soidle_exit?can read it and the existing accessor stops being dead.Test plan
New tests in
test/ci/queue/redis/worker_idle_exit_test.rb(no Redis server needed —::Redis.newis lazy) andtest/ci/queue/configuration_test.rb.I could not run the Redis-backed suites locally (no Redis available); CI covers those.
Before the metric will land
Two things outside this repo, neither of which this PR can fix:
CI_QUEUE_STATSD_ADDRis not set anywhere infigma/figma. Without itqueue_config.statsd_endpointis nil, theStatsdReporteris never registered, andreport_worker_slackreturns early. It needs setting on the sinatra test jobs.devex/buildkite/images/linux/config/datadog.yamlsetsuse_dogstatsd: trueanddogstatsd_port: 8125, but notdogstatsd_non_local_traffic, which defaults tofalse. The agent therefore binds 127.0.0.1:8125 and a UDP packet from a job container to the bridge gateway is dropped silently. Noteapm_non_local_traffic: trueis set, which is why the Ruby profiler reaches the agent but statsd would not.Both are one-line changes in
figma/figma, and thefigma/figmaGemfile ref needs bumping to pick this up.🤖 Generated with Claude Code