fix(gateway): stop Qdrant upsert aborts (semantic cache was being skipped)

Qdrant upserts were failing with 'This operation was aborted' ~32x/2h,
so semantic moderation cache entries were silently dropped. Root cause:
upsertQdrantPoint ran ensureQdrantCollection() on EVERY call — a GET
(and sometimes DELETE+PUT) round-trip — while the request AbortController
had only a 10s timeout. Under moderation load Qdrant is busy (the
gmw_text_moderation collection is not yet HNSW-indexed, so searches are
full-scans), the extra round-trips pushed the upsert past 10s, and the
client aborted it.

- Memoise ensureQdrantCollection() at module scope so the collection is
  verified exactly once per process (resetQdrantCollectionCache() for
  tests / config reload).
- Bump the upsert request timeout 10s -> 30s so a transiently busy
  Qdrant no longer aborts the write.

Qdrant server itself is healthy (<100ms for direct upsert; collection is
green), so no server-side change is needed. Semantic cache should now
populate reliably.
This commit is contained in:
asepharyana
2026-08-15 23:40:19 +07:00
parent 416c690ebc
commit 2d7c7f2c35
@@ -17,6 +17,18 @@ import { config } from "../../shared/config/config.js";
const log = createChildLogger("qdrant"); const log = createChildLogger("qdrant");
// ensureQdrantCollection performs a network round-trip (GET, possibly
// DELETE+PUT). Running it on every upsert adds 1-3 HTTP calls per
// moderation verdict, which under Qdrant load pushes the upsert past the
// request timeout and aborts it ("This operation was aborted"). Memoise the
// result so the collection is only verified once per process lifetime.
let ensureCollectionPromise: Promise<boolean> | null = null;
/** Reset the memoised ensure result (used by tests / config reload). */
export function resetQdrantCollectionCache(): void {
ensureCollectionPromise = null;
}
export interface QdrantVerdictPayload { export interface QdrantVerdictPayload {
text: string; text: string;
flags: string; // JSON string of the full moderation result flags: string; // JSON string of the full moderation result
@@ -97,13 +109,18 @@ export function qdrantPointId(cacheKey: string): number {
export async function ensureQdrantCollection( export async function ensureQdrantCollection(
vectorSize: number, vectorSize: number,
): Promise<boolean> { ): Promise<boolean> {
if (ensureCollectionPromise) return ensureCollectionPromise;
ensureCollectionPromise = (async () => {
try { try {
// 404 = collection doesn't exist yet → create it. // 404 = collection doesn't exist yet → create it.
let existing: { let existing: {
result?: { config?: { params?: { vectors?: { size?: number } } } }; result?: { config?: { params?: { vectors?: { size?: number } } } };
} | null = null; } | null = null;
try { try {
existing = (await request("GET", `/collections/${collectionName()}`)) as { existing = (await request(
"GET",
`/collections/${collectionName()}`,
)) as {
result?: { config?: { params?: { vectors?: { size?: number } } } }; result?: { config?: { params?: { vectors?: { size?: number } } } };
}; };
} catch (error) { } catch (error) {
@@ -137,6 +154,8 @@ export async function ensureQdrantCollection(
); );
return false; return false;
} }
})();
return ensureCollectionPromise;
} }
/** Upsert one embedding + verdict payload point. Returns false on failure. */ /** Upsert one embedding + verdict payload point. Returns false on failure. */
@@ -147,10 +166,15 @@ export async function upsertQdrantPoint(
): Promise<boolean> { ): Promise<boolean> {
try { try {
if (!(await ensureQdrantCollection(vector.length))) return false; if (!(await ensureQdrantCollection(vector.length))) return false;
await request("PUT", `/collections/${collectionName()}/points`, { await request(
"PUT",
`/collections/${collectionName()}/points`,
{
points: [{ id: qdrantPointId(cacheKey), vector, payload }], points: [{ id: qdrantPointId(cacheKey), vector, payload }],
wait: true, wait: true,
}); },
30_000,
);
return true; return true;
} catch (error) { } catch (error) {
log.warn( log.warn(