mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
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>
136 lines
4.3 KiB
Go
136 lines
4.3 KiB
Go
package prebuilds
|
|
|
|
import (
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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 {
|
|
hardLimitedPresetsMap[preset.PresetID] = preset
|
|
}
|
|
|
|
return GlobalSnapshot{
|
|
Presets: presets,
|
|
PrebuildSchedules: prebuildSchedules,
|
|
RunningPrebuilds: runningPrebuilds,
|
|
PrebuildsInProgress: prebuildsInProgress,
|
|
Backoffs: backoffs,
|
|
HardLimitedPresetsMap: hardLimitedPresetsMap,
|
|
clock: clock,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, error) {
|
|
preset, found := slice.Find(s.Presets, func(preset database.GetTemplatePresetsWithPrebuildsRow) bool {
|
|
return preset.ID == presetID
|
|
})
|
|
if !found {
|
|
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 {
|
|
return false
|
|
}
|
|
return prebuild.CurrentPresetID.UUID == preset.ID
|
|
})
|
|
|
|
// Separate running workspaces into non-expired and expired based on the preset's TTL
|
|
nonExpired, expired := filterExpiredWorkspaces(preset, running)
|
|
|
|
inProgress := slice.Filter(s.PrebuildsInProgress, func(prebuild database.CountInProgressPrebuildsRow) bool {
|
|
return prebuild.PresetID.UUID == preset.ID
|
|
})
|
|
|
|
var backoffPtr *database.GetPresetsBackoffRow
|
|
backoff, found := slice.Find(s.Backoffs, func(row database.GetPresetsBackoffRow) bool {
|
|
return row.PresetID == preset.ID
|
|
})
|
|
if found {
|
|
backoffPtr = &backoff
|
|
}
|
|
|
|
_, isHardLimited := s.HardLimitedPresetsMap[preset.ID]
|
|
|
|
presetSnapshot := NewPresetSnapshot(
|
|
preset,
|
|
prebuildSchedules,
|
|
nonExpired,
|
|
expired,
|
|
inProgress,
|
|
backoffPtr,
|
|
isHardLimited,
|
|
s.clock,
|
|
s.logger,
|
|
)
|
|
|
|
return &presetSnapshot, nil
|
|
}
|
|
|
|
func (s GlobalSnapshot) IsHardLimited(presetID uuid.UUID) bool {
|
|
_, isHardLimited := s.HardLimitedPresetsMap[presetID]
|
|
|
|
return isHardLimited
|
|
}
|
|
|
|
// filterExpiredWorkspaces splits running workspaces into expired and non-expired
|
|
// based on the preset's TTL.
|
|
// If TTL is missing or zero, all workspaces are considered non-expired.
|
|
func filterExpiredWorkspaces(preset database.GetTemplatePresetsWithPrebuildsRow, runningWorkspaces []database.GetRunningPrebuiltWorkspacesRow) (nonExpired []database.GetRunningPrebuiltWorkspacesRow, expired []database.GetRunningPrebuiltWorkspacesRow) {
|
|
if !preset.Ttl.Valid {
|
|
return runningWorkspaces, expired
|
|
}
|
|
|
|
ttl := time.Duration(preset.Ttl.Int32) * time.Second
|
|
if ttl <= 0 {
|
|
return runningWorkspaces, expired
|
|
}
|
|
|
|
for _, prebuild := range runningWorkspaces {
|
|
if time.Since(prebuild.CreatedAt) > ttl {
|
|
expired = append(expired, prebuild)
|
|
} else {
|
|
nonExpired = append(nonExpired, prebuild)
|
|
}
|
|
}
|
|
return nonExpired, expired
|
|
}
|