@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/src/index.ts CHANGED
@@ -70,7 +70,10 @@ import type {
70
70
  FanoutTreeDataEvent,
71
71
  FanoutTreeJoinOptions,
72
72
  } from "./fanout-tree.js";
73
- import { TopicRootControlPlane } from "./topic-root-control-plane.js";
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
- if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
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.topicRootControlPlane.getTopicRootCandidates();
851
- for (const c of candidates) {
852
- if (!isCanonicalTopicRootCandidate(c)) continue;
853
- managed.add(c);
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
- return true;
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
- const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic);
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(topic);
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 delay(150 * (attempt < 4 ? 1 : 2));
1284
- const retried = await this.resolveTopicRootThroughPeers(topic);
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(topic: string): Promise<string | undefined> {
1298
- return (await this.resolveTopicRootState(topic)).root;
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
- const timer = setTimeout(() => {
1415
- this.pendingTopicRootQueries.delete(requestId);
1416
- resolve(undefined);
1417
- }, Math.max(1, Math.floor(timeoutMs)));
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 this.sendDirectControlMessage(
1429
- peer,
1430
- new TopicRootQuery({ requestId, topic }),
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
- for (;;) {
1486
- this.assertTopicControlPlaneActive(lifecycleRevision);
1487
- const rootCandidateGeneration = this.getTopicRootCandidateGeneration();
1488
- const root =
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
- if (
1509
- rootCandidateGeneration !== this.getTopicRootCandidateGeneration()
1510
- ) {
1511
- continue;
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
- return root;
1514
- } catch (error) {
1515
- warn(
1516
- `Failed to host shard root ${topic} before answering root query: ${
1517
- error instanceof Error ? error.message : String(error)
1518
- }`,
1519
- );
1520
- return undefined;
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(peer, topic);
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 delay(150 * (attempt + 1));
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 joins: Promise<void>[] = [];
1958
- for (let i = 0; i < this.shardCount; i++) {
1959
- const shardTopic = `${this.shardTopicPrefix}${i}`;
1960
- const resolved = await this.resolveShardRootState(shardTopic);
1961
- if (resolved.root !== this.publicKeyHash) continue;
1962
- joins.push(
1963
- this.ensureFanoutChannel(shardTopic, {
1964
- pin: true,
1965
- root: resolved.root,
1966
- rootCandidateGeneration: resolved.candidateGeneration,
1967
- }),
1968
- );
1969
- }
1970
- await Promise.all(joins);
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) {