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