Files
gitea/services/actions/max_parallel.go
Pascal Zimmermann 10c678a0a0 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>
2026-07-27 16:18:43 +00:00

79 lines
3.2 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"math"
"strconv"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/log"
)
// parseMaxParallel returns strategy.max-parallel for a job, 0 meaning unlimited.
// GitHub accepts any YAML number here and casts it to an int, so 1.5 truncates to 1.
// Expressions are not evaluated yet and fall back to unlimited.
func parseMaxParallel(jobID, maxParallelString string) int {
if maxParallelString == "" {
return 0
}
maxParallel, err := strconv.ParseFloat(maxParallelString, 64)
if err != nil || math.IsNaN(maxParallel) {
log.Debug("job %s: unsupported max-parallel value %q, treating as unlimited", jobID, maxParallelString)
return 0
}
// a run can never hold more jobs than MaxJobNumPerRun, so clamping there keeps the cast total
return int(min(max(maxParallel, 0), actions_model.MaxJobNumPerRun))
}
// maxParallelSlots counts the jobs holding a max-parallel slot. Slots are scoped by ParentJobID as
// well as JobID so the same job name in two reusable workflow calls does not cross-link, matching
// how jobStatusResolver scopes needs.
type maxParallelSlots map[maxParallelKey]int
type maxParallelKey struct {
parentJobID int64
jobID string
}
// hold seeds a job's slot from a status it already holds, bypassing the limit check.
// The status is passed separately because the job emitter tracks statuses outside the job model.
func (s maxParallelSlots) hold(job *actions_model.ActionRunJob, status actions_model.Status) {
if job.MaxParallel <= 0 {
return // an unlimited job's count is never read by take, don't accumulate it
}
// A Cancelling job still owns its runner, so it keeps its slot until cleanup finishes.
// An expanded reusable caller is underway even while its children aggregate it back to Blocked, so it holds a slot too.
if status.In(actions_model.StatusRunning, actions_model.StatusWaiting, actions_model.StatusCancelling) ||
(job.IsReusableCaller && job.IsExpanded && !status.IsDone()) {
s[maxParallelKey{job.ParentJobID, job.JobID}]++
}
}
// available reports whether a slot is free without reserving it.
// Peek it before running the concurrency check: DO NOT cancel the group's other pending jobs if no available slots.
func (s maxParallelSlots) available(job *actions_model.ActionRunJob) bool {
return job.MaxParallel <= 0 || s[maxParallelKey{job.ParentJobID, job.JobID}] < job.MaxParallel
}
// take reserves a slot and reports whether one was free. A job without a limit always succeeds.
func (s maxParallelSlots) take(job *actions_model.ActionRunJob) bool {
if !s.available(job) {
return false
}
if job.MaxParallel > 0 {
s[maxParallelKey{job.ParentJobID, job.JobID}]++
}
return true
}
// applyMaxParallel demotes a ready job to Blocked when its slots are full, leaving the job emitter to
// promote it once one frees. Call it after concurrency evaluation so a concurrency-blocked job does
// not consume a slot, and before the insert so a held-back reusable caller is not expanded.
func applyMaxParallel(job *actions_model.ActionRunJob, slots maxParallelSlots) {
if job.Status == actions_model.StatusWaiting && !slots.take(job) {
job.Status = actions_model.StatusBlocked
}
}