mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +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
@ -71,6 +71,29 @@ func Daily(raw string) (*Schedule, error) {
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
// TimeRange parses a Schedule from a cron specification interpreted as a continuous time range.
|
||||
//
|
||||
// For example, the expression "* 9-18 * * 1-5" represents a continuous time span
|
||||
// from 09:00:00 to 18:59:59, Monday through Friday.
|
||||
//
|
||||
// The specification consists of space-delimited fields in the following order:
|
||||
// - (Optional) Timezone, e.g., CRON_TZ=US/Central
|
||||
// - Minutes: must be "*" to represent the full range within each hour
|
||||
// - Hour of day: e.g., 9-18 (required)
|
||||
// - Day of month: e.g., * or 1-15 (required)
|
||||
// - Month: e.g., * or 1-6 (required)
|
||||
// - Day of week: e.g., * or 1-5 (required)
|
||||
//
|
||||
// Unlike standard cron, this function interprets the input as a continuous active period
|
||||
// rather than discrete scheduled times.
|
||||
func TimeRange(raw string) (*Schedule, error) {
|
||||
if err := validateTimeRangeSpec(raw); err != nil {
|
||||
return nil, xerrors.Errorf("validate time range schedule: %w", err)
|
||||
}
|
||||
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
func parse(raw string) (*Schedule, error) {
|
||||
// If schedule does not specify a timezone, default to UTC. Otherwise,
|
||||
// the library will default to time.Local which we want to avoid.
|
||||
@ -155,6 +178,24 @@ func (s Schedule) Next(t time.Time) time.Time {
|
||||
return s.sched.Next(t)
|
||||
}
|
||||
|
||||
// IsWithinRange interprets a cron spec as a continuous time range,
|
||||
// and returns whether the provided time value falls within that range.
|
||||
//
|
||||
// For example, the expression "* 9-18 * * 1-5" represents a continuous time range
|
||||
// from 09:00:00 to 18:59:59, Monday through Friday.
|
||||
func (s Schedule) IsWithinRange(t time.Time) bool {
|
||||
// Truncate to the beginning of the current minute.
|
||||
currentMinute := t.Truncate(time.Minute)
|
||||
|
||||
// Go back 1 second from the current minute to find what the next scheduled time would be.
|
||||
justBefore := currentMinute.Add(-time.Second)
|
||||
next := s.Next(justBefore)
|
||||
|
||||
// If the next scheduled time is exactly at the current minute,
|
||||
// then we are within the range.
|
||||
return next.Equal(currentMinute)
|
||||
}
|
||||
|
||||
var (
|
||||
t0 = time.Date(1970, 1, 1, 1, 1, 1, 0, time.UTC)
|
||||
tMax = t0.Add(168 * time.Hour)
|
||||
@ -263,3 +304,18 @@ func validateDailySpec(spec string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTimeRangeSpec ensures that the minutes field is set to *
|
||||
func validateTimeRangeSpec(spec string) error {
|
||||
parts := strings.Fields(spec)
|
||||
if len(parts) < 5 {
|
||||
return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix")
|
||||
}
|
||||
if len(parts) == 6 {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if parts[0] != "*" {
|
||||
return xerrors.Errorf("expected minutes to be *")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user