@peerbit/pubsub 5.2.11 → 5.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +3 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +90 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +2 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +712 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +2 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +1591 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +2 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +297 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +73 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +1 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +183 -0
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +1 -0
- package/dist/benchmark/fanout-tree-sim-lib.d.ts +101 -0
- package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +1 -1
- package/dist/benchmark/fanout-tree-sim-lib.js +540 -38
- package/dist/benchmark/fanout-tree-sim-lib.js.map +1 -1
- package/dist/benchmark/fanout-tree-sim.d.ts +0 -9
- package/dist/benchmark/fanout-tree-sim.d.ts.map +1 -1
- package/dist/benchmark/fanout-tree-sim.js +369 -28
- package/dist/benchmark/fanout-tree-sim.js.map +1 -1
- package/dist/benchmark/index.js +20 -0
- package/dist/benchmark/index.js.map +1 -1
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts +4 -0
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +1 -1
- package/dist/benchmark/pubsub-topic-sim-lib.js +58 -3
- package/dist/benchmark/pubsub-topic-sim-lib.js.map +1 -1
- package/dist/benchmark/pubsub-topic-sim.js +18 -0
- package/dist/benchmark/pubsub-topic-sim.js.map +1 -1
- package/dist/src/debounced-set.d.ts +1 -1
- package/dist/src/debounced-set.d.ts.map +1 -1
- package/dist/src/debounced-set.js +5 -1
- package/dist/src/debounced-set.js.map +1 -1
- package/dist/src/fanout-tree-codec.d.ts +330 -0
- package/dist/src/fanout-tree-codec.d.ts.map +1 -0
- package/dist/src/fanout-tree-codec.js +1209 -0
- package/dist/src/fanout-tree-codec.js.map +1 -0
- package/dist/src/fanout-tree-parent-upgrade.d.ts +117 -0
- package/dist/src/fanout-tree-parent-upgrade.d.ts.map +1 -0
- package/dist/src/fanout-tree-parent-upgrade.js +135 -0
- package/dist/src/fanout-tree-parent-upgrade.js.map +1 -0
- package/dist/src/fanout-tree.d.ts +245 -73
- package/dist/src/fanout-tree.d.ts.map +1 -1
- package/dist/src/fanout-tree.js +2290 -1057
- package/dist/src/fanout-tree.js.map +1 -1
- package/dist/src/index.d.ts +58 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +316 -45
- package/dist/src/index.js.map +1 -1
- package/dist/src/topic-root-control-plane.d.ts +13 -0
- package/dist/src/topic-root-control-plane.d.ts.map +1 -1
- package/dist/src/topic-root-control-plane.js +45 -0
- package/dist/src/topic-root-control-plane.js.map +1 -1
- package/package.json +9 -12
- package/src/debounced-set.ts +5 -0
- package/src/fanout-tree-codec.ts +1580 -0
- package/src/fanout-tree-parent-upgrade.ts +353 -0
- package/src/fanout-tree.ts +4558 -2230
- package/src/index.ts +425 -59
- package/src/topic-root-control-plane.ts +48 -0
package/dist/src/index.js
CHANGED
|
@@ -11,11 +11,28 @@ import { debouncedAccumulatorSetCounter, } from "./debounced-set.js";
|
|
|
11
11
|
import { FanoutChannel } from "./fanout-channel.js";
|
|
12
12
|
import { TopicRootControlPlane } from "./topic-root-control-plane.js";
|
|
13
13
|
export * from "./fanout-tree.js";
|
|
14
|
+
// The complete /peerbit/fanout-tree/0.5.0 wire codec and the
|
|
15
|
+
// parent-upgrade decision module (the TS references the native rust-core
|
|
16
|
+
// implementations are parity-tested against).
|
|
17
|
+
export * as fanoutWire from "./fanout-tree-codec.js";
|
|
18
|
+
export * as fanoutParentUpgrade from "./fanout-tree-parent-upgrade.js";
|
|
14
19
|
export * from "./fanout-channel.js";
|
|
15
20
|
export * from "./topic-root-control-plane.js";
|
|
16
21
|
export { toUint8Array } from "./bytes.js";
|
|
17
22
|
export const logger = loggerFn("peerbit:transport:topic-control-plane");
|
|
18
23
|
const warn = logger.newScope("warn");
|
|
24
|
+
const utf8Encoder = new TextEncoder();
|
|
25
|
+
/**
|
|
26
|
+
* Offset of the `data` bytes inside a borsh-serialized `PubSubData`
|
|
27
|
+
* (variant byte + topics vec + strict bool + data length prefix).
|
|
28
|
+
*/
|
|
29
|
+
const preEncodedPubSubDataOffset = (topics) => {
|
|
30
|
+
let offset = 1 + 4; // variant + topics vec length
|
|
31
|
+
for (const topic of topics) {
|
|
32
|
+
offset += 4 + utf8Encoder.encode(topic).byteLength;
|
|
33
|
+
}
|
|
34
|
+
return offset + 1 + 4; // strict + data length prefix
|
|
35
|
+
};
|
|
19
36
|
const logError = (e) => {
|
|
20
37
|
logger.error(e?.message);
|
|
21
38
|
};
|
|
@@ -62,6 +79,7 @@ const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
|
|
|
62
79
|
// Topic-root queries may need to wait for the responder to finish opening an
|
|
63
80
|
// outbound stream back to the requester after an inbound-only dial.
|
|
64
81
|
const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
|
|
82
|
+
const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
|
|
65
83
|
const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS = {
|
|
66
84
|
msgRate: 30,
|
|
67
85
|
msgSize: 1024,
|
|
@@ -89,11 +107,26 @@ export class TopicControlPlane extends DirectStream {
|
|
|
89
107
|
subscriptions;
|
|
90
108
|
// Local topics requested via debounced subscribe, not yet applied in `subscriptions`.
|
|
91
109
|
pendingSubscriptions;
|
|
110
|
+
/**
|
|
111
|
+
* Per-shard batching of Subscribe{requestSubscribers} responses. When many
|
|
112
|
+
* peers announce subscriptions in a burst (group subscribe), answering each
|
|
113
|
+
* announcer with a routed unicast is quadratic in subscriber count and
|
|
114
|
+
* melts the fanout route-resolution machinery (#983); a short window lets
|
|
115
|
+
* one broadcast answer all concurrent announcers instead.
|
|
116
|
+
*/
|
|
117
|
+
pendingSubscriberResponses = new Map();
|
|
92
118
|
lastSubscriptionMessages = new Map();
|
|
93
119
|
dispatchEventOnSelfPublish;
|
|
94
120
|
topicRootControlPlane;
|
|
95
121
|
subscriberCacheMaxEntries;
|
|
96
122
|
fanout;
|
|
123
|
+
// Native topic-control components of the rust-core DirectStream engine
|
|
124
|
+
// (`@peerbit/network-rust`). When set, the PubSubMessage codec, the topic
|
|
125
|
+
// hashing behind shard mapping and root selection, the root-directory
|
|
126
|
+
// state and the subscribe-state convergence rules run natively; the
|
|
127
|
+
// observable subscription maps and all events stay unchanged. Unset (the
|
|
128
|
+
// default) leaves every code path as-is.
|
|
129
|
+
nativeTopicControl;
|
|
97
130
|
debounceSubscribeAggregator;
|
|
98
131
|
debounceUnsubscribeAggregator;
|
|
99
132
|
shardCount;
|
|
@@ -132,6 +165,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
132
165
|
this.peerToTopic = new Map();
|
|
133
166
|
this.topicRootControlPlane =
|
|
134
167
|
props?.topicRootControlPlane || new TopicRootControlPlane();
|
|
168
|
+
this.nativeTopicControl = this.rustCore?.topicControl;
|
|
169
|
+
if (this.nativeTopicControl) {
|
|
170
|
+
this.topicRootControlPlane.adoptNativeDirectoryState(this.nativeTopicControl.createRootDirectoryState());
|
|
171
|
+
}
|
|
135
172
|
this.dispatchEventOnSelfPublish =
|
|
136
173
|
props?.dispatchEventOnSelfPublish || false;
|
|
137
174
|
if (!props?.fanout) {
|
|
@@ -173,10 +210,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
173
210
|
this.fanoutPublishMaxEphemeralChannels = Math.max(0, Math.floor(requestedMaxEphemeral));
|
|
174
211
|
const requestedSubscriberCacheMaxEntries = props?.subscriberCacheMaxEntries ?? SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES;
|
|
175
212
|
this.subscriberCacheMaxEntries = Math.min(SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP, Math.max(1, Math.floor(requestedSubscriberCacheMaxEntries)));
|
|
176
|
-
this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._subscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50);
|
|
213
|
+
this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._subscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50, (error) => logger.error(`Debounced subscribe failed: ${error?.message ?? error}`));
|
|
177
214
|
// NOTE: Unsubscribe should update local state immediately and batch only the
|
|
178
215
|
// best-effort network announcements to avoid teardown stalls (program close).
|
|
179
|
-
this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._announceUnsubscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50);
|
|
216
|
+
this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._announceUnsubscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50, (error) => logger.error(`Debounced unsubscribe announce failed: ${error?.message ?? error}`));
|
|
180
217
|
}
|
|
181
218
|
/**
|
|
182
219
|
* Configure deterministic topic-root candidates and disable the pubsub "auto"
|
|
@@ -268,6 +305,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
268
305
|
this.hostOwnedShardRootsDirty = false;
|
|
269
306
|
this.subscriptions.clear();
|
|
270
307
|
this.pendingSubscriptions.clear();
|
|
308
|
+
for (const entry of this.pendingSubscriberResponses.values()) {
|
|
309
|
+
clearTimeout(entry.timer);
|
|
310
|
+
}
|
|
311
|
+
this.pendingSubscriberResponses.clear();
|
|
271
312
|
this.topics.clear();
|
|
272
313
|
this.peerToTopic.clear();
|
|
273
314
|
this.lastSubscriptionMessages.clear();
|
|
@@ -362,6 +403,9 @@ export class TopicControlPlane extends DirectStream {
|
|
|
362
403
|
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
363
404
|
}
|
|
364
405
|
normalizeAutoTopicRootCandidates(candidates) {
|
|
406
|
+
if (this.nativeTopicControl) {
|
|
407
|
+
return this.nativeTopicControl.normalizeAutoCandidates(candidates, this.publicKeyHash);
|
|
408
|
+
}
|
|
365
409
|
const unique = new Set();
|
|
366
410
|
for (const c of candidates) {
|
|
367
411
|
if (!c)
|
|
@@ -430,7 +474,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
430
474
|
if (candidates.length === 0)
|
|
431
475
|
return;
|
|
432
476
|
const msg = new TopicRootCandidates({ candidates });
|
|
433
|
-
const embedded = await this.createMessage(
|
|
477
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
434
478
|
mode: new AnyWhere(),
|
|
435
479
|
priority: 1,
|
|
436
480
|
skipRecipientValidation: true,
|
|
@@ -527,7 +571,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
527
571
|
topics: userTopics,
|
|
528
572
|
requestSubscribers: true,
|
|
529
573
|
});
|
|
530
|
-
const embedded = await this.createMessage(
|
|
574
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
531
575
|
mode: new AnyWhere(),
|
|
532
576
|
priority: 1,
|
|
533
577
|
skipRecipientValidation: true,
|
|
@@ -671,11 +715,95 @@ export class TopicControlPlane extends DirectStream {
|
|
|
671
715
|
const cached = this.shardTopicCache.get(t);
|
|
672
716
|
if (cached)
|
|
673
717
|
return cached;
|
|
674
|
-
const
|
|
675
|
-
|
|
718
|
+
const shardTopic = this.nativeTopicControl
|
|
719
|
+
? this.nativeTopicControl.shardTopic(t, this.shardCount, this.shardTopicPrefix)
|
|
720
|
+
: `${this.shardTopicPrefix}${topicHash32(t) % this.shardCount}`;
|
|
676
721
|
this.shardTopicCache.set(t, shardTopic);
|
|
677
722
|
return shardTopic;
|
|
678
723
|
}
|
|
724
|
+
/**
|
|
725
|
+
* Serialize a control-plane message (borsh `PubSubMessage`); rust-core
|
|
726
|
+
* mode encodes natively (byte-identical, covered by the parity suite).
|
|
727
|
+
*/
|
|
728
|
+
encodePubSubMessage(message) {
|
|
729
|
+
const native = this.nativeTopicControl;
|
|
730
|
+
if (native) {
|
|
731
|
+
if (message instanceof PubSubData) {
|
|
732
|
+
return native.encodePubSubData(message.topics, message.strict, message.data);
|
|
733
|
+
}
|
|
734
|
+
if (message instanceof Subscribe) {
|
|
735
|
+
return native.encodeSubscribe(message.topics, message.requestSubscribers);
|
|
736
|
+
}
|
|
737
|
+
if (message instanceof Unsubscribe) {
|
|
738
|
+
return native.encodeUnsubscribe(message.topics);
|
|
739
|
+
}
|
|
740
|
+
if (message instanceof GetSubscribers) {
|
|
741
|
+
return native.encodeGetSubscribers(message.topics);
|
|
742
|
+
}
|
|
743
|
+
if (message instanceof TopicRootCandidates) {
|
|
744
|
+
return native.encodeTopicRootCandidates(message.candidates);
|
|
745
|
+
}
|
|
746
|
+
if (message instanceof PeerUnavailable) {
|
|
747
|
+
return native.encodePeerUnavailable(message.publicKeyHash, message.session, message.timestamp, message.topics);
|
|
748
|
+
}
|
|
749
|
+
if (message instanceof TopicRootQuery) {
|
|
750
|
+
return native.encodeTopicRootQuery(message.requestId, message.topic);
|
|
751
|
+
}
|
|
752
|
+
if (message instanceof TopicRootQueryResponse) {
|
|
753
|
+
return native.encodeTopicRootQueryResponse(message.requestId, message.topic, message.root);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return toUint8Array(message.bytes());
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Parse a control-plane payload; rust-core mode decodes natively into the
|
|
760
|
+
* same message classes (decode failures throw either way, so callers keep
|
|
761
|
+
* their fallback behavior).
|
|
762
|
+
*/
|
|
763
|
+
decodePubSubMessage(bytes) {
|
|
764
|
+
const native = this.nativeTopicControl;
|
|
765
|
+
if (native) {
|
|
766
|
+
const decoded = native.decodePubSubMessage(bytes);
|
|
767
|
+
switch (decoded.type) {
|
|
768
|
+
case "data":
|
|
769
|
+
return new PubSubData({
|
|
770
|
+
topics: decoded.topics,
|
|
771
|
+
data: decoded.data,
|
|
772
|
+
strict: decoded.strict,
|
|
773
|
+
});
|
|
774
|
+
case "subscribe":
|
|
775
|
+
return new Subscribe({
|
|
776
|
+
topics: decoded.topics,
|
|
777
|
+
requestSubscribers: decoded.requestSubscribers,
|
|
778
|
+
});
|
|
779
|
+
case "unsubscribe":
|
|
780
|
+
return new Unsubscribe({ topics: decoded.topics });
|
|
781
|
+
case "get-subscribers":
|
|
782
|
+
return new GetSubscribers({ topics: decoded.topics });
|
|
783
|
+
case "topic-root-candidates":
|
|
784
|
+
return new TopicRootCandidates({ candidates: decoded.candidates });
|
|
785
|
+
case "peer-unavailable":
|
|
786
|
+
return new PeerUnavailable({
|
|
787
|
+
publicKeyHash: decoded.publicKeyHash,
|
|
788
|
+
session: decoded.session,
|
|
789
|
+
timestamp: decoded.timestamp,
|
|
790
|
+
topics: decoded.topics,
|
|
791
|
+
});
|
|
792
|
+
case "topic-root-query":
|
|
793
|
+
return new TopicRootQuery({
|
|
794
|
+
requestId: decoded.requestId,
|
|
795
|
+
topic: decoded.topic,
|
|
796
|
+
});
|
|
797
|
+
case "topic-root-query-response":
|
|
798
|
+
return new TopicRootQueryResponse({
|
|
799
|
+
requestId: decoded.requestId,
|
|
800
|
+
topic: decoded.topic,
|
|
801
|
+
root: decoded.root,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return PubSubMessage.from(bytes);
|
|
806
|
+
}
|
|
679
807
|
async resolveTopicRootState(topic) {
|
|
680
808
|
const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic);
|
|
681
809
|
if (tracked) {
|
|
@@ -747,7 +875,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
747
875
|
} while (true);
|
|
748
876
|
}
|
|
749
877
|
async sendDirectControlMessage(peer, pubsubMessage) {
|
|
750
|
-
const embedded = await this.createMessage(
|
|
878
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(pubsubMessage), {
|
|
751
879
|
mode: new SilentDelivery({
|
|
752
880
|
to: [peer.publicKey.hashcode()],
|
|
753
881
|
redundancy: 1,
|
|
@@ -767,7 +895,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
767
895
|
pending.resolve(message.root);
|
|
768
896
|
return true;
|
|
769
897
|
}
|
|
770
|
-
async queryTopicRootFromPeer(peer, topic) {
|
|
898
|
+
async queryTopicRootFromPeer(peer, topic, timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS) {
|
|
771
899
|
if (!this.started || this.stopping)
|
|
772
900
|
return undefined;
|
|
773
901
|
const requestId = this.nextTopicRootRequestIdValue();
|
|
@@ -775,7 +903,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
775
903
|
const timer = setTimeout(() => {
|
|
776
904
|
this.pendingTopicRootQueries.delete(requestId);
|
|
777
905
|
resolve(undefined);
|
|
778
|
-
},
|
|
906
|
+
}, Math.max(1, Math.floor(timeoutMs)));
|
|
779
907
|
timer.unref?.();
|
|
780
908
|
this.pendingTopicRootQueries.set(requestId, { topic, resolve, timer });
|
|
781
909
|
});
|
|
@@ -792,6 +920,23 @@ export class TopicControlPlane extends DirectStream {
|
|
|
792
920
|
}
|
|
793
921
|
return responsePromise;
|
|
794
922
|
}
|
|
923
|
+
async confirmDirectShardRoot(shardTopic, root, signal) {
|
|
924
|
+
if (root === this.publicKeyHash)
|
|
925
|
+
return root;
|
|
926
|
+
const rootPeer = this.peers.get(root);
|
|
927
|
+
if (!rootPeer)
|
|
928
|
+
return root;
|
|
929
|
+
const confirmation = await withAbort(this.queryTopicRootFromPeer(rootPeer, shardTopic, DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS), signal);
|
|
930
|
+
if (!confirmation)
|
|
931
|
+
return root;
|
|
932
|
+
if (confirmation !== root) {
|
|
933
|
+
this.shardRootCache.set(shardTopic, {
|
|
934
|
+
root: confirmation,
|
|
935
|
+
authoritative: true,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return confirmation;
|
|
939
|
+
}
|
|
795
940
|
async resolveQueryableTopicRoot(topic) {
|
|
796
941
|
const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
|
|
797
942
|
if (root !== this.publicKeyHash) {
|
|
@@ -828,6 +973,8 @@ export class TopicControlPlane extends DirectStream {
|
|
|
828
973
|
return undefined;
|
|
829
974
|
}
|
|
830
975
|
async ensureFanoutChannel(shardTopic, options) {
|
|
976
|
+
if (!this.started)
|
|
977
|
+
throw new NotStartedError();
|
|
831
978
|
const t = shardTopic.toString();
|
|
832
979
|
const pin = options?.pin === true;
|
|
833
980
|
const wantEphemeral = options?.ephemeral === true;
|
|
@@ -856,6 +1003,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
856
1003
|
await withAbort(this.closeFanoutChannel(t, { force: true }), options?.signal);
|
|
857
1004
|
}
|
|
858
1005
|
root = root ?? (await this.resolveShardRoot(t));
|
|
1006
|
+
root = await this.confirmDirectShardRoot(t, root, options?.signal);
|
|
859
1007
|
const channel = new FanoutChannel(this.fanout, { topic: t, root });
|
|
860
1008
|
const onPayload = (payload) => {
|
|
861
1009
|
let dm;
|
|
@@ -874,7 +1022,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
874
1022
|
return;
|
|
875
1023
|
let pubsubMessage;
|
|
876
1024
|
try {
|
|
877
|
-
pubsubMessage =
|
|
1025
|
+
pubsubMessage = this.decodePubSubMessage(dm.data);
|
|
878
1026
|
}
|
|
879
1027
|
catch {
|
|
880
1028
|
return;
|
|
@@ -915,6 +1063,9 @@ export class TopicControlPlane extends DirectStream {
|
|
|
915
1063
|
this.seenCache.add(msgId, seen ? seen + 1 : 1);
|
|
916
1064
|
if (seen)
|
|
917
1065
|
return;
|
|
1066
|
+
// fanout-delivered frames bypass the inbound stream decode, so
|
|
1067
|
+
// the nested envelope is verified natively here when possible
|
|
1068
|
+
this.seedNativeWireVerification(dm, payload);
|
|
918
1069
|
if ((await this.verifyAndProcess(dm)) === false) {
|
|
919
1070
|
return;
|
|
920
1071
|
}
|
|
@@ -983,10 +1134,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
983
1134
|
// ignore
|
|
984
1135
|
}
|
|
985
1136
|
try {
|
|
986
|
-
channel.
|
|
1137
|
+
await channel.leave({ notifyParent: false, kickChildren: false });
|
|
987
1138
|
}
|
|
988
1139
|
catch {
|
|
989
|
-
|
|
1140
|
+
channel.close();
|
|
990
1141
|
}
|
|
991
1142
|
throw error;
|
|
992
1143
|
}
|
|
@@ -1106,7 +1257,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1106
1257
|
topics: userTopics,
|
|
1107
1258
|
requestSubscribers: true,
|
|
1108
1259
|
});
|
|
1109
|
-
const embedded = await this.createMessage(
|
|
1260
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
1110
1261
|
mode: new AnyWhere(),
|
|
1111
1262
|
priority: 1,
|
|
1112
1263
|
skipRecipientValidation: true,
|
|
@@ -1173,7 +1324,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1173
1324
|
// Announce first.
|
|
1174
1325
|
try {
|
|
1175
1326
|
const msg = new Unsubscribe({ topics: userTopics });
|
|
1176
|
-
const embedded = await this.createMessage(
|
|
1327
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
1177
1328
|
mode: new AnyWhere(),
|
|
1178
1329
|
priority: 1,
|
|
1179
1330
|
skipRecipientValidation: true,
|
|
@@ -1238,7 +1389,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1238
1389
|
timestamp: batch.timestamp,
|
|
1239
1390
|
topics: batch.topics,
|
|
1240
1391
|
});
|
|
1241
|
-
const embedded = await this.createMessage(
|
|
1392
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
1242
1393
|
mode: new AnyWhere(),
|
|
1243
1394
|
priority: 1,
|
|
1244
1395
|
skipRecipientValidation: true,
|
|
@@ -1265,7 +1416,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1265
1416
|
timestamp: 0n,
|
|
1266
1417
|
topics: [],
|
|
1267
1418
|
});
|
|
1268
|
-
const embedded = await this.createMessage(
|
|
1419
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
1269
1420
|
mode: new AnyWhere(),
|
|
1270
1421
|
priority: 1,
|
|
1271
1422
|
skipRecipientValidation: true,
|
|
@@ -1349,7 +1500,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1349
1500
|
}
|
|
1350
1501
|
const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
|
|
1351
1502
|
await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
|
|
1352
|
-
const embedded = await this.createMessage(
|
|
1503
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
1353
1504
|
mode: new AnyWhere(),
|
|
1354
1505
|
priority: 1,
|
|
1355
1506
|
skipRecipientValidation: true,
|
|
@@ -1384,7 +1535,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1384
1535
|
const msg = data
|
|
1385
1536
|
? new PubSubData({ topics: topicsAll, data, strict: true })
|
|
1386
1537
|
: undefined;
|
|
1387
|
-
const message = await this.createMessage(msg
|
|
1538
|
+
const message = await this.createMessage(msg ? this.encodePubSubMessage(msg) : undefined, {
|
|
1388
1539
|
...options,
|
|
1389
1540
|
skipRecipientValidation: this.dispatchEventOnSelfPublish,
|
|
1390
1541
|
});
|
|
@@ -1417,7 +1568,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1417
1568
|
}
|
|
1418
1569
|
}
|
|
1419
1570
|
const msg = new PubSubData({ topics: topicsAll, data, strict: false });
|
|
1420
|
-
const embedded = await this.createMessage(
|
|
1571
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
1421
1572
|
mode: new AnyWhere(),
|
|
1422
1573
|
priority: options?.priority,
|
|
1423
1574
|
responsePriority: options?.responsePriority,
|
|
@@ -1467,6 +1618,50 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1467
1618
|
}
|
|
1468
1619
|
return embedded.id;
|
|
1469
1620
|
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Publish a pre-encoded `PubSubData` payload to explicit recipients.
|
|
1623
|
+
*
|
|
1624
|
+
* `payload` must be the exact borsh serialization of
|
|
1625
|
+
* `new PubSubData({ topics, data, strict: true })` — callers that encode
|
|
1626
|
+
* the payload elsewhere (e.g. the shared-log fused send path serializing
|
|
1627
|
+
* inside wasm) hand the finished bytes here so the wrapping `PubSubData`
|
|
1628
|
+
* copy of the regular `publish` path is skipped. Everything else matches
|
|
1629
|
+
* the explicit-recipient branch of {@link publish}: the payload becomes a
|
|
1630
|
+
* signed `DataMessage` delivered over DirectStream.
|
|
1631
|
+
*/
|
|
1632
|
+
async publishPreEncodedData(payload, properties, options) {
|
|
1633
|
+
if (!this.started)
|
|
1634
|
+
throw new NotStartedError();
|
|
1635
|
+
if (!options?.mode || !deliveryModeHasReceiver(options.mode)) {
|
|
1636
|
+
throw new Error("publishPreEncodedData requires a delivery mode with explicit recipients");
|
|
1637
|
+
}
|
|
1638
|
+
const message = await this.createMessage(payload, {
|
|
1639
|
+
...options,
|
|
1640
|
+
skipRecipientValidation: this.dispatchEventOnSelfPublish,
|
|
1641
|
+
});
|
|
1642
|
+
this.dispatchEvent(new CustomEvent("publish", {
|
|
1643
|
+
detail: new PublishEvent({
|
|
1644
|
+
client: options?.client,
|
|
1645
|
+
data: new PubSubData({
|
|
1646
|
+
topics: properties.topics,
|
|
1647
|
+
data: payload.subarray(preEncodedPubSubDataOffset(properties.topics)),
|
|
1648
|
+
strict: true,
|
|
1649
|
+
}),
|
|
1650
|
+
message,
|
|
1651
|
+
}),
|
|
1652
|
+
}));
|
|
1653
|
+
const silentDelivery = options.mode instanceof SilentDelivery;
|
|
1654
|
+
try {
|
|
1655
|
+
await this.publishMessage(this.publicKey, message, undefined, undefined, options?.signal);
|
|
1656
|
+
}
|
|
1657
|
+
catch (error) {
|
|
1658
|
+
if (error instanceof DeliveryError && silentDelivery !== false) {
|
|
1659
|
+
return message.id;
|
|
1660
|
+
}
|
|
1661
|
+
throw error;
|
|
1662
|
+
}
|
|
1663
|
+
return message.id;
|
|
1664
|
+
}
|
|
1470
1665
|
onPeerSession(key, _session) {
|
|
1471
1666
|
if (!Number.isFinite(_session) || _session < 0) {
|
|
1472
1667
|
return;
|
|
@@ -1575,20 +1770,36 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1575
1770
|
return changed;
|
|
1576
1771
|
}
|
|
1577
1772
|
subscriptionStateIsLatest(subscriberKey, session, timestamp, relevantTopics) {
|
|
1578
|
-
|
|
1579
|
-
const
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1773
|
+
if (this.nativeTopicControl) {
|
|
1774
|
+
const lasts = [];
|
|
1775
|
+
for (const topic of relevantTopics) {
|
|
1776
|
+
const last = this.lastSubscriptionMessages
|
|
1777
|
+
.get(subscriberKey)
|
|
1778
|
+
?.get(topic);
|
|
1779
|
+
if (last) {
|
|
1780
|
+
lasts.push(last.session, last.timestamp);
|
|
1781
|
+
}
|
|
1584
1782
|
}
|
|
1585
|
-
if (
|
|
1783
|
+
if (!this.nativeTopicControl.subscriptionIsLatest(BigUint64Array.from(lasts), session, timestamp)) {
|
|
1586
1784
|
return false;
|
|
1587
1785
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1786
|
+
}
|
|
1787
|
+
else {
|
|
1788
|
+
for (const topic of relevantTopics) {
|
|
1789
|
+
const last = this.lastSubscriptionMessages
|
|
1790
|
+
.get(subscriberKey)
|
|
1791
|
+
?.get(topic);
|
|
1792
|
+
if (!last) {
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
if (last.session > session) {
|
|
1796
|
+
return false;
|
|
1797
|
+
}
|
|
1798
|
+
if (timestamp !== 0n &&
|
|
1799
|
+
last.session === session &&
|
|
1800
|
+
last.timestamp > timestamp) {
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1592
1803
|
}
|
|
1593
1804
|
}
|
|
1594
1805
|
for (const topic of relevantTopics) {
|
|
@@ -1604,10 +1815,80 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1604
1815
|
}
|
|
1605
1816
|
return true;
|
|
1606
1817
|
}
|
|
1818
|
+
/**
|
|
1819
|
+
* `Subscribe` replaces tracked subscription data for new subscribers or
|
|
1820
|
+
* strictly newer sessions; equal/older sessions only refresh cache order.
|
|
1821
|
+
*/
|
|
1822
|
+
subscribeShouldReplace(existing, session) {
|
|
1823
|
+
if (this.nativeTopicControl) {
|
|
1824
|
+
return this.nativeTopicControl.subscribeShouldReplace(existing?.session, session);
|
|
1825
|
+
}
|
|
1826
|
+
return !existing || existing.session < session;
|
|
1827
|
+
}
|
|
1607
1828
|
subscriptionMessageIsLatest(message, _pubsubMessage, relevantTopics) {
|
|
1608
1829
|
const subscriber = message.header.signatures.signatures[0].publicKey;
|
|
1609
1830
|
return this.subscriptionStateIsLatest(subscriber.hashcode(), message.header.session, message.header.timestamp, relevantTopics);
|
|
1610
1831
|
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Answer Subscribe{requestSubscribers} announces without letting bursts go
|
|
1834
|
+
* quadratic (#983). Leading edge: the first announcer is answered
|
|
1835
|
+
* immediately with a targeted unicast — a lone joiner sees no added
|
|
1836
|
+
* latency. Announcers arriving within the trailing window that this opens
|
|
1837
|
+
* are batched and answered with ONE broadcast, instead of S(S-1) routed
|
|
1838
|
+
* unicasts whose cold route resolutions flood the shard tree.
|
|
1839
|
+
*/
|
|
1840
|
+
queueSubscriberResponse(shardTopic, targetHash, topics) {
|
|
1841
|
+
let entry = this.pendingSubscriberResponses.get(shardTopic);
|
|
1842
|
+
if (!entry) {
|
|
1843
|
+
const created = {
|
|
1844
|
+
targets: new Set(),
|
|
1845
|
+
topics: new Set(),
|
|
1846
|
+
timer: setTimeout(() => {
|
|
1847
|
+
this.pendingSubscriberResponses.delete(shardTopic);
|
|
1848
|
+
if (created.targets.size === 0)
|
|
1849
|
+
return;
|
|
1850
|
+
void this.flushSubscriberResponses(shardTopic, created).catch(logErrorIfStarted);
|
|
1851
|
+
}, 150 + Math.floor(Math.random() * 150)),
|
|
1852
|
+
};
|
|
1853
|
+
this.pendingSubscriberResponses.set(shardTopic, created);
|
|
1854
|
+
// Leading edge: respond to the burst opener right away.
|
|
1855
|
+
void this.flushSubscriberResponses(shardTopic, {
|
|
1856
|
+
targets: new Set([targetHash]),
|
|
1857
|
+
topics: new Set(topics),
|
|
1858
|
+
}).catch(logErrorIfStarted);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
entry.targets.add(targetHash);
|
|
1862
|
+
for (const t of topics)
|
|
1863
|
+
entry.topics.add(t);
|
|
1864
|
+
}
|
|
1865
|
+
async flushSubscriberResponses(shardTopic, entry) {
|
|
1866
|
+
if (!this.started)
|
|
1867
|
+
return;
|
|
1868
|
+
// Re-filter: our subscriptions may have changed within the window.
|
|
1869
|
+
const topics = this.getSubscriptionOverlap([...entry.topics]);
|
|
1870
|
+
if (topics.length === 0)
|
|
1871
|
+
return;
|
|
1872
|
+
const response = new Subscribe({
|
|
1873
|
+
topics,
|
|
1874
|
+
requestSubscribers: false,
|
|
1875
|
+
});
|
|
1876
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(response), {
|
|
1877
|
+
mode: new AnyWhere(),
|
|
1878
|
+
priority: 1,
|
|
1879
|
+
skipRecipientValidation: true,
|
|
1880
|
+
});
|
|
1881
|
+
const payload = toUint8Array(embedded.bytes());
|
|
1882
|
+
const [firstTarget] = entry.targets;
|
|
1883
|
+
if (entry.targets.size === 1 && firstTarget) {
|
|
1884
|
+
await this.sendFanoutUnicastOrBroadcast(shardTopic, firstTarget, payload);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
1888
|
+
if (!st)
|
|
1889
|
+
return;
|
|
1890
|
+
await st.channel.publishMaybe(payload);
|
|
1891
|
+
}
|
|
1611
1892
|
async sendFanoutUnicastOrBroadcast(shardTopic, targetHash, payload) {
|
|
1612
1893
|
const st = this.fanoutChannels.get(shardTopic);
|
|
1613
1894
|
if (!st)
|
|
@@ -1667,7 +1948,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1667
1948
|
continue;
|
|
1668
1949
|
this.initializePeer(sender);
|
|
1669
1950
|
const existing = peers.get(senderKey);
|
|
1670
|
-
if (
|
|
1951
|
+
if (this.subscribeShouldReplace(existing, message.header.session)) {
|
|
1671
1952
|
peers.delete(senderKey);
|
|
1672
1953
|
peers.set(senderKey, new SubscriptionData({
|
|
1673
1954
|
session: message.header.session,
|
|
@@ -1746,7 +2027,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1746
2027
|
continue;
|
|
1747
2028
|
this.initializePeer(sender);
|
|
1748
2029
|
const existing = peers.get(senderKey);
|
|
1749
|
-
if (
|
|
2030
|
+
if (this.subscribeShouldReplace(existing, message.header.session)) {
|
|
1750
2031
|
peers.delete(senderKey);
|
|
1751
2032
|
peers.set(senderKey, new SubscriptionData({
|
|
1752
2033
|
session: message.header.session,
|
|
@@ -1773,17 +2054,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1773
2054
|
if (pubsubMessage.requestSubscribers) {
|
|
1774
2055
|
const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
|
|
1775
2056
|
if (overlap.length > 0) {
|
|
1776
|
-
|
|
1777
|
-
topics: overlap,
|
|
1778
|
-
requestSubscribers: false,
|
|
1779
|
-
});
|
|
1780
|
-
const embedded = await this.createMessage(toUint8Array(response.bytes()), {
|
|
1781
|
-
mode: new AnyWhere(),
|
|
1782
|
-
priority: 1,
|
|
1783
|
-
skipRecipientValidation: true,
|
|
1784
|
-
});
|
|
1785
|
-
const payload = toUint8Array(embedded.bytes());
|
|
1786
|
-
await this.sendFanoutUnicastOrBroadcast(shardTopic, senderKey, payload);
|
|
2057
|
+
this.queueSubscriberResponse(shardTopic, senderKey, overlap);
|
|
1787
2058
|
}
|
|
1788
2059
|
}
|
|
1789
2060
|
return;
|
|
@@ -1873,7 +2144,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1873
2144
|
topics: overlap,
|
|
1874
2145
|
requestSubscribers: false,
|
|
1875
2146
|
});
|
|
1876
|
-
const embedded = await this.createMessage(
|
|
2147
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(response), {
|
|
1877
2148
|
mode: new AnyWhere(),
|
|
1878
2149
|
priority: 1,
|
|
1879
2150
|
skipRecipientValidation: true,
|
|
@@ -1891,7 +2162,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1891
2162
|
return false;
|
|
1892
2163
|
let pubsubMessage;
|
|
1893
2164
|
try {
|
|
1894
|
-
pubsubMessage =
|
|
2165
|
+
pubsubMessage = this.decodePubSubMessage(message.data);
|
|
1895
2166
|
}
|
|
1896
2167
|
catch {
|
|
1897
2168
|
return super.onDataMessage(from, stream, message, seenBefore);
|