@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
@@ -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
@@ -201,6 +206,11 @@ export class NodeReplicationConnection extends EventEmitter {
201
206
  retries = 0;
202
207
  isConnected = true; // we start out assuming we will be connected
203
208
  isFinished = false;
209
+ // Set when this connection should never reconnect: user-driven unsubscribe(), or the
210
+ // empty-subscription delayed close inside replicateOverWS. Distinct from `isFinished`,
211
+ // which is the post-close terminal marker. Anything else (protocol errors, peer
212
+ // DISCONNECT, etc.) leaves this false so the close handler schedules a retry.
213
+ intentionallyUnsubscribed = false;
204
214
  nodeSubscriptions?: NodeSubscription[];
205
215
  latency = 0;
206
216
  replicateTablesByDefault: boolean;
@@ -243,18 +253,36 @@ export class NodeReplicationConnection extends EventEmitter {
243
253
  });
244
254
  }
245
255
  this.isConnected = true;
246
- session = replicateOverWS(
247
- this.socket,
248
- {
249
- database: this.databaseName,
250
- subscription: this.subscription,
251
- url: this.url,
252
- connection: this,
253
- isSubscriptionConnection: this.nodeSubscriptions !== undefined,
254
- },
255
- { replicates: true } // pre-authorized, but should only make publish: true if we are allowing reverse subscriptions
256
- );
257
- this.sessionResolve(session);
256
+ try {
257
+ session = replicateOverWS(
258
+ this.socket,
259
+ {
260
+ database: this.databaseName,
261
+ subscription: this.subscription,
262
+ url: this.url,
263
+ connection: this,
264
+ isSubscriptionConnection: this.nodeSubscriptions !== undefined,
265
+ },
266
+ { replicates: true } // pre-authorized, but should only make publish: true if we are allowing reverse subscriptions
267
+ );
268
+ this.sessionResolve(session);
269
+ } catch (error) {
270
+ // replicateOverWS does a fair amount of synchronous setup (setDatabase, audit
271
+ // store wiring, ping bookkeeping) and any of it can throw — most worryingly,
272
+ // audit decoder corruption surfaced via setDatabase or the immediate getRange.
273
+ // Without this guard the throw escapes the WS 'open' listener as an
274
+ // uncaughtException, the socket stays open, sessionResolve is never called, no
275
+ // 'close' fires, and the retry timer in the close handler never gets scheduled —
276
+ // leaving the (peer, db) pair stuck with `connected: false` on main but no further
277
+ // activity until a process restart. Terminating the socket forces the close
278
+ // handler to run, which now retries.
279
+ logger.error?.(
280
+ `Error setting up replication session to ${this.url} (db: "${this.databaseName}"), terminating to retry`,
281
+ error
282
+ );
283
+ this.sessionReject(error);
284
+ this.socket.terminate();
285
+ }
258
286
  });
259
287
  this.socket.on('error', (error) => {
260
288
  if (error.code === 'SELF_SIGNED_CERT_IN_CHAIN') {
@@ -272,21 +300,27 @@ export class NodeReplicationConnection extends EventEmitter {
272
300
  this.sessionReject(error);
273
301
  });
274
302
  this.socket.on('close', (code, reasonBuffer) => {
275
- // if we get disconnected, notify subscriptions manager so we can reroute through another node
303
+ // Only treat the close as terminal when something explicitly marked it as a deliberate
304
+ // teardown (user unsubscribe or the empty-subscription delayed close). Protocol-level
305
+ // closes — peer DISCONNECT, unauthorized after open, node-name-mismatch, invalid
306
+ // sequence id, etc. — used to also set `isFinished` via replicateOverWS's close()
307
+ // helper, which left the connection silently dead and required hdb_nodes churn to
308
+ // recover. Those now fall through to the retry path below.
309
+ const intentional = this.intentionallyUnsubscribed;
276
310
  if (this.isConnected) {
277
311
  if (this.nodeSubscriptions) {
278
312
  disconnectedFromNode({
279
313
  name: this.nodeName,
280
314
  database: this.databaseName,
281
315
  url: this.url,
282
- finished: this.socket.isFinished,
316
+ finished: intentional,
283
317
  });
284
318
  }
285
319
  this.isConnected = false;
286
320
  }
287
321
  this.removeAllListeners('subscriptions-updated');
288
322
 
289
- if (this.socket.isFinished) {
323
+ if (intentional) {
290
324
  this.isFinished = true;
291
325
  session?.end();
292
326
  this.emit('finished');
@@ -322,7 +356,7 @@ export class NodeReplicationConnection extends EventEmitter {
322
356
  this.emit('subscriptions-updated', nodeSubscriptions);
323
357
  }
324
358
  unsubscribe() {
325
- this.socket.isFinished = true;
359
+ this.intentionallyUnsubscribed = true;
326
360
  this.socket.close(1008, 'No longer subscribed');
327
361
  }
328
362
 
@@ -455,13 +489,37 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
455
489
  const MAX_OUTSTANDING_BLOBS_BEING_SENT = env.get(CONFIG_PARAMS.REPLICATION_BLOBCONCURRENCY) ?? 5;
456
490
  let outstandingCommits = 0;
457
491
  let lastStructureLength = 0;
458
- let replicationPaused = false;
492
+ // Multiple independent conditions can ask to pause receive on this WS (commit backlog,
493
+ // consumer queue full, blob write backpressure). We refcount the reasons so that resuming
494
+ // one does not race ahead of another that still wants the WS paused.
495
+ let pauseReasons = 0;
496
+ let commitBacklogPaused = false;
497
+ function addPauseReason(): void {
498
+ if (pauseReasons === 0) ws.pause();
499
+ pauseReasons++;
500
+ }
501
+ function removePauseReason(): void {
502
+ if (pauseReasons === 0) return;
503
+ pauseReasons--;
504
+ if (pauseReasons === 0) ws.resume();
505
+ }
459
506
  let subscriptionRequest, auditSubscription;
460
507
  let nodeSubscriptions;
461
508
  let excludedNodes: string[]; // list of nodes to exclude from this subscription
462
509
  let remoteShortIdToLocalId: Map<number, number>;
463
510
  let subscribedNodeIds: Array<boolean | { startTime: number; endTime?: number }> | undefined; // map of node IDs to their subscription time ranges
464
- ws.on('message', onWSMessage);
511
+ // Serialize message handling so that async backpressure inside onWSMessage doesn't allow
512
+ // the WS library to start processing the next frame before the current one is fully decoded.
513
+ // Without serialization, awaiting inside the handler would let concurrent message handlers
514
+ // share the consumer queue and defeat the per-record backpressure below.
515
+ let messageProcessing: Promise<void> = Promise.resolve();
516
+ let wsClosed = false;
517
+ ws.on('message', (body: Buffer) => {
518
+ messageProcessing = messageProcessing.then(
519
+ () => (wsClosed ? undefined : onWSMessage(body)),
520
+ () => (wsClosed ? undefined : onWSMessage(body))
521
+ );
522
+ });
465
523
  let authorizationFinished = false;
466
524
  function checkAuthorization(): boolean {
467
525
  authorizationFinished = true;
@@ -473,29 +531,23 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
473
531
  }
474
532
  return true;
475
533
  }
476
- function onWSMessage(body: Buffer) {
534
+ async function onWSMessage(body: Buffer): Promise<void> {
477
535
  if (!authorizationFinished) {
478
536
  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
537
+ try {
538
+ authorization = await authorization;
539
+ } catch (error) {
540
+ authorizationFinished = true;
541
+ logger.error?.(connectionId, 'Authorization failed', error);
542
+ // don't send disconnect because we want the client to potentially retry
543
+ close(1008, 'Unauthorized');
544
+ return;
496
545
  }
546
+ if (!checkAuthorization()) return;
547
+ } else if (!checkAuthorization()) {
548
+ return;
497
549
  }
498
- return; // we recursively call onWSMessage if authorization succeeded/completed
550
+ // fall through to handle this message now that authorization succeeded
499
551
  }
500
552
  if (!authorization) return;
501
553
  // A replication header should begin with either a transaction timestamp or messagepack message of
@@ -542,9 +594,17 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
542
594
  schemaUpdateListener = forEachReplicatedDatabase(options, (database, databaseName) => {
543
595
  if (checkDatabaseAccess(databaseName)) sendDBSchema(databaseName);
544
596
  });
545
- ws.on('close', () => {
546
- schemaUpdateListener?.remove();
547
- });
597
+ // onWSMessage is async, so the WS may have already closed by the time we get
598
+ // here — in that case 'close' has fired and adding the cleanup listener now
599
+ // would silently leak. Drop the registration immediately.
600
+ if (wsClosed) {
601
+ schemaUpdateListener.remove();
602
+ schemaUpdateListener = undefined;
603
+ } else {
604
+ ws.on('close', () => {
605
+ schemaUpdateListener?.remove();
606
+ });
607
+ }
548
608
  }
549
609
  } catch (error) {
550
610
  // if this fails, we should close the connection and indicate that we should not reconnect
@@ -773,7 +833,21 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
773
833
  );
774
834
  } else stream.end(blobBody);
775
835
  if (stream.connectedToBlob) blobsInFlight.delete(fileId);
776
- } else stream.write(blobBody);
836
+ } else if (!stream.write(blobBody)) {
837
+ // The PassThrough's internal queue is over its HWM, meaning the downstream
838
+ // file write (via pipeline in saveBlob) can't keep up. Pause the WS until the
839
+ // stream drains so blob chunks don't accumulate in memory faster than they
840
+ // can be flushed to disk. Also listen for 'close' so a destroyed stream
841
+ // (e.g. saveBlob error) doesn't strand the pause reason.
842
+ addPauseReason();
843
+ const release = () => {
844
+ stream.off('drain', release);
845
+ stream.off('close', release);
846
+ removePauseReason();
847
+ };
848
+ stream.on('drain', release);
849
+ stream.on('close', release);
850
+ }
777
851
  } catch (error) {
778
852
  logger.error?.(
779
853
  `Error receiving blob for ${stream.recordId} from ${remoteNodeName} and streaming to storage`,
@@ -1055,11 +1129,20 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1055
1129
  const table = tableEntry.table;
1056
1130
  const primaryStore = table.primaryStore;
1057
1131
  const encoder = primaryStore.encoder;
1132
+ // Force a reload the first time this connection touches each table:
1133
+ // `primaryStore.encoder` is a process-wide singleton, so its typedStructs
1134
+ // may have been populated to a stale length by prior activity on this
1135
+ // thread, and this connection's initial TABLE_FIXED_STRUCTURE must reflect
1136
+ // what's actually in LMDB right now. After this, HAS_STRUCTURE_UPDATE on
1137
+ // subsequent audit records keeps the encoder in sync, since every
1138
+ // typed-struct addition produces a flagged audit record.
1058
1139
  if (
1140
+ !tableEntry.structuresLoaded ||
1059
1141
  auditRecord.extendedType & HAS_STRUCTURE_UPDATE ||
1060
1142
  !encoder.typedStructs ||
1061
1143
  auditRecord.structureVersion > encoder.typedStructs.length + encoder.structures.length
1062
1144
  ) {
1145
+ tableEntry.structuresLoaded = true;
1063
1146
  // there is a structure update, we need to reload the structure from storage.
1064
1147
  // this is copied from msgpackr's struct, may want to expose as public method
1065
1148
  encoder._mergeStructures(encoder.getStructures());
@@ -1344,6 +1427,16 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1344
1427
  close();
1345
1428
  }
1346
1429
  });
1430
+ // We are inside an async .then(); if the WS closed while waiting for it to
1431
+ // resolve, attaching a 'close' handler now will not fire and the listeners
1432
+ // above would stay subscribed on the global databaseEventsEmitter forever.
1433
+ if (wsClosed) {
1434
+ schemaUpdateListener.remove();
1435
+ dbRemovalListener.remove();
1436
+ schemaUpdateListener = undefined;
1437
+ dbRemovalListener = undefined;
1438
+ return;
1439
+ }
1347
1440
  ws.on('close', () => {
1348
1441
  schemaUpdateListener?.remove();
1349
1442
  dbRemovalListener?.remove();
@@ -1601,6 +1694,19 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1601
1694
  event.nodeId
1602
1695
  );
1603
1696
  tableSubscriptionToReplicator.send(event);
1697
+ // Per-record backpressure: a single large WS message can synchronously decode
1698
+ // thousands of records, each holding a decoded value object and a closure over
1699
+ // the source buffer. Without yielding here the consumer can never drain the
1700
+ // queue mid-message and the worker heap balloons until it OOMs.
1701
+ const queueLength = tableSubscriptionToReplicator.queue?.length ?? 0;
1702
+ if (queueLength > RECEIVE_EVENT_HIGH_WATER_MARK) {
1703
+ addPauseReason();
1704
+ try {
1705
+ await tableSubscriptionToReplicator.waitForDrain();
1706
+ } finally {
1707
+ removePauseReason();
1708
+ }
1709
+ }
1604
1710
  }
1605
1711
  decoder.position = start + eventLength;
1606
1712
  } while (decoder.position < body.byteLength);
@@ -1614,9 +1720,9 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1614
1720
  'ingest'
1615
1721
  );
1616
1722
  }
1617
- if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !replicationPaused) {
1618
- replicationPaused = true;
1619
- ws.pause();
1723
+ if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !commitBacklogPaused) {
1724
+ commitBacklogPaused = true;
1725
+ addPauseReason();
1620
1726
  logger.debug?.(
1621
1727
  `Commit backlog causing replication back-pressure, requesting that ${remoteNodeName} pause replication`
1622
1728
  );
@@ -1639,9 +1745,9 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1639
1745
  }
1640
1746
  }
1641
1747
  outstandingCommits--;
1642
- if (replicationPaused) {
1643
- replicationPaused = false;
1644
- ws.resume();
1748
+ if (commitBacklogPaused) {
1749
+ commitBacklogPaused = false;
1750
+ removePauseReason();
1645
1751
  logger.debug?.(`Replication resuming ${remoteNodeName}`);
1646
1752
  }
1647
1753
  // if there are outstanding blobs to finish writing, delay commit receipts until they are finished (so that if we are interrupting
@@ -1689,6 +1795,7 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1689
1795
  });
1690
1796
  ws.on('close', (code, reasonBuffer) => {
1691
1797
  // cleanup
1798
+ wsClosed = true;
1692
1799
  clearInterval(sendPingInterval);
1693
1800
  clearTimeout(receivePingTimer);
1694
1801
  clearInterval(blobsTimer);
@@ -1701,12 +1808,19 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1701
1808
  logger.debug?.(connectionId, 'closed', code, reasonBuffer?.toString());
1702
1809
  });
1703
1810
 
1704
- function close(code?, reason?) {
1811
+ function close(code?, reason?, intentional?: boolean) {
1705
1812
  try {
1706
- ws.isFinished = true;
1813
+ // Only the deliberate "we are done with this connection" call sites pass intentional=true
1814
+ // (currently just the empty-subscription delayed close below). Everything else — auth
1815
+ // failures after open, peer-initiated DISCONNECT, schema/sequence errors — is a
1816
+ // transient protocol close that should reconnect, so we let the WS close event fall
1817
+ // through to NodeReplicationConnection's normal retry path instead of marking the
1818
+ // connection as finished or emitting 'finished' (which would remove it from the
1819
+ // worker's connections map).
1820
+ if (intentional && options.connection) options.connection.intentionallyUnsubscribed = true;
1707
1821
  logger.debug?.(connectionId, 'closing', remoteNodeName, databaseName, code, reason);
1708
1822
  ws.close(code, reason);
1709
- options.connection?.emit('finished'); // we want to synchronously indicate that the connection is finished, so it is not accidently reused
1823
+ if (intentional) options.connection?.emit('finished'); // synchronously indicate that the connection is finished, so it is not accidentally reused
1710
1824
  } catch (error) {
1711
1825
  logger.error?.(connectionId, 'Error closing connection', error);
1712
1826
  }
@@ -1808,15 +1922,21 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1808
1922
  tableSubscriptionToReplicator.auditStore?.rootStore
1809
1923
  );
1810
1924
  if (finished) {
1811
- finished.blobId = blobId;
1812
- outstandingBlobsToFinish.push(finished);
1813
- finished
1925
+ // We log the rejection via .catch() and also need the resulting promise — not the
1926
+ // raw `finished` — to be what we hand to `Promise.all(outstandingBlobsToFinish)` in
1927
+ // the end_txn onCommit path below. If we pushed `finished` directly, a save
1928
+ // rejection would surface to that `await Promise.all(...)` as an unhandled error
1929
+ // even though we already logged it here, and it would escape onCommit as an
1930
+ // uncaughtException — observed in prod as ~35/sec ENOENT spam during catch-up.
1931
+ const tracked = finished
1814
1932
  .catch((err) => logger.error?.(`Blob save failed for ${blobId} from ${remoteNodeName}`, err))
1815
1933
  .finally(() => {
1816
1934
  logger.debug?.(`Finished receiving blob stream ${blobId}`);
1817
- const index = outstandingBlobsToFinish.indexOf(finished);
1935
+ const index = outstandingBlobsToFinish.indexOf(tracked);
1818
1936
  if (index > -1) outstandingBlobsToFinish.splice(index, 1);
1819
1937
  });
1938
+ tracked.blobId = blobId;
1939
+ outstandingBlobsToFinish.push(tracked);
1820
1940
  }
1821
1941
  return localBlob;
1822
1942
  }
@@ -2035,7 +2155,8 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
2035
2155
  const scheduled = performance.now();
2036
2156
  delayedClose = setTimeout(() => {
2037
2157
  // if we have not received any messages in a while, we can close the connection
2038
- if (lastMessageTime <= scheduled) close(1008, 'Connection has no subscriptions and is no longer used');
2158
+ if (lastMessageTime <= scheduled)
2159
+ close(1008, 'Connection has no subscriptions and is no longer used', true);
2039
2160
  else scheduleClose();
2040
2161
  }, DELAY_CLOSE_TIME).unref();
2041
2162
  };
@@ -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
  }