mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-18 23:47:50 +00:00
Introduce the capability to manually prioritize individual workflow runs. If possible, manually prioritized workflow runs will be run before all others. If multiple workflow runs have been prioritized manually, they will be run in their order of arrival, not in the order they have been prioritized manually. Workflow run prioritization is best-effort, no matter whether a workflow run has been (de-)prioritized manually or by a prioritization algorithm. That means that it usually has an effect, but it's not guaranteed to have one. Workflow run prioritization is performed by implementations of `RunPrioritizationStrategy`. Currently, only one implementation exists: first in, first out, with the option to manually mark individual workflow runs as prioritized. It is possible to add more strategies in the future and make them selectable in the user interface per repository. Implementations of `RunPrioritizationStrategy` can only influence the ordering of `ActionRunJob`s by altering the priority of the `ActionRun` they belong to. That is a conscious choice to reduce the risks of deadlocks or other potentially weird behaviour that would be hard to debug. The priority of `ActionRun`s that are already running is not recalculated for the same reason. The run priority cannot be observed by external systems because it is neither exposed in the HTTP API nor to webhook listeners. That limitation can be alleviated in future versions. See also https://codeberg.org/forgejo/forgejo/issues/12830 and https://code.forgejo.org/forgejo/forgejo-actions-feature-requests/issues/92. ### Tests for Go changes - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. *The decision if the pull request will be shown in the release notes is up to the mergers / release team.* The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13045 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org> Reviewed-by: 0ko <0ko@noreply.codeberg.org>
274 lines
9.9 KiB
Go
274 lines
9.9 KiB
Go
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
|
|
actions_model "forgejo.org/models/actions"
|
|
"forgejo.org/models/db"
|
|
"forgejo.org/models/unit"
|
|
"forgejo.org/modules/container"
|
|
"forgejo.org/modules/timeutil"
|
|
"forgejo.org/modules/util"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
var (
|
|
// ErrRerunWorkflowInvalid signals that the workflow cannot be run because it is invalid, for example, due to syntax
|
|
// errors.
|
|
ErrRerunWorkflowInvalid = errors.New("workflow is invalid")
|
|
// ErrRerunWorkflowDisabled indicates that the workflow cannot be run because it has been disabled by the user or
|
|
// Forgejo.
|
|
ErrRerunWorkflowDisabled = errors.New("workflow is disabled")
|
|
// ErrRerunWorkflowStillRunning signals that the workflow cannot be rerun because at least one job is still running.
|
|
ErrRerunWorkflowStillRunning = errors.New("workflow is still running")
|
|
// ErrRerunJobStillRunning signals that the job cannot be rerun because it is still running.
|
|
ErrRerunJobStillRunning = errors.New("job is still running")
|
|
)
|
|
|
|
// GetAllRerunJobs get all jobs that need to be rerun when job should be rerun
|
|
func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
|
|
rerunJobs := []*actions_model.ActionRunJob{job}
|
|
rerunJobsIDSet := make(container.Set[string])
|
|
rerunJobsIDSet.Add(job.JobID)
|
|
|
|
for _, j := range allJobs {
|
|
if rerunJobsIDSet.Contains(j.JobID) {
|
|
continue
|
|
}
|
|
if slices.ContainsFunc(j.Needs, rerunJobsIDSet.Contains) {
|
|
rerunJobs = append(rerunJobs, j)
|
|
rerunJobsIDSet.Add(j.JobID)
|
|
}
|
|
}
|
|
|
|
return rerunJobs
|
|
}
|
|
|
|
// RerunAllJobs reruns all jobs of the given run and returns them. For it to succeed, the workflow must be valid, and the
|
|
// previous run must have completed.
|
|
func RerunAllJobs(ctx context.Context, run *actions_model.ActionRun) ([]*actions_model.ActionRunJob, error) {
|
|
if !run.IsValid() {
|
|
return nil, ErrRerunWorkflowInvalid
|
|
}
|
|
if !run.Status.IsDone() {
|
|
return nil, ErrRerunWorkflowStillRunning
|
|
}
|
|
|
|
if err := run.LoadRepo(ctx); err != nil {
|
|
return nil, fmt.Errorf("cannot load repo of run %d: %w", run.ID, err)
|
|
}
|
|
|
|
actionsConfig := run.Repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
|
|
if actionsConfig.IsWorkflowDisabled(run.WorkflowID) {
|
|
return nil, ErrRerunWorkflowDisabled
|
|
}
|
|
|
|
var rerunJobs []*actions_model.ActionRunJob
|
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
|
if run.Status != actions_model.StatusUnknown && !run.Status.IsDone() {
|
|
return fmt.Errorf("cannot prepare next attempt because run %d is active: %s", run.ID, run.Status.String())
|
|
}
|
|
|
|
// Wipe all artifacts before a rerun to prevent stale artifacts from polluting artifacts collected during the
|
|
// rerun.
|
|
if err := actions_model.SetArtifactsOfRunDeleted(ctx, run.ID); err != nil {
|
|
return fmt.Errorf("cannot remove artifacts of previous run of run %d: %w", run.ID, err)
|
|
}
|
|
|
|
run.PreviousDuration = run.Duration()
|
|
|
|
run.Status = actions_model.StatusWaiting
|
|
run.Started = 0
|
|
run.Stopped = 0
|
|
run.Priority = actions_model.DefaultRunPriority
|
|
run.Prioritize = false
|
|
|
|
// The columns have to be specified here to work around a xorm quirk: It won't update columns that are set to
|
|
// their zero value without AllCols().
|
|
if err := UpdateRun(ctx, run, "status", "started", "stopped", "previous_duration", "priority", "prioritize"); err != nil {
|
|
return fmt.Errorf("cannot update run %d: %w", run.ID, err)
|
|
}
|
|
|
|
if err := recalculateRunPriorities(ctx, run.RepoID); err != nil {
|
|
return fmt.Errorf("could not recalculate workflow run priorities of repository %d: %w", run.RepoID, err)
|
|
}
|
|
|
|
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("could not load jobs of run %d: %w", run.ID, err)
|
|
}
|
|
|
|
for _, job := range jobs {
|
|
initialStatus := actions_model.StatusWaiting
|
|
if len(job.Needs) > 0 {
|
|
initialStatus = actions_model.StatusBlocked
|
|
}
|
|
|
|
if err := rerunSingleJob(ctx, job, initialStatus); err != nil {
|
|
return fmt.Errorf("could not rerun job %d of run %d: %w", job.ID, run.ID, err)
|
|
}
|
|
|
|
rerunJobs = append(rerunJobs, job)
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return rerunJobs, nil
|
|
}
|
|
|
|
// RerunJob reruns the given job and all its dependent jobs. It returns all jobs that were rerun. For it to succeed, the
|
|
// workflow that defines this job must be valid, and the previous run must have completed. Dependent jobs that have not
|
|
// completed yet are ignored.
|
|
func RerunJob(ctx context.Context, job *actions_model.ActionRunJob) ([]*actions_model.ActionRunJob, error) {
|
|
if err := job.LoadAttributes(ctx); err != nil {
|
|
return nil, fmt.Errorf("cannot load attributes of job %d: %w", job.ID, err)
|
|
}
|
|
if !job.Run.IsValid() {
|
|
return nil, ErrRerunWorkflowInvalid
|
|
}
|
|
|
|
actionsConfig := job.Run.Repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
|
|
if actionsConfig.IsWorkflowDisabled(job.Run.WorkflowID) {
|
|
return nil, ErrRerunWorkflowDisabled
|
|
}
|
|
|
|
if !job.Status.IsDone() {
|
|
return nil, ErrRerunJobStillRunning
|
|
}
|
|
|
|
var rerunJobs []*actions_model.ActionRunJob
|
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
|
if job.Run.Status.IsUnknown() || job.Run.Status.IsDone() {
|
|
job.Run.PreviousDuration = job.Run.Duration()
|
|
job.Run.Status = actions_model.StatusWaiting
|
|
job.Run.Started = 0
|
|
job.Run.Stopped = 0
|
|
job.Run.Priority = actions_model.DefaultRunPriority
|
|
job.Run.Prioritize = false
|
|
|
|
// The columns have to be specified here to work around a xorm quirk: It won't update columns that are set
|
|
// to their zero value without AllCols().
|
|
if err := UpdateRun(ctx, job.Run, "previous_duration", "status", "started", "stopped", "priority", "prioritize"); err != nil {
|
|
return fmt.Errorf("unable to update run %d of job %d: %w", job.RunID, job.ID, err)
|
|
}
|
|
|
|
if err := recalculateRunPriorities(ctx, job.RepoID); err != nil {
|
|
return fmt.Errorf("could not recalculate workflow run priorities of repository %d: %w", job.RepoID, err)
|
|
}
|
|
}
|
|
|
|
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
|
|
if err != nil {
|
|
return fmt.Errorf("could not load jobs of run %d: %w", job.RunID, err)
|
|
}
|
|
|
|
// Wipe all artifacts before a rerun to prevent stale artifacts from polluting the artifacts collected during
|
|
// the rerun. Because artifacts are bound to a run and not to a job, it is not possible to only remove the
|
|
// artifacts of the jobs that are going to be rerun. That means that artifacts created by jobs that are not
|
|
// rerun will be lost. That matches GitHub Actions' behaviour as of May 2026.
|
|
if err := actions_model.SetArtifactsOfRunDeleted(ctx, job.RunID); err != nil {
|
|
return fmt.Errorf("cannot remove artifacts of previous run of run %d: %w", job.RunID, err)
|
|
}
|
|
|
|
for _, jobToRerun := range GetAllRerunJobs(job, jobs) {
|
|
// If the dependent job is still running, cancel it so that it can be rerun, too. Its results are obsolete
|
|
// when the job it depends on is rerun.
|
|
if !jobToRerun.Status.IsDone() {
|
|
if err := cancelSingleJob(ctx, jobToRerun, actions_model.StatusCancelled); err != nil {
|
|
return fmt.Errorf("cannot cancel dependent job %d with status %s: %w",
|
|
jobToRerun.ID, jobToRerun.Status, err)
|
|
}
|
|
|
|
// Refresh the job after cancellation.
|
|
if jobToRerun, err = actions_model.GetRunJobByID(ctx, jobToRerun.ID); err != nil {
|
|
return fmt.Errorf("cannot refresh cancelled dependent job %d: %w", jobToRerun.ID, err)
|
|
}
|
|
}
|
|
|
|
canBeRerun, err := jobToRerun.CanBeRerun(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot determine whether job %d can be rerun: %w", jobToRerun.ID, err)
|
|
}
|
|
|
|
// This should never happen because the run was validated and the job cancelled if it was running.
|
|
if !canBeRerun {
|
|
return fmt.Errorf("cannot rerun dependent job %d", jobToRerun.ID)
|
|
}
|
|
|
|
// The job that should be rerun cannot be blocked, even if it has needs.
|
|
initialStatus := actions_model.StatusWaiting
|
|
if len(jobToRerun.Needs) > 0 && jobToRerun.ID != job.ID {
|
|
initialStatus = actions_model.StatusBlocked
|
|
}
|
|
|
|
if err := rerunSingleJob(ctx, jobToRerun, initialStatus); err != nil {
|
|
return fmt.Errorf("cannot rerun job %d: %w", jobToRerun.ID, err)
|
|
}
|
|
rerunJobs = append(rerunJobs, jobToRerun)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return rerunJobs, nil
|
|
}
|
|
|
|
func rerunSingleJob(ctx context.Context, job *actions_model.ActionRunJob, initialStatus actions_model.Status) error {
|
|
oldStatus := job.Status
|
|
|
|
if err := job.PrepareNextAttempt(initialStatus); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The columns have to be specified here to work around a xorm quirk: It won't update columns that are set to their
|
|
// zero value without AllCols().
|
|
if _, err := UpdateRunJob(ctx, job, builder.Eq{"status": oldStatus}, "handle", "attempt", "task_id", "status", "started", "stopped"); err != nil {
|
|
return err
|
|
}
|
|
|
|
CreateCommitStatus(ctx, job)
|
|
|
|
return nil
|
|
}
|
|
|
|
// cancelSingleJob cancels the given job and its associated task, if any. outcomeStatus defines the status that should
|
|
// be assigned to the cancelled job and its associated task after cancellation; a non-terminal status will result in an
|
|
// error. Nothing happens if the job has already been completed.
|
|
func cancelSingleJob(ctx context.Context, job *actions_model.ActionRunJob, outcomeStatus actions_model.Status) error {
|
|
if !outcomeStatus.IsDone() {
|
|
return fmt.Errorf("outcomeStatus must be a terminal status, but is: %s", outcomeStatus)
|
|
}
|
|
if job.Status.IsDone() {
|
|
return nil
|
|
}
|
|
|
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
|
if job.TaskID == 0 {
|
|
job.Status = outcomeStatus
|
|
job.Stopped = timeutil.TimeStampNow()
|
|
_, err := UpdateRunJob(ctx, job, nil, "status", "stopped")
|
|
if err != nil {
|
|
return fmt.Errorf("could not cancel job %d: %w", job.ID, err)
|
|
}
|
|
}
|
|
|
|
// A task might have been created while we're trying to cancel the job. Therefore, always try to stop the task.
|
|
if err := StopTask(ctx, job.TaskID, outcomeStatus); err != nil {
|
|
if errors.Is(err, util.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|