Skip to content

Commit 3e2c66c

Browse files
Merge branch 'NNS1-1214' into 'master'
NNS1-1214: Continue to latest_tally after a proposal is decided early until deadline Previously, the ProposalData::latest_tally is recomputed within process_propossal() when the proposal is open, in 3 cases: (1) when proposal is created (2) for each register_vote (3) process_proposal() triggered by heartbeat (although only the timestamp would be changed). This change tries to move recompute_tally() out of process_proposal() and only call it for a new vote (i.e. ballots are changed). Therefore every time a new vote is cast, the tally is recomputed regardless of the proposal's status. Assumptions: 1. It's OK to update latest_tally less often, as long as the tally reflects the ballots correctly (no need for Tally::timestamp_seconds to be updated if yes/no is unchanged) 2. ProposalData::ballots can only be changed by register_vote after proposal initialization 3. The process_proposal() call within the distritributed_rewards() should not cause tally change See merge request dfinity-lab/public/ic!12551
2 parents 2ced287 + 10dc344 commit 3e2c66c

File tree

4 files changed

+249
-59
lines changed

4 files changed

+249
-59
lines changed

rs/nns/governance/proto/ic_nns_governance/pb/v1/governance.proto

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,10 +1214,9 @@ message ProposalData {
12141214
// respect to rewards.
12151215
map<fixed64, Ballot> ballots = 6;
12161216

1217-
// Latest tally. Recomputed for open proposals, when proposals are
1218-
// processed. If the proposal is decided (not open), then the tally
1219-
// will never change again. (But the ballots may still change as
1220-
// neurons may vote after the proposal has been decided.)
1217+
// Latest tally. Recomputed for every vote. Even after the proposal has been
1218+
// decided, the latest_tally will still be updated based on the recent vote,
1219+
// until the voting deadline.
12211220
Tally latest_tally = 7;
12221221

12231222
// If specified: the timestamp when this proposal was adopted or

rs/nns/governance/src/gen/ic_nns_governance.pb.v1.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,10 +1617,9 @@ pub struct ProposalData {
16171617
/// respect to rewards.
16181618
#[prost(map = "fixed64, message", tag = "6")]
16191619
pub ballots: ::std::collections::HashMap<u64, Ballot>,
1620-
/// Latest tally. Recomputed for open proposals, when proposals are
1621-
/// processed. If the proposal is decided (not open), then the tally
1622-
/// will never change again. (But the ballots may still change as
1623-
/// neurons may vote after the proposal has been decided.)
1620+
/// Latest tally. Recomputed for every vote. Even after the proposal has been
1621+
/// decided, the latest_tally will still be updated based on the recent vote,
1622+
/// until the voting deadline.
16241623
#[prost(message, optional, tag = "7")]
16251624
pub latest_tally: ::core::option::Option<Tally>,
16261625
/// If specified: the timestamp when this proposal was adopted or

rs/nns/governance/src/governance.rs

Lines changed: 65 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4167,68 +4167,82 @@ impl Governance {
41674167
///
41684168
/// If a proposal is adopted but not executed, this method
41694169
/// attempts to execute it.
4170-
pub fn process_proposal(&mut self, pid: u64) {
4170+
pub fn process_proposal(&mut self, proposal_id: u64) {
41714171
let now_seconds = self.env.now();
41724172
// Due to Rust lifetime issues, we must extract a closure that
41734173
// computes the voting period from a topic before we borrow
41744174
// `self.proto` mutably.
41754175
let voting_period_seconds_fn = self.voting_period_seconds();
4176-
if let Some(p) = self.proto.proposals.get_mut(&pid) {
4177-
if p.status() != ProposalStatus::Open {
4178-
return;
4179-
}
4180-
let topic = p.topic();
4181-
let voting_period_seconds = voting_period_seconds_fn(topic);
4182-
// Recompute the tally here. It is imperative that only
4183-
// 'open' proposals have their tally recomputed. Votes may
4184-
// arrive after a decision has been made: such votes count
4185-
// for voting rewards, but shall not make it into the
4186-
// tally.
4187-
p.recompute_tally(now_seconds, voting_period_seconds);
41884176

4189-
if !p.can_make_decision(now_seconds, voting_period_seconds) {
4190-
return;
4191-
}
4192-
// This marks the proposal as no longer open.
4193-
p.decided_timestamp_seconds = now_seconds;
4194-
if !p.is_accepted() {
4195-
self.start_process_rejected_proposal(pid);
4177+
let proposal = match self.proto.proposals.get_mut(&proposal_id) {
4178+
Some(p) => p,
4179+
None => {
4180+
println!(
4181+
"{}Cannot find proposal {} when trying to process it.",
4182+
LOG_PREFIX, proposal_id
4183+
);
41964184
return;
41974185
}
4198-
// The proposal was adopted, return the rejection fee for non-ManageNeuron
4199-
// proposals.
4200-
if !p
4201-
.proposal
4202-
.as_ref()
4203-
.map(|x| x.is_manage_neuron())
4204-
.unwrap_or(false)
4205-
{
4206-
if let Some(nid) = &p.proposer {
4207-
if let Some(neuron) = self.proto.neurons.get_mut(&nid.id) {
4208-
if neuron.neuron_fees_e8s >= p.reject_cost_e8s {
4209-
neuron.neuron_fees_e8s -= p.reject_cost_e8s;
4210-
}
4186+
};
4187+
let topic = proposal.topic();
4188+
let voting_period_seconds = voting_period_seconds_fn(topic);
4189+
4190+
// Recompute the tally here. It should correctly reflect all votes,
4191+
// even the ones after the proposal has been decided. It's possible
4192+
// to have Open status while it does not accept votes anymore, since
4193+
// the status change happens below this point.
4194+
if proposal.status() == ProposalStatus::Open
4195+
|| proposal.accepts_vote(now_seconds, voting_period_seconds)
4196+
{
4197+
proposal.recompute_tally(now_seconds, voting_period_seconds);
4198+
}
4199+
4200+
if proposal.status() != ProposalStatus::Open {
4201+
return;
4202+
}
4203+
4204+
if !proposal.can_make_decision(now_seconds, voting_period_seconds) {
4205+
return;
4206+
}
4207+
// This marks the proposal as no longer open.
4208+
proposal.decided_timestamp_seconds = now_seconds;
4209+
if !proposal.is_accepted() {
4210+
self.start_process_rejected_proposal(proposal_id);
4211+
return;
4212+
}
4213+
// The proposal was adopted, return the rejection fee for non-ManageNeuron
4214+
// proposals.
4215+
if !proposal
4216+
.proposal
4217+
.as_ref()
4218+
.map(|x| x.is_manage_neuron())
4219+
.unwrap_or(false)
4220+
{
4221+
if let Some(nid) = &proposal.proposer {
4222+
if let Some(neuron) = self.proto.neurons.get_mut(&nid.id) {
4223+
if neuron.neuron_fees_e8s >= proposal.reject_cost_e8s {
4224+
neuron.neuron_fees_e8s -= proposal.reject_cost_e8s;
42114225
}
42124226
}
42134227
}
4214-
let original_total_community_fund_maturity_e8s_equivalent =
4215-
p.original_total_community_fund_maturity_e8s_equivalent;
4216-
if let Some(action) = p.proposal.as_ref().and_then(|x| x.action.clone()) {
4217-
// A yes decision as been made, execute the proposal!
4218-
self.start_proposal_execution(
4219-
pid,
4220-
&action,
4221-
original_total_community_fund_maturity_e8s_equivalent,
4222-
);
4223-
} else {
4224-
self.set_proposal_execution_status(
4225-
pid,
4226-
Err(GovernanceError::new_with_message(
4227-
ErrorType::PreconditionFailed,
4228-
"Proposal is missing.",
4229-
)),
4230-
);
4231-
}
4228+
}
4229+
let original_total_community_fund_maturity_e8s_equivalent =
4230+
proposal.original_total_community_fund_maturity_e8s_equivalent;
4231+
if let Some(action) = proposal.proposal.as_ref().and_then(|x| x.action.clone()) {
4232+
// A yes decision as been made, execute the proposal!
4233+
self.start_proposal_execution(
4234+
proposal_id,
4235+
&action,
4236+
original_total_community_fund_maturity_e8s_equivalent,
4237+
);
4238+
} else {
4239+
self.set_proposal_execution_status(
4240+
proposal_id,
4241+
Err(GovernanceError::new_with_message(
4242+
ErrorType::PreconditionFailed,
4243+
"Proposal is missing.",
4244+
)),
4245+
);
42324246
}
42334247
}
42344248

rs/nns/governance/tests/governance.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2295,6 +2295,184 @@ async fn test_invalid_proposals_fail() {
22952295
.unwrap();
22962296
}
22972297

2298+
fn get_current_voting_power(gov: &Governance, neuron_id: u64, now: u64) -> u64 {
2299+
gov.get_neuron(&NeuronId { id: neuron_id })
2300+
.unwrap()
2301+
.voting_power(now)
2302+
}
2303+
2304+
#[tokio::test]
2305+
async fn test_compute_tally_while_open() {
2306+
// Prepare the test with 2 neurons
2307+
let fake_driver = fake::FakeDriver::default();
2308+
let mut gov = Governance::new(
2309+
GovernanceProto {
2310+
wait_for_quiet_threshold_seconds: 5,
2311+
..fixture_two_neurons_second_is_bigger()
2312+
},
2313+
fake_driver.get_fake_env(),
2314+
fake_driver.get_fake_ledger(),
2315+
fake_driver.get_fake_cmc(),
2316+
);
2317+
2318+
// Make the proposal from the smaller neuron.
2319+
let pid = gov
2320+
.make_proposal(
2321+
&NeuronId { id: 1 },
2322+
&principal(1),
2323+
&Proposal {
2324+
title: Some("A Reasonable Title".to_string()),
2325+
summary: "proposal 1".to_string(),
2326+
action: Some(proposal::Action::Motion(Motion {
2327+
motion_text: "".to_string(),
2328+
})),
2329+
..Default::default()
2330+
},
2331+
)
2332+
.await
2333+
.unwrap();
2334+
2335+
// Tally should have the smaller neuron voting yes.
2336+
assert_eq!(
2337+
gov.get_proposal_data(pid).unwrap().latest_tally,
2338+
Some(Tally {
2339+
timestamp_seconds: fake_driver.now(),
2340+
no: 0,
2341+
yes: get_current_voting_power(&gov, 1, fake_driver.now()),
2342+
total: get_current_voting_power(&gov, 1, fake_driver.now())
2343+
+ get_current_voting_power(&gov, 2, fake_driver.now())
2344+
})
2345+
);
2346+
}
2347+
2348+
#[tokio::test]
2349+
async fn test_compute_tally_after_decided() {
2350+
// Prepare the test with 2 neurons
2351+
let fake_driver = fake::FakeDriver::default();
2352+
let mut gov = Governance::new(
2353+
GovernanceProto {
2354+
wait_for_quiet_threshold_seconds: 5,
2355+
..fixture_two_neurons_second_is_bigger()
2356+
},
2357+
fake_driver.get_fake_env(),
2358+
fake_driver.get_fake_ledger(),
2359+
fake_driver.get_fake_cmc(),
2360+
);
2361+
2362+
// Make the proposal from the larger neuron.
2363+
let pid = gov
2364+
.make_proposal(
2365+
&NeuronId { id: 2 },
2366+
&principal(2),
2367+
&Proposal {
2368+
title: Some("A Reasonable Title".to_string()),
2369+
summary: "proposal 1".to_string(),
2370+
action: Some(proposal::Action::Motion(Motion {
2371+
motion_text: "".to_string(),
2372+
})),
2373+
..Default::default()
2374+
},
2375+
)
2376+
.await
2377+
.unwrap();
2378+
2379+
// Tally should have the larger neuron voting yes, and the proposal is decided.
2380+
assert_eq!(
2381+
gov.get_proposal_data(pid).unwrap().latest_tally,
2382+
Some(Tally {
2383+
timestamp_seconds: fake_driver.now(),
2384+
no: 0,
2385+
yes: get_current_voting_power(&gov, 2, fake_driver.now()),
2386+
total: get_current_voting_power(&gov, 1, fake_driver.now())
2387+
+ get_current_voting_power(&gov, 2, fake_driver.now())
2388+
})
2389+
);
2390+
2391+
// Let the smaller neuron vote no.
2392+
fake::register_vote_assert_success(
2393+
&mut gov,
2394+
principal(1),
2395+
NeuronId { id: 1 },
2396+
ProposalId { id: 1 },
2397+
Vote::No,
2398+
);
2399+
2400+
// The tally should still be recomputed.
2401+
assert_eq!(
2402+
gov.get_proposal_data(pid).unwrap().latest_tally,
2403+
Some(Tally {
2404+
timestamp_seconds: fake_driver.now(),
2405+
no: get_current_voting_power(&gov, 1, fake_driver.now()),
2406+
yes: get_current_voting_power(&gov, 2, fake_driver.now()),
2407+
total: get_current_voting_power(&gov, 1, fake_driver.now())
2408+
+ get_current_voting_power(&gov, 2, fake_driver.now())
2409+
})
2410+
);
2411+
}
2412+
2413+
#[tokio::test]
2414+
async fn test_no_compute_tally_after_deadline() {
2415+
// Prepare the test with 2 neurons
2416+
let mut fake_driver = fake::FakeDriver::default();
2417+
let mut gov = Governance::new(
2418+
GovernanceProto {
2419+
wait_for_quiet_threshold_seconds: 5,
2420+
..fixture_two_neurons_second_is_bigger()
2421+
},
2422+
fake_driver.get_fake_env(),
2423+
fake_driver.get_fake_ledger(),
2424+
fake_driver.get_fake_cmc(),
2425+
);
2426+
2427+
// Make the proposal from the larger neuron and let the smaller neuron vote no.
2428+
let pid = gov
2429+
.make_proposal(
2430+
&NeuronId { id: 2 },
2431+
&principal(2),
2432+
&Proposal {
2433+
title: Some("A Reasonable Title".to_string()),
2434+
summary: "proposal 1".to_string(),
2435+
action: Some(proposal::Action::Motion(Motion {
2436+
motion_text: "".to_string(),
2437+
})),
2438+
..Default::default()
2439+
},
2440+
)
2441+
.await
2442+
.unwrap();
2443+
2444+
// Advance time past the deadline.
2445+
let previous_time = fake_driver.now();
2446+
fake_driver.advance_time_by(6);
2447+
2448+
// Attempt to cast another vote after deadline which should fail.
2449+
assert_matches!(
2450+
fake::register_vote(
2451+
&mut gov,
2452+
principal(1),
2453+
NeuronId { id: 1 },
2454+
ProposalId { id: 1 },
2455+
Vote::No,
2456+
).command,
2457+
Some(manage_neuron_response::Command::Error(err))
2458+
if err.error_type == ErrorType::PreconditionFailed as i32
2459+
);
2460+
2461+
// Simulate a heartbeat.
2462+
gov.run_periodic_tasks().now_or_never();
2463+
2464+
// The tally should not be recomputed after deadline because of heartbeat.
2465+
// This is important since computing the tally is expensive.
2466+
assert_eq!(
2467+
gov.get_proposal_data(pid)
2468+
.unwrap()
2469+
.latest_tally
2470+
.as_ref()
2471+
.unwrap()
2472+
.timestamp_seconds,
2473+
previous_time
2474+
);
2475+
}
22982476
/// In this scenario, the wait-for-quiet policy make that proposals last though
22992477
/// several reward periods.
23002478
///

0 commit comments

Comments
 (0)