@peerbit/pubsub 5.4.8 → 5.4.11

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.js CHANGED
@@ -144,6 +144,8 @@ const TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS = 3_000;
144
144
  const TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS;
145
145
  const TOPIC_ROOT_HOST_SCAN_CONCURRENCY = 8;
146
146
  const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
147
+ const RELAY_ROOT_PREFLIGHT_TIMEOUT_MS = 2_000;
148
+ const RELAY_ROOT_PREFLIGHT_REUSE_MS = 2_000;
147
149
  const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS = {
148
150
  msgRate: 30,
149
151
  msgSize: 1024,
@@ -225,9 +227,9 @@ export class TopicControlPlane extends DirectStream {
225
227
  hostOwnedShardRootsInFlight;
226
228
  hostOwnedShardRootsDirty = false;
227
229
  signedTopicRootCandidateClaims = new Map();
228
- // A directly observed departure makes only that origin's current lease
229
- // ineffective locally. This is not a component-wide withdrawal: claims learned
230
- // only through relays keep their existing lease semantics. Keeping the verified
230
+ relayRootProbeCohort;
231
+ // Locally observed unreachability makes only that origin's current lease
232
+ // ineffective locally. This is not a component-wide withdrawal. Keeping the verified
231
233
  // bytes + replay floor prevents a relay from resurrecting the departed lease;
232
234
  // the map is bounded by the same retained-origin cap as the claim map.
233
235
  suppressedDepartedTopicRootCandidateClaims = new Map();
@@ -245,6 +247,34 @@ export class TopicControlPlane extends DirectStream {
245
247
  refilledAt: Date.now(),
246
248
  };
247
249
  _onFanoutPeerUnreachable;
250
+ onFanoutAttached = (ev) => {
251
+ const st = this.fanoutChannels.get(ev.detail.topic);
252
+ if (!st || st.root !== ev.detail.root)
253
+ return;
254
+ // A successful publish before attachment may have had no listeners.
255
+ // Coalesce attachment bursts, retaining one trailing pass during a send.
256
+ st.announceDirty = true;
257
+ if (st.announceTask)
258
+ return;
259
+ const revision = this.topicControlPlaneLifecycleRevision;
260
+ st.announceTask = Promise.resolve()
261
+ .then(async () => {
262
+ await st.join;
263
+ while (st.announceDirty &&
264
+ this.isFanoutChannelCurrent(ev.detail.topic, st.channel, revision)) {
265
+ st.announceDirty = false;
266
+ await this.announceShardSubscriptions(ev.detail.topic, revision);
267
+ }
268
+ })
269
+ .catch(logErrorIfStarted)
270
+ .finally(() => {
271
+ st.announceTask = undefined;
272
+ if (st.announceDirty &&
273
+ this.isFanoutChannelCurrent(ev.detail.topic, st.channel, revision)) {
274
+ this.onFanoutAttached(ev);
275
+ }
276
+ });
277
+ };
248
278
  fanoutChannels = new Map();
249
279
  ensureFanoutChannelInFlight = new Map();
250
280
  pendingTopicRootQueries = new Map();
@@ -387,6 +417,8 @@ export class TopicControlPlane extends DirectStream {
387
417
  this._onFanoutPeerUnreachable =
388
418
  this._onFanoutPeerUnreachable || this.onFanoutPeerUnreachable.bind(this);
389
419
  await this.fanout.addEventListener("fanout:peer-unreachable", this._onFanoutPeerUnreachable);
420
+ this.fanout.addEventListener("fanout:joined", this.onFanoutAttached);
421
+ this.fanout.addEventListener("fanout:child-joined", this.onFanoutAttached);
390
422
  await super.start();
391
423
  if (this.autoTopicRootCandidates) {
392
424
  let retryDelayMs = 0;
@@ -427,9 +459,10 @@ export class TopicControlPlane extends DirectStream {
427
459
  if (this._onFanoutPeerUnreachable) {
428
460
  this.fanout.removeEventListener("fanout:peer-unreachable", this._onFanoutPeerUnreachable);
429
461
  }
462
+ this.fanout.removeEventListener("fanout:joined", this.onFanoutAttached);
463
+ this.fanout.removeEventListener("fanout:child-joined", this.onFanoutAttached);
430
464
  for (const st of this.fanoutChannels.values()) {
431
- if (st.idleCloseTimeout)
432
- clearTimeout(st.idleCloseTimeout);
465
+ this.clearFanoutIdleClose(st);
433
466
  try {
434
467
  st.channel.removeEventListener("data", st.onData);
435
468
  }
@@ -492,22 +525,23 @@ export class TopicControlPlane extends DirectStream {
492
525
  this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
493
526
  immediate: hadSignedPeer !== hasSignedPeer,
494
527
  });
495
- if (peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1) {
496
- const sendWhenOutboundReady = () => {
497
- if (!this.autoTopicRootCandidates ||
498
- this.peers.get(publicKey.hashcode()) !== peer) {
499
- return;
500
- }
501
- void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
502
- };
503
- if (peer.isWritable) {
504
- void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
505
- }
506
- if (existingPeer !== peer) {
507
- peer.addEventListener("stream:outbound", sendWhenOutboundReady);
508
- peer.addEventListener("close", () => peer.removeEventListener("stream:outbound", sendWhenOutboundReady), { once: true });
509
- }
528
+ }
529
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
530
+ const sendWhenOutboundReady = () => {
531
+ if (!this.isTopicControlPlaneActive(lifecycleRevision) ||
532
+ this.peers.get(publicKey.hashcode()) !== peer)
533
+ return;
534
+ if (this.autoTopicRootCandidates &&
535
+ peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1) {
536
+ void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
510
537
  }
538
+ void this.announceDirectSubscriptions([...this.subscriptions.keys()], [peer]).catch(logErrorIfStarted);
539
+ };
540
+ if (peer.isWritable)
541
+ sendWhenOutboundReady();
542
+ if (existingPeer !== peer) {
543
+ peer.addEventListener("stream:outbound", sendWhenOutboundReady);
544
+ peer.addEventListener("close", () => peer.removeEventListener("stream:outbound", sendWhenOutboundReady), { once: true });
511
545
  }
512
546
  return peer;
513
547
  }
@@ -525,15 +559,7 @@ export class TopicControlPlane extends DirectStream {
525
559
  protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
526
560
  this.signedTopicRootCandidateClaims.has(hash)) {
527
561
  const claim = this.signedTopicRootCandidateClaims.get(hash);
528
- const suppressed = this.suppressedDepartedTopicRootCandidateClaims.get(hash);
529
- suppressedDepartedOrigin =
530
- !suppressed ||
531
- suppressed.timestamp !== claim.timestamp ||
532
- !bytesEqual(suppressed.bytes, claim.bytes);
533
- this.suppressedDepartedTopicRootCandidateClaims.set(hash, {
534
- bytes: claim.bytes,
535
- timestamp: claim.timestamp,
536
- });
562
+ suppressedDepartedOrigin = this.suppressTopicRootCandidateClaim(hash, claim);
537
563
  }
538
564
  if (this.autoTopicRootCandidates &&
539
565
  (protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
@@ -577,6 +603,8 @@ export class TopicControlPlane extends DirectStream {
577
603
  }
578
604
  }
579
605
  clearSignedTopicRootCandidateState() {
606
+ this.relayRootProbeCohort?.controller.abort(new AbortError("topic root candidate recovery stopped"));
607
+ this.relayRootProbeCohort = undefined;
580
608
  this.clearTopicRootCandidateClaimTimer();
581
609
  this.signedTopicRootCandidateClaims.clear();
582
610
  this.suppressedDepartedTopicRootCandidateClaims.clear();
@@ -863,6 +891,264 @@ export class TopicControlPlane extends DirectStream {
863
891
  return (suppressed?.timestamp === claim.timestamp &&
864
892
  bytesEqual(suppressed.bytes, claim.bytes));
865
893
  }
894
+ suppressTopicRootCandidateClaim(origin, claim) {
895
+ if (!this.autoTopicRootCandidates ||
896
+ origin === this.publicKeyHash ||
897
+ this.peers.has(origin) ||
898
+ this.signedTopicRootCandidateClaims.get(origin) !== claim ||
899
+ this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim))
900
+ return false;
901
+ this.suppressedDepartedTopicRootCandidateClaims.set(origin, {
902
+ bytes: claim.bytes,
903
+ timestamp: claim.timestamp,
904
+ });
905
+ return true;
906
+ }
907
+ topicRootRouteObservations(origin) {
908
+ return this.routes
909
+ .getRouteHints(this.publicKeyHash, origin)
910
+ .map((hint) => JSON.stringify([hint.nextHop, hint.session, hint.updatedAt]));
911
+ }
912
+ createRelayRootProbeCohort(claims) {
913
+ const previous = this.relayRootProbeCohort?.results;
914
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
915
+ const controller = new AbortController();
916
+ const users = new Set();
917
+ const deadline = new AbortError("relay root reachability deadline reached");
918
+ const timer = setTimeout(() => controller.abort(deadline), RELAY_ROOT_PREFLIGHT_TIMEOUT_MS);
919
+ timer.unref?.();
920
+ let resolveRetry;
921
+ const retryBoundary = new Promise((resolve) => {
922
+ resolveRetry = resolve;
923
+ });
924
+ const retryTimer = setTimeout(resolveRetry, RELAY_ROOT_PREFLIGHT_TIMEOUT_MS / 2);
925
+ retryTimer.unref?.();
926
+ const captured = claims.map(([origin, claim]) => ({
927
+ origin,
928
+ claim,
929
+ routes: this.topicRootRouteObservations(origin),
930
+ }));
931
+ const cohort = {
932
+ controller,
933
+ users,
934
+ lifecycleRevision,
935
+ promise: Promise.resolve()
936
+ .then(async () => {
937
+ const outcomes = await Promise.all(captured.map(async ({ origin, claim, routes }) => {
938
+ const cached = previous?.get(origin);
939
+ if (cached?.claim === claim &&
940
+ cached.status === "alive" &&
941
+ performance.now() - cached.observedAt <
942
+ RELAY_ROOT_PREFLIGHT_REUSE_MS)
943
+ return [origin, { ...cached, routes }];
944
+ const winner = new AbortController();
945
+ const signals = linkAbortSignals([
946
+ controller.signal,
947
+ winner.signal,
948
+ ]);
949
+ let alive = false;
950
+ let resolveAlive;
951
+ const firstSuccess = new Promise((resolve) => {
952
+ resolveAlive = resolve;
953
+ });
954
+ const probe = async (retry) => {
955
+ const signal = signals.signal;
956
+ let submitted = false;
957
+ try {
958
+ if (retry)
959
+ await withAbort(retryBoundary, signal);
960
+ throwIfAborted(signal);
961
+ if (alive)
962
+ return "alive";
963
+ const message = await withAbort(this.createMessage(undefined, {
964
+ mode: new AcknowledgeDelivery({
965
+ to: [origin],
966
+ redundancy: 1,
967
+ }),
968
+ priority: 1,
969
+ responsePriority: 1,
970
+ }), signal);
971
+ // Signers may ignore cancellation. Fence the send continuation.
972
+ throwIfAborted(signal);
973
+ if (alive)
974
+ return "alive";
975
+ if (!this.isTopicControlPlaneActive(lifecycleRevision) ||
976
+ ![...users].some((current) => current())) {
977
+ return "unknown";
978
+ }
979
+ // No transport neighbor is local readiness, not proof of origin death.
980
+ if (this.peers.size === 0)
981
+ return "unknown";
982
+ submitted = true;
983
+ await withAbort(this.publishMessage(this.publicKey, message, undefined, undefined, signal), signal);
984
+ throwIfAborted(signal);
985
+ alive = true;
986
+ resolveAlive("alive");
987
+ return "alive";
988
+ }
989
+ catch (error) {
990
+ if (submitted &&
991
+ (error === deadline || error instanceof DeliveryError))
992
+ return "failed";
993
+ else if (!signal.aborted)
994
+ throw error;
995
+ return "unknown";
996
+ }
997
+ };
998
+ // Hedge once at the shared midpoint without discarding the first
999
+ // authenticated ACK wait: a 1.5s first reply is still timely.
1000
+ let status;
1001
+ try {
1002
+ status = await Promise.race([
1003
+ firstSuccess,
1004
+ Promise.all([probe(false), probe(true)]).then((results) => results.includes("unknown")
1005
+ ? "unknown"
1006
+ : "failed"),
1007
+ ]);
1008
+ }
1009
+ finally {
1010
+ winner.abort(new AbortError("origin probe settled"));
1011
+ signals.clear();
1012
+ }
1013
+ return [
1014
+ origin,
1015
+ { claim, routes, status, observedAt: performance.now() },
1016
+ ];
1017
+ }));
1018
+ cohort.results = new Map(outcomes);
1019
+ return cohort.results;
1020
+ })
1021
+ .finally(() => {
1022
+ clearTimeout(timer);
1023
+ clearTimeout(retryTimer);
1024
+ controller.abort(new AbortError("relay root probes settled"));
1025
+ }),
1026
+ };
1027
+ this.relayRootProbeCohort = cohort;
1028
+ return cohort;
1029
+ }
1030
+ async preflightRelayRootCandidates(shardTopic, attempt, signal, isCurrent, localPolicyOnly = false) {
1031
+ throwIfAborted(signal);
1032
+ const revision = this.topicControlPlaneLifecycleRevision;
1033
+ const current = () => isCurrent() && this.isTopicControlPlaneActive(revision);
1034
+ if (!current())
1035
+ throw new AbortError("obsolete relay root recovery");
1036
+ if (!this.autoTopicRootCandidates ||
1037
+ this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
1038
+ return false;
1039
+ const claims = [...this.signedTopicRootCandidateClaims.entries()]
1040
+ .filter(([origin, claim]) => origin !== this.publicKeyHash &&
1041
+ !this.peers.has(origin) &&
1042
+ !this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim))
1043
+ .slice(0, TOPIC_ROOT_CANDIDATES_MAX);
1044
+ if (claims.length === 0)
1045
+ return false;
1046
+ const shared = this.relayRootProbeCohort;
1047
+ const cohort = (attempt.cohort ??=
1048
+ shared &&
1049
+ (shared.results
1050
+ ? shared.users.size > 0
1051
+ : !shared.controller.signal.aborted) &&
1052
+ shared.lifecycleRevision === revision
1053
+ ? shared
1054
+ : this.createRelayRootProbeCohort(claims));
1055
+ cohort.users.add(current);
1056
+ let held = true;
1057
+ const release = () => {
1058
+ if (!held)
1059
+ return;
1060
+ held = false;
1061
+ cohort.users.delete(current);
1062
+ if (cohort.users.size === 0 && !cohort.results) {
1063
+ cohort.controller.abort(new AbortError("relay root recovery cancelled"));
1064
+ }
1065
+ };
1066
+ signal?.addEventListener("abort", release, { once: true });
1067
+ try {
1068
+ const outcomes = await withAbort(cohort.promise, signal);
1069
+ throwIfAborted(signal);
1070
+ if (!current())
1071
+ throw new AbortError("obsolete relay root recovery");
1072
+ // Policy setters do not share the candidate generation. Re-read configured
1073
+ // roots/resolvers/trackers after observation; a stalled policy is unknown,
1074
+ // never evidence authorizing local suppression.
1075
+ attempt.policyDeadline ??=
1076
+ performance.now() + RELAY_ROOT_PREFLIGHT_TIMEOUT_MS;
1077
+ return await this.withRelayRootPolicyDeadline(attempt, signal, async (policySignal) => {
1078
+ const configured = await withAbort(localPolicyOnly
1079
+ ? this.topicRootControlPlane.resolveLocalTopicRoot(shardTopic, {
1080
+ signal: policySignal,
1081
+ })
1082
+ : this.topicRootControlPlane.resolveTrackedTopicRoot(shardTopic, {
1083
+ signal: policySignal,
1084
+ }), policySignal);
1085
+ throwIfAborted(policySignal);
1086
+ if (!current())
1087
+ throw new AbortError("obsolete relay root recovery");
1088
+ this.shardRootCache.delete(shardTopic);
1089
+ if (configured ||
1090
+ this.topicRootControlPlane.getTopicRoot(shardTopic) ||
1091
+ !this.autoTopicRootCandidates ||
1092
+ this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
1093
+ return true;
1094
+ let changed = false;
1095
+ let unknown = false;
1096
+ for (const [origin, claim] of claims) {
1097
+ if (this.peers.has(origin) ||
1098
+ this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim))
1099
+ continue;
1100
+ const observed = outcomes.get(origin);
1101
+ if (this.signedTopicRootCandidateClaims.get(origin) !== claim ||
1102
+ observed?.claim !== claim) {
1103
+ unknown = true;
1104
+ continue;
1105
+ }
1106
+ const freshRoute = this.routes.isReachable(this.publicKeyHash, origin) &&
1107
+ this.topicRootRouteObservations(origin).some((route) => !observed.routes.includes(route));
1108
+ if (freshRoute || observed.status === "alive")
1109
+ continue;
1110
+ if (observed.status === "unknown")
1111
+ unknown = true;
1112
+ else
1113
+ changed =
1114
+ this.suppressTopicRootCandidateClaim(origin, claim) || changed;
1115
+ }
1116
+ if (changed)
1117
+ this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
1118
+ immediate: true,
1119
+ });
1120
+ if (unknown)
1121
+ throw new DeliveryError("Relay root reachability could not be established");
1122
+ return true;
1123
+ });
1124
+ }
1125
+ finally {
1126
+ signal?.removeEventListener("abort", release);
1127
+ release();
1128
+ }
1129
+ }
1130
+ async withRelayRootPolicyDeadline(attempt, signal, resolve) {
1131
+ // Ordinary initial resolution retains its existing contract. Only work
1132
+ // following a probe shares the one additional policy budget, including retries.
1133
+ if (attempt.policyDeadline === undefined)
1134
+ return resolve(signal);
1135
+ throwIfAborted(signal);
1136
+ const remaining = attempt.policyDeadline - performance.now();
1137
+ const deadline = new AbortError("relay root policy recheck deadline reached");
1138
+ if (remaining <= 0)
1139
+ throw deadline;
1140
+ const controller = new AbortController();
1141
+ const timer = setTimeout(() => controller.abort(deadline), remaining);
1142
+ timer.unref?.();
1143
+ const signals = linkAbortSignals([signal, controller.signal]);
1144
+ try {
1145
+ return await withAbort(resolve(signals.signal), signals.signal);
1146
+ }
1147
+ finally {
1148
+ clearTimeout(timer);
1149
+ signals.clear();
1150
+ }
1151
+ }
866
1152
  canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp) {
867
1153
  const floor = this.topicRootCandidateClaimReplayFloors.get(origin);
868
1154
  if (floor !== undefined) {
@@ -923,7 +1209,7 @@ export class TopicControlPlane extends DirectStream {
923
1209
  this.topicRootCandidateClaimGlobalVerifyBudget.remaining -= 1;
924
1210
  return true;
925
1211
  }
926
- async importSignedTopicRootCandidateClaim(rawClaim, outerPeerHash, expectedPeer) {
1212
+ async importSignedTopicRootCandidateClaim(rawClaim, outerPeerHash, expectedPeer, batchUpdate) {
927
1213
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
928
1214
  if (rawClaim.byteLength === 0 ||
929
1215
  rawClaim.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
@@ -962,7 +1248,13 @@ export class TopicControlPlane extends DirectStream {
962
1248
  if (acceptanceDeadline === undefined) {
963
1249
  return false;
964
1250
  }
965
- this.rebuildAutoTopicRootCandidatesFromClaims(now);
1251
+ if (batchUpdate) {
1252
+ if (this.pruneExpiredTopicRootCandidateClaims(now))
1253
+ batchUpdate.immediate = true;
1254
+ }
1255
+ else {
1256
+ this.rebuildAutoTopicRootCandidatesFromClaims(now);
1257
+ }
966
1258
  const retained = this.signedTopicRootCandidateClaims.get(origin);
967
1259
  if (outerPeerHash === origin &&
968
1260
  expectedPeer !== undefined &&
@@ -980,7 +1272,10 @@ export class TopicControlPlane extends DirectStream {
980
1272
  // monotonic acceptance deadline. A current authenticated origin stream is
981
1273
  // sufficient liveness evidence; do not advance the floor or lease.
982
1274
  this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
983
- this.rebuildAutoTopicRootCandidatesFromClaims(now, { immediate: true });
1275
+ if (batchUpdate)
1276
+ batchUpdate.immediate = true;
1277
+ else
1278
+ this.rebuildAutoTopicRootCandidatesFromClaims(now, { immediate: true });
984
1279
  return false;
985
1280
  }
986
1281
  if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
@@ -1224,6 +1519,7 @@ export class TopicControlPlane extends DirectStream {
1224
1519
  async reconcileShardOverlays() {
1225
1520
  if (!this.started || this.topicControlPlaneStopping)
1226
1521
  return;
1522
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1227
1523
  const byShard = new Map();
1228
1524
  for (const topic of this.subscriptions.keys()) {
1229
1525
  const shardTopic = this.getShardTopicForUserTopic(topic);
@@ -1231,25 +1527,19 @@ export class TopicControlPlane extends DirectStream {
1231
1527
  }
1232
1528
  // Ensure shard overlays are joined using the current root mapping (may
1233
1529
  // migrate channels if roots changed), then re-announce subscriptions.
1234
- await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
1530
+ const results = await Promise.allSettled([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
1235
1531
  if (userTopics.length === 0)
1236
1532
  return;
1237
- await this.ensureFanoutChannel(shardTopic, { ephemeral: false });
1238
- const msg = new Subscribe({
1239
- topics: userTopics,
1240
- requestSubscribers: true,
1241
- });
1242
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1243
- mode: new AnyWhere(),
1244
- priority: 1,
1245
- skipRecipientValidation: true,
1533
+ const owners = userTopics.map((topic) => [topic, this.subscriptions.get(topic)]);
1534
+ await this.ensureFanoutChannel(shardTopic, {
1535
+ ephemeral: false,
1536
+ isCurrent: () => owners.some(([topic, owner]) => owner && this.subscriptions.get(topic) === owner),
1246
1537
  });
1247
- const st = this.fanoutChannels.get(shardTopic);
1248
- if (!st)
1249
- return;
1250
- await st.channel.publish(toUint8Array(embedded.bytes()));
1251
- this.touchFanoutChannel(shardTopic);
1538
+ await this.announceShardSubscriptions(shardTopic, lifecycleRevision);
1252
1539
  }));
1540
+ const failure = results.find((result) => result.status === "rejected");
1541
+ if (failure?.status === "rejected")
1542
+ throw failure.reason;
1253
1543
  }
1254
1544
  isTrackedTopic(topic) {
1255
1545
  return this.topics.has(topic);
@@ -1260,16 +1550,17 @@ export class TopicControlPlane extends DirectStream {
1260
1550
  untrackTopic(topic) {
1261
1551
  const peers = this.topics.get(topic);
1262
1552
  this.topics.delete(topic);
1263
- if (!peers)
1264
- return;
1265
- for (const peerHash of peers.keys()) {
1553
+ for (const peerHash of peers?.keys() ?? []) {
1266
1554
  this.peerToTopic.get(peerHash)?.delete(topic);
1267
- this.lastSubscriptionMessages.get(peerHash)?.delete(topic);
1268
1555
  if (!this.peerToTopic.get(peerHash)?.size) {
1269
1556
  this.peerToTopic.delete(peerHash);
1270
- this.lastSubscriptionMessages.delete(peerHash);
1271
1557
  }
1272
1558
  }
1559
+ for (const [peerHash, messages] of this.lastSubscriptionMessages) {
1560
+ messages.delete(topic);
1561
+ if (messages.size === 0)
1562
+ this.lastSubscriptionMessages.delete(peerHash);
1563
+ }
1273
1564
  }
1274
1565
  initializePeer(publicKey) {
1275
1566
  // Remote subscribers can be non-neighbours (learned via relayed topic control).
@@ -1292,10 +1583,26 @@ export class TopicControlPlane extends DirectStream {
1292
1583
  this.lastSubscriptionMessages.get(oldest)?.delete(topic);
1293
1584
  if (!this.peerToTopic.get(oldest)?.size) {
1294
1585
  this.peerToTopic.delete(oldest);
1586
+ }
1587
+ if (!this.lastSubscriptionMessages.get(oldest)?.size) {
1295
1588
  this.lastSubscriptionMessages.delete(oldest);
1296
1589
  }
1297
1590
  }
1298
1591
  }
1592
+ pruneUnsubscribedWatermarks(topic) {
1593
+ let remaining = this.subscriberCacheMaxEntries;
1594
+ for (const [peerHash, messages] of [
1595
+ ...this.lastSubscriptionMessages,
1596
+ ].reverse()) {
1597
+ if (!messages.has(topic) || this.topics.get(topic)?.has(peerHash))
1598
+ continue;
1599
+ if (remaining-- > 0)
1600
+ continue;
1601
+ messages.delete(topic);
1602
+ if (messages.size === 0)
1603
+ this.lastSubscriptionMessages.delete(peerHash);
1604
+ }
1605
+ }
1299
1606
  getSubscriptionOverlap(topics) {
1300
1607
  const subscriptions = [];
1301
1608
  if (topics) {
@@ -1327,24 +1634,35 @@ export class TopicControlPlane extends DirectStream {
1327
1634
  }
1328
1635
  scheduleFanoutIdleClose(topic) {
1329
1636
  const st = this.fanoutChannels.get(topic);
1330
- if (!st || !st.ephemeral)
1637
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1638
+ if (!st ||
1639
+ !st.ephemeral ||
1640
+ !this.isTopicControlPlaneActive(lifecycleRevision))
1331
1641
  return;
1332
1642
  this.clearFanoutIdleClose(st);
1333
1643
  if (this.fanoutPublishIdleCloseMs <= 0)
1334
1644
  return;
1335
- st.idleCloseTimeout = setTimeout(() => {
1336
- const cur = this.fanoutChannels.get(topic);
1337
- if (!cur || !cur.ephemeral)
1645
+ const timer = setTimeout(() => {
1646
+ // Cancellation can race a queued callback. It must still own this exact
1647
+ // timer, channel state and lifecycle before acting on the topic again.
1648
+ if (!this.isTopicControlPlaneActive(lifecycleRevision) ||
1649
+ this.fanoutChannels.get(topic) !== st ||
1650
+ st.idleCloseTimeout !== timer ||
1651
+ !st.ephemeral)
1338
1652
  return;
1339
- const idleMs = Date.now() - cur.lastUsedAt;
1653
+ st.idleCloseTimeout = undefined;
1654
+ const idleMs = Date.now() - st.lastUsedAt;
1340
1655
  if (idleMs >= this.fanoutPublishIdleCloseMs) {
1341
1656
  void this.closeFanoutChannel(topic);
1342
1657
  return;
1343
1658
  }
1344
1659
  this.scheduleFanoutIdleClose(topic);
1345
1660
  }, this.fanoutPublishIdleCloseMs);
1661
+ st.idleCloseTimeout = timer;
1346
1662
  }
1347
- touchFanoutChannel(topic) {
1663
+ touchFanoutChannel(topic, channel, lifecycleRevision) {
1664
+ if (!this.isFanoutChannelCurrent(topic, channel, lifecycleRevision))
1665
+ return;
1348
1666
  const st = this.fanoutChannels.get(topic);
1349
1667
  if (!st)
1350
1668
  return;
@@ -1555,20 +1873,27 @@ export class TopicControlPlane extends DirectStream {
1555
1873
  topic.startsWith(this.shardTopicPrefix) &&
1556
1874
  deterministic &&
1557
1875
  root !== deterministic) {
1558
- return { root: deterministic, authoritative: false };
1876
+ return { root: deterministic, authoritative: false, autoCandidate: true };
1559
1877
  }
1560
- return { root, authoritative: true };
1878
+ return {
1879
+ root,
1880
+ authoritative: true,
1881
+ autoCandidate: this.autoTopicRootCandidates &&
1882
+ topic.startsWith(this.shardTopicPrefix) &&
1883
+ root === deterministic,
1884
+ };
1561
1885
  }
1562
1886
  async resolveTopicRootState(topic, options) {
1563
1887
  throwIfAborted(options?.signal);
1564
1888
  const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic, options);
1565
1889
  if (tracked) {
1566
- return { root: tracked, authoritative: true };
1890
+ return { root: tracked, authoritative: true, autoCandidate: false };
1567
1891
  }
1568
1892
  if (options?.queryConnectedPeers === false) {
1569
1893
  return {
1570
1894
  root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
1571
1895
  authoritative: false,
1896
+ autoCandidate: this.autoTopicRootCandidates,
1572
1897
  };
1573
1898
  }
1574
1899
  const deadlineController = new AbortController();
@@ -1630,6 +1955,7 @@ export class TopicControlPlane extends DirectStream {
1630
1955
  return {
1631
1956
  root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
1632
1957
  authoritative: false,
1958
+ autoCandidate: this.autoTopicRootCandidates,
1633
1959
  };
1634
1960
  }
1635
1961
  async resolveTopicRoot(topic, options) {
@@ -1658,7 +1984,11 @@ export class TopicControlPlane extends DirectStream {
1658
1984
  ? undefined
1659
1985
  : this.shardRootCache.get(shardTopic);
1660
1986
  if (cached && (cached.authoritative || !hasConnectedTrackers)) {
1661
- return { root: cached.root, candidateGeneration };
1987
+ return {
1988
+ root: cached.root,
1989
+ candidateGeneration,
1990
+ autoCandidate: cached.autoCandidate,
1991
+ };
1662
1992
  }
1663
1993
  const resolved = await this.resolveTopicRootState(shardTopic, options);
1664
1994
  this.assertTopicControlPlaneActive(lifecycleRevision);
@@ -1674,7 +2004,11 @@ export class TopicControlPlane extends DirectStream {
1674
2004
  else {
1675
2005
  this.shardRootCache.delete(shardTopic);
1676
2006
  }
1677
- return { root: resolved.root, candidateGeneration };
2007
+ return {
2008
+ root: resolved.root,
2009
+ candidateGeneration,
2010
+ autoCandidate: resolved.autoCandidate,
2011
+ };
1678
2012
  }
1679
2013
  }
1680
2014
  getConnectedTopicRootTrackers() {
@@ -1698,6 +2032,11 @@ export class TopicControlPlane extends DirectStream {
1698
2032
  } while (true);
1699
2033
  }
1700
2034
  async sendDirectControlMessage(peer, pubsubMessage, signal) {
2035
+ const revision = this.topicControlPlaneLifecycleRevision;
2036
+ const isCurrent = () => this.isTopicControlPlaneActive(revision) &&
2037
+ this.peers.get(peer.publicKey.hashcode()) === peer;
2038
+ if (!isCurrent())
2039
+ return;
1701
2040
  const embedded = await this.createMessage(this.encodePubSubMessage(pubsubMessage), {
1702
2041
  mode: new SilentDelivery({
1703
2042
  to: [peer.publicKey.hashcode()],
@@ -1706,7 +2045,9 @@ export class TopicControlPlane extends DirectStream {
1706
2045
  priority: 1,
1707
2046
  skipRecipientValidation: true,
1708
2047
  });
1709
- await this.publishMessage(this.publicKey, embedded, [peer], undefined, signal);
2048
+ if (!isCurrent())
2049
+ return;
2050
+ await this.publishMessage(this.publicKey, embedded, [peer], undefined, signal ?? this.topicRootResolutionAbortController.signal);
1710
2051
  }
1711
2052
  resolvePendingTopicRootQuery(message, responderPeerHash) {
1712
2053
  const pending = this.pendingTopicRootQueries.get(message.requestId);
@@ -1812,20 +2153,43 @@ export class TopicControlPlane extends DirectStream {
1812
2153
  this.shardRootCache.set(shardTopic, {
1813
2154
  root: resolved.root,
1814
2155
  authoritative: true,
2156
+ autoCandidate: resolved.autoCandidate,
1815
2157
  });
1816
2158
  }
1817
2159
  return resolved.root;
1818
2160
  }
1819
- async resolveQueryableTopicRoot(topic) {
2161
+ async resolveQueryableTopicRoot(topic, isCurrent = () => true) {
1820
2162
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1821
2163
  const lifecycleSignal = this.topicRootResolutionAbortController.signal;
2164
+ const recovery = {};
1822
2165
  return this.withTopicRootCandidateResolution(async ({ candidateGeneration, signal }) => {
1823
2166
  throwIfAborted(signal);
1824
2167
  this.assertTopicControlPlaneActive(lifecycleRevision);
1825
- const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic, {
1826
- signal,
2168
+ if (!isCurrent())
2169
+ throw new AbortError("obsolete topic root query");
2170
+ // Incoming queries retain canonical directory semantics: never ask
2171
+ // trackers here, since a tracker may itself query this peer.
2172
+ const resolveCanonical = () => this.withRelayRootPolicyDeadline(recovery, signal, async (policySignal) => {
2173
+ const local = await this.topicRootControlPlane.resolveLocalTopicRoot(topic, {
2174
+ signal: policySignal,
2175
+ });
2176
+ return {
2177
+ root: local ??
2178
+ this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
2179
+ autoCandidate: !local && this.autoTopicRootCandidates,
2180
+ };
1827
2181
  });
2182
+ let resolved = await resolveCanonical();
2183
+ if (resolved.autoCandidate && topic.startsWith(this.shardTopicPrefix)) {
2184
+ if (await this.preflightRelayRootCandidates(topic, recovery, signal, isCurrent, true)) {
2185
+ throwIfAborted(signal);
2186
+ resolved = await resolveCanonical();
2187
+ }
2188
+ }
2189
+ const root = resolved.root;
1828
2190
  this.assertTopicControlPlaneActive(lifecycleRevision);
2191
+ if (!isCurrent())
2192
+ throw new AbortError("obsolete topic root query");
1829
2193
  if (root !== this.publicKeyHash) {
1830
2194
  return root;
1831
2195
  }
@@ -1925,8 +2289,11 @@ export class TopicControlPlane extends DirectStream {
1925
2289
  async ensureFanoutChannel(shardTopic, options) {
1926
2290
  const topic = shardTopic.toString();
1927
2291
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
2292
+ const recovery = {};
1928
2293
  for (;;) {
1929
2294
  this.assertTopicControlPlaneActive(lifecycleRevision);
2295
+ if (options?.isCurrent?.() === false)
2296
+ throw new AbortError("obsolete fanout channel owner");
1930
2297
  if (options?.signal?.aborted) {
1931
2298
  throw (options.signal.reason ??
1932
2299
  new AbortError("fanout channel opening aborted"));
@@ -1934,7 +2301,8 @@ export class TopicControlPlane extends DirectStream {
1934
2301
  const candidateGeneration = this.getTopicRootCandidateGeneration();
1935
2302
  const active = this.ensureFanoutChannelInFlight.get(topic);
1936
2303
  if (active?.candidateGeneration === candidateGeneration &&
1937
- active.lifecycleRevision === lifecycleRevision) {
2304
+ active.lifecycleRevision === lifecycleRevision &&
2305
+ active.isCurrent()) {
1938
2306
  try {
1939
2307
  await withAbort(active.opening, options?.signal);
1940
2308
  }
@@ -1942,6 +2310,10 @@ export class TopicControlPlane extends DirectStream {
1942
2310
  if (options?.signal?.aborted)
1943
2311
  throw error;
1944
2312
  this.assertTopicControlPlaneActive(lifecycleRevision);
2313
+ if (candidateGeneration === this.getTopicRootCandidateGeneration() &&
2314
+ !active.abortController.signal.aborted &&
2315
+ active.isCurrent())
2316
+ throw error;
1945
2317
  }
1946
2318
  await withAbort(active.settled, options?.signal);
1947
2319
  if (this.ensureFanoutChannelInFlight.get(topic) === active) {
@@ -1970,13 +2342,19 @@ export class TopicControlPlane extends DirectStream {
1970
2342
  once: true,
1971
2343
  });
1972
2344
  }
1973
- const opening = this.ensureFanoutChannelOnce(topic, { ...options, signal: abortController.signal }, lifecycleRevision, candidateGeneration);
2345
+ // Publish the opening identity before even a synchronously resolved root
2346
+ // can enter its guarded recovery/creation continuation.
2347
+ const opening = Promise.resolve().then(() => this.ensureFanoutChannelOnce(topic, { ...options, signal: abortController.signal }, lifecycleRevision, candidateGeneration, recovery, () => this.ensureFanoutChannelInFlight.get(topic)?.abortController ===
2348
+ abortController &&
2349
+ !abortController.signal.aborted &&
2350
+ options?.isCurrent?.() !== false));
1974
2351
  let resolveSettled;
1975
2352
  const settled = new Promise((resolve) => {
1976
2353
  resolveSettled = resolve;
1977
2354
  });
1978
2355
  const inFlight = {
1979
2356
  abortController,
2357
+ isCurrent: () => options?.isCurrent?.() !== false,
1980
2358
  candidateGeneration,
1981
2359
  lifecycleRevision,
1982
2360
  opening,
@@ -2019,7 +2397,7 @@ export class TopicControlPlane extends DirectStream {
2019
2397
  }
2020
2398
  }
2021
2399
  }
2022
- async ensureFanoutChannelOnce(shardTopic, options, lifecycleRevision, candidateGeneration) {
2400
+ async ensureFanoutChannelOnce(shardTopic, options, lifecycleRevision, candidateGeneration, recovery = {}, isOpeningCurrent = () => true) {
2023
2401
  this.assertTopicControlPlaneActive(lifecycleRevision);
2024
2402
  if (candidateGeneration !== this.getTopicRootCandidateGeneration())
2025
2403
  return;
@@ -2038,15 +2416,16 @@ export class TopicControlPlane extends DirectStream {
2038
2416
  this.assertTopicControlPlaneActive(lifecycleRevision);
2039
2417
  let resolvedGeneration = candidateGeneration;
2040
2418
  let root = suppliedRoot;
2419
+ let autoCandidate = false;
2420
+ const resolveRoot = () => this.withRelayRootPolicyDeadline(recovery, options?.signal, (signal) => this.resolveShardRootState(t, { signal }));
2041
2421
  const existing = this.fanoutChannels.get(t);
2042
2422
  if (existing) {
2043
2423
  if (!root) {
2044
- const resolved = await this.resolveShardRootState(t, {
2045
- signal: options?.signal,
2046
- });
2424
+ const resolved = await resolveRoot();
2047
2425
  this.assertTopicControlPlaneActive(lifecycleRevision);
2048
2426
  root = resolved.root;
2049
2427
  resolvedGeneration = resolved.candidateGeneration;
2428
+ autoCandidate = resolved.autoCandidate;
2050
2429
  }
2051
2430
  if (resolvedGeneration !== candidateGeneration ||
2052
2431
  candidateGeneration !== this.getTopicRootCandidateGeneration()) {
@@ -2080,15 +2459,30 @@ export class TopicControlPlane extends DirectStream {
2080
2459
  }
2081
2460
  }
2082
2461
  if (!root) {
2083
- const resolved = await this.resolveShardRootState(t, {
2084
- signal: options?.signal,
2085
- });
2462
+ const resolved = await resolveRoot();
2086
2463
  this.assertTopicControlPlaneActive(lifecycleRevision);
2087
2464
  root = resolved.root;
2088
2465
  resolvedGeneration = resolved.candidateGeneration;
2466
+ autoCandidate = resolved.autoCandidate;
2467
+ }
2468
+ if (autoCandidate &&
2469
+ (await this.preflightRelayRootCandidates(t, recovery, options?.signal, isOpeningCurrent))) {
2470
+ throwIfAborted(options?.signal);
2471
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration())
2472
+ return;
2473
+ const resolved = await resolveRoot();
2474
+ root = resolved.root;
2475
+ resolvedGeneration = resolved.candidateGeneration;
2476
+ autoCandidate = resolved.autoCandidate;
2477
+ }
2478
+ if (suppliedRoot !== undefined ||
2479
+ !this.autoTopicRootCandidates ||
2480
+ autoCandidate) {
2481
+ root = await this.confirmDirectShardRoot(t, root, resolvedGeneration, lifecycleRevision, options?.signal);
2089
2482
  }
2090
- root = await this.confirmDirectShardRoot(t, root, resolvedGeneration, lifecycleRevision, options?.signal);
2091
2483
  this.assertTopicControlPlaneActive(lifecycleRevision);
2484
+ if (!isOpeningCurrent())
2485
+ throw new AbortError("obsolete fanout channel owner");
2092
2486
  if (resolvedGeneration !== candidateGeneration ||
2093
2487
  candidateGeneration !== this.getTopicRootCandidateGeneration()) {
2094
2488
  return;
@@ -2272,7 +2666,7 @@ export class TopicControlPlane extends DirectStream {
2272
2666
  join
2273
2667
  .then(() => {
2274
2668
  const st = this.fanoutChannels.get(t);
2275
- if (st === channelState && st.ephemeral) {
2669
+ if (st === channelState && st.ephemeral && isCurrent()) {
2276
2670
  this.scheduleFanoutIdleClose(t);
2277
2671
  }
2278
2672
  })
@@ -2409,6 +2803,78 @@ export class TopicControlPlane extends DirectStream {
2409
2803
  this.initializeTopic(topic);
2410
2804
  return this.debounceSubscribeAggregator.add({ key: topic });
2411
2805
  }
2806
+ async announceShardSubscriptions(shardTopic, lifecycleRevision) {
2807
+ const st = this.fanoutChannels.get(shardTopic);
2808
+ if (!st)
2809
+ return;
2810
+ let topics = [...this.subscriptions.entries()].filter(([topic]) => this.getShardTopicForUserTopic(topic) === shardTopic);
2811
+ while (topics.length > 0 &&
2812
+ this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision)) {
2813
+ const embedded = await this.createMessage(this.encodePubSubMessage(new Subscribe({
2814
+ topics: topics.map(([topic]) => topic),
2815
+ requestSubscribers: true,
2816
+ })), {
2817
+ mode: new AnyWhere(),
2818
+ priority: 1,
2819
+ skipRecipientValidation: true,
2820
+ });
2821
+ if (!this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision))
2822
+ return;
2823
+ const current = topics.filter(([topic, sub]) => this.subscriptions.get(topic) === sub);
2824
+ if (current.length !== topics.length) {
2825
+ // Never announce a subscription removed while signing. This retry
2826
+ // only shrinks the original batch, so churn cannot extend it forever.
2827
+ topics = current;
2828
+ continue;
2829
+ }
2830
+ await st.channel.publish(toUint8Array(embedded.bytes()));
2831
+ if (this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision)) {
2832
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
2833
+ }
2834
+ return;
2835
+ }
2836
+ }
2837
+ async announceDirectSubscriptions(topics, peers = [...this.peers.values()], subscribed = true) {
2838
+ const revision = this.topicControlPlaneLifecycleRevision;
2839
+ const signal = this.topicRootResolutionAbortController.signal;
2840
+ const subscriptions = new Map(topics.map((topic) => [topic, this.subscriptions.get(topic)]));
2841
+ const isCurrentTopic = (topic) => subscribed
2842
+ ? subscriptions.get(topic) != null &&
2843
+ this.subscriptions.get(topic) === subscriptions.get(topic)
2844
+ : !this.subscriptions.has(topic);
2845
+ await Promise.all(peers
2846
+ .map(async (peer) => {
2847
+ const isCurrent = () => this.isTopicControlPlaneActive(revision) &&
2848
+ this.peers.get(peer.publicKey.hashcode()) === peer &&
2849
+ peer.isWritable;
2850
+ let currentTopics = topics.filter(isCurrentTopic);
2851
+ while (isCurrent() && currentTopics.length > 0) {
2852
+ const embedded = await this.createMessage(this.encodePubSubMessage(subscribed
2853
+ ? new Subscribe({
2854
+ topics: currentTopics,
2855
+ requestSubscribers: true,
2856
+ })
2857
+ : new Unsubscribe({ topics: currentTopics })), {
2858
+ mode: new SilentDelivery({
2859
+ to: [peer.publicKey.hashcode()],
2860
+ redundancy: 1,
2861
+ }),
2862
+ priority: 1,
2863
+ skipRecipientValidation: true,
2864
+ });
2865
+ if (!isCurrent())
2866
+ return;
2867
+ const remaining = currentTopics.filter(isCurrentTopic);
2868
+ if (remaining.length !== currentTopics.length) {
2869
+ currentTopics = remaining;
2870
+ continue;
2871
+ }
2872
+ await this.publishMessage(this.publicKey, embedded, [peer], undefined, signal);
2873
+ return;
2874
+ }
2875
+ })
2876
+ .map((task) => task.catch(logErrorIfStarted)));
2877
+ }
2412
2878
  async _subscribe(topics, lifecycleRevision = this.topicControlPlaneLifecycleRevision) {
2413
2879
  if (!this.started)
2414
2880
  throw new NotStartedError();
@@ -2417,7 +2883,6 @@ export class TopicControlPlane extends DirectStream {
2417
2883
  if (topics.length === 0)
2418
2884
  return;
2419
2885
  const byShard = new Map();
2420
- const joins = [];
2421
2886
  for (const { key: topic, counter } of topics) {
2422
2887
  let prev = this.subscriptions.get(topic);
2423
2888
  if (prev) {
@@ -2431,33 +2896,25 @@ export class TopicControlPlane extends DirectStream {
2431
2896
  const shardTopic = this.getShardTopicForUserTopic(topic);
2432
2897
  byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
2433
2898
  this.shardRefCounts.set(shardTopic, (this.shardRefCounts.get(shardTopic) ?? 0) + 1);
2434
- joins.push(this.ensureFanoutChannel(shardTopic));
2435
2899
  }
2436
- await Promise.all(joins);
2437
- if (!this.isTopicControlPlaneActive(lifecycleRevision))
2438
- return;
2439
- // Announce subscriptions per shard overlay.
2440
- await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
2441
- if (userTopics.length === 0)
2442
- return;
2443
- const st = this.fanoutChannels.get(shardTopic);
2444
- if (!st)
2445
- throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
2446
- const msg = new Subscribe({
2447
- topics: userTopics,
2448
- requestSubscribers: true,
2449
- });
2450
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
2451
- mode: new AnyWhere(),
2452
- priority: 1,
2453
- skipRecipientValidation: true,
2900
+ // Neighbours can use different root policies. Discover them independently
2901
+ // of shard joins, without adopting or overriding either peer's root policy.
2902
+ void this.announceDirectSubscriptions([...byShard.values()].flat()).catch(logErrorIfStarted);
2903
+ const results = await Promise.allSettled([...byShard.keys()].map(async (shardTopic) => {
2904
+ const owners = byShard
2905
+ .get(shardTopic)
2906
+ .map((topic) => [topic, this.subscriptions.get(topic)]);
2907
+ await this.ensureFanoutChannel(shardTopic, {
2908
+ isCurrent: () => owners.some(([topic, owner]) => owner && this.subscriptions.get(topic) === owner),
2454
2909
  });
2455
- if (!this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision))
2456
- return;
2457
- await st.channel.publish(toUint8Array(embedded.bytes()));
2458
- if (this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision))
2459
- this.touchFanoutChannel(shardTopic);
2910
+ await this.announceShardSubscriptions(shardTopic, lifecycleRevision);
2460
2911
  }));
2912
+ const failure = results.find((result) => result.status === "rejected");
2913
+ if (failure?.status === "rejected") {
2914
+ if (this.isTopicControlPlaneActive(lifecycleRevision))
2915
+ this.scheduleReconcileShardOverlays();
2916
+ throw failure.reason;
2917
+ }
2461
2918
  }
2462
2919
  async unsubscribe(topic, options) {
2463
2920
  this.pendingSubscriptions.delete(topic);
@@ -2489,6 +2946,10 @@ export class TopicControlPlane extends DirectStream {
2489
2946
  const next = (this.shardRefCounts.get(shardTopic) ?? 0) - 1;
2490
2947
  if (next <= 0) {
2491
2948
  this.shardRefCounts.delete(shardTopic);
2949
+ const opening = this.ensureFanoutChannelInFlight.get(shardTopic);
2950
+ if (opening && !opening.isCurrent()) {
2951
+ opening.abortController.abort(new AbortError("last shard subscription removed"));
2952
+ }
2492
2953
  }
2493
2954
  else {
2494
2955
  this.shardRefCounts.set(shardTopic, next);
@@ -2506,12 +2967,14 @@ export class TopicControlPlane extends DirectStream {
2506
2967
  return;
2507
2968
  const byShard = new Map();
2508
2969
  for (const { key: topic } of topics) {
2509
- // If the topic got re-subscribed before this debounced batch ran, skip.
2970
+ // A pending reopen must not erase the previous departure: its Subscribe
2971
+ // shares the transport session and would otherwise only refresh the cache.
2510
2972
  if (this.subscriptions.has(topic))
2511
2973
  continue;
2512
2974
  const shardTopic = this.getShardTopicForUserTopic(topic);
2513
2975
  byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
2514
2976
  }
2977
+ void this.announceDirectSubscriptions([...byShard.values()].flat(), undefined, false).catch(logErrorIfStarted);
2515
2978
  await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
2516
2979
  if (userTopics.length === 0)
2517
2980
  return;
@@ -2532,7 +2995,7 @@ export class TopicControlPlane extends DirectStream {
2532
2995
  .publish(toUint8Array(embedded.bytes()))
2533
2996
  .catch(() => { });
2534
2997
  if (this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision))
2535
- this.touchFanoutChannel(shardTopic);
2998
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
2536
2999
  }
2537
3000
  }
2538
3001
  catch {
@@ -2554,6 +3017,7 @@ export class TopicControlPlane extends DirectStream {
2554
3017
  async announcePeerUnavailable(publicKeyHash, batches) {
2555
3018
  if (!this.started)
2556
3019
  throw new NotStartedError();
3020
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
2557
3021
  const byShard = new Map();
2558
3022
  for (const batch of batches) {
2559
3023
  for (const topic of batch.topics) {
@@ -2596,7 +3060,7 @@ export class TopicControlPlane extends DirectStream {
2596
3060
  const st = this.fanoutChannels.get(shardTopic);
2597
3061
  if (st) {
2598
3062
  void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
2599
- this.touchFanoutChannel(shardTopic);
3063
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
2600
3064
  }
2601
3065
  }
2602
3066
  catch {
@@ -2607,6 +3071,7 @@ export class TopicControlPlane extends DirectStream {
2607
3071
  async announcePeerUnavailableOnShard(publicKeyHash, shardTopic) {
2608
3072
  if (!this.started)
2609
3073
  throw new NotStartedError();
3074
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
2610
3075
  try {
2611
3076
  const msg = new PeerUnavailable({
2612
3077
  publicKeyHash,
@@ -2623,7 +3088,7 @@ export class TopicControlPlane extends DirectStream {
2623
3088
  const st = this.fanoutChannels.get(shardTopic);
2624
3089
  if (st) {
2625
3090
  void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
2626
- this.touchFanoutChannel(shardTopic);
3091
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
2627
3092
  }
2628
3093
  }
2629
3094
  catch {
@@ -2675,6 +3140,7 @@ export class TopicControlPlane extends DirectStream {
2675
3140
  async requestSubscribers(topic, to) {
2676
3141
  if (!this.started)
2677
3142
  throw new NotStartedError();
3143
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
2678
3144
  if (topic == null)
2679
3145
  throw new Error("ERR_NOT_VALID_TOPIC");
2680
3146
  if (topic.length === 0)
@@ -2718,12 +3184,13 @@ export class TopicControlPlane extends DirectStream {
2718
3184
  else {
2719
3185
  await st.channel.publish(payload);
2720
3186
  }
2721
- this.touchFanoutChannel(shardTopic);
3187
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
2722
3188
  }));
2723
3189
  }
2724
3190
  async publish(data, options) {
2725
3191
  if (!this.started)
2726
3192
  throw new NotStartedError();
3193
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
2727
3194
  const topicsAll = options.topics?.map((x) => x.toString()) || [];
2728
3195
  const hasExplicitTOs = options?.mode && deliveryModeHasReceiver(options.mode);
2729
3196
  // Explicit recipients: use DirectStream delivery (no shard broadcast).
@@ -2802,7 +3269,7 @@ export class TopicControlPlane extends DirectStream {
2802
3269
  throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
2803
3270
  }
2804
3271
  await withAbort(st.channel.publish(payload), options?.signal);
2805
- this.touchFanoutChannel(shardTopic);
3272
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
2806
3273
  }));
2807
3274
  if (this.fanoutPublishIdleCloseMs == 0 ||
2808
3275
  this.fanoutPublishMaxEphemeralChannels == 0) {
@@ -2866,6 +3333,16 @@ export class TopicControlPlane extends DirectStream {
2866
3333
  }
2867
3334
  onPeerUnreachable(publicKeyHash) {
2868
3335
  super.onPeerUnreachable(publicKeyHash);
3336
+ const claim = this.signedTopicRootCandidateClaims.get(publicKeyHash);
3337
+ if (this.isTopicControlPlaneActive(this.topicControlPlaneLifecycleRevision) &&
3338
+ !this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured() &&
3339
+ claim &&
3340
+ !this.routes.isReachable(this.publicKeyHash, publicKeyHash) &&
3341
+ this.suppressTopicRootCandidateClaim(publicKeyHash, claim)) {
3342
+ this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
3343
+ immediate: true,
3344
+ });
3345
+ }
2869
3346
  const key = this.peerKeyHashToPublicKey.get(publicKeyHash);
2870
3347
  if (!key) {
2871
3348
  return;
@@ -3149,14 +3626,20 @@ export class TopicControlPlane extends DirectStream {
3149
3626
  }
3150
3627
  async processDirectPubSubMessage(input) {
3151
3628
  const { pubsubMessage, message, from, stream } = input;
3152
- const isDirectRootControl = pubsubMessage instanceof TopicRootCandidates ||
3629
+ if ((pubsubMessage instanceof Subscribe ||
3630
+ pubsubMessage instanceof Unsubscribe) &&
3631
+ this.peers.get(stream.publicKey.hashcode()) !== stream)
3632
+ return;
3633
+ const isBoundDirectControl = pubsubMessage instanceof TopicRootCandidates ||
3153
3634
  pubsubMessage instanceof TopicRootCandidateClaims ||
3154
3635
  pubsubMessage instanceof TopicRootQuery ||
3155
- pubsubMessage instanceof TopicRootQueryResponse;
3156
- const directRootSigner = isDirectRootControl
3636
+ pubsubMessage instanceof TopicRootQueryResponse ||
3637
+ pubsubMessage instanceof Subscribe ||
3638
+ pubsubMessage instanceof Unsubscribe;
3639
+ const directRootSigner = isBoundDirectControl
3157
3640
  ? message.header.signatures?.publicKeys[0]
3158
3641
  : undefined;
3159
- if (isDirectRootControl &&
3642
+ if (isBoundDirectControl &&
3160
3643
  (!directRootSigner || !directRootSigner.equals(stream.publicKey))) {
3161
3644
  return;
3162
3645
  }
@@ -3174,14 +3657,21 @@ export class TopicControlPlane extends DirectStream {
3174
3657
  return;
3175
3658
  }
3176
3659
  const imported = [];
3660
+ const batchUpdate = { immediate: false };
3661
+ const revision = this.topicControlPlaneLifecycleRevision;
3177
3662
  const outerPeerHash = stream.publicKey.hashcode();
3178
3663
  for (const claim of pubsubMessage.claims) {
3179
- if (await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash, stream)) {
3664
+ if (await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash, stream, batchUpdate)) {
3180
3665
  imported.push(claim);
3181
3666
  }
3182
3667
  }
3668
+ if (!this.isTopicControlPlaneActive(revision) ||
3669
+ this.peers.get(outerPeerHash) !== stream)
3670
+ return;
3671
+ if (imported.length > 0 || batchUpdate.immediate) {
3672
+ this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), batchUpdate);
3673
+ }
3183
3674
  if (imported.length > 0) {
3184
- this.rebuildAutoTopicRootCandidatesFromClaims();
3185
3675
  // Relay the exact nested claims; no relay signature is substituted for
3186
3676
  // their origin signatures.
3187
3677
  void this.sendSignedTopicRootCandidateClaims(imported, [...this.peers.values()].filter((peer) => peer.publicKey.hashcode() !== outerPeerHash)).catch(() => { });
@@ -3189,7 +3679,7 @@ export class TopicControlPlane extends DirectStream {
3189
3679
  return;
3190
3680
  }
3191
3681
  if (pubsubMessage instanceof TopicRootQuery) {
3192
- const root = await this.resolveQueryableTopicRoot(pubsubMessage.topic);
3682
+ const root = await this.resolveQueryableTopicRoot(pubsubMessage.topic, () => this.peers.get(from.hashcode()) === stream);
3193
3683
  await this.sendDirectControlMessage(stream, new TopicRootQueryResponse({
3194
3684
  requestId: pubsubMessage.requestId,
3195
3685
  topic: pubsubMessage.topic,
@@ -3250,6 +3740,10 @@ export class TopicControlPlane extends DirectStream {
3250
3740
  }
3251
3741
  return;
3252
3742
  }
3743
+ if (pubsubMessage instanceof Unsubscribe) {
3744
+ this.processUnsubscribeMessage(message, pubsubMessage, from);
3745
+ return;
3746
+ }
3253
3747
  if (pubsubMessage instanceof GetSubscribers) {
3254
3748
  const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
3255
3749
  if (overlap.length === 0)
@@ -3270,6 +3764,35 @@ export class TopicControlPlane extends DirectStream {
3270
3764
  return;
3271
3765
  }
3272
3766
  }
3767
+ processUnsubscribeMessage(message, unsubscribe, from) {
3768
+ const senderKey = from.hashcode();
3769
+ const relevantTopics = unsubscribe.topics.filter((topic) => this.isTrackedTopic(topic));
3770
+ if (!relevantTopics.length ||
3771
+ !this.subscriptionMessageIsLatest(message, unsubscribe, relevantTopics))
3772
+ return;
3773
+ const changed = [];
3774
+ for (const topic of relevantTopics) {
3775
+ if (this.topics.get(topic)?.delete(senderKey)) {
3776
+ changed.push(topic);
3777
+ this.peerToTopic.get(senderKey)?.delete(topic);
3778
+ }
3779
+ }
3780
+ if (!this.peerToTopic.get(senderKey)?.size) {
3781
+ this.peerToTopic.delete(senderKey);
3782
+ }
3783
+ // Independent direct/shard paths may deliver an older Subscribe later.
3784
+ // Retain recent unsubscribe floors, bounded separately from live subscribers.
3785
+ const messages = this.lastSubscriptionMessages.get(senderKey);
3786
+ this.lastSubscriptionMessages.delete(senderKey);
3787
+ this.lastSubscriptionMessages.set(senderKey, messages);
3788
+ for (const topic of relevantTopics)
3789
+ this.pruneUnsubscribedWatermarks(topic);
3790
+ if (changed.length > 0) {
3791
+ this.dispatchEvent(new CustomEvent("unsubscribe", {
3792
+ detail: new UnsubcriptionEvent(from, changed, "remote-unsubscribe"),
3793
+ }));
3794
+ }
3795
+ }
3273
3796
  async processShardPubSubMessage(input) {
3274
3797
  const { pubsubMessage, message, from, shardTopic, isCurrent } = input;
3275
3798
  if (isCurrent?.() === false)
@@ -3331,31 +3854,7 @@ export class TopicControlPlane extends DirectStream {
3331
3854
  return;
3332
3855
  }
3333
3856
  if (pubsubMessage instanceof Unsubscribe) {
3334
- const sender = from;
3335
- const senderKey = sender.hashcode();
3336
- const relevantTopics = pubsubMessage.topics.filter((t) => this.isTrackedTopic(t));
3337
- if (relevantTopics.length > 0 &&
3338
- this.subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics)) {
3339
- const changed = [];
3340
- for (const topic of relevantTopics) {
3341
- const peers = this.topics.get(topic);
3342
- if (!peers)
3343
- continue;
3344
- if (peers.delete(senderKey)) {
3345
- changed.push(topic);
3346
- this.peerToTopic.get(senderKey)?.delete(topic);
3347
- }
3348
- }
3349
- if (!this.peerToTopic.get(senderKey)?.size) {
3350
- this.peerToTopic.delete(senderKey);
3351
- this.lastSubscriptionMessages.delete(senderKey);
3352
- }
3353
- if (changed.length > 0) {
3354
- this.dispatchEvent(new CustomEvent("unsubscribe", {
3355
- detail: new UnsubcriptionEvent(sender, changed, "remote-unsubscribe"),
3356
- }));
3357
- }
3358
- }
3857
+ this.processUnsubscribeMessage(message, pubsubMessage, from);
3359
3858
  return;
3360
3859
  }
3361
3860
  if (pubsubMessage instanceof PeerUnavailable) {
@@ -3448,6 +3947,7 @@ export class TopicControlPlane extends DirectStream {
3448
3947
  !(pubsubMessage instanceof TopicRootQuery) &&
3449
3948
  !(pubsubMessage instanceof GetSubscribers) &&
3450
3949
  !(pubsubMessage instanceof Subscribe) &&
3950
+ !(pubsubMessage instanceof Unsubscribe) &&
3451
3951
  !(pubsubMessage instanceof TopicRootQueryResponse)) {
3452
3952
  return true;
3453
3953
  }