@bitsocial/pubsub-voting 0.1.4 → 0.1.5

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.
@@ -1,6 +1,6 @@
1
1
  import { type Criteria } from "../schema/criteria.js";
2
2
  import { type Vote, type VotesBundle } from "../schema/votes.js";
3
- import type { ChainClientFactory, NameResolver } from "../chain/types.js";
3
+ import type { ChainClient, ChainClientFactory, NameResolver } from "../chain/types.js";
4
4
  import type { HeliaInstance } from "../transport/types.js";
5
5
  import type { RuleRegistry } from "../rules/types.js";
6
6
  import type { ContestTally } from "../tally/types.js";
@@ -241,6 +241,35 @@ export interface PubsubVoterOptions {
241
241
  */
242
242
  dataPath?: string | false;
243
243
  }
244
+ /**
245
+ * The {@link ResolvedDeps.readHead} factory: coalesce CONCURRENT `getBlockNumber` calls to one chain
246
+ * onto a single in-flight read, keyed on the client instance (a `WeakMap`, so a chain the voter stops
247
+ * using is collected). It deliberately does NOT cache across time — the moment a read settles its
248
+ * slot clears, so the next caller reads a fresh head. Only callers that arrive WHILE a read is in
249
+ * flight share it.
250
+ *
251
+ * In-flight sharing curbs the cold-start `getBlockNumber` storm. Every tally recompute refreshes its
252
+ * expiry bucket off the gating chain's head, and a directory join drives ~4 recomputes per contest (a
253
+ * chased-bundle admit plus two deferred-check settlements) across dozens of contests that all share
254
+ * ONE gating-chain client — so the reads land in overlapping bursts. Measured on the production 5chan
255
+ * cold start (63 contests, Chromium): 252 `readHead` calls collapse to 66 actual `getBlockNumber`
256
+ * posts (−74%), a real cut in redundant RPC to the shared endpoint (which the read coalescer's own
257
+ * notes show throttles concurrent posts) and in main-thread promise churn.
258
+ *
259
+ * It is NOT a first-tally latency win, and the commit is careful not to claim one: the first contest
260
+ * to admit still awaits its own (uncoalesced) head read on the critical path, and the measured
261
+ * bulk→first-tally wall is unchanged within noise — that window is network- and scheduling-bound, not
262
+ * head-read-bound (the 82s of `getBlockNumber` "wall" a naive probe attributes here is an async-overlap
263
+ * artifact: the true per-read CPU is negligible). This is a load/tidiness fix, banked because the
264
+ * redundancy is real and the change is cheap and safe.
265
+ *
266
+ * Coalescing is correct here without any staleness: `getBlockNumber` is a pure current-head read, so
267
+ * two callers overlapping in time genuinely want the same answer. A settled result is never reused, so
268
+ * a later recompute after the head has moved still sees the move (the expiry tests pin this), and a
269
+ * REJECTED read clears its slot too, so a transient RPC failure is retried on the next call rather than
270
+ * shared by latecomers to the same failed read.
271
+ */
272
+ export declare function makeHeadReader(): (chain: ChainClient) => Promise<bigint>;
244
273
  /**
245
274
  * The default `VoteClient`. Construct with the host-injected seams: a `helia` node (must carry a
246
275
  * gossipsub service, a blockstore, and a libp2p fetch service), a `chains` factory, an optional
@@ -9,7 +9,7 @@ import { makeBlockstoreBundleStore } from "../transport/bundle-store.js";
9
9
  import { makeRateLimiter } from "../transport/rate-limit.js";
10
10
  import { makeGossipGate } from "../transport/gossip-validator.js";
11
11
  import { makeVoteTransport } from "../transport/transport.js";
12
- import { decodeVoteMessage, decodeRootRecord, encodeRootRecord, maxBundleMessageBytes, rootFetchKey, MAX_ROOT_MESSAGE_BYTES, ROOT_FETCH_KEY_SUFFIX, ROOT_RECORD_VERSION } from "../transport/messages.js";
12
+ import { decodeVoteMessage, decodeRootRecord, encodeRootRecord, maxBundleMessageBytes, rootFetchKey, decodeBulkRootRecords, encodeBulkRootRecords, BULK_ROOTS_FETCH_KEY, BULK_ROOTS_MAX_RECORDS, BULK_ROOTS_MAX_INLINE_BYTES, MAX_ROOT_MESSAGE_BYTES, ROOT_FETCH_KEY_SUFFIX, ROOT_RECORD_VERSION } from "../transport/messages.js";
13
13
  import { makeRootChaser, toChaseSession } from "../transport/chase.js";
14
14
  import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js";
15
15
  import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
@@ -77,6 +77,17 @@ const COLD_START_PEERS = 4;
77
77
  * whatever the gossipsub-subscriber source and live gossip provide.
78
78
  */
79
79
  const COLD_START_ROUTER_TIMEOUT_MS = 10_000;
80
+ /**
81
+ * Per-provider dial deadline (ms) for cold-join discovery source 2. Each provider dial shares the
82
+ * router-lookup {@link COLD_START_ROUTER_TIMEOUT_MS} signal, but is ALSO bounded on its own by this
83
+ * shorter timeout — because a browser WSS dial can HANG (neither resolve nor reject) indefinitely,
84
+ * and since the root-record `pull` only runs after the dial await settles, a hung dial otherwise
85
+ * gates the pull for the full 10s router timeout even when a connection to that peer opens by other
86
+ * means in the meantime. Measured in production browser traces: 4 of 32 recent cold-start rounds hit
87
+ * the hang, turning ~6s loads into 8.7–22s totals. Bounding the dial at 3s lets the catch swallow the
88
+ * timeout and fire `pull` at ≤3s instead of ≤10s; only the dial is cut, never the pull.
89
+ */
90
+ const COLD_START_DIAL_TIMEOUT_MS = 3_000;
80
91
  /**
81
92
  * Root-record heartbeat interval (ms): 10 minutes — the IPNS-over-pubsub rebroadcast default
82
93
  * (`go-libp2p-pubsub-router`) — jittered ±25% per firing, with suppression on top (skip when a
@@ -183,6 +194,313 @@ function makePerPeerBudget(limitPerPeer) {
183
194
  }
184
195
  };
185
196
  }
197
+ /**
198
+ * The {@link ResolvedDeps.readHead} factory: coalesce CONCURRENT `getBlockNumber` calls to one chain
199
+ * onto a single in-flight read, keyed on the client instance (a `WeakMap`, so a chain the voter stops
200
+ * using is collected). It deliberately does NOT cache across time — the moment a read settles its
201
+ * slot clears, so the next caller reads a fresh head. Only callers that arrive WHILE a read is in
202
+ * flight share it.
203
+ *
204
+ * In-flight sharing curbs the cold-start `getBlockNumber` storm. Every tally recompute refreshes its
205
+ * expiry bucket off the gating chain's head, and a directory join drives ~4 recomputes per contest (a
206
+ * chased-bundle admit plus two deferred-check settlements) across dozens of contests that all share
207
+ * ONE gating-chain client — so the reads land in overlapping bursts. Measured on the production 5chan
208
+ * cold start (63 contests, Chromium): 252 `readHead` calls collapse to 66 actual `getBlockNumber`
209
+ * posts (−74%), a real cut in redundant RPC to the shared endpoint (which the read coalescer's own
210
+ * notes show throttles concurrent posts) and in main-thread promise churn.
211
+ *
212
+ * It is NOT a first-tally latency win, and the commit is careful not to claim one: the first contest
213
+ * to admit still awaits its own (uncoalesced) head read on the critical path, and the measured
214
+ * bulk→first-tally wall is unchanged within noise — that window is network- and scheduling-bound, not
215
+ * head-read-bound (the 82s of `getBlockNumber` "wall" a naive probe attributes here is an async-overlap
216
+ * artifact: the true per-read CPU is negligible). This is a load/tidiness fix, banked because the
217
+ * redundancy is real and the change is cheap and safe.
218
+ *
219
+ * Coalescing is correct here without any staleness: `getBlockNumber` is a pure current-head read, so
220
+ * two callers overlapping in time genuinely want the same answer. A settled result is never reused, so
221
+ * a later recompute after the head has moved still sees the move (the expiry tests pin this), and a
222
+ * REJECTED read clears its slot too, so a transient RPC failure is retried on the next call rather than
223
+ * shared by latecomers to the same failed read.
224
+ */
225
+ export function makeHeadReader() {
226
+ const inFlight = new WeakMap();
227
+ return (chain) => {
228
+ const existing = inFlight.get(chain);
229
+ if (existing !== undefined)
230
+ return existing; // a read is already in flight — share it
231
+ const head = chain.getBlockNumber();
232
+ inFlight.set(chain, head);
233
+ // Clear the slot the instant the read settles (fulfilled OR rejected), but only if it is
234
+ // still ours — a defensive guard, though nothing replaces an entry before it settles.
235
+ const clear = () => {
236
+ if (inFlight.get(chain) === head)
237
+ inFlight.delete(chain);
238
+ };
239
+ head.then(clear, clear);
240
+ return head;
241
+ };
242
+ }
243
+ /**
244
+ * How long the root-record batcher holds a peer's first request open, waiting for its siblings.
245
+ * A directory join fans out in one tick, so this only has to survive the microtask queue and the
246
+ * engines' pre-fetch awaits; it is latency added to every cold start, so it stays small.
247
+ */
248
+ const BULK_ROOTS_COALESCE_MS = 50;
249
+ /**
250
+ * How long a decoded (non-empty) bulk answer stays reusable for later pulls against the same peer.
251
+ * A directory cold start does NOT arrive in one batch: each contest's pull fires when its own
252
+ * router lookup returns, and lookups straggle over seconds — measured against production, a
253
+ * 63-contest cold start coalesced into 3 batches, the last two refetching the byte-identical
254
+ * answer (and the first tally waited on batch 3, not batch 1). Within this window a straggler is
255
+ * served from the previous answer without touching the wire. Staleness is bounded and harmless:
256
+ * a root is an unverifiable hint wherever it comes from, a re-pull that chases an already-chased
257
+ * root dedups in the chaser, and real divergence is what the 10-minute heartbeat exists for.
258
+ * Empty answers are never cached ("I serve nothing yet" expires in seconds on a booting seeder).
259
+ */
260
+ const BULK_ROOTS_CACHE_MS = 10_000;
261
+ /** Bound on cached bulk answers (one per peer): cold start touches a handful of peers, not 32. */
262
+ const BULK_ROOTS_CACHE_MAX_PEERS = 32;
263
+ /**
264
+ * A fetch-protocol ERROR response — the peer received the request and refused the key — as opposed
265
+ * to a transport-level failure (a stream reset, a dial failure, a timeout). `@libp2p/fetch` answers
266
+ * the ERROR status (not NOT_FOUND) whenever it has NO lookup function registered for the requested
267
+ * key's prefix at all, and surfaces it to the caller as a thrown `ProtocolError` (from
268
+ * `@libp2p/interface`). Detected by `name` rather than `instanceof` so a duplicated
269
+ * `@libp2p/interface` copy in the dependency tree cannot defeat the check (libp2p sets these names
270
+ * exactly so cross-package callers can match them). See {@link makeRootPuller}'s `flush`: a bulk
271
+ * fetch that hits this is a peer serving its root records under a narrower/foreign prefix (or under
272
+ * no bitsocial prefix yet), and must fall back to the per-topic key instead of being retried to the
273
+ * cold-start deadline as if it were unreachable.
274
+ */
275
+ function isFetchProtocolError(error) {
276
+ return error instanceof Error && error.name === "ProtocolError";
277
+ }
278
+ /**
279
+ * The {@link ResolvedDeps.pullRoot} factory: turn N per-contest root fetches against one peer into
280
+ * ONE bulk fetch (see {@link BULK_ROOTS_FETCH_KEY}), transparently to the engines.
281
+ *
282
+ * Requests to the same peer that arrive within {@link BULK_ROOTS_COALESCE_MS} share one bulk
283
+ * request. Its answer is authoritative for what it contains: a topic PRESENT resolves to that
284
+ * record, and a topic ABSENT from a non-empty answer resolves to "no record" — the peer told us it
285
+ * does not serve that contest, and re-asking individually would rebuild the very fan-out this
286
+ * exists to remove.
287
+ *
288
+ * The one case that does fall back is a peer that answers the bulk key with NOTHING. Those waiters
289
+ * re-issue per-topic fetches — and that verdict is deliberately NOT remembered.
290
+ *
291
+ * Not remembering looks wasteful and is the important part. "Nothing" does not only mean "old
292
+ * peer": the responder registers lazily on the first topic join, so a node that has not joined
293
+ * anything yet has no handler for this prefix at all and answers nothing for a reason that expires
294
+ * in seconds. Caching that verdict would strand a browser that happened to probe a seeder mid
295
+ * startup on the per-contest path for its whole session, long after the seeder began serving the
296
+ * whole directory in one reply. Re-probing per batch costs one wasted round trip per batch against
297
+ * a genuinely old peer (measured: ~3 batches in a cold start) and self-heals against a young one,
298
+ * which is the better trade in both directions.
299
+ *
300
+ * Throws propagate to every waiter in the batch: the engines' own retry loop
301
+ * (`#fetchRootWithRetry`) treats a throw as transient and backs off, which is the correct response
302
+ * to a saturated or resetting peer whether one contest asked or sixty-three did.
303
+ *
304
+ * On top of the batching, one answered request is squeezed for everything it is worth, because a
305
+ * directory cold start does NOT arrive as one tidy batch — each contest pulls only when its own
306
+ * router lookup returns, and lookups straggle over seconds (measured: 3 batches, the last two
307
+ * refetching the byte-identical answer while the first tally waited on them):
308
+ *
309
+ * - a fresh non-empty answer is CACHED ({@link BULK_ROOTS_CACHE_MS}), so a straggler's pull is
310
+ * served without touching the wire;
311
+ * - a pull arriving while a bulk request is already in flight JOINS it;
312
+ * - topics present in the answer that nobody in the batch asked about fan out to their engines
313
+ * through {@link RootPullerHooks.onRecord}, so the slowest lookup no longer gates the last
314
+ * contest's chase;
315
+ * - inlined checkpoint chunk blocks are hash-verified and stored through
316
+ * {@link RootPullerHooks.putBlock} BEFORE any waiter resolves, so the chases the answer
317
+ * triggers find their blocks locally instead of over bitswap.
318
+ */
319
+ function makeRootPuller(fetch, hooks = {}) {
320
+ const batches = new Map();
321
+ /** Non-empty answers kept for {@link BULK_ROOTS_CACHE_MS} so stragglers skip the wire. */
322
+ const cached = new Map();
323
+ /** In-flight bulk requests: a pull arriving mid-request joins it instead of starting another. */
324
+ const inflight = new Map();
325
+ const fetchOne = async (peer, topic) => await fetch.fetch(peer, rootFetchKey(topic));
326
+ const serve = (waiter, records) => {
327
+ const record = records[waiter.topic];
328
+ // Re-encode rather than thread a decoded record through: the engines' pull path decodes
329
+ // what it receives, so handing back bytes keeps the bulk and per-topic paths identical
330
+ // (and re-validates the entry through the same schema on the way out). A topic ABSENT
331
+ // from a non-empty answer is a definitive "no record" — the peer just told us what it
332
+ // serves, and re-asking individually would rebuild the fan-out this exists to remove.
333
+ if (record === undefined) {
334
+ waiter.resolve(undefined);
335
+ return;
336
+ }
337
+ try {
338
+ waiter.resolve(encodeRootRecord(record));
339
+ }
340
+ catch (error) {
341
+ waiter.reject(error);
342
+ }
343
+ };
344
+ /**
345
+ * Strip, verify and distribute one decoded bulk answer: inlined chunk blocks are re-hashed and
346
+ * (only when the hash matches their record's chunk CID) stored via `putBlock` — content
347
+ * addressing is the verification, so a lying block simply falls back to the chase — then the
348
+ * answer is cached for stragglers and every topic nobody in this batch asked about is fanned
349
+ * out through `onRecord`. Returns the stripped records the waiters are served from.
350
+ */
351
+ const accept = async (peer, id, decoded, askedTopics) => {
352
+ const records = {};
353
+ const puts = [];
354
+ for (const [topic, entry] of Object.entries(decoded)) {
355
+ const { chunkBlocks, ...record } = entry;
356
+ records[topic] = record;
357
+ // All-or-nothing per record (mirrors the responder): a partial set cannot skip the
358
+ // chase anyway, and a length mismatch means the answer is not what the encoder sends.
359
+ if (chunkBlocks === undefined || hooks.putBlock === undefined || chunkBlocks.length !== record.chunks.length)
360
+ continue;
361
+ for (let i = 0; i < chunkBlocks.length; i++) {
362
+ const bytes = chunkBlocks[i];
363
+ const expected = record.chunks[i];
364
+ puts.push((async () => {
365
+ try {
366
+ const block = await blockForBytes(bytes);
367
+ if (block.cid.equals(expected))
368
+ await hooks.putBlock(block.cid, block.bytes);
369
+ }
370
+ catch {
371
+ // A bad inline block contributes nothing; the chase fetches it instead.
372
+ }
373
+ })());
374
+ }
375
+ }
376
+ // Blocks land BEFORE any waiter resolves or record fans out, so the chases they trigger
377
+ // find every verified chunk already local.
378
+ await Promise.all(puts);
379
+ if (Object.keys(records).length > 0) {
380
+ if (cached.size >= BULK_ROOTS_CACHE_MAX_PEERS && !cached.has(id)) {
381
+ const oldest = cached.keys().next().value;
382
+ if (oldest !== undefined)
383
+ cached.delete(oldest);
384
+ }
385
+ cached.set(id, { at: Date.now(), records });
386
+ }
387
+ if (hooks.onRecord !== undefined) {
388
+ for (const [topic, record] of Object.entries(records)) {
389
+ if (askedTopics.has(topic))
390
+ continue;
391
+ try {
392
+ hooks.onRecord(peer, topic, encodeRootRecord(record));
393
+ }
394
+ catch {
395
+ // One unservable record must not stop the fan-out (mirrors `serve`).
396
+ }
397
+ }
398
+ }
399
+ return records;
400
+ };
401
+ const flush = async (peer, id) => {
402
+ const batch = batches.get(id);
403
+ if (batch === undefined)
404
+ return;
405
+ batches.delete(id);
406
+ const { waiters } = batch;
407
+ const run = (async () => {
408
+ let answer;
409
+ try {
410
+ answer = await fetch.fetch(peer, BULK_ROOTS_FETCH_KEY);
411
+ }
412
+ catch (error) {
413
+ // A peer that speaks the fetch protocol but has NO lookup registered for the bulk
414
+ // key's prefix answers with a protocol ERROR status, which `@libp2p/fetch` surfaces
415
+ // as a thrown `ProtocolError` — NOT the `undefined` a registered-but-empty responder
416
+ // returns for NOT_FOUND. Both mean the same thing to us ("this peer will not serve me
417
+ // over the bulk key"), so treat the ERROR exactly like the empty answer: return null
418
+ // and fall through to the per-topic path every root-serving peer answers. A
419
+ // transport failure (a stream reset from a saturated inbound cap, a dial failure, a
420
+ // timeout) is a DIFFERENT throw — never a `ProtocolError` — and must stay transient so
421
+ // the retry loop backs off, rather than permanently downgrading a live seeder that was
422
+ // only briefly over its stream cap to the per-topic fan-out this batching exists to
423
+ // remove. (See isFetchProtocolError; the integration harness's per-topic-prefix seeder
424
+ // is exactly the ERROR-answering peer this handles.)
425
+ if (!isFetchProtocolError(error))
426
+ throw error;
427
+ return null;
428
+ }
429
+ if (answer === undefined || answer === null)
430
+ return null;
431
+ // A malformed bulk answer throws here — this peer's problem, not a reason to re-ask
432
+ // it 63 times: the throw rejects every waiter and the engines' retry loop backs off.
433
+ return await accept(peer, id, decodeBulkRootRecords(answer), new Set(waiters.map((waiter) => waiter.topic)));
434
+ })();
435
+ // Registered synchronously (before any await), so a pull landing after this batch closed
436
+ // but before the answer joins THIS request instead of opening a redundant second one.
437
+ inflight.set(id, run);
438
+ let records;
439
+ try {
440
+ records = await run;
441
+ }
442
+ catch (error) {
443
+ for (const waiter of waiters)
444
+ waiter.reject(error);
445
+ return;
446
+ }
447
+ finally {
448
+ inflight.delete(id);
449
+ }
450
+ if (records === null) {
451
+ // Either a pre-bulk peer or one whose responder is not registered yet (it has joined
452
+ // nothing so far). Indistinguishable here, and deliberately not cached as a verdict —
453
+ // see this factory's note. Serve this batch per-topic; the next batch re-probes.
454
+ for (const waiter of waiters) {
455
+ fetchOne(peer, waiter.topic).then(waiter.resolve, waiter.reject);
456
+ }
457
+ return;
458
+ }
459
+ for (const waiter of waiters)
460
+ serve(waiter, records);
461
+ };
462
+ return async (peer, topic) => {
463
+ const id = peer.toString();
464
+ // Freshness first: a straggler whose discovery returned after an earlier batch answered is
465
+ // served from that answer without touching the wire (see BULK_ROOTS_CACHE_MS). Deliberately
466
+ // asymmetric with `serve`: only a topic PRESENT in the cached answer is served from it. The
467
+ // "absent = definitive no record" contract holds within one answer, but a cached answer
468
+ // predates contests joined after it was taken — a fresh batch (one coalesced round trip)
469
+ // re-asks for those instead of handing a late-created contest a stale "no" that would
470
+ // strand it until the heartbeat.
471
+ const fresh = cached.get(id);
472
+ if (fresh !== undefined) {
473
+ if (Date.now() - fresh.at <= BULK_ROOTS_CACHE_MS) {
474
+ const record = fresh.records[topic];
475
+ if (record !== undefined) {
476
+ return await new Promise((resolve, reject) => serve({ topic, resolve, reject }, fresh.records));
477
+ }
478
+ }
479
+ else {
480
+ cached.delete(id);
481
+ }
482
+ }
483
+ const running = inflight.get(id);
484
+ if (running !== undefined) {
485
+ const records = await running; // a throw propagates: same failure the batch saw
486
+ if (records !== null) {
487
+ return await new Promise((resolve, reject) => serve({ topic, resolve, reject }, records));
488
+ }
489
+ return await fetchOne(peer, topic); // the in-flight probe said "no bulk" — go per-topic
490
+ }
491
+ return await new Promise((resolve, reject) => {
492
+ const existing = batches.get(id);
493
+ if (existing !== undefined) {
494
+ existing.waiters.push({ topic, resolve, reject });
495
+ return;
496
+ }
497
+ const timer = setTimeout(() => void flush(peer, id), BULK_ROOTS_COALESCE_MS);
498
+ // Don't hold a Node process open for a coalesce window; no-op in the browser.
499
+ timer.unref?.();
500
+ batches.set(id, { waiters: [{ topic, resolve, reject }], timer });
501
+ });
502
+ };
503
+ }
186
504
  /** Fisher–Yates copy-shuffle (cold-start peer selection; see `#coldStart`). */
187
505
  function shuffled(items) {
188
506
  const out = [...items];
@@ -471,9 +789,23 @@ class ContestEngine {
471
789
  throw new Error(`no chain client configured for chain "${ticker}"`);
472
790
  return client;
473
791
  }
474
- /** Read the gating-chain head and update {@link #currentBucketCache}; returns the bucket. */
792
+ /**
793
+ * Read the gating-chain head and update {@link #currentBucketCache}; returns the bucket.
794
+ *
795
+ * The head read goes through the voter-wide {@link ResolvedDeps.readHead} coalescer, NOT the raw
796
+ * client, because the tally recompute calls this on EVERY state change: a cold directory join
797
+ * admits ~one chased bundle per contest and then settles two deferred checks per bundle, so a
798
+ * 63-contest join drives ~4 recomputes per contest ≈ 250 tally computes in a couple of seconds,
799
+ * each of which — before this — fired its own `eth_blockNumber`. The gating chain is shared by
800
+ * every contest (one `ruleChain` per chain across the directory), so those reads land in
801
+ * overlapping bursts; coalescing collapses concurrent callers to a single in-flight read (see
802
+ * {@link makeHeadReader}) without ever serving a stale head. The signing path ({@link signVote})
803
+ * and the tie-break block-hash read ({@link #bucketBlockHash}) still read the client directly.
804
+ * Measured on the production 5chan cold start: 252 head-read requests → 66 actual `getBlockNumber`
805
+ * (−74%). This is an RPC-load / churn reduction, not a first-tally latency win (see the commit).
806
+ */
475
807
  async #refreshBucket() {
476
- const head = await this.#ruleChain.getBlockNumber();
808
+ const head = await this.#deps.readHead(this.#ruleChain);
477
809
  this.#currentBucketCache = this.#bucketMath.bucketForBlock(Number(head));
478
810
  this.#headReadMs = Date.now();
479
811
  this.#maybePurgeGateResults();
@@ -848,7 +1180,7 @@ class ContestEngine {
848
1180
  return undefined; // left mid-backoff — abandon quietly
849
1181
  }
850
1182
  try {
851
- return await this.#deps.fetchBudget(peer.toString(), () => this.#deps.fetch.fetch(peer, rootFetchKey(this.topic)));
1183
+ return await this.#deps.fetchBudget(peer.toString(), () => this.#deps.pullRoot(peer, this.topic));
852
1184
  }
853
1185
  catch (error) {
854
1186
  lastError = error; // transient (e.g. seeder over its inbound-stream cap) — back off and retry
@@ -891,11 +1223,19 @@ class ContestEngine {
891
1223
  dials.push((async () => {
892
1224
  try {
893
1225
  if (provider.multiaddrs.length > 0) {
894
- await libp2p.dial(provider.multiaddrs, { signal: controller.signal });
1226
+ // Bound each dial by its own COLD_START_DIAL_TIMEOUT_MS in addition to the
1227
+ // shared router signal: a hung WSS dial that never settles must not gate the
1228
+ // pull for the full router timeout (see COLD_START_DIAL_TIMEOUT_MS). Only the
1229
+ // dial carries this signal — `pull` below is never aborted by it.
1230
+ const dialSignal = AbortSignal.any([
1231
+ controller.signal,
1232
+ AbortSignal.timeout(COLD_START_DIAL_TIMEOUT_MS)
1233
+ ]);
1234
+ await libp2p.dial(provider.multiaddrs, { signal: dialSignal });
895
1235
  }
896
1236
  }
897
1237
  catch {
898
- // Undialable via its advertised addrs — `pull` still tries (it may be connected).
1238
+ // Undialable, timed out, or router-aborted — `pull` still tries (it may be connected).
899
1239
  }
900
1240
  await pull(provider.id);
901
1241
  })());
@@ -1167,6 +1507,46 @@ class ContestEngine {
1167
1507
  latestCheckpointRoot() {
1168
1508
  return this.#rootRecordCache?.record.root;
1169
1509
  }
1510
+ /**
1511
+ * The current root record plus its checkpoint chunk blocks, for the bulk responder's inline
1512
+ * path (see `BulkFetchRootRecord.chunkBlocks`): the blocks are already in hand from the same
1513
+ * on-demand encode that produced the record, so inlining them is a copy, never extra encoding
1514
+ * work. `chunkBlocks` aligns positionally with `record.chunks`; the root-manifest block is
1515
+ * excluded (a receiver handed a verified chunk index never fetches the manifest).
1516
+ */
1517
+ async rootRecordWithBlocks() {
1518
+ const record = await this.rootRecord();
1519
+ // No await between the encode above and this read, so the cache is the one that made
1520
+ // `record`; the by-CID lookup is still belt-and-braces over positional slicing.
1521
+ const blocks = new Map((this.#rootRecordCache?.blocks ?? []).map((block) => [block.cid.toString(), block]));
1522
+ const chunkBlocks = [];
1523
+ for (const chunk of record.chunks) {
1524
+ const block = blocks.get(chunk.toString());
1525
+ if (block === undefined)
1526
+ return { record, chunkBlocks: [] }; // can't inline coherently — serve the record alone
1527
+ chunkBlocks.push(block);
1528
+ }
1529
+ return { record, chunkBlocks };
1530
+ }
1531
+ /**
1532
+ * Ingest a root record fetched on this engine's behalf by the voter-wide bulk puller: a bulk
1533
+ * answer names every contest the peer serves, not just the batch that asked, so the voter fans
1534
+ * the surplus records out here (see {@link RootPullerHooks.onRecord}) instead of letting each
1535
+ * contest wait out its own discovery to ask for bytes already in hand. Identical handling to a
1536
+ * cold-start pull result — note the advertiser, chase a divergence — minus the fetch. A no-op
1537
+ * before join()/after leave() (no chaser to hand the hint to); throws surface to the caller's
1538
+ * catch exactly like a malformed pull.
1539
+ */
1540
+ async ingestRootRecord(peer, encoded) {
1541
+ if (this.#chaser === undefined)
1542
+ return;
1543
+ const record = decodeRootRecord(encoded);
1544
+ const own = await this.rootRecord();
1545
+ this.#notePeerRoot(peer.toString(), record.root);
1546
+ if (!record.root.equals(own.root)) {
1547
+ this.#chaser.chase(record.root, record.chunks, this.#sessionProvidersFor(record.root, peer));
1548
+ }
1549
+ }
1170
1550
  /**
1171
1551
  * Handle a heard root record (an unverifiable hint, surfaced by the gate at layer 1):
1172
1552
  * matching our own root ⇒ note it for heartbeat suppression; differing ⇒ chase it lazily and
@@ -1473,6 +1853,27 @@ export class PubsubVoter {
1473
1853
  onTopicLeft: this.#onTopicLeft,
1474
1854
  onCheckpointChanged: () => this.#announcer?.notifyChange(),
1475
1855
  fetchBudget: makePerPeerBudget(COLD_START_PEER_FETCH_LIMIT),
1856
+ pullRoot: makeRootPuller(fetch, {
1857
+ // Verified inline chunk blocks land in the host blockstore, so the chases a bulk
1858
+ // answer triggers resolve locally instead of over bitswap (see RootPullerHooks).
1859
+ putBlock: async (cid, bytes) => {
1860
+ await blockstore.put(cid, bytes);
1861
+ },
1862
+ // Fan surplus records out to the contests that did not ask (see RootPullerHooks):
1863
+ // fire-and-forget like the cold-start pull it substitutes for — an ingest failure
1864
+ // loses an optimisation, never a correctness property (live gossip + the heartbeat
1865
+ // still converge that contest).
1866
+ onRecord: (peer, topic, encoded) => {
1867
+ const engine = this.#engines.get(topic);
1868
+ if (engine?.joined)
1869
+ void engine.ingestRootRecord(peer, encoded).catch(() => { });
1870
+ }
1871
+ }),
1872
+ // One head-read coalescer per voter, shared by every contest on the (shared) gating
1873
+ // chain: a cold-start burst of concurrent tally recomputes shares one in-flight
1874
+ // getBlockNumber per chain instead of each firing its own (see makeHeadReader /
1875
+ // #refreshBucket).
1876
+ readHead: makeHeadReader(),
1476
1877
  gateStore: this.#storage.openLru({ cacheName: "gate-results", maxItems: GATE_RESULTS_MAX_ITEMS }),
1477
1878
  nameResolutionCache: makeNameResolutionCache(this.#storage.openLru({ cacheName: "name-resolutions", maxItems: NAME_RESOLUTIONS_MAX_ITEMS })),
1478
1879
  snapshots: this.#storage.openSnapshots()
@@ -1582,6 +1983,8 @@ export class PubsubVoter {
1582
1983
  // `@libp2p/fetch` hands the lookup the requested key as raw bytes; decode the utf8 topic
1583
1984
  // string the requester sent (`rootFetchKey(topic)`) before matching.
1584
1985
  const key = new TextDecoder().decode(keyBytes);
1986
+ if (key === BULK_ROOTS_FETCH_KEY)
1987
+ return await this.#bulkRootsAnswer();
1585
1988
  if (!key.endsWith(ROOT_FETCH_KEY_SUFFIX))
1586
1989
  return undefined;
1587
1990
  const engine = this.#engines.get(key.slice(0, -ROOT_FETCH_KEY_SUFFIX.length));
@@ -1594,6 +1997,57 @@ export class PubsubVoter {
1594
1997
  return undefined;
1595
1998
  }
1596
1999
  };
2000
+ /**
2001
+ * The bulk half of the responder (see {@link BULK_ROOTS_FETCH_KEY}): one reply carrying the
2002
+ * root record of every joined contest, so a directory-sized cold joiner pays one round trip
2003
+ * instead of one per contest. Same rules as the per-topic answer, applied per entry — an
2004
+ * unjoined engine or an encode failure contributes nothing rather than a zero record that
2005
+ * would masquerade as an empty contest.
2006
+ *
2007
+ * Capped at {@link BULK_ROOTS_MAX_RECORDS}: a request must never be able to compel unbounded
2008
+ * encoding work. Truncation is safe and invisible to correctness — a caller that wanted a
2009
+ * topic missing from the answer falls back to fetching it individually.
2010
+ *
2011
+ * Answers an EMPTY map rather than nothing when it serves no contest, and that distinction is
2012
+ * load-bearing. A pre-bulk peer has this prefix registered but does not know this key, so it
2013
+ * answers nothing; if "serves nothing" also answered nothing, a seeder caught mid-startup would
2014
+ * be indistinguishable from one that will never support bulk, and the caller — which remembers
2015
+ * that verdict for the session — would fall back to one fetch per contest against a peer that
2016
+ * was about to serve the whole directory in one. An empty map says "I speak bulk, I have
2017
+ * nothing yet", which is a different and recoverable answer.
2018
+ */
2019
+ async #bulkRootsAnswer() {
2020
+ const joined = [...this.#engines.entries()].filter(([, engine]) => engine.joined).slice(0, BULK_ROOTS_MAX_RECORDS);
2021
+ const records = {};
2022
+ // The checkpoint payload rides along while the budget lasts (see BULK_ROOTS_MAX_INLINE_BYTES):
2023
+ // chunk blocks come from the same encode cache as the record, so inlining is a copy, and a
2024
+ // record past the budget simply answers without blocks — its chase fetches them as before.
2025
+ let inlineBudget = BULK_ROOTS_MAX_INLINE_BYTES;
2026
+ // Sequential on purpose: `rootRecord()` is served from the engine's on-demand encode cache
2027
+ // and this runs on the serving side of an unauthenticated request, so it must not fan out.
2028
+ for (const [topic, engine] of joined) {
2029
+ try {
2030
+ const { record, chunkBlocks } = await engine.rootRecordWithBlocks();
2031
+ const inlineBytes = chunkBlocks.reduce((total, block) => total + block.bytes.length, 0);
2032
+ if (chunkBlocks.length > 0 && inlineBytes <= inlineBudget) {
2033
+ inlineBudget -= inlineBytes;
2034
+ records[topic] = { ...record, chunkBlocks: chunkBlocks.map((block) => block.bytes) };
2035
+ }
2036
+ else {
2037
+ records[topic] = record;
2038
+ }
2039
+ }
2040
+ catch {
2041
+ // This contest contributes nothing; the rest of the directory still answers.
2042
+ }
2043
+ }
2044
+ try {
2045
+ return encodeBulkRootRecords(records);
2046
+ }
2047
+ catch {
2048
+ return undefined;
2049
+ }
2050
+ }
1597
2051
  /**
1598
2052
  * Lazy responder lifecycle, driven by the engines' real join/leave transitions: the first
1599
2053
  * joined topic registers the fetch responder, the last left topic unregisters it. This is an
@@ -72,6 +72,52 @@ export declare function decodeVoteMessage(data: Uint8Array): VoteMessage;
72
72
  export declare const ROOT_FETCH_KEY_SUFFIX = "/root";
73
73
  /** The fetch-protocol key for one contest's root record. */
74
74
  export declare function rootFetchKey(topic: string): string;
75
+ /**
76
+ * The fetch-protocol key for the BULK root record: "every contest you currently serve", answered
77
+ * as one `{ [topic]: FetchRootRecord }` map. A directory-sized cold joiner needs one root record
78
+ * per contest, and asking for them one at a time costs one request/response round trip each —
79
+ * measured as the dominant cold-start term for a 63-contest directory, since the multistream-select
80
+ * negotiation (~1-2 RTT) dominates each fetch regardless of how tiny the answer is. Records are
81
+ * ~100 B, so a whole directory fits in a handful of KB: one round trip instead of 63. Each record
82
+ * may additionally inline its checkpoint's chunk blocks (see {@link BulkFetchRootRecord}), making
83
+ * the one round trip carry the whole cold-pull payload, not just the pointers to it.
84
+ *
85
+ * Deliberately *not* parameterized by the topics the caller wants. The fetch protocol's key is the
86
+ * only client-to-server payload, so a topic list would mean a multi-KB key; and scoping the answer
87
+ * to a "directory" would push the manifest concept — which lives in the host above this library —
88
+ * into the wire format. "Everything I serve" needs neither: the caller intersects the answer with
89
+ * its own topics, exactly as it would have done across 63 separate replies.
90
+ *
91
+ * Shares the {@link TOPIC_PREFIX} registration with {@link rootFetchKey} and cannot collide with it
92
+ * (a per-topic key ends in `/root`, this one in `/roots`).
93
+ */
94
+ export declare const BULK_ROOTS_FETCH_KEY = "bitsocial-votes/roots";
95
+ /**
96
+ * The bulk answer's cap. A responder never returns more than this many records in one reply, so a
97
+ * single unauthenticated request cannot compel a node serving thousands of contests to encode all
98
+ * of them. Callers treat a capped (hence possibly incomplete) answer the same as any other: topics
99
+ * they asked about that are missing simply fall back to a per-topic fetch.
100
+ */
101
+ export declare const BULK_ROOTS_MAX_RECORDS = 512;
102
+ /**
103
+ * Byte budget for checkpoint chunk blocks INLINED into one bulk answer (see
104
+ * {@link BulkFetchRootRecord.chunkBlocks}). Checkpoint payloads are tiny in practice (~300 B per
105
+ * contest for a leaderboard directory), so a whole directory's cold-pull payload rides the one
106
+ * bulk round trip — but the budget caps what a single unauthenticated request can compel: once
107
+ * spent, remaining records answer without their blocks and the caller chases them over bitswap
108
+ * exactly as before. Spent in joined-contest iteration order, all-or-nothing per record (a
109
+ * partial chunk set would still cost the chase round-trip it exists to remove).
110
+ */
111
+ export declare const BULK_ROOTS_MAX_INLINE_BYTES: number;
112
+ export type BulkFetchRootRecord = FetchRootRecord & {
113
+ chunkBlocks?: Uint8Array[] | undefined;
114
+ };
115
+ /** The bulk root-record answer: contest topic → that contest's root record (chunks maybe inline). */
116
+ export type BulkRootRecords = Record<string, BulkFetchRootRecord>;
117
+ /** Encode a bulk root-record answer (see {@link BULK_ROOTS_FETCH_KEY}). */
118
+ export declare function encodeBulkRootRecords(records: BulkRootRecords): Uint8Array;
119
+ /** Decode a bulk root-record answer; throws on malformed (caller treats a throw as "no answer"). */
120
+ export declare function decodeBulkRootRecords(bytes: Uint8Array): BulkRootRecords;
75
121
  /**
76
122
  * Standalone root-record codec — the record served over the libp2p fetch protocol, carrying the
77
123
  * chunk-CID index so a cold joiner can skip the root-manifest round-trip (see {@link FetchRootRecord}).
@@ -2,6 +2,7 @@ import { CID } from "multiformats/cid";
2
2
  import * as dagCbor from "@ipld/dag-cbor";
3
3
  import { z } from "zod";
4
4
  import { encodeCanonical } from "../encoding/canonical.js";
5
+ import { TOPIC_PREFIX } from "../topic.js";
5
6
  /**
6
7
  * The pubsub message payload: a two-kind discriminated union (see DESIGN.md "Transport").
7
8
  *
@@ -77,6 +78,61 @@ export const ROOT_FETCH_KEY_SUFFIX = "/root";
77
78
  export function rootFetchKey(topic) {
78
79
  return `${topic}${ROOT_FETCH_KEY_SUFFIX}`;
79
80
  }
81
+ /**
82
+ * The fetch-protocol key for the BULK root record: "every contest you currently serve", answered
83
+ * as one `{ [topic]: FetchRootRecord }` map. A directory-sized cold joiner needs one root record
84
+ * per contest, and asking for them one at a time costs one request/response round trip each —
85
+ * measured as the dominant cold-start term for a 63-contest directory, since the multistream-select
86
+ * negotiation (~1-2 RTT) dominates each fetch regardless of how tiny the answer is. Records are
87
+ * ~100 B, so a whole directory fits in a handful of KB: one round trip instead of 63. Each record
88
+ * may additionally inline its checkpoint's chunk blocks (see {@link BulkFetchRootRecord}), making
89
+ * the one round trip carry the whole cold-pull payload, not just the pointers to it.
90
+ *
91
+ * Deliberately *not* parameterized by the topics the caller wants. The fetch protocol's key is the
92
+ * only client-to-server payload, so a topic list would mean a multi-KB key; and scoping the answer
93
+ * to a "directory" would push the manifest concept — which lives in the host above this library —
94
+ * into the wire format. "Everything I serve" needs neither: the caller intersects the answer with
95
+ * its own topics, exactly as it would have done across 63 separate replies.
96
+ *
97
+ * Shares the {@link TOPIC_PREFIX} registration with {@link rootFetchKey} and cannot collide with it
98
+ * (a per-topic key ends in `/root`, this one in `/roots`).
99
+ */
100
+ export const BULK_ROOTS_FETCH_KEY = `${TOPIC_PREFIX}roots`;
101
+ /**
102
+ * The bulk answer's cap. A responder never returns more than this many records in one reply, so a
103
+ * single unauthenticated request cannot compel a node serving thousands of contests to encode all
104
+ * of them. Callers treat a capped (hence possibly incomplete) answer the same as any other: topics
105
+ * they asked about that are missing simply fall back to a per-topic fetch.
106
+ */
107
+ export const BULK_ROOTS_MAX_RECORDS = 512;
108
+ /**
109
+ * Byte budget for checkpoint chunk blocks INLINED into one bulk answer (see
110
+ * {@link BulkFetchRootRecord.chunkBlocks}). Checkpoint payloads are tiny in practice (~300 B per
111
+ * contest for a leaderboard directory), so a whole directory's cold-pull payload rides the one
112
+ * bulk round trip — but the budget caps what a single unauthenticated request can compel: once
113
+ * spent, remaining records answer without their blocks and the caller chases them over bitswap
114
+ * exactly as before. Spent in joined-contest iteration order, all-or-nothing per record (a
115
+ * partial chunk set would still cost the chase round-trip it exists to remove).
116
+ */
117
+ export const BULK_ROOTS_MAX_INLINE_BYTES = 512 * 1024;
118
+ /**
119
+ * One bulk-answer entry: the root record, optionally carrying the checkpoint's chunk blocks
120
+ * inline. `chunkBlocks[i]` is the raw block whose content address must be `chunks[i]` — the
121
+ * receiver re-hashes every block and drops any that does not match, so inlined bytes are exactly
122
+ * as trustworthy as bitswap-fetched ones (content addressing is the verification either way, and
123
+ * the bundles inside are re-verified offline before merge regardless). Absent on the wire when a
124
+ * record has no chunks or the answer's inline budget ran out.
125
+ */
126
+ const BulkFetchRootRecordSchema = FetchRootRecordSchema.extend({ chunkBlocks: z.array(BytesSchema).optional() });
127
+ const BulkRootRecordsSchema = z.record(z.string(), BulkFetchRootRecordSchema);
128
+ /** Encode a bulk root-record answer (see {@link BULK_ROOTS_FETCH_KEY}). */
129
+ export function encodeBulkRootRecords(records) {
130
+ return encodeCanonical(BulkRootRecordsSchema.parse(records));
131
+ }
132
+ /** Decode a bulk root-record answer; throws on malformed (caller treats a throw as "no answer"). */
133
+ export function decodeBulkRootRecords(bytes) {
134
+ return BulkRootRecordsSchema.parse(dagCbor.decode(bytes));
135
+ }
80
136
  /**
81
137
  * Standalone root-record codec — the record served over the libp2p fetch protocol, carrying the
82
138
  * chunk-CID index so a cold joiner can skip the root-manifest round-trip (see {@link FetchRootRecord}).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",