Files
gita/tests/integration/actions_concurrent_claim_test.go
T
admin e4afba3416
giteabot backport / giteabot (push) Canceled after 0s
giteabot / giteabot (push) Canceled after 0s
release-nightly / nightly-binary (push) Canceled after 0s
release-nightly / nightly-container (push) Canceled after 0s
cache-seeder / gobuild (push) Canceled after 0s
cache-seeder / lint (bindata, lint-backend) (push) Canceled after 0s
release-nightly-snapcraft / build-and-publish (push) Canceled after 0s
chore: initialize Gitea v1.27.2 fork
Includes direct password setup links in registration emails.

Assisted-by: Codex:GPT-5
2026-08-15 22:56:18 +08:00

119 lines
3.9 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"sync"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps.
func minimalConcurrentWorkflowPayload(jobID string) []byte {
return []byte("on: push\njobs:\n " + jobID + ":\n runs-on: ubuntu-latest\n")
}
// TestCreateTaskForRunnerConcurrentClaim verifies that when multiple runners
// poll simultaneously and all initially see the same first waiting job,
// each runner claims a distinct job rather than all but one being left
// empty-handed. This is the regression test for the race condition where
// runners losing the optimistic-lock on job #1 would receive latestVersion
// and never retry the remaining 49+ jobs.
//
// It lives in tests/integration rather than a unit test because SQLite
// serializes write transactions, so the contended optimistic-lock path this
// guards only runs concurrently against MySQL/PostgreSQL in CI.
func TestCreateTaskForRunnerConcurrentClaim(t *testing.T) {
defer tests.PrepareTestEnv(t)()
const numJobs = 3
run := &actions_model.ActionRun{
Title: "concurrent-claim-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 9901,
TriggerUserID: 2,
Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: actions_model.StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
jobs := make([]*actions_model.ActionRunJob, numJobs)
for i := range numJobs {
jobID := "concurrent-job-" + string(rune('a'+i))
jobs[i] = &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: jobID,
Attempt: 1,
JobID: jobID,
Status: actions_model.StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: minimalConcurrentWorkflowPayload(jobID),
}
require.NoError(t, db.Insert(t.Context(), jobs[i]))
}
runners := make([]*actions_model.ActionRunner, numJobs)
for i := range numJobs {
r := &actions_model.ActionRunner{
UUID: "concurrent-runner-uuid-" + string(rune('a'+i)),
Name: "concurrent-runner-" + string(rune('a'+i)),
AgentLabels: []string{"ubuntu-latest"},
}
r.GenerateAndFillToken()
runners[i] = r
require.NoError(t, db.Insert(t.Context(), runners[i]))
}
// Simulate the burst: all runners call CreateTaskForRunner concurrently,
// as happens when all see the same stale tasksVersion simultaneously.
type result struct {
task *actions_model.ActionTask
ok bool
err error
}
results := make([]result, numJobs)
var wg sync.WaitGroup
for i := range numJobs {
wg.Go(func() {
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runners[i])
results[i] = result{task, ok, err}
})
}
wg.Wait()
// Every runner must have received a task without error.
claimedJobIDs := make(map[int64]bool)
for i, r := range results {
require.NoError(t, r.err, "runner %d got an unexpected error", i)
require.True(t, r.ok, "runner %d did not get a task even though free jobs exist", i)
require.NotNil(t, r.task)
assert.False(t, claimedJobIDs[r.task.JobID], "job %d was claimed by more than one runner", r.task.JobID)
claimedJobIDs[r.task.JobID] = true
}
assert.Len(t, claimedJobIDs, numJobs, "expected %d distinct jobs to be claimed", numJobs)
// All jobs must now be running with a task assigned.
for _, j := range jobs {
updated := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: j.ID})
assert.Equal(t, actions_model.StatusRunning, updated.Status)
assert.NotZero(t, updated.TaskID)
}
}