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

@ -6511,7 +6511,8 @@ SELECT
tvp.id,
tvp.name,
tvp.desired_instances AS desired_instances,
tvp.invalidate_after_secs AS ttl,
tvp.scheduling_timezone,
tvp.invalidate_after_secs AS ttl,
tvp.prebuild_status,
t.deleted,
t.deprecated != '' AS deprecated
@ -6535,6 +6536,7 @@ type GetTemplatePresetsWithPrebuildsRow struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"`
SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"`
Ttl sql.NullInt32 `db:"ttl" json:"ttl"`
PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"`
Deleted bool `db:"deleted" json:"deleted"`
@ -6564,6 +6566,7 @@ func (q *sqlQuerier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templa
&i.ID,
&i.Name,
&i.DesiredInstances,
&i.SchedulingTimezone,
&i.Ttl,
&i.PrebuildStatus,
&i.Deleted,
@ -6582,8 +6585,51 @@ func (q *sqlQuerier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templa
return items, nil
}
const getActivePresetPrebuildSchedules = `-- name: GetActivePresetPrebuildSchedules :many
SELECT
tvpps.id, tvpps.preset_id, tvpps.cron_expression, tvpps.desired_instances
FROM
template_version_preset_prebuild_schedules tvpps
INNER JOIN template_version_presets tvp ON tvp.id = tvpps.preset_id
INNER JOIN template_versions tv ON tv.id = tvp.template_version_id
INNER JOIN templates t ON t.id = tv.template_id
WHERE
-- Template version is active, and template is not deleted or deprecated
tv.id = t.active_version_id
AND NOT t.deleted
AND t.deprecated = ''
`
func (q *sqlQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) {
rows, err := q.db.QueryContext(ctx, getActivePresetPrebuildSchedules)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TemplateVersionPresetPrebuildSchedule
for rows.Next() {
var i TemplateVersionPresetPrebuildSchedule
if err := rows.Scan(
&i.ID,
&i.PresetID,
&i.CronExpression,
&i.DesiredInstances,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getPresetByID = `-- name: GetPresetByID :one
SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tv.template_id, tv.organization_id FROM
SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tv.template_id, tv.organization_id FROM
template_version_presets tvp
INNER JOIN template_versions tv ON tvp.template_version_id = tv.id
WHERE tvp.id = $1
@ -6597,6 +6643,7 @@ type GetPresetByIDRow struct {
DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"`
InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"`
PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"`
SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"`
TemplateID uuid.NullUUID `db:"template_id" json:"template_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
}
@ -6612,6 +6659,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get
&i.DesiredInstances,
&i.InvalidateAfterSecs,
&i.PrebuildStatus,
&i.SchedulingTimezone,
&i.TemplateID,
&i.OrganizationID,
)
@ -6620,7 +6668,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get
const getPresetByWorkspaceBuildID = `-- name: GetPresetByWorkspaceBuildID :one
SELECT
template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status
template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone
FROM
template_version_presets
INNER JOIN workspace_builds ON workspace_builds.template_version_preset_id = template_version_presets.id
@ -6639,6 +6687,7 @@ func (q *sqlQuerier) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceB
&i.DesiredInstances,
&i.InvalidateAfterSecs,
&i.PrebuildStatus,
&i.SchedulingTimezone,
)
return i, err
}
@ -6720,7 +6769,7 @@ func (q *sqlQuerier) GetPresetParametersByTemplateVersionID(ctx context.Context,
const getPresetsByTemplateVersionID = `-- name: GetPresetsByTemplateVersionID :many
SELECT
id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status
id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone
FROM
template_version_presets
WHERE
@ -6744,6 +6793,7 @@ func (q *sqlQuerier) GetPresetsByTemplateVersionID(ctx context.Context, template
&i.DesiredInstances,
&i.InvalidateAfterSecs,
&i.PrebuildStatus,
&i.SchedulingTimezone,
); err != nil {
return nil, err
}
@ -6765,7 +6815,8 @@ INSERT INTO template_version_presets (
name,
created_at,
desired_instances,
invalidate_after_secs
invalidate_after_secs,
scheduling_timezone
)
VALUES (
$1,
@ -6773,8 +6824,9 @@ VALUES (
$3,
$4,
$5,
$6
) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status
$6,
$7
) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone
`
type InsertPresetParams struct {
@ -6784,6 +6836,7 @@ type InsertPresetParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"`
InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"`
SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"`
}
func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error) {
@ -6794,6 +6847,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (
arg.CreatedAt,
arg.DesiredInstances,
arg.InvalidateAfterSecs,
arg.SchedulingTimezone,
)
var i TemplateVersionPreset
err := row.Scan(
@ -6804,6 +6858,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (
&i.DesiredInstances,
&i.InvalidateAfterSecs,
&i.PrebuildStatus,
&i.SchedulingTimezone,
)
return i, err
}
@ -6852,6 +6907,37 @@ func (q *sqlQuerier) InsertPresetParameters(ctx context.Context, arg InsertPrese
return items, nil
}
const insertPresetPrebuildSchedule = `-- name: InsertPresetPrebuildSchedule :one
INSERT INTO template_version_preset_prebuild_schedules (
preset_id,
cron_expression,
desired_instances
)
VALUES (
$1,
$2,
$3
) RETURNING id, preset_id, cron_expression, desired_instances
`
type InsertPresetPrebuildScheduleParams struct {
PresetID uuid.UUID `db:"preset_id" json:"preset_id"`
CronExpression string `db:"cron_expression" json:"cron_expression"`
DesiredInstances int32 `db:"desired_instances" json:"desired_instances"`
}
func (q *sqlQuerier) InsertPresetPrebuildSchedule(ctx context.Context, arg InsertPresetPrebuildScheduleParams) (TemplateVersionPresetPrebuildSchedule, error) {
row := q.db.QueryRowContext(ctx, insertPresetPrebuildSchedule, arg.PresetID, arg.CronExpression, arg.DesiredInstances)
var i TemplateVersionPresetPrebuildSchedule
err := row.Scan(
&i.ID,
&i.PresetID,
&i.CronExpression,
&i.DesiredInstances,
)
return i, err
}
const updatePresetPrebuildStatus = `-- name: UpdatePresetPrebuildStatus :exec
UPDATE template_version_presets
SET prebuild_status = $1