//! Stateful application that manages the pending-tip DAG of merkleized batches on behalf of an [`Application`].
//!
//! The [`Stateful`] actor is split into two control loops:
//! - [`Syncing`] manages the state sync process.
//! - [`Processing`] manages the pending-tip DAG and drives the inner application.
use crate::stateful::{
Application,
actor::{
core::{mailbox::Message, processing::Processing, syncing::Syncing},
metrics::Metrics as StatefulMetrics,
processor::{PendingSyncTargets, Processor, Pruning},
syncer::{self, SyncPlan, SyncResult},
},
db::{AttachableResolverSet, DatabaseSet, StateSyncSet, SyncEngineConfig},
};
use commonware_actor::mailbox::{self as actor_mailbox};
use commonware_consensus::{
marshal::{
ancestry::BlockProvider,
core::{Floor, Mailbox as MarshalMailbox, Variant},
},
simplex::types::Finalization,
};
use commonware_cryptography::{Digestible, certificate::Scheme};
use commonware_runtime::{ContextCell, Handle, Spawner, spawn_cell, telemetry::metrics::GaugeExt};
use commonware_storage::Context;
use commonware_utils::channel::oneshot;
use futures::join;
use rand_core::Rng;
use std::num::NonZeroUsize;
mod mailbox;
pub use mailbox::Mailbox;
pub(super) use mailbox::Verification;
mod processing;
mod syncing;
mod verifications;
type BlockDigest = <>::Block as Digestible>::Digest;
/// Periodic pruning configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PruneConfig {
/// Finalized blocks between database and marshal pruning attempts.
///
/// Stateful selects a random phase within the interval when it starts. This controls only how
/// often pruning runs, not how much history is retained.
pub maintenance_interval: NonZeroUsize,
/// Finalized blocks to retain in marshal beyond its acknowledgement window plus one.
///
/// This should generally be set to a large enough number of blocks to facilitate downtime
/// on a validator that has completed state sync. If marshal retains too few blocks, a rebooted
/// node may fail to recover due to peers being unable to serve the blocks it needs to catch up.
pub retained_marshal_blocks: usize,
/// Finalized blocks' worth of operations to retain in QMDB beyond marshal's
/// acknowledgement window plus one.
///
/// This value is generally safe to set to 0, as QMDB operations below the active range are only
/// needed to serve state sync requests for lagging peers. Some network topologies may benefit from
/// a non-zero value here to provide a larger buffer for serving state sync requests during periods
/// of instability.
pub retained_qmdb_blocks: usize,
}
impl PruneConfig {
/// Ensure marshal is never pruned more aggressively than QMDB.
pub const fn assert_valid(self) {
assert!(
self.retained_marshal_blocks >= self.retained_qmdb_blocks,
"marshal must retain at least as many blocks as QMDB",
);
}
}
/// Configuration for constructing a [`Stateful`] application.
pub struct Config
where
E: Rng + Spawner + Context,
A: Application,
S: Scheme,
V: Variant,
{
/// The inner application that drives state transitions.
pub application: A,
/// Configuration used to construct the database set.
pub db_config: >::Config,
/// Provider cloned into each proposal.
pub provider: A::Provider,
/// Marshal mailbox and the durable floor returned with it during initialization.
pub marshal: (MarshalMailbox, Floor),
/// Capacity of the stateful actor mailbox channel.
pub mailbox_size: NonZeroUsize,
/// Startup plan loaded via [`SyncPlan::init`], optionally augmented with
/// a finalized floor via [`SyncPlan::with_floor`]. Carries the durable
/// metadata handle and the startup decision shared with marshal.
pub plan: SyncPlan,
/// Resolver(s) for state sync fetches and post-bootstrap serving.
pub resolvers: R,
/// Sync engine tuning knobs.
pub sync_config: SyncEngineConfig,
/// Periodic database and marshal pruning configuration.
///
/// When enabled, glue retains `max_pending_acks + 1` finalized blocks plus
/// the configured retained block windows before pruning. Marshal must retain
/// at least as many blocks as QMDB.
pub prune_config: Option,
}
/// Stateful application that manages the pending-tip DAG of merkleized
/// batches on behalf of an [`Application`], implementing the consensus
/// application and verifying traits.
pub struct Stateful
where
E: Rng + Spawner + Context,
A: Application,
S: Scheme,
V: Variant,
{
/// Runtime context providing RNG, task spawning, metrics, and clock.
context: ContextCell,
/// The receiver for messages.
mailbox: actor_mailbox::Receiver>,
/// The inner application that drives state transitions.
application: A,
/// Provider cloned into each proposal.
provider: A::Provider,
/// Marshal mailbox and the durable floor returned with it during initialization.
marshal: (MarshalMailbox, Floor),
/// Configuration used to initialize the database set at startup.
db_config: >::Config,
/// Startup plan carrying the metadata handle and floor decision.
plan: SyncPlan,
/// Resolver(s) for state sync fetches and post-bootstrap serving.
resolvers: R,
/// Sync engine tuning knobs.
sync_config: SyncEngineConfig,
/// Periodic pruning state.
pruning: Option>>,
}
impl Stateful
where
E: Rng + Spawner + Context,
A: Application,
A::Databases: StateSyncSet>,
S: Scheme,
V: Variant,
R: AttachableResolverSet,
MarshalMailbox: BlockProvider,
{
/// Construct a [`Stateful`] actor and its [`Mailbox`].
///
/// This only wires dependencies and allocates the mailbox. The actor does
/// not process messages until [`Stateful::start`] is called.
pub fn init(mut context: E, config: Config) -> (Self, Mailbox) {
let pruning = config.prune_config.map(|prune_config| {
Pruning::random(
prune_config,
config.marshal.0.max_pending_acks(),
&mut context,
)
});
let (sender, mailbox) = actor_mailbox::new(context.child("mailbox"), config.mailbox_size);
(
Self {
context: ContextCell::new(context),
mailbox,
application: config.application,
provider: config.provider,
marshal: config.marshal,
db_config: config.db_config,
plan: config.plan,
resolvers: config.resolvers,
sync_config: config.sync_config,
pruning,
},
Mailbox::new(sender),
)
}
pub fn start(mut self) -> Handle<()> {
spawn_cell!(self.context, self.run())
}
async fn run(self) {
if let Some(floor) = self.plan.floor().cloned() {
self.start_state_sync(floor).await;
} else if self.plan.requires_state_sync_floor() {
panic!("interrupted state sync is missing its persisted floor");
} else {
self.start_from_marshal().await;
}
}
/// Starts the application in [`Syncing`] mode, kicking off a state sync process
/// towards the finalized floor specified in the [`SyncPlan`].
async fn start_state_sync(self, finalization: Finalization) {
let (marshal, floor) = self.marshal;
let metrics = StatefulMetrics::new(self.context.as_present());
let sync_metadata = self
.plan
.into_sync_metadata()
.begin_sync(finalization.clone())
.await;
let (sync_complete, sync_completed) = oneshot::channel();
let (syncer, syncer_mailbox) = syncer::Syncer::new(syncer::Config {
context: self.context.child("syncer"),
db_config: self.db_config,
sync_config: self.sync_config,
resolvers: self.resolvers.clone(),
finalization,
marshal: (marshal.clone(), floor),
sync_complete,
});
let syncing = Syncing {
context: self.context,
mailbox: self.mailbox,
application: self.application,
provider: self.provider,
marshal,
sync_metadata,
syncer: syncer_mailbox,
deferred_verifications: Vec::new(),
database_subscribers: Vec::new(),
artifact: None,
resolvers: self.resolvers,
sync_completed,
pending_finalizations: Default::default(),
pruning: self.pruning,
metrics,
};
let _ = join!(syncer.start(), syncing.start());
}
/// Starts the application by initializing the database set at marshal's current floor.
async fn start_from_marshal(self) {
let (marshal, _) = self.marshal;
let syncer::StartupResult {
sync: SyncResult { databases, anchor },
skip_finalized_until,
} = syncer::init_databases_from_marshal::(
self.context.as_present(),
&marshal,
self.db_config,
self.plan.into_sync_metadata(),
)
.await;
// Attach the resolvers to the initialized databases before starting the processor,
// so that this instance can serve peers database operations and proofs. The
// resolver handles can be dropped after this: serving runs on the resolver
// actors' own contexts.
self.resolvers.attach_databases(databases.clone()).await;
let metrics = StatefulMetrics::new(self.context.as_present());
let _ = metrics.sync_done.try_set(1);
let processor = Processor::new(self.application, databases, anchor, metrics, self.pruning);
Processing {
context: self.context,
mailbox: self.mailbox,
provider: self.provider,
marshal,
processor,
deferred_verifications: Vec::new(),
skip_finalized_until,
}
.start()
.await
}
}
#[cfg(test)]
mod tests {
use super::{Config, Stateful};
use crate::stateful::{
actor::syncer::SyncPlan,
db::{AttachableResolver, Shared, StateSyncDb, SyncEngineConfig},
tests::{
fixtures,
mocks::{TestApp, TestBlock, TestDb},
},
};
use commonware_consensus::{
Application as _, CertifiableBlock as _, Reporter as _,
marshal::{Update, ancestry},
simplex::mocks::scheme as scheme_mocks,
};
use commonware_cryptography::sha256::Digest as Sha256Digest;
use commonware_macros::select;
use commonware_runtime::{Clock as _, Runner as _, Supervisor as _, deterministic};
use commonware_utils::{
Acknowledgement as _, NZU64, NZUsize,
acknowledgement::Exact,
channel::{mpsc, oneshot},
sync::Mutex,
};
use futures::poll;
use std::{convert::Infallible, sync::Arc, time::Duration};
/// Blocks startup before the actor begins polling its mailbox.
struct StartupGate {
started: oneshot::Sender<()>,
release: oneshot::Receiver<()>,
}
/// Resolver that can pause database attachment during startup.
#[derive(Clone, Default)]
struct NoopResolver {
startup_gate: Arc>>,
}
impl NoopResolver {
/// Creates a resolver with handles to observe and release its next database attachment.
fn gated() -> (Self, oneshot::Receiver<()>, oneshot::Sender<()>) {
let (started, started_rx) = oneshot::channel();
let (release, release_rx) = oneshot::channel();
(
Self {
startup_gate: Arc::new(Mutex::new(Some(StartupGate {
started,
release: release_rx,
}))),
},
started_rx,
release,
)
}
}
impl AttachableResolver for NoopResolver {
async fn attach_database(&self, _db: Shared) {
// Consume the single-use gate before waiting so the wait does not hold the gate lock.
let Some(StartupGate {
started,
mut release,
}) = self.startup_gate.lock().take()
else {
return;
};
started
.send(())
.expect("test should await the startup gate");
let _ = (&mut release).await;
}
}
impl StateSyncDb for TestDb {
type SyncError = Infallible;
async fn sync_db(
_context: deterministic::Context,
_config: Self::Config,
_resolver: NoopResolver,
_target: Self::SyncTarget,
_tip_updates: mpsc::Receiver,
_finish: Option>,
_reached_target: Option>,
_sync_config: SyncEngineConfig,
) -> Result {
Ok(Self::default())
}
}
#[test]
fn mailbox_rejects_propose_while_floor_resolution_waits() {
deterministic::Runner::timed(Duration::from_secs(5)).start(|context| async move {
let mut signing_context = context.child("signing");
let fixture = scheme_mocks::fixture(&mut signing_context, b"pending-floor", 1);
let finalization = fixtures::finalization(&fixture, 1, Sha256Digest::from([7; 32]));
let marshal = fixtures::marshal_fixture(
context.child("marshal_fixture"),
"pending-floor",
fixture.schemes[0].clone(),
None,
NZUsize!(1),
false,
)
.await;
let plan = SyncPlan::init(&context, "pending-floor-stateful".to_string()).await;
let (stateful, mut mailbox) = Stateful::init(
context.child("stateful"),
Config {
application: TestApp::default(),
db_config: (),
provider: (),
marshal: (marshal.mailbox, marshal.floor),
mailbox_size: NZUsize!(8),
plan: plan.with_floor(finalization),
resolvers: NoopResolver::default(),
sync_config: SyncEngineConfig {
fetch_batch_size: NZU64!(1),
apply_batch_size: NZU64!(1),
max_outstanding_requests: 1,
update_channel_size: NZUsize!(1),
max_retained_roots: 1,
},
prune_config: None,
},
);
let handle = stateful.start();
select! {
result = mailbox.propose(
(context.child("proposal"), TestBlock::new(1, 1).context()),
ancestry::from_iter([]),
(),
) => {
assert!(result.is_none());
},
_ = context.sleep(Duration::from_millis(100)) => {
panic!("stateful mailbox stalled while resolving state sync floor");
},
}
handle.abort();
});
}
#[test]
fn startup_recovery_releases_cancelled_verify_ancestries() {
deterministic::Runner::timed(Duration::from_secs(5)).start(|mut context| async move {
// Hold startup after database recovery but before processing polls the mailbox.
let prefix = "startup-recovery-cancelled-verifications";
let scheme = scheme_mocks::fixture(&mut context, prefix.as_bytes(), 1);
let genesis = TestBlock::new(0, 0);
let finalized = TestBlock::child(&genesis, 1);
let marshal = fixtures::marshal_fixture(
context.child("marshal"),
prefix,
scheme.schemes[0].clone(),
None,
NZUsize!(8),
true,
)
.await;
let (resolver, startup_started, startup_release) = NoopResolver::gated();
let plan = SyncPlan::init(&context, format!("{prefix}-stateful")).await;
let (stateful, mut mailbox) = Stateful::init(
context.child("stateful"),
Config {
application: TestApp::default(),
db_config: (),
provider: (),
marshal: (marshal.mailbox.clone(), marshal.floor),
mailbox_size: NZUsize!(1),
plan,
resolvers: resolver,
sync_config: SyncEngineConfig {
fetch_batch_size: NZU64!(1),
apply_batch_size: NZU64!(1),
max_outstanding_requests: 1,
update_channel_size: NZUsize!(1),
max_retained_roots: 1,
},
prune_config: None,
},
);
let actor = stateful.start();
startup_started
.await
.expect("startup should reach resolver attachment before processing");
// Fill the single ready slot and reliable overflow with independently owned ancestries.
let owners = [
Arc::new(TestBlock::new(2, 2)),
Arc::new(TestBlock::new(3, 3)),
Arc::new(TestBlock::new(4, 4)),
];
let weak_owners = owners.iter().map(Arc::downgrade).collect::>();
let mut first_mailbox = mailbox.clone();
let mut first = Box::pin(first_mailbox.verify(
(context.child("verify_first"), owners[0].context()),
ancestry::from_iter([Arc::clone(&owners[0])]),
));
assert!(poll!(&mut first).is_pending());
let mut second_mailbox = mailbox.clone();
let mut second = Box::pin(second_mailbox.verify(
(context.child("verify_second"), owners[1].context()),
ancestry::from_iter([Arc::clone(&owners[1])]),
));
assert!(poll!(&mut second).is_pending());
let mut third_mailbox = mailbox.clone();
let mut third = Box::pin(third_mailbox.verify(
(context.child("verify_third"), owners[2].context()),
ancestry::from_iter([Arc::clone(&owners[2])]),
));
assert!(poll!(&mut third).is_pending());
// Queue a finalization behind the verifications, then cancel every caller.
let (acknowledgement, mut acknowledgement_waiter) = Exact::handle();
let _ = mailbox.report(Update::Block(Arc::new(finalized), acknowledgement));
drop(first);
drop(second);
drop(third);
drop(owners);
context.sleep(Duration::from_millis(10)).await;
// Startup remains blocked while cancellation releases every ancestry block.
assert!(poll!(&mut acknowledgement_waiter).is_pending());
for (index, owner) in weak_owners.iter().enumerate() {
assert!(
owner.upgrade().is_none(),
"cancelled startup verification {index} retained its ancestry owner",
);
}
// Resuming startup drains the queue and acknowledges the later finalization.
startup_release
.send(())
.expect("startup should remain gated");
select! {
result = acknowledgement_waiter => {
result.expect("finalized block should be acknowledged after startup");
},
_ = context.sleep(Duration::from_millis(100)) => {
panic!("finalized acknowledgement stalled after startup");
},
}
actor.abort();
drop(marshal.guards);
});
}
}