[v14.0/forgejo] fix: empty dynamic matrix can leave action run hanging incomplete (#11072)

**Backport:** #11063

Fixes #11030.

When a `strategy.matrix` needs to be evaluated on the output of another job, it can become evaluated into an empty set of jobs.  In this case, and assuming no other jobs in the run are active, the run should reach a settled state.  The logic to check the other jobs in the run and determine if this state has been hit needs to be explicitly added to the job emitter.

To accomplish this change, this action run state logic was extracted out of `UpdateRunJobWithoutNotification` where it could be reused.

(cherry picked from commit c198082975)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11072
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2026-01-27 20:14:04 +01:00 committed by Gusted
parent 9347d06de5
commit 032b0bbeda
8 changed files with 274 additions and 32 deletions

View file

@ -500,4 +500,35 @@ func UpdateRunWithoutNotification(ctx context.Context, run *ActionRun, cols ...s
return nil
}
// Compute the Status, Started, and Stopped fields of an ActionRun based upon the current job state within the run.
// Returned is the [ActionRun] with modifications if necessary, a slice of column names that have been updated, or an
// error if the calculation failed. The caller is responsible for then invoking [actions_service.UpdateRun] for an
// update with notifications, or [actions_model.UpdateRunWithoutNotification] if notifications are already handled.
func ComputeRunStatus(ctx context.Context, runID int64) (run *ActionRun, columns []string, err error) {
run, err = GetRunByID(ctx, runID)
if err != nil {
return nil, nil, err
}
jobs, err := GetRunJobsByRunID(ctx, runID)
if err != nil {
return nil, nil, err
}
newStatus := AggregateJobStatus(jobs)
if run.Status != newStatus {
run.Status = newStatus
columns = append(columns, "status")
}
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
columns = append(columns, "started")
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
columns = append(columns, "stopped")
}
return run, columns, nil
}
type ActionRunIndex db.ResourceIndex

View file

@ -174,38 +174,12 @@ func UpdateRunJobWithoutNotification(ctx context.Context, job *ActionRunJob, con
}
}
{
// 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 := GetRunByID(ctx, job.RunID)
if err != nil {
return 0, err
}
jobs, err := GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
return 0, err
}
updateRequired := false
newStatus := AggregateJobStatus(jobs)
if run.Status != newStatus {
run.Status = newStatus
updateRequired = true
}
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
updateRequired = true
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
updateRequired = true
}
if updateRequired {
// As the caller has to ensure the ActionRunNowDone notification is sent we can ignore doing so here.
if err := UpdateRunWithoutNotification(ctx, run, "status", "started", "stopped"); err != nil {
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
}
}
run, columns, err := ComputeRunStatus(ctx, job.RunID)
if err != nil {
return 0, fmt.Errorf("compute run status: %w", err)
}
if err := UpdateRunWithoutNotification(ctx, run, columns...); err != nil {
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
}
return affected, nil

View file

@ -272,3 +272,122 @@ jobs:
// Expect job with an incomplete runs-on to be StatusBlocked:
assert.Equal(t, StatusBlocked, job.Status)
}
func TestComputeRunStatus(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("no changes", func(t *testing.T) {
run, columns, err := ComputeRunStatus(t.Context(), 791)
require.NoError(t, err)
assert.Equal(t, StatusSuccess, run.Status)
assert.NotContains(t, columns, "status")
assert.EqualValues(t, 1683636528, run.Started)
assert.NotContains(t, columns, "started")
assert.EqualValues(t, 1683636626, run.Stopped)
assert.NotContains(t, columns, "stopped")
})
t.Run("change status", func(t *testing.T) {
job := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: 192})
job.Status = StatusFailure
affected, err := db.GetEngine(t.Context()).Cols("status").ID(job.ID).Update(job)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
run, columns, err := ComputeRunStatus(t.Context(), 791)
require.NoError(t, err)
assert.Equal(t, StatusFailure, run.Status)
assert.Contains(t, columns, "status")
assert.NotContains(t, columns, "started")
assert.NotContains(t, columns, "stopped")
})
t.Run("won't change started if not running", func(t *testing.T) {
job := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: 192})
job.Status = StatusBlocked
affected, err := db.GetEngine(t.Context()).Cols("status").ID(job.ID).Update(job)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
preRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: 791})
preRun.Started = 0
affected, err = db.GetEngine(t.Context()).Cols("started").ID(preRun.ID).Update(preRun)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
run, columns, err := ComputeRunStatus(t.Context(), 791)
require.NoError(t, err)
assert.Equal(t, StatusBlocked, run.Status)
assert.EqualValues(t, 0, run.Started)
assert.Contains(t, columns, "status")
assert.NotContains(t, columns, "started")
assert.NotContains(t, columns, "stopped")
})
t.Run("change started", func(t *testing.T) {
// Need the job to be "Running" for started to appear to change
job := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: 192})
job.Status = StatusRunning
affected, err := db.GetEngine(t.Context()).Cols("status").ID(job.ID).Update(job)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
preRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: 791})
preRun.Started = 0
affected, err = db.GetEngine(t.Context()).Cols("started").ID(preRun.ID).Update(preRun)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
run, columns, err := ComputeRunStatus(t.Context(), 791)
require.NoError(t, err)
assert.Equal(t, StatusRunning, run.Status)
assert.NotEqualValues(t, 0, run.Started)
assert.Contains(t, columns, "status")
assert.Contains(t, columns, "started")
assert.NotContains(t, columns, "stopped")
})
t.Run("won't change stopped if not done", func(t *testing.T) {
job := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: 192})
job.Status = StatusRunning
affected, err := db.GetEngine(t.Context()).Cols("status").ID(job.ID).Update(job)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
preRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: 791})
preRun.Stopped = 0
affected, err = db.GetEngine(t.Context()).Cols("stopped").ID(preRun.ID).Update(preRun)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
run, columns, err := ComputeRunStatus(t.Context(), 791)
require.NoError(t, err)
assert.Equal(t, StatusRunning, run.Status)
assert.EqualValues(t, 0, run.Stopped)
assert.Contains(t, columns, "status")
assert.NotContains(t, columns, "stopped")
})
t.Run("change stopped", func(t *testing.T) {
// Need the job to be some version of Done for stopped to appear to change
job := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: 192})
job.Status = StatusSuccess
affected, err := db.GetEngine(t.Context()).Cols("status").ID(job.ID).Update(job)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
preRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: 791})
preRun.Stopped = 0
affected, err = db.GetEngine(t.Context()).Cols("stopped").ID(preRun.ID).Update(preRun)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
run, columns, err := ComputeRunStatus(t.Context(), 791)
require.NoError(t, err)
assert.Equal(t, StatusSuccess, run.Status)
assert.NotEqualValues(t, 0, run.Stopped)
assert.NotContains(t, columns, "status")
assert.NotContains(t, columns, "started")
assert.Contains(t, columns, "stopped")
})
}