@harperfast/harper-pro 5.0.30 → 5.0.31

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 (27) hide show
  1. package/core/package-lock.json +37 -37
  2. package/core/resources/Table.ts +32 -2
  3. package/dist/core/resources/Table.js +23 -2
  4. package/dist/core/resources/Table.js.map +1 -1
  5. package/dist/replication/knownNodes.js +29 -1
  6. package/dist/replication/knownNodes.js.map +1 -1
  7. package/dist/replication/replicationConnection.js +75 -12
  8. package/dist/replication/replicationConnection.js.map +1 -1
  9. package/dist/replication/replicator.js +12 -2
  10. package/dist/replication/replicator.js.map +1 -1
  11. package/dist/replication/subscriptionManager.js +150 -9
  12. package/dist/replication/subscriptionManager.js.map +1 -1
  13. package/npm-shrinkwrap.json +37 -37
  14. package/package.json +1 -1
  15. package/replication/knownNodes.ts +31 -1
  16. package/replication/replicationConnection.ts +73 -12
  17. package/replication/replicator.ts +12 -2
  18. package/replication/subscriptionManager.ts +166 -9
  19. package/studio/web/assets/{index-CybLScHg.js → index-BA-5bmxI.js} +5 -5
  20. package/studio/web/assets/{index-CybLScHg.js.map → index-BA-5bmxI.js.map} +1 -1
  21. package/studio/web/assets/{index.lazy-DKx5-iXF.js → index.lazy-D97owG2z.js} +2 -2
  22. package/studio/web/assets/{index.lazy-DKx5-iXF.js.map → index.lazy-D97owG2z.js.map} +1 -1
  23. package/studio/web/assets/{profile-BOjes0Wl.js → profile-B-xiyCsJ.js} +2 -2
  24. package/studio/web/assets/{profile-BOjes0Wl.js.map → profile-B-xiyCsJ.js.map} +1 -1
  25. package/studio/web/assets/{status-EWKUIrjT.js → status-DEEb31XH.js} +2 -2
  26. package/studio/web/assets/{status-EWKUIrjT.js.map → status-DEEb31XH.js.map} +1 -1
  27. package/studio/web/index.html +1 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@harperfast/harper-pro",
3
- "version": "5.0.30",
3
+ "version": "5.0.31",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@harperfast/harper-pro",
9
- "version": "5.0.30",
9
+ "version": "5.0.31",
10
10
  "license": "Elastic-2.0",
11
11
  "dependencies": {
12
12
  "@aws-sdk/client-s3": "^3.1012.0",
@@ -2220,9 +2220,9 @@
2220
2220
  "license": "Apache-2.0"
2221
2221
  },
2222
2222
  "node_modules/@harperfast/rocksdb-js": {
2223
- "version": "1.4.2",
2224
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js/-/rocksdb-js-1.4.2.tgz",
2225
- "integrity": "sha512-wQ0buhmNVcw6jPn5VOoYDsGmO1IAmtithcSgaKrnBRicf+ATvQSCB1I7ljEBuqqzWG6HcJXeCgPhFpas3kRwOw==",
2223
+ "version": "1.5.0",
2224
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js/-/rocksdb-js-1.5.0.tgz",
2225
+ "integrity": "sha512-NyH8Nr+jOclzzFzPmN9aC58lvl7X7Gf94E/laJqIeWP7/dsrdbDgNPHBueBdh1JnAq5Fxd+qvEDf/PCGaA/cgw==",
2226
2226
  "license": "Apache-2.0",
2227
2227
  "dependencies": {
2228
2228
  "@harperfast/extended-iterable": "1.0.3",
@@ -2236,20 +2236,20 @@
2236
2236
  "node": ">=18"
2237
2237
  },
2238
2238
  "optionalDependencies": {
2239
- "@harperfast/rocksdb-js-darwin-arm64": "1.4.2",
2240
- "@harperfast/rocksdb-js-darwin-x64": "1.4.2",
2241
- "@harperfast/rocksdb-js-linux-arm64-glibc": "1.4.2",
2242
- "@harperfast/rocksdb-js-linux-arm64-musl": "1.4.2",
2243
- "@harperfast/rocksdb-js-linux-x64-glibc": "1.4.2",
2244
- "@harperfast/rocksdb-js-linux-x64-musl": "1.4.2",
2245
- "@harperfast/rocksdb-js-win32-arm64": "1.4.2",
2246
- "@harperfast/rocksdb-js-win32-x64": "1.4.2"
2239
+ "@harperfast/rocksdb-js-darwin-arm64": "1.5.0",
2240
+ "@harperfast/rocksdb-js-darwin-x64": "1.5.0",
2241
+ "@harperfast/rocksdb-js-linux-arm64-glibc": "1.5.0",
2242
+ "@harperfast/rocksdb-js-linux-arm64-musl": "1.5.0",
2243
+ "@harperfast/rocksdb-js-linux-x64-glibc": "1.5.0",
2244
+ "@harperfast/rocksdb-js-linux-x64-musl": "1.5.0",
2245
+ "@harperfast/rocksdb-js-win32-arm64": "1.5.0",
2246
+ "@harperfast/rocksdb-js-win32-x64": "1.5.0"
2247
2247
  }
2248
2248
  },
2249
2249
  "node_modules/@harperfast/rocksdb-js-darwin-arm64": {
2250
- "version": "1.4.2",
2251
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-arm64/-/rocksdb-js-darwin-arm64-1.4.2.tgz",
2252
- "integrity": "sha512-qQyv8BNCjvZQayhoTd7iqt1CLNEFHfqe8/SDZk8ztAR0ZAIyHIFkmNnaQ/Izn6p3HrNCRp6lNdwO4TKPWHj4SQ==",
2250
+ "version": "1.5.0",
2251
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-arm64/-/rocksdb-js-darwin-arm64-1.5.0.tgz",
2252
+ "integrity": "sha512-g8P4H/imnxn/AoGp+R5TZjHbwezdcMJCyIqo+2F1bLm3lGGaqTMrrmXK7vXxLj/COUMbooJ04QlGei7+nGShhA==",
2253
2253
  "cpu": [
2254
2254
  "arm64"
2255
2255
  ],
@@ -2263,9 +2263,9 @@
2263
2263
  }
2264
2264
  },
2265
2265
  "node_modules/@harperfast/rocksdb-js-darwin-x64": {
2266
- "version": "1.4.2",
2267
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-x64/-/rocksdb-js-darwin-x64-1.4.2.tgz",
2268
- "integrity": "sha512-cPCsB7IVeuDhNjFcb/nqUkwtBI9BlaFNecJ/OjkG8hUpYAhd5rdNHFMt1vpu4sAYFaorFrMZoNKGbq43v14+hQ==",
2266
+ "version": "1.5.0",
2267
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-x64/-/rocksdb-js-darwin-x64-1.5.0.tgz",
2268
+ "integrity": "sha512-62g7+n0G/QV2iBPFGV8ihCi6ZFW/4cxfe+SqQAOQ7p+YtA8/68o3X7b9j2+/fEixAC0ktLp/5mUie5hQVSmZrg==",
2269
2269
  "cpu": [
2270
2270
  "x64"
2271
2271
  ],
@@ -2279,9 +2279,9 @@
2279
2279
  }
2280
2280
  },
2281
2281
  "node_modules/@harperfast/rocksdb-js-linux-arm64-glibc": {
2282
- "version": "1.4.2",
2283
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-glibc/-/rocksdb-js-linux-arm64-glibc-1.4.2.tgz",
2284
- "integrity": "sha512-NquwD+7Gxu3BYAIQhvSThjIRlZRBieDZKXGEmrc0gfcxcORbgOjMCdxDzhjF6l1nR4ODlfJfbKqGyF8JtmnIjA==",
2282
+ "version": "1.5.0",
2283
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-glibc/-/rocksdb-js-linux-arm64-glibc-1.5.0.tgz",
2284
+ "integrity": "sha512-hls3Hg8tHIAwBLL+3VHsHHpZO/D1BWoPkN8cjq8vnVSLEAAYS1qyJpePY1hdwEsL8TD2jBPs5YZwMGQ2EQCiWQ==",
2285
2285
  "cpu": [
2286
2286
  "arm64"
2287
2287
  ],
@@ -2298,9 +2298,9 @@
2298
2298
  }
2299
2299
  },
2300
2300
  "node_modules/@harperfast/rocksdb-js-linux-arm64-musl": {
2301
- "version": "1.4.2",
2302
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-musl/-/rocksdb-js-linux-arm64-musl-1.4.2.tgz",
2303
- "integrity": "sha512-hp2IOXYBvloJEkZ/+bCI/AsQPtzza51Pkz+U+JkaAaayZ6+zsXdVp5GuGYPnCiH4O6QnOihKkgBW8msFhR0ogA==",
2301
+ "version": "1.5.0",
2302
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-musl/-/rocksdb-js-linux-arm64-musl-1.5.0.tgz",
2303
+ "integrity": "sha512-aS+/ZzoOAGiEcIfhpS0xPMMBlBKkEjZAVE9Y92yhVFaMIMhE8UhRPwu0tFyWos6BdyLQ7/Ud5ncR42ZIUh8Y1g==",
2304
2304
  "cpu": [
2305
2305
  "arm64"
2306
2306
  ],
@@ -2317,9 +2317,9 @@
2317
2317
  }
2318
2318
  },
2319
2319
  "node_modules/@harperfast/rocksdb-js-linux-x64-glibc": {
2320
- "version": "1.4.2",
2321
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-glibc/-/rocksdb-js-linux-x64-glibc-1.4.2.tgz",
2322
- "integrity": "sha512-zEkBHRoLanN9MTVY1mFRGDdAGyXBUrk962xvtf0sGj/wIK2M3c2KL39FftWHgtm/BHA3uGHssWk6B7O2O/Dlig==",
2320
+ "version": "1.5.0",
2321
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-glibc/-/rocksdb-js-linux-x64-glibc-1.5.0.tgz",
2322
+ "integrity": "sha512-70kPD6KKwtRJMsBBnKm8JYP+QncIYl7xb02/dVswdv1I5AqN1UNeqJBU4+K4q6QP6FeNpK25oruz8S1iy8QkLQ==",
2323
2323
  "cpu": [
2324
2324
  "x64"
2325
2325
  ],
@@ -2336,9 +2336,9 @@
2336
2336
  }
2337
2337
  },
2338
2338
  "node_modules/@harperfast/rocksdb-js-linux-x64-musl": {
2339
- "version": "1.4.2",
2340
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-musl/-/rocksdb-js-linux-x64-musl-1.4.2.tgz",
2341
- "integrity": "sha512-K7y1CC+LJma2E+wEqFq67jk7+hZW+oPdKcqxyDxrIRKO3jyn0Dtr/fwQf3cFuU8cRnZBmZTAcnr5LeNJrUuYRw==",
2339
+ "version": "1.5.0",
2340
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-musl/-/rocksdb-js-linux-x64-musl-1.5.0.tgz",
2341
+ "integrity": "sha512-jnGa96c1w9L98ioGAzAezlni6Nls9LQ3A5Z4HYnPrY+Dz7Q9SeYANVv+edLR/Xt8fEQn9Yz6DkpYpns3ykUzGA==",
2342
2342
  "cpu": [
2343
2343
  "x64"
2344
2344
  ],
@@ -2355,9 +2355,9 @@
2355
2355
  }
2356
2356
  },
2357
2357
  "node_modules/@harperfast/rocksdb-js-win32-arm64": {
2358
- "version": "1.4.2",
2359
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-arm64/-/rocksdb-js-win32-arm64-1.4.2.tgz",
2360
- "integrity": "sha512-xc4oav0zTLfnjBIan1wlgB3g0NeWrzUPxR0Vq5HkLc4BGyAlPql+v1FwtQ4X6AwcAOHnS/QeYSGDQOZn6Ac1Ow==",
2358
+ "version": "1.5.0",
2359
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-arm64/-/rocksdb-js-win32-arm64-1.5.0.tgz",
2360
+ "integrity": "sha512-a3A68UqztdRS1Ind5UK9sytOJPoE9UJKlGt3623HVIaiQPfP3me9mx6YpnddqBshK0Lojzr5+cc6G1ewSFGEBw==",
2361
2361
  "cpu": [
2362
2362
  "arm64"
2363
2363
  ],
@@ -2371,9 +2371,9 @@
2371
2371
  }
2372
2372
  },
2373
2373
  "node_modules/@harperfast/rocksdb-js-win32-x64": {
2374
- "version": "1.4.2",
2375
- "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-x64/-/rocksdb-js-win32-x64-1.4.2.tgz",
2376
- "integrity": "sha512-60vqTYJdw0ysvHeFHoxZ/sHNesdfvri+jWIyU5TTua2s20LmLZ4hKLglnsa0LP7/IA5JutZsxJiyUUMCoG3PDw==",
2374
+ "version": "1.5.0",
2375
+ "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-x64/-/rocksdb-js-win32-x64-1.5.0.tgz",
2376
+ "integrity": "sha512-0PKUTon/o5TUT3XBMPGPJl6Ry4aP/1D134HDP9XGSYWqtcQwzDb0LsRQdJhF9ixJQzVPrXw9mDGMQFzasVmvcw==",
2377
2377
  "cpu": [
2378
2378
  "x64"
2379
2379
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harperfast/harper-pro",
3
- "version": "5.0.30",
3
+ "version": "5.0.31",
4
4
  "description": "Harper is a distributed database, caching service, streaming broker, and application development platform focused on performance and ease of use.",
5
5
  "keywords": [
6
6
  "database",
@@ -74,6 +74,20 @@ export function getReplicationSharedStatus(
74
74
  )
75
75
  );
76
76
  }
77
+ /**
78
+ * Decide whether a change-stream event that carried *no usable decoded value* represents a
79
+ * genuine node deletion. (Callers only reach this for a nullish value; an event with a
80
+ * decoded value is always an upsert.) The ambiguity is only for `put`/`patch` events, where
81
+ * a decode failure can produce a null value while the record still physically exists.
82
+ * A `delete` event from the change stream is already the reliable signal that the record is
83
+ * truly gone — the physical-existence check was overly conservative and caused genuine
84
+ * `remove_node` deletes to be suppressed because LMDB/RocksDB reads open against an older
85
+ * snapshot that predates the delete commit (harper#1163 regression).
86
+ */
87
+ export function isGenuineNodeDeletion(eventType: string): boolean {
88
+ return eventType === 'delete'; // put/patch with null value = decode failure; delete events are genuine
89
+ }
90
+
77
91
  export function subscribeToNodeUpdates(listener: (node: any, id: string) => void) {
78
92
  getHDBNodeTable()
79
93
  .subscribe({})
@@ -103,7 +117,23 @@ export function subscribeToNodeUpdates(listener: (node: any, id: string) => void
103
117
  }
104
118
  server.shards = shards;
105
119
  if (event.type === 'put' || event.type === 'delete') {
106
- listener(event.value, event.id);
120
+ if (event.value != null) {
121
+ // normal upsert with a decoded value
122
+ listener(event.value, event.id);
123
+ } else if (isGenuineNodeDeletion(event.type)) {
124
+ // genuine deletion — forward the nullish value so the listener tears the node down
125
+ listener(event.value, event.id);
126
+ } else {
127
+ // A put/patch with no decodable value is a transient decode failure (e.g. stale
128
+ // msgpackr shared-structures, harper#1163) — NOT a node removal. Forwarding it here
129
+ // would make onNodeUpdate treat the nullish value as a deletion and unsubscribe the
130
+ // peer from every database. Drop it instead; the next decodable event self-heals.
131
+ logger.warn?.(
132
+ 'hdb_nodes change event for',
133
+ event.id,
134
+ 'had no decodable value; treating as a transient decode failure (see harper#1163), not a node deletion'
135
+ );
136
+ }
107
137
  }
108
138
  }
109
139
  });
@@ -91,6 +91,12 @@ const MAX_PAYLOAD = env.get('replication_maxPayload') ?? 100_000_000;
91
91
  // heap past its limit. If the local replicator queue grows beyond this threshold we pause
92
92
  // the WS connection and wait for it to drain before continuing the decode loop.
93
93
  const RECEIVE_EVENT_HIGH_WATER_MARK = env.get('replication_receiveEventHighWaterMark') ?? 100;
94
+ // Even when the consumer keeps up (queue below the high-water mark), a single large inbound message
95
+ // would otherwise decode thousands of records in one synchronous turn — pegging the worker, blocking
96
+ // replication ping responses, and tripping core's "JavaScript execution has taken too long" monitor
97
+ // (MAX_EVENT_DELAY_TIME = 3 s). Yield the event loop at least this often (ms) while decoding so the
98
+ // worker stays responsive during a bulk copy/clone.
99
+ const RECEIVE_YIELD_INTERVAL = env.get('replication_receiveYieldInterval') ?? 100;
94
100
 
95
101
  export const tableUpdateListeners = new Map();
96
102
  // This a map of the database name to the subscription object, for the subscriptions from our tables to the replication module
@@ -105,6 +111,28 @@ const SKIPPED_MESSAGE_SEQUENCE_UPDATE_DELAY = 300;
105
111
  // (but still allow for batching so we aren't sending out a message for every update under load)
106
112
  const COMMITTED_UPDATE_DELAY = 2;
107
113
  const PING_INTERVAL = 30000;
114
+ // Time (ms) without any socket activity before a connection is treated as dead.
115
+ // On v5.0 this is hardcoded; main exposes it via `replication.pingTimeout` (v5.1+).
116
+ const PING_TIMEOUT = PING_INTERVAL * 2; // 60 s; configurable via replication.pingTimeout in v5.1+
117
+
118
+ /**
119
+ * Decide whether an idle replication connection should be terminated as dead.
120
+ *
121
+ * Liveness is measured from the last observed socket byte movement (in either direction), not from a
122
+ * single ping interval. A bulk transfer — notably the initial clone copy of a large table — makes
123
+ * slow but real progress: the sender's socket buffer drains in bursts as the peer consumes, so bytes
124
+ * keep moving within the timeout window even while it is otherwise stalled. A genuinely dead or
125
+ * unreachable peer moves no bytes at all, so it still trips the timeout. We deliberately do NOT exempt
126
+ * the sender's `isPausedForBackPressure` drain-wait here: if the peer dies after we have filled our
127
+ * socket buffer, the drain event never fires, and exempting it would hang the connection forever.
128
+ *
129
+ * The one exemption is `pauseReasons > 0`: the receiver has intentionally stopped reading to drain its
130
+ * own queue. That stall is local and self-clearing (it does not depend on the peer), so it is never a
131
+ * death signal; the caller keeps liveness fresh while paused and resumes normal detection afterward.
132
+ */
133
+ export function shouldTerminateIdlePing(idleMs: number, pingTimeout: number, pauseReasons: number): boolean {
134
+ return pauseReasons === 0 && idleMs >= pingTimeout;
135
+ }
108
136
  let secureContexts: Map<string, tls.SecureContext>;
109
137
  /**
110
138
  * Handles reconnection, and requesting catch-up
@@ -413,22 +441,51 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
413
441
  // track bytes read and written so we can verify if a connection is really dead on pings
414
442
  let bytesRead = 0;
415
443
  let bytesWritten = 0;
444
+ // wall-clock time of the last observed socket activity (bytes moved in either direction); the
445
+ // keep-alive timeout is measured from this so a legitimately slow-but-progressing transfer stays
446
+ // alive while a truly idle/dead peer is still terminated.
447
+ let lastByteActivity = performance.now();
448
+ // Multiple independent conditions can ask to pause receive on this WS (commit backlog, consumer
449
+ // queue full, blob write backpressure). We refcount the reasons so that resuming one does not race
450
+ // ahead of another that still wants the WS paused. Declared before the ping setup below because the
451
+ // immediate sendPing() reads it.
452
+ let pauseReasons = 0;
416
453
  const blobTimeout = env.get(CONFIG_PARAMS.REPLICATION_BLOBTIMEOUT) ?? 120000;
417
454
  const blobsInFlight = new Map();
418
455
  const outstandingBlobsToFinish: Promise<void>[] = [];
419
456
  let outstandingBlobsBeingSent = 0;
420
457
  let blobSentCallback: (v?: any) => void;
458
+ // Refresh the keep-alive liveness clock from observed socket byte movement. If the underlying
459
+ // _socket isn't observable (test mocks, pre-connect, or a change in the ws library internals),
460
+ // bytesRead/bytesWritten read as undefined; we can't measure activity, so treat the connection as
461
+ // live rather than let the keep-alive falsely terminate a healthy peer.
462
+ function noteByteActivity(): void {
463
+ const read = ws._socket?.bytesRead;
464
+ const written = ws._socket?.bytesWritten;
465
+ if (read === undefined || written === undefined || read !== bytesRead || written !== bytesWritten) {
466
+ lastByteActivity = performance.now();
467
+ }
468
+ }
421
469
  if (options.url) {
422
470
  const sendPing = () => {
423
- // if we have not received a message in the last ping interval, we should terminate the connection (but check to make sure we aren't just waiting for other data to flow)
424
- if (lastPingTime && bytesRead === ws._socket?.bytesRead && bytesWritten === ws._socket?.bytesWritten)
425
- ws.terminate(); // timeout
426
- else {
427
- lastPingTime = performance.now();
428
- ws.ping();
429
- bytesRead = ws._socket?.bytesRead;
430
- bytesWritten = ws._socket?.bytesWritten;
471
+ // Note any socket activity since the last interval (incoming pong/data or our send buffer
472
+ // draining as the peer consumes) either proves the peer is still alive.
473
+ noteByteActivity();
474
+ if (shouldTerminateIdlePing(performance.now() - lastByteActivity, PING_TIMEOUT, pauseReasons)) {
475
+ ws.terminate(); // no socket activity within the timeout — peer is gone
476
+ return;
431
477
  }
478
+ // While paused for receiver backpressure, keep our own liveness fresh: the stall is local and
479
+ // self-clearing (it doesn't depend on the peer), so we must not time the peer out for it.
480
+ if (pauseReasons > 0) lastByteActivity = performance.now();
481
+ // Always send the keep-alive ping. ws.pause() only stops reads, not writes, and the accepted
482
+ // peer relies on our pings to keep its own receive timer alive even when it has no data to send
483
+ // us. Record byte counts AFTER the ping so the ping's own bytes aren't later mistaken for peer
484
+ // activity.
485
+ lastPingTime = performance.now();
486
+ ws.ping();
487
+ bytesRead = ws._socket?.bytesRead;
488
+ bytesWritten = ws._socket?.bytesWritten;
432
489
  };
433
490
  sendPingInterval = setInterval(sendPing, PING_INTERVAL).unref();
434
491
  sendPing(); // send the first ping immediately so we can measure latency
@@ -489,10 +546,6 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
489
546
  const MAX_OUTSTANDING_BLOBS_BEING_SENT = env.get(CONFIG_PARAMS.REPLICATION_BLOBCONCURRENCY) ?? 5;
490
547
  let outstandingCommits = 0;
491
548
  let lastStructureLength = 0;
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
549
  let commitBacklogPaused = false;
497
550
  function addPauseReason(): void {
498
551
  if (pauseReasons === 0) ws.pause();
@@ -1599,6 +1652,7 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1599
1652
  let beginTxn = true;
1600
1653
  let event; // could also get txnTime from decoder.getFloat64(0);
1601
1654
  let sequenceIdReceived;
1655
+ let lastYieldTime = performance.now();
1602
1656
  do {
1603
1657
  getSharedStatus();
1604
1658
  const eventLength = decoder.readInt();
@@ -1706,6 +1760,13 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1706
1760
  } finally {
1707
1761
  removePauseReason();
1708
1762
  }
1763
+ lastYieldTime = performance.now();
1764
+ } else if (performance.now() - lastYieldTime >= RECEIVE_YIELD_INTERVAL) {
1765
+ // The high-water-mark pause only fires under heap pressure. When the consumer keeps
1766
+ // up, yield on a time budget anyway so a large message doesn't decode in one
1767
+ // synchronous turn and stall ping responses (see RECEIVE_YIELD_INTERVAL).
1768
+ await new Promise(setImmediate);
1769
+ lastYieldTime = performance.now();
1709
1770
  }
1710
1771
  }
1711
1772
  decoder.position = start + eventLength;
@@ -447,6 +447,16 @@ export function setReplicator(dbName: string, table: any, options: any) {
447
447
  }
448
448
  const connections = new Map<string, Map<string, NodeReplicationConnection>>();
449
449
 
450
+ // A connection that was intentionally torn down — the empty-subscription delayed close marks it
451
+ // intentionallyUnsubscribed/isFinished — must not be handed back out of the cache. connect()
452
+ // early-returns forever on intentionallyUnsubscribed, so a peer whose subscription set transiently
453
+ // emptied (e.g. during a peer restart) and was then restored would otherwise stay permanently dead
454
+ // on a connected:false connection that never retries. Callers drop a non-reusable cached connection
455
+ // and create a fresh one through the normal connect path. See harper-pro#233 / #289.
456
+ export function isReusableConnection(connection?: NodeReplicationConnection): boolean {
457
+ return !!connection && !connection.isFinished && !connection.intentionallyUnsubscribed;
458
+ }
459
+
450
460
  /**
451
461
  * Get or create a connection to the specified node
452
462
  * @param url
@@ -468,7 +478,7 @@ function getSubscriptionConnection(
468
478
  connections.set(connectionKey, dbConnections);
469
479
  }
470
480
  let connection = dbConnections.get(dbName);
471
- if (connection) return connection;
481
+ if (isReusableConnection(connection)) return connection;
472
482
  if (subscription) {
473
483
  dbConnections.set(
474
484
  dbName,
@@ -490,7 +500,7 @@ function getRetrievalConnectionByName(nodeName, subscription, dbName): NodeRepli
490
500
  nodeNameToRetrievalConnections.set(nodeName, dbConnections);
491
501
  }
492
502
  let connection = dbConnections.get(dbName);
493
- if (connection) return connection;
503
+ if (isReusableConnection(connection)) return connection;
494
504
  const node = getHDBNodeTable().primaryStore.get(nodeName);
495
505
  if (node?.url) {
496
506
  connection = new NodeReplicationConnection(getNodeURL(node), subscription, dbName, nodeName, node.authorization);
@@ -31,6 +31,9 @@ type ConnectedWorkerStatus = {
31
31
  worker: Worker | null;
32
32
  connected?: boolean;
33
33
  latency?: number;
34
+ // Timestamp (ms) of the most recent transition to connected:false, used by the reconcile to
35
+ // distinguish a connection that is briefly retrying from one that is wedged. Cleared on connect.
36
+ disconnectedAt?: number;
34
37
  };
35
38
  type ReplicationConnectionStatus = {
36
39
  nodes: ({
@@ -52,8 +55,79 @@ const NODE_SUBSCRIBE_DELAY = 200; // delay before sending node subscribe to othe
52
55
  // caused the OOM in the first place. We stagger the re-subscriptions in time so the new
53
56
  // worker(s) absorb them gradually.
54
57
  const WORKER_EXIT_REASSIGN_STAGGER_MS = 100;
58
+ // Cadence of the per-process safety-net reconcile that rebinds subscriptions whose
59
+ // worker no longer exists. Pure read-side filter against `workers` and
60
+ // `connectionReplicationMap` on each tick when nothing is wrong, so a short interval
61
+ // is cheap. Sized for the deploy-time rapid-restart-storm pattern (stacked
62
+ // `restart_http_workers` at ~1.5s spacing under live write traffic), where the per-
63
+ // worker exit chain races against shutdown and silently drops half the subscription
64
+ // assignments — this is the user-visible recovery latency for the resulting drift.
65
+ const RECONCILE_INTERVAL_MS = 5_000;
66
+ // A connection that is connected:false but still actively retrying reconnects within seconds (the
67
+ // retry backoff starts at 500ms). Only re-drive a connection that has stayed disconnected well
68
+ // beyond that, so the reconcile targets genuinely wedged connections (e.g. an intentionally-closed
69
+ // connection with no pending retry) rather than churning connections that are mid-reconnect.
70
+ const WEDGE_RECONCILE_THRESHOLD_MS = 30_000;
55
71
  let nextWorkerExitReassignAt = 0;
56
72
  const connectionReplicationMap = new Map<string, DBReplicationStatusMap>();
73
+ // Returns the set of node URLs whose replication entries either point at a worker no longer
74
+ // in the supplied http pool, OR have no worker assigned at all while live workers exist.
75
+ // The second case covers "all workers were down at registration time" — onDatabase stores
76
+ // `worker: undefined` when httpWorkers is empty, and without this the entry would never
77
+ // get reassigned once workers came back. Pure helper so the reconcile pass below — and its
78
+ // unit tests — can verify the broken-chain detection without spinning up real workers.
79
+ export function findStaleNodeUrls(connectionMap: Map<string, DBReplicationStatusMap>, httpWorkers: any[]): Set<string> {
80
+ const staleNodeUrls = new Set<string>();
81
+ // No live workers to reassign to — flagging here would cause endless no-op reassignments.
82
+ if (httpWorkers.length === 0) return staleNodeUrls;
83
+ for (const [url, dbReplicationWorkers] of connectionMap) {
84
+ for (const entry of dbReplicationWorkers.values()) {
85
+ if (!entry.worker || !httpWorkers.includes(entry.worker)) {
86
+ staleNodeUrls.add(url);
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ return staleNodeUrls;
92
+ }
93
+ // Returns the set of node URLs that have a desired replication subscription but have been
94
+ // connected:false on a *live* worker for longer than `thresholdMs`. This is the recovery path for a
95
+ // connection that wedged without a pending retry — most notably the empty-subscription delayed close
96
+ // (intentionallyUnsubscribed) firing during a peer restart and then never re-establishing even though
97
+ // the peer is reachable and still subscribed. findStaleNodeUrls does not catch this because the
98
+ // worker is alive. Re-driving these through onNodeUpdate creates a fresh connection (the prior one is
99
+ // no longer reusable — see replicator.isReusableConnection). A threshold well above the normal
100
+ // reconnect backoff keeps this from firing on connections that are merely mid-retry.
101
+ // `isDesired` must be the same predicate onDatabase uses to decide shouldSubscribe
102
+ // (shouldReplicateFromNode), so a connection intentionally unsubscribed because this node should NOT
103
+ // subscribe (replication off, or a sendsTo/subscription targeting another database) is not flagged
104
+ // and re-driven forever. See harper-pro#233 / #289.
105
+ export function findWedgedNodeUrls(
106
+ connectionMap: Map<string, DBReplicationStatusMap>,
107
+ httpWorkers: any[],
108
+ now: number,
109
+ thresholdMs: number,
110
+ isDesired: (node: any, database: string) => boolean
111
+ ): Set<string> {
112
+ const wedgedNodeUrls = new Set<string>();
113
+ if (httpWorkers.length === 0) return wedgedNodeUrls;
114
+ for (const [url, dbReplicationWorkers] of connectionMap) {
115
+ for (const [database, entry] of dbReplicationWorkers) {
116
+ if (
117
+ entry.connected === false &&
118
+ entry.worker &&
119
+ httpWorkers.includes(entry.worker) &&
120
+ entry.disconnectedAt != null &&
121
+ now - entry.disconnectedAt >= thresholdMs &&
122
+ isDesired(entry.nodes?.[0], database)
123
+ ) {
124
+ wedgedNodeUrls.add(url);
125
+ break;
126
+ }
127
+ }
128
+ }
129
+ return wedgedNodeUrls;
130
+ }
57
131
  export let disconnectedFromNode; // this is set by thread to handle when a node is disconnected (or notify main thread so it can handle)
58
132
  export let connectedToNode; // this is set by thread to handle when a node is connected (or notify main thread so it can handle)
59
133
  const nodeMap = new Map(); // this is a map of all nodes that are available to connect to
@@ -126,7 +200,7 @@ export async function startOnMainThread(options) {
126
200
  * This is called when a new node is added to the hdbNodes table
127
201
  * @param node
128
202
  */
129
- function onNodeUpdate(node, hostname = node?.name) {
203
+ function onNodeUpdate(node, hostname = node?.name, forceResubscribe = false) {
130
204
  const isSelf =
131
205
  (getThisNodeName() && hostname === getThisNodeName()) || (getThisNodeUrl() && node?.url === getThisNodeUrl());
132
206
  if (isSelf) {
@@ -194,9 +268,9 @@ export async function startOnMainThread(options) {
194
268
  }
195
269
  dbReplicationWorkers.iterator = forEachReplicatedDatabase(options, (database, databaseName, replicateByDefault) => {
196
270
  if (replicateByDefault) {
197
- onDatabase(databaseName, true);
271
+ onDatabase(databaseName, true, forceResubscribe);
198
272
  } else {
199
- onDatabase(databaseName, false);
273
+ onDatabase(databaseName, false, forceResubscribe);
200
274
  }
201
275
  });
202
276
  // check to see if there are any explicit subscriptions to databases that don't exist yet
@@ -207,12 +281,12 @@ export async function startOnMainThread(options) {
207
281
  const databaseName = sub.database || sub.schema;
208
282
  if (!databases[databaseName]) {
209
283
  logger.warn(`Database ${databaseName} not found for node ${node.name}, making a subscription anyway`);
210
- onDatabase(databaseName, false);
284
+ onDatabase(databaseName, false, forceResubscribe);
211
285
  }
212
286
  }
213
287
  }
214
288
 
215
- function onDatabase(databaseName, tablesReplicateByDefault) {
289
+ function onDatabase(databaseName, tablesReplicateByDefault, forceResubscribe = false) {
216
290
  logger.trace('Setting up replication for database', databaseName, 'on node', node.name);
217
291
  const existingEntry = dbReplicationWorkers.get(databaseName);
218
292
  let worker;
@@ -236,8 +310,13 @@ export async function startOnMainThread(options) {
236
310
  if (existingEntry) {
237
311
  worker = existingEntry.worker;
238
312
  existingEntry.nodes = nodes;
239
- if (shouldSubscribe) {
240
- // already subscribed, don't need to do anything
313
+ // Normally an existing subscribed entry is left alone. Only the wedge reconcile passes
314
+ // forceResubscribe for a connection that has been connected:false past the threshold: that
315
+ // falls through to re-post subscribe-to-node on the same worker (the worker then reuses a
316
+ // still-retrying connection or builds a fresh one — replicator.isReusableConnection). We
317
+ // deliberately do NOT re-subscribe every connected:false entry on an ordinary onNodeUpdate —
318
+ // doing so disrupts in-flight replication (e.g. an active legacy-node base copy).
319
+ if (shouldSubscribe && !(forceResubscribe && existingEntry.connected === false)) {
241
320
  return;
242
321
  }
243
322
  } else if (shouldSubscribe) {
@@ -345,6 +424,9 @@ export async function startOnMainThread(options) {
345
424
  logger.warn('Disconnected node not found in replication map', connection.database, dbReplicationWorkers);
346
425
  return;
347
426
  }
427
+ // Record the first transition to disconnected so the reconcile can tell a wedged connection
428
+ // from one that is briefly mid-retry; don't reset it on repeated disconnect notifications.
429
+ if (existingWorkerEntry.connected !== false) existingWorkerEntry.disconnectedAt = Date.now();
348
430
  existingWorkerEntry.connected = false;
349
431
  if (connection.finished) {
350
432
  return;
@@ -413,6 +495,7 @@ export async function startOnMainThread(options) {
413
495
  return;
414
496
  }
415
497
  mainWorkerEntry.connected = true;
498
+ mainWorkerEntry.disconnectedAt = undefined;
416
499
  mainWorkerEntry.latency = connection.latency;
417
500
  const restoredNode = mainWorkerEntry.nodes[0];
418
501
  if (!restoredNode) {
@@ -478,6 +561,59 @@ export async function startOnMainThread(options) {
478
561
  });
479
562
  } else subscribeToNode({ url: getNodeURL(connectingNode), name: connectingNode.name, database, nodes: [node] });
480
563
  }
564
+ // Periodic safety net for stale subscription entries. The existing per-database
565
+ // worker.on('exit') chain reassigns to a healthy worker after a worker dies, but a
566
+ // single broken link in that chain (identity check failing, setTimeout retry being
567
+ // lost under load, shouldSubscribe early-return pinning to a dead worker before
568
+ // the defensive check was added) used to leave the entry permanently pointing at
569
+ // an exited worker, silently breaking outbound replication for the lifetime of the
570
+ // process. This reconciles independently of the chain so the broken-state node
571
+ // can never get stuck.
572
+ function reconcileWorkers() {
573
+ const httpWorkers = workers.filter((worker) => worker.name === 'http');
574
+ const staleNodeUrls = findStaleNodeUrls(connectionReplicationMap, httpWorkers);
575
+ const wedgedNodeUrls = findWedgedNodeUrls(
576
+ connectionReplicationMap,
577
+ httpWorkers,
578
+ Date.now(),
579
+ WEDGE_RECONCILE_THRESHOLD_MS,
580
+ shouldReplicateFromNode
581
+ );
582
+ if (staleNodeUrls.size === 0 && wedgedNodeUrls.size === 0) return;
583
+ if (staleNodeUrls.size > 0)
584
+ logger.warn(
585
+ 'Reconciling replication subscriptions for nodes pointing at exited workers:',
586
+ Array.from(staleNodeUrls)
587
+ );
588
+ if (wedgedNodeUrls.size > 0)
589
+ logger.warn(
590
+ 'Reconciling replication subscriptions for nodes wedged disconnected on a live worker:',
591
+ Array.from(wedgedNodeUrls)
592
+ );
593
+ for (const node of nodeMap.values()) {
594
+ const url = getNodeURL(node);
595
+ const isWedged = wedgedNodeUrls.has(url);
596
+ if (!staleNodeUrls.has(url) && !isWedged) continue;
597
+ if (isWedged) {
598
+ // Restart the disconnect clock before re-driving so a peer that is still unreachable after
599
+ // the re-subscribe (it stays connected:false, which does not re-stamp disconnectedAt) is
600
+ // retried at most once per threshold window rather than on every reconcile tick.
601
+ // onNodeUpdate -> onDatabase then re-posts subscribe-to-node for the connected:false entry
602
+ // (it no longer early-returns), reusing the existing worker — so no entry/listener churn.
603
+ const entries = connectionReplicationMap.get(url);
604
+ if (entries)
605
+ for (const entry of entries.values()) if (entry.connected === false) entry.disconnectedAt = Date.now();
606
+ }
607
+ try {
608
+ // forceResubscribe only for wedged entries, so a normal stale-worker reconcile keeps its
609
+ // original behavior and ordinary onNodeUpdate calls never re-subscribe live subscriptions.
610
+ onNodeUpdate(node, undefined, isWedged);
611
+ } catch (error) {
612
+ logger.error('Error reconciling node', node?.name, error);
613
+ }
614
+ }
615
+ }
616
+ setInterval(reconcileWorkers, RECONCILE_INTERVAL_MS).unref();
481
617
  onMessageByType('disconnected-from-node', disconnectedFromNode);
482
618
  onMessageByType('connected-to-node', connectedToNode);
483
619
  onMessageByType('request-cluster-status', requestClusterStatus);
@@ -526,6 +662,18 @@ export function requestClusterStatus(message, port) {
526
662
  return { connections };
527
663
  }
528
664
 
665
+ // threadServer.js starts servers at import time on non-main workers, and job workers import this
666
+ // module (replication is a HARPER_BUILTIN_COMPONENT), so importing it at module load would spuriously
667
+ // start servers / keep job workers alive. Lazily import it only when a subscribe/unsubscribe actually
668
+ // arrives — which only happens on HTTP/replication workers, where threadServer is already loaded, so
669
+ // the dynamic import resolves to the cached module with no side effect. Cached after first use.
670
+ let componentsLoadedPromise: Promise<unknown> | undefined;
671
+ function whenWorkerComponentsLoaded(): Promise<unknown> {
672
+ return (componentsLoadedPromise ??= import('../core/server/threads/threadServer.js').then(
673
+ (threadServer) => threadServer.whenComponentsLoaded
674
+ ));
675
+ }
676
+
529
677
  if (parentPort) {
530
678
  disconnectedFromNode = (connection) => {
531
679
  parentPort.postMessage({ type: 'disconnected-from-node', ...connection });
@@ -534,10 +682,19 @@ if (parentPort) {
534
682
  parentPort.postMessage({ type: 'connected-to-node', ...connection });
535
683
  };
536
684
  onMessageByType('subscribe-to-node', (message) => {
537
- subscribeToNode(message);
685
+ // Defer until this worker has finished loading components (databases/tables + persisted hdb_nodes
686
+ // rows). subscribeToNode re-checks shouldReplicateFromNode, which reads that thread-local state; if
687
+ // it runs before the state is loaded it filters the request down to empty and arms a permanent
688
+ // "no subscriptions" close, wedging the (peer, db) until restart (harper-pro#289 / #233). Once
689
+ // components are loaded the predicate is authoritative. In steady state the promise is already
690
+ // resolved, so this is effectively synchronous.
691
+ whenWorkerComponentsLoaded().then(() => subscribeToNode(message));
538
692
  });
539
693
  onMessageByType('unsubscribe-from-node', (message) => {
540
- unsubscribeFromNode(message);
694
+ // Defer through the same gate as subscribe-to-node so the two stay ordered: a pre-load
695
+ // subscribe followed by an unsubscribe must apply in that order (else the deferred subscribe
696
+ // would run after the unsubscribe and re-open a connection the main thread already removed).
697
+ whenWorkerComponentsLoaded().then(() => unsubscribeFromNode(message));
541
698
  });
542
699
  }
543
700