@bitsocial/pubsub-voting 0.7.2 → 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 +1 -1
- package/dist/client/voter.js +207 -48
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +29 -0
- package/dist/transport/chase.d.ts +12 -2
- package/dist/transport/chase.js +3 -3
- package/dist/transport/integration/harness.js +6 -1
- package/package.json +5 -1
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),
|
|
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
|
package/dist/client/voter.js
CHANGED
|
@@ -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
|
|
666
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
|
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
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
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
|
-
//
|
|
1516
|
-
//
|
|
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.
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1677
|
+
catch {
|
|
1678
|
+
// Infrastructure, not the bundle (a chain read, a blockstore write): keep it.
|
|
1679
|
+
backlog.push(bundle);
|
|
1571
1680
|
}
|
|
1572
1681
|
}
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
81
|
-
|
|
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
|
package/dist/transport/chase.js
CHANGED
|
@@ -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,
|
|
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
|
|
125
|
-
continue; // already
|
|
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
|
-
|
|
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,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitsocial/pubsub-voting",
|
|
3
|
-
"version": "0.7.
|
|
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",
|
|
7
|
+
"packageManager": "npm@11.13.0",
|
|
8
|
+
"engines": {
|
|
9
|
+
"npm": ">=11"
|
|
10
|
+
},
|
|
7
11
|
"repository": {
|
|
8
12
|
"type": "git",
|
|
9
13
|
"url": "git+https://github.com/bitsocialnet/pubsub-voting.git"
|