use melin_app::AppEvent;
use melin_pipeline::wait::WaitStrategy;
use melin_transport_core::pipeline::{JournalStage, StageWaits};
pub(crate) const DEFAULT_CORES: &str = "journal-seq=1,matching=2,response=3,reader=4,\
event-publisher=6,shadow=7,repl-handler-0=8,repl-handler-1=9,journal-prep=10,journal-disk=11";
const THREAD_COUNT: usize = 10;
const MANDATORY_THREADS: [&str; 5] = [
"journal-seq",
"matching",
"response",
"reader",
"journal-disk",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placement {
pub core: usize,
pub wait: WaitStrategy,
}
impl Placement {
pub const fn spinning(core: usize) -> Self {
if core == 0 {
Self::unpinned()
} else {
Self {
core,
wait: WaitStrategy::BusySpin,
}
}
}
pub const fn yielding(core: usize) -> Self {
Self {
core,
wait: WaitStrategy::SpinThenYield,
}
}
pub const fn unpinned() -> Self {
Self::yielding(0)
}
pub fn is_pinned(&self) -> bool {
self.core != 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReaderThread {
IoUring,
DpdkPoll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PipelineCores {
pub journal_seq: Placement,
pub matching: Placement,
pub response: Placement,
pub reader: Placement,
pub event_publisher: Placement,
pub shadow: Placement,
pub repl_handler_0: Placement,
pub repl_handler_1: Placement,
pub journal_prep: Placement,
pub journal_disk: Placement,
}
impl PipelineCores {
pub fn unpinned() -> Self {
let unpinned = Placement::unpinned();
Self {
journal_seq: unpinned,
matching: unpinned,
response: unpinned,
reader: unpinned,
event_publisher: unpinned,
shadow: unpinned,
repl_handler_0: unpinned,
repl_handler_1: unpinned,
journal_prep: unpinned,
journal_disk: unpinned,
}
}
fn named_mut(&mut self) -> [(&'static str, &mut Placement); THREAD_COUNT] {
let Self {
journal_seq,
matching,
response,
reader,
event_publisher,
shadow,
repl_handler_0,
repl_handler_1,
journal_prep,
journal_disk,
} = self;
[
("journal-seq", journal_seq),
("matching", matching),
("response", response),
("reader", reader),
("event-publisher", event_publisher),
("shadow", shadow),
("repl-handler-0", repl_handler_0),
("repl-handler-1", repl_handler_1),
("journal-prep", journal_prep),
("journal-disk", journal_disk),
]
}
fn named(&self) -> [(&'static str, Placement); THREAD_COUNT] {
let mut layout = *self;
layout.journal_prep = Placement::yielding(layout.journal_prep.core);
layout
.named_mut()
.map(|(name, placement)| (name, *placement))
}
pub fn stage_waits(&self) -> StageWaits {
StageWaits {
ingress: self.reader.wait,
journal: self.journal_seq.wait,
matching: self.matching.wait,
}
}
pub fn place_journal_children<E: AppEvent>(&self, stage: &mut JournalStage<E>) {
stage.set_preparer_core(self.journal_prep.core);
stage.set_disk_core(self.journal_disk.core);
stage.set_disk_wait(self.journal_disk.wait);
}
pub fn all_yielding(mut self) -> Self {
for (_, placement) in self.named_mut() {
*placement = Placement::yielding(placement.core);
}
self
}
pub fn unpinned_mandatory(&self) -> Vec<&'static str> {
self.named()
.into_iter()
.filter(|(name, placement)| MANDATORY_THREADS.contains(name) && !placement.is_pinned())
.map(|(name, _)| name)
.collect()
}
pub fn pins_nothing(&self) -> bool {
self.named()
.iter()
.all(|(_, placement)| !placement.is_pinned())
}
pub fn resolve(self, reader: ReaderThread) -> Result<Self, String> {
if reader == ReaderThread::DpdkPoll
&& self.reader.is_pinned()
&& self.reader.wait == WaitStrategy::SpinThenYield
{
return Err(format!(
"--cores: the reader entry is `reader={core}y`, but on DPDK the reader is the \
NIC poll thread, which busy-polls whatever its entry says. Drop the suffix and \
give it core {core} to itself",
core = self.reader.core
));
}
self.validate()?;
Ok(self)
}
pub fn validate(&self) -> Result<(), String> {
let named = self.named();
for (i, (name_a, a)) in named.iter().enumerate() {
if !a.is_pinned() {
if a.wait == WaitStrategy::BusySpin {
return Err(format!(
"--cores: {name_a} is unpinned but busy-spins; a thread without a \
core of its own would spin wherever the scheduler puts it, \
starving whatever shares that core. Give it a core, or let it yield"
));
}
continue;
}
for (name_b, b) in &named[i + 1..] {
if a.core != b.core {
continue;
}
let spinner = match (a.wait, b.wait) {
(WaitStrategy::BusySpin, _) => name_a,
(_, WaitStrategy::BusySpin) => name_b,
_ => continue,
};
return Err(format!(
"--cores: {name_a} and {name_b} share core {} but {spinner} busy-spins; \
a busy-spinning thread needs the core to itself. Give one of them \
another core, or suffix both with `y` so they yield when idle",
a.core
));
}
}
Ok(())
}
pub fn compact(num_cpus: usize) -> Result<(Self, usize), String> {
if num_cpus < 8 {
return Err(format!(
"compact layout needs >= 8 logical cores; have {num_cpus}"
));
}
let cores = PipelineCores {
journal_seq: Placement::spinning(1),
matching: Placement::spinning(2),
response: Placement::spinning(3),
reader: Placement::spinning(4),
event_publisher: Placement::spinning(5),
shadow: Placement::spinning(6),
repl_handler_0: Placement::unpinned(),
repl_handler_1: Placement::unpinned(),
journal_prep: Placement::unpinned(),
journal_disk: Placement::unpinned(),
};
Ok((cores, 7))
}
}
impl std::fmt::Display for PipelineCores {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.pins_nothing() {
return f.write_str("none");
}
for (i, (name, placement)) in self.named().into_iter().enumerate() {
if i > 0 {
f.write_str(",")?;
}
match (name, placement.core, placement.wait) {
(_, 0, _) => write!(f, "{name}=0")?,
("journal-prep", core, _) => write!(f, "{name}={core}")?,
(_, core, WaitStrategy::BusySpin) => write!(f, "{name}={core}")?,
(_, core, WaitStrategy::SpinThenYield) => write!(f, "{name}={core}y")?,
}
}
Ok(())
}
}
fn parse_placement(entry: &str) -> Result<Placement, String> {
let (digits, suffix) = match entry.as_bytes().last() {
Some(b'y') => (&entry[..entry.len() - 1], Some(WaitStrategy::SpinThenYield)),
Some(b's') => (&entry[..entry.len() - 1], Some(WaitStrategy::BusySpin)),
_ => (entry, None),
};
let core = digits
.parse::<usize>()
.map_err(|_| format!("invalid core ID `{entry}`"))?;
match (core, suffix) {
(0, Some(WaitStrategy::BusySpin)) => Err(format!(
"invalid core entry `{entry}`: 0 means unpinned, and a thread without a core \
of its own cannot busy-spin"
)),
(0, _) => Ok(Placement::unpinned()),
(core, Some(WaitStrategy::SpinThenYield)) => Ok(Placement::yielding(core)),
(core, _) => Ok(Placement::spinning(core)),
}
}
fn thread_names() -> String {
PipelineCores::unpinned()
.named()
.map(|(name, _)| name)
.join(", ")
}
pub(crate) fn parse_cores(s: &str) -> Result<PipelineCores, String> {
let mut cores = PipelineCores::unpinned();
if s.trim() == "none" {
return Ok(cores);
}
if s.split(',')
.all(|entry| parse_placement(entry.trim()).is_ok())
{
return Err(format!(
"expected named entries such as `journal-seq=1,matching=2`; bare cores are the \
positional form earlier releases took. Name each thread; the threads are {}. \
An all-`0` list is `none`, which unpins every thread",
thread_names()
));
}
let mut seen = [false; THREAD_COUNT];
let slots = cores.named_mut();
for entry in s.split(',') {
let entry = entry.trim();
let Some((name, value)) = entry.split_once('=') else {
return Err(format!(
"invalid entry `{entry}`: expected thread=core, such as `journal-seq=1`"
));
};
let (name, value) = (name.trim(), value.trim());
let Some(index) = slots.iter().position(|(slot, _)| *slot == name) else {
return Err(format!(
"unknown thread `{name}`; the threads are {}",
thread_names()
));
};
if std::mem::replace(&mut seen[index], true) {
return Err(format!("{name} is named twice"));
}
let is_prep = name == "journal-prep";
if is_prep && value.ends_with('s') {
return Err(format!(
"invalid journal-prep entry `{value}`: the preparer never busy-waits and \
takes no `s` suffix"
));
}
let placement = parse_placement(value).map_err(|e| format!("{name}: {e}"))?;
*slots[index].1 = if is_prep {
Placement::yielding(placement.core)
} else {
placement
};
}
let missing: Vec<&str> = slots
.iter()
.zip(seen)
.filter(|(_, was_named)| !was_named)
.map(|((name, _), _)| *name)
.collect();
if !missing.is_empty() {
return Err(format!(
"missing {}: every thread must be named (`0` leaves one unpinned, and `none` \
unpins them all)",
missing.join(", ")
));
}
Ok(cores)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::ServerConfig;
use clap::Parser;
const MIXED: &str = "journal-seq=1,matching=2,response=3,reader=4,journal-disk=5,\
repl-handler-0=6,repl-handler-1=7,event-publisher=8y,shadow=8y,journal-prep=8";
fn complete(entries: &str) -> String {
let mut value = entries.to_owned();
for (name, _) in PipelineCores::unpinned().named() {
let named = entries
.split(',')
.any(|entry| entry.split_once('=').is_some_and(|(n, _)| n.trim() == name));
if !named {
value.push_str(&format!(",{name}=0"));
}
}
value
}
#[test]
fn parse_cores_requires_every_thread() {
const REQUIRED: [&str; 10] = [
"journal-seq",
"matching",
"response",
"reader",
"event-publisher",
"shadow",
"repl-handler-0",
"repl-handler-1",
"journal-prep",
"journal-disk",
];
for missing in REQUIRED {
let prefix = format!("{missing}=");
let value = DEFAULT_CORES
.split(',')
.filter(|entry| !entry.starts_with(&prefix))
.collect::<Vec<_>>()
.join(",");
let err = parse_cores(&value).expect_err(missing);
assert!(err.starts_with(&format!("missing {missing}:")), "{err}");
}
let err = parse_cores("journal-seq=1,matching=2").expect_err("eight threads missing");
assert!(
err.starts_with(
"missing response, reader, event-publisher, shadow, repl-handler-0, \
repl-handler-1, journal-prep, journal-disk:"
),
"{err}"
);
assert!(err.contains("`none`"), "points at the shorthand: {err}");
}
#[test]
fn parse_cores_reads_named_entries_in_any_order() {
let forward =
parse_cores(&complete("journal-seq=1,matching=2,journal-disk=3")).expect("parses");
let reversed =
parse_cores(&complete("journal-disk=3, matching=2 ,journal-seq = 1")).expect("parses");
assert_eq!(forward, reversed);
assert_eq!(forward.journal_seq, Placement::spinning(1));
assert_eq!(forward.matching, Placement::spinning(2));
assert_eq!(forward.journal_disk, Placement::spinning(3));
let every = parse_cores(DEFAULT_CORES).expect("the default parses");
assert_eq!(every.event_publisher, Placement::spinning(6));
assert_eq!(every.journal_prep, Placement::yielding(10));
assert_eq!(every.journal_disk, Placement::spinning(11));
}
#[test]
fn parse_cores_none_unpins_every_thread() {
assert_eq!(parse_cores("none"), Ok(PipelineCores::unpinned()));
assert_eq!(
parse_cores(&complete("journal-seq=0")),
Ok(PipelineCores::unpinned())
);
assert!(
parse_cores("none,journal-seq=1").is_err(),
"none is the whole value, not an entry"
);
assert!(PipelineCores::unpinned().pins_nothing());
assert!(!ServerConfig::default().cores.pins_nothing());
}
#[test]
fn parse_cores_refuses_a_positional_list() {
for positional in [
"1,2,3,4,0,6,7,8,9",
"1,2,3,4,5,6,7,8,9,10",
"1,2,3,4,5,6,7,8,9,10,11",
"1y,2y,3y,4y,0,6y,7y,8y,9y,10,11y",
"0,0,0,0,0,0,0,0,0",
] {
let err = parse_cores(positional).expect_err(positional);
assert!(err.contains("named entries"), "{positional}: {err}");
assert!(err.contains("journal-disk"), "lists the threads: {err}");
assert!(err.contains("`none`"), "points at the shorthand: {err}");
}
}
#[test]
fn parse_cores_refuses_unknown_repeated_and_malformed_entries() {
let err = parse_cores("journal-seq=1,jornal=2").expect_err("misspelt thread");
assert!(err.contains("unknown thread `jornal`"), "{err}");
let err = parse_cores("repl-sender=5").expect_err("retired thread");
assert!(err.contains("unknown thread `repl-sender`"), "{err}");
let err =
parse_cores("journal-seq=1,matching=2,journal-seq=3").expect_err("repeated thread");
assert!(err.contains("journal-seq is named twice"), "{err}");
let err = parse_cores("journal-seq=x").expect_err("bad core");
assert!(err.contains("journal-seq: invalid core ID `x`"), "{err}");
let err = parse_cores("journal-seq=").expect_err("empty core");
assert!(
err.contains("invalid core ID ``"),
"an empty core is shown as such, not as a message trailing off: {err}"
);
assert!(parse_cores("journal-seq=0s").is_err());
assert!(parse_cores("journal").is_err());
assert!(parse_cores("journal-seq=1,").is_err(), "trailing comma");
assert!(parse_cores("=1").is_err());
assert!(parse_cores("").is_err());
}
#[test]
fn parse_placement_reads_the_wait_suffix() {
assert_eq!(parse_placement("7"), Ok(Placement::spinning(7)));
assert_eq!(parse_placement("7s"), Ok(Placement::spinning(7)));
assert_eq!(parse_placement("7y"), Ok(Placement::yielding(7)));
assert_eq!(parse_placement("0"), Ok(Placement::unpinned()));
assert_eq!(parse_placement("0y"), Ok(Placement::unpinned()));
assert!(
parse_placement("0s").is_err(),
"a thread with no core of its own cannot busy-spin"
);
assert!(parse_placement("x").is_err());
assert!(parse_placement("7z").is_err());
assert!(parse_placement("").is_err());
}
#[test]
fn journal_prep_always_yields() {
let cores = parse_cores(&complete("journal-prep=10")).expect("parses");
assert_eq!(cores.journal_prep, Placement::yielding(10));
let explicit = parse_cores(&complete("journal-prep=10y")).expect("parses");
assert_eq!(explicit.journal_prep, Placement::yielding(10));
let err = parse_cores(&complete("journal-prep=10s")).expect_err("must refuse");
assert!(err.contains("journal-prep"), "{err}");
}
#[test]
fn validate_refuses_a_busy_spinner_sharing_a_core() {
let err = parse_cores(&complete("journal-seq=1,matching=1"))
.expect("parses")
.validate()
.expect_err("two spinners on core 1 must be refused");
assert!(err.contains("journal"), "{err}");
assert!(err.contains("matching"), "{err}");
assert!(err.contains("core 1"), "{err}");
let err = parse_cores(&complete("event-publisher=7,shadow=7y"))
.expect("parses")
.validate()
.expect_err("a spinner sharing with a yielder must be refused");
assert!(err.contains("event-publisher"), "{err}");
assert!(err.contains("shadow"), "{err}");
assert!(err.contains("event-publisher busy-spins"), "{err}");
let err = parse_cores(&complete("event-publisher=6y,shadow=6"))
.expect("parses")
.validate()
.expect_err("6y and 6 is still a spinner sharing a core");
assert!(err.contains("shadow busy-spins"), "{err}");
let err = parse_cores(&complete("journal-seq=1,journal-disk=1"))
.expect("parses")
.validate()
.expect_err("journal-disk on the journal's core must be refused");
assert!(err.contains("journal-disk"), "{err}");
}
#[test]
fn validate_allows_yielders_to_share_a_core() {
parse_cores(
"journal-seq=1,matching=2,response=3,reader=4,event-publisher=7y,shadow=7y,\
repl-handler-0=7y,repl-handler-1=7y,journal-prep=7,journal-disk=7y",
)
.expect("parses")
.validate()
.expect("yielders and the preparer may share core 7");
parse_cores("none")
.expect("parses")
.validate()
.expect("every thread unpinned is a valid (test) layout");
}
#[test]
fn validate_refuses_an_unpinned_busy_spinner_built_by_hand() {
let mut cores = ServerConfig::default().cores;
cores.shadow = Placement {
core: 0,
wait: WaitStrategy::BusySpin,
};
let err = cores
.validate()
.expect_err("unpinned spinner must be refused");
assert!(err.contains("shadow"), "{err}");
assert!(err.contains("unpinned"), "{err}");
}
#[test]
fn a_spinning_preparer_built_by_hand_is_read_as_yielding() {
let mut cores = parse_cores(&complete("event-publisher=8y,shadow=8y")).expect("parses");
cores.journal_prep = Placement::spinning(8);
cores
.validate()
.expect("the preparer never spins, so it may share core 8 with yielders");
let reparsed = parse_cores(&cores.to_string()).expect("the rendering parses");
assert_eq!(reparsed.journal_prep, Placement::yielding(8));
assert_eq!(reparsed.named(), cores.named(), "log and check agree");
cores.journal_prep = Placement {
core: 0,
wait: WaitStrategy::BusySpin,
};
cores
.validate()
.expect("nor is an unpinned preparer an unpinned spinner");
}
#[test]
fn shipped_layouts_validate() {
let default = ServerConfig::default().cores;
for reader in [ReaderThread::IoUring, ReaderThread::DpdkPoll] {
default.resolve(reader).expect("default layout");
}
let (compact, _) = PipelineCores::compact(16).expect("compact fits 16 cores");
compact
.resolve(ReaderThread::DpdkPoll)
.expect("compact layout");
assert_eq!(
compact.journal_disk,
Placement::unpinned(),
"compact leaves the disk thread unpinned, hence yielding"
);
}
#[test]
fn unpinned_mandatory_names_the_hot_path_threads_without_a_core() {
const MANDATORY: [&str; 5] = [
"journal-seq",
"matching",
"response",
"reader",
"journal-disk",
];
let known = PipelineCores::unpinned().named().map(|(name, _)| name);
for name in MANDATORY {
assert!(known.contains(&name), "{name} is not a --cores thread");
}
assert_eq!(PipelineCores::unpinned().unpinned_mandatory(), MANDATORY);
assert!(
ServerConfig::default()
.cores
.unpinned_mandatory()
.is_empty()
);
let hot_pinned = parse_cores(&complete(
"journal-seq=1,matching=2,response=3,reader=4,journal-disk=5",
))
.expect("parses");
assert!(hot_pinned.unpinned_mandatory().is_empty());
let disk_unpinned = parse_cores(
"journal-seq=1,matching=2,response=3,reader=4,event-publisher=6,shadow=7,\
repl-handler-0=8,repl-handler-1=9,journal-prep=10,journal-disk=0",
)
.expect("parses");
assert_eq!(disk_unpinned.unpinned_mandatory(), ["journal-disk"]);
let (compact, _) = PipelineCores::compact(16).expect("compact fits 16 cores");
assert_eq!(compact.unpinned_mandatory(), ["journal-disk"]);
}
#[test]
fn all_yielding_makes_a_packed_layout_legal() {
let packed = parse_cores(
"journal-seq=1,matching=1,response=1,reader=1,event-publisher=1,shadow=1,\
repl-handler-0=1,repl-handler-1=1,journal-prep=0,journal-disk=0",
)
.expect("parses");
assert!(
packed.resolve(ReaderThread::IoUring).is_err(),
"eight spinners on core 1 must be refused"
);
let cores = packed
.all_yielding()
.resolve(ReaderThread::IoUring)
.expect("the same cores, all yielding, are legal");
assert_eq!(cores.journal_seq, Placement::yielding(1));
assert_eq!(cores.journal_disk, Placement::unpinned());
assert_eq!(
cores.to_string(),
"journal-seq=1y,matching=1y,response=1y,reader=1y,event-publisher=1y,shadow=1y,\
repl-handler-0=1y,repl-handler-1=1y,journal-prep=0,journal-disk=0",
"every pinned entry carries the suffix"
);
}
#[test]
fn dpdk_reader_never_yields() {
let explicit = parse_cores(&complete("reader=4y")).expect("parses");
let err = explicit
.resolve(ReaderThread::DpdkPoll)
.expect_err("`4y` on the DPDK reader must be refused");
assert!(err.contains("reader=4y"), "{err}");
assert!(err.contains("poll"), "{err}");
explicit
.resolve(ReaderThread::IoUring)
.expect("the io_uring reader may yield");
let shared = parse_cores(&complete("reader=4,event-publisher=4y")).expect("parses");
let err = shared
.resolve(ReaderThread::DpdkPoll)
.expect_err("a core shared with the poll thread is refused");
assert!(err.contains("reader busy-spins"), "{err}");
let unpinned =
parse_cores(&complete("journal-seq=1,matching=2,response=3,reader=0")).expect("parses");
let cores = unpinned
.resolve(ReaderThread::DpdkPoll)
.expect("an unpinned reader is accepted as written");
assert_eq!(cores.reader, Placement::unpinned());
}
#[test]
fn place_journal_children_forwards_the_disk_and_preparer_placements() {
use counter_server::CounterEvent;
use melin_pipeline::ring::DisruptorBuilder;
use melin_transport_core::pipeline::InputSlot;
use std::time::Duration;
let dir = tempfile::tempdir().expect("tempdir");
let writer = melin_journal::BufferedWriter::<CounterEvent>::create(&dir.path().join("j"))
.expect("create journal");
let (_producer, mut consumers) = DisruptorBuilder::<InputSlot<CounterEvent>>::new(4)
.add_consumer()
.build(WaitStrategy::SpinThenYield);
let mut stage = JournalStage::new(
writer,
consumers.pop().expect("one consumer"),
Duration::ZERO,
64,
WaitStrategy::BusySpin,
);
assert_eq!(
stage.disk_wait(),
WaitStrategy::BusySpin,
"before placement the disk thread inherits the sequencer's strategy"
);
let cores =
parse_cores(&complete("journal-seq=1,journal-prep=8,journal-disk=5y")).expect("parses");
cores.place_journal_children(&mut stage);
assert_eq!(stage.disk_core(), 5);
assert_eq!(stage.disk_wait(), WaitStrategy::SpinThenYield);
assert_eq!(stage.preparer_core(), 8);
}
#[test]
fn layout_display_round_trips_through_parse_cores() {
for input in [
DEFAULT_CORES.to_owned(),
MIXED.to_owned(),
"none".to_owned(),
complete("journal-seq=1y,matching=2y,response=3y,reader=4y,journal-disk=7y"),
] {
let cores = parse_cores(&input).expect("parses");
let rendered = cores.to_string();
let reparsed = parse_cores(&rendered).expect("rendering parses");
assert_eq!(reparsed, cores, "{input} -> {rendered}");
}
assert_eq!(PipelineCores::unpinned().to_string(), "none");
assert_eq!(
ServerConfig::default().cores.to_string(),
DEFAULT_CORES,
"the hand-written default renders as the CLI default"
);
assert_eq!(
ServerConfig::try_parse_from(["melin-server"])
.expect("parses")
.cores,
ServerConfig::default().cores,
"a node started without --cores runs the hand-written default"
);
}
#[test]
fn mixed_layout_parses_and_resolves() {
let config =
ServerConfig::try_parse_from(["melin-server", "--cores", MIXED]).expect("parses");
for reader in [ReaderThread::IoUring, ReaderThread::DpdkPoll] {
let cores = config.resolved_cores(reader).expect("a legal mixed layout");
assert_eq!(cores.journal_seq, Placement::spinning(1));
assert_eq!(cores.reader, Placement::spinning(4));
assert_eq!(cores.journal_disk, Placement::spinning(5));
assert_eq!(cores.repl_handler_0, Placement::spinning(6));
assert_eq!(cores.repl_handler_1, Placement::spinning(7));
assert_eq!(cores.event_publisher, Placement::yielding(8));
assert_eq!(cores.shadow, Placement::yielding(8));
assert_eq!(cores.journal_prep, Placement::yielding(8));
assert_eq!(
cores.stage_waits(),
StageWaits {
ingress: WaitStrategy::BusySpin,
journal: WaitStrategy::BusySpin,
matching: WaitStrategy::BusySpin,
}
);
}
}
}