@peerbit/pubsub 5.3.9 → 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 +256 -82
- 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 +3 -3
- package/src/index.ts +356 -109
- package/src/topic-root-control-plane.ts +89 -15
package/src/index.ts
CHANGED
|
@@ -70,7 +70,10 @@ import type {
|
|
|
70
70
|
FanoutTreeDataEvent,
|
|
71
71
|
FanoutTreeJoinOptions,
|
|
72
72
|
} from "./fanout-tree.js";
|
|
73
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
TopicRootControlPlane,
|
|
75
|
+
type TopicRootResolutionOptions,
|
|
76
|
+
} from "./topic-root-control-plane.js";
|
|
74
77
|
|
|
75
78
|
export * from "./fanout-tree.js";
|
|
76
79
|
// The complete /peerbit/fanout-tree/0.5.0 wire codec and the
|
|
@@ -107,6 +110,7 @@ const logErrorIfStarted = (e?: { message: string }) => {
|
|
|
107
110
|
const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> => {
|
|
108
111
|
if (!signal) return promise;
|
|
109
112
|
if (signal.aborted) {
|
|
113
|
+
void promise.catch(() => {});
|
|
110
114
|
throw signal.reason ?? new AbortError("Operation was aborted");
|
|
111
115
|
}
|
|
112
116
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -135,6 +139,53 @@ const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<
|
|
|
135
139
|
});
|
|
136
140
|
};
|
|
137
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
|
+
|
|
138
189
|
const SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP = 100_000;
|
|
139
190
|
const SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES = 4_096;
|
|
140
191
|
const DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS = 60_000;
|
|
@@ -142,6 +193,7 @@ const DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS = 64;
|
|
|
142
193
|
const DEFAULT_PUBSUB_SHARD_COUNT = 256;
|
|
143
194
|
const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
|
|
144
195
|
const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
|
|
196
|
+
const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
|
|
145
197
|
const sameCandidates = (left: string[], right: string[]) =>
|
|
146
198
|
left.length === right.length &&
|
|
147
199
|
left.every((candidate, index) => candidate === right[index]);
|
|
@@ -350,10 +402,15 @@ export class TopicControlPlane
|
|
|
350
402
|
// This keeps small ad-hoc networks working without explicit bootstraps.
|
|
351
403
|
private autoTopicRootCandidates = false;
|
|
352
404
|
private autoTopicRootCandidateSet?: Set<string>;
|
|
405
|
+
private pendingAutoTopicRootCandidates?: string[];
|
|
406
|
+
private autoTopicRootCandidateUpdateTimer?: ReturnType<typeof setTimeout>;
|
|
353
407
|
private reconcileShardOverlaysInFlight?: Promise<void>;
|
|
354
408
|
private reconcileShardOverlaysDirty = false;
|
|
355
409
|
private topicControlPlaneStopping = false;
|
|
356
410
|
private topicControlPlaneLifecycleRevision = 0;
|
|
411
|
+
private topicRootResolutionAbortController = new AbortController();
|
|
412
|
+
private topicRootCandidateResolutionGeneration?: string;
|
|
413
|
+
private topicRootCandidateResolutionAbortController = new AbortController();
|
|
357
414
|
private hostOwnedShardRootsInFlight?: Promise<void>;
|
|
358
415
|
private hostOwnedShardRootsDirty = false;
|
|
359
416
|
private autoCandidatesBroadcastTimers: Array<ReturnType<typeof setTimeout>> =
|
|
@@ -537,6 +594,7 @@ export class TopicControlPlane
|
|
|
537
594
|
// Disable auto mode and stop its background gossip/timers.
|
|
538
595
|
this.autoTopicRootCandidates = false;
|
|
539
596
|
this.autoTopicRootCandidateSet = undefined;
|
|
597
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
540
598
|
for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
|
|
541
599
|
this.autoCandidatesBroadcastTimers = [];
|
|
542
600
|
if (this.autoCandidatesGossipInterval) {
|
|
@@ -557,6 +615,9 @@ export class TopicControlPlane
|
|
|
557
615
|
|
|
558
616
|
public override async start() {
|
|
559
617
|
this.topicControlPlaneStopping = false;
|
|
618
|
+
if (this.topicRootResolutionAbortController.signal.aborted) {
|
|
619
|
+
this.topicRootResolutionAbortController = new AbortController();
|
|
620
|
+
}
|
|
560
621
|
await this.fanout.start();
|
|
561
622
|
this._onFanoutPeerUnreachable =
|
|
562
623
|
this._onFanoutPeerUnreachable ||
|
|
@@ -580,6 +641,10 @@ export class TopicControlPlane
|
|
|
580
641
|
|
|
581
642
|
public override async stop() {
|
|
582
643
|
this.topicControlPlaneStopping = true;
|
|
644
|
+
this.topicRootResolutionAbortController.abort(
|
|
645
|
+
new AbortError("topic control plane stopped"),
|
|
646
|
+
);
|
|
647
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
583
648
|
this.topicControlPlaneLifecycleRevision += 1;
|
|
584
649
|
this.reconcileShardOverlaysDirty = false;
|
|
585
650
|
for (const opening of this.ensureFanoutChannelInFlight.values()) {
|
|
@@ -703,6 +768,7 @@ export class TopicControlPlane
|
|
|
703
768
|
// intact and reconcile shard overlays under the new mapping.
|
|
704
769
|
this.autoTopicRootCandidates = false;
|
|
705
770
|
this.autoTopicRootCandidateSet = undefined;
|
|
771
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
706
772
|
this.shardRootCache.clear();
|
|
707
773
|
|
|
708
774
|
// Ensure we host any shard roots we're now responsible for. This is important
|
|
@@ -722,34 +788,7 @@ export class TopicControlPlane
|
|
|
722
788
|
)
|
|
723
789
|
return;
|
|
724
790
|
|
|
725
|
-
|
|
726
|
-
return;
|
|
727
|
-
|
|
728
|
-
const current = this.topicRootControlPlane.getTopicRootCandidates();
|
|
729
|
-
const managed = this.autoTopicRootCandidateSet;
|
|
730
|
-
|
|
731
|
-
if (current.includes(peerHash)) return;
|
|
732
|
-
|
|
733
|
-
managed?.add(peerHash);
|
|
734
|
-
const next = this.normalizeAutoTopicRootCandidates(
|
|
735
|
-
managed ? [...managed] : [...current, peerHash],
|
|
736
|
-
);
|
|
737
|
-
this.autoTopicRootCandidateSet = new Set(next);
|
|
738
|
-
if (sameCandidates(current, next)) return;
|
|
739
|
-
this.topicRootControlPlane.setTopicRootCandidates(next);
|
|
740
|
-
this.shardRootCache.clear();
|
|
741
|
-
this.scheduleReconcileShardOverlays();
|
|
742
|
-
|
|
743
|
-
// In auto-candidate mode, shard roots are selected deterministically across
|
|
744
|
-
// *all* connected peers (not just those currently subscribed to a shard).
|
|
745
|
-
// That means a peer can be selected as root for shards it isn't using yet.
|
|
746
|
-
// Ensure we proactively host the shard roots we're responsible for so other
|
|
747
|
-
// peers can join without timing out in small ad-hoc networks.
|
|
748
|
-
this.scheduleHostOwnedShardRoots();
|
|
749
|
-
|
|
750
|
-
// Share the updated candidate set so other peers converge on the same
|
|
751
|
-
// deterministic mapping even in partially connected topologies.
|
|
752
|
-
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
791
|
+
this.queueAutoTopicRootCandidateUpdate([peerHash]);
|
|
753
792
|
}
|
|
754
793
|
|
|
755
794
|
private normalizeAutoTopicRootCandidates(candidates: string[]): string[] {
|
|
@@ -842,26 +881,88 @@ export class TopicControlPlane
|
|
|
842
881
|
|
|
843
882
|
private mergeAutoTopicRootCandidatesFromPeer(candidates: string[]): boolean {
|
|
844
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;
|
|
845
896
|
if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
|
|
846
897
|
return false;
|
|
847
898
|
const managed = this.autoTopicRootCandidateSet;
|
|
848
899
|
if (!managed) return false;
|
|
849
900
|
|
|
850
|
-
const before = this.
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const next = this.normalizeAutoTopicRootCandidates([...managed]);
|
|
856
|
-
this.autoTopicRootCandidateSet = new Set(next);
|
|
901
|
+
const before = this.pendingAutoTopicRootCandidates ?? [...managed];
|
|
902
|
+
const next = this.normalizeAutoTopicRootCandidates([
|
|
903
|
+
...before,
|
|
904
|
+
...candidates,
|
|
905
|
+
]);
|
|
857
906
|
if (sameCandidates(before, next)) return false;
|
|
858
907
|
|
|
908
|
+
if (this.autoTopicRootCandidateUpdateTimer) {
|
|
909
|
+
this.pendingAutoTopicRootCandidates = next;
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
this.scheduleAutoTopicRootCandidateUpdateCooldown();
|
|
914
|
+
this.applyAutoTopicRootCandidates(next);
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
private applyAutoTopicRootCandidates(next: string[]) {
|
|
919
|
+
this.autoTopicRootCandidateSet = new Set(next);
|
|
859
920
|
this.topicRootControlPlane.setTopicRootCandidates(next);
|
|
860
921
|
this.shardRootCache.clear();
|
|
861
922
|
this.scheduleReconcileShardOverlays();
|
|
862
923
|
this.scheduleHostOwnedShardRoots();
|
|
863
924
|
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
864
|
-
|
|
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;
|
|
865
966
|
}
|
|
866
967
|
|
|
867
968
|
private scheduleHostOwnedShardRoots() {
|
|
@@ -891,6 +992,7 @@ export class TopicControlPlane
|
|
|
891
992
|
|
|
892
993
|
private scheduleReconcileShardOverlays() {
|
|
893
994
|
const candidateGeneration = this.getTopicRootCandidateGeneration();
|
|
995
|
+
this.syncTopicRootCandidateResolutionSignal(candidateGeneration);
|
|
894
996
|
for (const opening of this.ensureFanoutChannelInFlight.values()) {
|
|
895
997
|
if (opening.candidateGeneration !== candidateGeneration) {
|
|
896
998
|
opening.abortController.abort(
|
|
@@ -1238,6 +1340,67 @@ export class TopicControlPlane
|
|
|
1238
1340
|
}
|
|
1239
1341
|
}
|
|
1240
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
|
+
|
|
1241
1404
|
private normalizePeerTopicRootState(
|
|
1242
1405
|
topic: string,
|
|
1243
1406
|
root: string,
|
|
@@ -1257,13 +1420,21 @@ export class TopicControlPlane
|
|
|
1257
1420
|
|
|
1258
1421
|
private async resolveTopicRootState(
|
|
1259
1422
|
topic: string,
|
|
1423
|
+
options?: TopicRootResolutionOptions,
|
|
1260
1424
|
): Promise<{ root?: string; authoritative: boolean }> {
|
|
1261
|
-
|
|
1425
|
+
throwIfAborted(options?.signal);
|
|
1426
|
+
const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(
|
|
1427
|
+
topic,
|
|
1428
|
+
options,
|
|
1429
|
+
);
|
|
1262
1430
|
if (tracked) {
|
|
1263
1431
|
return { root: tracked, authoritative: true };
|
|
1264
1432
|
}
|
|
1265
1433
|
|
|
1266
|
-
const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
|
|
1434
|
+
const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
|
|
1435
|
+
topic,
|
|
1436
|
+
options,
|
|
1437
|
+
);
|
|
1267
1438
|
if (resolvedThroughPeers) {
|
|
1268
1439
|
// Unconfigured peer-query replies cannot override the locally
|
|
1269
1440
|
// deterministic root for internal shards in auto mode. Roots, resolvers,
|
|
@@ -1280,8 +1451,11 @@ export class TopicControlPlane
|
|
|
1280
1451
|
this.getConnectedTopicRootTrackers().length > 0
|
|
1281
1452
|
) {
|
|
1282
1453
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1283
|
-
await
|
|
1284
|
-
|
|
1454
|
+
await withAbort(
|
|
1455
|
+
delay(150 * (attempt < 4 ? 1 : 2), options),
|
|
1456
|
+
options?.signal,
|
|
1457
|
+
);
|
|
1458
|
+
const retried = await this.resolveTopicRootThroughPeers(topic, options);
|
|
1285
1459
|
if (retried) {
|
|
1286
1460
|
return this.normalizePeerTopicRootState(topic, retried);
|
|
1287
1461
|
}
|
|
@@ -1294,15 +1468,28 @@ export class TopicControlPlane
|
|
|
1294
1468
|
};
|
|
1295
1469
|
}
|
|
1296
1470
|
|
|
1297
|
-
public async resolveTopicRoot(
|
|
1298
|
-
|
|
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
|
+
);
|
|
1299
1484
|
}
|
|
1300
1485
|
|
|
1301
1486
|
private async resolveShardRootState(
|
|
1302
1487
|
shardTopic: string,
|
|
1488
|
+
options?: TopicRootResolutionOptions,
|
|
1303
1489
|
): Promise<{ root: string; candidateGeneration: string }> {
|
|
1304
1490
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1305
1491
|
for (;;) {
|
|
1492
|
+
throwIfAborted(options?.signal);
|
|
1306
1493
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1307
1494
|
// If someone configured topic-root candidates externally (e.g.
|
|
1308
1495
|
// TestSession router selection or Peerbit.bootstrap) after this peer
|
|
@@ -1319,7 +1506,7 @@ export class TopicControlPlane
|
|
|
1319
1506
|
return { root: cached.root, candidateGeneration };
|
|
1320
1507
|
}
|
|
1321
1508
|
|
|
1322
|
-
const resolved = await this.resolveTopicRootState(shardTopic);
|
|
1509
|
+
const resolved = await this.resolveTopicRootState(shardTopic, options);
|
|
1323
1510
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1324
1511
|
if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
|
|
1325
1512
|
continue;
|
|
@@ -1405,40 +1592,65 @@ export class TopicControlPlane
|
|
|
1405
1592
|
peer: PeerStreams,
|
|
1406
1593
|
topic: string,
|
|
1407
1594
|
timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
|
|
1595
|
+
signal?: AbortSignal,
|
|
1408
1596
|
): Promise<string | undefined> {
|
|
1597
|
+
throwIfAborted(signal);
|
|
1409
1598
|
if (!this.started || this.stopping) return undefined;
|
|
1410
1599
|
|
|
1411
1600
|
const expectedPeerHash = peer.publicKey.hashcode();
|
|
1412
1601
|
const requestId = this.nextTopicRootRequestIdValue();
|
|
1413
1602
|
const responsePromise = new Promise<string | undefined>((resolve) => {
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
+
);
|
|
1418
1622
|
timer.unref?.();
|
|
1419
1623
|
this.pendingTopicRootQueries.set(requestId, {
|
|
1420
1624
|
expectedPeerHash,
|
|
1421
1625
|
topic,
|
|
1422
|
-
resolve,
|
|
1626
|
+
resolve: settle,
|
|
1423
1627
|
timer,
|
|
1424
1628
|
});
|
|
1629
|
+
if (signal && onAbort) {
|
|
1630
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1631
|
+
if (signal.aborted) onAbort();
|
|
1632
|
+
}
|
|
1425
1633
|
});
|
|
1426
1634
|
|
|
1427
1635
|
try {
|
|
1428
|
-
await
|
|
1429
|
-
|
|
1430
|
-
|
|
1636
|
+
await withAbort(
|
|
1637
|
+
this.sendDirectControlMessage(
|
|
1638
|
+
peer,
|
|
1639
|
+
new TopicRootQuery({ requestId, topic }),
|
|
1640
|
+
),
|
|
1641
|
+
signal,
|
|
1431
1642
|
);
|
|
1432
|
-
} catch {
|
|
1643
|
+
} catch (error) {
|
|
1433
1644
|
const pending = this.pendingTopicRootQueries.get(requestId);
|
|
1434
1645
|
if (pending) {
|
|
1435
1646
|
this.pendingTopicRootQueries.delete(requestId);
|
|
1436
1647
|
clearTimeout(pending.timer);
|
|
1437
1648
|
pending.resolve(undefined);
|
|
1438
1649
|
}
|
|
1650
|
+
if (signal?.aborted) throw error;
|
|
1439
1651
|
}
|
|
1440
1652
|
|
|
1441
|
-
return responsePromise;
|
|
1653
|
+
return withAbort(responsePromise, signal);
|
|
1442
1654
|
}
|
|
1443
1655
|
|
|
1444
1656
|
private async confirmDirectShardRoot(
|
|
@@ -1458,6 +1670,7 @@ export class TopicControlPlane
|
|
|
1458
1670
|
rootPeer,
|
|
1459
1671
|
shardTopic,
|
|
1460
1672
|
DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS,
|
|
1673
|
+
signal,
|
|
1461
1674
|
),
|
|
1462
1675
|
signal,
|
|
1463
1676
|
);
|
|
@@ -1482,49 +1695,56 @@ export class TopicControlPlane
|
|
|
1482
1695
|
topic: string,
|
|
1483
1696
|
): Promise<string | undefined> {
|
|
1484
1697
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
|
|
1490
|
-
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1491
|
-
if (rootCandidateGeneration !== this.getTopicRootCandidateGeneration()) {
|
|
1492
|
-
continue;
|
|
1493
|
-
}
|
|
1494
|
-
if (root !== this.publicKeyHash) {
|
|
1495
|
-
return root;
|
|
1496
|
-
}
|
|
1497
|
-
if (!topic.startsWith(this.shardTopicPrefix)) {
|
|
1498
|
-
return root;
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
try {
|
|
1502
|
-
await this.ensureFanoutChannel(topic, {
|
|
1503
|
-
pin: true,
|
|
1504
|
-
root,
|
|
1505
|
-
rootCandidateGeneration,
|
|
1506
|
-
});
|
|
1698
|
+
const lifecycleSignal = this.topicRootResolutionAbortController.signal;
|
|
1699
|
+
return this.withTopicRootCandidateResolution(
|
|
1700
|
+
async ({ candidateGeneration, signal }) => {
|
|
1701
|
+
throwIfAborted(signal);
|
|
1507
1702
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1703
|
+
const root =
|
|
1704
|
+
await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic, {
|
|
1705
|
+
signal,
|
|
1706
|
+
});
|
|
1707
|
+
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1708
|
+
if (root !== this.publicKeyHash) {
|
|
1709
|
+
return root;
|
|
1512
1710
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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
|
+
);
|
|
1523
1741
|
}
|
|
1524
1742
|
|
|
1525
1743
|
private async resolveTopicRootThroughPeers(
|
|
1526
1744
|
topic: string,
|
|
1745
|
+
options?: TopicRootResolutionOptions,
|
|
1527
1746
|
): Promise<string | undefined> {
|
|
1747
|
+
throwIfAborted(options?.signal);
|
|
1528
1748
|
const peers = this.getConnectedTopicRootTrackers();
|
|
1529
1749
|
if (peers.length === 0) {
|
|
1530
1750
|
return undefined;
|
|
@@ -1532,14 +1752,22 @@ export class TopicControlPlane
|
|
|
1532
1752
|
|
|
1533
1753
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1534
1754
|
for (const peer of peers) {
|
|
1535
|
-
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
|
+
);
|
|
1536
1761
|
if (resolved) {
|
|
1537
1762
|
return resolved;
|
|
1538
1763
|
}
|
|
1539
1764
|
}
|
|
1540
1765
|
|
|
1541
1766
|
if (attempt < 2) {
|
|
1542
|
-
await
|
|
1767
|
+
await withAbort(
|
|
1768
|
+
delay(150 * (attempt + 1), options),
|
|
1769
|
+
options?.signal,
|
|
1770
|
+
);
|
|
1543
1771
|
}
|
|
1544
1772
|
}
|
|
1545
1773
|
return undefined;
|
|
@@ -1683,7 +1911,9 @@ export class TopicControlPlane
|
|
|
1683
1911
|
const existing = this.fanoutChannels.get(t);
|
|
1684
1912
|
if (existing) {
|
|
1685
1913
|
if (!root) {
|
|
1686
|
-
const resolved = await this.resolveShardRootState(t
|
|
1914
|
+
const resolved = await this.resolveShardRootState(t, {
|
|
1915
|
+
signal: options?.signal,
|
|
1916
|
+
});
|
|
1687
1917
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1688
1918
|
root = resolved.root;
|
|
1689
1919
|
resolvedGeneration = resolved.candidateGeneration;
|
|
@@ -1727,7 +1957,9 @@ export class TopicControlPlane
|
|
|
1727
1957
|
}
|
|
1728
1958
|
|
|
1729
1959
|
if (!root) {
|
|
1730
|
-
const resolved = await this.resolveShardRootState(t
|
|
1960
|
+
const resolved = await this.resolveShardRootState(t, {
|
|
1961
|
+
signal: options?.signal,
|
|
1962
|
+
});
|
|
1731
1963
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1732
1964
|
root = resolved.root;
|
|
1733
1965
|
resolvedGeneration = resolved.candidateGeneration;
|
|
@@ -1952,22 +2184,37 @@ export class TopicControlPlane
|
|
|
1952
2184
|
}
|
|
1953
2185
|
}
|
|
1954
2186
|
|
|
1955
|
-
public async hostShardRootsNow() {
|
|
2187
|
+
public async hostShardRootsNow(options?: TopicRootResolutionOptions) {
|
|
1956
2188
|
if (!this.started) throw new NotStartedError();
|
|
1957
|
-
const
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
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
|
+
);
|
|
1971
2218
|
}
|
|
1972
2219
|
|
|
1973
2220
|
async subscribe(topic: string) {
|