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

@ -366,6 +366,11 @@ func (c *StoreReconciler) SnapshotState(ctx context.Context, store database.Stor
return nil
}
presetPrebuildSchedules, err := db.GetActivePresetPrebuildSchedules(ctx)
if err != nil {
return xerrors.Errorf("failed to get preset prebuild schedules: %w", err)
}
allRunningPrebuilds, err := db.GetRunningPrebuiltWorkspaces(ctx)
if err != nil {
return xerrors.Errorf("failed to get running prebuilds: %w", err)
@ -388,10 +393,13 @@ func (c *StoreReconciler) SnapshotState(ctx context.Context, store database.Stor
state = prebuilds.NewGlobalSnapshot(
presetsWithPrebuilds,
presetPrebuildSchedules,
allRunningPrebuilds,
allPrebuildsInProgress,
presetsBackoff,
hardLimitedPresets,
c.clock,
c.logger,
)
return nil
}, &database.TxOptions{
@ -608,7 +616,8 @@ func (c *StoreReconciler) executeReconciliationAction(ctx context.Context, logge
// Unexpected things happen (i.e. bugs or bitflips); let's defend against disastrous outcomes.
// See https://blog.robertelder.org/causes-of-bit-flips-in-computer-memory/.
// This is obviously not comprehensive protection against this sort of problem, but this is one essential check.
desired := ps.Preset.DesiredInstances.Int32
desired := ps.CalculateDesiredInstances(c.clock.Now())
if action.Create > desired {
logger.Critical(ctx, "determined excessive count of prebuilds to create; clamping to desired count",
slog.F("create_count", action.Create), slog.F("desired_count", desired))

View File

@ -522,6 +522,151 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
}
}
func TestPrebuildScheduling(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres")
}
templateDeleted := false
// The test includes 2 presets, each with 2 schedules.
// It checks that the number of created prebuilds match expectations for various provided times,
// based on the corresponding schedules.
testCases := []struct {
name string
// now specifies the current time.
now time.Time
// expected prebuild counts for preset1 and preset2, respectively.
expectedPrebuildCounts []int
}{
{
name: "Before the 1st schedule",
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 01:00:00 UTC"),
expectedPrebuildCounts: []int{1, 1},
},
{
name: "1st schedule",
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 03:00:00 UTC"),
expectedPrebuildCounts: []int{2, 1},
},
{
name: "2nd schedule",
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 07:00:00 UTC"),
expectedPrebuildCounts: []int{3, 1},
},
{
name: "3rd schedule",
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 11:00:00 UTC"),
expectedPrebuildCounts: []int{1, 4},
},
{
name: "4th schedule",
now: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 15:00:00 UTC"),
expectedPrebuildCounts: []int{1, 5},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
clock := quartz.NewMock(t)
clock.Set(tc.now)
ctx := testutil.Context(t, testutil.WaitShort)
cfg := codersdk.PrebuildsConfig{}
logger := slogtest.Make(
t, &slogtest.Options{IgnoreErrors: true},
).Leveled(slog.LevelDebug)
db, pubSub := dbtestutil.NewDB(t)
controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer())
ownerID := uuid.New()
dbgen.User(t, db, database.User{
ID: ownerID,
})
org, template := setupTestDBTemplate(t, db, ownerID, templateDeleted)
templateVersionID := setupTestDBTemplateVersion(
ctx,
t,
clock,
db,
pubSub,
org.ID,
ownerID,
template.ID,
)
preset1 := setupTestDBPresetWithScheduling(
t,
db,
templateVersionID,
1,
uuid.New().String(),
"UTC",
)
preset2 := setupTestDBPresetWithScheduling(
t,
db,
templateVersionID,
1,
uuid.New().String(),
"UTC",
)
dbgen.PresetPrebuildSchedule(t, db, database.InsertPresetPrebuildScheduleParams{
PresetID: preset1.ID,
CronExpression: "* 2-4 * * 1-5",
DesiredInstances: 2,
})
dbgen.PresetPrebuildSchedule(t, db, database.InsertPresetPrebuildScheduleParams{
PresetID: preset1.ID,
CronExpression: "* 6-8 * * 1-5",
DesiredInstances: 3,
})
dbgen.PresetPrebuildSchedule(t, db, database.InsertPresetPrebuildScheduleParams{
PresetID: preset2.ID,
CronExpression: "* 10-12 * * 1-5",
DesiredInstances: 4,
})
dbgen.PresetPrebuildSchedule(t, db, database.InsertPresetPrebuildScheduleParams{
PresetID: preset2.ID,
CronExpression: "* 14-16 * * 1-5",
DesiredInstances: 5,
})
err := controller.ReconcileAll(ctx)
require.NoError(t, err)
// get workspace builds
workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID)
require.NoError(t, err)
workspaceIDs := make([]uuid.UUID, 0, len(workspaces))
for _, workspace := range workspaces {
workspaceIDs = append(workspaceIDs, workspace.ID)
}
workspaceBuilds, err := db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs)
require.NoError(t, err)
// calculate number of workspace builds per preset
var (
preset1PrebuildCount int
preset2PrebuildCount int
)
for _, workspaceBuild := range workspaceBuilds {
if preset1.ID == workspaceBuild.TemplateVersionPresetID.UUID {
preset1PrebuildCount++
}
if preset2.ID == workspaceBuild.TemplateVersionPresetID.UUID {
preset2PrebuildCount++
}
}
require.Equal(t, tc.expectedPrebuildCounts[0], preset1PrebuildCount)
require.Equal(t, tc.expectedPrebuildCounts[1], preset2PrebuildCount)
})
}
}
func TestInvalidPreset(t *testing.T) {
t.Parallel()
@ -1821,6 +1966,32 @@ func setupTestDBPreset(
return preset
}
func setupTestDBPresetWithScheduling(
t *testing.T,
db database.Store,
templateVersionID uuid.UUID,
desiredInstances int32,
presetName string,
schedulingTimezone string,
) database.TemplateVersionPreset {
t.Helper()
preset := dbgen.Preset(t, db, database.InsertPresetParams{
TemplateVersionID: templateVersionID,
Name: presetName,
DesiredInstances: sql.NullInt32{
Valid: true,
Int32: desiredInstances,
},
SchedulingTimezone: schedulingTimezone,
})
dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{
TemplateVersionPresetID: preset.ID,
Names: []string{"test"},
Values: []string{"test"},
})
return preset
}
// prebuildOptions holds optional parameters for creating a prebuild workspace.
type prebuildOptions struct {
createdAt *time.Time
@ -1988,3 +2159,10 @@ func allJobStatusesExcept(except ...database.ProvisionerJobStatus) []database.Pr
return !slice.Contains(allJobStatuses, status)
})
}
func mustParseTime(t *testing.T, layout, value string) time.Time {
t.Helper()
parsedTime, err := time.Parse(layout, value)
require.NoError(t, err)
return parsedTime
}