mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +09:00
fix(actions): dynamic matrix expansion correctness fixes (#38690)
Follow-up to https://github.com/go-gitea/gitea/pull/36564 (dynamic matrix) and https://github.com/go-gitea/gitea/pull/36357 (max-parallel), fixing issues found reviewing the two features together. - **A placeholder could stall its run forever.** Its payload keeps the raw matrix but loses its `needs`, so `ParseJob` re-expanded it instead of reading it back — fatal for `include: ${{ fromJson(needs.*.outputs.*) }}`. - **An `if:` reading `matrix.*` skipped the whole job**, with or without the `${{ }}`. It now reduces to the needs gate, except under `always()`/`failure()`/`cancelled()`, and each combination is decided on its own values once the matrix expands. - **Dependents could be skipped before the combinations ran**, since inserted siblings are absent from the resolver's job set. The pass now stops after an insert and defers to the re-emit it schedules. - **Expansion failures stranded the placeholder.** A retryable one is returned so the queue retries it; a malformed payload fails the job instead of requeueing forever. - **Rerun could rewind a pass-through row** into a raw placeholder keeping its old terminal status, which nothing expands. Now gated on the anchor itself. Plus: `max-parallel` distinguishes an unevaluated `${{ }}` (debug) from a non-numeric literal (warn — it silently drops the cap). Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
co-authored by
Zettat123
silverwind
parent
e80a62f555
commit
11d0ed699b
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func getWorkflowDispatchInputsFromRun(run *actions_model.ActionRun) (map[string]any, error) {
|
||||
@@ -42,7 +43,7 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act
|
||||
}
|
||||
var p api.WorkflowCallPayload
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
|
||||
return nil, fmt.Errorf("decode caller %d payload: %w", caller.ID, err)
|
||||
return nil, util.NewInvalidArgumentErrorf("decode caller %d payload: %v", caller.ID, err)
|
||||
}
|
||||
if p.Inputs == nil {
|
||||
return map[string]any{}, nil
|
||||
@@ -60,6 +61,12 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
|
||||
if len(parsedJob.If.Value) == 0 {
|
||||
return allNeedsSucceed, nil
|
||||
}
|
||||
// A deferred-matrix placeholder has no combination yet, so an `if:` reading `matrix.*` can only be
|
||||
// decided by the emitter's post-expansion pass, against each combination's own values.
|
||||
// always()/failure()/cancelled() opt out of the needs gate this falls back to.
|
||||
if job.IsMatrixDeferred && jobparser.ExpressionReadsMatrix(parsedJob.If.Value) {
|
||||
return allNeedsSucceed || jobparser.ExpressionIgnoresNeedResults(parsedJob.If.Value), nil
|
||||
}
|
||||
jobResults, err := findJobNeedsAndFillJobResults(ctx, job)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -77,7 +84,7 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
|
||||
return false, err
|
||||
}
|
||||
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
|
||||
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
|
||||
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs, job.IsMatrixDeferred)
|
||||
}
|
||||
|
||||
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEvaluateJobIfDefersMatrixExpression(t *testing.T) {
|
||||
// A placeholder's `if:` reading `matrix.*` can only be decided per combination once the matrix is expanded.
|
||||
|
||||
emptyRun := &actions_model.ActionRun{} // if the gate is removed, the emptyRun will cause an error
|
||||
deferredJob := func(ifExpr string) *actions_model.ActionRunJob {
|
||||
return &actions_model.ActionRunJob{
|
||||
ID: 1, JobID: "build", Needs: []string{"setup"}, IsMatrixDeferred: true,
|
||||
WorkflowPayload: fmt.Appendf(nil, `name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: %s
|
||||
strategy:
|
||||
matrix:
|
||||
value: ${{ fromJson(needs.setup.outputs.m) }}
|
||||
steps:
|
||||
- run: echo
|
||||
`, ifExpr),
|
||||
}
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
ifExpr string
|
||||
// wantNeedsFailed is what the `if:` decides when the needs did not all succeed.
|
||||
wantNeedsFailed bool
|
||||
}{
|
||||
// These hold only for a real combination, so none can be decided before the matrix expands,
|
||||
// and the fallback is the needs gate: a job whose needs did not succeed is still skipped.
|
||||
{ifExpr: "${{ matrix.value == 1 }}"},
|
||||
{ifExpr: "matrix.value == 1"}, // an `if:` may omit the `${{ }}`
|
||||
// always() asks to run whatever the needs did, so it must not fall back to their gate.
|
||||
{ifExpr: "${{ always() && matrix.value == 1 }}", wantNeedsFailed: true},
|
||||
} {
|
||||
t.Run(tt.ifExpr, func(t *testing.T) {
|
||||
got, err := evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, true)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got, "must reach the expansion that gates each combination on its own values")
|
||||
|
||||
got, err = evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantNeedsFailed, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -361,6 +361,8 @@ type jobStatusResolver struct {
|
||||
// matrixChanged is set when matrix expansion inserted siblings or failed a placeholder, both of
|
||||
// which need a follow-up pass to resolve the dependents.
|
||||
matrixChanged bool
|
||||
// matrixInserted is set when matrix expansion inserted sibling rows, which Resolve stops on.
|
||||
matrixInserted bool
|
||||
// matrixUpdatedJobs holds jobs whose status matrix expansion persisted itself, so they are
|
||||
// notified like the ones the caller updates from the resolved status map.
|
||||
matrixUpdatedJobs []*actions_model.ActionRunJob
|
||||
@@ -418,6 +420,14 @@ func (r *jobStatusResolver) Resolve(ctx context.Context) (map[int64]actions_mode
|
||||
ret[k] = v
|
||||
r.statuses[k] = v
|
||||
}
|
||||
if r.matrixInserted {
|
||||
// Matrix expansion inserted sibling rows this round. They are not in statuses/needs, so
|
||||
// another round would resolve a dependent of the expanded job against the placeholder's
|
||||
// own combination alone: if that combination was just skipped by its `if:`, the dependent
|
||||
// sees all its needs done and gets skipped before any sibling has even started. Stop here
|
||||
// and let the re-emit, which reloads the full job set, resolve them.
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
@@ -472,8 +482,8 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
|
||||
|
||||
// Decide whether the job runs at all before expanding a deferred matrix: a job whose needs
|
||||
// failed or were skipped has to be skipped too, not failed for a matrix those needs never
|
||||
// produced the outputs for. A job-level `if:` cannot read `matrix.*`, so it does not need
|
||||
// the combination, unlike the concurrency expression evaluated below.
|
||||
// produced the outputs for. An `if:` that reads `matrix.*` cannot be decided this early, so
|
||||
// evaluateJobIf reduces it to that needs gate and the pass below decides it per combination.
|
||||
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
|
||||
if err != nil {
|
||||
// TODO: surface deterministic expression errors to users by failing the job with a message.
|
||||
@@ -489,8 +499,10 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
|
||||
wasDeferred := actionRunJob.IsMatrixDeferred
|
||||
siblings, err := expandDeferredMatrix(ctx, actionRunJob, r.vars)
|
||||
if err != nil {
|
||||
// Aborting the pass is required: the placeholder is already claimed as the first
|
||||
// combination, so committing here would drop the remaining ones for good.
|
||||
// Aborting the pass is required: once the placeholder is claimed as the first combination,
|
||||
// committing here would drop the remaining ones for good. Before the claim it is what gets
|
||||
// the pass retried by the job-emitter queue, since a run whose needs are all done has
|
||||
// nothing left to trigger another pass on its own.
|
||||
return nil, fmt.Errorf("expand matrix of job %d: %w", id, err)
|
||||
}
|
||||
if actionRunJob.Status != actions_model.StatusBlocked {
|
||||
@@ -503,10 +515,12 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
|
||||
if actionRunJob.IsMatrixDeferred {
|
||||
continue // could not be expanded yet, it stays blocked and is retried on the next pass
|
||||
}
|
||||
r.matrixChanged = r.matrixChanged || len(siblings) > 0
|
||||
if len(siblings) > 0 {
|
||||
r.matrixChanged, r.matrixInserted = true, true
|
||||
}
|
||||
if wasDeferred {
|
||||
// The `if:` above was decided against the raw matrix, so this row still has to be gated
|
||||
// by its own combination like the siblings are on the next pass.
|
||||
// This row is now the first combination, and the `if:` can be evaluated.
|
||||
// Gate it on its own combination here, as the siblings will be on the next pass.
|
||||
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
|
||||
if err != nil {
|
||||
log.Error("evaluateJobIf failed after matrix expansion, job will stay blocked: job: %d, err: %v", id, err)
|
||||
|
||||
@@ -589,3 +589,39 @@ func Test_maxParallelReusableCallerLifecycle(t *testing.T) {
|
||||
|
||||
assert.Equal(t, len(callers), statusCounts(callers)[actions_model.StatusSuccess])
|
||||
}
|
||||
|
||||
// Test_jobStatusResolverStopsAfterMatrixInsert covers the invariant that keeps a dynamic matrix's
|
||||
// dependents honest: a round resolved after an insert would judge them against a job set that is
|
||||
// missing the siblings. See Resolve for why that is wrong.
|
||||
func Test_jobStatusResolverStopsAfterMatrixInsert(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
// build (2) stands for the expanded anchor: it reaches a terminal status this round, which is
|
||||
// what would let report (3) resolve in the next one.
|
||||
newChain := func() actions_model.ActionJobList {
|
||||
return actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "generate", Status: actions_model.StatusFailure, WorkflowPayload: minimalWorkflowPayload("generate")},
|
||||
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{"generate"}, WorkflowPayload: minimalWorkflowPayload("build")},
|
||||
{ID: 3, JobID: "report", Status: actions_model.StatusBlocked, Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report")},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("without an insert the whole chain resolves in one pass", func(t *testing.T) {
|
||||
got, err := newJobStatusResolver(newChain(), nil).Resolve(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[int64]actions_model.Status{
|
||||
2: actions_model.StatusSkipped,
|
||||
3: actions_model.StatusSkipped,
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("an insert stops the pass before the dependents are resolved", func(t *testing.T) {
|
||||
r := newJobStatusResolver(newChain(), nil)
|
||||
r.matrixInserted = true // as resolve() sets it once expansion has inserted siblings
|
||||
|
||||
got, err := r.Resolve(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[int64]actions_model.Status{2: actions_model.StatusSkipped}, got,
|
||||
"report must wait for the re-emit, which sees the sibling combinations too")
|
||||
})
|
||||
}
|
||||
|
||||
+31
-40
@@ -17,7 +17,6 @@ import (
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -28,16 +27,19 @@ import (
|
||||
//
|
||||
// It runs inside the caller's transaction (job_emitter's resolver) and must not open a nested
|
||||
// db.WithTx, which would reuse the ambient session and roll the whole emitter pass back on error.
|
||||
// The three outcomes are reported through the job itself:
|
||||
// The three outcomes are:
|
||||
// - expanded: IsMatrixDeferred is cleared and the job stays StatusBlocked.
|
||||
// - the workflow's fault (a matrix that cannot resolve, or one too large): a terminal status is
|
||||
// persisted here, reported by the job leaving StatusBlocked. IsMatrixDeferred stays set, marking
|
||||
// the payload as still unexpanded for a later rerun to re-derive.
|
||||
// - not now: the job is left deferred and blocked for the next emitter pass to retry. This covers
|
||||
// a transient failure before anything was written, and losing the claim to a concurrent pass.
|
||||
// - not now: losing the claim to a concurrent pass leaves the job deferred and blocked, for the
|
||||
// pass that won the claim to carry through.
|
||||
//
|
||||
// A returned error is reserved for a failure after the placeholder was claimed and must roll the
|
||||
// caller's transaction back: committing it would drop the remaining combinations for good.
|
||||
// Every other failure is returned, which aborts the emitter pass and lets the job-emitter queue retry
|
||||
// it. Swallowing one would strand the placeholder: nothing re-emits a run whose needs have all already
|
||||
// finished, so there would be no next pass to retry in. After the placeholder has been claimed a
|
||||
// returned error additionally has to roll the caller's transaction back, because committing it would
|
||||
// drop the remaining combinations for good.
|
||||
func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob, vars map[string]string) ([]*actions_model.ActionRunJob, error) {
|
||||
if !job.IsMatrixDeferred {
|
||||
return nil, nil
|
||||
@@ -47,7 +49,7 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
// reusable caller (a job may be both) would drop: its branch only handles waiting and skipped.
|
||||
// The commit status is unaffected by the surviving flag: suppression only applies while the job
|
||||
// is not done.
|
||||
failTerminal := func(cause error) ([]*actions_model.ActionRunJob, error) {
|
||||
failTerminal := func(cause error) error {
|
||||
log.Warn("Matrix expansion failed for job %d (JobID: %s): %v", job.ID, job.JobID, cause)
|
||||
prevStatus, prevStopped := job.Status, job.Stopped
|
||||
job.Status = actions_model.StatusFailure
|
||||
@@ -58,52 +60,45 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
|
||||
"status", "stopped")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail deferred matrix job %d: %w", job.ID, err)
|
||||
return fmt.Errorf("fail deferred matrix job %d: %w", job.ID, err)
|
||||
}
|
||||
if affected != 1 {
|
||||
// A concurrent pass already advanced the row. Restore the in-memory state so this pass
|
||||
// does not report a failure that was never persisted.
|
||||
job.Status, job.Stopped = prevStatus, prevStopped
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// retryLater leaves the placeholder untouched. It is only used before anything is written, so a
|
||||
// transient failure neither fails the job nor aborts the emitter pass for the whole run.
|
||||
retryLater := func(cause error) ([]*actions_model.ActionRunJob, error) {
|
||||
log.Error("Matrix expansion of job %d (JobID: %s) postponed to the next pass: %v", job.ID, job.JobID, cause)
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// The resolver only calls this once every need is done, as it does for job concurrency.
|
||||
results, err := findJobNeedsAndFillJobResults(ctx, job)
|
||||
if err != nil {
|
||||
return retryLater(fmt.Errorf("find needs: %w", err))
|
||||
return nil, fmt.Errorf("find needs: %w", err)
|
||||
}
|
||||
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
return retryLater(fmt.Errorf("load attributes: %w", err))
|
||||
return nil, fmt.Errorf("load attributes: %w", err)
|
||||
}
|
||||
|
||||
// The payload still carries the raw, unevaluated matrix: planning only erases the needs.
|
||||
var baseSWF jobparser.SingleWorkflow
|
||||
if err := yaml.Unmarshal(job.WorkflowPayload, &baseSWF); err != nil {
|
||||
return failTerminal(fmt.Errorf("unmarshal payload: %w", err))
|
||||
}
|
||||
_, parsedJob := baseSWF.Job()
|
||||
if parsedJob == nil {
|
||||
return failTerminal(errors.New("payload contains no job"))
|
||||
baseSWF, parsedJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
|
||||
if err != nil {
|
||||
return nil, failTerminal(fmt.Errorf("parse payload: %w", err))
|
||||
}
|
||||
|
||||
// `strategy` may reference the inputs context as well as needs, so resolve it like `if:` does.
|
||||
inputs, err := getInputsForJob(ctx, job.Run, job)
|
||||
if err != nil {
|
||||
return retryLater(fmt.Errorf("get inputs: %w", err))
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
// A malformed payload never becomes readable, so retrying would requeue the run forever.
|
||||
return nil, failTerminal(fmt.Errorf("get inputs: %w", err))
|
||||
}
|
||||
return nil, fmt.Errorf("get inputs: %w", err)
|
||||
}
|
||||
|
||||
existingJobs, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
return retryLater(fmt.Errorf("count jobs of attempt %d: %w", job.RunAttemptID, err))
|
||||
return nil, fmt.Errorf("count jobs of attempt %d: %w", job.RunAttemptID, err)
|
||||
}
|
||||
// The placeholder is reused as the first combination, so the attempt only grows by len-1.
|
||||
maxCombinations := int(actions_model.MaxJobNumPerRun - existingJobs + 1)
|
||||
@@ -111,12 +106,12 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
giteaCtx := GenerateGiteaContext(ctx, job.Run, nil, job)
|
||||
expandedJobs, err := jobparser.ExpandMatrixWithNeeds(job.JobID, parsedJob, giteaCtx.ToGitHubContext(), results, vars, inputs, maxCombinations)
|
||||
if err != nil {
|
||||
return failTerminal(fmt.Errorf("expand matrix: %w", err))
|
||||
return nil, failTerminal(fmt.Errorf("expand matrix: %w", err))
|
||||
}
|
||||
// Combinations differ only in what the matrix feeds: the name, the payload, and a
|
||||
// runs-on/continue-on-error that may interpolate matrix.*.
|
||||
applyCombo := func(dst *actions_model.ActionRunJob, combo *jobparser.Job) error {
|
||||
swf := baseSWF
|
||||
swf := baseSWF.CloneHeader()
|
||||
if err := swf.SetJob(job.JobID, combo.EraseNeeds()); err != nil {
|
||||
return fmt.Errorf("set expanded job: %w", err)
|
||||
}
|
||||
@@ -142,7 +137,7 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
// Only the placeholder keeps the raw payload, which is what identifies it as the group's anchor.
|
||||
sibling.DeferredMatrixPayload = nil
|
||||
if err := applyCombo(&sibling, combo); err != nil {
|
||||
return failTerminal(err)
|
||||
return nil, failTerminal(err)
|
||||
}
|
||||
siblings = append(siblings, &sibling)
|
||||
}
|
||||
@@ -154,13 +149,13 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
if job.ParentJobID > 0 {
|
||||
parent, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
|
||||
if err != nil {
|
||||
return retryLater(fmt.Errorf("load parent of job %d: %w", job.ID, err))
|
||||
return nil, fmt.Errorf("load parent of job %d: %w", job.ID, err)
|
||||
}
|
||||
parentAttemptJobID = parent.AttemptJobID
|
||||
}
|
||||
priorCombos, err := actions_model.GetPriorAttemptMatrixCombos(ctx, job.RunID, job.RunAttemptID, parentAttemptJobID, job.JobID)
|
||||
if err != nil {
|
||||
return retryLater(fmt.Errorf("lookup prior attempt combos of job %d: %w", job.ID, err))
|
||||
return nil, fmt.Errorf("lookup prior attempt combos of job %d: %w", job.ID, err)
|
||||
}
|
||||
usedIDs := container.SetOf(job.AttemptJobID)
|
||||
for _, sibling := range siblings {
|
||||
@@ -174,7 +169,7 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
// conditional update is an atomic claim: only the caller that flips IsMatrixDeferred inserts.
|
||||
beforeClaim := *job
|
||||
if err := applyCombo(job, expandedJobs[0]); err != nil {
|
||||
return failTerminal(err)
|
||||
return nil, failTerminal(err)
|
||||
}
|
||||
job.IsMatrixDeferred = false
|
||||
affected, err := actions_model.UpdateRunJob(ctx, job,
|
||||
@@ -209,13 +204,9 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
|
||||
// restoreDeferredMatrixPlaceholder rewinds a rerun clone of a dynamic-matrix combination into the unexpanded placeholder it grew from
|
||||
func restoreDeferredMatrixPlaceholder(clone *actions_model.ActionRunJob) error {
|
||||
var swf jobparser.SingleWorkflow
|
||||
if err := yaml.Unmarshal(clone.DeferredMatrixPayload, &swf); err != nil {
|
||||
return fmt.Errorf("unmarshal deferred matrix payload: %w", err)
|
||||
}
|
||||
_, parsed := swf.Job()
|
||||
if parsed == nil {
|
||||
return errors.New("deferred matrix payload contains no job")
|
||||
_, parsed, err := jobparser.ParseRawSingleWorkflow(clone.DeferredMatrixPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse deferred matrix payload: %w", err)
|
||||
}
|
||||
clone.Name = util.EllipsisDisplayString(parsed.Name, 255)
|
||||
clone.WorkflowPayload = slices.Clone(clone.DeferredMatrixPayload)
|
||||
|
||||
@@ -142,6 +142,7 @@ func TestExpandDeferredMatrix(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, matrixValue string
|
||||
outputs map[string]string
|
||||
prepare func(t *testing.T, job *actions_model.ActionRunJob)
|
||||
}{
|
||||
{name: "unresolvable matrix", matrixValue: "${{ fromJson(needs.generate.outputs.missing) }}"},
|
||||
{
|
||||
@@ -151,6 +152,16 @@ func TestExpandDeferredMatrix(t *testing.T) {
|
||||
// the rows the setup plants, plus the placeholder the first combination reuses.
|
||||
outputs: map[string]string{"many": "[" + strings.Repeat("0,", actions_model.MaxJobNumPerRun-2) + "0]"},
|
||||
},
|
||||
{
|
||||
// A malformed payload fails the same way on every pass, so returning it would requeue the
|
||||
// run forever instead of ever reaching a terminal status.
|
||||
name: "unreadable caller inputs",
|
||||
matrixValue: "${{ fromJson(needs.generate.outputs.values) }}",
|
||||
prepare: func(t *testing.T, job *actions_model.ActionRunJob) {
|
||||
_, err := db.Exec(t.Context(), "UPDATE `action_run_job` SET call_payload = ? WHERE id = ?", "{", job.ParentJobID)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name+" fails the job", func(t *testing.T) {
|
||||
outputs := tt.outputs
|
||||
@@ -158,6 +169,9 @@ func TestExpandDeferredMatrix(t *testing.T) {
|
||||
outputs = map[string]string{"values": `["a","b","c"]`}
|
||||
}
|
||||
job := setupDeferredMatrixJob(t, tt.matrixValue, "", outputs)
|
||||
if tt.prepare != nil {
|
||||
tt.prepare(t, job)
|
||||
}
|
||||
|
||||
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -191,7 +205,13 @@ func TestDeferredMatrixResolverGating(t *testing.T) {
|
||||
{name: "skipped need", needStatus: actions_model.StatusSkipped, wantBuilds: []string{"build"}},
|
||||
{
|
||||
name: "`if:` gated per combination", needStatus: actions_model.StatusSuccess,
|
||||
jobIf: "${{ matrix.value != 'a' }}", outputs: map[string]string{"values": `["a","b"]`},
|
||||
jobIf: "${{ matrix.value == 'b' }}", outputs: map[string]string{"values": `["a","b"]`},
|
||||
wantBuilds: []string{"build (a)", "build (b)"},
|
||||
},
|
||||
{
|
||||
// The same gate without the `${{ }}`, which must expand rather than skip the whole job.
|
||||
name: "brace-less `if:` gated per combination", needStatus: actions_model.StatusSuccess,
|
||||
jobIf: "matrix.value == 'b'", outputs: map[string]string{"values": `["a","b"]`},
|
||||
wantBuilds: []string{"build (a)", "build (b)"},
|
||||
},
|
||||
} {
|
||||
@@ -218,3 +238,34 @@ func TestDeferredMatrixResolverGating(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredMatrixResolverDefersDependents(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// `build (a)` is skipped by the `if:` once it carries its own combination, `build (b)` runs.
|
||||
build := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}",
|
||||
"${{ matrix.value != 'a' }}", map[string]string{"values": `["a","b"]`})
|
||||
|
||||
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, build.RunID)
|
||||
require.NoError(t, err)
|
||||
report := &actions_model.ActionRunJob{
|
||||
RunID: build.RunID, RunAttemptID: build.RunAttemptID, AttemptJobID: attemptJobID,
|
||||
RepoID: build.RepoID, OwnerID: build.OwnerID, ParentJobID: build.ParentJobID,
|
||||
JobID: "report", Name: "report", Status: actions_model.StatusBlocked,
|
||||
Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report"),
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, report))
|
||||
|
||||
jobs := runJobs(t, build.RunID, build.RunAttemptID)
|
||||
require.NoError(t, jobs.LoadRuns(ctx, false))
|
||||
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, actions_model.StatusSkipped, updates[build.ID], "the combination the `if:` excludes")
|
||||
assert.NotContains(t, updates, report.ID, "report must wait for the re-emit, which sees `build (b)` too")
|
||||
|
||||
// The sibling the pass would otherwise have resolved report against is there, and still to run.
|
||||
sibling := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: build.RunID, Name: "build (b)"})
|
||||
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package actions
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/log"
|
||||
@@ -20,7 +21,15 @@ func parseMaxParallel(jobID, maxParallelString string) int {
|
||||
}
|
||||
maxParallel, err := strconv.ParseFloat(maxParallelString, 64)
|
||||
if err != nil || math.IsNaN(maxParallel) {
|
||||
log.Debug("job %s: unsupported max-parallel value %q, treating as unlimited", jobID, maxParallelString)
|
||||
// Both fall back to unlimited, but an expression is a gap in Gitea while a non-number is the
|
||||
// author's mistake, so dropping the cap must not be reported the same way.
|
||||
if strings.Contains(maxParallelString, "${{") {
|
||||
// TODO: evaluate it against the contexts `if:` and the matrix already resolve, so that an
|
||||
// expression can actually cap a job instead of quietly disabling the cap.
|
||||
log.Debug("job %s: max-parallel %q is an expression, which is not evaluated yet: treating as unlimited", jobID, maxParallelString)
|
||||
} else {
|
||||
log.Warn("job %s: max-parallel %q is not a number, treating as unlimited", jobID, maxParallelString)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// a run can never hold more jobs than MaxJobNumPerRun, so clamping there keeps the cast total
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestParseMaxParallel(t *testing.T) {
|
||||
{"-1.5", 0}, // truncates to -1, which means unlimited
|
||||
{"1e3", 256}, // clamped to MaxJobNumPerRun
|
||||
{"nan", 0}, // must not reach the int cast
|
||||
{"${{ vars.n }}", 0}, // expressions are not evaluated yet
|
||||
{"abc", 0},
|
||||
{"${{ vars.n }}", 0}, // expressions are not evaluated yet, logged as such
|
||||
{"abc", 0}, // a plain workflow error, warned about rather than hidden
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, parseMaxParallel("job", tt.input), "input %q", tt.input)
|
||||
|
||||
@@ -653,11 +653,14 @@ func (p *rerunPlan) collectMatrixCollapse() {
|
||||
if p.skipCloneTemplateJobIDs.Contains(anchor.ID) {
|
||||
continue
|
||||
}
|
||||
inRerunSet := slices.ContainsFunc(rows, func(j *actions_model.ActionRunJob) bool {
|
||||
return p.rerunAttemptJobIDs.Contains(j.AttemptJobID)
|
||||
})
|
||||
if !inRerunSet {
|
||||
continue // pass-through group, cloned as-is
|
||||
// Gate on the anchor rather than on any row of the group: execRerunPlan only resets a row whose
|
||||
// own AttemptJobID is in the rerun set, so restoring the placeholder onto an anchor it treats
|
||||
// as pass-through would clone a row that keeps its old terminal status while carrying the raw,
|
||||
// unexpanded payload, and nothing expands a job that is not blocked. An anchor can be left
|
||||
// out of the rerun set while a sibling is in it: partially re-running the subtree of a
|
||||
// matrix-expanded reusable caller puts that combination in ancestorAttemptJobIDs instead.
|
||||
if !p.rerunAttemptJobIDs.Contains(anchor.AttemptJobID) {
|
||||
continue // pass-through anchor, the group is cloned as-is
|
||||
}
|
||||
unexpanded := len(rows) == 1 && anchor.IsMatrixDeferred
|
||||
if !unexpanded && !p.hasRerunDependency(anchor) {
|
||||
|
||||
@@ -363,3 +363,93 @@ func rowIDsOf(jobs ...*actions_model.ActionRunJob) []int64 {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCollectMatrixCollapse(t *testing.T) {
|
||||
// A dynamic-matrix group is the anchor (the combination that kept DeferredMatrixPayload) plus its
|
||||
// sibling combinations. Collapsing it rewinds the anchor to an unexpanded placeholder and drops
|
||||
// the siblings, so the new attempt re-derives the matrix from the fresh needs outputs.
|
||||
matrixRow := func(id, attemptJobID int64, jobID string, parentID int64, isAnchor, isCaller bool, needs ...string) *actions_model.ActionRunJob {
|
||||
job := templateJob(id, attemptJobID, jobID, parentID, isCaller, needs...)
|
||||
if isAnchor {
|
||||
job.DeferredMatrixPayload = []byte("name: t\non: push\njobs:\n build:\n steps: [{run: echo}]\n")
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
t.Run("a rerun of the needs collapses the group", func(t *testing.T) {
|
||||
generate := templateJob(101, 1, "generate", 0, false)
|
||||
build1 := matrixRow(102, 2, "build", 0, true, false, "generate")
|
||||
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
|
||||
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{generate}))
|
||||
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
|
||||
plan.collectMatrixCollapse()
|
||||
|
||||
assert.ElementsMatch(t, rowIDsOf(build1), plan.matrixPlaceholderTemplateIDs.Values())
|
||||
assert.ElementsMatch(t, rowIDsOf(build2), plan.matrixSiblingSkipTemplateIDs.Values())
|
||||
})
|
||||
|
||||
t.Run("re-running only a combination keeps the group as it is", func(t *testing.T) {
|
||||
// generate is not re-run, so its outputs still stand and the combinations stay valid.
|
||||
generate := templateJob(101, 1, "generate", 0, false)
|
||||
build1 := matrixRow(102, 2, "build", 0, true, false, "generate")
|
||||
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
|
||||
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{build2}))
|
||||
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
|
||||
plan.collectMatrixCollapse()
|
||||
|
||||
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
|
||||
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
|
||||
})
|
||||
|
||||
t.Run("a pass-through anchor is never rewound", func(t *testing.T) {
|
||||
// build is a matrix-expanded reusable caller. Re-running a job inside build (1)'s subtree
|
||||
// together with generate leaves build (1) an ancestor rather than a rerun job, while build (2)
|
||||
// does join the rerun set. execRerunPlan clones an ancestor with its old terminal status, so
|
||||
// restoring the placeholder onto it would leave a done job holding the raw, unexpanded payload
|
||||
// that nothing ever expands - and drop build (2) on top of it.
|
||||
generate := templateJob(101, 1, "generate", 0, false)
|
||||
build1 := matrixRow(102, 2, "build", 0, true, true, "generate")
|
||||
build2 := matrixRow(103, 3, "build", 0, false, true, "generate")
|
||||
inner := templateJob(104, 4, "inner", 102, false)
|
||||
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2, inner}}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{inner, generate}))
|
||||
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
|
||||
|
||||
require.Contains(t, plan.ancestorAttemptJobIDs, build1.AttemptJobID)
|
||||
require.NotContains(t, plan.rerunAttemptJobIDs, build1.AttemptJobID)
|
||||
require.Contains(t, plan.rerunAttemptJobIDs, build2.AttemptJobID)
|
||||
|
||||
plan.collectMatrixCollapse()
|
||||
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
|
||||
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
|
||||
})
|
||||
|
||||
t.Run("an unexpanded placeholder is rewound whenever it is re-run", func(t *testing.T) {
|
||||
// The previous attempt never got to expand it, so there is nothing to reuse.
|
||||
generate := templateJob(101, 1, "generate", 0, false)
|
||||
build := matrixRow(102, 2, "build", 0, true, false, "generate")
|
||||
build.IsMatrixDeferred = true
|
||||
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build}}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{build}))
|
||||
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
|
||||
plan.collectMatrixCollapse()
|
||||
|
||||
assert.ElementsMatch(t, rowIDsOf(build), plan.matrixPlaceholderTemplateIDs.Values())
|
||||
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
|
||||
})
|
||||
|
||||
t.Run("a plan-time matrix is left alone", func(t *testing.T) {
|
||||
generate := templateJob(101, 1, "generate", 0, false)
|
||||
build1 := matrixRow(102, 2, "build", 0, false, false, "generate")
|
||||
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
|
||||
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
|
||||
require.NoError(t, plan.expandRerunJobIDs(nil))
|
||||
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
|
||||
plan.collectMatrixCollapse()
|
||||
|
||||
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
|
||||
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user