Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
d6a95ce
support concurrency
Zettat123 Dec 7, 2024
f713c68
Merge branch 'main' of https://github.com/go-gitea/gitea into pr/Zett…
ChristopherHX Jul 8, 2025
76f58ef
fix: lint
ChristopherHX Jul 8, 2025
249e315
fix: assert correct status code
ChristopherHX Jul 8, 2025
b012126
fix: rerun all jobs did not respect concurrency
ChristopherHX Jul 9, 2025
91fccb7
Fix and Test rerun single jobs
ChristopherHX Jul 9, 2025
2e6032a
Remove CancelJobsByJobConcurrency in PickTask
ChristopherHX Jul 13, 2025
1a08b87
Merge branch 'main' of https://github.com/go-gitea/gitea into pr/Zett…
ChristopherHX Jul 13, 2025
9bba25e
remove code
ChristopherHX Jul 15, 2025
e6d25e2
Merge branch 'main' of https://github.com/go-gitea/gitea into pr/Zett…
ChristopherHX Jul 15, 2025
dc86086
call EmitJobsIfReady when cancelling a run
Zettat123 Jul 19, 2025
8721155
add TestCancelConcurrentRun
Zettat123 Jul 19, 2025
c5444e7
improve concurrency index
Zettat123 Jul 21, 2025
63eaf15
modify cancel-in-progress behavior
Zettat123 Jul 22, 2025
96d6938
fix bugs and tests
Zettat123 Jul 22, 2025
ea2bc92
Merge branch 'main' into support-actions-concurrency
Zettat123 Jul 22, 2025
a4b76b6
update InsertRun
Zettat123 Jul 22, 2025
ca18672
modify cancel-in-progress behavior
Zettat123 Jul 23, 2025
dc003e4
fix lint
Zettat123 Jul 23, 2025
e45ca56
improve handling cancellation
Zettat123 Jul 25, 2025
5e2059a
rename migration
Zettat123 Jul 25, 2025
4ba788d
Merge branch 'main' into support-actions-concurrency
Zettat123 Jul 25, 2025
2481e83
fix migration
Zettat123 Jul 25, 2025
f585f90
remove blockRunByConcurrency todo for jobs
ChristopherHX Jul 25, 2025
5e43aa2
fix migration to use same index as model
ChristopherHX Jul 25, 2025
d5f6c44
implement evaluate concurrency expression again, vars may change afte…
ChristopherHX Jul 25, 2025
796d2c1
Use a single RawConcurrency in db
ChristopherHX Jul 25, 2025
6a9eb9e
fix fmt
ChristopherHX Jul 25, 2025
42c2ca2
add TestAbandonConcurrentRun
Zettat123 Jul 25, 2025
5a45b76
update to act dev version add new tests
ChristopherHX Jul 29, 2025
989e274
Merge branch 'main' into support-actions-concurrency
Zettat123 Oct 3, 2025
15cc5c5
resolve conflicts
Zettat123 Oct 3, 2025
defa2fd
fix status update
Zettat123 Oct 3, 2025
7d5c377
Merge branch 'main' into support-actions-concurrency
Zettat123 Oct 5, 2025
ffe50aa
Merge branch 'main' into support-actions-concurrency
Zettat123 Oct 6, 2025
63d9a56
resolve conflicts
Zettat123 Oct 6, 2025
2dbede9
add 'IgnoreDropIndices' option to migration
Zettat123 Oct 6, 2025
8356dbe
only keep new index
Zettat123 Oct 6, 2025
ffe9597
fix comments
Zettat123 Oct 7, 2025
9a70f52
Merge branch 'main' into support-actions-concurrency
Zettat123 Oct 7, 2025
7b30080
skip chanaged jobs when cancelling jobs
Zettat123 Oct 7, 2025
c652810
add more comments and fix ErrUnevaluatedConcurrency
Zettat123 Oct 8, 2025
15c504c
fix rerun job and update test
Zettat123 Oct 9, 2025
3b4ad5d
remove ErrUnevaluatedConcurrency
wxiaoguang Oct 9, 2025
ae81158
fix comment
wxiaoguang Oct 9, 2025
d91ec9b
update comments
wxiaoguang Oct 9, 2025
87d7f65
fix comment
wxiaoguang Oct 9, 2025
84d6254
fix comment
Zettat123 Oct 10, 2025
bb399e6
fix lint
Zettat123 Oct 10, 2025
90a31d0
refactor
wxiaoguang Oct 10, 2025
d7dcd8b
fine tune
wxiaoguang Oct 10, 2025
23c80cf
fix lint
wxiaoguang Oct 10, 2025
2ad1e67
fix
wxiaoguang Oct 10, 2025
02dc4fa
fix
wxiaoguang Oct 10, 2025
023e6e2
use shouldBlock instead of status checking
wxiaoguang Oct 10, 2025
e38d886
fix test
wxiaoguang Oct 10, 2025
4f5143f
fix prepare
wxiaoguang Oct 10, 2025
2383132
rename variable
wxiaoguang Oct 10, 2025
6d12149
fix
wxiaoguang Oct 10, 2025
d69d345
fine tune comment and func name
wxiaoguang Oct 10, 2025
dc1faf9
Merge branch 'main' into support-actions-concurrency
wxiaoguang Oct 10, 2025
aa419a5
Merge branch 'main' into support-actions-concurrency
GiteaBot Oct 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 101 additions & 96 deletions models/actions/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,21 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"

"github.com/nektos/act/pkg/jobparser"
"xorm.io/builder"
)

// ActionRun represents a run of a workflow file
type ActionRun struct {
ID int64
Title string
RepoID int64 `xorm:"index unique(repo_index)"`
RepoID int64 `xorm:"unique(repo_index) index(repo_concurrency)"`
Repo *repo_model.Repository `xorm:"-"`
OwnerID int64 `xorm:"index"`
WorkflowID string `xorm:"index"` // the name of workflow file
Expand All @@ -49,6 +49,9 @@ type ActionRun struct {
TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow
Status Status `xorm:"index"`
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
RawConcurrency string // raw concurrency
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"`
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
// Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0
Started timeutil.TimeStamp
Stopped timeutil.TimeStamp
Expand Down Expand Up @@ -190,7 +193,7 @@ func (run *ActionRun) IsSchedule() bool {
return run.ScheduleID > 0
}

func updateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error {
func UpdateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error {
_, err := db.GetEngine(ctx).ID(repo.ID).
NoAutoTime().
SetExpr("num_action_runs",
Expand Down Expand Up @@ -247,116 +250,62 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin
return cancelledJobs, err
}

// Iterate over each job and attempt to cancel it.
for _, job := range jobs {
// Skip jobs that are already in a terminal state (completed, cancelled, etc.).
status := job.Status
if status.IsDone() {
continue
}

// If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it.
if job.TaskID == 0 {
job.Status = StatusCancelled
job.Stopped = timeutil.TimeStampNow()

// Update the job's status and stopped time in the database.
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
if err != nil {
return cancelledJobs, err
}

// If the update affected 0 rows, it means the job has changed in the meantime, so we need to try again.
if n == 0 {
return cancelledJobs, errors.New("job has changed, try again")
}

cancelledJobs = append(cancelledJobs, job)
// Continue with the next job.
continue
}

// If the job has an associated task, try to stop the task, effectively cancelling the job.
if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil {
return cancelledJobs, err
}
cancelledJobs = append(cancelledJobs, job)
cjs, err := CancelJobs(ctx, jobs)
if err != nil {
return cancelledJobs, err
}
cancelledJobs = append(cancelledJobs, cjs...)
}

// Return nil to indicate successful cancellation of all running and waiting jobs.
return cancelledJobs, nil
}

// InsertRun inserts a run
// The title will be cut off at 255 characters if it's longer than 255 characters.
func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error {
return db.WithTx(ctx, func(ctx context.Context) error {
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
if err != nil {
return err
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
// Iterate over each job and attempt to cancel it.
for _, job := range jobs {
// Skip jobs that are already in a terminal state (completed, cancelled, etc.).
status := job.Status
if status.IsDone() {
continue
}
run.Index = index
run.Title = util.EllipsisDisplayString(run.Title, 255)

if err := db.Insert(ctx, run); err != nil {
return err
}
// If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it.
if job.TaskID == 0 {
job.Status = StatusCancelled
job.Stopped = timeutil.TimeStampNow()

if run.Repo == nil {
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
// Update the job's status and stopped time in the database.
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
if err != nil {
return err
return cancelledJobs, err
}
run.Repo = repo
}

if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil {
return err
// If the update affected 0 rows, it means the job has changed in the meantime
if n == 0 {
log.Error("Failed to cancel job %d because it has changed", job.ID)
continue
}

cancelledJobs = append(cancelledJobs, job)
// Continue with the next job.
continue
}

runJobs := make([]*ActionRunJob, 0, len(jobs))
var hasWaiting bool
for _, v := range jobs {
id, job := v.Job()
needs := job.Needs()
if err := v.SetJob(id, job.EraseNeeds()); err != nil {
return err
}
payload, _ := v.Marshal()
status := StatusWaiting
if len(needs) > 0 || run.NeedApproval {
status = StatusBlocked
} else {
hasWaiting = true
}
job.Name = util.EllipsisDisplayString(job.Name, 255)
runJobs = append(runJobs, &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
IsForkPullRequest: run.IsForkPullRequest,
Name: job.Name,
WorkflowPayload: payload,
JobID: id,
Needs: needs,
RunsOn: job.RunsOn(),
Status: status,
})
// If the job has an associated task, try to stop the task, effectively cancelling the job.
if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil {
return cancelledJobs, err
}
if err := db.Insert(ctx, runJobs); err != nil {
return err
updatedJob, err := GetRunJobByID(ctx, job.ID)
if err != nil {
return cancelledJobs, fmt.Errorf("get job: %w", err)
}
cancelledJobs = append(cancelledJobs, updatedJob)
}

// if there is a job in the waiting status, increase tasks version.
if hasWaiting {
if err := IncreaseTaskVersion(ctx, run.OwnerID, run.RepoID); err != nil {
return err
}
}
return nil
})
// Return nil to indicate successful cancellation of all running and waiting jobs.
return cancelledJobs, nil
}

func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, error) {
Expand Down Expand Up @@ -441,7 +390,7 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
if err = run.LoadRepo(ctx); err != nil {
return err
}
if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil {
if err := UpdateRepoRunsNumbers(ctx, run.Repo); err != nil {
return err
}
}
Expand All @@ -450,3 +399,59 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
}

type ActionRunIndex db.ResourceIndex

func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRun, []*ActionRunJob, error) {
runs, err := db.Find[ActionRun](ctx, &FindRunOptions{
RepoID: repoID,
ConcurrencyGroup: concurrencyGroup,
Status: status,
})
if err != nil {
return nil, nil, fmt.Errorf("find runs: %w", err)
}

jobs, err := db.Find[ActionRunJob](ctx, &FindRunJobOptions{
RepoID: repoID,
ConcurrencyGroup: concurrencyGroup,
Statuses: status,
})
if err != nil {
return nil, nil, fmt.Errorf("find jobs: %w", err)
}

return runs, jobs, nil
}

func CancelPreviousJobsByRunConcurrency(ctx context.Context, actionRun *ActionRun) ([]*ActionRunJob, error) {
if actionRun.ConcurrencyGroup == "" {
return nil, nil
}

var jobsToCancel []*ActionRunJob

statusFindOption := []Status{StatusWaiting, StatusBlocked}
if actionRun.ConcurrencyCancel {
statusFindOption = append(statusFindOption, StatusRunning)
}
runs, jobs, err := GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, statusFindOption)
if err != nil {
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
}
jobsToCancel = append(jobsToCancel, jobs...)

// cancel runs in the same concurrency group
for _, run := range runs {
if run.ID == actionRun.ID {
continue
}
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
RunID: run.ID,
})
if err != nil {
return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
}
jobsToCancel = append(jobsToCancel, jobs...)
}

return CancelJobs(ctx, jobsToCancel)
}
75 changes: 63 additions & 12 deletions models/actions/run_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,38 @@ type ActionRunJob struct {
ID int64
RunID int64 `xorm:"index"`
Run *ActionRun `xorm:"-"`
RepoID int64 `xorm:"index"`
RepoID int64 `xorm:"index(repo_concurrency)"`
Repo *repo_model.Repository `xorm:"-"`
OwnerID int64 `xorm:"index"`
CommitSHA string `xorm:"index"`
IsForkPullRequest bool
Name string `xorm:"VARCHAR(255)"`
Attempt int64
WorkflowPayload []byte
JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id
Needs []string `xorm:"JSON TEXT"`
RunsOn []string `xorm:"JSON TEXT"`
TaskID int64 // the latest task of the job
Status Status `xorm:"index"`
Started timeutil.TimeStamp
Stopped timeutil.TimeStamp
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated index"`

// WorkflowPayload is act/jobparser.SingleWorkflow for act/jobparser.Parse
// it should contain exactly one job with global workflow fields for this model
WorkflowPayload []byte

JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id
Needs []string `xorm:"JSON TEXT"`
RunsOn []string `xorm:"JSON TEXT"`
TaskID int64 // the latest task of the job
Status Status `xorm:"index"`

RawConcurrency string // raw concurrency from job YAML's "concurrency" section

// IsConcurrencyEvaluated is only valid/needed when this job's RawConcurrency is not empty.
// If RawConcurrency can't be evaluated (e.g. depend on other job's outputs or have errors), this field will be false.
// If RawConcurrency has been successfully evaluated, this field will be true, ConcurrencyGroup and ConcurrencyCancel are also set.
IsConcurrencyEvaluated bool

ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress

Started timeutil.TimeStamp
Stopped timeutil.TimeStamp
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated index"`
}

func init() {
Expand Down Expand Up @@ -125,7 +140,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
return affected, nil
}

if affected != 0 && slices.Contains(cols, "status") && job.Status.IsWaiting() {
if slices.Contains(cols, "status") && job.Status.IsWaiting() {
// if the status of job changes to waiting again, increase tasks version.
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
return 0, err
Expand Down Expand Up @@ -197,3 +212,39 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
return StatusUnknown // it shouldn't happen
}
}

func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) {
if job.RawConcurrency == "" {
return nil, nil
}
if !job.IsConcurrencyEvaluated {
return nil, nil
}
if job.ConcurrencyGroup == "" {
return nil, nil
}

statusFindOption := []Status{StatusWaiting, StatusBlocked}
if job.ConcurrencyCancel {
statusFindOption = append(statusFindOption, StatusRunning)
}
runs, jobs, err := GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
if err != nil {
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
}
jobs = slices.DeleteFunc(jobs, func(j *ActionRunJob) bool { return j.ID == job.ID })
jobsToCancel = append(jobsToCancel, jobs...)

// cancel runs in the same concurrency group
for _, run := range runs {
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
RunID: run.ID,
})
if err != nil {
return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
}
jobsToCancel = append(jobsToCancel, jobs...)
}

return CancelJobs(ctx, jobsToCancel)
}
19 changes: 13 additions & 6 deletions models/actions/run_job_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) err

type FindRunJobOptions struct {
db.ListOptions
RunID int64
RepoID int64
OwnerID int64
CommitSHA string
Statuses []Status
UpdatedBefore timeutil.TimeStamp
RunID int64
RepoID int64
OwnerID int64
CommitSHA string
Statuses []Status
UpdatedBefore timeutil.TimeStamp
ConcurrencyGroup string
}

func (opts FindRunJobOptions) ToConds() builder.Cond {
Expand All @@ -94,6 +95,12 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
if opts.UpdatedBefore > 0 {
cond = cond.And(builder.Lt{"`action_run_job`.updated": opts.UpdatedBefore})
}
if opts.ConcurrencyGroup != "" {
if opts.RepoID == 0 {
panic("Invalid FindRunJobOptions: repo_id is required")
}
cond = cond.And(builder.Eq{"`action_run_job`.concurrency_group": opts.ConcurrencyGroup})
}
return cond
}

Expand Down
Loading