A newer run replaces older runs waiting in its concurrency group, like GitHub (#251)
* A newer run replaces older runs waiting in its concurrency group, like GitHub
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
5 files changed+70−5
Files (5)
modifiedapps/diffinery/docs/actions/compatibility.md+1−1View file
| @@ -70,7 +70,7 @@ | ||
| 70 | 70 | | `jobs.<id>.timeout-minutes` | Default and maximum 360. Larger values are capped at 360. | |
| 71 | 71 | | `jobs.<id>.strategy.matrix` | At most 256 jobs per matrix, and at most 1,000 jobs in a run, matrix legs and the jobs of called workflows included. | |
| 72 | 72 | | `jobs.<id>.uses` with `strategy.matrix` | Not supported yet. The run fails with `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet`. | |
| 73 | -| `concurrency` (workflow level) | Several runs can wait in a group; GitHub keeps only the newest pending one. Job-level `concurrency` keeps only the newest pending job, as on GitHub. See [Concurrency](/docs/actions/overview#concurrency). | | |
| 73 | +| `concurrency` (workflow level) | Same as GitHub: one run in progress and at most one waiting per group; a newer run cancels older waiting ones. After 6 hours of waiting a run starts anyway. See [Concurrency](/docs/actions/overview#concurrency). | | |
| 74 | 74 | | `defaults.run.shell`, `jobs.<id>.steps[*].shell` | `cmd` isn't supported. `pwsh` and `powershell` need PowerShell, which hosted runners don't have. | |
| 75 | 75 | |
| 76 | 76 | An unknown key gives a warning such as `unknown job key '<key>'` or `unknown step key '<key>'`, and the key is ignored. The full reference is [Workflow syntax](/docs/actions/workflow-syntax). |
modifiedapps/diffinery/docs/actions/overview.md+2−2View file
| @@ -405,10 +405,10 @@ | ||
| 405 | 405 | |
| 406 | 406 | - The group is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. Groups are scoped to the repository, so two workflows can share a group. |
| 407 | 407 | - With `cancel-in-progress: true`, creating a run cancels every unfinished run (queued or in progress) in the same group, with the reason `cancelled by a newer run in the same concurrency group`. |
| 408 | -- Without it, a new run waits while another run in the group is in progress, checking once a minute. After 6 hours of waiting it starts anyway. | |
| 408 | +- Without it, a new run waits while another run in the group is in progress, checking once a minute. After 6 hours of waiting it starts anyway. As on GitHub, a group has at most one waiting run: a new run cancels older runs still waiting in the group (not the one in progress), with the reason `Canceling since a higher priority waiting request for '<group>' exists`, so a deploy group runs the newest push next, never older ones one after another. A run cancelled while it waits finishes within a minute, with none of its jobs run. | |
| 409 | 409 | - A run or job on a protected branch is cancelled or replaced through its group only by runs on that same branch. A workflow on another branch that names the same group, `cancel-in-progress` or not, waits for it instead, so a branch can't cancel a protected branch's deploy. |
| 410 | 410 | |
| 411 | -Differences from GitHub: GitHub keeps at most one pending run per group and cancels older pending ones; Diffinery lets several runs wait and starts them as the group frees up. If the group expression fails to evaluate, the run has no concurrency group. | |
| 411 | +If the group expression fails to evaluate, the run has no concurrency group. | |
| 412 | 412 | |
| 413 | 413 | ### Job concurrency |
| 414 | 414 |
modifiedapps/diffinery/docs/actions/workflow-syntax.md+1−1View file
| @@ -220,7 +220,7 @@ | ||
| 220 | 220 | cancel-in-progress: true |
| 221 | 221 | ``` |
| 222 | 222 | |
| 223 | -`group` is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. `cancel-in-progress` is `true`, `false` (the default) or an expression evaluated with the same contexts. With `true`, a new run cancels every unfinished run in its group. With `false`, the new run waits while another run in the group is in progress. Groups are shared by all workflows of the repository, but a run on a protected branch is only cancelled by runs on that same branch; runs from elsewhere wait for it. See [Concurrency](/docs/actions/overview#concurrency) for the differences from GitHub. | |
| 223 | +`group` is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. `cancel-in-progress` is `true`, `false` (the default) or an expression evaluated with the same contexts. With `true`, a new run cancels every unfinished run in its group. With `false`, the new run waits while another run in the group is in progress, and cancels older runs still waiting there (as on GitHub, a group has one waiting run at most). Groups are shared by all workflows of the repository, but a run on a protected branch is only cancelled by runs on that same branch; runs from elsewhere wait for it. See [Concurrency](/docs/actions/overview#concurrency) for the differences from GitHub. | |
| 224 | 224 | |
| 225 | 225 | A value that is neither a string nor a mapping with `group` fails with `concurrency must be a group string or a mapping with 'group'`. |
| 226 | 226 |
modifiedapps/diffinery/src/repo/actions.ts+24−1View file
| @@ -804,7 +804,9 @@ | ||
| 804 | 804 | const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx); |
| 805 | 805 | const others = this.sql.exec<{ id: string; ref: string }>("SELECT id, ref FROM runs WHERE concurrency_group = ? AND status != 'completed' AND id != ?", run.concurrency_group, id).toArray(); |
| 806 | 806 | // A run the newcomer may not cancel stays, and the newcomer waits for it like any run in the group. |
| 807 | - for (const other of others) if ((cancelInProgress === true || cancelInProgress === "true") && this.mayCancel(run, other)) await this.cancelInternal(other.id, "cancelled by a newer run in the same concurrency group"); | |
| 807 | + if (cancelInProgress === true || cancelInProgress === "true") { | |
| 808 | + for (const other of others) if (this.mayCancel(run, other)) await this.cancelInternal(other.id, "cancelled by a newer run in the same concurrency group"); | |
| 809 | + } else await this.replaceWaiting(run); | |
| 808 | 810 | } |
| 809 | 811 | await this.env.RUN_WORKFLOW.create({ id, params: { repoId: this.repo.meta.repoId, runId: id } }); |
| 810 | 812 | const m = this.repo.meta; |
| @@ -910,6 +912,13 @@ | ||
| 910 | 912 | const { order, errors } = jobOrder(exp.workflow); |
| 911 | 913 | if (errors.length) return { jobs: [], error: errors.map((e) => e.message).join("; "), concurrencyBlocked: false }; |
| 912 | 914 | if (order.flat().length > MAX_RUN_JOBS) return { jobs: [], error: `A run may have at most ${MAX_RUN_JOBS} jobs, those of called workflows included; this one has ${order.flat().length}.`, concurrencyBlocked: false }; |
| 915 | + if (run.concurrency_group) { | |
| 916 | + // Runs that waited in the group before newer runs replaced older ones: the newest replaces them now. | |
| 917 | + const newest = this.sql.exec<RunRow>("SELECT * FROM runs WHERE concurrency_group = ? AND status = 'queued' AND conclusion IS NULL ORDER BY rowid DESC LIMIT 1", run.concurrency_group).toArray()[0]; | |
| 918 | + if (newest && newest.id !== run.id) await this.replaceWaiting(newest); | |
| 919 | + } | |
| 920 | + // Cancelled while it waited for its group: none of its jobs will run, so it finishes now instead of waiting its turn. | |
| 921 | + if (this.runRow(runId).conclusion === "cancelled") return { jobs: [], concurrencyBlocked: false }; | |
| 913 | 922 | const holders = () => (run.concurrency_group ? this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE concurrency_group = ? AND status = 'in_progress' AND id != ?", run.concurrency_group, runId).one().n : 0); |
| 914 | 923 | // A run holding the group may be stuck though its jobs all finished (STUCK_RUN_MS): repair it rather than wait behind it. |
| 915 | 924 | if (holders()) await this.repairStuckRuns(); |
| @@ -1694,6 +1703,20 @@ | ||
| 1694 | 1703 | } |
| 1695 | 1704 | |
| 1696 | 1705 | /** |
| 1706 | + * Like GitHub, a concurrency group without cancel-in-progress has at most one run waiting: a newer | |
| 1707 | + * run takes the place of older ones still waiting, which are cancelled. Otherwise every push to a | |
| 1708 | + * deploy's branch waits, and once the running deploy ends they start in no set order, each | |
| 1709 | + * deploying an older commit than the last push's (tactfolio, 2026-10-16: 15 deploys queued | |
| 1710 | + * behind one that hung, production went back to older commits). | |
| 1711 | + */ | |
| 1712 | + private async replaceWaiting(run: RunRow): Promise<void> { | |
| 1713 | + const older = this.sql | |
| 1714 | + .exec<{ id: string; ref: string }>("SELECT id, ref FROM runs WHERE concurrency_group = ? AND status = 'queued' AND conclusion IS NULL AND id != ? AND rowid < (SELECT rowid FROM runs WHERE id = ?)", run.concurrency_group, run.id, run.id) | |
| 1715 | + .toArray(); | |
| 1716 | + for (const o of older) if (this.mayCancel(run, o)) await this.cancelInternal(o.id, `Canceling since a higher priority waiting request for '${run.concurrency_group}' exists`); | |
| 1717 | + } | |
| 1718 | + | |
| 1719 | + /** | |
| 1697 | 1720 | * Whether a run of `by` may cancel or replace `other` in a shared concurrency group. Groups are |
| 1698 | 1721 | * repository-wide, as on GitHub, but a workflow pushed to some branch could otherwise name a |
| 1699 | 1722 | * deploy's group and cancel it halfway: a run on a protected branch is cancelled only by runs on |
modifiedapps/diffinery/test/unit/stuck-runs.test.ts+42−0View file
| @@ -206,3 +206,45 @@ | ||
| 206 | 206 | expect(f.actions.getRun(b).status).toBe("in_progress"); |
| 207 | 207 | }); |
| 208 | 208 | }); |
| 209 | + | |
| 210 | +describe("a concurrency group without cancel-in-progress", () => { | |
| 211 | + it("keeps one run waiting, like GitHub: a newer run replaces older waiting ones, which finish cancelled at once", async () => { | |
| 212 | + const f = fixture(); | |
| 213 | + const a = await f.push(); | |
| 214 | + expect((await f.actions.plan(a)).concurrencyBlocked).toBe(false); | |
| 215 | + await f.actions.startJobKey(a, "test"); | |
| 216 | + const b = await f.push(); | |
| 217 | + expect((await f.actions.plan(b)).concurrencyBlocked).toBe(true); | |
| 218 | + const c = await f.push(); | |
| 219 | + const d = await f.push(); | |
| 220 | + // b and c are replaced as d comes in; the running a is left alone. | |
| 221 | + expect(f.actions.getRun(b).conclusion).toBe("cancelled"); | |
| 222 | + expect(f.actions.getRun(c).conclusion).toBe("cancelled"); | |
| 223 | + expect(f.actions.getRun(a)).toMatchObject({ status: "in_progress", conclusion: null }); | |
| 224 | + // A replaced run doesn't wait its turn: its next plan has nothing to run, and its workflow finishes it. | |
| 225 | + expect(await f.actions.plan(c)).toEqual({ jobs: [], concurrencyBlocked: false }); | |
| 226 | + expect((await f.actions.finishRun(c)).conclusion).toBe("cancelled"); | |
| 227 | + expect(f.jobs(c)).toEqual([]); | |
| 228 | + expect((await f.actions.plan(d)).concurrencyBlocked).toBe(true); | |
| 229 | + // When a ends, d (the last push) runs next. | |
| 230 | + await (f.actions as unknown as { completeJob(id: string, c: unknown): Promise<void> }).completeJob(f.jobs(a)[0]!.id, { conclusion: "success", outputs: {} }); | |
| 231 | + await f.actions.startJobKey(a, "deploy"); | |
| 232 | + await (f.actions as unknown as { completeJob(id: string, c: unknown): Promise<void> }).completeJob(f.jobs(a)[1]!.id, { conclusion: "success", outputs: {} }); | |
| 233 | + await f.actions.finishRun(a); | |
| 234 | + expect((await f.actions.plan(d)).concurrencyBlocked).toBe(false); | |
| 235 | + expect(f.actions.getRun(d).status).toBe("in_progress"); | |
| 236 | + }); | |
| 237 | + | |
| 238 | + it("clears runs that waited before newer runs replaced older ones: the oldest doesn't take the group ahead of the newest", async () => { | |
| 239 | + const f = fixture(); | |
| 240 | + const runs = [await f.push(), await f.push(), await f.push()]; | |
| 241 | + // As in tactfolio's deploy group before this: every push waiting, none cancelled. | |
| 242 | + f.sql.exec("UPDATE runs SET status = 'queued', conclusion = NULL"); | |
| 243 | + // The group is free, and the oldest plans first: it is replaced by the newest, and runs nothing. | |
| 244 | + expect(await f.actions.plan(runs[0]!)).toEqual({ jobs: [], concurrencyBlocked: false }); | |
| 245 | + expect(f.actions.getRun(runs[1]!).conclusion).toBe("cancelled"); | |
| 246 | + expect(f.actions.getRun(runs[2]!)).toMatchObject({ status: "queued", conclusion: null }); | |
| 247 | + expect((await f.actions.plan(runs[2]!)).concurrencyBlocked).toBe(false); | |
| 248 | + expect(f.actions.getRun(runs[2]!).status).toBe("in_progress"); | |
| 249 | + }); | |
| 250 | +}); |