BC-463 — PendingPolls + tally write path redesign

Branch explainer for feature/polkadot-v1.6.0-vm-bc463-pending-polls-redesign
Base: feature/polkadot-v1.6.0-vm Commits: 2 Diff: +442 / −324 across 6 files Scope: pallets/reputation-voting/ only
TL;DR. Splits the legacy PendingPolls<BoundedVec<…>> into a lightweight counter plus a per-voter StorageDoubleMap. Every vote / remove_vote / update_vote becomes O(1) instead of decode-and-re-encode of a ~2.1 MB BoundedVec. on_idle now drains processed entries in-place, so multi-block batch resumption is implicit. The v2 migration shim added in the first commit was reverted in the second — the chain starts from scratch.

Headline numbers

vote() ref_time
~38 ms
↓ from 327 ms (~8×)
vote() proof_size
116 085 B
↓ from 2 103 489 B
remove_vote() ref_time
~28 ms
↓ from 286 ms (~10×)
PendingPolls entry max
28 B
↓ from 2 100 024 B

Numbers from the hand-adjusted weights.rs. The proof_size figures are tight (derived from MaxEncodedLen); the ref_time figures are estimates and want a fresh cargo bench sweep before tagging.

Storage layout: before → after

BEFORE PendingPolls CountedStorageMap key: PollIndex value: BoundedVec< (AccountId, VoteDirection), MaxVotersPerPoll // up to ~1M entries > max_encoded_len 2 100 024 bytes — decoded on every vote() vote() → O(1) try_push + full re-encode remove_vote() → O(n) retain over BoundedVec update_vote() → O(n) find+mutate over BoundedVec split AFTER PendingPolls CountedStorageMap (counter only) key: PollIndex value: u32 // 28 B max PendingPollVoters (NEW) StorageDoubleMap key1: PollIndex key2: AccountId value: VoteDirection // 57 B entry vote() → O(1) insert + counter bump remove_vote() → O(1) remove + counter dec update_vote() → O(1) overwrite on_idle → iter_prefix() + drain

Per-extrinsic complexity

Extrinsic Before After Storage touched (after)
vote() O(1) push + ~2 MB decode/encode O(1) double-map insert PendingPollVoters insert + PendingPolls counter +1
remove_vote() O(n) retain O(1) double-map remove PendingPollVoters remove + PendingPolls counter −1
update_vote() O(n) find + mutate O(1) double-map overwrite PendingPollVoters overwrite (no counter change)
on_idle (per voter) 3R / 4W 4R / 4W + PendingPollVoters read+remove inline

MaxVotersReached is still enforced — but now via a counter compare against MaxVotersPerPoll::get() rather than BoundedVec::try_push. Semantically identical cap; the error now surfaces before any tally mutation.

Commits on this branch

d6ba863BC-463: redesign pallet-reputation-voting PendingPolls + tally write path

The core change. Reworks storage, all three extrinsics, the on_idle batch processor, weights, and benchmarks. Originally shipped with a v2 migration (reverted in commit 2).

Storage layout

  • PendingPolls: CountedStorageMap<PollIndex, u32> — just a voter count. The built-in CounterForPendingPolls still lets on_idle bail out quickly when the queue is empty.
  • PendingPollVoters: StorageDoubleMap<PollIndex, AccountId, VoteDirection> — the actual per-voter records. Iterated via iter_prefix(poll_index).

on_idle batch processing

  • process_voters_batch now iter_prefix(poll).take(max_voters) and drains processed entries in place. Resumption across blocks is implicit — the next batch just continues the shorter prefix. No more voter_offset arithmetic.
  • ProcessingState drops voter_offset and adds total_voters (captured at first-batch time so the PollProcessed / PollCancelled event still reports the full poll's count after multi-block processing).
  • Per-voter accounting is now 4R / 4W (was 3R/4W) and per_voter_proof_size adds a fourth trie-proof overhead + the new entry's encoded size.

Included cleanups (small, intentional)

  • Runtime API: dead Tally::is_passing() removed (only test fixtures called it). Inlined to tally.ayes > tally.nays in mock and tests — this is what required the test-file edits.
  • Pallet::get_tally: defensive unreachable Ongoing arm changed from Some(*tally) to None, matching the sibling defensive arm in is_passing and avoiding a redundant referenda re-entry while a mutable borrow is held.
  • Doc fix: vote() comment ExecutedCompleted.
  • weights.rs: hand-adjusted on 2026-05-18 to reflect new storage entry sizes (header comment notes a fresh cargo bench pass is still wanted).
  • Benchmarks: prefill_voters writes into PendingPollVoters + bumps the counter; documented that prefill no longer drives measured weight for the three extrinsics.

Test rewrites

Three helpers — pending_voter_count, add_pending_voter, pending_voters_sorted — replace the old PendingPolls::mutate(_, try_push) patterns and abstract over the unordered double-map iteration. Tests that asserted specific voter ordering were rewritten to count processed voters rather than inspect indices (storage prefix iteration order is unspecified). ProcessingState field changes propagate into the cancelled_poll_* and on_idle_batched_processing_with_progress_state tests.

584d7d1BC-463: drop reputation-voting v2 migration

Reverts the migration shim that the first commit originally added.

  • Deletes pallets/reputation-voting/src/migrations.rs.
  • Drops pub mod migrations; from the pallet root.
  • Rolls STORAGE_VERSION from StorageVersion::new(2) back to StorageVersion::new(1); reverts its visibility to private.

Why: the chain starts from scratch — no v1 state exists in production, so the migration was dead weight. It also tripped SonarCloud rust:S2208 (wildcard imports) on MR !269. Consistent with the standing rule that this branch never carries storage migrations.

Breaking / behavioural notes

!
Storage layout change. PendingPolls value type changes from BoundedVec<…> to u32, and a new PendingPollVoters double-map is introduced. Since the v2 migration was dropped, this is only safe on a fresh chain — which is the explicit assumption here.
!
Runtime API surface change. Tally::is_passing() is removed. If any out-of-tree consumer (RPC client, off-chain worker) called it, they need to inline tally.ayes > tally.nays.
!
ProcessingState layout change. Encoded size 10 B → 14 B. No on-disk migration handles this, but ProcessingProgress is transient (only set during multi-block batch processing), so on a fresh-genesis chain this is moot.
i
MaxVotersReached ordering. Now enforced via a counter compare instead of BoundedVec::try_push. Same cap, but the error surfaces before any tally mutation (previously rolled back, but computed first).

Worth double-checking before tagging