diff --git a/lambdas/functions/webhook/src/runners/dispatch.test.ts b/lambdas/functions/webhook/src/runners/dispatch.test.ts index e8eff9be4c..d55b485c50 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.test.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.test.ts @@ -153,6 +153,54 @@ describe('Dispatcher', () => { }); }); + it('should dispatch to lowest priority pool when multiple pools match the same labels', async () => { + config = await createConfig(undefined, [ + { + ...runnerConfig[0], + id: 'large-ondemand', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux', 'x64', 'large', 'ondemand']], + exactMatch: true, + priority: 100, + }, + }, + { + ...runnerConfig[0], + id: 'medium-spot', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux', 'x64', 'medium', 'spot']], + exactMatch: true, + priority: 5, + }, + }, + { + ...runnerConfig[0], + id: 'small-spot', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux', 'x64', 'small', 'spot']], + exactMatch: true, + priority: 1, + }, + }, + ]); + + // Job requests only the common subset — all pools match + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux', 'x64'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalledWith( + expect.objectContaining({ + queueId: 'small-spot', + }), + ); + }); + it('should not accept jobs where not all labels are supported (single matcher).', async () => { config = await createConfig(undefined, [ { diff --git a/lambdas/functions/webhook/src/runners/dispatch.ts b/lambdas/functions/webhook/src/runners/dispatch.ts index fe81e63a26..68604657fb 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.ts @@ -42,9 +42,12 @@ async function handleWorkflowJob( `Job ID: ${body.workflow_job.id}, Job Name: ${body.workflow_job.name}, ` + `Run ID: ${body.workflow_job.run_id}, Labels: ${JSON.stringify(body.workflow_job.labels)}`, ); - // sort the queuesConfig by order of matcher config exact match, with all true matches lined up ahead. + // Sort: exact-match first, then by ascending priority (lower = preferred/cheaper). matcherConfig.sort((a, b) => { - return a.matcherConfig.exactMatch === b.matcherConfig.exactMatch ? 0 : a.matcherConfig.exactMatch ? -1 : 1; + if (a.matcherConfig.exactMatch !== b.matcherConfig.exactMatch) { + return a.matcherConfig.exactMatch ? -1 : 1; + } + return (a.matcherConfig.priority ?? 999) - (b.matcherConfig.priority ?? 999); }); for (const queue of matcherConfig) { if (canRunJob(body.workflow_job.labels, queue.matcherConfig.labelMatchers, queue.matcherConfig.exactMatch)) { diff --git a/lambdas/functions/webhook/src/sqs/index.ts b/lambdas/functions/webhook/src/sqs/index.ts index a028d7dcc4..09fad25473 100644 --- a/lambdas/functions/webhook/src/sqs/index.ts +++ b/lambdas/functions/webhook/src/sqs/index.ts @@ -17,6 +17,7 @@ export interface ActionRequestMessage { export interface MatcherConfig { labelMatchers: string[][]; exactMatch: boolean; + priority?: number; } export type RunnerConfig = RunnerMatcherConfig[];