From 1df85d13453b717dbee8f4ebf47b87de4f647866 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 24 Oct 2026 13:09:22 +0200 Subject: introduce majority voting on remakes --- src/remake/api.rs | 69 ++++++++++++++++++++++--------------------------------- 1 file changed, 27 insertions(+), 42 deletions(-) (limited to 'src/remake/api.rs') diff --git a/src/remake/api.rs b/src/remake/api.rs index 8467429..de8166c 100644 --- a/src/remake/api.rs +++ b/src/remake/api.rs @@ -3,8 +3,9 @@ //! * `POST /admin/remakes` proposes a remake of the current epoch (`POST //! /admin/compaction` is a shortcut, kept from before). The receiving //! node becomes its coordinator. `202` with the report, or with `?wait=true` the final -//! report once decided. A dropped request does not abort anything. `?force=true` -//! accepts a `settleSecs` below the dormancy threshold (tests, single-site swarms). +//! report once decided. A dropped request does not abort anything. The remake +//! commits as soon as a majority of its electorate voted for it (`vote`), and aborts +//! once that is out of reach or `settleSecs` passed without it. //! * `GET /admin/remakes` lists all remakes. //! * `GET /admin/remakes/{id}` reports one; `?wait=true` long-polls until decided //! (bounded by the proposal's own expiry). @@ -21,8 +22,8 @@ //! "archiveRetention": {"epochs": 2, "seconds": 2592000}, //! "settleSecs": 480, "switchDelaySecs": 60 }` //! -//! `?force=true` accepts `settleSecs` below the dormancy threshold and `switchDelaySecs` -//! below one keepalive interval: for tests and debugging, not for real swarms. +//! `?force=true` accepts a `switchDelaySecs` below one keepalive interval: for tests +//! and debugging, not for real swarms. //! //! `kind` is `compact` (no spec), `replace`, whose spec is //! `{ "document": {…store root…}, "rules": [{"from": "a", "to": "b"}, {"from": "c", "drop": true}] }` @@ -60,9 +61,9 @@ use uuid::Uuid; use super::coordinator; use super::local::LocalStatus; use super::records::{ - self, AbortRequest, EpochRecord, Phase, Policy, Proposal, Record, Retention, unix_millis, + self, AbortRequest, EpochRecord, Phase, Policy, Proposal, Record, Retention, Vote, unix_millis, }; -use super::{Host, kind}; +use super::{Host, kind, vote}; use crate::document::store::StoreError; #[derive(Debug, Default, Deserialize)] @@ -76,7 +77,7 @@ pub(crate) struct WaitQuery { pub(crate) struct StartQuery { #[serde(default)] wait: bool, - /// Allows a settle period shorter than the dormancy threshold. + /// Allows a switch delay shorter than one keepalive interval. #[serde(default)] force: bool, // Only for an import uploaded as `application/octet-stream`, which has no JSON @@ -145,6 +146,7 @@ struct StartRequest { base_heads: Option>, replay_window: Option, archive_retention: Option, + /// How long votes are collected; twice the dormancy threshold if absent. settle_secs: Option, /// How long nodes wait after seeing the commit before switching; one keepalive /// interval if absent. Shorter only with `?force=true`. @@ -354,27 +356,13 @@ async fn start_kind( } }; + // The voting period. No lower bound: the majority protects against competitors, + // a short period only risks an abort for lack of votes. let dormant_after = host.dormant_after(); let settle = request .settle_secs .map(Duration::from_secs) .unwrap_or(dormant_after * 2); - if settle < dormant_after && query.force { - warn!( - settle_secs = settle.as_secs_f64(), - dormant_after_secs = dormant_after.as_secs(), - "Forced settle period shorter than the dormancy threshold; a partition may go unnoticed" - ); - } else if settle < dormant_after { - return error_response( - StatusCode::BAD_REQUEST, - format!( - "settleSecs must be at least {} (the dormancy threshold), so a partition is detected before deciding; add ?force=true to override", - dormant_after.as_secs() - ), - None, - ); - } // The delay lets the commit reach every node through ordinary sync before any of // them stops syncing the old epoch. @@ -401,24 +389,6 @@ async fn start_kind( _ => {} } - // Fail fast instead of proposing something that will abort anyway. - let peers = host.peer_summaries().await; - let dormant: Vec = peers - .iter() - .filter(|peer| !peer.active) - .map(|peer| peer.id.clone()) - .collect(); - if !dormant.is_empty() { - return error_response( - StatusCode::CONFLICT, - "dormant peers block remake; forget them first (DELETE /peers/{pk})", - Some(json!({ "dormantPeers": dormant })), - ); - } - - let mut peers_considered: Vec = peers.into_iter().map(|peer| peer.id).collect(); - peers_considered.sort(); - let now = unix_millis(); let settle_ms = settle.as_millis() as u64; let id = Uuid::new_v4().to_string(); @@ -440,7 +410,9 @@ async fn start_kind( proposed_at: now, settle_until: now + settle_ms, switch_delay_ms: switch_delay.map(|delay| delay.as_millis() as u64), - peers_considered, + // Filled in when the proposal is written, from the document at that moment. + electorate: Vec::new(), + quorum: 0, kind: kind.name().to_owned(), content: prepared.content, translation: prepared.translation, @@ -524,6 +496,8 @@ async fn propose( grace: u64, ) -> Result { let record_id = id.clone(); + let electorate_host = host.clone(); + let me = host.node_id(); let record = host .commit_doc(move |doc| -> Result { if let Some((open, _)) = records::load_all(doc).into_iter().find(|(_, record)| { @@ -551,11 +525,22 @@ async fn propose( heads.sort(); proposal.base_heads = heads; + // We vote for our own remake, so we are always in the electorate. + let mut electorate = electorate_host.electorate(doc, now); + if let Err(position) = electorate.binary_search(&me) { + electorate.insert(position, me.clone()); + } + proposal.quorum = vote::quorum(electorate.len()); + proposal.electorate = electorate; + records::write_proposal(doc, &record_id, &proposal)?; + let own_vote = Vote { at: now }; + records::write_vote(doc, &record_id, &me, &own_vote)?; Ok(Record { proposal, decision: None, abort_requested: None, + votes: [(me, own_vote)].into(), }) }) .await?; -- cgit v1.3.1