Skip to content

Commit cb67c64

Browse files
authored
Merge pull request #7106 from shin-core/fix/qdrant-vectorize-fetch-timeout-7072
fix(selfhost): bound every Qdrant REST call with an AbortSignal timeout
2 parents fc6d689 + c97e567 commit cb67c64

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

src/selfhost/qdrant-vectorize.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import type {
2828

2929
const DEFAULT_COLLECTION = "loopover";
3030
const DEFAULT_DIM = 1024; // bge-m3 / mxbai-embed-large (1024-d); set QDRANT_DIM to override
31+
// Per-request ceiling for every Qdrant REST call (#7072). This is a self-host Node.js adapter with no
32+
// platform-level subrequest limit, so an unresponsive/partitioned Qdrant instance would otherwise hang these
33+
// calls indefinitely -- matching the bounded-fetch convention of the other self-host adapters (e.g. ai.ts).
34+
const QDRANT_FETCH_TIMEOUT_MS = 15_000;
3135

3236
interface QdrantSearchResult {
3337
result: Array<{ id: string; score: number; payload: Record<string, unknown> }>;
@@ -73,12 +77,16 @@ export async function initQdrantCollection(
7377
method: "PUT",
7478
headers: qdrantHeaders(),
7579
body: JSON.stringify({ vectors: { size: dim, distance: "Cosine" } }),
80+
signal: AbortSignal.timeout(QDRANT_FETCH_TIMEOUT_MS),
7681
});
7782
if (!res.ok && res.status !== 409) {
7883
throw new Error(`Qdrant collection init failed: HTTP ${res.status}`);
7984
}
8085
if (res.status === 409) {
81-
const existing = await fetch(`${base}/collections/${collection}`, { headers: qdrantHeaders() });
86+
const existing = await fetch(`${base}/collections/${collection}`, {
87+
headers: qdrantHeaders(),
88+
signal: AbortSignal.timeout(QDRANT_FETCH_TIMEOUT_MS),
89+
});
8290
if (!existing.ok) throw new Error(`Qdrant collection lookup failed: HTTP ${existing.status}`);
8391
const info = (await existing.json()) as QdrantCollectionInfo;
8492
const existingDim = info.result.config.params.vectors.size;
@@ -103,6 +111,7 @@ export function createQdrantVectorize(url: string, collection = DEFAULT_COLLECTI
103111
method: "PUT",
104112
headers: qdrantHeaders(),
105113
body: JSON.stringify({ points }),
114+
signal: AbortSignal.timeout(QDRANT_FETCH_TIMEOUT_MS),
106115
});
107116
if (!res.ok) {
108117
incr("loopover_qdrant_errors_total", { op: "upsert" });
@@ -123,6 +132,7 @@ export function createQdrantVectorize(url: string, collection = DEFAULT_COLLECTI
123132
method: "POST",
124133
headers: qdrantHeaders(),
125134
body: JSON.stringify(body),
135+
signal: AbortSignal.timeout(QDRANT_FETCH_TIMEOUT_MS),
126136
});
127137
} catch {
128138
// Qdrant unreachable — degrade gracefully (RAG returns no context rather than crashing)
@@ -150,6 +160,7 @@ export function createQdrantVectorize(url: string, collection = DEFAULT_COLLECTI
150160
method: "POST",
151161
headers: qdrantHeaders(),
152162
body: JSON.stringify({ points }),
163+
signal: AbortSignal.timeout(QDRANT_FETCH_TIMEOUT_MS),
153164
});
154165
if (!res.ok) {
155166
incr("loopover_qdrant_errors_total", { op: "delete" });

test/unit/selfhost-qdrant-vectorize.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,52 @@ describe("createQdrantVectorize (#1217 Qdrant adapter)", () => {
302302
expect(url).not.toContain("//collections");
303303
});
304304
});
305+
306+
describe("Qdrant REST calls are bounded by an AbortSignal timeout (#7072)", () => {
307+
afterEach(() => {
308+
vi.unstubAllGlobals();
309+
});
310+
311+
function assertEveryFetchTimed(fake: ReturnType<typeof mockFetch>) {
312+
expect(fake.mock.calls.length).toBeGreaterThan(0);
313+
for (const call of fake.mock.calls) {
314+
const init = (call as unknown as [string, RequestInit | undefined])[1];
315+
expect(init?.signal).toBeInstanceOf(AbortSignal);
316+
}
317+
}
318+
319+
it("bounds initQdrantCollection's PUT (fresh create) and its GET dimension-check (pre-existing collection)", async () => {
320+
// Fresh create: the PUT returns 200 and no GET follows.
321+
const created = mockFetch(200);
322+
vi.stubGlobal("fetch", created);
323+
await initQdrantCollection(BASE);
324+
assertEveryFetchTimed(created);
325+
326+
// Pre-existing collection: the PUT 409s, so the GET dimension-check runs too -- both must be timed.
327+
const existing = vi
328+
.fn()
329+
.mockResolvedValueOnce(new Response("conflict", { status: 409 }))
330+
.mockResolvedValueOnce(new Response(JSON.stringify(collectionInfo(1024)), { status: 200 }));
331+
vi.stubGlobal("fetch", existing);
332+
await initQdrantCollection(BASE);
333+
assertEveryFetchTimed(existing as unknown as ReturnType<typeof mockFetch>);
334+
expect(existing).toHaveBeenCalledTimes(2);
335+
});
336+
337+
it("bounds upsert, query, and deleteByIds", async () => {
338+
const upsertFake = mockFetch(200, { status: "ok" });
339+
vi.stubGlobal("fetch", upsertFake);
340+
await createQdrantVectorize(BASE).upsert([{ id: "r/f:1", values: [0.1, 0.2] }]);
341+
assertEveryFetchTimed(upsertFake);
342+
343+
const queryFake = mockFetch(200, { result: [] });
344+
vi.stubGlobal("fetch", queryFake);
345+
await createQdrantVectorize(BASE).query([0.5, 0.5], { topK: 5 });
346+
assertEveryFetchTimed(queryFake);
347+
348+
const deleteFake = mockFetch(200, { status: "ok" });
349+
vi.stubGlobal("fetch", deleteFake);
350+
await createQdrantVectorize(BASE).deleteByIds(["r/f:1"]);
351+
assertEveryFetchTimed(deleteFake);
352+
});
353+
});

0 commit comments

Comments
 (0)