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
co-authored by Danny Kopping
parent 511fd09582
commit 0f6ca55238
38 changed files with 2528 additions and 871 deletions
+2 -1
View File
@@ -35,7 +35,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
+30 -2
View File
@@ -5,7 +5,8 @@ INSERT INTO template_version_presets (
name,
created_at,
desired_instances,
invalidate_after_secs
invalidate_after_secs,
scheduling_timezone
)
VALUES (
@id,
@@ -13,7 +14,8 @@ VALUES (
@name,
@created_at,
@desired_instances,
@invalidate_after_secs
@invalidate_after_secs,
@scheduling_timezone
) RETURNING *;
-- name: InsertPresetParameters :many
@@ -25,6 +27,18 @@ SELECT
unnest(@values :: TEXT[])
RETURNING *;
-- name: InsertPresetPrebuildSchedule :one
INSERT INTO template_version_preset_prebuild_schedules (
preset_id,
cron_expression,
desired_instances
)
VALUES (
@preset_id,
@cron_expression,
@desired_instances
) RETURNING *;
-- name: UpdatePresetPrebuildStatus :exec
UPDATE template_version_presets
SET prebuild_status = @status
@@ -69,3 +83,17 @@ SELECT tvp.*, 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 = @preset_id;
-- name: GetActivePresetPrebuildSchedules :many
SELECT
tvpps.*
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 = '';