fix(gateway): local infra defaults + qdrant collection retry-on-failure
- AI_LLM_BASE_URL default -> http://127.0.0.1:4014/v1 (was imrnes :20128/api/v1) - QDRANT_URL fallback -> http://127.0.0.1:6333 (was imrnes :6333) - ensureQdrantCollection: reset memoised promise on failure so a mid-way recreate abort (DELETE done, PUT failed) does not leave the collection permanently missing until process restart - tests: qdrantEnsure.test.ts (3 cases: retry-on-failure, recreate, idempotent)
This commit is contained in:
@@ -50,10 +50,7 @@ export interface QdrantVerdictPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function baseUrl(): string {
|
function baseUrl(): string {
|
||||||
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
|
return (config.QDRANT_URL ?? "http://127.0.0.1:6333").replace(/\/+$/, "");
|
||||||
/\/+$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectionName(): string {
|
function collectionName(): string {
|
||||||
@@ -166,6 +163,11 @@ export async function ensureQdrantCollection(
|
|||||||
},
|
},
|
||||||
"Failed to ensure Qdrant collection",
|
"Failed to ensure Qdrant collection",
|
||||||
);
|
);
|
||||||
|
// The memoised promise is permanently sticky: once it rejects (e.g. a
|
||||||
|
// recreate aborted mid-way — DELETE done, PUT failed), every later call
|
||||||
|
// returns the same rejected promise and the collection is never
|
||||||
|
// re-created until process restart. Reset so the next call retries.
|
||||||
|
ensureCollectionPromise = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -151,10 +151,7 @@ export const configSchema = z
|
|||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default(false),
|
.default(false),
|
||||||
AI_LLM_API_KEY: z.string().optional(),
|
AI_LLM_API_KEY: z.string().optional(),
|
||||||
AI_LLM_BASE_URL: z
|
AI_LLM_BASE_URL: z.string().url().default("http://127.0.0.1:4014/v1"),
|
||||||
.string()
|
|
||||||
.url()
|
|
||||||
.default("http://100.121.180.82:20128/api/v1"),
|
|
||||||
AI_LLM_MODEL: z.string().default("text"),
|
AI_LLM_MODEL: z.string().default("text"),
|
||||||
// Vision uses the SAME router/base URL as text moderation
|
// Vision uses the SAME router/base URL as text moderation
|
||||||
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// The qdrant client reads config at import time — we only need the
|
||||||
|
// reset-on-failure behavior, so mock global fetch and import fresh.
|
||||||
|
import {
|
||||||
|
ensureQdrantCollection,
|
||||||
|
resetQdrantCollectionCache,
|
||||||
|
} from "../src/modules/ai-moderation/qdrantClient.js";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ensureQdrantCollection retry-on-failure", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetQdrantCollectionCache();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetQdrantCollectionCache();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on failure and does NOT get stuck — retries on next call", async () => {
|
||||||
|
// First call: GET collection fails hard (not a 404) → ensure rejects.
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockRejectedValueOnce(new Error("network down"))
|
||||||
|
// Second call: GET returns a matching-size collection → success.
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
result: { config: { params: { vectors: { size: 3072 } } } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await ensureQdrantCollection(3072);
|
||||||
|
expect(first).toBe(false);
|
||||||
|
|
||||||
|
// Without the fix, the second call returns the memoised rejected promise
|
||||||
|
// and fetch is never called again. With the fix, it retries.
|
||||||
|
const second = await ensureQdrantCollection(3072);
|
||||||
|
expect(second).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreates collection when vector size changes (DELETE + PUT)", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
result: { config: { params: { vectors: { size: 2048 } } } },
|
||||||
|
}),
|
||||||
|
) // GET old size
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ result: true })) // DELETE
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ result: true })); // PUT
|
||||||
|
|
||||||
|
const ok = await ensureQdrantCollection(3072);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
|
||||||
|
const methods = fetchMock.mock.calls.map(
|
||||||
|
(c) => (c[1] as RequestInit).method,
|
||||||
|
);
|
||||||
|
expect(methods).toEqual(["GET", "DELETE", "PUT"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when the collection already matches", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
result: { config: { params: { vectors: { size: 3072 } } } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ok = await ensureQdrantCollection(3072);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user