Skip to content

Commit eade1cb

Browse files
committed
Treat draining control planes as live in orphan worker sweep
Follow-up to #407. The startup orphan sweep used ListActiveControlPlaneInstanceIDs which only returned CPs in state 'active', so a CP that was mid-graceful-shutdown (state='draining', still serving in-flight queries while waiting for them to finish) appeared "dead" to a new peer's startup sweep. Phase 1 would then delete the draining CP's worker pods, killing every query the draining CP was waiting on. The pre-existing ListStuckWorkers query already uses `cp.state <> 'expired'` for the same "alive" notion, so this aligns the new sweep with the existing convention. - Renamed ListActiveControlPlaneInstanceIDs to ListLiveControlPlaneInstanceIDs and changed the underlying query from `state = 'active'` to `state <> 'expired'`. The doc comment spells out why draining must count as live. - Renamed activeCPIDs / activeCPLabels to liveCPIDs / liveCPLabels in cleanupOrphanedWorkers and updated both the phase 1 pod-delete filter and the phase 2 owner-skip filter to use them. - Updated the captureRuntimeWorkerStore mock and existing test field names. - Added TestCleanupOrphanedWorkers_PreservesDrainingPeerWorkers as a regression test: a draining peer with both a busy worker pod and a stale-looking idle row must end the sweep with the pod intact and the row untouched. - Added TestListLiveControlPlaneInstanceIDsPostgres as an integration test against real Postgres covering active / draining / expired states. Workers serving queries are in reserved/hot/activating state, which phase 2 doesn't query at all (it only lists idle and spawning rows), so phase 2 was already safe. The bug was strictly in phase 1's pod-delete filter.
1 parent aabc9a5 commit eade1cb

5 files changed

Lines changed: 171 additions & 31 deletions

File tree

‎controlplane/configstore/store.go‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -422,16 +422,21 @@ func (cs *ConfigStore) GetControlPlaneInstance(id string) (*ControlPlaneInstance
422422
return &instance, nil
423423
}
424424

425-
// ListActiveControlPlaneInstanceIDs returns the IDs of control-plane instances
426-
// currently in the active state. Used by the K8s pool's startup orphan sweep
427-
// to distinguish "owned by a live peer" from "owned by a dead CP" without
428-
// needing N round-trips.
429-
func (cs *ConfigStore) ListActiveControlPlaneInstanceIDs() ([]string, error) {
425+
// ListLiveControlPlaneInstanceIDs returns the IDs of control-plane instances
426+
// that are not yet expired — i.e. either currently active OR draining (still
427+
// alive, waiting on in-flight queries to finish before SIGTERM completes).
428+
// Used by the K8s pool's startup orphan sweep to distinguish "owned by a CP
429+
// that is still serving traffic" from "owned by a dead CP".
430+
//
431+
// Including draining CPs is critical: a draining CP's worker pods are still
432+
// running queries that haven't finished yet, and treating them as orphans
433+
// would kill those queries mid-flight.
434+
func (cs *ConfigStore) ListLiveControlPlaneInstanceIDs() ([]string, error) {
430435
var ids []string
431436
if err := cs.db.Table(cs.runtimeTable((&ControlPlaneInstance{}).TableName())).
432-
Where("state = ?", ControlPlaneInstanceStateActive).
437+
Where("state <> ?", ControlPlaneInstanceStateExpired).
433438
Pluck("id", &ids).Error; err != nil {
434-
return nil, fmt.Errorf("list active control plane instance ids: %w", err)
439+
return nil, fmt.Errorf("list live control plane instance ids: %w", err)
435440
}
436441
return ids, nil
437442
}

‎controlplane/k8s_pool.go‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -216,27 +216,32 @@ func (p *K8sWorkerPool) cleanupOrphanedWorkers() {
216216
return
217217
}
218218

219-
// Build the set of currently-active CP instance IDs (both raw and label-
219+
// Build the set of currently-live CP instance IDs (both raw and label-
220220
// sanitized forms) so phase 1 can compare against pod labels and phase 2
221221
// can compare against owner_cp_instance_id columns.
222-
activeCPIDs := map[string]bool{}
223-
activeCPLabels := map[string]bool{}
222+
//
223+
// "Live" includes both `active` and `draining` states. A draining CP
224+
// is mid-graceful-shutdown waiting on in-flight queries to finish — its
225+
// worker pods are still serving traffic and must NOT be treated as
226+
// orphans. Only `expired` CPs are dead enough to clean up after.
227+
liveCPIDs := map[string]bool{}
228+
liveCPLabels := map[string]bool{}
224229
if p.runtimeStore != nil {
225-
ids, err := p.runtimeStore.ListActiveControlPlaneInstanceIDs()
230+
ids, err := p.runtimeStore.ListLiveControlPlaneInstanceIDs()
226231
if err != nil {
227-
slog.Warn("Failed to list active control-plane instances for orphan cleanup; skipping sweep to avoid wiping live peers.", "error", err)
232+
slog.Warn("Failed to list live control-plane instances for orphan cleanup; skipping sweep to avoid wiping live peers.", "error", err)
228233
return
229234
}
230235
for _, id := range ids {
231-
activeCPIDs[id] = true
232-
activeCPLabels[controlPlaneIDLabelValue(id)] = true
236+
liveCPIDs[id] = true
237+
liveCPLabels[controlPlaneIDLabelValue(id)] = true
233238
}
234239
}
235-
// Always treat the current CP as active so the sweep is safe in
240+
// Always treat the current CP as live so the sweep is safe in
236241
// non-runtime-store deployments and during the brief window before this
237242
// CP's heartbeat is first written.
238-
activeCPIDs[p.cpInstanceID] = true
239-
activeCPLabels[controlPlaneIDLabelValue(p.cpInstanceID)] = true
243+
liveCPIDs[p.cpInstanceID] = true
244+
liveCPLabels[controlPlaneIDLabelValue(p.cpInstanceID)] = true
240245

241246
// Phase 1: delete pods owned by dead CPs.
242247
gracePeriod := int64(10)
@@ -245,7 +250,7 @@ func (p *K8sWorkerPool) cleanupOrphanedWorkers() {
245250
var deletedNames sync.Map
246251
for _, pod := range pods.Items {
247252
podInstanceID := pod.Labels["duckgres/cp-instance-id"]
248-
if podInstanceID == "" || activeCPLabels[podInstanceID] {
253+
if podInstanceID == "" || liveCPLabels[podInstanceID] {
249254
continue
250255
}
251256
wg.Add(1)
@@ -295,7 +300,7 @@ func (p *K8sWorkerPool) cleanupOrphanedWorkers() {
295300
if livePodNames[row.PodName] {
296301
continue // pod is alive in K8s; row reflects reality
297302
}
298-
if row.OwnerCPInstanceID != "" && activeCPIDs[row.OwnerCPInstanceID] {
303+
if row.OwnerCPInstanceID != "" && liveCPIDs[row.OwnerCPInstanceID] {
299304
continue // a live owner is responsible for this row's lifecycle
300305
}
301306
record := row

‎controlplane/k8s_pool_test.go‎

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ type captureRuntimeWorkerStore struct {
5656
takeOverOwnerCPID string
5757
takeOverOrgID string
5858
takeOverExpectedEpoch int64
59-
activeCPIDs []string
60-
activeCPIDsErr error
59+
liveCPIDs []string
60+
liveCPIDsErr error
6161
stateBeforeRows []configstore.WorkerRecord
6262
stateBeforeErr error
6363
stateBeforeCalls int
@@ -190,14 +190,14 @@ func (s *captureRuntimeWorkerStore) TakeOverWorker(workerID int, ownerCPInstance
190190
return &record, nil
191191
}
192192

193-
func (s *captureRuntimeWorkerStore) ListActiveControlPlaneInstanceIDs() ([]string, error) {
193+
func (s *captureRuntimeWorkerStore) ListLiveControlPlaneInstanceIDs() ([]string, error) {
194194
s.mu.Lock()
195195
defer s.mu.Unlock()
196-
if s.activeCPIDsErr != nil {
197-
return nil, s.activeCPIDsErr
196+
if s.liveCPIDsErr != nil {
197+
return nil, s.liveCPIDsErr
198198
}
199-
out := make([]string, len(s.activeCPIDs))
200-
copy(out, s.activeCPIDs)
199+
out := make([]string, len(s.liveCPIDs))
200+
copy(out, s.liveCPIDs)
201201
return out, nil
202202
}
203203

@@ -1965,7 +1965,7 @@ func TestCleanupOrphanedWorkers_PreservesLivePeerPods(t *testing.T) {
19651965
deadCPID := "cp-dead:boot-789"
19661966

19671967
store := &captureRuntimeWorkerStore{
1968-
activeCPIDs: []string{"cp-self:boot-123", livePeerID},
1968+
liveCPIDs: []string{"cp-self:boot-123", livePeerID},
19691969
}
19701970
pool.runtimeStore = store
19711971

@@ -2037,7 +2037,7 @@ func TestCleanupOrphanedWorkers_MarksStaleIdleRowsLost(t *testing.T) {
20372037

20382038
stale := time.Now().Add(-2 * time.Hour)
20392039
store := &captureRuntimeWorkerStore{
2040-
activeCPIDs: []string{"cp-self:boot-123"},
2040+
liveCPIDs: []string{"cp-self:boot-123"},
20412041
stateBeforeRows: []configstore.WorkerRecord{
20422042
{
20432043
WorkerID: 1,
@@ -2099,7 +2099,7 @@ func TestCleanupOrphanedWorkers_PreservesInflightSpawnsByLivePeers(t *testing.T)
20992099

21002100
stale := time.Now().Add(-2 * time.Hour)
21012101
store := &captureRuntimeWorkerStore{
2102-
activeCPIDs: []string{"cp-self:boot-123", peerID},
2102+
liveCPIDs: []string{"cp-self:boot-123", peerID},
21032103
stateBeforeRows: []configstore.WorkerRecord{
21042104
// In-flight spawn owned by a live peer: pod isn't visible to us
21052105
// yet (e.g. slow image pull), but the peer is alive and will
@@ -2134,6 +2134,72 @@ func TestCleanupOrphanedWorkers_PreservesInflightSpawnsByLivePeers(t *testing.T)
21342134
}
21352135
}
21362136

2137+
func TestCleanupOrphanedWorkers_PreservesDrainingPeerWorkers(t *testing.T) {
2138+
// A draining CP is mid-graceful-shutdown, still serving in-flight queries
2139+
// on its worker pods. The startup sweep must treat draining CPs as live
2140+
// (state <> 'expired'), otherwise it would delete the worker pods and
2141+
// kill the queries the draining CP is waiting to finish.
2142+
pool, cs := newTestK8sPool(t, 5)
2143+
pool.cpInstanceID = "cp-self:boot-123"
2144+
drainingPeerID := "cp-draining-peer:boot-456"
2145+
2146+
// The mock returns whatever the test sets in liveCPIDs; the production
2147+
// store query is `state <> expired` so callers always get both active
2148+
// and draining CPs in this list.
2149+
store := &captureRuntimeWorkerStore{
2150+
liveCPIDs: []string{"cp-self:boot-123", drainingPeerID},
2151+
stateBeforeRows: []configstore.WorkerRecord{
2152+
// A warm idle row owned (still!) by the draining peer. Even
2153+
// though idle rows have empty owner in the production code,
2154+
// exercise the owner-active path here to make sure draining
2155+
// owners are honored.
2156+
{
2157+
WorkerID: 50,
2158+
PodName: "duckgres-worker-draining-warm",
2159+
State: configstore.WorkerStateIdle,
2160+
OwnerCPInstanceID: drainingPeerID,
2161+
UpdatedAt: time.Now().Add(-2 * time.Hour),
2162+
},
2163+
},
2164+
}
2165+
pool.runtimeStore = store
2166+
2167+
// Worker pod owned by the draining peer — pretend it's serving a
2168+
// long-running query right now.
2169+
_, err := cs.CoreV1().Pods("default").Create(context.Background(), &corev1.Pod{
2170+
ObjectMeta: metav1.ObjectMeta{
2171+
Name: "duckgres-worker-draining-busy",
2172+
Namespace: "default",
2173+
Labels: map[string]string{
2174+
"app": "duckgres-worker",
2175+
"duckgres/cp-instance-id": controlPlaneIDLabelValue(drainingPeerID),
2176+
},
2177+
},
2178+
}, metav1.CreateOptions{})
2179+
if err != nil {
2180+
t.Fatal(err)
2181+
}
2182+
2183+
pool.cleanupOrphanedWorkers()
2184+
2185+
pods, _ := cs.CoreV1().Pods("default").List(context.Background(), metav1.ListOptions{
2186+
LabelSelector: "app=duckgres-worker",
2187+
})
2188+
survived := false
2189+
for _, p := range pods.Items {
2190+
if p.Name == "duckgres-worker-draining-busy" {
2191+
survived = true
2192+
}
2193+
}
2194+
if !survived {
2195+
t.Fatal("draining peer's worker pod was deleted — would kill in-flight queries")
2196+
}
2197+
2198+
if rows := store.snapshot(); len(rows) != 0 {
2199+
t.Fatalf("draining peer's idle row was marked lost: %#v", rows)
2200+
}
2201+
}
2202+
21372203
func TestCleanupOrphanedWorkers_GracePeriodProtectsFreshRows(t *testing.T) {
21382204
// The mock honors the cutoff itself, so passing a fresh updated_at row
21392205
// exercises the grace-period filter end-to-end: the sweep asks for rows
@@ -2143,7 +2209,7 @@ func TestCleanupOrphanedWorkers_GracePeriodProtectsFreshRows(t *testing.T) {
21432209
pool.cpInstanceID = "cp-self:boot-123"
21442210

21452211
store := &captureRuntimeWorkerStore{
2146-
activeCPIDs: []string{"cp-self:boot-123"},
2212+
liveCPIDs: []string{"cp-self:boot-123"},
21472213
stateBeforeRows: []configstore.WorkerRecord{
21482214
{
21492215
WorkerID: 21,

‎controlplane/worker_pool.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ type RuntimeWorkerStore interface {
8181
CreateNeutralWarmWorkerSlot(ownerCPInstanceID, podNamePrefix string, targetWarmWorkers, maxGlobalWorkers int) (*configstore.WorkerRecord, error)
8282
GetWorkerRecord(workerID int) (*configstore.WorkerRecord, error)
8383
TakeOverWorker(workerID int, ownerCPInstanceID, orgID string, expectedOwnerEpoch int64) (*configstore.WorkerRecord, error)
84-
ListActiveControlPlaneInstanceIDs() ([]string, error)
84+
ListLiveControlPlaneInstanceIDs() ([]string, error)
8585
ListWorkerRecordsByStatesBefore(states []configstore.WorkerState, updatedBefore time.Time) ([]configstore.WorkerRecord, error)
8686
}
8787

‎tests/configstore/runtime_store_postgres_test.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,70 @@ func TestExpireControlPlaneInstancesPostgres(t *testing.T) {
277277
}
278278
}
279279

280+
func TestListLiveControlPlaneInstanceIDsPostgres(t *testing.T) {
281+
store := newIsolatedConfigStore(t)
282+
now := time.Date(2026, time.April, 9, 12, 0, 0, 0, time.UTC)
283+
284+
// Active CP — should appear.
285+
if err := store.UpsertControlPlaneInstance(&configstore.ControlPlaneInstance{
286+
ID: "cp-active:boot-a",
287+
PodName: "duckgres-active",
288+
PodUID: "pod-active",
289+
BootID: "boot-a",
290+
State: configstore.ControlPlaneInstanceStateActive,
291+
StartedAt: now.Add(-time.Hour),
292+
LastHeartbeatAt: now,
293+
}); err != nil {
294+
t.Fatalf("UpsertControlPlaneInstance(active): %v", err)
295+
}
296+
// Draining CP — must also appear (still serving in-flight queries).
297+
drainingAt := now.Add(-time.Minute)
298+
if err := store.UpsertControlPlaneInstance(&configstore.ControlPlaneInstance{
299+
ID: "cp-draining:boot-b",
300+
PodName: "duckgres-draining",
301+
PodUID: "pod-draining",
302+
BootID: "boot-b",
303+
State: configstore.ControlPlaneInstanceStateDraining,
304+
StartedAt: now.Add(-time.Hour),
305+
LastHeartbeatAt: now.Add(-30 * time.Second),
306+
DrainingAt: &drainingAt,
307+
}); err != nil {
308+
t.Fatalf("UpsertControlPlaneInstance(draining): %v", err)
309+
}
310+
// Expired CP — must NOT appear.
311+
expiredAt := now.Add(-2 * time.Minute)
312+
if err := store.UpsertControlPlaneInstance(&configstore.ControlPlaneInstance{
313+
ID: "cp-expired:boot-c",
314+
PodName: "duckgres-expired",
315+
PodUID: "pod-expired",
316+
BootID: "boot-c",
317+
State: configstore.ControlPlaneInstanceStateExpired,
318+
StartedAt: now.Add(-time.Hour),
319+
LastHeartbeatAt: now.Add(-5 * time.Minute),
320+
ExpiredAt: &expiredAt,
321+
}); err != nil {
322+
t.Fatalf("UpsertControlPlaneInstance(expired): %v", err)
323+
}
324+
325+
ids, err := store.ListLiveControlPlaneInstanceIDs()
326+
if err != nil {
327+
t.Fatalf("ListLiveControlPlaneInstanceIDs: %v", err)
328+
}
329+
got := map[string]bool{}
330+
for _, id := range ids {
331+
got[id] = true
332+
}
333+
if !got["cp-active:boot-a"] {
334+
t.Error("expected active CP to be listed as live")
335+
}
336+
if !got["cp-draining:boot-b"] {
337+
t.Error("expected draining CP to be listed as live (still serving in-flight queries)")
338+
}
339+
if got["cp-expired:boot-c"] {
340+
t.Error("expected expired CP to NOT be listed as live")
341+
}
342+
}
343+
280344
func TestExpireDrainingControlPlaneInstancesPostgres(t *testing.T) {
281345
store := newIsolatedConfigStore(t)
282346

0 commit comments

Comments
 (0)