@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.
- package/core/package-lock.json +37 -37
- package/core/resources/Table.ts +32 -2
- package/dist/core/resources/Table.js +23 -2
- package/dist/core/resources/Table.js.map +1 -1
- package/dist/replication/knownNodes.js +29 -1
- package/dist/replication/knownNodes.js.map +1 -1
- package/dist/replication/replicationConnection.js +75 -12
- package/dist/replication/replicationConnection.js.map +1 -1
- package/dist/replication/replicator.js +12 -2
- package/dist/replication/replicator.js.map +1 -1
- package/dist/replication/subscriptionManager.js +150 -9
- package/dist/replication/subscriptionManager.js.map +1 -1
- package/npm-shrinkwrap.json +37 -37
- package/package.json +1 -1
- package/replication/knownNodes.ts +31 -1
- package/replication/replicationConnection.ts +73 -12
- package/replication/replicator.ts +12 -2
- package/replication/subscriptionManager.ts +166 -9
- package/studio/web/assets/{index-CybLScHg.js → index-BA-5bmxI.js} +5 -5
- package/studio/web/assets/{index-CybLScHg.js.map → index-BA-5bmxI.js.map} +1 -1
- package/studio/web/assets/{index.lazy-DKx5-iXF.js → index.lazy-D97owG2z.js} +2 -2
- package/studio/web/assets/{index.lazy-DKx5-iXF.js.map → index.lazy-D97owG2z.js.map} +1 -1
- package/studio/web/assets/{profile-BOjes0Wl.js → profile-B-xiyCsJ.js} +2 -2
- package/studio/web/assets/{profile-BOjes0Wl.js.map → profile-B-xiyCsJ.js.map} +1 -1
- package/studio/web/assets/{status-EWKUIrjT.js → status-DEEb31XH.js} +2 -2
- package/studio/web/assets/{status-EWKUIrjT.js.map → status-DEEb31XH.js.map} +1 -1
- package/studio/web/index.html +1 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harperfast/harper-pro",
|
|
3
|
-
"version": "5.0.
|
|
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.
|
|
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.
|
|
2224
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js/-/rocksdb-js-1.
|
|
2225
|
-
"integrity": "sha512-
|
|
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.
|
|
2240
|
-
"@harperfast/rocksdb-js-darwin-x64": "1.
|
|
2241
|
-
"@harperfast/rocksdb-js-linux-arm64-glibc": "1.
|
|
2242
|
-
"@harperfast/rocksdb-js-linux-arm64-musl": "1.
|
|
2243
|
-
"@harperfast/rocksdb-js-linux-x64-glibc": "1.
|
|
2244
|
-
"@harperfast/rocksdb-js-linux-x64-musl": "1.
|
|
2245
|
-
"@harperfast/rocksdb-js-win32-arm64": "1.
|
|
2246
|
-
"@harperfast/rocksdb-js-win32-x64": "1.
|
|
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.
|
|
2251
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-arm64/-/rocksdb-js-darwin-arm64-1.
|
|
2252
|
-
"integrity": "sha512-
|
|
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.
|
|
2267
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-x64/-/rocksdb-js-darwin-x64-1.
|
|
2268
|
-
"integrity": "sha512-
|
|
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.
|
|
2283
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-glibc/-/rocksdb-js-linux-arm64-glibc-1.
|
|
2284
|
-
"integrity": "sha512-
|
|
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.
|
|
2302
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-musl/-/rocksdb-js-linux-arm64-musl-1.
|
|
2303
|
-
"integrity": "sha512-
|
|
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.
|
|
2321
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-glibc/-/rocksdb-js-linux-x64-glibc-1.
|
|
2322
|
-
"integrity": "sha512-
|
|
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.
|
|
2340
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-musl/-/rocksdb-js-linux-x64-musl-1.
|
|
2341
|
-
"integrity": "sha512-
|
|
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.
|
|
2359
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-arm64/-/rocksdb-js-win32-arm64-1.
|
|
2360
|
-
"integrity": "sha512-
|
|
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.
|
|
2375
|
-
"resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-x64/-/rocksdb-js-win32-x64-1.
|
|
2376
|
-
"integrity": "sha512-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|