Skip to content

Commit 92242ff

Browse files
committed
feat: hold CoderWorkspacePool members at a lowered owner cap, and keep the failure run across a rescale
Change-Id: I06dd5f74f7c244bf1ce6b444438fee1f4fcea25e Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent fb42486 commit 92242ff

8 files changed

Lines changed: 230 additions & 36 deletions

‎api/v1alpha1/coderworkspacepool_types.go‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,10 @@ type CoderWorkspacePoolResolved struct {
232232
// current members from outdated ones without storing values twice.
233233
// +optional
234234
ParametersHash string `json:"parametersHash,omitempty"`
235+
// ReadyTimeoutSeconds is spec.readyTimeoutSeconds. A change of any of
236+
// these values starts a new run of failures, because it can fix one.
237+
// +optional
238+
ReadyTimeoutSeconds int32 `json:"readyTimeoutSeconds,omitempty"`
235239
// ResolvedTime is when the controller last changed these values.
236240
// +optional
237241
ResolvedTime *metav1.Time `json:"resolvedTime,omitempty"`
@@ -293,16 +297,17 @@ type CoderWorkspacePoolStatus struct {
293297
// +optional
294298
Resolved *CoderWorkspacePoolResolved `json:"resolved,omitempty"`
295299
// ConsecutiveFailures is the number of member failures in a row: the
296-
// length of RunFailures. A ready member, a spec change, and a new
297-
// version reset it.
300+
// length of RunFailures. A ready member and a change of status.resolved
301+
// (template, version, preset, parameters, owner, or readyTimeoutSeconds)
302+
// reset it. A change of replicas or maxInFlight does not.
298303
// +optional
299304
ConsecutiveFailures int32 `json:"consecutiveFailures,omitempty"`
300305
// LastFailure is the newest member failure.
301306
// +optional
302307
LastFailure *CoderWorkspacePoolFailure `json:"lastFailure,omitempty"`
303308
// FailureRunStart is the start of the current run of failures: the
304-
// latest of the last spec or version change and the newest ready time
305-
// of a member. Failures at or before it do not count.
309+
// latest of the last change of status.resolved and the newest ready
310+
// time of a member. Failures at or before it do not count.
306311
// +optional
307312
FailureRunStart *metav1.Time `json:"failureRunStart,omitempty"`
308313
// RunFailures are the member failures of the current run, oldest first,

‎config/crd/dormant/coder.com_coderworkspacepools.yaml‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,15 +307,16 @@ spec:
307307
consecutiveFailures:
308308
description: |-
309309
ConsecutiveFailures is the number of member failures in a row: the
310-
length of RunFailures. A ready member, a spec change, and a new
311-
version reset it.
310+
length of RunFailures. A ready member and a change of status.resolved
311+
(template, version, preset, parameters, owner, or readyTimeoutSeconds)
312+
reset it. A change of replicas or maxInFlight does not.
312313
format: int32
313314
type: integer
314315
failureRunStart:
315316
description: |-
316317
FailureRunStart is the start of the current run of failures: the
317-
latest of the last spec or version change and the newest ready time
318-
of a member. Failures at or before it do not count.
318+
latest of the last change of status.resolved and the newest ready
319+
time of a member. Failures at or before it do not count.
319320
format: date-time
320321
type: string
321322
lastFailure:
@@ -386,6 +387,12 @@ spec:
386387
presetID:
387388
description: PresetID is the UUID of the preset named by spec.presetName.
388389
type: string
390+
readyTimeoutSeconds:
391+
description: |-
392+
ReadyTimeoutSeconds is spec.readyTimeoutSeconds. A change of any of
393+
these values starts a new run of failures, because it can fix one.
394+
format: int32
395+
type: integer
389396
resolvedTime:
390397
description: ResolvedTime is when the controller last changed
391398
these values.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package controller_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
"k8s.io/apimachinery/pkg/types"
8+
9+
coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1"
10+
)
11+
12+
// setOwnerCap sets maxWorkspaces of the control plane's only owner entry.
13+
func (e *poolMemberEnv) setOwnerCap(t *testing.T, maxWorkspaces int32) {
14+
t.Helper()
15+
cp := &coderv1alpha1.CoderControlPlane{}
16+
require.NoError(t, k8sClient.Get(e.ctx, types.NamespacedName{Namespace: e.ns, Name: "coder"}, cp))
17+
cp.Spec.WorkspacePools.Owners[0].MaxWorkspaces = &maxWorkspaces
18+
require.NoError(t, k8sClient.Update(e.ctx, cp))
19+
}
20+
21+
// TestPoolLoweredOwnerCap: an admin lowers maxWorkspaces while members of
22+
// the pool wait for their create (Codex 4271920249). No member creates a
23+
// workspace above the new cap: each member checks the cap again, without
24+
// the cache, right before its create marker. The pool then deletes the
25+
// members that sent no create request, down to the cap.
26+
func TestPoolLoweredOwnerCap(t *testing.T) {
27+
t.Parallel()
28+
e := newPoolMemberEnv(t)
29+
e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) {
30+
s.Replicas, s.MaxInFlight = new(int32(3)), new(int32(3))
31+
})
32+
e.reconcilePool(t, 2)
33+
members := e.poolMembers(t, e.pool.Name)
34+
require.Len(t, members, 3)
35+
e.setOwnerCap(t, 1)
36+
37+
// Every member reconciles before the pool sees the new cap.
38+
waiting := map[string]bool{}
39+
for _, m := range members {
40+
got := e.reconcileMember(t, memberKey(m), 2) // The finalizer, then the create.
41+
if got.Status.Reason == "OwnerWorkspaceLimit" {
42+
require.Nil(t, got.Status.CreateAttemptTime, "a waiting member writes no marker")
43+
waiting[got.Name] = true
44+
}
45+
}
46+
require.Equal(t, 1, e.fake.workspaceCount(), "one workspace at maxWorkspaces 1")
47+
require.Len(t, waiting, 2)
48+
49+
// The pool deletes the two members without a create request and keeps
50+
// the member that created its workspace.
51+
e.reconcilePool(t, 1)
52+
for _, m := range e.poolMembers(t, e.pool.Name) {
53+
require.Equal(t, waiting[m.Name], !m.DeletionTimestamp.IsZero(), "member %s", m.Name)
54+
}
55+
requirePool(t, e.reconcilePool(t, 1), "OwnerWorkspaceLimit", false, false)
56+
require.Len(t, e.poolMembers(t, e.pool.Name), 3, "no new member: the deleting members wait for their finalizers")
57+
require.Equal(t, 1, e.fake.workspaceCount())
58+
}

‎internal/controller/coderworkspacepool_controller.go‎

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
154160
func (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.
305363
func (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

‎internal/controller/coderworkspacepool_controller_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ func TestPoolResolvesInputs(t *testing.T) {
9696
require.Equal(t, coderv1alpha1.CoderWorkspacePoolResolved{
9797
OrganizationID: e.orgID.String(), TemplateID: e.tplID.String(), TemplateVersionID: e.v1.String(), TemplateVersionName: "v1",
9898
PresetID: presetID.String(), OwnerID: e.tester.String(),
99-
ParametersHash: parametersHash(coderv1alpha1.CoderWorkspacePoolMemberParameter{Name: "region", Value: "eu"}),
100-
ResolvedTime: resolved.ResolvedTime,
99+
ParametersHash: parametersHash(coderv1alpha1.CoderWorkspacePoolMemberParameter{Name: "region", Value: "eu"}),
100+
ReadyTimeoutSeconds: 900, ResolvedTime: resolved.ResolvedTime,
101101
}, *resolved)
102102
require.True(t, resolved.ResolvedTime.Time.Equal(e.clock.Now()))
103103
// The same inputs keep the time.

‎internal/controller/coderworkspacepool_failure_test.go‎

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/coder/coder/v2/codersdk"
1010
"github.com/stretchr/testify/require"
11+
autoscalingv1 "k8s.io/api/autoscaling/v1"
1112
apierrors "k8s.io/apimachinery/pkg/api/errors"
1213
"k8s.io/apimachinery/pkg/api/meta"
1314
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -48,7 +49,7 @@ func (e *poolMemberEnv) readyAt(at time.Time) func(*coderv1alpha1.CoderWorkspace
4849
func (e *poolMemberEnv) tick() { e.clock.SetTime(e.clock.Now().Add(time.Second)) }
4950

5051
// TestPoolFailureBackoffAndLimit: the pool counts each failed member once,
51-
// deletes it, backs off, and stops after 5 failures in a row until its spec
52+
// deletes it, backs off, and stops after 5 failures in a row until an input
5253
// changes. A member that becomes ready resets the count.
5354
func TestPoolFailureBackoffAndLimit(t *testing.T) {
5455
t.Parallel()
@@ -88,9 +89,9 @@ func TestPoolFailureBackoffAndLimit(t *testing.T) {
8889
e.reconcilePool(t, 1)
8990
require.Empty(t, e.poolMembers(t, e.pool.Name), "the limit holds")
9091

91-
// A spec change lifts the limit, and a ready member resets the count.
92-
e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.MaxInFlight = new(int32(4)) })
93-
pool := e.reconcilePool(t, 1)
92+
// An input change lifts the limit, and a ready member resets the count.
93+
e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.ReadyTimeoutSeconds = new(int32(1800)) })
94+
pool := e.reconcilePool(t, 2) // Publishes the new inputs, then creates.
9495
require.Zero(t, pool.Status.ConsecutiveFailures)
9596
failNewest(t)
9697
require.Equal(t, int32(1), e.reconcilePool(t, 1).Status.ConsecutiveFailures)
@@ -159,7 +160,7 @@ func TestPoolFailureRunOrder(t *testing.T) {
159160
require.Zero(t, pool.Status.ConsecutiveFailures)
160161
}
161162

162-
// TestPoolFailureRunResets: failures from before a spec change or a new
163+
// TestPoolFailureRunResets: failures from before an input change or a new
163164
// version never count, also when their members are still there at the
164165
// change, and a failed member with old inputs never counts.
165166
func TestPoolFailureRunResets(t *testing.T) {
@@ -171,9 +172,9 @@ func TestPoolFailureRunResets(t *testing.T) {
171172
e.setMemberStatus(t, e.createMember(t, nil), e.failedStatus("BuildFailed"))
172173
}
173174
e.tick()
174-
e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.MaxInFlight = new(int32(3)) })
175+
e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.ReadyTimeoutSeconds = new(int32(1800)) })
175176
pool := e.reconcilePool(t, 1)
176-
require.Zero(t, pool.Status.ConsecutiveFailures, "the spec change started a new run")
177+
require.Zero(t, pool.Status.ConsecutiveFailures, "the input change started a new run")
177178

178179
e.tick()
179180
outdated := e.createMember(t, func(m *coderv1alpha1.CoderWorkspacePoolMember) {
@@ -198,6 +199,43 @@ func TestPoolFailureRunResets(t *testing.T) {
198199
require.Zero(t, e.reconcilePool(t, 1).Status.ConsecutiveFailures, "the new version started a new run")
199200
}
200201

202+
// TestPoolFailureRunSurvivesRescale: only an input that can fix a failure
203+
// starts a new run. A replicas change through /scale, as an HPA sends it,
204+
// and a maxInFlight change keep the run, so a pool that keeps rescaling
205+
// still stops at the failure limit. A new readyTimeoutSeconds can fix a
206+
// DeadlineExceeded failure, so it starts a new run.
207+
func TestPoolFailureRunSurvivesRescale(t *testing.T) {
208+
t.Parallel()
209+
e := newPoolMemberEnv(t)
210+
e.reconcilePool(t, 1)
211+
e.tick()
212+
e.setMemberStatus(t, memberKey(e.poolMembers(t, e.pool.Name)[0]), e.failedStatus("DeadlineExceeded"))
213+
require.Equal(t, int32(1), e.reconcilePool(t, 1).Status.ConsecutiveFailures)
214+
215+
scale := &autoscalingv1.Scale{}
216+
require.NoError(t, k8sClient.SubResource("scale").Get(e.ctx, e.pool, scale))
217+
scale.Spec.Replicas = 2
218+
require.NoError(t, k8sClient.SubResource("scale").Update(e.ctx, e.pool, client.WithSubResourceBody(scale)))
219+
e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.MaxInFlight = new(int32(4)) })
220+
e.tick()
221+
pool := e.reconcilePool(t, 1)
222+
require.Equal(t, pool.Generation, pool.Status.ObservedGeneration)
223+
require.Equal(t, int32(1), pool.Status.ConsecutiveFailures, "a rescale keeps the run")
224+
requirePool(t, pool, "FailureBackoff", false, false)
225+
226+
e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.ReadyTimeoutSeconds = new(int32(1800)) })
227+
e.tick()
228+
pool = e.reconcilePool(t, 1)
229+
require.Zero(t, pool.Status.ConsecutiveFailures, "a new readyTimeoutSeconds starts a new run")
230+
require.Equal(t, int32(1800), pool.Status.Resolved.ReadyTimeoutSeconds)
231+
e.reconcilePool(t, 1)
232+
members := e.poolMembers(t, e.pool.Name)
233+
require.Len(t, members, 2)
234+
for _, m := range members {
235+
require.Equal(t, int32(1800), m.Spec.ReadyTimeoutSeconds, "new members pin the resolved timeout")
236+
}
237+
}
238+
201239
// deleteHookClient runs hook before each member delete and fails the pool
202240
// status writes after a member delete when failStatus is set.
203241
type deleteHookClient struct {

0 commit comments

Comments
 (0)