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:
Yevhenii Shcherbina
2025-06-19 11:08:48 -04:00
committed by GitHub
parent 511fd09582
commit 0f6ca55238
38 changed files with 2528 additions and 871 deletions

View File

@ -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 {

View File

@ -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)
}

View File

@ -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,