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