Merge pull request #652 from logos-blockchain/moudy/durable-watcher-cursor

feat(cross-zone): persist each watcher's peer read cursor
This commit is contained in:
Moudy 2026-07-31 16:48:51 +02:00 committed by GitHub
commit 39bdec0d44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 3118 additions and 151 deletions

1
Cargo.lock generated
View File

@ -9307,6 +9307,7 @@ dependencies = [
"logos-blockchain-zone-sdk",
"mempool",
"num-bigint 0.4.6",
"ping_core",
"programs",
"rand 0.8.6",
"risc0-zkvm",

View File

@ -0,0 +1,227 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! A sequencer restart must resume its cross-zone watcher from the persisted
//! per-peer delivery floor instead of re-reading the peer channel from genesis.
//!
//! Re-reading is safe (the dispatch key is content-addressed and the inbox
//! no-ops a replay) so on-chain state cannot tell the two apart. What does tell
//! them apart is the transactions: a watcher that lost its cursor re-injects
//! every already-delivered dispatch, which shows up as inbox transactions in
//! blocks produced after the restart. This is the only test that covers the
//! wiring from `spawn_watchers` through the store, so a silent regression here
//! would leave every other test green while the feature does nothing.
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
/// Blocks zone B must produce after the restart before we judge the watcher.
/// A watcher that lost its cursor re-reads the peer channel on its first pass,
/// so a handful of blocks is ample room for the replay to appear.
const BLOCKS_AFTER_RESTART: u64 = 5;
const RESTART_TIMEOUT: Duration = Duration::from_secs(240);
const PING_PAYLOAD: &[u8] = b"hello-cross-zone";
#[test]
async fn restarted_watcher_resumes_instead_of_replaying_the_peer_channel() -> Result<()> {
// Declared first so it outlives both zones (drops run in reverse order).
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up shared Bedrock node")?;
let partial = SequencerPartialConfig::default();
let channel_a = config::bedrock_channel_id();
let channel_b = config::bedrock_channel_id_b();
let zone_a: [u8; 32] = *channel_a.as_ref();
let zone_b: [u8; 32] = *channel_b.as_ref();
let receiver_id = programs::ping_receiver().id();
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: zone_a,
allowed_targets: vec![receiver_id],
expected_block_signing_pubkey: None,
}],
};
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_a)
.with_genesis(vec![])
.setup()
.await
.context("Failed to set up zone A sequencer")?;
// Zone B keeps an explicit home so it can be restarted on the same store.
let home_b = tempfile::tempdir().context("Failed to create zone B home")?;
let mut seq_b = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_b)
.with_genesis(vec![])
.with_cross_zone(cross_zone.clone())
.setup_at(home_b.path())
.await
.context("Failed to set up zone B sequencer")?;
// Deliver one ping, so the peer channel holds a dispatch worth replaying.
sequencer_client(seq_a.addr())?
.send_transaction(build_ping_tx(zone_b, receiver_id))
.await
.context("Failed to submit ping on zone A")?;
let record_id = ping_record_pda(receiver_id);
let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?;
assert_eq!(
delivered, PING_PAYLOAD,
"Zone B must record the payload before the restart"
);
let tip_before = sequencer_client(seq_b.addr())?.get_last_block_id().await?;
// Restart zone B on the same home. Zone A stays quiet from here, so any
// inbox transaction after the restart is a replay, not a new delivery.
//
// `shutdown` rather than `drop`: dropping aborts the main loop without
// awaiting it and leaves the watchers and the publisher's drive task holding
// the store, so the reopen below would race the `RocksDB` lock.
seq_b.shutdown().await;
seq_b = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_b)
.with_genesis(vec![])
.with_cross_zone(cross_zone)
.setup_at(home_b.path())
.await
.context("Failed to restart zone B sequencer")?;
let client_b = sequencer_client(seq_b.addr())?;
let tip_after = wait_for_block_id(
&client_b,
tip_before.saturating_add(BLOCKS_AFTER_RESTART),
RESTART_TIMEOUT,
)
.await?;
let replayed = count_inbox_transactions(&client_b, tip_before.saturating_add(1), tip_after)
.await
.context("Failed to scan zone B blocks after the restart")?;
assert_eq!(
replayed,
0,
"a restarted watcher must resume from its persisted delivery floor; found {replayed} inbox transaction(s) in blocks {}..={tip_after}, which means it re-read the peer channel from genesis",
tip_before.saturating_add(1)
);
// The delivery itself must survive the restart untouched.
let account = client_b.get_account(record_id).await?;
assert_eq!(
account.data.into_inner(),
PING_PAYLOAD,
"the delivered payload must survive the restart"
);
Ok(())
}
/// Counts inbox transactions across `from..=to`, the signature of a re-injected
/// dispatch.
async fn count_inbox_transactions(client: &SequencerClient, from: u64, to: u64) -> Result<usize> {
let inbox_id = programs::cross_zone_inbox().id();
let mut count = 0_usize;
for block_id in from..=to {
let Some(block) = client.get_block(block_id).await? else {
continue;
};
for tx in &block.body.transactions {
if let LeeTransaction::Public(public_tx) = tx
&& public_tx.message().program_id == inbox_id
{
count = count.saturating_add(1);
}
}
}
Ok(count)
}
/// Waits until the sequencer's tip reaches `target`, returning the tip.
async fn wait_for_block_id(
client: &SequencerClient,
target: u64,
timeout: Duration,
) -> Result<u64> {
let wait = async {
loop {
let tip = client.get_last_block_id().await?;
if tip >= target {
return Ok::<u64, anyhow::Error>(tip);
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
};
tokio::time::timeout(timeout, wait)
.await
.context("Zone B did not produce enough blocks after the restart")?
}
/// Builds a top-level `ping_sender` transaction that chains into the outbox to emit
/// a message carrying a `ping_receiver::Record` instruction for the target zone.
fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction {
let outbox_id = programs::cross_zone_outbox().id();
let ordinal = 0;
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: PING_PAYLOAD.to_vec(),
})
.expect("serialize ping instruction");
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
let send = SenderInstruction::Send {
outbox_program_id: outbox_id,
target_zone,
target_program_id: receiver_id,
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
payload,
ordinal,
};
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
let message = Message::try_new(
programs::ping_sender().id(),
vec![outbox_account],
vec![],
send,
)
.expect("build ping message");
LeeTransaction::Public(PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
}
/// Polls zone B's sequencer until the ping record PDA holds a payload.
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
let wait = async {
loop {
let account = client.get_account(record_id).await?;
let data = account.data.into_inner();
if !data.is_empty() {
return Ok::<Vec<u8>, anyhow::Error>(data);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
};
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
.await
.context("Zone B did not record the cross-zone payload in time")?
}

View File

@ -57,3 +57,4 @@ test_programs.workspace = true
lee = { workspace = true, features = ["test-utils"] }
key_protocol.workspace = true
token_core.workspace = true
ping_core.workspace = true

View File

@ -1,4 +1,4 @@
use std::{sync::Arc, time::Duration};
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, ensure};
use common::block::Block;
@ -34,13 +34,10 @@ use logos_blockchain_zone_sdk::{
ZoneSequencer,
},
};
use tokio::{
sync::{mpsc, oneshot, watch},
task::JoinHandle,
};
use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::sync::CancellationToken;
use crate::config::BedrockConfig;
use crate::{config::BedrockConfig, task_group::TaskGroup};
/// Channel capacity for the publish inbox. One publish per produced block, drained
/// in microseconds by the drive task — 32 is huge headroom and just provides
@ -120,6 +117,14 @@ pub trait BlockPublisherTrait: Sized {
/// are processed past that point, so the node must halt.
fn driver_cancellation(&self) -> CancellationToken;
/// The publisher's background tasks, for a caller that needs to know when
/// they have actually stopped. Its sinks capture a store handle, so the
/// `RocksDB` lock outlives the sequencer until the drive task is gone.
/// Empty by default, for publishers that run no tasks.
fn background_tasks(&self) -> TaskGroup {
TaskGroup::default()
}
/// Current channel frontier slot on the connected chain, or `None` if the
/// channel does not exist there. Drives the startup frontier check.
async fn channel_tip_slot(&self) -> Result<Option<Slot>>;
@ -143,19 +148,12 @@ pub struct ZoneSdkPublisher {
turn_rx: watch::Receiver<TurnNotification>,
// Cancelled when the drive task ends for any reason, including a panic.
driver_cancellation: CancellationToken,
// Aborts the drive task when the last clone is dropped.
_drive_task: Arc<DriveTaskGuard>,
// Stops the drive task when the last clone is dropped, and lets a shutdown
// path wait until it has actually stopped.
drive_task: TaskGroup,
indexer: ZoneIndexer<NodeHttpClient>,
}
struct DriveTaskGuard(JoinHandle<()>);
impl Drop for DriveTaskGuard {
fn drop(&mut self) {
self.0.abort();
}
}
impl BlockPublisherTrait for ZoneSdkPublisher {
async fn new(
config: &BedrockConfig,
@ -318,7 +316,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
command_tx,
turn_rx,
driver_cancellation,
_drive_task: Arc::new(DriveTaskGuard(drive_task)),
drive_task: TaskGroup::new(vec![drive_task]),
})
}
@ -359,6 +357,10 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
self.driver_cancellation.clone()
}
fn background_tasks(&self) -> TaskGroup {
self.drive_task.clone()
}
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
Ok(self
.node

View File

@ -9,10 +9,12 @@ use common::{
use lee::V03State;
use lee_core::BlockId;
use log::info;
use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
use logos_blockchain_zone_sdk::{Slot, sequencer::SequencerCheckpoint};
use storage::sequencer::{
RocksDBIO,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
sequencer_cells::{
PeerZoneKey, PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord,
},
};
pub use storage::{DbResult, sequencer::DbDump};
@ -233,6 +235,36 @@ pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64>
.collect()
}
/// A cross-zone watcher's delivery floor on `peer_zone`'s channel.
///
/// The highest slot every message of which was delivered, or `None` before it
/// has delivered anything from that peer. Stored as a little-endian `u64`.
///
/// Free functions rather than only [`SequencerStore`] methods because each
/// watcher runs as its own spawned task and holds an `Arc<RocksDBIO>`;
/// `SequencerStore` is not `Clone`.
pub fn get_cross_zone_peer_floor(dbio: &RocksDBIO, peer_zone: PeerZoneKey) -> Result<Option<Slot>> {
let Some(bytes) = dbio.get_cross_zone_peer_floor_bytes(peer_zone)? else {
return Ok(None);
};
let bytes: [u8; 8] = bytes.as_slice().try_into().with_context(|| {
format!(
"Stored cross-zone peer floor is {} bytes, expected 8",
bytes.len()
)
})?;
Ok(Some(Slot::new(u64::from_le_bytes(bytes))))
}
pub fn set_cross_zone_peer_floor(
dbio: &RocksDBIO,
peer_zone: PeerZoneKey,
floor: Slot,
) -> Result<()> {
dbio.put_cross_zone_peer_floor_bytes(peer_zone, &floor.to_le_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests {
use common::{block::HashableBlockData, test_utils::sequencer_sign_key_for_testing};

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ use common::{
transaction::{LeeTransaction, clock_invocation},
};
use config::{GenesisAction, SequencerConfig};
use cross_zone_inbox_core::CrossZoneMessage;
use futures::StreamExt as _;
use itertools::Itertools as _;
use lee::{AccountId, PublicTransaction, public_transaction::Message};
@ -33,12 +34,16 @@ use num_bigint::BigUint;
pub use storage::error::DbError;
use storage::sequencer::{
RocksDBIO, StoreUpdate,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
sequencer_cells::{
PendingCrossZoneDispatchRecord, PendingDepositEventRecord, WithdrawalReconciliationKey,
ZoneAnchorRecord,
},
};
use crate::{
block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher},
block_store::SequencerStore,
task_group::{StoreRelease, TaskGroup},
};
pub mod block_publisher;
@ -48,6 +53,23 @@ pub mod cross_zone_watcher;
#[cfg(feature = "mock")]
pub mod mock;
pub mod task_group;
/// Failed production attempts before a cross-zone dispatch is given up on.
///
/// One attempt per block, so this is tens of seconds of retrying. Enough for a
/// failure that is not the message's fault to clear, short enough that a message
/// which will never execute stops being retried.
const RETIRE_DISPATCH_AFTER_FAILURES: u32 = 3;
/// Cross-zone deliveries one block may carry.
///
/// Each one costs a guest execution whether it succeeds or fails, and what
/// queues them up is chosen by peer zones. Without a bound, a backlog decides
/// how long a block takes to build and leaves no room for user transactions,
/// since store-drained work is taken before the mempool. The rest wait one
/// block; nothing is dropped.
const MAX_DISPATCHES_PER_BLOCK: usize = 16;
/// The origin of a transaction.
#[derive(Clone, Copy)]
@ -71,6 +93,10 @@ pub struct SequencerCore<BP: BlockPublisherTrait = ZoneSdkPublisher> {
mempool: MemPool<(TransactionOrigin, LeeTransaction)>,
sequencer_config: SequencerConfig,
block_publisher: BP,
/// Cross-zone watchers, stopped when this sequencer is dropped. They hold a
/// store handle, so leaving them running would keep the `RocksDB` lock held
/// and make the home directory unopenable by a restarting sequencer.
watchers: TaskGroup,
}
impl<BP: BlockPublisherTrait> SequencerCore<BP> {
@ -202,14 +228,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
// Cross-zone messaging: start a watcher per configured peer. The inbox
// config account is seeded into genesis state in `build_genesis_state`.
if let Some(cross_zone) = &config.cross_zone {
cross_zone_watcher::spawn_watchers(
&config.bedrock_config,
cross_zone,
config.block_create_timeout,
&mempool_handle,
);
}
let watchers = config
.cross_zone
.as_ref()
.map_or_else(TaskGroup::default, |cross_zone| {
cross_zone_watcher::spawn_watchers(
&config.bedrock_config,
cross_zone,
config.block_create_timeout,
&store.dbio(),
)
});
// Before producing, verify our local state still belongs to the chain
// the channel serves and replay any channel blocks we are missing
// (e.g. from other sequencers).
@ -267,6 +296,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
mempool,
sequencer_config: config,
block_publisher,
watchers,
};
(sequencer_core, mempool_handle)
@ -423,6 +453,12 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.context("Failed to read stored block")?
{
Some(stored) if stored.header.hash == block_hash => {
// Already applied, but the channel serving it is what makes
// it irreversible, so its deliveries are settled and their
// records are owed nothing. Without this a restart leaves a
// record for every delivery it already published, and
// nothing downstream would ever remove them.
settle_reconstructed_deliveries(store, &stored);
store
.set_zone_anchor(&record)
.context("Failed to persist zone anchor")?;
@ -464,6 +500,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.iter()
.filter_map(extract_bridge_deposit_id)
.collect();
// The same for the deliveries it carries: the inbox has seen them, so
// the drain would skip them anyway, and the records are owed nothing.
let finalized_dispatch_keys = settled_dispatch_keys(&store.dbio(), block);
// The tip meta stays pinned to the head tip even when the reconstructed
// block lands below it, and the anchor only advances if the block
@ -477,6 +516,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
head_tip: head_tip.as_ref(),
final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
remove_deposit_records: &finalized_deposit_ids,
remove_dispatch_records: &finalized_dispatch_keys,
zone_anchor: Some(&record),
..StoreUpdate::new(chain.head_state())
})
@ -638,8 +678,42 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
fn build_block_from_mempool(&mut self) -> Result<BlockWithMeta> {
let now = Instant::now();
// Build on the head: its tip is the parent, its state the validation base.
let (prev_block_hash, new_block_height, mut working_state) = {
// Decoded outside the chain lock, and read before it is taken: the usual
// case is no delivery records at all, and decoding is the expensive part.
// One that does not decode is dropped rather than kept, since nothing
// will ever turn those bytes into a block transaction.
let mut settled = Vec::new();
let recorded_dispatches: Vec<_> = self
.store
.dbio()
.get_pending_cross_zone_dispatches()
.context("Failed to load pending cross-zone dispatches")?
.into_iter()
.filter_map(
|record| match borsh::from_slice::<LeeTransaction>(&record.transaction) {
Ok(tx) => {
let message = extract_cross_zone_dispatch(&tx);
Some((record.message_key, message, tx))
}
Err(err) => {
warn!(
"Dropping pending cross-zone dispatch {} that does not decode: {err:#}",
hex::encode(record.message_key)
);
settled.push(record.message_key);
None
}
},
)
.collect();
// Build on the head: its tip is the parent, its state the validation
// base.
//
// The delivery records are classified in here rather than after, so the
// final state can be read by reference. Cloning it cost a full state
// copy on every block of every zone, cross-zone or not.
let (prev_block_hash, new_block_height, mut working_state, pending_dispatches) = {
let chain = self.chain.lock().expect("chain state mutex poisoned");
let tip = chain.head_tip();
let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| {
@ -648,9 +722,43 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.expect("block id should not overflow")
});
let prev = tip.map_or(HashType([0; 32]), |head| head.hash);
(prev, height, chain.head_state().clone())
// Three outcomes per record. Already in the final state means the
// delivery is irreversible, so the record is dropped; that is the
// only thing that removes a record the watcher re-added after its
// delivery had already settled, which it does whenever it re-reads a
// slot it has consumed. Already in the head state but not the final
// one means the delivery is on this chain but could still orphan, so
// the record is skipped and kept. Otherwise it goes in this block.
let mut pending: VecDeque<LeeTransaction> = VecDeque::new();
for (key, message, tx) in recorded_dispatches {
match message {
Some(message) if dispatch_already_delivered(chain.final_state(), &message) => {
settled.push(key);
}
Some(message) if dispatch_already_delivered(chain.head_state(), &message) => {}
_ if pending.len() >= MAX_DISPATCHES_PER_BLOCK => {}
_ => pending.push_back(tx),
}
}
(prev, height, chain.head_state().clone(), pending)
};
if !settled.is_empty()
&& let Err(err) = self
.store
.dbio()
.drop_settled_cross_zone_dispatches(&settled)
{
// Only bookkeeping: the deliveries themselves are irreversible, and
// the next turn tries again.
warn!(
"Failed to drop {} settled delivery record(s): {err:#}",
settled.len()
);
}
let mut valid_transactions = Vec::new();
let mut withdrawals = Vec::new();
@ -664,7 +772,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
// build on — it was minted by us or by a peer whose block we adopted.
// An orphan reverts the receipt with the block, so the next turn
// re-mints without any bookkeeping of our own.
let mut pending_deposits: VecDeque<LeeTransaction> = self
let pending_deposits: VecDeque<LeeTransaction> = self
.store
.get_pending_deposit_events()
.context("Failed to load pending deposit events")?
@ -691,10 +799,13 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let clock_tx = clock_invocation(new_block_timestamp);
let clock_lee_tx = LeeTransaction::Public(clock_tx.clone());
// Pending deposit mints first, then user work. `from_store` is not the
// same as a `Sequencer` origin — the cross-zone watcher pushes those
// into the mempool too.
while let Some((origin, tx, from_store)) = pending_deposits
// Everything drained from the store first, then user work. `from_store`
// is not the same as a `Sequencer` origin: it says the transaction has a
// record behind it and so needs no requeue, where the origin only says
// it was not submitted by a user.
let mut pending_from_store = pending_deposits;
pending_from_store.extend(pending_dispatches);
while let Some((origin, tx, from_store)) = pending_from_store
.pop_front()
.map(|tx| (TransactionOrigin::Sequencer, tx, true))
.or_else(|| self.mempool.pop().map(|(origin, tx)| (origin, tx, false)))
@ -719,12 +830,39 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.len();
if block_size > max_block_size {
// Would a block carrying nothing but this still be too big? Then
// it does not fit in any block and deferring it defers it for
// ever. A store-drained transaction is at the head of the queue
// every turn, so breaking here would stop production reaching
// anything behind it, including the whole mempool, permanently.
// Count it against the delivery instead so it is given up on.
//
// Measured on its own rather than from `block_size`, which also
// counts whatever this block already holds: a transaction that
// merely does not fit *today* is the ordinary deferral below.
if from_store
&& !self.fits_in_an_empty_block(
&tx,
&clock_lee_tx,
new_block_height,
prev_block_hash,
new_block_timestamp,
)?
{
error!(
"Sequencer-drained transaction {tx_hash} cannot fit in any block under the \
{max_block_size} byte limit; giving up on it rather than stalling production",
);
self.count_dispatch_failure(&tx);
continue;
}
warn!(
"Transaction with hash {tx_hash} deferred to next block: \
block size {block_size} bytes would exceed limit of {max_block_size} bytes",
);
// A deposit mint needs no requeue: its record stays unfulfilled
// in the store and is drained again on the next turn.
// Anything drained from the store needs no requeue: its record
// stays there and is drained again on the next turn.
if !from_store {
self.mempool.push_front((origin, tx));
}
@ -740,6 +878,11 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
&mut withdrawals,
) {
valid_transactions.push(tx);
} else {
// A failed transaction is simply left out of the block, except a
// dispatch: that one is re-fed from the store every turn, so one
// that can never execute would fail on every block for ever.
self.count_dispatch_failure(&tx);
}
if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block {
@ -828,6 +971,93 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
&self.block_publisher
}
/// Whether a block carrying nothing but `tx` and the clock would be within
/// the size limit.
///
/// Distinguishes "does not fit in this block" from "does not fit in any
/// block". The first is an ordinary deferral; the second, for a transaction
/// the store re-feeds every turn, is a permanent stall unless it is given up
/// on.
fn fits_in_an_empty_block(
&self,
tx: &LeeTransaction,
clock_tx: &LeeTransaction,
block_id: u64,
prev_block_hash: HashType,
timestamp: u64,
) -> Result<bool> {
let alone = HashableBlockData {
block_id,
transactions: vec![tx.clone(), clock_tx.clone()],
prev_block_hash,
timestamp,
};
let size = borsh::to_vec(&alone)
.context("Failed to serialize block for size check")?
.len();
let max = usize::try_from(self.sequencer_config.max_block_size.as_u64())
.expect("`max_block_size` should fit into usize");
Ok(size <= max)
}
/// Counts one failed production attempt against `tx` if it is a cross-zone
/// delivery, giving up on it once too many accumulate.
///
/// A delivery's payload and target accounts are chosen on the peer zone and
/// validated by nobody in between, so one can fail for good; but a failure
/// can equally be a property of the moment, so give up only after several.
/// Giving up drops the record, which is also what keeps a peer from growing
/// the pending list with deliveries that can never execute.
fn count_dispatch_failure(&self, tx: &LeeTransaction) {
let Some(message) = extract_cross_zone_dispatch(tx) else {
return;
};
let key = cross_zone_inbox_core::message_key(
&message.src_zone,
message.src_block_id,
message.src_tx_index,
);
match self
.store
.dbio()
.record_dispatch_failure(key, RETIRE_DISPATCH_AFTER_FAILURES)
{
Ok(true) => error!(
"Giving up on cross-zone delivery {} after {RETIRE_DISPATCH_AFTER_FAILURES} failed attempts; it will not be retried",
hex::encode(key)
),
Ok(false) => warn!(
"Cross-zone delivery {} failed to execute, will retry next block",
hex::encode(key)
),
Err(err) => error!(
"Failed to count the failed attempt for cross-zone delivery {}: {err:#}",
hex::encode(key)
),
}
}
/// A weak reference to this sequencer's store, for a shutdown path that
/// needs to observe the database actually closing rather than infer it.
#[must_use]
pub fn store_release(&self) -> StoreRelease {
StoreRelease::new(&self.store.dbio())
}
/// Every background task that holds this sequencer's store handle.
///
/// Taken before the core is shared, so a shutdown path can wait for them
/// without owning the core. Until all of them have stopped the `RocksDB`
/// lock is still held and the home directory cannot be reopened, which is
/// what a restart does.
#[must_use]
pub fn background_tasks(&self) -> Vec<TaskGroup> {
vec![
self.watchers.clone(),
self.block_publisher.background_tasks(),
]
}
/// Whether this sequencer is currently authorized to write to the channel.
#[must_use]
pub fn is_our_turn(&self) -> bool {
@ -857,6 +1087,29 @@ fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> boo
.is_some_and(|receipt| *receipt != lee::Account::default())
}
/// Whether a cross-zone delivery is already on the chain we are building on.
///
/// The inbox records every delivered message key in a seen shard and no-ops a
/// replay, so that shard is the same kind of answer the deposit receipt gives:
/// state, not bookkeeping. An orphan reverts the entry with the block, so the
/// next turn re-delivers with nothing of ours to unwind.
fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) -> bool {
let shard_id = cross_zone_inbox_core::inbox_seen_shard_account_id(
programs::cross_zone_inbox().id(),
&message.src_zone,
message.src_block_id,
);
state.get_account_by_id_ref(shard_id).is_some_and(|shard| {
cross_zone_inbox_core::SeenShard::from_bytes(shard.data.as_ref()).is_ok_and(|seen| {
seen.contains(&cross_zone_inbox_core::message_key(
&message.src_zone,
message.src_block_id,
message.src_tx_index,
))
})
})
}
/// Feed one channel delta into the follow state and mirror it to the store:
/// revert orphaned, then apply and persist adopted and finalized blocks.
/// Production builds on this same head. Wired to the publisher via
@ -975,6 +1228,14 @@ fn apply_follow_update(
.filter_map(extract_bridge_deposit_id)
.collect();
// The same for cross-zone deliveries, keyed by message key: a record
// goes once its own delivery is irreversible, never because another
// block finalized at its height.
let finalized_dispatch_keys: Vec<[u8; 32]> = irreversible
.iter()
.flat_map(|block| settled_dispatch_keys(dbio, block))
.collect();
// A persist failure is fatal: the in-memory chain has already advanced,
// and continuing would leave a permanent gap in the store. The `panic!`
// ends the drive task, whose cancellation halts the node.
@ -987,6 +1248,7 @@ fn apply_follow_update(
finalized_up_to: last_finalized,
new_deposit_events: &deposit_records,
remove_deposit_records: &finalized_deposit_ids,
remove_dispatch_records: &finalized_dispatch_keys,
consumed_withdrawals: &consumed_withdrawals,
..StoreUpdate::new(chain.head_state())
})
@ -1226,6 +1488,104 @@ fn is_sequencer_only_tx(tx: &LeeTransaction) -> bool {
if is_sequencer_only_program(tx.message().program_id))
}
/// The cross-zone message an inbox dispatch delivers, or `None` if `tx` is not
/// a dispatch.
#[must_use]
fn extract_cross_zone_dispatch(tx: &LeeTransaction) -> Option<CrossZoneMessage> {
let LeeTransaction::Public(tx) = tx else {
return None;
};
let message = tx.message();
if message.program_id != programs::cross_zone_inbox().id() {
return None;
}
match risc0_zkvm::serde::from_slice::<cross_zone_inbox_core::Instruction, u32>(
&message.instruction_data,
) {
Ok(cross_zone_inbox_core::Instruction::Dispatch(msg)) => Some(msg),
Ok(cross_zone_inbox_core::Instruction::InitConfig(_)) | Err(_) => None,
}
}
/// The content-addressed key of the message an inbox dispatch delivers.
///
/// A delivery in an irreversible block settles its pending record, so the record
/// is dropped by identity rather than by the height it happened to land at.
#[must_use]
fn extract_cross_zone_dispatch_key(tx: &LeeTransaction) -> Option<[u8; 32]> {
extract_cross_zone_dispatch(tx).map(|msg| {
cross_zone_inbox_core::message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index)
})
}
/// The keys of the deliveries `block` carries, reporting any whose transaction
/// is not the one we recorded for that key.
///
/// The key covers `(src_zone, src_block_id, src_tx_index)` and nothing about the
/// payload, and so does the inbox's own replay check, so a sequencer that
/// publishes a dispatch with the right key and a forged payload settles our
/// correct record along with it. The forgery is caught downstream by the
/// indexer, which re-derives every delivery and halts, but the local record is
/// the last copy of what we believed and it is about to be dropped either way.
/// Saying so in the log is what makes the halt diagnosable.
fn settled_dispatch_keys(dbio: &RocksDBIO, block: &Block) -> Vec<[u8; 32]> {
let recorded = dbio.get_pending_cross_zone_dispatches().unwrap_or_default();
let (keys, forged) = classify_settled_deliveries(&recorded, block);
for key in forged {
error!(
"Cross-zone delivery {} settled with a transaction that is not the one this node recorded for that key. The message key does not cover the payload, so a peer's sequencer can publish a different delivery under it.",
hex::encode(key)
);
}
keys
}
/// Splits the deliveries `block` carries into every settled key, and the subset
/// whose transaction is not the one `recorded` holds for that key.
///
/// Separated from the logging so the detection is testable: a forged delivery
/// leaves no trace in state that differs from an honest one, precisely because
/// the key does not cover the payload.
fn classify_settled_deliveries(
recorded: &[PendingCrossZoneDispatchRecord],
block: &Block,
) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) {
let mut keys = Vec::new();
let mut forged = Vec::new();
for tx in &block.body.transactions {
let Some(key) = extract_cross_zone_dispatch_key(tx) else {
continue;
};
let mismatched = recorded
.iter()
.find(|record| record.message_key == key)
.is_some_and(|record| {
borsh::to_vec(tx).is_ok_and(|encoded| encoded != record.transaction)
});
if mismatched {
forged.push(key);
}
keys.push(key);
}
(keys, forged)
}
/// Drops the records of deliveries carried by a reconstructed block.
///
/// A persist failure is only logged: the deliveries are already irreversible, so
/// the worst case is a record the next drain drops instead.
fn settle_reconstructed_deliveries(store: &SequencerStore, block: &Block) {
let keys = settled_dispatch_keys(&store.dbio(), block);
if keys.is_empty() {
return;
}
if let Err(err) = store.dbio().drop_settled_cross_zone_dispatches(&keys) {
warn!("Failed to settle reconstructed delivery records: {err:#}");
}
}
#[must_use]
fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option<HashType> {
let LeeTransaction::Public(tx) = tx else {

View File

@ -0,0 +1,137 @@
//! A set of background tasks that can be stopped and waited on.
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
use log::warn;
use storage::sequencer::RocksDBIO;
use tokio::task::JoinHandle;
/// Background tasks owned by one component, stoppable on demand and stopped
/// anyway when the last handle goes away.
///
/// `JoinHandle::abort` only *requests* cancellation, and dropping a handle
/// detaches rather than cancels, so neither on its own says when a task has
/// actually stopped. That matters because these tasks hold a store handle:
/// until they are gone the `RocksDB` lock is still held and a restarting
/// sequencer cannot reopen its home directory. [`TaskGroup::shutdown`] is the
/// answer to "have they stopped yet"; the `Drop` below stays as the best-effort
/// path for panics and tests that never call it.
///
/// Cloneable so the owner can keep it (tying task lifetime to its own) while a
/// shutdown path elsewhere holds a clone.
#[derive(Clone, Default)]
pub struct TaskGroup(Arc<TaskGroupInner>);
#[derive(Default)]
struct TaskGroupInner(Mutex<Vec<JoinHandle<()>>>);
/// A weak handle to the store, for observing when it is finally closed.
///
/// Every strong reference lives inside a task or a server that shutdown stops,
/// but the last drop runs on whichever thread owned it, not on the one awaiting
/// shutdown. Watching the count is the difference between knowing the database
/// file is closed and assuming it from another crate's drop order.
pub struct StoreRelease(Weak<RocksDBIO>);
impl StoreRelease {
#[must_use]
pub fn new(store: &Arc<RocksDBIO>) -> Self {
Self(Arc::downgrade(store))
}
/// How many holders are left. Zero means the store is closed.
#[must_use]
pub fn holders(&self) -> usize {
self.0.strong_count()
}
}
impl Drop for TaskGroupInner {
fn drop(&mut self) {
for task in Self::take(&self.0) {
task.abort();
}
}
}
impl TaskGroupInner {
/// Empties the handle list, so a second shutdown (or a drop after one) is a
/// no-op rather than a second abort.
fn handles(handles: &Mutex<Vec<JoinHandle<()>>>) -> MutexGuard<'_, Vec<JoinHandle<()>>> {
handles.lock().unwrap_or_else(PoisonError::into_inner)
}
fn take(handles: &Mutex<Vec<JoinHandle<()>>>) -> Vec<JoinHandle<()>> {
std::mem::take(&mut *Self::handles(handles))
}
}
impl TaskGroup {
/// Takes ownership of already-spawned tasks.
#[must_use]
pub fn new(handles: Vec<JoinHandle<()>>) -> Self {
Self(Arc::new(TaskGroupInner(Mutex::new(handles))))
}
/// Whether any task has ended on its own.
///
/// These tasks run for the lifetime of the sequencer, so a finished one is a
/// task that panicked, and whatever it was doing is not happening any more.
#[must_use]
pub fn any_finished(&self) -> bool {
TaskGroupInner::handles(&self.0.0)
.iter()
.any(JoinHandle::is_finished)
}
/// Stops every task and waits for it to finish.
///
/// Returns only once the runtime has dropped each task's future, so whatever
/// they held (a store handle, a network client) is released by the time this
/// returns. Cancellation is the expected outcome, so it is not reported; a
/// panic is, since it means the task died on its own terms earlier.
pub async fn shutdown(&self) {
let handles = TaskGroupInner::take(&self.0.0);
for handle in handles {
handle.abort();
if let Err(err) = handle.await
&& err.is_panic()
{
warn!("Background task panicked before shutdown: {err}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn a_task_that_ends_on_its_own_is_visible() {
let group = TaskGroup::new(vec![tokio::spawn(async {})]);
// A watcher only ends by panicking, so "finished" is the signal that a
// peer's deliveries have stopped happening.
tokio::task::yield_now().await;
assert!(group.any_finished());
let running = TaskGroup::new(vec![tokio::spawn(std::future::pending())]);
assert!(!running.any_finished());
}
#[tokio::test]
async fn shutdown_ends_a_task_that_would_never_end_on_its_own() {
let group = TaskGroup::new(vec![tokio::spawn(std::future::pending())]);
// The watchers and the drive task are infinite loops, so awaiting one
// without cancelling it first hangs here for ever.
tokio::time::timeout(std::time::Duration::from_secs(5), group.shutdown())
.await
.expect("shutdown must not hang on a task that never finishes by itself");
// Shutting down twice is a no-op rather than a second abort.
tokio::time::timeout(std::time::Duration::from_secs(5), group.shutdown())
.await
.expect("a second shutdown must return immediately");
}
}

View File

@ -4,7 +4,7 @@ use std::{pin::pin, time::Duration};
use common::{
HashType,
block::{BedrockStatus, HashableBlockData},
block::{BedrockStatus, Block, HashableBlockData},
test_utils::sequencer_sign_key_for_testing,
transaction::{LeeTransaction, clock_invocation},
};
@ -29,23 +29,32 @@ use logos_blockchain_core::mantle::{
};
use logos_blockchain_zone_sdk::sequencer::DepositInfo;
use mempool::MemPoolHandle;
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
use ping_core::{ReceiverInstruction, ping_record_pda};
use storage::sequencer::sequencer_cells::{
PendingCrossZoneDispatchRecord, PendingDepositEventRecord,
};
use tempfile::tempdir;
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
use crate::{
TransactionOrigin, apply_follow_update,
MAX_DISPATCHES_PER_BLOCK, RETIRE_DISPATCH_AFTER_FAILURES, TransactionOrigin,
apply_follow_update,
block_publisher::FollowUpdate,
block_store::SequencerStore,
build_bridge_deposit_tx_from_event, build_genesis_state,
config::{BedrockConfig, GenesisAction, SequencerConfig},
deposit_already_minted, is_sequencer_only_program,
build_bridge_deposit_tx_from_event, build_genesis_state, classify_settled_deliveries,
config::{BedrockConfig, CrossZoneConfig, CrossZonePeer, GenesisAction, SequencerConfig},
deposit_already_minted, dispatch_already_delivered, extract_cross_zone_dispatch,
extract_cross_zone_dispatch_key, is_sequencer_only_program,
mock::{SequencerCoreWithMockClients, mock_checkpoint},
resubmittable_txs,
};
mod reconstruction;
/// The peer zone a cross-zone test receives from. Distinct from the test
/// channel id (`[0; 32]`), which the inbox guest rejects as a source.
const PEER_ZONE: [u8; 32] = [0xbe_u8; 32];
#[derive(borsh::BorshSerialize)]
struct DepositMetadataForEncoding {
recipient_id: lee::AccountId,
@ -162,6 +171,80 @@ fn tx_is_bridge_deposit(
)
}
/// A config that receives `ping_receiver` messages from [`PEER_ZONE`], so
/// `build_genesis_state` seeds the inbox config PDA and a delivery has an
/// allowlist to pass.
fn cross_zone_test_config() -> SequencerConfig {
SequencerConfig {
cross_zone: Some(CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: PEER_ZONE,
allowed_targets: vec![programs::ping_receiver().id()],
expected_block_signing_pubkey: None,
}],
}),
..setup_sequencer_config()
}
}
/// A `ping_receiver::Record` instruction as risc0 words, little-endian: the wire
/// form an emitter on the peer zone puts in the message payload.
fn ping_payload(payload: &[u8]) -> Vec<u8> {
risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: payload.to_vec(),
})
.expect("ping instruction serializes")
.iter()
.flat_map(|word| word.to_le_bytes())
.collect()
}
/// The dispatch transaction for a message at index 0 of [`PEER_ZONE`] block
/// `src_block_id`. Built through the same builder the watcher uses, so a change
/// to the encoding shows up here rather than passing silently.
fn dispatch_tx(src_block_id: u64, payload: Vec<u8>) -> LeeTransaction {
let receiver_id = programs::ping_receiver().id();
LeeTransaction::Public(cross_zone::build_dispatch_from_emission(
PEER_ZONE,
src_block_id,
0,
programs::ping_sender().id(),
receiver_id,
&[ping_record_pda(receiver_id).into_value()],
payload,
))
}
/// The pending record the watcher would leave behind for that dispatch.
fn dispatch_record(src_block_id: u64, payload: Vec<u8>) -> PendingCrossZoneDispatchRecord {
let tx = dispatch_tx(src_block_id, payload);
PendingCrossZoneDispatchRecord::recorded(
cross_zone_inbox_core::message_key(&PEER_ZONE, src_block_id, 0),
borsh::to_vec(&tx).expect("dispatch encodes"),
)
}
/// The message keys of the deliveries a block carries.
fn dispatches_in(block: &Block) -> Vec<[u8; 32]> {
block
.body
.transactions
.iter()
.filter_map(extract_cross_zone_dispatch_key)
.collect()
}
/// The pending dispatch records a sequencer still holds.
fn pending_dispatches(
sequencer: &SequencerCoreWithMockClients,
) -> Vec<PendingCrossZoneDispatchRecord> {
sequencer
.store
.dbio()
.get_pending_cross_zone_dispatches()
.expect("pending dispatches readable")
}
#[tokio::test]
async fn start_from_config() {
let config = setup_sequencer_config();
@ -477,6 +560,363 @@ async fn a_replayed_deposit_mint_no_ops_in_the_guest() {
);
}
#[tokio::test]
async fn recorded_dispatches_are_drained_from_the_store_on_production() {
let payload = b"hello-cross-zone".to_vec();
let record = dispatch_record(7, ping_payload(&payload));
let key = record.message_key;
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
assert_eq!(
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap(),
1
);
// The delivery never goes through the mempool: the record is the queue, and
// production drains it. That is what makes the window between the watcher's
// durable read cursor and a block carrying the dispatch survivable.
assert!(
sequencer.mempool.pop().is_none(),
"deliveries are drained from the store, never queued in the mempool"
);
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(block_id)
.unwrap()
.expect("produced block is stored");
assert_eq!(
dispatches_in(&block),
vec![key],
"the drained delivery should be included in the produced block"
);
let record_id = ping_record_pda(programs::ping_receiver().id());
assert_eq!(
sequencer.with_state(|state| state.get_account_by_id(record_id).data.into_inner()),
payload,
"the dispatch must reach its target program, not just sit in the block"
);
// The record stays until the delivery finalizes; re-delivery is prevented by
// the inbox seen-set now in head state, not by any marker on the record.
assert_eq!(
pending_dispatches(&sequencer)
.iter()
.map(|record| record.message_key)
.collect::<Vec<_>>(),
vec![key],
"the record remains until the delivery becomes irreversible"
);
}
#[tokio::test]
async fn a_delivered_dispatch_is_skipped_on_the_next_turn() {
// The seen-set is what replaces the submitted mark: the drain asks the state
// it is building on whether the inbox has already taken this message, so a
// record that outlives its delivery costs one skipped drain, not a replay.
let record = dispatch_record(11, ping_payload(b"once"));
let key = record.message_key;
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
let first = sequencer.produce_new_block().await.unwrap();
let second = sequencer.produce_new_block().await.unwrap();
let delivered_in = |block_id: u64| {
dispatches_in(
&sequencer
.store
.get_block_at_id(block_id)
.unwrap()
.expect("produced block is stored"),
)
};
assert_eq!(delivered_in(first), vec![key]);
assert!(
delivered_in(second).is_empty(),
"the inbox seen-set must keep the drain from re-delivering"
);
let message = extract_cross_zone_dispatch(&dispatch_tx(11, ping_payload(b"once")))
.expect("the dispatch carries a cross-zone message");
assert!(
sequencer.with_state(|state| dispatch_already_delivered(state, &message)),
"the seen shard in head state is what the skip reads"
);
}
#[tokio::test]
async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() {
// A payload that is not `u32`-aligned: the inbox guest rejects it outright,
// so this is a delivery that can never execute however often it is retried.
// Its content is chosen on the peer zone and validated by nobody in between,
// so without a give-up policy it would fail on every block for ever.
let record = dispatch_record(13, b"odd".to_vec());
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
for attempt in 1..RETIRE_DISPATCH_AFTER_FAILURES {
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(block_id)
.unwrap()
.expect("produced block is stored");
assert!(
dispatches_in(&block).is_empty(),
"a dispatch that fails to execute must not reach the block"
);
let records = pending_dispatches(&sequencer);
assert_eq!(records.len(), 1);
assert_eq!(
records[0].failed_attempts, attempt,
"the counter advances once per block, not once per process start"
);
}
// The attempt at the limit gives up on it, and giving up drops the record.
// Anything else leaves an entry no later block can ever remove, which is how
// a peer that can make deliveries fail would grow this list without bound.
sequencer.produce_new_block().await.unwrap();
assert!(
pending_dispatches(&sequencer).is_empty(),
"giving up on a delivery must drop its record, not flag it"
);
// And nothing re-feeds it, so it stops costing a guest execution per block.
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap();
assert!(dispatches_in(&block).is_empty());
assert!(pending_dispatches(&sequencer).is_empty());
}
#[tokio::test]
async fn a_redelivered_record_is_dropped_once_its_delivery_is_irreversible() {
// The watcher persists its floor only at slot boundaries, so a crash inside
// a slot makes the next run re-read it and re-record deliveries that have
// already settled. Their keys are in the inbox seen-set for good, so no
// future block will ever carry them and the settlement path cannot reach
// them. The drain dropping them is the only thing that does.
let record = dispatch_record(29, ping_payload(b"again"));
let key = record.message_key;
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record.clone()])
.unwrap();
let block_id = sequencer.produce_new_block().await.unwrap();
let delivery_block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap();
assert_eq!(dispatches_in(&delivery_block), vec![key]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
finalized: vec![(MsgId::from(delivery_block.header.hash.0), delivery_block)],
..empty_follow_update()
},
);
assert!(pending_dispatches(&sequencer).is_empty());
// The watcher re-reads the slot and records it again.
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
assert_eq!(pending_dispatches(&sequencer).len(), 1);
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap();
assert!(
dispatches_in(&block).is_empty(),
"the delivery is already on the chain, so it must not be delivered again"
);
assert!(
pending_dispatches(&sequencer).is_empty(),
"a record whose delivery is already irreversible must be dropped, not kept for ever"
);
}
#[tokio::test]
async fn a_delivery_still_reversible_keeps_its_record() {
// The counterpart to the test above, and the reason the drain checks two
// states rather than one. In head but not yet final means the delivery can
// still orphan, so skipping it is right but dropping its record would lose
// the delivery when it does.
let record = dispatch_record(31, ping_payload(b"pending"));
let key = record.message_key;
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
sequencer.produce_new_block().await.unwrap();
sequencer.produce_new_block().await.unwrap();
assert_eq!(
pending_dispatches(&sequencer)
.iter()
.map(|record| record.message_key)
.collect::<Vec<_>>(),
vec![key],
"nothing has finalized, so the record must survive in case the block orphans"
);
}
#[test]
fn a_settled_delivery_that_is_not_the_one_we_recorded_is_reported() {
// The message key covers (src_zone, src_block_id, src_tx_index) and nothing
// about the payload, and so does the inbox's own replay check. So a peer's
// sequencer can publish a delivery under a key we hold with a payload we
// never saw, and it settles our correct record along with it. The indexer
// catches the forgery and halts; this record is the last local copy of what
// we believed, so the mismatch has to be reported before it is dropped.
let honest = dispatch_record(53, ping_payload(b"honest"));
let key = honest.message_key;
let forged = dispatch_tx(53, ping_payload(b"forged"));
assert_eq!(
extract_cross_zone_dispatch_key(&forged),
Some(key),
"the forged delivery must share the key, or it proves nothing"
);
let block = common::test_utils::produce_dummy_block(2, None, vec![forged]);
let (keys, mismatched) = classify_settled_deliveries(std::slice::from_ref(&honest), &block);
assert_eq!(keys, vec![key], "the record is settled either way");
assert_eq!(
mismatched,
vec![key],
"a delivery that differs from the one recorded under that key must be reported"
);
// The honest case must stay quiet, or the report is noise.
let honest_block = common::test_utils::produce_dummy_block(
2,
None,
vec![dispatch_tx(53, ping_payload(b"honest"))],
);
let (keys, mismatched) = classify_settled_deliveries(&[honest], &honest_block);
assert_eq!(keys, vec![key]);
assert!(mismatched.is_empty());
}
#[tokio::test]
async fn a_delivery_too_large_for_any_block_does_not_stall_production() {
// A store-drained transaction is at the head of the queue every turn, so one
// that cannot fit in any block would defer itself for ever and, because the
// deferral breaks the loop, stop production ever reaching the mempool behind
// it. The peer chooses the payload, so this is theirs to trigger.
let record = dispatch_record(41, ping_payload(&[7_u8; 8192]));
let mut config = cross_zone_test_config();
config.max_block_size = bytesize::ByteSize::kib(4);
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
let user_tx = common::test_utils::create_transaction_native_token_transfer(
initial_public_user_accounts()[0].account_id,
0,
initial_public_user_accounts()[1].account_id,
10,
&create_signing_key_for_account1(),
);
mempool_handle
.push((TransactionOrigin::User, user_tx.clone()))
.await
.unwrap();
// Production must get past it to the mempool in the very first block.
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap();
assert!(
block.body.transactions.contains(&user_tx),
"an oversized drained delivery must not stop production reaching the mempool"
);
assert!(dispatches_in(&block).is_empty());
// And it is given up on rather than retried for ever.
for _ in 1..RETIRE_DISPATCH_AFTER_FAILURES {
sequencer.produce_new_block().await.unwrap();
}
assert!(
pending_dispatches(&sequencer).is_empty(),
"a delivery that fits in no block must be given up on"
);
}
#[tokio::test]
async fn a_delivery_backlog_is_spread_across_blocks() {
// Each delivery costs a guest execution and peers decide how many queue up,
// so an unbounded drain would let a backlog decide how long a block takes to
// build and leave no room for user work, since store-drained transactions
// are taken before the mempool.
let backlog = MAX_DISPATCHES_PER_BLOCK + 3;
let records: Vec<_> = (0..backlog)
.map(|index| {
let src_block_id = 100 + u64::try_from(index).expect("test index fits");
dispatch_record(src_block_id, ping_payload(b"backlog"))
})
.collect();
let mut config = cross_zone_test_config();
config.max_num_tx_in_block = backlog + 10;
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(records)
.unwrap();
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap();
assert_eq!(
dispatches_in(&block).len(),
MAX_DISPATCHES_PER_BLOCK,
"one block must not carry an unbounded number of deliveries"
);
// Deferred, not dropped: the rest go in the next block.
let block_id = sequencer.produce_new_block().await.unwrap();
let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap();
assert_eq!(dispatches_in(&block).len(), 3);
}
#[test]
fn transaction_pre_check_pass() {
let tx = common::test_utils::produce_dummy_empty_transaction();
@ -1774,6 +2214,98 @@ async fn follow_finalized_own_block_moves_final_tier_and_marks_store() {
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[tokio::test]
async fn follow_finalized_delivery_drops_its_pending_record() {
// The record exists to bridge the gap between the watcher's durable read
// cursor and a block that carries the delivery. Once that block is
// irreversible the delivery cannot be lost any more, so the record is owed
// nothing and goes with the same update that made the block irreversible.
let record = dispatch_record(17, ping_payload(b"settled"));
let key = record.message_key;
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
let block_id = sequencer.produce_new_block().await.unwrap();
let delivery_block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap();
assert_eq!(dispatches_in(&delivery_block), vec![key]);
assert_eq!(
pending_dispatches(&sequencer).len(),
1,
"including the delivery is not enough to settle its record"
);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
finalized: vec![(MsgId::from(delivery_block.header.hash.0), delivery_block)],
..empty_follow_update()
},
);
assert!(
pending_dispatches(&sequencer).is_empty(),
"a delivery in an irreversible block settles its record"
);
}
#[tokio::test]
async fn a_parked_finalized_block_does_not_drop_a_dispatch_record() {
// Keyed by message key, not by height: a finalized block the final tier
// parks never became irreversible, so nothing it happens to sit above may
// settle a record. Dropping one here would lose the delivery for good, since
// the watcher's floor has already moved past the peer block it came from.
let record = dispatch_record(19, ping_payload(b"parked"));
let key = record.message_key;
let delivery = dispatch_tx(19, ping_payload(b"parked"));
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
sequencer
.store
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
// A skip-ahead block carrying the same delivery: not in head and linking to
// nothing we hold, so the final tier parks it instead of applying it.
let parked =
common::test_utils::produce_dummy_block(9, Some(HashType([44; 32])), vec![delivery]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
finalized: vec![(MsgId::from([9; 32]), parked)],
..empty_follow_update()
},
);
assert_eq!(
pending_dispatches(&sequencer)
.iter()
.map(|record| record.message_key)
.collect::<Vec<_>>(),
vec![key],
"a parked finalized block must not drop its delivery's record"
);
}
#[tokio::test]
async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
let config = setup_sequencer_config();

View File

@ -586,6 +586,146 @@ async fn reconstruction_reconciles_already_finished_deposit() {
);
}
/// A cross-zone delivery whose record is still pending locally, but whose block
/// arrives already finalized on the channel. Reconstruction must settle the
/// record on the way through: the delivery is permanently reflected in the
/// reconstructed state (the inbox seen shard), so the next production neither
/// re-delivers it nor leaves a record nothing will ever drop.
#[tokio::test]
async fn reconstructed_delivery_settles_its_pending_record() {
let payload = b"reconstructed".to_vec();
let record = dispatch_record(23, ping_payload(&payload));
let key = record.message_key;
// Sequencer A produces the block that carries the delivery.
let config_a = cross_zone_test_config();
let (mut seq_a, _mempool_a) =
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
seq_a
.block_store()
.dbio()
.add_pending_cross_zone_dispatches(vec![record.clone()])
.unwrap();
seq_a.produce_new_block().await.unwrap();
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
let messages = channel_from_store(seq_a.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
let channel_id = config_a.bedrock_config.channel_id;
// Sequencer B holds the same record, as its own watcher would after reading
// the peer block, and reconstructs A's chain from a fresh store.
let (mut seq_b, _mempool_b) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
assert_eq!(
seq_b
.block_store()
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap(),
1
);
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b,
&seq_b.store,
&seq_b.chain,
true,
)
.await
.expect("reconstruct");
let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap();
assert_eq!(tip_b.id, tip_a.id);
assert_eq!(tip_b.hash, tip_a.hash);
assert!(
seq_b
.block_store()
.dbio()
.get_pending_cross_zone_dispatches()
.unwrap()
.is_empty(),
"reconstruction must settle the record of a delivery it replayed"
);
// The delivery landed exactly once, and the next turn does not re-emit it.
let record_id = ping_record_pda(programs::ping_receiver().id());
assert_eq!(
seq_b.with_state(|state| state.get_account_by_id(record_id).data.into_inner()),
payload,
"the reconstructed delivery must reach its target program"
);
seq_b.produce_new_block().await.unwrap();
let produced = seq_b
.block_store()
.get_block_at_id(tip_b.id + 1)
.unwrap()
.expect("produced block present");
assert!(
!dispatches_in(&produced).contains(&key),
"the reconstructed delivery must not be re-emitted"
);
}
/// A delivery this node published itself, served back by the channel at or below
/// its own tip. That path verifies the block matches and returns early, so it is
/// reached on every restart. It must still settle the delivery's record: the
/// channel serving the block is what makes it irreversible, and nothing later
/// will ever put that key in a block again.
#[tokio::test]
async fn a_verified_own_block_settles_its_delivery_records() {
let record = dispatch_record(37, ping_payload(b"verified"));
let key = record.message_key;
let (mut seq, _mempool) =
SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await;
seq.block_store()
.dbio()
.add_pending_cross_zone_dispatches(vec![record])
.unwrap();
let block_id = seq.produce_new_block().await.unwrap();
let block = seq
.block_store()
.get_block_at_id(block_id)
.unwrap()
.unwrap();
assert_eq!(dispatches_in(&block), vec![key]);
assert_eq!(
seq.block_store()
.dbio()
.get_pending_cross_zone_dispatches()
.unwrap()
.len(),
1,
"producing the block is not what settles the record"
);
// The channel serves our own chain back, tip included.
let messages = channel_from_store(seq.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
let mock = MockBlockPublisher::with_canned_channel(
seq.sequencer_config.bedrock_config.channel_id,
Some(tip_slot),
messages,
);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &seq.store, &seq.chain, true,
)
.await
.expect("reconstruct");
assert!(
seq.block_store()
.dbio()
.get_pending_cross_zone_dispatches()
.unwrap()
.is_empty(),
"a delivery the channel confirms must not leave a record nothing can remove"
);
}
#[tokio::test]
async fn committed_local_against_missing_channel_fails_without_anchor() {
// A sequencer that has committed blocks — a non-genesis tip plus a persisted

View File

@ -12,7 +12,11 @@ use sequencer_core::SequencerCore;
#[cfg(feature = "standalone")]
use sequencer_core::SequencerCoreWithMockClients as SequencerCore;
pub use sequencer_core::config::*;
use sequencer_core::{TransactionOrigin, block_publisher::BlockPublisherTrait as _};
use sequencer_core::{
TransactionOrigin,
block_publisher::BlockPublisherTrait as _,
task_group::{StoreRelease, TaskGroup},
};
use sequencer_service_rpc::RpcServer as _;
use tokio::{sync::Mutex, task::JoinHandle};
use tokio_util::sync::CancellationToken;
@ -26,12 +30,19 @@ const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10);
/// Implements `Drop` to ensure all tasks are aborted and the RPC server is stopped when dropped.
pub struct SequencerHandle {
addr: SocketAddr,
/// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`.
server_handle: Option<ServerHandle>,
server_handle: ServerHandle,
main_loop_handle: JoinHandle<Result<Never>>,
/// Cancelled when the publisher's drive task terminates (e.g. a panicked
/// persist sink); no channel events are processed past that point.
driver_cancellation: CancellationToken,
/// The core's background tasks, taken before the core was shared. This
/// handle owns no reference to the core itself, so without these there is
/// nothing to wait on: aborting the main loop only starts the teardown.
background_tasks: Vec<TaskGroup>,
/// The store, weakly. Every strong reference lives inside something this
/// handle stops, so watching the count go to zero is how shutdown knows the
/// database file is actually closed rather than assuming it from drop order.
store: StoreRelease,
}
impl SequencerHandle {
@ -40,29 +51,73 @@ impl SequencerHandle {
server_handle: ServerHandle,
main_loop_handle: JoinHandle<Result<Never>>,
driver_cancellation: CancellationToken,
background_tasks: Vec<TaskGroup>,
store: StoreRelease,
) -> Self {
Self {
addr,
server_handle: Some(server_handle),
server_handle,
main_loop_handle,
driver_cancellation,
background_tasks,
store,
}
}
/// Stops the sequencer and waits for every part of it to be gone.
///
/// `Drop` alone cannot do this: it aborts the main loop without awaiting it,
/// and the core lives behind `Arc`s held by that task and the RPC server, so
/// after a plain drop the store is still open for an unbounded stretch. That
/// is why restarting a sequencer on the same home directory used to need a
/// sleep, and why an in-process restart could fail outright with a `RocksDB`
/// lock error.
///
/// Order matters: the main loop stops first so nothing new is produced while
/// the publisher is torn down, then the background tasks that hold the store,
/// then the server. Consuming `self` drops the last references, so the store
/// is closed by the time this returns.
pub async fn shutdown(mut self) {
self.main_loop_handle.abort();
if let Err(err) = (&mut self.main_loop_handle).await
&& err.is_panic()
{
error!("Sequencer main loop panicked before shutdown: {err}");
}
for tasks in &self.background_tasks {
tasks.shutdown().await;
}
if let Err(err) = self.server_handle.stop() {
error!("An error occurred while stopping Sequencer RPC server: {err}");
}
self.server_handle.clone().stopped().await;
// Nothing this handle owns holds the store, so waiting here rather than
// after the drop is the same thing, and it keeps the guarantee inside
// the call the caller awaits.
wait_for_store_release(&self.store).await;
}
/// Wait for any of the sequencer tasks to fail and return the error.
#[expect(
clippy::integer_division_remainder_used,
reason = "Generated by select! macro, can't be easily rewritten to avoid this lint"
)]
pub async fn failed(mut self) -> Result<Never> {
pub async fn failed(&mut self) -> Result<Never> {
let Self {
addr: _,
server_handle,
main_loop_handle,
driver_cancellation,
} = &mut self;
background_tasks: _,
store: _,
} = self;
let server_handle = server_handle.take().expect("Server handle is set");
// Cloned rather than taken: `stopped()` consumes a handle, and taking
// this one would leave `shutdown` with no way to stop the server.
let server_handle = server_handle.clone();
tokio::select! {
() = server_handle.stopped() => {
Err(anyhow!("RPC Server stopped"))
@ -89,11 +144,16 @@ impl SequencerHandle {
server_handle,
main_loop_handle,
driver_cancellation,
background_tasks,
store: _,
} = self;
let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped)
let stopped = server_handle.is_stopped()
|| main_loop_handle.is_finished()
|| driver_cancellation.is_cancelled();
|| driver_cancellation.is_cancelled()
// A watcher only ends by panicking, and a peer whose deliveries have
// silently stopped is exactly what this predicate exists to catch.
|| background_tasks.iter().any(TaskGroup::any_finished);
!stopped
}
@ -110,20 +170,46 @@ impl Drop for SequencerHandle {
server_handle,
main_loop_handle,
driver_cancellation: _,
background_tasks: _,
store: _,
} = self;
main_loop_handle.abort();
let Some(handle) = server_handle else {
return;
};
if let Err(err) = handle.stop() {
if let Err(err) = server_handle.stop() {
error!("An error occurred while stopping Sequencer RPC server: {err}");
}
}
}
/// Waits until nothing holds the store any more.
///
/// Everything that holds one lives inside a task or a server this handle has
/// already stopped, but the last drop happens on whichever thread ran them, not
/// on this one. Without this the caller can reopen the database a moment too
/// early and hit a `RocksDB` lock error, which is the kind of failure that shows
/// up as an occasional flake rather than a bug.
async fn wait_for_store_release(store: &StoreRelease) {
/// Long enough for a drop that is already in flight, short enough that a
/// leak is reported rather than hung on.
const RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
const POLL: Duration = Duration::from_millis(10);
let released = tokio::time::timeout(RELEASE_TIMEOUT, async {
while store.holders() > 0 {
tokio::time::sleep(POLL).await;
}
})
.await;
if released.is_err() {
error!(
"Sequencer store still held by {} reference(s) after shutdown; something outlived the tasks it should have died with",
store.holders()
);
}
}
pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<SequencerHandle> {
let block_timeout = config.block_create_timeout;
let max_block_size = config.max_block_size;
@ -134,6 +220,11 @@ pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<Seq
info!("Sequencer core set up");
let driver_cancellation = sequencer_core.block_publisher().driver_cancellation();
// Taken while the core is still owned here: once it is behind the `Arc`
// below, the only owners are the RPC server and the main loop task, and
// neither hands it back.
let background_tasks = sequencer_core.background_tasks();
let store = sequencer_core.store_release();
let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core));
let mempool_handle_for_server = mempool_handle.clone();
@ -156,6 +247,8 @@ pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<Seq
server_handle,
main_loop_handle,
driver_cancellation,
background_tasks,
store,
))
}

View File

@ -6,6 +6,7 @@ use std::{
use anyhow::Result;
use clap::Parser;
use log::{error, info};
use tokio::signal::unix::{SignalKind, signal};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Parser)]
@ -40,15 +41,13 @@ async fn main() -> Result<()> {
home,
} = Args::parse();
// TODO: handle this cancellation token more gracefully within Sequencer service
// similar to how we do in Indexer
let cancellation_token = listen_for_shutdown_signal();
let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?;
if let Some(home) = home {
config.home = home;
}
let sequencer_handle =
let mut sequencer_handle =
sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?;
tokio::select! {
@ -60,21 +59,50 @@ async fn main() -> Result<()> {
}
}
// Stop the watchers, the publisher's drive task, the block loop and the RPC
// server, and wait for each. Dropping the handle only asks; the store stays
// open for an unbounded stretch after that, so a restart can find its own
// home directory locked, and a watcher can be killed between recording a
// delivery and handing it over.
sequencer_handle.shutdown().await;
info!("Sequencer shutdown complete");
Ok(())
}
/// Cancelled on Ctrl-C or `SIGTERM`.
///
/// `SIGTERM` is what a container runtime sends first, so without it every
/// orchestrated stop is the ungraceful path.
#[expect(
clippy::integer_division_remainder_used,
reason = "Generated by select! macro, can't be easily rewritten to avoid this lint"
)]
fn listen_for_shutdown_signal() -> CancellationToken {
let cancellation_token = CancellationToken::new();
let cancellation_token_clone = cancellation_token.clone();
tokio::spawn(async move {
if let Err(err) = tokio::signal::ctrl_c().await {
error!("Failed to listen for Ctrl-C signal: {err}");
return;
let mut terminate = match signal(SignalKind::terminate()) {
Ok(terminate) => terminate,
Err(err) => {
error!("Failed to listen for SIGTERM: {err}");
return;
}
};
tokio::select! {
result = tokio::signal::ctrl_c() => match result {
Ok(()) => info!("Received Ctrl-C signal"),
Err(err) => {
error!("Failed to listen for Ctrl-C signal: {err}");
return;
}
},
_ = terminate.recv() => info!("Received SIGTERM"),
}
info!("Received Ctrl-C signal");
cancellation_token_clone.cancel();
});

View File

@ -1,4 +1,8 @@
use std::{collections::BTreeMap, path::Path, sync::Arc};
use std::{
collections::BTreeMap,
path::Path,
sync::{Arc, Mutex, MutexGuard, PoisonError},
};
use borsh::{BorshDeserialize, BorshSerialize};
use common::{
@ -18,7 +22,9 @@ use crate::{
sequencer::sequencer_cells::{
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell,
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord,
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PeerFloorCellOwned, PeerFloorCellRef,
PeerZoneKey, PendingCrossZoneDispatchRecord, PendingCrossZoneDispatchesCellOwned,
PendingCrossZoneDispatchesCellRef, PendingDepositEventRecord,
PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell,
WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned,
ZoneSdkCheckpointCellRef,
@ -40,9 +46,25 @@ pub const DB_META_ZONE_CURSOR_KEY: &str = "zone_cursor";
/// Key base for storing queued deposit events that were not yet
/// fulfilled on L2.
pub const DB_META_PENDING_DEPOSIT_EVENTS_KEY: &str = "pending_deposit_events";
/// Key base for storing a cross-zone watcher's delivery floor on one peer
/// channel (opaque bytes). Keyed per peer zone.
pub const DB_META_CROSS_ZONE_PEER_FLOOR_KEY: &str = "cross_zone_peer_floor";
/// Key base for storing cross-zone deliveries the watcher has recorded but
/// which are not yet known to be irreversibly delivered.
pub const DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY: &str = "pending_cross_zone_dispatches";
/// Key base for counting unseen L2 withdraw intents.
pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count";
/// How many cross-zone deliveries may be pending at once.
///
/// The whole list is a single value, read on every block and rewritten on every
/// change, and what fills it is chosen by peer zones rather than by us. Refusing
/// to record past this bound turns "a peer decides how large our store gets"
/// into "a peer's messages wait", since a watcher that cannot record holds its
/// delivery floor and reads the slot again later.
pub const MAX_PENDING_CROSS_ZONE_DISPATCHES: usize = 4096;
/// Key base for storing the LEE state.
pub const DB_LEE_STATE_KEY: &str = "lee_state";
/// Key base for storing the LEE state at the last L1-finalized block.
@ -123,6 +145,8 @@ pub struct StoreUpdate<'update> {
pub new_deposit_events: &'update [PendingDepositEventRecord],
/// Deposit op ids whose mint finalized: their pending records are dropped.
pub remove_deposit_records: &'update [HashType],
/// Message keys whose delivery finalized: their pending records are dropped.
pub remove_dispatch_records: &'update [[u8; 32]],
/// L1 withdraw events to reconcile against the local unseen counters.
pub consumed_withdrawals: &'update [WithdrawalReconciliationKey],
/// L2 withdraw intents this update raises, awaiting their L1 event.
@ -146,6 +170,7 @@ impl<'update> StoreUpdate<'update> {
finalized_up_to: None,
new_deposit_events: &[],
remove_deposit_records: &[],
remove_dispatch_records: &[],
consumed_withdrawals: &[],
new_withdraw_intents: &[],
zone_anchor: None,
@ -165,8 +190,21 @@ pub struct StoreUpdateOutcome {
pub unmatched_withdrawals: Vec<WithdrawalReconciliationKey>,
}
#[expect(
clippy::partial_pub_fields,
reason = "the pending-record lock is an implementation detail and must stay private"
)]
pub struct RocksDBIO {
pub db: DBWithThreadMode<MultiThreaded>,
/// Serializes the read-modify-write cycles over the pending cross-zone
/// dispatch list.
///
/// The list is a single value holding the whole `Vec`, and three tasks
/// rewrite it: the watcher recording a delivery, the production loop
/// counting a failed attempt, and the publisher's drive task settling
/// finalized deliveries. Rocksdb makes the write atomic, not the cycle, so
/// without this the writer that read first silently drops the others.
pending_records: Mutex<()>,
}
impl DBIO for RocksDBIO {
@ -176,6 +214,18 @@ impl DBIO for RocksDBIO {
}
impl RocksDBIO {
/// Held across a pending-record read-modify-write. See
/// [`RocksDBIO::pending_records`].
///
/// A poisoned lock is recovered rather than propagated: the records behind
/// it are a plain `Vec` that a panicking writer cannot leave half-written,
/// since the write is a single rocksdb put.
fn lock_pending_records(&self) -> MutexGuard<'_, ()> {
self.pending_records
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
pub fn open(path: &Path) -> DbResult<Self> {
let db_opts = Options::default();
Self::open_inner(path, &db_opts)
@ -289,7 +339,10 @@ impl RocksDBIO {
additional_info: Some("Failed to open or create DB".to_owned()),
})?;
let dbio = Self { db };
let dbio = Self {
db,
pending_records: Mutex::new(()),
};
Ok(dbio)
}
@ -539,6 +592,185 @@ impl RocksDBIO {
Ok(accepted)
}
/// One cross-zone watcher's delivery floor on `peer_zone`'s channel, or
/// `None` before it has delivered anything from that peer.
pub fn get_cross_zone_peer_floor_bytes(
&self,
peer_zone: PeerZoneKey,
) -> DbResult<Option<Vec<u8>>> {
Ok(self
.get_opt::<PeerFloorCellOwned>(peer_zone)?
.map(|cell| cell.0))
}
pub fn put_cross_zone_peer_floor_bytes(
&self,
peer_zone: PeerZoneKey,
bytes: &[u8],
) -> DbResult<()> {
self.put(&PeerFloorCellRef(bytes), peer_zone)
}
pub fn get_pending_cross_zone_dispatches(
&self,
) -> DbResult<Vec<PendingCrossZoneDispatchRecord>> {
Ok(self
.get_opt::<PendingCrossZoneDispatchesCellOwned>(())?
.map_or_else(Vec::new, |cell| cell.0))
}
fn put_pending_cross_zone_dispatches(
&self,
records: &[PendingCrossZoneDispatchRecord],
) -> DbResult<()> {
self.put(&PendingCrossZoneDispatchesCellRef(records), ())
}
fn put_pending_cross_zone_dispatches_batch(
&self,
records: &[PendingCrossZoneDispatchRecord],
batch: &mut WriteBatch,
) -> DbResult<()> {
self.put_batch(&PendingCrossZoneDispatchesCellRef(records), (), batch)
}
/// Records every delivery one peer block carries, in a single write.
///
/// Returns how many were new. Ones already recorded are skipped, so a slot
/// the watcher re-reads is not double-tracked.
///
/// Batched rather than one call per delivery because the whole list is one
/// value: recording a block's messages one at a time rewrites the list once
/// per message, which is quadratic in a block that carries many.
///
/// Fails without writing anything if the list would exceed
/// [`MAX_PENDING_CROSS_ZONE_DISPATCHES`]. The caller's floor then stays put
/// and the slot is read again later, which is the difference between
/// backpressure and an unbounded list a peer controls the size of.
pub fn add_pending_cross_zone_dispatches(
&self,
dispatches: Vec<PendingCrossZoneDispatchRecord>,
) -> DbResult<usize> {
if dispatches.is_empty() {
return Ok(0);
}
let _pending = self.lock_pending_records();
let mut records = self.get_pending_cross_zone_dispatches()?;
let before = records.len();
for dispatch in dispatches {
if records
.iter()
.any(|record| record.message_key == dispatch.message_key)
{
continue;
}
records.push(dispatch);
}
let accepted = records.len().saturating_sub(before);
if accepted == 0 {
return Ok(0);
}
if records.len() > MAX_PENDING_CROSS_ZONE_DISPATCHES {
return Err(DbError::db_interaction_error(format!(
"Refusing to hold more than {MAX_PENDING_CROSS_ZONE_DISPATCHES} pending cross-zone deliveries; {before} already pending"
)));
}
self.put_pending_cross_zone_dispatches(&records)?;
Ok(accepted)
}
/// Counts a failed production attempt against a delivery, dropping its
/// record once it reaches `retire_at`. Returns whether it was dropped.
///
/// Dropped rather than flagged: a retired record is one the drain will never
/// turn into a block transaction again, so nothing would ever remove it, and
/// a peer that can make deliveries fail could grow the list without bound.
/// The delivery is given up on either way; this way the cost is a log line
/// rather than a permanent entry.
///
/// A delivery with no record is already retired as far as this is concerned:
/// there is nothing left to count against.
pub fn record_dispatch_failure(&self, message_key: [u8; 32], retire_at: u32) -> DbResult<bool> {
let _pending = self.lock_pending_records();
let mut records = self.get_pending_cross_zone_dispatches()?;
let Some(position) = records
.iter()
.position(|record| record.message_key == message_key)
else {
return Ok(true);
};
let attempts = {
let record = &mut records[position];
record.failed_attempts = record.failed_attempts.saturating_add(1);
record.failed_attempts
};
let retired = attempts >= retire_at;
if retired {
records.remove(position);
}
self.put_pending_cross_zone_dispatches(&records)?;
Ok(retired)
}
/// Drops the records of deliveries that are settled for good, outside any
/// store update.
///
/// The settlement path in [`Self::store_update`] catches a delivery as its
/// block becomes irreversible. This catches the ones that path cannot: a
/// record re-added after its delivery had already settled, which the watcher
/// does whenever it re-reads a slot it has already consumed. Nothing would
/// ever put such a key in a block again, so without this it stays for ever.
pub fn drop_settled_cross_zone_dispatches(&self, message_keys: &[[u8; 32]]) -> DbResult<usize> {
if message_keys.is_empty() {
return Ok(0);
}
let _pending = self.lock_pending_records();
let to_remove: std::collections::HashSet<&[u8; 32]> = message_keys.iter().collect();
let mut records = self.get_pending_cross_zone_dispatches()?;
let before = records.len();
records.retain(|record| !to_remove.contains(&record.message_key));
let removed = before.saturating_sub(records.len());
if removed > 0 {
self.put_pending_cross_zone_dispatches(&records)?;
}
Ok(removed)
}
/// Drops the pending records of deliveries that just became irreversible,
/// staged into `batch` so they go with the update that made them so.
///
/// Removal only, unlike [`Self::stage_pending_deposit_events`]: a delivery is
/// recorded by the watcher through
/// [`Self::add_pending_cross_zone_dispatch`], on its own task and outside
/// any store update, so nothing ever adds one here.
fn stage_removed_dispatches(
&self,
remove_keys: &[[u8; 32]],
batch: &mut WriteBatch,
) -> DbResult<usize> {
if remove_keys.is_empty() {
return Ok(0);
}
let to_remove: std::collections::HashSet<&[u8; 32]> = remove_keys.iter().collect();
let mut records = self.get_pending_cross_zone_dispatches()?;
let before = records.len();
records.retain(|record| !to_remove.contains(&record.message_key));
let removed = before.saturating_sub(records.len());
if removed > 0 {
self.put_pending_cross_zone_dispatches_batch(&records, batch)?;
}
Ok(removed)
}
/// Stages the unseen-withdraw decrements for one update into `batch`,
/// returning one entry per occurrence that matched no local counter.
///
@ -875,6 +1107,7 @@ impl RocksDBIO {
/// one, most carry nothing else) must not drag a full state serialization
/// with it.
pub fn store_update(&self, update: &StoreUpdate<'_>) -> DbResult<StoreUpdateOutcome> {
let _pending = self.lock_pending_records();
let StoreUpdate {
checkpoint,
blocks,
@ -884,6 +1117,7 @@ impl RocksDBIO {
finalized_up_to,
new_deposit_events,
remove_deposit_records,
remove_dispatch_records,
consumed_withdrawals,
new_withdraw_intents,
zone_anchor,
@ -940,6 +1174,7 @@ impl RocksDBIO {
remove_deposit_records,
&mut batch,
)?;
self.stage_removed_dispatches(remove_dispatch_records, &mut batch)?;
let unmatched_withdrawals =
self.stage_consumed_withdrawals(consumed_withdrawals, &mut batch)?;
self.stage_new_withdraw_intents(new_withdraw_intents, &mut batch)?;

View File

@ -8,7 +8,8 @@ use crate::{
error::DbError,
sequencer::{
CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY,
DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY,
DB_META_CROSS_ZONE_PEER_FLOOR_KEY, DB_META_LAST_FINALIZED_BLOCK_ID,
DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY,
DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY,
DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
},
@ -245,6 +246,82 @@ pub struct PendingDepositEventRecord {
pub metadata: Vec<u8>,
}
/// A cross-zone delivery the watcher has read off a peer block but which is not
/// yet known to be irreversibly delivered.
///
/// The watcher's delivery floor is durable, so once it advances past a peer
/// block that block is never re-read. This record is what stands in its place:
/// block production drains it every turn, and it survives a restart. Mirrors
/// [`PendingDepositEventRecord`], which solves the same problem for deposits,
/// and like it carries no "submitted" mark: the record is dropped when the
/// delivery itself finalizes, and re-including one meanwhile is harmless
/// because the inbox no-ops a replay on chain.
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct PendingCrossZoneDispatchRecord {
/// Content-addressed replay key of the delivered message, and this record's
/// identity.
pub message_key: [u8; 32],
/// The borsh-encoded dispatch transaction, so production can re-feed it
/// without re-reading the peer channel.
pub transaction: Vec<u8>,
/// Production attempts that ended in an execution failure.
///
/// A dispatch's payload and target accounts are chosen on the peer zone and
/// validated by nobody in between, so one can fail for good. A failure can
/// equally be a property of the moment, so a single one is not enough to
/// give up on a delivery. Once too many accumulate the record is dropped
/// rather than flagged, since a delivery nothing will retry is also a
/// delivery nothing would ever remove.
pub failed_attempts: u32,
}
impl PendingCrossZoneDispatchRecord {
/// A delivery the watcher has just read: never attempted.
#[must_use]
pub const fn recorded(message_key: [u8; 32], transaction: Vec<u8>) -> Self {
Self {
message_key,
transaction,
failed_attempts: 0,
}
}
}
#[derive(BorshDeserialize)]
pub struct PendingCrossZoneDispatchesCellOwned(pub Vec<PendingCrossZoneDispatchRecord>);
impl SimpleStorableCell for PendingCrossZoneDispatchesCellOwned {
type KeyParams = ();
const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleReadableCell for PendingCrossZoneDispatchesCellOwned {}
#[derive(BorshSerialize)]
pub struct PendingCrossZoneDispatchesCellRef<'records>(
pub &'records [PendingCrossZoneDispatchRecord],
);
impl SimpleStorableCell for PendingCrossZoneDispatchesCellRef<'_> {
type KeyParams = ();
const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleWritableCell for PendingCrossZoneDispatchesCellRef<'_> {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(
err,
Some("Failed to serialize pending cross-zone dispatches cell".to_owned()),
)
})
}
}
#[derive(BorshDeserialize)]
pub struct PendingDepositEventsCellOwned(pub Vec<PendingDepositEventRecord>);
@ -278,6 +355,72 @@ impl SimpleWritableCell for PendingDepositEventsCellRef<'_> {
}
}
/// Identifies which peer channel a cross-zone watcher cursor belongs to. The
/// 32-byte peer channel id doubles as the peer's zone id.
pub type PeerZoneKey = [u8; 32];
/// Opaque bytes for one peer's cross-zone read cursor. As with the zone-sdk
/// checkpoint, the caller owns the encoding, since the cursor type derives serde
/// rather than borsh.
#[derive(BorshDeserialize)]
pub struct PeerFloorCellOwned(pub Vec<u8>);
impl SimpleStorableCell for PeerFloorCellOwned {
type KeyParams = PeerZoneKey;
const CELL_NAME: &'static str = DB_META_CROSS_ZONE_PEER_FLOOR_KEY;
const CF_NAME: &'static str = CF_META_NAME;
/// Folds the peer zone into the key so each peer keeps its own cursor.
fn key_constructor(peer_zone: Self::KeyParams) -> DbResult<Vec<u8>> {
borsh::to_vec(&(Self::CELL_NAME, peer_zone)).map_err(|err| {
DbError::borsh_cast_message(
err,
Some(format!(
"Failed to serialize {:?} key params",
Self::CELL_NAME
)),
)
})
}
}
impl SimpleReadableCell for PeerFloorCellOwned {}
#[derive(BorshSerialize)]
pub struct PeerFloorCellRef<'bytes>(pub &'bytes [u8]);
impl SimpleStorableCell for PeerFloorCellRef<'_> {
type KeyParams = PeerZoneKey;
const CELL_NAME: &'static str = DB_META_CROSS_ZONE_PEER_FLOOR_KEY;
const CF_NAME: &'static str = CF_META_NAME;
/// Folds the peer zone into the key so each peer keeps its own cursor.
fn key_constructor(peer_zone: Self::KeyParams) -> DbResult<Vec<u8>> {
borsh::to_vec(&(Self::CELL_NAME, peer_zone)).map_err(|err| {
DbError::borsh_cast_message(
err,
Some(format!(
"Failed to serialize {:?} key params",
Self::CELL_NAME
)),
)
})
}
}
impl SimpleWritableCell for PeerFloorCellRef<'_> {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(
err,
Some("Failed to serialize cross-zone peer floor cell".to_owned()),
)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WithdrawalReconciliationKey {
pub amount: u64,

View File

@ -37,6 +37,17 @@ fn deposit_record(seed: u8) -> PendingDepositEventRecord {
}
}
fn dispatch_record(seed: u8) -> PendingCrossZoneDispatchRecord {
PendingCrossZoneDispatchRecord::recorded([seed; 32], vec![seed; 4])
}
/// A distinct message key per index, for filling the pending list.
fn key_from_index(index: usize) -> [u8; 32] {
let mut key = [0_u8; 32];
key[..8].copy_from_slice(&u64::try_from(index).expect("test index fits").to_le_bytes());
key
}
fn stored_balance(dbio: &RocksDBIO) -> u128 {
dbio.get_lee_state()
.unwrap()
@ -399,6 +410,171 @@ fn finalized_deposit_records_are_removed_by_op_id() {
assert_eq!(stored, vec![second]);
}
#[test]
fn dispatch_records_round_trip_and_dedupe_by_message_key() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let record = dispatch_record(1);
assert_eq!(
dbio.add_pending_cross_zone_dispatches(vec![record.clone()])
.unwrap(),
1
);
// The watcher re-reads a slot it stalled on, so the same delivery arrives
// again; recording it twice would double-count its failed attempts.
assert_eq!(
dbio.add_pending_cross_zone_dispatches(vec![record.clone(), dispatch_record(2)])
.unwrap(),
1,
"only the delivery not already held is newly recorded"
);
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![record, dispatch_record(2)]
);
}
#[test]
fn recording_past_the_cap_writes_nothing() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
// What fills this list is chosen by peer zones, so the bound is what stops a
// peer deciding how large our store gets. Refusing the whole write leaves
// the watcher's floor where it is, so the slot is read again later and
// nothing is lost.
let full: Vec<_> = (0..MAX_PENDING_CROSS_ZONE_DISPATCHES)
.map(|seed| PendingCrossZoneDispatchRecord::recorded(key_from_index(seed), vec![0_u8; 4]))
.collect();
assert_eq!(
dbio.add_pending_cross_zone_dispatches(full).unwrap(),
MAX_PENDING_CROSS_ZONE_DISPATCHES
);
let over = PendingCrossZoneDispatchRecord::recorded(
key_from_index(MAX_PENDING_CROSS_ZONE_DISPATCHES),
vec![0_u8; 4],
);
assert!(
dbio.add_pending_cross_zone_dispatches(vec![over]).is_err(),
"recording past the cap must fail so the caller holds its floor"
);
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap().len(),
MAX_PENDING_CROSS_ZONE_DISPATCHES,
"a refused write must leave the list untouched"
);
// Re-offering only what is already held is not growth, so it still succeeds.
assert_eq!(
dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded(
key_from_index(0),
vec![0_u8; 4]
)])
.unwrap(),
0
);
}
#[test]
fn settled_dispatch_records_are_dropped_outside_an_update() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
// The watcher re-reads a slot it already consumed and re-records a delivery
// that settled long ago. Its key will never appear in a future block, so the
// store-update path cannot reach it and this is the only thing that does.
let first = dispatch_record(1);
let second = dispatch_record(2);
dbio.add_pending_cross_zone_dispatches(vec![first.clone(), second.clone()])
.unwrap();
assert_eq!(
dbio.drop_settled_cross_zone_dispatches(&[first.message_key])
.unwrap(),
1
);
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![second]
);
// Dropping one that is already gone is a no-op, not an error.
assert_eq!(
dbio.drop_settled_cross_zone_dispatches(&[first.message_key])
.unwrap(),
0
);
}
#[test]
fn finalized_dispatch_records_are_removed_by_message_key() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let first = dispatch_record(1);
let second = dispatch_record(2);
dbio.add_pending_cross_zone_dispatches(vec![first.clone(), second.clone()])
.unwrap();
// Only the finalized delivery's key is dropped. Two deliveries can sit in
// the same block, so a record must go by its own identity rather than by
// anything about the height its delivery landed at.
dbio.store_update(&StoreUpdate {
remove_dispatch_records: &[first.message_key],
..StoreUpdate::new(&state_with_balance(100))
})
.unwrap();
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![second]
);
}
#[test]
fn record_dispatch_failure_drops_the_record_at_the_limit() {
let temp_dir = tempdir().unwrap();
let (dbio, _genesis) = dbio_with_genesis(temp_dir.path());
let record = dispatch_record(1);
let key = record.message_key;
let survivor = dispatch_record(2);
dbio.add_pending_cross_zone_dispatches(vec![record, survivor.clone()])
.unwrap();
assert!(!dbio.record_dispatch_failure(key, 3).unwrap());
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap()[0].failed_attempts,
1,
"a failure short of the limit is counted, not given up on"
);
assert!(!dbio.record_dispatch_failure(key, 3).unwrap());
assert!(
dbio.record_dispatch_failure(key, 3).unwrap(),
"the third failure is the one it is given up on"
);
// Dropped rather than flagged: a delivery the drain will never feed into a
// block again is one nothing would ever remove, so flagging it would let a
// peer that can make deliveries fail grow the list without bound.
assert_eq!(
dbio.get_pending_cross_zone_dispatches().unwrap(),
vec![survivor],
"giving up on a delivery drops its record and leaves the others alone"
);
// A key with no record reads as given up on: there is nothing left to count
// against, and nothing will feed it into a block.
assert!(
dbio.record_dispatch_failure(key, 3).unwrap(),
"a failure against a dropped delivery must not re-create its record"
);
assert_eq!(dbio.get_pending_cross_zone_dispatches().unwrap().len(), 1);
}
#[test]
fn repeated_withdrawal_key_in_one_update_folds_once_per_occurrence() {
let temp_dir = tempdir().unwrap();

View File

@ -217,7 +217,7 @@ impl Drop for TestContext {
temp_wallet_dir: _,
} = self;
let sequencer_handle = sequencer_handle
let mut sequencer_handle = sequencer_handle
.take()
.expect("Sequencer handle should be present in TestContext drop");
if !sequencer_handle.is_healthy() {