@bitsocial/pubsub-voting 0.1.4 → 0.1.6
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/README.md +1 -1
- package/dist/client/voter.d.ts +30 -1
- package/dist/client/voter.js +460 -6
- package/dist/schema/directory.d.ts +2 -1
- package/dist/schema/directory.js +2 -1
- package/dist/transport/messages.d.ts +46 -0
- package/dist/transport/messages.js +56 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,7 +158,7 @@ See [DESIGN.md, Republishing is the client's job](./DESIGN.md#republishing-is-th
|
|
|
158
158
|
|
|
159
159
|
### Many contests (a 5chan-style directory)
|
|
160
160
|
|
|
161
|
-
One criteria document is one contest (one topic). A directory is conveniently authored as a single manifest of shared `defaults` plus one entry per slot — as in [5chan-directory-criteria.jsonc](
|
|
161
|
+
One criteria document is one contest (one topic). A directory is conveniently authored as a single manifest of shared `defaults` plus one entry per slot — as in the published [5chan-directory-criteria.jsonc](https://github.com/bitsocialnet/lists/blob/master/5chan-directory-criteria.jsonc) and [examples/5chan.ts](./examples/5chan.ts) — and `deriveDirectoryCriteria` derives the finished documents (`{ ...defaults, ...entry }`, shallow — an override replaces that whole field) and validates each one. What participants must share **byte-identically** is the derived documents (the topic is their CID), which is why every consumer of the same directory should derive through this one helper rather than re-implement the merge. The manifest is JSONC by convention; strip comments before parsing:
|
|
162
162
|
|
|
163
163
|
```ts
|
|
164
164
|
import { deriveDirectoryCriteria } from "@bitsocial/pubsub-voting";
|
package/dist/client/voter.d.ts
CHANGED
|
@@ -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
|
package/dist/client/voter.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.#
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -16,7 +16,8 @@ import { type Criteria } from "./criteria.js";
|
|
|
16
16
|
* The merge is shallow ON PURPOSE: an entry override replaces that whole top-level field
|
|
17
17
|
* (a `rule` override brings its own complete rule object), no deep merge. Editing
|
|
18
18
|
* `defaults` re-forks every inheriting contest in lockstep; editing one entry forks only
|
|
19
|
-
* that contest. See the
|
|
19
|
+
* that contest. See the published 5chan manifest
|
|
20
|
+
* (https://github.com/bitsocialnet/lists/blob/master/5chan-directory-criteria.jsonc) and
|
|
20
21
|
* DESIGN.md "Criteria document".
|
|
21
22
|
*
|
|
22
23
|
* Manifest files are conventionally JSONC (commented for human readers); strip comments
|
package/dist/schema/directory.js
CHANGED
|
@@ -17,7 +17,8 @@ import { DuplicateContestIdError } from "../errors.js";
|
|
|
17
17
|
* The merge is shallow ON PURPOSE: an entry override replaces that whole top-level field
|
|
18
18
|
* (a `rule` override brings its own complete rule object), no deep merge. Editing
|
|
19
19
|
* `defaults` re-forks every inheriting contest in lockstep; editing one entry forks only
|
|
20
|
-
* that contest. See the
|
|
20
|
+
* that contest. See the published 5chan manifest
|
|
21
|
+
* (https://github.com/bitsocialnet/lists/blob/master/5chan-directory-criteria.jsonc) and
|
|
21
22
|
* DESIGN.md "Criteria document".
|
|
22
23
|
*
|
|
23
24
|
* Manifest files are conventionally JSONC (commented for human readers); strip comments
|
|
@@ -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}).
|