mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: implement scheduling mechanism for prebuilds (#18126)
Closes https://github.com/coder/internal/issues/312 Depends on https://github.com/coder/terraform-provider-coder/pull/408 This PR adds support for defining an **autoscaling block** for prebuilds, allowing number of desired instances to scale dynamically based on a schedule. Example usage: ``` data "coder_workspace_preset" "us-nix" { ... prebuilds = { instances = 0 # default to 0 instances scheduling = { timezone = "UTC" # a single timezone is used for simplicity # Scale to 3 instances during the work week schedule { cron = "* 8-18 * * 1-5" # from 8AM–6:59PM, Mon–Fri, UTC instances = 3 # scale to 3 instances } # Scale to 1 instance on Saturdays for urgent support queries schedule { cron = "* 8-14 * * 6" # from 8AM–2:59PM, Sat, UTC instances = 1 # scale to 1 instance } } } } ``` ### Behavior - Multiple `schedule` blocks per `prebuilds` block are supported. - If the current time matches any defined autoscaling schedule, the corresponding number of instances is used. - If no schedule matches, the **default instance count** (`prebuilds.instances`) is used as a fallback. ### Why This feature allows prebuild instance capacity to adapt to predictable usage patterns, such as: - Scaling up during business hours or high-demand periods - Reducing capacity during off-hours to save resources ### Cron specification The cron specification is interpreted as a **continuous time range.** For example, the expression: ``` * 9-18 * * 1-5 ``` is intended to represent a continuous range from **09:00 to 18:59**, Monday through Friday. However, due to minor implementation imprecision, it is currently interpreted as a range from **08:59:00 to 18:58:59**, Monday through Friday. This slight discrepancy arises because the evaluation is based on whether a specific **point in time** falls within the range, using the `github.com/coder/coder/v2/coderd/schedule/cron` library, which performs per-minute matching rather than strict range evaluation. --------- Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
committed by
GitHub
parent
511fd09582
commit
0f6ca55238
@ -6,6 +6,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/quartz"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
@ -13,18 +17,24 @@ import (
|
||||
// GlobalSnapshot represents a full point-in-time snapshot of state relating to prebuilds across all templates.
|
||||
type GlobalSnapshot struct {
|
||||
Presets []database.GetTemplatePresetsWithPrebuildsRow
|
||||
PrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule
|
||||
RunningPrebuilds []database.GetRunningPrebuiltWorkspacesRow
|
||||
PrebuildsInProgress []database.CountInProgressPrebuildsRow
|
||||
Backoffs []database.GetPresetsBackoffRow
|
||||
HardLimitedPresetsMap map[uuid.UUID]database.GetPresetsAtFailureLimitRow
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func NewGlobalSnapshot(
|
||||
presets []database.GetTemplatePresetsWithPrebuildsRow,
|
||||
prebuildSchedules []database.TemplateVersionPresetPrebuildSchedule,
|
||||
runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow,
|
||||
prebuildsInProgress []database.CountInProgressPrebuildsRow,
|
||||
backoffs []database.GetPresetsBackoffRow,
|
||||
hardLimitedPresets []database.GetPresetsAtFailureLimitRow,
|
||||
clock quartz.Clock,
|
||||
logger slog.Logger,
|
||||
) GlobalSnapshot {
|
||||
hardLimitedPresetsMap := make(map[uuid.UUID]database.GetPresetsAtFailureLimitRow, len(hardLimitedPresets))
|
||||
for _, preset := range hardLimitedPresets {
|
||||
@ -33,10 +43,13 @@ func NewGlobalSnapshot(
|
||||
|
||||
return GlobalSnapshot{
|
||||
Presets: presets,
|
||||
PrebuildSchedules: prebuildSchedules,
|
||||
RunningPrebuilds: runningPrebuilds,
|
||||
PrebuildsInProgress: prebuildsInProgress,
|
||||
Backoffs: backoffs,
|
||||
HardLimitedPresetsMap: hardLimitedPresetsMap,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +61,10 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err
|
||||
return nil, xerrors.Errorf("no preset found with ID %q", presetID)
|
||||
}
|
||||
|
||||
prebuildSchedules := slice.Filter(s.PrebuildSchedules, func(schedule database.TemplateVersionPresetPrebuildSchedule) bool {
|
||||
return schedule.PresetID == presetID
|
||||
})
|
||||
|
||||
// Only include workspaces that have successfully started
|
||||
running := slice.Filter(s.RunningPrebuilds, func(prebuild database.GetRunningPrebuiltWorkspacesRow) bool {
|
||||
if !prebuild.CurrentPresetID.Valid {
|
||||
@ -73,14 +90,19 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err
|
||||
|
||||
_, isHardLimited := s.HardLimitedPresetsMap[preset.ID]
|
||||
|
||||
return &PresetSnapshot{
|
||||
Preset: preset,
|
||||
Running: nonExpired,
|
||||
Expired: expired,
|
||||
InProgress: inProgress,
|
||||
Backoff: backoffPtr,
|
||||
IsHardLimited: isHardLimited,
|
||||
}, nil
|
||||
presetSnapshot := NewPresetSnapshot(
|
||||
preset,
|
||||
prebuildSchedules,
|
||||
nonExpired,
|
||||
expired,
|
||||
inProgress,
|
||||
backoffPtr,
|
||||
isHardLimited,
|
||||
s.clock,
|
||||
s.logger,
|
||||
)
|
||||
|
||||
return &presetSnapshot, nil
|
||||
}
|
||||
|
||||
func (s GlobalSnapshot) IsHardLimited(presetID uuid.UUID) bool {
|
||||
|
@ -1,14 +1,22 @@
|
||||
package prebuilds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/quartz"
|
||||
|
||||
tf_provider_helpers "github.com/coder/terraform-provider-coder/v2/provider/helpers"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||||
)
|
||||
|
||||
// ActionType represents the type of action needed to reconcile prebuilds.
|
||||
@ -36,12 +44,39 @@ const (
|
||||
// - InProgress: prebuilds currently in progress
|
||||
// - Backoff: holds failure info to decide if prebuild creation should be backed off
|
||||
type PresetSnapshot struct {
|
||||
Preset database.GetTemplatePresetsWithPrebuildsRow
|
||||
Running []database.GetRunningPrebuiltWorkspacesRow
|
||||
Expired []database.GetRunningPrebuiltWorkspacesRow
|
||||
InProgress []database.CountInProgressPrebuildsRow
|
||||
Backoff *database.GetPresetsBackoffRow
|
||||
IsHardLimited bool
|
||||
Preset database.GetTemplatePresetsWithPrebuildsRow
|
||||
PrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule
|
||||
Running []database.GetRunningPrebuiltWorkspacesRow
|
||||
Expired []database.GetRunningPrebuiltWorkspacesRow
|
||||
InProgress []database.CountInProgressPrebuildsRow
|
||||
Backoff *database.GetPresetsBackoffRow
|
||||
IsHardLimited bool
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func NewPresetSnapshot(
|
||||
preset database.GetTemplatePresetsWithPrebuildsRow,
|
||||
prebuildSchedules []database.TemplateVersionPresetPrebuildSchedule,
|
||||
running []database.GetRunningPrebuiltWorkspacesRow,
|
||||
expired []database.GetRunningPrebuiltWorkspacesRow,
|
||||
inProgress []database.CountInProgressPrebuildsRow,
|
||||
backoff *database.GetPresetsBackoffRow,
|
||||
isHardLimited bool,
|
||||
clock quartz.Clock,
|
||||
logger slog.Logger,
|
||||
) PresetSnapshot {
|
||||
return PresetSnapshot{
|
||||
Preset: preset,
|
||||
PrebuildSchedules: prebuildSchedules,
|
||||
Running: running,
|
||||
Expired: expired,
|
||||
InProgress: inProgress,
|
||||
Backoff: backoff,
|
||||
IsHardLimited: isHardLimited,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ReconciliationState represents the processed state of a preset's prebuilds,
|
||||
@ -83,6 +118,92 @@ func (ra *ReconciliationActions) IsNoop() bool {
|
||||
return ra.Create == 0 && len(ra.DeleteIDs) == 0 && ra.BackoffUntil.IsZero()
|
||||
}
|
||||
|
||||
// MatchesCron interprets a cron spec as a continuous time range,
|
||||
// and returns whether the provided time value falls within that range.
|
||||
func MatchesCron(cronExpression string, at time.Time) (bool, error) {
|
||||
sched, err := cron.TimeRange(cronExpression)
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("failed to parse cron expression: %w", err)
|
||||
}
|
||||
|
||||
return sched.IsWithinRange(at), nil
|
||||
}
|
||||
|
||||
// CalculateDesiredInstances returns the number of desired instances based on the provided time.
|
||||
// If the time matches any defined prebuild schedule, the corresponding number of instances is returned.
|
||||
// Otherwise, it falls back to the default number of instances specified in the prebuild configuration.
|
||||
func (p PresetSnapshot) CalculateDesiredInstances(at time.Time) int32 {
|
||||
if len(p.PrebuildSchedules) == 0 {
|
||||
// If no schedules are defined, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
if p.Preset.SchedulingTimezone == "" {
|
||||
p.logger.Error(context.Background(), "timezone is not set in prebuild scheduling configuration",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("timezone", p.Preset.SchedulingTimezone))
|
||||
|
||||
// If timezone is not set, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// Validate that the provided timezone is valid
|
||||
_, err := time.LoadLocation(p.Preset.SchedulingTimezone)
|
||||
if err != nil {
|
||||
p.logger.Error(context.Background(), "invalid timezone in prebuild scheduling configuration",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("timezone", p.Preset.SchedulingTimezone),
|
||||
slog.Error(err))
|
||||
|
||||
// If timezone is invalid, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// Validate that all prebuild schedules are valid and don't overlap with each other.
|
||||
// If any schedule is invalid or schedules overlap, fall back to the default desired instance count.
|
||||
cronSpecs := make([]string, len(p.PrebuildSchedules))
|
||||
for i, schedule := range p.PrebuildSchedules {
|
||||
cronSpecs[i] = schedule.CronExpression
|
||||
}
|
||||
err = tf_provider_helpers.ValidateSchedules(cronSpecs)
|
||||
if err != nil {
|
||||
p.logger.Error(context.Background(), "schedules are invalid or overlap with each other",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("cron_specs", cronSpecs),
|
||||
slog.Error(err))
|
||||
|
||||
// If schedules are invalid, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// Look for a schedule whose cron expression matches the provided time
|
||||
for _, schedule := range p.PrebuildSchedules {
|
||||
// Prefix the cron expression with timezone information
|
||||
cronExprWithTimezone := fmt.Sprintf("CRON_TZ=%s %s", p.Preset.SchedulingTimezone, schedule.CronExpression)
|
||||
matches, err := MatchesCron(cronExprWithTimezone, at)
|
||||
if err != nil {
|
||||
p.logger.Error(context.Background(), "cron expression is invalid",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("cron_expression", cronExprWithTimezone),
|
||||
slog.Error(err))
|
||||
continue
|
||||
}
|
||||
if matches {
|
||||
p.logger.Debug(context.Background(), "current time matched cron expression",
|
||||
slog.F("preset_id", p.Preset.ID),
|
||||
slog.F("current_time", at.String()),
|
||||
slog.F("cron_expression", cronExprWithTimezone),
|
||||
slog.F("desired_instances", schedule.DesiredInstances),
|
||||
)
|
||||
|
||||
return schedule.DesiredInstances
|
||||
}
|
||||
}
|
||||
|
||||
// If no schedule matches, fall back to the default desired instance count
|
||||
return p.Preset.DesiredInstances.Int32
|
||||
}
|
||||
|
||||
// CalculateState computes the current state of prebuilds for a preset, including:
|
||||
// - Actual: Number of currently running prebuilds, i.e., non-expired and expired prebuilds
|
||||
// - Expired: Number of currently running expired prebuilds
|
||||
@ -111,7 +232,7 @@ func (p PresetSnapshot) CalculateState() *ReconciliationState {
|
||||
expired = int32(len(p.Expired))
|
||||
|
||||
if p.isActive() {
|
||||
desired = p.Preset.DesiredInstances.Int32
|
||||
desired = p.CalculateDesiredInstances(p.clock.Now())
|
||||
eligible = p.countEligible()
|
||||
extraneous = max(actual-expired-desired, 0)
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -84,7 +86,7 @@ func TestNoPrebuilds(t *testing.T) {
|
||||
preset(true, 0, current),
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -106,7 +108,7 @@ func TestNetNew(t *testing.T) {
|
||||
preset(true, 1, current),
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -148,7 +150,7 @@ func TestOutdatedPrebuilds(t *testing.T) {
|
||||
var inProgress []database.CountInProgressPrebuildsRow
|
||||
|
||||
// WHEN: calculating the outdated preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(outdated.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -214,7 +216,7 @@ func TestDeleteOutdatedPrebuilds(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the outdated preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(outdated.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -459,7 +461,7 @@ func TestInProgressActions(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -502,7 +504,7 @@ func TestExtraneous(t *testing.T) {
|
||||
var inProgress []database.CountInProgressPrebuildsRow
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -683,7 +685,7 @@ func TestExpiredPrebuilds(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, nil, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -719,7 +721,7 @@ func TestDeprecated(t *testing.T) {
|
||||
var inProgress []database.CountInProgressPrebuildsRow
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
ps, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -772,7 +774,7 @@ func TestLatestBuildFailed(t *testing.T) {
|
||||
}
|
||||
|
||||
// WHEN: calculating the current preset's state.
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, running, inProgress, backoffs, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, backoffs, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
psCurrent, err := snapshot.FilterByPreset(current.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -865,7 +867,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, inProgress, nil, nil)
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
|
||||
|
||||
// Nothing has to be created for preset 1.
|
||||
{
|
||||
@ -905,6 +907,498 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrebuildScheduling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The test includes 2 presets, each with 2 schedules.
|
||||
// It checks that the calculated actions match expectations for various provided times,
|
||||
// based on the corresponding schedules.
|
||||
testCases := []struct {
|
||||
name string
|
||||
// now specifies the current time.
|
||||
now time.Time
|
||||
// expected instances for preset1 and preset2, respectively.
|
||||
expectedInstances []int32
|
||||
}{
|
||||
{
|
||||
name: "Before the 1st schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 01:00:00 UTC"),
|
||||
expectedInstances: []int32{1, 1},
|
||||
},
|
||||
{
|
||||
name: "1st schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 03:00:00 UTC"),
|
||||
expectedInstances: []int32{2, 1},
|
||||
},
|
||||
{
|
||||
name: "2nd schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 07:00:00 UTC"),
|
||||
expectedInstances: []int32{3, 1},
|
||||
},
|
||||
{
|
||||
name: "3rd schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 11:00:00 UTC"),
|
||||
expectedInstances: []int32{1, 4},
|
||||
},
|
||||
{
|
||||
name: "4th schedule",
|
||||
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 15:00:00 UTC"),
|
||||
expectedInstances: []int32{1, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
templateID := uuid.New()
|
||||
templateVersionID := uuid.New()
|
||||
presetOpts1 := options{
|
||||
templateID: templateID,
|
||||
templateVersionID: templateVersionID,
|
||||
presetID: uuid.New(),
|
||||
presetName: "my-preset-1",
|
||||
prebuiltWorkspaceID: uuid.New(),
|
||||
workspaceName: "prebuilds1",
|
||||
}
|
||||
presetOpts2 := options{
|
||||
templateID: templateID,
|
||||
templateVersionID: templateVersionID,
|
||||
presetID: uuid.New(),
|
||||
presetName: "my-preset-2",
|
||||
prebuiltWorkspaceID: uuid.New(),
|
||||
workspaceName: "prebuilds2",
|
||||
}
|
||||
|
||||
clock := quartz.NewMock(t)
|
||||
clock.Set(tc.now)
|
||||
enableScheduling := func(preset database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow {
|
||||
preset.SchedulingTimezone = "UTC"
|
||||
return preset
|
||||
}
|
||||
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||
preset(true, 1, presetOpts1, enableScheduling),
|
||||
preset(true, 1, presetOpts2, enableScheduling),
|
||||
}
|
||||
schedules := []database.TemplateVersionPresetPrebuildSchedule{
|
||||
schedule(presets[0].ID, "* 2-4 * * 1-5", 2),
|
||||
schedule(presets[0].ID, "* 6-8 * * 1-5", 3),
|
||||
schedule(presets[1].ID, "* 10-12 * * 1-5", 4),
|
||||
schedule(presets[1].ID, "* 14-16 * * 1-5", 5),
|
||||
}
|
||||
|
||||
snapshot := prebuilds.NewGlobalSnapshot(presets, schedules, nil, nil, nil, nil, clock, testutil.Logger(t))
|
||||
|
||||
// Check 1st preset.
|
||||
{
|
||||
ps, err := snapshot.FilterByPreset(presetOpts1.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
state := ps.CalculateState()
|
||||
actions, err := ps.CalculateActions(clock, backoffInterval)
|
||||
require.NoError(t, err)
|
||||
|
||||
validateState(t, prebuilds.ReconciliationState{
|
||||
Starting: 0,
|
||||
Desired: tc.expectedInstances[0],
|
||||
}, *state)
|
||||
validateActions(t, []*prebuilds.ReconciliationActions{
|
||||
{
|
||||
ActionType: prebuilds.ActionTypeCreate,
|
||||
Create: tc.expectedInstances[0],
|
||||
},
|
||||
}, actions)
|
||||
}
|
||||
|
||||
// Check 2nd preset.
|
||||
{
|
||||
ps, err := snapshot.FilterByPreset(presetOpts2.presetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
state := ps.CalculateState()
|
||||
actions, err := ps.CalculateActions(clock, backoffInterval)
|
||||
require.NoError(t, err)
|
||||
|
||||
validateState(t, prebuilds.ReconciliationState{
|
||||
Starting: 0,
|
||||
Desired: tc.expectedInstances[1],
|
||||
}, *state)
|
||||
validateActions(t, []*prebuilds.ReconciliationActions{
|
||||
{
|
||||
ActionType: prebuilds.ActionTypeCreate,
|
||||
Create: tc.expectedInstances[1],
|
||||
},
|
||||
}, actions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesCron(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
at time.Time
|
||||
expectedMatches bool
|
||||
}{
|
||||
// A comprehensive test suite for time range evaluation is implemented in TestIsWithinRange.
|
||||
// This test provides only basic coverage.
|
||||
{
|
||||
name: "Right before the start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:59:59 UTC"),
|
||||
expectedMatches: false,
|
||||
},
|
||||
{
|
||||
name: "Start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:00:00 UTC"),
|
||||
expectedMatches: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matches, err := prebuilds.MatchesCron(testCase.spec, testCase.at)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.expectedMatches, matches)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDesiredInstances(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mkPreset := func(instances int32, timezone string) database.GetTemplatePresetsWithPrebuildsRow {
|
||||
return database.GetTemplatePresetsWithPrebuildsRow{
|
||||
DesiredInstances: sql.NullInt32{
|
||||
Int32: instances,
|
||||
Valid: true,
|
||||
},
|
||||
SchedulingTimezone: timezone,
|
||||
}
|
||||
}
|
||||
mkSchedule := func(cronExpr string, instances int32) database.TemplateVersionPresetPrebuildSchedule {
|
||||
return database.TemplateVersionPresetPrebuildSchedule{
|
||||
CronExpression: cronExpr,
|
||||
DesiredInstances: instances,
|
||||
}
|
||||
}
|
||||
mkSnapshot := func(preset database.GetTemplatePresetsWithPrebuildsRow, schedules ...database.TemplateVersionPresetPrebuildSchedule) prebuilds.PresetSnapshot {
|
||||
return prebuilds.NewPresetSnapshot(
|
||||
preset,
|
||||
schedules,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
quartz.NewMock(t),
|
||||
testutil.Logger(t),
|
||||
)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot prebuilds.PresetSnapshot
|
||||
at time.Time
|
||||
expectedCalculatedInstances int32
|
||||
}{
|
||||
// "* 9-18 * * 1-5" should be interpreted as a continuous time range from 09:00:00 to 18:59:59, Monday through Friday
|
||||
{
|
||||
name: "Right before the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:59:59 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "Start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "9:01AM - One minute after the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:01:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "2PM - The middle of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "6PM - One hour before the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "End of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:59:59 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "Right after the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "7:01PM - Around one minute after the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:01:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "2AM - Significantly outside the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 02:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #1",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #2",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Test multiple schedules during the day
|
||||
// - "* 6-10 * * 1-5"
|
||||
// - "* 12-16 * * 1-5"
|
||||
// - "* 18-22 * * 1-5"
|
||||
{
|
||||
name: "Before the first schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 5:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "The middle of the first schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:00:00 UTC"),
|
||||
expectedCalculatedInstances: 2,
|
||||
},
|
||||
{
|
||||
name: "Between the first and second schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 11:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "The middle of the second schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "The middle of the third schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 20:00:00 UTC"),
|
||||
expectedCalculatedInstances: 4,
|
||||
},
|
||||
{
|
||||
name: "After the last schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 6-10 * * 1-5", 2),
|
||||
mkSchedule("* 12-16 * * 1-5", 3),
|
||||
mkSchedule("* 18-22 * * 1-5", 4),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 23:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Test multiple schedules during the week
|
||||
// - "* 9-18 * * 1-5"
|
||||
// - "* 9-13 * * 6-7"
|
||||
{
|
||||
name: "First schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 2),
|
||||
mkSchedule("* 9-13 * * 6,0", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 2,
|
||||
},
|
||||
{
|
||||
name: "Second schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 2),
|
||||
mkSchedule("* 9-13 * * 6,0", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 10:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "Outside schedule",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 2),
|
||||
mkSchedule("* 9-13 * * 6,0", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 14:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Test different timezones
|
||||
{
|
||||
name: "3PM UTC - 8AM America/Los_Angeles; An hour before the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 15:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "4PM UTC - 9AM America/Los_Angeles; Start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 16:00:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "8:59PM UTC - 1:58PM America/Los_Angeles; Right before the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 20:59:00 UTC"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
{
|
||||
name: "9PM UTC - 2PM America/Los_Angeles; Right after the end of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 21:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "11PM UTC - 4PM America/Los_Angeles; Outside the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "America/Los_Angeles"),
|
||||
mkSchedule("* 9-13 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 23:00:00 UTC"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
|
||||
// Verify support for time values specified in non-UTC time zones.
|
||||
{
|
||||
name: "8AM - before the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123Z, "Mon, 02 Jun 2025 04:00:00 -0400"),
|
||||
expectedCalculatedInstances: 1,
|
||||
},
|
||||
{
|
||||
name: "9AM - after the start of the time range",
|
||||
snapshot: mkSnapshot(
|
||||
mkPreset(1, "UTC"),
|
||||
mkSchedule("* 9-18 * * 1-5", 3),
|
||||
),
|
||||
at: mustParseTime(t, time.RFC1123Z, "Mon, 02 Jun 2025 05:00:00 -0400"),
|
||||
expectedCalculatedInstances: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
desiredInstances := tc.snapshot.CalculateDesiredInstances(tc.at)
|
||||
require.Equal(t, tc.expectedCalculatedInstances, desiredInstances)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T, layout, value string) time.Time {
|
||||
t.Helper()
|
||||
parsedTime, err := time.Parse(layout, value)
|
||||
require.NoError(t, err)
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func preset(active bool, instances int32, opts options, muts ...func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow {
|
||||
ttl := sql.NullInt32{}
|
||||
if opts.ttl > 0 {
|
||||
@ -934,6 +1428,15 @@ func preset(active bool, instances int32, opts options, muts ...func(row databas
|
||||
return entry
|
||||
}
|
||||
|
||||
func schedule(presetID uuid.UUID, cronExpr string, instances int32) database.TemplateVersionPresetPrebuildSchedule {
|
||||
return database.TemplateVersionPresetPrebuildSchedule{
|
||||
ID: uuid.New(),
|
||||
PresetID: presetID,
|
||||
CronExpression: cronExpr,
|
||||
DesiredInstances: instances,
|
||||
}
|
||||
}
|
||||
|
||||
func prebuiltWorkspace(
|
||||
opts options,
|
||||
clock quartz.Clock,
|
||||
|
Reference in New Issue
Block a user