use crate::{marshal::core::durability::Durable as _, types::Round};
use commonware_cryptography::Digest;
use commonware_macros::select;
use commonware_runtime::Handle;
use commonware_utils::{
channel::{fallible::OneshotExt, oneshot},
sync::Mutex,
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use tracing::debug;
pub(crate) struct Staged<B> {
pub(crate) block: B,
pub(crate) ack: oneshot::Sender<Handle<()>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GateOutcome {
Ready(bool),
Recover,
}
struct Inner<D: Digest, B> {
certifications: BTreeMap<(Round, D), oneshot::Receiver<GateOutcome>>,
proposals: BTreeMap<(Round, D), Staged<B>>,
sent: BTreeSet<(Round, D)>,
decided: Option<Round>,
}
#[derive(Clone)]
pub(crate) struct Gates<D: Digest, B> {
inner: Arc<Mutex<Inner<D, B>>>,
}
impl<D: Digest, B: Clone> Default for Gates<D, B> {
fn default() -> Self {
Self::new()
}
}
impl<D: Digest, B: Clone> Gates<D, B> {
pub(crate) fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(Inner {
certifications: BTreeMap::new(),
proposals: BTreeMap::new(),
sent: BTreeSet::new(),
decided: None,
})),
}
}
pub(crate) fn insert(&self, round: Round, digest: D, task: oneshot::Receiver<GateOutcome>) {
self.inner
.lock()
.certifications
.insert((round, digest), task);
}
#[cfg(test)]
pub(crate) fn take(&self, round: Round, digest: D) -> Option<oneshot::Receiver<GateOutcome>> {
self.inner.lock().certifications.remove(&(round, digest))
}
pub(crate) fn take_staged(&self, round: Round, digest: D) -> Option<(Staged<B>, bool)> {
let mut inner = self.inner.lock();
let staged = inner.proposals.remove(&(round, digest))?;
let sent = !inner.sent.insert((round, digest));
Some((staged, sent))
}
pub(crate) fn send_staged(&self, round: Round, digest: D) -> Option<B> {
let mut inner = self.inner.lock();
let block = inner.proposals.get(&(round, digest))?.block.clone();
inner.sent.insert((round, digest)).then_some(block)
}
pub(crate) fn claim(
&self,
round: Round,
digest: D,
persist: impl FnOnce(B, oneshot::Sender<Handle<()>>),
) -> Option<oneshot::Receiver<GateOutcome>> {
let (gate, staged) = {
let mut inner = self.inner.lock();
(
inner.certifications.remove(&(round, digest)),
inner.proposals.remove(&(round, digest)),
)
};
if let Some(Staged { block, ack }) = staged {
persist(block, ack);
}
gate
}
pub(crate) fn retain_after(&self, finalized_round: Round) {
let mut inner = self.inner.lock();
inner.decided = inner.decided.max(Some(finalized_round));
inner
.certifications
.retain(|(round, _), _| *round > finalized_round);
inner
.proposals
.retain(|(round, _), _| *round > finalized_round);
inner.sent.retain(|(round, _)| *round > finalized_round);
}
pub(crate) async fn stage(
&self,
round: Round,
digest: D,
block: B,
publish: impl FnOnce(D),
name: &'static str,
) {
let (durable_tx, durable_rx) = oneshot::channel();
let (ack, persist) = oneshot::channel();
{
let mut inner = self.inner.lock();
inner.certifications.insert((round, digest), durable_rx);
if inner.decided.is_some_and(|decided| round <= decided) {
inner.sent.insert((round, digest));
}
inner
.proposals
.insert((round, digest), Staged { block, ack });
}
publish(digest);
let Ok(handle) = persist.await else {
return;
};
if !handle.durable(round, name).await {
return;
}
durable_tx.send_lossy(GateOutcome::Ready(true));
debug!(?round, ?digest, name, "block durable");
}
}
pub(crate) const fn resolve(verdict: Option<bool>, durable: bool) -> Option<bool> {
match verdict {
Some(true) if !durable => None,
other => other,
}
}
pub(crate) async fn forward<T, U>(
mut output: oneshot::Sender<T>,
input: oneshot::Receiver<U>,
map: impl FnOnce(U) -> Option<T>,
) {
let result = select! {
_ = output.closed() => return,
result = input => result,
};
if let Ok(value) = result
&& let Some(value) = map(value)
{
output.send_lossy(value);
}
}
pub(crate) async fn drive<D, F>(
mut tx: oneshot::Sender<bool>,
task: oneshot::Receiver<GateOutcome>,
round: Round,
digest: D,
fallback: F,
) where
D: Digest,
F: FnOnce() -> oneshot::Receiver<bool>,
{
let result = select! {
_ = tx.closed() => {
debug!(
reason = "consensus dropped receiver",
"skipping certification"
);
return;
},
result = task => result,
};
match result {
Ok(GateOutcome::Ready(result)) => {
tx.send_lossy(result);
}
Ok(GateOutcome::Recover) | Err(_) => {
debug!(
?round,
?digest,
"certification gate requires recovery, falling back to embedded context"
);
let fallback = fallback();
let result = select! {
_ = tx.closed() => {
debug!(
reason = "consensus dropped receiver",
"skipping certification"
);
return;
},
result = fallback => result,
};
if let Ok(result) = result {
tx.send_lossy(result);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Epoch, View};
use commonware_cryptography::{Hasher, Sha256, sha256::Digest as Sha256Digest};
use commonware_runtime::{Runner, Spawner, Supervisor, deterministic};
use futures::FutureExt as _;
type D = Sha256Digest;
type TestGates = Gates<D, u64>;
fn round(view: u64) -> Round {
Round::new(Epoch::zero(), View::new(view))
}
#[test]
fn test_retain_after_releases_in_deterministic_order() {
fn run(seed: u64) -> String {
deterministic::Runner::seeded(seed).start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"candidate"]);
let mut tasks = Vec::new();
for view in 1..=16 {
let (tx, rx) = oneshot::channel();
let gates = gates.clone();
tasks.push(context.child("stage").spawn(move |_| async move {
gates
.stage(
round(view),
digest,
view,
|digest| {
tx.send_lossy(digest);
},
"test",
)
.await;
}));
rx.await.expect("digest published");
}
gates.retain_after(round(16));
for task in tasks {
task.await.expect("stage task completes");
}
context.auditor().state()
})
}
for seed in 0..8 {
assert_eq!(run(seed), run(seed), "seed {seed}");
}
}
fn pending_task() -> oneshot::Receiver<GateOutcome> {
let (_tx, rx) = oneshot::channel();
rx
}
fn no_fallback() -> oneshot::Receiver<bool> {
unreachable!("certification must not fall back")
}
#[test]
fn test_insert_and_take_returns_task() {
let tasks = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
tasks.insert(round(1), digest, pending_task());
assert!(tasks.take(round(1), digest).is_some());
assert!(
tasks.take(round(1), digest).is_none(),
"taking twice should yield None"
);
}
#[test]
fn test_take_absent_key_is_none() {
let tasks = TestGates::new();
assert!(tasks.take(round(1), Sha256::hash(&[b"missing"])).is_none());
}
#[test]
fn test_take_distinguishes_rounds_and_digests() {
let tasks = TestGates::new();
let digest_a = Sha256::hash(&[b"a"]);
let digest_b = Sha256::hash(&[b"b"]);
tasks.insert(round(1), digest_a, pending_task());
tasks.insert(round(2), digest_a, pending_task());
tasks.insert(round(1), digest_b, pending_task());
assert!(tasks.take(round(1), digest_a).is_some());
assert!(tasks.take(round(2), digest_a).is_some());
assert!(tasks.take(round(1), digest_b).is_some());
}
#[test]
fn test_retain_after_drops_at_and_below_boundary() {
let tasks = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
tasks.insert(round(1), digest, pending_task());
tasks.insert(round(2), digest, pending_task());
tasks.insert(round(3), digest, pending_task());
tasks.retain_after(round(2));
assert!(
tasks.take(round(1), digest).is_none(),
"tasks strictly below boundary should be dropped"
);
assert!(
tasks.take(round(2), digest).is_none(),
"tasks at boundary should be dropped"
);
assert!(
tasks.take(round(3), digest).is_some(),
"tasks strictly above boundary should be retained"
);
}
#[test]
fn test_retain_after_spans_epochs() {
let tasks = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let early = Round::new(Epoch::zero(), View::new(100));
let late = Round::new(Epoch::new(1), View::zero());
tasks.insert(early, digest, pending_task());
tasks.insert(late, digest, pending_task());
tasks.retain_after(early);
assert!(
tasks.take(early, digest).is_none(),
"task at boundary must be dropped"
);
assert!(
tasks.take(late, digest).is_some(),
"task in later epoch must outlive an earlier boundary"
);
}
#[test]
fn test_retain_after_empty_map_is_noop() {
let tasks = TestGates::new();
tasks.retain_after(round(5));
assert!(tasks.take(round(5), Sha256::hash(&[b"x"])).is_none());
}
#[test]
fn test_default_matches_new() {
let default = <TestGates as Default>::default();
let digest = Sha256::hash(&[b"block"]);
default.insert(round(1), digest, pending_task());
assert!(default.take(round(1), digest).is_some());
}
#[test]
fn test_resolve() {
assert_eq!(resolve(None, true), None);
assert_eq!(resolve(None, false), None);
assert_eq!(resolve(Some(false), false), Some(false));
assert_eq!(resolve(Some(false), true), Some(false));
assert_eq!(resolve(Some(true), true), Some(true));
assert_eq!(resolve(Some(true), false), None);
}
#[test]
fn test_forward_cancels_input_when_output_closes() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let (input_tx, input_rx) = oneshot::channel::<bool>();
let (output_tx, output_rx) = oneshot::channel::<bool>();
drop(output_rx);
forward(output_tx, input_rx, Some).await;
assert!(input_tx.is_closed());
});
}
#[test]
fn test_forward_cancels_in_flight_input_when_output_closes() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let (input_tx, input_rx) = oneshot::channel::<bool>();
let (output_tx, output_rx) = oneshot::channel::<bool>();
let (started_tx, started_rx) = oneshot::channel();
let forwarder = context.child("forwarder").spawn(|_| async move {
started_tx.send_lossy(());
forward(output_tx, input_rx, Some).await;
});
started_rx.await.expect("forwarder should start");
assert!(!input_tx.is_closed());
drop(output_rx);
forwarder.await.expect("forwarder should stop");
assert!(input_tx.is_closed());
});
}
#[test]
fn test_drive_adopts_ready_verdict_without_fallback() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
for verdict in [true, false] {
let digest = Sha256::hash(&[b"block"]);
let (task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
task_tx.send_lossy(GateOutcome::Ready(verdict));
drive(tx, task_rx, round(1), digest, no_fallback).await;
assert_eq!(rx.await.expect("verdict published"), verdict);
}
});
}
#[test]
fn test_drive_recover_publishes_fallback_verdict() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let digest = Sha256::hash(&[b"block"]);
let (task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
task_tx.send_lossy(GateOutcome::Recover);
let (fallback_tx, fallback_rx) = oneshot::channel();
fallback_tx.send_lossy(true);
drive(tx, task_rx, round(1), digest, || fallback_rx).await;
assert!(rx.await.expect("fallback verdict published"));
});
}
#[test]
fn test_drive_dropped_sender_publishes_fallback_verdict() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let digest = Sha256::hash(&[b"block"]);
let (task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
drop(task_tx);
let (fallback_tx, fallback_rx) = oneshot::channel();
fallback_tx.send_lossy(false);
drive(tx, task_rx, round(1), digest, || fallback_rx).await;
assert!(!rx.await.expect("fallback verdict published"));
});
}
#[test]
fn test_drive_abandons_when_consensus_receiver_dropped() {
let runner = deterministic::Runner::default();
runner.start(|_| async move {
let digest = Sha256::hash(&[b"block"]);
let (_task_tx, task_rx) = oneshot::channel();
let (tx, rx) = oneshot::channel();
drop(rx);
drive(tx, task_rx, round(1), digest, no_fallback).await;
});
}
#[test]
fn test_stage_handshake() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let (tx, rx) = oneshot::channel();
context.spawn({
let gates = gates.clone();
move |_| async move {
gates
.stage(
round(1),
digest,
7,
|digest| {
tx.send_lossy(digest);
},
"test",
)
.await;
}
});
assert_eq!(rx.await.expect("digest published"), digest);
let gate = gates.take(round(1), digest).expect("gate registered");
let (Staged { block, ack }, sent) =
gates.take_staged(round(1), digest).expect("block staged");
assert_eq!(block, 7);
assert!(!sent, "a staged block starts unsent");
assert!(
gates.take_staged(round(1), digest).is_none(),
"taking twice should yield None"
);
ack.send_lossy(Handle::ready(Ok(())));
assert_eq!(gate.await.expect("gate resolved"), GateOutcome::Ready(true));
});
}
#[test]
fn test_claim_takes_gate_with_staged_proposal() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let (tx, rx) = oneshot::channel();
let stage = context.child("stage").spawn({
let gates = gates.clone();
move |_| async move {
gates
.stage(
round(1),
digest,
7,
|digest| {
tx.send_lossy(digest);
},
"test",
)
.await;
}
});
assert_eq!(rx.await.expect("digest published"), digest);
let mut restage = Box::pin(gates.stage(round(1), digest, 7, |_| {}, "test"));
let gate = gates
.claim(round(1), digest, |block, ack| {
assert_eq!(block, 7);
assert!((&mut restage).now_or_never().is_none());
ack.send_lossy(Handle::ready(Ok(())));
})
.expect("gate registered");
stage.await.expect("first stage completes");
assert_eq!(
gate.now_or_never()
.map(|outcome| outcome.expect("gate resolved")),
Some(GateOutcome::Ready(true)),
"the claimed gate must belong to the persisted proposal",
);
});
}
#[test]
fn test_send_staged_marks_sent_and_keeps_entry() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
for send in [true, false] {
let (tx, rx) = oneshot::channel();
context.child("stage").spawn({
let gates = gates.clone();
move |_| async move {
gates
.stage(
round(1),
digest,
7,
|digest| {
tx.send_lossy(digest);
},
"test",
)
.await;
}
});
assert_eq!(rx.await.expect("digest published"), digest);
if send {
assert!(
gates
.send_staged(round(1), Sha256::hash(&[b"other"]))
.is_none()
);
let block = gates.send_staged(round(1), digest).expect("block staged");
assert_eq!(block, 7);
}
}
assert!(
gates.send_staged(round(1), digest).is_none(),
"a sent block must not be sent again"
);
let (_, sent) = gates.take_staged(round(1), digest).expect("still staged");
assert!(sent, "a sent block must stay marked sent when staged again");
});
}
#[test]
fn test_restage_after_take_or_claim_stays_sent() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let stage = |view: u64| {
let gates = gates.clone();
let (tx, rx) = oneshot::channel();
context.child("stage").spawn(move |_| async move {
gates
.stage(
round(view),
digest,
7,
|digest| {
tx.send_lossy(digest);
},
"test",
)
.await;
});
rx
};
assert_eq!(stage(1).await.expect("digest published"), digest);
let (_, sent) = gates.take_staged(round(1), digest).expect("staged");
assert!(!sent);
assert_eq!(stage(1).await.expect("digest published"), digest);
assert!(
gates.send_staged(round(1), digest).is_none(),
"a block sent at lock-in must not be sent again"
);
let (_, sent) = gates.take_staged(round(1), digest).expect("staged again");
assert!(sent, "a block sent at lock-in must be taken as sent");
assert_eq!(stage(2).await.expect("digest published"), digest);
assert!(gates.send_staged(round(2), digest).is_some());
gates.claim(round(2), digest, |_, ack| {
ack.send_lossy(Handle::ready(Ok(())));
});
assert_eq!(stage(2).await.expect("digest published"), digest);
assert!(
gates.send_staged(round(2), digest).is_none(),
"a claimed block sent early must not be sent again"
);
let (_, sent) = gates.take_staged(round(2), digest).expect("staged again");
assert!(sent, "a claimed block sent early must be taken as sent");
});
}
#[test]
fn test_retain_after_cutoff_suppresses_sends() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
gates.retain_after(round(2));
gates.retain_after(round(1));
for view in [2, 3] {
let (tx, rx) = oneshot::channel();
context.child("stage").spawn({
let gates = gates.clone();
move |_| async move {
gates
.stage(
round(view),
digest,
7,
|digest| {
tx.send_lossy(digest);
},
"test",
)
.await;
}
});
assert_eq!(rx.await.expect("digest published"), digest);
}
assert!(
gates.send_staged(round(2), digest).is_none(),
"a block staged at or below the cutoff must not be sent"
);
let (_, sent) = gates.take_staged(round(2), digest).expect("staged");
assert!(
sent,
"a block staged at or below the cutoff must be taken as sent"
);
assert!(gates.send_staged(round(3), digest).is_some());
});
}
#[test]
fn test_retain_after_drops_staged_and_abandons_handshake() {
let runner = deterministic::Runner::default();
runner.start(|context| async move {
let gates = TestGates::new();
let digest = Sha256::hash(&[b"block"]);
let (tx, rx) = oneshot::channel();
context.spawn({
let gates = gates.clone();
move |_| async move {
gates
.stage(
round(1),
digest,
7,
|digest| {
tx.send_lossy(digest);
},
"test",
)
.await;
}
});
assert_eq!(rx.await.expect("digest published"), digest);
let gate = gates.take(round(1), digest).expect("gate registered");
assert!(gates.send_staged(round(1), digest).is_some());
gates.retain_after(round(1));
assert!(gates.take_staged(round(1), digest).is_none());
assert!(
gates.inner.lock().sent.is_empty(),
"pruning must discard sent marks"
);
assert!(gate.await.is_err(), "gate must be abandoned, not resolved");
});
}
}