Files
gitea/models/actions/run_job.go
T
bircni 3875db1974 feat(actions): add build queue view (#38585)
Adds a read-only Actions job queue: running jobs first, then waiting
jobs in the order a runner picks them up. It is shown instance-wide in
the admin Actions section with owner, repository and status filters, and
per repository in the Actions tab. Both lists refresh in place.

Pending work is currently only visible per repository and newest-first,
so nothing shows what is queued, in which order, or what occupies the
runners. Reordering the queue will be proposed separately.

A migration adds indexes for the runner pickup query and
repository-scoped status lookups.

* Fix #34198

<img width="1345" height="451" alt="image"
src="https://github.com/user-attachments/assets/7d52ff76-81b4-44e8-b583-d7d89c9dffcd"
/>
<img width="1809" height="1134" alt="image"
src="https://github.com/user-attachments/assets/4d56c0cb-bae7-4ce2-8f3c-75163b2bc7f4"
/>

---------

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-26 08:01:41 +00:00

862 lines
32 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"cmp"
"context"
"fmt"
"slices"
"time"
act_model "gitea.dev/actionslib/pkg/model"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
"xorm.io/builder"
)
// MaxJobNumPerRun is the maximum number of jobs in a single run.
// https://docs.github.com/en/actions/reference/limits#existing-system-limits
// TODO: check this limit when creating jobs
const MaxJobNumPerRun = 256
// ActionRunJob represents a job of a run
type ActionRunJob struct {
ID int64
RunID int64 `xorm:"index"`
Run *ActionRun `xorm:"-"`
RepoID int64 `xorm:"index(repo_concurrency) index(repo_status)"`
Repo *repo_model.Repository `xorm:"-"`
OwnerID int64 `xorm:"index"`
CommitSHA string `xorm:"index"`
IsForkPullRequest bool
Name string `xorm:"VARCHAR(255)"`
// for legacy jobs, this counts how many times the job has run;
// otherwise it matches the Attempt of the ActionRunAttempt identified by job.RunAttemptID
Attempt int64
// WorkflowPayload is act/jobparser.SingleWorkflow for act/jobparser.Parse
// it should contain exactly one job with global workflow fields for this model
WorkflowPayload []byte
JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id
Needs []string `xorm:"JSON TEXT"`
RunsOn []string `xorm:"JSON TEXT"`
TaskID int64 `xorm:"index(pickup)"` // the task created by this job in its own attempt
SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"` // SourceTaskID points to a historical task when this job reuses an earlier attempt's result.
Status Status `xorm:"index index(pickup) index(repo_status)"`
RawConcurrency string // raw concurrency from job YAML's "concurrency" section
// IsConcurrencyEvaluated is only valid/needed when this job's RawConcurrency is not empty.
// If RawConcurrency can't be evaluated (e.g. depend on other job's outputs or have errors), this field will be false.
// If RawConcurrency has been successfully evaluated, this field will be true, ConcurrencyGroup and ConcurrencyCancel are also set.
IsConcurrencyEvaluated bool
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress
// TokenPermissions stores the explicit permissions from workflow/job YAML (no org/repo clamps applied).
// Org/repo clamps are enforced when the token is used at runtime.
// It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified.
TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"`
// MaxParallel is strategy.max-parallel, shared by all matrix jobs with the same JobID (0 = unlimited).
MaxParallel int `xorm:"NOT NULL DEFAULT 0"`
// IsMatrixDeferred marks a placeholder for a job whose matrix references `needs.*.outputs.*` and so
// could not be expanded at planning time. Its WorkflowPayload still carries the raw, unevaluated
// matrix; the job emitter expands it once the needs finish. Only a successful expansion clears the flag:
// it survives a terminal status (skipped, cancelled, failed expansion) so a rerun can recognize
// the row as unexpanded and re-derive the matrix instead of dispatching the raw payload.
IsMatrixDeferred bool `xorm:"NOT NULL DEFAULT FALSE"`
// DeferredMatrixPayload preserves a deferred-matrix placeholder's original WorkflowPayload (the raw, unevaluated matrix).
// A rerun whose needs re-run collapses the combinations back into a single placeholder built from this payload,
// so the matrix is re-derived from the fresh outputs.
DeferredMatrixPayload []byte `xorm:"LONGBLOB"`
// RunAttemptID identifies the ActionRunAttempt this job belongs to.
// A value of 0 indicates a legacy job created before ActionRunAttempt existed.
RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
// AttemptJobID is unique within a single attempt.
// For jobs created after ActionRunAttempt was introduced, the same logical job is expected to keep the same AttemptJobID across attempts.
// A value of 0 indicates a legacy job created before ActionRunAttempt existed.
AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
// WorkflowSourceRepoID + WorkflowSourceCommitSHA record the (repo, commit) this job's containing workflow file came from.
WorkflowSourceRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
WorkflowSourceCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
// IsReusableCaller marks this job as a reusable workflow caller.
// Caller jobs do not run on a runner; their status is derived from their child jobs.
IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"`
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
// CallUses stores the raw "uses:" string of a reusable workflow caller job.
// Only set when IsReusableCaller is true.
CallUses string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
// ReusableWorkflowContent is the content of the reusable workflow specified by "uses:".
// Only set when IsReusableCaller is true.
ReusableWorkflowContent []byte `xorm:"LONGBLOB"`
// CallSecrets encodes the reusable workflow caller's "secrets:" section:
// - "" : no "secrets:" section (children only see auto-generated tokens).
// - "inherit" : the caller wrote "secrets: inherit".
// - JSON object : explicit mapping {alias: source_name}; names only, no values.
// Only set when IsReusableCaller is true.
CallSecrets string `xorm:"LONGTEXT"`
// CallPayload is the JSON-encoded WorkflowCallPayload exposed to children as gitea.event.
// Populated atomically with IsExpanded at the end of expandReusableWorkflowCaller.
// Only set when IsReusableCaller is true.
CallPayload string `xorm:"LONGTEXT"`
// ParentJobID scopes `Needs` resolution: name lookups happen only among rows sharing the same ParentJobID. 0 for top-level rows.
ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
// ContinueOnError mirrors the job-level continue-on-error field from the workflow YAML.
// When true, a failure of this job does not fail the overall workflow run.
ContinueOnError bool `xorm:"NOT NULL DEFAULT FALSE"`
Started timeutil.TimeStamp
Stopped timeutil.TimeStamp
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated index index(pickup)"`
}
// ActionRunAttemptJobIDIndex backs the run-wide AttemptJobID counter, keyed by ActionRun.ID.
// Use GetNextAttemptJobID to allocate the next ID for a run.
type ActionRunAttemptJobIDIndex db.ResourceIndex
// GetNextAttemptJobID atomically allocates the next AttemptJobID for a job in the given run.
// AttemptJobIDs are unique within a single attempt and stable across attempts for the same logical job
func GetNextAttemptJobID(ctx context.Context, runID int64) (int64, error) {
return db.GetNextResourceIndex(ctx, "action_run_attempt_job_id_index", runID)
}
func init() {
db.RegisterModel(new(ActionRunJob))
db.RegisterModel(new(ActionRunAttemptJobIDIndex))
}
func (job *ActionRunJob) Duration() time.Duration {
return calculateDuration(job.Started, job.Stopped, job.Status, job.Updated)
}
func (job *ActionRunJob) EffectiveTaskID() int64 {
if job.TaskID > 0 {
return job.TaskID
}
return job.SourceTaskID
}
func (job *ActionRunJob) LoadRun(ctx context.Context) error {
if job.Run == nil {
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
if err != nil {
return err
}
job.Run = run
}
return nil
}
func (job *ActionRunJob) LoadRepo(ctx context.Context) error {
if job.Repo == nil {
repo, err := repo_model.GetRepositoryByID(ctx, job.RepoID)
if err != nil {
return err
}
job.Repo = repo
}
return nil
}
// LoadAttributes load Run if not loaded
func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
if err := job.LoadRun(ctx); err != nil {
return err
}
return job.Run.LoadAttributes(ctx)
}
// ParseJob parses the job structure from the ActionRunJob.WorkflowPayload
func (job *ActionRunJob) ParseJob() (*jobparser.Job, error) {
// read as stored, jobparser.Parse would evaluate the payload again and reset its strategy.job-index
_, workflowJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
if err != nil {
return nil, fmt.Errorf("job %d single workflow: unable to parse: %w", job.ID, err)
}
return workflowJob, nil
}
func (job *ActionRunJob) IsMatrixSiblingOf(other *ActionRunJob) bool {
return job.JobID == other.JobID && job.ParentJobID == other.ParentJobID && job.ID != other.ID
}
func (job *ActionRunJob) GetFailFast() (bool, error) {
parsed, err := job.ParseJob()
if err != nil {
return false, err
}
return (&act_model.Strategy{FailFastString: parsed.Strategy.FailFastString}).GetFailFast(), nil
}
func GetRunJobByRepoAndID(ctx context.Context, repoID, jobID int64) (*ActionRunJob, error) {
var job ActionRunJob
has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", jobID, repoID).Get(&job)
if err != nil {
return nil, err
} else if !has {
return nil, fmt.Errorf("run job with id %d: %w", jobID, util.ErrNotExist)
}
return &job, nil
}
func GetRunJobByRunAndID(ctx context.Context, runID, jobID int64) (*ActionRunJob, error) {
var job ActionRunJob
has, err := db.GetEngine(ctx).Where("id=? AND run_id=?", jobID, runID).Get(&job)
if err != nil {
return nil, err
} else if !has {
return nil, fmt.Errorf("run job with id %d: %w", jobID, util.ErrNotExist)
}
return &job, nil
}
func GetRunJobByAttemptJobID(ctx context.Context, runID, attemptID, attemptJobID int64) (*ActionRunJob, error) {
var job ActionRunJob
has, err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=? AND attempt_job_id=?", runID, attemptID, attemptJobID).Get(&job)
if err != nil {
return nil, err
} else if !has {
return nil, fmt.Errorf("run job with attempt_job_id %d in run %d attempt %d: %w", attemptJobID, runID, attemptID, util.ErrNotExist)
}
return &job, nil
}
// GetLatestAttemptJobsByRepoAndRunID returns the jobs of the latest attempt for a run.
// It prefers the latest attempt when one exists, and falls back to legacy jobs with run_attempt_id=0 for runs created before ActionRunAttempt existed.
func GetLatestAttemptJobsByRepoAndRunID(ctx context.Context, repoID, runID int64) (ActionJobList, error) {
run, err := GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
return nil, err
}
return GetLatestAttemptJobsByRun(ctx, run)
}
func GetLatestAttemptJobsByRun(ctx context.Context, run *ActionRun) (ActionJobList, error) {
if run.LatestAttemptID > 0 {
return GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID)
}
var jobs []*ActionRunJob
if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", run.RepoID, run.ID).OrderBy("id").Find(&jobs); err != nil {
return nil, err
}
return jobs, nil
}
// GetAllRunJobsByRepoAndRunID returns all jobs for a run across all attempts.
func GetAllRunJobsByRepoAndRunID(ctx context.Context, repoID, runID int64) (ActionJobList, error) {
var jobs []*ActionRunJob
if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=?", repoID, runID).OrderBy("id").Find(&jobs); err != nil {
return nil, err
}
return jobs, nil
}
// GetRunJobsByRunAndAttemptID returns jobs for a run within a specific attempt.
// runAttemptID may be 0 to address legacy jobs that were created before ActionRunAttempt existed and therefore have no attempt association.
func GetRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (ActionJobList, error) {
var jobs []*ActionRunJob
if err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=?", runID, runAttemptID).OrderBy("id").Find(&jobs); err != nil {
return nil, err
}
return jobs, nil
}
// GetPriorAttemptChildrenByParent returns the children of the most recent prior attempt where
// the parent (identified by parentAttemptJobID) actually had children, indexed by child JobID then child Name.
// Returns (nil, nil) when no such attempt exists.
// The (JobID, Name) key disambiguates both reusable-workflow subtrees and matrix-expanded instances (whose Name carries the matrix suffix).
func GetPriorAttemptChildrenByParent(ctx context.Context, runID, currentAttemptID, parentAttemptJobID int64) (map[string]map[string]*ActionRunJob, error) {
// query every prior caller row sharing this AttemptJobID, newest first.
var priorCallers []*ActionRunJob
if err := db.GetEngine(ctx).
Where("run_id = ? AND attempt_job_id = ? AND run_attempt_id < ?", runID, parentAttemptJobID, currentAttemptID).
Desc("run_attempt_id").
Find(&priorCallers); err != nil {
return nil, fmt.Errorf("find prior callers: %w", err)
}
if len(priorCallers) == 0 {
return nil, nil //nolint:nilnil // caller is brand new in this attempt
}
// query for every child of every prior caller
callerIDs := make([]int64, len(priorCallers))
for i, c := range priorCallers {
callerIDs[i] = c.ID
}
var allChildren []*ActionRunJob
if err := db.GetEngine(ctx).
Where("run_id = ?", runID).
In("parent_job_id", callerIDs).
Find(&allChildren); err != nil {
return nil, fmt.Errorf("find prior children: %w", err)
}
childrenByCallerID := make(map[int64][]*ActionRunJob, len(callerIDs))
for _, c := range allChildren {
childrenByCallerID[c.ParentJobID] = append(childrenByCallerID[c.ParentJobID], c)
}
// Walk priorCallers in run_attempt_id-desc order and return the children of the first caller that actually had any.
// Skipped attempts (caller exists but no children) are bypassed.
for _, caller := range priorCallers {
children := childrenByCallerID[caller.ID]
if len(children) == 0 {
continue
}
out := make(map[string]map[string]*ActionRunJob)
for _, c := range children {
if out[c.JobID] == nil {
out[c.JobID] = make(map[string]*ActionRunJob)
}
out[c.JobID][c.Name] = c
}
return out, nil
}
return nil, nil //nolint:nilnil // every prior attempt skipped this caller
}
// GetPriorAttemptMatrixCombos returns the most recent prior attempt's combination rows of the given
// dynamic-matrix job, indexed by Name, so re-expansion keeps AttemptJobIDs stable across attempts.
func GetPriorAttemptMatrixCombos(ctx context.Context, runID, currentAttemptID, parentAttemptJobID int64, jobID string) (map[string]*ActionRunJob, error) {
// An unexpanded placeholder is not a combination, so it is skipped and the search looks further
// back past it. Only the columns the scope check and the result need are read: the rows carry
// two payload blobs, and every prior attempt of the job is a candidate.
var candidates []*ActionRunJob
if err := db.GetEngine(ctx).
Where("run_id = ? AND job_id = ? AND run_attempt_id < ? AND is_matrix_deferred = ?", runID, jobID, currentAttemptID, false).
Cols("id", "name", "attempt_job_id", "run_attempt_id", "parent_job_id").
Desc("run_attempt_id").
Find(&candidates); err != nil {
return nil, fmt.Errorf("find prior matrix combos: %w", err)
}
// Every combination of one attempt shares a parent, so dedupe before the lookup.
parentIDs := container.FilterSlice(candidates, func(c *ActionRunJob) (int64, bool) {
return c.ParentJobID, c.ParentJobID > 0
})
parentAttemptIDByRowID := make(map[int64]int64, len(parentIDs))
if len(parentIDs) > 0 {
var parents []*ActionRunJob
if err := db.GetEngine(ctx).In("id", parentIDs).Cols("id", "attempt_job_id").Find(&parents); err != nil {
return nil, fmt.Errorf("find prior matrix combo parents: %w", err)
}
for _, p := range parents {
parentAttemptIDByRowID[p.ID] = p.AttemptJobID
}
}
// Rows arrive newest-attempt-first, so the first in-scope row fixes the attempt to take.
combos := map[string]*ActionRunJob{}
newestAttemptID := int64(0)
for _, c := range candidates {
if parentAttemptIDByRowID[c.ParentJobID] != parentAttemptJobID {
continue
}
if newestAttemptID == 0 {
newestAttemptID = c.RunAttemptID
} else if c.RunAttemptID != newestAttemptID {
break
}
combos[c.Name] = c
}
return combos, nil
}
// GetDirectChildJobsByParent returns the direct child jobs of a parent job (e.g. a reusable workflow caller).
func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (ActionJobList, error) {
var jobs []*ActionRunJob
if err := db.GetEngine(ctx).
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
OrderBy("id").
Find(&jobs); err != nil {
return nil, err
}
return jobs, nil
}
// DeleteDirectChildJobsByParent deletes the direct child jobs of a parent job.
func DeleteDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) error {
_, err := db.GetEngine(ctx).
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
Delete(new(ActionRunJob))
return err
}
// CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself
func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob {
parents := map[int64]bool{parent.ID: true}
for {
grew := false
for _, j := range allJobs {
if j.ParentJobID == 0 {
continue
}
if parents[j.ParentJobID] && !parents[j.ID] {
parents[j.ID] = true
grew = true
}
}
if !grew {
break
}
}
out := make([]*ActionRunJob, 0)
for _, j := range allJobs {
if j.ID == parent.ID || !parents[j.ID] {
continue
}
out = append(out, j)
}
return out
}
// hasWaitingJobsToPick reports whether any waiting, unclaimed, non-reusable job
// remains in the repo, i.e. work that an idle runner could still pick up.
func hasWaitingJobsToPick(ctx context.Context, repoID int64) (bool, error) {
return db.GetEngine(ctx).
Where("repo_id = ? AND task_id = ? AND status = ? AND is_reusable_caller = ?", repoID, 0, StatusWaiting, false).
Exist(&ActionRunJob{})
}
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
e := db.GetEngine(ctx)
sess := e.ID(job.ID)
if len(cols) > 0 {
sess.Cols(cols...)
}
if cond != nil {
sess.Where(cond)
}
affected, err := sess.Update(job)
if err != nil {
return 0, err
}
// xorm's Update writes only non-zero fields when cols is empty, so a zero job.Status
// with empty cols means status isn't actually being persisted — skip aggregation.
statusUpdated := slices.Contains(cols, "status") || (len(cols) == 0 && job.Status != 0)
if affected == 0 || !statusUpdated {
return affected, nil
}
if job.RunID == 0 {
var err error
if job, err = GetRunJobByRepoAndID(ctx, job.RepoID, job.ID); err != nil {
return 0, err
}
}
// Reusable workflow caller jobs are never picked up by runners, so they don't need a task-version bump.
if statusUpdated && !job.IsReusableCaller {
switch {
case job.Status.IsWaiting():
// A job returning to the waiting queue is work a runner can pick up, so bump the
// version to wake idle runners whose tasksVersion already equals latestVersion.
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
return 0, err
}
case job.Status.IsDone():
// When a job finishes, bump the version so that idle runners — whose
// tasksVersion already equals the current latestVersion — learn that
// remaining waiting jobs are still available and attempt PickTask again.
// Without this bump, runners that completed their tasks would see
// tasksVersion==latestVersion and skip PickTask, leaving the other jobs
// permanently unassigned until the version changes for another reason.
// Only bump when waiting work actually remains for this repo, otherwise
// every job completion would needlessly bump the global version and wake
// every idle runner instance-wide for nothing.
hasWaiting, err := hasWaitingJobsToPick(ctx, job.RepoID)
if err != nil {
return 0, err
}
if hasWaiting {
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
return 0, err
}
}
}
}
if statusUpdated && job.ParentJobID > 0 {
// Reusable workflow caller's children cascade their status changes upward to the parent caller.
parent, err := GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
if err != nil {
return affected, fmt.Errorf("load parent caller %d: %w", job.ParentJobID, err)
}
return affected, RefreshReusableCallerStatus(ctx, parent)
}
if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
return 0, err
}
return affected, nil
}
// refreshRunStatus recomputes the status of an attempt from the jobs currently stored and persists it.
// The latest attempt propagates its status to its run, an older one only updates itself.
// noJobsStatus settles an attempt without any job, which AggregateJobStatus cannot conclude on its own.
func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, noJobsStatus Status) error {
// Other goroutines may aggregate the status of the attempt/run and update it too.
// So we need to load the current jobs before updating the aggregate state.
if runAttemptID > 0 {
attempt, err := GetRunAttemptByRepoAndID(ctx, repoID, runAttemptID)
if err != nil {
return err
}
jobs, err := GetRunJobsByRunAndAttemptID(ctx, runID, runAttemptID)
if err != nil {
return err
}
attempt.Status = AggregateJobStatus(jobs)
if len(jobs) == 0 {
attempt.Status = noJobsStatus
}
if err := UpdateRunAttempt(ctx, attempt, "status"); err != nil {
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
}
return nil
}
// TODO: Remove this fallback in the future.
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
// This path keeps those runs' status consistent when their jobs finish, including:
// - jobs created before migration v331 and complete on the new version starts
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
// - cancelling a legacy run whose jobs are all already done
run, err := GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
return err
}
jobs, err := GetLatestAttemptJobsByRun(ctx, run)
if err != nil {
return err
}
run.Status = AggregateJobStatus(jobs)
if len(jobs) == 0 {
run.Status = noJobsStatus
}
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
return fmt.Errorf("update run %d: %w", run.ID, err)
}
return nil
}
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
// No-op if caller is not a reusable caller.
//
// Concurrency: two sibling children finishing at roughly the same time can each invoke this for the same parent caller.
// No row-level lock is taken because AggregateJobStatus is a pure function of the children's statuses (order-independent), so racing callers arrive at the same Status.
func RefreshReusableCallerStatus(ctx context.Context, caller *ActionRunJob) error {
if !caller.IsReusableCaller {
return nil
}
children, err := GetDirectChildJobsByParent(ctx, caller)
if err != nil {
return err
}
newStatus := AggregateJobStatus(children)
cols := make([]string, 0, 3)
if caller.Status != newStatus {
caller.Status = newStatus
cols = append(cols, "status")
}
if newStatus != StatusSkipped {
now := timeutil.TimeStampNow()
if caller.Started.IsZero() && newStatus == StatusRunning {
caller.Started = now
cols = append(cols, "started")
}
if caller.Stopped.IsZero() && newStatus.IsDone() {
caller.Stopped = now
cols = append(cols, "stopped")
}
}
if len(cols) == 0 {
return nil
}
_, err = UpdateRunJob(ctx, caller, nil, cols...)
return err
}
func AggregateJobStatus(jobs []*ActionRunJob) Status {
allSuccessOrSkipped := len(jobs) != 0
allSkipped := len(jobs) != 0
var hasFailure, hasCancelled, hasCancelling, hasWaiting, hasRunning, hasBlocked bool
for _, job := range jobs {
// A failed job with continue-on-error:true does not fail the workflow run.
// It counts as a "continued failure" and is treated like success for aggregation.
isContinuedFailure := job.ContinueOnError && job.Status == StatusFailure
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped || isContinuedFailure)
allSkipped = allSkipped && job.Status == StatusSkipped
hasFailure = hasFailure || (job.Status == StatusFailure && !job.ContinueOnError)
hasCancelled = hasCancelled || job.Status == StatusCancelled
hasCancelling = hasCancelling || job.Status == StatusCancelling
hasWaiting = hasWaiting || job.Status == StatusWaiting
hasRunning = hasRunning || job.Status == StatusRunning
hasBlocked = hasBlocked || job.Status == StatusBlocked
}
switch {
case allSkipped:
return StatusSkipped
case allSuccessOrSkipped:
return StatusSuccess
case hasCancelling:
return StatusCancelling
case hasRunning:
return StatusRunning
case hasWaiting:
return StatusWaiting
case hasBlocked:
// Blocked is still a pending state, so it should outrank terminal
// statuses like cancelled/failure when no job is waiting or running.
return StatusBlocked
case hasCancelled:
if hasFailure && hasFailFastMatrixFailure(jobs) {
return StatusFailure
}
return StatusCancelled
case hasFailure:
return StatusFailure
default:
return StatusUnknown // it shouldn't happen
}
}
func hasFailFastMatrixFailure(jobs []*ActionRunJob) bool {
for _, failed := range jobs {
if failed.Status != StatusFailure || failed.ContinueOnError || !slices.ContainsFunc(jobs, func(sibling *ActionRunJob) bool {
return sibling.IsMatrixSiblingOf(failed) && sibling.Status == StatusCancelled
}) {
continue
}
failFast, err := failed.GetFailFast()
if err == nil && failFast {
return true
}
}
return false
}
// CancelPreviousJobs cancels all previous jobs of the same repository, reference, workflow, and event.
// It's useful when a new run is triggered, and all previous runs needn't be continued anymore.
func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) ([]*ActionRunJob, error) {
// Find all runs in the specified repository, reference, and workflow with non-final status
runs, total, err := db.FindAndCount[ActionRun](ctx, FindRunOptions{
RepoID: repoID,
Ref: ref,
WorkflowID: workflowID,
TriggerEvent: event,
Status: []Status{StatusRunning, StatusWaiting, StatusBlocked, StatusCancelling},
})
if err != nil {
return nil, err
}
// If there are no runs found, there's no need to proceed with cancellation, so return nil.
if total == 0 {
return nil, nil
}
cancelledJobs := make([]*ActionRunJob, 0, total)
// Iterate over each found run and cancel its associated jobs.
for _, run := range runs {
// Find all jobs associated with the current run.
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
RunID: run.ID,
})
if err != nil {
return cancelledJobs, err
}
cjs, err := CancelJobs(ctx, jobs, false)
if err != nil {
return cancelledJobs, err
}
cancelledJobs = append(cancelledJobs, cjs...)
}
// Return nil to indicate successful cancellation of all running and waiting jobs.
return cancelledJobs, nil
}
func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) {
if job.RawConcurrency == "" {
return nil, nil
}
if !job.IsConcurrencyEvaluated {
return nil, nil
}
if job.ConcurrencyGroup == "" {
return nil, nil
}
statusFindOption := []Status{StatusWaiting, StatusBlocked}
if job.ConcurrencyCancel {
statusFindOption = append(statusFindOption, StatusRunning)
statusFindOption = append(statusFindOption, StatusCancelling)
}
attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
if err != nil {
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
}
jobs = slices.DeleteFunc(jobs, func(j *ActionRunJob) bool { return j.ID == job.ID })
jobsToCancel = append(jobsToCancel, jobs...)
// cancel runs in the same concurrency group
for _, attempt := range attempts {
if attempt.ID == job.RunAttemptID {
continue
}
jobs, err := GetRunJobsByRunAndAttemptID(ctx, attempt.RunID, attempt.ID)
if err != nil {
return nil, fmt.Errorf("find run %d attempt %d jobs: %w", attempt.RunID, attempt.ID, err)
}
jobsToCancel = append(jobsToCancel, jobs...)
}
return CancelJobs(ctx, jobsToCancel, false)
}
// CancelJobs cancels every cancellable job it is given, force skipping the graceful cancelling
// handshake so a running task is marked cancelled without waiting for its runner. It leaves the
// status of a run it cancelled nothing in untouched, SettleRunAfterCancel gives such a run a final one.
func CancelJobs(ctx context.Context, jobs []*ActionRunJob, force bool) ([]*ActionRunJob, error) {
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
for _, job := range jobs {
if job.IsReusableCaller {
sub, err := cancelReusableCaller(ctx, job, force)
if err != nil {
return cancelledJobs, err
}
cancelledJobs = append(cancelledJobs, sub...)
continue
}
c, err := cancelOneJob(ctx, job, force)
if err != nil {
return cancelledJobs, err
}
if c != nil {
cancelledJobs = append(cancelledJobs, c)
}
}
return cancelledJobs, nil
}
// SettleRunAfterCancel gives a run a final status when cancelling it updated no job at all.
// A run's status is otherwise only ever written as a side effect of a job update, so a run whose
// jobs are all done already, or that has no job at all, would stay unfinished forever.
func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
if run.Status.IsDone() {
return nil
}
return refreshRunStatus(ctx, run.RepoID, run.ID, run.LatestAttemptID, StatusCancelled)
}
// cancelOneJob cancels a single job and returns the post-cancel row
func cancelOneJob(ctx context.Context, job *ActionRunJob, force bool) (*ActionRunJob, error) {
if job.Status.IsDone() {
return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error"
}
// No associated task: mark Cancelled directly. This includes reusable callers and jobs that never reached PickTask.
if job.TaskID == 0 {
job.Status = StatusCancelled
job.Stopped = timeutil.TimeStampNow()
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
if err != nil {
return nil, err
}
if n == 0 {
log.Error("Failed to cancel job %d because it has changed", job.ID)
return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error"
}
return job, nil
}
// Has a task: stop the task and re-read the row.
stopStatus := util.Iif(force, StatusCancelled, StatusCancelling)
if err := StopTask(ctx, job.TaskID, stopStatus); err != nil {
return nil, err
}
updated, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
if err != nil {
return nil, fmt.Errorf("get job: %w", err)
}
return updated, nil
}
// cancelReusableCaller cancels `caller` and all its child jobs
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool) ([]*ActionRunJob, error) {
cancelledJobs := make([]*ActionRunJob, 0)
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID)
if err != nil {
return cancelledJobs, err
}
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
// so each child must reach its final state before its parent caller is re-aggregated.
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
descendants := CollectAllDescendantJobs(caller, attemptJobs)
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
for _, c := range descendants {
cancelled, err := cancelOneJob(ctx, c, force)
if err != nil {
return cancelledJobs, err
}
if cancelled != nil {
cancelledJobs = append(cancelledJobs, cancelled)
}
}
if c, err := cancelOneJob(ctx, caller, force); err != nil {
return cancelledJobs, err
} else if c != nil {
cancelledJobs = append(cancelledJobs, c)
}
return cancelledJobs, nil
}