Skip to content

Commit 0f6ca55

Browse files
feat: implement scheduling mechanism for prebuilds (#18126)
Closes coder/internal#312 Depends on coder/terraform-provider-coder#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>
1 parent 511fd09 commit 0f6ca55

38 files changed

+2528
-871
lines changed

coderd/database/dbauthz/dbauthz.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,13 @@ func (q *querier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Tim
16861686
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetAPIKeysLastUsedAfter)(ctx, lastUsed)
16871687
}
16881688

1689+
func (q *querier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
1690+
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate.All()); err != nil {
1691+
return nil, err
1692+
}
1693+
return q.db.GetActivePresetPrebuildSchedules(ctx)
1694+
}
1695+
16891696
func (q *querier) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) {
16901697
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
16911698
return 0, err
@@ -3661,6 +3668,15 @@ func (q *querier) InsertPresetParameters(ctx context.Context, arg database.Inser
36613668
return q.db.InsertPresetParameters(ctx, arg)
36623669
}
36633670

3671+
func (q *querier) InsertPresetPrebuildSchedule(ctx context.Context, arg database.InsertPresetPrebuildScheduleParams) (database.TemplateVersionPresetPrebuildSchedule, error) {
3672+
err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceTemplate)
3673+
if err != nil {
3674+
return database.TemplateVersionPresetPrebuildSchedule{}, err
3675+
}
3676+
3677+
return q.db.InsertPresetPrebuildSchedule(ctx, arg)
3678+
}
3679+
36643680
func (q *querier) InsertProvisionerJob(ctx context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) {
36653681
// TODO: Remove this once we have a proper rbac check for provisioner jobs.
36663682
// Details in https://github.com/coder/coder/issues/16160

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,29 @@ func (s *MethodTestSuite) TestOrganization() {
979979
}
980980
check.Args(insertPresetParametersParams).Asserts(rbac.ResourceTemplate, policy.ActionUpdate)
981981
}))
982+
s.Run("InsertPresetPrebuildSchedule", s.Subtest(func(db database.Store, check *expects) {
983+
org := dbgen.Organization(s.T(), db, database.Organization{})
984+
user := dbgen.User(s.T(), db, database.User{})
985+
template := dbgen.Template(s.T(), db, database.Template{
986+
CreatedBy: user.ID,
987+
OrganizationID: org.ID,
988+
})
989+
templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
990+
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
991+
OrganizationID: org.ID,
992+
CreatedBy: user.ID,
993+
})
994+
preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{
995+
TemplateVersionID: templateVersion.ID,
996+
Name: "test",
997+
})
998+
arg := database.InsertPresetPrebuildScheduleParams{
999+
PresetID: preset.ID,
1000+
}
1001+
check.Args(arg).
1002+
Asserts(rbac.ResourceTemplate, policy.ActionUpdate).
1003+
ErrorsWithInMemDB(dbmem.ErrUnimplemented)
1004+
}))
9821005
s.Run("DeleteOrganizationMember", s.Subtest(func(db database.Store, check *expects) {
9831006
o := dbgen.Organization(s.T(), db, database.Organization{})
9841007
u := dbgen.User(s.T(), db, database.User{})
@@ -4916,6 +4939,12 @@ func (s *MethodTestSuite) TestPrebuilds() {
49164939
Asserts(template.RBACObject(), policy.ActionRead).
49174940
Returns(insertedParameters)
49184941
}))
4942+
s.Run("GetActivePresetPrebuildSchedules", s.Subtest(func(db database.Store, check *expects) {
4943+
check.Args().
4944+
Asserts(rbac.ResourceTemplate.All(), policy.ActionRead).
4945+
Returns([]database.TemplateVersionPresetPrebuildSchedule{}).
4946+
ErrorsWithInMemDB(dbmem.ErrUnimplemented)
4947+
}))
49194948
s.Run("GetPresetsByTemplateVersionID", s.Subtest(func(db database.Store, check *expects) {
49204949
ctx := context.Background()
49214950
org := dbgen.Organization(s.T(), db, database.Organization{})

coderd/database/dbfake/dbfake.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
415415
CreatedAt: version.CreatedAt,
416416
DesiredInstances: preset.DesiredInstances,
417417
InvalidateAfterSecs: preset.InvalidateAfterSecs,
418+
SchedulingTimezone: preset.SchedulingTimezone,
418419
})
419420
}
420421

coderd/database/dbgen/dbgen.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,11 +1302,22 @@ func Preset(t testing.TB, db database.Store, seed database.InsertPresetParams) d
13021302
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
13031303
DesiredInstances: seed.DesiredInstances,
13041304
InvalidateAfterSecs: seed.InvalidateAfterSecs,
1305+
SchedulingTimezone: seed.SchedulingTimezone,
13051306
})
13061307
require.NoError(t, err, "insert preset")
13071308
return preset
13081309
}
13091310

1311+
func PresetPrebuildSchedule(t testing.TB, db database.Store, seed database.InsertPresetPrebuildScheduleParams) database.TemplateVersionPresetPrebuildSchedule {
1312+
schedule, err := db.InsertPresetPrebuildSchedule(genCtx, database.InsertPresetPrebuildScheduleParams{
1313+
PresetID: takeFirst(seed.PresetID, uuid.New()),
1314+
CronExpression: takeFirst(seed.CronExpression, "* 9-18 * * 1-5"),
1315+
DesiredInstances: takeFirst(seed.DesiredInstances, 1),
1316+
})
1317+
require.NoError(t, err, "insert preset prebuild schedule")
1318+
return schedule
1319+
}
1320+
13101321
func PresetParameter(t testing.TB, db database.Store, seed database.InsertPresetParametersParams) []database.TemplateVersionPresetParameter {
13111322
parameters, err := db.InsertPresetParameters(genCtx, database.InsertPresetParametersParams{
13121323
TemplateVersionPresetID: takeFirst(seed.TemplateVersionPresetID, uuid.New()),

coderd/database/dbmem/dbmem.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2778,6 +2778,10 @@ func (q *FakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time
27782778
return apiKeys, nil
27792779
}
27802780

2781+
func (q *FakeQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
2782+
return nil, ErrUnimplemented
2783+
}
2784+
27812785
// nolint:revive // It's not a control flag, it's a filter.
27822786
func (q *FakeQuerier) GetActiveUserCount(_ context.Context, includeSystem bool) (int64, error) {
27832787
q.mutex.RLock()
@@ -9191,6 +9195,15 @@ func (q *FakeQuerier) InsertPresetParameters(_ context.Context, arg database.Ins
91919195
return presetParameters, nil
91929196
}
91939197

9198+
func (q *FakeQuerier) InsertPresetPrebuildSchedule(ctx context.Context, arg database.InsertPresetPrebuildScheduleParams) (database.TemplateVersionPresetPrebuildSchedule, error) {
9199+
err := validateDatabaseType(arg)
9200+
if err != nil {
9201+
return database.TemplateVersionPresetPrebuildSchedule{}, err
9202+
}
9203+
9204+
return database.TemplateVersionPresetPrebuildSchedule{}, ErrUnimplemented
9205+
}
9206+
91949207
func (q *FakeQuerier) InsertProvisionerJob(_ context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) {
91959208
if err := validateDatabaseType(arg); err != nil {
91969209
return database.ProvisionerJob{}, err

coderd/database/dbmetrics/querymetrics.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dump.sql

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/foreign_key_constraint.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Drop the prebuild schedules table
2+
DROP TABLE template_version_preset_prebuild_schedules;
3+
4+
-- Remove scheduling_timezone column from template_version_presets table
5+
ALTER TABLE template_version_presets
6+
DROP COLUMN scheduling_timezone;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Add scheduling_timezone column to template_version_presets table
2+
ALTER TABLE template_version_presets
3+
ADD COLUMN scheduling_timezone TEXT DEFAULT '' NOT NULL;
4+
5+
-- Add table for prebuild schedules
6+
CREATE TABLE template_version_preset_prebuild_schedules (
7+
id UUID PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
8+
preset_id UUID NOT NULL,
9+
cron_expression TEXT NOT NULL,
10+
desired_instances INTEGER NOT NULL,
11+
FOREIGN KEY (preset_id) REFERENCES template_version_presets (id) ON DELETE CASCADE
12+
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
INSERT INTO
2+
template_version_preset_prebuild_schedules (
3+
id,
4+
preset_id,
5+
cron_expression,
6+
desired_instances
7+
)
8+
VALUES (
9+
'e387cac1-9bf1-4fb6-8a34-db8cfb750dd0',
10+
'28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe',
11+
'* 8-18 * * 1-5',
12+
1
13+
);

coderd/database/models.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/querier.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)