532 lines
19 KiB
TypeScript
532 lines
19 KiB
TypeScript
/// <reference types="jest" />
|
|
/**
|
|
* End-to-end tests for Qdrant dimension mismatch fix.
|
|
*
|
|
* Requires a running Qdrant instance at localhost:6333 (v1.13.x).
|
|
* These tests replicate the exact scenarios from issues #4212, #4173, #4056.
|
|
*
|
|
* Skipped automatically when Qdrant is not available.
|
|
*
|
|
* Run: npx jest --config jest.config.js src/oss/tests/qdrant-e2e.test.ts --forceExit
|
|
*/
|
|
|
|
import { QdrantClient } from "@qdrant/js-client-rest";
|
|
import { Qdrant } from "../src/vector_stores/qdrant";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
jest.setTimeout(30000);
|
|
|
|
const QDRANT_HOST = "localhost";
|
|
const QDRANT_PORT = 6333;
|
|
|
|
// Check if Qdrant is reachable synchronously at load time using
|
|
// a sync check via child_process so describe.skip works correctly.
|
|
function isQdrantAvailable(): boolean {
|
|
try {
|
|
const { execSync } = require("child_process");
|
|
execSync(
|
|
`node -e "const s=require('net').createConnection({host:'${QDRANT_HOST}',port:${QDRANT_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
|
|
{ timeout: 3000, stdio: "ignore" },
|
|
);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const qdrantAvailable = isQdrantAvailable();
|
|
if (!qdrantAvailable) {
|
|
console.warn("Qdrant not available at localhost:6333 — skipping e2e tests");
|
|
}
|
|
|
|
let qdrantClient: QdrantClient;
|
|
|
|
beforeAll(async () => {
|
|
if (!qdrantAvailable) return;
|
|
qdrantClient = new QdrantClient({ host: QDRANT_HOST, port: QDRANT_PORT });
|
|
const collections = await qdrantClient.getCollections();
|
|
expect(collections).toBeDefined();
|
|
});
|
|
|
|
// Helper: delete a collection if it exists
|
|
async function deleteCollectionIfExists(name: string) {
|
|
try {
|
|
await qdrantClient.deleteCollection(name);
|
|
} catch {
|
|
// Collection doesn't exist — fine
|
|
}
|
|
}
|
|
|
|
// Helper: create a fake embedder that produces vectors of a given dimension
|
|
function createFakeEmbedder(dims: number) {
|
|
return {
|
|
embed: jest.fn().mockImplementation(async (_text: string) => {
|
|
const vec = new Array(dims).fill(0);
|
|
for (let i = 0; i < _text.length && i < dims; i++) {
|
|
vec[i] = _text.charCodeAt(i) / 255;
|
|
}
|
|
return vec;
|
|
}),
|
|
embedBatch: jest.fn().mockImplementation(async (texts: string[]) => {
|
|
return Promise.all(
|
|
texts.map(async (t) => {
|
|
const vec = new Array(dims).fill(0);
|
|
for (let i = 0; i < t.length && i < dims; i++) {
|
|
vec[i] = t.charCodeAt(i) / 255;
|
|
}
|
|
return vec;
|
|
}),
|
|
);
|
|
}),
|
|
};
|
|
}
|
|
|
|
// Conditionally skip tests when Qdrant is unavailable
|
|
const describeIfQdrant = qdrantAvailable ? describe : describe.skip;
|
|
|
|
afterAll(async () => {
|
|
await deleteCollectionIfExists("e2e_test_768");
|
|
await deleteCollectionIfExists("e2e_test_1536");
|
|
await deleteCollectionIfExists("e2e_test_race");
|
|
await deleteCollectionIfExists("e2e_test_race2");
|
|
await deleteCollectionIfExists("e2e_test_noexplicit");
|
|
await deleteCollectionIfExists("e2e_test_explicit");
|
|
await deleteCollectionIfExists("e2e_test_embdims");
|
|
await deleteCollectionIfExists("e2e_test_autodetect");
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// 1. Reproduce #4212 / #4173: dimension mismatch with 768-dim embedder
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
|
|
it("BEFORE FIX scenario: 768-dim vector into 1536-dim collection → Bad Request", async () => {
|
|
const collectionName = "e2e_test_1536";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await qdrantClient.createCollection(collectionName, {
|
|
vectors: { size: 1536, distance: "Cosine" },
|
|
});
|
|
|
|
// Insert a 768-dim vector — this is what nomic-embed-text produces
|
|
const vector768 = new Array(768).fill(0.1);
|
|
try {
|
|
await qdrantClient.upsert(collectionName, {
|
|
points: [
|
|
{ id: "test-1", vector: vector768, payload: { data: "hello" } },
|
|
],
|
|
});
|
|
fail("Expected Qdrant to reject 768-dim vector into 1536-dim collection");
|
|
} catch (error: any) {
|
|
// This is the exact "Bad Request" error users were hitting
|
|
expect(error.status).toBe(400);
|
|
}
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
});
|
|
|
|
it("AFTER FIX: Qdrant store with dimension=768 works end-to-end", async () => {
|
|
const collectionName = "e2e_test_768";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
|
|
// Create Qdrant store with correct dimension (what our auto-detect provides)
|
|
const store = new Qdrant({
|
|
host: QDRANT_HOST,
|
|
port: QDRANT_PORT,
|
|
collectionName,
|
|
embeddingModelDims: 768,
|
|
dimension: 768,
|
|
});
|
|
await store.initialize();
|
|
|
|
// Verify collection was created with 768 dims
|
|
const info = await qdrantClient.getCollection(collectionName);
|
|
expect(info.config?.params?.vectors?.size).toBe(768);
|
|
|
|
// Insert 768-dim vectors (what nomic-embed-text produces)
|
|
const vec1 = new Array(768).fill(0);
|
|
vec1[0] = 1.0;
|
|
const vec2 = new Array(768).fill(0);
|
|
vec2[1] = 1.0;
|
|
|
|
const id1 = uuidv4();
|
|
const id2 = uuidv4();
|
|
|
|
await store.insert(
|
|
[vec1, vec2],
|
|
[id1, id2],
|
|
[
|
|
{ data: "hello", userId: "u1" },
|
|
{ data: "world", userId: "u1" },
|
|
],
|
|
);
|
|
|
|
// Search with 768-dim query — this USED TO fail with Bad Request
|
|
const results = await store.search(vec1, 2, { userId: "u1" });
|
|
expect(results.length).toBe(2);
|
|
expect(results[0].id).toBe(id1); // Most similar to itself
|
|
expect(results[0].score).toBeGreaterThan(0.9);
|
|
|
|
// Get by ID
|
|
const item = await store.get(id1);
|
|
expect(item).not.toBeNull();
|
|
expect(item!.payload.data).toBe("hello");
|
|
|
|
// Update with 768-dim vector
|
|
const vec3 = new Array(768).fill(0);
|
|
vec3[2] = 1.0;
|
|
await store.update(id1, vec3, { data: "updated", userId: "u1" });
|
|
const updated = await store.get(id1);
|
|
expect(updated!.payload.data).toBe("updated");
|
|
|
|
// Delete
|
|
await store.delete(id2);
|
|
const deleted = await store.get(id2);
|
|
expect(deleted).toBeNull();
|
|
|
|
// List
|
|
const [listed, count] = await store.list({ userId: "u1" });
|
|
expect(count).toBe(1);
|
|
expect(listed[0].payload.data).toBe("updated");
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
});
|
|
|
|
it("AFTER FIX: Memory auto-detects 768 dims via probe (full integration)", async () => {
|
|
const collectionName = "e2e_test_autodetect";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
|
|
const fakeEmbedder = createFakeEmbedder(768);
|
|
|
|
// Mock only the non-Qdrant factories to avoid Google SDK import crash
|
|
jest.resetModules();
|
|
jest.doMock("../src/utils/factory", () => {
|
|
// Import Qdrant directly (avoids loading Google embedder via factory)
|
|
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
|
return {
|
|
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
|
VectorStoreFactory: {
|
|
create: jest
|
|
.fn()
|
|
.mockImplementation((_provider: string, config: any) => {
|
|
return new QdrantStore(config);
|
|
}),
|
|
},
|
|
LLMFactory: {
|
|
create: jest.fn().mockReturnValue({
|
|
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
|
}),
|
|
},
|
|
HistoryManagerFactory: {
|
|
create: jest.fn().mockReturnValue({
|
|
addHistory: jest.fn().mockResolvedValue(undefined),
|
|
getHistory: jest.fn().mockResolvedValue([]),
|
|
reset: jest.fn().mockResolvedValue(undefined),
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
jest.doMock("../src/utils/telemetry", () => ({
|
|
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const { Memory } = require("../src/memory");
|
|
|
|
// This is the EXACT config from issue #4212 — NO dimension specified
|
|
const mem = new Memory({
|
|
embedder: {
|
|
provider: "ollama",
|
|
config: { model: "nomic-embed-text" },
|
|
},
|
|
vectorStore: {
|
|
provider: "qdrant",
|
|
config: {
|
|
host: QDRANT_HOST,
|
|
port: QDRANT_PORT,
|
|
collectionName,
|
|
},
|
|
},
|
|
llm: { provider: "openai", config: { apiKey: "fake" } },
|
|
disableHistory: true,
|
|
});
|
|
|
|
// This triggers init — probe should detect 768 dims
|
|
await mem.getAll({ userId: "test-user" });
|
|
|
|
// Verify the probe was called
|
|
expect(fakeEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
|
|
|
// Verify Qdrant collection was created with auto-detected 768 dims
|
|
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
|
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
|
|
|
// Search should work (this used to throw Bad Request)
|
|
const searchResult = await mem.search("hello world", {
|
|
userId: "test-user",
|
|
});
|
|
expect(searchResult).toBeDefined();
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
jest.resetModules();
|
|
});
|
|
|
|
it("AFTER FIX: explicit dimension=768 skips probe (backward compat)", async () => {
|
|
const collectionName = "e2e_test_explicit";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
|
|
const fakeEmbedder = createFakeEmbedder(768);
|
|
|
|
jest.resetModules();
|
|
jest.doMock("../src/utils/factory", () => {
|
|
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
|
return {
|
|
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
|
VectorStoreFactory: {
|
|
create: jest
|
|
.fn()
|
|
.mockImplementation((_provider: string, config: any) => {
|
|
return new QdrantStore(config);
|
|
}),
|
|
},
|
|
LLMFactory: {
|
|
create: jest.fn().mockReturnValue({
|
|
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
|
}),
|
|
},
|
|
HistoryManagerFactory: {
|
|
create: jest.fn().mockReturnValue({
|
|
addHistory: jest.fn().mockResolvedValue(undefined),
|
|
getHistory: jest.fn().mockResolvedValue([]),
|
|
reset: jest.fn().mockResolvedValue(undefined),
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
jest.doMock("../src/utils/telemetry", () => ({
|
|
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const { Memory } = require("../src/memory");
|
|
|
|
// Workaround config from #4212 — explicit dimension
|
|
const mem = new Memory({
|
|
embedder: {
|
|
provider: "ollama",
|
|
config: { model: "nomic-embed-text" },
|
|
},
|
|
vectorStore: {
|
|
provider: "qdrant",
|
|
config: {
|
|
host: QDRANT_HOST,
|
|
port: QDRANT_PORT,
|
|
collectionName,
|
|
dimension: 768,
|
|
},
|
|
},
|
|
llm: { provider: "openai", config: { apiKey: "fake" } },
|
|
disableHistory: true,
|
|
});
|
|
|
|
await mem.getAll({ userId: "test-user" });
|
|
|
|
// Probe should NOT have been called
|
|
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
|
|
|
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
|
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
jest.resetModules();
|
|
});
|
|
|
|
it("AFTER FIX: embeddingDims in embedder config skips probe", async () => {
|
|
const collectionName = "e2e_test_embdims";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
|
|
const fakeEmbedder = createFakeEmbedder(768);
|
|
|
|
jest.resetModules();
|
|
jest.doMock("../src/utils/factory", () => {
|
|
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
|
return {
|
|
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
|
VectorStoreFactory: {
|
|
create: jest
|
|
.fn()
|
|
.mockImplementation((_provider: string, config: any) => {
|
|
return new QdrantStore(config);
|
|
}),
|
|
},
|
|
LLMFactory: {
|
|
create: jest.fn().mockReturnValue({
|
|
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
|
}),
|
|
},
|
|
HistoryManagerFactory: {
|
|
create: jest.fn().mockReturnValue({
|
|
addHistory: jest.fn().mockResolvedValue(undefined),
|
|
getHistory: jest.fn().mockResolvedValue([]),
|
|
reset: jest.fn().mockResolvedValue(undefined),
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
jest.doMock("../src/utils/telemetry", () => ({
|
|
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const { Memory } = require("../src/memory");
|
|
|
|
const mem = new Memory({
|
|
embedder: {
|
|
provider: "ollama",
|
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
|
},
|
|
vectorStore: {
|
|
provider: "qdrant",
|
|
config: {
|
|
host: QDRANT_HOST,
|
|
port: QDRANT_PORT,
|
|
collectionName,
|
|
},
|
|
},
|
|
llm: { provider: "openai", config: { apiKey: "fake" } },
|
|
disableHistory: true,
|
|
});
|
|
|
|
await mem.getAll({ userId: "test-user" });
|
|
|
|
// Probe should NOT have been called — dimension inferred from embeddingDims
|
|
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
|
|
|
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
|
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
jest.resetModules();
|
|
});
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// 2. Reproduce #4056 issue 1: Collection creation race condition
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
describeIfQdrant("Issue #4056: Qdrant race condition", () => {
|
|
it("concurrent ensureCollection calls don't crash (no 409 error leak)", async () => {
|
|
const collectionName = "e2e_test_race";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
|
|
// Create 5 Qdrant instances concurrently — this simulates the race
|
|
// that caused "Collection memory_migrations already exists!" in #4056
|
|
const instances = Array.from(
|
|
{ length: 5 },
|
|
() =>
|
|
new Qdrant({
|
|
host: QDRANT_HOST,
|
|
port: QDRANT_PORT,
|
|
collectionName,
|
|
embeddingModelDims: 768,
|
|
dimension: 768,
|
|
}),
|
|
);
|
|
|
|
// All should initialize without throwing 409 Conflict
|
|
await Promise.all(instances.map((inst) => inst.initialize()));
|
|
|
|
// Verify collection exists with correct dimension
|
|
const info = await qdrantClient.getCollection(collectionName);
|
|
expect(info.config?.params?.vectors?.size).toBe(768);
|
|
|
|
// memory_migrations should also exist (created by initialize)
|
|
const migrInfo = await qdrantClient.getCollection("memory_migrations");
|
|
expect(migrInfo.config?.params?.vectors?.size).toBe(1);
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
});
|
|
|
|
it("getUserId works after concurrent initialization", async () => {
|
|
const collectionName = "e2e_test_race2";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
|
|
const instance = new Qdrant({
|
|
host: QDRANT_HOST,
|
|
port: QDRANT_PORT,
|
|
collectionName,
|
|
embeddingModelDims: 768,
|
|
dimension: 768,
|
|
});
|
|
|
|
await instance.initialize();
|
|
|
|
// getUserId should work without 409 crash
|
|
const userId = await instance.getUserId();
|
|
expect(typeof userId).toBe("string");
|
|
expect(userId.length).toBeGreaterThan(0);
|
|
|
|
// setUserId + getUserId roundtrip
|
|
await instance.setUserId("custom-e2e-user");
|
|
const updated = await instance.getUserId();
|
|
expect(updated).toBe("custom-e2e-user");
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
});
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// 3. Reproduce #4056 issue 2: memory_migrations dimension isolation
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
describeIfQdrant("Issue #4056: memory_migrations dimension isolation", () => {
|
|
it("memory_migrations uses dim=1 independently of main collection dim=768", async () => {
|
|
const collectionName = "e2e_test_noexplicit";
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
|
|
const instance = new Qdrant({
|
|
host: QDRANT_HOST,
|
|
port: QDRANT_PORT,
|
|
collectionName,
|
|
embeddingModelDims: 768,
|
|
dimension: 768,
|
|
});
|
|
|
|
await instance.initialize();
|
|
|
|
// Allow Qdrant a moment to fully commit collections
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
|
|
// Main collection should be 768
|
|
const mainInfo = await qdrantClient.getCollection(collectionName);
|
|
expect(mainInfo.config?.params?.vectors?.size).toBe(768);
|
|
|
|
// memory_migrations should be 1 (NOT 768!)
|
|
// This was the bug in #4056 issue 2 — telemetry used wrong dimension
|
|
const migrationsInfo =
|
|
await qdrantClient.getCollection("memory_migrations");
|
|
expect(migrationsInfo.config?.params?.vectors?.size).toBe(1);
|
|
|
|
// getUserId should work — vector dim=1 in memory_migrations
|
|
const userId = await instance.getUserId();
|
|
expect(typeof userId).toBe("string");
|
|
|
|
// setUserId should also work
|
|
await instance.setUserId("custom-test-user");
|
|
const newUserId = await instance.getUserId();
|
|
expect(newUserId).toBe("custom-test-user");
|
|
|
|
await deleteCollectionIfExists(collectionName);
|
|
await deleteCollectionIfExists("memory_migrations");
|
|
});
|
|
});
|