@harperfast/harper-pro 5.0.16 → 5.0.17

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 (33) hide show
  1. package/core/DESIGN.md +32 -0
  2. package/core/bin/copyDb.ts +19 -0
  3. package/core/resources/replayLogs.ts +36 -3
  4. package/core/resources/replayLogsGuards.ts +42 -0
  5. package/core/resources/transactionBroadcast.ts +121 -66
  6. package/dist/core/bin/copyDb.js +16 -0
  7. package/dist/core/bin/copyDb.js.map +1 -1
  8. package/dist/core/resources/replayLogs.js +26 -2
  9. package/dist/core/resources/replayLogs.js.map +1 -1
  10. package/dist/core/resources/replayLogsGuards.js +43 -0
  11. package/dist/core/resources/replayLogsGuards.js.map +1 -0
  12. package/dist/core/resources/transactionBroadcast.js +129 -71
  13. package/dist/core/resources/transactionBroadcast.js.map +1 -1
  14. package/dist/replication/replicationConnection.js +111 -30
  15. package/dist/replication/replicationConnection.js.map +1 -1
  16. package/dist/replication/replicator.js +11 -2
  17. package/dist/replication/replicator.js.map +1 -1
  18. package/dist/replication/subscriptionManager.js +11 -1
  19. package/dist/replication/subscriptionManager.js.map +1 -1
  20. package/npm-shrinkwrap.json +2 -2
  21. package/package.json +1 -1
  22. package/replication/replicationConnection.ts +110 -35
  23. package/replication/replicator.ts +11 -2
  24. package/replication/subscriptionManager.ts +11 -1
  25. package/studio/web/assets/{index-pr02wSIB.js → index-DhLu-DHX.js} +5 -5
  26. package/studio/web/assets/{index-pr02wSIB.js.map → index-DhLu-DHX.js.map} +1 -1
  27. package/studio/web/assets/{index.lazy-CorGZz3L.js → index.lazy-DBjOisCz.js} +2 -2
  28. package/studio/web/assets/{index.lazy-CorGZz3L.js.map → index.lazy-DBjOisCz.js.map} +1 -1
  29. package/studio/web/assets/{profile-SSvkzt9H.js → profile-DSL-499E.js} +2 -2
  30. package/studio/web/assets/{profile-SSvkzt9H.js.map → profile-DSL-499E.js.map} +1 -1
  31. package/studio/web/assets/{status-Xk93QrPQ.js → status-BRW5QtzY.js} +2 -2
  32. package/studio/web/assets/{status-Xk93QrPQ.js.map → status-BRW5QtzY.js.map} +1 -1
  33. package/studio/web/index.html +1 -1
@@ -86,6 +86,11 @@ export const RECEIVING_STATUS_WAITING = 0;
86
86
  export const RECEIVING_STATUS_RECEIVING = 1;
87
87
 
88
88
  const MAX_PAYLOAD = env.get('replication_maxPayload') ?? 100_000_000;
89
+ // When receiving a replication message, we apply per-record backpressure to keep a single
90
+ // large batch from synchronously decoding thousands of records and ballooning the worker
91
+ // heap past its limit. If the local replicator queue grows beyond this threshold we pause
92
+ // the WS connection and wait for it to drain before continuing the decode loop.
93
+ const RECEIVE_EVENT_HIGH_WATER_MARK = env.get('replication_receiveEventHighWaterMark') ?? 100;
89
94
 
90
95
  export const tableUpdateListeners = new Map();
91
96
  // This a map of the database name to the subscription object, for the subscriptions from our tables to the replication module
@@ -455,13 +460,37 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
455
460
  const MAX_OUTSTANDING_BLOBS_BEING_SENT = env.get(CONFIG_PARAMS.REPLICATION_BLOBCONCURRENCY) ?? 5;
456
461
  let outstandingCommits = 0;
457
462
  let lastStructureLength = 0;
458
- let replicationPaused = false;
463
+ // Multiple independent conditions can ask to pause receive on this WS (commit backlog,
464
+ // consumer queue full, blob write backpressure). We refcount the reasons so that resuming
465
+ // one does not race ahead of another that still wants the WS paused.
466
+ let pauseReasons = 0;
467
+ let commitBacklogPaused = false;
468
+ function addPauseReason(): void {
469
+ if (pauseReasons === 0) ws.pause();
470
+ pauseReasons++;
471
+ }
472
+ function removePauseReason(): void {
473
+ if (pauseReasons === 0) return;
474
+ pauseReasons--;
475
+ if (pauseReasons === 0) ws.resume();
476
+ }
459
477
  let subscriptionRequest, auditSubscription;
460
478
  let nodeSubscriptions;
461
479
  let excludedNodes: string[]; // list of nodes to exclude from this subscription
462
480
  let remoteShortIdToLocalId: Map<number, number>;
463
481
  let subscribedNodeIds: Array<boolean | { startTime: number; endTime?: number }> | undefined; // map of node IDs to their subscription time ranges
464
- ws.on('message', onWSMessage);
482
+ // Serialize message handling so that async backpressure inside onWSMessage doesn't allow
483
+ // the WS library to start processing the next frame before the current one is fully decoded.
484
+ // Without serialization, awaiting inside the handler would let concurrent message handlers
485
+ // share the consumer queue and defeat the per-record backpressure below.
486
+ let messageProcessing: Promise<void> = Promise.resolve();
487
+ let wsClosed = false;
488
+ ws.on('message', (body: Buffer) => {
489
+ messageProcessing = messageProcessing.then(
490
+ () => (wsClosed ? undefined : onWSMessage(body)),
491
+ () => (wsClosed ? undefined : onWSMessage(body))
492
+ );
493
+ });
465
494
  let authorizationFinished = false;
466
495
  function checkAuthorization(): boolean {
467
496
  authorizationFinished = true;
@@ -473,29 +502,23 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
473
502
  }
474
503
  return true;
475
504
  }
476
- function onWSMessage(body: Buffer) {
505
+ async function onWSMessage(body: Buffer): Promise<void> {
477
506
  if (!authorizationFinished) {
478
507
  if (authorization?.then) {
479
- return authorization.then(
480
- (resolvedAuth) => {
481
- authorization = resolvedAuth;
482
- if (checkAuthorization()) {
483
- onWSMessage(body); // continue on, now that authorization succeeded
484
- }
485
- },
486
- (error) => {
487
- authorizationFinished = true;
488
- logger.error?.(connectionId, 'Authorization failed', error);
489
- // don't send disconnect because we want the client to potentially retry
490
- close(1008, 'Unauthorized');
491
- }
492
- );
493
- } else {
494
- if (checkAuthorization()) {
495
- onWSMessage(body); // continue on, now that authorization succeeded
508
+ try {
509
+ authorization = await authorization;
510
+ } catch (error) {
511
+ authorizationFinished = true;
512
+ logger.error?.(connectionId, 'Authorization failed', error);
513
+ // don't send disconnect because we want the client to potentially retry
514
+ close(1008, 'Unauthorized');
515
+ return;
496
516
  }
517
+ if (!checkAuthorization()) return;
518
+ } else if (!checkAuthorization()) {
519
+ return;
497
520
  }
498
- return; // we recursively call onWSMessage if authorization succeeded/completed
521
+ // fall through to handle this message now that authorization succeeded
499
522
  }
500
523
  if (!authorization) return;
501
524
  // A replication header should begin with either a transaction timestamp or messagepack message of
@@ -542,9 +565,17 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
542
565
  schemaUpdateListener = forEachReplicatedDatabase(options, (database, databaseName) => {
543
566
  if (checkDatabaseAccess(databaseName)) sendDBSchema(databaseName);
544
567
  });
545
- ws.on('close', () => {
546
- schemaUpdateListener?.remove();
547
- });
568
+ // onWSMessage is async, so the WS may have already closed by the time we get
569
+ // here — in that case 'close' has fired and adding the cleanup listener now
570
+ // would silently leak. Drop the registration immediately.
571
+ if (wsClosed) {
572
+ schemaUpdateListener.remove();
573
+ schemaUpdateListener = undefined;
574
+ } else {
575
+ ws.on('close', () => {
576
+ schemaUpdateListener?.remove();
577
+ });
578
+ }
548
579
  }
549
580
  } catch (error) {
550
581
  // if this fails, we should close the connection and indicate that we should not reconnect
@@ -773,7 +804,21 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
773
804
  );
774
805
  } else stream.end(blobBody);
775
806
  if (stream.connectedToBlob) blobsInFlight.delete(fileId);
776
- } else stream.write(blobBody);
807
+ } else if (!stream.write(blobBody)) {
808
+ // The PassThrough's internal queue is over its HWM, meaning the downstream
809
+ // file write (via pipeline in saveBlob) can't keep up. Pause the WS until the
810
+ // stream drains so blob chunks don't accumulate in memory faster than they
811
+ // can be flushed to disk. Also listen for 'close' so a destroyed stream
812
+ // (e.g. saveBlob error) doesn't strand the pause reason.
813
+ addPauseReason();
814
+ const release = () => {
815
+ stream.off('drain', release);
816
+ stream.off('close', release);
817
+ removePauseReason();
818
+ };
819
+ stream.on('drain', release);
820
+ stream.on('close', release);
821
+ }
777
822
  } catch (error) {
778
823
  logger.error?.(
779
824
  `Error receiving blob for ${stream.recordId} from ${remoteNodeName} and streaming to storage`,
@@ -1344,6 +1389,16 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1344
1389
  close();
1345
1390
  }
1346
1391
  });
1392
+ // We are inside an async .then(); if the WS closed while waiting for it to
1393
+ // resolve, attaching a 'close' handler now will not fire and the listeners
1394
+ // above would stay subscribed on the global databaseEventsEmitter forever.
1395
+ if (wsClosed) {
1396
+ schemaUpdateListener.remove();
1397
+ dbRemovalListener.remove();
1398
+ schemaUpdateListener = undefined;
1399
+ dbRemovalListener = undefined;
1400
+ return;
1401
+ }
1347
1402
  ws.on('close', () => {
1348
1403
  schemaUpdateListener?.remove();
1349
1404
  dbRemovalListener?.remove();
@@ -1601,6 +1656,19 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1601
1656
  event.nodeId
1602
1657
  );
1603
1658
  tableSubscriptionToReplicator.send(event);
1659
+ // Per-record backpressure: a single large WS message can synchronously decode
1660
+ // thousands of records, each holding a decoded value object and a closure over
1661
+ // the source buffer. Without yielding here the consumer can never drain the
1662
+ // queue mid-message and the worker heap balloons until it OOMs.
1663
+ const queueLength = tableSubscriptionToReplicator.queue?.length ?? 0;
1664
+ if (queueLength > RECEIVE_EVENT_HIGH_WATER_MARK) {
1665
+ addPauseReason();
1666
+ try {
1667
+ await tableSubscriptionToReplicator.waitForDrain();
1668
+ } finally {
1669
+ removePauseReason();
1670
+ }
1671
+ }
1604
1672
  }
1605
1673
  decoder.position = start + eventLength;
1606
1674
  } while (decoder.position < body.byteLength);
@@ -1614,9 +1682,9 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1614
1682
  'ingest'
1615
1683
  );
1616
1684
  }
1617
- if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !replicationPaused) {
1618
- replicationPaused = true;
1619
- ws.pause();
1685
+ if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !commitBacklogPaused) {
1686
+ commitBacklogPaused = true;
1687
+ addPauseReason();
1620
1688
  logger.debug?.(
1621
1689
  `Commit backlog causing replication back-pressure, requesting that ${remoteNodeName} pause replication`
1622
1690
  );
@@ -1639,9 +1707,9 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1639
1707
  }
1640
1708
  }
1641
1709
  outstandingCommits--;
1642
- if (replicationPaused) {
1643
- replicationPaused = false;
1644
- ws.resume();
1710
+ if (commitBacklogPaused) {
1711
+ commitBacklogPaused = false;
1712
+ removePauseReason();
1645
1713
  logger.debug?.(`Replication resuming ${remoteNodeName}`);
1646
1714
  }
1647
1715
  // if there are outstanding blobs to finish writing, delay commit receipts until they are finished (so that if we are interrupting
@@ -1689,6 +1757,7 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1689
1757
  });
1690
1758
  ws.on('close', (code, reasonBuffer) => {
1691
1759
  // cleanup
1760
+ wsClosed = true;
1692
1761
  clearInterval(sendPingInterval);
1693
1762
  clearTimeout(receivePingTimer);
1694
1763
  clearInterval(blobsTimer);
@@ -1808,15 +1877,21 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1808
1877
  tableSubscriptionToReplicator.auditStore?.rootStore
1809
1878
  );
1810
1879
  if (finished) {
1811
- finished.blobId = blobId;
1812
- outstandingBlobsToFinish.push(finished);
1813
- finished
1880
+ // We log the rejection via .catch() and also need the resulting promise — not the
1881
+ // raw `finished` — to be what we hand to `Promise.all(outstandingBlobsToFinish)` in
1882
+ // the end_txn onCommit path below. If we pushed `finished` directly, a save
1883
+ // rejection would surface to that `await Promise.all(...)` as an unhandled error
1884
+ // even though we already logged it here, and it would escape onCommit as an
1885
+ // uncaughtException — observed in prod as ~35/sec ENOENT spam during catch-up.
1886
+ const tracked = finished
1814
1887
  .catch((err) => logger.error?.(`Blob save failed for ${blobId} from ${remoteNodeName}`, err))
1815
1888
  .finally(() => {
1816
1889
  logger.debug?.(`Finished receiving blob stream ${blobId}`);
1817
- const index = outstandingBlobsToFinish.indexOf(finished);
1890
+ const index = outstandingBlobsToFinish.indexOf(tracked);
1818
1891
  if (index > -1) outstandingBlobsToFinish.splice(index, 1);
1819
1892
  });
1893
+ tracked.blobId = blobId;
1894
+ outstandingBlobsToFinish.push(tracked);
1820
1895
  }
1821
1896
  return localBlob;
1822
1897
  }
@@ -595,12 +595,21 @@ export function forEachReplicatedDatabase(options, callback) {
595
595
  for (const databaseName of Object.getOwnPropertyNames(databases)) {
596
596
  forDatabase(databaseName);
597
597
  }
598
- onRemovedDB((databaseName) => {
598
+ // Both listeners must be returned through the same handle, otherwise callers that
599
+ // .remove() the result still leak the dropDatabase listener forever — which over time
600
+ // trips MaxListenersExceededWarning on the global databaseEventsEmitter.
601
+ const removedListener = onRemovedDB((databaseName) => {
599
602
  forDatabase(databaseName);
600
603
  });
601
- return onUpdatedTable((Table) => {
604
+ const updatedListener = onUpdatedTable((Table) => {
602
605
  forDatabase(Table.databaseName);
603
606
  });
607
+ return {
608
+ remove() {
609
+ removedListener.remove();
610
+ updatedListener.remove();
611
+ },
612
+ };
604
613
  function forDatabase(databaseName) {
605
614
  const database = databases[databaseName];
606
615
  logger.trace('Checking replication status of ', databaseName, options?.databases);
@@ -46,6 +46,13 @@ type ReplicationConnectionStatus = {
46
46
  type DBReplicationStatusMap = Map<string, ReplicationConnectionStatus> & { iterator: any };
47
47
 
48
48
  const NODE_SUBSCRIBE_DELAY = 200; // delay before sending node subscribe to other nodes, so operations can complete first
49
+ // When a worker dies it may have been holding subscriptions for many (database, node) pairs.
50
+ // All of those pairs fire onDatabase reassignments in the same tick, which would otherwise
51
+ // slam a fresh worker with a burst of catchup connections and is the kind of pressure that
52
+ // caused the OOM in the first place. We stagger the re-subscriptions in time so the new
53
+ // worker(s) absorb them gradually.
54
+ const WORKER_EXIT_REASSIGN_STAGGER_MS = 100;
55
+ let nextWorkerExitReassignAt = 0;
49
56
  const connectionReplicationMap = new Map<string, DBReplicationStatusMap>();
50
57
  export let disconnectedFromNode; // this is set by thread to handle when a node is disconnected (or notify main thread so it can handle)
51
58
  export let connectedToNode; // this is set by thread to handle when a node is connected (or notify main thread so it can handle)
@@ -249,7 +256,10 @@ export async function startOnMainThread(options) {
249
256
  if (dbReplicationWorkers.get(databaseName)?.worker === worker) {
250
257
  // first verify it is still the worker
251
258
  dbReplicationWorkers.delete(databaseName);
252
- onDatabase(databaseName, tablesReplicateByDefault);
259
+ const now = Date.now();
260
+ nextWorkerExitReassignAt = Math.max(now, nextWorkerExitReassignAt) + WORKER_EXIT_REASSIGN_STAGGER_MS;
261
+ const delay = nextWorkerExitReassignAt - now;
262
+ setTimeout(() => onDatabase(databaseName, tablesReplicateByDefault), delay).unref();
253
263
  }
254
264
  });
255
265
  }