mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-04 08:27:31 +00:00
10c678a0a0
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>
586 lines
22 KiB
Go
586 lines
22 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
actions_model "gitea.dev/models/actions"
|
|
"gitea.dev/models/db"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unittest"
|
|
user_model "gitea.dev/models/user"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func minimalWorkflowPayload(jobID string) []byte {
|
|
return fmt.Appendf(nil, `name: test
|
|
on: push
|
|
jobs:
|
|
%s:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo
|
|
`, jobID)
|
|
}
|
|
|
|
func Test_jobStatusResolver_Resolve(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
jobs actions_model.ActionJobList
|
|
want map[int64]actions_model.Status
|
|
}{
|
|
{
|
|
name: "no blocked",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
},
|
|
want: map[int64]actions_model.Status{},
|
|
},
|
|
{
|
|
name: "single blocked",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
},
|
|
want: map[int64]actions_model.Status{
|
|
2: actions_model.StatusWaiting,
|
|
},
|
|
},
|
|
{
|
|
name: "multiple blocked",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
},
|
|
want: map[int64]actions_model.Status{
|
|
2: actions_model.StatusWaiting,
|
|
3: actions_model.StatusWaiting,
|
|
},
|
|
},
|
|
{
|
|
name: "chain blocked",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusFailure, Needs: []string{}},
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
|
|
},
|
|
want: map[int64]actions_model.Status{
|
|
2: actions_model.StatusSkipped,
|
|
3: actions_model.StatusSkipped,
|
|
},
|
|
},
|
|
{
|
|
name: "loop need",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusBlocked, Needs: []string{"3"}},
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
|
|
},
|
|
want: map[int64]actions_model.Status{},
|
|
},
|
|
{
|
|
name: "`if` is not empty and all jobs in `needs` completed successfully",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
|
`
|
|
name: test
|
|
on: push
|
|
jobs:
|
|
job2:
|
|
runs-on: ubuntu-latest
|
|
needs: job1
|
|
if: ${{ always() && needs.job1.result == 'success' }}
|
|
steps:
|
|
- run: echo "will be checked by act_runner"
|
|
`)},
|
|
},
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
|
},
|
|
{
|
|
name: "`if` is not empty and not all jobs in `needs` completed successfully",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
|
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
|
`
|
|
name: test
|
|
on: push
|
|
jobs:
|
|
job2:
|
|
runs-on: ubuntu-latest
|
|
needs: job1
|
|
if: ${{ always() && needs.job1.result == 'failure' }}
|
|
steps:
|
|
- run: echo "will be checked by act_runner"
|
|
`)},
|
|
},
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
|
},
|
|
{
|
|
name: "`if` is empty and not all jobs in `needs` completed successfully",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
|
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
|
`
|
|
name: test
|
|
on: push
|
|
jobs:
|
|
job2:
|
|
runs-on: ubuntu-latest
|
|
needs: job1
|
|
steps:
|
|
- run: echo "should be skipped"
|
|
`)},
|
|
},
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
|
|
},
|
|
{
|
|
name: "max-parallel: a freed slot promotes the lowest blocked job id",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "build", Status: actions_model.StatusSuccess, Needs: []string{}, MaxParallel: 1},
|
|
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
|
|
{ID: 3, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
|
|
},
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
|
},
|
|
{
|
|
name: "max-parallel: a cancelling job still holds its slot",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "test", Status: actions_model.StatusRunning, Needs: []string{}, MaxParallel: 2},
|
|
{ID: 2, JobID: "test", Status: actions_model.StatusCancelling, Needs: []string{}, MaxParallel: 2},
|
|
{ID: 3, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 2},
|
|
},
|
|
want: map[int64]actions_model.Status{},
|
|
},
|
|
{
|
|
name: "max-parallel: two freed slots promote two jobs",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "test", Status: actions_model.StatusCancelled, Needs: []string{}, MaxParallel: 2},
|
|
{ID: 2, JobID: "test", Status: actions_model.StatusSuccess, Needs: []string{}, MaxParallel: 2},
|
|
{ID: 3, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 2},
|
|
{ID: 4, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 2},
|
|
},
|
|
want: map[int64]actions_model.Status{3: actions_model.StatusWaiting, 4: actions_model.StatusWaiting},
|
|
},
|
|
{
|
|
name: "max-parallel: slots are counted per job id",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "build", Status: actions_model.StatusRunning, Needs: []string{}, MaxParallel: 1},
|
|
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
|
|
{ID: 3, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
|
|
},
|
|
want: map[int64]actions_model.Status{3: actions_model.StatusWaiting},
|
|
},
|
|
{
|
|
name: "max-parallel: slots are scoped per reusable workflow caller",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "build", ParentJobID: 10, Status: actions_model.StatusRunning, Needs: []string{}, MaxParallel: 1},
|
|
{ID: 2, JobID: "build", ParentJobID: 10, Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
|
|
{ID: 3, JobID: "build", ParentJobID: 20, Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
|
|
},
|
|
want: map[int64]actions_model.Status{3: actions_model.StatusWaiting},
|
|
},
|
|
{
|
|
name: "max-parallel: a caller promoted in an earlier round keeps its slot",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true},
|
|
{ID: 2, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true},
|
|
},
|
|
want: map[int64]actions_model.Status{1: actions_model.StatusWaiting},
|
|
},
|
|
{
|
|
name: "max-parallel: an expanded caller aggregated back to Blocked keeps its slot",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true, IsExpanded: true},
|
|
{ID: 2, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true},
|
|
},
|
|
want: map[int64]actions_model.Status{},
|
|
},
|
|
{
|
|
name: "`if` is empty and a failed need has continue-on-error",
|
|
jobs: actions_model.ActionJobList{
|
|
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, ContinueOnError: true, Needs: []string{}},
|
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
|
`
|
|
name: test
|
|
on: push
|
|
jobs:
|
|
job2:
|
|
runs-on: ubuntu-latest
|
|
needs: job1
|
|
steps:
|
|
- run: echo "should run, job1 failure is masked by continue-on-error"
|
|
`)},
|
|
},
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
|
},
|
|
}
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
ctx := t.Context()
|
|
stubRun := &actions_model.ActionRun{TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{}}
|
|
for i, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
|
|
runID := int64(9001 + i)
|
|
attemptID := int64(9001 + i)
|
|
|
|
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
|
|
idMap := make(map[int64]int64, len(tt.jobs))
|
|
for _, j := range tt.jobs {
|
|
origID := j.ID
|
|
j.ID = 0
|
|
j.RunID = runID
|
|
j.RunAttemptID = attemptID
|
|
j.Run = stubRun
|
|
|
|
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
|
|
// supply a minimal one when the case didn't.
|
|
if j.Status == actions_model.StatusBlocked && len(j.WorkflowPayload) == 0 {
|
|
j.WorkflowPayload = minimalWorkflowPayload(j.JobID)
|
|
}
|
|
|
|
assert.NoError(t, db.Insert(ctx, j))
|
|
idMap[origID] = j.ID
|
|
}
|
|
|
|
want := make(map[int64]actions_model.Status, len(tt.want))
|
|
for k, v := range tt.want {
|
|
want[idMap[k]] = v
|
|
}
|
|
|
|
r := newJobStatusResolver(tt.jobs, nil)
|
|
assert.Equal(t, want, r.Resolve(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test_maxParallelConverges covers the liveness property the status table cannot express:
|
|
// repeated resolve cycles never stall and never overshoot the cap.
|
|
func Test_maxParallelConverges(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
const totalJobs, maxParallel = 5, 2
|
|
jobs := make(actions_model.ActionJobList, totalJobs)
|
|
for i := range jobs {
|
|
jobs[i] = &actions_model.ActionRunJob{
|
|
ID: int64(i + 1), JobID: "matrix", Status: actions_model.StatusBlocked, MaxParallel: maxParallel,
|
|
WorkflowPayload: minimalWorkflowPayload("matrix"),
|
|
}
|
|
}
|
|
|
|
for cycle := range totalJobs + 1 {
|
|
for id, status := range newJobStatusResolver(jobs, nil).Resolve(ctx) {
|
|
jobs[id-1].Status = status
|
|
}
|
|
counts := statusCounts(jobs)
|
|
remaining := totalJobs - counts[actions_model.StatusSuccess]
|
|
assert.Equal(t, min(remaining, maxParallel), counts[actions_model.StatusWaiting]+counts[actions_model.StatusRunning],
|
|
"cycle %d: active jobs must fill every free slot without exceeding max-parallel", cycle)
|
|
|
|
for _, job := range jobs { // a runner picks up every waiting job
|
|
if job.Status == actions_model.StatusWaiting {
|
|
job.Status = actions_model.StatusRunning
|
|
}
|
|
}
|
|
for _, job := range jobs { // the first running job finishes
|
|
if job.Status == actions_model.StatusRunning {
|
|
job.Status = actions_model.StatusSuccess
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, totalJobs, statusCounts(jobs)[actions_model.StatusSuccess])
|
|
}
|
|
|
|
// Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck verifies that when a run's
|
|
// ConcurrencyGroup has already been checked at the run level, the same group is not
|
|
// re-checked for individual jobs.
|
|
func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
ctx := t.Context()
|
|
|
|
// Run A: the triggering run of attempt A. It is done, so it no longer holds "test-cg", which is what lets checkRunConcurrency wake the blocked waiter.
|
|
runA := &actions_model.ActionRun{
|
|
RepoID: 4,
|
|
OwnerID: 1,
|
|
TriggerUserID: 1,
|
|
WorkflowID: "test.yml",
|
|
Index: 9901,
|
|
Ref: "refs/heads/main",
|
|
Status: actions_model.StatusSuccess,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runA))
|
|
|
|
// Attempt A: a done attempt of run A with concurrency group "test-cg"
|
|
runAAttempt := &actions_model.ActionRunAttempt{
|
|
RepoID: 4,
|
|
RunID: runA.ID,
|
|
Attempt: 1,
|
|
Status: actions_model.StatusSuccess,
|
|
ConcurrencyGroup: "test-cg",
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runAAttempt))
|
|
_, err := db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
|
|
assert.NoError(t, err)
|
|
|
|
// A done job for run A with the same ConcurrencyGroup.
|
|
// This triggers the job-level concurrency check in checkRunConcurrency.
|
|
jobADone := &actions_model.ActionRunJob{
|
|
RunID: runA.ID,
|
|
RunAttemptID: runAAttempt.ID,
|
|
AttemptJobID: 1,
|
|
RepoID: 4,
|
|
OwnerID: 1,
|
|
JobID: "job1",
|
|
Name: "job1",
|
|
Status: actions_model.StatusSuccess,
|
|
ConcurrencyGroup: "test-cg",
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, jobADone))
|
|
|
|
// Run B: a run blocked by concurrency
|
|
runB := &actions_model.ActionRun{
|
|
RepoID: 4,
|
|
OwnerID: 1,
|
|
TriggerUserID: 1,
|
|
WorkflowID: "test.yml",
|
|
Index: 9902,
|
|
Ref: "refs/heads/main",
|
|
Status: actions_model.StatusBlocked,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runB))
|
|
|
|
// Attempt B: an blocked attempt of run B
|
|
runBAttempt := &actions_model.ActionRunAttempt{
|
|
RepoID: 4,
|
|
RunID: runB.ID,
|
|
Attempt: 1,
|
|
Status: actions_model.StatusBlocked,
|
|
ConcurrencyGroup: "test-cg",
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runBAttempt))
|
|
_, err = db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
|
|
assert.NoError(t, err)
|
|
|
|
// A blocked job belonging to run B (no job-level concurrency group).
|
|
jobBBlocked := &actions_model.ActionRunJob{
|
|
RunID: runB.ID,
|
|
RunAttemptID: runBAttempt.ID,
|
|
AttemptJobID: 1,
|
|
RepoID: 4,
|
|
OwnerID: 1,
|
|
JobID: "job1",
|
|
Name: "job1",
|
|
Status: actions_model.StatusBlocked,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, jobBBlocked))
|
|
|
|
runA, _, _ = db.GetByID[actions_model.ActionRun](t.Context(), runA.ID)
|
|
result, err := checkRunConcurrency(ctx, runA)
|
|
assert.NoError(t, err)
|
|
|
|
// "test-cg" is free, so the single blocked waiter (run B) is collected for re-emit.
|
|
if assert.Len(t, result.RunIDsToReEmit, 1) {
|
|
assert.Equal(t, runB.ID, result.RunIDsToReEmit[0])
|
|
}
|
|
assert.Empty(t, result.Jobs)
|
|
}
|
|
|
|
// Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked verifies that
|
|
// the resolver does not transition a job out of Blocked while another run still holds
|
|
// the workflow-level concurrency group. Regression for #37446.
|
|
func Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
ctx := t.Context()
|
|
|
|
const group = "test-run-level-concurrency-keeps-blocked"
|
|
|
|
// Holder run: Running attempt in the concurrency group.
|
|
holderRun := &actions_model.ActionRun{
|
|
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
|
|
WorkflowID: "test.yml", Index: 9911, Ref: "refs/heads/main",
|
|
Status: actions_model.StatusRunning,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, holderRun))
|
|
holderAttempt := &actions_model.ActionRunAttempt{
|
|
RepoID: 4, RunID: holderRun.ID, Attempt: 1,
|
|
Status: actions_model.StatusRunning, ConcurrencyGroup: group,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, holderAttempt))
|
|
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", holderAttempt.ID, holderRun.ID)
|
|
assert.NoError(t, err)
|
|
|
|
// Blocked run: Blocked attempt in the same group, with one Blocked job that has
|
|
// no needs and no job-level concurrency. Without the run-level guard in
|
|
// checkJobsOfCurrentRunAttempt, the resolver would transition this job to Waiting.
|
|
blockedRun := &actions_model.ActionRun{
|
|
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
|
|
WorkflowID: "test.yml", Index: 9912, Ref: "refs/heads/main",
|
|
Status: actions_model.StatusBlocked,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, blockedRun))
|
|
blockedAttempt := &actions_model.ActionRunAttempt{
|
|
RepoID: 4, RunID: blockedRun.ID, Attempt: 1,
|
|
Status: actions_model.StatusBlocked, ConcurrencyGroup: group,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, blockedAttempt))
|
|
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", blockedAttempt.ID, blockedRun.ID)
|
|
assert.NoError(t, err)
|
|
blockedRun.LatestAttemptID = blockedAttempt.ID
|
|
blockedJob := &actions_model.ActionRunJob{
|
|
RunID: blockedRun.ID, RunAttemptID: blockedAttempt.ID, AttemptJobID: 1,
|
|
RepoID: 4, OwnerID: 1, JobID: "job1", Name: "job1",
|
|
Status: actions_model.StatusBlocked,
|
|
WorkflowPayload: []byte(`
|
|
name: test
|
|
on: push
|
|
jobs:
|
|
job1:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo
|
|
`),
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, blockedJob))
|
|
|
|
result, err := checkJobsOfCurrentRunAttempt(ctx, blockedRun)
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, result.UpdatedJobs)
|
|
|
|
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
|
|
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
|
|
}
|
|
|
|
// Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job.
|
|
func Test_checkRunConcurrency_HeldGroupDoesNotWake(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
ctx := t.Context()
|
|
|
|
// Run A holds "test-cg": its attempt is still running.
|
|
runA := &actions_model.ActionRun{
|
|
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
|
|
Index: 9911, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runA))
|
|
runAAttempt := &actions_model.ActionRunAttempt{
|
|
RepoID: 4, RunID: runA.ID, Attempt: 1, Status: actions_model.StatusRunning, ConcurrencyGroup: "test-cg",
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runAAttempt))
|
|
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
|
|
assert.NoError(t, err)
|
|
|
|
// Run B is blocked on the same group.
|
|
runB := &actions_model.ActionRun{
|
|
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
|
|
Index: 9912, Ref: "refs/heads/main", Status: actions_model.StatusBlocked,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runB))
|
|
runBAttempt := &actions_model.ActionRunAttempt{
|
|
RepoID: 4, RunID: runB.ID, Attempt: 1, Status: actions_model.StatusBlocked, ConcurrencyGroup: "test-cg",
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, runBAttempt))
|
|
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
|
|
assert.NoError(t, err)
|
|
|
|
runA, _, _ = db.GetByID[actions_model.ActionRun](ctx, runA.ID)
|
|
result, err := checkRunConcurrency(ctx, runA)
|
|
assert.NoError(t, err)
|
|
|
|
// The group is held by run A, so run B must not be woken; A will wake it when it releases the group.
|
|
assert.Empty(t, result.RunIDsToReEmit)
|
|
}
|
|
|
|
// Test_findConcurrencyWaiterToWake covers the finder's contract: it skips the run being processed (excludeRunID),
|
|
// returns another blocked waiter when the group is free, and returns 0 while the group is still held.
|
|
func Test_findConcurrencyWaiterToWake(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
ctx := t.Context()
|
|
|
|
const repoID int64 = 4
|
|
seed := func(index int64, group string, status actions_model.Status) *actions_model.ActionRun {
|
|
run := &actions_model.ActionRun{
|
|
RepoID: repoID, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
|
|
Index: index, Ref: "refs/heads/main", Status: status,
|
|
}
|
|
assert.NoError(t, db.Insert(ctx, run))
|
|
assert.NoError(t, db.Insert(ctx, &actions_model.ActionRunAttempt{
|
|
RepoID: repoID, RunID: run.ID, Attempt: 1, Status: status, ConcurrencyGroup: group,
|
|
}))
|
|
return run
|
|
}
|
|
|
|
// Free group "excl-cg" with two blocked runs: excluding self returns the other waiter, not self.
|
|
self := seed(99701, "excl-cg", actions_model.StatusBlocked)
|
|
other := seed(99702, "excl-cg", actions_model.StatusBlocked)
|
|
id, err := findConcurrencyWaiterToWake(ctx, repoID, self.ID, "excl-cg")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, other.ID, id)
|
|
|
|
// Free group "solo-cg" with only self blocked: excluding it leaves no waiter.
|
|
solo := seed(99703, "solo-cg", actions_model.StatusBlocked)
|
|
id, err = findConcurrencyWaiterToWake(ctx, repoID, solo.ID, "solo-cg")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(0), id)
|
|
|
|
// Held group "held-cg" (a running holder) has a blocked waiter, but nothing is woken while held.
|
|
seed(99704, "held-cg", actions_model.StatusRunning)
|
|
seed(99705, "held-cg", actions_model.StatusBlocked)
|
|
id, err = findConcurrencyWaiterToWake(ctx, repoID, 0, "held-cg")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(0), id)
|
|
}
|
|
|
|
func Test_maxParallelReusableCallerLifecycle(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
const callerJobNum = 3
|
|
callers := make(actions_model.ActionJobList, callerJobNum)
|
|
idToCaller := make(map[int64]*actions_model.ActionRunJob, callerJobNum)
|
|
for i := range callers {
|
|
callers[i] = &actions_model.ActionRunJob{
|
|
ID: int64(i + 1), JobID: "call", Status: actions_model.StatusBlocked, MaxParallel: 1,
|
|
IsReusableCaller: true, WorkflowPayload: minimalWorkflowPayload("call"),
|
|
}
|
|
idToCaller[callers[i].ID] = callers[i]
|
|
}
|
|
underway := func() (n int) {
|
|
for _, caller := range callers {
|
|
if caller.IsExpanded && !caller.Status.IsDone() {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
for cycle := range 2 * len(callers) {
|
|
promoted := newJobStatusResolver(callers, nil).Resolve(ctx)
|
|
for id, status := range promoted {
|
|
caller := idToCaller[id]
|
|
assert.False(t, caller.IsExpanded, "cycle %d: resolver re-promoted already-expanded caller %d", cycle, id)
|
|
assert.Equal(t, actions_model.StatusWaiting, status, "cycle %d: caller %d", cycle, id)
|
|
caller.IsExpanded = true // the emitter expands the caller; children insert as Blocked, so the aggregate keeps it Blocked
|
|
}
|
|
assert.LessOrEqual(t, underway(), 1, "cycle %d: at most one caller may be underway", cycle)
|
|
|
|
if len(promoted) == 0 { // steady state: the underway caller's children finish and cascade Success to it
|
|
for _, caller := range callers {
|
|
if caller.IsExpanded && !caller.Status.IsDone() {
|
|
caller.Status = actions_model.StatusSuccess
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, len(callers), statusCounts(callers)[actions_model.StatusSuccess])
|
|
}
|