@bitsocial/pubsub-voting 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,6 +121,14 @@ const COLD_START_FETCH_BACKOFF_CAP_MS = 4_000;
121
121
  * others idle.
122
122
  */
123
123
  const COLD_START_PEER_FETCH_LIMIT = 24;
124
+ /**
125
+ * How long after `join()` the cold-start pull stays armed on gossipsub's `subscription-change`
126
+ * (see `#armSubscriptionRepull`). One heartbeat interval: the window exists to close the
127
+ * join-races-subscription-gossip gap (issue #15), and past the first heartbeat the passive
128
+ * root-record heartbeat covers divergence detection anyway — an unbounded listener would
129
+ * instead pay one root fetch per churning subscriber for the node's whole lifetime.
130
+ */
131
+ const COLD_START_REPULL_WINDOW_MS = HEARTBEAT_INTERVAL_MS;
124
132
  /**
125
133
  * Debounce (ms) for persisting the checkpoint snapshot after a winner-set change, mirroring the
126
134
  * announcer's cadence on the same transition: a gossip burst costs one write per window, and
@@ -261,6 +269,11 @@ class ContestEngine {
261
269
  #heartbeatTimer;
262
270
  /** The armed (debounced) snapshot-write timer; flushed by `leave()`. See {@link #writeSnapshot}. */
263
271
  #snapshotTimer;
272
+ /**
273
+ * Tears down the per-join `subscription-change` re-pull (listener + window timer); set by
274
+ * {@link #armSubscriptionRepull}, cleared by `leave()` or by the window expiring.
275
+ */
276
+ #subscriptionRepullDisarm;
264
277
  /** True when a heartbeat matching our own root was heard this interval (suppression). */
265
278
  #heardMatchingRoot = false;
266
279
  /** True once we published our record this interval (heartbeat OR divergence response). */
@@ -688,6 +701,14 @@ class ContestEngine {
688
701
  * the providers of the criteria CID from the host's HTTP content router. Roots are **unioned,
689
702
  * never quorum'd** — a record served by a single peer is still chased, so a colluding majority
690
703
  * cannot hide a vote.
704
+ *
705
+ * The `getSubscribers` source is an instantaneous snapshot, and a joiner that dials a seeder
706
+ * and joins immediately (the normal browser boot order) races subscription gossip: at the
707
+ * instant of `join()` it sees zero subscribers, pulls nothing, and would idle until the topic
708
+ * heartbeat (issue #15 — measured 90+ s vs ~4 s). So the pull closure (and its `seen` dedup)
709
+ * stays armed on gossipsub's `subscription-change` for {@link COLD_START_REPULL_WINDOW_MS}:
710
+ * each peer whose subscription to this topic becomes visible inside the window is asked once,
711
+ * closing the race for router-less clients too. See {@link #armSubscriptionRepull}.
691
712
  */
692
713
  async #coldStart() {
693
714
  const seen = new Set();
@@ -719,12 +740,45 @@ class ContestEngine {
719
740
  // live gossip still converge.
720
741
  }
721
742
  };
743
+ // Arm the re-pull BEFORE the initial fan-out so no subscriber can land in the gap
744
+ // between the snapshot below and the listener; `seen` dedups any overlap.
745
+ this.#armSubscriptionRepull(pull);
722
746
  // Shuffle before slicing: a deterministic first-N pick would funnel a whole directory
723
747
  // join through the same peers' stream caps while other subscribers idle; a random N
724
748
  // spreads contests across the topic's serving peers (see COLD_START_PEER_FETCH_LIMIT).
725
749
  const fromSubscribers = shuffled(this.#deps.pubsub.getSubscribers(this.topic)).slice(0, COLD_START_PEERS).map(pull);
726
750
  await Promise.allSettled([...fromSubscribers, this.#discoverProviders(pull)]);
727
751
  }
752
+ /**
753
+ * Keep the cold-start pull live on gossipsub's `subscription-change` for one re-pull window:
754
+ * a peer whose subscription to this topic becomes visible after `join()`'s instantaneous
755
+ * `getSubscribers` snapshot is pulled the moment it appears (once — the shared `seen` set
756
+ * dedups), instead of waiting for the heartbeat. Bounded by {@link COLD_START_REPULL_WINDOW_MS}:
757
+ * past the first heartbeat interval the passive heartbeat already covers divergence detection,
758
+ * so a long-lived node does not pay one fetch per churning subscriber forever. Disarmed by
759
+ * `leave()`; a re-join arms a fresh window.
760
+ */
761
+ #armSubscriptionRepull(pull) {
762
+ this.#disarmSubscriptionRepull(); // a stale listener from a prior join must not leak
763
+ const pubsub = this.#deps.pubsub;
764
+ const listener = (evt) => {
765
+ if (!evt.detail.subscriptions.some((s) => s.topic === this.topic && s.subscribe))
766
+ return;
767
+ void pull(evt.detail.peerId).catch(() => { });
768
+ };
769
+ pubsub.addEventListener("subscription-change", listener);
770
+ const timer = setTimeout(() => this.#disarmSubscriptionRepull(), COLD_START_REPULL_WINDOW_MS);
771
+ // Don't hold a Node process open; no-op in the browser.
772
+ timer.unref?.();
773
+ this.#subscriptionRepullDisarm = () => {
774
+ clearTimeout(timer);
775
+ pubsub.removeEventListener("subscription-change", listener);
776
+ };
777
+ }
778
+ #disarmSubscriptionRepull() {
779
+ this.#subscriptionRepullDisarm?.();
780
+ this.#subscriptionRepullDisarm = undefined;
781
+ }
728
782
  /**
729
783
  * Pull one peer's root record over the fetch protocol, retrying a THROWN fetch with full-jittered
730
784
  * exponential backoff until {@link COLD_START_FETCH_DEADLINE_MS} (see the constant's note for the
@@ -821,6 +875,7 @@ class ContestEngine {
821
875
  }
822
876
  // Pause the background verifier's retry timer; pending state survives for a re-join.
823
877
  this.#background.stop();
878
+ this.#disarmSubscriptionRepull();
824
879
  this.#heardMatchingRoot = false;
825
880
  this.#publishedRootThisInterval = false;
826
881
  this.#chaser = undefined;
@@ -75,6 +75,15 @@ export interface PubsubService {
75
75
  from?: PeerId;
76
76
  };
77
77
  }) => void): void;
78
+ /**
79
+ * gossipsub's "a peer's subscription set changed" notification. The cold-start pull
80
+ * re-runs off it (see client/voter.ts `#armSubscriptionRepull`): a joiner that dials a
81
+ * seeder and joins immediately sees zero subscribers at the instant of `join()` — the
82
+ * seeder only appears here once subscription gossip lands — so without this trigger it
83
+ * would idle until the topic heartbeat.
84
+ */
85
+ addEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
86
+ removeEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
78
87
  /**
79
88
  * gossipsub's per-topic validator map. The transport installs the async forward-gate
80
89
  * here (`topicValidators.set(topic, gate)`); gossipsub awaits the returned promise
@@ -85,6 +94,16 @@ export interface PubsubService {
85
94
  */
86
95
  topicValidators?: Map<string, GossipTopicValidator>;
87
96
  }
97
+ /** Listener for gossipsub's `subscription-change` (the libp2p `SubscriptionChangeData` shape). */
98
+ export type SubscriptionChangeListener = (evt: {
99
+ detail: {
100
+ peerId: PeerId;
101
+ subscriptions: Array<{
102
+ topic: string;
103
+ subscribe: boolean;
104
+ }>;
105
+ };
106
+ }) => void;
88
107
  /** A received pubsub message, as passed to a gossipsub topic validator. */
89
108
  export interface GossipMessage {
90
109
  topic: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",