mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-30 09:39:40 +09:00
fix(actions): run every due schedule exactly once per occurrence (#39078)
Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
co-authored by
silverwind
Lunny Xiao
wxiaoguang
parent
fedf219e77
commit
3c0bfe9463
@@ -40,17 +40,8 @@ func init() {
|
||||
db.RegisterModel(new(ActionSchedule))
|
||||
}
|
||||
|
||||
// GetSchedulesMapByIDs returns the schedules by given id slice.
|
||||
func GetSchedulesMapByIDs(ctx context.Context, ids []int64) (map[int64]*ActionSchedule, error) {
|
||||
schedules := make(map[int64]*ActionSchedule, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return schedules, nil
|
||||
}
|
||||
return schedules, db.GetEngine(ctx).In("id", ids).Find(&schedules)
|
||||
}
|
||||
|
||||
// CreateScheduleTask creates new schedule task.
|
||||
func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error {
|
||||
// CreateScheduleTaskBySchedules creates new schedule task.
|
||||
func CreateScheduleTaskBySchedules(ctx context.Context, rows []*ActionSchedule) error {
|
||||
// Return early if there are no rows to insert
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
@@ -74,13 +65,16 @@ func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error {
|
||||
ScheduleID: row.ID,
|
||||
Spec: spec,
|
||||
}
|
||||
// Parse the spec and check for errors
|
||||
schedule, err := specRow.Parse()
|
||||
if err != nil {
|
||||
continue // skip to the next spec if there's an error
|
||||
continue
|
||||
}
|
||||
|
||||
specRow.Next = timeutil.TimeStamp(schedule.Next(now).Unix())
|
||||
next := schedule.Next(now)
|
||||
if next.IsZero() {
|
||||
continue // the spec parses but can never occur, like "0 0 30 2 *"
|
||||
}
|
||||
specRow.Next = timeutil.TimeStamp(next.Unix())
|
||||
|
||||
// Insert the new schedule spec row
|
||||
if err = db.Insert(ctx, specRow); err != nil {
|
||||
|
||||
@@ -26,7 +26,7 @@ type ActionScheduleSpec struct {
|
||||
// Next time the job will run, or the zero time if Cron has not been
|
||||
// started or this entry's schedule is unsatisfiable
|
||||
Next timeutil.TimeStamp `xorm:"index"`
|
||||
// Prev is the last time this job was run, or the zero time if never.
|
||||
// Prev is the occurrence this spec was last processed for, or the zero time if never.
|
||||
Prev timeutil.TimeStamp
|
||||
Spec string
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type SpecList []*ActionScheduleSpec
|
||||
|
||||
func (specs SpecList) GetScheduleIDs() []int64 {
|
||||
return container.FilterSlice(specs, func(spec *ActionScheduleSpec) (int64, bool) {
|
||||
return spec.ScheduleID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (specs SpecList) LoadSchedules(ctx context.Context) error {
|
||||
scheduleIDs := specs.GetScheduleIDs()
|
||||
schedules, err := GetSchedulesMapByIDs(ctx, scheduleIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range specs {
|
||||
spec.Schedule = schedules[spec.ScheduleID]
|
||||
}
|
||||
|
||||
repoIDs := specs.GetRepoIDs()
|
||||
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range specs {
|
||||
spec.Repo = repos[spec.RepoID]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (specs SpecList) GetRepoIDs() []int64 {
|
||||
return container.FilterSlice(specs, func(spec *ActionScheduleSpec) (int64, bool) {
|
||||
return spec.RepoID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (specs SpecList) LoadRepos(ctx context.Context) error {
|
||||
repoIDs := specs.GetRepoIDs()
|
||||
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range specs {
|
||||
spec.Repo = repos[spec.RepoID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FindSpecOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
Next int64
|
||||
}
|
||||
|
||||
func (opts FindSpecOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.RepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
||||
}
|
||||
|
||||
if opts.Next > 0 {
|
||||
cond = cond.And(builder.Lte{"next": opts.Next})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts FindSpecOptions) ToOrders() string {
|
||||
return "`id` DESC"
|
||||
}
|
||||
|
||||
func FindSpecs(ctx context.Context, opts FindSpecOptions) (SpecList, int64, error) {
|
||||
specs, total, err := db.FindAndCount[ActionScheduleSpec](ctx, opts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := SpecList(specs).LoadSchedules(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return specs, total, nil
|
||||
}
|
||||
@@ -595,7 +595,7 @@ func handleSchedules(
|
||||
crons = append(crons, run)
|
||||
}
|
||||
|
||||
return actions_model.CreateScheduleTask(ctx, crons)
|
||||
return actions_model.CreateScheduleTaskBySchedules(ctx, crons)
|
||||
}
|
||||
|
||||
// DetectAndHandleSchedules detects the schedule workflows on the default branch and create schedule tasks
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// StartScheduleTasks start the task
|
||||
@@ -29,84 +31,85 @@ func StartScheduleTasks(ctx context.Context) error {
|
||||
return startTasks(ctx)
|
||||
}
|
||||
|
||||
// startTasks retrieves specifications in pages, creates a schedule task for each specification,
|
||||
// and updates the specification's next run time and previous run time.
|
||||
// The function returns an error if there's an issue with finding or updating the specifications.
|
||||
// startTasks starts every due spec and returns an error if any of them failed.
|
||||
func startTasks(ctx context.Context) error {
|
||||
// Set the page size
|
||||
pageSize := 50
|
||||
|
||||
// Retrieve specs in pages until all specs have been retrieved
|
||||
var failed int
|
||||
now := time.Now()
|
||||
for page := 1; ; page++ {
|
||||
// Retrieve the specs for the current page
|
||||
specs, _, err := actions_model.FindSpecs(ctx, actions_model.FindSpecOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
Next: now.Unix(),
|
||||
err := db.Iterate(ctx,
|
||||
builder.And(builder.Gt{"next": 0}, builder.Lte{"next": now.Unix()}),
|
||||
func(ctx context.Context, row *actions_model.ActionScheduleSpec) error {
|
||||
// one failing spec must not abort the pass, or a single broken workflow stops every other schedule
|
||||
if err := startTask(ctx, row, now); err != nil {
|
||||
failed++
|
||||
log.Error("start schedule spec %d (repo %d, schedule %d): %v", row.ID, row.RepoID, row.ScheduleID, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find specs: %w", err)
|
||||
}
|
||||
|
||||
if err := specs.LoadRepos(ctx); err != nil {
|
||||
return fmt.Errorf("LoadRepos: %w", err)
|
||||
}
|
||||
|
||||
// Loop through each spec and create a schedule task for it
|
||||
for _, row := range specs {
|
||||
if row.Repo.IsArchived {
|
||||
// Skip if the repo is archived
|
||||
continue
|
||||
}
|
||||
|
||||
cfg, err := row.Repo.GetUnit(ctx, unit.TypeActions)
|
||||
if err != nil {
|
||||
if repo_model.IsErrUnitTypeNotExist(err) {
|
||||
// Skip the actions unit of this repo is disabled.
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("GetUnit: %w", err)
|
||||
}
|
||||
if cfg.ActionsConfig().IsWorkflowDisabled(row.Schedule.WorkflowID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := CreateScheduleTask(ctx, row); err != nil {
|
||||
log.Error("CreateScheduleTask: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse the spec
|
||||
schedule, err := row.Parse()
|
||||
if err != nil {
|
||||
log.Error("Parse: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the spec's next run time and previous run time
|
||||
row.Prev = row.Next
|
||||
row.Next = timeutil.TimeStamp(schedule.Next(now.Add(1 * time.Minute)).Unix())
|
||||
if err := actions_model.UpdateScheduleSpec(ctx, row, "prev", "next"); err != nil {
|
||||
log.Error("UpdateScheduleSpec: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if all specs have been retrieved
|
||||
if len(specs) < pageSize {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("iterate specs: %w", err)
|
||||
}
|
||||
|
||||
// surfaces as an admin notice through the cron task, once per occurrence rather than once per pass
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d schedule(s) could not be started", failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateScheduleTask creates a scheduled task from a cron action schedule spec.
|
||||
// startTask advances the spec to its next occurrence before creating the run, so a failing workflow
|
||||
// retries on its own schedule instead of on every pass, and a failed update cannot duplicate the run.
|
||||
func startTask(ctx context.Context, row *actions_model.ActionScheduleSpec, now time.Time) error {
|
||||
cronSchedule, err := row.Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %q: %w", row.Spec, err)
|
||||
}
|
||||
row.Prev = row.Next
|
||||
row.Next = timeutil.TimeStamp(cronSchedule.Next(now.Add(time.Minute)).Unix())
|
||||
if err := actions_model.UpdateScheduleSpec(ctx, row, "prev", "next"); err != nil {
|
||||
return fmt.Errorf("update spec: %w", err)
|
||||
}
|
||||
|
||||
// a spec whose schedule or repo row is gone is skipped, not reported on every occurrence
|
||||
schedule, exist, err := db.GetByID[actions_model.ActionSchedule](ctx, row.ScheduleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get schedule %d: %w", row.ScheduleID, err)
|
||||
} else if !exist {
|
||||
return nil
|
||||
}
|
||||
repo, exist, err := db.GetByID[repo_model.Repository](ctx, row.RepoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get repo %d: %w", row.RepoID, err)
|
||||
} else if !exist {
|
||||
return nil
|
||||
}
|
||||
row.Schedule, row.Repo = schedule, repo
|
||||
|
||||
// only archived repos are skipped; mirrors keep their schedules because a mirror is a normal repo
|
||||
// for Actions, and nightly builds or scans of the mirrored code are a common reason to run one
|
||||
if row.Repo.IsArchived {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := row.Repo.GetUnit(ctx, unit.TypeActions)
|
||||
if err != nil {
|
||||
if repo_model.IsErrUnitTypeNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("GetUnit: %w", err)
|
||||
}
|
||||
if cfg.ActionsConfig().IsWorkflowDisabled(row.Schedule.WorkflowID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := CreateScheduleTaskBySpec(ctx, row); err != nil {
|
||||
return fmt.Errorf("create run for %s workflow %q: %w", row.Repo.FullName(), row.Schedule.WorkflowID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateScheduleTaskBySpec creates a scheduled task from a cron action schedule spec.
|
||||
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
|
||||
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
|
||||
func CreateScheduleTaskBySpec(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
|
||||
cron := spec.Schedule
|
||||
|
||||
// Scheduled runs carry no webhook payload; synthesize what github.event.* expects.
|
||||
|
||||
@@ -5,9 +5,15 @@ package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -73,3 +79,47 @@ func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
assert.Equal(t, payload, updated)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStartTasks(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
insertSchedule := func(repoID, ownerID int64, workflowID, cronSpec, content string, next timeutil.TimeStamp) *actions_model.ActionScheduleSpec {
|
||||
schedule := &actions_model.ActionSchedule{
|
||||
Title: workflowID,
|
||||
RepoID: repoID,
|
||||
OwnerID: ownerID,
|
||||
WorkflowID: workflowID,
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: webhook_module.HookEventSchedule,
|
||||
EventPayload: "{}",
|
||||
Content: []byte(content),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), schedule))
|
||||
spec := &actions_model.ActionScheduleSpec{RepoID: repoID, ScheduleID: schedule.ID, Spec: cronSpec, Next: next}
|
||||
require.NoError(t, db.Insert(t.Context(), spec))
|
||||
return spec
|
||||
}
|
||||
|
||||
due := timeutil.TimeStamp(time.Now().Add(-time.Minute).Unix())
|
||||
validWorkflow := "jobs:\n job:\n runs-on: ubuntu-latest\n steps:\n - run: true\n"
|
||||
|
||||
// specs are processed by ascending id, so the broken one runs first and used to abort the whole pass
|
||||
broken := insertSchedule(1, 2, "broken.yml", "@every 1m", "this: [is: not: a: workflow", due)
|
||||
valid := insertSchedule(4, 5, "valid.yml", "@every 1m", validWorkflow, due)
|
||||
never := insertSchedule(4, 5, "never.yml", "0 0 30 2 *", validWorkflow, timeutil.TimeStamp(time.Time{}.Unix()))
|
||||
|
||||
require.ErrorContains(t, startTasks(t.Context()), "1 schedule(s) could not be started")
|
||||
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: 4, WorkflowID: "valid.yml"}))
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: 1, WorkflowID: "broken.yml"}))
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: 4, WorkflowID: "never.yml"}))
|
||||
|
||||
// the broken spec moves on too, so it does not fail again on every pass
|
||||
for _, spec := range []*actions_model.ActionScheduleSpec{broken, valid} {
|
||||
updated := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{ID: spec.ID})
|
||||
assert.Greater(t, updated.Next, spec.Next)
|
||||
}
|
||||
assert.Equal(t, never.Next, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{ID: never.ID}).Next)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user