@malloy-publisher/server 0.0.231 → 0.0.233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +135 -13
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
- package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +22627 -1260
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/controller/query.controller.ts +26 -21
- package/src/errors.ts +19 -0
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +358 -5
- package/src/mcp/tools/get_context_tool.ts +202 -56
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +339 -20
- package/src/service/connection_config.spec.ts +108 -0
- package/src/service/connection_config.ts +47 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +569 -59
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
it,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as os from "os";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import { DuckDBConnection } from "../../storage/duckdb/DuckDBConnection";
|
|
13
|
+
import { createEntityEmbeddingsTable } from "../../storage/duckdb/schema";
|
|
14
|
+
import { EmbeddingProvider } from "../../service/embedding_provider";
|
|
15
|
+
import type { Package } from "../../service/package";
|
|
16
|
+
import {
|
|
17
|
+
EmbeddableEntity,
|
|
18
|
+
MIN_SIMILARITY,
|
|
19
|
+
SemanticSearchResult,
|
|
20
|
+
_clearProviderCooldownForTests,
|
|
21
|
+
_lastPurgeAtMsForTests,
|
|
22
|
+
_resetEmbeddingIndexStateForTests,
|
|
23
|
+
_setTimingForTests,
|
|
24
|
+
_syncMetaSizeForTests,
|
|
25
|
+
deleteEnvironmentEmbeddings,
|
|
26
|
+
deletePackageEmbeddings,
|
|
27
|
+
embeddingText,
|
|
28
|
+
humanizeName,
|
|
29
|
+
trySemanticSearch,
|
|
30
|
+
} from "./embedding_index";
|
|
31
|
+
|
|
32
|
+
let tempDir: string;
|
|
33
|
+
let db: DuckDBConnection;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "embedding-index-spec-"));
|
|
37
|
+
db = new DuckDBConnection(path.join(tempDir, "test.db"));
|
|
38
|
+
await db.initialize();
|
|
39
|
+
await createEntityEmbeddingsTable(db);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
// Close before removing: DuckDB holds an exclusive file handle and
|
|
44
|
+
// Windows refuses to delete a directory containing an open file.
|
|
45
|
+
await db.close();
|
|
46
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
_resetEmbeddingIndexStateForTests();
|
|
51
|
+
await db.run("DELETE FROM entity_embeddings");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A provider whose "embeddings" come from an explicit text -> vector map,
|
|
56
|
+
* so cosine ranking in the tests is hand-computable. Also counts how
|
|
57
|
+
* often each text was embedded, to pin the hash-diff behavior.
|
|
58
|
+
*/
|
|
59
|
+
function mapProvider(
|
|
60
|
+
vectors: Record<string, number[]>,
|
|
61
|
+
options: {
|
|
62
|
+
model?: string;
|
|
63
|
+
dimensions?: number;
|
|
64
|
+
fail?: () => boolean;
|
|
65
|
+
// Requests whose input includes this text block until the promise
|
|
66
|
+
// resolves, so a test can hold one call mid-flight deterministically.
|
|
67
|
+
gate?: { forText: string; until: Promise<void> };
|
|
68
|
+
} = {},
|
|
69
|
+
): { provider: EmbeddingProvider; counts: Map<string, number> } {
|
|
70
|
+
const counts = new Map<string, number>();
|
|
71
|
+
const fetchStub = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
72
|
+
if (options.fail?.()) {
|
|
73
|
+
return new Response("stub failure", { status: 500 });
|
|
74
|
+
}
|
|
75
|
+
const body = JSON.parse(String(init?.body)) as { input: string[] };
|
|
76
|
+
if (options.gate && body.input.includes(options.gate.forText)) {
|
|
77
|
+
await options.gate.until;
|
|
78
|
+
}
|
|
79
|
+
const data = body.input.map((text, index) => {
|
|
80
|
+
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
81
|
+
const embedding = vectors[text];
|
|
82
|
+
if (!embedding) throw new Error(`no stub vector for "${text}"`);
|
|
83
|
+
return { index, embedding };
|
|
84
|
+
});
|
|
85
|
+
return new Response(JSON.stringify({ data }), { status: 200 });
|
|
86
|
+
}) as typeof fetch;
|
|
87
|
+
const provider = new EmbeddingProvider(
|
|
88
|
+
{
|
|
89
|
+
apiKey: "test",
|
|
90
|
+
model: options.model ?? "stub-model",
|
|
91
|
+
baseUrl: "https://stub.example.com/v1",
|
|
92
|
+
dimensions: options.dimensions,
|
|
93
|
+
},
|
|
94
|
+
fetchStub,
|
|
95
|
+
);
|
|
96
|
+
return { provider, counts };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function entity(
|
|
100
|
+
name: string,
|
|
101
|
+
source: string | undefined,
|
|
102
|
+
embedDoc = "",
|
|
103
|
+
): EmbeddableEntity {
|
|
104
|
+
return { kind: "measure", name, source, modelPath: "m.malloy", embedDoc };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Poll through the cold-start "indexing" response until the sync lands. */
|
|
108
|
+
async function searchReady(
|
|
109
|
+
args: Parameters<typeof trySemanticSearch>[0],
|
|
110
|
+
): Promise<SemanticSearchResult> {
|
|
111
|
+
for (let i = 0; i < 200; i++) {
|
|
112
|
+
const result = await trySemanticSearch(args);
|
|
113
|
+
if (!("unavailable" in result) || result.unavailable !== "indexing") {
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
117
|
+
}
|
|
118
|
+
throw new Error("sync never completed");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isCooldown(result: SemanticSearchResult): boolean {
|
|
122
|
+
return "unavailable" in result && result.unavailable === "cooldown";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const QUERY_VECTORS = {
|
|
126
|
+
"find alpha": [1, 0, 0],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const ENTITY_VECTORS = {
|
|
130
|
+
alpha: [1, 0, 0], // cosine 1.0 vs the query
|
|
131
|
+
beta: [0.8, 0.6, 0], // cosine 0.8
|
|
132
|
+
gamma: [0, 0, 1], // cosine 0 -> below MIN_SIMILARITY, dropped
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
describe("humanizeName / embeddingText", () => {
|
|
136
|
+
it("splits snake_case, kebab, dots, and camelCase into words", () => {
|
|
137
|
+
expect(humanizeName("dep_delay")).toBe("dep delay");
|
|
138
|
+
expect(humanizeName("totalSales")).toBe("total sales");
|
|
139
|
+
expect(humanizeName("sales-by.region")).toBe("sales by region");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("appends the doc text when present", () => {
|
|
143
|
+
expect(embeddingText(entity("total_sales", "s", "Total revenue"))).toBe(
|
|
144
|
+
"total sales: Total revenue",
|
|
145
|
+
);
|
|
146
|
+
expect(embeddingText(entity("total_sales", "s"))).toBe("total sales");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("never produces empty embed text for punctuation-only names", () => {
|
|
150
|
+
// `_` is a legal Malloy identifier; an empty input would 400 the
|
|
151
|
+
// whole package's embedding batch at the provider.
|
|
152
|
+
expect(humanizeName("_")).toBe("");
|
|
153
|
+
expect(embeddingText(entity("_", "s"))).toBe("_");
|
|
154
|
+
expect(embeddingText(entity("_", "s", "odd but documented"))).toBe(
|
|
155
|
+
"_: odd but documented",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("trySemanticSearch", () => {
|
|
161
|
+
it("cold start reports indexing, then ranks by cosine with a floor", async () => {
|
|
162
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
163
|
+
const pkg = {} as unknown as Package;
|
|
164
|
+
const args = {
|
|
165
|
+
db,
|
|
166
|
+
provider,
|
|
167
|
+
pkg,
|
|
168
|
+
environmentName: "env",
|
|
169
|
+
packageName: "pkg",
|
|
170
|
+
entities: [
|
|
171
|
+
entity("alpha", "src"),
|
|
172
|
+
entity("beta", "src"),
|
|
173
|
+
entity("gamma", "src"),
|
|
174
|
+
],
|
|
175
|
+
query: "find alpha",
|
|
176
|
+
limit: 10,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const first = await trySemanticSearch(args);
|
|
180
|
+
expect(first).toEqual({ unavailable: "indexing" });
|
|
181
|
+
|
|
182
|
+
const ready = await searchReady(args);
|
|
183
|
+
if (!("hits" in ready)) throw new Error("expected hits");
|
|
184
|
+
expect(ready.hits.map((h) => h.name)).toEqual(["alpha", "beta"]);
|
|
185
|
+
expect(ready.hits[0].score).toBeCloseTo(1.0, 3);
|
|
186
|
+
expect(ready.hits[1].score).toBeCloseTo(0.8, 3);
|
|
187
|
+
expect(ready.hits.every((h) => h.score >= MIN_SIMILARITY)).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("does not re-embed unchanged entities for a new package instance", async () => {
|
|
191
|
+
const { provider, counts } = mapProvider({
|
|
192
|
+
...ENTITY_VECTORS,
|
|
193
|
+
...QUERY_VECTORS,
|
|
194
|
+
});
|
|
195
|
+
const entities = [entity("alpha", "src"), entity("beta", "src")];
|
|
196
|
+
const base = {
|
|
197
|
+
db,
|
|
198
|
+
provider,
|
|
199
|
+
environmentName: "env",
|
|
200
|
+
packageName: "pkg",
|
|
201
|
+
entities,
|
|
202
|
+
query: "find alpha",
|
|
203
|
+
limit: 10,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
await searchReady({ ...base, pkg: {} as unknown as Package });
|
|
207
|
+
expect(counts.get("alpha")).toBe(1);
|
|
208
|
+
|
|
209
|
+
// A "reload": same package name, new instance. The sync re-runs but
|
|
210
|
+
// the content hashes match, so nothing re-embeds.
|
|
211
|
+
await searchReady({ ...base, pkg: {} as unknown as Package });
|
|
212
|
+
expect(counts.get("alpha")).toBe(1);
|
|
213
|
+
expect(counts.get("beta")).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("re-embeds only the entity whose text changed, via upsert", async () => {
|
|
217
|
+
const vectors = {
|
|
218
|
+
...ENTITY_VECTORS,
|
|
219
|
+
...QUERY_VECTORS,
|
|
220
|
+
"alpha: now documented": [0, 1, 0],
|
|
221
|
+
};
|
|
222
|
+
const { provider, counts } = mapProvider(vectors);
|
|
223
|
+
const base = {
|
|
224
|
+
db,
|
|
225
|
+
provider,
|
|
226
|
+
environmentName: "env",
|
|
227
|
+
packageName: "pkg",
|
|
228
|
+
query: "find alpha",
|
|
229
|
+
limit: 10,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await searchReady({
|
|
233
|
+
...base,
|
|
234
|
+
pkg: {} as unknown as Package,
|
|
235
|
+
entities: [entity("alpha", "src"), entity("beta", "src")],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const changed = await searchReady({
|
|
239
|
+
...base,
|
|
240
|
+
pkg: {} as unknown as Package,
|
|
241
|
+
entities: [
|
|
242
|
+
entity("alpha", "src", "now documented"),
|
|
243
|
+
entity("beta", "src"),
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
expect(counts.get("alpha: now documented")).toBe(1);
|
|
247
|
+
expect(counts.get("beta")).toBe(1);
|
|
248
|
+
if (!("hits" in changed)) throw new Error("expected hits");
|
|
249
|
+
// alpha's new vector is orthogonal to the query, so beta leads now.
|
|
250
|
+
expect(changed.hits.map((h) => h.name)).toEqual(["beta"]);
|
|
251
|
+
|
|
252
|
+
const rows = await db.all<{ n: number }>(
|
|
253
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
|
|
254
|
+
);
|
|
255
|
+
expect(rows[0].n).toBe(2);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("re-embeds everything on a model switch without a key collision", async () => {
|
|
259
|
+
const first = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
260
|
+
const base = {
|
|
261
|
+
db,
|
|
262
|
+
environmentName: "env",
|
|
263
|
+
packageName: "pkg",
|
|
264
|
+
entities: [entity("alpha", "src"), entity("beta", "src")],
|
|
265
|
+
query: "find alpha",
|
|
266
|
+
limit: 10,
|
|
267
|
+
};
|
|
268
|
+
await searchReady({
|
|
269
|
+
...base,
|
|
270
|
+
provider: first.provider,
|
|
271
|
+
pkg: {} as unknown as Package,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const second = mapProvider(
|
|
275
|
+
{ ...ENTITY_VECTORS, ...QUERY_VECTORS },
|
|
276
|
+
{ model: "other-model" },
|
|
277
|
+
);
|
|
278
|
+
const result = await searchReady({
|
|
279
|
+
...base,
|
|
280
|
+
provider: second.provider,
|
|
281
|
+
pkg: {} as unknown as Package,
|
|
282
|
+
});
|
|
283
|
+
if (!("hits" in result)) throw new Error("expected hits");
|
|
284
|
+
expect(result.hits.map((h) => h.name)).toEqual(["alpha", "beta"]);
|
|
285
|
+
expect(second.counts.get("alpha")).toBe(1);
|
|
286
|
+
|
|
287
|
+
const rows = await db.all<{ n: number }>(
|
|
288
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE embedding_model = 'other-model'",
|
|
289
|
+
);
|
|
290
|
+
expect(rows[0].n).toBe(2);
|
|
291
|
+
const oldRows = await db.all<{ n: number }>(
|
|
292
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE embedding_model = 'stub-model'",
|
|
293
|
+
);
|
|
294
|
+
expect(oldRows[0].n).toBe(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("re-syncs an already-synced instance when the model changes", async () => {
|
|
298
|
+
const pkg = {} as unknown as Package;
|
|
299
|
+
const first = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
300
|
+
const base = {
|
|
301
|
+
db,
|
|
302
|
+
pkg,
|
|
303
|
+
environmentName: "env",
|
|
304
|
+
packageName: "pkg",
|
|
305
|
+
entities: [entity("alpha", "src")],
|
|
306
|
+
query: "find alpha",
|
|
307
|
+
limit: 10,
|
|
308
|
+
};
|
|
309
|
+
await searchReady({ ...base, provider: first.provider });
|
|
310
|
+
|
|
311
|
+
// Same Package instance, new model: the provider-key mismatch must
|
|
312
|
+
// trigger a re-sync instead of matching zero rows forever.
|
|
313
|
+
const second = mapProvider(
|
|
314
|
+
{ ...ENTITY_VECTORS, ...QUERY_VECTORS },
|
|
315
|
+
{ model: "other-model" },
|
|
316
|
+
);
|
|
317
|
+
const result = await searchReady({ ...base, provider: second.provider });
|
|
318
|
+
if (!("hits" in result)) throw new Error("expected hits");
|
|
319
|
+
expect(result.hits.map((h) => h.name)).toEqual(["alpha"]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("deletes rows for entities that no longer exist", async () => {
|
|
323
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
324
|
+
const base = {
|
|
325
|
+
db,
|
|
326
|
+
provider,
|
|
327
|
+
environmentName: "env",
|
|
328
|
+
packageName: "pkg",
|
|
329
|
+
query: "find alpha",
|
|
330
|
+
limit: 10,
|
|
331
|
+
};
|
|
332
|
+
await searchReady({
|
|
333
|
+
...base,
|
|
334
|
+
pkg: {} as unknown as Package,
|
|
335
|
+
entities: [entity("alpha", "src"), entity("gamma", "src")],
|
|
336
|
+
});
|
|
337
|
+
await searchReady({
|
|
338
|
+
...base,
|
|
339
|
+
pkg: {} as unknown as Package,
|
|
340
|
+
entities: [entity("alpha", "src")],
|
|
341
|
+
});
|
|
342
|
+
const rows = await db.all<{ entity_name: string }>(
|
|
343
|
+
"SELECT entity_name FROM entity_embeddings WHERE environment_name = 'env' ORDER BY entity_name",
|
|
344
|
+
);
|
|
345
|
+
expect(rows.map((r) => r.entity_name)).toEqual(["alpha"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("narrows to one source with sourceName", async () => {
|
|
349
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
350
|
+
const args = {
|
|
351
|
+
db,
|
|
352
|
+
provider,
|
|
353
|
+
pkg: {} as unknown as Package,
|
|
354
|
+
environmentName: "env",
|
|
355
|
+
packageName: "pkg",
|
|
356
|
+
entities: [entity("alpha", "orders"), entity("beta", "customers")],
|
|
357
|
+
query: "find alpha",
|
|
358
|
+
limit: 10,
|
|
359
|
+
sourceName: "customers",
|
|
360
|
+
};
|
|
361
|
+
const result = await searchReady(args);
|
|
362
|
+
if (!("hits" in result)) throw new Error("expected hits");
|
|
363
|
+
expect(result.hits.map((h) => h.name)).toEqual(["beta"]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("scopes rows per package", async () => {
|
|
367
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
368
|
+
const base = {
|
|
369
|
+
db,
|
|
370
|
+
provider,
|
|
371
|
+
environmentName: "env",
|
|
372
|
+
query: "find alpha",
|
|
373
|
+
limit: 10,
|
|
374
|
+
};
|
|
375
|
+
await searchReady({
|
|
376
|
+
...base,
|
|
377
|
+
packageName: "pkg-a",
|
|
378
|
+
pkg: {} as unknown as Package,
|
|
379
|
+
entities: [entity("alpha", "src")],
|
|
380
|
+
});
|
|
381
|
+
const other = await searchReady({
|
|
382
|
+
...base,
|
|
383
|
+
packageName: "pkg-b",
|
|
384
|
+
pkg: {} as unknown as Package,
|
|
385
|
+
entities: [entity("beta", "src")],
|
|
386
|
+
});
|
|
387
|
+
if (!("hits" in other)) throw new Error("expected hits");
|
|
388
|
+
expect(other.hits.map((h) => h.name)).toEqual(["beta"]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("purges and re-syncs when the provider's dimensionality changes under the same model", async () => {
|
|
392
|
+
// Sync at 3 dims (EMBEDDING_DIMENSIONS unset everywhere here).
|
|
393
|
+
const first = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
394
|
+
const base = {
|
|
395
|
+
db,
|
|
396
|
+
environmentName: "env",
|
|
397
|
+
packageName: "pkg",
|
|
398
|
+
entities: [entity("alpha", "src")],
|
|
399
|
+
query: "find alpha",
|
|
400
|
+
limit: 10,
|
|
401
|
+
};
|
|
402
|
+
await searchReady({
|
|
403
|
+
...base,
|
|
404
|
+
provider: first.provider,
|
|
405
|
+
pkg: {} as unknown as Package,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// The same model name now returns 4-dim vectors (a different
|
|
409
|
+
// backend behind EMBEDDING_API_BASE). The hash diff cannot see
|
|
410
|
+
// this: a fresh instance's sync embeds nothing, and the search
|
|
411
|
+
// finds zero compatible rows. The empty-result heal must purge
|
|
412
|
+
// and re-sync instead of returning empty "semantic" results
|
|
413
|
+
// forever.
|
|
414
|
+
const wide = mapProvider({
|
|
415
|
+
alpha: [1, 0, 0, 0],
|
|
416
|
+
"find alpha": [1, 0, 0, 0],
|
|
417
|
+
});
|
|
418
|
+
const pkg = {} as unknown as Package;
|
|
419
|
+
const result = await searchReady({
|
|
420
|
+
...base,
|
|
421
|
+
provider: wide.provider,
|
|
422
|
+
pkg,
|
|
423
|
+
});
|
|
424
|
+
if (!("hits" in result)) throw new Error("expected hits");
|
|
425
|
+
expect(result.hits.map((h) => h.name)).toEqual(["alpha"]);
|
|
426
|
+
const rows = await db.all<{ dims: number }>(
|
|
427
|
+
"SELECT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
|
|
428
|
+
);
|
|
429
|
+
expect(rows.map((r) => r.dims)).toEqual([4]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("a purge invalidates every instance's memo, not just the caller's", async () => {
|
|
433
|
+
// Instance A syncs at 3 dims and stays around with done=true.
|
|
434
|
+
const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
435
|
+
const base = {
|
|
436
|
+
db,
|
|
437
|
+
environmentName: "env",
|
|
438
|
+
packageName: "pkg",
|
|
439
|
+
entities: [entity("alpha", "src")],
|
|
440
|
+
query: "find alpha",
|
|
441
|
+
limit: 10,
|
|
442
|
+
};
|
|
443
|
+
const pkgA = {} as unknown as Package;
|
|
444
|
+
await searchReady({ ...base, provider: narrow.provider, pkg: pkgA });
|
|
445
|
+
|
|
446
|
+
// Instance B (a reload) triggers the dims heal with a 4-dim
|
|
447
|
+
// provider. Drive B ONLY until the purge lands (row count drops
|
|
448
|
+
// to zero) and then stop, so no re-sync repopulates the table:
|
|
449
|
+
// this reproduces the exact race window where a stale done memo
|
|
450
|
+
// sits over an empty table.
|
|
451
|
+
const wide = mapProvider({
|
|
452
|
+
alpha: [1, 0, 0, 0],
|
|
453
|
+
"find alpha": [1, 0, 0, 0],
|
|
454
|
+
});
|
|
455
|
+
const pkgB = {} as unknown as Package;
|
|
456
|
+
for (let i = 0; i < 200; i++) {
|
|
457
|
+
await trySemanticSearch({
|
|
458
|
+
...base,
|
|
459
|
+
provider: wide.provider,
|
|
460
|
+
pkg: pkgB,
|
|
461
|
+
});
|
|
462
|
+
const rows = await db.all<{ n: number }>(
|
|
463
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
|
|
464
|
+
);
|
|
465
|
+
if (rows[0].n === 0) break;
|
|
466
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
467
|
+
}
|
|
468
|
+
const purged = await db.all<{ n: number }>(
|
|
469
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
|
|
470
|
+
);
|
|
471
|
+
expect(purged[0].n).toBe(0);
|
|
472
|
+
|
|
473
|
+
// A's memo says done, but the table is empty. Without generation
|
|
474
|
+
// tracking, A would trust its memo, search zero rows, find no
|
|
475
|
+
// stale rows to heal, and serve `{hits: []}` marked semantic
|
|
476
|
+
// forever. With it, A's stale generation forces a re-sync (the
|
|
477
|
+
// table is empty, so A re-embeds at its own 3 dims) and A answers
|
|
478
|
+
// with real hits again.
|
|
479
|
+
const back = await searchReady({
|
|
480
|
+
...base,
|
|
481
|
+
provider: narrow.provider,
|
|
482
|
+
pkg: pkgA,
|
|
483
|
+
});
|
|
484
|
+
if (!("hits" in back))
|
|
485
|
+
throw new Error("expected hits, got " + JSON.stringify(back));
|
|
486
|
+
expect(back.hits.map((h) => h.name)).toEqual(["alpha"]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("heals the mixed-dims state where a partial sync stranded old rows", async () => {
|
|
490
|
+
// alpha indexed at 3 dims.
|
|
491
|
+
const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
492
|
+
const base = {
|
|
493
|
+
db,
|
|
494
|
+
environmentName: "env",
|
|
495
|
+
packageName: "pkg",
|
|
496
|
+
query: "find alpha",
|
|
497
|
+
limit: 10,
|
|
498
|
+
};
|
|
499
|
+
await searchReady({
|
|
500
|
+
...base,
|
|
501
|
+
provider: narrow.provider,
|
|
502
|
+
pkg: {} as unknown as Package,
|
|
503
|
+
entities: [entity("alpha", "src")],
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// The endpoint now returns 4-dim vectors for the same model, and a
|
|
507
|
+
// new entity (beta) arrives. The sync diff skips alpha (hash and
|
|
508
|
+
// model match) and embeds only beta, leaving a MIXED table: alpha
|
|
509
|
+
// at 3 dims, beta at 4. An empty-result heal trigger would never
|
|
510
|
+
// fire here (compatible rows exist), stranding alpha invisibly.
|
|
511
|
+
const wide = mapProvider({
|
|
512
|
+
alpha: [1, 0, 0, 0],
|
|
513
|
+
beta: [0.8, 0.6, 0, 0],
|
|
514
|
+
"find alpha": [1, 0, 0, 0],
|
|
515
|
+
});
|
|
516
|
+
const pkg = {} as unknown as Package;
|
|
517
|
+
const args = {
|
|
518
|
+
...base,
|
|
519
|
+
provider: wide.provider,
|
|
520
|
+
pkg,
|
|
521
|
+
entities: [entity("alpha", "src"), entity("beta", "src")],
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const result = await searchReady(args);
|
|
525
|
+
if (!("hits" in result)) throw new Error("expected hits");
|
|
526
|
+
// Both entities retrievable: the stale-row heal purged alpha's
|
|
527
|
+
// 3-dim row and the follow-up sync re-embedded it at 4 dims.
|
|
528
|
+
expect(result.hits.map((h) => h.name)).toEqual(["alpha", "beta"]);
|
|
529
|
+
const dims = await db.all<{ dims: number }>(
|
|
530
|
+
"SELECT DISTINCT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
|
|
531
|
+
);
|
|
532
|
+
expect(dims.map((d) => d.dims)).toEqual([4]);
|
|
533
|
+
// The purge deleted ONLY the stale row: beta was embedded exactly
|
|
534
|
+
// once. A delete-all purge would re-embed it and fail this pin.
|
|
535
|
+
expect(wide.counts.get("beta")).toBe(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("a sync that changes rows invalidates other instances' memos", async () => {
|
|
539
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
540
|
+
const base = {
|
|
541
|
+
db,
|
|
542
|
+
provider,
|
|
543
|
+
environmentName: "env",
|
|
544
|
+
packageName: "pkg",
|
|
545
|
+
query: "find alpha",
|
|
546
|
+
limit: 10,
|
|
547
|
+
};
|
|
548
|
+
const pkgA = {} as unknown as Package;
|
|
549
|
+
await searchReady({
|
|
550
|
+
...base,
|
|
551
|
+
pkg: pkgA,
|
|
552
|
+
entities: [entity("alpha", "src")],
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// A reload's instance syncs with a CHANGED entity text: the rows
|
|
556
|
+
// pkgA's snapshot logic trusts have been rewritten.
|
|
557
|
+
const changedVectors = {
|
|
558
|
+
...ENTITY_VECTORS,
|
|
559
|
+
...QUERY_VECTORS,
|
|
560
|
+
"alpha: reworded": [0, 1, 0],
|
|
561
|
+
};
|
|
562
|
+
const changed = mapProvider(changedVectors);
|
|
563
|
+
await searchReady({
|
|
564
|
+
...base,
|
|
565
|
+
provider: changed.provider,
|
|
566
|
+
pkg: {} as unknown as Package,
|
|
567
|
+
entities: [entity("alpha", "src", "reworded")],
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// pkgA's done memo must now be stale (the sync bumped the
|
|
571
|
+
// generation): its next call re-kicks a sync instead of serving
|
|
572
|
+
// from the trusted memo.
|
|
573
|
+
const next = await trySemanticSearch({
|
|
574
|
+
...base,
|
|
575
|
+
pkg: pkgA,
|
|
576
|
+
entities: [entity("alpha", "src")],
|
|
577
|
+
});
|
|
578
|
+
expect(next).toEqual({ unavailable: "indexing" });
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("a call overlapping a purge answers indexing, not empty semantic hits", async () => {
|
|
582
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
583
|
+
const base = {
|
|
584
|
+
db,
|
|
585
|
+
environmentName: "env",
|
|
586
|
+
packageName: "pkg",
|
|
587
|
+
entities: [entity("alpha", "src")],
|
|
588
|
+
query: "find alpha",
|
|
589
|
+
limit: 10,
|
|
590
|
+
};
|
|
591
|
+
const pkgA = {} as unknown as Package;
|
|
592
|
+
await searchReady({ ...base, provider, pkg: pkgA });
|
|
593
|
+
|
|
594
|
+
// Call C on pkgA holds at its query embed (gated), memo done and
|
|
595
|
+
// generation current at entry.
|
|
596
|
+
let release!: () => void;
|
|
597
|
+
const gate = new Promise<void>((resolve) => {
|
|
598
|
+
release = resolve;
|
|
599
|
+
});
|
|
600
|
+
const gated = mapProvider(
|
|
601
|
+
{ ...ENTITY_VECTORS, ...QUERY_VECTORS },
|
|
602
|
+
{ gate: { forText: "find alpha", until: gate } },
|
|
603
|
+
);
|
|
604
|
+
const cPromise = trySemanticSearch({
|
|
605
|
+
...base,
|
|
606
|
+
provider: gated.provider,
|
|
607
|
+
pkg: pkgA,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// While C is gated, another instance's heal purges the table (a
|
|
611
|
+
// 4-dim provider makes every row stale) and stops before any
|
|
612
|
+
// re-sync repopulates it.
|
|
613
|
+
const wide = mapProvider({
|
|
614
|
+
alpha: [1, 0, 0, 0],
|
|
615
|
+
"find alpha": [1, 0, 0, 0],
|
|
616
|
+
});
|
|
617
|
+
const pkgB = {} as unknown as Package;
|
|
618
|
+
for (let i = 0; i < 200; i++) {
|
|
619
|
+
await trySemanticSearch({
|
|
620
|
+
...base,
|
|
621
|
+
provider: wide.provider,
|
|
622
|
+
pkg: pkgB,
|
|
623
|
+
});
|
|
624
|
+
const rows = await db.all<{ n: number }>(
|
|
625
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
|
|
626
|
+
);
|
|
627
|
+
if (rows[0].n === 0) break;
|
|
628
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Released, C searches an empty table with no stale rows left to
|
|
632
|
+
// heal. Without the entry-generation re-check it would return
|
|
633
|
+
// {hits: []} (served as semantic "nothing relevant here"); with
|
|
634
|
+
// it, it reports indexing and the tool answers marked lexical.
|
|
635
|
+
release();
|
|
636
|
+
expect(await cPromise).toEqual({ unavailable: "indexing" });
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("a torn write (sync failing mid-loop) still invalidates other memos", async () => {
|
|
640
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
641
|
+
const base = {
|
|
642
|
+
db,
|
|
643
|
+
provider,
|
|
644
|
+
environmentName: "env",
|
|
645
|
+
packageName: "pkg",
|
|
646
|
+
query: "find alpha",
|
|
647
|
+
limit: 10,
|
|
648
|
+
};
|
|
649
|
+
const pkgA = {} as unknown as Package;
|
|
650
|
+
await searchReady({
|
|
651
|
+
...base,
|
|
652
|
+
pkg: pkgA,
|
|
653
|
+
entities: [entity("alpha", "src")],
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// A reload instance syncs two changed entities through a DB whose
|
|
657
|
+
// SECOND insert fails: one row was already rewritten, so even
|
|
658
|
+
// though the sync rejects, snapshots must be invalidated.
|
|
659
|
+
let inserts = 0;
|
|
660
|
+
const failingDb = new Proxy(db, {
|
|
661
|
+
get(target, prop, receiver) {
|
|
662
|
+
if (prop === "run") {
|
|
663
|
+
return async (query: string, params?: unknown[]) => {
|
|
664
|
+
if (query.includes("INSERT INTO entity_embeddings")) {
|
|
665
|
+
inserts++;
|
|
666
|
+
if (inserts === 2) throw new Error("disk full (test)");
|
|
667
|
+
}
|
|
668
|
+
return target.run(query, params);
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return Reflect.get(target, prop, receiver);
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
const changed = mapProvider({
|
|
675
|
+
...QUERY_VECTORS,
|
|
676
|
+
"alpha: reworded": [0, 1, 0],
|
|
677
|
+
"beta: new": [0, 0, 1],
|
|
678
|
+
});
|
|
679
|
+
const first = await trySemanticSearch({
|
|
680
|
+
...base,
|
|
681
|
+
db: failingDb,
|
|
682
|
+
provider: changed.provider,
|
|
683
|
+
pkg: {} as unknown as Package,
|
|
684
|
+
entities: [
|
|
685
|
+
entity("alpha", "src", "reworded"),
|
|
686
|
+
entity("beta", "src", "new"),
|
|
687
|
+
],
|
|
688
|
+
});
|
|
689
|
+
expect(first).toEqual({ unavailable: "indexing" });
|
|
690
|
+
|
|
691
|
+
// Settle phase 1: wait until the second insert has been attempted
|
|
692
|
+
// (it increments the counter before throwing).
|
|
693
|
+
for (let i = 0; i < 200 && inserts < 2; i++) {
|
|
694
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
695
|
+
}
|
|
696
|
+
expect(inserts).toBe(2);
|
|
697
|
+
// Settle phase 2: the rejection handler (which sets the cool-down)
|
|
698
|
+
// runs asynchronously after the throw; wait until a call observes
|
|
699
|
+
// the cool-down so the failed sync has fully settled. This
|
|
700
|
+
// converges with or without the finally bump, so it does not mask
|
|
701
|
+
// the pin below.
|
|
702
|
+
let settled: SemanticSearchResult = { hits: [] };
|
|
703
|
+
for (let i = 0; i < 200; i++) {
|
|
704
|
+
settled = await trySemanticSearch({
|
|
705
|
+
...base,
|
|
706
|
+
pkg: pkgA,
|
|
707
|
+
entities: [entity("alpha", "src")],
|
|
708
|
+
});
|
|
709
|
+
if ("unavailable" in settled && settled.unavailable === "cooldown") {
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
713
|
+
}
|
|
714
|
+
expect(settled).toEqual({ unavailable: "cooldown" });
|
|
715
|
+
|
|
716
|
+
// The pin: with the cool-down cleared, pkgA's done memo must be
|
|
717
|
+
// stale, because the failed sync changed a row before it died. A
|
|
718
|
+
// success-only bump would leave pkgA serving the half-rewritten
|
|
719
|
+
// table as semantic hits here.
|
|
720
|
+
_clearProviderCooldownForTests();
|
|
721
|
+
const next = await trySemanticSearch({
|
|
722
|
+
...base,
|
|
723
|
+
pkg: pkgA,
|
|
724
|
+
entities: [entity("alpha", "src")],
|
|
725
|
+
});
|
|
726
|
+
expect(next).toEqual({ unavailable: "indexing" });
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("querying through an old instance after a package delete recovers, never empty-semantic", async () => {
|
|
730
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
731
|
+
const base = {
|
|
732
|
+
db,
|
|
733
|
+
provider,
|
|
734
|
+
environmentName: "env",
|
|
735
|
+
packageName: "pkg",
|
|
736
|
+
entities: [entity("alpha", "src")],
|
|
737
|
+
query: "find alpha",
|
|
738
|
+
limit: 10,
|
|
739
|
+
};
|
|
740
|
+
const pkgA = {} as unknown as Package;
|
|
741
|
+
await searchReady({ ...base, pkg: pkgA });
|
|
742
|
+
|
|
743
|
+
// Delete removes the rows AND the syncMeta entry (the churn-leak
|
|
744
|
+
// fix); the map must shrink back.
|
|
745
|
+
const before = _syncMetaSizeForTests();
|
|
746
|
+
await deletePackageEmbeddings(db, "env", "pkg");
|
|
747
|
+
expect(_syncMetaSizeForTests()).toBe(before - 1);
|
|
748
|
+
|
|
749
|
+
// pkgA still holds a done memo minted under the deleted meta. Its
|
|
750
|
+
// generation can never match the re-minted meta's (globally unique
|
|
751
|
+
// values), so the next call re-syncs and answers with real hits.
|
|
752
|
+
// Trusting the stale memo would serve {hits: []} marked semantic
|
|
753
|
+
// over the emptied table.
|
|
754
|
+
const back = await searchReady({ ...base, pkg: pkgA });
|
|
755
|
+
if (!("hits" in back))
|
|
756
|
+
throw new Error("expected hits, got " + JSON.stringify(back));
|
|
757
|
+
expect(back.hits.map((h) => h.name)).toEqual(["alpha"]);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("a sync queued behind a package delete aborts instead of writing under an orphaned meta", async () => {
|
|
761
|
+
// Hold the package mutex via a gated first sync, queue the delete
|
|
762
|
+
// behind it, and queue a second instance's sync behind the delete.
|
|
763
|
+
// When the gate opens: sync 1 completes, the delete purges and
|
|
764
|
+
// orphans the meta, and sync 2 must abort as a no-op rather than
|
|
765
|
+
// re-embedding rows for the deleted package.
|
|
766
|
+
let release!: () => void;
|
|
767
|
+
const gate = new Promise<void>((resolve) => {
|
|
768
|
+
release = resolve;
|
|
769
|
+
});
|
|
770
|
+
const gated = mapProvider(
|
|
771
|
+
{ ...ENTITY_VECTORS, ...QUERY_VECTORS },
|
|
772
|
+
{ gate: { forText: "alpha", until: gate } },
|
|
773
|
+
);
|
|
774
|
+
const base = {
|
|
775
|
+
db,
|
|
776
|
+
environmentName: "env",
|
|
777
|
+
packageName: "pkg",
|
|
778
|
+
entities: [entity("alpha", "src")],
|
|
779
|
+
query: "find alpha",
|
|
780
|
+
limit: 10,
|
|
781
|
+
};
|
|
782
|
+
// Sync 1 (holds the mutex at its embed once it starts).
|
|
783
|
+
const s1 = trySemanticSearch({
|
|
784
|
+
...base,
|
|
785
|
+
provider: gated.provider,
|
|
786
|
+
pkg: {} as unknown as Package,
|
|
787
|
+
});
|
|
788
|
+
// Delete queues behind sync 1.
|
|
789
|
+
const del = deletePackageEmbeddings(db, "env", "pkg");
|
|
790
|
+
// Sync 2 queues behind the delete, under the SAME (old) meta.
|
|
791
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
792
|
+
const s2kick = trySemanticSearch({
|
|
793
|
+
...base,
|
|
794
|
+
provider,
|
|
795
|
+
pkg: {} as unknown as Package,
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
release();
|
|
799
|
+
await Promise.all([s1, del, s2kick]);
|
|
800
|
+
|
|
801
|
+
// The orphaned sync runs as soon as the delete releases the mutex.
|
|
802
|
+
// Poll a failure-detection window: if the orphan guard were gone,
|
|
803
|
+
// sync 2 would re-insert alpha within milliseconds; with it, the
|
|
804
|
+
// table stays empty for the whole window.
|
|
805
|
+
let resurrected = 0;
|
|
806
|
+
for (let i = 0; i < 60; i++) {
|
|
807
|
+
const rows = await db.all<{ n: number }>(
|
|
808
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
|
|
809
|
+
);
|
|
810
|
+
resurrected = rows[0].n;
|
|
811
|
+
if (resurrected > 0) break;
|
|
812
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
813
|
+
}
|
|
814
|
+
expect(resurrected).toBe(0);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("answers indexing when the heal finds the mutex held mid-flight (busy path)", async () => {
|
|
818
|
+
// pkgA synced at 3 dims; a 4-dim query makes its rows stale, so
|
|
819
|
+
// the call reaches the heal.
|
|
820
|
+
const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
821
|
+
const base = {
|
|
822
|
+
db,
|
|
823
|
+
environmentName: "env",
|
|
824
|
+
packageName: "pkg",
|
|
825
|
+
entities: [entity("alpha", "src")],
|
|
826
|
+
query: "find alpha",
|
|
827
|
+
limit: 10,
|
|
828
|
+
};
|
|
829
|
+
const pkgA = {} as unknown as Package;
|
|
830
|
+
await searchReady({ ...base, provider: narrow.provider, pkg: pkgA });
|
|
831
|
+
const initialRows = await db.all<{ content_hash: string }>(
|
|
832
|
+
"SELECT content_hash FROM entity_embeddings WHERE environment_name = 'env' AND entity_name = 'alpha'",
|
|
833
|
+
);
|
|
834
|
+
const staleHash = initialRows[0].content_hash;
|
|
835
|
+
|
|
836
|
+
// Call C: gate its out-of-mutex stale-count SELECT so we can slip
|
|
837
|
+
// a mutex-holding sync in between the isLocked() check and the
|
|
838
|
+
// heal's acquire, the exact race the tryAcquire busy path covers.
|
|
839
|
+
let releaseSelect!: () => void;
|
|
840
|
+
const selectGate = new Promise<void>((resolve) => {
|
|
841
|
+
releaseSelect = resolve;
|
|
842
|
+
});
|
|
843
|
+
let gatedOnce = false;
|
|
844
|
+
const gatedDb = new Proxy(db, {
|
|
845
|
+
get(target, prop, receiver) {
|
|
846
|
+
if (prop === "get") {
|
|
847
|
+
return async (query: string, params?: unknown[]) => {
|
|
848
|
+
if (!gatedOnce && query.includes("NOT (embedding_model")) {
|
|
849
|
+
gatedOnce = true;
|
|
850
|
+
await selectGate;
|
|
851
|
+
}
|
|
852
|
+
return target.get(query, params);
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return Reflect.get(target, prop, receiver);
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
const wide = mapProvider({
|
|
859
|
+
alpha: [1, 0, 0, 0],
|
|
860
|
+
"find alpha": [1, 0, 0, 0],
|
|
861
|
+
});
|
|
862
|
+
const cPromise = trySemanticSearch({
|
|
863
|
+
...base,
|
|
864
|
+
db: gatedDb,
|
|
865
|
+
provider: wide.provider,
|
|
866
|
+
pkg: pkgA,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// ORDER IS LOAD-BEARING: wait until C is provably parked at the
|
|
870
|
+
// gated SELECT (it has already passed the isLocked() check with
|
|
871
|
+
// the mutex free) BEFORE the holder takes the mutex. Kicking the
|
|
872
|
+
// holder first would park the mutex before C's isLocked() check,
|
|
873
|
+
// and C would exit through that pre-existing guard without ever
|
|
874
|
+
// reaching the tryAcquire busy path this test exists to pin.
|
|
875
|
+
for (let i = 0; i < 200 && !gatedOnce; i++) {
|
|
876
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
877
|
+
}
|
|
878
|
+
expect(gatedOnce).toBe(true);
|
|
879
|
+
|
|
880
|
+
// Now a sync with a changed entity takes and holds the package
|
|
881
|
+
// mutex (its provider fetch is gated).
|
|
882
|
+
let releaseSync!: () => void;
|
|
883
|
+
const syncGate = new Promise<void>((resolve) => {
|
|
884
|
+
releaseSync = resolve;
|
|
885
|
+
});
|
|
886
|
+
const holder = mapProvider(
|
|
887
|
+
{ ...QUERY_VECTORS, "alpha: changed": [0, 1, 0] },
|
|
888
|
+
{ gate: { forText: "alpha: changed", until: syncGate } },
|
|
889
|
+
);
|
|
890
|
+
const holdKick = trySemanticSearch({
|
|
891
|
+
...base,
|
|
892
|
+
provider: holder.provider,
|
|
893
|
+
pkg: {} as unknown as Package,
|
|
894
|
+
entities: [entity("alpha", "src", "changed")],
|
|
895
|
+
});
|
|
896
|
+
await holdKick;
|
|
897
|
+
|
|
898
|
+
// Release C: it finds stale rows, tries the heal, and must answer
|
|
899
|
+
// indexing immediately (busy) instead of queueing behind the held
|
|
900
|
+
// mutex for the sync's duration. With a blocking acquire instead
|
|
901
|
+
// of tryAcquire, C queues behind the still-gated holder and the
|
|
902
|
+
// race below times out.
|
|
903
|
+
releaseSelect();
|
|
904
|
+
const timeoutMarker = Symbol("timeout");
|
|
905
|
+
const raced = await Promise.race([
|
|
906
|
+
cPromise,
|
|
907
|
+
new Promise((resolve) =>
|
|
908
|
+
setTimeout(() => resolve(timeoutMarker), 1_000),
|
|
909
|
+
),
|
|
910
|
+
]);
|
|
911
|
+
expect(raced).toEqual({ unavailable: "indexing" });
|
|
912
|
+
|
|
913
|
+
// Let the holder sync settle deterministically (its upsert changes
|
|
914
|
+
// alpha's content hash) so no write leaks past the next test's
|
|
915
|
+
// cleanup.
|
|
916
|
+
releaseSync();
|
|
917
|
+
for (let i = 0; i < 200; i++) {
|
|
918
|
+
const rows = await db.all<{ content_hash: string }>(
|
|
919
|
+
"SELECT content_hash FROM entity_embeddings WHERE environment_name = 'env' AND entity_name = 'alpha'",
|
|
920
|
+
);
|
|
921
|
+
if (rows.length === 1 && rows[0].content_hash !== staleHash) break;
|
|
922
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("deletion helpers drop a package's and an environment's rows", async () => {
|
|
927
|
+
const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
928
|
+
const base = { db, provider, query: "find alpha", limit: 10 };
|
|
929
|
+
await searchReady({
|
|
930
|
+
...base,
|
|
931
|
+
environmentName: "env",
|
|
932
|
+
packageName: "pkg-a",
|
|
933
|
+
pkg: {} as unknown as Package,
|
|
934
|
+
entities: [entity("alpha", "src")],
|
|
935
|
+
});
|
|
936
|
+
await searchReady({
|
|
937
|
+
...base,
|
|
938
|
+
environmentName: "env",
|
|
939
|
+
packageName: "pkg-b",
|
|
940
|
+
pkg: {} as unknown as Package,
|
|
941
|
+
entities: [entity("beta", "src")],
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
await deletePackageEmbeddings(db, "env", "pkg-a");
|
|
945
|
+
const afterPkg = await db.all<{ package_name: string }>(
|
|
946
|
+
"SELECT DISTINCT package_name FROM entity_embeddings WHERE environment_name = 'env' ORDER BY package_name",
|
|
947
|
+
);
|
|
948
|
+
expect(afterPkg.map((r) => r.package_name)).toEqual(["pkg-b"]);
|
|
949
|
+
|
|
950
|
+
// Clear the sync metas so pkg-b has none: the environment delete
|
|
951
|
+
// must then reach its rows via the final env-wide sweep, whose
|
|
952
|
+
// whole purpose is packages never queried in this process. Without
|
|
953
|
+
// this reset the per-meta loop would empty the table first and the
|
|
954
|
+
// sweep would pass vacuously.
|
|
955
|
+
_resetEmbeddingIndexStateForTests();
|
|
956
|
+
await deleteEnvironmentEmbeddings(db, "env");
|
|
957
|
+
const afterEnv = await db.all<{ n: number }>(
|
|
958
|
+
"SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
|
|
959
|
+
);
|
|
960
|
+
expect(afterEnv[0].n).toBe(0);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it("backs off instead of purging twice within the cool-down window", async () => {
|
|
964
|
+
const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
965
|
+
const base = {
|
|
966
|
+
db,
|
|
967
|
+
environmentName: "env",
|
|
968
|
+
packageName: "pkg",
|
|
969
|
+
entities: [entity("alpha", "src")],
|
|
970
|
+
query: "find alpha",
|
|
971
|
+
limit: 10,
|
|
972
|
+
};
|
|
973
|
+
await searchReady({
|
|
974
|
+
...base,
|
|
975
|
+
provider: narrow.provider,
|
|
976
|
+
pkg: {} as unknown as Package,
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// First dims flip: heals via one purge + re-sync at 4 dims.
|
|
980
|
+
const wide = mapProvider({
|
|
981
|
+
alpha: [1, 0, 0, 0],
|
|
982
|
+
"find alpha": [1, 0, 0, 0],
|
|
983
|
+
});
|
|
984
|
+
const healed = await searchReady({
|
|
985
|
+
...base,
|
|
986
|
+
provider: wide.provider,
|
|
987
|
+
pkg: {} as unknown as Package,
|
|
988
|
+
});
|
|
989
|
+
if (!("hits" in healed)) throw new Error("expected hits");
|
|
990
|
+
|
|
991
|
+
// Second flip inside the window (back to 3 dims): the endpoint is
|
|
992
|
+
// inconsistent; the heal must NOT purge again but cool down, and
|
|
993
|
+
// the 4-dim rows must survive. One instance throughout, so the
|
|
994
|
+
// poll passes its cold start and reaches the heal check.
|
|
995
|
+
const pkgC = {} as unknown as Package;
|
|
996
|
+
let result = await trySemanticSearch({
|
|
997
|
+
...base,
|
|
998
|
+
provider: narrow.provider,
|
|
999
|
+
pkg: pkgC,
|
|
1000
|
+
});
|
|
1001
|
+
for (
|
|
1002
|
+
let i = 0;
|
|
1003
|
+
i < 200 &&
|
|
1004
|
+
"unavailable" in result &&
|
|
1005
|
+
result.unavailable === "indexing";
|
|
1006
|
+
i++
|
|
1007
|
+
) {
|
|
1008
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1009
|
+
result = await trySemanticSearch({
|
|
1010
|
+
...base,
|
|
1011
|
+
provider: narrow.provider,
|
|
1012
|
+
pkg: pkgC,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
expect(result).toEqual({ unavailable: "cooldown" });
|
|
1016
|
+
const rows = await db.all<{ dims: number }>(
|
|
1017
|
+
"SELECT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
|
|
1018
|
+
);
|
|
1019
|
+
expect(rows.map((r) => r.dims)).toEqual([4]);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("does not re-purge once per cooldown window: suppression outlasts the cooldown", async () => {
|
|
1023
|
+
// The core of blocker 4. cooldown 15ms, suppression 400ms: after
|
|
1024
|
+
// the cooldown clears (but well inside the suppression window) a
|
|
1025
|
+
// fresh mismatch must BACK OFF, not re-purge. With the co-anchored
|
|
1026
|
+
// bug (equal windows) the first post-cooldown call re-purges and
|
|
1027
|
+
// full-re-embeds; this test fails against that.
|
|
1028
|
+
_setTimingForTests({ cooldownMs: 15, purgeSuppressionMs: 400 });
|
|
1029
|
+
const base = {
|
|
1030
|
+
db,
|
|
1031
|
+
environmentName: "env",
|
|
1032
|
+
packageName: "pkg",
|
|
1033
|
+
entities: [entity("alpha", "src")],
|
|
1034
|
+
query: "find alpha",
|
|
1035
|
+
limit: 10,
|
|
1036
|
+
};
|
|
1037
|
+
// Sync at 3 dims, then flip to 4: one purge + resync leaves 4-dim rows.
|
|
1038
|
+
const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
1039
|
+
await searchReady({
|
|
1040
|
+
...base,
|
|
1041
|
+
provider: narrow.provider,
|
|
1042
|
+
pkg: {} as unknown as Package,
|
|
1043
|
+
});
|
|
1044
|
+
const wide = mapProvider({
|
|
1045
|
+
alpha: [1, 0, 0, 0],
|
|
1046
|
+
"find alpha": [1, 0, 0, 0],
|
|
1047
|
+
});
|
|
1048
|
+
const healed = await searchReady({
|
|
1049
|
+
...base,
|
|
1050
|
+
provider: wide.provider,
|
|
1051
|
+
pkg: {} as unknown as Package,
|
|
1052
|
+
});
|
|
1053
|
+
if (!("hits" in healed)) throw new Error("expected hits");
|
|
1054
|
+
const purgeAt = _lastPurgeAtMsForTests("env", "pkg") ?? 0;
|
|
1055
|
+
expect(purgeAt).toBeGreaterThan(0);
|
|
1056
|
+
|
|
1057
|
+
// Query back at 3 dims (rows are 4-dim, so stale) across several
|
|
1058
|
+
// cooldown-expiry cycles, all inside the 400ms suppression window.
|
|
1059
|
+
const pkgQ = {} as unknown as Package;
|
|
1060
|
+
for (let cycle = 0; cycle < 4; cycle++) {
|
|
1061
|
+
let r = await trySemanticSearch({
|
|
1062
|
+
...base,
|
|
1063
|
+
provider: narrow.provider,
|
|
1064
|
+
pkg: pkgQ,
|
|
1065
|
+
});
|
|
1066
|
+
for (
|
|
1067
|
+
let j = 0;
|
|
1068
|
+
j < 200 && "unavailable" in r && r.unavailable === "indexing";
|
|
1069
|
+
j++
|
|
1070
|
+
) {
|
|
1071
|
+
await new Promise((resolve) => setTimeout(resolve, 3));
|
|
1072
|
+
r = await trySemanticSearch({
|
|
1073
|
+
...base,
|
|
1074
|
+
provider: narrow.provider,
|
|
1075
|
+
pkg: pkgQ,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
expect(r).toEqual({ unavailable: "cooldown" });
|
|
1079
|
+
await new Promise((resolve) => setTimeout(resolve, 20)); // clear the 15ms cooldown
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Never re-purged: lastPurgeAtMs unchanged and the 4-dim rows survive
|
|
1083
|
+
// (the 3-dim vector was only ever the query, never re-embedded).
|
|
1084
|
+
expect(_lastPurgeAtMsForTests("env", "pkg")).toBe(purgeAt);
|
|
1085
|
+
const dims = await db.all<{ dims: number }>(
|
|
1086
|
+
"SELECT DISTINCT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
|
|
1087
|
+
);
|
|
1088
|
+
expect(dims.map((d) => d.dims)).toEqual([4]);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("re-heals once the suppression window elapses (self-recovery)", async () => {
|
|
1092
|
+
// Same setup but a short suppression window: after it elapses a
|
|
1093
|
+
// mismatch re-purges and re-adopts the current dims, so a provider
|
|
1094
|
+
// that stabilizes on a new dimensionality is not stranded lexical.
|
|
1095
|
+
_setTimingForTests({ cooldownMs: 10, purgeSuppressionMs: 60 });
|
|
1096
|
+
const base = {
|
|
1097
|
+
db,
|
|
1098
|
+
environmentName: "env",
|
|
1099
|
+
packageName: "pkg",
|
|
1100
|
+
entities: [entity("alpha", "src")],
|
|
1101
|
+
query: "find alpha",
|
|
1102
|
+
limit: 10,
|
|
1103
|
+
};
|
|
1104
|
+
const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
1105
|
+
await searchReady({
|
|
1106
|
+
...base,
|
|
1107
|
+
provider: narrow.provider,
|
|
1108
|
+
pkg: {} as unknown as Package,
|
|
1109
|
+
});
|
|
1110
|
+
const wide = mapProvider({
|
|
1111
|
+
alpha: [1, 0, 0, 0],
|
|
1112
|
+
"find alpha": [1, 0, 0, 0],
|
|
1113
|
+
});
|
|
1114
|
+
const healed = await searchReady({
|
|
1115
|
+
...base,
|
|
1116
|
+
provider: wide.provider,
|
|
1117
|
+
pkg: {} as unknown as Package,
|
|
1118
|
+
});
|
|
1119
|
+
if (!("hits" in healed)) throw new Error("expected hits");
|
|
1120
|
+
const purgeAt = _lastPurgeAtMsForTests("env", "pkg") ?? 0;
|
|
1121
|
+
|
|
1122
|
+
// Wait past the 60ms suppression window, then the provider is now
|
|
1123
|
+
// stably 3-dim: the next mismatch re-purges and re-adopts 3 dims.
|
|
1124
|
+
await new Promise((resolve) => setTimeout(resolve, 90));
|
|
1125
|
+
const recovered = await searchReady({
|
|
1126
|
+
...base,
|
|
1127
|
+
provider: narrow.provider,
|
|
1128
|
+
pkg: {} as unknown as Package,
|
|
1129
|
+
});
|
|
1130
|
+
if (!("hits" in recovered))
|
|
1131
|
+
throw new Error("expected hits after re-heal");
|
|
1132
|
+
expect(recovered.hits.map((h) => h.name)).toEqual(["alpha"]);
|
|
1133
|
+
expect(_lastPurgeAtMsForTests("env", "pkg") ?? 0).toBeGreaterThan(
|
|
1134
|
+
purgeAt,
|
|
1135
|
+
);
|
|
1136
|
+
const dims = await db.all<{ dims: number }>(
|
|
1137
|
+
"SELECT DISTINCT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
|
|
1138
|
+
);
|
|
1139
|
+
expect(dims.map((d) => d.dims)).toEqual([3]);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it("a provider failure cools down only its own package, not healthy siblings", async () => {
|
|
1143
|
+
// pkg-a's provider fails on every call; pkg-b (same env) is healthy.
|
|
1144
|
+
const failing = mapProvider(
|
|
1145
|
+
{ ...ENTITY_VECTORS, ...QUERY_VECTORS },
|
|
1146
|
+
{ fail: () => true },
|
|
1147
|
+
);
|
|
1148
|
+
const argsA = {
|
|
1149
|
+
db,
|
|
1150
|
+
provider: failing.provider,
|
|
1151
|
+
pkg: {} as unknown as Package,
|
|
1152
|
+
environmentName: "env",
|
|
1153
|
+
packageName: "pkg-a",
|
|
1154
|
+
entities: [entity("alpha", "src")],
|
|
1155
|
+
query: "find alpha",
|
|
1156
|
+
limit: 10,
|
|
1157
|
+
};
|
|
1158
|
+
let a = await trySemanticSearch(argsA);
|
|
1159
|
+
expect(a).toEqual({ unavailable: "indexing" });
|
|
1160
|
+
for (let i = 0; i < 200 && !isCooldown(a); i++) {
|
|
1161
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1162
|
+
a = await trySemanticSearch(argsA);
|
|
1163
|
+
}
|
|
1164
|
+
expect(a).toEqual({ unavailable: "cooldown" });
|
|
1165
|
+
|
|
1166
|
+
// pkg-b, healthy, same env: a per-package cool-down leaves it able
|
|
1167
|
+
// to reach semantic. A global cool-down (the old bug) would make
|
|
1168
|
+
// this return cooldown and never produce hits.
|
|
1169
|
+
const healthy = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
|
|
1170
|
+
const b = await searchReady({
|
|
1171
|
+
db,
|
|
1172
|
+
provider: healthy.provider,
|
|
1173
|
+
pkg: {} as unknown as Package,
|
|
1174
|
+
environmentName: "env",
|
|
1175
|
+
packageName: "pkg-b",
|
|
1176
|
+
entities: [entity("alpha", "src")],
|
|
1177
|
+
query: "find alpha",
|
|
1178
|
+
limit: 10,
|
|
1179
|
+
});
|
|
1180
|
+
expect("hits" in b).toBe(true);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it("cools down after a provider failure instead of erroring every call", async () => {
|
|
1184
|
+
let failing = true;
|
|
1185
|
+
const { provider } = mapProvider(
|
|
1186
|
+
{ ...ENTITY_VECTORS, ...QUERY_VECTORS },
|
|
1187
|
+
{ fail: () => failing },
|
|
1188
|
+
);
|
|
1189
|
+
const args = {
|
|
1190
|
+
db,
|
|
1191
|
+
provider,
|
|
1192
|
+
pkg: {} as unknown as Package,
|
|
1193
|
+
environmentName: "env",
|
|
1194
|
+
packageName: "pkg",
|
|
1195
|
+
entities: [entity("alpha", "src")],
|
|
1196
|
+
query: "find alpha",
|
|
1197
|
+
limit: 10,
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const first = await trySemanticSearch(args);
|
|
1201
|
+
expect(first).toEqual({ unavailable: "indexing" });
|
|
1202
|
+
// Poll until the background sync failure lands and starts the
|
|
1203
|
+
// cool-down; a fixed sleep would race the rejection handler.
|
|
1204
|
+
let second = await trySemanticSearch(args);
|
|
1205
|
+
for (let i = 0; i < 200 && !isCooldown(second); i++) {
|
|
1206
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1207
|
+
second = await trySemanticSearch(args);
|
|
1208
|
+
}
|
|
1209
|
+
expect(second).toEqual({ unavailable: "cooldown" });
|
|
1210
|
+
|
|
1211
|
+
// After the cool-down clears (test reset) the path recovers.
|
|
1212
|
+
failing = false;
|
|
1213
|
+
_resetEmbeddingIndexStateForTests();
|
|
1214
|
+
const recovered = await searchReady(args);
|
|
1215
|
+
expect("hits" in recovered).toBe(true);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it("stays lexical for oversized packages", async () => {
|
|
1219
|
+
const { provider, counts } = mapProvider(QUERY_VECTORS);
|
|
1220
|
+
const entities = Array.from({ length: 5_001 }, (_, i) =>
|
|
1221
|
+
entity(`e${i}`, "src"),
|
|
1222
|
+
);
|
|
1223
|
+
const result = await trySemanticSearch({
|
|
1224
|
+
db,
|
|
1225
|
+
provider,
|
|
1226
|
+
pkg: {} as unknown as Package,
|
|
1227
|
+
environmentName: "env",
|
|
1228
|
+
packageName: "huge",
|
|
1229
|
+
entities,
|
|
1230
|
+
query: "find alpha",
|
|
1231
|
+
limit: 10,
|
|
1232
|
+
});
|
|
1233
|
+
expect(result).toEqual({ unavailable: "too-many-entities" });
|
|
1234
|
+
expect(counts.size).toBe(0);
|
|
1235
|
+
});
|
|
1236
|
+
});
|