@@ -89,9 +89,12 @@ func (r *CoderWorkspacePoolReconciler) Reconcile(ctx context.Context, req ctrl.R
8989 pool .Status .Resolved = resolved
9090 }
9191 resolvedChanged := ! poolResolvedEqual (before .Resolved , pool .Status .Resolved )
92- if pool .Generation != before .ObservedGeneration ||
93- (before .Resolved != nil && pool .Status .Resolved != nil && before .Resolved .TemplateVersionID != pool .Status .Resolved .TemplateVersionID ) {
94- // A spec change or a new version starts a new run of failures.
92+ if resolvedChanged && before .Resolved != nil {
93+ // Only an input that can fix a failure starts a new run of failures:
94+ // the template, the version, the preset, the parameters, the owner,
95+ // or readyTimeoutSeconds. A rescale, for example by an HPA through
96+ // /scale, keeps the run, so a pool that keeps rescaling still stops
97+ // at the failure limit.
9598 pool .Status .FailureRunStart = & metav1.Time {Time : r .Clock .Now ()}
9699 }
97100
@@ -123,6 +126,9 @@ func (r *CoderWorkspacePoolReconciler) Reconcile(ctx context.Context, req ctrl.R
123126 // its own object shows the new resolved inputs stored.
124127 requeue = time .Second
125128 default :
129+ if err := r .trimToOwnerCap (ctx , pool , all .Items ); err != nil {
130+ return ctrl.Result {}, err
131+ }
126132 step , requeue , err = r .createMembers (ctx , pool , all .Items , & counts )
127133 if err != nil {
128134 return ctrl.Result {}, err
@@ -147,8 +153,8 @@ func (r *CoderWorkspacePoolReconciler) Reconcile(ctx context.Context, req ctrl.R
147153
148154// deleteFailedMembers records the failures of the pool's failed unclaimed
149155// members in the current run, stores them, and then deletes all those
150- // members (plan 6.3 step 6). The run starts at the latest spec or version
151- // change or member ready time. A failure counts once, and only when it
156+ // members (plan 6.3 step 6). The run starts at the latest change of
157+ // status.resolved or member ready time. A failure counts once, and only when it
152158// happened after the run start; a failure in the same second as the start
153159// does not count. A failed member with old inputs never counts.
154160func (r * CoderWorkspacePoolReconciler ) deleteFailedMembers (
@@ -248,7 +254,7 @@ func (r *CoderWorkspacePoolReconciler) createMembers(
248254 return nil , 0 , nil
249255 }
250256 if n := len (st .RunFailures ); n >= poolFailureLimit {
251- return coderFail ("MemberFailureLimit" , "%d members failed in a row. Change the pool or wait for a new version." , n ), 0 , nil
257+ return coderFail ("MemberFailureLimit" , "%d members failed in a row. Change the template, version, preset, parameters, or readyTimeoutSeconds, or wait for a new version." , n ), 0 , nil
252258 } else if n > 0 {
253259 last := st .RunFailures [n - 1 ]
254260 if retryAt := last .Time .Add (min (poolFailureBackoffBase << (n - 1 ), poolFailureBackoffCap )); now .Before (retryAt ) {
@@ -275,16 +281,7 @@ func (r *CoderWorkspacePoolReconciler) createMembers(
275281 return nil , 0 , fmt .Errorf ("assertion failed: CoderControlPlane %s has no defaulted maxWorkspaces for Coder user %s" , controlPlane .Name , st .Resolved .OwnerID )
276282 }
277283 maxWorkspaces := * owner .MaxWorkspaces
278- ownerID := uuid .MustParse (st .Resolved .OwnerID ) // resolve parsed it.
279- var live int32
280- for i := range all {
281- m := & all [i ]
282- id , err := uuid .Parse (m .Spec .OwnerID )
283- if err == nil && id == ownerID && m .Spec .ControlPlaneRef .Name == pool .Spec .ControlPlaneRef .Name &&
284- ! meta .IsStatusConditionTrue (m .Status .Conditions , coderv1alpha1 .CoderWorkspacePoolMemberConditionWorkspaceDeleted ) {
285- live ++
286- }
287- }
284+ live := poolOwnerLiveMembers (all , st .Resolved .OwnerID , pool .Spec .ControlPlaneRef .Name , nil )
288285 n := min (need , * pool .Spec .MaxInFlight - counts .building , maxWorkspaces - live )
289286 for range n {
290287 if err := r .createMember (ctx , pool ); err != nil {
@@ -301,6 +298,67 @@ func (r *CoderWorkspacePoolReconciler) createMembers(
301298 return nil , 0 , nil
302299}
303300
301+ // poolOwnerLiveMembers counts the members of one owner on one control plane
302+ // whose workspace is not deleted, and that match, if match is set.
303+ func poolOwnerLiveMembers (
304+ all []coderv1alpha1.CoderWorkspacePoolMember , ownerID , controlPlane string , match func (* coderv1alpha1.CoderWorkspacePoolMember ) bool ,
305+ ) int32 {
306+ want := uuid .MustParse (ownerID ) // Callers pass a checked owner ID.
307+ var live int32
308+ for i := range all {
309+ m := & all [i ]
310+ id , err := uuid .Parse (m .Spec .OwnerID )
311+ if err == nil && id == want && m .Spec .ControlPlaneRef .Name == controlPlane && (match == nil || match (m )) &&
312+ ! meta .IsStatusConditionTrue (m .Status .Conditions , coderv1alpha1 .CoderWorkspacePoolMemberConditionWorkspaceDeleted ) {
313+ live ++
314+ }
315+ }
316+ return live
317+ }
318+
319+ // trimToOwnerCap deletes the pool's members that sent no create request yet,
320+ // newest first, while the owner has more live members than maxWorkspaces
321+ // allows, because an admin lowered it (Codex 4271920249). Such a member only
322+ // waits at its own cap check until its deadline fails it. The preconditions
323+ // keep a member that wrote its create marker after the list.
324+ func (r * CoderWorkspacePoolReconciler ) trimToOwnerCap (
325+ ctx context.Context , pool * coderv1alpha1.CoderWorkspacePool , all []coderv1alpha1.CoderWorkspacePoolMember ,
326+ ) error {
327+ controlPlane := & coderv1alpha1.CoderControlPlane {}
328+ if err := r .APIReader .Get (ctx , types.NamespacedName {Namespace : pool .Namespace , Name : pool .Spec .ControlPlaneRef .Name }, controlPlane ); err != nil {
329+ return fmt .Errorf ("get CoderControlPlane %s/%s: %w" , pool .Namespace , pool .Spec .ControlPlaneRef .Name , err )
330+ }
331+ owner := workspacePoolOwner (controlPlane , pool .Status .Resolved .OwnerID )
332+ if ! controlPlane .DeletionTimestamp .IsZero () || owner == nil || owner .MaxWorkspaces == nil {
333+ return nil // createMembers reports these.
334+ }
335+ excess := poolOwnerLiveMembers (all , pool .Status .Resolved .OwnerID , pool .Spec .ControlPlaneRef .Name , nil ) - * owner .MaxWorkspaces
336+ if excess <= 0 {
337+ return nil
338+ }
339+ var pending []* coderv1alpha1.CoderWorkspacePoolMember
340+ for i := range all {
341+ m := & all [i ]
342+ controller := metav1 .GetControllerOf (m )
343+ if controller != nil && controller .UID == pool .UID && m .Spec .ClaimRef == nil && m .DeletionTimestamp .IsZero () &&
344+ m .Status .Phase != coderv1alpha1 .CoderWorkspacePoolMemberPhaseFailed && ! poolMemberMayHaveWorkspace (m ) {
345+ pending = append (pending , m )
346+ }
347+ }
348+ slices .SortStableFunc (pending , func (a , b * coderv1alpha1.CoderWorkspacePoolMember ) int {
349+ return b .CreationTimestamp .Compare (a .CreationTimestamp .Time )
350+ })
351+ pending = pending [:min (int (excess ), len (pending ))]
352+ var errs []error
353+ for _ , m := range pending {
354+ uid , version := m .UID , m .ResourceVersion
355+ if err := r .Delete (ctx , m , client.Preconditions {UID : & uid , ResourceVersion : & version }); client .IgnoreNotFound (err ) != nil {
356+ errs = append (errs , fmt .Errorf ("delete pool member %s/%s above the owner cap: %w" , m .Namespace , m .Name , err ))
357+ }
358+ }
359+ return errors .Join (errs ... )
360+ }
361+
304362// createMember creates one member that pins the pool's resolved inputs.
305363func (r * CoderWorkspacePoolReconciler ) createMember (ctx context.Context , pool * coderv1alpha1.CoderWorkspacePool ) error {
306364 resolved := pool .Status .Resolved
@@ -317,7 +375,7 @@ func (r *CoderWorkspacePoolReconciler) createMember(ctx context.Context, pool *c
317375 ControlPlaneRef : pool .Spec .ControlPlaneRef , PoolName : pool .Name ,
318376 OwnerID : resolved .OwnerID , OrganizationID : resolved .OrganizationID , TemplateID : resolved .TemplateID ,
319377 TemplateVersionID : resolved .TemplateVersionID , PresetID : resolved .PresetID ,
320- Parameters : params , ParametersHash : resolved .ParametersHash , ReadyTimeoutSeconds : * pool . Spec .ReadyTimeoutSeconds ,
378+ Parameters : params , ParametersHash : resolved .ParametersHash , ReadyTimeoutSeconds : resolved .ReadyTimeoutSeconds ,
321379 },
322380 }
323381 if err := controllerutil .SetControllerReference (pool , member , r .Scheme ); err != nil {
@@ -387,7 +445,7 @@ func (r *CoderWorkspacePoolReconciler) resolve(
387445 return & coderv1alpha1.CoderWorkspacePoolResolved {
388446 OrganizationID : template .OrganizationID .String (), TemplateID : template .ID .String (),
389447 TemplateVersionID : version .ID .String (), TemplateVersionName : version .Name , PresetID : presetID ,
390- OwnerID : ownerID .String (), ParametersHash : workspacePoolParametersHash (params ),
448+ OwnerID : ownerID .String (), ParametersHash : workspacePoolParametersHash (params ), ReadyTimeoutSeconds : * spec . ReadyTimeoutSeconds ,
391449 }, nil , nil
392450}
393451
0 commit comments