GeezerVison is showing github.com — fix: keep in-flight builds from blocking the prebuild hard limit (#30… · coder/coder@5563b74
File treeExpand file treeCollapse file tree Open diff view settings enterprise/coderd/prebuilds
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: coderd/database/querier.go +1-1Lines changed: 1 addition & 1 deletion Collapse file Copy file name to clipboardExpand all lines: coderd/database/querier_test.go +35-5Lines changed: 35 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -7685,6 +7685,7 @@ func createTmplVersionAndPreset( | 7685 | 7685 |
| 7686 | 7686 | type createPrebuiltWorkspaceOpts struct { | 7687 | 7687 | failedJob bool | | 7688 | + succeededJob bool | 7688 | 7689 | createdAt time.Time | 7689 | 7690 | readyAgents int | 7690 | 7691 | notReadyAgents int | @@ -7705,12 +7706,19 @@ func createPrebuiltWorkspace( | 7705 | 7706 | if opts != nil && opts.failedJob { | 7706 | 7707 | jobError = sql.NullString{String: "failed", Valid: true} | 7707 | 7708 | } | | 7709 | + var jobStartedAt, jobCompletedAt sql.NullTime | | 7710 | + if opts != nil && opts.succeededJob { | | 7711 | + jobStartedAt = sql.NullTime{Time: now.Add(-1 * time.Minute), Valid: true} | | 7712 | + jobCompletedAt = sql.NullTime{Time: now, Valid: true} | | 7713 | + } | 7708 | 7714 | job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ | 7709 | 7715 | Type: database.ProvisionerJobTypeWorkspaceBuild, | 7710 | 7716 | OrganizationID: orgID, | 7711 | 7717 |
| 7712 | | - CreatedAt: now.Add(-1 * time.Minute), | 7713 | | - Error: jobError, | | 7718 | + CreatedAt: now.Add(-1 * time.Minute), | | 7719 | + StartedAt: jobStartedAt, | | 7720 | + CompletedAt: jobCompletedAt, | | 7721 | + Error: jobError, | 7714 | 7722 | }) | 7715 | 7723 |
| 7716 | 7724 | // create ready agents | @@ -8717,7 +8725,9 @@ func TestGetPresetsAtFailureLimit(t *testing.T) { | 8717 | 8725 | name string | 8718 | 8726 | // true - build is successful | 8719 | 8727 | // false - build is unsuccessful | 8720 | | - buildSuccesses []bool | | 8728 | + buildSuccesses []bool | | 8729 | + // newestInFlight adds a pending build after buildSuccesses, as the reconciler sees while it is still creating. | | 8730 | + newestInFlight bool | 8721 | 8731 | hardLimit int64 | 8722 | 8732 | expHitHardLimit bool | 8723 | 8733 | }{ | @@ -8763,6 +8773,20 @@ func TestGetPresetsAtFailureLimit(t *testing.T) { | 8763 | 8773 | hardLimit: 3, | 8764 | 8774 | expHitHardLimit: false, | 8765 | 8775 | }, | | 8776 | + { | | 8777 | + name: "last 3 builds are failed and a newer one is in flight - hard limit is reached", | | 8778 | + buildSuccesses: []bool{false, false, false}, | | 8779 | + newestInFlight: true, | | 8780 | + hardLimit: 3, | | 8781 | + expHitHardLimit: true, | | 8782 | + }, | | 8783 | + { | | 8784 | + name: "last build is successful and a newer one is in flight - hard limit is NOT reached", | | 8785 | + buildSuccesses: []bool{false, false, true}, | | 8786 | + newestInFlight: true, | | 8787 | + hardLimit: 3, | | 8788 | + expHitHardLimit: false, | | 8789 | + }, | 8766 | 8790 | // hardLimit set to zero, implicitly disables the hard limit. | 8767 | 8791 | { | 8768 | 8792 | name: "despite 5 failed builds, the hard limit is not reached because it's disabled.", | @@ -8789,8 +8813,14 @@ func TestGetPresetsAtFailureLimit(t *testing.T) { | 8789 | 8813 | tmplV1 := createTmplVersionAndPreset(t, db, tmpl, tmpl.ActiveVersionID, now, nil) | 8790 | 8814 | for idx, buildSuccess := range tc.buildSuccesses { | 8791 | 8815 | createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ | 8792 | | - failedJob: !buildSuccess, | 8793 | | - createdAt: hourBefore.Add(time.Duration(idx) * time.Second), | | 8816 | + failedJob: !buildSuccess, | | 8817 | + succeededJob: buildSuccess, | | 8818 | + createdAt: hourBefore.Add(time.Duration(idx) * time.Second), | | 8819 | + }) | | 8820 | + } | | 8821 | + if tc.newestInFlight { | | 8822 | + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ | | 8823 | + createdAt: hourBefore.Add(time.Duration(len(tc.buildSuccesses)) * time.Second), | 8794 | 8824 | }) | 8795 | 8825 | } | 8796 | 8826 |
| |
Collapse file Copy file name to clipboardExpand all lines: coderd/database/queries.sql.go +3-1Lines changed: 3 additions & 1 deletion Collapse file Copy file name to clipboardExpand all lines: coderd/database/queries/prebuilds.sql +3-1Lines changed: 3 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -247,7 +247,7 @@ GROUP BY tsb.template_version_id, tsb.preset_id, fc.num_failed; | 247 | 247 |
| 248 | 248 | -- GetPresetsAtFailureLimit groups workspace builds by preset ID. | 249 | 249 | -- Each preset is associated with exactly one template version ID. | 250 | | --- For each preset, the query checks the last hard_limit builds. | | 250 | +-- For each preset, the query checks the last hard_limit finished builds. | 251 | 251 | -- If all of them failed, the preset is considered to have hit the hard failure limit. | 252 | 252 | -- The query returns a list of preset IDs that have reached this failure threshold. | 253 | 253 | -- Only active template versions with configured presets are considered. | @@ -263,6 +263,8 @@ WITH filtered_builds AS ( | 263 | 263 | WHERE tvp.desired_instances IS NOT NULL -- Consider only presets that have a prebuild configuration. | 264 | 264 | AND wlb.transition = 'start'::workspace_transition | 265 | 265 | AND w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0' | | 266 | + -- Rank only finished builds, so an in-flight build cannot hold a slot in the last hard_limit. | | 267 | + AND wlb.job_status IN ('succeeded'::provisioner_job_status, 'failed'::provisioner_job_status, 'canceled'::provisioner_job_status) | 266 | 268 | ), | 267 | 269 | time_sorted_builds AS ( | 268 | 270 | -- Group builds by preset, then sort each group by created_at. | |
Collapse file Copy file name to clipboardExpand all lines: enterprise/coderd/prebuilds/reconcile_test.go +53Lines changed: 53 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -1078,6 +1078,59 @@ func TestSkippingHardLimitedPresets(t *testing.T) { | 1078 | 1078 | } | 1079 | 1079 | } | 1080 | 1080 |
| | 1081 | +func TestHardLimitedPresetWithBuildInFlight(t *testing.T) { | | 1082 | + t.Parallel() | | 1083 | + | | 1084 | + clock := quartz.NewMock(t) | | 1085 | + ctx := testutil.Context(t, testutil.WaitShort) | | 1086 | + cfg := codersdk.PrebuildsConfig{ | | 1087 | + FailureHardLimit: serpent.Int64(1), | | 1088 | + ReconciliationBackoffInterval: 0, | | 1089 | + } | | 1090 | + logger := slogtest.Make( | | 1091 | + t, &slogtest.Options{IgnoreErrors: true}, | | 1092 | + ).Leveled(slog.LevelDebug) | | 1093 | + db, pubSub := dbtestutil.NewDB(t) | | 1094 | + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) | | 1095 | + controller := prebuilds.NewStoreReconciler( | | 1096 | + db, pubSub, cache, cfg, logger, | | 1097 | + clock, | | 1098 | + prometheus.NewRegistry(), | | 1099 | + newFakeEnqueuer(), | | 1100 | + newNoopUsageCheckerPtr(), | | 1101 | + noop.NewTracerProvider(), | | 1102 | + 10, | | 1103 | + nil, | | 1104 | + ) | | 1105 | + | | 1106 | + ownerID := uuid.New() | | 1107 | + dbgen.User(t, db, database.User{ | | 1108 | + ID: ownerID, | | 1109 | + }) | | 1110 | + org, template := setupTestDBTemplate(t, db, ownerID, false) | | 1111 | + templateVersionID := setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID) | | 1112 | + preset := setupTestDBPreset(t, db, templateVersionID, 2, uuid.New().String()) | | 1113 | + | | 1114 | + // Given: a failed prebuild, followed by a newer one that is still pending, as the reconciler | | 1115 | + // sees when a failing build outlasts a reconciliation interval. | | 1116 | + setupTestDBPrebuild(t, clock, db, pubSub, database.WorkspaceTransitionStart, database.ProvisionerJobStatusFailed, org.ID, preset, template.ID, templateVersionID) | | 1117 | + clock.Advance(time.Second).MustWait(ctx) | | 1118 | + setupTestDBPrebuild(t, clock, db, pubSub, database.WorkspaceTransitionStart, database.ProvisionerJobStatusPending, org.ID, preset, template.ID, templateVersionID) | | 1119 | + clock.Advance(time.Nanosecond).MustWait(ctx) | | 1120 | + | | 1121 | + // When: reconciling. | | 1122 | + _, err := controller.ReconcileAll(ctx) | | 1123 | + require.NoError(t, err) | | 1124 | + | | 1125 | + // Then: the in-flight build does not hide the failure, so the preset is hard limited and nothing new is created. | | 1126 | + workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID) | | 1127 | + require.NoError(t, err) | | 1128 | + require.Len(t, workspaces, 2) | | 1129 | + updatedPreset, err := db.GetPresetByID(ctx, preset.ID) | | 1130 | + require.NoError(t, err) | | 1131 | + require.Equal(t, database.PrebuildStatusHardLimited, updatedPreset.PrebuildStatus) | | 1132 | +} | | 1133 | + | 1081 | 1134 | func TestValidationFailedPresets(t *testing.T) { | 1082 | 1135 | t.Parallel() | 1083 | 1136 |
| |
|