@peerbit/pubsub 5.3.0 → 5.3.2
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 +4 -0
- package/dist/src/fanout-tree.d.ts.map +1 -1
- package/dist/src/fanout-tree.js +66 -7
- package/dist/src/fanout-tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +23 -4
- package/dist/src/index.js.map +1 -1
- package/package.json +9 -12
- package/src/fanout-tree.ts +75 -10
- package/src/index.ts +33 -3
package/src/fanout-tree.ts
CHANGED
|
@@ -727,6 +727,7 @@ export type FanoutTreeChannelMetrics = {
|
|
|
727
727
|
joinAcceptReceived: number;
|
|
728
728
|
joinRejectSent: number;
|
|
729
729
|
joinRejectReceived: number;
|
|
730
|
+
joinPeerResets: number;
|
|
730
731
|
kickSent: number;
|
|
731
732
|
kickReceived: number;
|
|
732
733
|
reparentDisconnect: number;
|
|
@@ -861,6 +862,11 @@ const PARENT_REPAIR_DEAD_STREAK_THRESHOLD = 16;
|
|
|
861
862
|
const PARENT_REPAIR_DEAD_MIN_LIVENESS_MS = 15_000;
|
|
862
863
|
const REPAIR_RETRY_MIN_MS = 1_000;
|
|
863
864
|
const REPAIR_RETRY_INTERVAL_FACTOR = 5;
|
|
865
|
+
// A stream can remain locally readable/writable while silently dropping every
|
|
866
|
+
// control frame. Repeated unanswered JOIN requests are end-to-end evidence that
|
|
867
|
+
// the peer path needs to be rebuilt.
|
|
868
|
+
const JOIN_TIMEOUT_RESET_STREAK_THRESHOLD = 3;
|
|
869
|
+
const JOIN_TIMEOUT_RESET_COOLDOWN_MS = 10_000;
|
|
864
870
|
|
|
865
871
|
const JOIN_REJECT_REDIRECT_QUEUE_MAX = 64;
|
|
866
872
|
// When a relay loses its parent, pause before trying to rejoin so its children can
|
|
@@ -1226,6 +1232,7 @@ const createEmptyMetrics = (): FanoutTreeChannelMetrics => ({
|
|
|
1226
1232
|
joinAcceptReceived: 0,
|
|
1227
1233
|
joinRejectSent: 0,
|
|
1228
1234
|
joinRejectReceived: 0,
|
|
1235
|
+
joinPeerResets: 0,
|
|
1229
1236
|
kickSent: 0,
|
|
1230
1237
|
kickReceived: 0,
|
|
1231
1238
|
reparentDisconnect: 0,
|
|
@@ -1314,6 +1321,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
1314
1321
|
string,
|
|
1315
1322
|
FanoutTreeChannelMetrics
|
|
1316
1323
|
>();
|
|
1324
|
+
private readonly joinTimeoutStreakByPeer = new Map<string, number>();
|
|
1325
|
+
private readonly joinResetCooldownUntilByPeer = new Map<string, number>();
|
|
1317
1326
|
private bootstraps: Multiaddr[] = [];
|
|
1318
1327
|
private trackerBySuffixKey = new Map<string, Map<string, TrackerEntry>>();
|
|
1319
1328
|
private trackerNamespaceLru = new Map<string, number>();
|
|
@@ -1398,6 +1407,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
1398
1407
|
);
|
|
1399
1408
|
this.underlayPeerDisconnectHandler = undefined;
|
|
1400
1409
|
}
|
|
1410
|
+
this.joinTimeoutStreakByPeer.clear();
|
|
1411
|
+
this.joinResetCooldownUntilByPeer.clear();
|
|
1401
1412
|
return super.stop();
|
|
1402
1413
|
}
|
|
1403
1414
|
|
|
@@ -2563,6 +2574,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
2563
2574
|
|
|
2564
2575
|
private onPeerDisconnectedFromUnderlay(peerHash: string) {
|
|
2565
2576
|
if (!peerHash) return;
|
|
2577
|
+
this.joinTimeoutStreakByPeer.delete(peerHash);
|
|
2566
2578
|
|
|
2567
2579
|
// Detach from a disconnected parent immediately, so children can rejoin.
|
|
2568
2580
|
// This is more reliable than polling `getConnections()` because the underlay
|
|
@@ -2796,11 +2808,21 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
2796
2808
|
|
|
2797
2809
|
if (!ch.joinedOnce) ch.joinedOnce = createDeferred();
|
|
2798
2810
|
if (!ch.joinLoop) {
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2811
|
+
const joinedOnce = ch.joinedOnce;
|
|
2812
|
+
const joinLoop = this._joinLoop(ch, joinOpts)
|
|
2813
|
+
.catch((err) => {
|
|
2814
|
+
// Surface join errors to the caller without crashing the process
|
|
2815
|
+
// via an unhandled rejection (joinLoop is not generally awaited).
|
|
2816
|
+
if (ch.joinLoop === joinLoop) ch.joinLoop = undefined;
|
|
2817
|
+
if (ch.joinedOnce === joinedOnce && !ch.joinedAtLeastOnce) {
|
|
2818
|
+
ch.joinedOnce = undefined;
|
|
2819
|
+
}
|
|
2820
|
+
joinedOnce.reject(err);
|
|
2821
|
+
})
|
|
2822
|
+
.finally(() => {
|
|
2823
|
+
if (ch.joinLoop === joinLoop) ch.joinLoop = undefined;
|
|
2824
|
+
});
|
|
2825
|
+
ch.joinLoop = joinLoop;
|
|
2804
2826
|
}
|
|
2805
2827
|
return ch.joinedOnce.promise;
|
|
2806
2828
|
}
|
|
@@ -4011,6 +4033,42 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
4011
4033
|
return ch.cachePayloads[idx];
|
|
4012
4034
|
}
|
|
4013
4035
|
|
|
4036
|
+
private noteJoinResponse(peerHash: string) {
|
|
4037
|
+
this.joinTimeoutStreakByPeer.delete(peerHash);
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
private noteJoinTimeout(ch: ChannelState, peerHash: string) {
|
|
4041
|
+
const streak = (this.joinTimeoutStreakByPeer.get(peerHash) ?? 0) + 1;
|
|
4042
|
+
if (streak < JOIN_TIMEOUT_RESET_STREAK_THRESHOLD) {
|
|
4043
|
+
this.joinTimeoutStreakByPeer.set(peerHash, streak);
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
this.joinTimeoutStreakByPeer.delete(peerHash);
|
|
4048
|
+
const now = Date.now();
|
|
4049
|
+
for (const [hash, until] of this.joinResetCooldownUntilByPeer) {
|
|
4050
|
+
if (until <= now) this.joinResetCooldownUntilByPeer.delete(hash);
|
|
4051
|
+
}
|
|
4052
|
+
const resetCooldownUntil =
|
|
4053
|
+
this.joinResetCooldownUntilByPeer.get(peerHash) ?? 0;
|
|
4054
|
+
if (resetCooldownUntil > now) return;
|
|
4055
|
+
|
|
4056
|
+
const stream = this.peers.get(peerHash);
|
|
4057
|
+
if (!stream) return;
|
|
4058
|
+
this.joinResetCooldownUntilByPeer.set(
|
|
4059
|
+
peerHash,
|
|
4060
|
+
now + JOIN_TIMEOUT_RESET_COOLDOWN_MS,
|
|
4061
|
+
);
|
|
4062
|
+
ch.metrics.joinPeerResets += 1;
|
|
4063
|
+
try {
|
|
4064
|
+
void this.components.connectionManager
|
|
4065
|
+
.closeConnections(stream.peerId)
|
|
4066
|
+
.catch(() => {});
|
|
4067
|
+
} catch {
|
|
4068
|
+
// Best-effort reset. The join loop keeps retrying other candidates.
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4014
4072
|
private async _sendControl(to: string, bytes: Uint8Array) {
|
|
4015
4073
|
const stream = this.peers.get(to);
|
|
4016
4074
|
if (!stream) return;
|
|
@@ -4023,17 +4081,17 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
4023
4081
|
}
|
|
4024
4082
|
|
|
4025
4083
|
private async _sendControlMany(to: string[], bytes: Uint8Array) {
|
|
4026
|
-
if (to.length === 0) return;
|
|
4084
|
+
if (to.length === 0) return true;
|
|
4027
4085
|
const streams = to
|
|
4028
4086
|
.map((t) => this.peers.get(t))
|
|
4029
4087
|
.filter((s): s is PeerStreams => Boolean(s));
|
|
4030
|
-
if (streams.length === 0) return;
|
|
4088
|
+
if (streams.length === 0) return false;
|
|
4031
4089
|
this.recordControlSend(bytes, streams.length);
|
|
4032
4090
|
const message = await this.createMessage(bytes, {
|
|
4033
4091
|
mode: new AnyWhere(),
|
|
4034
4092
|
priority: CONTROL_PRIORITY,
|
|
4035
4093
|
} as any);
|
|
4036
|
-
|
|
4094
|
+
return this.publishMessageMaybe(this.publicKey, message, streams);
|
|
4037
4095
|
}
|
|
4038
4096
|
|
|
4039
4097
|
private refillUploadTokens(ch: ChannelState, now = Date.now()) {
|
|
@@ -5904,7 +5962,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
5904
5962
|
? " No fanout bootstraps are configured for this channel, and the root is not a direct neighbor. If this peer reached the network via a bootstrap or relay node, initialize it with Peerbit.bootstrap(...) instead of Peerbit.dial(...), or configure FanoutTree.setBootstraps(...) before joining sharded topics."
|
|
5905
5963
|
: "";
|
|
5906
5964
|
throw new Error(
|
|
5907
|
-
`fanout join timed out after ${timeoutMs}ms (topic=${ch.id.topic} root=${ch.id.root} self=${this.publicKeyHash} rootNeighbor=${rootNeighbor} peers=${this.peers.size} bootstraps=${bootstrapsCount}).${bootstrapHint}`,
|
|
5965
|
+
`fanout join timed out after ${timeoutMs}ms (topic=${ch.id.topic} root=${ch.id.root} self=${this.publicKeyHash} rootNeighbor=${rootNeighbor} peers=${this.peers.size} bootstraps=${bootstrapsCount} joinReqSent=${ch.metrics.joinReqSent} joinAcceptReceived=${ch.metrics.joinAcceptReceived} joinRejectReceived=${ch.metrics.joinRejectReceived} peerResets=${ch.metrics.joinPeerResets}).${bootstrapHint}`,
|
|
5908
5966
|
);
|
|
5909
5967
|
}
|
|
5910
5968
|
|
|
@@ -6290,6 +6348,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
6290
6348
|
}
|
|
6291
6349
|
|
|
6292
6350
|
if (res.timedOut) {
|
|
6351
|
+
this.noteJoinTimeout(ch, c.hash);
|
|
6293
6352
|
try {
|
|
6294
6353
|
await this.sendTrackerFeedback(
|
|
6295
6354
|
ch,
|
|
@@ -7544,7 +7603,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
7544
7603
|
const resetPeerConnections = options?.resetPeerConnections === true;
|
|
7545
7604
|
let kickFailed = false;
|
|
7546
7605
|
try {
|
|
7547
|
-
await this._sendControlMany(
|
|
7606
|
+
const kickDelivered = await this._sendControlMany(
|
|
7607
|
+
unique,
|
|
7608
|
+
this.codec.encodeKick(ch.id.key),
|
|
7609
|
+
);
|
|
7610
|
+
kickFailed = !kickDelivered;
|
|
7548
7611
|
} catch (error) {
|
|
7549
7612
|
kickFailed = true;
|
|
7550
7613
|
throw error;
|
|
@@ -8379,6 +8442,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
|
|
|
8379
8442
|
if (kind === MSG_JOIN_ACCEPT || kind === MSG_JOIN_REJECT) {
|
|
8380
8443
|
const reqId = this.codec.decodeJoinResponseReqId(data);
|
|
8381
8444
|
if (reqId == null) return false;
|
|
8445
|
+
// Any explicit join response proves this path is alive end to end.
|
|
8446
|
+
this.noteJoinResponse(fromHash);
|
|
8382
8447
|
const pending = ch.pendingJoin.get(reqId);
|
|
8383
8448
|
if (!pending) return true;
|
|
8384
8449
|
ch.pendingJoin.delete(reqId);
|
package/src/index.ts
CHANGED
|
@@ -138,6 +138,7 @@ const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
|
|
|
138
138
|
// Topic-root queries may need to wait for the responder to finish opening an
|
|
139
139
|
// outbound stream back to the requester after an inbound-only dial.
|
|
140
140
|
const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
|
|
141
|
+
const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
|
|
141
142
|
|
|
142
143
|
const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS: Omit<
|
|
143
144
|
FanoutTreeChannelOptions,
|
|
@@ -1217,6 +1218,7 @@ export class TopicControlPlane
|
|
|
1217
1218
|
private async queryTopicRootFromPeer(
|
|
1218
1219
|
peer: PeerStreams,
|
|
1219
1220
|
topic: string,
|
|
1221
|
+
timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
|
|
1220
1222
|
): Promise<string | undefined> {
|
|
1221
1223
|
if (!this.started || this.stopping) return undefined;
|
|
1222
1224
|
|
|
@@ -1225,7 +1227,7 @@ export class TopicControlPlane
|
|
|
1225
1227
|
const timer = setTimeout(() => {
|
|
1226
1228
|
this.pendingTopicRootQueries.delete(requestId);
|
|
1227
1229
|
resolve(undefined);
|
|
1228
|
-
},
|
|
1230
|
+
}, Math.max(1, Math.floor(timeoutMs)));
|
|
1229
1231
|
timer.unref?.();
|
|
1230
1232
|
this.pendingTopicRootQueries.set(requestId, { topic, resolve, timer });
|
|
1231
1233
|
});
|
|
@@ -1247,6 +1249,33 @@ export class TopicControlPlane
|
|
|
1247
1249
|
return responsePromise;
|
|
1248
1250
|
}
|
|
1249
1251
|
|
|
1252
|
+
private async confirmDirectShardRoot(
|
|
1253
|
+
shardTopic: string,
|
|
1254
|
+
root: string,
|
|
1255
|
+
signal?: AbortSignal,
|
|
1256
|
+
): Promise<string> {
|
|
1257
|
+
if (root === this.publicKeyHash) return root;
|
|
1258
|
+
const rootPeer = this.peers.get(root);
|
|
1259
|
+
if (!rootPeer) return root;
|
|
1260
|
+
|
|
1261
|
+
const confirmation = await withAbort(
|
|
1262
|
+
this.queryTopicRootFromPeer(
|
|
1263
|
+
rootPeer,
|
|
1264
|
+
shardTopic,
|
|
1265
|
+
DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS,
|
|
1266
|
+
),
|
|
1267
|
+
signal,
|
|
1268
|
+
);
|
|
1269
|
+
if (!confirmation) return root;
|
|
1270
|
+
if (confirmation !== root) {
|
|
1271
|
+
this.shardRootCache.set(shardTopic, {
|
|
1272
|
+
root: confirmation,
|
|
1273
|
+
authoritative: true,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
return confirmation;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1250
1279
|
private async resolveQueryableTopicRoot(
|
|
1251
1280
|
topic: string,
|
|
1252
1281
|
): Promise<string | undefined> {
|
|
@@ -1333,6 +1362,7 @@ export class TopicControlPlane
|
|
|
1333
1362
|
}
|
|
1334
1363
|
|
|
1335
1364
|
root = root ?? (await this.resolveShardRoot(t));
|
|
1365
|
+
root = await this.confirmDirectShardRoot(t, root, options?.signal);
|
|
1336
1366
|
const channel = new FanoutChannel(this.fanout, { topic: t, root });
|
|
1337
1367
|
|
|
1338
1368
|
const onPayload = (payload: Uint8Array) => {
|
|
@@ -1465,9 +1495,9 @@ export class TopicControlPlane
|
|
|
1465
1495
|
// ignore
|
|
1466
1496
|
}
|
|
1467
1497
|
try {
|
|
1468
|
-
channel.
|
|
1498
|
+
await channel.leave({ notifyParent: false, kickChildren: false });
|
|
1469
1499
|
} catch {
|
|
1470
|
-
|
|
1500
|
+
channel.close();
|
|
1471
1501
|
}
|
|
1472
1502
|
throw error;
|
|
1473
1503
|
}
|