@peerbit/pubsub 5.0.8 → 5.1.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/src/index.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  SubscriptionData,
18
18
  SubscriptionEvent,
19
19
  TopicRootCandidates,
20
+ TopicRootQuery,
21
+ TopicRootQueryResponse,
20
22
  UnsubcriptionEvent,
21
23
  type UnsubscriptionReason,
22
24
  Unsubscribe,
@@ -26,7 +28,6 @@ import {
26
28
  type DirectStreamComponents,
27
29
  type DirectStreamOptions,
28
30
  type PeerStreams,
29
- dontThrowIfDeliveryError,
30
31
  } from "@peerbit/stream";
31
32
  import {
32
33
  AcknowledgeAnyWhere,
@@ -116,6 +117,9 @@ const DEFAULT_PUBSUB_SHARD_COUNT = 256;
116
117
  const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
117
118
  const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
118
119
  const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
120
+ // Topic-root queries may need to wait for the responder to finish opening an
121
+ // outbound stream back to the requester after an inbound-only dial.
122
+ const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
119
123
 
120
124
  const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS: Omit<
121
125
  FanoutTreeChannelOptions,
@@ -250,7 +254,10 @@ export class TopicControlPlane
250
254
  private readonly shardCount: number;
251
255
  private readonly shardTopicPrefix: string;
252
256
  private readonly hostShards: boolean;
253
- private readonly shardRootCache = new Map<string, string>();
257
+ private readonly shardRootCache = new Map<
258
+ string,
259
+ { root: string; authoritative: boolean }
260
+ >();
254
261
  private readonly shardTopicCache = new Map<string, string>();
255
262
  private readonly shardRefCounts = new Map<string, number>();
256
263
  private readonly pinnedShards = new Set<string>();
@@ -297,6 +304,15 @@ export class TopicControlPlane
297
304
  idleCloseTimeout?: ReturnType<typeof setTimeout>;
298
305
  }
299
306
  >();
307
+ private pendingTopicRootQueries = new Map<
308
+ number,
309
+ {
310
+ topic: string;
311
+ resolve: (root: string | undefined) => void;
312
+ timer: ReturnType<typeof setTimeout>;
313
+ }
314
+ >();
315
+ private nextTopicRootQueryId = 1;
300
316
 
301
317
  constructor(
302
318
  components: TopicControlPlaneComponents,
@@ -489,6 +505,11 @@ export class TopicControlPlane
489
505
  this.shardTopicCache.clear();
490
506
  this.shardRefCounts.clear();
491
507
  this.pinnedShards.clear();
508
+ for (const pending of this.pendingTopicRootQueries.values()) {
509
+ clearTimeout(pending.timer);
510
+ pending.resolve(undefined);
511
+ }
512
+ this.pendingTopicRootQueries.clear();
492
513
 
493
514
  this.debounceSubscribeAggregator.close();
494
515
  this.debounceUnsubscribeAggregator.close();
@@ -665,9 +686,7 @@ export class TopicControlPlane
665
686
  priority: 1,
666
687
  skipRecipientValidation: true,
667
688
  } as any);
668
- await this.publishMessage(this.publicKey, embedded, streams).catch(
669
- dontThrowIfDeliveryError,
670
- );
689
+ await this.publishMessageMaybe(this.publicKey, embedded, streams);
671
690
  }
672
691
 
673
692
  private mergeAutoTopicRootCandidatesFromPeer(candidates: string[]): boolean {
@@ -913,6 +932,41 @@ export class TopicControlPlane
913
932
  return shardTopic;
914
933
  }
915
934
 
935
+ private async resolveTopicRootState(
936
+ topic: string,
937
+ ): Promise<{ root?: string; authoritative: boolean }> {
938
+ const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic);
939
+ if (tracked) {
940
+ return { root: tracked, authoritative: true };
941
+ }
942
+
943
+ const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(topic);
944
+ if (resolvedThroughPeers) {
945
+ return { root: resolvedThroughPeers, authoritative: true };
946
+ }
947
+
948
+ const deterministic = this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
949
+ if (
950
+ deterministic === this.publicKeyHash &&
951
+ this.autoTopicRootCandidates &&
952
+ this.getConnectedTopicRootTrackers().length > 0
953
+ ) {
954
+ for (let attempt = 0; attempt < 8; attempt++) {
955
+ await delay(150 * (attempt < 4 ? 1 : 2));
956
+ const retried = await this.resolveTopicRootThroughPeers(topic);
957
+ if (retried) {
958
+ return { root: retried, authoritative: true };
959
+ }
960
+ }
961
+ }
962
+
963
+ return { root: deterministic, authoritative: false };
964
+ }
965
+
966
+ public async resolveTopicRoot(topic: string): Promise<string | undefined> {
967
+ return (await this.resolveTopicRootState(topic)).root;
968
+ }
969
+
916
970
  private async resolveShardRoot(shardTopic: string): Promise<string> {
917
971
  // If someone configured topic-root candidates externally (e.g. TestSession router
918
972
  // selection or Peerbit.bootstrap) after this peer entered auto mode, disable auto
@@ -921,17 +975,134 @@ export class TopicControlPlane
921
975
  this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured();
922
976
  }
923
977
 
978
+ const hasConnectedTrackers = this.getConnectedTopicRootTrackers().length > 0;
924
979
  const cached = this.shardRootCache.get(shardTopic);
925
- if (cached) return cached;
926
- const resolved =
927
- await this.topicRootControlPlane.resolveTopicRoot(shardTopic);
928
- if (!resolved) {
980
+ if (cached && (cached.authoritative || !hasConnectedTrackers)) {
981
+ return cached.root;
982
+ }
983
+ const resolved = await this.resolveTopicRootState(shardTopic);
984
+ if (!resolved.root) {
929
985
  throw new Error(
930
- `No root resolved for shard topic ${shardTopic}. Configure TopicRootControlPlane candidates/resolver/trackers.`,
986
+ `No root resolved for shard topic ${shardTopic}. Configure TopicRootControlPlane candidates/resolver/trackers, or dial/bootstrap a peer that can resolve shard roots.`,
931
987
  );
932
988
  }
933
- this.shardRootCache.set(shardTopic, resolved);
934
- return resolved;
989
+ if (resolved.authoritative || !hasConnectedTrackers) {
990
+ this.shardRootCache.set(shardTopic, resolved as {
991
+ root: string;
992
+ authoritative: boolean;
993
+ });
994
+ } else {
995
+ this.shardRootCache.delete(shardTopic);
996
+ }
997
+ return resolved.root;
998
+ }
999
+
1000
+ private getConnectedTopicRootTrackers(): PeerStreams[] {
1001
+ return [...this.peers.values()]
1002
+ .filter((peer) => peer.isReadable || peer.isWritable)
1003
+ .sort((a, b) =>
1004
+ a.publicKey.hashcode() < b.publicKey.hashcode()
1005
+ ? -1
1006
+ : a.publicKey.hashcode() > b.publicKey.hashcode()
1007
+ ? 1
1008
+ : 0,
1009
+ );
1010
+ }
1011
+
1012
+ private nextTopicRootRequestIdValue() {
1013
+ let requestId = this.nextTopicRootQueryId >>> 0;
1014
+ do {
1015
+ requestId = requestId === 0 ? 1 : requestId;
1016
+ this.nextTopicRootQueryId = ((requestId + 1) >>> 0) || 1;
1017
+ if (!this.pendingTopicRootQueries.has(requestId)) {
1018
+ return requestId;
1019
+ }
1020
+ requestId = this.nextTopicRootQueryId >>> 0;
1021
+ } while (true);
1022
+ }
1023
+
1024
+ private async sendDirectControlMessage(
1025
+ peer: PeerStreams,
1026
+ pubsubMessage: PubSubMessage,
1027
+ ) {
1028
+ const embedded = await this.createMessage(toUint8Array(pubsubMessage.bytes()), {
1029
+ mode: new SilentDelivery({
1030
+ to: [peer.publicKey.hashcode()],
1031
+ redundancy: 1,
1032
+ }),
1033
+ priority: 1,
1034
+ skipRecipientValidation: true,
1035
+ } as any);
1036
+ await this.publishMessage(this.publicKey, embedded, [peer]);
1037
+ }
1038
+
1039
+ private resolvePendingTopicRootQuery(
1040
+ message: TopicRootQueryResponse,
1041
+ ): boolean {
1042
+ const pending = this.pendingTopicRootQueries.get(message.requestId);
1043
+ if (!pending || pending.topic !== message.topic) {
1044
+ return false;
1045
+ }
1046
+ this.pendingTopicRootQueries.delete(message.requestId);
1047
+ clearTimeout(pending.timer);
1048
+ pending.resolve(message.root);
1049
+ return true;
1050
+ }
1051
+
1052
+ private async queryTopicRootFromPeer(
1053
+ peer: PeerStreams,
1054
+ topic: string,
1055
+ ): Promise<string | undefined> {
1056
+ if (!this.started || this.stopping) return undefined;
1057
+
1058
+ const requestId = this.nextTopicRootRequestIdValue();
1059
+ const responsePromise = new Promise<string | undefined>((resolve) => {
1060
+ const timer = setTimeout(() => {
1061
+ this.pendingTopicRootQueries.delete(requestId);
1062
+ resolve(undefined);
1063
+ }, DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS);
1064
+ timer.unref?.();
1065
+ this.pendingTopicRootQueries.set(requestId, { topic, resolve, timer });
1066
+ });
1067
+
1068
+ try {
1069
+ await this.sendDirectControlMessage(
1070
+ peer,
1071
+ new TopicRootQuery({ requestId, topic }),
1072
+ );
1073
+ } catch {
1074
+ const pending = this.pendingTopicRootQueries.get(requestId);
1075
+ if (pending) {
1076
+ this.pendingTopicRootQueries.delete(requestId);
1077
+ clearTimeout(pending.timer);
1078
+ pending.resolve(undefined);
1079
+ }
1080
+ }
1081
+
1082
+ return responsePromise;
1083
+ }
1084
+
1085
+ private async resolveTopicRootThroughPeers(
1086
+ topic: string,
1087
+ ): Promise<string | undefined> {
1088
+ const peers = this.getConnectedTopicRootTrackers();
1089
+ if (peers.length === 0) {
1090
+ return undefined;
1091
+ }
1092
+
1093
+ for (let attempt = 0; attempt < 3; attempt++) {
1094
+ for (const peer of peers) {
1095
+ const resolved = await this.queryTopicRootFromPeer(peer, topic);
1096
+ if (resolved) {
1097
+ return resolved;
1098
+ }
1099
+ }
1100
+
1101
+ if (attempt < 2) {
1102
+ await delay(150 * (attempt + 1));
1103
+ }
1104
+ }
1105
+ return undefined;
935
1106
  }
936
1107
 
937
1108
  private async ensureFanoutChannel(
@@ -1385,7 +1556,7 @@ export class TopicControlPlane
1385
1556
  await this.ensureFanoutChannel(shardTopic, { ephemeral: true });
1386
1557
  const st = this.fanoutChannels.get(shardTopic);
1387
1558
  if (st) {
1388
- void st.channel.publish(toUint8Array(embedded.bytes())).catch(() => {});
1559
+ void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
1389
1560
  this.touchFanoutChannel(shardTopic);
1390
1561
  }
1391
1562
  } catch {
@@ -1415,7 +1586,7 @@ export class TopicControlPlane
1415
1586
  await this.ensureFanoutChannel(shardTopic, { ephemeral: true });
1416
1587
  const st = this.fanoutChannels.get(shardTopic);
1417
1588
  if (st) {
1418
- void st.channel.publish(toUint8Array(embedded.bytes())).catch(() => {});
1589
+ void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
1419
1590
  this.touchFanoutChannel(shardTopic);
1420
1591
  }
1421
1592
  } catch {
@@ -1498,10 +1669,16 @@ export class TopicControlPlane
1498
1669
 
1499
1670
  await Promise.all(
1500
1671
  [...byShard.entries()].map(async ([shardTopic, userTopics]) => {
1672
+ const msg = new GetSubscribers({ topics: userTopics });
1673
+ const directPeer = to ? this.peers.get(to.hashcode()) : undefined;
1674
+ if (directPeer) {
1675
+ await this.sendDirectControlMessage(directPeer, msg);
1676
+ return;
1677
+ }
1678
+
1501
1679
  const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
1502
1680
  await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
1503
1681
 
1504
- const msg = new GetSubscribers({ topics: userTopics });
1505
1682
  const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1506
1683
  mode: new AnyWhere(),
1507
1684
  priority: 1,
@@ -1519,7 +1696,7 @@ export class TopicControlPlane
1519
1696
  timeoutMs: 5_000,
1520
1697
  });
1521
1698
  } catch {
1522
- await st.channel.publish(payload);
1699
+ await st.channel.publishMaybe(payload);
1523
1700
  }
1524
1701
  } else {
1525
1702
  await st.channel.publish(payload);
@@ -1874,18 +2051,16 @@ export class TopicControlPlane
1874
2051
  } catch {
1875
2052
  // ignore and fall back
1876
2053
  }
1877
- try {
1878
- await st.channel.publish(payload);
1879
- } catch {
1880
- // ignore
1881
- }
2054
+ await st.channel.publishMaybe(payload);
1882
2055
  }
1883
2056
 
1884
2057
  private async processDirectPubSubMessage(input: {
1885
2058
  pubsubMessage: PubSubMessage;
1886
2059
  message: DataMessage;
2060
+ from: PublicSignKey;
2061
+ stream: PeerStreams;
1887
2062
  }): Promise<void> {
1888
- const { pubsubMessage, message } = input;
2063
+ const { pubsubMessage, message, from, stream } = input;
1889
2064
 
1890
2065
  if (pubsubMessage instanceof TopicRootCandidates) {
1891
2066
  // Used only to converge deterministic shard-root candidates in auto mode.
@@ -1893,6 +2068,103 @@ export class TopicControlPlane
1893
2068
  return;
1894
2069
  }
1895
2070
 
2071
+ if (pubsubMessage instanceof TopicRootQuery) {
2072
+ const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(
2073
+ pubsubMessage.topic,
2074
+ );
2075
+ await this.sendDirectControlMessage(
2076
+ stream,
2077
+ new TopicRootQueryResponse({
2078
+ requestId: pubsubMessage.requestId,
2079
+ topic: pubsubMessage.topic,
2080
+ root,
2081
+ }),
2082
+ ).catch(() => {});
2083
+ return;
2084
+ }
2085
+
2086
+ if (pubsubMessage instanceof TopicRootQueryResponse) {
2087
+ this.resolvePendingTopicRootQuery(pubsubMessage);
2088
+ return;
2089
+ }
2090
+
2091
+ if (pubsubMessage instanceof Subscribe) {
2092
+ const sender = from;
2093
+ const senderKey = sender.hashcode();
2094
+ const relevantTopics = pubsubMessage.topics.filter((t) =>
2095
+ this.isTrackedTopic(t),
2096
+ );
2097
+
2098
+ if (
2099
+ relevantTopics.length > 0 &&
2100
+ this.subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics)
2101
+ ) {
2102
+ const changed: string[] = [];
2103
+ for (const topic of relevantTopics) {
2104
+ const peers = this.topics.get(topic);
2105
+ if (!peers) continue;
2106
+ this.initializePeer(sender);
2107
+
2108
+ const existing = peers.get(senderKey);
2109
+ if (!existing || existing.session < message.header.session) {
2110
+ peers.delete(senderKey);
2111
+ peers.set(
2112
+ senderKey,
2113
+ new SubscriptionData({
2114
+ session: message.header.session,
2115
+ timestamp: message.header.timestamp,
2116
+ publicKey: sender,
2117
+ }),
2118
+ );
2119
+ changed.push(topic);
2120
+ } else {
2121
+ peers.delete(senderKey);
2122
+ peers.set(senderKey, existing);
2123
+ }
2124
+
2125
+ if (!existing) {
2126
+ this.peerToTopic.get(senderKey)!.add(topic);
2127
+ }
2128
+ this.pruneTopicSubscribers(topic);
2129
+ }
2130
+
2131
+ if (changed.length > 0) {
2132
+ this.dispatchEvent(
2133
+ new CustomEvent<SubscriptionEvent>("subscribe", {
2134
+ detail: new SubscriptionEvent(sender, changed),
2135
+ }),
2136
+ );
2137
+ }
2138
+ }
2139
+
2140
+ if (pubsubMessage.requestSubscribers) {
2141
+ const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
2142
+ if (overlap.length > 0) {
2143
+ await this.sendDirectControlMessage(
2144
+ stream,
2145
+ new Subscribe({
2146
+ topics: overlap,
2147
+ requestSubscribers: false,
2148
+ }),
2149
+ );
2150
+ }
2151
+ }
2152
+ return;
2153
+ }
2154
+
2155
+ if (pubsubMessage instanceof GetSubscribers) {
2156
+ const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
2157
+ if (overlap.length === 0) return;
2158
+ await this.sendDirectControlMessage(
2159
+ stream,
2160
+ new Subscribe({
2161
+ topics: overlap,
2162
+ requestSubscribers: false,
2163
+ }),
2164
+ );
2165
+ return;
2166
+ }
2167
+
1896
2168
  if (pubsubMessage instanceof PubSubData) {
1897
2169
  this.dispatchEvent(
1898
2170
  new CustomEvent("data", {
@@ -2145,11 +2417,15 @@ export class TopicControlPlane
2145
2417
  return super.onDataMessage(from, stream, message, seenBefore);
2146
2418
  }
2147
2419
 
2148
- // DirectStream only supports targeted pubsub data and a small set of utility
2149
- // messages. All membership/control traffic is shard-only.
2420
+ // DirectStream supports targeted pubsub data plus targeted subscriber snapshot
2421
+ // traffic used by Program.waitFor() when a peer is already connected directly.
2150
2422
  if (
2151
2423
  !(pubsubMessage instanceof PubSubData) &&
2152
- !(pubsubMessage instanceof TopicRootCandidates)
2424
+ !(pubsubMessage instanceof TopicRootCandidates) &&
2425
+ !(pubsubMessage instanceof TopicRootQuery) &&
2426
+ !(pubsubMessage instanceof GetSubscribers) &&
2427
+ !(pubsubMessage instanceof Subscribe) &&
2428
+ !(pubsubMessage instanceof TopicRootQueryResponse)
2153
2429
  ) {
2154
2430
  return true;
2155
2431
  }
@@ -2176,7 +2452,12 @@ export class TopicControlPlane
2176
2452
  if ((await this.verifyAndProcess(message)) === false) return false;
2177
2453
  await this.maybeAcknowledgeMessage(stream, message, seenBefore);
2178
2454
  if (seenBefore === 0) {
2179
- await this.processDirectPubSubMessage({ pubsubMessage, message });
2455
+ await this.processDirectPubSubMessage({
2456
+ pubsubMessage,
2457
+ message,
2458
+ from,
2459
+ stream,
2460
+ });
2180
2461
  }
2181
2462
  }
2182
2463
 
@@ -135,11 +135,30 @@ export class TopicRootControlPlane {
135
135
  return [...this.trackers];
136
136
  }
137
137
 
138
+ public resolveLocalTopicRoot(topic: string) {
139
+ return this.directory.resolveLocal(topic);
140
+ }
141
+
142
+ public resolveDeterministicTopicRoot(topic: string) {
143
+ return this.directory.resolveDeterministicCandidate(topic);
144
+ }
145
+
146
+ public resolveCanonicalTopicRoot(topic: string) {
147
+ return this.directory.resolveRoot(topic);
148
+ }
149
+
150
+ public resolveTrackedTopicRoot(topic: string) {
151
+ return this.resolveWithTrackers(topic, false);
152
+ }
153
+
138
154
  public resolveTopicRoot(topic: string) {
139
- return this.resolveWithTrackers(topic);
155
+ return this.resolveWithTrackers(topic, true);
140
156
  }
141
157
 
142
- private async resolveWithTrackers(topic: string): Promise<string | undefined> {
158
+ private async resolveWithTrackers(
159
+ topic: string,
160
+ fallbackToDeterministic = true,
161
+ ): Promise<string | undefined> {
143
162
  const local = await this.directory.resolveLocal(topic);
144
163
  if (local) {
145
164
  return local;
@@ -155,6 +174,8 @@ export class TopicRootControlPlane {
155
174
  // ignore tracker failures and continue with remaining trackers
156
175
  }
157
176
  }
158
- return this.directory.resolveDeterministicCandidate(topic);
177
+ return fallbackToDeterministic
178
+ ? this.directory.resolveDeterministicCandidate(topic)
179
+ : undefined;
159
180
  }
160
181
  }