Skip to content

Commit 2e5f1d5

Browse files
committed
fix: preserve runtime wake across pod deletion
1 parent 0b8d855 commit 2e5f1d5

2 files changed

Lines changed: 85 additions & 26 deletions

File tree

‎apps/api/src/services/kubernetes-pods.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,62 @@ describe("idempotent Kubernetes pod creation", () => {
5151
).resolves.toBe("created");
5252
expect(api.createNamespacedPod).toHaveBeenCalledTimes(2);
5353
});
54+
55+
it("retries when creation conflicts again after the old pod disappears", async () => {
56+
const api = {
57+
createNamespacedPod: jest
58+
.fn()
59+
.mockRejectedValueOnce(statusError(409))
60+
.mockRejectedValueOnce(statusError(409))
61+
.mockResolvedValueOnce({}),
62+
readNamespacedPod: jest
63+
.fn()
64+
.mockResolvedValueOnce({
65+
metadata: {
66+
name: "computer-one",
67+
deletionTimestamp: "2026-07-11T00:00:00Z",
68+
},
69+
})
70+
.mockRejectedValueOnce(statusError(404))
71+
.mockRejectedValueOnce(statusError(404)),
72+
};
73+
74+
await expect(
75+
createKubernetesPodIdempotently(
76+
api,
77+
"tenant-one",
78+
{ metadata: { name: "computer-one" } },
79+
{ deletionTimeoutMs: 100, pollIntervalMs: 0 },
80+
),
81+
).resolves.toBe("created");
82+
expect(api.createNamespacedPod).toHaveBeenCalledTimes(3);
83+
});
84+
85+
it("accepts a concurrently-created replacement after deletion", async () => {
86+
const api = {
87+
createNamespacedPod: jest
88+
.fn()
89+
.mockRejectedValueOnce(statusError(409))
90+
.mockRejectedValueOnce(statusError(409)),
91+
readNamespacedPod: jest
92+
.fn()
93+
.mockResolvedValueOnce({
94+
metadata: {
95+
name: "computer-one",
96+
deletionTimestamp: "2026-07-11T00:00:00Z",
97+
},
98+
})
99+
.mockRejectedValueOnce(statusError(404))
100+
.mockResolvedValueOnce({ metadata: { name: "computer-one" } }),
101+
};
102+
103+
await expect(
104+
createKubernetesPodIdempotently(
105+
api,
106+
"tenant-one",
107+
{ metadata: { name: "computer-one" } },
108+
{ deletionTimeoutMs: 100, pollIntervalMs: 0 },
109+
),
110+
).resolves.toBe("existing");
111+
});
54112
});

‎apps/api/src/services/kubernetes-pods.ts‎

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,40 +48,41 @@ export async function createKubernetesPodIdempotently<TPod extends PodLike>(
4848
const name = body.metadata?.name;
4949
if (!name) throw new Error("Kubernetes pod metadata.name is required");
5050

51-
try {
52-
await coreApi.createNamespacedPod({ namespace, body });
53-
return "created";
54-
} catch (error) {
55-
if (kubernetesStatusCode(error) !== 409) throw error;
56-
}
51+
const timeoutMs = options.deletionTimeoutMs ?? 180_000;
52+
const intervalMs = options.pollIntervalMs ?? 250;
53+
const deadline = Date.now() + timeoutMs;
5754

58-
let current: PodLike;
59-
try {
60-
current = responsePod(
61-
await coreApi.readNamespacedPod({ namespace, name }),
62-
);
63-
} catch (error) {
64-
if (kubernetesStatusCode(error) === 404) {
55+
while (Date.now() < deadline) {
56+
try {
6557
await coreApi.createNamespacedPod({ namespace, body });
6658
return "created";
59+
} catch (error) {
60+
if (kubernetesStatusCode(error) !== 409) throw error;
6761
}
68-
throw error;
69-
}
70-
if (!current.metadata?.deletionTimestamp) return "existing";
7162

72-
const timeoutMs = options.deletionTimeoutMs ?? 45_000;
73-
const intervalMs = options.pollIntervalMs ?? 250;
74-
const deadline = Date.now() + timeoutMs;
75-
while (Date.now() < deadline) {
76-
await wait(intervalMs);
63+
let current: PodLike;
7764
try {
78-
await coreApi.readNamespacedPod({ namespace, name });
65+
current = responsePod(
66+
await coreApi.readNamespacedPod({ namespace, name }),
67+
);
7968
} catch (error) {
80-
if (kubernetesStatusCode(error) === 404) {
81-
await coreApi.createNamespacedPod({ namespace, body });
82-
return "created";
69+
if (kubernetesStatusCode(error) !== 404) throw error;
70+
await wait(intervalMs);
71+
continue;
72+
}
73+
if (!current.metadata?.deletionTimestamp) return "existing";
74+
75+
while (Date.now() < deadline) {
76+
await wait(intervalMs);
77+
try {
78+
current = responsePod(
79+
await coreApi.readNamespacedPod({ namespace, name }),
80+
);
81+
if (!current.metadata?.deletionTimestamp) return "existing";
82+
} catch (error) {
83+
if (kubernetesStatusCode(error) === 404) break;
84+
throw error;
8385
}
84-
throw error;
8586
}
8687
}
8788

0 commit comments

Comments
 (0)