diff --git a/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts b/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts index 018b7de7..7a8dda0b 100644 --- a/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts @@ -50,10 +50,7 @@ export interface QdrantVerdictPayload { } 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 { @@ -166,6 +163,11 @@ export async function ensureQdrantCollection( }, "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; } })(); diff --git a/services/discord-gateway/src/shared/config/index.ts b/services/discord-gateway/src/shared/config/index.ts index 74eac44f..dd820db1 100644 --- a/services/discord-gateway/src/shared/config/index.ts +++ b/services/discord-gateway/src/shared/config/index.ts @@ -151,10 +151,7 @@ export const configSchema = z .transform((v) => v === "true") .default(false), AI_LLM_API_KEY: z.string().optional(), - AI_LLM_BASE_URL: z - .string() - .url() - .default("http://100.121.180.82:20128/api/v1"), + AI_LLM_BASE_URL: z.string().url().default("http://127.0.0.1:4014/v1"), AI_LLM_MODEL: z.string().default("text"), // Vision uses the SAME router/base URL as text moderation // (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA diff --git a/services/discord-gateway/tests/qdrantEnsure.test.ts b/services/discord-gateway/tests/qdrantEnsure.test.ts new file mode 100644 index 00000000..808435f9 --- /dev/null +++ b/services/discord-gateway/tests/qdrantEnsure.test.ts @@ -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); + }); +});