@peerbit/pubsub 5.4.6 → 5.4.8

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
@@ -397,6 +397,7 @@ export class TopicControlPlane
397
397
  {
398
398
  targets: Set<string>;
399
399
  topics: Set<string>;
400
+ isCurrent: () => boolean;
400
401
  timer: ReturnType<typeof setTimeout>;
401
402
  }
402
403
  > = new Map();
@@ -416,8 +417,9 @@ export class TopicControlPlane
416
417
  // default) leaves every code path as-is.
417
418
  private readonly nativeTopicControl?: RustTopicControl;
418
419
 
419
- private debounceSubscribeAggregator: DebouncedAccumulatorCounterMap;
420
- private debounceUnsubscribeAggregator: DebouncedAccumulatorCounterMap;
420
+ private debounceSubscribeAggregator!: DebouncedAccumulatorCounterMap;
421
+ private debounceUnsubscribeAggregator!: DebouncedAccumulatorCounterMap;
422
+ private readonly subscriptionDebounceDelay: number;
421
423
 
422
424
  private readonly shardCount: number;
423
425
  private readonly shardTopicPrefix: string;
@@ -636,17 +638,23 @@ export class TopicControlPlane
636
638
  Math.max(1, Math.floor(requestedSubscriberCacheMaxEntries)),
637
639
  );
638
640
 
641
+ this.subscriptionDebounceDelay = props?.subscriptionDebounceDelay ?? 50;
642
+ this.createSubscriptionAggregators();
643
+ }
644
+
645
+ private createSubscriptionAggregators(): void {
646
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
639
647
  this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter(
640
- (set) => this._subscribe([...set.values()]),
641
- props?.subscriptionDebounceDelay ?? 50,
648
+ (set) => this._subscribe([...set.values()], lifecycleRevision),
649
+ this.subscriptionDebounceDelay,
642
650
  (error) =>
643
651
  logger.error(`Debounced subscribe failed: ${error?.message ?? error}`),
644
652
  );
645
653
  // NOTE: Unsubscribe should update local state immediately and batch only the
646
654
  // best-effort network announcements to avoid teardown stalls (program close).
647
655
  this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter(
648
- (set) => this._announceUnsubscribe([...set.values()]),
649
- props?.subscriptionDebounceDelay ?? 50,
656
+ (set) => this._announceUnsubscribe([...set.values()], lifecycleRevision),
657
+ this.subscriptionDebounceDelay,
650
658
  (error) =>
651
659
  logger.error(
652
660
  `Debounced unsubscribe announce failed: ${error?.message ?? error}`,
@@ -712,6 +720,9 @@ export class TopicControlPlane
712
720
  // candidates. In particular, a repeated start must not restore unsigned
713
721
  // self while a signed-protocol peer is connected.
714
722
  if (this.started) return;
723
+ // close() is terminal. Only a completed stop/start gets a fresh pair;
724
+ // repeated start() must preserve the active pair's queued reference counts.
725
+ if (this.topicControlPlaneStopping) this.createSubscriptionAggregators();
715
726
  this.topicControlPlaneStopping = false;
716
727
  if (
717
728
  this.autoTopicRootCandidates &&
@@ -764,6 +775,8 @@ export class TopicControlPlane
764
775
  this.clearAutoTopicRootCandidateUpdateSchedule();
765
776
  this.clearSignedTopicRootCandidateState();
766
777
  this.topicControlPlaneLifecycleRevision += 1;
778
+ this.debounceSubscribeAggregator.close();
779
+ this.debounceUnsubscribeAggregator.close();
767
780
  this.reconcileShardOverlaysDirty = false;
768
781
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
769
782
  opening.abortController.abort(
@@ -822,8 +835,6 @@ export class TopicControlPlane
822
835
  }
823
836
  this.pendingTopicRootQueries.clear();
824
837
 
825
- this.debounceSubscribeAggregator.close();
826
- this.debounceUnsubscribeAggregator.close();
827
838
  await super.stop();
828
839
  this.reconcileShardOverlaysDirty = false;
829
840
  }
@@ -2062,17 +2073,32 @@ export class TopicControlPlane
2062
2073
  ]);
2063
2074
  }
2064
2075
 
2076
+ private isTopicControlPlaneActive(lifecycleRevision: number): boolean {
2077
+ return (
2078
+ lifecycleRevision === this.topicControlPlaneLifecycleRevision &&
2079
+ this.started &&
2080
+ !this.stopping &&
2081
+ !this.topicControlPlaneStopping
2082
+ );
2083
+ }
2084
+
2065
2085
  private assertTopicControlPlaneActive(lifecycleRevision: number): void {
2066
- if (
2067
- lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
2068
- !this.started ||
2069
- this.stopping ||
2070
- this.topicControlPlaneStopping
2071
- ) {
2086
+ if (!this.isTopicControlPlaneActive(lifecycleRevision)) {
2072
2087
  throw new NotStartedError();
2073
2088
  }
2074
2089
  }
2075
2090
 
2091
+ private isFanoutChannelCurrent(
2092
+ shardTopic: string,
2093
+ channel: FanoutChannel,
2094
+ lifecycleRevision: number,
2095
+ ): boolean {
2096
+ return (
2097
+ this.isTopicControlPlaneActive(lifecycleRevision) &&
2098
+ this.fanoutChannels.get(shardTopic)?.channel === channel
2099
+ );
2100
+ }
2101
+
2076
2102
  private syncTopicRootCandidateResolutionSignal(
2077
2103
  candidateGeneration = this.getTopicRootCandidateGeneration(),
2078
2104
  ): AbortSignal {
@@ -2851,8 +2877,11 @@ export class TopicControlPlane
2851
2877
  return;
2852
2878
  }
2853
2879
  const channel = new FanoutChannel(this.fanout, { topic: t, root });
2880
+ const isCurrent = () =>
2881
+ this.isFanoutChannelCurrent(t, channel, lifecycleRevision);
2854
2882
 
2855
2883
  const onPayload = (payload: Uint8Array) => {
2884
+ if (!isCurrent()) return;
2856
2885
  let dm: DataMessage;
2857
2886
  try {
2858
2887
  dm = DataMessage.from(new Uint8ArrayList(payload));
@@ -2912,24 +2941,38 @@ export class TopicControlPlane
2912
2941
 
2913
2942
  void (async () => {
2914
2943
  const msgId = await getMsgId(payload);
2915
- const seen = this.seenCache.get(msgId);
2916
- this.seenCache.add(msgId, seen ? seen + 1 : 1);
2917
- if (seen) return;
2944
+ if (!isCurrent()) return;
2945
+ const previouslySeen = this.seenCache.get(msgId);
2946
+ if (previouslySeen) {
2947
+ this.seenCache.add(msgId, previouslySeen + 1);
2948
+ return;
2949
+ }
2918
2950
 
2919
2951
  // fanout-delivered frames bypass the inbound stream decode, so
2920
2952
  // the nested envelope is verified natively here when possible
2921
2953
  this.seedNativeWireVerification(dm, payload);
2922
- if ((await this.verifyAndProcess(dm)) === false) {
2954
+ if ((await this.verifyAndProcess(dm, isCurrent)) === false) {
2923
2955
  return;
2924
2956
  }
2957
+ if (!isCurrent()) return;
2958
+ // Only a verified frame owned by this exact channel may consume the
2959
+ // shared dedupe slot. A stale verifier cannot suppress its replacement's
2960
+ // copy. Concurrent verifiers recheck here before dispatching once.
2961
+ const seen = this.seenCache.get(msgId);
2962
+ this.seenCache.add(msgId, seen ? seen + 1 : 1);
2963
+ if (seen) return;
2925
2964
  const sender = dm.header.signatures!.signatures[0]!.publicKey!;
2926
2965
  await this.processShardPubSubMessage({
2927
2966
  pubsubMessage,
2928
2967
  message: dm,
2929
2968
  from: sender,
2930
2969
  shardTopic: t,
2970
+ isCurrent,
2931
2971
  });
2932
- })();
2972
+ })().catch((error) => {
2973
+ if (error instanceof NotStartedError && !isCurrent()) return;
2974
+ logError(error);
2975
+ });
2933
2976
  };
2934
2977
 
2935
2978
  const onData = (ev?: CustomEvent<FanoutTreeDataEvent>) => {
@@ -3035,6 +3078,11 @@ export class TopicControlPlane
3035
3078
  if (!st) return;
3036
3079
  this.fanoutChannels.delete(t);
3037
3080
  this.clearFanoutIdleClose(st);
3081
+ const pendingResponse = this.pendingSubscriberResponses.get(t);
3082
+ if (pendingResponse) {
3083
+ clearTimeout(pendingResponse.timer);
3084
+ this.pendingSubscriberResponses.delete(t);
3085
+ }
3038
3086
  try {
3039
3087
  st.channel.removeEventListener("data", st.onData as any);
3040
3088
  } catch {
@@ -3149,8 +3197,12 @@ export class TopicControlPlane
3149
3197
  return this.debounceSubscribeAggregator.add({ key: topic });
3150
3198
  }
3151
3199
 
3152
- private async _subscribe(topics: { key: string; counter: number }[]) {
3200
+ private async _subscribe(
3201
+ topics: { key: string; counter: number }[],
3202
+ lifecycleRevision = this.topicControlPlaneLifecycleRevision,
3203
+ ) {
3153
3204
  if (!this.started) throw new NotStartedError();
3205
+ if (!this.isTopicControlPlaneActive(lifecycleRevision)) return;
3154
3206
  if (topics.length === 0) return;
3155
3207
 
3156
3208
  const byShard = new Map<string, string[]>();
@@ -3176,11 +3228,15 @@ export class TopicControlPlane
3176
3228
  }
3177
3229
 
3178
3230
  await Promise.all(joins);
3231
+ if (!this.isTopicControlPlaneActive(lifecycleRevision)) return;
3179
3232
 
3180
3233
  // Announce subscriptions per shard overlay.
3181
3234
  await Promise.all(
3182
3235
  [...byShard.entries()].map(async ([shardTopic, userTopics]) => {
3183
3236
  if (userTopics.length === 0) return;
3237
+ const st = this.fanoutChannels.get(shardTopic);
3238
+ if (!st)
3239
+ throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
3184
3240
  const msg = new Subscribe({
3185
3241
  topics: userTopics,
3186
3242
  requestSubscribers: true,
@@ -3193,11 +3249,19 @@ export class TopicControlPlane
3193
3249
  skipRecipientValidation: true,
3194
3250
  } as any,
3195
3251
  );
3196
- const st = this.fanoutChannels.get(shardTopic);
3197
- if (!st)
3198
- throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
3252
+ if (
3253
+ !this.isFanoutChannelCurrent(
3254
+ shardTopic,
3255
+ st.channel,
3256
+ lifecycleRevision,
3257
+ )
3258
+ )
3259
+ return;
3199
3260
  await st.channel.publish(toUint8Array(embedded.bytes()));
3200
- this.touchFanoutChannel(shardTopic);
3261
+ if (
3262
+ this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision)
3263
+ )
3264
+ this.touchFanoutChannel(shardTopic);
3201
3265
  }),
3202
3266
  );
3203
3267
  }
@@ -3253,8 +3317,10 @@ export class TopicControlPlane
3253
3317
 
3254
3318
  private async _announceUnsubscribe(
3255
3319
  topics: { key: string; counter: number }[],
3320
+ lifecycleRevision = this.topicControlPlaneLifecycleRevision,
3256
3321
  ) {
3257
3322
  if (!this.started) throw new NotStartedError();
3323
+ if (!this.isTopicControlPlaneActive(lifecycleRevision)) return;
3258
3324
 
3259
3325
  const byShard = new Map<string, string[]>();
3260
3326
  for (const { key: topic } of topics) {
@@ -3267,6 +3333,8 @@ export class TopicControlPlane
3267
3333
  await Promise.all(
3268
3334
  [...byShard.entries()].map(async ([shardTopic, userTopics]) => {
3269
3335
  if (userTopics.length === 0) return;
3336
+ const st = this.fanoutChannels.get(shardTopic);
3337
+ if (!st) return;
3270
3338
 
3271
3339
  // Announce first.
3272
3340
  try {
@@ -3279,20 +3347,39 @@ export class TopicControlPlane
3279
3347
  skipRecipientValidation: true,
3280
3348
  } as any,
3281
3349
  );
3282
- const st = this.fanoutChannels.get(shardTopic);
3283
- if (st) {
3350
+ if (
3351
+ this.isFanoutChannelCurrent(
3352
+ shardTopic,
3353
+ st.channel,
3354
+ lifecycleRevision,
3355
+ )
3356
+ ) {
3284
3357
  // Best-effort: do not let a stuck proxy publish stall teardown.
3285
3358
  void st.channel
3286
3359
  .publish(toUint8Array(embedded.bytes()))
3287
3360
  .catch(() => {});
3288
- this.touchFanoutChannel(shardTopic);
3361
+ if (
3362
+ this.isFanoutChannelCurrent(
3363
+ shardTopic,
3364
+ st.channel,
3365
+ lifecycleRevision,
3366
+ )
3367
+ )
3368
+ this.touchFanoutChannel(shardTopic);
3289
3369
  }
3290
3370
  } catch {
3291
3371
  // best-effort
3292
3372
  }
3293
3373
 
3294
3374
  // Close shard overlay if no local topics remain.
3295
- if ((this.shardRefCounts.get(shardTopic) ?? 0) <= 0) {
3375
+ if (
3376
+ this.isFanoutChannelCurrent(
3377
+ shardTopic,
3378
+ st.channel,
3379
+ lifecycleRevision,
3380
+ ) &&
3381
+ (this.shardRefCounts.get(shardTopic) ?? 0) <= 0
3382
+ ) {
3296
3383
  try {
3297
3384
  // Shutdown should be bounded and not depend on network I/O.
3298
3385
  await this.closeFanoutChannel(shardTopic);
@@ -3955,17 +4042,36 @@ export class TopicControlPlane
3955
4042
  targetHash: string,
3956
4043
  topics: string[],
3957
4044
  ) {
4045
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
4046
+ const st = this.fanoutChannels.get(shardTopic);
4047
+ if (!st) return;
4048
+ const isCurrent = () =>
4049
+ this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision);
4050
+ if (!isCurrent()) return;
4051
+ const onError = (error: Error) => {
4052
+ if (error instanceof NotStartedError && !isCurrent()) return;
4053
+ logError(error);
4054
+ };
3958
4055
  let entry = this.pendingSubscriberResponses.get(shardTopic);
4056
+ if (entry && !entry.isCurrent()) {
4057
+ clearTimeout(entry.timer);
4058
+ this.pendingSubscriberResponses.delete(shardTopic);
4059
+ entry = undefined;
4060
+ }
3959
4061
  if (!entry) {
3960
4062
  const created = {
3961
4063
  targets: new Set<string>(),
3962
4064
  topics: new Set<string>(),
4065
+ isCurrent,
3963
4066
  timer: setTimeout(
3964
4067
  () => {
4068
+ if (this.pendingSubscriberResponses.get(shardTopic) !== created) {
4069
+ return;
4070
+ }
3965
4071
  this.pendingSubscriberResponses.delete(shardTopic);
3966
- if (created.targets.size === 0) return;
4072
+ if (!isCurrent() || created.targets.size === 0) return;
3967
4073
  void this.flushSubscriberResponses(shardTopic, created).catch(
3968
- logErrorIfStarted,
4074
+ onError,
3969
4075
  );
3970
4076
  },
3971
4077
  150 + Math.floor(Math.random() * 150),
@@ -3976,7 +4082,8 @@ export class TopicControlPlane
3976
4082
  void this.flushSubscriberResponses(shardTopic, {
3977
4083
  targets: new Set([targetHash]),
3978
4084
  topics: new Set(topics),
3979
- }).catch(logErrorIfStarted);
4085
+ isCurrent,
4086
+ }).catch(onError);
3980
4087
  return;
3981
4088
  }
3982
4089
  entry.targets.add(targetHash);
@@ -3985,9 +4092,13 @@ export class TopicControlPlane
3985
4092
 
3986
4093
  private async flushSubscriberResponses(
3987
4094
  shardTopic: string,
3988
- entry: { targets: Set<string>; topics: Set<string> },
4095
+ entry: {
4096
+ targets: Set<string>;
4097
+ topics: Set<string>;
4098
+ isCurrent: () => boolean;
4099
+ },
3989
4100
  ) {
3990
- if (!this.started) return;
4101
+ if (!entry.isCurrent()) return;
3991
4102
  // Re-filter: our subscriptions may have changed within the window.
3992
4103
  const topics = this.getSubscriptionOverlap([...entry.topics]);
3993
4104
  if (topics.length === 0) return;
@@ -4003,6 +4114,7 @@ export class TopicControlPlane
4003
4114
  skipRecipientValidation: true,
4004
4115
  } as any,
4005
4116
  );
4117
+ if (!entry.isCurrent()) return;
4006
4118
  const payload = toUint8Array(embedded.bytes());
4007
4119
  const [firstTarget] = entry.targets;
4008
4120
  if (entry.targets.size === 1 && firstTarget) {
@@ -4019,8 +4131,12 @@ export class TopicControlPlane
4019
4131
  targetHash: string,
4020
4132
  payload: Uint8Array,
4021
4133
  ) {
4134
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
4022
4135
  const st = this.fanoutChannels.get(shardTopic);
4023
4136
  if (!st) return;
4137
+ const isCurrent = () =>
4138
+ this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision);
4139
+ if (!isCurrent()) return;
4024
4140
  const hints = this.getUnifiedRouteHints(shardTopic, targetHash);
4025
4141
  const fanoutHint = hints.find(
4026
4142
  (hint): hint is Extract<RouteHint, { kind: "fanout-token" }> =>
@@ -4032,16 +4148,25 @@ export class TopicControlPlane
4032
4148
  timeoutMs: 5_000,
4033
4149
  });
4034
4150
  return;
4035
- } catch {
4151
+ } catch (error) {
4152
+ if (!isCurrent()) {
4153
+ if (error instanceof NotStartedError) return;
4154
+ throw error;
4155
+ }
4036
4156
  // ignore and fall back
4037
4157
  }
4038
4158
  }
4039
4159
  try {
4040
4160
  await st.channel.unicastToAck(targetHash, payload, { timeoutMs: 5_000 });
4041
4161
  return;
4042
- } catch {
4162
+ } catch (error) {
4163
+ if (!isCurrent()) {
4164
+ if (error instanceof NotStartedError) return;
4165
+ throw error;
4166
+ }
4043
4167
  // ignore and fall back
4044
4168
  }
4169
+ if (!isCurrent()) return;
4045
4170
  await st.channel.publishMaybe(payload);
4046
4171
  }
4047
4172
 
@@ -4227,8 +4352,10 @@ export class TopicControlPlane
4227
4352
  message: DataMessage;
4228
4353
  from: PublicSignKey;
4229
4354
  shardTopic: string;
4355
+ isCurrent?: () => boolean;
4230
4356
  }): Promise<void> {
4231
- const { pubsubMessage, message, from, shardTopic } = input;
4357
+ const { pubsubMessage, message, from, shardTopic, isCurrent } = input;
4358
+ if (isCurrent?.() === false) return;
4232
4359
 
4233
4360
  if (pubsubMessage instanceof PubSubData) {
4234
4361
  this.dispatchEvent(
@@ -4295,6 +4422,7 @@ export class TopicControlPlane
4295
4422
  }
4296
4423
  }
4297
4424
 
4425
+ if (isCurrent?.() === false) return;
4298
4426
  if (pubsubMessage.requestSubscribers) {
4299
4427
  const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
4300
4428
  if (overlap.length > 0) {
@@ -4425,6 +4553,7 @@ export class TopicControlPlane
4425
4553
  } as any,
4426
4554
  );
4427
4555
  const payload = toUint8Array(embedded.bytes());
4556
+ if (isCurrent?.() === false) return;
4428
4557
  await this.sendFanoutUnicastOrBroadcast(shardTopic, senderKey, payload);
4429
4558
  return;
4430
4559
  }