@peerbit/pubsub 4.1.3 → 4.1.4-000e3f1
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/README.md +23 -20
- package/dist/benchmark/fanout-tree-sim-lib.d.ts +201 -0
- package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +1 -0
- package/dist/benchmark/fanout-tree-sim-lib.js +1225 -0
- package/dist/benchmark/fanout-tree-sim-lib.js.map +1 -0
- package/dist/benchmark/fanout-tree-sim.d.ts +11 -0
- package/dist/benchmark/fanout-tree-sim.d.ts.map +1 -0
- package/dist/benchmark/fanout-tree-sim.js +521 -0
- package/dist/benchmark/fanout-tree-sim.js.map +1 -0
- package/dist/benchmark/index.d.ts +6 -0
- package/dist/benchmark/index.d.ts.map +1 -1
- package/dist/benchmark/index.js +38 -80
- package/dist/benchmark/index.js.map +1 -1
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts +82 -0
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +1 -0
- package/dist/benchmark/pubsub-topic-sim-lib.js +625 -0
- package/dist/benchmark/pubsub-topic-sim-lib.js.map +1 -0
- package/dist/benchmark/pubsub-topic-sim.d.ts +9 -0
- package/dist/benchmark/pubsub-topic-sim.d.ts.map +1 -0
- package/dist/benchmark/pubsub-topic-sim.js +116 -0
- package/dist/benchmark/pubsub-topic-sim.js.map +1 -0
- package/dist/benchmark/sim/bench-utils.d.ts +25 -0
- package/dist/benchmark/sim/bench-utils.d.ts.map +1 -0
- package/dist/benchmark/sim/bench-utils.js +141 -0
- package/dist/benchmark/sim/bench-utils.js.map +1 -0
- package/dist/src/fanout-channel.d.ts +62 -0
- package/dist/src/fanout-channel.d.ts.map +1 -0
- package/dist/src/fanout-channel.js +114 -0
- package/dist/src/fanout-channel.js.map +1 -0
- package/dist/src/fanout-tree.d.ts +550 -0
- package/dist/src/fanout-tree.d.ts.map +1 -0
- package/dist/src/fanout-tree.js +4943 -0
- package/dist/src/fanout-tree.js.map +1 -0
- package/dist/src/index.d.ts +161 -39
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1380 -462
- package/dist/src/index.js.map +1 -1
- package/dist/src/topic-root-control-plane.d.ts +43 -0
- package/dist/src/topic-root-control-plane.d.ts.map +1 -0
- package/dist/src/topic-root-control-plane.js +120 -0
- package/dist/src/topic-root-control-plane.js.map +1 -0
- package/package.json +11 -10
- package/src/fanout-channel.ts +150 -0
- package/src/fanout-tree.ts +6297 -0
- package/src/index.ts +1691 -647
- package/src/topic-root-control-plane.ts +160 -0
package/src/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type Connection,
|
|
3
|
+
type PeerId as Libp2pPeerId,
|
|
4
|
+
} from "@libp2p/interface";
|
|
2
5
|
import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
|
|
3
6
|
import { logger as loggerFn } from "@peerbit/logger";
|
|
4
7
|
import {
|
|
@@ -12,6 +15,7 @@ import {
|
|
|
12
15
|
Subscribe,
|
|
13
16
|
SubscriptionData,
|
|
14
17
|
SubscriptionEvent,
|
|
18
|
+
TopicRootCandidates,
|
|
15
19
|
UnsubcriptionEvent,
|
|
16
20
|
Unsubscribe,
|
|
17
21
|
} from "@peerbit/pubsub-interface";
|
|
@@ -23,6 +27,7 @@ import {
|
|
|
23
27
|
dontThrowIfDeliveryError,
|
|
24
28
|
} from "@peerbit/stream";
|
|
25
29
|
import {
|
|
30
|
+
AcknowledgeAnyWhere,
|
|
26
31
|
AcknowledgeDelivery,
|
|
27
32
|
AnyWhere,
|
|
28
33
|
DataMessage,
|
|
@@ -31,9 +36,10 @@ import {
|
|
|
31
36
|
MessageHeader,
|
|
32
37
|
NotStartedError,
|
|
33
38
|
type PriorityOptions,
|
|
34
|
-
SeekDelivery,
|
|
35
39
|
SilentDelivery,
|
|
40
|
+
type WithExtraSigners,
|
|
36
41
|
deliveryModeHasReceiver,
|
|
42
|
+
getMsgId,
|
|
37
43
|
} from "@peerbit/stream-interface";
|
|
38
44
|
import { AbortError, TimeoutError } from "@peerbit/time";
|
|
39
45
|
import { Uint8ArrayList } from "uint8arraylist";
|
|
@@ -41,11 +47,23 @@ import {
|
|
|
41
47
|
type DebouncedAccumulatorCounterMap,
|
|
42
48
|
debouncedAccumulatorSetCounter,
|
|
43
49
|
} from "./debounced-set.js";
|
|
50
|
+
import { FanoutChannel } from "./fanout-channel.js";
|
|
51
|
+
import type {
|
|
52
|
+
FanoutTree,
|
|
53
|
+
FanoutTreeChannelOptions,
|
|
54
|
+
FanoutTreeDataEvent,
|
|
55
|
+
FanoutTreeJoinOptions,
|
|
56
|
+
} from "./fanout-tree.js";
|
|
57
|
+
import { TopicRootControlPlane } from "./topic-root-control-plane.js";
|
|
58
|
+
|
|
59
|
+
export * from "./fanout-tree.js";
|
|
60
|
+
export * from "./fanout-channel.js";
|
|
61
|
+
export * from "./topic-root-control-plane.js";
|
|
44
62
|
|
|
45
63
|
export const toUint8Array = (arr: Uint8ArrayList | Uint8Array) =>
|
|
46
64
|
arr instanceof Uint8ArrayList ? arr.subarray() : arr;
|
|
47
65
|
|
|
48
|
-
export const logger = loggerFn("peerbit:transport:
|
|
66
|
+
export const logger = loggerFn("peerbit:transport:topic-control-plane");
|
|
49
67
|
const warn = logger.newScope("warn");
|
|
50
68
|
const logError = (e?: { message: string }) => {
|
|
51
69
|
logger.error(e?.message);
|
|
@@ -54,780 +72,1708 @@ const logErrorIfStarted = (e?: { message: string }) => {
|
|
|
54
72
|
e instanceof NotStartedError === false && logError(e);
|
|
55
73
|
};
|
|
56
74
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> => {
|
|
76
|
+
if (!signal) return promise;
|
|
77
|
+
if (signal.aborted) {
|
|
78
|
+
throw signal.reason ?? new AbortError("Operation was aborted");
|
|
79
|
+
}
|
|
80
|
+
return new Promise<T>((resolve, reject) => {
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
cleanup();
|
|
83
|
+
reject(signal.reason ?? new AbortError("Operation was aborted"));
|
|
84
|
+
};
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
try {
|
|
87
|
+
signal.removeEventListener("abort", onAbort);
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
93
|
+
promise.then(
|
|
94
|
+
(v) => {
|
|
95
|
+
cleanup();
|
|
96
|
+
resolve(v);
|
|
97
|
+
},
|
|
98
|
+
(e) => {
|
|
99
|
+
cleanup();
|
|
100
|
+
reject(e);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP = 100_000;
|
|
107
|
+
const SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES = 4_096;
|
|
108
|
+
const DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS = 60_000;
|
|
109
|
+
const DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS = 64;
|
|
110
|
+
const DEFAULT_PUBSUB_SHARD_COUNT = 256;
|
|
111
|
+
const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
|
|
112
|
+
const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
|
|
113
|
+
const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
|
|
114
|
+
|
|
115
|
+
const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS: Omit<
|
|
116
|
+
FanoutTreeChannelOptions,
|
|
117
|
+
"role"
|
|
118
|
+
> = {
|
|
119
|
+
msgRate: 30,
|
|
120
|
+
msgSize: 1024,
|
|
121
|
+
uploadLimitBps: 5_000_000,
|
|
122
|
+
maxChildren: 24,
|
|
123
|
+
repair: true,
|
|
124
|
+
};
|
|
61
125
|
|
|
62
|
-
export type
|
|
63
|
-
|
|
126
|
+
export type TopicControlPlaneOptions = DirectStreamOptions & {
|
|
127
|
+
dispatchEventOnSelfPublish?: boolean;
|
|
128
|
+
subscriptionDebounceDelay?: number;
|
|
129
|
+
topicRootControlPlane?: TopicRootControlPlane;
|
|
130
|
+
/**
|
|
131
|
+
* Fanout overlay used for sharded topic delivery.
|
|
132
|
+
*/
|
|
133
|
+
fanout: FanoutTree;
|
|
134
|
+
/**
|
|
135
|
+
* Base fanout channel options for shard overlays (applies to both roots and nodes).
|
|
136
|
+
*/
|
|
137
|
+
fanoutChannel?: Partial<Omit<FanoutTreeChannelOptions, "role">>;
|
|
138
|
+
/**
|
|
139
|
+
* Fanout channel overrides applied only when this node is the shard root.
|
|
140
|
+
*/
|
|
141
|
+
fanoutRootChannel?: Partial<Omit<FanoutTreeChannelOptions, "role">>;
|
|
142
|
+
/**
|
|
143
|
+
* Fanout channel overrides applied when joining shard overlays as a node.
|
|
144
|
+
*
|
|
145
|
+
* This is the primary knob for "leaf-only" subscribers: set `maxChildren=0`
|
|
146
|
+
* for non-router nodes so they never become relays under churn.
|
|
147
|
+
*/
|
|
148
|
+
fanoutNodeChannel?: Partial<Omit<FanoutTreeChannelOptions, "role">>;
|
|
149
|
+
/**
|
|
150
|
+
* Fanout join options for overlay topics.
|
|
151
|
+
*/
|
|
152
|
+
fanoutJoin?: FanoutTreeJoinOptions;
|
|
153
|
+
/**
|
|
154
|
+
* Number of pubsub shards (overlays) used for topic delivery.
|
|
155
|
+
*
|
|
156
|
+
* Each user-topic deterministically maps to exactly one shard topic:
|
|
157
|
+
* `shard = hash(topic) % shardCount`, and subscription joins that shard overlay.
|
|
158
|
+
*
|
|
159
|
+
* Default: 256.
|
|
160
|
+
*/
|
|
161
|
+
shardCount?: number;
|
|
162
|
+
/**
|
|
163
|
+
* Prefix used to form internal shard topics.
|
|
164
|
+
*
|
|
165
|
+
* Default: `/peerbit/pubsub-shard/1/`.
|
|
166
|
+
*/
|
|
167
|
+
shardTopicPrefix?: string;
|
|
168
|
+
/**
|
|
169
|
+
* If enabled, this node will host (open as root) every shard for which it is
|
|
170
|
+
* the deterministically selected root.
|
|
171
|
+
*
|
|
172
|
+
* This is intended for "router"/"supernode" deployments.
|
|
173
|
+
*
|
|
174
|
+
* Default: `false`.
|
|
175
|
+
*/
|
|
176
|
+
hostShards?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* Fanout-backed topics: require a local `subscribe(topic)` before `publish(topic)` is allowed.
|
|
179
|
+
*
|
|
180
|
+
* Default: `false` (publishing without subscribing will temporarily join the overlay).
|
|
181
|
+
*/
|
|
182
|
+
fanoutPublishRequiresSubscribe?: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* When publishing on a fanout topic without subscribing, keep the ephemeral join
|
|
185
|
+
* open for this long since the last publish, then auto-leave.
|
|
186
|
+
*
|
|
187
|
+
* Default: 60s. Set to `0` to close immediately after each publish.
|
|
188
|
+
*/
|
|
189
|
+
fanoutPublishIdleCloseMs?: number;
|
|
190
|
+
/**
|
|
191
|
+
* Max number of ephemeral fanout channels kept open concurrently for publish-only usage.
|
|
192
|
+
*
|
|
193
|
+
* Default: 64. Set to `0` to disable caching (channels will close after publish).
|
|
194
|
+
*/
|
|
195
|
+
fanoutPublishMaxEphemeralChannels?: number;
|
|
196
|
+
/**
|
|
197
|
+
* Best-effort bound on cached remote subscribers per topic.
|
|
198
|
+
*
|
|
199
|
+
* This controls memory growth at scale and bounds `getSubscribers()` and the
|
|
200
|
+
* receiver lists used for routing optimizations.
|
|
201
|
+
*/
|
|
202
|
+
subscriberCacheMaxEntries?: number;
|
|
64
203
|
};
|
|
65
204
|
|
|
66
|
-
export type
|
|
205
|
+
export type TopicControlPlaneComponents = DirectStreamComponents;
|
|
67
206
|
|
|
68
207
|
export type PeerId = Libp2pPeerId | PublicSignKey;
|
|
69
208
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
209
|
+
const topicHash32 = (topic: string) => {
|
|
210
|
+
let hash = 0x811c9dc5; // FNV-1a
|
|
211
|
+
for (let index = 0; index < topic.length; index++) {
|
|
212
|
+
hash ^= topic.charCodeAt(index);
|
|
213
|
+
hash = (hash * 0x01000193) >>> 0;
|
|
214
|
+
}
|
|
215
|
+
return hash >>> 0;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Runtime control-plane implementation for pubsub topic membership + forwarding.
|
|
220
|
+
*/
|
|
221
|
+
export class TopicControlPlane
|
|
222
|
+
extends DirectStream<PubSubEvents>
|
|
223
|
+
implements PubSub
|
|
224
|
+
{
|
|
225
|
+
// Tracked topics -> remote subscribers (best-effort).
|
|
226
|
+
public topics: Map<string, Map<string, SubscriptionData>>;
|
|
227
|
+
// Remote peer -> tracked topics.
|
|
228
|
+
public peerToTopic: Map<string, Set<string>>;
|
|
229
|
+
// Local topic -> reference count.
|
|
230
|
+
public subscriptions: Map<string, { counter: number }>;
|
|
231
|
+
public lastSubscriptionMessages: Map<string, Map<string, bigint>> = new Map();
|
|
77
232
|
public dispatchEventOnSelfPublish: boolean;
|
|
233
|
+
public readonly topicRootControlPlane: TopicRootControlPlane;
|
|
234
|
+
public readonly subscriberCacheMaxEntries: number;
|
|
235
|
+
public readonly fanout: FanoutTree;
|
|
78
236
|
|
|
79
237
|
private debounceSubscribeAggregator: DebouncedAccumulatorCounterMap;
|
|
80
238
|
private debounceUnsubscribeAggregator: DebouncedAccumulatorCounterMap;
|
|
81
239
|
|
|
240
|
+
private readonly shardCount: number;
|
|
241
|
+
private readonly shardTopicPrefix: string;
|
|
242
|
+
private readonly hostShards: boolean;
|
|
243
|
+
private readonly shardRootCache = new Map<string, string>();
|
|
244
|
+
private readonly shardTopicCache = new Map<string, string>();
|
|
245
|
+
private readonly shardRefCounts = new Map<string, number>();
|
|
246
|
+
private readonly pinnedShards = new Set<string>();
|
|
247
|
+
|
|
248
|
+
private readonly fanoutRootChannelOptions: Omit<
|
|
249
|
+
FanoutTreeChannelOptions,
|
|
250
|
+
"role"
|
|
251
|
+
>;
|
|
252
|
+
private readonly fanoutNodeChannelOptions: Omit<
|
|
253
|
+
FanoutTreeChannelOptions,
|
|
254
|
+
"role"
|
|
255
|
+
>;
|
|
256
|
+
private readonly fanoutJoinOptions?: FanoutTreeJoinOptions;
|
|
257
|
+
private readonly fanoutPublishRequiresSubscribe: boolean;
|
|
258
|
+
private readonly fanoutPublishIdleCloseMs: number;
|
|
259
|
+
private readonly fanoutPublishMaxEphemeralChannels: number;
|
|
260
|
+
|
|
261
|
+
// If no shard-root candidates are configured, we fall back to an "auto" mode:
|
|
262
|
+
// start with `[self]` and expand candidates as underlay peers connect.
|
|
263
|
+
// This keeps small ad-hoc networks working without explicit bootstraps.
|
|
264
|
+
private autoTopicRootCandidates = false;
|
|
265
|
+
private autoTopicRootCandidateSet?: Set<string>;
|
|
266
|
+
private reconcileShardOverlaysInFlight?: Promise<void>;
|
|
267
|
+
private autoCandidatesBroadcastTimers: Array<ReturnType<typeof setTimeout>> =
|
|
268
|
+
[];
|
|
269
|
+
private autoCandidatesGossipInterval?: ReturnType<typeof setInterval>;
|
|
270
|
+
private autoCandidatesGossipUntil = 0;
|
|
271
|
+
|
|
272
|
+
private fanoutChannels = new Map<
|
|
273
|
+
string,
|
|
274
|
+
{
|
|
275
|
+
root: string;
|
|
276
|
+
channel: FanoutChannel;
|
|
277
|
+
join: Promise<void>;
|
|
278
|
+
onData: (ev: CustomEvent<FanoutTreeDataEvent>) => void;
|
|
279
|
+
onUnicast: (ev: any) => void;
|
|
280
|
+
ephemeral: boolean;
|
|
281
|
+
lastUsedAt: number;
|
|
282
|
+
idleCloseTimeout?: ReturnType<typeof setTimeout>;
|
|
283
|
+
}
|
|
284
|
+
>();
|
|
285
|
+
|
|
82
286
|
constructor(
|
|
83
|
-
components:
|
|
84
|
-
props?:
|
|
85
|
-
dispatchEventOnSelfPublish?: boolean;
|
|
86
|
-
subscriptionDebounceDelay?: number;
|
|
87
|
-
},
|
|
287
|
+
components: TopicControlPlaneComponents,
|
|
288
|
+
props?: TopicControlPlaneOptions,
|
|
88
289
|
) {
|
|
89
|
-
super(components, ["/
|
|
290
|
+
super(components, ["/peerbit/topic-control-plane/2.0.0"], props);
|
|
90
291
|
this.subscriptions = new Map();
|
|
91
292
|
this.topics = new Map();
|
|
92
|
-
this.topicsToPeers = new Map();
|
|
93
293
|
this.peerToTopic = new Map();
|
|
294
|
+
|
|
295
|
+
this.topicRootControlPlane =
|
|
296
|
+
props?.topicRootControlPlane || new TopicRootControlPlane();
|
|
94
297
|
this.dispatchEventOnSelfPublish =
|
|
95
298
|
props?.dispatchEventOnSelfPublish || false;
|
|
299
|
+
|
|
300
|
+
if (!props?.fanout) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"TopicControlPlane requires a FanoutTree instance (options.fanout)",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
this.fanout = props.fanout;
|
|
306
|
+
|
|
307
|
+
// Default to a local-only shard-root candidate set so standalone peers can
|
|
308
|
+
// subscribe/publish without explicit bootstraps. We'll expand candidates
|
|
309
|
+
// opportunistically as neighbours connect.
|
|
310
|
+
if (this.topicRootControlPlane.getTopicRootCandidates().length === 0) {
|
|
311
|
+
this.autoTopicRootCandidates = true;
|
|
312
|
+
this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
|
|
313
|
+
this.topicRootControlPlane.setTopicRootCandidates([this.publicKeyHash]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const requestedShardCount = props?.shardCount ?? DEFAULT_PUBSUB_SHARD_COUNT;
|
|
317
|
+
this.shardCount = Math.min(
|
|
318
|
+
PUBSUB_SHARD_COUNT_HARD_CAP,
|
|
319
|
+
Math.max(1, Math.floor(requestedShardCount)),
|
|
320
|
+
);
|
|
321
|
+
const prefix = props?.shardTopicPrefix ?? DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX;
|
|
322
|
+
this.shardTopicPrefix = prefix.endsWith("/") ? prefix : prefix + "/";
|
|
323
|
+
this.hostShards = props?.hostShards ?? false;
|
|
324
|
+
|
|
325
|
+
const baseFanoutChannelOptions = {
|
|
326
|
+
...DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS,
|
|
327
|
+
...(props?.fanoutChannel || {}),
|
|
328
|
+
} as Omit<FanoutTreeChannelOptions, "role">;
|
|
329
|
+
this.fanoutRootChannelOptions = {
|
|
330
|
+
...baseFanoutChannelOptions,
|
|
331
|
+
...(props?.fanoutRootChannel || {}),
|
|
332
|
+
} as Omit<FanoutTreeChannelOptions, "role">;
|
|
333
|
+
this.fanoutNodeChannelOptions = {
|
|
334
|
+
...baseFanoutChannelOptions,
|
|
335
|
+
...(props?.fanoutNodeChannel || {}),
|
|
336
|
+
} as Omit<FanoutTreeChannelOptions, "role">;
|
|
337
|
+
this.fanoutJoinOptions = props?.fanoutJoin;
|
|
338
|
+
|
|
339
|
+
this.fanoutPublishRequiresSubscribe =
|
|
340
|
+
props?.fanoutPublishRequiresSubscribe ?? false;
|
|
341
|
+
const requestedIdleCloseMs =
|
|
342
|
+
props?.fanoutPublishIdleCloseMs ?? DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS;
|
|
343
|
+
this.fanoutPublishIdleCloseMs = Math.max(
|
|
344
|
+
0,
|
|
345
|
+
Math.floor(requestedIdleCloseMs),
|
|
346
|
+
);
|
|
347
|
+
const requestedMaxEphemeral =
|
|
348
|
+
props?.fanoutPublishMaxEphemeralChannels ??
|
|
349
|
+
DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS;
|
|
350
|
+
this.fanoutPublishMaxEphemeralChannels = Math.max(
|
|
351
|
+
0,
|
|
352
|
+
Math.floor(requestedMaxEphemeral),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const requestedSubscriberCacheMaxEntries =
|
|
356
|
+
props?.subscriberCacheMaxEntries ?? SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES;
|
|
357
|
+
this.subscriberCacheMaxEntries = Math.min(
|
|
358
|
+
SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP,
|
|
359
|
+
Math.max(1, Math.floor(requestedSubscriberCacheMaxEntries)),
|
|
360
|
+
);
|
|
361
|
+
|
|
96
362
|
this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter(
|
|
97
363
|
(set) => this._subscribe([...set.values()]),
|
|
98
364
|
props?.subscriptionDebounceDelay ?? 50,
|
|
99
365
|
);
|
|
366
|
+
// NOTE: Unsubscribe should update local state immediately and batch only the
|
|
367
|
+
// best-effort network announcements to avoid teardown stalls (program close).
|
|
100
368
|
this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter(
|
|
101
|
-
(set) => this.
|
|
369
|
+
(set) => this._announceUnsubscribe([...set.values()]),
|
|
102
370
|
props?.subscriptionDebounceDelay ?? 50,
|
|
103
371
|
);
|
|
104
372
|
}
|
|
105
373
|
|
|
106
|
-
stop() {
|
|
107
|
-
this.subscriptions.clear();
|
|
108
|
-
this.topics.clear();
|
|
109
|
-
this.peerToTopic.clear();
|
|
110
|
-
this.topicsToPeers.clear();
|
|
111
|
-
this.debounceSubscribeAggregator.close();
|
|
112
|
-
this.debounceUnsubscribeAggregator.close();
|
|
113
|
-
return super.stop();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private initializeTopic(topic: string) {
|
|
117
|
-
this.topics.get(topic) || this.topics.set(topic, new Map());
|
|
118
|
-
this.topicsToPeers.get(topic) || this.topicsToPeers.set(topic, new Set());
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private initializePeer(publicKey: PublicSignKey) {
|
|
122
|
-
this.peerToTopic.get(publicKey.hashcode()) ||
|
|
123
|
-
this.peerToTopic.set(publicKey.hashcode(), new Set());
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async subscribe(topic: string) {
|
|
127
|
-
// this.debounceUnsubscribeAggregator.delete(topic);
|
|
128
|
-
return this.debounceSubscribeAggregator.add({ key: topic });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
374
|
/**
|
|
132
|
-
*
|
|
375
|
+
* Configure deterministic topic-root candidates and disable the pubsub "auto"
|
|
376
|
+
* candidate mode.
|
|
377
|
+
*
|
|
378
|
+
* Auto mode is a convenience for small ad-hoc networks where no bootstraps/
|
|
379
|
+
* routers are configured. When an explicit candidate set is provided (e.g.
|
|
380
|
+
* from bootstraps or a test harness), we must stop mutating/gossiping the
|
|
381
|
+
* candidate set; otherwise shard root resolution can diverge and overlays can
|
|
382
|
+
* partition (especially in sparse graphs).
|
|
133
383
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
384
|
+
public setTopicRootCandidates(candidates: string[]) {
|
|
385
|
+
this.topicRootControlPlane.setTopicRootCandidates(candidates);
|
|
386
|
+
|
|
387
|
+
// Disable auto mode and stop its background gossip/timers.
|
|
388
|
+
this.autoTopicRootCandidates = false;
|
|
389
|
+
this.autoTopicRootCandidateSet = undefined;
|
|
390
|
+
for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
|
|
391
|
+
this.autoCandidatesBroadcastTimers = [];
|
|
392
|
+
if (this.autoCandidatesGossipInterval) {
|
|
393
|
+
clearInterval(this.autoCandidatesGossipInterval);
|
|
394
|
+
this.autoCandidatesGossipInterval = undefined;
|
|
137
395
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
396
|
+
this.autoCandidatesGossipUntil = 0;
|
|
397
|
+
|
|
398
|
+
// Re-resolve roots under the new mapping.
|
|
399
|
+
this.shardRootCache.clear();
|
|
400
|
+
// Only candidates can become deterministic roots. Avoid doing a full shard
|
|
401
|
+
// scan on non-candidates in large sessions.
|
|
402
|
+
if (candidates.includes(this.publicKeyHash)) {
|
|
403
|
+
void this.hostShardRootsNow().catch(() => {});
|
|
141
404
|
}
|
|
405
|
+
this.scheduleReconcileShardOverlays();
|
|
406
|
+
}
|
|
142
407
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (prev) {
|
|
147
|
-
prev.counter += counter;
|
|
148
|
-
} else {
|
|
149
|
-
prev = {
|
|
150
|
-
counter: counter,
|
|
151
|
-
};
|
|
152
|
-
this.subscriptions.set(topic, prev);
|
|
408
|
+
public override async start() {
|
|
409
|
+
await this.fanout.start();
|
|
410
|
+
await super.start();
|
|
153
411
|
|
|
154
|
-
|
|
155
|
-
|
|
412
|
+
if (this.hostShards) {
|
|
413
|
+
await this.hostShardRootsNow();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
public override async stop() {
|
|
418
|
+
for (const st of this.fanoutChannels.values()) {
|
|
419
|
+
if (st.idleCloseTimeout) clearTimeout(st.idleCloseTimeout);
|
|
420
|
+
try {
|
|
421
|
+
st.channel.removeEventListener("data", st.onData as any);
|
|
422
|
+
} catch {
|
|
423
|
+
// ignore
|
|
156
424
|
}
|
|
425
|
+
try {
|
|
426
|
+
st.channel.removeEventListener("unicast", st.onUnicast as any);
|
|
427
|
+
} catch {
|
|
428
|
+
// ignore
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
// Shutdown should be bounded and not depend on network I/O.
|
|
432
|
+
await st.channel.leave({ notifyParent: false, kickChildren: false });
|
|
433
|
+
} catch {
|
|
434
|
+
try {
|
|
435
|
+
st.channel.close();
|
|
436
|
+
} catch {
|
|
437
|
+
// ignore
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
this.fanoutChannels.clear();
|
|
442
|
+
for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
|
|
443
|
+
this.autoCandidatesBroadcastTimers = [];
|
|
444
|
+
if (this.autoCandidatesGossipInterval) {
|
|
445
|
+
clearInterval(this.autoCandidatesGossipInterval);
|
|
446
|
+
this.autoCandidatesGossipInterval = undefined;
|
|
157
447
|
}
|
|
448
|
+
this.autoCandidatesGossipUntil = 0;
|
|
158
449
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
header: new MessageHeader({
|
|
168
|
-
priority: 1,
|
|
169
|
-
mode: new SeekDelivery({ redundancy: 2 }),
|
|
170
|
-
session: this.session,
|
|
171
|
-
}),
|
|
172
|
-
});
|
|
450
|
+
this.subscriptions.clear();
|
|
451
|
+
this.topics.clear();
|
|
452
|
+
this.peerToTopic.clear();
|
|
453
|
+
this.lastSubscriptionMessages.clear();
|
|
454
|
+
this.shardRootCache.clear();
|
|
455
|
+
this.shardTopicCache.clear();
|
|
456
|
+
this.shardRefCounts.clear();
|
|
457
|
+
this.pinnedShards.clear();
|
|
173
458
|
|
|
174
|
-
|
|
175
|
-
|
|
459
|
+
this.debounceSubscribeAggregator.close();
|
|
460
|
+
this.debounceUnsubscribeAggregator.close();
|
|
461
|
+
return super.stop();
|
|
176
462
|
}
|
|
177
463
|
|
|
178
|
-
async
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
464
|
+
public override async onPeerConnected(
|
|
465
|
+
peerId: Libp2pPeerId,
|
|
466
|
+
connection: Connection,
|
|
467
|
+
) {
|
|
468
|
+
await super.onPeerConnected(peerId, connection);
|
|
469
|
+
|
|
470
|
+
// If we're in auto-candidate mode, expand the deterministic shard-root
|
|
471
|
+
// candidate set as neighbours connect, then reconcile shard overlays and
|
|
472
|
+
// re-announce subscriptions so membership knowledge converges.
|
|
473
|
+
if (!this.autoTopicRootCandidates) return;
|
|
474
|
+
let peerHash: string;
|
|
475
|
+
try {
|
|
476
|
+
peerHash = getPublicKeyFromPeerId(peerId).hashcode();
|
|
477
|
+
} catch {
|
|
478
|
+
return;
|
|
182
479
|
}
|
|
183
|
-
|
|
184
|
-
await this.debounceUnsubscribeAggregator.add({ key: topic });
|
|
185
|
-
return !!subscriptions;
|
|
480
|
+
void this.maybeUpdateAutoTopicRootCandidates(peerHash);
|
|
186
481
|
}
|
|
187
482
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
483
|
+
// Ensure auto-candidate mode converges even when libp2p topology callbacks
|
|
484
|
+
// are delayed or only fire for one side of a connection. `addPeer()` runs for
|
|
485
|
+
// both inbound + outbound protocol streams once the remote public key is known.
|
|
486
|
+
public override addPeer(
|
|
487
|
+
peerId: Libp2pPeerId,
|
|
488
|
+
publicKey: PublicSignKey,
|
|
489
|
+
protocol: string,
|
|
490
|
+
connId: string,
|
|
491
|
+
): PeerStreams {
|
|
492
|
+
const peer = super.addPeer(peerId, publicKey, protocol, connId);
|
|
493
|
+
if (this.autoTopicRootCandidates) {
|
|
494
|
+
void this.maybeUpdateAutoTopicRootCandidates(publicKey.hashcode());
|
|
495
|
+
this.scheduleAutoTopicRootCandidatesBroadcast([peer]);
|
|
194
496
|
}
|
|
497
|
+
return peer;
|
|
498
|
+
}
|
|
195
499
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
500
|
+
private maybeDisableAutoTopicRootCandidatesIfExternallyConfigured(): boolean {
|
|
501
|
+
if (!this.autoTopicRootCandidates) return false;
|
|
502
|
+
|
|
503
|
+
const managed = this.autoTopicRootCandidateSet;
|
|
504
|
+
if (!managed) return false;
|
|
505
|
+
|
|
506
|
+
const current = this.topicRootControlPlane.getTopicRootCandidates();
|
|
507
|
+
const externallyConfigured =
|
|
508
|
+
current.length !== managed.size || current.some((c) => !managed.has(c));
|
|
509
|
+
if (!externallyConfigured) return false;
|
|
510
|
+
|
|
511
|
+
// Stop mutating the candidate set. Leave the externally configured candidates
|
|
512
|
+
// intact and reconcile shard overlays under the new mapping.
|
|
513
|
+
this.autoTopicRootCandidates = false;
|
|
514
|
+
this.autoTopicRootCandidateSet = undefined;
|
|
515
|
+
this.shardRootCache.clear();
|
|
516
|
+
|
|
517
|
+
// Ensure we host any shard roots we're now responsible for. This is important
|
|
518
|
+
// in tests where candidates may be configured before protocol streams have
|
|
519
|
+
// fully started; earlier `hostShardRootsNow()` attempts can be skipped,
|
|
520
|
+
// leading to join timeouts.
|
|
521
|
+
void this.hostShardRootsNow().catch(() => {});
|
|
522
|
+
this.scheduleReconcileShardOverlays();
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
206
525
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
this.lastSubscriptionMessages.delete(peer);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
526
|
+
private maybeUpdateAutoTopicRootCandidates(peerHash: string) {
|
|
527
|
+
if (!this.autoTopicRootCandidates) return;
|
|
528
|
+
if (!peerHash || peerHash === this.publicKeyHash) return;
|
|
213
529
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
530
|
+
if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
|
|
531
|
+
return;
|
|
217
532
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
533
|
+
const current = this.topicRootControlPlane.getTopicRootCandidates();
|
|
534
|
+
const managed = this.autoTopicRootCandidateSet;
|
|
221
535
|
|
|
222
|
-
|
|
223
|
-
topicsToUnsubscribe.push(topic);
|
|
224
|
-
this.subscriptions.delete(topic);
|
|
225
|
-
this.topics.delete(topic);
|
|
226
|
-
this.topicsToPeers.delete(topic);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
536
|
+
if (current.includes(peerHash)) return;
|
|
229
537
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
538
|
+
managed?.add(peerHash);
|
|
539
|
+
const next = this.normalizeAutoTopicRootCandidates(
|
|
540
|
+
managed ? [...managed] : [...current, peerHash],
|
|
541
|
+
);
|
|
542
|
+
this.autoTopicRootCandidateSet = new Set(next);
|
|
543
|
+
this.topicRootControlPlane.setTopicRootCandidates(next);
|
|
544
|
+
this.shardRootCache.clear();
|
|
545
|
+
this.scheduleReconcileShardOverlays();
|
|
546
|
+
|
|
547
|
+
// In auto-candidate mode, shard roots are selected deterministically across
|
|
548
|
+
// *all* connected peers (not just those currently subscribed to a shard).
|
|
549
|
+
// That means a peer can be selected as root for shards it isn't using yet.
|
|
550
|
+
// Ensure we proactively host the shard roots we're responsible for so other
|
|
551
|
+
// peers can join without timing out in small ad-hoc networks.
|
|
552
|
+
void this.hostShardRootsNow().catch(() => {});
|
|
553
|
+
|
|
554
|
+
// Share the updated candidate set so other peers converge on the same
|
|
555
|
+
// deterministic mapping even in partially connected topologies.
|
|
556
|
+
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
247
557
|
}
|
|
248
558
|
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
const ret: PublicSignKey[] = [];
|
|
256
|
-
for (const v of remote.values()) {
|
|
257
|
-
ret.push(v.publicKey);
|
|
559
|
+
private normalizeAutoTopicRootCandidates(candidates: string[]): string[] {
|
|
560
|
+
const unique = new Set<string>();
|
|
561
|
+
for (const c of candidates) {
|
|
562
|
+
if (!c) continue;
|
|
563
|
+
unique.add(c);
|
|
258
564
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return ret;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
private listenForSubscribers(topic: string) {
|
|
266
|
-
this.initializeTopic(topic);
|
|
565
|
+
unique.add(this.publicKeyHash);
|
|
566
|
+
const sorted = [...unique].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
567
|
+
return sorted.slice(0, AUTO_TOPIC_ROOT_CANDIDATES_MAX);
|
|
267
568
|
}
|
|
268
569
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
): Promise<void> {
|
|
273
|
-
if (!this.started) {
|
|
274
|
-
throw new NotStartedError();
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (topic == null) {
|
|
278
|
-
throw new Error("ERR_NOT_VALID_TOPIC");
|
|
279
|
-
}
|
|
570
|
+
private scheduleAutoTopicRootCandidatesBroadcast(targets?: PeerStreams[]) {
|
|
571
|
+
if (!this.autoTopicRootCandidates) return;
|
|
572
|
+
if (!this.started || this.stopping) return;
|
|
280
573
|
|
|
281
|
-
if (
|
|
574
|
+
if (targets && targets.length > 0) {
|
|
575
|
+
void this.sendAutoTopicRootCandidates(targets).catch(() => {});
|
|
282
576
|
return;
|
|
283
577
|
}
|
|
284
578
|
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
579
|
+
for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
|
|
580
|
+
this.autoCandidatesBroadcastTimers = [];
|
|
581
|
+
|
|
582
|
+
// Burst a few times to survive early "stream not writable yet" races.
|
|
583
|
+
const delays = [25, 500, 2_000];
|
|
584
|
+
for (const delayMs of delays) {
|
|
585
|
+
const t = setTimeout(() => {
|
|
586
|
+
void this.sendAutoTopicRootCandidates().catch(() => {});
|
|
587
|
+
}, delayMs);
|
|
588
|
+
t.unref?.();
|
|
589
|
+
this.autoCandidatesBroadcastTimers.push(t);
|
|
288
590
|
}
|
|
289
591
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
header: new MessageHeader({
|
|
295
|
-
mode: new SeekDelivery({
|
|
296
|
-
to: to ? [to.hashcode()] : undefined,
|
|
297
|
-
redundancy: 2,
|
|
298
|
-
}),
|
|
299
|
-
session: this.session,
|
|
300
|
-
priority: 1,
|
|
301
|
-
}),
|
|
302
|
-
}).sign(this.sign),
|
|
303
|
-
);
|
|
592
|
+
// Keep gossiping for a while after changes so partially connected topologies
|
|
593
|
+
// converge even under slow stream negotiation.
|
|
594
|
+
this.autoCandidatesGossipUntil = Date.now() + 60_000;
|
|
595
|
+
this.ensureAutoCandidatesGossipInterval();
|
|
304
596
|
}
|
|
305
597
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
598
|
+
private ensureAutoCandidatesGossipInterval() {
|
|
599
|
+
if (!this.autoTopicRootCandidates) return;
|
|
600
|
+
if (!this.started || this.stopping) return;
|
|
601
|
+
if (this.autoCandidatesGossipInterval) return;
|
|
602
|
+
this.autoCandidatesGossipInterval = setInterval(() => {
|
|
603
|
+
if (!this.started || this.stopping || !this.autoTopicRootCandidates)
|
|
604
|
+
return;
|
|
605
|
+
if (
|
|
606
|
+
this.autoCandidatesGossipUntil > 0 &&
|
|
607
|
+
Date.now() > this.autoCandidatesGossipUntil
|
|
608
|
+
) {
|
|
609
|
+
if (this.autoCandidatesGossipInterval) {
|
|
610
|
+
clearInterval(this.autoCandidatesGossipInterval);
|
|
611
|
+
this.autoCandidatesGossipInterval = undefined;
|
|
315
612
|
}
|
|
613
|
+
return;
|
|
316
614
|
}
|
|
317
|
-
|
|
318
|
-
|
|
615
|
+
void this.sendAutoTopicRootCandidates().catch(() => {});
|
|
616
|
+
}, 2_000);
|
|
617
|
+
this.autoCandidatesGossipInterval.unref?.();
|
|
319
618
|
}
|
|
320
619
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
620
|
+
private async sendAutoTopicRootCandidates(targets?: PeerStreams[]) {
|
|
621
|
+
if (!this.started) throw new NotStartedError();
|
|
622
|
+
const streams = targets ?? [...this.peers.values()];
|
|
623
|
+
if (streams.length === 0) return;
|
|
624
|
+
|
|
625
|
+
const candidates = this.topicRootControlPlane.getTopicRootCandidates();
|
|
626
|
+
if (candidates.length === 0) return;
|
|
627
|
+
|
|
628
|
+
const msg = new TopicRootCandidates({ candidates });
|
|
629
|
+
const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
|
|
630
|
+
mode: new AnyWhere(),
|
|
631
|
+
priority: 1,
|
|
632
|
+
skipRecipientValidation: true,
|
|
633
|
+
} as any);
|
|
634
|
+
await this.publishMessage(this.publicKey, embedded, streams).catch(
|
|
635
|
+
dontThrowIfDeliveryError,
|
|
325
636
|
);
|
|
326
|
-
}
|
|
637
|
+
}
|
|
327
638
|
|
|
328
|
-
private
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
(tos.length === 0 || (tos.length === 1 && tos[0] === this.publicKeyHash))
|
|
332
|
-
) {
|
|
333
|
-
// skip this one
|
|
639
|
+
private mergeAutoTopicRootCandidatesFromPeer(candidates: string[]): boolean {
|
|
640
|
+
if (!this.autoTopicRootCandidates) return false;
|
|
641
|
+
if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
|
|
334
642
|
return false;
|
|
335
|
-
|
|
643
|
+
const managed = this.autoTopicRootCandidateSet;
|
|
644
|
+
if (!managed) return false;
|
|
336
645
|
|
|
646
|
+
const before = this.topicRootControlPlane.getTopicRootCandidates();
|
|
647
|
+
for (const c of candidates) {
|
|
648
|
+
if (!c) continue;
|
|
649
|
+
managed.add(c);
|
|
650
|
+
}
|
|
651
|
+
const next = this.normalizeAutoTopicRootCandidates([...managed]);
|
|
337
652
|
if (
|
|
338
|
-
|
|
339
|
-
(
|
|
653
|
+
before.length === next.length &&
|
|
654
|
+
before.every((c, i) => c === next[i])
|
|
340
655
|
) {
|
|
341
|
-
// skip this one
|
|
342
656
|
return false;
|
|
343
657
|
}
|
|
344
658
|
|
|
659
|
+
this.autoTopicRootCandidateSet = new Set(next);
|
|
660
|
+
this.topicRootControlPlane.setTopicRootCandidates(next);
|
|
661
|
+
this.shardRootCache.clear();
|
|
662
|
+
this.scheduleReconcileShardOverlays();
|
|
663
|
+
void this.hostShardRootsNow().catch(() => {});
|
|
664
|
+
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
345
665
|
return true;
|
|
346
666
|
}
|
|
347
|
-
async publish(
|
|
348
|
-
data: Uint8Array | undefined,
|
|
349
|
-
options?: {
|
|
350
|
-
topics: string[];
|
|
351
|
-
} & { client?: string } & {
|
|
352
|
-
mode?: SilentDelivery | AcknowledgeDelivery | SeekDelivery;
|
|
353
|
-
} & PriorityOptions &
|
|
354
|
-
IdOptions & { signal?: AbortSignal },
|
|
355
|
-
): Promise<Uint8Array | undefined> {
|
|
356
|
-
if (!this.started) {
|
|
357
|
-
throw new NotStartedError();
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const topics =
|
|
361
|
-
(options as { topics: string[] }).topics?.map((x) => x.toString()) || [];
|
|
362
667
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
: undefined;
|
|
668
|
+
private scheduleReconcileShardOverlays() {
|
|
669
|
+
if (this.reconcileShardOverlaysInFlight) return;
|
|
670
|
+
this.reconcileShardOverlaysInFlight = this.reconcileShardOverlays()
|
|
671
|
+
.catch(() => {
|
|
672
|
+
// best-effort retry: fanout streams/roots might not be ready yet.
|
|
673
|
+
if (!this.started || this.stopping) return;
|
|
674
|
+
const t = setTimeout(() => this.scheduleReconcileShardOverlays(), 250);
|
|
675
|
+
t.unref?.();
|
|
676
|
+
})
|
|
677
|
+
.finally(() => {
|
|
678
|
+
this.reconcileShardOverlaysInFlight = undefined;
|
|
679
|
+
});
|
|
680
|
+
}
|
|
377
681
|
|
|
378
|
-
|
|
379
|
-
|
|
682
|
+
private async reconcileShardOverlays() {
|
|
683
|
+
if (!this.started) return;
|
|
380
684
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
685
|
+
const byShard = new Map<string, string[]>();
|
|
686
|
+
for (const topic of this.subscriptions.keys()) {
|
|
687
|
+
const shardTopic = this.getShardTopicForUserTopic(topic);
|
|
688
|
+
byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
|
|
384
689
|
}
|
|
385
690
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
691
|
+
// Ensure shard overlays are joined using the current root mapping (may
|
|
692
|
+
// migrate channels if roots changed), then re-announce subscriptions.
|
|
693
|
+
await Promise.all(
|
|
694
|
+
[...byShard.entries()].map(async ([shardTopic, userTopics]) => {
|
|
695
|
+
if (userTopics.length === 0) return;
|
|
696
|
+
await this.ensureFanoutChannel(shardTopic, { ephemeral: false });
|
|
697
|
+
|
|
698
|
+
const msg = new Subscribe({
|
|
699
|
+
topics: userTopics,
|
|
700
|
+
requestSubscribers: true,
|
|
701
|
+
});
|
|
702
|
+
const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
|
|
703
|
+
mode: new AnyWhere(),
|
|
704
|
+
priority: 1,
|
|
705
|
+
skipRecipientValidation: true,
|
|
706
|
+
} as any);
|
|
707
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
708
|
+
if (!st) return;
|
|
709
|
+
await st.channel.publish(toUint8Array(embedded.bytes()));
|
|
710
|
+
this.touchFanoutChannel(shardTopic);
|
|
711
|
+
}),
|
|
712
|
+
);
|
|
713
|
+
}
|
|
391
714
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
detail: new PublishEvent({
|
|
396
|
-
client: options?.client,
|
|
397
|
-
data: dataMessage,
|
|
398
|
-
message,
|
|
399
|
-
}),
|
|
400
|
-
}),
|
|
401
|
-
);
|
|
402
|
-
}
|
|
715
|
+
private isTrackedTopic(topic: string) {
|
|
716
|
+
return this.topics.has(topic);
|
|
717
|
+
}
|
|
403
718
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
719
|
+
private initializeTopic(topic: string) {
|
|
720
|
+
this.topics.get(topic) || this.topics.set(topic, new Map());
|
|
721
|
+
}
|
|
408
722
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (error instanceof DeliveryError) {
|
|
420
|
-
if (silentDelivery === false) {
|
|
421
|
-
// If we are not in silent mode, we should throw the error
|
|
422
|
-
throw error;
|
|
423
|
-
}
|
|
424
|
-
return message.id;
|
|
723
|
+
private untrackTopic(topic: string) {
|
|
724
|
+
const peers = this.topics.get(topic);
|
|
725
|
+
this.topics.delete(topic);
|
|
726
|
+
if (!peers) return;
|
|
727
|
+
for (const peerHash of peers.keys()) {
|
|
728
|
+
this.peerToTopic.get(peerHash)?.delete(topic);
|
|
729
|
+
this.lastSubscriptionMessages.get(peerHash)?.delete(topic);
|
|
730
|
+
if (!this.peerToTopic.get(peerHash)?.size) {
|
|
731
|
+
this.peerToTopic.delete(peerHash);
|
|
732
|
+
this.lastSubscriptionMessages.delete(peerHash);
|
|
425
733
|
}
|
|
426
|
-
throw error;
|
|
427
734
|
}
|
|
735
|
+
}
|
|
428
736
|
|
|
429
|
-
|
|
737
|
+
private initializePeer(publicKey: PublicSignKey) {
|
|
738
|
+
this.peerToTopic.get(publicKey.hashcode()) ||
|
|
739
|
+
this.peerToTopic.set(publicKey.hashcode(), new Set());
|
|
430
740
|
}
|
|
431
741
|
|
|
432
|
-
private
|
|
742
|
+
private pruneTopicSubscribers(topic: string) {
|
|
433
743
|
const peers = this.topics.get(topic);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
744
|
+
if (!peers) return;
|
|
745
|
+
|
|
746
|
+
while (peers.size > this.subscriberCacheMaxEntries) {
|
|
747
|
+
const oldest = peers.keys().next().value as string | undefined;
|
|
748
|
+
if (!oldest) break;
|
|
749
|
+
peers.delete(oldest);
|
|
750
|
+
this.peerToTopic.get(oldest)?.delete(topic);
|
|
751
|
+
this.lastSubscriptionMessages.get(oldest)?.delete(topic);
|
|
752
|
+
if (!this.peerToTopic.get(oldest)?.size) {
|
|
753
|
+
this.peerToTopic.delete(oldest);
|
|
754
|
+
this.lastSubscriptionMessages.delete(oldest);
|
|
755
|
+
}
|
|
444
756
|
}
|
|
445
|
-
|
|
446
|
-
this.topicsToPeers.get(topic)?.delete(publicKeyHash);
|
|
447
|
-
|
|
448
|
-
return change;
|
|
449
757
|
}
|
|
450
758
|
|
|
451
759
|
private getSubscriptionOverlap(topics?: string[]) {
|
|
452
760
|
const subscriptions: string[] = [];
|
|
453
761
|
if (topics) {
|
|
454
762
|
for (const topic of topics) {
|
|
455
|
-
|
|
456
|
-
if (subscription) {
|
|
457
|
-
subscriptions.push(topic);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
} else {
|
|
461
|
-
for (const [topic, _subscription] of this.subscriptions) {
|
|
462
|
-
subscriptions.push(topic);
|
|
763
|
+
if (this.subscriptions.get(topic)) subscriptions.push(topic);
|
|
463
764
|
}
|
|
765
|
+
return subscriptions;
|
|
464
766
|
}
|
|
767
|
+
for (const [topic] of this.subscriptions) subscriptions.push(topic);
|
|
465
768
|
return subscriptions;
|
|
466
769
|
}
|
|
467
770
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
// Aggregate subscribers for my topics through this new peer because if we don't do this we might end up with a situtation where
|
|
475
|
-
// we act as a relay and relay messages for a topic, but don't forward it to this new peer because we never learned about their subscriptions
|
|
476
|
-
|
|
477
|
-
const resp = super.onPeerReachable(publicKey);
|
|
478
|
-
const stream = this.peers.get(publicKey.hashcode());
|
|
479
|
-
const isNeigbour = !!stream;
|
|
480
|
-
|
|
481
|
-
if (this.subscriptions.size > 0) {
|
|
482
|
-
// tell the peer about all topics we subscribe to
|
|
483
|
-
this.publishMessage(
|
|
484
|
-
this.publicKey,
|
|
485
|
-
await new DataMessage({
|
|
486
|
-
data: toUint8Array(
|
|
487
|
-
new Subscribe({
|
|
488
|
-
topics: this.getSubscriptionOverlap(), // TODO make the protocol more efficient, do we really need to share *everything* ?
|
|
489
|
-
requestSubscribers: true,
|
|
490
|
-
}).bytes(),
|
|
491
|
-
),
|
|
492
|
-
header: new MessageHeader({
|
|
493
|
-
// is new neighbour ? then send to all, though that connection (potentially find new peers)
|
|
494
|
-
// , else just try to reach the remote
|
|
495
|
-
priority: 1,
|
|
496
|
-
mode: new SeekDelivery({
|
|
497
|
-
redundancy: 2,
|
|
498
|
-
to: isNeigbour ? undefined : [publicKey.hashcode()],
|
|
499
|
-
}),
|
|
500
|
-
session: this.session,
|
|
501
|
-
}),
|
|
502
|
-
}).sign(this.sign),
|
|
503
|
-
).catch(dontThrowIfDeliveryError); // peer might have become unreachable immediately
|
|
771
|
+
private clearFanoutIdleClose(st: {
|
|
772
|
+
idleCloseTimeout?: ReturnType<typeof setTimeout>;
|
|
773
|
+
}) {
|
|
774
|
+
if (st.idleCloseTimeout) {
|
|
775
|
+
clearTimeout(st.idleCloseTimeout);
|
|
776
|
+
st.idleCloseTimeout = undefined;
|
|
504
777
|
}
|
|
505
|
-
|
|
506
|
-
return resp;
|
|
507
778
|
}
|
|
508
779
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
780
|
+
private scheduleFanoutIdleClose(topic: string) {
|
|
781
|
+
const st = this.fanoutChannels.get(topic);
|
|
782
|
+
if (!st || !st.ephemeral) return;
|
|
783
|
+
this.clearFanoutIdleClose(st);
|
|
784
|
+
if (this.fanoutPublishIdleCloseMs <= 0) return;
|
|
785
|
+
st.idleCloseTimeout = setTimeout(() => {
|
|
786
|
+
const cur = this.fanoutChannels.get(topic);
|
|
787
|
+
if (!cur || !cur.ephemeral) return;
|
|
788
|
+
const idleMs = Date.now() - cur.lastUsedAt;
|
|
789
|
+
if (idleMs >= this.fanoutPublishIdleCloseMs) {
|
|
790
|
+
void this.closeFanoutChannel(topic);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
this.scheduleFanoutIdleClose(topic);
|
|
794
|
+
}, this.fanoutPublishIdleCloseMs);
|
|
512
795
|
}
|
|
513
796
|
|
|
514
|
-
private
|
|
515
|
-
const
|
|
797
|
+
private touchFanoutChannel(topic: string) {
|
|
798
|
+
const st = this.fanoutChannels.get(topic);
|
|
799
|
+
if (!st) return;
|
|
800
|
+
st.lastUsedAt = Date.now();
|
|
801
|
+
if (st.ephemeral) {
|
|
802
|
+
this.scheduleFanoutIdleClose(topic);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
516
805
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
806
|
+
private evictEphemeralFanoutChannels(exceptTopic?: string) {
|
|
807
|
+
const max = this.fanoutPublishMaxEphemeralChannels;
|
|
808
|
+
if (max < 0) return;
|
|
809
|
+
const exceptIsEphemeral = exceptTopic
|
|
810
|
+
? this.fanoutChannels.get(exceptTopic)?.ephemeral === true
|
|
811
|
+
: false;
|
|
812
|
+
const keep = Math.max(0, max - (exceptIsEphemeral ? 1 : 0));
|
|
813
|
+
|
|
814
|
+
const candidates: Array<[string, { lastUsedAt: number }]> = [];
|
|
815
|
+
for (const [t, st] of this.fanoutChannels) {
|
|
816
|
+
if (!st.ephemeral) continue;
|
|
817
|
+
if (exceptTopic && t === exceptTopic) continue;
|
|
818
|
+
candidates.push([t, st]);
|
|
525
819
|
}
|
|
526
|
-
|
|
820
|
+
if (candidates.length <= keep) return;
|
|
527
821
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
);
|
|
822
|
+
candidates.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
|
|
823
|
+
const toClose = candidates.length - keep;
|
|
824
|
+
for (let i = 0; i < toClose; i++) {
|
|
825
|
+
const t = candidates[i]![0];
|
|
826
|
+
void this.closeFanoutChannel(t);
|
|
534
827
|
}
|
|
535
828
|
}
|
|
536
829
|
|
|
537
|
-
private
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
const
|
|
830
|
+
private getShardTopicForUserTopic(topic: string): string {
|
|
831
|
+
const t = topic.toString();
|
|
832
|
+
const cached = this.shardTopicCache.get(t);
|
|
833
|
+
if (cached) return cached;
|
|
834
|
+
const index = topicHash32(t) % this.shardCount;
|
|
835
|
+
const shardTopic = `${this.shardTopicPrefix}${index}`;
|
|
836
|
+
this.shardTopicCache.set(t, shardTopic);
|
|
837
|
+
return shardTopic;
|
|
838
|
+
}
|
|
543
839
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
840
|
+
private async resolveShardRoot(shardTopic: string): Promise<string> {
|
|
841
|
+
// If someone configured topic-root candidates externally (e.g. TestSession router
|
|
842
|
+
// selection or Peerbit.bootstrap) after this peer entered auto mode, disable auto
|
|
843
|
+
// mode before we cache any roots based on a stale candidate set.
|
|
844
|
+
if (this.autoTopicRootCandidates) {
|
|
845
|
+
this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured();
|
|
551
846
|
}
|
|
552
847
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
848
|
+
const cached = this.shardRootCache.get(shardTopic);
|
|
849
|
+
if (cached) return cached;
|
|
850
|
+
const resolved =
|
|
851
|
+
await this.topicRootControlPlane.resolveTopicRoot(shardTopic);
|
|
852
|
+
if (!resolved) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`No root resolved for shard topic ${shardTopic}. Configure TopicRootControlPlane candidates/resolver/trackers.`,
|
|
855
|
+
);
|
|
558
856
|
}
|
|
559
|
-
|
|
857
|
+
this.shardRootCache.set(shardTopic, resolved);
|
|
858
|
+
return resolved;
|
|
560
859
|
}
|
|
561
860
|
|
|
562
|
-
private
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
861
|
+
private async ensureFanoutChannel(
|
|
862
|
+
shardTopic: string,
|
|
863
|
+
options?: {
|
|
864
|
+
ephemeral?: boolean;
|
|
865
|
+
pin?: boolean;
|
|
866
|
+
root?: string;
|
|
867
|
+
signal?: AbortSignal;
|
|
868
|
+
},
|
|
869
|
+
): Promise<void> {
|
|
870
|
+
const t = shardTopic.toString();
|
|
871
|
+
const pin = options?.pin === true;
|
|
872
|
+
const wantEphemeral = options?.ephemeral === true;
|
|
873
|
+
|
|
874
|
+
// Allow callers that already resolved the shard root (e.g. hostShardRootsNow)
|
|
875
|
+
// to pass it through to avoid a race where the candidate set changes between
|
|
876
|
+
// two resolve calls, causing an unnecessary (and potentially slow) join.
|
|
877
|
+
let root: string | undefined = options?.root;
|
|
878
|
+
const existing = this.fanoutChannels.get(t);
|
|
879
|
+
if (existing) {
|
|
880
|
+
root = root ?? (await this.resolveShardRoot(t));
|
|
881
|
+
if (root === existing.root) {
|
|
882
|
+
existing.lastUsedAt = Date.now();
|
|
883
|
+
if (existing.ephemeral && !wantEphemeral) {
|
|
884
|
+
existing.ephemeral = false;
|
|
885
|
+
this.clearFanoutIdleClose(existing);
|
|
886
|
+
} else if (existing.ephemeral) {
|
|
887
|
+
this.scheduleFanoutIdleClose(t);
|
|
888
|
+
}
|
|
889
|
+
if (pin) this.pinnedShards.add(t);
|
|
890
|
+
await withAbort(existing.join, options?.signal);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
568
893
|
|
|
569
|
-
|
|
570
|
-
|
|
894
|
+
// Root mapping changed (candidate set updated): migrate to the new overlay.
|
|
895
|
+
await withAbort(this.closeFanoutChannel(t, { force: true }), options?.signal);
|
|
571
896
|
}
|
|
572
897
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
898
|
+
root = root ?? (await this.resolveShardRoot(t));
|
|
899
|
+
const channel = new FanoutChannel(this.fanout, { topic: t, root });
|
|
576
900
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
901
|
+
const onPayload = (payload: Uint8Array) => {
|
|
902
|
+
let dm: DataMessage;
|
|
903
|
+
try {
|
|
904
|
+
dm = DataMessage.from(new Uint8ArrayList(payload));
|
|
905
|
+
} catch {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (!dm?.data) return;
|
|
909
|
+
if (!dm.header.signatures?.signatures?.length) return;
|
|
586
910
|
|
|
587
|
-
|
|
588
|
-
|
|
911
|
+
const signedBySelf =
|
|
912
|
+
dm.header.signatures?.publicKeys.some((x) =>
|
|
913
|
+
x.equals(this.publicKey),
|
|
914
|
+
) ?? false;
|
|
915
|
+
if (signedBySelf) return;
|
|
916
|
+
|
|
917
|
+
let pubsubMessage: PubSubMessage;
|
|
918
|
+
try {
|
|
919
|
+
pubsubMessage = PubSubMessage.from(dm.data);
|
|
920
|
+
} catch {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Fast filter before hashing/verifying.
|
|
925
|
+
if (pubsubMessage instanceof PubSubData) {
|
|
926
|
+
const forMe = pubsubMessage.topics.some((x) =>
|
|
927
|
+
this.subscriptions.has(x),
|
|
928
|
+
);
|
|
929
|
+
if (!forMe) return;
|
|
930
|
+
} else if (
|
|
931
|
+
pubsubMessage instanceof Subscribe ||
|
|
932
|
+
pubsubMessage instanceof Unsubscribe
|
|
933
|
+
) {
|
|
934
|
+
const relevant = pubsubMessage.topics.some((x) =>
|
|
935
|
+
this.isTrackedTopic(x),
|
|
936
|
+
);
|
|
937
|
+
const needRespond =
|
|
938
|
+
pubsubMessage instanceof Subscribe && pubsubMessage.requestSubscribers
|
|
939
|
+
? pubsubMessage.topics.some((x) => this.subscriptions.has(x))
|
|
940
|
+
: false;
|
|
941
|
+
if (!relevant && !needRespond) return;
|
|
942
|
+
} else if (pubsubMessage instanceof GetSubscribers) {
|
|
943
|
+
const overlap = pubsubMessage.topics.some((x) =>
|
|
944
|
+
this.subscriptions.has(x),
|
|
945
|
+
);
|
|
946
|
+
if (!overlap) return;
|
|
947
|
+
} else {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
void (async () => {
|
|
952
|
+
const msgId = await getMsgId(payload);
|
|
953
|
+
const seen = this.seenCache.get(msgId);
|
|
954
|
+
this.seenCache.add(msgId, seen ? seen + 1 : 1);
|
|
955
|
+
if (seen) return;
|
|
956
|
+
|
|
957
|
+
if ((await this.verifyAndProcess(dm)) === false) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const sender = dm.header.signatures!.signatures[0]!.publicKey!;
|
|
961
|
+
await this.processShardPubSubMessage({
|
|
962
|
+
pubsubMessage,
|
|
963
|
+
message: dm,
|
|
964
|
+
from: sender,
|
|
965
|
+
shardTopic: t,
|
|
966
|
+
});
|
|
967
|
+
})();
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const onData = (ev?: CustomEvent<FanoutTreeDataEvent>) => {
|
|
971
|
+
const detail = ev?.detail as FanoutTreeDataEvent | undefined;
|
|
972
|
+
if (!detail) return;
|
|
973
|
+
onPayload(detail.payload);
|
|
974
|
+
};
|
|
975
|
+
const onUnicast = (ev?: any) => {
|
|
976
|
+
const detail = ev?.detail as any | undefined;
|
|
977
|
+
if (!detail) return;
|
|
978
|
+
if (detail.to && detail.to !== this.publicKeyHash) return;
|
|
979
|
+
onPayload(detail.payload);
|
|
980
|
+
};
|
|
981
|
+
channel.addEventListener("data", onData as any);
|
|
982
|
+
channel.addEventListener("unicast", onUnicast as any);
|
|
983
|
+
|
|
984
|
+
const join = (async () => {
|
|
985
|
+
try {
|
|
986
|
+
if (root === this.publicKeyHash) {
|
|
987
|
+
channel.openAsRoot(this.fanoutRootChannelOptions);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
// Joining by root hash is much more reliable if the fanout protocol
|
|
991
|
+
// stream is already established (especially in small test nets without
|
|
992
|
+
// trackers/bootstraps). Best-effort only: join can still succeed via
|
|
993
|
+
// trackers/other routing if this times out.
|
|
994
|
+
try {
|
|
995
|
+
await this.fanout.waitFor(root, {
|
|
996
|
+
target: "neighbor",
|
|
997
|
+
// Best-effort pre-check only: do not block subscribe/publish setup
|
|
998
|
+
// for long periods if the root is not yet a direct stream neighbor.
|
|
999
|
+
timeout: 1_000,
|
|
1000
|
+
});
|
|
1001
|
+
} catch {
|
|
1002
|
+
// ignore
|
|
1003
|
+
}
|
|
1004
|
+
const joinOpts = options?.signal
|
|
1005
|
+
? { ...(this.fanoutJoinOptions ?? {}), signal: options.signal }
|
|
1006
|
+
: this.fanoutJoinOptions;
|
|
1007
|
+
await channel.join(this.fanoutNodeChannelOptions, joinOpts);
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
try {
|
|
1010
|
+
channel.removeEventListener("data", onData as any);
|
|
1011
|
+
} catch {
|
|
1012
|
+
// ignore
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
channel.removeEventListener("unicast", onUnicast as any);
|
|
1016
|
+
} catch {
|
|
1017
|
+
// ignore
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
channel.close();
|
|
1021
|
+
} catch {
|
|
1022
|
+
// ignore
|
|
1023
|
+
}
|
|
1024
|
+
throw error;
|
|
1025
|
+
}
|
|
1026
|
+
})();
|
|
1027
|
+
|
|
1028
|
+
const lastUsedAt = Date.now();
|
|
1029
|
+
if (pin) this.pinnedShards.add(t);
|
|
1030
|
+
this.fanoutChannels.set(t, {
|
|
1031
|
+
root,
|
|
1032
|
+
channel,
|
|
1033
|
+
join,
|
|
1034
|
+
onData,
|
|
1035
|
+
onUnicast,
|
|
1036
|
+
ephemeral: wantEphemeral,
|
|
1037
|
+
lastUsedAt,
|
|
1038
|
+
});
|
|
1039
|
+
join.catch(() => {
|
|
1040
|
+
this.fanoutChannels.delete(t);
|
|
1041
|
+
});
|
|
1042
|
+
join
|
|
1043
|
+
.then(() => {
|
|
1044
|
+
const st = this.fanoutChannels.get(t);
|
|
1045
|
+
if (st?.ephemeral) this.scheduleFanoutIdleClose(t);
|
|
1046
|
+
})
|
|
1047
|
+
.catch(() => {
|
|
1048
|
+
// ignore
|
|
1049
|
+
});
|
|
1050
|
+
if (wantEphemeral) {
|
|
1051
|
+
this.evictEphemeralFanoutChannels(t);
|
|
589
1052
|
}
|
|
1053
|
+
await withAbort(join, options?.signal);
|
|
1054
|
+
}
|
|
590
1055
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1056
|
+
private async closeFanoutChannel(
|
|
1057
|
+
shardTopic: string,
|
|
1058
|
+
options?: { force?: boolean },
|
|
1059
|
+
): Promise<void> {
|
|
1060
|
+
const t = shardTopic.toString();
|
|
1061
|
+
if (!options?.force && this.pinnedShards.has(t)) return;
|
|
1062
|
+
if (options?.force) this.pinnedShards.delete(t);
|
|
1063
|
+
const st = this.fanoutChannels.get(t);
|
|
1064
|
+
if (!st) return;
|
|
1065
|
+
this.fanoutChannels.delete(t);
|
|
1066
|
+
this.clearFanoutIdleClose(st);
|
|
1067
|
+
try {
|
|
1068
|
+
st.channel.removeEventListener("data", st.onData as any);
|
|
1069
|
+
} catch {
|
|
1070
|
+
// ignore
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
st.channel.removeEventListener("unicast", st.onUnicast as any);
|
|
1074
|
+
} catch {
|
|
1075
|
+
// ignore
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
await st.channel.leave({ notifyParent: true });
|
|
1079
|
+
} catch {
|
|
1080
|
+
try {
|
|
1081
|
+
st.channel.close();
|
|
1082
|
+
} catch {
|
|
1083
|
+
// ignore
|
|
595
1084
|
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
596
1087
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
1088
|
+
public async hostShardRootsNow() {
|
|
1089
|
+
if (!this.started) throw new NotStartedError();
|
|
1090
|
+
const joins: Promise<void>[] = [];
|
|
1091
|
+
for (let i = 0; i < this.shardCount; i++) {
|
|
1092
|
+
const shardTopic = `${this.shardTopicPrefix}${i}`;
|
|
1093
|
+
const root = await this.resolveShardRoot(shardTopic);
|
|
1094
|
+
if (root !== this.publicKeyHash) continue;
|
|
1095
|
+
joins.push(this.ensureFanoutChannel(shardTopic, { pin: true, root }));
|
|
1096
|
+
}
|
|
1097
|
+
await Promise.all(joins);
|
|
1098
|
+
}
|
|
600
1099
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1100
|
+
async subscribe(topic: string) {
|
|
1101
|
+
return this.debounceSubscribeAggregator.add({ key: topic });
|
|
1102
|
+
}
|
|
604
1103
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
(pubsubMessage.topics.length === 0 && meInTOs);
|
|
1104
|
+
private async _subscribe(topics: { key: string; counter: number }[]) {
|
|
1105
|
+
if (!this.started) throw new NotStartedError();
|
|
1106
|
+
if (topics.length === 0) return;
|
|
1107
|
+
|
|
1108
|
+
const byShard = new Map<string, string[]>();
|
|
1109
|
+
const joins: Promise<void>[] = [];
|
|
1110
|
+
for (const { key: topic, counter } of topics) {
|
|
1111
|
+
let prev = this.subscriptions.get(topic);
|
|
1112
|
+
if (prev) {
|
|
1113
|
+
prev.counter += counter;
|
|
1114
|
+
continue;
|
|
617
1115
|
}
|
|
1116
|
+
this.subscriptions.set(topic, { counter });
|
|
1117
|
+
this.initializeTopic(topic);
|
|
1118
|
+
|
|
1119
|
+
const shardTopic = this.getShardTopicForUserTopic(topic);
|
|
1120
|
+
byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
|
|
1121
|
+
this.shardRefCounts.set(
|
|
1122
|
+
shardTopic,
|
|
1123
|
+
(this.shardRefCounts.get(shardTopic) ?? 0) + 1,
|
|
1124
|
+
);
|
|
1125
|
+
joins.push(this.ensureFanoutChannel(shardTopic));
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
await Promise.all(joins);
|
|
1129
|
+
|
|
1130
|
+
// Announce subscriptions per shard overlay.
|
|
1131
|
+
await Promise.all(
|
|
1132
|
+
[...byShard.entries()].map(async ([shardTopic, userTopics]) => {
|
|
1133
|
+
if (userTopics.length === 0) return;
|
|
1134
|
+
const msg = new Subscribe({
|
|
1135
|
+
topics: userTopics,
|
|
1136
|
+
requestSubscribers: true,
|
|
1137
|
+
});
|
|
1138
|
+
const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
|
|
1139
|
+
mode: new AnyWhere(),
|
|
1140
|
+
priority: 1,
|
|
1141
|
+
skipRecipientValidation: true,
|
|
1142
|
+
} as any);
|
|
1143
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
1144
|
+
if (!st)
|
|
1145
|
+
throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
|
|
1146
|
+
await st.channel.publish(toUint8Array(embedded.bytes()));
|
|
1147
|
+
this.touchFanoutChannel(shardTopic);
|
|
1148
|
+
}),
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async unsubscribe(
|
|
1153
|
+
topic: string,
|
|
1154
|
+
options?: {
|
|
1155
|
+
force?: boolean;
|
|
1156
|
+
data?: Uint8Array;
|
|
1157
|
+
},
|
|
1158
|
+
) {
|
|
1159
|
+
if (this.debounceSubscribeAggregator.has(topic)) {
|
|
1160
|
+
this.debounceSubscribeAggregator.delete(topic);
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const sub = this.subscriptions.get(topic);
|
|
1165
|
+
if (!sub) return false;
|
|
1166
|
+
|
|
1167
|
+
if (options?.force) {
|
|
1168
|
+
sub.counter = 0;
|
|
1169
|
+
} else {
|
|
1170
|
+
sub.counter -= 1;
|
|
1171
|
+
}
|
|
1172
|
+
if (sub.counter > 0) return true;
|
|
1173
|
+
|
|
1174
|
+
// Remove local subscription immediately so `publish()`/delivery paths observe
|
|
1175
|
+
// the change without waiting for batched control-plane announces.
|
|
1176
|
+
this.subscriptions.delete(topic);
|
|
1177
|
+
this.untrackTopic(topic);
|
|
1178
|
+
|
|
1179
|
+
// Update shard refcount immediately. The debounced announcer will close the
|
|
1180
|
+
// channel if this was the last local subscription for that shard.
|
|
1181
|
+
const shardTopic = this.getShardTopicForUserTopic(topic);
|
|
1182
|
+
const next = (this.shardRefCounts.get(shardTopic) ?? 0) - 1;
|
|
1183
|
+
if (next <= 0) {
|
|
1184
|
+
this.shardRefCounts.delete(shardTopic);
|
|
1185
|
+
} else {
|
|
1186
|
+
this.shardRefCounts.set(shardTopic, next);
|
|
1187
|
+
}
|
|
618
1188
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1189
|
+
// Best-effort: do not block callers on network I/O (can hang under teardown).
|
|
1190
|
+
void this.debounceUnsubscribeAggregator.add({ key: topic }).catch(logErrorIfStarted);
|
|
1191
|
+
return true;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
private async _announceUnsubscribe(topics: { key: string; counter: number }[]) {
|
|
1195
|
+
if (!this.started) throw new NotStartedError();
|
|
1196
|
+
|
|
1197
|
+
const byShard = new Map<string, string[]>();
|
|
1198
|
+
for (const { key: topic } of topics) {
|
|
1199
|
+
// If the topic got re-subscribed before this debounced batch ran, skip.
|
|
1200
|
+
if (this.subscriptions.has(topic)) continue;
|
|
1201
|
+
const shardTopic = this.getShardTopicForUserTopic(topic);
|
|
1202
|
+
byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
await Promise.all(
|
|
1206
|
+
[...byShard.entries()].map(async ([shardTopic, userTopics]) => {
|
|
1207
|
+
if (userTopics.length === 0) return;
|
|
1208
|
+
|
|
1209
|
+
// Announce first.
|
|
1210
|
+
try {
|
|
1211
|
+
const msg = new Unsubscribe({ topics: userTopics });
|
|
1212
|
+
const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
|
|
1213
|
+
mode: new AnyWhere(),
|
|
1214
|
+
priority: 1,
|
|
1215
|
+
skipRecipientValidation: true,
|
|
1216
|
+
} as any);
|
|
1217
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
1218
|
+
if (st) {
|
|
1219
|
+
// Best-effort: do not let a stuck proxy publish stall teardown.
|
|
1220
|
+
void st.channel
|
|
1221
|
+
.publish(toUint8Array(embedded.bytes()))
|
|
1222
|
+
.catch(() => {});
|
|
1223
|
+
this.touchFanoutChannel(shardTopic);
|
|
1224
|
+
}
|
|
1225
|
+
} catch {
|
|
1226
|
+
// best-effort
|
|
623
1227
|
}
|
|
624
|
-
}
|
|
625
1228
|
|
|
626
|
-
|
|
1229
|
+
// Close shard overlay if no local topics remain.
|
|
1230
|
+
if ((this.shardRefCounts.get(shardTopic) ?? 0) <= 0) {
|
|
1231
|
+
try {
|
|
1232
|
+
// Shutdown should be bounded and not depend on network I/O.
|
|
1233
|
+
await this.closeFanoutChannel(shardTopic);
|
|
1234
|
+
} catch {
|
|
1235
|
+
// best-effort
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}),
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
627
1241
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1242
|
+
getSubscribers(topic: string): PublicSignKey[] | undefined {
|
|
1243
|
+
const t = topic.toString();
|
|
1244
|
+
const remote = this.topics.get(t);
|
|
1245
|
+
const includeSelf = this.subscriptions.has(t);
|
|
1246
|
+
if (!remote || remote.size == 0) {
|
|
1247
|
+
return includeSelf ? [this.publicKey] : undefined;
|
|
1248
|
+
}
|
|
1249
|
+
const ret: PublicSignKey[] = [];
|
|
1250
|
+
for (const v of remote.values()) ret.push(v.publicKey);
|
|
1251
|
+
if (includeSelf) ret.push(this.publicKey);
|
|
1252
|
+
return ret;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async requestSubscribers(
|
|
1256
|
+
topic: string | string[],
|
|
1257
|
+
to?: PublicSignKey,
|
|
1258
|
+
): Promise<void> {
|
|
1259
|
+
if (!this.started) throw new NotStartedError();
|
|
1260
|
+
if (topic == null) throw new Error("ERR_NOT_VALID_TOPIC");
|
|
1261
|
+
if (topic.length === 0) return;
|
|
1262
|
+
|
|
1263
|
+
const topicsAll = (typeof topic === "string" ? [topic] : topic).map((t) =>
|
|
1264
|
+
t.toString(),
|
|
1265
|
+
);
|
|
1266
|
+
for (const t of topicsAll) this.initializeTopic(t);
|
|
1267
|
+
|
|
1268
|
+
const byShard = new Map<string, string[]>();
|
|
1269
|
+
for (const t of topicsAll) {
|
|
1270
|
+
const shardTopic = this.getShardTopicForUserTopic(t);
|
|
1271
|
+
byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), t]);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
await Promise.all(
|
|
1275
|
+
[...byShard.entries()].map(async ([shardTopic, userTopics]) => {
|
|
1276
|
+
const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
|
|
1277
|
+
await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
|
|
1278
|
+
|
|
1279
|
+
const msg = new GetSubscribers({ topics: userTopics });
|
|
1280
|
+
const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
|
|
1281
|
+
mode: new AnyWhere(),
|
|
1282
|
+
priority: 1,
|
|
1283
|
+
skipRecipientValidation: true,
|
|
1284
|
+
} as any);
|
|
1285
|
+
const payload = toUint8Array(embedded.bytes());
|
|
1286
|
+
|
|
1287
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
1288
|
+
if (!st)
|
|
1289
|
+
throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
|
|
1290
|
+
|
|
1291
|
+
if (to) {
|
|
1292
|
+
try {
|
|
1293
|
+
await st.channel.unicastToAck(to.hashcode(), payload, {
|
|
1294
|
+
timeoutMs: 5_000,
|
|
1295
|
+
});
|
|
1296
|
+
} catch {
|
|
1297
|
+
await st.channel.publish(payload);
|
|
1298
|
+
}
|
|
1299
|
+
} else {
|
|
1300
|
+
await st.channel.publish(payload);
|
|
1301
|
+
}
|
|
1302
|
+
this.touchFanoutChannel(shardTopic);
|
|
1303
|
+
}),
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async publish(
|
|
1308
|
+
data: Uint8Array | undefined,
|
|
1309
|
+
options?: {
|
|
1310
|
+
topics: string[];
|
|
1311
|
+
} & { client?: string } & {
|
|
1312
|
+
mode?: SilentDelivery | AcknowledgeDelivery;
|
|
1313
|
+
} & PriorityOptions &
|
|
1314
|
+
IdOptions &
|
|
1315
|
+
WithExtraSigners & { signal?: AbortSignal },
|
|
1316
|
+
): Promise<Uint8Array | undefined> {
|
|
1317
|
+
if (!this.started) throw new NotStartedError();
|
|
1318
|
+
|
|
1319
|
+
const topicsAll =
|
|
1320
|
+
(options as { topics: string[] }).topics?.map((x) => x.toString()) || [];
|
|
1321
|
+
|
|
1322
|
+
const hasExplicitTOs =
|
|
1323
|
+
options?.mode && deliveryModeHasReceiver(options.mode);
|
|
1324
|
+
|
|
1325
|
+
// Explicit recipients: use DirectStream delivery (no shard broadcast).
|
|
1326
|
+
if (hasExplicitTOs || !data) {
|
|
1327
|
+
const msg = data
|
|
1328
|
+
? new PubSubData({ topics: topicsAll, data, strict: true })
|
|
1329
|
+
: undefined;
|
|
1330
|
+
const message = await this.createMessage(msg?.bytes(), {
|
|
1331
|
+
...options,
|
|
1332
|
+
skipRecipientValidation: this.dispatchEventOnSelfPublish,
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
if (msg) {
|
|
1336
|
+
this.dispatchEvent(
|
|
1337
|
+
new CustomEvent("publish", {
|
|
1338
|
+
detail: new PublishEvent({
|
|
1339
|
+
client: options?.client,
|
|
1340
|
+
data: msg,
|
|
1341
|
+
message,
|
|
636
1342
|
}),
|
|
1343
|
+
}),
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const silentDelivery = options?.mode instanceof SilentDelivery;
|
|
1348
|
+
try {
|
|
1349
|
+
await this.publishMessage(
|
|
1350
|
+
this.publicKey,
|
|
1351
|
+
message,
|
|
1352
|
+
undefined,
|
|
1353
|
+
undefined,
|
|
1354
|
+
options?.signal,
|
|
1355
|
+
);
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
if (error instanceof DeliveryError && silentDelivery !== false) {
|
|
1358
|
+
return message.id;
|
|
1359
|
+
}
|
|
1360
|
+
throw error;
|
|
1361
|
+
}
|
|
1362
|
+
return message.id;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (this.fanoutPublishRequiresSubscribe) {
|
|
1366
|
+
for (const t of topicsAll) {
|
|
1367
|
+
if (!this.subscriptions.has(t)) {
|
|
1368
|
+
throw new Error(
|
|
1369
|
+
`Cannot publish to topic ${t} without subscribing (fanoutPublishRequiresSubscribe=true)`,
|
|
637
1370
|
);
|
|
638
1371
|
}
|
|
639
1372
|
}
|
|
1373
|
+
}
|
|
640
1374
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
1375
|
+
const msg = new PubSubData({ topics: topicsAll, data, strict: false });
|
|
1376
|
+
const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
|
|
1377
|
+
mode: new AnyWhere(),
|
|
1378
|
+
priority: options?.priority,
|
|
1379
|
+
id: options?.id,
|
|
1380
|
+
extraSigners: options?.extraSigners,
|
|
1381
|
+
skipRecipientValidation: true,
|
|
1382
|
+
} as any);
|
|
1383
|
+
|
|
1384
|
+
this.dispatchEvent(
|
|
1385
|
+
new CustomEvent("publish", {
|
|
1386
|
+
detail: new PublishEvent({
|
|
1387
|
+
client: options?.client,
|
|
1388
|
+
data: msg,
|
|
1389
|
+
message: embedded,
|
|
1390
|
+
}),
|
|
1391
|
+
}),
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1394
|
+
const byShard = new Map<string, string[]>();
|
|
1395
|
+
for (const t of topicsAll) {
|
|
1396
|
+
const shardTopic = this.getShardTopicForUserTopic(t);
|
|
1397
|
+
byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), t]);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
for (const shardTopic of byShard.keys()) {
|
|
1401
|
+
const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
|
|
1402
|
+
await this.ensureFanoutChannel(shardTopic, {
|
|
1403
|
+
ephemeral: !persistent,
|
|
1404
|
+
signal: options?.signal,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const payload = toUint8Array(embedded.bytes());
|
|
1409
|
+
await Promise.all(
|
|
1410
|
+
[...byShard.keys()].map(async (shardTopic) => {
|
|
1411
|
+
if (options?.signal?.aborted) {
|
|
1412
|
+
throw new AbortError("Publish was aborted");
|
|
1413
|
+
}
|
|
1414
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
1415
|
+
if (!st) {
|
|
1416
|
+
throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
|
|
1417
|
+
}
|
|
1418
|
+
await withAbort(st.channel.publish(payload), options?.signal);
|
|
1419
|
+
this.touchFanoutChannel(shardTopic);
|
|
1420
|
+
}),
|
|
1421
|
+
);
|
|
1422
|
+
|
|
1423
|
+
if (
|
|
1424
|
+
this.fanoutPublishIdleCloseMs == 0 ||
|
|
1425
|
+
this.fanoutPublishMaxEphemeralChannels == 0
|
|
1426
|
+
) {
|
|
1427
|
+
for (const shardTopic of byShard.keys()) {
|
|
1428
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
1429
|
+
if (st?.ephemeral) await this.closeFanoutChannel(shardTopic);
|
|
649
1430
|
}
|
|
1431
|
+
}
|
|
650
1432
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1433
|
+
return embedded.id;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
public onPeerSession(key: PublicSignKey, _session: number): void {
|
|
1437
|
+
this.removeSubscriptions(key);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
public override onPeerUnreachable(publicKeyHash: string) {
|
|
1441
|
+
super.onPeerUnreachable(publicKeyHash);
|
|
1442
|
+
const key = this.peerKeyHashToPublicKey.get(publicKeyHash);
|
|
1443
|
+
if (key) this.removeSubscriptions(key);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
private removeSubscriptions(publicKey: PublicSignKey) {
|
|
1447
|
+
const peerHash = publicKey.hashcode();
|
|
1448
|
+
const peerTopics = this.peerToTopic.get(peerHash);
|
|
1449
|
+
const changed: string[] = [];
|
|
1450
|
+
if (peerTopics) {
|
|
1451
|
+
for (const topic of peerTopics) {
|
|
1452
|
+
const peers = this.topics.get(topic);
|
|
1453
|
+
if (!peers) continue;
|
|
1454
|
+
if (peers.delete(peerHash)) {
|
|
1455
|
+
changed.push(topic);
|
|
1456
|
+
}
|
|
661
1457
|
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
|
|
1458
|
+
}
|
|
1459
|
+
this.peerToTopic.delete(peerHash);
|
|
1460
|
+
this.lastSubscriptionMessages.delete(peerHash);
|
|
1461
|
+
|
|
1462
|
+
if (changed.length > 0) {
|
|
1463
|
+
this.dispatchEvent(
|
|
1464
|
+
new CustomEvent<UnsubcriptionEvent>("unsubscribe", {
|
|
1465
|
+
detail: new UnsubcriptionEvent(publicKey, changed),
|
|
1466
|
+
}),
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
private subscriptionMessageIsLatest(
|
|
1472
|
+
message: DataMessage,
|
|
1473
|
+
pubsubMessage: Subscribe | Unsubscribe,
|
|
1474
|
+
relevantTopics: string[],
|
|
1475
|
+
) {
|
|
1476
|
+
const subscriber = message.header.signatures!.signatures[0].publicKey!;
|
|
1477
|
+
const subscriberKey = subscriber.hashcode();
|
|
1478
|
+
const messageTimestamp = message.header.timestamp;
|
|
1479
|
+
|
|
1480
|
+
for (const topic of relevantTopics) {
|
|
1481
|
+
const lastTimestamp = this.lastSubscriptionMessages
|
|
1482
|
+
.get(subscriberKey)
|
|
1483
|
+
?.get(topic);
|
|
1484
|
+
if (lastTimestamp != null && lastTimestamp > messageTimestamp) {
|
|
665
1485
|
return false;
|
|
666
1486
|
}
|
|
1487
|
+
}
|
|
667
1488
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1489
|
+
for (const topic of relevantTopics) {
|
|
1490
|
+
if (!this.lastSubscriptionMessages.has(subscriberKey)) {
|
|
1491
|
+
this.lastSubscriptionMessages.set(subscriberKey, new Map());
|
|
671
1492
|
}
|
|
1493
|
+
this.lastSubscriptionMessages
|
|
1494
|
+
.get(subscriberKey)!
|
|
1495
|
+
.set(topic, messageTimestamp);
|
|
1496
|
+
}
|
|
1497
|
+
return true;
|
|
1498
|
+
}
|
|
672
1499
|
|
|
673
|
-
|
|
1500
|
+
private async sendFanoutUnicastOrBroadcast(
|
|
1501
|
+
shardTopic: string,
|
|
1502
|
+
targetHash: string,
|
|
1503
|
+
payload: Uint8Array,
|
|
1504
|
+
) {
|
|
1505
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
1506
|
+
if (!st) return;
|
|
1507
|
+
try {
|
|
1508
|
+
await st.channel.unicastToAck(targetHash, payload, { timeoutMs: 5_000 });
|
|
1509
|
+
return;
|
|
1510
|
+
} catch {
|
|
1511
|
+
// ignore and fall back
|
|
1512
|
+
}
|
|
1513
|
+
try {
|
|
1514
|
+
await st.channel.publish(payload);
|
|
1515
|
+
} catch {
|
|
1516
|
+
// ignore
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
674
1519
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
seenBefore === 0 &&
|
|
681
|
-
this.subscriptionMessageIsLatest(message, pubsubMessage) &&
|
|
682
|
-
pubsubMessage.topics.length > 0
|
|
683
|
-
) {
|
|
684
|
-
const changed: string[] = [];
|
|
685
|
-
pubsubMessage.topics.forEach((topic) => {
|
|
686
|
-
const peers = this.topics.get(topic);
|
|
687
|
-
if (peers == null) {
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
this.initializePeer(sender);
|
|
692
|
-
|
|
693
|
-
// if no subscription data, or new subscription has data (and is newer) then overwrite it.
|
|
694
|
-
// subscription where data is undefined is not intended to replace existing data
|
|
695
|
-
const existingSubscription = peers.get(senderKey);
|
|
696
|
-
|
|
697
|
-
if (
|
|
698
|
-
!existingSubscription ||
|
|
699
|
-
existingSubscription.session < message.header.session
|
|
700
|
-
) {
|
|
701
|
-
peers.set(
|
|
702
|
-
senderKey,
|
|
703
|
-
new SubscriptionData({
|
|
704
|
-
session: message.header.session,
|
|
705
|
-
timestamp: message.header.timestamp, // TODO update timestamps on all messages?
|
|
706
|
-
publicKey: sender,
|
|
707
|
-
}),
|
|
708
|
-
);
|
|
709
|
-
|
|
710
|
-
changed.push(topic);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
if (!existingSubscription) {
|
|
714
|
-
this.topicsToPeers.get(topic)?.add(senderKey);
|
|
715
|
-
this.peerToTopic.get(senderKey)?.add(topic);
|
|
716
|
-
}
|
|
717
|
-
});
|
|
1520
|
+
private async processDirectPubSubMessage(input: {
|
|
1521
|
+
pubsubMessage: PubSubMessage;
|
|
1522
|
+
message: DataMessage;
|
|
1523
|
+
}): Promise<void> {
|
|
1524
|
+
const { pubsubMessage, message } = input;
|
|
718
1525
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
1526
|
+
if (pubsubMessage instanceof TopicRootCandidates) {
|
|
1527
|
+
// Used only to converge deterministic shard-root candidates in auto mode.
|
|
1528
|
+
this.mergeAutoTopicRootCandidatesFromPeer(pubsubMessage.candidates);
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (pubsubMessage instanceof PubSubData) {
|
|
1533
|
+
this.dispatchEvent(
|
|
1534
|
+
new CustomEvent("data", {
|
|
1535
|
+
detail: new DataEvent({
|
|
1536
|
+
data: pubsubMessage,
|
|
1537
|
+
message,
|
|
1538
|
+
}),
|
|
1539
|
+
}),
|
|
1540
|
+
);
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
private async processShardPubSubMessage(input: {
|
|
1546
|
+
pubsubMessage: PubSubMessage;
|
|
1547
|
+
message: DataMessage;
|
|
1548
|
+
from: PublicSignKey;
|
|
1549
|
+
shardTopic: string;
|
|
1550
|
+
}): Promise<void> {
|
|
1551
|
+
const { pubsubMessage, message, from, shardTopic } = input;
|
|
1552
|
+
|
|
1553
|
+
if (pubsubMessage instanceof PubSubData) {
|
|
1554
|
+
this.dispatchEvent(
|
|
1555
|
+
new CustomEvent("data", {
|
|
1556
|
+
detail: new DataEvent({
|
|
1557
|
+
data: pubsubMessage,
|
|
1558
|
+
message,
|
|
1559
|
+
}),
|
|
1560
|
+
}),
|
|
1561
|
+
);
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if (pubsubMessage instanceof Subscribe) {
|
|
1566
|
+
const sender = from;
|
|
1567
|
+
const senderKey = sender.hashcode();
|
|
1568
|
+
const relevantTopics = pubsubMessage.topics.filter((t) =>
|
|
1569
|
+
this.isTrackedTopic(t),
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
if (
|
|
1573
|
+
relevantTopics.length > 0 &&
|
|
1574
|
+
this.subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics)
|
|
1575
|
+
) {
|
|
1576
|
+
const changed: string[] = [];
|
|
1577
|
+
for (const topic of relevantTopics) {
|
|
1578
|
+
const peers = this.topics.get(topic);
|
|
1579
|
+
if (!peers) continue;
|
|
1580
|
+
this.initializePeer(sender);
|
|
1581
|
+
|
|
1582
|
+
const existing = peers.get(senderKey);
|
|
1583
|
+
if (!existing || existing.session < message.header.session) {
|
|
1584
|
+
peers.delete(senderKey);
|
|
1585
|
+
peers.set(
|
|
1586
|
+
senderKey,
|
|
1587
|
+
new SubscriptionData({
|
|
1588
|
+
session: message.header.session,
|
|
1589
|
+
timestamp: message.header.timestamp,
|
|
1590
|
+
publicKey: sender,
|
|
723
1591
|
}),
|
|
724
1592
|
);
|
|
1593
|
+
changed.push(topic);
|
|
1594
|
+
} else {
|
|
1595
|
+
peers.delete(senderKey);
|
|
1596
|
+
peers.set(senderKey, existing);
|
|
725
1597
|
}
|
|
726
1598
|
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
const mySubscriptions = this.getSubscriptionOverlap(
|
|
730
|
-
pubsubMessage.topics,
|
|
731
|
-
);
|
|
732
|
-
if (mySubscriptions.length > 0) {
|
|
733
|
-
const response = new DataMessage({
|
|
734
|
-
data: toUint8Array(
|
|
735
|
-
new Subscribe({
|
|
736
|
-
topics: mySubscriptions,
|
|
737
|
-
requestSubscribers: false,
|
|
738
|
-
}).bytes(),
|
|
739
|
-
),
|
|
740
|
-
// needs to be Ack or Silent else we will run into a infite message loop
|
|
741
|
-
header: new MessageHeader({
|
|
742
|
-
session: this.session,
|
|
743
|
-
priority: 1,
|
|
744
|
-
mode: new SeekDelivery({
|
|
745
|
-
redundancy: 2,
|
|
746
|
-
to: [senderKey],
|
|
747
|
-
}),
|
|
748
|
-
}),
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
this.publishMessage(
|
|
752
|
-
this.publicKey,
|
|
753
|
-
await response.sign(this.sign),
|
|
754
|
-
).catch(dontThrowIfDeliveryError);
|
|
755
|
-
}
|
|
1599
|
+
if (!existing) {
|
|
1600
|
+
this.peerToTopic.get(senderKey)!.add(topic);
|
|
756
1601
|
}
|
|
1602
|
+
this.pruneTopicSubscribers(topic);
|
|
757
1603
|
}
|
|
758
1604
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const change = this.deletePeerFromTopic(unsubscription, senderKey);
|
|
768
|
-
if (change) {
|
|
769
|
-
changed.push(unsubscription);
|
|
770
|
-
}
|
|
771
|
-
}
|
|
1605
|
+
if (changed.length > 0) {
|
|
1606
|
+
this.dispatchEvent(
|
|
1607
|
+
new CustomEvent<SubscriptionEvent>("subscribe", {
|
|
1608
|
+
detail: new SubscriptionEvent(sender, changed),
|
|
1609
|
+
}),
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
772
1613
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
1614
|
+
if (pubsubMessage.requestSubscribers) {
|
|
1615
|
+
const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
|
|
1616
|
+
if (overlap.length > 0) {
|
|
1617
|
+
const response = new Subscribe({
|
|
1618
|
+
topics: overlap,
|
|
1619
|
+
requestSubscribers: false,
|
|
1620
|
+
});
|
|
1621
|
+
const embedded = await this.createMessage(
|
|
1622
|
+
toUint8Array(response.bytes()),
|
|
1623
|
+
{
|
|
1624
|
+
mode: new AnyWhere(),
|
|
1625
|
+
priority: 1,
|
|
1626
|
+
skipRecipientValidation: true,
|
|
1627
|
+
} as any,
|
|
1628
|
+
);
|
|
1629
|
+
const payload = toUint8Array(embedded.bytes());
|
|
1630
|
+
await this.sendFanoutUnicastOrBroadcast(
|
|
1631
|
+
shardTopic,
|
|
1632
|
+
senderKey,
|
|
1633
|
+
payload,
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
780
1639
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1640
|
+
if (pubsubMessage instanceof Unsubscribe) {
|
|
1641
|
+
const sender = from;
|
|
1642
|
+
const senderKey = sender.hashcode();
|
|
1643
|
+
const relevantTopics = pubsubMessage.topics.filter((t) =>
|
|
1644
|
+
this.isTrackedTopic(t),
|
|
1645
|
+
);
|
|
1646
|
+
|
|
1647
|
+
if (
|
|
1648
|
+
relevantTopics.length > 0 &&
|
|
1649
|
+
this.subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics)
|
|
1650
|
+
) {
|
|
1651
|
+
const changed: string[] = [];
|
|
1652
|
+
for (const topic of relevantTopics) {
|
|
1653
|
+
const peers = this.topics.get(topic);
|
|
1654
|
+
if (!peers) continue;
|
|
1655
|
+
if (peers.delete(senderKey)) {
|
|
1656
|
+
changed.push(topic);
|
|
1657
|
+
this.peerToTopic.get(senderKey)?.delete(topic);
|
|
793
1658
|
}
|
|
794
1659
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
this.publicKey,
|
|
806
|
-
await new DataMessage({
|
|
807
|
-
data: toUint8Array(
|
|
808
|
-
new Subscribe({
|
|
809
|
-
topics: subscriptionsToSend,
|
|
810
|
-
requestSubscribers: false,
|
|
811
|
-
}).bytes(),
|
|
812
|
-
),
|
|
813
|
-
header: new MessageHeader({
|
|
814
|
-
priority: 1,
|
|
815
|
-
mode: new SilentDelivery({
|
|
816
|
-
redundancy: 2,
|
|
817
|
-
to: [sender.hashcode()],
|
|
818
|
-
}),
|
|
819
|
-
session: this.session,
|
|
820
|
-
}),
|
|
821
|
-
}).sign(this.sign),
|
|
822
|
-
[stream],
|
|
823
|
-
).catch(dontThrowIfDeliveryError); // send back to same stream
|
|
1660
|
+
if (!this.peerToTopic.get(senderKey)?.size) {
|
|
1661
|
+
this.peerToTopic.delete(senderKey);
|
|
1662
|
+
this.lastSubscriptionMessages.delete(senderKey);
|
|
1663
|
+
}
|
|
1664
|
+
if (changed.length > 0) {
|
|
1665
|
+
this.dispatchEvent(
|
|
1666
|
+
new CustomEvent<UnsubcriptionEvent>("unsubscribe", {
|
|
1667
|
+
detail: new UnsubcriptionEvent(sender, changed),
|
|
1668
|
+
}),
|
|
1669
|
+
);
|
|
824
1670
|
}
|
|
1671
|
+
}
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
if (pubsubMessage instanceof GetSubscribers) {
|
|
1676
|
+
const sender = from;
|
|
1677
|
+
const senderKey = sender.hashcode();
|
|
1678
|
+
const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
|
|
1679
|
+
if (overlap.length === 0) return;
|
|
1680
|
+
|
|
1681
|
+
const response = new Subscribe({
|
|
1682
|
+
topics: overlap,
|
|
1683
|
+
requestSubscribers: false,
|
|
1684
|
+
});
|
|
1685
|
+
const embedded = await this.createMessage(
|
|
1686
|
+
toUint8Array(response.bytes()),
|
|
1687
|
+
{
|
|
1688
|
+
mode: new AnyWhere(),
|
|
1689
|
+
priority: 1,
|
|
1690
|
+
skipRecipientValidation: true,
|
|
1691
|
+
} as any,
|
|
1692
|
+
);
|
|
1693
|
+
const payload = toUint8Array(embedded.bytes());
|
|
1694
|
+
await this.sendFanoutUnicastOrBroadcast(shardTopic, senderKey, payload);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
public override async onDataMessage(
|
|
1700
|
+
from: PublicSignKey,
|
|
1701
|
+
stream: PeerStreams,
|
|
1702
|
+
message: DataMessage,
|
|
1703
|
+
seenBefore: number,
|
|
1704
|
+
) {
|
|
1705
|
+
if (!message.data || message.data.length === 0) {
|
|
1706
|
+
return super.onDataMessage(from, stream, message, seenBefore);
|
|
1707
|
+
}
|
|
1708
|
+
if (this.shouldIgnore(message, seenBefore)) return false;
|
|
1709
|
+
|
|
1710
|
+
let pubsubMessage: PubSubMessage;
|
|
1711
|
+
try {
|
|
1712
|
+
pubsubMessage = PubSubMessage.from(message.data);
|
|
1713
|
+
} catch {
|
|
1714
|
+
return super.onDataMessage(from, stream, message, seenBefore);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// DirectStream only supports targeted pubsub data and a small set of utility
|
|
1718
|
+
// messages. All membership/control traffic is shard-only.
|
|
1719
|
+
if (
|
|
1720
|
+
!(pubsubMessage instanceof PubSubData) &&
|
|
1721
|
+
!(pubsubMessage instanceof TopicRootCandidates)
|
|
1722
|
+
) {
|
|
1723
|
+
return true;
|
|
1724
|
+
}
|
|
825
1725
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1726
|
+
// Determine if this node should process it.
|
|
1727
|
+
let isForMe = false;
|
|
1728
|
+
if (deliveryModeHasReceiver(message.header.mode)) {
|
|
1729
|
+
isForMe = message.header.mode.to.includes(this.publicKeyHash);
|
|
1730
|
+
} else if (
|
|
1731
|
+
message.header.mode instanceof AnyWhere ||
|
|
1732
|
+
message.header.mode instanceof AcknowledgeAnyWhere
|
|
1733
|
+
) {
|
|
1734
|
+
isForMe = true;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (pubsubMessage instanceof PubSubData) {
|
|
1738
|
+
const wantsTopic = pubsubMessage.topics.some((t) =>
|
|
1739
|
+
this.subscriptions.has(t),
|
|
1740
|
+
);
|
|
1741
|
+
isForMe = pubsubMessage.strict ? isForMe && wantsTopic : wantsTopic;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (isForMe) {
|
|
1745
|
+
if ((await this.verifyAndProcess(message)) === false) return false;
|
|
1746
|
+
await this.maybeAcknowledgeMessage(stream, message, seenBefore);
|
|
1747
|
+
if (seenBefore === 0) {
|
|
1748
|
+
await this.processDirectPubSubMessage({ pubsubMessage, message });
|
|
829
1749
|
}
|
|
830
1750
|
}
|
|
1751
|
+
|
|
1752
|
+
// Forward direct PubSubData only (subscription control lives on fanout shards).
|
|
1753
|
+
if (!(pubsubMessage instanceof PubSubData)) {
|
|
1754
|
+
return true;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
if (
|
|
1758
|
+
message.header.mode instanceof SilentDelivery ||
|
|
1759
|
+
message.header.mode instanceof AcknowledgeDelivery
|
|
1760
|
+
) {
|
|
1761
|
+
if (
|
|
1762
|
+
message.header.mode.to.length === 1 &&
|
|
1763
|
+
message.header.mode.to[0] === this.publicKeyHash
|
|
1764
|
+
) {
|
|
1765
|
+
return true;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const shouldForward =
|
|
1770
|
+
seenBefore === 0 ||
|
|
1771
|
+
((message.header.mode instanceof AcknowledgeDelivery ||
|
|
1772
|
+
message.header.mode instanceof AcknowledgeAnyWhere) &&
|
|
1773
|
+
seenBefore < message.header.mode.redundancy);
|
|
1774
|
+
if (shouldForward) {
|
|
1775
|
+
this.relayMessage(from, message).catch(logErrorIfStarted);
|
|
1776
|
+
}
|
|
831
1777
|
return true;
|
|
832
1778
|
}
|
|
833
1779
|
}
|
|
@@ -861,48 +1807,146 @@ export const waitForSubscribers = async (
|
|
|
861
1807
|
: getPublicKeyFromPeerId(id).hashcode();
|
|
862
1808
|
});
|
|
863
1809
|
|
|
864
|
-
// await libp2p.services.pubsub.requestSubscribers(topic);
|
|
865
1810
|
return new Promise<void>((resolve, reject) => {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
});
|
|
870
|
-
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
871
|
-
if (options?.timeout) {
|
|
872
|
-
timeout = setTimeout(() => {
|
|
873
|
-
reject(new TimeoutError("waitForSubscribers timed out"));
|
|
874
|
-
}, options.timeout);
|
|
875
|
-
options.signal?.addEventListener("abort", () => {
|
|
876
|
-
clearTimeout(timeout);
|
|
877
|
-
});
|
|
1811
|
+
if (peerIdsToWait.length === 0) {
|
|
1812
|
+
resolve();
|
|
1813
|
+
return;
|
|
878
1814
|
}
|
|
1815
|
+
|
|
1816
|
+
let settled = false;
|
|
1817
|
+
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
1818
|
+
let interval: ReturnType<typeof setInterval> | undefined = undefined;
|
|
1819
|
+
let pollInFlight = false;
|
|
1820
|
+
const wanted = new Set(peerIdsToWait);
|
|
1821
|
+
const seen = new Set<string>();
|
|
1822
|
+
const pubsub = libp2p.services.pubsub;
|
|
1823
|
+
const shouldRejectWithTimeoutError = options?.timeout != null;
|
|
1824
|
+
|
|
879
1825
|
const clear = () => {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
if (counter > 100) {
|
|
1826
|
+
if (timeout) {
|
|
1827
|
+
clearTimeout(timeout);
|
|
1828
|
+
timeout = undefined;
|
|
1829
|
+
}
|
|
1830
|
+
if (interval) {
|
|
886
1831
|
clearInterval(interval);
|
|
887
|
-
|
|
888
|
-
new Error("Failed to find expected subscribers for topic: " + topic),
|
|
889
|
-
);
|
|
1832
|
+
interval = undefined;
|
|
890
1833
|
}
|
|
1834
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
891
1835
|
try {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1836
|
+
pubsub.removeEventListener("subscribe", onSubscribe);
|
|
1837
|
+
pubsub.removeEventListener("unsubscribe", onUnsubscribe);
|
|
1838
|
+
} catch {
|
|
1839
|
+
// ignore
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
const resolveOnce = () => {
|
|
1844
|
+
if (settled) return;
|
|
1845
|
+
settled = true;
|
|
1846
|
+
clear();
|
|
1847
|
+
resolve();
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1850
|
+
const rejectOnce = (error: unknown) => {
|
|
1851
|
+
if (settled) return;
|
|
1852
|
+
settled = true;
|
|
1853
|
+
clear();
|
|
1854
|
+
reject(error);
|
|
1855
|
+
};
|
|
1856
|
+
|
|
1857
|
+
const onAbort = () => {
|
|
1858
|
+
rejectOnce(new AbortError("waitForSubscribers was aborted"));
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
const updateSeen = (hash?: string, isSubscribed?: boolean) => {
|
|
1862
|
+
if (!hash) return false;
|
|
1863
|
+
if (!wanted.has(hash)) return false;
|
|
1864
|
+
if (isSubscribed) {
|
|
1865
|
+
seen.add(hash);
|
|
1866
|
+
} else {
|
|
1867
|
+
seen.delete(hash);
|
|
1868
|
+
}
|
|
1869
|
+
return seen.size === wanted.size;
|
|
1870
|
+
};
|
|
1871
|
+
|
|
1872
|
+
const reconcileFromSubscribers = (peers?: PublicSignKey[]) => {
|
|
1873
|
+
const current = new Set<string>();
|
|
1874
|
+
for (const peer of peers || []) current.add(peer.hashcode());
|
|
1875
|
+
for (const hash of wanted) {
|
|
1876
|
+
if (current.has(hash)) seen.add(hash);
|
|
1877
|
+
else seen.delete(hash);
|
|
905
1878
|
}
|
|
906
|
-
|
|
1879
|
+
if (seen.size === wanted.size) resolveOnce();
|
|
1880
|
+
};
|
|
1881
|
+
|
|
1882
|
+
const onSubscribe = (ev: any) => {
|
|
1883
|
+
const detail = ev?.detail as SubscriptionEvent | undefined;
|
|
1884
|
+
if (!detail) return;
|
|
1885
|
+
if (!detail.topics || detail.topics.length === 0) return;
|
|
1886
|
+
if (!detail.topics.includes(topic)) return;
|
|
1887
|
+
const hash = detail.from?.hashcode?.();
|
|
1888
|
+
if (updateSeen(hash, true)) {
|
|
1889
|
+
resolveOnce();
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
const onUnsubscribe = (ev: any) => {
|
|
1894
|
+
const detail = ev?.detail as UnsubcriptionEvent | undefined;
|
|
1895
|
+
if (!detail) return;
|
|
1896
|
+
if (!detail.topics || detail.topics.length === 0) return;
|
|
1897
|
+
if (!detail.topics.includes(topic)) return;
|
|
1898
|
+
const hash = detail.from?.hashcode?.();
|
|
1899
|
+
updateSeen(hash, false);
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
if (options?.signal?.aborted) {
|
|
1903
|
+
rejectOnce(new AbortError("waitForSubscribers was aborted"));
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
options?.signal?.addEventListener("abort", onAbort);
|
|
1908
|
+
|
|
1909
|
+
// Preserve previous behavior: without an explicit timeout, fail after ~20s.
|
|
1910
|
+
const timeoutMs = Math.max(0, Math.floor(options?.timeout ?? 20_000));
|
|
1911
|
+
if (timeoutMs > 0) {
|
|
1912
|
+
timeout = setTimeout(() => {
|
|
1913
|
+
rejectOnce(
|
|
1914
|
+
shouldRejectWithTimeoutError
|
|
1915
|
+
? new TimeoutError("waitForSubscribers timed out")
|
|
1916
|
+
: new Error(
|
|
1917
|
+
"Failed to find expected subscribers for topic: " + topic,
|
|
1918
|
+
),
|
|
1919
|
+
);
|
|
1920
|
+
}, timeoutMs);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// Observe new subscriptions.
|
|
1924
|
+
try {
|
|
1925
|
+
void pubsub.addEventListener("subscribe", onSubscribe);
|
|
1926
|
+
void pubsub.addEventListener("unsubscribe", onUnsubscribe);
|
|
1927
|
+
} catch (e) {
|
|
1928
|
+
rejectOnce(e);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const poll = () => {
|
|
1933
|
+
if (settled) return;
|
|
1934
|
+
if (pollInFlight) return;
|
|
1935
|
+
pollInFlight = true;
|
|
1936
|
+
void Promise.resolve(pubsub.getSubscribers(topic))
|
|
1937
|
+
.then((peers) => {
|
|
1938
|
+
if (settled) return;
|
|
1939
|
+
reconcileFromSubscribers(peers || []);
|
|
1940
|
+
})
|
|
1941
|
+
.catch((e) => rejectOnce(e))
|
|
1942
|
+
.finally(() => {
|
|
1943
|
+
pollInFlight = false;
|
|
1944
|
+
});
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
// Polling is a fallback for cases where no event is emitted (e.g. local subscribe completion),
|
|
1948
|
+
// and keeps behavior stable across implementations.
|
|
1949
|
+
poll();
|
|
1950
|
+
interval = setInterval(poll, 200);
|
|
907
1951
|
});
|
|
908
1952
|
};
|