1use melin_app::AppEvent;
26use melin_pipeline::wait::WaitStrategy;
27use melin_transport_core::pipeline::{JournalStage, StageWaits};
28
29pub(crate) const DEFAULT_CORES: &str = "journal-seq=1,matching=2,response=3,reader=4,\
31 event-publisher=6,shadow=7,repl-handler-0=8,repl-handler-1=9,journal-prep=10,journal-disk=11";
32
33const THREAD_COUNT: usize = 10;
36
37const MANDATORY_THREADS: [&str; 5] = [
42 "journal-seq",
43 "matching",
44 "response",
45 "reader",
46 "journal-disk",
47];
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct Placement {
60 pub core: usize,
62 pub wait: WaitStrategy,
64}
65
66impl Placement {
67 pub const fn spinning(core: usize) -> Self {
71 if core == 0 {
72 Self::unpinned()
73 } else {
74 Self {
75 core,
76 wait: WaitStrategy::BusySpin,
77 }
78 }
79 }
80
81 pub const fn yielding(core: usize) -> Self {
84 Self {
85 core,
86 wait: WaitStrategy::SpinThenYield,
87 }
88 }
89
90 pub const fn unpinned() -> Self {
93 Self::yielding(0)
94 }
95
96 pub fn is_pinned(&self) -> bool {
98 self.core != 0
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ReaderThread {
107 IoUring,
111 DpdkPoll,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct PipelineCores {
129 pub journal_seq: Placement,
132 pub matching: Placement,
133 pub response: Placement,
134 pub reader: Placement,
137 pub event_publisher: Placement,
138 pub shadow: Placement,
139 pub repl_handler_0: Placement,
141 pub repl_handler_1: Placement,
143 pub journal_prep: Placement,
154 pub journal_disk: Placement,
163}
164
165impl PipelineCores {
166 pub fn unpinned() -> Self {
168 let unpinned = Placement::unpinned();
169 Self {
170 journal_seq: unpinned,
171 matching: unpinned,
172 response: unpinned,
173 reader: unpinned,
174 event_publisher: unpinned,
175 shadow: unpinned,
176 repl_handler_0: unpinned,
177 repl_handler_1: unpinned,
178 journal_prep: unpinned,
179 journal_disk: unpinned,
180 }
181 }
182
183 fn named_mut(&mut self) -> [(&'static str, &mut Placement); THREAD_COUNT] {
189 let Self {
190 journal_seq,
191 matching,
192 response,
193 reader,
194 event_publisher,
195 shadow,
196 repl_handler_0,
197 repl_handler_1,
198 journal_prep,
199 journal_disk,
200 } = self;
201 [
202 ("journal-seq", journal_seq),
203 ("matching", matching),
204 ("response", response),
205 ("reader", reader),
206 ("event-publisher", event_publisher),
207 ("shadow", shadow),
208 ("repl-handler-0", repl_handler_0),
209 ("repl-handler-1", repl_handler_1),
210 ("journal-prep", journal_prep),
211 ("journal-disk", journal_disk),
212 ]
213 }
214
215 fn named(&self) -> [(&'static str, Placement); THREAD_COUNT] {
221 let mut layout = *self;
222 layout.journal_prep = Placement::yielding(layout.journal_prep.core);
223 layout
224 .named_mut()
225 .map(|(name, placement)| (name, *placement))
226 }
227
228 pub fn stage_waits(&self) -> StageWaits {
233 StageWaits {
234 ingress: self.reader.wait,
235 journal: self.journal_seq.wait,
236 matching: self.matching.wait,
237 }
238 }
239
240 pub fn place_journal_children<E: AppEvent>(&self, stage: &mut JournalStage<E>) {
248 stage.set_preparer_core(self.journal_prep.core);
249 stage.set_disk_core(self.journal_disk.core);
250 stage.set_disk_wait(self.journal_disk.wait);
251 }
252
253 pub fn all_yielding(mut self) -> Self {
260 for (_, placement) in self.named_mut() {
261 *placement = Placement::yielding(placement.core);
262 }
263 self
264 }
265
266 pub fn unpinned_mandatory(&self) -> Vec<&'static str> {
274 self.named()
275 .into_iter()
276 .filter(|(name, placement)| MANDATORY_THREADS.contains(name) && !placement.is_pinned())
277 .map(|(name, _)| name)
278 .collect()
279 }
280
281 pub fn pins_nothing(&self) -> bool {
283 self.named()
284 .iter()
285 .all(|(_, placement)| !placement.is_pinned())
286 }
287
288 pub fn resolve(self, reader: ReaderThread) -> Result<Self, String> {
303 if reader == ReaderThread::DpdkPoll
304 && self.reader.is_pinned()
305 && self.reader.wait == WaitStrategy::SpinThenYield
306 {
307 return Err(format!(
308 "--cores: the reader entry is `reader={core}y`, but on DPDK the reader is the \
309 NIC poll thread, which busy-polls whatever its entry says. Drop the suffix and \
310 give it core {core} to itself",
311 core = self.reader.core
312 ));
313 }
314 self.validate()?;
315 Ok(self)
316 }
317
318 pub fn validate(&self) -> Result<(), String> {
330 let named = self.named();
331 for (i, (name_a, a)) in named.iter().enumerate() {
332 if !a.is_pinned() {
333 if a.wait == WaitStrategy::BusySpin {
334 return Err(format!(
335 "--cores: {name_a} is unpinned but busy-spins; a thread without a \
336 core of its own would spin wherever the scheduler puts it, \
337 starving whatever shares that core. Give it a core, or let it yield"
338 ));
339 }
340 continue;
341 }
342 for (name_b, b) in &named[i + 1..] {
343 if a.core != b.core {
344 continue;
345 }
346 let spinner = match (a.wait, b.wait) {
347 (WaitStrategy::BusySpin, _) => name_a,
348 (_, WaitStrategy::BusySpin) => name_b,
349 _ => continue,
350 };
351 return Err(format!(
352 "--cores: {name_a} and {name_b} share core {} but {spinner} busy-spins; \
353 a busy-spinning thread needs the core to itself. Give one of them \
354 another core, or suffix both with `y` so they yield when idle",
355 a.core
356 ));
357 }
358 }
359 Ok(())
360 }
361
362 pub fn compact(num_cpus: usize) -> Result<(Self, usize), String> {
376 if num_cpus < 8 {
380 return Err(format!(
381 "compact layout needs >= 8 logical cores; have {num_cpus}"
382 ));
383 }
384 let cores = PipelineCores {
385 journal_seq: Placement::spinning(1),
386 matching: Placement::spinning(2),
387 response: Placement::spinning(3),
388 reader: Placement::spinning(4),
389 event_publisher: Placement::spinning(5),
390 shadow: Placement::spinning(6),
391 repl_handler_0: Placement::unpinned(),
393 repl_handler_1: Placement::unpinned(),
394 journal_prep: Placement::unpinned(),
398 journal_disk: Placement::unpinned(),
405 };
406 Ok((cores, 7))
407 }
408}
409
410impl std::fmt::Display for PipelineCores {
416 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417 if self.pins_nothing() {
418 return f.write_str("none");
419 }
420 for (i, (name, placement)) in self.named().into_iter().enumerate() {
421 if i > 0 {
422 f.write_str(",")?;
423 }
424 match (name, placement.core, placement.wait) {
425 (_, 0, _) => write!(f, "{name}=0")?,
426 ("journal-prep", core, _) => write!(f, "{name}={core}")?,
428 (_, core, WaitStrategy::BusySpin) => write!(f, "{name}={core}")?,
429 (_, core, WaitStrategy::SpinThenYield) => write!(f, "{name}={core}y")?,
430 }
431 }
432 Ok(())
433 }
434}
435
436fn parse_placement(entry: &str) -> Result<Placement, String> {
443 let (digits, suffix) = match entry.as_bytes().last() {
444 Some(b'y') => (&entry[..entry.len() - 1], Some(WaitStrategy::SpinThenYield)),
445 Some(b's') => (&entry[..entry.len() - 1], Some(WaitStrategy::BusySpin)),
446 _ => (entry, None),
447 };
448 let core = digits
449 .parse::<usize>()
450 .map_err(|_| format!("invalid core ID `{entry}`"))?;
451 match (core, suffix) {
452 (0, Some(WaitStrategy::BusySpin)) => Err(format!(
453 "invalid core entry `{entry}`: 0 means unpinned, and a thread without a core \
454 of its own cannot busy-spin"
455 )),
456 (0, _) => Ok(Placement::unpinned()),
457 (core, Some(WaitStrategy::SpinThenYield)) => Ok(Placement::yielding(core)),
458 (core, _) => Ok(Placement::spinning(core)),
459 }
460}
461
462fn thread_names() -> String {
464 PipelineCores::unpinned()
465 .named()
466 .map(|(name, _)| name)
467 .join(", ")
468}
469
470pub(crate) fn parse_cores(s: &str) -> Result<PipelineCores, String> {
475 let mut cores = PipelineCores::unpinned();
476 if s.trim() == "none" {
477 return Ok(cores);
478 }
479 if s.split(',')
483 .all(|entry| parse_placement(entry.trim()).is_ok())
484 {
485 return Err(format!(
486 "expected named entries such as `journal-seq=1,matching=2`; bare cores are the \
487 positional form earlier releases took. Name each thread; the threads are {}. \
488 An all-`0` list is `none`, which unpins every thread",
489 thread_names()
490 ));
491 }
492 let mut seen = [false; THREAD_COUNT];
496 let slots = cores.named_mut();
497 for entry in s.split(',') {
498 let entry = entry.trim();
499 let Some((name, value)) = entry.split_once('=') else {
500 return Err(format!(
501 "invalid entry `{entry}`: expected thread=core, such as `journal-seq=1`"
502 ));
503 };
504 let (name, value) = (name.trim(), value.trim());
505 let Some(index) = slots.iter().position(|(slot, _)| *slot == name) else {
506 return Err(format!(
507 "unknown thread `{name}`; the threads are {}",
508 thread_names()
509 ));
510 };
511 if std::mem::replace(&mut seen[index], true) {
512 return Err(format!("{name} is named twice"));
513 }
514 let is_prep = name == "journal-prep";
518 if is_prep && value.ends_with('s') {
519 return Err(format!(
520 "invalid journal-prep entry `{value}`: the preparer never busy-waits and \
521 takes no `s` suffix"
522 ));
523 }
524 let placement = parse_placement(value).map_err(|e| format!("{name}: {e}"))?;
525 *slots[index].1 = if is_prep {
526 Placement::yielding(placement.core)
527 } else {
528 placement
529 };
530 }
531 let missing: Vec<&str> = slots
535 .iter()
536 .zip(seen)
537 .filter(|(_, was_named)| !was_named)
538 .map(|((name, _), _)| *name)
539 .collect();
540 if !missing.is_empty() {
541 return Err(format!(
542 "missing {}: every thread must be named (`0` leaves one unpinned, and `none` \
543 unpins them all)",
544 missing.join(", ")
545 ));
546 }
547 Ok(cores)
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use crate::server::ServerConfig;
554 use clap::Parser;
555
556 const MIXED: &str = "journal-seq=1,matching=2,response=3,reader=4,journal-disk=5,\
561 repl-handler-0=6,repl-handler-1=7,event-publisher=8y,shadow=8y,journal-prep=8";
562
563 fn complete(entries: &str) -> String {
566 let mut value = entries.to_owned();
567 for (name, _) in PipelineCores::unpinned().named() {
568 let named = entries
569 .split(',')
570 .any(|entry| entry.split_once('=').is_some_and(|(n, _)| n.trim() == name));
571 if !named {
572 value.push_str(&format!(",{name}=0"));
573 }
574 }
575 value
576 }
577
578 #[test]
585 fn parse_cores_requires_every_thread() {
586 const REQUIRED: [&str; 10] = [
587 "journal-seq",
588 "matching",
589 "response",
590 "reader",
591 "event-publisher",
592 "shadow",
593 "repl-handler-0",
594 "repl-handler-1",
595 "journal-prep",
596 "journal-disk",
597 ];
598 for missing in REQUIRED {
599 let prefix = format!("{missing}=");
600 let value = DEFAULT_CORES
601 .split(',')
602 .filter(|entry| !entry.starts_with(&prefix))
603 .collect::<Vec<_>>()
604 .join(",");
605 let err = parse_cores(&value).expect_err(missing);
606 assert!(err.starts_with(&format!("missing {missing}:")), "{err}");
607 }
608
609 let err = parse_cores("journal-seq=1,matching=2").expect_err("eight threads missing");
610 assert!(
611 err.starts_with(
612 "missing response, reader, event-publisher, shadow, repl-handler-0, \
613 repl-handler-1, journal-prep, journal-disk:"
614 ),
615 "{err}"
616 );
617 assert!(err.contains("`none`"), "points at the shorthand: {err}");
618 }
619
620 #[test]
623 fn parse_cores_reads_named_entries_in_any_order() {
624 let forward =
625 parse_cores(&complete("journal-seq=1,matching=2,journal-disk=3")).expect("parses");
626 let reversed =
627 parse_cores(&complete("journal-disk=3, matching=2 ,journal-seq = 1")).expect("parses");
628 assert_eq!(forward, reversed);
629 assert_eq!(forward.journal_seq, Placement::spinning(1));
630 assert_eq!(forward.matching, Placement::spinning(2));
631 assert_eq!(forward.journal_disk, Placement::spinning(3));
632
633 let every = parse_cores(DEFAULT_CORES).expect("the default parses");
634 assert_eq!(every.event_publisher, Placement::spinning(6));
635 assert_eq!(every.journal_prep, Placement::yielding(10));
636 assert_eq!(every.journal_disk, Placement::spinning(11));
637 }
638
639 #[test]
642 fn parse_cores_none_unpins_every_thread() {
643 assert_eq!(parse_cores("none"), Ok(PipelineCores::unpinned()));
644 assert_eq!(
645 parse_cores(&complete("journal-seq=0")),
646 Ok(PipelineCores::unpinned())
647 );
648 assert!(
649 parse_cores("none,journal-seq=1").is_err(),
650 "none is the whole value, not an entry"
651 );
652 assert!(PipelineCores::unpinned().pins_nothing());
653 assert!(!ServerConfig::default().cores.pins_nothing());
654 }
655
656 #[test]
664 fn parse_cores_refuses_a_positional_list() {
665 for positional in [
666 "1,2,3,4,0,6,7,8,9",
667 "1,2,3,4,5,6,7,8,9,10",
668 "1,2,3,4,5,6,7,8,9,10,11",
669 "1y,2y,3y,4y,0,6y,7y,8y,9y,10,11y",
670 "0,0,0,0,0,0,0,0,0",
671 ] {
672 let err = parse_cores(positional).expect_err(positional);
673 assert!(err.contains("named entries"), "{positional}: {err}");
674 assert!(err.contains("journal-disk"), "lists the threads: {err}");
675 assert!(err.contains("`none`"), "points at the shorthand: {err}");
676 }
677 }
678
679 #[test]
684 fn parse_cores_refuses_unknown_repeated_and_malformed_entries() {
685 let err = parse_cores("journal-seq=1,jornal=2").expect_err("misspelt thread");
686 assert!(err.contains("unknown thread `jornal`"), "{err}");
687 let err = parse_cores("repl-sender=5").expect_err("retired thread");
688 assert!(err.contains("unknown thread `repl-sender`"), "{err}");
689
690 let err =
691 parse_cores("journal-seq=1,matching=2,journal-seq=3").expect_err("repeated thread");
692 assert!(err.contains("journal-seq is named twice"), "{err}");
693
694 let err = parse_cores("journal-seq=x").expect_err("bad core");
695 assert!(err.contains("journal-seq: invalid core ID `x`"), "{err}");
696 let err = parse_cores("journal-seq=").expect_err("empty core");
697 assert!(
698 err.contains("invalid core ID ``"),
699 "an empty core is shown as such, not as a message trailing off: {err}"
700 );
701 assert!(parse_cores("journal-seq=0s").is_err());
702 assert!(parse_cores("journal").is_err());
703 assert!(parse_cores("journal-seq=1,").is_err(), "trailing comma");
704 assert!(parse_cores("=1").is_err());
705 assert!(parse_cores("").is_err());
706 }
707
708 #[test]
712 fn parse_placement_reads_the_wait_suffix() {
713 assert_eq!(parse_placement("7"), Ok(Placement::spinning(7)));
714 assert_eq!(parse_placement("7s"), Ok(Placement::spinning(7)));
715 assert_eq!(parse_placement("7y"), Ok(Placement::yielding(7)));
716 assert_eq!(parse_placement("0"), Ok(Placement::unpinned()));
717 assert_eq!(parse_placement("0y"), Ok(Placement::unpinned()));
718 assert!(
719 parse_placement("0s").is_err(),
720 "a thread with no core of its own cannot busy-spin"
721 );
722 assert!(parse_placement("x").is_err());
723 assert!(parse_placement("7z").is_err());
724 assert!(parse_placement("").is_err());
725 }
726
727 #[test]
731 fn journal_prep_always_yields() {
732 let cores = parse_cores(&complete("journal-prep=10")).expect("parses");
733 assert_eq!(cores.journal_prep, Placement::yielding(10));
734 let explicit = parse_cores(&complete("journal-prep=10y")).expect("parses");
735 assert_eq!(explicit.journal_prep, Placement::yielding(10));
736 let err = parse_cores(&complete("journal-prep=10s")).expect_err("must refuse");
737 assert!(err.contains("journal-prep"), "{err}");
738 }
739
740 #[test]
745 fn validate_refuses_a_busy_spinner_sharing_a_core() {
746 let err = parse_cores(&complete("journal-seq=1,matching=1"))
748 .expect("parses")
749 .validate()
750 .expect_err("two spinners on core 1 must be refused");
751 assert!(err.contains("journal"), "{err}");
752 assert!(err.contains("matching"), "{err}");
753 assert!(err.contains("core 1"), "{err}");
754
755 let err = parse_cores(&complete("event-publisher=7,shadow=7y"))
757 .expect("parses")
758 .validate()
759 .expect_err("a spinner sharing with a yielder must be refused");
760 assert!(err.contains("event-publisher"), "{err}");
761 assert!(err.contains("shadow"), "{err}");
762 assert!(err.contains("event-publisher busy-spins"), "{err}");
763
764 let err = parse_cores(&complete("event-publisher=6y,shadow=6"))
766 .expect("parses")
767 .validate()
768 .expect_err("6y and 6 is still a spinner sharing a core");
769 assert!(err.contains("shadow busy-spins"), "{err}");
770
771 let err = parse_cores(&complete("journal-seq=1,journal-disk=1"))
773 .expect("parses")
774 .validate()
775 .expect_err("journal-disk on the journal's core must be refused");
776 assert!(err.contains("journal-disk"), "{err}");
777 }
778
779 #[test]
783 fn validate_allows_yielders_to_share_a_core() {
784 parse_cores(
785 "journal-seq=1,matching=2,response=3,reader=4,event-publisher=7y,shadow=7y,\
786 repl-handler-0=7y,repl-handler-1=7y,journal-prep=7,journal-disk=7y",
787 )
788 .expect("parses")
789 .validate()
790 .expect("yielders and the preparer may share core 7");
791 parse_cores("none")
792 .expect("parses")
793 .validate()
794 .expect("every thread unpinned is a valid (test) layout");
795 }
796
797 #[test]
801 fn validate_refuses_an_unpinned_busy_spinner_built_by_hand() {
802 let mut cores = ServerConfig::default().cores;
803 cores.shadow = Placement {
804 core: 0,
805 wait: WaitStrategy::BusySpin,
806 };
807 let err = cores
808 .validate()
809 .expect_err("unpinned spinner must be refused");
810 assert!(err.contains("shadow"), "{err}");
811 assert!(err.contains("unpinned"), "{err}");
812 }
813
814 #[test]
820 fn a_spinning_preparer_built_by_hand_is_read_as_yielding() {
821 let mut cores = parse_cores(&complete("event-publisher=8y,shadow=8y")).expect("parses");
822 cores.journal_prep = Placement::spinning(8);
823 cores
824 .validate()
825 .expect("the preparer never spins, so it may share core 8 with yielders");
826 let reparsed = parse_cores(&cores.to_string()).expect("the rendering parses");
827 assert_eq!(reparsed.journal_prep, Placement::yielding(8));
828 assert_eq!(reparsed.named(), cores.named(), "log and check agree");
829
830 cores.journal_prep = Placement {
831 core: 0,
832 wait: WaitStrategy::BusySpin,
833 };
834 cores
835 .validate()
836 .expect("nor is an unpinned preparer an unpinned spinner");
837 }
838
839 #[test]
842 fn shipped_layouts_validate() {
843 let default = ServerConfig::default().cores;
844 for reader in [ReaderThread::IoUring, ReaderThread::DpdkPoll] {
845 default.resolve(reader).expect("default layout");
846 }
847 let (compact, _) = PipelineCores::compact(16).expect("compact fits 16 cores");
848 compact
849 .resolve(ReaderThread::DpdkPoll)
850 .expect("compact layout");
851 assert_eq!(
852 compact.journal_disk,
853 Placement::unpinned(),
854 "compact leaves the disk thread unpinned, hence yielding"
855 );
856 }
857
858 #[test]
865 fn unpinned_mandatory_names_the_hot_path_threads_without_a_core() {
866 const MANDATORY: [&str; 5] = [
867 "journal-seq",
868 "matching",
869 "response",
870 "reader",
871 "journal-disk",
872 ];
873 let known = PipelineCores::unpinned().named().map(|(name, _)| name);
874 for name in MANDATORY {
875 assert!(known.contains(&name), "{name} is not a --cores thread");
876 }
877
878 assert_eq!(PipelineCores::unpinned().unpinned_mandatory(), MANDATORY);
879 assert!(
880 ServerConfig::default()
881 .cores
882 .unpinned_mandatory()
883 .is_empty()
884 );
885
886 let hot_pinned = parse_cores(&complete(
889 "journal-seq=1,matching=2,response=3,reader=4,journal-disk=5",
890 ))
891 .expect("parses");
892 assert!(hot_pinned.unpinned_mandatory().is_empty());
893
894 let disk_unpinned = parse_cores(
897 "journal-seq=1,matching=2,response=3,reader=4,event-publisher=6,shadow=7,\
898 repl-handler-0=8,repl-handler-1=9,journal-prep=10,journal-disk=0",
899 )
900 .expect("parses");
901 assert_eq!(disk_unpinned.unpinned_mandatory(), ["journal-disk"]);
902
903 let (compact, _) = PipelineCores::compact(16).expect("compact fits 16 cores");
905 assert_eq!(compact.unpinned_mandatory(), ["journal-disk"]);
906 }
907
908 #[test]
912 fn all_yielding_makes_a_packed_layout_legal() {
913 let packed = parse_cores(
914 "journal-seq=1,matching=1,response=1,reader=1,event-publisher=1,shadow=1,\
915 repl-handler-0=1,repl-handler-1=1,journal-prep=0,journal-disk=0",
916 )
917 .expect("parses");
918 assert!(
919 packed.resolve(ReaderThread::IoUring).is_err(),
920 "eight spinners on core 1 must be refused"
921 );
922 let cores = packed
923 .all_yielding()
924 .resolve(ReaderThread::IoUring)
925 .expect("the same cores, all yielding, are legal");
926 assert_eq!(cores.journal_seq, Placement::yielding(1));
927 assert_eq!(cores.journal_disk, Placement::unpinned());
928 assert_eq!(
929 cores.to_string(),
930 "journal-seq=1y,matching=1y,response=1y,reader=1y,event-publisher=1y,shadow=1y,\
931 repl-handler-0=1y,repl-handler-1=1y,journal-prep=0,journal-disk=0",
932 "every pinned entry carries the suffix"
933 );
934 }
935
936 #[test]
942 fn dpdk_reader_never_yields() {
943 let explicit = parse_cores(&complete("reader=4y")).expect("parses");
944 let err = explicit
945 .resolve(ReaderThread::DpdkPoll)
946 .expect_err("`4y` on the DPDK reader must be refused");
947 assert!(err.contains("reader=4y"), "{err}");
948 assert!(err.contains("poll"), "{err}");
949 explicit
950 .resolve(ReaderThread::IoUring)
951 .expect("the io_uring reader may yield");
952
953 let shared = parse_cores(&complete("reader=4,event-publisher=4y")).expect("parses");
954 let err = shared
955 .resolve(ReaderThread::DpdkPoll)
956 .expect_err("a core shared with the poll thread is refused");
957 assert!(err.contains("reader busy-spins"), "{err}");
958
959 let unpinned =
960 parse_cores(&complete("journal-seq=1,matching=2,response=3,reader=0")).expect("parses");
961 let cores = unpinned
962 .resolve(ReaderThread::DpdkPoll)
963 .expect("an unpinned reader is accepted as written");
964 assert_eq!(cores.reader, Placement::unpinned());
965 }
966
967 #[test]
972 fn place_journal_children_forwards_the_disk_and_preparer_placements() {
973 use counter_server::CounterEvent;
974 use melin_pipeline::ring::DisruptorBuilder;
975 use melin_transport_core::pipeline::InputSlot;
976 use std::time::Duration;
977
978 let dir = tempfile::tempdir().expect("tempdir");
979 let writer = melin_journal::BufferedWriter::<CounterEvent>::create(&dir.path().join("j"))
980 .expect("create journal");
981 let (_producer, mut consumers) = DisruptorBuilder::<InputSlot<CounterEvent>>::new(4)
982 .add_consumer()
983 .build(WaitStrategy::SpinThenYield);
984 let mut stage = JournalStage::new(
987 writer,
988 consumers.pop().expect("one consumer"),
989 Duration::ZERO,
990 64,
991 WaitStrategy::BusySpin,
992 );
993 assert_eq!(
994 stage.disk_wait(),
995 WaitStrategy::BusySpin,
996 "before placement the disk thread inherits the sequencer's strategy"
997 );
998
999 let cores =
1000 parse_cores(&complete("journal-seq=1,journal-prep=8,journal-disk=5y")).expect("parses");
1001 cores.place_journal_children(&mut stage);
1002 assert_eq!(stage.disk_core(), 5);
1003 assert_eq!(stage.disk_wait(), WaitStrategy::SpinThenYield);
1004 assert_eq!(stage.preparer_core(), 8);
1005 }
1006
1007 #[test]
1011 fn layout_display_round_trips_through_parse_cores() {
1012 for input in [
1013 DEFAULT_CORES.to_owned(),
1014 MIXED.to_owned(),
1015 "none".to_owned(),
1016 complete("journal-seq=1y,matching=2y,response=3y,reader=4y,journal-disk=7y"),
1017 ] {
1018 let cores = parse_cores(&input).expect("parses");
1019 let rendered = cores.to_string();
1020 let reparsed = parse_cores(&rendered).expect("rendering parses");
1021 assert_eq!(reparsed, cores, "{input} -> {rendered}");
1022 }
1023 assert_eq!(PipelineCores::unpinned().to_string(), "none");
1024 assert_eq!(
1025 ServerConfig::default().cores.to_string(),
1026 DEFAULT_CORES,
1027 "the hand-written default renders as the CLI default"
1028 );
1029 assert_eq!(
1030 ServerConfig::try_parse_from(["melin-server"])
1031 .expect("parses")
1032 .cores,
1033 ServerConfig::default().cores,
1034 "a node started without --cores runs the hand-written default"
1035 );
1036 }
1037
1038 #[test]
1040 fn mixed_layout_parses_and_resolves() {
1041 let config =
1042 ServerConfig::try_parse_from(["melin-server", "--cores", MIXED]).expect("parses");
1043 for reader in [ReaderThread::IoUring, ReaderThread::DpdkPoll] {
1044 let cores = config.resolved_cores(reader).expect("a legal mixed layout");
1045 assert_eq!(cores.journal_seq, Placement::spinning(1));
1046 assert_eq!(cores.reader, Placement::spinning(4));
1047 assert_eq!(cores.journal_disk, Placement::spinning(5));
1048 assert_eq!(cores.repl_handler_0, Placement::spinning(6));
1049 assert_eq!(cores.repl_handler_1, Placement::spinning(7));
1050 assert_eq!(cores.event_publisher, Placement::yielding(8));
1051 assert_eq!(cores.shadow, Placement::yielding(8));
1052 assert_eq!(cores.journal_prep, Placement::yielding(8));
1053 assert_eq!(
1054 cores.stage_waits(),
1055 StageWaits {
1056 ingress: WaitStrategy::BusySpin,
1057 journal: WaitStrategy::BusySpin,
1058 matching: WaitStrategy::BusySpin,
1059 }
1060 );
1061 }
1062 }
1063}