@peerbit/pubsub 5.4.7 → 5.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peerbit/pubsub",
3
- "version": "5.4.7",
3
+ "version": "5.4.9",
4
4
  "description": "Direct streaming for libp2p",
5
5
  "type": "module",
6
6
  "types": "./dist/src/index.d.ts",
@@ -85,13 +85,13 @@
85
85
  "p-defer": "^4.0.0",
86
86
  "uint8arraylist": "^3.0.2",
87
87
  "uint8arrays": "^5.1.0",
88
- "@peerbit/crypto": "3.1.6",
89
88
  "@peerbit/any-store-interface": "1.1.4",
90
89
  "@peerbit/logger": "2.0.2",
91
- "@peerbit/pubsub-interface": "5.2.2",
92
90
  "@peerbit/stream": "5.2.3",
93
- "@peerbit/stream-interface": "6.0.16",
94
- "@peerbit/time": "3.0.1"
91
+ "@peerbit/pubsub-interface": "5.2.2",
92
+ "@peerbit/crypto": "3.1.6",
93
+ "@peerbit/time": "3.0.1",
94
+ "@peerbit/stream-interface": "6.0.16"
95
95
  },
96
96
  "scripts": {
97
97
  "bench": "TS_NODE_PROJECT=../../../tsconfig.json node --loader ts-node/esm ./benchmark/index.ts",
package/src/index.ts CHANGED
@@ -417,8 +417,9 @@ export class TopicControlPlane
417
417
  // default) leaves every code path as-is.
418
418
  private readonly nativeTopicControl?: RustTopicControl;
419
419
 
420
- private debounceSubscribeAggregator: DebouncedAccumulatorCounterMap;
421
- private debounceUnsubscribeAggregator: DebouncedAccumulatorCounterMap;
420
+ private debounceSubscribeAggregator!: DebouncedAccumulatorCounterMap;
421
+ private debounceUnsubscribeAggregator!: DebouncedAccumulatorCounterMap;
422
+ private readonly subscriptionDebounceDelay: number;
422
423
 
423
424
  private readonly shardCount: number;
424
425
  private readonly shardTopicPrefix: string;
@@ -637,17 +638,23 @@ export class TopicControlPlane
637
638
  Math.max(1, Math.floor(requestedSubscriberCacheMaxEntries)),
638
639
  );
639
640
 
641
+ this.subscriptionDebounceDelay = props?.subscriptionDebounceDelay ?? 50;
642
+ this.createSubscriptionAggregators();
643
+ }
644
+
645
+ private createSubscriptionAggregators(): void {
646
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
640
647
  this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter(
641
- (set) => this._subscribe([...set.values()]),
642
- props?.subscriptionDebounceDelay ?? 50,
648
+ (set) => this._subscribe([...set.values()], lifecycleRevision),
649
+ this.subscriptionDebounceDelay,
643
650
  (error) =>
644
651
  logger.error(`Debounced subscribe failed: ${error?.message ?? error}`),
645
652
  );
646
653
  // NOTE: Unsubscribe should update local state immediately and batch only the
647
654
  // best-effort network announcements to avoid teardown stalls (program close).
648
655
  this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter(
649
- (set) => this._announceUnsubscribe([...set.values()]),
650
- props?.subscriptionDebounceDelay ?? 50,
656
+ (set) => this._announceUnsubscribe([...set.values()], lifecycleRevision),
657
+ this.subscriptionDebounceDelay,
651
658
  (error) =>
652
659
  logger.error(
653
660
  `Debounced unsubscribe announce failed: ${error?.message ?? error}`,
@@ -713,6 +720,9 @@ export class TopicControlPlane
713
720
  // candidates. In particular, a repeated start must not restore unsigned
714
721
  // self while a signed-protocol peer is connected.
715
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();
716
726
  this.topicControlPlaneStopping = false;
717
727
  if (
718
728
  this.autoTopicRootCandidates &&
@@ -765,6 +775,8 @@ export class TopicControlPlane
765
775
  this.clearAutoTopicRootCandidateUpdateSchedule();
766
776
  this.clearSignedTopicRootCandidateState();
767
777
  this.topicControlPlaneLifecycleRevision += 1;
778
+ this.debounceSubscribeAggregator.close();
779
+ this.debounceUnsubscribeAggregator.close();
768
780
  this.reconcileShardOverlaysDirty = false;
769
781
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
770
782
  opening.abortController.abort(
@@ -779,7 +791,7 @@ export class TopicControlPlane
779
791
  );
780
792
  }
781
793
  for (const st of this.fanoutChannels.values()) {
782
- if (st.idleCloseTimeout) clearTimeout(st.idleCloseTimeout);
794
+ this.clearFanoutIdleClose(st);
783
795
  try {
784
796
  st.channel.removeEventListener("data", st.onData as any);
785
797
  } catch {
@@ -823,8 +835,6 @@ export class TopicControlPlane
823
835
  }
824
836
  this.pendingTopicRootQueries.clear();
825
837
 
826
- this.debounceSubscribeAggregator.close();
827
- this.debounceUnsubscribeAggregator.close();
828
838
  await super.stop();
829
839
  this.reconcileShardOverlaysDirty = false;
830
840
  }
@@ -1765,6 +1775,7 @@ export class TopicControlPlane
1765
1775
 
1766
1776
  private async reconcileShardOverlays() {
1767
1777
  if (!this.started || this.topicControlPlaneStopping) return;
1778
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1768
1779
 
1769
1780
  const byShard = new Map<string, string[]>();
1770
1781
  for (const topic of this.subscriptions.keys()) {
@@ -1794,7 +1805,7 @@ export class TopicControlPlane
1794
1805
  const st = this.fanoutChannels.get(shardTopic);
1795
1806
  if (!st) return;
1796
1807
  await st.channel.publish(toUint8Array(embedded.bytes()));
1797
- this.touchFanoutChannel(shardTopic);
1808
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
1798
1809
  }),
1799
1810
  );
1800
1811
  }
@@ -1883,22 +1894,42 @@ export class TopicControlPlane
1883
1894
 
1884
1895
  private scheduleFanoutIdleClose(topic: string) {
1885
1896
  const st = this.fanoutChannels.get(topic);
1886
- if (!st || !st.ephemeral) return;
1897
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1898
+ if (
1899
+ !st ||
1900
+ !st.ephemeral ||
1901
+ !this.isTopicControlPlaneActive(lifecycleRevision)
1902
+ )
1903
+ return;
1887
1904
  this.clearFanoutIdleClose(st);
1888
1905
  if (this.fanoutPublishIdleCloseMs <= 0) return;
1889
- st.idleCloseTimeout = setTimeout(() => {
1890
- const cur = this.fanoutChannels.get(topic);
1891
- if (!cur || !cur.ephemeral) return;
1892
- const idleMs = Date.now() - cur.lastUsedAt;
1906
+ const timer = setTimeout(() => {
1907
+ // Cancellation can race a queued callback. It must still own this exact
1908
+ // timer, channel state and lifecycle before acting on the topic again.
1909
+ if (
1910
+ !this.isTopicControlPlaneActive(lifecycleRevision) ||
1911
+ this.fanoutChannels.get(topic) !== st ||
1912
+ st.idleCloseTimeout !== timer ||
1913
+ !st.ephemeral
1914
+ )
1915
+ return;
1916
+ st.idleCloseTimeout = undefined;
1917
+ const idleMs = Date.now() - st.lastUsedAt;
1893
1918
  if (idleMs >= this.fanoutPublishIdleCloseMs) {
1894
1919
  void this.closeFanoutChannel(topic);
1895
1920
  return;
1896
1921
  }
1897
1922
  this.scheduleFanoutIdleClose(topic);
1898
1923
  }, this.fanoutPublishIdleCloseMs);
1924
+ st.idleCloseTimeout = timer;
1899
1925
  }
1900
1926
 
1901
- private touchFanoutChannel(topic: string) {
1927
+ private touchFanoutChannel(
1928
+ topic: string,
1929
+ channel: FanoutChannel,
1930
+ lifecycleRevision: number,
1931
+ ) {
1932
+ if (!this.isFanoutChannelCurrent(topic, channel, lifecycleRevision)) return;
1902
1933
  const st = this.fanoutChannels.get(topic);
1903
1934
  if (!st) return;
1904
1935
  st.lastUsedAt = Date.now();
@@ -3043,7 +3074,7 @@ export class TopicControlPlane
3043
3074
  join
3044
3075
  .then(() => {
3045
3076
  const st = this.fanoutChannels.get(t);
3046
- if (st === channelState && st.ephemeral) {
3077
+ if (st === channelState && st.ephemeral && isCurrent()) {
3047
3078
  this.scheduleFanoutIdleClose(t);
3048
3079
  }
3049
3080
  })
@@ -3187,8 +3218,12 @@ export class TopicControlPlane
3187
3218
  return this.debounceSubscribeAggregator.add({ key: topic });
3188
3219
  }
3189
3220
 
3190
- private async _subscribe(topics: { key: string; counter: number }[]) {
3221
+ private async _subscribe(
3222
+ topics: { key: string; counter: number }[],
3223
+ lifecycleRevision = this.topicControlPlaneLifecycleRevision,
3224
+ ) {
3191
3225
  if (!this.started) throw new NotStartedError();
3226
+ if (!this.isTopicControlPlaneActive(lifecycleRevision)) return;
3192
3227
  if (topics.length === 0) return;
3193
3228
 
3194
3229
  const byShard = new Map<string, string[]>();
@@ -3214,11 +3249,15 @@ export class TopicControlPlane
3214
3249
  }
3215
3250
 
3216
3251
  await Promise.all(joins);
3252
+ if (!this.isTopicControlPlaneActive(lifecycleRevision)) return;
3217
3253
 
3218
3254
  // Announce subscriptions per shard overlay.
3219
3255
  await Promise.all(
3220
3256
  [...byShard.entries()].map(async ([shardTopic, userTopics]) => {
3221
3257
  if (userTopics.length === 0) return;
3258
+ const st = this.fanoutChannels.get(shardTopic);
3259
+ if (!st)
3260
+ throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
3222
3261
  const msg = new Subscribe({
3223
3262
  topics: userTopics,
3224
3263
  requestSubscribers: true,
@@ -3231,11 +3270,19 @@ export class TopicControlPlane
3231
3270
  skipRecipientValidation: true,
3232
3271
  } as any,
3233
3272
  );
3234
- const st = this.fanoutChannels.get(shardTopic);
3235
- if (!st)
3236
- throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
3273
+ if (
3274
+ !this.isFanoutChannelCurrent(
3275
+ shardTopic,
3276
+ st.channel,
3277
+ lifecycleRevision,
3278
+ )
3279
+ )
3280
+ return;
3237
3281
  await st.channel.publish(toUint8Array(embedded.bytes()));
3238
- this.touchFanoutChannel(shardTopic);
3282
+ if (
3283
+ this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision)
3284
+ )
3285
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
3239
3286
  }),
3240
3287
  );
3241
3288
  }
@@ -3291,8 +3338,10 @@ export class TopicControlPlane
3291
3338
 
3292
3339
  private async _announceUnsubscribe(
3293
3340
  topics: { key: string; counter: number }[],
3341
+ lifecycleRevision = this.topicControlPlaneLifecycleRevision,
3294
3342
  ) {
3295
3343
  if (!this.started) throw new NotStartedError();
3344
+ if (!this.isTopicControlPlaneActive(lifecycleRevision)) return;
3296
3345
 
3297
3346
  const byShard = new Map<string, string[]>();
3298
3347
  for (const { key: topic } of topics) {
@@ -3305,6 +3354,8 @@ export class TopicControlPlane
3305
3354
  await Promise.all(
3306
3355
  [...byShard.entries()].map(async ([shardTopic, userTopics]) => {
3307
3356
  if (userTopics.length === 0) return;
3357
+ const st = this.fanoutChannels.get(shardTopic);
3358
+ if (!st) return;
3308
3359
 
3309
3360
  // Announce first.
3310
3361
  try {
@@ -3317,20 +3368,43 @@ export class TopicControlPlane
3317
3368
  skipRecipientValidation: true,
3318
3369
  } as any,
3319
3370
  );
3320
- const st = this.fanoutChannels.get(shardTopic);
3321
- if (st) {
3371
+ if (
3372
+ this.isFanoutChannelCurrent(
3373
+ shardTopic,
3374
+ st.channel,
3375
+ lifecycleRevision,
3376
+ )
3377
+ ) {
3322
3378
  // Best-effort: do not let a stuck proxy publish stall teardown.
3323
3379
  void st.channel
3324
3380
  .publish(toUint8Array(embedded.bytes()))
3325
3381
  .catch(() => {});
3326
- this.touchFanoutChannel(shardTopic);
3382
+ if (
3383
+ this.isFanoutChannelCurrent(
3384
+ shardTopic,
3385
+ st.channel,
3386
+ lifecycleRevision,
3387
+ )
3388
+ )
3389
+ this.touchFanoutChannel(
3390
+ shardTopic,
3391
+ st.channel,
3392
+ lifecycleRevision,
3393
+ );
3327
3394
  }
3328
3395
  } catch {
3329
3396
  // best-effort
3330
3397
  }
3331
3398
 
3332
3399
  // Close shard overlay if no local topics remain.
3333
- if ((this.shardRefCounts.get(shardTopic) ?? 0) <= 0) {
3400
+ if (
3401
+ this.isFanoutChannelCurrent(
3402
+ shardTopic,
3403
+ st.channel,
3404
+ lifecycleRevision,
3405
+ ) &&
3406
+ (this.shardRefCounts.get(shardTopic) ?? 0) <= 0
3407
+ ) {
3334
3408
  try {
3335
3409
  // Shutdown should be bounded and not depend on network I/O.
3336
3410
  await this.closeFanoutChannel(shardTopic);
@@ -3347,6 +3421,7 @@ export class TopicControlPlane
3347
3421
  batches: { session: bigint; timestamp: bigint; topics: string[] }[],
3348
3422
  ) {
3349
3423
  if (!this.started) throw new NotStartedError();
3424
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
3350
3425
 
3351
3426
  const byShard = new Map<
3352
3427
  string,
@@ -3397,7 +3472,7 @@ export class TopicControlPlane
3397
3472
  const st = this.fanoutChannels.get(shardTopic);
3398
3473
  if (st) {
3399
3474
  void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
3400
- this.touchFanoutChannel(shardTopic);
3475
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
3401
3476
  }
3402
3477
  } catch {
3403
3478
  // best-effort
@@ -3411,6 +3486,7 @@ export class TopicControlPlane
3411
3486
  shardTopic: string,
3412
3487
  ) {
3413
3488
  if (!this.started) throw new NotStartedError();
3489
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
3414
3490
  try {
3415
3491
  const msg = new PeerUnavailable({
3416
3492
  publicKeyHash,
@@ -3427,7 +3503,7 @@ export class TopicControlPlane
3427
3503
  const st = this.fanoutChannels.get(shardTopic);
3428
3504
  if (st) {
3429
3505
  void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
3430
- this.touchFanoutChannel(shardTopic);
3506
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
3431
3507
  }
3432
3508
  } catch {
3433
3509
  // best-effort
@@ -3494,6 +3570,7 @@ export class TopicControlPlane
3494
3570
  to?: PublicSignKey,
3495
3571
  ): Promise<void> {
3496
3572
  if (!this.started) throw new NotStartedError();
3573
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
3497
3574
  if (topic == null) throw new Error("ERR_NOT_VALID_TOPIC");
3498
3575
  if (topic.length === 0) return;
3499
3576
 
@@ -3545,7 +3622,7 @@ export class TopicControlPlane
3545
3622
  } else {
3546
3623
  await st.channel.publish(payload);
3547
3624
  }
3548
- this.touchFanoutChannel(shardTopic);
3625
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
3549
3626
  }),
3550
3627
  );
3551
3628
  }
@@ -3563,6 +3640,7 @@ export class TopicControlPlane
3563
3640
  WithExtraSigners & { signal?: AbortSignal },
3564
3641
  ): Promise<Uint8Array | undefined> {
3565
3642
  if (!this.started) throw new NotStartedError();
3643
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
3566
3644
 
3567
3645
  const topicsAll =
3568
3646
  (options as { topics: string[] }).topics?.map((x) => x.toString()) || [];
@@ -3670,7 +3748,7 @@ export class TopicControlPlane
3670
3748
  throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
3671
3749
  }
3672
3750
  await withAbort(st.channel.publish(payload), options?.signal);
3673
- this.touchFanoutChannel(shardTopic);
3751
+ this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
3674
3752
  }),
3675
3753
  );
3676
3754