@harperfast/harper-pro 5.0.16 → 5.0.17
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/replayLogs.ts +36 -3
- package/core/resources/replayLogsGuards.ts +42 -0
- package/core/resources/transactionBroadcast.ts +121 -66
- package/dist/core/bin/copyDb.js +16 -0
- package/dist/core/bin/copyDb.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 +111 -30
- 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 +110 -35
- package/replication/replicator.ts +11 -2
- package/replication/subscriptionManager.ts +11 -1
- package/studio/web/assets/{index-pr02wSIB.js → index-DhLu-DHX.js} +5 -5
- package/studio/web/assets/{index-pr02wSIB.js.map → index-DhLu-DHX.js.map} +1 -1
- package/studio/web/assets/{index.lazy-CorGZz3L.js → index.lazy-DBjOisCz.js} +2 -2
- package/studio/web/assets/{index.lazy-CorGZz3L.js.map → index.lazy-DBjOisCz.js.map} +1 -1
- package/studio/web/assets/{profile-SSvkzt9H.js → profile-DSL-499E.js} +2 -2
- package/studio/web/assets/{profile-SSvkzt9H.js.map → profile-DSL-499E.js.map} +1 -1
- package/studio/web/assets/{status-Xk93QrPQ.js → status-BRW5QtzY.js} +2 -2
- package/studio/web/assets/{status-Xk93QrPQ.js.map → status-BRW5QtzY.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
|
|
@@ -455,13 +460,37 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
455
460
|
const MAX_OUTSTANDING_BLOBS_BEING_SENT = env.get(CONFIG_PARAMS.REPLICATION_BLOBCONCURRENCY) ?? 5;
|
|
456
461
|
let outstandingCommits = 0;
|
|
457
462
|
let lastStructureLength = 0;
|
|
458
|
-
|
|
463
|
+
// Multiple independent conditions can ask to pause receive on this WS (commit backlog,
|
|
464
|
+
// consumer queue full, blob write backpressure). We refcount the reasons so that resuming
|
|
465
|
+
// one does not race ahead of another that still wants the WS paused.
|
|
466
|
+
let pauseReasons = 0;
|
|
467
|
+
let commitBacklogPaused = false;
|
|
468
|
+
function addPauseReason(): void {
|
|
469
|
+
if (pauseReasons === 0) ws.pause();
|
|
470
|
+
pauseReasons++;
|
|
471
|
+
}
|
|
472
|
+
function removePauseReason(): void {
|
|
473
|
+
if (pauseReasons === 0) return;
|
|
474
|
+
pauseReasons--;
|
|
475
|
+
if (pauseReasons === 0) ws.resume();
|
|
476
|
+
}
|
|
459
477
|
let subscriptionRequest, auditSubscription;
|
|
460
478
|
let nodeSubscriptions;
|
|
461
479
|
let excludedNodes: string[]; // list of nodes to exclude from this subscription
|
|
462
480
|
let remoteShortIdToLocalId: Map<number, number>;
|
|
463
481
|
let subscribedNodeIds: Array<boolean | { startTime: number; endTime?: number }> | undefined; // map of node IDs to their subscription time ranges
|
|
464
|
-
|
|
482
|
+
// Serialize message handling so that async backpressure inside onWSMessage doesn't allow
|
|
483
|
+
// the WS library to start processing the next frame before the current one is fully decoded.
|
|
484
|
+
// Without serialization, awaiting inside the handler would let concurrent message handlers
|
|
485
|
+
// share the consumer queue and defeat the per-record backpressure below.
|
|
486
|
+
let messageProcessing: Promise<void> = Promise.resolve();
|
|
487
|
+
let wsClosed = false;
|
|
488
|
+
ws.on('message', (body: Buffer) => {
|
|
489
|
+
messageProcessing = messageProcessing.then(
|
|
490
|
+
() => (wsClosed ? undefined : onWSMessage(body)),
|
|
491
|
+
() => (wsClosed ? undefined : onWSMessage(body))
|
|
492
|
+
);
|
|
493
|
+
});
|
|
465
494
|
let authorizationFinished = false;
|
|
466
495
|
function checkAuthorization(): boolean {
|
|
467
496
|
authorizationFinished = true;
|
|
@@ -473,29 +502,23 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
473
502
|
}
|
|
474
503
|
return true;
|
|
475
504
|
}
|
|
476
|
-
function onWSMessage(body: Buffer) {
|
|
505
|
+
async function onWSMessage(body: Buffer): Promise<void> {
|
|
477
506
|
if (!authorizationFinished) {
|
|
478
507
|
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
|
|
508
|
+
try {
|
|
509
|
+
authorization = await authorization;
|
|
510
|
+
} catch (error) {
|
|
511
|
+
authorizationFinished = true;
|
|
512
|
+
logger.error?.(connectionId, 'Authorization failed', error);
|
|
513
|
+
// don't send disconnect because we want the client to potentially retry
|
|
514
|
+
close(1008, 'Unauthorized');
|
|
515
|
+
return;
|
|
496
516
|
}
|
|
517
|
+
if (!checkAuthorization()) return;
|
|
518
|
+
} else if (!checkAuthorization()) {
|
|
519
|
+
return;
|
|
497
520
|
}
|
|
498
|
-
|
|
521
|
+
// fall through to handle this message now that authorization succeeded
|
|
499
522
|
}
|
|
500
523
|
if (!authorization) return;
|
|
501
524
|
// A replication header should begin with either a transaction timestamp or messagepack message of
|
|
@@ -542,9 +565,17 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
542
565
|
schemaUpdateListener = forEachReplicatedDatabase(options, (database, databaseName) => {
|
|
543
566
|
if (checkDatabaseAccess(databaseName)) sendDBSchema(databaseName);
|
|
544
567
|
});
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
568
|
+
// onWSMessage is async, so the WS may have already closed by the time we get
|
|
569
|
+
// here — in that case 'close' has fired and adding the cleanup listener now
|
|
570
|
+
// would silently leak. Drop the registration immediately.
|
|
571
|
+
if (wsClosed) {
|
|
572
|
+
schemaUpdateListener.remove();
|
|
573
|
+
schemaUpdateListener = undefined;
|
|
574
|
+
} else {
|
|
575
|
+
ws.on('close', () => {
|
|
576
|
+
schemaUpdateListener?.remove();
|
|
577
|
+
});
|
|
578
|
+
}
|
|
548
579
|
}
|
|
549
580
|
} catch (error) {
|
|
550
581
|
// if this fails, we should close the connection and indicate that we should not reconnect
|
|
@@ -773,7 +804,21 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
773
804
|
);
|
|
774
805
|
} else stream.end(blobBody);
|
|
775
806
|
if (stream.connectedToBlob) blobsInFlight.delete(fileId);
|
|
776
|
-
} else stream.write(blobBody)
|
|
807
|
+
} else if (!stream.write(blobBody)) {
|
|
808
|
+
// The PassThrough's internal queue is over its HWM, meaning the downstream
|
|
809
|
+
// file write (via pipeline in saveBlob) can't keep up. Pause the WS until the
|
|
810
|
+
// stream drains so blob chunks don't accumulate in memory faster than they
|
|
811
|
+
// can be flushed to disk. Also listen for 'close' so a destroyed stream
|
|
812
|
+
// (e.g. saveBlob error) doesn't strand the pause reason.
|
|
813
|
+
addPauseReason();
|
|
814
|
+
const release = () => {
|
|
815
|
+
stream.off('drain', release);
|
|
816
|
+
stream.off('close', release);
|
|
817
|
+
removePauseReason();
|
|
818
|
+
};
|
|
819
|
+
stream.on('drain', release);
|
|
820
|
+
stream.on('close', release);
|
|
821
|
+
}
|
|
777
822
|
} catch (error) {
|
|
778
823
|
logger.error?.(
|
|
779
824
|
`Error receiving blob for ${stream.recordId} from ${remoteNodeName} and streaming to storage`,
|
|
@@ -1344,6 +1389,16 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
1344
1389
|
close();
|
|
1345
1390
|
}
|
|
1346
1391
|
});
|
|
1392
|
+
// We are inside an async .then(); if the WS closed while waiting for it to
|
|
1393
|
+
// resolve, attaching a 'close' handler now will not fire and the listeners
|
|
1394
|
+
// above would stay subscribed on the global databaseEventsEmitter forever.
|
|
1395
|
+
if (wsClosed) {
|
|
1396
|
+
schemaUpdateListener.remove();
|
|
1397
|
+
dbRemovalListener.remove();
|
|
1398
|
+
schemaUpdateListener = undefined;
|
|
1399
|
+
dbRemovalListener = undefined;
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1347
1402
|
ws.on('close', () => {
|
|
1348
1403
|
schemaUpdateListener?.remove();
|
|
1349
1404
|
dbRemovalListener?.remove();
|
|
@@ -1601,6 +1656,19 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
1601
1656
|
event.nodeId
|
|
1602
1657
|
);
|
|
1603
1658
|
tableSubscriptionToReplicator.send(event);
|
|
1659
|
+
// Per-record backpressure: a single large WS message can synchronously decode
|
|
1660
|
+
// thousands of records, each holding a decoded value object and a closure over
|
|
1661
|
+
// the source buffer. Without yielding here the consumer can never drain the
|
|
1662
|
+
// queue mid-message and the worker heap balloons until it OOMs.
|
|
1663
|
+
const queueLength = tableSubscriptionToReplicator.queue?.length ?? 0;
|
|
1664
|
+
if (queueLength > RECEIVE_EVENT_HIGH_WATER_MARK) {
|
|
1665
|
+
addPauseReason();
|
|
1666
|
+
try {
|
|
1667
|
+
await tableSubscriptionToReplicator.waitForDrain();
|
|
1668
|
+
} finally {
|
|
1669
|
+
removePauseReason();
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1604
1672
|
}
|
|
1605
1673
|
decoder.position = start + eventLength;
|
|
1606
1674
|
} while (decoder.position < body.byteLength);
|
|
@@ -1614,9 +1682,9 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
1614
1682
|
'ingest'
|
|
1615
1683
|
);
|
|
1616
1684
|
}
|
|
1617
|
-
if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !
|
|
1618
|
-
|
|
1619
|
-
|
|
1685
|
+
if (outstandingCommits > MAX_OUTSTANDING_COMMITS && !commitBacklogPaused) {
|
|
1686
|
+
commitBacklogPaused = true;
|
|
1687
|
+
addPauseReason();
|
|
1620
1688
|
logger.debug?.(
|
|
1621
1689
|
`Commit backlog causing replication back-pressure, requesting that ${remoteNodeName} pause replication`
|
|
1622
1690
|
);
|
|
@@ -1639,9 +1707,9 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
1639
1707
|
}
|
|
1640
1708
|
}
|
|
1641
1709
|
outstandingCommits--;
|
|
1642
|
-
if (
|
|
1643
|
-
|
|
1644
|
-
|
|
1710
|
+
if (commitBacklogPaused) {
|
|
1711
|
+
commitBacklogPaused = false;
|
|
1712
|
+
removePauseReason();
|
|
1645
1713
|
logger.debug?.(`Replication resuming ${remoteNodeName}`);
|
|
1646
1714
|
}
|
|
1647
1715
|
// if there are outstanding blobs to finish writing, delay commit receipts until they are finished (so that if we are interrupting
|
|
@@ -1689,6 +1757,7 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
1689
1757
|
});
|
|
1690
1758
|
ws.on('close', (code, reasonBuffer) => {
|
|
1691
1759
|
// cleanup
|
|
1760
|
+
wsClosed = true;
|
|
1692
1761
|
clearInterval(sendPingInterval);
|
|
1693
1762
|
clearTimeout(receivePingTimer);
|
|
1694
1763
|
clearInterval(blobsTimer);
|
|
@@ -1808,15 +1877,21 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
|
|
|
1808
1877
|
tableSubscriptionToReplicator.auditStore?.rootStore
|
|
1809
1878
|
);
|
|
1810
1879
|
if (finished) {
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
finished
|
|
1880
|
+
// We log the rejection via .catch() and also need the resulting promise — not the
|
|
1881
|
+
// raw `finished` — to be what we hand to `Promise.all(outstandingBlobsToFinish)` in
|
|
1882
|
+
// the end_txn onCommit path below. If we pushed `finished` directly, a save
|
|
1883
|
+
// rejection would surface to that `await Promise.all(...)` as an unhandled error
|
|
1884
|
+
// even though we already logged it here, and it would escape onCommit as an
|
|
1885
|
+
// uncaughtException — observed in prod as ~35/sec ENOENT spam during catch-up.
|
|
1886
|
+
const tracked = finished
|
|
1814
1887
|
.catch((err) => logger.error?.(`Blob save failed for ${blobId} from ${remoteNodeName}`, err))
|
|
1815
1888
|
.finally(() => {
|
|
1816
1889
|
logger.debug?.(`Finished receiving blob stream ${blobId}`);
|
|
1817
|
-
const index = outstandingBlobsToFinish.indexOf(
|
|
1890
|
+
const index = outstandingBlobsToFinish.indexOf(tracked);
|
|
1818
1891
|
if (index > -1) outstandingBlobsToFinish.splice(index, 1);
|
|
1819
1892
|
});
|
|
1893
|
+
tracked.blobId = blobId;
|
|
1894
|
+
outstandingBlobsToFinish.push(tracked);
|
|
1820
1895
|
}
|
|
1821
1896
|
return localBlob;
|
|
1822
1897
|
}
|
|
@@ -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
|
}
|