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