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 {
|
||||
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;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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