@delali/narsil 0.1.1 → 0.1.2
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.md +39 -0
- package/dist/adapters/filesystem.mjs +1 -1
- package/dist/adapters/indexeddb.mjs +1 -1
- package/dist/{chunk-JKIBUPAY.mjs → chunk-7GKPWGCN.mjs} +2 -1
- package/dist/chunk-GVN2W4VA.mjs +570 -0
- package/dist/chunk-HRABJLPB.mjs +64 -0
- package/dist/{chunk-5HXMVMSM.mjs → chunk-LIRVYUQ5.mjs} +1174 -181
- package/dist/direct-executor-DFRFDLFP.mjs +4 -0
- package/dist/index.d.ts +46 -3
- package/dist/index.mjs +1367 -415
- package/dist/vector/hnsw-build-worker.d.ts +37 -0
- package/dist/vector/hnsw-build-worker.mjs +81 -0
- package/dist/workers/entry.mjs +3 -32
- package/package.json +17 -11
- package/dist/chunk-N37VMC62.mjs +0 -18
- package/dist/direct-executor-MQD7JRD4.mjs +0 -3
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,413 @@
|
|
|
1
|
-
import { createDirectExecutor,
|
|
2
|
-
export { getLanguage, registerLanguage } from './chunk-
|
|
1
|
+
import { createDirectExecutor, createPartitionRouter, getLanguage, validateSchema, deserializePayloadV2, tokenize, fanOutQuery, createPartitionIndex, detectRuntime, fnv1a, kWayMerge } from './chunk-LIRVYUQ5.mjs';
|
|
2
|
+
export { getLanguage, registerLanguage } from './chunk-LIRVYUQ5.mjs';
|
|
3
|
+
export { isSimdAvailable } from './chunk-GVN2W4VA.mjs';
|
|
3
4
|
import './chunk-WYC635GI.mjs';
|
|
4
|
-
import { NarsilError, ErrorCodes } from './chunk-
|
|
5
|
-
export { ErrorCodes, NarsilError } from './chunk-
|
|
6
|
-
import { generateId } from './chunk-
|
|
5
|
+
import { NarsilError, ErrorCodes } from './chunk-7GKPWGCN.mjs';
|
|
6
|
+
export { ErrorCodes, NarsilError } from './chunk-7GKPWGCN.mjs';
|
|
7
|
+
import { generateId, isValidWorkerResponse, createRequestId } from './chunk-HRABJLPB.mjs';
|
|
8
|
+
import { decode } from '@msgpack/msgpack';
|
|
9
|
+
|
|
10
|
+
// src/workers/worker-executor.ts
|
|
11
|
+
var DEFAULT_BACKPRESSURE_LIMIT = 100;
|
|
12
|
+
var DEFAULT_REQUEST_TIMEOUT = 3e4;
|
|
13
|
+
var SHUTDOWN_TIMEOUT = 5e3;
|
|
14
|
+
function createWorkerExecutor(worker, config) {
|
|
15
|
+
const backpressureLimit = DEFAULT_BACKPRESSURE_LIMIT;
|
|
16
|
+
const requestTimeout = DEFAULT_REQUEST_TIMEOUT;
|
|
17
|
+
const pending = /* @__PURE__ */ new Map();
|
|
18
|
+
function processResponse(msg) {
|
|
19
|
+
if (!isValidWorkerResponse(msg)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const response = msg;
|
|
23
|
+
const entry = pending.get(response.requestId);
|
|
24
|
+
if (!entry) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
clearTimeout(entry.timeoutId);
|
|
28
|
+
pending.delete(response.requestId);
|
|
29
|
+
if (response.type === "error") {
|
|
30
|
+
entry.reject(new NarsilError(response.code, response.message));
|
|
31
|
+
} else {
|
|
32
|
+
entry.resolve(response.data);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (typeof worker.on === "function") {
|
|
36
|
+
worker.on("message", (msg) => processResponse(msg));
|
|
37
|
+
} else if (typeof worker.addEventListener === "function") {
|
|
38
|
+
worker.addEventListener("message", (event) => {
|
|
39
|
+
const msg = event.data;
|
|
40
|
+
processResponse(msg);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function execute(action) {
|
|
44
|
+
if (pending.size >= backpressureLimit) {
|
|
45
|
+
return Promise.reject(
|
|
46
|
+
new NarsilError(ErrorCodes.WORKER_BUSY, `Backpressure limit of ${backpressureLimit} pending requests reached`)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const requestId = createRequestId();
|
|
50
|
+
const taggedAction = { ...action, requestId };
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timeoutId = setTimeout(() => {
|
|
53
|
+
pending.delete(requestId);
|
|
54
|
+
reject(new NarsilError(ErrorCodes.WORKER_TIMEOUT, `Request ${requestId} timed out after ${requestTimeout}ms`));
|
|
55
|
+
}, requestTimeout);
|
|
56
|
+
pending.set(requestId, {
|
|
57
|
+
resolve,
|
|
58
|
+
reject,
|
|
59
|
+
timeoutId
|
|
60
|
+
});
|
|
61
|
+
worker.postMessage(taggedAction);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function shutdown() {
|
|
65
|
+
const requestId = createRequestId();
|
|
66
|
+
const shutdownAction = { type: "shutdown", requestId };
|
|
67
|
+
const shutdownPromise = new Promise((resolve, reject) => {
|
|
68
|
+
const timeoutId = setTimeout(() => {
|
|
69
|
+
pending.delete(requestId);
|
|
70
|
+
reject(new NarsilError(ErrorCodes.WORKER_TIMEOUT, "Shutdown timed out"));
|
|
71
|
+
}, SHUTDOWN_TIMEOUT);
|
|
72
|
+
pending.set(requestId, {
|
|
73
|
+
resolve: () => resolve(),
|
|
74
|
+
reject,
|
|
75
|
+
timeoutId
|
|
76
|
+
});
|
|
77
|
+
worker.postMessage(shutdownAction);
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
await shutdownPromise;
|
|
81
|
+
} finally {
|
|
82
|
+
for (const [id, entry] of pending) {
|
|
83
|
+
clearTimeout(entry.timeoutId);
|
|
84
|
+
entry.reject(new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker is shutting down"));
|
|
85
|
+
pending.delete(id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { execute, shutdown };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/workers/factory.ts
|
|
93
|
+
function resolveEntryPoint() {
|
|
94
|
+
const base = import.meta.url;
|
|
95
|
+
if (base.endsWith(".mjs") || base.endsWith(".js")) {
|
|
96
|
+
return new URL("./workers/entry.mjs", base).href;
|
|
97
|
+
}
|
|
98
|
+
return base.replace(/\/src\/workers\/[^/]+$/, "/dist/workers/entry.mjs");
|
|
99
|
+
}
|
|
100
|
+
async function createWorkerFactory(entryPoint) {
|
|
101
|
+
const runtime = detectRuntime();
|
|
102
|
+
const resolvedEntry = resolveEntryPoint();
|
|
103
|
+
if (runtime.supportsWorkerThreads) {
|
|
104
|
+
const workerThreadsModule = await import('worker_threads');
|
|
105
|
+
return function nodeFactory(_workerId) {
|
|
106
|
+
const instance = new workerThreadsModule.Worker(new URL(resolvedEntry));
|
|
107
|
+
return createWorkerExecutor(instance);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (runtime.supportsWebWorkers) {
|
|
111
|
+
return function webFactory(_workerId) {
|
|
112
|
+
const instance = new Worker(resolvedEntry, { type: "module" });
|
|
113
|
+
return createWorkerExecutor(instance);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
throw new NarsilError(ErrorCodes.WORKER_CRASHED, `No worker support available on runtime "${runtime.runtime}"`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/workers/pool.ts
|
|
120
|
+
function resolveWorkerCount(requested) {
|
|
121
|
+
if (requested !== void 0 && requested > 0) {
|
|
122
|
+
return requested;
|
|
123
|
+
}
|
|
124
|
+
let cpuCount = 4;
|
|
125
|
+
try {
|
|
126
|
+
if (navigator?.hardwareConcurrency) {
|
|
127
|
+
cpuCount = navigator.hardwareConcurrency;
|
|
128
|
+
} else if (typeof process !== "undefined") {
|
|
129
|
+
const ap = process.availableParallelism;
|
|
130
|
+
if (typeof ap === "function") {
|
|
131
|
+
cpuCount = ap();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
cpuCount = 4;
|
|
136
|
+
}
|
|
137
|
+
return Math.max(2, Math.min(8, cpuCount - 1));
|
|
138
|
+
}
|
|
139
|
+
function createWorkerPool(config) {
|
|
140
|
+
const workerCount = resolveWorkerCount(config.count);
|
|
141
|
+
const workers = [];
|
|
142
|
+
const indexAssignment = /* @__PURE__ */ new Map();
|
|
143
|
+
let isShutdown = false;
|
|
144
|
+
function ensureWorker(slotIndex) {
|
|
145
|
+
if (!workers[slotIndex]) {
|
|
146
|
+
workers[slotIndex] = {
|
|
147
|
+
executor: config.workerFactory(slotIndex),
|
|
148
|
+
indexes: /* @__PURE__ */ new Set(),
|
|
149
|
+
memoryStats: { heapUsed: 0, heapTotal: 0, external: 0 }
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return workers[slotIndex];
|
|
153
|
+
}
|
|
154
|
+
function assignSlot(indexName) {
|
|
155
|
+
return fnv1a(indexName) % workerCount;
|
|
156
|
+
}
|
|
157
|
+
function addIndex(indexName) {
|
|
158
|
+
if (isShutdown) {
|
|
159
|
+
throw new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker pool has been shut down");
|
|
160
|
+
}
|
|
161
|
+
if (indexAssignment.has(indexName)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const slotIndex = assignSlot(indexName);
|
|
165
|
+
const slot = ensureWorker(slotIndex);
|
|
166
|
+
slot.indexes.add(indexName);
|
|
167
|
+
indexAssignment.set(indexName, slotIndex);
|
|
168
|
+
}
|
|
169
|
+
function removeIndex(indexName) {
|
|
170
|
+
const slotIndex = indexAssignment.get(indexName);
|
|
171
|
+
if (slotIndex === void 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const slot = workers[slotIndex];
|
|
175
|
+
if (slot) {
|
|
176
|
+
slot.indexes.delete(indexName);
|
|
177
|
+
}
|
|
178
|
+
indexAssignment.delete(indexName);
|
|
179
|
+
}
|
|
180
|
+
function getExecutor(indexName) {
|
|
181
|
+
if (isShutdown) {
|
|
182
|
+
throw new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker pool has been shut down");
|
|
183
|
+
}
|
|
184
|
+
const slotIndex = indexAssignment.get(indexName);
|
|
185
|
+
if (slotIndex === void 0) {
|
|
186
|
+
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" is not registered in the worker pool`);
|
|
187
|
+
}
|
|
188
|
+
return workers[slotIndex].executor;
|
|
189
|
+
}
|
|
190
|
+
function getMemoryStats() {
|
|
191
|
+
const stats = [];
|
|
192
|
+
for (let i = 0; i < workers.length; i++) {
|
|
193
|
+
const slot = workers[i];
|
|
194
|
+
if (slot) {
|
|
195
|
+
stats.push({
|
|
196
|
+
workerId: i,
|
|
197
|
+
heapUsed: slot.memoryStats.heapUsed,
|
|
198
|
+
heapTotal: slot.memoryStats.heapTotal,
|
|
199
|
+
external: slot.memoryStats.external
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return stats;
|
|
204
|
+
}
|
|
205
|
+
async function shutdown() {
|
|
206
|
+
if (isShutdown) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
isShutdown = true;
|
|
210
|
+
const shutdownPromises = workers.filter(Boolean).map((slot) => {
|
|
211
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
212
|
+
setTimeout(resolve, 5e3);
|
|
213
|
+
});
|
|
214
|
+
return Promise.race([slot.executor.shutdown(), timeoutPromise]);
|
|
215
|
+
});
|
|
216
|
+
await Promise.allSettled(shutdownPromises);
|
|
217
|
+
indexAssignment.clear();
|
|
218
|
+
}
|
|
219
|
+
function getAllExecutors() {
|
|
220
|
+
return workers.filter(Boolean).map((slot) => slot.executor);
|
|
221
|
+
}
|
|
222
|
+
function addIndexToAll(indexName) {
|
|
223
|
+
if (isShutdown) {
|
|
224
|
+
throw new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker pool has been shut down");
|
|
225
|
+
}
|
|
226
|
+
for (let i = 0; i < workerCount; i++) {
|
|
227
|
+
const slot = ensureWorker(i);
|
|
228
|
+
slot.indexes.add(indexName);
|
|
229
|
+
}
|
|
230
|
+
indexAssignment.set(indexName, 0);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
getExecutor,
|
|
234
|
+
getAllExecutors,
|
|
235
|
+
get workerCount() {
|
|
236
|
+
return workerCount;
|
|
237
|
+
},
|
|
238
|
+
addIndex,
|
|
239
|
+
addIndexToAll,
|
|
240
|
+
removeIndex,
|
|
241
|
+
getMemoryStats,
|
|
242
|
+
shutdown
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/engine/orchestration.ts
|
|
247
|
+
function createWorkerOrchestrator(config, executor, promoter, indexRegistry, callbacks) {
|
|
248
|
+
let workerPool = null;
|
|
249
|
+
let promotionInProgress = false;
|
|
250
|
+
const promotionBuffer = [];
|
|
251
|
+
const workersEnabled = config?.workers?.enabled === true;
|
|
252
|
+
async function checkPromotion() {
|
|
253
|
+
if (!workersEnabled || promotionInProgress || workerPool) return;
|
|
254
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
255
|
+
for (const [name] of indexRegistry) {
|
|
256
|
+
const mgr = executor.getManager(name);
|
|
257
|
+
indexMap.set(name, { documentCount: mgr?.countDocuments() ?? 0 });
|
|
258
|
+
}
|
|
259
|
+
const result = promoter.check(indexMap);
|
|
260
|
+
if (result.shouldPromote) {
|
|
261
|
+
promotionInProgress = true;
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
runPromotion(result.reason).catch((err) => {
|
|
264
|
+
console.warn("Worker promotion failed:", err);
|
|
265
|
+
promotionInProgress = false;
|
|
266
|
+
});
|
|
267
|
+
}, 0);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function runPromotion(reason) {
|
|
271
|
+
try {
|
|
272
|
+
const factory = await createWorkerFactory();
|
|
273
|
+
const pool = createWorkerPool({
|
|
274
|
+
count: config?.workers?.count,
|
|
275
|
+
workerFactory: factory
|
|
276
|
+
});
|
|
277
|
+
for (const [name] of indexRegistry) {
|
|
278
|
+
pool.addIndexToAll(name);
|
|
279
|
+
}
|
|
280
|
+
const allExecutors = pool.getAllExecutors();
|
|
281
|
+
for (const [name, entry] of indexRegistry) {
|
|
282
|
+
await Promise.all(
|
|
283
|
+
allExecutors.map(
|
|
284
|
+
(workerExecutor) => workerExecutor.execute({
|
|
285
|
+
type: "createIndex",
|
|
286
|
+
indexName: name,
|
|
287
|
+
config: entry.config,
|
|
288
|
+
requestId: `promote-create-${name}`
|
|
289
|
+
})
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
const manager = executor.getManager(name);
|
|
293
|
+
if (manager) {
|
|
294
|
+
for (let i = 0; i < manager.partitionCount; i++) {
|
|
295
|
+
const serialized = manager.serializePartition(i);
|
|
296
|
+
await Promise.all(
|
|
297
|
+
allExecutors.map(
|
|
298
|
+
(workerExecutor) => workerExecutor.execute({
|
|
299
|
+
type: "deserialize",
|
|
300
|
+
indexName: name,
|
|
301
|
+
partitionId: i,
|
|
302
|
+
data: serialized,
|
|
303
|
+
requestId: `promote-sync-${name}-${i}`
|
|
304
|
+
})
|
|
305
|
+
)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
workerPool = pool;
|
|
311
|
+
promoter.markPromoted();
|
|
312
|
+
if (promotionBuffer.length > 0) {
|
|
313
|
+
const buffered = [...promotionBuffer];
|
|
314
|
+
promotionBuffer.length = 0;
|
|
315
|
+
for (const action of buffered) {
|
|
316
|
+
await replicateToWorkers(action);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
callbacks?.onPromotion?.(pool.workerCount, reason);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
promotionBuffer.length = 0;
|
|
322
|
+
throw err;
|
|
323
|
+
} finally {
|
|
324
|
+
promotionInProgress = false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async function replicateToWorkers(action) {
|
|
328
|
+
if (promotionInProgress) {
|
|
329
|
+
promotionBuffer.push(action);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!workerPool) return;
|
|
333
|
+
const allExecutors = workerPool.getAllExecutors();
|
|
334
|
+
const results = await Promise.allSettled(allExecutors.map((workerExecutor) => workerExecutor.execute(action)));
|
|
335
|
+
for (const result of results) {
|
|
336
|
+
if (result.status === "rejected") {
|
|
337
|
+
console.warn("Worker replication failed:", result.reason);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function searchViaWorker(indexName, params) {
|
|
342
|
+
if (!workerPool) return null;
|
|
343
|
+
const manager = executor.getManager(indexName);
|
|
344
|
+
if (!manager) return null;
|
|
345
|
+
const allExecutors = workerPool.getAllExecutors();
|
|
346
|
+
const totalPartitions = manager.partitionCount;
|
|
347
|
+
const numWorkers = allExecutors.length;
|
|
348
|
+
if (numWorkers === 0) return null;
|
|
349
|
+
if (numWorkers === 1) {
|
|
350
|
+
try {
|
|
351
|
+
return await allExecutors[0].execute({
|
|
352
|
+
type: "query",
|
|
353
|
+
indexName,
|
|
354
|
+
params,
|
|
355
|
+
requestId: createRequestId()
|
|
356
|
+
});
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.warn("Worker search failed, falling back to local:", err);
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const workerAssignments = Array.from({ length: numWorkers }, () => []);
|
|
364
|
+
for (let p = 0; p < totalPartitions; p++) {
|
|
365
|
+
workerAssignments[p % numWorkers].push(p);
|
|
366
|
+
}
|
|
367
|
+
const results = await Promise.all(
|
|
368
|
+
allExecutors.map(
|
|
369
|
+
(workerExecutor, idx) => workerExecutor.execute({
|
|
370
|
+
type: "query",
|
|
371
|
+
indexName,
|
|
372
|
+
params,
|
|
373
|
+
requestId: createRequestId(),
|
|
374
|
+
partitionIds: workerAssignments[idx]
|
|
375
|
+
})
|
|
376
|
+
)
|
|
377
|
+
);
|
|
378
|
+
const allScored = results.map((r) => r.scored);
|
|
379
|
+
const merged = kWayMerge(allScored);
|
|
380
|
+
let totalMatched = 0;
|
|
381
|
+
for (const r of results) {
|
|
382
|
+
totalMatched += r.totalMatched;
|
|
383
|
+
}
|
|
384
|
+
return { scored: merged, totalMatched };
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.warn("Parallel worker search failed, falling back to local:", err);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function isPromoted() {
|
|
391
|
+
return workerPool !== null;
|
|
392
|
+
}
|
|
393
|
+
function getMemoryStats() {
|
|
394
|
+
const workers = workerPool ? workerPool.getMemoryStats().map((s) => ({
|
|
395
|
+
workerId: s.workerId,
|
|
396
|
+
heapUsed: s.heapUsed,
|
|
397
|
+
heapTotal: s.heapTotal,
|
|
398
|
+
external: s.external
|
|
399
|
+
})) : [];
|
|
400
|
+
const totalBytes = workers.reduce((sum, w) => sum + w.heapUsed, 0);
|
|
401
|
+
return { totalBytes, workers };
|
|
402
|
+
}
|
|
403
|
+
async function shutdown() {
|
|
404
|
+
if (workerPool) {
|
|
405
|
+
await workerPool.shutdown();
|
|
406
|
+
workerPool = null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return { checkPromotion, replicateToWorkers, searchViaWorker, isPromoted, getMemoryStats, shutdown };
|
|
410
|
+
}
|
|
7
411
|
|
|
8
412
|
// src/highlighting/highlighter.ts
|
|
9
413
|
function buildTokenCharOffsets(text, originalTokens) {
|
|
@@ -139,222 +543,66 @@ function highlightField(text, queryTokens, language, options) {
|
|
|
139
543
|
return { snippet: result, positions: mergedPositions };
|
|
140
544
|
}
|
|
141
545
|
|
|
142
|
-
// src/
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
persistence,
|
|
150
|
-
invalidation,
|
|
151
|
-
interval = DEFAULT_INTERVAL,
|
|
152
|
-
mutationThreshold = DEFAULT_MUTATION_THRESHOLD,
|
|
153
|
-
maxRetries = DEFAULT_MAX_RETRIES,
|
|
154
|
-
baseRetryDelay = DEFAULT_BASE_RETRY_DELAY,
|
|
155
|
-
onError
|
|
156
|
-
} = config;
|
|
157
|
-
const dirtyPartitions = /* @__PURE__ */ new Map();
|
|
158
|
-
let mutationCount = 0;
|
|
159
|
-
let flushTimerId = null;
|
|
160
|
-
let isShuttingDown = false;
|
|
161
|
-
let flushPromise = null;
|
|
162
|
-
function startTimer() {
|
|
163
|
-
if (flushTimerId !== null || isShuttingDown) {
|
|
164
|
-
return;
|
|
546
|
+
// src/search/grouping.ts
|
|
547
|
+
function getNestedValue(obj, path) {
|
|
548
|
+
const segments = path.split(".");
|
|
549
|
+
let current = obj;
|
|
550
|
+
for (const segment of segments) {
|
|
551
|
+
if (current === null || current === void 0 || typeof current !== "object") {
|
|
552
|
+
return void 0;
|
|
165
553
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}, interval);
|
|
169
|
-
}
|
|
170
|
-
function addToDirtySet(indexName, partitionId) {
|
|
171
|
-
let partitions = dirtyPartitions.get(indexName);
|
|
172
|
-
if (partitions === void 0) {
|
|
173
|
-
partitions = /* @__PURE__ */ new Set();
|
|
174
|
-
dirtyPartitions.set(indexName, partitions);
|
|
554
|
+
if (!Object.hasOwn(current, segment)) {
|
|
555
|
+
return void 0;
|
|
175
556
|
}
|
|
176
|
-
|
|
557
|
+
current = current[segment];
|
|
177
558
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
dirtyPartitions.clear();
|
|
184
|
-
return snapshot;
|
|
559
|
+
return current;
|
|
560
|
+
}
|
|
561
|
+
function applyGrouping(hits, group, getDocument) {
|
|
562
|
+
if (!group.fields || group.fields.length === 0) {
|
|
563
|
+
return [{ values: {}, hits }];
|
|
185
564
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
565
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
566
|
+
for (const hit of hits) {
|
|
567
|
+
const doc = getDocument(hit.id);
|
|
568
|
+
const fieldValues = [];
|
|
569
|
+
const valuesRecord = {};
|
|
570
|
+
for (const field of group.fields) {
|
|
571
|
+
const value = doc ? getNestedValue(doc, field) : void 0;
|
|
572
|
+
fieldValues.push(value);
|
|
573
|
+
valuesRecord[field] = value;
|
|
574
|
+
}
|
|
575
|
+
const compositeKey = JSON.stringify(fieldValues);
|
|
576
|
+
const existing = groupMap.get(compositeKey);
|
|
577
|
+
if (existing) {
|
|
578
|
+
existing.hits.push(hit);
|
|
579
|
+
} else {
|
|
580
|
+
groupMap.set(compositeKey, { values: valuesRecord, hits: [hit] });
|
|
203
581
|
}
|
|
204
|
-
return false;
|
|
205
582
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
583
|
+
const groups = [];
|
|
584
|
+
for (const entry of groupMap.values()) {
|
|
585
|
+
let groupHits = entry.hits;
|
|
586
|
+
if (group.maxPerGroup !== void 0 && group.maxPerGroup > 0) {
|
|
587
|
+
groupHits = groupHits.slice(0, group.maxPerGroup);
|
|
588
|
+
}
|
|
589
|
+
const result = { values: entry.values, hits: groupHits };
|
|
590
|
+
if (group.reduce) {
|
|
591
|
+
try {
|
|
592
|
+
let accumulator = group.reduce.initialValue();
|
|
593
|
+
for (const hit of groupHits) {
|
|
594
|
+
const doc = getDocument(hit.id);
|
|
595
|
+
if (doc) {
|
|
596
|
+
accumulator = group.reduce.reducer(accumulator, doc, hit.score);
|
|
597
|
+
}
|
|
216
598
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
indexName,
|
|
222
|
-
partitions: succeededPartitions,
|
|
223
|
-
timestamp: Date.now(),
|
|
224
|
-
sourceInstanceId: getInstanceId()
|
|
225
|
-
});
|
|
599
|
+
;
|
|
600
|
+
result.reduced = accumulator;
|
|
601
|
+
} catch (err) {
|
|
602
|
+
result.reducerError = err instanceof Error ? err.message : String(err);
|
|
226
603
|
}
|
|
227
604
|
}
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
function flush() {
|
|
231
|
-
if (flushPromise !== null) {
|
|
232
|
-
return flushPromise;
|
|
233
|
-
}
|
|
234
|
-
flushPromise = performFlush().finally(() => {
|
|
235
|
-
flushPromise = null;
|
|
236
|
-
});
|
|
237
|
-
return flushPromise;
|
|
238
|
-
}
|
|
239
|
-
function markDirty(indexName, partitionId) {
|
|
240
|
-
if (isShuttingDown) return;
|
|
241
|
-
addToDirtySet(indexName, partitionId);
|
|
242
|
-
mutationCount++;
|
|
243
|
-
if (mutationCount >= mutationThreshold) {
|
|
244
|
-
flush();
|
|
245
|
-
}
|
|
246
|
-
startTimer();
|
|
247
|
-
}
|
|
248
|
-
async function shutdown() {
|
|
249
|
-
isShuttingDown = true;
|
|
250
|
-
if (flushTimerId !== null) {
|
|
251
|
-
clearInterval(flushTimerId);
|
|
252
|
-
flushTimerId = null;
|
|
253
|
-
}
|
|
254
|
-
await flush();
|
|
255
|
-
}
|
|
256
|
-
return {
|
|
257
|
-
markDirty,
|
|
258
|
-
flush,
|
|
259
|
-
shutdown
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// src/plugins/registry.ts
|
|
264
|
-
function isThenable(value) {
|
|
265
|
-
return value !== null && typeof value === "object" && typeof value.then === "function";
|
|
266
|
-
}
|
|
267
|
-
async function continueAsync(pending, hooks, startIndex, context) {
|
|
268
|
-
await pending;
|
|
269
|
-
for (let i = startIndex; i < hooks.length; i++) {
|
|
270
|
-
await hooks[i](context);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
function createPluginRegistry() {
|
|
274
|
-
const plugins = [];
|
|
275
|
-
return {
|
|
276
|
-
register(plugin) {
|
|
277
|
-
plugins.push(plugin);
|
|
278
|
-
},
|
|
279
|
-
runHook(hookName, context) {
|
|
280
|
-
const hooks = [];
|
|
281
|
-
for (const plugin of plugins) {
|
|
282
|
-
const hook = plugin[hookName];
|
|
283
|
-
if (typeof hook === "function") {
|
|
284
|
-
hooks.push(hook.bind(plugin));
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
if (hooks.length === 0) return;
|
|
288
|
-
for (let i = 0; i < hooks.length; i++) {
|
|
289
|
-
const result = hooks[i](context);
|
|
290
|
-
if (isThenable(result)) {
|
|
291
|
-
return continueAsync(result, hooks, i + 1, context);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// src/search/grouping.ts
|
|
299
|
-
function getNestedValue(obj, path) {
|
|
300
|
-
const segments = path.split(".");
|
|
301
|
-
let current = obj;
|
|
302
|
-
for (const segment of segments) {
|
|
303
|
-
if (current === null || current === void 0 || typeof current !== "object") {
|
|
304
|
-
return void 0;
|
|
305
|
-
}
|
|
306
|
-
if (!Object.hasOwn(current, segment)) {
|
|
307
|
-
return void 0;
|
|
308
|
-
}
|
|
309
|
-
current = current[segment];
|
|
310
|
-
}
|
|
311
|
-
return current;
|
|
312
|
-
}
|
|
313
|
-
function applyGrouping(hits, group, getDocument) {
|
|
314
|
-
if (!group.fields || group.fields.length === 0) {
|
|
315
|
-
return [{ values: {}, hits }];
|
|
316
|
-
}
|
|
317
|
-
const groupMap = /* @__PURE__ */ new Map();
|
|
318
|
-
for (const hit of hits) {
|
|
319
|
-
const doc = getDocument(hit.id);
|
|
320
|
-
const fieldValues = [];
|
|
321
|
-
const valuesRecord = {};
|
|
322
|
-
for (const field of group.fields) {
|
|
323
|
-
const value = doc ? getNestedValue(doc, field) : void 0;
|
|
324
|
-
fieldValues.push(value);
|
|
325
|
-
valuesRecord[field] = value;
|
|
326
|
-
}
|
|
327
|
-
const compositeKey = JSON.stringify(fieldValues);
|
|
328
|
-
const existing = groupMap.get(compositeKey);
|
|
329
|
-
if (existing) {
|
|
330
|
-
existing.hits.push(hit);
|
|
331
|
-
} else {
|
|
332
|
-
groupMap.set(compositeKey, { values: valuesRecord, hits: [hit] });
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
const groups = [];
|
|
336
|
-
for (const entry of groupMap.values()) {
|
|
337
|
-
let groupHits = entry.hits;
|
|
338
|
-
if (group.maxPerGroup !== void 0 && group.maxPerGroup > 0) {
|
|
339
|
-
groupHits = groupHits.slice(0, group.maxPerGroup);
|
|
340
|
-
}
|
|
341
|
-
const result = { values: entry.values, hits: groupHits };
|
|
342
|
-
if (group.reduce) {
|
|
343
|
-
try {
|
|
344
|
-
let accumulator = group.reduce.initialValue();
|
|
345
|
-
for (const hit of groupHits) {
|
|
346
|
-
const doc = getDocument(hit.id);
|
|
347
|
-
if (doc) {
|
|
348
|
-
accumulator = group.reduce.reducer(accumulator, doc, hit.score);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
;
|
|
352
|
-
result.reduced = accumulator;
|
|
353
|
-
} catch (err) {
|
|
354
|
-
result.reducerError = err instanceof Error ? err.message : String(err);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
groups.push(result);
|
|
605
|
+
groups.push(result);
|
|
358
606
|
}
|
|
359
607
|
groups.sort((a, b) => {
|
|
360
608
|
const aScore = a.hits.length > 0 ? a.hits[0].score : 0;
|
|
@@ -543,13 +791,583 @@ function applySorting(hits, sort, getDocument) {
|
|
|
543
791
|
} else if (typeof aVal === "boolean" && typeof bVal === "boolean") {
|
|
544
792
|
comparison = aVal === bVal ? 0 : aVal ? 1 : -1;
|
|
545
793
|
}
|
|
546
|
-
if (comparison !== 0) {
|
|
547
|
-
return direction === "desc" ? -comparison : comparison;
|
|
794
|
+
if (comparison !== 0) {
|
|
795
|
+
return direction === "desc" ? -comparison : comparison;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return 0;
|
|
799
|
+
});
|
|
800
|
+
return sorted;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/engine/validation.ts
|
|
804
|
+
var INDEX_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
805
|
+
var MAX_INDEX_NAME_LENGTH = 256;
|
|
806
|
+
var MAX_DOC_ID_LENGTH = 512;
|
|
807
|
+
var BATCH_CHUNK_SIZE = 1e3;
|
|
808
|
+
var MAX_LIMIT = 1e4;
|
|
809
|
+
var MAX_OFFSET = 1e5;
|
|
810
|
+
var DEFAULT_LIMIT = 10;
|
|
811
|
+
var DEFAULT_OFFSET = 0;
|
|
812
|
+
function now() {
|
|
813
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
814
|
+
return performance.now();
|
|
815
|
+
}
|
|
816
|
+
return Date.now();
|
|
817
|
+
}
|
|
818
|
+
function validateIndexName(name) {
|
|
819
|
+
if (!name || name.length === 0) {
|
|
820
|
+
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, "Index name must not be empty", { indexName: name });
|
|
821
|
+
}
|
|
822
|
+
if (name.length > MAX_INDEX_NAME_LENGTH) {
|
|
823
|
+
throw new NarsilError(
|
|
824
|
+
ErrorCodes.INDEX_NOT_FOUND,
|
|
825
|
+
`Index name must not exceed ${MAX_INDEX_NAME_LENGTH} characters`,
|
|
826
|
+
{
|
|
827
|
+
indexName: name,
|
|
828
|
+
length: name.length
|
|
829
|
+
}
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
if (!INDEX_NAME_PATTERN.test(name)) {
|
|
833
|
+
throw new NarsilError(
|
|
834
|
+
ErrorCodes.INDEX_NOT_FOUND,
|
|
835
|
+
`Index name "${name}" contains invalid characters; use alphanumeric, dots, hyphens, and underscores only`,
|
|
836
|
+
{ indexName: name }
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
if (name.includes("..")) {
|
|
840
|
+
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index name "${name}" must not contain ".."`, { indexName: name });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function validateDocId(docId) {
|
|
844
|
+
if (!docId || docId.length === 0) {
|
|
845
|
+
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not be empty", { docId });
|
|
846
|
+
}
|
|
847
|
+
if (docId.length > MAX_DOC_ID_LENGTH) {
|
|
848
|
+
throw new NarsilError(
|
|
849
|
+
ErrorCodes.DOC_VALIDATION_FAILED,
|
|
850
|
+
`Document ID must not exceed ${MAX_DOC_ID_LENGTH} characters`,
|
|
851
|
+
{
|
|
852
|
+
docId,
|
|
853
|
+
length: docId.length
|
|
854
|
+
}
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
if (docId.includes("\0")) {
|
|
858
|
+
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not contain null bytes", { docId });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
function clampLimit(limit) {
|
|
862
|
+
if (limit === void 0) return DEFAULT_LIMIT;
|
|
863
|
+
return Math.max(0, Math.min(limit, MAX_LIMIT));
|
|
864
|
+
}
|
|
865
|
+
function clampOffset(offset) {
|
|
866
|
+
if (offset === void 0) return DEFAULT_OFFSET;
|
|
867
|
+
return Math.max(0, Math.min(offset, MAX_OFFSET));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/engine/query.ts
|
|
871
|
+
async function executeQuery(params, context) {
|
|
872
|
+
const { manager, language, config, workerSearch, indexName } = context;
|
|
873
|
+
const startTime = now();
|
|
874
|
+
const limit = clampLimit(params.limit);
|
|
875
|
+
const offset = clampOffset(params.offset);
|
|
876
|
+
let fanOutResult;
|
|
877
|
+
const workerResult = workerSearch ? await workerSearch(indexName, params) : null;
|
|
878
|
+
if (workerResult) {
|
|
879
|
+
fanOutResult = workerResult;
|
|
880
|
+
} else {
|
|
881
|
+
const searchOptions = {
|
|
882
|
+
bm25Params: config.bm25,
|
|
883
|
+
stopWords: config.stopWords,
|
|
884
|
+
customTokenizer: config.tokenizer
|
|
885
|
+
};
|
|
886
|
+
fanOutResult = await fanOutQuery(
|
|
887
|
+
manager,
|
|
888
|
+
params,
|
|
889
|
+
language,
|
|
890
|
+
config.schema,
|
|
891
|
+
{ scoringMode: params.scoring ?? config.defaultScoring ?? "local" },
|
|
892
|
+
searchOptions
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
const needsFullHits = params.sort !== void 0 || params.group !== void 0 || params.pinned !== void 0 || params.searchAfter !== void 0;
|
|
896
|
+
let hits;
|
|
897
|
+
if (needsFullHits) {
|
|
898
|
+
hits = fanOutResult.scored.map((scored) => ({
|
|
899
|
+
id: scored.docId,
|
|
900
|
+
score: scored.score,
|
|
901
|
+
document: void 0,
|
|
902
|
+
scoreComponents: params.includeScoreComponents ? { termFrequencies: scored.termFrequencies, fieldLengths: scored.fieldLengths, idf: scored.idf } : void 0
|
|
903
|
+
}));
|
|
904
|
+
} else {
|
|
905
|
+
const end = Math.min(offset + limit + 1, fanOutResult.scored.length);
|
|
906
|
+
hits = new Array(end);
|
|
907
|
+
for (let i = 0; i < end; i++) {
|
|
908
|
+
const scored = fanOutResult.scored[i];
|
|
909
|
+
hits[i] = {
|
|
910
|
+
id: scored.docId,
|
|
911
|
+
score: scored.score,
|
|
912
|
+
document: void 0,
|
|
913
|
+
scoreComponents: params.includeScoreComponents ? { termFrequencies: scored.termFrequencies, fieldLengths: scored.fieldLengths, idf: scored.idf } : void 0
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (params.sort) {
|
|
918
|
+
hits = applySorting(hits, params.sort, (docId) => manager.getRef(docId));
|
|
919
|
+
}
|
|
920
|
+
let groups;
|
|
921
|
+
if (params.group) {
|
|
922
|
+
groups = applyGrouping(hits, params.group, (docId) => manager.getRef(docId));
|
|
923
|
+
}
|
|
924
|
+
if (params.pinned) {
|
|
925
|
+
hits = applyPinning(hits, params.pinned, (docId) => {
|
|
926
|
+
const doc = manager.getRef(docId);
|
|
927
|
+
if (!doc) return void 0;
|
|
928
|
+
return { id: docId, score: 0, document: doc };
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
const { paginated, nextCursor } = applyPagination(hits, limit, offset, params.searchAfter);
|
|
932
|
+
for (const hit of paginated) {
|
|
933
|
+
hit.document = manager.get(hit.id) ?? {};
|
|
934
|
+
}
|
|
935
|
+
if (groups) {
|
|
936
|
+
for (const group of groups) {
|
|
937
|
+
for (const hit of group.hits) {
|
|
938
|
+
hit.document = manager.get(hit.id) ?? {};
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (params.highlight) {
|
|
943
|
+
applyHighlights(paginated, params, language, config);
|
|
944
|
+
}
|
|
945
|
+
const elapsed = now() - startTime;
|
|
946
|
+
return {
|
|
947
|
+
hits: paginated,
|
|
948
|
+
count: fanOutResult.totalMatched,
|
|
949
|
+
elapsed,
|
|
950
|
+
cursor: nextCursor,
|
|
951
|
+
facets: fanOutResult.facets,
|
|
952
|
+
groups
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
async function executePreflight(params, context) {
|
|
956
|
+
const { manager, language, config, workerSearch, indexName } = context;
|
|
957
|
+
const startTime = now();
|
|
958
|
+
let totalMatched;
|
|
959
|
+
const workerResult = workerSearch ? await workerSearch(indexName, params) : null;
|
|
960
|
+
if (workerResult) {
|
|
961
|
+
totalMatched = workerResult.totalMatched;
|
|
962
|
+
} else {
|
|
963
|
+
const searchOptions = {
|
|
964
|
+
bm25Params: config.bm25,
|
|
965
|
+
stopWords: config.stopWords,
|
|
966
|
+
customTokenizer: config.tokenizer
|
|
967
|
+
};
|
|
968
|
+
const fanOutResult = await fanOutQuery(
|
|
969
|
+
manager,
|
|
970
|
+
params,
|
|
971
|
+
language,
|
|
972
|
+
config.schema,
|
|
973
|
+
{ scoringMode: params.scoring ?? config.defaultScoring ?? "local" },
|
|
974
|
+
searchOptions
|
|
975
|
+
);
|
|
976
|
+
totalMatched = fanOutResult.totalMatched;
|
|
977
|
+
}
|
|
978
|
+
const elapsed = now() - startTime;
|
|
979
|
+
return { count: totalMatched, elapsed };
|
|
980
|
+
}
|
|
981
|
+
function applyHighlights(hits, params, language, config) {
|
|
982
|
+
if (!params.highlight) return;
|
|
983
|
+
const queryTokenResult = tokenize(params.term ?? "", language, {
|
|
984
|
+
stem: true,
|
|
985
|
+
removeStopWords: true,
|
|
986
|
+
customTokenizer: config.tokenizer,
|
|
987
|
+
stopWordOverride: config.stopWords
|
|
988
|
+
});
|
|
989
|
+
for (const hit of hits) {
|
|
990
|
+
const highlights = {};
|
|
991
|
+
for (const field of params.highlight.fields) {
|
|
992
|
+
const doc = hit.document;
|
|
993
|
+
const fieldValue = doc[field];
|
|
994
|
+
if (typeof fieldValue === "string") {
|
|
995
|
+
highlights[field] = highlightField(fieldValue, queryTokenResult.tokens, language, {
|
|
996
|
+
preTag: params.highlight.preTag,
|
|
997
|
+
postTag: params.highlight.postTag,
|
|
998
|
+
maxSnippetLength: params.highlight.maxSnippetLength
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
hit.highlights = highlights;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// src/partitioning/rebalancer.ts
|
|
1007
|
+
var CHUNK_SIZE = 1e3;
|
|
1008
|
+
function createRebalancer() {
|
|
1009
|
+
let rebalancing = false;
|
|
1010
|
+
async function rebalance(manager, newPartitionCount, router, onProgress) {
|
|
1011
|
+
if (rebalancing) {
|
|
1012
|
+
throw new NarsilError(
|
|
1013
|
+
ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
|
|
1014
|
+
"A rebalance operation is already in progress"
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
if (newPartitionCount <= 0) {
|
|
1018
|
+
throw new NarsilError(
|
|
1019
|
+
ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
|
|
1020
|
+
`New partition count must be greater than 0, received ${newPartitionCount}`,
|
|
1021
|
+
{ newPartitionCount }
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
if (newPartitionCount === manager.partitionCount) {
|
|
1025
|
+
throw new NarsilError(
|
|
1026
|
+
ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
|
|
1027
|
+
`New partition count (${newPartitionCount}) is the same as the current count`,
|
|
1028
|
+
{ newPartitionCount, currentPartitionCount: manager.partitionCount }
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
rebalancing = true;
|
|
1032
|
+
try {
|
|
1033
|
+
const collectedDocs = [];
|
|
1034
|
+
const currentPartitions = manager.getAllPartitions();
|
|
1035
|
+
for (const partition of currentPartitions) {
|
|
1036
|
+
for (const docId of partition.docIds()) {
|
|
1037
|
+
const document = partition.get(docId);
|
|
1038
|
+
if (document) {
|
|
1039
|
+
collectedDocs.push({ docId, document });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const documentsTotal = collectedDocs.length;
|
|
1044
|
+
onProgress?.({
|
|
1045
|
+
phase: "scanning",
|
|
1046
|
+
documentsProcessed: 0,
|
|
1047
|
+
documentsTotal
|
|
1048
|
+
});
|
|
1049
|
+
const newPartitions = [];
|
|
1050
|
+
for (let i = 0; i < newPartitionCount; i++) {
|
|
1051
|
+
newPartitions.push(createPartitionIndex(i));
|
|
1052
|
+
}
|
|
1053
|
+
let documentsProcessed = 0;
|
|
1054
|
+
for (let chunkStart = 0; chunkStart < collectedDocs.length; chunkStart += CHUNK_SIZE) {
|
|
1055
|
+
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, collectedDocs.length);
|
|
1056
|
+
for (let i = chunkStart; i < chunkEnd; i++) {
|
|
1057
|
+
const { docId, document } = collectedDocs[i];
|
|
1058
|
+
const targetPartitionId = router.route(docId, newPartitionCount);
|
|
1059
|
+
const targetPartition = newPartitions[targetPartitionId];
|
|
1060
|
+
if (targetPartition.has(docId)) {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
targetPartition.insert(docId, document, manager.schema, manager.language, { validate: false });
|
|
1064
|
+
documentsProcessed++;
|
|
1065
|
+
}
|
|
1066
|
+
onProgress?.({
|
|
1067
|
+
phase: "moving",
|
|
1068
|
+
documentsProcessed,
|
|
1069
|
+
documentsTotal
|
|
1070
|
+
});
|
|
1071
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1072
|
+
}
|
|
1073
|
+
onProgress?.({
|
|
1074
|
+
phase: "swapping",
|
|
1075
|
+
documentsProcessed,
|
|
1076
|
+
documentsTotal
|
|
1077
|
+
});
|
|
1078
|
+
manager.setPartitions(newPartitions);
|
|
1079
|
+
onProgress?.({
|
|
1080
|
+
phase: "complete",
|
|
1081
|
+
documentsProcessed,
|
|
1082
|
+
documentsTotal
|
|
1083
|
+
});
|
|
1084
|
+
} finally {
|
|
1085
|
+
rebalancing = false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
rebalance,
|
|
1090
|
+
isRebalancing() {
|
|
1091
|
+
return rebalancing;
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/partitioning/write-ahead-queue.ts
|
|
1097
|
+
function createWriteAheadQueue(maxSize = 1e4) {
|
|
1098
|
+
const entries = [];
|
|
1099
|
+
let nextSequence = 1;
|
|
1100
|
+
return {
|
|
1101
|
+
push(entry) {
|
|
1102
|
+
if (entries.length >= maxSize) {
|
|
1103
|
+
throw new NarsilError(
|
|
1104
|
+
ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
|
|
1105
|
+
`Write-ahead queue is full (${maxSize} entries). Retry after rebalancing completes.`,
|
|
1106
|
+
{ maxSize, currentSize: entries.length }
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
const seq = nextSequence++;
|
|
1110
|
+
entries.push({ ...entry, sequenceNumber: seq });
|
|
1111
|
+
return seq;
|
|
1112
|
+
},
|
|
1113
|
+
drain() {
|
|
1114
|
+
const result = entries.splice(0);
|
|
1115
|
+
result.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
1116
|
+
return result;
|
|
1117
|
+
},
|
|
1118
|
+
clear() {
|
|
1119
|
+
entries.length = 0;
|
|
1120
|
+
},
|
|
1121
|
+
get size() {
|
|
1122
|
+
return entries.length;
|
|
1123
|
+
},
|
|
1124
|
+
get isFull() {
|
|
1125
|
+
return entries.length >= maxSize;
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/persistence/flush-manager.ts
|
|
1131
|
+
var DEFAULT_INTERVAL = 5e3;
|
|
1132
|
+
var DEFAULT_MUTATION_THRESHOLD = 1e3;
|
|
1133
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
1134
|
+
var DEFAULT_BASE_RETRY_DELAY = 100;
|
|
1135
|
+
function createFlushManager(config, getPartitionData, getInstanceId) {
|
|
1136
|
+
const {
|
|
1137
|
+
persistence,
|
|
1138
|
+
invalidation,
|
|
1139
|
+
interval = DEFAULT_INTERVAL,
|
|
1140
|
+
mutationThreshold = DEFAULT_MUTATION_THRESHOLD,
|
|
1141
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
1142
|
+
baseRetryDelay = DEFAULT_BASE_RETRY_DELAY,
|
|
1143
|
+
onError
|
|
1144
|
+
} = config;
|
|
1145
|
+
const dirtyPartitions = /* @__PURE__ */ new Map();
|
|
1146
|
+
let mutationCount = 0;
|
|
1147
|
+
let flushTimerId = null;
|
|
1148
|
+
let isShuttingDown = false;
|
|
1149
|
+
let flushPromise = null;
|
|
1150
|
+
function startTimer() {
|
|
1151
|
+
if (flushTimerId !== null || isShuttingDown) {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
flushTimerId = setInterval(() => {
|
|
1155
|
+
flush();
|
|
1156
|
+
}, interval);
|
|
1157
|
+
}
|
|
1158
|
+
function addToDirtySet(indexName, partitionId) {
|
|
1159
|
+
let partitions = dirtyPartitions.get(indexName);
|
|
1160
|
+
if (partitions === void 0) {
|
|
1161
|
+
partitions = /* @__PURE__ */ new Set();
|
|
1162
|
+
dirtyPartitions.set(indexName, partitions);
|
|
1163
|
+
}
|
|
1164
|
+
partitions.add(partitionId);
|
|
1165
|
+
}
|
|
1166
|
+
function snapshotAndClearDirty() {
|
|
1167
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
1168
|
+
for (const [indexName, partitions] of dirtyPartitions) {
|
|
1169
|
+
snapshot.set(indexName, new Set(partitions));
|
|
1170
|
+
}
|
|
1171
|
+
dirtyPartitions.clear();
|
|
1172
|
+
return snapshot;
|
|
1173
|
+
}
|
|
1174
|
+
async function saveWithRetry(indexName, partitionId) {
|
|
1175
|
+
const key = `${indexName}/${partitionId}`;
|
|
1176
|
+
const data = getPartitionData(indexName, partitionId);
|
|
1177
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1178
|
+
try {
|
|
1179
|
+
await persistence.save(key, data);
|
|
1180
|
+
return true;
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
const isLastAttempt = attempt === maxRetries - 1;
|
|
1183
|
+
if (isLastAttempt) {
|
|
1184
|
+
onError?.(indexName, partitionId, err, true);
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
const cappedAttempt = Math.min(attempt, 20);
|
|
1188
|
+
const delay = baseRetryDelay * 2 ** cappedAttempt * (0.5 + Math.random() * 0.5);
|
|
1189
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
async function performFlush() {
|
|
1195
|
+
const snapshot = snapshotAndClearDirty();
|
|
1196
|
+
for (const [indexName, partitions] of snapshot) {
|
|
1197
|
+
const succeededPartitions = [];
|
|
1198
|
+
for (const partitionId of partitions) {
|
|
1199
|
+
const saved = await saveWithRetry(indexName, partitionId);
|
|
1200
|
+
if (saved) {
|
|
1201
|
+
succeededPartitions.push(partitionId);
|
|
1202
|
+
} else {
|
|
1203
|
+
addToDirtySet(indexName, partitionId);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (succeededPartitions.length > 0) {
|
|
1207
|
+
await invalidation.publish({
|
|
1208
|
+
type: "partition",
|
|
1209
|
+
indexName,
|
|
1210
|
+
partitions: succeededPartitions,
|
|
1211
|
+
timestamp: Date.now(),
|
|
1212
|
+
sourceInstanceId: getInstanceId()
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
mutationCount = 0;
|
|
1217
|
+
}
|
|
1218
|
+
function flush() {
|
|
1219
|
+
if (flushPromise !== null) {
|
|
1220
|
+
return flushPromise;
|
|
1221
|
+
}
|
|
1222
|
+
flushPromise = performFlush().finally(() => {
|
|
1223
|
+
flushPromise = null;
|
|
1224
|
+
});
|
|
1225
|
+
return flushPromise;
|
|
1226
|
+
}
|
|
1227
|
+
function markDirty(indexName, partitionId) {
|
|
1228
|
+
if (isShuttingDown) return;
|
|
1229
|
+
addToDirtySet(indexName, partitionId);
|
|
1230
|
+
mutationCount++;
|
|
1231
|
+
if (mutationCount >= mutationThreshold) {
|
|
1232
|
+
flush();
|
|
1233
|
+
}
|
|
1234
|
+
startTimer();
|
|
1235
|
+
}
|
|
1236
|
+
async function shutdown() {
|
|
1237
|
+
isShuttingDown = true;
|
|
1238
|
+
if (flushTimerId !== null) {
|
|
1239
|
+
clearInterval(flushTimerId);
|
|
1240
|
+
flushTimerId = null;
|
|
1241
|
+
}
|
|
1242
|
+
await flush();
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
markDirty,
|
|
1246
|
+
flush,
|
|
1247
|
+
shutdown
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/plugins/registry.ts
|
|
1252
|
+
function isThenable(value) {
|
|
1253
|
+
return value !== null && typeof value === "object" && typeof value.then === "function";
|
|
1254
|
+
}
|
|
1255
|
+
async function continueAsync(pending, hooks, startIndex, context) {
|
|
1256
|
+
await pending;
|
|
1257
|
+
for (let i = startIndex; i < hooks.length; i++) {
|
|
1258
|
+
await hooks[i](context);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
function createPluginRegistry() {
|
|
1262
|
+
const plugins = [];
|
|
1263
|
+
return {
|
|
1264
|
+
register(plugin) {
|
|
1265
|
+
plugins.push(plugin);
|
|
1266
|
+
},
|
|
1267
|
+
hasHooks(hookName) {
|
|
1268
|
+
return plugins.some((p) => typeof p[hookName] === "function");
|
|
1269
|
+
},
|
|
1270
|
+
runHook(hookName, context) {
|
|
1271
|
+
const hooks = [];
|
|
1272
|
+
for (const plugin of plugins) {
|
|
1273
|
+
const hook = plugin[hookName];
|
|
1274
|
+
if (typeof hook === "function") {
|
|
1275
|
+
hooks.push(hook.bind(plugin));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (hooks.length === 0) return;
|
|
1279
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
1280
|
+
const result = hooks[i](context);
|
|
1281
|
+
if (isThenable(result)) {
|
|
1282
|
+
return continueAsync(result, hooks, i + 1, context);
|
|
1283
|
+
}
|
|
548
1284
|
}
|
|
549
1285
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
var VALID_HNSW_METRICS = /* @__PURE__ */ new Set(["cosine", "dotProduct", "euclidean"]);
|
|
1289
|
+
function validateHnswMetric(value) {
|
|
1290
|
+
if (value === void 0 || value === null) return void 0;
|
|
1291
|
+
if (typeof value === "string" && VALID_HNSW_METRICS.has(value)) {
|
|
1292
|
+
return value;
|
|
1293
|
+
}
|
|
1294
|
+
return void 0;
|
|
1295
|
+
}
|
|
1296
|
+
function wireToPartition(raw) {
|
|
1297
|
+
const documents = {};
|
|
1298
|
+
for (const [docId, doc] of Object.entries(raw.documents ?? {})) {
|
|
1299
|
+
documents[docId] = {
|
|
1300
|
+
fields: doc.fields,
|
|
1301
|
+
fieldLengths: doc.field_lengths ?? {}
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
const invertedIndex = {};
|
|
1305
|
+
for (const [token, list] of Object.entries(raw.inverted_index ?? {})) {
|
|
1306
|
+
invertedIndex[token] = {
|
|
1307
|
+
docFrequency: list.doc_freq,
|
|
1308
|
+
postings: (list.postings ?? []).map((p) => ({
|
|
1309
|
+
docId: p.doc_id,
|
|
1310
|
+
termFrequency: p.term_freq,
|
|
1311
|
+
field: p.field,
|
|
1312
|
+
positions: p.positions ?? []
|
|
1313
|
+
}))
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
const numeric = {};
|
|
1317
|
+
for (const [field, entries] of Object.entries(raw.field_indexes?.numeric ?? {})) {
|
|
1318
|
+
numeric[field] = entries.map((e) => ({ value: e.value, docId: e.doc_id }));
|
|
1319
|
+
}
|
|
1320
|
+
const boolean = {};
|
|
1321
|
+
for (const [field, idx] of Object.entries(raw.field_indexes?.boolean ?? {})) {
|
|
1322
|
+
boolean[field] = { trueDocs: idx.true_docs ?? [], falseDocs: idx.false_docs ?? [] };
|
|
1323
|
+
}
|
|
1324
|
+
const geopoint = {};
|
|
1325
|
+
for (const [field, entries] of Object.entries(raw.field_indexes?.geopoint ?? {})) {
|
|
1326
|
+
geopoint[field] = entries.map((e) => ({ lat: e.lat, lon: e.lon, docId: e.doc_id }));
|
|
1327
|
+
}
|
|
1328
|
+
const vectorData = {};
|
|
1329
|
+
for (const [field, data] of Object.entries(raw.vector_data ?? {})) {
|
|
1330
|
+
vectorData[field] = {
|
|
1331
|
+
dimension: data.dimension,
|
|
1332
|
+
vectors: (data.vectors ?? []).map((v) => ({ docId: v.doc_id, vector: v.vector })),
|
|
1333
|
+
hnswGraph: data.hnsw_graph ? {
|
|
1334
|
+
entryPoint: data.hnsw_graph.entry_point,
|
|
1335
|
+
maxLayer: data.hnsw_graph.max_layer,
|
|
1336
|
+
m: data.hnsw_graph.m,
|
|
1337
|
+
efConstruction: data.hnsw_graph.ef_construction,
|
|
1338
|
+
metric: validateHnswMetric(data.hnsw_graph.metric),
|
|
1339
|
+
nodes: data.hnsw_graph.nodes
|
|
1340
|
+
} : null
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
indexName: raw.index_name,
|
|
1345
|
+
partitionId: raw.partition_id,
|
|
1346
|
+
totalPartitions: raw.total_partitions,
|
|
1347
|
+
language: raw.language ?? "english",
|
|
1348
|
+
schema: raw.schema ?? {},
|
|
1349
|
+
docCount: raw.doc_count ?? 0,
|
|
1350
|
+
avgDocLength: raw.avg_doc_length ?? 0,
|
|
1351
|
+
documents,
|
|
1352
|
+
invertedIndex,
|
|
1353
|
+
fieldIndexes: {
|
|
1354
|
+
numeric,
|
|
1355
|
+
boolean,
|
|
1356
|
+
enum: raw.field_indexes?.enum ?? {},
|
|
1357
|
+
geopoint
|
|
1358
|
+
},
|
|
1359
|
+
vectorData,
|
|
1360
|
+
statistics: {
|
|
1361
|
+
totalDocuments: raw.statistics?.total_documents ?? 0,
|
|
1362
|
+
totalFieldLengths: raw.statistics?.total_field_lengths ?? {},
|
|
1363
|
+
averageFieldLengths: raw.statistics?.average_field_lengths ?? {},
|
|
1364
|
+
docFrequencies: raw.statistics?.doc_frequencies ?? {}
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
function deserializePayloadV1(data) {
|
|
1369
|
+
const raw = decode(data);
|
|
1370
|
+
return wireToPartition(raw);
|
|
553
1371
|
}
|
|
554
1372
|
|
|
555
1373
|
// src/workers/promoter.ts
|
|
@@ -591,65 +1409,6 @@ function createExecutionPromoter(config) {
|
|
|
591
1409
|
}
|
|
592
1410
|
|
|
593
1411
|
// src/narsil.ts
|
|
594
|
-
var INDEX_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
595
|
-
var MAX_INDEX_NAME_LENGTH = 256;
|
|
596
|
-
var MAX_DOC_ID_LENGTH = 512;
|
|
597
|
-
var BATCH_CHUNK_SIZE = 1e3;
|
|
598
|
-
var MAX_LIMIT = 1e4;
|
|
599
|
-
var MAX_OFFSET = 1e5;
|
|
600
|
-
var DEFAULT_LIMIT = 10;
|
|
601
|
-
var DEFAULT_OFFSET = 0;
|
|
602
|
-
function now() {
|
|
603
|
-
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
604
|
-
return performance.now();
|
|
605
|
-
}
|
|
606
|
-
return Date.now();
|
|
607
|
-
}
|
|
608
|
-
function validateIndexName(name) {
|
|
609
|
-
if (!name || name.length === 0) {
|
|
610
|
-
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, "Index name must not be empty", { indexName: name });
|
|
611
|
-
}
|
|
612
|
-
if (name.length > MAX_INDEX_NAME_LENGTH) {
|
|
613
|
-
throw new NarsilError(
|
|
614
|
-
ErrorCodes.INDEX_NOT_FOUND,
|
|
615
|
-
`Index name must not exceed ${MAX_INDEX_NAME_LENGTH} characters`,
|
|
616
|
-
{ indexName: name, length: name.length }
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
if (!INDEX_NAME_PATTERN.test(name)) {
|
|
620
|
-
throw new NarsilError(
|
|
621
|
-
ErrorCodes.INDEX_NOT_FOUND,
|
|
622
|
-
`Index name "${name}" contains invalid characters; use alphanumeric, dots, hyphens, and underscores only`,
|
|
623
|
-
{ indexName: name }
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
if (name.includes("..")) {
|
|
627
|
-
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index name "${name}" must not contain ".."`, { indexName: name });
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
function validateDocId(docId) {
|
|
631
|
-
if (!docId || docId.length === 0) {
|
|
632
|
-
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not be empty", { docId });
|
|
633
|
-
}
|
|
634
|
-
if (docId.length > MAX_DOC_ID_LENGTH) {
|
|
635
|
-
throw new NarsilError(
|
|
636
|
-
ErrorCodes.DOC_VALIDATION_FAILED,
|
|
637
|
-
`Document ID must not exceed ${MAX_DOC_ID_LENGTH} characters`,
|
|
638
|
-
{ docId, length: docId.length }
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
if (docId.includes("\0")) {
|
|
642
|
-
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not contain null bytes", { docId });
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
function clampLimit(limit) {
|
|
646
|
-
if (limit === void 0) return DEFAULT_LIMIT;
|
|
647
|
-
return Math.max(0, Math.min(limit, MAX_LIMIT));
|
|
648
|
-
}
|
|
649
|
-
function clampOffset(offset) {
|
|
650
|
-
if (offset === void 0) return DEFAULT_OFFSET;
|
|
651
|
-
return Math.max(0, Math.min(offset, MAX_OFFSET));
|
|
652
|
-
}
|
|
653
1412
|
async function createNarsil(config) {
|
|
654
1413
|
const executor = createDirectExecutor();
|
|
655
1414
|
const promoter = createExecutionPromoter({
|
|
@@ -687,6 +1446,21 @@ async function createNarsil(config) {
|
|
|
687
1446
|
const indexRegistry = /* @__PURE__ */ new Map();
|
|
688
1447
|
const eventHandlers = /* @__PURE__ */ new Map();
|
|
689
1448
|
let isShutdown = false;
|
|
1449
|
+
const orchestrator = createWorkerOrchestrator(config, executor, promoter, indexRegistry, {
|
|
1450
|
+
onPromotion(workerCount, reason) {
|
|
1451
|
+
const handlers = eventHandlers.get("workerPromote");
|
|
1452
|
+
if (handlers) {
|
|
1453
|
+
for (const handler of handlers) {
|
|
1454
|
+
handler({ workerCount, reason });
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
const rebalancer = createRebalancer();
|
|
1460
|
+
const rebalanceRouter = createPartitionRouter();
|
|
1461
|
+
const rebalancingIndexes = /* @__PURE__ */ new Set();
|
|
1462
|
+
const waqMap = /* @__PURE__ */ new Map();
|
|
1463
|
+
const lastAppliedSeqMap = /* @__PURE__ */ new Map();
|
|
690
1464
|
function guardShutdown() {
|
|
691
1465
|
if (isShutdown) {
|
|
692
1466
|
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, "This Narsil instance has been shut down");
|
|
@@ -699,6 +1473,20 @@ async function createNarsil(config) {
|
|
|
699
1473
|
}
|
|
700
1474
|
return entry;
|
|
701
1475
|
}
|
|
1476
|
+
function bufferIfRebalancing(indexName, entry) {
|
|
1477
|
+
if (!rebalancingIndexes.has(indexName)) return false;
|
|
1478
|
+
const waq = waqMap.get(indexName);
|
|
1479
|
+
if (!waq) return false;
|
|
1480
|
+
waq.push(entry);
|
|
1481
|
+
return true;
|
|
1482
|
+
}
|
|
1483
|
+
function requireManager(indexName) {
|
|
1484
|
+
const manager = executor.getManager(indexName);
|
|
1485
|
+
if (!manager) {
|
|
1486
|
+
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" manager not found`, { indexName });
|
|
1487
|
+
}
|
|
1488
|
+
return manager;
|
|
1489
|
+
}
|
|
702
1490
|
const narsil = {
|
|
703
1491
|
async createIndex(name, indexConfig) {
|
|
704
1492
|
guardShutdown();
|
|
@@ -717,10 +1505,7 @@ async function createNarsil(config) {
|
|
|
717
1505
|
const entry = requireIndex(name);
|
|
718
1506
|
executor.dropIndex(name);
|
|
719
1507
|
indexRegistry.delete(name);
|
|
720
|
-
await pluginRegistry.runHook("onIndexDrop", {
|
|
721
|
-
indexName: name,
|
|
722
|
-
config: entry.config
|
|
723
|
-
});
|
|
1508
|
+
await pluginRegistry.runHook("onIndexDrop", { indexName: name, config: entry.config });
|
|
724
1509
|
},
|
|
725
1510
|
listIndexes() {
|
|
726
1511
|
const infos = [];
|
|
@@ -739,61 +1524,90 @@ async function createNarsil(config) {
|
|
|
739
1524
|
guardShutdown();
|
|
740
1525
|
const entry = requireIndex(indexName);
|
|
741
1526
|
const manager = executor.getManager(indexName);
|
|
1527
|
+
const estimatedMemory = manager?.estimateMemoryBytes() ?? 0;
|
|
742
1528
|
return {
|
|
743
1529
|
documentCount: manager?.countDocuments() ?? 0,
|
|
744
1530
|
partitionCount: manager?.partitionCount ?? 0,
|
|
745
|
-
memoryBytes:
|
|
746
|
-
indexSizeBytes:
|
|
1531
|
+
memoryBytes: estimatedMemory,
|
|
1532
|
+
indexSizeBytes: estimatedMemory,
|
|
747
1533
|
language: entry.language.name,
|
|
748
1534
|
schema: entry.config.schema
|
|
749
1535
|
};
|
|
750
1536
|
},
|
|
751
|
-
|
|
1537
|
+
getPartitionStats(indexName) {
|
|
1538
|
+
guardShutdown();
|
|
1539
|
+
requireIndex(indexName);
|
|
1540
|
+
const manager = executor.getManager(indexName);
|
|
1541
|
+
return manager?.getPartitionStats() ?? [];
|
|
1542
|
+
},
|
|
1543
|
+
async insert(indexName, document, docId, options) {
|
|
752
1544
|
guardShutdown();
|
|
753
1545
|
requireIndex(indexName);
|
|
754
1546
|
const resolvedDocId = docId ?? idGenerator();
|
|
755
1547
|
validateDocId(resolvedDocId);
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
});
|
|
1548
|
+
if (bufferIfRebalancing(indexName, { action: "insert", docId: resolvedDocId, document, indexName })) {
|
|
1549
|
+
return resolvedDocId;
|
|
1550
|
+
}
|
|
1551
|
+
await pluginRegistry.runHook("beforeInsert", { indexName, docId: resolvedDocId, document });
|
|
761
1552
|
await executor.execute({
|
|
762
1553
|
type: "insert",
|
|
763
1554
|
indexName,
|
|
764
1555
|
docId: resolvedDocId,
|
|
765
1556
|
document,
|
|
766
|
-
requestId: resolvedDocId
|
|
1557
|
+
requestId: resolvedDocId,
|
|
1558
|
+
skipClone: options?.skipClone
|
|
767
1559
|
});
|
|
768
1560
|
try {
|
|
769
|
-
await pluginRegistry.runHook("afterInsert", {
|
|
770
|
-
indexName,
|
|
771
|
-
docId: resolvedDocId,
|
|
772
|
-
document
|
|
773
|
-
});
|
|
1561
|
+
await pluginRegistry.runHook("afterInsert", { indexName, docId: resolvedDocId, document });
|
|
774
1562
|
} catch (err) {
|
|
775
1563
|
console.warn("afterInsert plugin hook error:", err);
|
|
776
1564
|
}
|
|
777
1565
|
flushManager?.markDirty(indexName, 0);
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1566
|
+
await orchestrator.replicateToWorkers({
|
|
1567
|
+
type: "insert",
|
|
1568
|
+
indexName,
|
|
1569
|
+
docId: resolvedDocId,
|
|
1570
|
+
document,
|
|
1571
|
+
requestId: `replicate-insert-${resolvedDocId}`,
|
|
1572
|
+
skipClone: options?.skipClone
|
|
1573
|
+
});
|
|
1574
|
+
await orchestrator.checkPromotion();
|
|
784
1575
|
return resolvedDocId;
|
|
785
1576
|
},
|
|
786
|
-
async insertBatch(indexName, documents) {
|
|
1577
|
+
async insertBatch(indexName, documents, options) {
|
|
787
1578
|
guardShutdown();
|
|
788
1579
|
requireIndex(indexName);
|
|
789
1580
|
const succeeded = [];
|
|
790
1581
|
const failed = [];
|
|
1582
|
+
const hasBeforeHook = pluginRegistry.hasHooks("beforeInsert");
|
|
1583
|
+
const hasAfterHook = pluginRegistry.hasHooks("afterInsert");
|
|
791
1584
|
for (let chunkStart = 0; chunkStart < documents.length; chunkStart += BATCH_CHUNK_SIZE) {
|
|
792
1585
|
const chunkEnd = Math.min(chunkStart + BATCH_CHUNK_SIZE, documents.length);
|
|
793
1586
|
for (let i = chunkStart; i < chunkEnd; i++) {
|
|
794
1587
|
const docId = idGenerator();
|
|
795
1588
|
try {
|
|
796
|
-
|
|
1589
|
+
validateDocId(docId);
|
|
1590
|
+
if (hasBeforeHook) {
|
|
1591
|
+
await pluginRegistry.runHook("beforeInsert", { indexName, docId, document: documents[i] });
|
|
1592
|
+
}
|
|
1593
|
+
const result = executor.execute({
|
|
1594
|
+
type: "insert",
|
|
1595
|
+
indexName,
|
|
1596
|
+
docId,
|
|
1597
|
+
document: documents[i],
|
|
1598
|
+
requestId: docId,
|
|
1599
|
+
skipClone: options?.skipClone
|
|
1600
|
+
});
|
|
1601
|
+
if (result && typeof result.then === "function") {
|
|
1602
|
+
await result;
|
|
1603
|
+
}
|
|
1604
|
+
if (hasAfterHook) {
|
|
1605
|
+
try {
|
|
1606
|
+
await pluginRegistry.runHook("afterInsert", { indexName, docId, document: documents[i] });
|
|
1607
|
+
} catch (err) {
|
|
1608
|
+
console.warn("afterInsert plugin hook error:", err);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
797
1611
|
succeeded.push(docId);
|
|
798
1612
|
} catch (err) {
|
|
799
1613
|
failed.push({
|
|
@@ -806,25 +1620,31 @@ async function createNarsil(config) {
|
|
|
806
1620
|
await new Promise((r) => setTimeout(r, 0));
|
|
807
1621
|
}
|
|
808
1622
|
}
|
|
1623
|
+
flushManager?.markDirty(indexName, 0);
|
|
1624
|
+
await orchestrator.checkPromotion();
|
|
809
1625
|
return { succeeded, failed };
|
|
810
1626
|
},
|
|
811
1627
|
async remove(indexName, docId) {
|
|
812
1628
|
guardShutdown();
|
|
813
1629
|
requireIndex(indexName);
|
|
814
1630
|
validateDocId(docId);
|
|
1631
|
+
if (bufferIfRebalancing(indexName, { action: "remove", docId, indexName })) {
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
815
1634
|
await pluginRegistry.runHook("beforeRemove", { indexName, docId });
|
|
816
|
-
await executor.execute({
|
|
817
|
-
type: "remove",
|
|
818
|
-
indexName,
|
|
819
|
-
docId,
|
|
820
|
-
requestId: docId
|
|
821
|
-
});
|
|
1635
|
+
await executor.execute({ type: "remove", indexName, docId, requestId: docId });
|
|
822
1636
|
try {
|
|
823
1637
|
await pluginRegistry.runHook("afterRemove", { indexName, docId });
|
|
824
1638
|
} catch (err) {
|
|
825
1639
|
console.warn("afterRemove plugin hook error:", err);
|
|
826
1640
|
}
|
|
827
1641
|
flushManager?.markDirty(indexName, 0);
|
|
1642
|
+
await orchestrator.replicateToWorkers({
|
|
1643
|
+
type: "remove",
|
|
1644
|
+
indexName,
|
|
1645
|
+
docId,
|
|
1646
|
+
requestId: `replicate-remove-${docId}`
|
|
1647
|
+
});
|
|
828
1648
|
},
|
|
829
1649
|
async removeBatch(indexName, docIds) {
|
|
830
1650
|
guardShutdown();
|
|
@@ -854,6 +1674,9 @@ async function createNarsil(config) {
|
|
|
854
1674
|
guardShutdown();
|
|
855
1675
|
requireIndex(indexName);
|
|
856
1676
|
validateDocId(docId);
|
|
1677
|
+
if (bufferIfRebalancing(indexName, { action: "update", docId, document, indexName })) {
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
857
1680
|
const manager = executor.getManager(indexName);
|
|
858
1681
|
const oldDocument = manager?.get(docId);
|
|
859
1682
|
await pluginRegistry.runHook("beforeUpdate", {
|
|
@@ -862,13 +1685,7 @@ async function createNarsil(config) {
|
|
|
862
1685
|
oldDocument: oldDocument ?? {},
|
|
863
1686
|
newDocument: document
|
|
864
1687
|
});
|
|
865
|
-
await executor.execute({
|
|
866
|
-
type: "update",
|
|
867
|
-
indexName,
|
|
868
|
-
docId,
|
|
869
|
-
document,
|
|
870
|
-
requestId: docId
|
|
871
|
-
});
|
|
1688
|
+
await executor.execute({ type: "update", indexName, docId, document, requestId: docId });
|
|
872
1689
|
try {
|
|
873
1690
|
await pluginRegistry.runHook("afterUpdate", {
|
|
874
1691
|
indexName,
|
|
@@ -880,6 +1697,13 @@ async function createNarsil(config) {
|
|
|
880
1697
|
console.warn("afterUpdate plugin hook error:", err);
|
|
881
1698
|
}
|
|
882
1699
|
flushManager?.markDirty(indexName, 0);
|
|
1700
|
+
await orchestrator.replicateToWorkers({
|
|
1701
|
+
type: "update",
|
|
1702
|
+
indexName,
|
|
1703
|
+
docId,
|
|
1704
|
+
document,
|
|
1705
|
+
requestId: `replicate-update-${docId}`
|
|
1706
|
+
});
|
|
883
1707
|
},
|
|
884
1708
|
async updateBatch(indexName, updates) {
|
|
885
1709
|
guardShutdown();
|
|
@@ -935,86 +1759,16 @@ async function createNarsil(config) {
|
|
|
935
1759
|
async query(indexName, params) {
|
|
936
1760
|
guardShutdown();
|
|
937
1761
|
const entry = requireIndex(indexName);
|
|
938
|
-
const
|
|
939
|
-
const limit = clampLimit(params.limit);
|
|
940
|
-
const offset = clampOffset(params.offset);
|
|
1762
|
+
const manager = requireManager(indexName);
|
|
941
1763
|
await pluginRegistry.runHook("beforeSearch", { indexName, params });
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" manager not found`, { indexName });
|
|
945
|
-
}
|
|
946
|
-
const searchOptions = {
|
|
947
|
-
bm25Params: entry.config.bm25,
|
|
948
|
-
stopWords: entry.config.stopWords,
|
|
949
|
-
customTokenizer: entry.config.tokenizer
|
|
950
|
-
};
|
|
951
|
-
const fanOutResult = fanOutQuery(
|
|
1764
|
+
const workerSearch = orchestrator.isPromoted() ? orchestrator.searchViaWorker.bind(orchestrator) : void 0;
|
|
1765
|
+
const result = await executeQuery(params, {
|
|
952
1766
|
manager,
|
|
953
|
-
|
|
954
|
-
entry.
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
},
|
|
959
|
-
searchOptions
|
|
960
|
-
);
|
|
961
|
-
let hits = fanOutResult.scored.map((scored) => ({
|
|
962
|
-
id: scored.docId,
|
|
963
|
-
score: scored.score,
|
|
964
|
-
document: manager.get(scored.docId) ?? {},
|
|
965
|
-
scoreComponents: {
|
|
966
|
-
termFrequencies: scored.termFrequencies,
|
|
967
|
-
fieldLengths: scored.fieldLengths,
|
|
968
|
-
idf: scored.idf
|
|
969
|
-
}
|
|
970
|
-
}));
|
|
971
|
-
if (params.highlight) {
|
|
972
|
-
const queryTokenResult = tokenize(params.term ?? "", entry.language, {
|
|
973
|
-
stem: true,
|
|
974
|
-
removeStopWords: true,
|
|
975
|
-
customTokenizer: entry.config.tokenizer,
|
|
976
|
-
stopWordOverride: entry.config.stopWords
|
|
977
|
-
});
|
|
978
|
-
for (const hit of hits) {
|
|
979
|
-
const highlights = {};
|
|
980
|
-
for (const field of params.highlight.fields) {
|
|
981
|
-
const doc = hit.document;
|
|
982
|
-
const fieldValue = doc[field];
|
|
983
|
-
if (typeof fieldValue === "string") {
|
|
984
|
-
highlights[field] = highlightField(fieldValue, queryTokenResult.tokens, entry.language, {
|
|
985
|
-
preTag: params.highlight.preTag,
|
|
986
|
-
postTag: params.highlight.postTag,
|
|
987
|
-
maxSnippetLength: params.highlight.maxSnippetLength
|
|
988
|
-
});
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
hit.highlights = highlights;
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
if (params.sort) {
|
|
995
|
-
hits = applySorting(hits, params.sort, (docId) => manager.get(docId));
|
|
996
|
-
}
|
|
997
|
-
let groups;
|
|
998
|
-
if (params.group) {
|
|
999
|
-
groups = applyGrouping(hits, params.group, (docId) => manager.get(docId));
|
|
1000
|
-
}
|
|
1001
|
-
if (params.pinned) {
|
|
1002
|
-
hits = applyPinning(hits, params.pinned, (docId) => {
|
|
1003
|
-
const doc = manager.get(docId);
|
|
1004
|
-
if (!doc) return void 0;
|
|
1005
|
-
return { id: docId, score: 0, document: doc };
|
|
1006
|
-
});
|
|
1007
|
-
}
|
|
1008
|
-
const { paginated, nextCursor } = applyPagination(hits, limit, offset, params.searchAfter);
|
|
1009
|
-
const elapsed = now() - startTime;
|
|
1010
|
-
const result = {
|
|
1011
|
-
hits: paginated,
|
|
1012
|
-
count: fanOutResult.totalMatched,
|
|
1013
|
-
elapsed,
|
|
1014
|
-
cursor: nextCursor,
|
|
1015
|
-
facets: fanOutResult.facets,
|
|
1016
|
-
groups
|
|
1017
|
-
};
|
|
1767
|
+
language: entry.language,
|
|
1768
|
+
config: entry.config,
|
|
1769
|
+
workerSearch,
|
|
1770
|
+
indexName
|
|
1771
|
+
});
|
|
1018
1772
|
try {
|
|
1019
1773
|
await pluginRegistry.runHook("afterSearch", {
|
|
1020
1774
|
indexName,
|
|
@@ -1029,36 +1783,233 @@ async function createNarsil(config) {
|
|
|
1029
1783
|
async preflight(indexName, params) {
|
|
1030
1784
|
guardShutdown();
|
|
1031
1785
|
const entry = requireIndex(indexName);
|
|
1032
|
-
const
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" manager not found`, { indexName });
|
|
1036
|
-
}
|
|
1037
|
-
const searchOptions = {
|
|
1038
|
-
bm25Params: entry.config.bm25,
|
|
1039
|
-
stopWords: entry.config.stopWords,
|
|
1040
|
-
customTokenizer: entry.config.tokenizer
|
|
1041
|
-
};
|
|
1042
|
-
const fanOutResult = fanOutQuery(
|
|
1786
|
+
const manager = requireManager(indexName);
|
|
1787
|
+
const workerSearch = orchestrator.isPromoted() ? orchestrator.searchViaWorker.bind(orchestrator) : void 0;
|
|
1788
|
+
return executePreflight(params, {
|
|
1043
1789
|
manager,
|
|
1044
|
-
|
|
1045
|
-
entry.
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
);
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1790
|
+
language: entry.language,
|
|
1791
|
+
config: entry.config,
|
|
1792
|
+
workerSearch,
|
|
1793
|
+
indexName
|
|
1794
|
+
});
|
|
1795
|
+
},
|
|
1796
|
+
async suggest(indexName, params) {
|
|
1797
|
+
guardShutdown();
|
|
1798
|
+
const entry = requireIndex(indexName);
|
|
1799
|
+
const manager = requireManager(indexName);
|
|
1800
|
+
const t0 = performance.now();
|
|
1801
|
+
const limit = Math.max(1, Math.min(params.limit ?? 10, 100));
|
|
1802
|
+
const rawPrefix = params.prefix.trim();
|
|
1803
|
+
if (rawPrefix.length === 0) {
|
|
1804
|
+
return { terms: [], elapsed: performance.now() - t0 };
|
|
1805
|
+
}
|
|
1806
|
+
const unstemmed = tokenize(rawPrefix, entry.language, { stem: false, removeStopWords: false });
|
|
1807
|
+
const lastToken = unstemmed.tokens.length > 0 ? unstemmed.tokens[unstemmed.tokens.length - 1].token : rawPrefix.toLowerCase();
|
|
1808
|
+
if (lastToken.length === 0) {
|
|
1809
|
+
return { terms: [], elapsed: performance.now() - t0 };
|
|
1810
|
+
}
|
|
1811
|
+
const stemmed = entry.language.stemmer ? entry.language.stemmer(lastToken) : lastToken;
|
|
1812
|
+
const prefixes = stemmed !== lastToken ? [lastToken, stemmed] : [lastToken];
|
|
1813
|
+
const partitions = manager.getAllPartitions();
|
|
1814
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1815
|
+
const perPartitionLimit = limit * 2;
|
|
1816
|
+
for (const partition of partitions) {
|
|
1817
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1818
|
+
for (const prefix of prefixes) {
|
|
1819
|
+
const suggestions = partition.suggestTerms(prefix, perPartitionLimit);
|
|
1820
|
+
for (const s of suggestions) {
|
|
1821
|
+
if (seen.has(s.term)) continue;
|
|
1822
|
+
seen.add(s.term);
|
|
1823
|
+
merged.set(s.term, (merged.get(s.term) ?? 0) + s.documentFrequency);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
const terms = Array.from(merged.entries()).map(([term, documentFrequency]) => ({ term, documentFrequency })).sort((a, b) => b.documentFrequency - a.documentFrequency);
|
|
1828
|
+
if (terms.length > limit) terms.length = limit;
|
|
1829
|
+
return { terms, elapsed: performance.now() - t0 };
|
|
1830
|
+
},
|
|
1831
|
+
async snapshot(indexName) {
|
|
1832
|
+
guardShutdown();
|
|
1833
|
+
const entry = requireIndex(indexName);
|
|
1834
|
+
const manager = requireManager(indexName);
|
|
1835
|
+
const partitionBuffers = [];
|
|
1836
|
+
for (let i = 0; i < manager.partitionCount; i++) {
|
|
1837
|
+
partitionBuffers.push(manager.serializePartitionToBytes(i));
|
|
1838
|
+
}
|
|
1839
|
+
const { encode: encode2 } = await import('@msgpack/msgpack');
|
|
1840
|
+
return encode2({
|
|
1841
|
+
version: 2,
|
|
1842
|
+
schema: entry.config.schema,
|
|
1843
|
+
language: entry.language.name,
|
|
1844
|
+
partitions: partitionBuffers
|
|
1845
|
+
});
|
|
1846
|
+
},
|
|
1847
|
+
async restore(indexName, data) {
|
|
1848
|
+
guardShutdown();
|
|
1849
|
+
if (!(data instanceof Uint8Array)) {
|
|
1850
|
+
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Snapshot data must be a Uint8Array");
|
|
1851
|
+
}
|
|
1852
|
+
const { decode: decode2 } = await import('@msgpack/msgpack');
|
|
1853
|
+
const envelope = decode2(data);
|
|
1854
|
+
if (envelope.version !== 1 && envelope.version !== 2) {
|
|
1855
|
+
throw new NarsilError(
|
|
1856
|
+
ErrorCodes.DOC_VALIDATION_FAILED,
|
|
1857
|
+
`Unsupported snapshot version: ${envelope.version}. Expected version 1 or 2`,
|
|
1858
|
+
{ version: envelope.version }
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
if (!envelope.schema || typeof envelope.schema !== "object") {
|
|
1862
|
+
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Invalid snapshot: missing or invalid schema");
|
|
1863
|
+
}
|
|
1864
|
+
if (!envelope.language || typeof envelope.language !== "string") {
|
|
1865
|
+
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Invalid snapshot: missing or invalid language");
|
|
1866
|
+
}
|
|
1867
|
+
if (!Array.isArray(envelope.partitions)) {
|
|
1868
|
+
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Invalid snapshot: missing partitions");
|
|
1869
|
+
}
|
|
1870
|
+
const language = getLanguage(envelope.language);
|
|
1871
|
+
const schema = envelope.schema;
|
|
1872
|
+
validateSchema(schema);
|
|
1873
|
+
if (indexRegistry.has(indexName)) {
|
|
1874
|
+
await narsil.dropIndex(indexName);
|
|
1875
|
+
}
|
|
1876
|
+
const indexConfig = { schema, language: envelope.language };
|
|
1877
|
+
executor.createIndex(indexName, indexConfig, language);
|
|
1878
|
+
indexRegistry.set(indexName, { config: indexConfig, language });
|
|
1879
|
+
try {
|
|
1880
|
+
const manager = requireManager(indexName);
|
|
1881
|
+
while (manager.partitionCount < envelope.partitions.length) {
|
|
1882
|
+
manager.addPartition();
|
|
1883
|
+
}
|
|
1884
|
+
const deserializePartitionPayload = envelope.version === 2 ? deserializePayloadV2 : deserializePayloadV1;
|
|
1885
|
+
for (let i = 0; i < envelope.partitions.length; i++) {
|
|
1886
|
+
const partition = deserializePartitionPayload(envelope.partitions[i]);
|
|
1887
|
+
manager.deserializePartition(i, partition);
|
|
1888
|
+
}
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
try {
|
|
1891
|
+
executor.dropIndex(indexName);
|
|
1892
|
+
indexRegistry.delete(indexName);
|
|
1893
|
+
} catch (_) {
|
|
1894
|
+
}
|
|
1895
|
+
throw err;
|
|
1896
|
+
}
|
|
1054
1897
|
},
|
|
1055
1898
|
async clear(indexName) {
|
|
1056
1899
|
guardShutdown();
|
|
1057
1900
|
requireIndex(indexName);
|
|
1058
1901
|
await executor.execute({ type: "clear", indexName, requestId: indexName });
|
|
1902
|
+
await orchestrator.replicateToWorkers({ type: "clear", indexName, requestId: `replicate-clear-${indexName}` });
|
|
1903
|
+
},
|
|
1904
|
+
async rebalance(indexName, targetPartitionCount) {
|
|
1905
|
+
guardShutdown();
|
|
1906
|
+
requireIndex(indexName);
|
|
1907
|
+
const manager = requireManager(indexName);
|
|
1908
|
+
if (targetPartitionCount <= 0 || !Number.isInteger(targetPartitionCount)) {
|
|
1909
|
+
throw new NarsilError(
|
|
1910
|
+
ErrorCodes.PARTITION_CAPACITY_EXCEEDED,
|
|
1911
|
+
`Target partition count must be a positive integer, got ${targetPartitionCount}`,
|
|
1912
|
+
{ targetPartitionCount }
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
if (rebalancingIndexes.has(indexName)) {
|
|
1916
|
+
throw new NarsilError(
|
|
1917
|
+
ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
|
|
1918
|
+
`Index "${indexName}" is already being rebalanced`
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
if (targetPartitionCount === manager.partitionCount) {
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const waq = createWriteAheadQueue();
|
|
1925
|
+
waqMap.set(indexName, waq);
|
|
1926
|
+
rebalancingIndexes.add(indexName);
|
|
1927
|
+
try {
|
|
1928
|
+
const oldCount = manager.partitionCount;
|
|
1929
|
+
await rebalancer.rebalance(manager, targetPartitionCount, rebalanceRouter, (progress) => {
|
|
1930
|
+
if (progress.phase === "complete") {
|
|
1931
|
+
const handlers = eventHandlers.get("partitionRebalance");
|
|
1932
|
+
if (handlers) {
|
|
1933
|
+
for (const handler of handlers) {
|
|
1934
|
+
handler({ indexName, oldCount, newCount: targetPartitionCount });
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
const entries = waq.drain();
|
|
1940
|
+
const appliedSeqs = lastAppliedSeqMap.get(indexName) ?? /* @__PURE__ */ new Map();
|
|
1941
|
+
for (const entry of entries) {
|
|
1942
|
+
const partitionLastSeq = appliedSeqs.get(0) ?? 0;
|
|
1943
|
+
if (entry.sequenceNumber <= partitionLastSeq) continue;
|
|
1944
|
+
try {
|
|
1945
|
+
if (entry.action === "insert" && entry.document) {
|
|
1946
|
+
manager.insert(entry.docId, entry.document);
|
|
1947
|
+
} else if (entry.action === "remove") {
|
|
1948
|
+
manager.remove(entry.docId);
|
|
1949
|
+
} else if (entry.action === "update" && entry.document) {
|
|
1950
|
+
manager.update(entry.docId, entry.document);
|
|
1951
|
+
}
|
|
1952
|
+
} catch (replayErr) {
|
|
1953
|
+
const isDuplicate = replayErr instanceof NarsilError && replayErr.code === ErrorCodes.DOC_ALREADY_EXISTS;
|
|
1954
|
+
const isMissing = replayErr instanceof NarsilError && replayErr.code === ErrorCodes.DOC_NOT_FOUND;
|
|
1955
|
+
if (!isDuplicate && !isMissing) {
|
|
1956
|
+
console.warn(`WAQ replay failed for ${entry.action} on doc "${entry.docId}":`, replayErr);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
appliedSeqs.set(0, entry.sequenceNumber);
|
|
1960
|
+
}
|
|
1961
|
+
lastAppliedSeqMap.set(indexName, appliedSeqs);
|
|
1962
|
+
} finally {
|
|
1963
|
+
rebalancingIndexes.delete(indexName);
|
|
1964
|
+
waqMap.delete(indexName);
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
async updatePartitionConfig(indexName, partitionConfig) {
|
|
1968
|
+
guardShutdown();
|
|
1969
|
+
const entry = requireIndex(indexName);
|
|
1970
|
+
const manager = requireManager(indexName);
|
|
1971
|
+
if (rebalancingIndexes.has(indexName)) {
|
|
1972
|
+
throw new NarsilError(
|
|
1973
|
+
ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
|
|
1974
|
+
`Index "${indexName}" is currently being rebalanced`
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
const currentDocCount = manager.countDocuments();
|
|
1978
|
+
const newMaxDocs = partitionConfig.maxDocsPerPartition ?? entry.config.partitions?.maxDocsPerPartition;
|
|
1979
|
+
const newMaxPartitions = partitionConfig.maxPartitions ?? entry.config.partitions?.maxPartitions ?? manager.partitionCount;
|
|
1980
|
+
if (newMaxDocs !== void 0) {
|
|
1981
|
+
const newTotalCapacity = newMaxDocs * newMaxPartitions;
|
|
1982
|
+
if (newTotalCapacity < currentDocCount) {
|
|
1983
|
+
throw new NarsilError(
|
|
1984
|
+
ErrorCodes.PARTITION_CAPACITY_EXCEEDED,
|
|
1985
|
+
`New capacity (${newTotalCapacity}) is less than current document count (${currentDocCount})`,
|
|
1986
|
+
{ newTotalCapacity, currentDocCount }
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
if (!entry.config.partitions) {
|
|
1991
|
+
entry.config.partitions = {};
|
|
1992
|
+
}
|
|
1993
|
+
if (partitionConfig.maxDocsPerPartition !== void 0) {
|
|
1994
|
+
entry.config.partitions.maxDocsPerPartition = partitionConfig.maxDocsPerPartition;
|
|
1995
|
+
}
|
|
1996
|
+
if (partitionConfig.maxPartitions !== void 0) {
|
|
1997
|
+
entry.config.partitions.maxPartitions = partitionConfig.maxPartitions;
|
|
1998
|
+
}
|
|
1059
1999
|
},
|
|
1060
2000
|
getMemoryStats() {
|
|
1061
|
-
|
|
2001
|
+
const workerStats = orchestrator.getMemoryStats();
|
|
2002
|
+
let mainThreadBytes = 0;
|
|
2003
|
+
for (const [name] of indexRegistry) {
|
|
2004
|
+
const manager = executor.getManager(name);
|
|
2005
|
+
if (manager) {
|
|
2006
|
+
mainThreadBytes += manager.estimateMemoryBytes();
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return {
|
|
2010
|
+
totalBytes: mainThreadBytes + workerStats.totalBytes,
|
|
2011
|
+
workers: workerStats.workers
|
|
2012
|
+
};
|
|
1062
2013
|
},
|
|
1063
2014
|
on(event, handler) {
|
|
1064
2015
|
const key = event;
|
|
@@ -1085,6 +2036,7 @@ async function createNarsil(config) {
|
|
|
1085
2036
|
if (flushManager) {
|
|
1086
2037
|
await flushManager.shutdown();
|
|
1087
2038
|
}
|
|
2039
|
+
await orchestrator.shutdown();
|
|
1088
2040
|
await executor.shutdown();
|
|
1089
2041
|
eventHandlers.clear();
|
|
1090
2042
|
indexRegistry.clear();
|