@peerbit/pubsub 5.3.8 → 5.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/index.d.ts +15 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +271 -90
- package/dist/src/index.js.map +1 -1
- package/dist/src/topic-root-control-plane.d.ts +11 -8
- package/dist/src/topic-root-control-plane.d.ts.map +1 -1
- package/dist/src/topic-root-control-plane.js +51 -15
- package/dist/src/topic-root-control-plane.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +379 -118
- package/src/topic-root-control-plane.ts +89 -15
package/src/index.ts
CHANGED
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
|
|
6
6
|
import { logger as loggerFn } from "@peerbit/logger";
|
|
7
7
|
import {
|
|
8
|
+
assertCanonicalTopicRootCandidates,
|
|
9
|
+
assertTopicRootCandidatesFrame,
|
|
8
10
|
DataEvent,
|
|
9
11
|
GetSubscribers,
|
|
12
|
+
isCanonicalTopicRootCandidate,
|
|
10
13
|
PeerUnavailable,
|
|
11
14
|
type PubSub,
|
|
12
15
|
PubSubData,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
Subscribe,
|
|
17
20
|
SubscriptionData,
|
|
18
21
|
SubscriptionEvent,
|
|
22
|
+
TOPIC_ROOT_CANDIDATES_MAX,
|
|
19
23
|
TopicRootCandidates,
|
|
20
24
|
TopicRootQuery,
|
|
21
25
|
TopicRootQueryResponse,
|
|
@@ -66,7 +70,10 @@ import type {
|
|
|
66
70
|
FanoutTreeDataEvent,
|
|
67
71
|
FanoutTreeJoinOptions,
|
|
68
72
|
} from "./fanout-tree.js";
|
|
69
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
TopicRootControlPlane,
|
|
75
|
+
type TopicRootResolutionOptions,
|
|
76
|
+
} from "./topic-root-control-plane.js";
|
|
70
77
|
|
|
71
78
|
export * from "./fanout-tree.js";
|
|
72
79
|
// The complete /peerbit/fanout-tree/0.5.0 wire codec and the
|
|
@@ -103,6 +110,7 @@ const logErrorIfStarted = (e?: { message: string }) => {
|
|
|
103
110
|
const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> => {
|
|
104
111
|
if (!signal) return promise;
|
|
105
112
|
if (signal.aborted) {
|
|
113
|
+
void promise.catch(() => {});
|
|
106
114
|
throw signal.reason ?? new AbortError("Operation was aborted");
|
|
107
115
|
}
|
|
108
116
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -131,6 +139,53 @@ const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<
|
|
|
131
139
|
});
|
|
132
140
|
};
|
|
133
141
|
|
|
142
|
+
const throwIfAborted = (signal?: AbortSignal): void => {
|
|
143
|
+
if (signal?.aborted) {
|
|
144
|
+
throw signal.reason ?? new AbortError("Operation was aborted");
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
type AbortSignalLink = {
|
|
149
|
+
signal: AbortSignal;
|
|
150
|
+
clear: () => void;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const linkAbortSignals = (
|
|
154
|
+
signals: Array<AbortSignal | undefined>,
|
|
155
|
+
): AbortSignalLink => {
|
|
156
|
+
const unique = [...new Set(signals.filter((signal) => signal !== undefined))];
|
|
157
|
+
if (unique.length === 1) {
|
|
158
|
+
return { signal: unique[0]!, clear: () => {} };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const listeners = new Map<AbortSignal, () => void>();
|
|
163
|
+
const clear = () => {
|
|
164
|
+
for (const [signal, listener] of listeners) {
|
|
165
|
+
signal.removeEventListener("abort", listener);
|
|
166
|
+
}
|
|
167
|
+
listeners.clear();
|
|
168
|
+
};
|
|
169
|
+
const abortFrom = (signal: AbortSignal) => {
|
|
170
|
+
clear();
|
|
171
|
+
controller.abort(
|
|
172
|
+
signal.reason ?? new AbortError("Topic-root resolution aborted"),
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const aborted = unique.find((signal) => signal.aborted);
|
|
177
|
+
if (aborted) {
|
|
178
|
+
abortFrom(aborted);
|
|
179
|
+
} else {
|
|
180
|
+
for (const signal of unique) {
|
|
181
|
+
const listener = () => abortFrom(signal);
|
|
182
|
+
listeners.set(signal, listener);
|
|
183
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { signal: controller.signal, clear };
|
|
187
|
+
};
|
|
188
|
+
|
|
134
189
|
const SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP = 100_000;
|
|
135
190
|
const SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES = 4_096;
|
|
136
191
|
const DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS = 60_000;
|
|
@@ -138,7 +193,10 @@ const DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS = 64;
|
|
|
138
193
|
const DEFAULT_PUBSUB_SHARD_COUNT = 256;
|
|
139
194
|
const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
|
|
140
195
|
const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
|
|
141
|
-
const
|
|
196
|
+
const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
|
|
197
|
+
const sameCandidates = (left: string[], right: string[]) =>
|
|
198
|
+
left.length === right.length &&
|
|
199
|
+
left.every((candidate, index) => candidate === right[index]);
|
|
142
200
|
// Topic-root queries may need to wait for the responder to finish opening an
|
|
143
201
|
// outbound stream back to the requester after an inbound-only dial.
|
|
144
202
|
const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
|
|
@@ -344,10 +402,15 @@ export class TopicControlPlane
|
|
|
344
402
|
// This keeps small ad-hoc networks working without explicit bootstraps.
|
|
345
403
|
private autoTopicRootCandidates = false;
|
|
346
404
|
private autoTopicRootCandidateSet?: Set<string>;
|
|
405
|
+
private pendingAutoTopicRootCandidates?: string[];
|
|
406
|
+
private autoTopicRootCandidateUpdateTimer?: ReturnType<typeof setTimeout>;
|
|
347
407
|
private reconcileShardOverlaysInFlight?: Promise<void>;
|
|
348
408
|
private reconcileShardOverlaysDirty = false;
|
|
349
409
|
private topicControlPlaneStopping = false;
|
|
350
410
|
private topicControlPlaneLifecycleRevision = 0;
|
|
411
|
+
private topicRootResolutionAbortController = new AbortController();
|
|
412
|
+
private topicRootCandidateResolutionGeneration?: string;
|
|
413
|
+
private topicRootCandidateResolutionAbortController = new AbortController();
|
|
351
414
|
private hostOwnedShardRootsInFlight?: Promise<void>;
|
|
352
415
|
private hostOwnedShardRootsDirty = false;
|
|
353
416
|
private autoCandidatesBroadcastTimers: Array<ReturnType<typeof setTimeout>> =
|
|
@@ -531,6 +594,7 @@ export class TopicControlPlane
|
|
|
531
594
|
// Disable auto mode and stop its background gossip/timers.
|
|
532
595
|
this.autoTopicRootCandidates = false;
|
|
533
596
|
this.autoTopicRootCandidateSet = undefined;
|
|
597
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
534
598
|
for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
|
|
535
599
|
this.autoCandidatesBroadcastTimers = [];
|
|
536
600
|
if (this.autoCandidatesGossipInterval) {
|
|
@@ -551,6 +615,9 @@ export class TopicControlPlane
|
|
|
551
615
|
|
|
552
616
|
public override async start() {
|
|
553
617
|
this.topicControlPlaneStopping = false;
|
|
618
|
+
if (this.topicRootResolutionAbortController.signal.aborted) {
|
|
619
|
+
this.topicRootResolutionAbortController = new AbortController();
|
|
620
|
+
}
|
|
554
621
|
await this.fanout.start();
|
|
555
622
|
this._onFanoutPeerUnreachable =
|
|
556
623
|
this._onFanoutPeerUnreachable ||
|
|
@@ -574,6 +641,10 @@ export class TopicControlPlane
|
|
|
574
641
|
|
|
575
642
|
public override async stop() {
|
|
576
643
|
this.topicControlPlaneStopping = true;
|
|
644
|
+
this.topicRootResolutionAbortController.abort(
|
|
645
|
+
new AbortError("topic control plane stopped"),
|
|
646
|
+
);
|
|
647
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
577
648
|
this.topicControlPlaneLifecycleRevision += 1;
|
|
578
649
|
this.reconcileShardOverlaysDirty = false;
|
|
579
650
|
for (const opening of this.ensureFanoutChannelInFlight.values()) {
|
|
@@ -697,6 +768,7 @@ export class TopicControlPlane
|
|
|
697
768
|
// intact and reconcile shard overlays under the new mapping.
|
|
698
769
|
this.autoTopicRootCandidates = false;
|
|
699
770
|
this.autoTopicRootCandidateSet = undefined;
|
|
771
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
700
772
|
this.shardRootCache.clear();
|
|
701
773
|
|
|
702
774
|
// Ensure we host any shard roots we're now responsible for. This is important
|
|
@@ -710,52 +782,34 @@ export class TopicControlPlane
|
|
|
710
782
|
|
|
711
783
|
private maybeUpdateAutoTopicRootCandidates(peerHash: string) {
|
|
712
784
|
if (!this.autoTopicRootCandidates) return;
|
|
713
|
-
if (
|
|
714
|
-
|
|
715
|
-
|
|
785
|
+
if (
|
|
786
|
+
!isCanonicalTopicRootCandidate(peerHash) ||
|
|
787
|
+
peerHash === this.publicKeyHash
|
|
788
|
+
)
|
|
716
789
|
return;
|
|
717
790
|
|
|
718
|
-
|
|
719
|
-
const managed = this.autoTopicRootCandidateSet;
|
|
720
|
-
|
|
721
|
-
if (current.includes(peerHash)) return;
|
|
722
|
-
|
|
723
|
-
managed?.add(peerHash);
|
|
724
|
-
const next = this.normalizeAutoTopicRootCandidates(
|
|
725
|
-
managed ? [...managed] : [...current, peerHash],
|
|
726
|
-
);
|
|
727
|
-
this.autoTopicRootCandidateSet = new Set(next);
|
|
728
|
-
this.topicRootControlPlane.setTopicRootCandidates(next);
|
|
729
|
-
this.shardRootCache.clear();
|
|
730
|
-
this.scheduleReconcileShardOverlays();
|
|
731
|
-
|
|
732
|
-
// In auto-candidate mode, shard roots are selected deterministically across
|
|
733
|
-
// *all* connected peers (not just those currently subscribed to a shard).
|
|
734
|
-
// That means a peer can be selected as root for shards it isn't using yet.
|
|
735
|
-
// Ensure we proactively host the shard roots we're responsible for so other
|
|
736
|
-
// peers can join without timing out in small ad-hoc networks.
|
|
737
|
-
this.scheduleHostOwnedShardRoots();
|
|
738
|
-
|
|
739
|
-
// Share the updated candidate set so other peers converge on the same
|
|
740
|
-
// deterministic mapping even in partially connected topologies.
|
|
741
|
-
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
791
|
+
this.queueAutoTopicRootCandidateUpdate([peerHash]);
|
|
742
792
|
}
|
|
743
793
|
|
|
744
794
|
private normalizeAutoTopicRootCandidates(candidates: string[]): string[] {
|
|
795
|
+
const canonicalCandidates = candidates.filter(
|
|
796
|
+
isCanonicalTopicRootCandidate,
|
|
797
|
+
);
|
|
745
798
|
if (this.nativeTopicControl) {
|
|
746
799
|
return this.nativeTopicControl.normalizeAutoCandidates(
|
|
747
|
-
|
|
800
|
+
canonicalCandidates,
|
|
748
801
|
this.publicKeyHash,
|
|
749
802
|
);
|
|
750
803
|
}
|
|
751
804
|
const unique = new Set<string>();
|
|
752
|
-
for (const c of
|
|
753
|
-
if (!c) continue;
|
|
805
|
+
for (const c of canonicalCandidates) {
|
|
754
806
|
unique.add(c);
|
|
755
807
|
}
|
|
756
|
-
|
|
808
|
+
if (isCanonicalTopicRootCandidate(this.publicKeyHash)) {
|
|
809
|
+
unique.add(this.publicKeyHash);
|
|
810
|
+
}
|
|
757
811
|
const sorted = [...unique].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
758
|
-
return sorted.slice(0,
|
|
812
|
+
return sorted.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
|
|
759
813
|
}
|
|
760
814
|
|
|
761
815
|
private scheduleAutoTopicRootCandidatesBroadcast(targets?: PeerStreams[]) {
|
|
@@ -827,31 +881,88 @@ export class TopicControlPlane
|
|
|
827
881
|
|
|
828
882
|
private mergeAutoTopicRootCandidatesFromPeer(candidates: string[]): boolean {
|
|
829
883
|
if (!this.autoTopicRootCandidates) return false;
|
|
884
|
+
return this.queueAutoTopicRootCandidateUpdate(candidates);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private queueAutoTopicRootCandidateUpdate(
|
|
888
|
+
candidates: readonly string[],
|
|
889
|
+
): boolean {
|
|
890
|
+
if (
|
|
891
|
+
!this.autoTopicRootCandidates ||
|
|
892
|
+
this.stopping ||
|
|
893
|
+
this.topicControlPlaneStopping
|
|
894
|
+
)
|
|
895
|
+
return false;
|
|
830
896
|
if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
|
|
831
897
|
return false;
|
|
832
898
|
const managed = this.autoTopicRootCandidateSet;
|
|
833
899
|
if (!managed) return false;
|
|
834
900
|
|
|
835
|
-
const before = this.
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
return false;
|
|
901
|
+
const before = this.pendingAutoTopicRootCandidates ?? [...managed];
|
|
902
|
+
const next = this.normalizeAutoTopicRootCandidates([
|
|
903
|
+
...before,
|
|
904
|
+
...candidates,
|
|
905
|
+
]);
|
|
906
|
+
if (sameCandidates(before, next)) return false;
|
|
907
|
+
|
|
908
|
+
if (this.autoTopicRootCandidateUpdateTimer) {
|
|
909
|
+
this.pendingAutoTopicRootCandidates = next;
|
|
910
|
+
return true;
|
|
846
911
|
}
|
|
847
912
|
|
|
913
|
+
this.scheduleAutoTopicRootCandidateUpdateCooldown();
|
|
914
|
+
this.applyAutoTopicRootCandidates(next);
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
private applyAutoTopicRootCandidates(next: string[]) {
|
|
848
919
|
this.autoTopicRootCandidateSet = new Set(next);
|
|
849
920
|
this.topicRootControlPlane.setTopicRootCandidates(next);
|
|
850
921
|
this.shardRootCache.clear();
|
|
851
922
|
this.scheduleReconcileShardOverlays();
|
|
852
923
|
this.scheduleHostOwnedShardRoots();
|
|
853
924
|
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
854
|
-
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private scheduleAutoTopicRootCandidateUpdateCooldown() {
|
|
928
|
+
if (this.autoTopicRootCandidateUpdateTimer) return;
|
|
929
|
+
const timer = setTimeout(() => {
|
|
930
|
+
if (this.autoTopicRootCandidateUpdateTimer !== timer) return;
|
|
931
|
+
this.flushPendingAutoTopicRootCandidateUpdate();
|
|
932
|
+
}, AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS);
|
|
933
|
+
timer.unref?.();
|
|
934
|
+
this.autoTopicRootCandidateUpdateTimer = timer;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private flushPendingAutoTopicRootCandidateUpdate() {
|
|
938
|
+
if (this.autoTopicRootCandidateUpdateTimer) {
|
|
939
|
+
clearTimeout(this.autoTopicRootCandidateUpdateTimer);
|
|
940
|
+
this.autoTopicRootCandidateUpdateTimer = undefined;
|
|
941
|
+
}
|
|
942
|
+
const next = this.pendingAutoTopicRootCandidates;
|
|
943
|
+
this.pendingAutoTopicRootCandidates = undefined;
|
|
944
|
+
if (!next) return;
|
|
945
|
+
if (
|
|
946
|
+
!this.autoTopicRootCandidates ||
|
|
947
|
+
this.stopping ||
|
|
948
|
+
this.topicControlPlaneStopping
|
|
949
|
+
)
|
|
950
|
+
return;
|
|
951
|
+
if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
|
|
952
|
+
return;
|
|
953
|
+
|
|
954
|
+
// The trailing update starts its own fixed window so another inbound update
|
|
955
|
+
// cannot create a second candidate generation immediately afterwards.
|
|
956
|
+
this.scheduleAutoTopicRootCandidateUpdateCooldown();
|
|
957
|
+
this.applyAutoTopicRootCandidates(next);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private clearAutoTopicRootCandidateUpdateSchedule() {
|
|
961
|
+
if (this.autoTopicRootCandidateUpdateTimer) {
|
|
962
|
+
clearTimeout(this.autoTopicRootCandidateUpdateTimer);
|
|
963
|
+
this.autoTopicRootCandidateUpdateTimer = undefined;
|
|
964
|
+
}
|
|
965
|
+
this.pendingAutoTopicRootCandidates = undefined;
|
|
855
966
|
}
|
|
856
967
|
|
|
857
968
|
private scheduleHostOwnedShardRoots() {
|
|
@@ -881,6 +992,7 @@ export class TopicControlPlane
|
|
|
881
992
|
|
|
882
993
|
private scheduleReconcileShardOverlays() {
|
|
883
994
|
const candidateGeneration = this.getTopicRootCandidateGeneration();
|
|
995
|
+
this.syncTopicRootCandidateResolutionSignal(candidateGeneration);
|
|
884
996
|
for (const opening of this.ensureFanoutChannelInFlight.values()) {
|
|
885
997
|
if (opening.candidateGeneration !== candidateGeneration) {
|
|
886
998
|
opening.abortController.abort(
|
|
@@ -1162,6 +1274,9 @@ export class TopicControlPlane
|
|
|
1162
1274
|
* their fallback behavior).
|
|
1163
1275
|
*/
|
|
1164
1276
|
private decodePubSubMessage(bytes: Uint8Array): PubSubMessage {
|
|
1277
|
+
if (bytes[0] === 4) {
|
|
1278
|
+
assertTopicRootCandidatesFrame(bytes);
|
|
1279
|
+
}
|
|
1165
1280
|
const native = this.nativeTopicControl;
|
|
1166
1281
|
if (native) {
|
|
1167
1282
|
const decoded = native.decodePubSubMessage(bytes);
|
|
@@ -1182,6 +1297,7 @@ export class TopicControlPlane
|
|
|
1182
1297
|
case "get-subscribers":
|
|
1183
1298
|
return new GetSubscribers({ topics: decoded.topics });
|
|
1184
1299
|
case "topic-root-candidates":
|
|
1300
|
+
assertCanonicalTopicRootCandidates(decoded.candidates);
|
|
1185
1301
|
return new TopicRootCandidates({ candidates: decoded.candidates });
|
|
1186
1302
|
case "peer-unavailable":
|
|
1187
1303
|
return new PeerUnavailable({
|
|
@@ -1224,6 +1340,67 @@ export class TopicControlPlane
|
|
|
1224
1340
|
}
|
|
1225
1341
|
}
|
|
1226
1342
|
|
|
1343
|
+
private syncTopicRootCandidateResolutionSignal(
|
|
1344
|
+
candidateGeneration = this.getTopicRootCandidateGeneration(),
|
|
1345
|
+
): AbortSignal {
|
|
1346
|
+
if (this.topicRootCandidateResolutionGeneration === undefined) {
|
|
1347
|
+
this.topicRootCandidateResolutionGeneration = candidateGeneration;
|
|
1348
|
+
} else if (
|
|
1349
|
+
candidateGeneration !== this.topicRootCandidateResolutionGeneration
|
|
1350
|
+
) {
|
|
1351
|
+
this.topicRootCandidateResolutionAbortController.abort(
|
|
1352
|
+
new AbortError("topic root candidates changed"),
|
|
1353
|
+
);
|
|
1354
|
+
this.topicRootCandidateResolutionAbortController = new AbortController();
|
|
1355
|
+
this.topicRootCandidateResolutionGeneration = candidateGeneration;
|
|
1356
|
+
}
|
|
1357
|
+
return this.topicRootCandidateResolutionAbortController.signal;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
private async withTopicRootCandidateResolution<T>(
|
|
1361
|
+
operation: (context: {
|
|
1362
|
+
candidateGeneration: string;
|
|
1363
|
+
signal: AbortSignal;
|
|
1364
|
+
}) => Promise<T>,
|
|
1365
|
+
terminalSignals: Array<AbortSignal | undefined> = [],
|
|
1366
|
+
): Promise<T> {
|
|
1367
|
+
for (;;) {
|
|
1368
|
+
for (const signal of terminalSignals) throwIfAborted(signal);
|
|
1369
|
+
const candidateGeneration = this.getTopicRootCandidateGeneration();
|
|
1370
|
+
const candidateSignal = this.syncTopicRootCandidateResolutionSignal(
|
|
1371
|
+
candidateGeneration,
|
|
1372
|
+
);
|
|
1373
|
+
const linked = linkAbortSignals([...terminalSignals, candidateSignal]);
|
|
1374
|
+
try {
|
|
1375
|
+
const result = await operation({
|
|
1376
|
+
candidateGeneration,
|
|
1377
|
+
signal: linked.signal,
|
|
1378
|
+
});
|
|
1379
|
+
for (const signal of terminalSignals) throwIfAborted(signal);
|
|
1380
|
+
if (
|
|
1381
|
+
candidateSignal.aborted ||
|
|
1382
|
+
candidateGeneration !== this.getTopicRootCandidateGeneration()
|
|
1383
|
+
) {
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
return result;
|
|
1387
|
+
} catch (error) {
|
|
1388
|
+
for (const signal of terminalSignals) {
|
|
1389
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
1390
|
+
}
|
|
1391
|
+
if (
|
|
1392
|
+
candidateSignal.aborted ||
|
|
1393
|
+
candidateGeneration !== this.getTopicRootCandidateGeneration()
|
|
1394
|
+
) {
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
throw error;
|
|
1398
|
+
} finally {
|
|
1399
|
+
linked.clear();
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1227
1404
|
private normalizePeerTopicRootState(
|
|
1228
1405
|
topic: string,
|
|
1229
1406
|
root: string,
|
|
@@ -1243,13 +1420,21 @@ export class TopicControlPlane
|
|
|
1243
1420
|
|
|
1244
1421
|
private async resolveTopicRootState(
|
|
1245
1422
|
topic: string,
|
|
1423
|
+
options?: TopicRootResolutionOptions,
|
|
1246
1424
|
): Promise<{ root?: string; authoritative: boolean }> {
|
|
1247
|
-
|
|
1425
|
+
throwIfAborted(options?.signal);
|
|
1426
|
+
const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(
|
|
1427
|
+
topic,
|
|
1428
|
+
options,
|
|
1429
|
+
);
|
|
1248
1430
|
if (tracked) {
|
|
1249
1431
|
return { root: tracked, authoritative: true };
|
|
1250
1432
|
}
|
|
1251
1433
|
|
|
1252
|
-
const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
|
|
1434
|
+
const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
|
|
1435
|
+
topic,
|
|
1436
|
+
options,
|
|
1437
|
+
);
|
|
1253
1438
|
if (resolvedThroughPeers) {
|
|
1254
1439
|
// Unconfigured peer-query replies cannot override the locally
|
|
1255
1440
|
// deterministic root for internal shards in auto mode. Roots, resolvers,
|
|
@@ -1266,8 +1451,11 @@ export class TopicControlPlane
|
|
|
1266
1451
|
this.getConnectedTopicRootTrackers().length > 0
|
|
1267
1452
|
) {
|
|
1268
1453
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1269
|
-
await
|
|
1270
|
-
|
|
1454
|
+
await withAbort(
|
|
1455
|
+
delay(150 * (attempt < 4 ? 1 : 2), options),
|
|
1456
|
+
options?.signal,
|
|
1457
|
+
);
|
|
1458
|
+
const retried = await this.resolveTopicRootThroughPeers(topic, options);
|
|
1271
1459
|
if (retried) {
|
|
1272
1460
|
return this.normalizePeerTopicRootState(topic, retried);
|
|
1273
1461
|
}
|
|
@@ -1280,15 +1468,28 @@ export class TopicControlPlane
|
|
|
1280
1468
|
};
|
|
1281
1469
|
}
|
|
1282
1470
|
|
|
1283
|
-
public async resolveTopicRoot(
|
|
1284
|
-
|
|
1471
|
+
public async resolveTopicRoot(
|
|
1472
|
+
topic: string,
|
|
1473
|
+
options?: TopicRootResolutionOptions,
|
|
1474
|
+
): Promise<string | undefined> {
|
|
1475
|
+
const lifecycleSignal =
|
|
1476
|
+
this.started && !this.stopping && !this.topicControlPlaneStopping
|
|
1477
|
+
? this.topicRootResolutionAbortController.signal
|
|
1478
|
+
: undefined;
|
|
1479
|
+
return this.withTopicRootCandidateResolution(
|
|
1480
|
+
async ({ signal }) =>
|
|
1481
|
+
(await this.resolveTopicRootState(topic, { signal })).root,
|
|
1482
|
+
[lifecycleSignal, options?.signal],
|
|
1483
|
+
);
|
|
1285
1484
|
}
|
|
1286
1485
|
|
|
1287
1486
|
private async resolveShardRootState(
|
|
1288
1487
|
shardTopic: string,
|
|
1488
|
+
options?: TopicRootResolutionOptions,
|
|
1289
1489
|
): Promise<{ root: string; candidateGeneration: string }> {
|
|
1290
1490
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1291
1491
|
for (;;) {
|
|
1492
|
+
throwIfAborted(options?.signal);
|
|
1292
1493
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1293
1494
|
// If someone configured topic-root candidates externally (e.g.
|
|
1294
1495
|
// TestSession router selection or Peerbit.bootstrap) after this peer
|
|
@@ -1305,7 +1506,7 @@ export class TopicControlPlane
|
|
|
1305
1506
|
return { root: cached.root, candidateGeneration };
|
|
1306
1507
|
}
|
|
1307
1508
|
|
|
1308
|
-
const resolved = await this.resolveTopicRootState(shardTopic);
|
|
1509
|
+
const resolved = await this.resolveTopicRootState(shardTopic, options);
|
|
1309
1510
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1310
1511
|
if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
|
|
1311
1512
|
continue;
|
|
@@ -1391,40 +1592,65 @@ export class TopicControlPlane
|
|
|
1391
1592
|
peer: PeerStreams,
|
|
1392
1593
|
topic: string,
|
|
1393
1594
|
timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
|
|
1595
|
+
signal?: AbortSignal,
|
|
1394
1596
|
): Promise<string | undefined> {
|
|
1597
|
+
throwIfAborted(signal);
|
|
1395
1598
|
if (!this.started || this.stopping) return undefined;
|
|
1396
1599
|
|
|
1397
1600
|
const expectedPeerHash = peer.publicKey.hashcode();
|
|
1398
1601
|
const requestId = this.nextTopicRootRequestIdValue();
|
|
1399
1602
|
const responsePromise = new Promise<string | undefined>((resolve) => {
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1603
|
+
let settled = false;
|
|
1604
|
+
const settle = (root: string | undefined) => {
|
|
1605
|
+
if (settled) return;
|
|
1606
|
+
settled = true;
|
|
1607
|
+
const pending = this.pendingTopicRootQueries.get(requestId);
|
|
1608
|
+
if (pending?.resolve === settle) {
|
|
1609
|
+
this.pendingTopicRootQueries.delete(requestId);
|
|
1610
|
+
}
|
|
1611
|
+
clearTimeout(timer);
|
|
1612
|
+
if (signal && onAbort) {
|
|
1613
|
+
signal.removeEventListener("abort", onAbort);
|
|
1614
|
+
}
|
|
1615
|
+
resolve(root);
|
|
1616
|
+
};
|
|
1617
|
+
const onAbort = signal ? () => settle(undefined) : undefined;
|
|
1618
|
+
const timer = setTimeout(
|
|
1619
|
+
() => settle(undefined),
|
|
1620
|
+
Math.max(1, Math.floor(timeoutMs)),
|
|
1621
|
+
);
|
|
1404
1622
|
timer.unref?.();
|
|
1405
1623
|
this.pendingTopicRootQueries.set(requestId, {
|
|
1406
1624
|
expectedPeerHash,
|
|
1407
1625
|
topic,
|
|
1408
|
-
resolve,
|
|
1626
|
+
resolve: settle,
|
|
1409
1627
|
timer,
|
|
1410
1628
|
});
|
|
1629
|
+
if (signal && onAbort) {
|
|
1630
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1631
|
+
if (signal.aborted) onAbort();
|
|
1632
|
+
}
|
|
1411
1633
|
});
|
|
1412
1634
|
|
|
1413
1635
|
try {
|
|
1414
|
-
await
|
|
1415
|
-
|
|
1416
|
-
|
|
1636
|
+
await withAbort(
|
|
1637
|
+
this.sendDirectControlMessage(
|
|
1638
|
+
peer,
|
|
1639
|
+
new TopicRootQuery({ requestId, topic }),
|
|
1640
|
+
),
|
|
1641
|
+
signal,
|
|
1417
1642
|
);
|
|
1418
|
-
} catch {
|
|
1643
|
+
} catch (error) {
|
|
1419
1644
|
const pending = this.pendingTopicRootQueries.get(requestId);
|
|
1420
1645
|
if (pending) {
|
|
1421
1646
|
this.pendingTopicRootQueries.delete(requestId);
|
|
1422
1647
|
clearTimeout(pending.timer);
|
|
1423
1648
|
pending.resolve(undefined);
|
|
1424
1649
|
}
|
|
1650
|
+
if (signal?.aborted) throw error;
|
|
1425
1651
|
}
|
|
1426
1652
|
|
|
1427
|
-
return responsePromise;
|
|
1653
|
+
return withAbort(responsePromise, signal);
|
|
1428
1654
|
}
|
|
1429
1655
|
|
|
1430
1656
|
private async confirmDirectShardRoot(
|
|
@@ -1444,6 +1670,7 @@ export class TopicControlPlane
|
|
|
1444
1670
|
rootPeer,
|
|
1445
1671
|
shardTopic,
|
|
1446
1672
|
DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS,
|
|
1673
|
+
signal,
|
|
1447
1674
|
),
|
|
1448
1675
|
signal,
|
|
1449
1676
|
);
|
|
@@ -1468,49 +1695,56 @@ export class TopicControlPlane
|
|
|
1468
1695
|
topic: string,
|
|
1469
1696
|
): Promise<string | undefined> {
|
|
1470
1697
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
|
|
1476
|
-
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1477
|
-
if (rootCandidateGeneration !== this.getTopicRootCandidateGeneration()) {
|
|
1478
|
-
continue;
|
|
1479
|
-
}
|
|
1480
|
-
if (root !== this.publicKeyHash) {
|
|
1481
|
-
return root;
|
|
1482
|
-
}
|
|
1483
|
-
if (!topic.startsWith(this.shardTopicPrefix)) {
|
|
1484
|
-
return root;
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
try {
|
|
1488
|
-
await this.ensureFanoutChannel(topic, {
|
|
1489
|
-
pin: true,
|
|
1490
|
-
root,
|
|
1491
|
-
rootCandidateGeneration,
|
|
1492
|
-
});
|
|
1698
|
+
const lifecycleSignal = this.topicRootResolutionAbortController.signal;
|
|
1699
|
+
return this.withTopicRootCandidateResolution(
|
|
1700
|
+
async ({ candidateGeneration, signal }) => {
|
|
1701
|
+
throwIfAborted(signal);
|
|
1493
1702
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1703
|
+
const root =
|
|
1704
|
+
await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic, {
|
|
1705
|
+
signal,
|
|
1706
|
+
});
|
|
1707
|
+
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1708
|
+
if (root !== this.publicKeyHash) {
|
|
1709
|
+
return root;
|
|
1498
1710
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1711
|
+
if (!topic.startsWith(this.shardTopicPrefix)) {
|
|
1712
|
+
return root;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
try {
|
|
1716
|
+
await this.ensureFanoutChannel(topic, {
|
|
1717
|
+
pin: true,
|
|
1718
|
+
root,
|
|
1719
|
+
rootCandidateGeneration: candidateGeneration,
|
|
1720
|
+
signal,
|
|
1721
|
+
});
|
|
1722
|
+
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1723
|
+
return root;
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
if (
|
|
1726
|
+
signal.aborted ||
|
|
1727
|
+
candidateGeneration !== this.getTopicRootCandidateGeneration()
|
|
1728
|
+
) {
|
|
1729
|
+
throw error;
|
|
1730
|
+
}
|
|
1731
|
+
warn(
|
|
1732
|
+
`Failed to host shard root ${topic} before answering root query: ${
|
|
1733
|
+
error instanceof Error ? error.message : String(error)
|
|
1734
|
+
}`,
|
|
1735
|
+
);
|
|
1736
|
+
return undefined;
|
|
1737
|
+
}
|
|
1738
|
+
},
|
|
1739
|
+
[lifecycleSignal],
|
|
1740
|
+
);
|
|
1509
1741
|
}
|
|
1510
1742
|
|
|
1511
1743
|
private async resolveTopicRootThroughPeers(
|
|
1512
1744
|
topic: string,
|
|
1745
|
+
options?: TopicRootResolutionOptions,
|
|
1513
1746
|
): Promise<string | undefined> {
|
|
1747
|
+
throwIfAborted(options?.signal);
|
|
1514
1748
|
const peers = this.getConnectedTopicRootTrackers();
|
|
1515
1749
|
if (peers.length === 0) {
|
|
1516
1750
|
return undefined;
|
|
@@ -1518,14 +1752,22 @@ export class TopicControlPlane
|
|
|
1518
1752
|
|
|
1519
1753
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1520
1754
|
for (const peer of peers) {
|
|
1521
|
-
const resolved = await this.queryTopicRootFromPeer(
|
|
1755
|
+
const resolved = await this.queryTopicRootFromPeer(
|
|
1756
|
+
peer,
|
|
1757
|
+
topic,
|
|
1758
|
+
DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
|
|
1759
|
+
options?.signal,
|
|
1760
|
+
);
|
|
1522
1761
|
if (resolved) {
|
|
1523
1762
|
return resolved;
|
|
1524
1763
|
}
|
|
1525
1764
|
}
|
|
1526
1765
|
|
|
1527
1766
|
if (attempt < 2) {
|
|
1528
|
-
await
|
|
1767
|
+
await withAbort(
|
|
1768
|
+
delay(150 * (attempt + 1), options),
|
|
1769
|
+
options?.signal,
|
|
1770
|
+
);
|
|
1529
1771
|
}
|
|
1530
1772
|
}
|
|
1531
1773
|
return undefined;
|
|
@@ -1669,7 +1911,9 @@ export class TopicControlPlane
|
|
|
1669
1911
|
const existing = this.fanoutChannels.get(t);
|
|
1670
1912
|
if (existing) {
|
|
1671
1913
|
if (!root) {
|
|
1672
|
-
const resolved = await this.resolveShardRootState(t
|
|
1914
|
+
const resolved = await this.resolveShardRootState(t, {
|
|
1915
|
+
signal: options?.signal,
|
|
1916
|
+
});
|
|
1673
1917
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1674
1918
|
root = resolved.root;
|
|
1675
1919
|
resolvedGeneration = resolved.candidateGeneration;
|
|
@@ -1713,7 +1957,9 @@ export class TopicControlPlane
|
|
|
1713
1957
|
}
|
|
1714
1958
|
|
|
1715
1959
|
if (!root) {
|
|
1716
|
-
const resolved = await this.resolveShardRootState(t
|
|
1960
|
+
const resolved = await this.resolveShardRootState(t, {
|
|
1961
|
+
signal: options?.signal,
|
|
1962
|
+
});
|
|
1717
1963
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1718
1964
|
root = resolved.root;
|
|
1719
1965
|
resolvedGeneration = resolved.candidateGeneration;
|
|
@@ -1938,22 +2184,37 @@ export class TopicControlPlane
|
|
|
1938
2184
|
}
|
|
1939
2185
|
}
|
|
1940
2186
|
|
|
1941
|
-
public async hostShardRootsNow() {
|
|
2187
|
+
public async hostShardRootsNow(options?: TopicRootResolutionOptions) {
|
|
1942
2188
|
if (!this.started) throw new NotStartedError();
|
|
1943
|
-
const
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2189
|
+
const lifecycleSignal = this.topicRootResolutionAbortController.signal;
|
|
2190
|
+
return this.withTopicRootCandidateResolution(
|
|
2191
|
+
async ({ candidateGeneration, signal }) => {
|
|
2192
|
+
const joins: Promise<void>[] = [];
|
|
2193
|
+
try {
|
|
2194
|
+
for (let i = 0; i < this.shardCount; i++) {
|
|
2195
|
+
throwIfAborted(signal);
|
|
2196
|
+
const shardTopic = `${this.shardTopicPrefix}${i}`;
|
|
2197
|
+
const resolved = await this.resolveShardRootState(shardTopic, {
|
|
2198
|
+
signal,
|
|
2199
|
+
});
|
|
2200
|
+
if (resolved.root !== this.publicKeyHash) continue;
|
|
2201
|
+
const joining = this.ensureFanoutChannel(shardTopic, {
|
|
2202
|
+
pin: true,
|
|
2203
|
+
root: resolved.root,
|
|
2204
|
+
rootCandidateGeneration: resolved.candidateGeneration,
|
|
2205
|
+
signal,
|
|
2206
|
+
});
|
|
2207
|
+
void joining.catch(() => {});
|
|
2208
|
+
joins.push(joining);
|
|
2209
|
+
}
|
|
2210
|
+
await Promise.all(joins);
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
await Promise.allSettled(joins);
|
|
2213
|
+
throw error;
|
|
2214
|
+
}
|
|
2215
|
+
},
|
|
2216
|
+
[lifecycleSignal, options?.signal],
|
|
2217
|
+
);
|
|
1957
2218
|
}
|
|
1958
2219
|
|
|
1959
2220
|
async subscribe(topic: string) {
|