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