-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Multi-phase elections solution resubmission #8290
Changes from 5 commits
62e1854
9a8c4a2
19d7251
02ad14b
f6a0fd9
59a0fb4
4612d07
c8ccbe9
7e4408f
f4b50fc
a192ba3
81ceca1
9023b61
449b799
85f715e
c6ecab8
ad3b786
bee456c
89cf445
458682b
d7dbcc0
b3e4950
0b77268
576f87c
d0f031a
681e6f0
0fac518
276a06a
ea3fc29
cc70d37
4b46a93
224d5e1
c5668e9
e9305be
fe84141
8b31773
9978693
ebcdeaa
0236288
bd7a195
1c896be
2243501
2b5d050
5eb2673
f12d59d
70b4f71
9c28048
3f1109b
af2d2c6
fc71d3d
840bad3
8eaf481
f4b52f0
6ae73c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -213,7 +213,7 @@ use frame_support::{ | |
| use frame_system::{ensure_none, offchain::SendTransactionTypes}; | ||
| use sp_election_providers::{ElectionDataProvider, ElectionProvider, onchain}; | ||
| use sp_npos_elections::{ | ||
| assignment_ratio_to_staked_normalized, is_score_better, CompactSolution, ElectionScore, | ||
| assignment_ratio_to_staked_normalized, CompactSolution, ElectionScore, | ||
| EvaluateSupport, PerThing128, Supports, VoteWeight, | ||
| }; | ||
| use sp_runtime::{ | ||
|
|
@@ -291,8 +291,16 @@ pub enum Phase<Bn> { | |
| Off, | ||
| /// Signed phase is open. | ||
| Signed, | ||
| /// Unsigned phase. First element is whether it is open or not, second the starting block | ||
| /// Unsigned phase. First element is whether it is active or not, second the starting block | ||
| /// number. | ||
| /// | ||
| /// We do not yet check whether the unsigned phase is active or passive. The intent is for the | ||
| /// blockchain to be able to declare: "I believe that there exists an adequate signed solution," | ||
| /// advising validators not to bother running the unsigned offchain worker. | ||
| /// | ||
| /// As validator nodes are free to edit their OCW code, they could simply ignore this advisory | ||
| /// and always compute their own solution. However, by default, when the unsigned phase is passive, | ||
| /// the offchain workers will not bother running. | ||
|
gui1117 marked this conversation as resolved.
|
||
| Unsigned((bool, Bn)), | ||
| } | ||
|
|
||
|
|
@@ -303,27 +311,27 @@ impl<Bn> Default for Phase<Bn> { | |
| } | ||
|
|
||
| impl<Bn: PartialEq + Eq> Phase<Bn> { | ||
| /// Weather the phase is signed or not. | ||
| /// Whether the phase is signed or not. | ||
| pub fn is_signed(&self) -> bool { | ||
| matches!(self, Phase::Signed) | ||
| } | ||
|
|
||
| /// Weather the phase is unsigned or not. | ||
| /// Whether the phase is unsigned or not. | ||
| pub fn is_unsigned(&self) -> bool { | ||
| matches!(self, Phase::Unsigned(_)) | ||
| } | ||
|
|
||
| /// Weather the phase is unsigned and open or not, with specific start. | ||
| /// Whether the phase is unsigned and open or not, with specific start. | ||
| pub fn is_unsigned_open_at(&self, at: Bn) -> bool { | ||
| matches!(self, Phase::Unsigned((true, real)) if *real == at) | ||
| } | ||
|
|
||
| /// Weather the phase is unsigned and open or not. | ||
| /// Whether the phase is unsigned and open or not. | ||
| pub fn is_unsigned_open(&self) -> bool { | ||
| matches!(self, Phase::Unsigned((true, _))) | ||
| } | ||
|
|
||
| /// Weather the phase is off or not. | ||
| /// Whether the phase is off or not. | ||
|
coriolinus marked this conversation as resolved.
|
||
| pub fn is_off(&self) -> bool { | ||
| matches!(self, Phase::Off) | ||
| } | ||
|
|
@@ -514,6 +522,13 @@ pub mod pallet { | |
| #[pallet::constant] | ||
| type SolutionImprovementThreshold: Get<Perbill>; | ||
|
|
||
| /// The repeat threshold of the offchain worker. | ||
| /// | ||
| /// For example, if it is 5, that means that at least 5 blocks will elapse between attempts | ||
| /// to submit the worker's solution. | ||
| #[pallet::constant] | ||
| type OffchainRepeat: Get<Self::BlockNumber>; | ||
|
|
||
| /// The priority of the unsigned transaction submitted in the unsigned-phase | ||
| type MinerTxPriority: Get<TransactionPriority>; | ||
| /// Maximum number of iteration of balancing that will be executed in the embedded miner of | ||
|
|
@@ -595,16 +610,26 @@ pub mod pallet { | |
| } | ||
| } | ||
|
|
||
| fn offchain_worker(n: T::BlockNumber) { | ||
| // We only run the OCW in the first block of the unsigned phase. | ||
| if Self::current_phase().is_unsigned_open_at(n) { | ||
| match Self::try_acquire_offchain_lock(n) { | ||
| Ok(_) => { | ||
| let outcome = Self::mine_check_and_submit().map_err(ElectionError::from); | ||
| log!(info, "miner exeuction done: {:?}", outcome); | ||
| fn offchain_worker(now: T::BlockNumber) { | ||
| let threshold = T::OffchainRepeat::get(); | ||
|
coriolinus marked this conversation as resolved.
Outdated
|
||
| match Self::current_phase() { | ||
| Phase::Unsigned((true, opened)) if opened == now => { | ||
| // mine a new solution, cache it, and attempt to submit it | ||
| let initial_output = Self::try_acquire_offchain_lock(now, threshold) | ||
| .and_then(|_| Self::mine_check_save_submit()); | ||
| log!(info, "initial OCW output at {:?}: {:?}", now, initial_output); | ||
| } | ||
| Phase::Unsigned((true, opened)) if opened < now => { | ||
| if !<QueuedSolution<T>>::exists() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe instead of accepting any solution there, we should consider the score of the solution. What if a validator submit a quite low score solution, and all other validator somehow miss their submition ? (I don't know exactly in which context a validator can miss his submittion, I expect this happens when he tried to submit to fork instead of the final block chain). Maybe we should always compute a solution at least one time. And compare here with the on-chain one.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #8253 (currently draft, not yet ready for review) is intended to address exactly this issue. It lets people challenge a bad solution.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what I think is that if the validator has generated one solution offchain he can quickly check that his solution is better or worse that the queued one. Currently we do:
I wonder why we don't do:
EDIT: IIRC challenge phase prevent from too bad solution to be submitted, but what if the solution is not so bad but not so good.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My reasoning there is that it's wasteful. We always submit at the first opportunity. That means that the only time a continuous resubmit like that would be valuable is when all of
This feels to me like it fits at the intersection of low-probability-of-occurring and low-benefit-if-implemented.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm actually my thought were confused. We save the solution in a persistent offchain storage. Thus if we generate a solution for the wrong election data snapshot. Our solution is still bad. If it happens that we generated the solution for a different snapshot I suggest that the node try to run the election again for the new snapshot and compare the solution score with the on-chain one. I think nodes should generate a solution for all the election data snapshots.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes and no in case we do the option1 from Kian, we need either to make use of fork-aware storage, or store the validator/nominators sets on which we computed the solution and compare with the current one. If we can easily afford computing 5 solutions then option2 from Kian seems ok. For me in option1 we should be able to know if our solution is outdated because of fork. So it is enough to mitigate this situations.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Hmm what I meant was more like this:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've never used What I would be curious in this case is what happens to that data? if it is not wiped, we should make sure it is cleaned at some point. In either case, it might be a good idea to clear all OCW storage once
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think the only difference between what you wrote there and what ea3fc29 implements is when we perform the queued solution check. Logically, it still only submits if the queued solution does not exist or if the local solution has a better score than what has been queued on-chain.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah my bad, didn't check the rest of the code. Also good call to add |
||
| // as long as there is no feasible solution, keep trying to submit ours | ||
|
coriolinus marked this conversation as resolved.
Outdated
|
||
| // | ||
|
coriolinus marked this conversation as resolved.
Outdated
|
||
| // the offchain_lock prevents us from spamming submissions too often. | ||
| let resubmit_output = Self::try_acquire_offchain_lock(now, threshold) | ||
| .and_then(|_| Self::restore_or_compute_then_submit()); | ||
| log!(info, "resubmit OCW output at {:?}: {:?}", now, resubmit_output); | ||
| } | ||
| Err(why) => log!(warn, "denied offchain worker: {:?}", why), | ||
| } | ||
| _ => {}, | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,22 +17,44 @@ | |
|
|
||
| //! The unsigned phase implementation. | ||
|
|
||
| use crate::*; | ||
| use frame_support::dispatch::DispatchResult; | ||
| use crate::{ | ||
|
coriolinus marked this conversation as resolved.
|
||
| helpers, | ||
| Call, | ||
| CompactAccuracyOf, | ||
| CompactOf, | ||
| CompactVoterIndexOf, | ||
| Config, | ||
| ElectionCompute, | ||
| Error, | ||
| FeasibilityError, | ||
| Pallet, | ||
| RawSolution, | ||
| ReadySolution, | ||
| RoundSnapshot, | ||
| SolutionOrSnapshotSize, | ||
| Weight, | ||
| WeightInfo, | ||
| }; | ||
| use codec::Decode; | ||
| use frame_support::{dispatch::DispatchResult, ensure, traits::Get}; | ||
| use frame_system::offchain::SubmitTransaction; | ||
| use sp_arithmetic::Perbill; | ||
| use sp_npos_elections::{ | ||
| seq_phragmen, CompactSolution, ElectionResult, assignment_ratio_to_staked_normalized, | ||
| assignment_ratio_to_staked_normalized, | ||
|
coriolinus marked this conversation as resolved.
Outdated
|
||
| assignment_staked_to_ratio_normalized, | ||
| is_score_better, | ||
| seq_phragmen, | ||
| CompactSolution, | ||
| ElectionResult, | ||
| }; | ||
| use sp_runtime::{offchain::storage::StorageValueRef, traits::TrailingZeroInput}; | ||
| use sp_std::cmp::Ordering; | ||
| use sp_std::{cmp::Ordering, vec::Vec}; | ||
|
kianenigma marked this conversation as resolved.
Outdated
|
||
|
|
||
| /// Storage key used to store the persistent offchain worker status. | ||
| pub(crate) const OFFCHAIN_HEAD_DB: &[u8] = b"parity/multi-phase-unsigned-election"; | ||
|
kianenigma marked this conversation as resolved.
Outdated
|
||
|
|
||
| /// The repeat threshold of the offchain worker. This means we won't run the offchain worker twice | ||
| /// within a window of 5 blocks. | ||
| pub(crate) const OFFCHAIN_REPEAT: u32 = 5; | ||
| /// Storage key used to cache the solution `call`. | ||
| pub(crate) const OFFCHAIN_CACHED_CALL: &[u8] = b"parity/multi-phase-unsigned-election/call"; | ||
|
|
||
| #[derive(Debug, Eq, PartialEq)] | ||
| pub enum MinerError { | ||
|
|
@@ -46,6 +68,8 @@ pub enum MinerError { | |
| PreDispatchChecksFailed, | ||
| /// The solution generated from the miner is not feasible. | ||
| Feasibility(FeasibilityError), | ||
| /// Something went wrong fetching the lock. | ||
| Lock(&'static str), | ||
| } | ||
|
|
||
| impl From<sp_npos_elections::Error> for MinerError { | ||
|
|
@@ -60,15 +84,48 @@ impl From<FeasibilityError> for MinerError { | |
| } | ||
| } | ||
|
|
||
| /// Save a given call into OCW storage. | ||
| fn save_solution<T: Config>(call: &Call<T>) { | ||
| let storage = StorageValueRef::persistent(&OFFCHAIN_CACHED_CALL); | ||
| storage.set(&call); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should use the atomic
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? We don't care about the current value, so what benefit is there in reading it? We're not using the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let me know if it is unclear, it is an important thing to grasp, the overlapping possibility of OCWs.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's very surprising that Obviously, we can run OCWs in parallel, so the storage needs to be shared. I haven't traced it back exhaustively, but what I'd expect to find is something like I believe that the atomic nature of the
AFAICT, normal mutable aliasing rules prevent OCW B from ever reading while OCW A is in the middle of writing. If I'm right, then we really don't need
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am totally happy to accept what you are proposing, if you actually find out that it is designed and works as you hypothesize, not based on numerous assumptions that you are making. I am not sure about any of this myself, to be frank, and generally prefer consulting and the trusting the opinion of someone who actually does (Tomek in the case of the linked discussion). You can probably ping him if you want to clarify this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. update? (learning from the past, I think you should just change this 1 line to a 1 liner that uses mutate and call it a day and be safe and save time as well.)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes ok. I just can't find the actual implementation of offchain storage anywhere; everything seems to be abstractions that layer on abstractions. I'll just use |
||
| } | ||
|
|
||
| /// Get a saved solution from OCW storage if it exists. | ||
| fn restore_solution<T: Config>() -> Option<Call<T>> { | ||
| StorageValueRef::persistent(&OFFCHAIN_CACHED_CALL).get().flatten() | ||
| } | ||
|
|
||
| impl<T: Config> Pallet<T> { | ||
| /// Mine a new solution, and submit it back to the chain as an unsigned transaction. | ||
| pub fn mine_check_and_submit() -> Result<(), MinerError> { | ||
| /// Attempt to restore a solution from cache. Otherwise, compute it fresh. Either way, submit. | ||
| pub fn restore_or_compute_then_submit() -> Result<(), MinerError> { | ||
| let call = match restore_solution() { | ||
| Some(call) => call, | ||
| None => { | ||
| let call = Self::mine_call()?; | ||
| save_solution(&call); | ||
| call | ||
| }, | ||
| }; | ||
| Self::submit_call(call) | ||
|
kianenigma marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// Mine a new solution, cache it, and submit it back to the chain as an unsigned transaction. | ||
| pub fn mine_check_save_submit() -> Result<(), MinerError> { | ||
| let call = Self::mine_call()?; | ||
| save_solution(&call); | ||
| Self::submit_call(call) | ||
| } | ||
|
|
||
| /// Mine a new solution as a call. Performs all checks. | ||
| fn mine_call() -> Result<Call<T>, MinerError> { | ||
|
kianenigma marked this conversation as resolved.
Outdated
|
||
| let iters = Self::get_balancing_iters(); | ||
| // get the solution, with a load of checks to ensure if submitted, IT IS ABSOLUTELY VALID. | ||
| let (raw_solution, witness) = Self::mine_and_check(iters)?; | ||
| Ok(Call::submit_unsigned(raw_solution, witness)) | ||
| } | ||
|
|
||
| let call = Call::submit_unsigned(raw_solution, witness).into(); | ||
| SubmitTransaction::<T, Call<T>>::submit_unsigned_transaction(call) | ||
| fn submit_call(call: Call<T>) -> Result<(), MinerError> { | ||
| SubmitTransaction::<T, Call<T>>::submit_unsigned_transaction(call.into()) | ||
| .map_err(|_| MinerError::PoolSubmissionFailed) | ||
| } | ||
|
|
||
|
|
@@ -148,7 +205,7 @@ impl<T: Config> Pallet<T> { | |
| .map_err::<MinerError, _>(Into::into)?; | ||
| sp_npos_elections::reduce(&mut staked); | ||
|
|
||
| // convert back to ration and make compact. | ||
| // convert back to ratio and make compact. | ||
| let ratio = assignment_staked_to_ratio_normalized(staked)?; | ||
| let compact = <CompactOf<T>>::from_assignment(ratio, &voter_index, &target_index)?; | ||
|
|
||
|
|
@@ -328,12 +385,11 @@ impl<T: Config> Pallet<T> { | |
| /// not. | ||
| /// | ||
| /// This essentially makes sure that we don't run on previous blocks in case of a re-org, and we | ||
| /// don't run twice within a window of length [`OFFCHAIN_REPEAT`]. | ||
| /// don't run twice within a window of length `threshold`. | ||
| /// | ||
| /// Returns `Ok(())` if offchain worker should happen, `Err(reason)` otherwise. | ||
| pub(crate) fn try_acquire_offchain_lock(now: T::BlockNumber) -> Result<(), &'static str> { | ||
| pub(crate) fn try_acquire_offchain_lock(now: T::BlockNumber, threshold: T::BlockNumber) -> Result<(), MinerError> { | ||
| let storage = StorageValueRef::persistent(&OFFCHAIN_HEAD_DB); | ||
| let threshold = T::BlockNumber::from(OFFCHAIN_REPEAT); | ||
|
|
||
| let mutate_stat = | ||
| storage.mutate::<_, &'static str, _>(|maybe_head: Option<Option<T::BlockNumber>>| { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sidenote about the code below: As far as I know, we run offchain worker only on the best block, if we do reorg we don't run the offchain worker on every block in the reorg. If this is true then we could run the offchain worker when |
||
|
|
@@ -357,9 +413,9 @@ impl<T: Config> Pallet<T> { | |
| // all good | ||
| Ok(Ok(_)) => Ok(()), | ||
| // failed to write. | ||
| Ok(Err(_)) => Err("failed to write to offchain db."), | ||
| Ok(Err(_)) => Err(MinerError::Lock("failed to write to offchain db.")), | ||
| // fork etc. | ||
| Err(why) => Err(why), | ||
| Err(why) => Err(MinerError::Lock(why)), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -400,7 +456,8 @@ impl<T: Config> Pallet<T> { | |
| #[cfg(test)] | ||
| mod max_weight { | ||
| #![allow(unused_variables)] | ||
| use super::{mock::*, *}; | ||
| use super::*; | ||
| use crate::mock::MultiPhase; | ||
|
|
||
| struct TestWeight; | ||
| impl crate::weights::WeightInfo for TestWeight { | ||
|
|
@@ -481,12 +538,30 @@ mod max_weight { | |
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ | ||
| mock::{Origin, *}, | ||
| Call, *, | ||
| use super::*; | ||
| use crate::{ | ||
| mock::{ | ||
| roll_to, | ||
| roll_to_with_ocw, | ||
| witness, | ||
| Call as OuterCall, | ||
| ExtBuilder, | ||
| Extrinsic, | ||
| MinerMaxWeight, | ||
| MultiPhase, | ||
| Origin, | ||
| Runtime, | ||
| TestCompact, | ||
| }, | ||
| CurrentPhase, | ||
| InvalidTransaction, | ||
| Phase, | ||
| QueuedSolution, | ||
| TransactionSource, | ||
| TransactionValidityError, | ||
| }; | ||
| use frame_support::{dispatch::Dispatchable, traits::OffchainWorker}; | ||
| use mock::Call as OuterCall; | ||
| use frame_benchmarking::Zero; | ||
| use frame_support::{assert_noop, assert_ok, dispatch::Dispatchable, traits::OffchainWorker}; | ||
| use sp_election_providers::Assignment; | ||
| use sp_runtime::{traits::ValidateUnsigned, PerU16}; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.