@peerbit/pubsub 5.0.2-cba1bcc → 5.0.3

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
@@ -7,6 +7,7 @@ import { logger as loggerFn } from "@peerbit/logger";
7
7
  import {
8
8
  DataEvent,
9
9
  GetSubscribers,
10
+ PeerUnavailable,
10
11
  type PubSub,
11
12
  PubSubData,
12
13
  type PubSubEvents,
@@ -279,6 +280,9 @@ export class TopicControlPlane
279
280
  [];
280
281
  private autoCandidatesGossipInterval?: ReturnType<typeof setInterval>;
281
282
  private autoCandidatesGossipUntil = 0;
283
+ private _onFanoutPeerUnreachable?: (
284
+ ev: CustomEvent<{ topic: string; root: string; publicKeyHash: string }>,
285
+ ) => void;
282
286
 
283
287
  private fanoutChannels = new Map<
284
288
  string,
@@ -419,6 +423,13 @@ export class TopicControlPlane
419
423
 
420
424
  public override async start() {
421
425
  await this.fanout.start();
426
+ this._onFanoutPeerUnreachable =
427
+ this._onFanoutPeerUnreachable ||
428
+ this.onFanoutPeerUnreachable.bind(this);
429
+ await this.fanout.addEventListener(
430
+ "fanout:peer-unreachable",
431
+ this._onFanoutPeerUnreachable as any,
432
+ );
422
433
  await super.start();
423
434
 
424
435
  if (this.hostShards) {
@@ -430,6 +441,12 @@ export class TopicControlPlane
430
441
  }
431
442
 
432
443
  public override async stop() {
444
+ if (this._onFanoutPeerUnreachable) {
445
+ this.fanout.removeEventListener(
446
+ "fanout:peer-unreachable",
447
+ this._onFanoutPeerUnreachable as any,
448
+ );
449
+ }
433
450
  for (const st of this.fanoutChannels.values()) {
434
451
  if (st.idleCloseTimeout) clearTimeout(st.idleCloseTimeout);
435
452
  try {
@@ -1003,6 +1020,14 @@ export class TopicControlPlane
1003
1020
  this.subscriptions.has(x),
1004
1021
  );
1005
1022
  if (!overlap) return;
1023
+ } else if (pubsubMessage instanceof PeerUnavailable) {
1024
+ const relevant =
1025
+ pubsubMessage.topics.length > 0
1026
+ ? pubsubMessage.topics.some((x) => this.isTrackedTopic(x))
1027
+ : [...this.topics.keys()].some(
1028
+ (topic) => this.getShardTopicForUserTopic(topic) === t,
1029
+ );
1030
+ if (!relevant) return;
1006
1031
  } else {
1007
1032
  return;
1008
1033
  }
@@ -1309,6 +1334,103 @@ export class TopicControlPlane
1309
1334
  );
1310
1335
  }
1311
1336
 
1337
+ private async announcePeerUnavailable(
1338
+ publicKeyHash: string,
1339
+ batches: { session: bigint; timestamp: bigint; topics: string[] }[],
1340
+ ) {
1341
+ if (!this.started) throw new NotStartedError();
1342
+
1343
+ const byShard = new Map<
1344
+ string,
1345
+ { session: bigint; timestamp: bigint; topics: string[] }
1346
+ >();
1347
+ for (const batch of batches) {
1348
+ for (const topic of batch.topics) {
1349
+ if (!this.isTrackedTopic(topic)) {
1350
+ continue;
1351
+ }
1352
+ const shardTopic = this.getShardTopicForUserTopic(topic);
1353
+ const key = `${shardTopic}:${batch.session}:${batch.timestamp}`;
1354
+ const existing = byShard.get(key);
1355
+ if (existing) {
1356
+ existing.topics.push(topic);
1357
+ } else {
1358
+ byShard.set(key, {
1359
+ session: batch.session,
1360
+ timestamp: batch.timestamp,
1361
+ topics: [topic],
1362
+ });
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ await Promise.all(
1368
+ [...byShard.entries()].map(async ([key, batch]) => {
1369
+ const [shardTopic] = key.split(":");
1370
+ if (!shardTopic || batch.topics.length === 0) {
1371
+ return;
1372
+ }
1373
+ try {
1374
+ const msg = new PeerUnavailable({
1375
+ publicKeyHash,
1376
+ session: batch.session,
1377
+ timestamp: batch.timestamp,
1378
+ topics: batch.topics,
1379
+ });
1380
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1381
+ mode: new AnyWhere(),
1382
+ priority: 1,
1383
+ skipRecipientValidation: true,
1384
+ } as any);
1385
+ await this.ensureFanoutChannel(shardTopic, { ephemeral: true });
1386
+ const st = this.fanoutChannels.get(shardTopic);
1387
+ if (st) {
1388
+ void st.channel.publish(toUint8Array(embedded.bytes())).catch(() => {});
1389
+ this.touchFanoutChannel(shardTopic);
1390
+ }
1391
+ } catch {
1392
+ // best-effort
1393
+ }
1394
+ }),
1395
+ );
1396
+ }
1397
+
1398
+ private async announcePeerUnavailableOnShard(
1399
+ publicKeyHash: string,
1400
+ shardTopic: string,
1401
+ ) {
1402
+ if (!this.started) throw new NotStartedError();
1403
+ try {
1404
+ const msg = new PeerUnavailable({
1405
+ publicKeyHash,
1406
+ session: 0n,
1407
+ timestamp: 0n,
1408
+ topics: [],
1409
+ });
1410
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1411
+ mode: new AnyWhere(),
1412
+ priority: 1,
1413
+ skipRecipientValidation: true,
1414
+ } as any);
1415
+ await this.ensureFanoutChannel(shardTopic, { ephemeral: true });
1416
+ const st = this.fanoutChannels.get(shardTopic);
1417
+ if (st) {
1418
+ void st.channel.publish(toUint8Array(embedded.bytes())).catch(() => {});
1419
+ this.touchFanoutChannel(shardTopic);
1420
+ }
1421
+ } catch {
1422
+ // best-effort
1423
+ }
1424
+ }
1425
+
1426
+ private onFanoutPeerUnreachable(
1427
+ ev: CustomEvent<{ topic: string; root: string; publicKeyHash: string }>,
1428
+ ) {
1429
+ void this
1430
+ .announcePeerUnavailableOnShard(ev.detail.publicKeyHash, ev.detail.topic)
1431
+ .catch(logErrorIfStarted);
1432
+ }
1433
+
1312
1434
  getSubscribers(topic: string): PublicSignKey[] | undefined {
1313
1435
  const t = topic.toString();
1314
1436
  const remote = this.topics.get(t);
@@ -1554,7 +1676,55 @@ export class TopicControlPlane
1554
1676
  public override onPeerUnreachable(publicKeyHash: string) {
1555
1677
  super.onPeerUnreachable(publicKeyHash);
1556
1678
  const key = this.peerKeyHashToPublicKey.get(publicKeyHash);
1557
- if (key) this.removeSubscriptions(key, "peer-unreachable");
1679
+ if (!key) {
1680
+ return;
1681
+ }
1682
+
1683
+ const removed = this.collectSubscriptionState(publicKeyHash);
1684
+ const changed = this.removeSubscriptions(key, "peer-unreachable");
1685
+ if (changed.length === 0) {
1686
+ return;
1687
+ }
1688
+
1689
+ const changedSet = new Set(changed);
1690
+ const batches = removed
1691
+ .map((batch) => ({
1692
+ ...batch,
1693
+ topics: batch.topics.filter((topic) => changedSet.has(topic)),
1694
+ }))
1695
+ .filter((batch) => batch.topics.length > 0);
1696
+ if (batches.length > 0) {
1697
+ void this
1698
+ .announcePeerUnavailable(publicKeyHash, batches)
1699
+ .catch(logErrorIfStarted);
1700
+ }
1701
+ }
1702
+
1703
+ private collectSubscriptionState(peerHash: string) {
1704
+ const peerTopics = this.peerToTopic.get(peerHash);
1705
+ if (!peerTopics) {
1706
+ return [];
1707
+ }
1708
+
1709
+ const grouped = new Map<string, { session: bigint; timestamp: bigint; topics: string[] }>();
1710
+ for (const topic of peerTopics) {
1711
+ const existing = this.topics.get(topic)?.get(peerHash);
1712
+ if (!existing) {
1713
+ continue;
1714
+ }
1715
+ const key = `${existing.session}:${existing.timestamp}`;
1716
+ const batch = grouped.get(key);
1717
+ if (batch) {
1718
+ batch.topics.push(topic);
1719
+ } else {
1720
+ grouped.set(key, {
1721
+ session: existing.session,
1722
+ timestamp: existing.timestamp,
1723
+ topics: [topic],
1724
+ });
1725
+ }
1726
+ }
1727
+ return [...grouped.values()];
1558
1728
  }
1559
1729
 
1560
1730
  private removeSubscriptionsBeforeSession(
@@ -1619,18 +1789,16 @@ export class TopicControlPlane
1619
1789
  }),
1620
1790
  );
1621
1791
  }
1792
+
1793
+ return changed;
1622
1794
  }
1623
1795
 
1624
- private subscriptionMessageIsLatest(
1625
- message: DataMessage,
1626
- pubsubMessage: Subscribe | Unsubscribe,
1796
+ private subscriptionStateIsLatest(
1797
+ subscriberKey: string,
1798
+ session: bigint,
1799
+ timestamp: bigint,
1627
1800
  relevantTopics: string[],
1628
1801
  ) {
1629
- const subscriber = message.header.signatures!.signatures[0].publicKey!;
1630
- const subscriberKey = subscriber.hashcode();
1631
- const messageSession = message.header.session;
1632
- const messageTimestamp = message.header.timestamp;
1633
-
1634
1802
  for (const topic of relevantTopics) {
1635
1803
  const last = this.lastSubscriptionMessages
1636
1804
  .get(subscriberKey)
@@ -1638,12 +1806,13 @@ export class TopicControlPlane
1638
1806
  if (!last) {
1639
1807
  continue;
1640
1808
  }
1641
- if (last.session > messageSession) {
1809
+ if (last.session > session) {
1642
1810
  return false;
1643
1811
  }
1644
1812
  if (
1645
- last.session === messageSession &&
1646
- last.timestamp > messageTimestamp
1813
+ timestamp !== 0n &&
1814
+ last.session === session &&
1815
+ last.timestamp > timestamp
1647
1816
  ) {
1648
1817
  return false;
1649
1818
  }
@@ -1656,13 +1825,27 @@ export class TopicControlPlane
1656
1825
  this.lastSubscriptionMessages
1657
1826
  .get(subscriberKey)!
1658
1827
  .set(topic, {
1659
- session: messageSession,
1660
- timestamp: messageTimestamp,
1828
+ session,
1829
+ timestamp,
1661
1830
  });
1662
1831
  }
1663
1832
  return true;
1664
1833
  }
1665
1834
 
1835
+ private subscriptionMessageIsLatest(
1836
+ message: DataMessage,
1837
+ _pubsubMessage: Subscribe | Unsubscribe,
1838
+ relevantTopics: string[],
1839
+ ) {
1840
+ const subscriber = message.header.signatures!.signatures[0].publicKey!;
1841
+ return this.subscriptionStateIsLatest(
1842
+ subscriber.hashcode(),
1843
+ message.header.session,
1844
+ message.header.timestamp,
1845
+ relevantTopics,
1846
+ );
1847
+ }
1848
+
1666
1849
  private async sendFanoutUnicastOrBroadcast(
1667
1850
  shardTopic: string,
1668
1851
  targetHash: string,
@@ -1857,6 +2040,69 @@ export class TopicControlPlane
1857
2040
  return;
1858
2041
  }
1859
2042
 
2043
+ if (pubsubMessage instanceof PeerUnavailable) {
2044
+ const peerHash = pubsubMessage.publicKeyHash;
2045
+ // Relay-originated shard deltas are keyed only by shard membership, not by
2046
+ // per-topic subscription watermarks. They are emitted immediately when the
2047
+ // relay loses a child so downstream peers can shed stale membership without
2048
+ // waiting for the slower shared-log liveness sweep.
2049
+ const isShardFastPath =
2050
+ pubsubMessage.topics.length === 0 && pubsubMessage.timestamp === 0n;
2051
+ const relevantTopics =
2052
+ pubsubMessage.topics.length > 0
2053
+ ? pubsubMessage.topics.filter((topic) => {
2054
+ if (!this.isTrackedTopic(topic)) {
2055
+ return false;
2056
+ }
2057
+ return this.topics.get(topic)?.has(peerHash) ?? false;
2058
+ })
2059
+ : [...this.topics.keys()].filter(
2060
+ (topic) =>
2061
+ this.getShardTopicForUserTopic(topic) === shardTopic &&
2062
+ (this.topics.get(topic)?.has(peerHash) ?? false),
2063
+ );
2064
+ const shouldApply =
2065
+ relevantTopics.length > 0 &&
2066
+ (isShardFastPath ||
2067
+ this.subscriptionStateIsLatest(
2068
+ peerHash,
2069
+ pubsubMessage.session,
2070
+ pubsubMessage.timestamp,
2071
+ relevantTopics,
2072
+ ));
2073
+ if (shouldApply) {
2074
+ const changed: string[] = [];
2075
+ let publicKey: PublicSignKey | undefined;
2076
+ for (const topic of relevantTopics) {
2077
+ const peers = this.topics.get(topic);
2078
+ if (!peers) continue;
2079
+ const existing = peers.get(peerHash);
2080
+ if (!existing) continue;
2081
+ publicKey = publicKey ?? existing.publicKey;
2082
+ if (peers.delete(peerHash)) {
2083
+ changed.push(topic);
2084
+ this.peerToTopic.get(peerHash)?.delete(topic);
2085
+ }
2086
+ }
2087
+ if (!this.peerToTopic.get(peerHash)?.size) {
2088
+ this.peerToTopic.delete(peerHash);
2089
+ this.lastSubscriptionMessages.delete(peerHash);
2090
+ }
2091
+ if (changed.length > 0 && publicKey) {
2092
+ this.dispatchEvent(
2093
+ new CustomEvent<UnsubcriptionEvent>("unsubscribe", {
2094
+ detail: new UnsubcriptionEvent(
2095
+ publicKey,
2096
+ changed,
2097
+ "peer-unreachable",
2098
+ ),
2099
+ }),
2100
+ );
2101
+ }
2102
+ }
2103
+ return;
2104
+ }
2105
+
1860
2106
  if (pubsubMessage instanceof GetSubscribers) {
1861
2107
  const sender = from;
1862
2108
  const senderKey = sender.hashcode();