@peerbit/pubsub 5.4.9 → 5.4.12
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/dist/src/fanout-tree.d.ts +13 -1
- package/dist/src/fanout-tree.d.ts.map +1 -1
- package/dist/src/fanout-tree.js +241 -38
- package/dist/src/fanout-tree.js.map +1 -1
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +612 -127
- package/dist/src/index.js.map +1 -1
- package/package.json +6 -6
- package/src/fanout-tree.ts +315 -42
- package/src/index.ts +857 -177
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peerbit/pubsub",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.12",
|
|
4
4
|
"description": "Direct streaming for libp2p",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/src/index.d.ts",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"homepage": "https://github.com/dao-xyz/peerbit#readme",
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@peerbit/libp2p-test-utils": "3.0.
|
|
70
|
+
"@peerbit/libp2p-test-utils": "3.0.11"
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"@dao-xyz/borsh": "^6.0.0",
|
|
@@ -86,12 +86,12 @@
|
|
|
86
86
|
"uint8arraylist": "^3.0.2",
|
|
87
87
|
"uint8arrays": "^5.1.0",
|
|
88
88
|
"@peerbit/any-store-interface": "1.1.4",
|
|
89
|
+
"@peerbit/crypto": "3.1.6",
|
|
89
90
|
"@peerbit/logger": "2.0.2",
|
|
90
|
-
"@peerbit/stream": "5.2.3",
|
|
91
91
|
"@peerbit/pubsub-interface": "5.2.2",
|
|
92
|
-
"@peerbit/
|
|
93
|
-
"@peerbit/
|
|
94
|
-
"@peerbit/
|
|
92
|
+
"@peerbit/stream": "5.2.4",
|
|
93
|
+
"@peerbit/stream-interface": "6.0.16",
|
|
94
|
+
"@peerbit/time": "3.0.1"
|
|
95
95
|
},
|
|
96
96
|
"scripts": {
|
|
97
97
|
"bench": "TS_NODE_PROJECT=../../../tsconfig.json node --loader ts-node/esm ./benchmark/index.ts",
|
package/src/fanout-tree.ts
CHANGED
|
@@ -512,7 +512,7 @@ export type FanoutTreeJoinOptions = {
|
|
|
512
512
|
parentUpgradeStaleRootProbeProbability?: number;
|
|
513
513
|
|
|
514
514
|
/**
|
|
515
|
-
* How long to wait for a parent probe reply
|
|
515
|
+
* How long to wait for a candidate or idle-parent probe reply.
|
|
516
516
|
*/
|
|
517
517
|
parentProbeTimeoutMs?: number;
|
|
518
518
|
|
|
@@ -766,6 +766,8 @@ export type FanoutTreeChannelMetrics = {
|
|
|
766
766
|
reparentUpgradeSkipProbeOverloaded: number;
|
|
767
767
|
reparentUpgradeSkipProbeCooldown: number;
|
|
768
768
|
parentProbeReqSent: number;
|
|
769
|
+
/** Subset of parentProbeReqSent used to check the current idle parent. */
|
|
770
|
+
parentHealthProbeReqSent: number;
|
|
769
771
|
parentProbeReqReceived: number;
|
|
770
772
|
parentProbeReplySent: number;
|
|
771
773
|
parentProbeReplyReceived: number;
|
|
@@ -836,6 +838,11 @@ export interface FanoutTreeEvents extends StreamEvents {
|
|
|
836
838
|
"fanout:data": CustomEvent<FanoutTreeDataEvent>;
|
|
837
839
|
"fanout:unicast": CustomEvent<FanoutTreeUnicastEvent>;
|
|
838
840
|
"fanout:joined": CustomEvent<{ topic: string; root: string; parent: string }>;
|
|
841
|
+
"fanout:child-joined": CustomEvent<{
|
|
842
|
+
topic: string;
|
|
843
|
+
root: string;
|
|
844
|
+
child: string;
|
|
845
|
+
}>;
|
|
839
846
|
"fanout:kicked": CustomEvent<{ topic: string; root: string; from: string }>;
|
|
840
847
|
"fanout:provider": CustomEvent<{
|
|
841
848
|
namespace: string;
|
|
@@ -870,6 +877,19 @@ const PROVIDER_DIRECTORY_MAX_NAMESPACES = 4_096;
|
|
|
870
877
|
// large networks can cause unbounded memory growth on the tracker nodes.
|
|
871
878
|
const TRACKER_DIRECTORY_MAX_ENTRIES = 16_384;
|
|
872
879
|
const TRACKER_DIRECTORY_MAX_NAMESPACES = 4_096;
|
|
880
|
+
// Best-effort bound on per-key control metrics for keys without an open channel.
|
|
881
|
+
// Every inbound control frame is recorded under its key, including tracker frames
|
|
882
|
+
// for channels we do not host, so bootstraps/relays see an open-ended key set.
|
|
883
|
+
// Open channels keep their metrics on the channel and are never evicted.
|
|
884
|
+
const DETACHED_METRICS_MAX_KEYS = 4_096;
|
|
885
|
+
// Best-effort bounds for provider watches and namespace names. Provider namespaces
|
|
886
|
+
// are commonly per block (`cid:<cid>`), so anything keyed by them must be bounded.
|
|
887
|
+
const PROVIDER_WATCH_MAX_NAMESPACES = 4_096;
|
|
888
|
+
const PROVIDER_NAMESPACE_NAME_MAX_ENTRIES = 16_384;
|
|
889
|
+
// Per-channel ingress token buckets (proxy-publish/unicast) are kept for recent
|
|
890
|
+
// senders only: a few multiples of the child limit, with a small floor.
|
|
891
|
+
const INGRESS_BUCKETS_MIN_ENTRIES = 64;
|
|
892
|
+
const INGRESS_BUCKETS_PER_CHILD = 4;
|
|
873
893
|
|
|
874
894
|
const FANOUT_PROTOCOLS = ["/peerbit/fanout-tree/0.5.0"];
|
|
875
895
|
|
|
@@ -886,6 +906,7 @@ const DATA_WRITE_FAIL_KICK_COOLDOWN_MS = 2_000;
|
|
|
886
906
|
const DATA_WRITE_FAIL_KICK_MAX_PER_EVENT = 4;
|
|
887
907
|
const PARENT_REPAIR_DEAD_STREAK_THRESHOLD = 16;
|
|
888
908
|
const PARENT_REPAIR_DEAD_MIN_LIVENESS_MS = 15_000;
|
|
909
|
+
const PARENT_IDLE_PROBE_INTERVAL_MS = 5_000;
|
|
889
910
|
const REPAIR_RETRY_MIN_MS = 1_000;
|
|
890
911
|
const REPAIR_RETRY_INTERVAL_FACTOR = 5;
|
|
891
912
|
// A stream can remain locally readable/writable while silently dropping every
|
|
@@ -1162,7 +1183,10 @@ type ChannelState = {
|
|
|
1162
1183
|
number,
|
|
1163
1184
|
{ resolve(entries: TrackerCandidate[]): void }
|
|
1164
1185
|
>;
|
|
1165
|
-
pendingParentProbe: Map<
|
|
1186
|
+
pendingParentProbe: Map<
|
|
1187
|
+
number,
|
|
1188
|
+
{ peer: PeerStreams; resolve(reply?: ParentProbeReply): void }
|
|
1189
|
+
>;
|
|
1166
1190
|
parentUpgradeReservationsByHash: Map<
|
|
1167
1191
|
string,
|
|
1168
1192
|
{ token: number; expiresAt: number; minFreeSlots: number }
|
|
@@ -1300,6 +1324,7 @@ const createEmptyMetrics = (): InternalFanoutTreeChannelMetrics => ({
|
|
|
1300
1324
|
reparentUpgradeSkipProbeOverloaded: 0,
|
|
1301
1325
|
reparentUpgradeSkipProbeCooldown: 0,
|
|
1302
1326
|
parentProbeReqSent: 0,
|
|
1327
|
+
parentHealthProbeReqSent: 0,
|
|
1303
1328
|
parentProbeReqReceived: 0,
|
|
1304
1329
|
parentProbeReplySent: 0,
|
|
1305
1330
|
parentProbeReplyReceived: 0,
|
|
@@ -1358,13 +1383,27 @@ const isDataId = (id: Uint8Array) =>
|
|
|
1358
1383
|
id[2] === ID_PREFIX[2] &&
|
|
1359
1384
|
id[3] === ID_PREFIX[3];
|
|
1360
1385
|
|
|
1386
|
+
const isProviderControlKind = (kind: number) =>
|
|
1387
|
+
kind === MSG_PROVIDER_ANNOUNCE ||
|
|
1388
|
+
kind === MSG_PROVIDER_QUERY ||
|
|
1389
|
+
kind === MSG_PROVIDER_REPLY ||
|
|
1390
|
+
kind === MSG_PROVIDER_SUBSCRIBE ||
|
|
1391
|
+
kind === MSG_PROVIDER_UNSUBSCRIBE ||
|
|
1392
|
+
kind === MSG_PROVIDER_NOTIFY;
|
|
1393
|
+
|
|
1361
1394
|
export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
1362
1395
|
private channelsBySuffixKey = new Map<string, ChannelState>();
|
|
1363
1396
|
private readonly cachedSuffixKey = new WeakMap<Uint8Array, string>();
|
|
1397
|
+
// Metrics for keys without an open channel, in LRU order (oldest first) and
|
|
1398
|
+
// bounded by DETACHED_METRICS_MAX_KEYS. Open channels own `ch.metrics`.
|
|
1364
1399
|
private readonly metricsBySuffixKey = new Map<
|
|
1365
1400
|
string,
|
|
1366
1401
|
InternalFanoutTreeChannelMetrics
|
|
1367
1402
|
>();
|
|
1403
|
+
// Provider control frames are keyed per provider namespace (often per block),
|
|
1404
|
+
// which no channel metrics lookup can address, so they share one aggregate.
|
|
1405
|
+
private readonly providerControlMetrics: InternalFanoutTreeChannelMetrics =
|
|
1406
|
+
createEmptyMetrics();
|
|
1368
1407
|
private readonly joinTimeoutStreakByPeer = new Map<string, number>();
|
|
1369
1408
|
private readonly joinResetCooldownUntilByPeer = new Map<string, number>();
|
|
1370
1409
|
private bootstraps: Multiaddr[] = [];
|
|
@@ -1425,6 +1464,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
1425
1464
|
this.topicRootControlPlane =
|
|
1426
1465
|
topicRootControlPlane ?? new TopicRootControlPlane();
|
|
1427
1466
|
|
|
1467
|
+
this.registerUnderlayPeerDisconnectHandler();
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
private registerUnderlayPeerDisconnectHandler() {
|
|
1471
|
+
if (this.underlayPeerDisconnectHandler) return;
|
|
1428
1472
|
const onPeerDisconnect = (ev: any) => {
|
|
1429
1473
|
const peerId = ev?.detail;
|
|
1430
1474
|
if (!peerId) return;
|
|
@@ -1443,6 +1487,15 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
1443
1487
|
);
|
|
1444
1488
|
}
|
|
1445
1489
|
|
|
1490
|
+
public override async start() {
|
|
1491
|
+
await super.start();
|
|
1492
|
+
// stop() removes the listener, so a restarted instance must add it again
|
|
1493
|
+
// (but not while a stop that raced this start is still tearing down).
|
|
1494
|
+
if (this.started && !this.stopping) {
|
|
1495
|
+
this.registerUnderlayPeerDisconnectHandler();
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1446
1499
|
private createLoopCloseController(): AbortController {
|
|
1447
1500
|
const controller = new AbortController();
|
|
1448
1501
|
const stopSignal = this.closeController.signal;
|
|
@@ -1551,6 +1604,27 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
1551
1604
|
}
|
|
1552
1605
|
}
|
|
1553
1606
|
|
|
1607
|
+
private touchProviderWatchers(
|
|
1608
|
+
suffixKey: string,
|
|
1609
|
+
): Map<string, ProviderWatchRegistration> {
|
|
1610
|
+
// LRU touch
|
|
1611
|
+
const watchers =
|
|
1612
|
+
this.providerWatchersBySuffixKey.get(suffixKey) ??
|
|
1613
|
+
new Map<string, ProviderWatchRegistration>();
|
|
1614
|
+
this.providerWatchersBySuffixKey.delete(suffixKey);
|
|
1615
|
+
this.providerWatchersBySuffixKey.set(suffixKey, watchers);
|
|
1616
|
+
while (
|
|
1617
|
+
this.providerWatchersBySuffixKey.size > PROVIDER_WATCH_MAX_NAMESPACES
|
|
1618
|
+
) {
|
|
1619
|
+
const oldest = this.providerWatchersBySuffixKey.keys().next().value as
|
|
1620
|
+
| string
|
|
1621
|
+
| undefined;
|
|
1622
|
+
if (oldest === undefined) break;
|
|
1623
|
+
this.providerWatchersBySuffixKey.delete(oldest);
|
|
1624
|
+
}
|
|
1625
|
+
return watchers;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1554
1628
|
private pruneProviderWatchersIfEmpty(suffixKey: string) {
|
|
1555
1629
|
const watchers = this.providerWatchersBySuffixKey.get(suffixKey);
|
|
1556
1630
|
if (watchers && watchers.size === 0) {
|
|
@@ -1686,7 +1760,19 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
1686
1760
|
private getProviderNamespaceId(namespace: string): ProviderNamespaceId {
|
|
1687
1761
|
const key = sha256Sync(textEncoder.encode(`provider|${namespace}`));
|
|
1688
1762
|
const suffixKey = toBase64(key.subarray(0, 24));
|
|
1763
|
+
// LRU touch: every announced block adds a namespace, so keep this bounded.
|
|
1764
|
+
this.providerNamespaceBySuffixKey.delete(suffixKey);
|
|
1689
1765
|
this.providerNamespaceBySuffixKey.set(suffixKey, namespace);
|
|
1766
|
+
while (
|
|
1767
|
+
this.providerNamespaceBySuffixKey.size >
|
|
1768
|
+
PROVIDER_NAMESPACE_NAME_MAX_ENTRIES
|
|
1769
|
+
) {
|
|
1770
|
+
const oldest = this.providerNamespaceBySuffixKey.keys().next().value as
|
|
1771
|
+
| string
|
|
1772
|
+
| undefined;
|
|
1773
|
+
if (oldest === undefined) break;
|
|
1774
|
+
this.providerNamespaceBySuffixKey.delete(oldest);
|
|
1775
|
+
}
|
|
1690
1776
|
return { namespace, key, suffixKey };
|
|
1691
1777
|
}
|
|
1692
1778
|
|
|
@@ -2505,6 +2591,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
2505
2591
|
lastTrackerQueryAt: 0,
|
|
2506
2592
|
};
|
|
2507
2593
|
this.channelsBySuffixKey.set(id.suffixKey, ch);
|
|
2594
|
+
// The open channel now owns its metrics; take them out of the detached LRU
|
|
2595
|
+
// so they can never be evicted while the channel is open.
|
|
2596
|
+
this.metricsBySuffixKey.delete(id.suffixKey);
|
|
2508
2597
|
const needsAnnounceLoop = ch.effectiveMaxChildren > 0;
|
|
2509
2598
|
if (needsAnnounceLoop) {
|
|
2510
2599
|
ch.announceLoop = this._announceLoop(ch).catch(() => {});
|
|
@@ -2705,6 +2794,13 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
2705
2794
|
if (!peerHash) return;
|
|
2706
2795
|
this.joinTimeoutStreakByPeer.delete(peerHash);
|
|
2707
2796
|
|
|
2797
|
+
// A disconnected watcher can no longer receive provider notifications.
|
|
2798
|
+
for (const [suffixKey, watchers] of this.providerWatchersBySuffixKey) {
|
|
2799
|
+
if (watchers.delete(peerHash) && watchers.size === 0) {
|
|
2800
|
+
this.providerWatchersBySuffixKey.delete(suffixKey);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2708
2804
|
// Detach from a disconnected parent immediately, so children can rejoin.
|
|
2709
2805
|
// This is more reliable than polling `getConnections()` because the underlay
|
|
2710
2806
|
// can flap/reconnect faster than the join loop cadence.
|
|
@@ -2803,6 +2899,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
2803
2899
|
|
|
2804
2900
|
this.channelsBySuffixKey.delete(id.suffixKey);
|
|
2805
2901
|
this.trackerBySuffixKey.delete(id.suffixKey);
|
|
2902
|
+
// Keep the counters readable after close (and across a reopen), bounded.
|
|
2903
|
+
this.storeDetachedMetrics(id.suffixKey, ch.metrics);
|
|
2806
2904
|
|
|
2807
2905
|
await Promise.all(pendingSends);
|
|
2808
2906
|
}
|
|
@@ -3985,20 +4083,57 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
3985
4083
|
private getMetricsForSuffixKey(
|
|
3986
4084
|
suffixKey: string,
|
|
3987
4085
|
): InternalFanoutTreeChannelMetrics {
|
|
3988
|
-
|
|
3989
|
-
if (
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
}
|
|
4086
|
+
const ch = this.channelsBySuffixKey.get(suffixKey);
|
|
4087
|
+
if (ch) return ch.metrics;
|
|
4088
|
+
const m = this.metricsBySuffixKey.get(suffixKey) ?? createEmptyMetrics();
|
|
4089
|
+
this.storeDetachedMetrics(suffixKey, m);
|
|
3993
4090
|
return m;
|
|
3994
4091
|
}
|
|
3995
4092
|
|
|
3996
|
-
private
|
|
4093
|
+
private storeDetachedMetrics(
|
|
4094
|
+
suffixKey: string,
|
|
4095
|
+
metrics: InternalFanoutTreeChannelMetrics,
|
|
4096
|
+
) {
|
|
4097
|
+
// LRU touch
|
|
4098
|
+
this.metricsBySuffixKey.delete(suffixKey);
|
|
4099
|
+
this.metricsBySuffixKey.set(suffixKey, metrics);
|
|
4100
|
+
while (this.metricsBySuffixKey.size > DETACHED_METRICS_MAX_KEYS) {
|
|
4101
|
+
const oldest = this.metricsBySuffixKey.keys().next().value as
|
|
4102
|
+
| string
|
|
4103
|
+
| undefined;
|
|
4104
|
+
if (oldest === undefined) break;
|
|
4105
|
+
this.metricsBySuffixKey.delete(oldest);
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
private getControlMetrics(
|
|
4110
|
+
suffixKey: string,
|
|
4111
|
+
kind: number,
|
|
4112
|
+
): InternalFanoutTreeChannelMetrics {
|
|
4113
|
+
return isProviderControlKind(kind)
|
|
4114
|
+
? this.providerControlMetrics
|
|
4115
|
+
: this.getMetricsForSuffixKey(suffixKey);
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
/**
|
|
4119
|
+
* Returns aggregate diagnostic counters for provider control frames.
|
|
4120
|
+
*
|
|
4121
|
+
* @internal
|
|
4122
|
+
*/
|
|
4123
|
+
public getProviderControlMetrics(): FanoutTreeChannelMetrics {
|
|
4124
|
+
return this.providerControlMetrics;
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
private recordControlSend(
|
|
4128
|
+
bytes: Uint8Array,
|
|
4129
|
+
transmissions: number,
|
|
4130
|
+
probePurpose: "upgrade" | "health" = "upgrade",
|
|
4131
|
+
) {
|
|
3997
4132
|
if (transmissions <= 0) return;
|
|
3998
4133
|
if (bytes.length < 1 + 24) return;
|
|
3999
4134
|
const kind = bytes[0]!;
|
|
4000
4135
|
const suffixKey = toBase64(bytes.subarray(1, 25));
|
|
4001
|
-
const m = this.
|
|
4136
|
+
const m = this.getControlMetrics(suffixKey, kind);
|
|
4002
4137
|
m.controlSends += transmissions;
|
|
4003
4138
|
const sentBytes = bytes.byteLength * transmissions;
|
|
4004
4139
|
m.controlBytesSent += sentBytes;
|
|
@@ -4053,6 +4188,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
4053
4188
|
break;
|
|
4054
4189
|
case MSG_PARENT_PROBE_REQ:
|
|
4055
4190
|
m.parentProbeReqSent += transmissions;
|
|
4191
|
+
if (probePurpose === "health") {
|
|
4192
|
+
m.parentHealthProbeReqSent += transmissions;
|
|
4193
|
+
}
|
|
4056
4194
|
m.controlBytesSentJoin += sentBytes;
|
|
4057
4195
|
break;
|
|
4058
4196
|
case MSG_PARENT_PROBE_REPLY:
|
|
@@ -4077,7 +4215,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
4077
4215
|
kind: number,
|
|
4078
4216
|
bytesReceived: number,
|
|
4079
4217
|
) {
|
|
4080
|
-
const m = this.
|
|
4218
|
+
const m = this.getControlMetrics(suffixKey, kind);
|
|
4081
4219
|
m.controlReceives += 1;
|
|
4082
4220
|
m.controlBytesReceived += bytesReceived;
|
|
4083
4221
|
switch (kind) {
|
|
@@ -4205,13 +4343,14 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
4205
4343
|
to: string,
|
|
4206
4344
|
bytes: Uint8Array,
|
|
4207
4345
|
signal?: AbortSignal,
|
|
4346
|
+
probePurpose: "upgrade" | "health" = "upgrade",
|
|
4208
4347
|
) {
|
|
4209
4348
|
if (signal?.aborted) {
|
|
4210
4349
|
throw signal.reason ?? new AbortError("fanout control send aborted");
|
|
4211
4350
|
}
|
|
4212
4351
|
const stream = this.peers.get(to);
|
|
4213
4352
|
if (!stream) return;
|
|
4214
|
-
this.recordControlSend(bytes, 1);
|
|
4353
|
+
this.recordControlSend(bytes, 1, probePurpose);
|
|
4215
4354
|
const message = await this.createMessage(bytes, {
|
|
4216
4355
|
mode: new AnyWhere(),
|
|
4217
4356
|
priority: CONTROL_PRIORITY,
|
|
@@ -4298,17 +4437,35 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
4298
4437
|
tokens = Math.min(capacity, tokens + (elapsedMs * budgetBps) / 1_000);
|
|
4299
4438
|
}
|
|
4300
4439
|
if (tokens < costBytes) {
|
|
4301
|
-
|
|
4302
|
-
byPeer.delete(fromHash);
|
|
4303
|
-
byPeer.set(fromHash, { tokens, lastRefillAt: now });
|
|
4440
|
+
this.setIngressBucket(ch, byPeer, fromHash, { tokens, lastRefillAt: now });
|
|
4304
4441
|
return false;
|
|
4305
4442
|
}
|
|
4306
4443
|
tokens -= costBytes;
|
|
4307
|
-
|
|
4308
|
-
byPeer.set(fromHash, { tokens, lastRefillAt: now });
|
|
4444
|
+
this.setIngressBucket(ch, byPeer, fromHash, { tokens, lastRefillAt: now });
|
|
4309
4445
|
return true;
|
|
4310
4446
|
}
|
|
4311
4447
|
|
|
4448
|
+
private setIngressBucket(
|
|
4449
|
+
ch: ChannelState,
|
|
4450
|
+
byPeer: Map<string, { tokens: number; lastRefillAt: number }>,
|
|
4451
|
+
fromHash: string,
|
|
4452
|
+
bucket: { tokens: number; lastRefillAt: number },
|
|
4453
|
+
) {
|
|
4454
|
+
// LRU touch, capped: long-lived (pinned) roots see an open-ended set of
|
|
4455
|
+
// children over time, and buckets are not dropped when a child leaves.
|
|
4456
|
+
byPeer.delete(fromHash);
|
|
4457
|
+
byPeer.set(fromHash, bucket);
|
|
4458
|
+
const maxBuckets = Math.max(
|
|
4459
|
+
INGRESS_BUCKETS_MIN_ENTRIES,
|
|
4460
|
+
ch.maxChildren * INGRESS_BUCKETS_PER_CHILD,
|
|
4461
|
+
);
|
|
4462
|
+
while (byPeer.size > maxBuckets) {
|
|
4463
|
+
const oldest = byPeer.keys().next().value as string | undefined;
|
|
4464
|
+
if (oldest === undefined) break;
|
|
4465
|
+
byPeer.delete(oldest);
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4312
4469
|
private async _sendData(
|
|
4313
4470
|
ch: ChannelState,
|
|
4314
4471
|
to: string[],
|
|
@@ -5894,24 +6051,56 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
5894
6051
|
signal: AbortSignal,
|
|
5895
6052
|
minFreeSlots = 0,
|
|
5896
6053
|
reserveRootCapacity = true,
|
|
6054
|
+
purpose: "upgrade" | "health" = "upgrade",
|
|
5897
6055
|
): Promise<ParentProbeReply | undefined> {
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
const
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
);
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
6056
|
+
const peer = this.peers.get(parentHash);
|
|
6057
|
+
if (!peer) return;
|
|
6058
|
+
const controller = new AbortController();
|
|
6059
|
+
let reqId = (this.random() * 0xffffffff) >>> 0;
|
|
6060
|
+
while (ch.pendingParentProbe.has(reqId)) reqId = (reqId + 1) >>> 0;
|
|
6061
|
+
let pending:
|
|
6062
|
+
| { peer: PeerStreams; resolve(reply?: ParentProbeReply): void }
|
|
6063
|
+
| undefined;
|
|
6064
|
+
let onAbort!: () => void;
|
|
6065
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
6066
|
+
try {
|
|
6067
|
+
return await new Promise<ParentProbeReply | undefined>((resolve, reject) => {
|
|
6068
|
+
pending = { peer, resolve };
|
|
6069
|
+
ch.pendingParentProbe.set(reqId, pending);
|
|
6070
|
+
onAbort = () => {
|
|
6071
|
+
const reason = signal.reason ?? new AbortError("fanout probe aborted");
|
|
6072
|
+
controller.abort(reason);
|
|
6073
|
+
reject(reason);
|
|
6074
|
+
};
|
|
6075
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
6076
|
+
if (signal.aborted) return onAbort();
|
|
6077
|
+
// Bound signing/queueing as well as the reply; a stuck send must not
|
|
6078
|
+
// prevent the join loop from recovering or closing the channel.
|
|
6079
|
+
timer = setTimeout(() => {
|
|
6080
|
+
controller.abort(new AbortError("fanout probe timed out"));
|
|
6081
|
+
resolve(undefined);
|
|
6082
|
+
}, Math.max(1, timeoutMs));
|
|
6083
|
+
timer.unref?.();
|
|
6084
|
+
void this._sendControl(
|
|
6085
|
+
parentHash,
|
|
6086
|
+
this.codec.encodeParentProbeReq(
|
|
6087
|
+
ch.id.key,
|
|
6088
|
+
reqId,
|
|
6089
|
+
minFreeSlots,
|
|
6090
|
+
reserveRootCapacity,
|
|
6091
|
+
),
|
|
6092
|
+
controller.signal,
|
|
6093
|
+
purpose,
|
|
6094
|
+
).catch(() => resolve(undefined));
|
|
6095
|
+
});
|
|
6096
|
+
} finally {
|
|
6097
|
+
clearTimeout(timer);
|
|
6098
|
+
signal.removeEventListener("abort", onAbort);
|
|
6099
|
+
if (ch.pendingParentProbe.get(reqId) === pending) {
|
|
6100
|
+
ch.pendingParentProbe.delete(reqId);
|
|
6101
|
+
}
|
|
6102
|
+
controller.abort(new AbortError("fanout probe settled"));
|
|
6103
|
+
}
|
|
5915
6104
|
}
|
|
5916
6105
|
|
|
5917
6106
|
private async _joinLoop(
|
|
@@ -6020,6 +6209,10 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
6020
6209
|
if (initialJoinRemainingMs() === 0) throwInitialJoinTimeout();
|
|
6021
6210
|
};
|
|
6022
6211
|
let nextParentUpgradeCheckAt = 0;
|
|
6212
|
+
let nextParentHealthProbeAt = 0;
|
|
6213
|
+
let failedParentProbe:
|
|
6214
|
+
| { peer: PeerStreams; route: ChannelState["routeFromRoot"] }
|
|
6215
|
+
| undefined;
|
|
6023
6216
|
let parentUpgradeCheckSeq = 0;
|
|
6024
6217
|
let parentUpgradeActiveGuardBackoffMs = 0;
|
|
6025
6218
|
const unsuccessfulColdBootstrapPeers = new Set<string>();
|
|
@@ -6110,8 +6303,10 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
6110
6303
|
|
|
6111
6304
|
// Parent disappeared? Rejoin.
|
|
6112
6305
|
if (ch.parent) {
|
|
6113
|
-
const
|
|
6306
|
+
const parentHash = ch.parent;
|
|
6307
|
+
const parentPeer = this.peers.get(parentHash);
|
|
6114
6308
|
let connected = false;
|
|
6309
|
+
let probeFailed = false;
|
|
6115
6310
|
if (parentPeer) {
|
|
6116
6311
|
try {
|
|
6117
6312
|
const conns = this.components.connectionManager.getConnections(
|
|
@@ -6122,8 +6317,61 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
6122
6317
|
connected = parentPeer.isReadable || parentPeer.isWritable;
|
|
6123
6318
|
}
|
|
6124
6319
|
}
|
|
6320
|
+
const now = Date.now();
|
|
6321
|
+
if (now - ch.lastParentDataAt < PARENT_IDLE_PROBE_INTERVAL_MS) {
|
|
6322
|
+
failedParentProbe = undefined;
|
|
6323
|
+
} else if (
|
|
6324
|
+
connected &&
|
|
6325
|
+
parentPeer &&
|
|
6326
|
+
now >= nextParentHealthProbeAt
|
|
6327
|
+
) {
|
|
6328
|
+
const route = ch.routeFromRoot;
|
|
6329
|
+
const lastDataAt = ch.lastParentDataAt;
|
|
6330
|
+
const reply = await this.probeParentCandidate(
|
|
6331
|
+
ch,
|
|
6332
|
+
parentHash,
|
|
6333
|
+
parentUpgrade.probe.timeoutMs,
|
|
6334
|
+
signal,
|
|
6335
|
+
0,
|
|
6336
|
+
false,
|
|
6337
|
+
"health",
|
|
6338
|
+
);
|
|
6339
|
+
if (
|
|
6340
|
+
ch.closed ||
|
|
6341
|
+
signal.aborted ||
|
|
6342
|
+
this.channelsBySuffixKey.get(ch.id.suffixKey) !== ch ||
|
|
6343
|
+
ch.parent !== parentHash ||
|
|
6344
|
+
ch.routeFromRoot !== route ||
|
|
6345
|
+
this.peers.get(parentHash) !== parentPeer
|
|
6346
|
+
) {
|
|
6347
|
+
failedParentProbe = undefined;
|
|
6348
|
+
continue;
|
|
6349
|
+
}
|
|
6350
|
+
nextParentHealthProbeAt = Date.now() + PARENT_IDLE_PROBE_INTERVAL_MS;
|
|
6351
|
+
// A full parent is still healthy. Do not reserve capacity or
|
|
6352
|
+
// refresh lastParentDataAt: probes must not mask stalled data.
|
|
6353
|
+
if (reply || ch.lastParentDataAt !== lastDataAt) {
|
|
6354
|
+
failedParentProbe = undefined;
|
|
6355
|
+
} else if (
|
|
6356
|
+
failedParentProbe?.peer === parentPeer &&
|
|
6357
|
+
failedParentProbe.route === route
|
|
6358
|
+
) {
|
|
6359
|
+
probeFailed = true;
|
|
6360
|
+
connected = false;
|
|
6361
|
+
cooldownUntilByHash.set(
|
|
6362
|
+
parentHash,
|
|
6363
|
+
Date.now() + candidateCooldownMs,
|
|
6364
|
+
);
|
|
6365
|
+
} else {
|
|
6366
|
+
failedParentProbe = { peer: parentPeer, route };
|
|
6367
|
+
nextParentHealthProbeAt = Date.now() + Math.max(retryMs, 1_000);
|
|
6368
|
+
}
|
|
6369
|
+
}
|
|
6125
6370
|
if (!connected) {
|
|
6126
|
-
ch.metrics.
|
|
6371
|
+
if (probeFailed) ch.metrics.reparentStale += 1;
|
|
6372
|
+
else ch.metrics.reparentDisconnect += 1;
|
|
6373
|
+
failedParentProbe = undefined;
|
|
6374
|
+
nextParentHealthProbeAt = 0;
|
|
6127
6375
|
const hadChildren = ch.children.size > 0;
|
|
6128
6376
|
this.detachFromParent(ch);
|
|
6129
6377
|
nextParentUpgradeCheckAt = 0;
|
|
@@ -8424,11 +8672,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
8424
8672
|
const want = Math.max(1, decoded.want);
|
|
8425
8673
|
const ttlMs = Math.min(120_000, Math.max(1_000, decoded.ttlMs));
|
|
8426
8674
|
const now = Date.now();
|
|
8427
|
-
|
|
8428
|
-
if (!watchers) {
|
|
8429
|
-
watchers = new Map<string, ProviderWatchRegistration>();
|
|
8430
|
-
this.providerWatchersBySuffixKey.set(suffixKey, watchers);
|
|
8431
|
-
}
|
|
8675
|
+
const watchers = this.touchProviderWatchers(suffixKey);
|
|
8432
8676
|
watchers.set(fromHash, {
|
|
8433
8677
|
hash: fromHash,
|
|
8434
8678
|
want,
|
|
@@ -8566,7 +8810,14 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
8566
8810
|
if (!decoded) return false;
|
|
8567
8811
|
const { reqId, ...reply } = decoded;
|
|
8568
8812
|
const pending = ch.pendingParentProbe.get(reqId);
|
|
8569
|
-
if (
|
|
8813
|
+
if (
|
|
8814
|
+
!pending ||
|
|
8815
|
+
pending.peer !== peerStream ||
|
|
8816
|
+
pending.peer.publicKey.hashcode() !== fromHash ||
|
|
8817
|
+
this.peers.get(fromHash) !== peerStream
|
|
8818
|
+
) {
|
|
8819
|
+
return true;
|
|
8820
|
+
}
|
|
8570
8821
|
ch.pendingParentProbe.delete(reqId);
|
|
8571
8822
|
pending.resolve(reply);
|
|
8572
8823
|
this.touchPeerHint(ch, fromHash);
|
|
@@ -8851,7 +9102,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
8851
9102
|
void this.kickChildHashes(ch, [worstChild]).catch(() => {});
|
|
8852
9103
|
}
|
|
8853
9104
|
|
|
8854
|
-
|
|
9105
|
+
const child = { bidPerByte };
|
|
9106
|
+
ch.children.set(fromHash, child);
|
|
8855
9107
|
if (ch.isRoot && consumedParentUpgradeReservation) {
|
|
8856
9108
|
ch.parentUpgradeTrackerNoCapacityUntil = Math.max(
|
|
8857
9109
|
ch.parentUpgradeTrackerNoCapacityUntil,
|
|
@@ -8868,6 +9120,12 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
8868
9120
|
);
|
|
8869
9121
|
void (async () => {
|
|
8870
9122
|
await this._sendControl(fromHash, joinAccept);
|
|
9123
|
+
if (ch.closed || ch.children.get(fromHash) !== child) return;
|
|
9124
|
+
this.dispatchEvent(
|
|
9125
|
+
new CustomEvent("fanout:child-joined", {
|
|
9126
|
+
detail: { topic: ch.id.topic, root: ch.id.root, child: fromHash },
|
|
9127
|
+
}),
|
|
9128
|
+
);
|
|
8871
9129
|
if (ch.endSeqExclusive > 0) {
|
|
8872
9130
|
await this._sendControl(
|
|
8873
9131
|
fromHash,
|
|
@@ -9311,6 +9569,21 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
9311
9569
|
}
|
|
9312
9570
|
|
|
9313
9571
|
if (kind === MSG_KICK) {
|
|
9572
|
+
if (this.peers.get(fromHash) !== peerStream) return true;
|
|
9573
|
+
if (fromHash !== ch.parent) {
|
|
9574
|
+
if (ch.parentUpgradeGrace?.previousParent === fromHash) {
|
|
9575
|
+
// This attachment was evicted; never roll back to it.
|
|
9576
|
+
this.clearParentUpgradeGrace(ch);
|
|
9577
|
+
}
|
|
9578
|
+
if (ch.parentShadow?.hash === fromHash) {
|
|
9579
|
+
ch.parentShadow = undefined;
|
|
9580
|
+
ch.metrics.parentShadowReset += 1;
|
|
9581
|
+
if (this.parentUpgradeShadowInFlightSuffixKey === ch.id.suffixKey) {
|
|
9582
|
+
this.parentUpgradeShadowInFlightSuffixKey = undefined;
|
|
9583
|
+
}
|
|
9584
|
+
}
|
|
9585
|
+
return true;
|
|
9586
|
+
}
|
|
9314
9587
|
ch.metrics.reparentKicked += 1;
|
|
9315
9588
|
ch.parent = undefined;
|
|
9316
9589
|
ch.level = Number.POSITIVE_INFINITY;
|