Introduce ActionRunAttempt to represent each execution of a run (#37119)
This PR introduces a new `ActionRunAttempt` model and makes Actions
execution attempt-scoped.
**Main Changes**
- Each workflow run trigger generates a new `ActionRunAttempt`. The
triggered jobs are then associated with this new `ActionRunAttempt`
record.
- Each rerun now creates:
- a new `ActionRunAttempt` record for the workflow run
- a full new set of `ActionRunJob` records for the new
`ActionRunAttempt`
- For jobs that need to be rerun, the new job records are created as
runnable jobs in the new attempt.
- For jobs that do not need to be rerun, new job records are still
created in the new attempt, but they reuse the result of the previous
attempt instead of executing again.
- Introduce `rerunPlan` to manage each rerun and refactored rerun flow
into a two-phase plan-based model:
- `buildRerunPlan`
- `execRerunPlan`
- `RerunFailedWorkflowRun` and `RerunFailed` no longer directly derives
all jobs that need to be rerun; this step is now handled by
`buildRerunPlan`.
- Converted artifacts from run-scoped to attempt-scoped:
- uploads are now associated with `RunAttemptID`
- listing, download, and deletion resolve against the current attempt
- Added attempt-aware web Actions views:
- the default run page shows the latest attempt
(`/actions/runs/{run_id}`)
- previous attempt pages show jobs and artifacts for that attempt
(`/actions/runs/{run_id}/attempts/{attempt_num}`)
- New APIs:
- `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}`
- `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs`
- New configuration `MAX_RERUN_ATTEMPTS`
- https://gitea.com/gitea/docs/pulls/383
**Compatibility**
- Existing legacy runs use `LatestAttemptID = 0` and legacy jobs use
`RunAttemptID = 0`. Therefore, these fields can be used to identify
legacy runs and jobs and provide backward compatibility.
- If a legacy run is rerun, an `ActionRunAttempt` with `attempt=1` will
be created to represent the original execution. Then a new
`ActionRunAttempt` with `attempt=2` will be created for the real rerun.
- Existing artifact records are not backfilled; legacy artifacts
continue to use `RunAttemptID = 0`.
**Improvements**
- It is now easier to inspect and download logs from previous attempts.
-
[`run_attempt`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context)
semantics are now aligned with GitHub.
- > A unique number for each attempt of a particular workflow run in a
repository. This number begins at 1 for the workflow run's first
attempt, and increments with each re-run.
- Rerun behavior is now clearer and more explicit.
- Instead of mutating the status of previous jobs in place, each rerun
creates a new attempt with a full new set of job records.
- Artifacts produced by different reruns can now be listed separately.
Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
@@ -61,7 +62,8 @@ const (
|
||||
// ActionArtifact is a file that is stored in the artifact storage.
|
||||
type ActionArtifact struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RunID int64 `xorm:"index unique(runid_name_path)"` // The run id of the artifact
|
||||
RunID int64 `xorm:"index unique(runid_attempt_name_path)"` // The run id of the artifact
|
||||
RunAttemptID int64 `xorm:"index unique(runid_attempt_name_path) NOT NULL DEFAULT 0"`
|
||||
RunnerID int64
|
||||
RepoID int64 `xorm:"index"`
|
||||
OwnerID int64
|
||||
@@ -80,9 +82,9 @@ type ActionArtifact struct {
|
||||
// * "application/pdf", "text/html", etc.: real content type of the artifact
|
||||
ContentEncodingOrType string `xorm:"content_encoding"`
|
||||
|
||||
ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it
|
||||
ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it
|
||||
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
|
||||
ArtifactPath string `xorm:"index unique(runid_attempt_name_path)"` // The path to the artifact when runner uploads it
|
||||
ArtifactName string `xorm:"index unique(runid_attempt_name_path)"` // The name of the artifact when runner uploads it
|
||||
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
|
||||
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
|
||||
@@ -92,12 +94,13 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
|
||||
if err := t.LoadJob(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifact, err := getArtifactByNameAndPath(ctx, t.Job.RunID, artifactName, artifactPath)
|
||||
artifact, err := getArtifactByNameAndPath(ctx, t.Job.RunID, t.Job.RunAttemptID, artifactName, artifactPath)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
artifact := &ActionArtifact{
|
||||
ArtifactName: artifactName,
|
||||
ArtifactPath: artifactPath,
|
||||
RunID: t.Job.RunID,
|
||||
RunAttemptID: t.Job.RunAttemptID,
|
||||
RunnerID: t.RunnerID,
|
||||
RepoID: t.RepoID,
|
||||
OwnerID: t.OwnerID,
|
||||
@@ -122,9 +125,9 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func getArtifactByNameAndPath(ctx context.Context, runID int64, name, fpath string) (*ActionArtifact, error) {
|
||||
func getArtifactByNameAndPath(ctx context.Context, runID, runAttemptID int64, name, fpath string) (*ActionArtifact, error) {
|
||||
var art ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ?", runID, name, fpath).Get(&art)
|
||||
has, err := db.GetEngine(ctx).Where("run_id = ? AND run_attempt_id = ? AND artifact_name = ? AND artifact_path = ?", runID, runAttemptID, name, fpath).Get(&art)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -144,6 +147,7 @@ type FindArtifactsOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
RunID int64
|
||||
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts have run_attempt_id=0)
|
||||
ArtifactName string
|
||||
Status int
|
||||
FinalizedArtifactsV4 bool
|
||||
@@ -163,6 +167,9 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
if opts.RunID > 0 {
|
||||
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
||||
}
|
||||
if opts.RunAttemptID.Has() {
|
||||
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()})
|
||||
}
|
||||
if opts.ArtifactName != "" {
|
||||
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
||||
}
|
||||
@@ -186,11 +193,12 @@ type ActionArtifactMeta struct {
|
||||
ExpiredUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
|
||||
func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*ActionArtifactMeta, error) {
|
||||
// ListUploadedArtifactsMetaByRunAttempt returns uploaded artifacts meta scoped to a specific run and attempt.
|
||||
// Pass runAttemptID=0 to target legacy artifacts (pre-v331) belonging to the run.
|
||||
func ListUploadedArtifactsMetaByRunAttempt(ctx context.Context, repoID, runID, runAttemptID int64) ([]*ActionArtifactMeta, error) {
|
||||
arts := make([]*ActionArtifactMeta, 0, 10)
|
||||
return arts, db.GetEngine(ctx).Table("action_artifact").
|
||||
Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND (status=? OR status=?)", repoID, runID, runAttemptID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||
GroupBy("artifact_name").
|
||||
Select("artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix").
|
||||
Find(&arts)
|
||||
@@ -217,12 +225,29 @@ func SetArtifactExpired(ctx context.Context, artifactID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetArtifactNeedDelete sets an artifact to need-delete, cron job will delete it
|
||||
func SetArtifactNeedDelete(ctx context.Context, runID int64, name string) error {
|
||||
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
|
||||
// SetArtifactNeedDeleteByID sets an artifact to need-delete by ID, cron job will delete it.
|
||||
func SetArtifactNeedDeleteByID(ctx context.Context, artifactID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetArtifactNeedDeleteByRunAttempt sets an artifact to need-delete in a run attempt, cron job will delete it.
|
||||
// runAttemptID may be 0 for legacy artifacts created before ActionRunAttempt existed.
|
||||
func SetArtifactNeedDeleteByRunAttempt(ctx context.Context, runID, runAttemptID int64, name string) error {
|
||||
_, err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=? AND artifact_name=? AND status = ?", runID, runAttemptID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetArtifactsByRunAttemptAndName returns all artifacts with the given name in the specified run attempt.
|
||||
// This supports both attempt-scoped data and legacy artifacts with run_attempt_id=0.
|
||||
func GetArtifactsByRunAttemptAndName(ctx context.Context, runID, runAttemptID int64, artifactName string) ([]*ActionArtifact, error) {
|
||||
arts := make([]*ActionArtifact, 0)
|
||||
return arts, db.GetEngine(ctx).
|
||||
Where("run_id = ? AND run_attempt_id = ? AND artifact_name = ?", runID, runAttemptID, artifactName).
|
||||
OrderBy("id").
|
||||
Find(&arts)
|
||||
}
|
||||
|
||||
// SetArtifactDeleted sets an artifact to deleted
|
||||
func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusDeleted})
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
type ActionRun struct {
|
||||
ID int64
|
||||
Title string
|
||||
RepoID int64 `xorm:"unique(repo_index) index(repo_concurrency)"`
|
||||
RepoID int64 `xorm:"unique(repo_index)"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
OwnerID int64 `xorm:"index"`
|
||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||
@@ -50,15 +50,20 @@ type ActionRun struct {
|
||||
Status Status `xorm:"index"`
|
||||
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
|
||||
RawConcurrency string // raw concurrency
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"`
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
// Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0
|
||||
|
||||
// Started and Stopped are identical to the latest attempt after ActionRunAttempt was introduced.
|
||||
// When a rerun creates a new latest attempt, they are reset until the new attempt starts and stops.
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
// PreviousDuration is used for recording previous duration
|
||||
|
||||
// PreviousDuration is kept only for legacy runs created before ActionRunAttempt existed.
|
||||
// New runs and reruns no longer update this field and use attempt-scoped durations instead.
|
||||
PreviousDuration time.Duration
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
|
||||
LatestAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -160,6 +165,31 @@ func (run *ActionRun) Duration() time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
// GetLatestAttempt returns
|
||||
// - the latest attempt of the run
|
||||
// - (nil, false, nil) for legacy runs that have no attempt records
|
||||
func (run *ActionRun) GetLatestAttempt(ctx context.Context) (*ActionRunAttempt, bool, error) {
|
||||
if run.LatestAttemptID == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return attempt, true, nil
|
||||
}
|
||||
|
||||
func (run *ActionRun) GetEffectiveConcurrency(ctx context.Context) (string, bool, error) {
|
||||
attempt, has, err := run.GetLatestAttempt(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if has {
|
||||
return attempt.ConcurrencyGroup, attempt.ConcurrencyCancel, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) {
|
||||
if run.Event == webhook_module.HookEventPush {
|
||||
var payload api.PushPayload
|
||||
@@ -406,14 +436,11 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
|
||||
|
||||
type ActionRunIndex db.ResourceIndex
|
||||
|
||||
func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRun, []*ActionRunJob, error) {
|
||||
runs, err := db.Find[ActionRun](ctx, &FindRunOptions{
|
||||
RepoID: repoID,
|
||||
ConcurrencyGroup: concurrencyGroup,
|
||||
Status: status,
|
||||
})
|
||||
// GetConcurrentRunAttemptsAndJobs returns run attempts and jobs in the same concurrency group by statuses.
|
||||
func GetConcurrentRunAttemptsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRunAttempt, []*ActionRunJob, error) {
|
||||
attempts, err := FindConcurrentRunAttempts(ctx, repoID, concurrencyGroup, status)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("find runs: %w", err)
|
||||
return nil, nil, fmt.Errorf("find run attempts: %w", err)
|
||||
}
|
||||
|
||||
jobs, err := db.Find[ActionRunJob](ctx, &FindRunJobOptions{
|
||||
@@ -425,36 +452,34 @@ func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGrou
|
||||
return nil, nil, fmt.Errorf("find jobs: %w", err)
|
||||
}
|
||||
|
||||
return runs, jobs, nil
|
||||
return attempts, jobs, nil
|
||||
}
|
||||
|
||||
func CancelPreviousJobsByRunConcurrency(ctx context.Context, actionRun *ActionRun) ([]*ActionRunJob, error) {
|
||||
if actionRun.ConcurrencyGroup == "" {
|
||||
func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunAttempt) ([]*ActionRunJob, error) {
|
||||
if attempt.ConcurrencyGroup == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var jobsToCancel []*ActionRunJob
|
||||
|
||||
statusFindOption := []Status{StatusWaiting, StatusBlocked}
|
||||
if actionRun.ConcurrencyCancel {
|
||||
if attempt.ConcurrencyCancel {
|
||||
statusFindOption = append(statusFindOption, StatusRunning)
|
||||
}
|
||||
runs, jobs, err := GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, statusFindOption)
|
||||
attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, statusFindOption)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
}
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
|
||||
// cancel runs in the same concurrency group
|
||||
for _, run := range runs {
|
||||
if run.ID == actionRun.ID {
|
||||
for _, concurrentAttempt := range attempts {
|
||||
if concurrentAttempt.RunID == attempt.RunID {
|
||||
continue
|
||||
}
|
||||
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: run.ID,
|
||||
})
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, concurrentAttempt.RunID, concurrentAttempt.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
|
||||
return nil, fmt.Errorf("find run %d attempt %d jobs: %w", concurrentAttempt.RunID, concurrentAttempt.ID, err)
|
||||
}
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
145
models/actions/run_attempt.go
Normal file
145
models/actions/run_attempt.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// ActionRunAttempt represents a single execution attempt of an ActionRun.
|
||||
type ActionRunAttempt struct {
|
||||
ID int64
|
||||
RepoID int64 `xorm:"index(repo_concurrency_status)"`
|
||||
RunID int64 `xorm:"UNIQUE(run_attempt)"`
|
||||
Run *ActionRun `xorm:"-"`
|
||||
Attempt int64 `xorm:"UNIQUE(run_attempt)"`
|
||||
|
||||
TriggerUserID int64
|
||||
TriggerUser *user_model.User `xorm:"-"`
|
||||
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency_status) NOT NULL DEFAULT ''"`
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
|
||||
Status Status `xorm:"index(repo_concurrency_status)"`
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func (*ActionRunAttempt) TableName() string {
|
||||
return "action_run_attempt"
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ActionRunAttempt))
|
||||
}
|
||||
|
||||
func (attempt *ActionRunAttempt) Duration() time.Duration {
|
||||
return calculateDuration(attempt.Started, attempt.Stopped, attempt.Status, attempt.Updated)
|
||||
}
|
||||
|
||||
func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) error {
|
||||
if attempt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if attempt.Run == nil {
|
||||
run, err := GetRunByRepoAndID(ctx, attempt.RepoID, attempt.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.Run = run
|
||||
}
|
||||
|
||||
if attempt.TriggerUser == nil {
|
||||
u, err := user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.TriggerUser = u
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) {
|
||||
var attempt ActionRunAttempt
|
||||
has, err := db.GetEngine(ctx).Where("repo_id=? AND id=?", repoID, attemptID).Get(&attempt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, fmt.Errorf("run attempt %d in repo %d: %w", attemptID, repoID, util.ErrNotExist)
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum int64) (*ActionRunAttempt, error) {
|
||||
var attempt ActionRunAttempt
|
||||
has, err := db.GetEngine(ctx).Where("run_id=? AND attempt=?", runID, attemptNum).Get(&attempt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, fmt.Errorf("run attempt %d for run %d: %w", attemptNum, runID, util.ErrNotExist)
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
|
||||
// Results are unordered; callers must not depend on any particular row order.
|
||||
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
|
||||
attempts := make([]*ActionRunAttempt, 0)
|
||||
sess := db.GetEngine(ctx).Where("repo_id=? AND concurrency_group=?", repoID, concurrencyGroup)
|
||||
if len(statuses) > 0 {
|
||||
sess = sess.In("status", statuses)
|
||||
}
|
||||
return attempts, sess.Find(&attempts)
|
||||
}
|
||||
|
||||
func UpdateRunAttempt(ctx context.Context, attempt *ActionRunAttempt, cols ...string) error {
|
||||
if slices.Contains(cols, "status") && attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
cols = append(cols, "started")
|
||||
}
|
||||
|
||||
sess := db.GetEngine(ctx).ID(attempt.ID)
|
||||
if len(cols) > 0 {
|
||||
sess.Cols(cols...)
|
||||
}
|
||||
if _, err := sess.Update(attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only status/timing changes on an attempt need to update the latest run.
|
||||
if len(cols) > 0 && !slices.Contains(cols, "status") && !slices.Contains(cols, "started") && !slices.Contains(cols, "stopped") {
|
||||
return nil
|
||||
}
|
||||
|
||||
run, err := GetRunByRepoAndID(ctx, attempt.RepoID, attempt.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run.LatestAttemptID != attempt.ID {
|
||||
log.Warn("run %d cannot be updated by an old attempt %d", run.LatestAttemptID, attempt.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
run.Status = attempt.Status
|
||||
run.Started = attempt.Started
|
||||
run.Stopped = attempt.Stopped
|
||||
return UpdateRun(ctx, run, "status", "started", "stopped")
|
||||
}
|
||||
46
models/actions/run_attempt_list.go
Normal file
46
models/actions/run_attempt_list.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
)
|
||||
|
||||
type ActionRunAttemptList []*ActionRunAttempt
|
||||
|
||||
// GetUserIDs returns a slice of user's id
|
||||
func (attempts ActionRunAttemptList) GetUserIDs() []int64 {
|
||||
return container.FilterSlice(attempts, func(attempt *ActionRunAttempt) (int64, bool) {
|
||||
return attempt.TriggerUserID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (attempts ActionRunAttemptList) LoadTriggerUser(ctx context.Context) error {
|
||||
userIDs := attempts.GetUserIDs()
|
||||
users := make(map[int64]*user_model.User, len(userIDs))
|
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, attempt := range attempts {
|
||||
if attempt.TriggerUserID == user_model.ActionsUserID {
|
||||
attempt.TriggerUser = user_model.NewActionsUser()
|
||||
} else {
|
||||
attempt.TriggerUser = users[attempt.TriggerUserID]
|
||||
if attempt.TriggerUser == nil {
|
||||
attempt.TriggerUser = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRunAttemptsByRunID returns all attempts of a run, ordered by attempt number DESC (newest first).
|
||||
func ListRunAttemptsByRunID(ctx context.Context, runID int64) (ActionRunAttemptList, error) {
|
||||
var attempts ActionRunAttemptList
|
||||
return attempts, db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("attempt DESC").Find(&attempts)
|
||||
}
|
||||
@@ -34,7 +34,10 @@ type ActionRunJob struct {
|
||||
CommitSHA string `xorm:"index"`
|
||||
IsForkPullRequest bool
|
||||
Name string `xorm:"VARCHAR(255)"`
|
||||
Attempt int64
|
||||
|
||||
// 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
|
||||
@@ -43,8 +46,11 @@ type ActionRunJob struct {
|
||||
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 // the latest task of the job
|
||||
Status Status `xorm:"index"`
|
||||
|
||||
TaskID int64 // 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"`
|
||||
|
||||
RawConcurrency string // raw concurrency from job YAML's "concurrency" section
|
||||
|
||||
@@ -61,6 +67,14 @@ type ActionRunJob struct {
|
||||
// It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified.
|
||||
TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"`
|
||||
|
||||
// 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"`
|
||||
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
@@ -75,6 +89,13 @@ 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)
|
||||
@@ -152,9 +173,50 @@ func GetRunJobByRunAndID(ctx context.Context, runID, jobID int64) (*ActionRunJob
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
func GetRunJobsByRunID(ctx context.Context, runID int64) (ActionJobList, error) {
|
||||
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
|
||||
}
|
||||
if run.LatestAttemptID > 0 {
|
||||
return GetRunJobsByRunAndAttemptID(ctx, runID, run.LatestAttemptID)
|
||||
}
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("id").Find(&jobs); err != nil {
|
||||
if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", repoID, runID).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
|
||||
@@ -196,25 +258,51 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
||||
}
|
||||
|
||||
{
|
||||
// Other goroutines may aggregate the status of the run and update it too.
|
||||
// So we need load the run and its jobs before updating the run.
|
||||
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
jobs, err := GetRunJobsByRunID(ctx, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
run.Status = AggregateJobStatus(jobs)
|
||||
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 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
||||
// 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 job.RunAttemptID > 0 {
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attempt.Status = AggregateJobStatus(jobs)
|
||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||
return 0, fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, job.RepoID, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
run.Status = AggregateJobStatus(jobs)
|
||||
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 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +357,7 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
if job.ConcurrencyCancel {
|
||||
statusFindOption = append(statusFindOption, StatusRunning)
|
||||
}
|
||||
runs, jobs, err := GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
|
||||
attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
}
|
||||
@@ -277,12 +365,13 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
|
||||
// cancel runs in the same concurrency group
|
||||
for _, run := range runs {
|
||||
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: run.ID,
|
||||
})
|
||||
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 jobs: %w", run.ID, err)
|
||||
return nil, fmt.Errorf("find run %d attempt %d jobs: %w", attempt.RunID, attempt.ID, err)
|
||||
}
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
@@ -70,6 +71,7 @@ func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) err
|
||||
type FindRunJobOptions struct {
|
||||
db.ListOptions
|
||||
RunID int64
|
||||
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy jobs have run_attempt_id=0)
|
||||
RepoID int64
|
||||
OwnerID int64
|
||||
CommitSHA string
|
||||
@@ -83,6 +85,9 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
|
||||
if opts.RunID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.run_id": opts.RunID})
|
||||
}
|
||||
if opts.RunAttemptID.Has() {
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.run_attempt_id": opts.RunAttemptID.Value()})
|
||||
}
|
||||
if opts.RepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
|
||||
}
|
||||
|
||||
@@ -83,12 +83,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.CommitSHA != "" {
|
||||
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
|
||||
}
|
||||
if len(opts.ConcurrencyGroup) > 0 {
|
||||
if opts.RepoID == 0 {
|
||||
panic("Invalid FindRunOptions: repo_id is required")
|
||||
}
|
||||
cond = cond.And(builder.Eq{"`action_run`.concurrency_group": opts.ConcurrencyGroup})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,6 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
job.Attempt++
|
||||
job.Started = now
|
||||
job.Status = StatusRunning
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"code.gitea.io/gitea/models/migrations/v1_24"
|
||||
"code.gitea.io/gitea/models/migrations/v1_25"
|
||||
"code.gitea.io/gitea/models/migrations/v1_26"
|
||||
"code.gitea.io/gitea/models/migrations/v1_27"
|
||||
"code.gitea.io/gitea/models/migrations/v1_6"
|
||||
"code.gitea.io/gitea/models/migrations/v1_7"
|
||||
"code.gitea.io/gitea/models/migrations/v1_8"
|
||||
@@ -405,6 +406,9 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
|
||||
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
|
||||
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
|
||||
// Gitea 1.26.0 ends at migration ID number 330 (database version 331)
|
||||
|
||||
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
14
models/migrations/v1_27/main_test.go
Normal file
14
models/migrations/v1_27/main_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/migrations/base"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
base.MainTest(m)
|
||||
}
|
||||
158
models/migrations/v1_27/v331.go
Normal file
158
models/migrations/v1_27/v331.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/migrations/base"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type actionRunAttempt struct {
|
||||
ID int64
|
||||
RepoID int64 `xorm:"index(repo_concurrency_status)"`
|
||||
RunID int64 `xorm:"UNIQUE(run_attempt)"`
|
||||
Attempt int64 `xorm:"UNIQUE(run_attempt)"`
|
||||
TriggerUserID int64
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency_status) NOT NULL DEFAULT ''"`
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
Status int `xorm:"index(repo_concurrency_status)"`
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func (actionRunAttempt) TableName() string {
|
||||
return "action_run_attempt"
|
||||
}
|
||||
|
||||
type actionArtifact struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RunID int64 `xorm:"index unique(runid_attempt_name_path)"`
|
||||
RunAttemptID int64 `xorm:"index unique(runid_attempt_name_path) NOT NULL DEFAULT 0"`
|
||||
RunnerID int64
|
||||
RepoID int64 `xorm:"index"`
|
||||
OwnerID int64
|
||||
CommitSHA string
|
||||
StoragePath string
|
||||
FileSize int64
|
||||
FileCompressedSize int64
|
||||
ContentEncoding string `xorm:"content_encoding"`
|
||||
ArtifactPath string `xorm:"index unique(runid_attempt_name_path)"`
|
||||
ArtifactName string `xorm:"index unique(runid_attempt_name_path)"`
|
||||
Status int `xorm:"index"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
|
||||
ExpiredUnix timeutil.TimeStamp `xorm:"index"`
|
||||
}
|
||||
|
||||
func (actionArtifact) TableName() string {
|
||||
return "action_artifact"
|
||||
}
|
||||
|
||||
// actionRun mirrors the post-migration action_run schema.
|
||||
type actionRun struct {
|
||||
ID int64
|
||||
Title string
|
||||
RepoID int64 `xorm:"unique(repo_index)"`
|
||||
OwnerID int64 `xorm:"index"`
|
||||
WorkflowID string `xorm:"index"`
|
||||
Index int64 `xorm:"index unique(repo_index)"`
|
||||
TriggerUserID int64 `xorm:"index"`
|
||||
ScheduleID int64
|
||||
Ref string `xorm:"index"`
|
||||
CommitSHA string
|
||||
IsForkPullRequest bool
|
||||
NeedApproval bool
|
||||
ApprovedBy int64 `xorm:"index"`
|
||||
Event string
|
||||
EventPayload string `xorm:"LONGTEXT"`
|
||||
TriggerEvent string
|
||||
Status int `xorm:"index"`
|
||||
Version int `xorm:"version default 0"`
|
||||
RawConcurrency string
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
PreviousDuration time.Duration
|
||||
LatestAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func (actionRun) TableName() string {
|
||||
return "action_run"
|
||||
}
|
||||
|
||||
// AddActionRunAttemptModel adds the ActionRunAttempt table and the supporting ActionRun/ActionRunJob fields.
|
||||
func AddActionRunAttemptModel(x *xorm.Engine) error {
|
||||
// add "action_run_attempt"
|
||||
if _, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
}, new(actionRunAttempt)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update "action_run_job"
|
||||
type ActionRunJob struct {
|
||||
RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
}
|
||||
if _, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
}, new(ActionRunJob)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update "action_artifact": let xorm sync add the new 4-column unique index (runid_attempt_name_path) and drop the old 3-column unique (runid_name_path)
|
||||
if err := x.Sync(new(actionArtifact)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update "action_run"
|
||||
//
|
||||
// This migration intentionally removes the legacy run-level concurrency columns after
|
||||
// introducing attempt-level concurrency on action_run_attempt.
|
||||
//
|
||||
// Existing values from action_run.concurrency_group / action_run.concurrency_cancel are
|
||||
// not backfilled into action_run_attempt:
|
||||
// - the old fields are only meaningful while a run is actively participating in
|
||||
// concurrency scheduling
|
||||
// - for completed legacy runs, keeping or backfilling those values has no practical
|
||||
// effect on future scheduling behavior
|
||||
// - scanning and backfilling old runs would add significant migration cost for little value
|
||||
//
|
||||
// This means the schema change is destructive for those two legacy columns by design.
|
||||
//
|
||||
// Let xorm sync add the latest_attempt_id column and drop the now-orphan (repo_id, concurrency_group) index.
|
||||
if err := x.Sync(new(actionRun)); err != nil {
|
||||
return err
|
||||
}
|
||||
concurrencyColumns := make([]string, 0, 2)
|
||||
for _, col := range []string{"concurrency_group", "concurrency_cancel"} {
|
||||
exist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "action_run", col)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
concurrencyColumns = append(concurrencyColumns, col)
|
||||
}
|
||||
}
|
||||
if len(concurrencyColumns) == 0 {
|
||||
return nil
|
||||
}
|
||||
sess := x.NewSession()
|
||||
defer sess.Close()
|
||||
if err := base.DropTableColumns(sess, "action_run", concurrencyColumns...); err != nil {
|
||||
return err
|
||||
}
|
||||
// DropTableColumns rebuilds the table on SQLite, which drops all existing indexes.
|
||||
// Re-sync to restore the indexes defined on actionRun.
|
||||
return x.Sync(new(actionRun))
|
||||
}
|
||||
156
models/migrations/v1_27/v331_test.go
Normal file
156
models/migrations/v1_27/v331_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/migrations/base"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
type actionRunBeforeV331 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
ConcurrencyGroup string
|
||||
ConcurrencyCancel bool
|
||||
LatestAttemptID int64 `xorm:"-"`
|
||||
}
|
||||
|
||||
func (actionRunBeforeV331) TableName() string {
|
||||
return "action_run"
|
||||
}
|
||||
|
||||
type actionRunJobBeforeV331 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RunID int64 `xorm:"index"`
|
||||
RepoID int64 `xorm:"index"`
|
||||
}
|
||||
|
||||
func (actionRunJobBeforeV331) TableName() string {
|
||||
return "action_run_job"
|
||||
}
|
||||
|
||||
type actionArtifactBeforeV331 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RunID int64 `xorm:"index unique(runid_name_path)"`
|
||||
RepoID int64 `xorm:"index"`
|
||||
ArtifactPath string `xorm:"index unique(runid_name_path)"`
|
||||
ArtifactName string `xorm:"index unique(runid_name_path)"`
|
||||
}
|
||||
|
||||
func (actionArtifactBeforeV331) TableName() string {
|
||||
return "action_artifact"
|
||||
}
|
||||
|
||||
func Test_AddActionRunAttemptModel(t *testing.T) {
|
||||
x, deferable := base.PrepareTestEnv(t, 0,
|
||||
new(actionRunBeforeV331),
|
||||
new(actionRunJobBeforeV331),
|
||||
new(actionArtifactBeforeV331),
|
||||
)
|
||||
defer deferable()
|
||||
if x == nil || t.Failed() {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := x.Insert(&actionArtifactBeforeV331{
|
||||
RunID: 1,
|
||||
RepoID: 1,
|
||||
ArtifactPath: "artifact/path",
|
||||
ArtifactName: "artifact-name",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, AddActionRunAttemptModel(x))
|
||||
|
||||
tableMap := base.LoadTableSchemasMap(t, x)
|
||||
|
||||
attemptTable := tableMap["action_run_attempt"]
|
||||
require.NotNil(t, attemptTable)
|
||||
attemptTablCols := []string{"id", "repo_id", "run_id", "attempt", "trigger_user_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "created", "updated"}
|
||||
require.ElementsMatch(t, attemptTable.ColumnsSeq(), attemptTablCols)
|
||||
|
||||
runTable := tableMap["action_run"]
|
||||
require.NotNil(t, runTable)
|
||||
require.Contains(t, runTable.ColumnsSeq(), "latest_attempt_id")
|
||||
require.NotContains(t, runTable.ColumnsSeq(), "concurrency_group")
|
||||
require.NotContains(t, runTable.ColumnsSeq(), "concurrency_cancel")
|
||||
|
||||
jobTable := tableMap["action_run_job"]
|
||||
require.NotNil(t, jobTable)
|
||||
require.Contains(t, jobTable.ColumnsSeq(), "run_attempt_id")
|
||||
require.Contains(t, jobTable.ColumnsSeq(), "attempt_job_id")
|
||||
require.Contains(t, jobTable.ColumnsSeq(), "source_task_id")
|
||||
|
||||
attemptIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_attempt")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"run_id", "attempt"}, true))
|
||||
assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"repo_id", "concurrency_group", "status"}, false))
|
||||
|
||||
runIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, hasIndexWithColumns(runIndexes, []string{"latest_attempt_id"}, false))
|
||||
assert.False(t, hasIndexWithColumns(runIndexes, []string{"repo_id", "concurrency_group"}, false))
|
||||
|
||||
jobIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_job")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, hasIndexWithColumns(jobIndexes, []string{"run_attempt_id"}, false))
|
||||
assert.True(t, hasIndexWithColumns(jobIndexes, []string{"attempt_job_id"}, false))
|
||||
|
||||
indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_artifact")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, hasIndexWithColumns(indexes, []string{"run_id", "artifact_path", "artifact_name"}, true))
|
||||
assert.True(t, hasIndexWithColumns(indexes, []string{"run_id", "run_attempt_id", "artifact_path", "artifact_name"}, true))
|
||||
|
||||
_, err = x.Insert(&actionArtifact{
|
||||
RunID: 1,
|
||||
RunAttemptID: 2,
|
||||
RepoID: 1,
|
||||
ArtifactPath: "artifact/path",
|
||||
ArtifactName: "artifact-name",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = x.Insert(&actionArtifact{
|
||||
RunID: 1,
|
||||
RunAttemptID: 2,
|
||||
RepoID: 1,
|
||||
ArtifactPath: "artifact/path",
|
||||
ArtifactName: "artifact-name",
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = x.Insert(&actionRunAttempt{
|
||||
RepoID: 1,
|
||||
RunID: 1,
|
||||
Attempt: 2,
|
||||
TriggerUserID: 1,
|
||||
Status: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = x.Insert(&actionRunAttempt{
|
||||
RepoID: 1,
|
||||
RunID: 1,
|
||||
Attempt: 2,
|
||||
TriggerUserID: 2,
|
||||
Status: 1,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func hasIndexWithColumns(indexes map[string]*schemas.Index, cols []string, isUnique bool) bool {
|
||||
for _, index := range indexes {
|
||||
if isUnique && index.Type != schemas.UniqueType {
|
||||
continue
|
||||
}
|
||||
if slices.Equal(index.Cols, cols) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user