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

+15 -21
View File
@@ -90,27 +90,16 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptI
return
}
res.Entries = make([]*api.ActionWorkflowJob, len(jobs))
isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
for i := range jobs {
var repository *repo_model.Repository
if isRepoLevel {
repository = ctx.Repo.Repository
} else {
if jobs[i].Run == nil || jobs[i].Run.Repo == nil {
ctx.APIErrorInternal(fmt.Errorf("job %d is missing its run or repository", jobs[i].ID))
return
}
repository = jobs[i].Run.Repo
}
convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i])
if err != nil {
ctx.APIErrorInternal(err)
for _, job := range jobs {
if job.Run == nil || job.Run.Repo == nil {
ctx.APIErrorInternal(fmt.Errorf("job %d is missing its run or repository", job.ID))
return
}
res.Entries[i] = convertedWorkflowJob
}
res.Entries, err = convert.ToActionWorkflowJobs(ctx, jobs)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
@@ -224,6 +213,12 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
return
}
latestAttempts, err := runList.GetLatestAttempts(ctx)
if err != nil {
ctx.APIErrorInternal(err)
return
}
res.Entries = make([]*api.ActionWorkflowRun, 0, len(runs))
for _, run := range runs {
if run.Repo == nil {
@@ -232,8 +227,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
total--
continue
}
// TODO: load run attempts in batch
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, excludePullRequests)
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, latestAttempts[run.LatestAttemptID], excludePullRequests)
if err != nil {
ctx.APIErrorInternal(err)
return
+11 -10
View File
@@ -543,17 +543,18 @@ func (data *actionRunListData) processActionRuns(ctx *context.Context) bool {
return false
}
activeRuns := slices.DeleteFunc(slices.Clone(data.ActionRuns), func(run *actions_model.ActionRun) bool {
return !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked)
})
jobsByRun, err := actions_model.GetLatestAttemptJobsByRuns(ctx, activeRuns)
if err != nil {
ctx.ServerError("GetLatestAttemptJobsByRuns", err)
return false
}
data.RunErrors = make(map[int64]string)
for _, run := range data.ActionRuns {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
continue
}
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return false
}
for _, job := range jobs {
for _, run := range activeRuns {
for _, job := range jobsByRun[run.ID] {
// A deferred matrix is unresolvable until its needs finish, so the whole per-job block
// is skipped: parsing the payload would report a valid workflow as invalid.
if job.IsMatrixDeferred || !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusPending) {