Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-repair/src/fence.rs

//! **`fence_primary`**: the old primary takes no client write and ships no copy,
//! so there are never two writable boxes (the split-brain a recharge would
//! otherwise open). Two existing, idempotent RPCs, in the order abdication uses
//! them: `Mirror.SetRole(STANDBY)` (refuse client pushes), then
//! `Mirror.DemoteReplication` (clear the roster), then the role read back.

#![allow(async_fn_in_trait)]

use serde::{Deserialize, Serialize};

use crate::plan::{refuse, Refusal, RefusalCode};
use crate::pump::Fault;

/// The role word a fenced box answers.
pub const STANDBY: &str = "standby";

/// **The box to fence**, as the host reaches it (signed with an admin key).
pub trait Fenceable {
    fn addr(&self) -> String;
    /// `Mirror.SetRole(STANDBY)`: the role the box answers after it.
    async fn set_standby(&self) -> Result<String, Fault>;
    /// `Mirror.DemoteReplication`: the targets left (0 = demoted).
    async fn demote(&self) -> Result<usize, Fault>;
}

/// What a fence did.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fenced {
    pub primary: String,
    /// The box answered and is now a standby with no roster.
    pub fenced: bool,
    pub role: String,
    pub targets_left: usize,
    /// The epoch the order named (0 = a local fence).
    pub epoch: u64,
    pub detail: String,
}

/// **Fence `primary`.** `Err(Unreachable)` when the box does not answer: a box that
/// answers nothing takes no write either, but only a cloud stop PROVES it — the
/// caller says so (`CloudProvider.stop` is monetize's lever, never this crate's).
pub async fn fence_primary<F: Fenceable>(primary: &F, epoch: u64) -> Result<Fenced, Refusal> {
    let addr = primary.addr();
    let unreachable = |f: Fault| {
        refuse(
            RefusalCode::Unreachable,
            format!(
                "the primary at {addr} could not be fenced ({f}); if it is down it takes no write, but only stopping the machine proves it — stop it before a recharge"
            ),
        )
    };
    let role = primary.set_standby().await.map_err(unreachable)?;
    let targets_left = primary.demote().await.map_err(unreachable)?;
    let fenced = role.eq_ignore_ascii_case(STANDBY) && targets_left == 0;
    let detail = if fenced {
        format!("the box at {addr} is a standby with no replication roster: it refuses client pushes and ships nothing")
    } else {
        format!("the box at {addr} answers role `{role}` with {targets_left} target(s) left after the fence: NOT fenced")
    };
    if !fenced {
        return Err(refuse(RefusalCode::NotFenced, detail));
    }
    Ok(Fenced {
        primary: addr,
        fenced,
        role,
        targets_left,
        epoch,
        detail,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pump::block_on_ready;
    use std::sync::Mutex;

    struct Box_ {
        up: bool,
        role: Mutex<String>,
        calls: Mutex<Vec<&'static str>>,
    }

    impl Fenceable for Box_ {
        fn addr(&self) -> String {
            "main:1".into()
        }
        async fn set_standby(&self) -> Result<String, Fault> {
            self.calls.lock().unwrap().push("set_role");
            if !self.up {
                return Err(Fault::new("unavailable", "connection refused"));
            }
            *self.role.lock().unwrap() = STANDBY.into();
            Ok(STANDBY.into())
        }
        async fn demote(&self) -> Result<usize, Fault> {
            self.calls.lock().unwrap().push("demote");
            Ok(0)
        }
    }

    #[test]
    fn a_fence_freezes_writes_first_then_clears_the_roster() {
        let b = Box_ {
            up: true,
            role: Mutex::new("primary".into()),
            calls: Mutex::new(vec![]),
        };
        let f = block_on_ready(fence_primary(&b, 5)).unwrap();
        assert!(f.fenced && f.role == STANDBY && f.epoch == 5);
        assert_eq!(*b.calls.lock().unwrap(), vec!["set_role", "demote"]);
    }

    #[test]
    fn an_unreachable_primary_is_not_called_fenced() {
        let b = Box_ {
            up: false,
            role: Mutex::new("primary".into()),
            calls: Mutex::new(vec![]),
        };
        let e = block_on_ready(fence_primary(&b, 1)).unwrap_err();
        assert_eq!(e.code, RefusalCode::Unreachable);
        assert!(e.message.contains("stop it"), "{e}");
    }
}