@harperfast/harper 5.2.4 → 5.2.5
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/components/status/crossThread.ts +69 -44
- package/components/status/types.ts +1 -0
- package/dist/components/status/crossThread.d.ts +1 -0
- package/dist/components/status/crossThread.js +57 -41
- package/dist/components/status/crossThread.js.map +1 -1
- package/dist/components/status/types.d.ts +1 -0
- package/dist/resources/DatabaseTransaction.d.ts +28 -0
- package/dist/resources/DatabaseTransaction.js +110 -8
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +15 -0
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Table.js +3 -0
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +11 -0
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +153 -51
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/transaction.js +3 -1
- package/dist/resources/transaction.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +2 -1
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +1 -0
- package/dist/server/threads/manageThreads.js +17 -2
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/resources/DESIGN.md +19 -18
- package/resources/DatabaseTransaction.ts +124 -15
- package/resources/ResourceInterface.ts +15 -0
- package/resources/Table.ts +3 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +153 -54
- package/resources/transaction.ts +3 -1
- package/schema.graphql +8 -3
- package/server/itc/serverHandlers.js +2 -1
- package/server/threads/manageThreads.js +17 -2
- package/studio/web/assets/{Chat-BvOlYIhe.js → Chat-Lgj1d-8d.js} +1 -1
- package/studio/web/assets/{FloatingChat-fCJiJCH2.js → FloatingChat-jLWvR4kI.js} +3 -3
- package/studio/web/assets/{apiToken-bKwPv0uN.js → apiToken-DYI4DqWa.js} +1 -1
- package/studio/web/assets/{applications-BZ47njJ_.js → applications-DCmc9-lk.js} +1 -1
- package/studio/web/assets/{index-DF1fSnXV.js → index-CqbCnqsT.js} +4 -4
- package/studio/web/assets/{index.lazy-DWt8ubrr.js → index.lazy-BNqr3j0f.js} +3 -3
- package/studio/web/assets/{notifications-B5ZBOni1.js → notifications-Bb2cj14_.js} +1 -1
- package/studio/web/assets/{notifications-gVx0lA8H.js → notifications-DVlKonbI.js} +1 -1
- package/studio/web/assets/{profile-D7g0x28A.js → profile-BSQWfYfq.js} +1 -1
- package/studio/web/assets/{regions-DRkhlkiD.js → regions-Dk3g8bBF.js} +1 -1
- package/studio/web/assets/{setComponentFile-DQkkobG5.js → setComponentFile-DoITgNLb.js} +1 -1
- package/studio/web/assets/{setup-nf-EOheW.js → setup-CiEALLa7.js} +1 -1
- package/studio/web/assets/{status-B2tOM3RF.js → status-CK9tD-Nd.js} +1 -1
- package/studio/web/assets/{swagger-ui-react-UktCouQ0.js → swagger-ui-react-CxjCDFB-.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-alqAn3dT.js → useEntityRestURL-Cy-0i1UZ.js} +1 -1
- package/studio/web/index.html +1 -1
|
@@ -16,6 +16,8 @@ import type { Entry } from './RecordEncoder.ts';
|
|
|
16
16
|
import { toBufferKey } from 'ordered-binary';
|
|
17
17
|
|
|
18
18
|
const trackedTxns = new Set<DatabaseTransaction>();
|
|
19
|
+
// Read options for a rotated generation's native transactions; shared because they never vary.
|
|
20
|
+
const SNAPSHOT_FREE = Object.freeze({ disableSnapshot: true });
|
|
19
21
|
// Logical transactions the monitor supervises for their WRITES, kept apart from trackedTxns because the
|
|
20
22
|
// two have different units and different consumers: trackedTxns is per-link, bounds a read snapshot, and
|
|
21
23
|
// is what the read-queue-depth metric counts, while this holds one entry per logical transaction — the
|
|
@@ -331,6 +333,15 @@ type RocksTransactionWithRetry = RocksTransaction & { isRetry?: boolean };
|
|
|
331
333
|
|
|
332
334
|
export class DatabaseTransaction implements Transaction {
|
|
333
335
|
#context: Context;
|
|
336
|
+
// Whether a resources/transaction.ts scope owns this instance — i.e. a final commit or abort is
|
|
337
|
+
// guaranteed to follow. Only such a transaction may be rotated to a new generation by a mid-scope
|
|
338
|
+
// commit (see rotateAfterMidScopeCommit); anything else must commit each later write immediately,
|
|
339
|
+
// because nothing would commit staged ones. Settable only at construction, so it cannot be turned on
|
|
340
|
+
// for a transaction that is already attached to a context and running.
|
|
341
|
+
#scopeOwned: boolean;
|
|
342
|
+
constructor(options?: { scopeOwned?: boolean }) {
|
|
343
|
+
this.#scopeOwned = options?.scopeOwned === true;
|
|
344
|
+
}
|
|
334
345
|
writes: TransactionWrite[] = []; // the set of writes to commit if the conditions are met
|
|
335
346
|
// the last staged write per store and key, used to chain repeat writes to the same key (linkWrite)
|
|
336
347
|
declare writesByKey?: Map<any, Map<unknown, TransactionWrite>>;
|
|
@@ -391,6 +402,11 @@ export class DatabaseTransaction implements Transaction {
|
|
|
391
402
|
// Set once the retained read handle's write intents have been released (see commit()'s
|
|
392
403
|
// outstanding-iterators branch), so a retry round cannot re-fire the release.
|
|
393
404
|
declare writesAbandoned?: boolean;
|
|
405
|
+
// Set once a mid-scope commit has rotated this instance to a new generation: every native
|
|
406
|
+
// transaction it opens from then on reads WITHOUT a snapshot. Committing mid-scope is how a handler
|
|
407
|
+
// asks to stop reading a pinned snapshot, so re-pinning one for the rest of the scope would take
|
|
408
|
+
// back what it asked for.
|
|
409
|
+
snapshotFree = false;
|
|
394
410
|
|
|
395
411
|
getReadTxn(disableSnapshot?: boolean): ReadTransaction {
|
|
396
412
|
this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
|
|
@@ -417,7 +433,12 @@ export class DatabaseTransaction implements Transaction {
|
|
|
417
433
|
// snapshot that blocks compaction. Only applied when creating the transaction fresh; an
|
|
418
434
|
// already-open transaction keeps whatever snapshot mode it was created with.
|
|
419
435
|
// `coordinatedRetry` signals IsBusy write conflicts as RETRY_NOW rather than ERR_BUSY.
|
|
420
|
-
this.attachOwnedTransaction(
|
|
436
|
+
this.attachOwnedTransaction(
|
|
437
|
+
new RocksTransaction(this.db.store, {
|
|
438
|
+
coordinatedRetry: true,
|
|
439
|
+
disableSnapshot: disableSnapshot || this.snapshotFree,
|
|
440
|
+
})
|
|
441
|
+
);
|
|
421
442
|
|
|
422
443
|
if (this.timestamp) {
|
|
423
444
|
this.transaction.setTimestamp(this.timestamp);
|
|
@@ -743,7 +764,10 @@ export class DatabaseTransaction implements Transaction {
|
|
|
743
764
|
if (!transaction && this.open === TRANSACTION_STATE.OPEN) transaction = this.transaction;
|
|
744
765
|
let immediateCommit = false;
|
|
745
766
|
if (!transaction) {
|
|
746
|
-
transaction = new RocksTransaction(
|
|
767
|
+
transaction = new RocksTransaction(
|
|
768
|
+
operation.store.store as RocksStore,
|
|
769
|
+
this.snapshotFree ? SNAPSHOT_FREE : undefined
|
|
770
|
+
);
|
|
747
771
|
if (operation.store.rootStore !== this.db.rootStore) {
|
|
748
772
|
harperLogger.warn?.('Created new transaction in save, but the store does match existing store', transaction.id);
|
|
749
773
|
}
|
|
@@ -831,6 +855,13 @@ export class DatabaseTransaction implements Transaction {
|
|
|
831
855
|
// check just in case we got any more transactions while we were waiting, if so just recursively continue to finish the additional writes now
|
|
832
856
|
return this.commit(options);
|
|
833
857
|
}
|
|
858
|
+
// The save loop above can be what opened this transaction's native handle — save() attaches
|
|
859
|
+
// one when it had none, which is every ImmediateTransaction commit since its getReadTxn
|
|
860
|
+
// opens none — leaving the local captured before the loop empty while that handle holds
|
|
861
|
+
// every staged write, for the detach below to drop uncommitted (issue #2288). Only when
|
|
862
|
+
// the local is empty: a truthy one is what the loop staged into, and the retained-handle
|
|
863
|
+
// and replay branches below deliberately commit a handle other than this.transaction.
|
|
864
|
+
if (!transaction) transaction = this.transaction;
|
|
834
865
|
this.open = TRANSACTION_STATE.CLOSED;
|
|
835
866
|
// RocksTransaction.commit() resolves with RETRY_NOW_VALUE (a number) under
|
|
836
867
|
// coordinatedRetry, or void on a normal commit/abort.
|
|
@@ -1024,15 +1055,27 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1024
1055
|
// now reset transactions tracking; this transaction be reused and committed again
|
|
1025
1056
|
this.retries = 0; // reset per-native-transaction retry counter so a reused DatabaseTransaction's next batch starts fresh
|
|
1026
1057
|
this.clearWrites();
|
|
1058
|
+
if (options.doneWriting) this.endScopeOwnership();
|
|
1027
1059
|
this.releaseContext(!!options.doneWriting);
|
|
1028
|
-
this.next = null;
|
|
1029
1060
|
let txnTime = this.timestamp;
|
|
1030
1061
|
this.timestamp = 0; // reset the timestamp as well
|
|
1031
|
-
return Promise.all(completions).then(
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1062
|
+
return Promise.all(completions).then(
|
|
1063
|
+
() => {
|
|
1064
|
+
// Only once the chained store's commit has settled, as on the synchronous path: a
|
|
1065
|
+
// partially failed mid-scope commit must not leave the scope resumable.
|
|
1066
|
+
this.completeMidScopeCommit(options);
|
|
1067
|
+
return {
|
|
1068
|
+
txnTime,
|
|
1069
|
+
};
|
|
1070
|
+
},
|
|
1071
|
+
(error) => {
|
|
1072
|
+
// As on the synchronous path: a completion that failed (a chained store's commit,
|
|
1073
|
+
// a replication confirmation) leaves this commit partly landed, so ownership goes
|
|
1074
|
+
// with it rather than letting a later commit rotate on top.
|
|
1075
|
+
this.endScopeOwnership();
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
);
|
|
1036
1079
|
},
|
|
1037
1080
|
(error) => {
|
|
1038
1081
|
// Coordinated transactions surface conflicts as RETRY_NOW (handled in the
|
|
@@ -1110,6 +1153,9 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1110
1153
|
// back-reference here too, or transaction.ts's onComplete() (which has no
|
|
1111
1154
|
// rejection handler of its own) would leave a long-lived context pinning this
|
|
1112
1155
|
// CLOSED wrapper forever.
|
|
1156
|
+
// A failed commit must never be followed by a resumed segment: this generation is
|
|
1157
|
+
// finished and its durability is unknown, so ownership goes with it.
|
|
1158
|
+
this.endScopeOwnership();
|
|
1113
1159
|
this.releaseContext(!!options.doneWriting);
|
|
1114
1160
|
throw error;
|
|
1115
1161
|
}
|
|
@@ -1121,6 +1167,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1121
1167
|
cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
|
|
1122
1168
|
}
|
|
1123
1169
|
this.clearWrites();
|
|
1170
|
+
if (options.doneWriting) this.endScopeOwnership();
|
|
1124
1171
|
this.releaseContext(!!options.doneWriting);
|
|
1125
1172
|
const txnResolution: CommitResolution = {
|
|
1126
1173
|
txnTime: this.timestamp,
|
|
@@ -1129,16 +1176,39 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1129
1176
|
// now run any other transactions
|
|
1130
1177
|
options.timestamp = this.timestamp;
|
|
1131
1178
|
// as above: the next store must not inherit this store's explicit native transaction
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1179
|
+
let nextResolution;
|
|
1180
|
+
try {
|
|
1181
|
+
nextResolution = this.next?.commit(options.transaction ? { ...options, transaction: undefined } : options);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
// A synchronous throw reaches neither rejection handler below, and the head has already
|
|
1184
|
+
// committed — surrender ownership here too, or the scope stays resumable on top of a
|
|
1185
|
+
// half-landed multi-store commit.
|
|
1186
|
+
this.endScopeOwnership();
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1135
1189
|
if ((nextResolution as any)?.then)
|
|
1136
|
-
return (nextResolution as any)?.then(
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1190
|
+
return (nextResolution as any)?.then(
|
|
1191
|
+
(nextResolution) => {
|
|
1192
|
+
// Only once the chained store's own commit has SETTLED: rotating first would leave the
|
|
1193
|
+
// scope resumable after a partially failed mid-scope commit.
|
|
1194
|
+
this.completeMidScopeCommit(options);
|
|
1195
|
+
return {
|
|
1196
|
+
txnTime: this.timestamp,
|
|
1197
|
+
next: nextResolution,
|
|
1198
|
+
};
|
|
1199
|
+
},
|
|
1200
|
+
(error) => {
|
|
1201
|
+
// A chained store's commit failed, so this multi-store commit half-landed. Surrender
|
|
1202
|
+
// ownership as the head's own failure branch does: a handler that catches this and
|
|
1203
|
+
// commits again must not rotate on top of it, and must not have the failed link
|
|
1204
|
+
// dropped from the chain before its abort can clean up its blobs.
|
|
1205
|
+
this.endScopeOwnership();
|
|
1206
|
+
throw error;
|
|
1207
|
+
}
|
|
1208
|
+
);
|
|
1140
1209
|
txnResolution.next = nextResolution as any;
|
|
1141
1210
|
}
|
|
1211
|
+
this.completeMidScopeCommit(options);
|
|
1142
1212
|
return txnResolution;
|
|
1143
1213
|
},
|
|
1144
1214
|
(error) => {
|
|
@@ -1147,6 +1217,44 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1147
1217
|
}
|
|
1148
1218
|
);
|
|
1149
1219
|
}
|
|
1220
|
+
/**
|
|
1221
|
+
* A successful commit that is NOT the scope's final one leaves the scope still running and still
|
|
1222
|
+
* responsible for a commit. Rotate to a fresh OPEN generation so the rest of the scope's writes
|
|
1223
|
+
* stage into it and are committed — or rolled back — as one unit, instead of each committing itself
|
|
1224
|
+
* the moment it is made. Every dispatch path keeps its plain `open === OPEN` check; CLOSED never
|
|
1225
|
+
* gains a second meaning.
|
|
1226
|
+
*
|
|
1227
|
+
* Deliberately not rotated when: the scope is finished (`doneWriting`), nothing owns this instance,
|
|
1228
|
+
* a timeout poisoned it, or a commit failed — a failed or uncertain commit must never be followed by
|
|
1229
|
+
* a resumed segment that can commit on its own. Nor when read iterators still hold the native
|
|
1230
|
+
* handle: that handle belongs to them until they drain, so there is nothing to rotate into and those
|
|
1231
|
+
* writes keep today's immediate-commit path.
|
|
1232
|
+
*/
|
|
1233
|
+
/**
|
|
1234
|
+
* Finish a commit: the chain goes with it, then the scope may rotate. A link left attached and CLOSED
|
|
1235
|
+
* would be reused by txnForContext for the next write to that database and commit itself, surviving a
|
|
1236
|
+
* rollback of the rotated head — the cross-store leftover this rotation exists to prevent. Every
|
|
1237
|
+
* commit path must run this, and none may do one half without the other.
|
|
1238
|
+
*/
|
|
1239
|
+
/** Both scope flags leave together, so no exit can clear one and keep the other. */
|
|
1240
|
+
private endScopeOwnership(): void {
|
|
1241
|
+
this.#scopeOwned = false;
|
|
1242
|
+
this.snapshotFree = false;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
private completeMidScopeCommit(options: CommitOptions): void {
|
|
1246
|
+
this.next = null;
|
|
1247
|
+
this.rotateAfterMidScopeCommit(options);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** See completeMidScopeCommit, which is the only caller and carries the reasoning. */
|
|
1251
|
+
private rotateAfterMidScopeCommit(options: CommitOptions): void {
|
|
1252
|
+
if (options.doneWriting || this.timedOut || this.transaction || !this.#scopeOwned) return;
|
|
1253
|
+
this.open = TRANSACTION_STATE.OPEN;
|
|
1254
|
+
this.snapshotFree = true;
|
|
1255
|
+
this.writesAbandoned = false;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1150
1258
|
abort(): void {
|
|
1151
1259
|
while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
|
|
1152
1260
|
// Defensively release any native handle whose reference bookkeeping was already consumed.
|
|
@@ -1159,6 +1267,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
1159
1267
|
cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
|
|
1160
1268
|
}
|
|
1161
1269
|
} finally {
|
|
1270
|
+
this.endScopeOwnership(); // the scope is over; nothing may rotate this instance again
|
|
1162
1271
|
this.clearWrites();
|
|
1163
1272
|
// A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
|
|
1164
1273
|
// Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
|
|
@@ -75,6 +75,21 @@ export interface Context {
|
|
|
75
75
|
* so code that commits mid-handler can keep using its context. LMDBTransaction does not release,
|
|
76
76
|
* so there the completed transaction itself stays in the slot — also safe to call, but retained.
|
|
77
77
|
* `null` was the previous released marker and is still accepted defensively.
|
|
78
|
+
*
|
|
79
|
+
* A transaction its own handler commits mid-scope is rotated to a fresh open generation, so the rest
|
|
80
|
+
* of that scope's writes are committed — or rolled back — with the scope's final commit rather than
|
|
81
|
+
* each committing itself immediately. Two cases keep the older per-write behavior: a commit made
|
|
82
|
+
* while a read iterator still holds the transaction's handle (that handle is the iterator's until it
|
|
83
|
+
* drains), and a commit that failed. LMDB has always behaved this way; the RocksDB path now matches.
|
|
84
|
+
*
|
|
85
|
+
* The engines still differ on reads. A rotated RocksDB generation is snapshot-free, so the rest of
|
|
86
|
+
* the scope keeps seeing other writers' committed data — which is what committing mid-scope asks for.
|
|
87
|
+
* LMDB cannot open a snapshot-free read transaction, so there the scope keeps its snapshot.
|
|
88
|
+
*
|
|
89
|
+
* Because the writes after a mid-scope commit stage rather than commit one at a time, a single
|
|
90
|
+
* mid-scope commit does not bound how much a long handler holds in memory. A handler streaming a
|
|
91
|
+
* large volume should keep committing (a checkpoint every N records), which commits each batch and
|
|
92
|
+
* rotates again.
|
|
78
93
|
*/
|
|
79
94
|
transaction?: DatabaseTransaction | null;
|
|
80
95
|
/** If the operation that will be performed with this context should check user authorization */
|
package/resources/Table.ts
CHANGED
|
@@ -5667,6 +5667,9 @@ export function makeTable(options) {
|
|
|
5667
5667
|
// commit is tracked with its own identity (DatabaseTransaction.ts's trackOutstandingCommit),
|
|
5668
5668
|
// so a wedged second-store commit is named just as precisely as a wedged first one.
|
|
5669
5669
|
transaction.next.startedFrom = transaction.startedFrom;
|
|
5670
|
+
// A second database joined after a mid-scope commit belongs to the same snapshot-free
|
|
5671
|
+
// generation as the head, or its reads would re-pin what the commit just unpinned.
|
|
5672
|
+
transaction.next.snapshotFree = transaction.snapshotFree;
|
|
5670
5673
|
if (transaction.open === TRANSACTION_STATE.CLOSED) {
|
|
5671
5674
|
// if the current transaction is already closed, we need to retain that state on new databases we work with
|
|
5672
5675
|
transaction.next.open = TRANSACTION_STATE.CLOSED;
|
|
@@ -43,11 +43,16 @@ function dequantizeInt8(q: Int8Array, scale: number): number[] {
|
|
|
43
43
|
|
|
44
44
|
// Auto-scaled search ef, used only when an index does not explicitly configure efConstructionSearch
|
|
45
45
|
// and a query does not pass its own ef. A fixed ef makes recall decay as the graph grows (it explores
|
|
46
|
-
// a shrinking fraction of the graph), so ef grows with sqrt(node count)
|
|
47
|
-
//
|
|
48
|
-
// 5K
|
|
49
|
-
//
|
|
50
|
-
// ef
|
|
46
|
+
// a shrinking fraction of the graph), so ef grows with sqrt(node count) in two regimes, with a
|
|
47
|
+
// ceiling to bound search cost. The first regime's constants come from the original recall/latency
|
|
48
|
+
// sweep (5K-30K, 768-dim cosine, int8) and plateau at AUTO_EF_MAX from ~13K nodes; that plateau was
|
|
49
|
+
// calibrated when layers above 0 were searched at the full ef, which made large efs cost seconds.
|
|
50
|
+
// After the greedy-descent fix (#2125) ef 1024 at 5M nodes costs ~45ms, and the measured decay at a
|
|
51
|
+
// pinned 512 (set-recall 0.997 -> 0.955 -> 0.935 across 1M/2M/5M on well-built graphs, #2181) is
|
|
52
|
+
// recall left on the table, so past AUTO_EF_LARGE_REF nodes the scale resumes from that plateau and
|
|
53
|
+
// runs to AUTO_EF_CEILING. Validated against the same sweeps: the second regime resolves 1,145 at
|
|
54
|
+
// 5M, and the measured ef-1024 point there holds 0.985. Apps preferring latency pin
|
|
55
|
+
// efConstructionSearch or a per-query ef; graphs past ~tens of millions of nodes should shard.
|
|
51
56
|
const AUTO_EF_BASE = 100;
|
|
52
57
|
// The index store holds a graph node plus a primary-key mapping per record, so a key count is twice
|
|
53
58
|
// the node count. Sizes here are in nodes; this converts back for the one consumer still calibrated
|
|
@@ -58,7 +63,15 @@ const INDEX_KEYS_PER_NODE = 2;
|
|
|
58
63
|
// a live count, so the resolved ef can differ from that formula by one at a rounding boundary.
|
|
59
64
|
const AUTO_EF_REF = 500;
|
|
60
65
|
const AUTO_EF_MAX = 512;
|
|
66
|
+
// Nodes at which the second regime starts: 512 was measured sufficient through 1M (set-recall
|
|
67
|
+
// 0.997) and short from 2M up, so the resumed curve is anchored to pass through (1M, 512).
|
|
68
|
+
const AUTO_EF_LARGE_REF = 1_000_000;
|
|
69
|
+
const AUTO_EF_CEILING = 2048;
|
|
61
70
|
function autoScaleEf(nodeCount: number): number {
|
|
71
|
+
if (nodeCount > AUTO_EF_LARGE_REF) {
|
|
72
|
+
const scaled = Math.round(AUTO_EF_MAX * Math.sqrt(nodeCount / AUTO_EF_LARGE_REF));
|
|
73
|
+
return Math.min(AUTO_EF_CEILING, scaled);
|
|
74
|
+
}
|
|
62
75
|
const scaled = Math.round(AUTO_EF_BASE * Math.sqrt(Math.max(1, nodeCount / AUTO_EF_REF)));
|
|
63
76
|
return Math.min(AUTO_EF_MAX, Math.max(AUTO_EF_BASE, scaled));
|
|
64
77
|
}
|
|
@@ -69,10 +82,24 @@ function autoScaleEf(nodeCount: number): number {
|
|
|
69
82
|
const ROUTING_EF = 1;
|
|
70
83
|
// Ceiling on the ef a query's own `offset + limit` can ask for. `limit` is unprivileged and set on
|
|
71
84
|
// every request, so this bounds what a caller can make one thread do synchronously: layer 0 holds
|
|
72
|
-
// `ef` candidates in a sorted array with an O(len) insert. Kept within a small multiple of
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
const LIMIT_EF_MAX =
|
|
85
|
+
// `ef` candidates in a sorted array with an O(len) insert. Kept within a small multiple of the
|
|
86
|
+
// index's own auto-scaled ceiling so the worst case stays the same order. Schema and per-query ef
|
|
87
|
+
// pins are authoritative cost ceilings; only an automatically scaled index widens from `limit`.
|
|
88
|
+
const LIMIT_EF_MAX = 2 * AUTO_EF_CEILING;
|
|
89
|
+
// Auto-scaled construction ef, used only when an index does not explicitly configure efConstruction.
|
|
90
|
+
// At a constant efConstruction, edge quality erodes as the graph grows until true neighbours become
|
|
91
|
+
// unreachable at ANY search ef; the cap bounds per-insert cost. Measurements and policy in
|
|
92
|
+
// DESIGN.md ("efConstruction and the search-ef ceiling both auto-scale with the graph") and #2180.
|
|
93
|
+
const AUTO_EFC_REF = 250_000;
|
|
94
|
+
// Validated to 447 (the 5M point, where the resulting graph held 0.985 set-recall at ef 1024); the
|
|
95
|
+
// headroom to 1024 is the same sqrt curve extrapolated, binding at ~26M nodes. Build cost grows
|
|
96
|
+
// with the curve (N^1.5 total under sqrt scaling), which is why the cap stays finite: past ~tens of
|
|
97
|
+
// millions of nodes, sharded medium graphs beat one huge graph on both build and query cost.
|
|
98
|
+
const AUTO_EFC_MAX = 1024;
|
|
99
|
+
function autoScaleEfConstruction(nodeCount: number): number {
|
|
100
|
+
const scaled = Math.round(AUTO_EF_BASE * Math.sqrt(Math.max(1, nodeCount / AUTO_EFC_REF)));
|
|
101
|
+
return Math.min(AUTO_EFC_MAX, Math.max(AUTO_EF_BASE, scaled));
|
|
102
|
+
}
|
|
76
103
|
// How long a resolved graph size is reused before it is looked up again (see approximateNodeCount).
|
|
77
104
|
// ef moves with the square root of the count and is capped, so a slightly stale size is immaterial;
|
|
78
105
|
// this only has to be short enough that a table growing from empty picks up a larger ef promptly.
|
|
@@ -182,22 +209,22 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
182
209
|
// a value of 1 is extremely aggressive.
|
|
183
210
|
optimizeRouting = 0.5;
|
|
184
211
|
nodesVisitedCount = 0;
|
|
185
|
-
// Visit-budget multiplier for predicate-aware traversal (#1241).
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
// ~ef/s visits; a multiplier of 24 fills down to ~4% selectivity before the budget bites, keeping
|
|
192
|
-
// recall at or above post-filtering across the range the query planner routes to traversal. Raising
|
|
193
|
-
// it is nearly free for condition-derived filters (they fill and self-terminate); it mainly trades
|
|
194
|
-
// latency for recall on selective *function* predicates. Per-query override via the search options.
|
|
212
|
+
// Visit-budget multiplier for predicate-aware traversal (#1241). Under-filled filtered searches
|
|
213
|
+
// stop after the resolved budget ef * filterExpansion visits; automatic search ef contributes at
|
|
214
|
+
// most AUTO_EF_MAX, while explicit schema/query ef remains authoritative. A filter that fills its
|
|
215
|
+
// candidate list terminates naturally before this bound. Raising the multiplier mainly trades
|
|
216
|
+
// latency for recall on selective function predicates; 24 fills to roughly 4% selectivity before
|
|
217
|
+
// the budget binds. Per-query override via the search options.
|
|
195
218
|
filterExpansion = 24;
|
|
196
219
|
|
|
197
220
|
idIncrementer: BigInt64Array | undefined;
|
|
198
221
|
distance: (a: number[], b: number[]) => number;
|
|
199
222
|
int8 = true; // store vectors as int8-quantized bins by default; opt out with `quantization: "none"`
|
|
200
|
-
efSearchConfigured = false; // whether the schema
|
|
223
|
+
efSearchConfigured = false; // whether the schema pins search ef directly or through efConstruction
|
|
224
|
+
efConstructionConfigured = false; // whether the schema set an explicit efConstruction; if not, it auto-scales with N
|
|
225
|
+
private lastLoggedEfConstruction = 0;
|
|
226
|
+
private idIncrementerRetryAt = 0;
|
|
227
|
+
private idIncrementerFailureLogged = false;
|
|
201
228
|
// Caches the Int8Array-converted clone of a frozen (decoded-from-disk) int8 node, keyed by the
|
|
202
229
|
// frozen node the object store hands back. WeakMap so entries are collected when the store evicts
|
|
203
230
|
// the frozen node — without it, every cache hit on a frozen node would re-slice and re-clone.
|
|
@@ -212,8 +239,9 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
212
239
|
this.indexStore.encoder.useFloat32 = FLOAT32_OPTIONS.ALWAYS;
|
|
213
240
|
}
|
|
214
241
|
this.int8 = options?.quantization !== 'none';
|
|
215
|
-
// Respect an explicitly-configured
|
|
242
|
+
// Respect an explicitly-configured ef (efConstruction seeds the search ef too); otherwise auto-scale both.
|
|
216
243
|
this.efSearchConfigured = options?.efConstructionSearch !== undefined || options?.efConstruction !== undefined;
|
|
244
|
+
this.efConstructionConfigured = options?.efConstruction !== undefined;
|
|
217
245
|
this.distance =
|
|
218
246
|
options?.distance === 'euclidean'
|
|
219
247
|
? euclideanDistance
|
|
@@ -258,24 +286,8 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
258
286
|
// that won't collide with the node ids, so we can't have a collision with internal
|
|
259
287
|
if (!nodeId) {
|
|
260
288
|
if (!vector) return; // didn't exist before, doesn't exist now, nothing to do
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
for (const key of this.indexStore.getKeys({
|
|
264
|
-
reverse: true,
|
|
265
|
-
limit: 1,
|
|
266
|
-
start: Infinity,
|
|
267
|
-
end: 0,
|
|
268
|
-
transaction: options.transaction,
|
|
269
|
-
})) {
|
|
270
|
-
if (typeof key === 'number') largestNodeId = key;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
this.idIncrementer = new BigInt64Array([BigInt(largestNodeId) + 1n]);
|
|
274
|
-
this.idIncrementer = new BigInt64Array(
|
|
275
|
-
this.indexStore.getUserSharedBuffer('next-id', this.idIncrementer.buffer)
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
nodeId = Number(Atomics.add(this.idIncrementer, 0, 1n));
|
|
289
|
+
this.ensureIdIncrementer(options);
|
|
290
|
+
nodeId = Number(Atomics.add(this.idIncrementer!, 0, 1n));
|
|
279
291
|
this.indexStore.put(safeKey, nodeId, options);
|
|
280
292
|
}
|
|
281
293
|
const updatedNodes = new Map<number, Node>();
|
|
@@ -375,9 +387,25 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
375
387
|
connections[i] = [];
|
|
376
388
|
}
|
|
377
389
|
|
|
378
|
-
//
|
|
390
|
+
// An update-only worker may not have attached the id counter yet. The healthy path is one
|
|
391
|
+
// atomic load; a failed attach falls back to the memoized seek and retries after its TTL.
|
|
392
|
+
let efConstruction = this.efConstruction;
|
|
393
|
+
if (!this.efConstructionConfigured) {
|
|
394
|
+
// Graph size only tunes a heuristic; a write must never fail because it could not be resolved.
|
|
395
|
+
try {
|
|
396
|
+
efConstruction = autoScaleEfConstruction(this.resolveConstructionNodeCount(options));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
logger.debug?.('could not resolve the HNSW construction node count; using the base ef', error);
|
|
399
|
+
}
|
|
400
|
+
if (efConstruction > this.efConstruction && this.lastLoggedEfConstruction !== efConstruction) {
|
|
401
|
+
// once per resolved value per process: makes replica-divergent build quality and the
|
|
402
|
+
// build-cost ramp diagnosable (the resolved value is otherwise surfaced nowhere)
|
|
403
|
+
this.lastLoggedEfConstruction = efConstruction;
|
|
404
|
+
logger.debug?.(`HNSW construction ef auto-scaled to ${efConstruction}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
379
407
|
for (let l = Math.min(level, currentLevel); l >= 0; l--) {
|
|
380
|
-
let neighbors = this.searchLayer(vector, entryPointId, entryPoint,
|
|
408
|
+
let neighbors = this.searchLayer(vector, entryPointId, entryPoint, efConstruction, l, options);
|
|
381
409
|
neighbors = neighbors.slice(0, this.M << 1) as SearchResults;
|
|
382
410
|
|
|
383
411
|
if (neighbors.length === 0 && l === 0) {
|
|
@@ -531,8 +559,7 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
531
559
|
}
|
|
532
560
|
}
|
|
533
561
|
this.indexStore.remove(nodeId, options);
|
|
534
|
-
//
|
|
535
|
-
// and a re-insert of this primary key gets a fresh node rather than the deleted node's id.
|
|
562
|
+
// A re-insert of this primary key must get a fresh node rather than the deleted node's id.
|
|
536
563
|
this.indexStore.remove(safeKey, options);
|
|
537
564
|
}
|
|
538
565
|
const needsReindexing = new Map();
|
|
@@ -717,19 +744,84 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
717
744
|
* measurements behind both. The memo is the only gate on how often the size is resolved; nothing on
|
|
718
745
|
* the query path may bypass it, or the O(1) lookup becomes per-query work again.
|
|
719
746
|
*/
|
|
720
|
-
private approximateNodeCount(): number {
|
|
747
|
+
private approximateNodeCount(options?: any): number {
|
|
721
748
|
const now = Date.now();
|
|
722
749
|
if (this.nodeCountAt > 0 && now - this.nodeCountAt < NODE_COUNT_TTL) return this.nodeCount;
|
|
723
|
-
this.nodeCount = this.resolveNodeCount();
|
|
750
|
+
this.nodeCount = this.resolveNodeCount(options);
|
|
724
751
|
this.nodeCountAt = now;
|
|
725
752
|
return this.nodeCount;
|
|
726
753
|
}
|
|
727
754
|
|
|
755
|
+
private resolveConstructionNodeCount(options?: any): number {
|
|
756
|
+
if (this.idIncrementer) return this.resolveNodeCount();
|
|
757
|
+
const now = Date.now();
|
|
758
|
+
if (now >= this.idIncrementerRetryAt) {
|
|
759
|
+
try {
|
|
760
|
+
this.ensureIdIncrementer(options);
|
|
761
|
+
this.idIncrementerRetryAt = 0;
|
|
762
|
+
this.idIncrementerFailureLogged = false;
|
|
763
|
+
return this.resolveNodeCount();
|
|
764
|
+
} catch (error) {
|
|
765
|
+
this.idIncrementerRetryAt = now + NODE_COUNT_TTL;
|
|
766
|
+
if (!this.idIncrementerFailureLogged) {
|
|
767
|
+
this.idIncrementerFailureLogged = true;
|
|
768
|
+
logger.warn?.('could not attach the shared HNSW id counter; using a memoized node count', error);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return this.approximateNodeCount(options);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Create-or-attach the shared id counter, seeded from a one-time reverse seek to the largest node
|
|
777
|
+
* id. getUserSharedBuffer returns the existing shared buffer when another worker created it
|
|
778
|
+
* first, so the seed only matters for whoever wins the race.
|
|
779
|
+
*/
|
|
780
|
+
private ensureIdIncrementer(options?: any): void {
|
|
781
|
+
if (this.idIncrementer) return;
|
|
782
|
+
let largestNodeId = 0;
|
|
783
|
+
for (const key of this.indexStore.getKeys({
|
|
784
|
+
reverse: true,
|
|
785
|
+
limit: 1,
|
|
786
|
+
start: Infinity,
|
|
787
|
+
end: 0,
|
|
788
|
+
transaction: options?.transaction,
|
|
789
|
+
})) {
|
|
790
|
+
if (typeof key === 'number') largestNodeId = key;
|
|
791
|
+
}
|
|
792
|
+
// Never install the counter until the shared attach succeeds: assigning the private seed
|
|
793
|
+
// array first would, on an attach failure, leave THIS process allocating ids nobody else can
|
|
794
|
+
// see — cross-worker id collisions. Left unset, the next write simply retries the ensure.
|
|
795
|
+
const seed = new BigInt64Array([BigInt(largestNodeId) + 1n]);
|
|
796
|
+
try {
|
|
797
|
+
const sharedBuffer = this.indexStore.getUserSharedBuffer('next-id', seed.buffer);
|
|
798
|
+
if (
|
|
799
|
+
!sharedBuffer ||
|
|
800
|
+
sharedBuffer.byteLength < BigInt64Array.BYTES_PER_ELEMENT ||
|
|
801
|
+
sharedBuffer.byteLength % BigInt64Array.BYTES_PER_ELEMENT !== 0
|
|
802
|
+
) {
|
|
803
|
+
throw new Error('Shared HNSW id counter buffer is unusable');
|
|
804
|
+
}
|
|
805
|
+
this.idIncrementer = new BigInt64Array(sharedBuffer);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
// Reuse the transactional seed seek as the degraded count instead of seeking a second time.
|
|
808
|
+
this.nodeCount = largestNodeId + 1;
|
|
809
|
+
this.nodeCountAt = Date.now();
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
728
814
|
/** O(1) node count — the shared id counter, else a single reverse seek to the largest node id. */
|
|
729
|
-
private resolveNodeCount(): number {
|
|
815
|
+
private resolveNodeCount(options?: any): number {
|
|
730
816
|
if (this.idIncrementer) return Number(Atomics.load(this.idIncrementer, 0));
|
|
731
817
|
try {
|
|
732
|
-
for (const key of this.indexStore.getKeys({
|
|
818
|
+
for (const key of this.indexStore.getKeys({
|
|
819
|
+
reverse: true,
|
|
820
|
+
limit: 1,
|
|
821
|
+
start: Infinity,
|
|
822
|
+
end: 0,
|
|
823
|
+
transaction: options?.transaction,
|
|
824
|
+
})) {
|
|
733
825
|
if (typeof key === 'number') return key + 1;
|
|
734
826
|
}
|
|
735
827
|
} catch (error) {
|
|
@@ -1026,8 +1118,8 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
1026
1118
|
if (!Array.isArray(target)) throw new ClientError('The target vector must be an array');
|
|
1027
1119
|
|
|
1028
1120
|
const options = context.transaction; // should have a nested RocksDB transaction
|
|
1029
|
-
// Resolve search ef: per-query ef wins; else
|
|
1030
|
-
//
|
|
1121
|
+
// Resolve search ef: per-query ef wins; else use the schema-pinned value (from either ef option);
|
|
1122
|
+
// otherwise auto-scale with the graph size so recall holds as the table grows.
|
|
1031
1123
|
let effectiveEf = this.efConstructionSearch;
|
|
1032
1124
|
const explicitEf = ef !== undefined && ef > 0;
|
|
1033
1125
|
if (explicitEf) effectiveEf = ef;
|
|
@@ -1040,22 +1132,29 @@ export class HierarchicalNavigableSmallWorld {
|
|
|
1040
1132
|
// to cover the request, up to LIMIT_EF_MAX — `ef` sizes a synchronous traversal that holds every
|
|
1041
1133
|
// admitted candidate in a sorted array with an O(len) insert, so an unbounded one lets a plain
|
|
1042
1134
|
// `limit` stall the thread. Past the ceiling the result set is still short, as it was before.
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1135
|
+
// Schema and per-query `ef` pins are authoritative cost ceilings. Only an automatically scaled
|
|
1136
|
+
// index widens toward LIMIT_EF_MAX to cover a larger bounded request.
|
|
1045
1137
|
// The ceiling is the only bound: clamping to the graph size as well would need a count exact as
|
|
1046
1138
|
// of this query — the memo reads low while a table grows, truncating the very limit this
|
|
1047
1139
|
// honours — and an ef above the node count is free, the traversal ending at the graph, not ef.
|
|
1048
|
-
if (minResults !== undefined && !explicitEf && minResults > effectiveEf) {
|
|
1140
|
+
if (minResults !== undefined && !explicitEf && !this.efSearchConfigured && minResults > effectiveEf) {
|
|
1049
1141
|
effectiveEf = Math.max(effectiveEf, Math.min(minResults, LIMIT_EF_MAX));
|
|
1050
1142
|
}
|
|
1051
1143
|
// Predicate-aware traversal budget (#1241): matches accrue slower than visits under a selective
|
|
1052
1144
|
// filter, so bound layer-0 work at ef * filterExpansion nodes. Only built when a filter is active.
|
|
1053
1145
|
// Deliberately `resolvedEf`, not the limit-widened ef: this budget is what stops a selective
|
|
1054
1146
|
// filter crawling the whole graph, and multiplying it by a caller's limit would turn a filtered
|
|
1055
|
-
// vector query into a record-loading scan.
|
|
1147
|
+
// vector query into a record-loading scan. When the ef came from the auto-scale (neither a
|
|
1148
|
+
// per-query ef nor a schema-configured one), its budget contribution is additionally capped at
|
|
1149
|
+
// AUTO_EF_MAX: every budgeted visit is a synchronous record load + predicate evaluation, so the
|
|
1150
|
+
// second-regime search ef (up to AUTO_EF_CEILING) must not silently quadruple the filtered
|
|
1151
|
+
// worst case — the recall decision and the filtered-scan budget are separate decisions. Callers
|
|
1152
|
+
// wanting a deeper filtered search set an explicit ef or filterExpansion, and own the cost.
|
|
1056
1153
|
const filterState: FilterState | undefined = filter
|
|
1057
1154
|
? {
|
|
1058
|
-
maxVisits:
|
|
1155
|
+
maxVisits:
|
|
1156
|
+
(explicitEf || this.efSearchConfigured ? resolvedEf : Math.min(resolvedEf, AUTO_EF_MAX)) *
|
|
1157
|
+
(filterExpansion && filterExpansion > 0 ? filterExpansion : this.filterExpansion),
|
|
1059
1158
|
nodesVisited: 0,
|
|
1060
1159
|
filterEvaluations: 0,
|
|
1061
1160
|
}
|
package/resources/transaction.ts
CHANGED
|
@@ -45,7 +45,9 @@ export function transaction<T>(
|
|
|
45
45
|
return callback(context.transaction); // nothing to be done, already in open transaction
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// scopeOwned: onComplete/onError below guarantee this instance a final commit or an abort, which is
|
|
49
|
+
// what lets a mid-scope commit rotate it instead of leaving later writes to commit themselves.
|
|
50
|
+
const transaction = new DatabaseTransaction({ scopeOwned: true });
|
|
49
51
|
context.transaction = transaction;
|
|
50
52
|
if (context.timestamp) transaction.timestamp = context.timestamp;
|
|
51
53
|
if (context.replicatedConfirmation) transaction.replicatedConfirmation = context.replicatedConfirmation;
|
package/schema.graphql
CHANGED
|
@@ -188,7 +188,9 @@ directive @indexed(
|
|
|
188
188
|
distance: String
|
|
189
189
|
"""
|
|
190
190
|
Construction effort/recall parameter (HNSW). Higher values improve recall at
|
|
191
|
-
the cost of build time and memory.
|
|
191
|
+
the cost of build time and memory. When omitted, it auto-scales with graph
|
|
192
|
+
size. Setting it pins both the build-side value and the search-side default;
|
|
193
|
+
changing it rebuilds the index.
|
|
192
194
|
"""
|
|
193
195
|
efConstruction: Int
|
|
194
196
|
"""
|
|
@@ -205,8 +207,11 @@ directive @indexed(
|
|
|
205
207
|
"""
|
|
206
208
|
mL: Int
|
|
207
209
|
"""
|
|
208
|
-
Search-time effort/recall parameter
|
|
209
|
-
|
|
210
|
+
Search-time effort/recall parameter (HNSW): the candidate-list size used when
|
|
211
|
+
querying. Larger values typically yield better accuracy at the cost of query
|
|
212
|
+
latency. When both this and efConstruction are omitted, it auto-scales with
|
|
213
|
+
graph size, continuing past one million nodes up to 2048. Pin it when query
|
|
214
|
+
latency matters more than recall on large tables.
|
|
210
215
|
"""
|
|
211
216
|
efConstructionSearch: Int
|
|
212
217
|
) on FIELD_DEFINITION
|
|
@@ -11,7 +11,7 @@ const harperBridge =
|
|
|
11
11
|
require('../../dataLayer/harperBridge/harperBridge.ts').default ||
|
|
12
12
|
require('../../dataLayer/harperBridge/harperBridge.ts');
|
|
13
13
|
const process = require('process');
|
|
14
|
-
const { isMainThread, workerData } = require('worker_threads');
|
|
14
|
+
const { isMainThread, threadId, workerData } = require('node:worker_threads');
|
|
15
15
|
const { resetDatabases, closeDatabase } = require('../../resources/databases.ts');
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -187,6 +187,7 @@ async function componentStatusRequestHandler(event) {
|
|
|
187
187
|
message: {
|
|
188
188
|
requestId: event.message.requestId,
|
|
189
189
|
statuses: statusArray,
|
|
190
|
+
threadId,
|
|
190
191
|
workerIndex: workerIndex,
|
|
191
192
|
isMainThread: isMainThread,
|
|
192
193
|
},
|