@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.
Files changed (50) hide show
  1. package/components/status/crossThread.ts +69 -44
  2. package/components/status/types.ts +1 -0
  3. package/dist/components/status/crossThread.d.ts +1 -0
  4. package/dist/components/status/crossThread.js +57 -41
  5. package/dist/components/status/crossThread.js.map +1 -1
  6. package/dist/components/status/types.d.ts +1 -0
  7. package/dist/resources/DatabaseTransaction.d.ts +28 -0
  8. package/dist/resources/DatabaseTransaction.js +110 -8
  9. package/dist/resources/DatabaseTransaction.js.map +1 -1
  10. package/dist/resources/ResourceInterface.d.ts +15 -0
  11. package/dist/resources/ResourceInterface.js.map +1 -1
  12. package/dist/resources/Table.js +3 -0
  13. package/dist/resources/Table.js.map +1 -1
  14. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +11 -0
  15. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +153 -51
  16. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  17. package/dist/resources/transaction.js +3 -1
  18. package/dist/resources/transaction.js.map +1 -1
  19. package/dist/server/itc/serverHandlers.js +2 -1
  20. package/dist/server/itc/serverHandlers.js.map +1 -1
  21. package/dist/server/threads/manageThreads.d.ts +1 -0
  22. package/dist/server/threads/manageThreads.js +17 -2
  23. package/dist/server/threads/manageThreads.js.map +1 -1
  24. package/npm-shrinkwrap.json +2 -2
  25. package/package.json +1 -1
  26. package/resources/DESIGN.md +19 -18
  27. package/resources/DatabaseTransaction.ts +124 -15
  28. package/resources/ResourceInterface.ts +15 -0
  29. package/resources/Table.ts +3 -0
  30. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +153 -54
  31. package/resources/transaction.ts +3 -1
  32. package/schema.graphql +8 -3
  33. package/server/itc/serverHandlers.js +2 -1
  34. package/server/threads/manageThreads.js +17 -2
  35. package/studio/web/assets/{Chat-BvOlYIhe.js → Chat-Lgj1d-8d.js} +1 -1
  36. package/studio/web/assets/{FloatingChat-fCJiJCH2.js → FloatingChat-jLWvR4kI.js} +3 -3
  37. package/studio/web/assets/{apiToken-bKwPv0uN.js → apiToken-DYI4DqWa.js} +1 -1
  38. package/studio/web/assets/{applications-BZ47njJ_.js → applications-DCmc9-lk.js} +1 -1
  39. package/studio/web/assets/{index-DF1fSnXV.js → index-CqbCnqsT.js} +4 -4
  40. package/studio/web/assets/{index.lazy-DWt8ubrr.js → index.lazy-BNqr3j0f.js} +3 -3
  41. package/studio/web/assets/{notifications-B5ZBOni1.js → notifications-Bb2cj14_.js} +1 -1
  42. package/studio/web/assets/{notifications-gVx0lA8H.js → notifications-DVlKonbI.js} +1 -1
  43. package/studio/web/assets/{profile-D7g0x28A.js → profile-BSQWfYfq.js} +1 -1
  44. package/studio/web/assets/{regions-DRkhlkiD.js → regions-Dk3g8bBF.js} +1 -1
  45. package/studio/web/assets/{setComponentFile-DQkkobG5.js → setComponentFile-DoITgNLb.js} +1 -1
  46. package/studio/web/assets/{setup-nf-EOheW.js → setup-CiEALLa7.js} +1 -1
  47. package/studio/web/assets/{status-B2tOM3RF.js → status-CK9tD-Nd.js} +1 -1
  48. package/studio/web/assets/{swagger-ui-react-UktCouQ0.js → swagger-ui-react-CxjCDFB-.js} +1 -1
  49. package/studio/web/assets/{useEntityRestURL-alqAn3dT.js → useEntityRestURL-Cy-0i1UZ.js} +1 -1
  50. 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(new RocksTransaction(this.db.store, { coordinatedRetry: true, disableSnapshot }));
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(operation.store.store as RocksStore);
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
- return {
1033
- txnTime,
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
- const nextResolution = this.next?.commit(
1133
- options.transaction ? { ...options, transaction: undefined } : options
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((nextResolution) => ({
1137
- txnTime: this.timestamp,
1138
- next: nextResolution,
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 */
@@ -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), capped to bound search cost.
47
- // Constants from a recall/latency-vs-N sweep (768-dim cosine, int8): ef≈400 holds ~0.8 recall@10 from
48
- // 5K30K, and the recall/latency tradeoff is steep (ef 800 at 30K 0.92 recall but ~2s p50), so the
49
- // cap deliberately favors latency apps wanting higher recall set efConstructionSearch or a per-query
50
- // ef. Tune as graph build quality / larger-N data improves.
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
- // AUTO_EF_MAX so the worst case stays the same order as the index's own auto-scaled ceiling; a
74
- // caller who genuinely wants more sets an explicit `ef` and owns the cost.
75
- const LIMIT_EF_MAX = 4 * AUTO_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). When a filter is selective enough
186
- // that layer-0 results never fill `ef`, the "closest candidate worse than worst result" stop rule
187
- // never triggers, so maxVisits = ef * filterExpansion caps how many nodes an under-filled filtered
188
- // search visits before returning what it has (approximate, like all ANN). It does NOT bound a filter
189
- // that fills `ef` that terminates naturally. Default 24 (not the 8 the design sketch assumed):
190
- // this HNSW visits a large fraction of the graph per query, so filling `ef` at selectivity `s` needs
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 set an explicit search ef; if not, search ef auto-scales with N
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 search ef (or efConstruction, which seeds it); otherwise auto-scale.
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
- if (!this.idIncrementer) {
262
- let largestNodeId = 0;
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
- // Connect the new element to neighbors at its level and below
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, this.efConstruction, l, options);
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
- // Remove the safeKey→nodeId mapping so the key count used by autoScaleEf stays accurate
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({ reverse: true, limit: 1, start: Infinity, end: 0 })) {
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 an explicitly-configured efConstructionSearch;
1030
- // else auto-scale with the graph size so recall holds as the table grows.
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
- // A per-query `ef` is left authoritative: it is an explicit cost ceiling, and a caller who sets
1044
- // one has said what they are willing to spend.
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: resolvedEf * (filterExpansion && filterExpansion > 0 ? filterExpansion : this.filterExpansion),
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
  }
@@ -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
- const transaction = new DatabaseTransaction();
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 used during construction (implementation-
209
- specific). Larger values typically yield better accuracy.
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
  },