Skip to content

Commit 366cc54

Browse files
committed
Verify CP pod existence during startup orphan sweep
When a CP pod is force-deleted (crash, preemption, node drain), the cp_instances row stays in 'active' state until the janitor expires the heartbeat (~20s) and the orphan grace period passes (~30s). If the deployment recreates the CP pod before that, the startup sweep sees the dead CP as "live" in the DB and leaves its orphaned worker pods alone. This caused TestK8sCPDeletionGarbageCollects to fail: the test force- deletes a CP pod and expects its workers to be cleaned up within 90s, but the startup sweep was deferring to the janitor path which is slower. The fix: for each "live" CP returned by ListLiveControlPlaneInstanceIDs, extract the pod name from the instance ID (format "pod-name:boot-id") and verify the pod actually exists in K8s. If it's gone, treat the CP as dead for the purpose of the sweep. This is ground truth — the DB may lag behind K8s, but K8s is authoritative on pod existence. Self is always skipped (the current CP is explicitly added to the live set afterward), so this only checks peers. Also fixes the existing peer-preservation tests to create CP pods for peers that should be considered alive, now that the code verifies pod existence.
1 parent 8553958 commit 366cc54

2 files changed

Lines changed: 84 additions & 1 deletion

File tree

‎controlplane/k8s_pool.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,19 @@ func (p *K8sWorkerPool) cleanupOrphanedWorkers() {
233233
return
234234
}
235235
for _, id := range ids {
236+
// The DB may lag behind K8s: a CP marked "active" in the DB
237+
// could have been force-deleted (crash, preemption) and the
238+
// janitor hasn't expired it yet. Verify the CP's pod actually
239+
// exists before trusting the DB. The instance ID format is
240+
// "pod-name:boot-id" — extract the pod name and check K8s.
241+
podName := strings.SplitN(id, ":", 2)[0]
242+
if podName != "" && id != p.cpInstanceID && podName != p.cpID {
243+
_, getErr := p.clientset.CoreV1().Pods(p.namespace).Get(ctx, podName, metav1.GetOptions{})
244+
if errors.IsNotFound(getErr) {
245+
slog.Info("Live CP in DB but pod is gone; treating as dead for orphan sweep.", "cp", id, "pod", podName)
246+
continue
247+
}
248+
}
236249
liveCPIDs[id] = true
237250
liveCPLabels[controlPlaneIDLabelValue(id)] = true
238251
}

‎controlplane/k8s_pool_test.go‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1958,6 +1958,61 @@ func TestCleanupOrphanedWorkers_DeletesPodsFromDeadCPs(t *testing.T) {
19581958
}
19591959
}
19601960

1961+
func TestCleanupOrphanedWorkers_DeletesPodsFromCrashedLivePeer(t *testing.T) {
1962+
// A CP that is still "active" in the config store but whose pod was
1963+
// force-deleted (crash, preemption) should be treated as dead by the
1964+
// startup sweep. Without this, the worker pods from the crashed CP
1965+
// would linger until the janitor expires the heartbeat (~50s), failing
1966+
// the TestK8sCPDeletionGarbageCollects integration test.
1967+
pool, cs := newTestK8sPool(t, 5)
1968+
pool.cpInstanceID = "cp-self:boot-123"
1969+
crashedPeerID := "cp-crashed:boot-456"
1970+
1971+
store := &captureRuntimeWorkerStore{
1972+
// DB says cp-crashed is live (janitor hasn't expired it yet)
1973+
liveCPIDs: []string{"cp-self:boot-123", crashedPeerID},
1974+
}
1975+
pool.runtimeStore = store
1976+
1977+
// Create the self CP pod (must exist) but NOT the crashed peer's pod.
1978+
_, err := cs.CoreV1().Pods("default").Create(context.Background(), &corev1.Pod{
1979+
ObjectMeta: metav1.ObjectMeta{
1980+
Name: "cp-self",
1981+
Namespace: "default",
1982+
},
1983+
}, metav1.CreateOptions{})
1984+
if err != nil {
1985+
t.Fatal(err)
1986+
}
1987+
// Note: no "cp-crashed" pod created — simulates force-delete.
1988+
1989+
// Worker pod from the crashed peer.
1990+
_, err = cs.CoreV1().Pods("default").Create(context.Background(), &corev1.Pod{
1991+
ObjectMeta: metav1.ObjectMeta{
1992+
Name: "duckgres-worker-crashed-peer",
1993+
Namespace: "default",
1994+
Labels: map[string]string{
1995+
"app": "duckgres-worker",
1996+
"duckgres/cp-instance-id": controlPlaneIDLabelValue(crashedPeerID),
1997+
},
1998+
},
1999+
}, metav1.CreateOptions{})
2000+
if err != nil {
2001+
t.Fatal(err)
2002+
}
2003+
2004+
pool.cleanupOrphanedWorkers()
2005+
2006+
pods, _ := cs.CoreV1().Pods("default").List(context.Background(), metav1.ListOptions{
2007+
LabelSelector: "app=duckgres-worker",
2008+
})
2009+
for _, p := range pods.Items {
2010+
if p.Name == "duckgres-worker-crashed-peer" {
2011+
t.Error("expected crashed peer's worker pod to be deleted — CP pod is gone even though DB says 'active'")
2012+
}
2013+
}
2014+
}
2015+
19612016
func TestCleanupOrphanedWorkers_PreservesLivePeerPods(t *testing.T) {
19622017
pool, cs := newTestK8sPool(t, 5)
19632018
pool.cpInstanceID = "cp-self:boot-123"
@@ -1969,6 +2024,11 @@ func TestCleanupOrphanedWorkers_PreservesLivePeerPods(t *testing.T) {
19692024
}
19702025
pool.runtimeStore = store
19712026

2027+
// Create the live peer's CP pod so the pod-existence check passes.
2028+
_, _ = cs.CoreV1().Pods("default").Create(context.Background(), &corev1.Pod{
2029+
ObjectMeta: metav1.ObjectMeta{Name: "cp-peer", Namespace: "default"},
2030+
}, metav1.CreateOptions{})
2031+
19722032
for _, tc := range []struct {
19732033
name string
19742034
instanceID string
@@ -2093,10 +2153,15 @@ func TestCleanupOrphanedWorkers_MarksStaleIdleRowsLost(t *testing.T) {
20932153
}
20942154

20952155
func TestCleanupOrphanedWorkers_PreservesInflightSpawnsByLivePeers(t *testing.T) {
2096-
pool, _ := newTestK8sPool(t, 5)
2156+
pool, cs := newTestK8sPool(t, 5)
20972157
pool.cpInstanceID = "cp-self:boot-123"
20982158
peerID := "cp-peer:boot-456"
20992159

2160+
// Create the live peer's CP pod so the pod-existence check passes.
2161+
_, _ = cs.CoreV1().Pods("default").Create(context.Background(), &corev1.Pod{
2162+
ObjectMeta: metav1.ObjectMeta{Name: "cp-peer", Namespace: "default"},
2163+
}, metav1.CreateOptions{})
2164+
21002165
stale := time.Now().Add(-2 * time.Hour)
21012166
store := &captureRuntimeWorkerStore{
21022167
liveCPIDs: []string{"cp-self:boot-123", peerID},
@@ -2143,6 +2208,11 @@ func TestCleanupOrphanedWorkers_PreservesDrainingPeerWorkers(t *testing.T) {
21432208
pool.cpInstanceID = "cp-self:boot-123"
21442209
drainingPeerID := "cp-draining-peer:boot-456"
21452210

2211+
// Create the draining peer's CP pod — it's still running (draining, not dead).
2212+
_, _ = cs.CoreV1().Pods("default").Create(context.Background(), &corev1.Pod{
2213+
ObjectMeta: metav1.ObjectMeta{Name: "cp-draining-peer", Namespace: "default"},
2214+
}, metav1.CreateOptions{})
2215+
21462216
// The mock returns whatever the test sets in liveCPIDs; the production
21472217
// store query is `state <> expired` so callers always get both active
21482218
// and draining CPs in this list.

0 commit comments

Comments
 (0)