perf(actions): batch per-row queries in run, job and cancellation paths (#39723)

Several Actions code paths issued one or more queries per listed row.
They now load in batch, with unchanged output.

- API job list: tasks, steps and runners load in batch
- API run list: latest attempts and their trigger users load in batch
- Repository runs page: the jobs of all active runs load in one query
- Cancelling previous runs and replacing concurrency group entries fetch
their jobs in one query

Per call with 50 rows on SQLite, base vs head:

| Path | Queries | Time |
| --- | --- | --- |
| API job list | 254 → 6 | 12.9 → 1.9 ms |
| API run list | 305 → 207 | 11.7 → 6.2 ms |
| Runs page job loading | 100 → 1 | 6.5 → 0.47 ms |
| `CancelPreviousJobs` | 546 → 496 | ~120 ms, per-job writes dominate |

The remaining run list queries come from per-run repository conversion,
which this PR doesn't change.

---------

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
authored and GitHub committed 2026-10-17 09:47:17 +02:00
1 parent efb0b34395
commit 3058f5ddf9
10 files changed
+241 -121

No files matched your search

+13 -6
View File
@@ -15,6 +15,7 @@ import (
"gitea.dev/models/unittest"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
@@ -126,7 +127,7 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) {
assert.Equal(t, "schedule", apiRun.Event)
}
func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) {
func TestToActionWorkflowJob_StepStatusAndBatchParity(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
@@ -142,10 +143,11 @@ func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) {
require.NoError(t, db.Insert(ctx, run))
task := &actions_model.ActionTask{
ID: 900102,
JobID: 9001,
RepoID: 2,
Status: actions_model.StatusFailure,
ID: 900102,
JobID: 9001,
RepoID: 2,
RunnerID: 34346,
Status: actions_model.StatusFailure,
}
require.NoError(t, db.Insert(ctx, task))
@@ -162,8 +164,8 @@ func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) {
require.NoError(t, db.Insert(ctx, job))
require.NoError(t, db.Insert(ctx,
&actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 0, Name: "step-success", Status: actions_model.StatusSuccess},
&actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 1, Name: "step-failure", Status: actions_model.StatusFailure},
&actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 0, Name: "step-success", Status: actions_model.StatusSuccess},
))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
@@ -172,10 +174,15 @@ func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) {
require.NoError(t, err)
require.Len(t, apiJob.Steps, 2)
assert.Equal(t, "runner_to_be_deleted-user", apiJob.RunnerName)
assert.Equal(t, "completed", apiJob.Steps[0].Status, "step 0 status")
assert.Equal(t, "success", apiJob.Steps[0].Conclusion, "step 0 conclusion (succeeded before the failure)")
assert.Equal(t, "completed", apiJob.Steps[1].Status, "step 1 status")
assert.Equal(t, "failure", apiJob.Steps[1].Conclusion, "step 1 conclusion")
apiJobs, err := ToActionWorkflowJobs(ctx, []*actions_model.ActionRunJob{job})
require.NoError(t, err)
assert.Equal(t, []*api.ActionWorkflowJob{apiJob}, apiJobs)
}
func TestToActionsStatus(t *testing.T) {
+63 -27
View File
@@ -491,11 +491,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
return nil, err
}
status, conclusion := ToRunActionsStatus(job.Run, job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
var runner *actions_model.ActionRunner
if effectiveTaskID := job.EffectiveTaskID(); effectiveTaskID != 0 {
if task == nil {
task, _, err = db.GetByID[actions_model.ActionTask](ctx, effectiveTaskID)
@@ -505,28 +501,68 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
}
if task != nil {
if task.Steps == nil {
task.Steps, err = actions_model.GetTaskStepsByTaskID(ctx, task.ID)
if err != nil {
return nil, err
}
task.Steps = util.SliceNilAsEmpty(task.Steps)
}
runnerID = task.RunnerID
if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok {
runnerName = runner.Name
}
for i, step := range task.Steps {
stepStatus, stepConclusion := ToActionsStatus(step.Status)
steps = append(steps, &api.ActionWorkflowStep{
Name: step.Name,
Number: int64(i),
Status: stepStatus,
Conclusion: stepConclusion,
StartedAt: step.Started.AsTime().UTC(),
CompletedAt: step.Stopped.AsTime().UTC(),
})
if err := (actions_model.TaskList{task}).LoadSteps(ctx); err != nil {
return nil, err
}
runner, _, _ = db.GetByID[actions_model.ActionRunner](ctx, task.RunnerID)
}
}
return toActionWorkflowJob(ctx, repo, task, runner, job), nil
}
// ToActionWorkflowJobs requires job.Run.Repo to be loaded
func ToActionWorkflowJobs(ctx context.Context, jobs []*actions_model.ActionRunJob) ([]*api.ActionWorkflowJob, error) {
tasks, err := actions_model.GetTasksMapByIDs(ctx, container.FilterSlice(jobs, func(job *actions_model.ActionRunJob) (int64, bool) {
taskID := job.EffectiveTaskID()
return taskID, taskID != 0
}))
if err != nil {
return nil, err
}
taskList := actions_model.TaskList(slices.Collect(maps.Values(tasks)))
if err := taskList.LoadSteps(ctx); err != nil {
return nil, err
}
runners, err := actions_model.GetRunnersMapByIDs(ctx, container.FilterSlice(taskList, func(task *actions_model.ActionTask) (int64, bool) {
return task.RunnerID, task.RunnerID != 0
}))
if err != nil {
return nil, err
}
result := make([]*api.ActionWorkflowJob, len(jobs))
for i, job := range jobs {
task := tasks[job.EffectiveTaskID()]
var runner *actions_model.ActionRunner
if task != nil {
runner = runners[task.RunnerID]
}
result[i] = toActionWorkflowJob(ctx, job.Run.Repo, task, runner, job)
}
return result, nil
}
func toActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, runner *actions_model.ActionRunner, job *actions_model.ActionRunJob) *api.ActionWorkflowJob {
status, conclusion := ToRunActionsStatus(job.Run, job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
if task != nil && job.EffectiveTaskID() != 0 {
runnerID = task.RunnerID
if runner != nil {
runnerName = runner.Name
}
for i, step := range task.Steps {
stepStatus, stepConclusion := ToActionsStatus(step.Status)
steps = append(steps, &api.ActionWorkflowStep{
Name: step.Name,
Number: int64(i),
Status: stepStatus,
Conclusion: stepConclusion,
StartedAt: step.Started.AsTime().UTC(),
CompletedAt: step.Stopped.AsTime().UTC(),
})
}
}
@@ -551,7 +587,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
CreatedAt: job.Created.AsTime().UTC(),
StartedAt: job.Started.AsTime().UTC(),
CompletedAt: job.Stopped.AsTime().UTC(),
}, nil
}
}
func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, commit *git.Commit, refName git.RefName, folder string, entry *git.TreeEntry) *api.ActionWorkflow {