use std::cmp::Ordering;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::MutexGuard;
use std::sync::mpsc::SyncSender;
use beamr::process::ExitReason;
use beamr::term::Term;
use beamr::{NativeContext, NativeHandler, NativeOutcome};
use crate::db::root_advance::RootTransition;
use crate::shard::actor::ShardActor;
use crate::shard::commit_state::ShardCommitState;
use crate::store::DiskStore;
use crate::tree::{Cursor, Hash, empty_root_hash};
use crate::ttl::filter::{Visibility, visible_value};
use crate::wal::{DurableWal, FsyncPolicy, Mutation, WalRecovery};
use super::expiry_index::{ArmError, DeadlineScheduler, Generation};
use super::handle::{
AppendReply, CommandQueue, CommitReply, MergeAdoptReply, RangeItem, ShardCommand,
ShardCommandKind, ShardError, StreamSeq,
};
use super::{GroupOutcome, GroupWrite};
type GroupReply = SyncSender<Result<Option<RootTransition>, ShardError>>;
pub struct ShardNativeHandler {
state: Option<ShardState>,
startup_error: Option<ShardError>,
commands: CommandQueue,
}
struct ShardState {
actor: ShardActor,
store: DiskStore,
commit_state: Arc<ShardCommitState>,
}
impl ShardState {
fn publish_commit(&self, before: Option<Hash>) -> Option<RootTransition> {
let after = self.actor.committed_root();
let prior_root = before.unwrap_or_else(empty_root_hash);
let new_root = after.unwrap_or_else(empty_root_hash);
let advanced = prior_root != new_root;
self.commit_state
.publish_committed(after, advanced)
.map(|advance_gen| RootTransition {
prior_root,
new_root,
advance_gen,
})
}
fn publish_after_commit(&self, before: Option<Hash>) -> Option<RootTransition> {
if self.actor.buffer().is_empty() {
self.publish_commit(before)
} else {
self.publish_from_buffer();
None
}
}
fn commit_or_rollback(
&self,
result: Result<(), ShardError>,
before: Option<Hash>,
) -> Result<Option<RootTransition>, ShardError> {
match result {
Ok(()) => Ok(self.publish_after_commit(before)),
Err(error) => {
self.publish_from_buffer();
Err(error)
}
}
}
fn publish_from_buffer(&self) {
self.commit_state
.sync_from_buffer(self.actor.buffer().is_empty());
}
fn publish_unreconciled(&self) {
self.commit_state.mark_unreconciled();
}
}
#[cfg(test)]
fn assert_sealed(state: &ShardState) {
let snapshot = state.commit_state.snapshot();
let clean = !(snapshot.recovering
|| snapshot.unreconciled
|| snapshot.committed_gen != snapshot.dirty_gen
|| snapshot.root.is_none());
if clean {
assert!(
state.actor.buffer().is_empty(),
"sealed seam: shard classified clean but its WAL buffer is non-empty"
);
}
}
#[cfg(not(test))]
const fn assert_sealed(_state: &ShardState) {}
impl ShardNativeHandler {
pub(super) fn make_factory(
store_dir: PathBuf,
wal_path: PathBuf,
commands: CommandQueue,
policy: crate::tree::TreePolicy,
commit_state: Arc<ShardCommitState>,
) -> beamr::native::native_process::NativeHandlerFactory {
Box::new(move || {
Box::new(Self::build(
&store_dir,
&wal_path,
Arc::clone(&commands),
policy,
Arc::clone(&commit_state),
))
})
}
fn build(
store_dir: &Path,
wal_path: &Path,
commands: CommandQueue,
policy: crate::tree::TreePolicy,
commit_state: Arc<ShardCommitState>,
) -> Self {
commit_state.begin_recovery();
#[cfg(test)]
crate::shard::commit_state::pause_at_seam_barrier(
&commit_state,
crate::shard::commit_state::SeamBarrier::Recovery,
);
match Self::boot(store_dir, wal_path, policy, commit_state) {
Ok(state) => {
state.commit_state.publish_recovered(
state.actor.committed_root(),
state.actor.buffer().is_empty(),
);
Self {
state: Some(state),
startup_error: None,
commands,
}
}
Err(error) => Self {
state: None,
startup_error: Some(error),
commands,
},
}
}
fn boot(
store_dir: &Path,
wal_path: &Path,
policy: crate::tree::TreePolicy,
commit_state: Arc<ShardCommitState>,
) -> Result<ShardState, ShardError> {
let store = DiskStore::new_unfenced(store_dir)?;
let recovered = WalRecovery::recover_path(wal_path, &store)?;
let wal = DurableWal::new(wal_path, FsyncPolicy::CommitOnly)?;
let actor = ShardActor::from_recovered(wal, recovered, &store, policy)?;
Ok(ShardState {
actor,
store,
commit_state,
})
}
fn pop_and_execute(
state: &mut ShardState,
commands: &CommandQueue,
ctx: &mut NativeContext<'_>,
) -> Option<NativeOutcome> {
let outcome = match pop_next_unit(commands)? {
DrainUnit::Group(group) => {
state.run_group(group, ctx);
None
}
DrainUnit::Single(command) => state.execute(command, ctx),
};
assert_sealed(state);
outcome
}
}
enum DrainUnit {
Group(Vec<(GroupWrite, GroupReply)>),
Single(ShardCommand),
}
fn pop_next_unit(commands: &CommandQueue) -> Option<DrainUnit> {
let mut queue = lock_queue(commands);
if !queue
.front()
.is_some_and(|command| is_groupable(&command.kind))
{
return queue.pop_front().map(DrainUnit::Single);
}
let mut group = Vec::new();
while queue
.front()
.is_some_and(|command| is_groupable(&command.kind))
{
if let Some(command) = queue.pop_front()
&& let Some(entry) = into_group_write(command.kind)
{
group.push(entry);
}
}
Some(DrainUnit::Group(group))
}
const fn is_groupable(kind: &ShardCommandKind) -> bool {
matches!(
kind,
ShardCommandKind::Cas { .. }
| ShardCommandKind::ApplyDurable { .. }
| ShardCommandKind::ApplyDurableTombstone { .. }
)
}
fn into_group_write(kind: ShardCommandKind) -> Option<(GroupWrite, GroupReply)> {
match kind {
ShardCommandKind::Cas {
key,
expected,
new,
reply,
} => Some((GroupWrite::Cas { key, expected, new }, reply)),
ShardCommandKind::ApplyDurable {
key,
expected,
value,
ttl,
stamp,
reply,
} => Some((
GroupWrite::ApplyValue {
key,
expected,
value,
ttl,
stamp,
},
reply,
)),
ShardCommandKind::ApplyDurableTombstone {
key,
expected,
stamp,
reply,
} => Some((
GroupWrite::ApplyTombstone {
key,
expected,
stamp,
},
reply,
)),
_ => None,
}
}
struct NativeDeadlineScheduler<'context, 'process> {
context: &'context mut NativeContext<'process>,
}
impl DeadlineScheduler for NativeDeadlineScheduler<'_, '_> {
fn schedule(
&mut self,
delay: std::time::Duration,
generation: Generation,
) -> Result<(), ArmError> {
let value =
i64::try_from(generation.value()).map_err(|_error| ArmError::TokenUnrepresentable)?;
let token = Term::try_small_int(value).ok_or(ArmError::TokenUnrepresentable)?;
self.context
.schedule(delay, token)
.map(drop)
.ok_or(ArmError::SchedulerUnavailable)
}
}
#[cfg(test)]
pub(super) fn schedule_probe(
context: &mut NativeContext<'_>,
delay: std::time::Duration,
token: u64,
) -> Result<(), ArmError> {
let generation = Generation::from_value(token).ok_or(ArmError::TokenUnrepresentable)?;
NativeDeadlineScheduler { context }.schedule(delay, generation)
}
impl ShardState {
fn arm_pending(&mut self, ctx: &mut NativeContext<'_>) {
let mut scheduler = NativeDeadlineScheduler { context: ctx };
if let Err(error) = self.actor.arm_expiry(&mut scheduler) {
log::error!("shard TTL deadline arm refused: {error}");
}
}
fn receive_deadline(&mut self, generation: Generation, ctx: &mut NativeContext<'_>) {
let Some(due) = self.actor.begin_expiry_deadline(generation) else {
return;
};
for (position, (_deadline, key)) in due.iter().enumerate() {
self.actor.inspect_expiry_key();
let (reply, response) = std::sync::mpsc::sync_channel(1);
let command = ShardCommand {
id: 0,
kind: ShardCommandKind::DeleteIfExpired {
key: key.clone(),
reply,
},
};
let _outcome = self.execute(command, ctx);
match response.recv() {
Ok(Ok(_removed)) => {}
Ok(Err(error)) => {
log::error!("shard TTL deadline delete failed: {error}");
self.actor.restore_detached(&due[position..]);
break;
}
Err(error) => {
log::error!("shard TTL deadline delete reply failed: {error}");
self.actor.restore_detached(&due[position..]);
break;
}
}
}
self.actor.finish_expiry_deadline();
self.arm_pending(ctx);
}
fn run_group(&mut self, group: Vec<(GroupWrite, GroupReply)>, ctx: &mut NativeContext<'_>) {
let mut writes = Vec::with_capacity(group.len());
let mut replies = Vec::with_capacity(group.len());
for (write, reply) in group {
writes.push(write);
replies.push(reply);
}
let before = self.actor.committed_root();
let outcomes = self.actor.apply_group(writes, &mut self.store);
self.arm_pending(ctx);
let mut advance = self.publish_after_commit(before);
for (reply, outcome) in replies.into_iter().zip(outcomes) {
let payload = match outcome {
GroupOutcome::Committed => Ok(advance.take()),
GroupOutcome::Rejected(error) | GroupOutcome::CommitFailed(error) => Err(error),
};
drop(reply.send(payload));
}
}
fn run_commit(&mut self, reply: &CommitReply) {
if self.actor.buffer().is_empty()
&& !self.commit_state.is_unreconciled()
&& let Some(root) = self.actor.committed_root()
{
self.publish_from_buffer();
drop(reply.send(Ok((root, None))));
return;
}
let before = self.actor.committed_root();
let result = self.actor.commit(&mut self.store).map_err(ShardError::from);
#[cfg(test)]
if result.is_ok() {
crate::shard::commit_state::pause_at_seam_barrier(
&self.commit_state,
crate::shard::commit_state::SeamBarrier::PostWalCommit,
);
}
let payload = match result {
Ok(root) => Ok((root, self.publish_after_commit(before))),
Err(error) => {
self.publish_from_buffer();
Err(error)
}
};
drop(reply.send(payload));
}
fn run_append(
&mut self,
key: &[u8],
entries: Vec<Vec<u8>>,
expected_seq: u64,
ttl: Option<std::time::Duration>,
reply: &AppendReply,
ctx: &mut NativeContext<'_>,
) {
let before = self.actor.committed_root();
let result = self
.actor
.append(key, entries, expected_seq, ttl, &mut self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
let payload = match result {
Ok(new_seq) => Ok((new_seq, self.publish_after_commit(before))),
Err(error) => {
self.publish_from_buffer();
Err(error)
}
};
drop(reply.send(payload));
}
fn run_merge_adopt(
&mut self,
promisers: &[(Option<Hash>, Vec<crate::sync::NodeTransfer>)],
reply: &MergeAdoptReply,
ctx: &mut NativeContext<'_>,
) {
let before = self.actor.committed_root();
let result = self
.actor
.merge_adopt(promisers, &mut self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
let payload = match result {
Ok(adopted) => Ok((adopted, self.publish_after_commit(before))),
Err(error) => {
self.publish_unreconciled();
Err(error)
}
};
drop(reply.send(payload));
}
fn execute(
&mut self,
command: ShardCommand,
ctx: &mut NativeContext<'_>,
) -> Option<NativeOutcome> {
match command.kind {
ShardCommandKind::Get { key, reply } => {
let result = self.actor.get(&key, &self.store).map_err(ShardError::from);
drop(reply.send(result));
None
}
ShardCommandKind::GetRaw { key, reply } => {
let result = self
.actor
.get_raw(&key, &self.store)
.map_err(ShardError::from);
drop(reply.send(result));
None
}
ShardCommandKind::Put {
key,
value,
ttl,
reply,
} => {
let result = self
.actor
.put_with_ttl(key, value, ttl, &self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
self.publish_from_buffer();
drop(reply.send(result));
None
}
ShardCommandKind::Delete { key, stamp, reply } => {
let result = self
.actor
.delete(key, stamp, &self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
self.publish_from_buffer();
drop(reply.send(result));
None
}
ShardCommandKind::DeleteIfExpired { key, reply } => {
let result = self
.actor
.delete_if_expired(&key, &self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
self.publish_from_buffer();
drop(reply.send(result));
None
}
ShardCommandKind::Commit { reply } => {
self.run_commit(&reply);
None
}
range @ (ShardCommandKind::Range { .. } | ShardCommandKind::HasLiveInRange(..)) => {
self.execute_range(range)
}
ShardCommandKind::Append {
key,
entries,
expected_seq,
ttl,
reply,
} => {
self.run_append(&key, entries, expected_seq, ttl, &reply, ctx);
None
}
ShardCommandKind::ReadValue { key, reply } => {
let result = self
.actor
.read_value(&key, &self.store)
.map_err(ShardError::from);
drop(reply.send(result));
None
}
ShardCommandKind::ReadPromiseState { reply } => {
drop(reply.send(Ok(self.actor.promise_state())));
None
}
extra @ (ShardCommandKind::Cas { .. }
| ShardCommandKind::ApplyDurable { .. }
| ShardCommandKind::ApplyDurableTombstone { .. }
| ShardCommandKind::ApplyDurableBatch { .. }
| ShardCommandKind::RecordPromise { .. }
| ShardCommandKind::RecordOwnerEpoch { .. }
| ShardCommandKind::ReserveMinted { .. }
| ShardCommandKind::ExportReachable { .. }
| ShardCommandKind::MergeAdopt { .. }) => self.execute_extra(extra, ctx),
ShardCommandKind::ScanSequences { reply } => {
drop(reply.send(self.scan_sequences()));
None
}
ShardCommandKind::Shutdown { reply } => {
self.actor.invalidate_expiry_on_shutdown();
drop(reply.send(Ok(())));
Some(NativeOutcome::Stop(ExitReason::Normal))
}
}
}
fn execute_extra(
&mut self,
command: ShardCommandKind,
ctx: &mut NativeContext<'_>,
) -> Option<NativeOutcome> {
match command {
ShardCommandKind::Cas {
key,
expected,
new,
reply,
} => {
let before = self.actor.committed_root();
let result = self
.actor
.cas(&key, expected, new, &mut self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
let payload = self.commit_or_rollback(result, before);
drop(reply.send(payload));
}
ShardCommandKind::ApplyDurable {
key,
expected,
value,
ttl,
stamp,
reply,
} => {
let before = self.actor.committed_root();
let result = self
.actor
.apply_durable(&key, expected, value, ttl, stamp, &mut self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
let payload = self.commit_or_rollback(result, before);
drop(reply.send(payload));
}
ShardCommandKind::ApplyDurableTombstone {
key,
expected,
stamp,
reply,
} => {
let before = self.actor.committed_root();
let result = self
.actor
.apply_durable_tombstone(&key, expected, stamp, &mut self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
let payload = self.commit_or_rollback(result, before);
drop(reply.send(payload));
}
ShardCommandKind::ApplyDurableBatch {
items,
stamp,
reply,
} => {
let before = self.actor.committed_root();
let result = self
.actor
.apply_durable_batch(items, stamp, &mut self.store)
.map_err(ShardError::from);
self.arm_pending(ctx);
let payload = self.commit_or_rollback(result, before);
drop(reply.send(payload));
}
ShardCommandKind::RecordPromise { ballot, reply } => {
let result = self.actor.record_promise(ballot).map_err(ShardError::from);
drop(reply.send(result));
}
ShardCommandKind::RecordOwnerEpoch { ballot, reply } => {
let result = self
.actor
.record_owner_epoch(ballot)
.map_err(ShardError::from);
drop(reply.send(result));
}
ShardCommandKind::ReserveMinted { counter, reply } => {
let result = self.actor.reserve_minted(counter).map_err(ShardError::from);
drop(reply.send(result));
}
ShardCommandKind::ExportReachable { shard_id, reply } => {
let result = self
.actor
.export_reachable(shard_id, &self.store)
.map_err(ShardError::from);
drop(reply.send(result));
}
ShardCommandKind::MergeAdopt { promisers, reply } => {
self.run_merge_adopt(&promisers, &reply, ctx);
}
_ => {}
}
None
}
fn scan_sequences(&self) -> Result<Vec<StreamSeq>, ShardError> {
self.actor.scan_sequences()
}
fn execute_range(&self, command: ShardCommandKind) -> Option<NativeOutcome> {
match command {
ShardCommandKind::Range { from, to, reply } => {
drop(reply.send(self.collect_range(&from, &to)));
}
ShardCommandKind::HasLiveInRange(from, to, reply) => {
let result = super::liveness::has_live_in_range(
&self.store,
self.actor.committed_root(),
self.actor.buffer(),
from.as_slice(),
to.as_slice(),
);
drop(reply.send(result));
}
_ => {}
}
None
}
fn collect_range(&self, from: &[u8], to: &[u8]) -> Result<Vec<RangeItem>, ShardError> {
let tree_entries = match self.actor.committed_root() {
Some(root) => Cursor::new(&self.store, root)
.range(from, to)
.collect::<Result<Vec<_>, _>>()
.map_err(ShardError::from)?,
None => Vec::new(),
};
let buffer_entries: Vec<&Mutation> = self
.actor
.buffer()
.iter()
.filter(|mutation| in_range(mutation, from, to))
.collect();
merge_range(&tree_entries, &buffer_entries)
}
}
impl NativeHandler for ShardNativeHandler {
fn handle(&mut self, ctx: &mut NativeContext<'_>) -> NativeOutcome {
let Some(state) = self.state.as_mut() else {
return self.fail_startup();
};
state.arm_pending(ctx);
while let Some(message) = ctx.recv() {
if let Some(raw_generation) = message.as_small_int()
&& let Ok(value) = u64::try_from(raw_generation)
&& let Some(generation) = Generation::from_value(value)
{
state.receive_deadline(generation, ctx);
} else if let Some(outcome) = Self::pop_and_execute(state, &self.commands, ctx) {
return outcome;
}
}
NativeOutcome::Wait
}
}
impl ShardNativeHandler {
fn fail_startup(&self) -> NativeOutcome {
let message = self
.startup_error
.as_ref()
.map_or_else(|| "shard startup failed".to_owned(), ToString::to_string);
while let Some(command) = pop_command(&self.commands) {
super::startup::reply_startup_error(command, &message);
}
NativeOutcome::Stop(ExitReason::Error)
}
}
pub(super) fn lock_queue(commands: &CommandQueue) -> MutexGuard<'_, VecDeque<ShardCommand>> {
commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn pop_command(commands: &CommandQueue) -> Option<ShardCommand> {
lock_queue(commands).pop_front()
}
fn in_range(mutation: &Mutation, from: &[u8], to: &[u8]) -> bool {
let key = mutation.key();
from <= key && key < to
}
fn merge_range(
tree_entries: &[(Vec<u8>, Vec<u8>)],
buffer_entries: &[&Mutation],
) -> Result<Vec<RangeItem>, ShardError> {
let capacity = tree_entries
.len()
.saturating_add(buffer_entries.len())
.saturating_add(1);
let mut items = Vec::with_capacity(capacity);
let mut tree_index = 0;
let mut buffer_index = 0;
loop {
match (
tree_entries.get(tree_index),
buffer_entries.get(buffer_index),
) {
(Some((tree_key, tree_value)), Some(buffer_mutation)) => {
match tree_key.as_slice().cmp(buffer_mutation.key()) {
Ordering::Less => {
push_entry(&mut items, tree_key, tree_value)?;
tree_index = tree_index.saturating_add(1);
}
Ordering::Equal => {
push_mutation(&mut items, buffer_mutation)?;
tree_index = tree_index.saturating_add(1);
buffer_index = buffer_index.saturating_add(1);
}
Ordering::Greater => {
push_mutation(&mut items, buffer_mutation)?;
buffer_index = buffer_index.saturating_add(1);
}
}
}
(Some((tree_key, tree_value)), None) => {
push_entry(&mut items, tree_key, tree_value)?;
tree_index = tree_index.saturating_add(1);
}
(None, Some(buffer_mutation)) => {
push_mutation(&mut items, buffer_mutation)?;
buffer_index = buffer_index.saturating_add(1);
}
(None, None) => break,
}
}
items.push(RangeItem::Done);
Ok(items)
}
fn push_entry(items: &mut Vec<RangeItem>, key: &[u8], value: &[u8]) -> Result<(), ShardError> {
match visible_value(value)
.map_err(|error| ShardError::Wal(crate::wal::WalError::TreeError(error.to_string())))?
{
Visibility::Live(value) => items.push(RangeItem::Entry {
key: key.to_vec(),
value,
}),
Visibility::Expired => {}
}
Ok(())
}
fn push_mutation(items: &mut Vec<RangeItem>, mutation: &Mutation) -> Result<(), ShardError> {
if let Mutation::Put { key, value } = mutation {
push_entry(items, key, value)?;
}
Ok(())
}
#[cfg(test)]
const fn assert_disk_store_send() {
const fn require_send<T: Send>() {}
require_send::<DiskStore>();
require_send::<ShardNativeHandler>();
let _ = require_send::<DiskStore>;
}
#[cfg(test)]
const _: () = assert_disk_store_send();
#[cfg(test)]
mod boot_failure_tests {
use super::ShardNativeHandler;
use crate::shard::actor::handle::{
CommandQueue, RangeItem, ShardCommand, ShardCommandKind, ShardError,
};
use beamr::NativeOutcome;
use beamr::process::ExitReason;
use std::collections::VecDeque;
use std::error::Error;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
fn boot_failed_handler(
commands: CommandQueue,
) -> Result<(ShardNativeHandler, tempfile::TempDir), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
let store_path = dir.path().join("store-is-a-file");
std::fs::write(&store_path, b"not a directory")?;
let wal_path = dir.path().join("shard.wal");
let seam = crate::db::root_advance::RootAdvanceSeam::new();
let handler = ShardNativeHandler::build(
&store_path,
&wal_path,
commands,
crate::tree::TreePolicy::V1_DEFAULT,
seam.commit_state(0),
);
Ok((handler, dir))
}
#[test]
fn fail_startup_drains_every_command_kind_with_spawn_then_stops() -> Result<(), Box<dyn Error>>
{
let commands: CommandQueue = Arc::new(Mutex::new(VecDeque::new()));
let (get_tx, get_rx) = mpsc::sync_channel(1);
let (put_tx, put_rx) = mpsc::sync_channel(1);
let (del_tx, del_rx) = mpsc::sync_channel(1);
let (commit_tx, commit_rx) = mpsc::sync_channel(1);
let (range_tx, range_rx) = mpsc::sync_channel::<Result<Vec<RangeItem>, ShardError>>(1);
{
let mut queue = commands.lock().map_err(|_| "queue poisoned")?;
queue.push_back(ShardCommand {
id: 1,
kind: ShardCommandKind::Get {
key: b"k".to_vec(),
reply: get_tx,
},
});
queue.push_back(ShardCommand {
id: 2,
kind: ShardCommandKind::Put {
key: b"k".to_vec(),
value: b"v".to_vec(),
ttl: None,
reply: put_tx,
},
});
queue.push_back(ShardCommand {
id: 3,
kind: ShardCommandKind::Delete {
key: b"k".to_vec(),
stamp: crate::sync::ballot::Stamp::bottom(),
reply: del_tx,
},
});
queue.push_back(ShardCommand {
id: 4,
kind: ShardCommandKind::Commit { reply: commit_tx },
});
queue.push_back(ShardCommand {
id: 5,
kind: ShardCommandKind::Range {
from: b"a".to_vec(),
to: b"z".to_vec(),
reply: range_tx,
},
});
}
let (handler, _dir) = boot_failed_handler(Arc::clone(&commands))?;
assert!(handler.state.is_none(), "boot must have failed");
assert!(handler.startup_error.is_some(), "startup_error must be set");
let outcome = handler.fail_startup();
assert!(
matches!(get_rx.try_recv(), Ok(Err(ShardError::Spawn(_)))),
"Get arm"
);
assert!(
matches!(put_rx.try_recv(), Ok(Err(ShardError::Spawn(_)))),
"Put arm"
);
assert!(
matches!(del_rx.try_recv(), Ok(Err(ShardError::Spawn(_)))),
"Delete arm"
);
assert!(
matches!(commit_rx.try_recv(), Ok(Err(ShardError::Spawn(_)))),
"Commit arm"
);
assert!(
matches!(range_rx.try_recv(), Ok(Err(ShardError::Spawn(_)))),
"Range arm"
);
assert!(
matches!(outcome, NativeOutcome::Stop(ExitReason::Error)),
"sentinel stops"
);
assert!(
commands.lock().map_err(|_| "queue poisoned")?.is_empty(),
"queue fully drained"
);
Ok(())
}
}
#[cfg(test)]
mod group_drain_tests {
use super::{DrainUnit, lock_queue, pop_next_unit};
use crate::shard::actor::handle::{CommandQueue, ShardCommand, ShardCommandKind};
use crate::sync::ballot::{Ballot, Stamp};
use crate::sync::topology::SyncNodeId;
use std::collections::VecDeque;
use std::error::Error;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
fn stamp() -> Stamp {
Stamp::new(Ballot::new(1, SyncNodeId::new("owner")), 0)
}
fn apply_command(id: u64, key: &[u8]) -> ShardCommand {
let (reply, _rx) = mpsc::sync_channel(1);
ShardCommand {
id,
kind: ShardCommandKind::ApplyDurable {
key: key.to_vec(),
expected: None,
value: b"v".to_vec(),
ttl: None,
stamp: stamp(),
reply,
},
}
}
fn cas_command(id: u64, key: &[u8]) -> ShardCommand {
let (reply, _rx) = mpsc::sync_channel(1);
ShardCommand {
id,
kind: ShardCommandKind::Cas {
key: key.to_vec(),
expected: None,
new: 1,
reply,
},
}
}
fn promise_command(id: u64) -> ShardCommand {
let (reply, _rx) = mpsc::sync_channel(1);
ShardCommand {
id,
kind: ShardCommandKind::RecordPromise {
ballot: Ballot::new(2, SyncNodeId::new("owner")),
reply,
},
}
}
#[test]
fn drain_groups_consecutive_writes_and_stops_at_non_groupable() -> Result<(), Box<dyn Error>> {
let commands: CommandQueue = Arc::new(Mutex::new(VecDeque::new()));
{
let mut queue = lock_queue(&commands);
queue.push_back(apply_command(1, b"a")); queue.push_back(cas_command(2, b"b")); queue.push_back(promise_command(3)); queue.push_back(apply_command(4, b"c")); }
match pop_next_unit(&commands).ok_or("expected a first unit")? {
DrainUnit::Group(group) => assert_eq!(group.len(), 2, "coalesce the leading run"),
DrainUnit::Single(_) => return Err("expected a group, got a single".into()),
}
match pop_next_unit(&commands).ok_or("expected a second unit")? {
DrainUnit::Single(command) => assert!(
matches!(command.kind, ShardCommandKind::RecordPromise { .. }),
"RecordPromise must be handled as a single, never grouped"
),
DrainUnit::Group(_) => return Err("RecordPromise must not be grouped".into()),
}
match pop_next_unit(&commands).ok_or("expected a third unit")? {
DrainUnit::Group(group) => assert_eq!(group.len(), 1, "trailing write groups alone"),
DrainUnit::Single(_) => return Err("expected a trailing group".into()),
}
assert!(pop_next_unit(&commands).is_none(), "queue fully drained");
Ok(())
}
#[test]
fn non_groupable_front_is_single_even_with_groupable_behind() -> Result<(), Box<dyn Error>> {
let commands: CommandQueue = Arc::new(Mutex::new(VecDeque::new()));
{
let mut queue = lock_queue(&commands);
queue.push_back(promise_command(1)); queue.push_back(apply_command(2, b"a")); }
match pop_next_unit(&commands).ok_or("expected a unit")? {
DrainUnit::Single(command) => assert!(matches!(
command.kind,
ShardCommandKind::RecordPromise { .. }
)),
DrainUnit::Group(_) => return Err("front non-groupable must be single".into()),
}
match pop_next_unit(&commands).ok_or("expected a unit")? {
DrainUnit::Group(group) => assert_eq!(group.len(), 1),
DrainUnit::Single(_) => return Err("trailing write should group".into()),
}
Ok(())
}
}