@bitsocial/pubsub-voting 0.7.3 → 0.7.4

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 CHANGED
@@ -94,7 +94,7 @@ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingF
94
94
  ```ts
95
95
  const contest = await voter.createContest({ criteria }); // criteria: the contest's full document (strictly validated here)
96
96
  contest.on("update", () => render(contest.tally)); // tally rides the object; recomputed before each emit
97
- contest.on("error", (err) => showConnectivityWarning(err)); // tally chain read failed, the background verifier's RPC/resolver is down (retrying), or a deferred check evicted THIS wallet's own vote (VoteEvictedError)
97
+ contest.on("error", (err) => showConnectivityWarning(err)); // tally chain read failed, the background verifier's RPC/resolver is down (retrying), a deferred check evicted THIS wallet's own vote (VoteEvictedError), or this node's persisted checkpoint could not be kept (SnapshotError)
98
98
  await contest.update(); // join the topic, cold-start, begin emitting
99
99
  // const fresh = await contest.getTally(); // or force a fresh read, bypassing the cache
100
100
  // await contest.stop(); // leave the topic
@@ -31,7 +31,7 @@ import { CID } from "multiformats/cid";
31
31
  import { makeTally } from "../tally/tally.js";
32
32
  import { ballotTypedData } from "../signer/eip712.js";
33
33
  import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
34
- import { InvalidCommunityNameError, MissingChainClientError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
34
+ import { InvalidCommunityNameError, MissingChainClientError, SnapshotError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
35
35
  /**
36
36
  * The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
37
37
  * it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
@@ -147,6 +147,22 @@ const COLD_START_REPULL_WINDOW_MS = HEARTBEAT_INTERVAL_MS;
147
147
  * `leave()` flushes whatever is still pending so a clean shutdown never loses the tail.
148
148
  */
149
149
  const SNAPSHOT_DEBOUNCE_MS = 10_000;
150
+ /**
151
+ * How long to wait before re-attempting the bundles a snapshot restore could not admit for a
152
+ * TRANSIENT reason — an RPC outage during a seeder's boot burst, or a gating-chain head that has
153
+ * not yet reached a bundle's sample bucket (see {@link ContestEngine.#restoreSnapshot}). Backs off
154
+ * exponentially to {@link RESTORE_RETRY_CAP_MS} and keeps retrying while joined: giving up would
155
+ * mean either abandoning those votes or suppressing this topic's snapshot writes forever, and a
156
+ * retry costs one head read against a memoized bucket, so the patient option is also the cheap one.
157
+ */
158
+ const RESTORE_RETRY_MS = 30_000;
159
+ const RESTORE_RETRY_CAP_MS = 600_000;
160
+ /**
161
+ * Snapshot write attempts (one per debounce window) before leaving it to the next winner-set
162
+ * change. A store that fails every write must not spin, but a topic quiet enough to never change
163
+ * again must not keep a stale snapshot forever because ONE write failed (issue #45).
164
+ */
165
+ const SNAPSHOT_WRITE_ATTEMPTS = 3;
150
166
  /** Per-root chase deadline (ms): a multi-block directed-bitswap pull, coarser than one message. */
151
167
  const CHASE_TIMEOUT_MS = 30_000;
152
168
  /** Concurrent root chases; a spray of divergent roots queues, never floods. */
@@ -593,6 +609,19 @@ class ContestEngine {
593
609
  #heartbeatTimer;
594
610
  /** The armed (debounced) snapshot-write timer; flushed by `leave()`. See {@link #writeSnapshot}. */
595
611
  #snapshotTimer;
612
+ /** Consecutive failed snapshot writes in the current run (see {@link SNAPSHOT_WRITE_ATTEMPTS}). */
613
+ #snapshotWriteFailures = 0;
614
+ /**
615
+ * Bundles a restore decoded but could not admit for a TRANSIENT reason, awaiting
616
+ * {@link #restoreTimer}. Non-empty means the restore is INCOMPLETE, which suppresses the
617
+ * snapshot write: our view is missing votes the blob holds, and persisting it would overwrite
618
+ * the good blob with the partial one — the loss the retry exists to prevent (issue #45).
619
+ */
620
+ #restoreBacklog = [];
621
+ /** The armed restore-retry timer; cleared by `leave()`. */
622
+ #restoreTimer;
623
+ /** Consecutive restore retries, driving the backoff (reset by a complete restore). */
624
+ #restoreAttempts = 0;
596
625
  /**
597
626
  * Tears down the per-join `subscription-change` re-pull (listener + window timer); set by
598
627
  * {@link #armSubscriptionRepull}, cleared by `leave()` or by the window expiring.
@@ -662,9 +691,8 @@ class ContestEngine {
662
691
  nameResolutionCache: deps.nameResolutionCache,
663
692
  readHead: ({ chain }) => this.#readHead({ chain })
664
693
  });
665
- // The gate/transport are (re)built on join(); the store, crdt, caches, verifier, and
666
- // background verifier are stable per contest, so they survive re-joins of the topic.
667
- this.#store = store;
694
+ // The gate/transport are (re)built on join(); the crdt, caches, verifier, and background
695
+ // verifier are stable per contest, so they survive re-joins of the topic.
668
696
  this.#cache = makeVerdictCache();
669
697
  this.#acceptedDedup = makeAcceptedDedup(this.#bucketMath);
670
698
  this.#verifier = verifier;
@@ -697,7 +725,6 @@ class ContestEngine {
697
725
  bucketBlockHash: () => this.#bucketBlockHash()
698
726
  });
699
727
  }
700
- #store;
701
728
  /** Per gate leaf: hash of its canonical rule ref + chainId — its keyspace in the shared gate store. */
702
729
  #ruleIds;
703
730
  /** The gate's leaf refs in document order, aligned with {@link #ruleIds}. */
@@ -997,6 +1024,23 @@ class ContestEngine {
997
1024
  if (i >= 0)
998
1025
  this.#errorListeners.splice(i, 1);
999
1026
  }
1027
+ /**
1028
+ * Drop decayed and superseded bundles from the CRDT **and** the per-bundle state keyed by
1029
+ * their CIDs — one method because the two must not drift. `#checks` is not bookkeeping: it is
1030
+ * what {@link #hasUnsettledChecks} reads (a leaked pending entry suppresses this contest's
1031
+ * snapshot writes for a bundle that is no longer in the winner-set) and what the chase's
1032
+ * `isAdmitted` reads (a leaked entry makes it skip a bundle we no longer hold). The join-time
1033
+ * prune used to discard the removed CIDs — reachable in one restart: `verifyOffline` is
1034
+ * deliberately expiry-blind (verify/bundle.ts), so a snapshot older than the expiry window
1035
+ * restores its bundles and this prune removes them again moments later.
1036
+ */
1037
+ async #pruneDecayed() {
1038
+ for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
1039
+ const key = removed.toString();
1040
+ this.#checks.delete(key);
1041
+ this.#forgetOwnBundle(key); // expiry is decay, not an eviction — no error
1042
+ }
1043
+ }
1000
1044
  /** Compute the current ranking fresh (refreshing the bucket + pruning when state is present). */
1001
1045
  async computeTally() {
1002
1046
  // With state present, refresh the bucket so the tally's `current()` filters expiry against
@@ -1005,11 +1049,7 @@ class ContestEngine {
1005
1049
  // chain reads" property).
1006
1050
  if (this.#crdt.nodeCount() > 0) {
1007
1051
  await this.#refreshBucket();
1008
- for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
1009
- const key = removed.toString();
1010
- this.#checks.delete(key);
1011
- this.#forgetOwnBundle(key); // expiry is decay, not an eviction — no error
1012
- }
1052
+ await this.#pruneDecayed();
1013
1053
  }
1014
1054
  return this.#tally.compute();
1015
1055
  }
@@ -1132,7 +1172,12 @@ class ContestEngine {
1132
1172
  verifyOffline: (bundle) => this.#verifier.verifyOffline(bundle),
1133
1173
  cache: this.#cache,
1134
1174
  isEvaluableNow: (bundle) => this.#isEvaluableNow(bundle),
1135
- hasBundle: (cid) => this.#store.has(cid),
1175
+ // ADMISSION, not block presence (issue #44): our blockstore may hold a bundle's block
1176
+ // while the winner-set does not know it — after a restart that lost the snapshot, an
1177
+ // eviction, or a prune. Keyed on the blockstore, such a node skipped that bundle on
1178
+ // every chase and never converged; `#checks` is the same admission map
1179
+ // `#restoreSnapshot` consults, and it is dropped in lockstep with the CRDT's membership.
1180
+ isAdmitted: async (cid) => this.#checks.has(cid.toString()),
1136
1181
  // `verified: false` is a provisional admit (offline checks only) whose deferred gate
1137
1182
  // read + name resolution ride `deferVerify`; `true` means a cached terminal verdict
1138
1183
  // already covers the full pipeline.
@@ -1181,7 +1226,7 @@ class ContestEngine {
1181
1226
  // constant-weight tally" property.
1182
1227
  if (this.#crdt.nodeCount() > 0) {
1183
1228
  await this.#refreshBucket();
1184
- await this.#crdt.prune(this.#currentBucketCache);
1229
+ await this.#pruneDecayed();
1185
1230
  }
1186
1231
  }
1187
1232
  /**
@@ -1366,12 +1411,22 @@ class ContestEngine {
1366
1411
  clearTimeout(this.#heartbeatTimer);
1367
1412
  this.#heartbeatTimer = undefined;
1368
1413
  // Flush the debounced snapshot write so a clean shutdown persists the latest state
1369
- // (still skipped if checks are pending the stale-but-good snapshot stays put).
1414
+ // (still skipped if checks are pending, or a restore backlog is outstanding — the
1415
+ // stale-but-good snapshot stays put). `retry: false`: a failed flush must not arm a
1416
+ // timer that outlives the join.
1370
1417
  if (this.#snapshotTimer !== undefined) {
1371
1418
  clearTimeout(this.#snapshotTimer);
1372
1419
  this.#snapshotTimer = undefined;
1373
- await this.#writeSnapshot();
1420
+ await this.#writeSnapshot({ retry: false });
1374
1421
  }
1422
+ // Drop the restore backlog with its timer: a re-join re-reads the blob (still on disk,
1423
+ // since the backlog suppressed every write that could have thinned it) and starts over.
1424
+ if (this.#restoreTimer !== undefined)
1425
+ clearTimeout(this.#restoreTimer);
1426
+ this.#restoreTimer = undefined;
1427
+ this.#restoreBacklog = [];
1428
+ this.#restoreAttempts = 0;
1429
+ this.#snapshotWriteFailures = 0;
1375
1430
  // Pause the background verifier's retry timer; pending state survives for a re-join.
1376
1431
  this.#background.stop();
1377
1432
  this.#disarmSubscriptionRepull();
@@ -1490,17 +1545,25 @@ class ContestEngine {
1490
1545
  * wire bytes plus the blocks it references (just re-put by the encode, read back from the
1491
1546
  * blockstore so no second copy is held in memory). Best-effort like every persistent-cache
1492
1547
  * write — a broken store degrades to the pre-persistence behavior (the cold-start pull),
1493
- * never an error.
1548
+ * never an error — but not silent: a write that keeps failing surfaces as a `SnapshotError`
1549
+ * on the contest's `error` event, because the state it fails to keep is invisible otherwise.
1550
+ *
1551
+ * Skipped while our view is knowably INCOMPLETE, in either of the two ways it can be, because
1552
+ * a write in that window overwrites a good blob with a lossy one:
1494
1553
  *
1495
- * Skipped while ANY admitted bundle has a deferred check pending: the encoder serves only
1496
- * fully verified bundles, so writing mid-settlement would persist a snapshot that OMITS the
1497
- * pending ones right after a restore (where every reloaded bundle is provisional) that
1498
- * would clobber a good snapshot with a near-empty one, and a crash in that window would lose
1499
- * the very votes persistence exists to keep. Every settlement and eviction re-marks the
1500
- * state changed, so the write that was skipped here is re-armed by the last one to land.
1554
+ * - ANY admitted bundle has a deferred check pending the encoder serves only fully
1555
+ * verified bundles, so writing mid-settlement persists a snapshot that OMITS the pending
1556
+ * ones (right after a restore, where every reloaded bundle is provisional, that is a
1557
+ * near-empty snapshot). Every settlement and eviction re-marks the state changed, so the
1558
+ * skipped write is re-armed by the last one to land.
1559
+ * - a restore is still carrying a backlog ({@link #restoreBacklog}) the blob holds votes
1560
+ * a transient failure kept us from admitting, and the retry owns them until it lands.
1561
+ *
1562
+ * `retry` is false on the `leave()` flush: a failed write there must not arm a timer that
1563
+ * outlives the join.
1501
1564
  */
1502
- async #writeSnapshot() {
1503
- if (this.#hasUnsettledChecks())
1565
+ async #writeSnapshot({ retry = true } = {}) {
1566
+ if (this.#hasUnsettledChecks() || this.#restoreBacklog.length > 0)
1504
1567
  return;
1505
1568
  try {
1506
1569
  await this.rootRecord(); // (re-)encode so the cache reflects the current winner-set
@@ -1510,10 +1573,19 @@ class ContestEngine {
1510
1573
  if (cached === undefined)
1511
1574
  return;
1512
1575
  await this.#deps.snapshots.set(this.topic, encodeSnapshot({ record: encodeRootRecord(cached.record), blocks: cached.blocks.map((block) => block.bytes) }));
1576
+ this.#snapshotWriteFailures = 0;
1513
1577
  }
1514
- catch {
1515
- // Best-effort: a failed write leaves the previous snapshot in place; the next
1516
- // state change re-arms the debounce and retries.
1578
+ catch (error) {
1579
+ // The previous snapshot stays in place (a restart restores older state, never none).
1580
+ // Retry on the debounce a couple of times rather than waiting for a winner-set change
1581
+ // that a quiet topic may never see — then report and stop, so a dead store cannot spin.
1582
+ this.#snapshotWriteFailures += 1;
1583
+ if (retry && this.#snapshotWriteFailures < SNAPSHOT_WRITE_ATTEMPTS) {
1584
+ this.#scheduleSnapshotWrite();
1585
+ return;
1586
+ }
1587
+ this.#snapshotWriteFailures = 0;
1588
+ this.#emitError(new SnapshotError("write-failed", this.topic, error));
1517
1589
  }
1518
1590
  }
1519
1591
  /**
@@ -1524,9 +1596,18 @@ class ContestEngine {
1524
1596
  * bundle re-passes the offline signature/constraint checks, and the deferred gate read +
1525
1597
  * name resolution ride the background verifier (mostly persisted-gate-cache hits on a
1526
1598
  * restart) — so the trust model is unchanged: this is the node's own previously-validated
1527
- * state, re-validated on load. A corrupt or version-mismatched blob is removed and the join
1528
- * proceeds empty, exactly as before persistence; the cold-start pull then still runs, so a
1529
- * stale snapshot self-heals by union with the live topic.
1599
+ * state, re-validated on load. The cold-start pull still runs afterwards, so a stale snapshot
1600
+ * self-heals by union with the live topic.
1601
+ *
1602
+ * **Only a provably bad blob is discarded** (issue #45). The decode is the whole of the
1603
+ * corruption test, so it is the whole of what this catch covers: a truncated, mangled or
1604
+ * version-mismatched blob is removed and the join proceeds empty, exactly as before
1605
+ * persistence. Everything after it — the per-bundle admission, which reads the gating chain —
1606
+ * runs under {@link #admitRestored}, where a transient failure yields a RETRY and never a
1607
+ * delete. The distinction is the incident: the two catches used to be one, so a momentary RPC
1608
+ * failure during a seeder's boot burst (64 topics restoring at once, one rate-limit window)
1609
+ * permanently discarded a topic's persisted votes and the node came up empty on a topic every
1610
+ * other peer still served.
1530
1611
  */
1531
1612
  async #restoreSnapshot() {
1532
1613
  let blob;
@@ -1538,43 +1619,108 @@ class ContestEngine {
1538
1619
  }
1539
1620
  if (blob === undefined)
1540
1621
  return;
1622
+ let winners;
1541
1623
  try {
1542
1624
  const snapshot = decodeSnapshot(blob);
1543
1625
  const record = decodeRootRecord(snapshot.record);
1544
1626
  const byCid = new Map();
1545
1627
  for (const bytes of snapshot.blocks)
1546
1628
  byCid.set((await blockForBytes(bytes)).cid.toString(), bytes);
1547
- const winners = await decodeCheckpoint(record.root, async (cid) => byCid.get(cid.toString()), record.chunks);
1548
- const pending = [];
1549
- for (const bundle of winners) {
1550
- // Same two-stage admit as the chase (see transport/chase.ts): offline checks
1551
- // synchronously before admit, chain/name checks deferred and batched. Admission
1552
- // goes through `crdt.add` (which stores the block AND registers the bundle) — a
1553
- // merge-by-CID would re-read through the blockstore, which the restore must not
1554
- // depend on: it may be fresh (the incident's in-memory case) or hold the block
1555
- // already (a persistent one), neither of which says the CRDT knows the bundle.
1629
+ winners = await decodeCheckpoint(record.root, async (cid) => byCid.get(cid.toString()), record.chunks);
1630
+ }
1631
+ catch (error) {
1632
+ // Corrupt, truncated, or version-mismatched blob: discard it and join empty the
1633
+ // pre-persistence behavior. Never let a bad snapshot block the join.
1634
+ void this.#deps.snapshots.remove(this.topic).catch(() => { });
1635
+ this.#emitError(new SnapshotError("discarded", this.topic, error));
1636
+ return;
1637
+ }
1638
+ this.#restoreAttempts = 0;
1639
+ await this.#admitRestored(winners);
1640
+ }
1641
+ /**
1642
+ * Admit a decoded snapshot's bundles, one independently of the next. Three outcomes per
1643
+ * bundle, and the whole point of the method is that they stay distinct:
1644
+ *
1645
+ * - **admitted** — the same two-stage admit as the chase (transport/chase.ts): offline
1646
+ * checks synchronously before admit, chain/name checks deferred and batched. Admission
1647
+ * goes through `crdt.add` (which stores the block AND registers the bundle) — a
1648
+ * merge-by-CID would re-read through the blockstore, which the restore must not depend
1649
+ * on: it may be fresh (the incident's in-memory case) or hold the block already (a
1650
+ * persistent one), neither of which says the CRDT knows the bundle.
1651
+ * - **dropped** — the offline checks REFUSED it. That is a verdict on the bundle, not on
1652
+ * the environment, and no retry changes it.
1653
+ * - **backlogged** — a transient failure: the read that decides evaluability threw (an RPC
1654
+ * outage), or our gating-chain head has not yet reached the bundle's sample bucket. Those
1655
+ * go to {@link #restoreBacklog}, which suppresses the snapshot write and arms a retry;
1656
+ * dropping them silently is how a restart's ballots disappeared with the blob intact.
1657
+ */
1658
+ async #admitRestored(winners) {
1659
+ const pending = [];
1660
+ const backlog = [];
1661
+ for (const bundle of winners) {
1662
+ try {
1556
1663
  const cid = await bundleCidForBytes(encodeBundle(bundle));
1557
1664
  if (this.#checks.has(cid.toString()))
1558
1665
  continue; // already admitted (a re-join)
1559
- if (!(await this.#isEvaluableNow(bundle)))
1666
+ if (!(await this.#isEvaluableNow(bundle))) {
1667
+ backlog.push(bundle); // our head is behind the ballot: retry, do not forget it
1560
1668
  continue;
1669
+ }
1561
1670
  const offline = await this.#verifier.verifyOffline(bundle);
1562
1671
  if (!offline.valid)
1563
- continue;
1672
+ continue; // the bundle's own fault — a sibling's admit stands
1564
1673
  await this.#crdt.add(bundle);
1565
1674
  this.#recordChecks(cid, bundle, false);
1566
1675
  pending.push({ cid, bundle });
1567
1676
  }
1568
- if (pending.length > 0) {
1569
- this.#background.enqueue(pending);
1570
- this.#onStateChanged();
1677
+ catch {
1678
+ // Infrastructure, not the bundle (a chain read, a blockstore write): keep it.
1679
+ backlog.push(bundle);
1571
1680
  }
1572
1681
  }
1573
- catch {
1574
- // Corrupt, truncated, or version-mismatched blob: discard it and join empty — the
1575
- // pre-persistence behavior. Never let a bad snapshot block the join.
1576
- void this.#deps.snapshots.remove(this.topic).catch(() => { });
1682
+ const wasIncomplete = this.#restoreBacklog.length > 0;
1683
+ this.#restoreBacklog = backlog;
1684
+ if (pending.length > 0) {
1685
+ this.#background.enqueue(pending);
1686
+ this.#onStateChanged();
1577
1687
  }
1688
+ if (backlog.length === 0) {
1689
+ // The backlog just drained: re-arm the write it was suppressing, so the now-complete
1690
+ // view reaches disk even if the retry admitted nothing new (a re-join that raced us).
1691
+ if (wasIncomplete)
1692
+ this.#scheduleSnapshotWrite();
1693
+ return;
1694
+ }
1695
+ // Report the incompleteness once per restore (not once per retry): an operator needs to
1696
+ // know this topic's tally is missing persisted votes, not a line every backoff window.
1697
+ if (!wasIncomplete)
1698
+ this.#emitError(new SnapshotError("restore-incomplete", this.topic));
1699
+ this.#armRestoreRetry();
1700
+ }
1701
+ /**
1702
+ * Arm the next backlog retry (exponential backoff, capped). Kept armed for as long as the
1703
+ * backlog is non-empty and we stay joined: the alternative — giving up — means either
1704
+ * discarding the votes or suppressing this topic's snapshot writes forever.
1705
+ */
1706
+ #armRestoreRetry() {
1707
+ if (this.#restoreTimer !== undefined || !this.#joined)
1708
+ return;
1709
+ const delay = Math.min(RESTORE_RETRY_MS * 2 ** this.#restoreAttempts, RESTORE_RETRY_CAP_MS);
1710
+ this.#restoreAttempts += 1;
1711
+ const timer = setTimeout(() => {
1712
+ this.#restoreTimer = undefined;
1713
+ const backlog = this.#restoreBacklog;
1714
+ if (backlog.length === 0 || !this.#joined)
1715
+ return;
1716
+ void this.#admitRestored(backlog).catch(() => {
1717
+ // #admitRestored never throws; re-arm defensively so a backlog cannot strand.
1718
+ this.#armRestoreRetry();
1719
+ });
1720
+ }, delay);
1721
+ // Don't hold a Node process open; no-op in the browser.
1722
+ timer.unref?.();
1723
+ this.#restoreTimer = timer;
1578
1724
  }
1579
1725
  /**
1580
1726
  * The contest's current root record, encoded **on demand** and cached until the winner-set
@@ -1900,10 +2046,23 @@ class ContestView {
1900
2046
  async update() {
1901
2047
  if (this.#subscribed)
1902
2048
  return;
1903
- await this.#engine.join();
2049
+ // Registered BEFORE the join, not after: the join restores this contest's persisted
2050
+ // snapshot, and every way that can go wrong (a blob discarded as corrupt, a restore an
2051
+ // RPC outage left incomplete — `SnapshotError`) is reported on the `error` event from
2052
+ // inside `join()`. Attaching afterwards made exactly those reports unobservable.
1904
2053
  this.#engine.addUpdateListener(this.#onEngineUpdate);
1905
2054
  this.#engine.addErrorListener(this.#onEngineError);
1906
2055
  this.#subscribed = true;
2056
+ try {
2057
+ await this.#engine.join();
2058
+ }
2059
+ catch (error) {
2060
+ // A failed join leaves no subscription behind (a retry must be able to re-arm).
2061
+ this.#engine.removeUpdateListener(this.#onEngineUpdate);
2062
+ this.#engine.removeErrorListener(this.#onEngineError);
2063
+ this.#subscribed = false;
2064
+ throw error;
2065
+ }
1907
2066
  // Populate `tally` and fire an initial `update` for the current state.
1908
2067
  await this.#engine.refreshTallyNow();
1909
2068
  }
package/dist/errors.d.ts CHANGED
@@ -146,3 +146,43 @@ export declare class VoteEvictedError extends Error {
146
146
  /** The failing verdict, with the same `reason` wording every verifier produces. */
147
147
  verdict: VerifyFail);
148
148
  }
149
+ /**
150
+ * Emitted (never thrown) on a contest's `error` event when this node's OWN persisted checkpoint
151
+ * snapshot could not be kept ({@link SnapshotFailure} says which way): a blob discarded as
152
+ * unreadable, a restore left incomplete by a transient failure, or a write that kept failing.
153
+ *
154
+ * Persistence is best-effort by design — every one of these degrades to the pre-persistence
155
+ * behaviour (the cold-start pull), never to a broken join — but "best-effort" used to mean
156
+ * "silent", and silent state loss is exactly how a seeder served a divergent tally for 13 days
157
+ * without anyone noticing (issue #45). Operators get a fact and a topic instead of an unexplained
158
+ * gap; there is nothing for a client to do about it beyond logging or alerting.
159
+ */
160
+ export declare class SnapshotError extends Error {
161
+ /** Which snapshot operation failed. */
162
+ readonly failure: SnapshotFailure;
163
+ /** The topic whose snapshot is affected. */
164
+ readonly topic: string;
165
+ /** The underlying throw, when there was one (a `restore-incomplete` may have none). */
166
+ readonly cause?: unknown | undefined;
167
+ constructor(
168
+ /** Which snapshot operation failed. */
169
+ failure: SnapshotFailure,
170
+ /** The topic whose snapshot is affected. */
171
+ topic: string,
172
+ /** The underlying throw, when there was one (a `restore-incomplete` may have none). */
173
+ cause?: unknown | undefined);
174
+ }
175
+ /**
176
+ * What went wrong with a persisted checkpoint snapshot:
177
+ *
178
+ * - `discarded` — the blob did not decode (corrupt, truncated, version-mismatched) and was
179
+ * removed. The votes it held are gone from disk; the topic joins empty and re-converges from
180
+ * peers.
181
+ * - `restore-incomplete` — the blob decoded, but a transient failure (an RPC outage during the
182
+ * boot burst, a head that has not reached a bundle's sample bucket) left some of its bundles
183
+ * un-admitted. The blob is KEPT and the restore retries; snapshot writes are suppressed for
184
+ * this topic meanwhile, so a partial view can never overwrite the good blob.
185
+ * - `write-failed` — the store rejected the snapshot write repeatedly. The previous snapshot
186
+ * stays in place, so a restart restores an older state rather than none.
187
+ */
188
+ export type SnapshotFailure = "discarded" | "restore-incomplete" | "write-failed";
package/dist/errors.js CHANGED
@@ -213,3 +213,32 @@ export class VoteEvictedError extends Error {
213
213
  this.name = "VoteEvictedError";
214
214
  }
215
215
  }
216
+ /**
217
+ * Emitted (never thrown) on a contest's `error` event when this node's OWN persisted checkpoint
218
+ * snapshot could not be kept ({@link SnapshotFailure} says which way): a blob discarded as
219
+ * unreadable, a restore left incomplete by a transient failure, or a write that kept failing.
220
+ *
221
+ * Persistence is best-effort by design — every one of these degrades to the pre-persistence
222
+ * behaviour (the cold-start pull), never to a broken join — but "best-effort" used to mean
223
+ * "silent", and silent state loss is exactly how a seeder served a divergent tally for 13 days
224
+ * without anyone noticing (issue #45). Operators get a fact and a topic instead of an unexplained
225
+ * gap; there is nothing for a client to do about it beyond logging or alerting.
226
+ */
227
+ export class SnapshotError extends Error {
228
+ failure;
229
+ topic;
230
+ cause;
231
+ constructor(
232
+ /** Which snapshot operation failed. */
233
+ failure,
234
+ /** The topic whose snapshot is affected. */
235
+ topic,
236
+ /** The underlying throw, when there was one (a `restore-incomplete` may have none). */
237
+ cause) {
238
+ super(`Checkpoint snapshot ${failure} for topic ${topic}${cause === undefined ? "" : `: ${String(cause)}`}`);
239
+ this.failure = failure;
240
+ this.topic = topic;
241
+ this.cause = cause;
242
+ this.name = "SnapshotError";
243
+ }
244
+ }
@@ -77,8 +77,18 @@ export interface RootChaserDeps {
77
77
  cache: VerdictCache;
78
78
  /** The gate's freshness guard (see gossip-validator.ts); omitted ⇒ no check. */
79
79
  isEvaluableNow?: (bundle: VotesBundle) => Promise<boolean>;
80
- /** Skip bundles we already hold (their CID is in the store) without re-verifying. */
81
- hasBundle: (cid: CID) => Promise<boolean>;
80
+ /**
81
+ * Have we already ADMITTED this bundle (is it in the CRDT / winner-set)? Admitted bundles are
82
+ * skipped without re-verifying.
83
+ *
84
+ * Admission, never block presence: this was wired to the blockstore once, and the two disagree
85
+ * exactly when it matters (issue #44). A node whose persistent blockstore holds a bundle's
86
+ * block but whose admission state was lost — a restart that dropped the snapshot, an eviction,
87
+ * a prune — skipped that bundle on EVERY chase and could never converge again, silently, while
88
+ * every peer served it. Re-verifying a locally-held block costs no network (the bytes are the
89
+ * ones we just decoded), so the safe side of the disagreement is cheap.
90
+ */
91
+ isAdmitted: (cid: CID) => Promise<boolean>;
82
92
  /**
83
93
  * Store an offline-valid bundle's block bytes and admit its CID into the CRDT (idempotent).
84
94
  * `verified: true` means a cached terminal verdict already covers the FULL pipeline (the
@@ -41,7 +41,7 @@ export function toChaseSession(session) {
41
41
  };
42
42
  }
43
43
  export function makeRootChaser(deps) {
44
- const { getBlock, openSession, verifyOffline, cache, isEvaluableNow, hasBundle, admit, deferVerify, onMerged, onCheckpointContents, limit, timeoutMs } = deps;
44
+ const { getBlock, openSession, verifyOffline, cache, isEvaluableNow, isAdmitted, admit, deferVerify, onMerged, onCheckpointContents, limit, timeoutMs } = deps;
45
45
  const inFlight = new Map();
46
46
  function addProviders(flight, providers) {
47
47
  for (const peer of providers) {
@@ -121,8 +121,8 @@ export function makeRootChaser(deps) {
121
121
  const bytes = encodeBundle(bundle);
122
122
  const cid = await bundleCidForBytes(bytes);
123
123
  contained.push(cid); // recorded BEFORE the skip below — see onCheckpointContents
124
- if (await hasBundle(cid))
125
- continue; // already held — nothing to verify
124
+ if (await isAdmitted(cid))
125
+ continue; // already in the winner-set — nothing to verify
126
126
  const cached = cache.get(cid);
127
127
  if (cached) {
128
128
  if (!cached.valid)
@@ -129,9 +129,13 @@ export async function makeVoteNode(topic, options = {}) {
129
129
  // calls checkGates, so it admits rather than pretending to model a gate.
130
130
  checkGates: async () => ({ kind: "leaf", leaf: 0, satisfied: true, score: 1n, penalize: false })
131
131
  };
132
+ // Mirrors the voter's admission map (`#checks`): what the CRDT knows, NOT what the blockstore
133
+ // holds — the chase's skip predicate must be admission (voter.ts `isAdmitted`, issue #44).
134
+ const admitted = new Set();
132
135
  const admit = async ({ cid, bytes }) => {
133
136
  await blockstore.put(cid, bytes);
134
137
  await crdt.merge([cid]);
138
+ admitted.add(cid.toString());
135
139
  };
136
140
  const acceptedBundles = [];
137
141
  const heardRoots = [];
@@ -185,7 +189,7 @@ export async function makeVoteNode(topic, options = {}) {
185
189
  },
186
190
  verifyOffline: (bundle) => verifier.verifyOffline(bundle),
187
191
  cache,
188
- hasBundle: (cid) => store.has(cid),
192
+ isAdmitted: async (cid) => admitted.has(cid.toString()),
189
193
  admit,
190
194
  // The harness runs the whole swapped pipeline in `verifyOffline` above, so nothing is
191
195
  // left deferred; the background verifier has its own unit tests.
@@ -275,6 +279,7 @@ export async function makeVoteNode(topic, options = {}) {
275
279
  const cid = await bundleCidForBytes(bytes);
276
280
  await blockstore.put(cid, bytes);
277
281
  await crdt.merge([cid]);
282
+ admitted.add(cid.toString());
278
283
  },
279
284
  stop: async () => {
280
285
  await transport.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",