feat: Add max-parallel Support for Gitea Actions (#36357)

Add support for `strategy.max-parallel` on Gitea Actions matrix jobs.

**How it works**
Jobs over the limit are inserted as `Blocked` instead of `Waiting`, so
runners never see them. When a job finishes, the job-status resolver
promotes one `Blocked` job per freed slot, in job order. Slots are
counted per `JobID` and scoped by reusable-workflow caller. A
`Cancelling` job still owns its runner, so it keeps its slot.

The cap is applied wherever a job can become `Waiting`: initial insert,
rerun, approval, and resolver promotion.

Best effort, not a hard invariant: two concurrent emitter passes can
each promote into the last slot, overshooting by one. It does not
compound, since every later pass recounts.

**Parsing**
Any YAML number, cast to an int as GitHub does (`1.5` → 1). `0` or
negative means unlimited. Expressions (`${{ ... }}`) are not evaluated
yet and fall back to unlimited.

**Migration**
Adds the `max_parallel` column on `action_run_job`. No index or
constraint changes.

**Compatibility**
Existing rows default to `0`, so behaviour is unchanged. No runner
changes needed: the runner protocol is untouched, and since the server
splits the matrix each runner still receives a single job.

Closes https://github.com/go-gitea/gitea/issues/35561
Signed-off-by: Pascal Zimmermann <pascal.zimmermann@theiotstudio.com>
Signed-off-by: ZPascal <pascal.zimmermann@theiotstudio.com>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
Pascal Zimmermann
2026-07-27 16:18:43 +00:00
committed by GitHub
parent e67dd4c818
commit 10c678a0a0
14 changed files with 637 additions and 17 deletions
+10 -3
View File
@@ -133,13 +133,16 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
var hasWaitingJobs bool
slots := maxParallelSlots{}
for _, v := range jobs {
runJob, jobsToCancel, jobNeedsPostCommitEmit, err := insertRunJob(ctx, run, runAttempt, v, vars, inputs)
runJob, jobsToCancel, jobNeedsPostCommitEmit, err := insertRunJob(ctx, run, runAttempt, v, vars, inputs, slots)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
needPostCommitEmit = needPostCommitEmit || jobNeedsPostCommitEmit
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !runJob.IsReusableCaller)
runJobs = append(runJobs, runJob)
@@ -180,7 +183,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
// inline-expands (or skips) it. It returns the inserted job, any jobs cancelled by
// job concurrency, and whether a post-commit emitter pass is needed to resolve the
// caller's dependents.
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, slots maxParallelSlots) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
id, job := workflowJob.Job()
needs := job.Needs()
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
@@ -215,6 +218,7 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
WorkflowSourceRepoID: run.WorkflowRepoID,
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
ContinueOnError: job.GetContinueOnError(),
MaxParallel: parseMaxParallel(id, job.Strategy.MaxParallelString),
}
// Parse workflow/job permissions (no clamping here)
if perms := ExtractJobPermissionsFromWorkflow(workflowJob, job); perms != nil {
@@ -244,7 +248,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
if runJob.Status == actions_model.StatusWaiting {
// A slot-starved job skips the check too: it will not start, so it must not cancel its group peers.
if runJob.Status == actions_model.StatusWaiting && slots.available(runJob) {
var jobsToCancel []*actions_model.ActionRunJob
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
if err != nil {
@@ -254,6 +259,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
}
}
applyMaxParallel(runJob, slots)
if err := db.Insert(ctx, runJob); err != nil {
return nil, nil, false, err
}