Skip to content

Commit 45b05f1

Browse files
committed
test(supervisor): test the pod-count logic as pure functions, drop the fake client
1 parent ae5cd5b commit 45b05f1

2 files changed

Lines changed: 66 additions & 50 deletions

File tree

apps/supervisor/src/backpressure/k8sPodCountSignalSource.test.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,41 @@
11
import { describe, it, expect } from "vitest";
22
import { K8sPodCountSignalSource } from "./k8sPodCountSignalSource.js";
3-
import { createPodCountFetcher } from "../clients/kubernetes.js";
4-
import type { K8sApi } from "../clients/kubernetes.js";
3+
import { podCountFromList, withTimeout } from "../clients/kubernetes.js";
54

6-
function apiReturning(list: unknown): K8sApi {
7-
return { core: { listNamespacedPod: async () => list } } as unknown as K8sApi;
8-
}
9-
10-
describe("createPodCountFetcher", () => {
11-
it("returns items.length when the list is not truncated", async () => {
12-
const fetch = createPodCountFetcher(
13-
apiReturning({ items: [{}], metadata: {} }),
14-
"v4-runs",
15-
1000
16-
);
17-
expect(await fetch()).toBe(1);
5+
describe("podCountFromList", () => {
6+
it("returns items.length when the list is not truncated", () => {
7+
expect(podCountFromList({ items: [{}], metadata: {} })).toBe(1);
188
});
199

20-
it("returns zero for an empty namespace", async () => {
21-
const fetch = createPodCountFetcher(apiReturning({ items: [], metadata: {} }), "v4-runs", 1000);
22-
expect(await fetch()).toBe(0);
10+
it("returns zero for an empty namespace", () => {
11+
expect(podCountFromList({ items: [], metadata: {} })).toBe(0);
2312
});
2413

25-
it("adds remainingItemCount when the list is truncated", async () => {
14+
it("adds remainingItemCount when the list is truncated", () => {
2615
const list = { items: [{}], metadata: { _continue: "tok", remainingItemCount: 24492 } };
27-
const fetch = createPodCountFetcher(apiReturning(list), "v4-runs", 1000);
28-
expect(await fetch()).toBe(24493);
16+
expect(podCountFromList(list)).toBe(24493);
2917
});
3018

31-
it("throws when truncated but remainingItemCount is absent", async () => {
19+
it("throws when truncated but remainingItemCount is absent", () => {
3220
const list = { items: [{}], metadata: { _continue: "tok" } };
33-
const fetch = createPodCountFetcher(apiReturning(list), "v4-runs", 1000);
34-
await expect(fetch()).rejects.toThrow(/remainingItemCount/);
21+
expect(() => podCountFromList(list)).toThrow(/remainingItemCount/);
3522
});
3623

37-
it("throws when truncated but remainingItemCount is negative", async () => {
24+
it("throws when truncated but remainingItemCount is negative", () => {
3825
const list = { items: [{}], metadata: { _continue: "tok", remainingItemCount: -1 } };
39-
const fetch = createPodCountFetcher(apiReturning(list), "v4-runs", 1000);
40-
await expect(fetch()).rejects.toThrow(/remainingItemCount/);
26+
expect(() => podCountFromList(list)).toThrow(/remainingItemCount/);
27+
});
28+
});
29+
30+
describe("withTimeout", () => {
31+
it("rejects once the deadline passes", async () => {
32+
await expect(withTimeout(new Promise(() => {}), 10, "pod count list")).rejects.toThrow(
33+
/timed out/
34+
);
4135
});
4236

43-
it("rejects when the list hangs past the timeout", async () => {
44-
const api = { core: { listNamespacedPod: () => new Promise(() => {}) } } as unknown as K8sApi;
45-
await expect(createPodCountFetcher(api, "v4-runs", 10)()).rejects.toThrow(/timed out/);
37+
it("passes a value through when it settles first", async () => {
38+
await expect(withTimeout(Promise.resolve(7), 1000, "pod count list")).resolves.toBe(7);
4639
});
4740
});
4841

apps/supervisor/src/clients/kubernetes.ts

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,38 +70,61 @@ export function createPodCountFetcher(
7070
timeoutMs: number
7171
): () => Promise<number> {
7272
const serverTimeoutSeconds = Math.max(1, Math.floor(timeoutMs / 1000));
73+
let pending: Promise<unknown> | undefined;
7374

7475
return async () => {
75-
const list = await withTimeout(
76-
api.core.listNamespacedPod({
77-
namespace,
78-
limit: 1,
79-
timeoutSeconds: serverTimeoutSeconds,
80-
}),
81-
timeoutMs,
82-
"pod count list"
83-
);
84-
85-
if (!list.metadata?._continue) {
86-
return list.items.length; // not truncated, so this is all of them
76+
if (pending) {
77+
throw new Error("pod count list still in flight from a previous tick");
8778
}
8879

89-
const remaining = list.metadata.remainingItemCount;
90-
if (typeof remaining !== "number" || !Number.isFinite(remaining) || remaining < 0) {
91-
throw new Error("pod list truncated but remainingItemCount absent or invalid");
92-
}
80+
const request = api.core.listNamespacedPod({
81+
namespace,
82+
limit: 1,
83+
timeoutSeconds: serverTimeoutSeconds,
84+
});
85+
86+
pending = request
87+
.catch(() => {})
88+
.finally(() => {
89+
pending = undefined;
90+
});
9391

94-
return list.items.length + remaining;
92+
return podCountFromList(await withTimeout(request, timeoutMs, "pod count list"));
9593
};
9694
}
9795

96+
/**
97+
* podCountFromList turns a `limit=1` pod list into a population.
98+
*
99+
* `remainingItemCount` is only set when the list is truncated, so `_continue` is the
100+
* truncation signal: absent means the returned page is the whole collection and its
101+
* length is already the answer. Truncated without a usable estimate is unknowable, so
102+
* it throws rather than guessing a low number the brake would act on.
103+
*/
104+
export function podCountFromList(list: {
105+
items: unknown[];
106+
metadata?: { _continue?: string; remainingItemCount?: number };
107+
}): number {
108+
if (!list.metadata?._continue) {
109+
return list.items.length;
110+
}
111+
112+
const remaining = list.metadata.remainingItemCount;
113+
if (typeof remaining !== "number" || !Number.isFinite(remaining) || remaining < 0) {
114+
throw new Error("pod list truncated but remainingItemCount absent or invalid");
115+
}
116+
117+
return list.items.length + remaining;
118+
}
119+
98120
/**
99121
* withTimeout rejects if `promise` outlives `timeoutMs`, so a hung request cannot
100-
* freeze the caller. A backstop only: the k8s client threads no AbortSignal through
101-
* to fetch, so callers must also bound the request server-side (`timeoutSeconds`),
102-
* or an abandoned request would stay open. The timer is cleared either way.
122+
* freeze the caller. It cannot cancel: the k8s client threads no AbortSignal through to
123+
* fetch, so an abandoned request keeps running. Callers must therefore also bound the
124+
* request server-side (`timeoutSeconds`) and refuse to start a second one while the
125+
* first is pending, or a blackholed connection accumulates one socket per attempt.
103126
*/
104-
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, what: string): Promise<T> {
127+
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, what: string): Promise<T> {
105128
let timer: NodeJS.Timeout;
106129
const deadline = new Promise<never>((_resolve, reject) => {
107130
timer = setTimeout(

0 commit comments

Comments
 (0)