@harperfast/harper-pro 5.0.16 → 5.0.18

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 (49) hide show
  1. package/core/DESIGN.md +32 -0
  2. package/core/bin/copyDb.ts +19 -0
  3. package/core/resources/RecordEncoder.ts +15 -12
  4. package/core/resources/RocksTransactionLogStore.ts +51 -0
  5. package/core/resources/Table.ts +17 -15
  6. package/core/resources/auditStore.ts +97 -5
  7. package/core/resources/databases.ts +67 -7
  8. package/core/resources/replayLogs.ts +36 -3
  9. package/core/resources/replayLogsGuards.ts +42 -0
  10. package/core/resources/transactionBroadcast.ts +121 -66
  11. package/dist/cloneNode/cloneNode.js +13 -8
  12. package/dist/cloneNode/cloneNode.js.map +1 -1
  13. package/dist/core/bin/copyDb.js +16 -0
  14. package/dist/core/bin/copyDb.js.map +1 -1
  15. package/dist/core/resources/RecordEncoder.js +1 -1
  16. package/dist/core/resources/RecordEncoder.js.map +1 -1
  17. package/dist/core/resources/RocksTransactionLogStore.js.map +1 -1
  18. package/dist/core/resources/Table.js +17 -17
  19. package/dist/core/resources/Table.js.map +1 -1
  20. package/dist/core/resources/auditStore.js +82 -5
  21. package/dist/core/resources/auditStore.js.map +1 -1
  22. package/dist/core/resources/databases.js +68 -5
  23. package/dist/core/resources/databases.js.map +1 -1
  24. package/dist/core/resources/replayLogs.js +26 -2
  25. package/dist/core/resources/replayLogs.js.map +1 -1
  26. package/dist/core/resources/replayLogsGuards.js +43 -0
  27. package/dist/core/resources/replayLogsGuards.js.map +1 -0
  28. package/dist/core/resources/transactionBroadcast.js +129 -71
  29. package/dist/core/resources/transactionBroadcast.js.map +1 -1
  30. package/dist/replication/replicationConnection.js +174 -48
  31. package/dist/replication/replicationConnection.js.map +1 -1
  32. package/dist/replication/replicator.js +11 -2
  33. package/dist/replication/replicator.js.map +1 -1
  34. package/dist/replication/subscriptionManager.js +11 -1
  35. package/dist/replication/subscriptionManager.js.map +1 -1
  36. package/npm-shrinkwrap.json +2 -2
  37. package/package.json +1 -1
  38. package/replication/replicationConnection.ts +176 -55
  39. package/replication/replicator.ts +11 -2
  40. package/replication/subscriptionManager.ts +11 -1
  41. package/studio/web/assets/{index-pr02wSIB.js → index-Tv7e9k8K.js} +5 -5
  42. package/studio/web/assets/{index-pr02wSIB.js.map → index-Tv7e9k8K.js.map} +1 -1
  43. package/studio/web/assets/{index.lazy-CorGZz3L.js → index.lazy-De4JGuec.js} +2 -2
  44. package/studio/web/assets/{index.lazy-CorGZz3L.js.map → index.lazy-De4JGuec.js.map} +1 -1
  45. package/studio/web/assets/{profile-SSvkzt9H.js → profile-voeNsl4C.js} +2 -2
  46. package/studio/web/assets/{profile-SSvkzt9H.js.map → profile-voeNsl4C.js.map} +1 -1
  47. package/studio/web/assets/{status-Xk93QrPQ.js → status-110CCE-v.js} +2 -2
  48. package/studio/web/assets/{status-Xk93QrPQ.js.map → status-110CCE-v.js.map} +1 -1
  49. package/studio/web/index.html +1 -1
@@ -91,6 +91,11 @@ exports.BACK_PRESSURE_RATIO_POSITION = 6;
91
91
  exports.RECEIVING_STATUS_WAITING = 0;
92
92
  exports.RECEIVING_STATUS_RECEIVING = 1;
93
93
  const MAX_PAYLOAD = environmentManager_js_1.default.get('replication_maxPayload') ?? 100_000_000;
94
+ // When receiving a replication message, we apply per-record backpressure to keep a single
95
+ // large batch from synchronously decoding thousands of records and ballooning the worker
96
+ // heap past its limit. If the local replicator queue grows beyond this threshold we pause
97
+ // the WS connection and wait for it to drain before continuing the decode loop.
98
+ const RECEIVE_EVENT_HIGH_WATER_MARK = environmentManager_js_1.default.get('replication_receiveEventHighWaterMark') ?? 100;
94
99
  exports.tableUpdateListeners = new Map();
95
100
  // This a map of the database name to the subscription object, for the subscriptions from our tables to the replication module
96
101
  // when we receive messages from other nodes, we then forward them on to as a notification on these subscriptions
@@ -175,6 +180,11 @@ class NodeReplicationConnection extends events_1.EventEmitter {
175
180
  retries = 0;
176
181
  isConnected = true; // we start out assuming we will be connected
177
182
  isFinished = false;
183
+ // Set when this connection should never reconnect: user-driven unsubscribe(), or the
184
+ // empty-subscription delayed close inside replicateOverWS. Distinct from `isFinished`,
185
+ // which is the post-close terminal marker. Anything else (protocol errors, peer
186
+ // DISCONNECT, etc.) leaves this false so the close handler schedules a retry.
187
+ intentionallyUnsubscribed = false;
178
188
  nodeSubscriptions;
179
189
  latency = 0;
180
190
  replicateTablesByDefault;
@@ -216,15 +226,31 @@ class NodeReplicationConnection extends events_1.EventEmitter {
216
226
  });
217
227
  }
218
228
  this.isConnected = true;
219
- session = replicateOverWS(this.socket, {
220
- database: this.databaseName,
221
- subscription: this.subscription,
222
- url: this.url,
223
- connection: this,
224
- isSubscriptionConnection: this.nodeSubscriptions !== undefined,
225
- }, { replicates: true } // pre-authorized, but should only make publish: true if we are allowing reverse subscriptions
226
- );
227
- this.sessionResolve(session);
229
+ try {
230
+ session = replicateOverWS(this.socket, {
231
+ database: this.databaseName,
232
+ subscription: this.subscription,
233
+ url: this.url,
234
+ connection: this,
235
+ isSubscriptionConnection: this.nodeSubscriptions !== undefined,
236
+ }, { replicates: true } // pre-authorized, but should only make publish: true if we are allowing reverse subscriptions
237
+ );
238
+ this.sessionResolve(session);
239
+ }
240
+ catch (error) {
241
+ // replicateOverWS does a fair amount of synchronous setup (setDatabase, audit
242
+ // store wiring, ping bookkeeping) and any of it can throw — most worryingly,
243
+ // audit decoder corruption surfaced via setDatabase or the immediate getRange.
244
+ // Without this guard the throw escapes the WS 'open' listener as an
245
+ // uncaughtException, the socket stays open, sessionResolve is never called, no
246
+ // 'close' fires, and the retry timer in the close handler never gets scheduled —
247
+ // leaving the (peer, db) pair stuck with `connected: false` on main but no further
248
+ // activity until a process restart. Terminating the socket forces the close
249
+ // handler to run, which now retries.
250
+ logger.error?.(`Error setting up replication session to ${this.url} (db: "${this.databaseName}"), terminating to retry`, error);
251
+ this.sessionReject(error);
252
+ this.socket.terminate();
253
+ }
228
254
  });
229
255
  this.socket.on('error', (error) => {
230
256
  if (error.code === 'SELF_SIGNED_CERT_IN_CHAIN') {
@@ -240,20 +266,26 @@ class NodeReplicationConnection extends events_1.EventEmitter {
240
266
  this.sessionReject(error);
241
267
  });
242
268
  this.socket.on('close', (code, reasonBuffer) => {
243
- // if we get disconnected, notify subscriptions manager so we can reroute through another node
269
+ // Only treat the close as terminal when something explicitly marked it as a deliberate
270
+ // teardown (user unsubscribe or the empty-subscription delayed close). Protocol-level
271
+ // closes — peer DISCONNECT, unauthorized after open, node-name-mismatch, invalid
272
+ // sequence id, etc. — used to also set `isFinished` via replicateOverWS's close()
273
+ // helper, which left the connection silently dead and required hdb_nodes churn to
274
+ // recover. Those now fall through to the retry path below.
275
+ const intentional = this.intentionallyUnsubscribed;
244
276
  if (this.isConnected) {
245
277
  if (this.nodeSubscriptions) {
246
278
  (0, subscriptionManager_ts_1.disconnectedFromNode)({
247
279
  name: this.nodeName,
248
280
  database: this.databaseName,
249
281
  url: this.url,
250
- finished: this.socket.isFinished,
282
+ finished: intentional,
251
283
  });
252
284
  }
253
285
  this.isConnected = false;
254
286
  }
255
287
  this.removeAllListeners('subscriptions-updated');
256
- if (this.socket.isFinished) {
288
+ if (intentional) {
257
289
  this.isFinished = true;
258
290
  session?.end();
259
291
  this.emit('finished');
@@ -285,7 +317,7 @@ class NodeReplicationConnection extends events_1.EventEmitter {
285
317
  this.emit('subscriptions-updated', nodeSubscriptions);
286
318
  }
287
319
  unsubscribe() {
288
- this.socket.isFinished = true;
320
+ this.intentionallyUnsubscribed = true;
289
321
  this.socket.close(1008, 'No longer subscribed');
290
322
  }
291
323
  getRecord(request) {
@@ -420,13 +452,37 @@ function replicateOverWS(ws, options, authorization) {
420
452
  const MAX_OUTSTANDING_BLOBS_BEING_SENT = environmentManager_js_1.default.get(hdbTerms_ts_1.CONFIG_PARAMS.REPLICATION_BLOBCONCURRENCY) ?? 5;
421
453
  let outstandingCommits = 0;
422
454
  let lastStructureLength = 0;
423
- let replicationPaused = false;
455
+ // Multiple independent conditions can ask to pause receive on this WS (commit backlog,
456
+ // consumer queue full, blob write backpressure). We refcount the reasons so that resuming
457
+ // one does not race ahead of another that still wants the WS paused.
458
+ let pauseReasons = 0;
459
+ let commitBacklogPaused = false;
460
+ function addPauseReason() {
461
+ if (pauseReasons === 0)
462
+ ws.pause();
463
+ pauseReasons++;
464
+ }
465
+ function removePauseReason() {
466
+ if (pauseReasons === 0)
467
+ return;
468
+ pauseReasons--;
469
+ if (pauseReasons === 0)
470
+ ws.resume();
471
+ }
424
472
  let subscriptionRequest, auditSubscription;
425
473
  let nodeSubscriptions;
426
474
  let excludedNodes; // list of nodes to exclude from this subscription
427
475
  let remoteShortIdToLocalId;
428
476
  let subscribedNodeIds; // map of node IDs to their subscription time ranges
429
- ws.on('message', onWSMessage);
477
+ // Serialize message handling so that async backpressure inside onWSMessage doesn't allow
478
+ // the WS library to start processing the next frame before the current one is fully decoded.
479
+ // Without serialization, awaiting inside the handler would let concurrent message handlers
480
+ // share the consumer queue and defeat the per-record backpressure below.
481
+ let messageProcessing = Promise.resolve();
482
+ let wsClosed = false;
483
+ ws.on('message', (body) => {
484
+ messageProcessing = messageProcessing.then(() => (wsClosed ? undefined : onWSMessage(body)), () => (wsClosed ? undefined : onWSMessage(body)));
485
+ });
430
486
  let authorizationFinished = false;
431
487
  function checkAuthorization() {
432
488
  authorizationFinished = true;
@@ -438,27 +494,26 @@ function replicateOverWS(ws, options, authorization) {
438
494
  }
439
495
  return true;
440
496
  }
441
- function onWSMessage(body) {
497
+ async function onWSMessage(body) {
442
498
  if (!authorizationFinished) {
443
499
  if (authorization?.then) {
444
- return authorization.then((resolvedAuth) => {
445
- authorization = resolvedAuth;
446
- if (checkAuthorization()) {
447
- onWSMessage(body); // continue on, now that authorization succeeded
448
- }
449
- }, (error) => {
500
+ try {
501
+ authorization = await authorization;
502
+ }
503
+ catch (error) {
450
504
  authorizationFinished = true;
451
505
  logger.error?.(connectionId, 'Authorization failed', error);
452
506
  // don't send disconnect because we want the client to potentially retry
453
507
  close(1008, 'Unauthorized');
454
- });
455
- }
456
- else {
457
- if (checkAuthorization()) {
458
- onWSMessage(body); // continue on, now that authorization succeeded
508
+ return;
459
509
  }
510
+ if (!checkAuthorization())
511
+ return;
512
+ }
513
+ else if (!checkAuthorization()) {
514
+ return;
460
515
  }
461
- return; // we recursively call onWSMessage if authorization succeeded/completed
516
+ // fall through to handle this message now that authorization succeeded
462
517
  }
463
518
  if (!authorization)
464
519
  return;
@@ -506,9 +561,18 @@ function replicateOverWS(ws, options, authorization) {
506
561
  if (checkDatabaseAccess(databaseName))
507
562
  sendDBSchema(databaseName);
508
563
  });
509
- ws.on('close', () => {
510
- schemaUpdateListener?.remove();
511
- });
564
+ // onWSMessage is async, so the WS may have already closed by the time we get
565
+ // here — in that case 'close' has fired and adding the cleanup listener now
566
+ // would silently leak. Drop the registration immediately.
567
+ if (wsClosed) {
568
+ schemaUpdateListener.remove();
569
+ schemaUpdateListener = undefined;
570
+ }
571
+ else {
572
+ ws.on('close', () => {
573
+ schemaUpdateListener?.remove();
574
+ });
575
+ }
512
576
  }
513
577
  }
514
578
  catch (error) {
@@ -695,8 +759,21 @@ function replicateOverWS(ws, options, authorization) {
695
759
  if (stream.connectedToBlob)
696
760
  blobsInFlight.delete(fileId);
697
761
  }
698
- else
699
- stream.write(blobBody);
762
+ else if (!stream.write(blobBody)) {
763
+ // The PassThrough's internal queue is over its HWM, meaning the downstream
764
+ // file write (via pipeline in saveBlob) can't keep up. Pause the WS until the
765
+ // stream drains so blob chunks don't accumulate in memory faster than they
766
+ // can be flushed to disk. Also listen for 'close' so a destroyed stream
767
+ // (e.g. saveBlob error) doesn't strand the pause reason.
768
+ addPauseReason();
769
+ const release = () => {
770
+ stream.off('drain', release);
771
+ stream.off('close', release);
772
+ removePauseReason();
773
+ };
774
+ stream.on('drain', release);
775
+ stream.on('close', release);
776
+ }
700
777
  }
701
778
  catch (error) {
702
779
  logger.error?.(`Error receiving blob for ${stream.recordId} from ${remoteNodeName} and streaming to storage`, error);
@@ -944,9 +1021,18 @@ function replicateOverWS(ws, options, authorization) {
944
1021
  const table = tableEntry.table;
945
1022
  const primaryStore = table.primaryStore;
946
1023
  const encoder = primaryStore.encoder;
947
- if (auditRecord.extendedType & RecordEncoder_ts_1.HAS_STRUCTURE_UPDATE ||
1024
+ // Force a reload the first time this connection touches each table:
1025
+ // `primaryStore.encoder` is a process-wide singleton, so its typedStructs
1026
+ // may have been populated to a stale length by prior activity on this
1027
+ // thread, and this connection's initial TABLE_FIXED_STRUCTURE must reflect
1028
+ // what's actually in LMDB right now. After this, HAS_STRUCTURE_UPDATE on
1029
+ // subsequent audit records keeps the encoder in sync, since every
1030
+ // typed-struct addition produces a flagged audit record.
1031
+ if (!tableEntry.structuresLoaded ||
1032
+ auditRecord.extendedType & RecordEncoder_ts_1.HAS_STRUCTURE_UPDATE ||
948
1033
  !encoder.typedStructs ||
949
1034
  auditRecord.structureVersion > encoder.typedStructs.length + encoder.structures.length) {
1035
+ tableEntry.structuresLoaded = true;
950
1036
  // there is a structure update, we need to reload the structure from storage.
951
1037
  // this is copied from msgpackr's struct, may want to expose as public method
952
1038
  encoder._mergeStructures(encoder.getStructures());
@@ -1186,6 +1272,16 @@ function replicateOverWS(ws, options, authorization) {
1186
1272
  close();
1187
1273
  }
1188
1274
  });
1275
+ // We are inside an async .then(); if the WS closed while waiting for it to
1276
+ // resolve, attaching a 'close' handler now will not fire and the listeners
1277
+ // above would stay subscribed on the global databaseEventsEmitter forever.
1278
+ if (wsClosed) {
1279
+ schemaUpdateListener.remove();
1280
+ dbRemovalListener.remove();
1281
+ schemaUpdateListener = undefined;
1282
+ dbRemovalListener = undefined;
1283
+ return;
1284
+ }
1189
1285
  ws.on('close', () => {
1190
1286
  schemaUpdateListener?.remove();
1191
1287
  dbRemovalListener?.remove();
@@ -1382,6 +1478,20 @@ function replicateOverWS(ws, options, authorization) {
1382
1478
  // TODO: Once it is committed, also record the localtime in the table with symbol metadata, so we can resume from that point
1383
1479
  logger.debug?.(connectionId, 'received replication message', auditRecord.type, 'id', event.id, 'version', new Date(auditRecord.version), 'nodeId', event.nodeId);
1384
1480
  tableSubscriptionToReplicator.send(event);
1481
+ // Per-record backpressure: a single large WS message can synchronously decode
1482
+ // thousands of records, each holding a decoded value object and a closure over
1483
+ // the source buffer. Without yielding here the consumer can never drain the
1484
+ // queue mid-message and the worker heap balloons until it OOMs.
1485
+ const queueLength = tableSubscriptionToReplicator.queue?.length ?? 0;
1486
+ if (queueLength > RECEIVE_EVENT_HIGH_WATER_MARK) {
1487
+ addPauseReason();
1488
+ try {
1489
+ await tableSubscriptionToReplicator.waitForDrain();
1490
+ }
1491
+ finally {
1492
+ removePauseReason();
1493
+ }
1494
+ }
1385
1495
  }
1386
1496
  decoder.position = start + eventLength;
1387
1497
  } while (decoder.position < body.byteLength);
@@ -1389,9 +1499,9 @@ function replicateOverWS(ws, options, authorization) {
1389
1499
  if (databaseName !== 'system') {
1390
1500
  (0, write_ts_1.recordAction)(body.byteLength, 'bytes-received', `${remoteNodeName}.${databaseName}.${event?.table || 'unknown_table'}`, 'replication', 'ingest');
1391
1501
  }
1392
- if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !replicationPaused) {
1393
- replicationPaused = true;
1394
- ws.pause();
1502
+ if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !commitBacklogPaused) {
1503
+ commitBacklogPaused = true;
1504
+ addPauseReason();
1395
1505
  logger.debug?.(`Commit backlog causing replication back-pressure, requesting that ${remoteNodeName} pause replication`);
1396
1506
  }
1397
1507
  tableSubscriptionToReplicator.send({
@@ -1406,9 +1516,9 @@ function replicateOverWS(ws, options, authorization) {
1406
1516
  }
1407
1517
  }
1408
1518
  outstandingCommits--;
1409
- if (replicationPaused) {
1410
- replicationPaused = false;
1411
- ws.resume();
1519
+ if (commitBacklogPaused) {
1520
+ commitBacklogPaused = false;
1521
+ removePauseReason();
1412
1522
  logger.debug?.(`Replication resuming ${remoteNodeName}`);
1413
1523
  }
1414
1524
  // if there are outstanding blobs to finish writing, delay commit receipts until they are finished (so that if we are interrupting
@@ -1458,6 +1568,7 @@ function replicateOverWS(ws, options, authorization) {
1458
1568
  });
1459
1569
  ws.on('close', (code, reasonBuffer) => {
1460
1570
  // cleanup
1571
+ wsClosed = true;
1461
1572
  clearInterval(sendPingInterval);
1462
1573
  clearTimeout(receivePingTimer);
1463
1574
  clearInterval(blobsTimer);
@@ -1472,12 +1583,21 @@ function replicateOverWS(ws, options, authorization) {
1472
1583
  }
1473
1584
  logger.debug?.(connectionId, 'closed', code, reasonBuffer?.toString());
1474
1585
  });
1475
- function close(code, reason) {
1586
+ function close(code, reason, intentional) {
1476
1587
  try {
1477
- ws.isFinished = true;
1588
+ // Only the deliberate "we are done with this connection" call sites pass intentional=true
1589
+ // (currently just the empty-subscription delayed close below). Everything else — auth
1590
+ // failures after open, peer-initiated DISCONNECT, schema/sequence errors — is a
1591
+ // transient protocol close that should reconnect, so we let the WS close event fall
1592
+ // through to NodeReplicationConnection's normal retry path instead of marking the
1593
+ // connection as finished or emitting 'finished' (which would remove it from the
1594
+ // worker's connections map).
1595
+ if (intentional && options.connection)
1596
+ options.connection.intentionallyUnsubscribed = true;
1478
1597
  logger.debug?.(connectionId, 'closing', remoteNodeName, databaseName, code, reason);
1479
1598
  ws.close(code, reason);
1480
- options.connection?.emit('finished'); // we want to synchronously indicate that the connection is finished, so it is not accidently reused
1599
+ if (intentional)
1600
+ options.connection?.emit('finished'); // synchronously indicate that the connection is finished, so it is not accidentally reused
1481
1601
  }
1482
1602
  catch (error) {
1483
1603
  logger.error?.(connectionId, 'Error closing connection', error);
@@ -1576,16 +1696,22 @@ function replicateOverWS(ws, options, authorization) {
1576
1696
  // we would skip this
1577
1697
  const finished = (0, blob_ts_1.decodeFromDatabase)(() => (0, blob_ts_1.saveBlob)(localBlob).saving, tableSubscriptionToReplicator.auditStore?.rootStore);
1578
1698
  if (finished) {
1579
- finished.blobId = blobId;
1580
- outstandingBlobsToFinish.push(finished);
1581
- finished
1699
+ // We log the rejection via .catch() and also need the resulting promise — not the
1700
+ // raw `finished` — to be what we hand to `Promise.all(outstandingBlobsToFinish)` in
1701
+ // the end_txn onCommit path below. If we pushed `finished` directly, a save
1702
+ // rejection would surface to that `await Promise.all(...)` as an unhandled error
1703
+ // even though we already logged it here, and it would escape onCommit as an
1704
+ // uncaughtException — observed in prod as ~35/sec ENOENT spam during catch-up.
1705
+ const tracked = finished
1582
1706
  .catch((err) => logger.error?.(`Blob save failed for ${blobId} from ${remoteNodeName}`, err))
1583
1707
  .finally(() => {
1584
1708
  logger.debug?.(`Finished receiving blob stream ${blobId}`);
1585
- const index = outstandingBlobsToFinish.indexOf(finished);
1709
+ const index = outstandingBlobsToFinish.indexOf(tracked);
1586
1710
  if (index > -1)
1587
1711
  outstandingBlobsToFinish.splice(index, 1);
1588
1712
  });
1713
+ tracked.blobId = blobId;
1714
+ outstandingBlobsToFinish.push(tracked);
1589
1715
  }
1590
1716
  return localBlob;
1591
1717
  }
@@ -1780,7 +1906,7 @@ function replicateOverWS(ws, options, authorization) {
1780
1906
  delayedClose = setTimeout(() => {
1781
1907
  // if we have not received any messages in a while, we can close the connection
1782
1908
  if (lastMessageTime <= scheduled)
1783
- close(1008, 'Connection has no subscriptions and is no longer used');
1909
+ close(1008, 'Connection has no subscriptions and is no longer used', true);
1784
1910
  else
1785
1911
  scheduleClose();
1786
1912
  }, DELAY_CLOSE_TIME).unref();