mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-08 09:53:10 +02:00
feat: Add max-parallel implementation inside the Gitea server
# Conflicts: # models/migrations/migrations.go # models/migrations/v1_26/v325.go # Conflicts: # models/actions/run_job.go # models/actions/runner.go # models/migrations/migrations.go # modules/structs/repo_actions.go # routers/api/actions/runner/runner.go # routers/api/v1/admin/runners.go # routers/api/v1/api.go # routers/api/v1/shared/runners.go # services/actions/run.go # templates/swagger/v1_json.tmpl # Conflicts: # models/migrations/migrations.go
This commit is contained in:
1 parent
a1c60ac854
commit
57400c725e
9 files changed
+958
-3
No files matched your search
@@ -6,6 +6,7 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
@@ -133,6 +134,14 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar
|
||||
runJob.TokenPermissions = perms
|
||||
}
|
||||
|
||||
|
||||
// Extract max-parallel from strategy if present
|
||||
if job.Strategy.MaxParallelString != "" {
|
||||
if maxParallel, err := strconv.Atoi(job.Strategy.MaxParallelString); err == nil && maxParallel > 0 {
|
||||
runJob.MaxParallel = maxParallel
|
||||
}
|
||||
}
|
||||
|
||||
// check job concurrency
|
||||
if job.RawConcurrency != nil {
|
||||
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateTaskForRunner_CapacityEnforcement(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("RunnerAtCapacity", func(t *testing.T) {
|
||||
// Create runner with capacity 2
|
||||
runner := &actions_model.ActionRunner{
|
||||
UUID: "capacity-test-1",
|
||||
Name: "Capacity Test Runner",
|
||||
Capacity: 2,
|
||||
TokenHash: "capacity_test_hash_1",
|
||||
Token: "capacity_test_token_1",
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), runner))
|
||||
|
||||
// Create 2 running tasks
|
||||
for i := range 2 {
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: int64(1000 + i),
|
||||
RunnerID: runner.ID,
|
||||
Status: actions_model.StatusRunning,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
TokenHash: "task_hash_" + string(rune('1'+i)),
|
||||
Token: "task_token_" + string(rune('1'+i)),
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), task))
|
||||
}
|
||||
|
||||
// Verify runner is at capacity
|
||||
count, err := actions_model.CountRunningTasksByRunner(context.Background(), runner.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
// Try to create another task - should fail due to capacity
|
||||
// Note: This would be tested in actual CreateTaskForRunner which checks capacity
|
||||
// For now, verify the count
|
||||
assert.Equal(t, runner.Capacity, count, "Runner should be at capacity")
|
||||
})
|
||||
|
||||
t.Run("RunnerBelowCapacity", func(t *testing.T) {
|
||||
runner := &actions_model.ActionRunner{
|
||||
UUID: "capacity-test-2",
|
||||
Name: "Below Capacity Runner",
|
||||
Capacity: 5,
|
||||
TokenHash: "capacity_test_hash_2",
|
||||
Token: "capacity_test_token_2",
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), runner))
|
||||
|
||||
// Create 2 running tasks
|
||||
for i := range 2 {
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: int64(2000 + i),
|
||||
RunnerID: runner.ID,
|
||||
Status: actions_model.StatusRunning,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
TokenHash: "task_hash_2_" + string(rune('a'+i)),
|
||||
Token: "task_token_2_" + string(rune('a'+i)),
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), task))
|
||||
}
|
||||
|
||||
count, err := actions_model.CountRunningTasksByRunner(context.Background(), runner.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Less(t, count, runner.Capacity, "Runner should be below capacity")
|
||||
})
|
||||
|
||||
t.Run("UnlimitedCapacity", func(t *testing.T) {
|
||||
runner := &actions_model.ActionRunner{
|
||||
UUID: "capacity-test-3",
|
||||
Name: "Unlimited Runner",
|
||||
Capacity: 0, // 0 = unlimited
|
||||
TokenHash: "capacity_test_hash_3",
|
||||
Token: "capacity_test_token_3",
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), runner))
|
||||
|
||||
// Create many running tasks
|
||||
for i := range 10 {
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: int64(3000 + i),
|
||||
RunnerID: runner.ID,
|
||||
Status: actions_model.StatusRunning,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
TokenHash: "task_hash_3_" + string(rune('a'+i)),
|
||||
Token: "task_token_3_" + string(rune('a'+i)),
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), task))
|
||||
}
|
||||
|
||||
count, err := actions_model.CountRunningTasksByRunner(context.Background(), runner.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 10, count)
|
||||
// With capacity 0, there's no limit
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTaskForRunner_MaxParallelEnforcement(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("MaxParallelReached", func(t *testing.T) {
|
||||
runID := int64(10000)
|
||||
jobID := "max-parallel-job"
|
||||
maxParallel := 2
|
||||
|
||||
// Create ActionRun first
|
||||
run := &actions_model.ActionRun{
|
||||
ID: runID,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
Index: 10000,
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), run))
|
||||
|
||||
// Create waiting jobs
|
||||
for range 4 {
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: runID,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
JobID: jobID,
|
||||
Name: "Test Job",
|
||||
Status: actions_model.StatusWaiting,
|
||||
MaxParallel: maxParallel,
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), job))
|
||||
}
|
||||
|
||||
// Start 2 jobs (max-parallel limit)
|
||||
jobs, err := actions_model.GetRunJobsByRunID(context.Background(), runID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, jobs, 4)
|
||||
|
||||
for i := range 2 {
|
||||
jobs[i].Status = actions_model.StatusRunning
|
||||
_, err := actions_model.UpdateRunJob(context.Background(), jobs[i], nil, "status")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Verify max-parallel is enforced
|
||||
runningCount, err := actions_model.CountRunningJobsByWorkflowAndRun(context.Background(), runID, jobID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, maxParallel, runningCount)
|
||||
|
||||
// Remaining jobs should stay in waiting
|
||||
remainingJobs, err := actions_model.GetRunJobsByRunID(context.Background(), runID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
waitingCount := 0
|
||||
for _, job := range remainingJobs {
|
||||
if job.Status == actions_model.StatusWaiting {
|
||||
waitingCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, waitingCount)
|
||||
})
|
||||
|
||||
t.Run("MaxParallelNotSet", func(t *testing.T) {
|
||||
runID := int64(20000)
|
||||
jobID := "no-limit-job"
|
||||
|
||||
// Create ActionRun first
|
||||
run := &actions_model.ActionRun{
|
||||
ID: runID,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
Index: 20000,
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), run))
|
||||
|
||||
// Create jobs without max-parallel
|
||||
for range 5 {
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: runID,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
JobID: jobID,
|
||||
Name: "Test Job",
|
||||
Status: actions_model.StatusWaiting,
|
||||
MaxParallel: 0, // No limit
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), job))
|
||||
}
|
||||
|
||||
// All jobs can run simultaneously
|
||||
jobs, err := actions_model.GetRunJobsByRunID(context.Background(), runID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for _, job := range jobs {
|
||||
job.Status = actions_model.StatusRunning
|
||||
_, err := actions_model.UpdateRunJob(context.Background(), job, nil, "status")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
runningCount, err := actions_model.CountRunningJobsByWorkflowAndRun(context.Background(), runID, jobID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 5, runningCount, "All jobs should be able to run without limit")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTaskForRunner_CombinedEnforcement(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("BothRunnerCapacityAndMaxParallel", func(t *testing.T) {
|
||||
// Create runner with capacity 3
|
||||
runner := &actions_model.ActionRunner{
|
||||
UUID: "combined-test",
|
||||
Name: "Combined Test Runner",
|
||||
Capacity: 3,
|
||||
TokenHash: "combined_test_hash",
|
||||
Token: "combined_test_token",
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), runner))
|
||||
|
||||
runID := int64(30000)
|
||||
jobID := "combined-job"
|
||||
|
||||
// Create ActionRun first
|
||||
run := &actions_model.ActionRun{
|
||||
ID: runID,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
Index: 30000,
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), run))
|
||||
|
||||
// Create jobs with max-parallel 2
|
||||
for range 5 {
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: runID,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
JobID: jobID,
|
||||
Name: "Combined Job",
|
||||
Status: actions_model.StatusWaiting,
|
||||
MaxParallel: 2,
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), job))
|
||||
}
|
||||
|
||||
// The most restrictive limit should apply
|
||||
// In this case: max-parallel = 2 (more restrictive than runner capacity = 3)
|
||||
jobs, err := actions_model.GetRunJobsByRunID(context.Background(), runID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Simulate starting jobs
|
||||
for i, job := range jobs[:2] {
|
||||
job.Status = actions_model.StatusRunning
|
||||
_, err := actions_model.UpdateRunJob(context.Background(), job, nil, "status")
|
||||
assert.NoError(t, err)
|
||||
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID,
|
||||
RunnerID: runner.ID,
|
||||
Status: actions_model.StatusRunning,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
TokenHash: "combined_task_hash_" + string(rune('a'+i)),
|
||||
Token: "combined_task_token_" + string(rune('a'+i)),
|
||||
}
|
||||
assert.NoError(t, db.Insert(context.Background(), task))
|
||||
}
|
||||
|
||||
// Verify both limits
|
||||
runningTasks, err := actions_model.CountRunningTasksByRunner(context.Background(), runner.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, runningTasks)
|
||||
assert.Less(t, runningTasks, runner.Capacity, "Should be under runner capacity")
|
||||
|
||||
runningJobs, err := actions_model.CountRunningJobsByWorkflowAndRun(context.Background(), runID, jobID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, runningJobs, "Should respect max-parallel")
|
||||
})
|
||||
}
|
||||
@@ -524,6 +524,7 @@ func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *ap
|
||||
Disabled: runner.IsDisabled,
|
||||
Ephemeral: runner.Ephemeral,
|
||||
Labels: labels,
|
||||
Capacity: runner.Capacity,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user