Skip to content

Commit a4ecd11

Browse files
jeff-frenchclaude
andauthored
fix(pool): clamp pool top-up to runners_maximum_count (github-aws-runners#5187)
## Description `runners_maximum_count` was enforced only by the **scale-up** lambda. The **pool** lambda (`adjustPool`) had no knowledge of the maximum and topped up purely against `pool_size`, so a warm pool could drive the total number of runners far past `runners_maximum_count`. `calculatePooSize()` counts only **idle** runners. Under a sustained burst of queued jobs, runners created to fill the pool are immediately picked up and become busy, so they stop counting toward `numberOfRunnersInPool`. Every scheduled pool cycle therefore sees ~0 idle runners and launches another full `pool_size` batch — with no upper bound — while the scale-up lambda correctly refuses to launch ("maximum number of runners reached"). The two lambdas actively disagree about the cap. Fixes github-aws-runners#5186. ## Changes - **`lambdas/.../pool/pool.ts`** — read `RUNNERS_MAXIMUM_COUNT` (default `-1` = unlimited, matching scale-up semantics) and clamp `topUp` to the remaining headroom under the cap. `ec2runners` already contains every running runner for the type (busy + idle), so its length is the current total — no extra API call. Logs when the cap limits the top-up. - **Terraform** — thread the value into the pool lambda's environment: - `modules/runners/pool/main.tf`: `RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count` - `modules/runners/pool/variables.tf`: add `runners_maximum_count` to the `config` object - `modules/runners/pool.tf`: `runners_maximum_count = var.runners_maximum_count` - `modules/runners/pool/README.md`: regenerated docs ## Backward compatibility Defaulting the env to `-1` preserves current behavior when it is unset and matches the documented "`-1` disables the maximum check" semantics. ## Relationship to github-aws-runners#5062 github-aws-runners#5062 added `Math.max(0, …)` in scale-up to stop a negative `TotalTargetCapacity` reaching CreateFleet when `currentRunners` already exceeds `maximumRunners`. That guards the crash symptom; this PR addresses the root cause of how `currentRunners` exceeds `maximumRunners` (the pool creating past the cap). The two are complementary. ## Tests `pool.test.ts` adds cap coverage: at-max ⇒ 0 created, over-max ⇒ 0, headroom-clamped ⇒ 2, within-headroom ⇒ pool-driven, and `-1` ⇒ unlimited. The base `RUNNERS_MAXIMUM_COUNT` in the suite is set to `-1` so the existing pool-logic tests remain cap-free. - control-plane vitest suite: **499 passed** - eslint / prettier --check: clean - `terraform validate` / `terraform fmt`: clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c9f9b4 commit a4ecd11

6 files changed

Lines changed: 88 additions & 3 deletions

File tree

lambdas/functions/control-plane/src/pool/pool.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ beforeEach(() => {
130130
process.env.GITHUB_APP_ID = '1337';
131131
process.env.GITHUB_APP_CLIENT_ID = 'TEST_CLIENT_ID';
132132
process.env.GITHUB_APP_CLIENT_SECRET = 'TEST_CLIENT_SECRET';
133-
process.env.RUNNERS_MAXIMUM_COUNT = '3';
133+
process.env.RUNNERS_MAXIMUM_COUNT = '-1';
134134
process.env.ENVIRONMENT = 'unit-test-environment';
135135
process.env.ENABLE_ORGANIZATION_RUNNERS = 'true';
136136
process.env.LAUNCH_TEMPLATE_NAME = 'lt-1';
@@ -360,4 +360,68 @@ describe('Test simple pool.', () => {
360360
);
361361
});
362362
});
363+
364+
describe('Respecting runners_maximum_count', () => {
365+
beforeEach(() => {
366+
(getGitHubEnterpriseApiUrl as ReturnType<typeof vi.fn>).mockReturnValue({
367+
ghesApiUrl: '',
368+
ghesBaseUrl: '',
369+
});
370+
});
371+
372+
it('Should not top up when the total number of running runners is at the maximum.', async () => {
373+
// 4 running runners (2 idle, 1 busy, 1 offline) already meet the maximum, so a large pool size
374+
// must not create more. This is the over-provisioning case from issue #5186.
375+
process.env.RUNNERS_MAXIMUM_COUNT = '4';
376+
await adjust({ poolSize: 10 });
377+
expect(createRunners).not.toHaveBeenCalled();
378+
});
379+
380+
it('Should not top up when the total number of running runners exceeds the maximum.', async () => {
381+
process.env.RUNNERS_MAXIMUM_COUNT = '3';
382+
await adjust({ poolSize: 10 });
383+
expect(createRunners).not.toHaveBeenCalled();
384+
});
385+
386+
it('Should clamp the top-up to the remaining headroom under the maximum.', async () => {
387+
// 4 running runners with a maximum of 6 leaves headroom for 2, even though the pool of 10 and the
388+
// 2 idle runners would otherwise request a top-up of 8.
389+
process.env.RUNNERS_MAXIMUM_COUNT = '6';
390+
await adjust({ poolSize: 10 });
391+
expect(createRunners).toHaveBeenCalledWith(
392+
expect.anything(),
393+
expect.anything(),
394+
2,
395+
expect.anything(),
396+
'pool-lambda',
397+
);
398+
});
399+
400+
it('Should top up against the pool size when below the maximum headroom.', async () => {
401+
// Headroom (6 - 4 = 2) is larger than the pool demand (5 - 2 idle = 3 would exceed it, so use a
402+
// pool that stays within headroom): pool of 3 with 2 idle requests 1, which is under the cap.
403+
process.env.RUNNERS_MAXIMUM_COUNT = '6';
404+
await adjust({ poolSize: 3 });
405+
expect(createRunners).toHaveBeenCalledWith(
406+
expect.anything(),
407+
expect.anything(),
408+
1,
409+
expect.anything(),
410+
'pool-lambda',
411+
);
412+
});
413+
414+
it('Should ignore the maximum when set to -1 (unlimited).', async () => {
415+
process.env.RUNNERS_MAXIMUM_COUNT = '-1';
416+
// 2 idle of 4 running, pool of 10 tops up with 8 regardless of how many are already running.
417+
await adjust({ poolSize: 10 });
418+
expect(createRunners).toHaveBeenCalledWith(
419+
expect.anything(),
420+
expect.anything(),
421+
8,
422+
expect.anything(),
423+
'pool-lambda',
424+
);
425+
});
426+
});
363427
});

lambdas/functions/control-plane/src/pool/pool.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export async function adjust(event: PoolEvent): Promise<void> {
4747
? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS)
4848
: [];
4949
const scaleErrors = JSON.parse(process.env.SCALE_ERRORS) as [string];
50+
// -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited
51+
// when unset so the pool keeps its previous behavior on stacks that do not provide the variable.
52+
const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1');
5053

5154
const { ghesApiUrl, ghesBaseUrl } = getGitHubEnterpriseApiUrl();
5255

@@ -70,7 +73,22 @@ export async function adjust(event: PoolEvent): Promise<void> {
7073
});
7174

7275
const numberOfRunnersInPool = calculatePooSize(ec2runners, runnerStatusses);
73-
const topUp = event.poolSize - numberOfRunnersInPool;
76+
let topUp = event.poolSize - numberOfRunnersInPool;
77+
78+
// The pool must never push the total number of runners (busy + idle) past the configured maximum.
79+
// ec2runners contains every running runner for this type, so its length is the current total and no
80+
// extra API call is needed. Without this clamp the pool keeps topping up against idle-only counts and
81+
// can overshoot runners_maximum_count, while the scale-up lambda correctly refuses to launch.
82+
if (maximumRunners !== -1 && topUp > 0) {
83+
const headroom = maximumRunners - ec2runners.length;
84+
if (topUp > headroom) {
85+
logger.info(
86+
`Capping pool top-up from ${topUp} to ${Math.max(headroom, 0)} to respect the maximum of ` +
87+
`${maximumRunners} runners (currently ${ec2runners.length} running).`,
88+
);
89+
topUp = headroom;
90+
}
91+
}
7492

7593
if (topUp > 0) {
7694
logger.info(`The pool will be topped up with ${topUp} runners.`);

modules/runners/pool.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module "pool" {
1515
instance_max_spot_price = var.instance_max_spot_price
1616
instance_target_capacity_type = var.instance_target_capacity_type
1717
instance_types = var.instance_types
18+
runners_maximum_count = var.runners_maximum_count
1819
kms_key_arn = local.kms_key_arn
1920
ami_kms_key_arn = local.ami_kms_key_arn
2021
ami_id_ssm_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn

modules/runners/pool/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ No modules.
4949
| Name | Description | Type | Default | Required |
5050
|------|-------------|------|---------|:--------:|
5151
| <a name="input_aws_partition"></a> [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no |
52-
| <a name="input_config"></a> [config](#input\_config) | Lookup details in parent module. | <pre>object({<br/> lambda = object({<br/> log_level = string<br/> logging_retention_in_days = number<br/> logging_kms_key_id = string<br/> log_class = string<br/> reserved_concurrent_executions = number<br/> s3_bucket = string<br/> s3_key = string<br/> s3_object_version = string<br/> security_group_ids = list(string)<br/> runtime = string<br/> architecture = string<br/> memory_size = number<br/> timeout = number<br/> zip = string<br/> subnet_ids = list(string)<br/> parameter_store_tags = string<br/> })<br/> tags = map(string)<br/> ghes = object({<br/> url = string<br/> ssl_verify = string<br/> })<br/> github_app_parameters = object({<br/> key_base64 = map(string)<br/> id = map(string)<br/> })<br/> subnet_ids = list(string)<br/> runner = object({<br/> disable_runner_autoupdate = bool<br/> ephemeral = bool<br/> enable_jit_config = bool<br/> enable_on_demand_failover_for_errors = list(string)<br/> scale_errors = list(string)<br/> boot_time_in_minutes = number<br/> labels = list(string)<br/> launch_template = object({<br/> name = string<br/> })<br/> group_name = string<br/> name_prefix = string<br/> pool_owner = string<br/> role = object({<br/> arn = string<br/> })<br/> use_dedicated_host = bool<br/> })<br/> instance_types = list(string)<br/> instance_target_capacity_type = string<br/> instance_allocation_strategy = string<br/> instance_max_spot_price = string<br/> prefix = string<br/> pool = list(object({<br/> schedule_expression = string<br/> schedule_expression_timezone = string<br/> size = number<br/> }))<br/> role_permissions_boundary = string<br/> kms_key_arn = string<br/> ami_kms_key_arn = string<br/> ami_id_ssm_parameter_arn = string<br/> role_path = string<br/> ssm_token_path = string<br/> ssm_config_path = string<br/> ami_id_ssm_parameter_name = string<br/> ami_id_ssm_parameter_read_policy_arn = string<br/> arn_ssm_parameters_path_config = string<br/> lambda_tags = map(string)<br/> user_agent = string<br/> })</pre> | n/a | yes |
52+
| <a name="input_config"></a> [config](#input\_config) | Lookup details in parent module. | <pre>object({<br/> lambda = object({<br/> log_level = string<br/> logging_retention_in_days = number<br/> logging_kms_key_id = string<br/> log_class = string<br/> reserved_concurrent_executions = number<br/> s3_bucket = string<br/> s3_key = string<br/> s3_object_version = string<br/> security_group_ids = list(string)<br/> runtime = string<br/> architecture = string<br/> memory_size = number<br/> timeout = number<br/> zip = string<br/> subnet_ids = list(string)<br/> parameter_store_tags = string<br/> })<br/> tags = map(string)<br/> ghes = object({<br/> url = string<br/> ssl_verify = string<br/> })<br/> github_app_parameters = object({<br/> key_base64 = map(string)<br/> id = map(string)<br/> })<br/> subnet_ids = list(string)<br/> runner = object({<br/> disable_runner_autoupdate = bool<br/> ephemeral = bool<br/> enable_jit_config = bool<br/> enable_on_demand_failover_for_errors = list(string)<br/> scale_errors = list(string)<br/> boot_time_in_minutes = number<br/> labels = list(string)<br/> launch_template = object({<br/> name = string<br/> })<br/> group_name = string<br/> name_prefix = string<br/> pool_owner = string<br/> role = object({<br/> arn = string<br/> })<br/> use_dedicated_host = bool<br/> })<br/> runners_maximum_count = number<br/> instance_types = list(string)<br/> instance_target_capacity_type = string<br/> instance_allocation_strategy = string<br/> instance_max_spot_price = string<br/> prefix = string<br/> pool = list(object({<br/> schedule_expression = string<br/> schedule_expression_timezone = string<br/> size = number<br/> }))<br/> role_permissions_boundary = string<br/> kms_key_arn = string<br/> ami_kms_key_arn = string<br/> ami_id_ssm_parameter_arn = string<br/> role_path = string<br/> ssm_token_path = string<br/> ssm_config_path = string<br/> ami_id_ssm_parameter_name = string<br/> ami_id_ssm_parameter_read_policy_arn = string<br/> arn_ssm_parameters_path_config = string<br/> lambda_tags = map(string)<br/> user_agent = string<br/> })</pre> | n/a | yes |
5353
| <a name="input_tracing_config"></a> [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. | <pre>object({<br/> mode = optional(string, null)<br/> capture_http_requests = optional(bool, false)<br/> capture_error = optional(bool, false)<br/> })</pre> | `{}` | no |
5454

5555
## Outputs

modules/runners/pool/main.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ resource "aws_lambda_function" "pool" {
3939
RUNNER_GROUP_NAME = var.config.runner.group_name
4040
RUNNER_NAME_PREFIX = var.config.runner.name_prefix
4141
RUNNER_OWNER = var.config.runner.pool_owner
42+
RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count
4243
SSM_TOKEN_PATH = var.config.ssm_token_path
4344
SSM_CONFIG_PATH = var.config.ssm_config_path
4445
SUBNET_IDS = join(",", var.config.subnet_ids)

modules/runners/pool/variables.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ variable "config" {
4848
})
4949
use_dedicated_host = bool
5050
})
51+
runners_maximum_count = number
5152
instance_types = list(string)
5253
instance_target_capacity_type = string
5354
instance_allocation_strategy = string

0 commit comments

Comments
 (0)