Skip to content

Commit ffc3778

Browse files
authored
Add concurrency queue support (#355)
1 parent a810405 commit ffc3778

File tree

9 files changed

+311
-5
lines changed

9 files changed

+311
-5
lines changed

expressions/src/features.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe("FeatureFlags", () => {
2525
it("returns true when all is enabled", () => {
2626
const flags = new FeatureFlags({all: true});
2727
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
28+
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
2829
});
2930

3031
it("explicit feature flag takes precedence over all:true", () => {
@@ -55,7 +56,8 @@ describe("FeatureFlags", () => {
5556
"missingInputsQuickfix",
5657
"blockScalarChompingWarning",
5758
"allowCaseFunction",
58-
"allowCopilotRequestsPermission"
59+
"allowCopilotRequestsPermission",
60+
"allowConcurrencyQueue"
5961
]);
6062
});
6163
});

expressions/src/features.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export interface ExperimentalFeatures {
4040
* @default false
4141
*/
4242
allowCopilotRequestsPermission?: boolean;
43+
44+
/**
45+
* Enable the queue property in workflow concurrency settings.
46+
* @default false
47+
*/
48+
allowConcurrencyQueue?: boolean;
4349
}
4450

4551
/**
@@ -55,7 +61,8 @@ const allFeatureKeys: ExperimentalFeatureKey[] = [
5561
"missingInputsQuickfix",
5662
"blockScalarChompingWarning",
5763
"allowCaseFunction",
58-
"allowCopilotRequestsPermission"
64+
"allowCopilotRequestsPermission",
65+
"allowConcurrencyQueue"
5966
];
6067

6168
export class FeatureFlags {

languageserver/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ initializationOptions: {
127127
|---------|-------------|
128128
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
129129
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
130+
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
130131

131132
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
132133

languageservice/src/validate.concurrency.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {FeatureFlags} from "@actions/expressions/features";
12
import {DiagnosticSeverity} from "vscode-languageserver-types";
23
import {validate} from "./validate.js";
34
import {createDocument} from "./test-utils/document.js";
@@ -7,6 +8,10 @@ beforeEach(() => {
78
clearCache();
89
});
910

11+
const queueValidationConfig = {
12+
featureFlags: new FeatureFlags({allowConcurrencyQueue: true})
13+
};
14+
1015
describe("validate concurrency deadlock", () => {
1116
describe("should error on matching concurrency groups", () => {
1217
it("simple string match", async () => {
@@ -243,3 +248,186 @@ jobs:
243248
});
244249
});
245250
});
251+
252+
describe("validate concurrency queue + cancel-in-progress conflict", () => {
253+
describe("should error", () => {
254+
it("workflow-level queue: max with cancel-in-progress: true", async () => {
255+
const input = `
256+
on: push
257+
concurrency:
258+
group: deploy
259+
cancel-in-progress: true
260+
queue: max
261+
jobs:
262+
job1:
263+
runs-on: ubuntu-latest
264+
steps:
265+
- run: echo hi`;
266+
267+
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
268+
269+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
270+
expect(queueErrors).toHaveLength(1);
271+
expect(queueErrors[0]).toMatchObject({
272+
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
273+
severity: DiagnosticSeverity.Error
274+
});
275+
});
276+
277+
it("job-level queue: max with cancel-in-progress: true", async () => {
278+
const input = `
279+
on: push
280+
jobs:
281+
job1:
282+
runs-on: ubuntu-latest
283+
concurrency:
284+
group: deploy
285+
cancel-in-progress: true
286+
queue: max
287+
steps:
288+
- run: echo hi`;
289+
290+
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
291+
292+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
293+
expect(queueErrors).toHaveLength(1);
294+
expect(queueErrors[0]).toMatchObject({
295+
severity: DiagnosticSeverity.Error
296+
});
297+
});
298+
299+
it("both workflow and job level have the conflict", async () => {
300+
const input = `
301+
on: push
302+
concurrency:
303+
group: deploy
304+
cancel-in-progress: true
305+
queue: max
306+
jobs:
307+
job1:
308+
runs-on: ubuntu-latest
309+
concurrency:
310+
group: build
311+
cancel-in-progress: true
312+
queue: max
313+
steps:
314+
- run: echo hi`;
315+
316+
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
317+
318+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
319+
expect(queueErrors).toHaveLength(2);
320+
});
321+
});
322+
323+
describe("should not error", () => {
324+
it("queue: max without cancel-in-progress", async () => {
325+
const input = `
326+
on: push
327+
concurrency:
328+
group: deploy
329+
queue: max
330+
jobs:
331+
job1:
332+
runs-on: ubuntu-latest
333+
steps:
334+
- run: echo hi`;
335+
336+
const result = await validate(createDocument("wf.yaml", input));
337+
338+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
339+
expect(queueErrors).toHaveLength(0);
340+
});
341+
342+
it("queue: single with cancel-in-progress: true", async () => {
343+
const input = `
344+
on: push
345+
concurrency:
346+
group: deploy
347+
cancel-in-progress: true
348+
queue: single
349+
jobs:
350+
job1:
351+
runs-on: ubuntu-latest
352+
steps:
353+
- run: echo hi`;
354+
355+
const result = await validate(createDocument("wf.yaml", input));
356+
357+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
358+
expect(queueErrors).toHaveLength(0);
359+
});
360+
361+
it("cancel-in-progress: false with queue: max", async () => {
362+
const input = `
363+
on: push
364+
concurrency:
365+
group: deploy
366+
cancel-in-progress: false
367+
queue: max
368+
jobs:
369+
job1:
370+
runs-on: ubuntu-latest
371+
steps:
372+
- run: echo hi`;
373+
374+
const result = await validate(createDocument("wf.yaml", input));
375+
376+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
377+
expect(queueErrors).toHaveLength(0);
378+
});
379+
380+
it("no queue property", async () => {
381+
const input = `
382+
on: push
383+
concurrency:
384+
group: deploy
385+
cancel-in-progress: true
386+
jobs:
387+
job1:
388+
runs-on: ubuntu-latest
389+
steps:
390+
- run: echo hi`;
391+
392+
const result = await validate(createDocument("wf.yaml", input));
393+
394+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
395+
expect(queueErrors).toHaveLength(0);
396+
});
397+
398+
it("string form concurrency (no mapping)", async () => {
399+
const input = `
400+
on: push
401+
concurrency: deploy
402+
jobs:
403+
job1:
404+
runs-on: ubuntu-latest
405+
steps:
406+
- run: echo hi`;
407+
408+
const result = await validate(createDocument("wf.yaml", input));
409+
410+
const queueErrors = result.filter(d => d.message.includes("queue: max"));
411+
expect(queueErrors).toHaveLength(0);
412+
});
413+
414+
it("does not report queue conflict when the feature is disabled", async () => {
415+
const input = `
416+
on: push
417+
concurrency:
418+
group: deploy
419+
cancel-in-progress: true
420+
queue: max
421+
jobs:
422+
job1:
423+
runs-on: ubuntu-latest
424+
steps:
425+
- run: echo hi`;
426+
427+
const result = await validate(createDocument("wf.yaml", input));
428+
429+
const queueConflictErrors = result.filter(d => d.message.includes("queue: max"));
430+
expect(queueConflictErrors).toHaveLength(0);
431+
});
432+
});
433+
});

languageservice/src/validate.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
22
import {Expr} from "@actions/expressions/ast";
3-
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
3+
import {
4+
TemplateParseResult,
5+
WorkflowTemplate,
6+
isBasicExpression,
7+
isBoolean,
8+
isMapping,
9+
isString
10+
} from "@actions/workflow-parser";
411
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
512
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
613
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
@@ -239,6 +246,11 @@ async function additionalValidations(
239246

240247
// Validate concurrency deadlock between workflow and job levels
241248
validateConcurrencyDeadlock(diagnostics, template);
249+
250+
// Validate incompatible concurrency options
251+
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
252+
validateConcurrencyQueueCancelInProgress(diagnostics, template);
253+
}
242254
}
243255

244256
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
@@ -664,6 +676,55 @@ function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: Workfl
664676
}
665677
}
666678

679+
/**
680+
* Validates that `queue: max` and `cancel-in-progress: true` are not both set
681+
* in a concurrency mapping, as this combination is invalid.
682+
*/
683+
function validateConcurrencyQueueCancelInProgress(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
684+
// Check workflow-level concurrency
685+
if (template.concurrency) {
686+
checkConcurrencyQueueConflict(diagnostics, template.concurrency);
687+
}
688+
689+
// Check job-level concurrency
690+
for (const job of template.jobs || []) {
691+
if (job.concurrency) {
692+
checkConcurrencyQueueConflict(diagnostics, job.concurrency);
693+
}
694+
}
695+
}
696+
697+
function checkConcurrencyQueueConflict(diagnostics: Diagnostic[], token: TemplateToken): void {
698+
if (!isMapping(token)) {
699+
return;
700+
}
701+
702+
let hasQueueMax = false;
703+
let hasCancelInProgressTrue = false;
704+
let queueRange: TokenRange | undefined;
705+
706+
for (const pair of token) {
707+
if (!isString(pair.key) || pair.key.isExpression || pair.value.isExpression) {
708+
continue;
709+
}
710+
if (pair.key.value === "queue" && isString(pair.value) && pair.value.value === "max") {
711+
hasQueueMax = true;
712+
queueRange = pair.key.range;
713+
}
714+
if (pair.key.value === "cancel-in-progress" && isBoolean(pair.value) && pair.value.value) {
715+
hasCancelInProgressTrue = true;
716+
}
717+
}
718+
719+
if (hasQueueMax && hasCancelInProgressTrue && queueRange) {
720+
diagnostics.push({
721+
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
722+
range: mapRange(queueRange),
723+
severity: DiagnosticSeverity.Error
724+
});
725+
}
726+
}
727+
667728
/**
668729
* Extracts the static concurrency group name from a concurrency token.
669730
* Returns undefined if the token is an expression or doesn't have a static group.

workflow-parser/src/model/converter/concurrency.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type {FeatureFlags} from "@actions/expressions/features";
12
import {TemplateContext} from "../../templates/template-context.js";
23
import {TemplateToken} from "../../templates/tokens/template-token.js";
34
import {isString} from "../../templates/tokens/type-guards.js";
4-
import {ConcurrencySetting} from "../workflow-template.js";
5+
import {ConcurrencyQueue, ConcurrencySetting} from "../workflow-template.js";
56

67
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
78
const result: ConcurrencySetting = {};
9+
const featureFlags = context.state.featureFlags as FeatureFlags | undefined;
810

911
if (token.isExpression) {
1012
return result;
@@ -26,6 +28,11 @@ export function convertConcurrency(context: TemplateContext, token: TemplateToke
2628
case "cancel-in-progress":
2729
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
2830
break;
31+
case "queue":
32+
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
33+
result.queue = property.value.assertString("queue").value as ConcurrencyQueue;
34+
}
35+
break;
2936
default:
3037
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
3138
}

workflow-parser/src/model/workflow-template.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ export type WorkflowTemplate = {
1818
}[];
1919
};
2020

21+
export type ConcurrencyQueue = "single" | "max";
22+
2123
export type ConcurrencySetting = {
2224
group?: StringToken;
2325
cancelInProgress?: boolean;
26+
queue?: ConcurrencyQueue;
2427
};
2528

2629
export type ActionsEnvironmentReference = {

workflow-parser/src/workflow-v1.0.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2050,10 +2050,20 @@
20502050
"cancel-in-progress": {
20512051
"type": "boolean",
20522052
"description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true."
2053+
},
2054+
"queue": {
2055+
"type": "concurrency-queue",
2056+
"description": "The queuing mode for the concurrency group. When set to `max`, workflows or jobs will wait in a queue for the concurrency group up to the maximum queue length. Default: `single` meaning at most one item can be pending."
20532057
}
20542058
}
20552059
}
20562060
},
2061+
"concurrency-queue": {
2062+
"allowed-values": [
2063+
"single",
2064+
"max"
2065+
]
2066+
},
20572067
"job-environment": {
20582068
"description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.",
20592069
"context": [

0 commit comments

Comments
 (0)