Skip to main content

melin_server_runtime/
layout.rs

1//! Where each pipeline thread runs and how it waits there: the `--cores`
2//! layout.
3//!
4//! A layout is one [`Placement`] per pipeline thread — a core and a
5//! [`WaitStrategy`] — parsed from `--cores`, resolved against the
6//! transport, and checked before any thread is spawned. `--cores` is the
7//! only place a wait policy is stated, so there is one spelling of every
8//! layout and nothing for a second flag to override. The check exists
9//! because the two halves of a placement
10//! constrain each other across threads: a busy-spinning thread needs its
11//! core to itself, and two threads that share a core must both yield, or
12//! the one waiting sits queued behind the one spinning for a full
13//! scheduler slice on every hand-off. A node refuses to start with a
14//! layout that breaks that rule rather than running slowly and
15//! unpredictably.
16//!
17//! `--cores` names each thread (`journal-seq=1,matching=2,…`) rather than
18//! listing cores by position. A positional list can only ever grow at its
19//! end and can never lose an entry: dropping one re-reads every later core
20//! as its neighbour's, and the result usually still parses. A named entry
21//! says which thread it places, so a misspelt, repeated or missing name is
22//! refused, a retired thread is simply an unknown one, and a thread added
23//! later is one more name rather than one more position.
24
25use melin_app::AppEvent;
26use melin_pipeline::wait::WaitStrategy;
27use melin_transport_core::pipeline::{JournalStage, StageWaits};
28
29/// The layout a node runs when `--cores` is not given.
30pub(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
33/// How many threads a layout places. `usize` because it is an array
34/// length: every per-thread list here is a fixed array of this size.
35const THREAD_COUNT: usize = 10;
36
37/// The threads every node runs, whatever its flags, and that the operator
38/// docs say need a core to themselves: a request passes through each of
39/// them on its way to an acknowledgement. Named as in `--cores`; a subset
40/// of the list in [`PipelineCores::named_mut`], which the tests hold it to.
41const MANDATORY_THREADS: [&str; 5] = [
42    "journal-seq",
43    "matching",
44    "response",
45    "reader",
46    "journal-disk",
47];
48
49/// Where one pipeline thread runs and how it waits there.
50///
51/// The two are one value because they constrain each other: a thread
52/// that busy-spins needs a core to itself, and a thread with no core of
53/// its own (`core == 0`, the unpinned sentinel) can only ever yield — a
54/// spinner the scheduler is free to place is the co-location bug with
55/// the victim chosen at random. [`PipelineCores::validate`] enforces the
56/// cross-thread half of that rule; the constructors here enforce the
57/// per-thread half.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct Placement {
60    /// Logical CPU to pin to. `0` = unpinned (OS scheduled).
61    pub core: usize,
62    /// How the thread waits when it has nothing to do.
63    pub wait: WaitStrategy,
64}
65
66impl Placement {
67    /// Pinned to `core` and busy-spinning: the thread owns that core.
68    /// A `core` of `0` cannot spin — see the type docs — so this yields
69    /// the unpinned placement instead.
70    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    /// Pinned to `core`, spinning briefly then yielding: the thread may
82    /// share that core with other yielding threads.
83    pub const fn yielding(core: usize) -> Self {
84        Self {
85            core,
86            wait: WaitStrategy::SpinThenYield,
87        }
88    }
89
90    /// No core of its own: the OS scheduler places it within the CPU set
91    /// the process was started with — and therefore it yields.
92    pub const fn unpinned() -> Self {
93        Self::yielding(0)
94    }
95
96    /// Whether this thread is pinned to a core (`core != 0`).
97    pub fn is_pinned(&self) -> bool {
98        self.core != 0
99    }
100}
101
102/// What the `reader` entry of `--cores` pins on a given transport. The
103/// two threads wait differently, and one of them cannot take a policy
104/// at all.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ReaderThread {
107    /// The io_uring reader (kernel TCP). It blocks in the kernel between
108    /// completions, so its entry's policy governs only the input ring it
109    /// produces into.
110    IoUring,
111    /// The DPDK NIC poll thread. It polls the device unconditionally and
112    /// never idles, so it busy-spins whatever its entry says; a `y` on
113    /// the reader is refused rather than silently ignored.
114    DpdkPoll,
115}
116
117/// Core assignments for pipeline threads, each with its wait policy.
118///
119/// All fields are always stored; event-publisher is only used when
120/// `--event-bind` is set, and shadow only when `--snapshot-interval-ms` > 0.
121/// repl-handler-0/1 are spawned on replica connect, not at startup.
122/// A `core` of 0 = unpinned (OS scheduled) for any field.
123///
124/// The thread that accepts replica connections (`repl-accept`) has no
125/// field: it accepts, reaps finished handlers and sleeps, so a core of its
126/// own would buy nothing.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct PipelineCores {
129    /// The journal's sequencing thread: orders, encodes and hash-chains
130    /// events, and feeds the replicas and the disk thread.
131    pub journal_seq: Placement,
132    pub matching: Placement,
133    pub response: Placement,
134    /// io_uring reader thread (TCP) or DPDK poll thread — see
135    /// [`ReaderThread`] for how the two differ.
136    pub reader: Placement,
137    pub event_publisher: Placement,
138    pub shadow: Placement,
139    /// Replication handler thread 0.
140    pub repl_handler_0: Placement,
141    /// Replication handler thread 1.
142    pub repl_handler_1: Placement,
143    /// The journal segment preparer (background staging of the next
144    /// segment).
145    ///
146    /// Its wait policy is always yielding: the preparer blocks in file
147    /// I/O and sleeps between attempts, it never polls a ring. The parser
148    /// only produces a yielding preparer, and the layout check and the
149    /// rendering read it as yielding whatever this field holds, so a
150    /// busy-spin value set in code is ignored rather than refused. It is
151    /// carried here so the layout check knows it is harmless to share a
152    /// core with.
153    pub journal_prep: Placement,
154    /// The journal disk thread — the half that writes, syncs, and
155    /// publishes durability.
156    ///
157    /// Unlike the preparer this is a hot-path thread: it polls for
158    /// batches, and the durability cursors every ack gates on are
159    /// published from it. Place it on the same CCD as `journal-seq` — the
160    /// hand-off bounces a cache line between the two on every batch,
161    /// and a cross-CCD transfer costs ~100 ns of that.
162    pub journal_disk: Placement,
163}
164
165impl PipelineCores {
166    /// Every thread unpinned, and therefore yielding: `--cores none`.
167    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    /// Every thread with its `--cores` name, in the order the layout is
184    /// rendered — the one list the parser, the layout check, the rendering
185    /// and [`all_yielding`](Self::all_yielding) go through. The
186    /// destructuring binds every field, so a thread added to the struct
187    /// does not build until it is named here too.
188    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    /// [`named_mut`](Self::named_mut) by value, with the preparer read as
216    /// yielding whatever its field holds. It blocks in I/O and never waits
217    /// in a loop, so a busy-spin policy on it describes nothing; the
218    /// layout check and the rendering both come through here, so neither
219    /// refuses a layout nor logs one over a policy the thread cannot have.
220    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    /// The per-stage policies the pipeline builders take, drawn from the
229    /// threads that own each stage: the reader produces into the input
230    /// ring, journal-seq feeds the write and replication rings, matching
231    /// produces into the output ring.
232    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    /// Hand the journal stage the placements of the two helper threads its
241    /// `start` launches: the segment preparer and the disk thread.
242    /// journal-seq's own placement is applied where that thread is
243    /// spawned, like every other stage's; the helpers are launched by the
244    /// stage, so their placement has to reach it before `start`. One
245    /// function so the primary, the DPDK primary and the replica cannot
246    /// drift apart on which fields they forward.
247    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    /// The same layout with every thread yielding: a `y` on every entry,
254    /// for code that keeps a layout's cores but must share them. Code
255    /// that only needs the node out of everyone's way wants
256    /// [`unpinned`](Self::unpinned) instead, which spreads the threads
257    /// rather than stacking them on the layout's cores. Not valid for a
258    /// DPDK reader, which cannot yield; see [`resolve`](Self::resolve).
259    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    /// The mandatory threads with no core of their own, in `--cores`
267    /// order: what the boot log warns about. A thread without a core runs
268    /// wherever the scheduler puts it and shares that core with whatever
269    /// else is there. On an auxiliary thread that is the documented trade
270    /// for a small box; on one of these five it is paid on every request,
271    /// so it should be a choice the operator made knowingly. A `Vec` only
272    /// to carry the names into one log line; this runs once, at startup.
273    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    /// Whether no thread at all has a core: the `--cores none` layout.
282    pub fn pins_nothing(&self) -> bool {
283        self.named()
284            .iter()
285            .all(|(_, placement)| !placement.is_pinned())
286    }
287
288    /// The layout the node actually runs: the reader entry checked
289    /// against what the transport's reader thread can do, then the whole
290    /// thing checked with [`validate`](Self::validate). Every spawn site
291    /// reads the result, so no thread waits under a policy the operator
292    /// did not ask for.
293    ///
294    /// On DPDK the reader is the NIC poll thread, which busy-polls
295    /// whatever its entry says, so a `y` there is a contradiction and is
296    /// refused. An unpinned DPDK reader is accepted as written: it polls
297    /// flat out wherever the scheduler puts it, which is the one
298    /// placement this module cannot make honest, and the operator docs
299    /// say so.
300    ///
301    /// An `Err` is a configuration the node refuses to start with.
302    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    /// Refuse a layout in which threads would starve each other: two
319    /// threads pinned to the same core where either one busy-spins, or
320    /// a busy-spinner with no core at all.
321    ///
322    /// Checked over every thread, whether or not today's flags spawn it
323    /// — a layout is meant to be right independent of which features are
324    /// on, and the message names the threads and the core so the fix is
325    /// obvious. Two yielding threads on one core is the supported way to
326    /// run on a small box and passes. The parser and the [`Placement`]
327    /// constructors never produce an unpinned spinner, but the fields are
328    /// public, so the check does not rely on that.
329    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    /// Compact pipeline layout that fits on `num_cpus` physical+logical
363    /// cores while reserving one core for an external client (bench or
364    /// reader). Used by the embedded bench so it doesn't HT-collide with
365    /// the pipeline cores.
366    ///
367    /// On the default workstation `Default` layout the journal (core 1),
368    /// matching (core 2), and shadow/repl_handler cores (7/8/9) are HT
369    /// siblings of each other on an 8-core (16-thread) Ryzen — a layout
370    /// designed for a 10-physical-core box. This packs everything into
371    /// the lower physical cores and leaves core 10+ free.
372    ///
373    /// Returns the chosen layout and the recommended bench/client core.
374    /// Errors if `num_cpus` is too small to host the pipeline.
375    pub fn compact(num_cpus: usize) -> Result<(Self, usize), String> {
376        // Need: journal, matching, response, event_publisher, shadow,
377        // reader (one each) + 1 reserved for bench = 7 cores. Plus
378        // core 0 for the OS / IRQs. So minimum 8 logical cores.
379        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 handlers not spawned in compact; unpinned.
392            repl_handler_0: Placement::unpinned(),
393            repl_handler_1: Placement::unpinned(),
394            // Preparer unpinned in compact — the embedded bench doesn't
395            // rotate at production cadence, and raising compact's core
396            // minimum for it isn't worth a core.
397            journal_prep: Placement::unpinned(),
398            // The disk thread IS hot, but compact exists for boxes that
399            // cannot spare a core per stage. Unpinned, it yields like
400            // every thread without a core of its own: on a box with an
401            // idle core to land on that costs one `sched_yield` per
402            // poll, not a timeslice. Give it `journal-disk=` on any box
403            // with the cores to spare.
404            journal_disk: Placement::unpinned(),
405        };
406        Ok((cores, 7))
407    }
408}
409
410/// Renders the layout in `--cores` syntax, suffixes included, so what
411/// the node logs at boot can be pasted straight back onto a command
412/// line. Every thread is named, unpinned ones as `=0`, so the log shows
413/// the whole layout rather than only what was pinned; a layout with
414/// nothing pinned renders as `none`.
415impl 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                // The preparer takes no suffix — its entry is a core only.
427                ("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
436/// Parse the core of one `--cores` entry, the part after `=`: a core ID
437/// with an optional wait suffix.
438///
439/// `7` and `7s` busy-spin, `7y` spins then yields, `0`/`0y` is unpinned
440/// (and yields — see [`Placement`]). `0s` is refused: it asks for a
441/// spinner with no core to spin on.
442fn 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
462/// The thread names `--cores` accepts, for error messages.
463fn thread_names() -> String {
464    PipelineCores::unpinned()
465        .named()
466        .map(|(name, _)| name)
467        .join(", ")
468}
469
470/// Parse a `--cores` value into `PipelineCores`: comma-separated
471/// `thread=core` entries in any order, each core with the suffix
472/// described at [`parse_placement`]. Every thread must be named; `none`
473/// leaves every thread unpinned.
474pub(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    // A value made only of bare cores is the positional list earlier
480    // releases took. Refuse it as a whole and say what replaced it, rather
481    // than failing on its first entry as if it were a typo.
482    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    // Which threads the value has named so far, indexed like `named_mut`.
493    // A fixed array rather than a set: the threads are a closed list known
494    // at compile time.
495    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        // The preparer never busy-waits (it blocks in I/O), so its entry is
515        // a core and nothing else: a `y` is redundant and an `s` asks for
516        // something the thread cannot do.
517        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    // Every thread must be named: one left out would run somewhere the
532    // operator did not choose. A `Vec` only to join the names into the
533    // message; this runs once, at startup.
534    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    /// The mixed layout the operator docs show: everything on the
557    /// acknowledgement path (the four stages, the disk thread, the two
558    /// replication handlers) spins on a core of its own; the event
559    /// publisher, the shadow and the preparer share one core and yield.
560    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    /// `entries` completed with `=0` for each thread it does not name, so
564    /// a test spells out only the threads it is about.
565    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    /// Every thread must be named: leaving one out is refused, with every
579    /// missing thread named at once, rather than leaving it somewhere the
580    /// operator did not choose. That includes journal-prep and
581    /// journal-disk, which shorter positional lists could leave out. The
582    /// set is spelled out here rather than derived from the parser's own
583    /// list, so a thread that stops being required is a test failure.
584    #[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    /// Entries are named, so their order carries no meaning and whitespace
621    /// around them is tolerated.
622    #[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    /// `none` is the whole layout unpinned, the same as naming nothing
640    /// pinned.
641    #[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    /// The reason the syntax is named. A positional value from an earlier
657    /// release must not parse under any length: read by name it would
658    /// mean nothing, and read by position with its retired fifth entry
659    /// gone it would put every later thread on its neighbour's core
660    /// without a word. The message has to say what replaced it, `none`
661    /// included: the all-`0` list is the positional value a test harness
662    /// or a container entrypoint is most likely to still carry.
663    #[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    /// A name that places nothing, or places a thread twice, is refused:
680    /// silently dropping either would leave a thread somewhere the
681    /// operator did not put it. The retired replication-sender thread is
682    /// just an unknown name.
683    #[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    /// A bare core busy-spins (the production default, and what an entry
709    /// without a suffix means), `y` yields, `s` spells the default out,
710    /// and `0` is unpinned — which can only yield.
711    #[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    /// The preparer blocks in I/O and never polls, so its entry is a
728    /// core only: it always yields, and asking it to spin is refused
729    /// rather than silently ignored.
730    #[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    /// The layout the whole change exists to refuse: two threads on one
741    /// core where either busy-spins. The message has to name both
742    /// threads, the core, and which one spins, or the operator is left
743    /// guessing which of ten entries to change.
744    #[test]
745    fn validate_refuses_a_busy_spinner_sharing_a_core() {
746        // Two spinners.
747        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        // A spinner next to a yielder: the yielder would still starve.
756        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        // Which of the two spins does not matter.
765        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        // The disk thread sharing the journal's core is the same mistake.
772        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    /// The supported ways to share: two yielders on one core, the
780    /// preparer (which never spins) next to them, and unpinned threads,
781    /// which are not on any core to collide on.
782    #[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    /// The fields are public, so a layout built in code (not parsed)
798    /// can hold what the parser refuses: an unpinned busy-spinner. The
799    /// check catches it rather than trusting the constructors.
800    #[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    /// The preparer never waits in a loop, so a busy-spin policy set on it
815    /// in code describes nothing. The layout check must not refuse it for
816    /// sharing a core with yielding threads, or for spinning unpinned, and
817    /// the layout the boot log shows must parse back to one the check
818    /// reads the same way.
819    #[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    /// The layouts the runtime ships must pass their own check, on both
840    /// transports.
841    #[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    /// The threads the boot log warns about when they have no core are
859    /// the five every node runs, and only those: an unpinned auxiliary
860    /// thread is a documented layout, an unpinned mandatory one a cost on
861    /// every request. The five are spelled out here rather than read from
862    /// the module, so a thread that silently stops counting is a test
863    /// failure, and each must be a name the parser knows.
864    #[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        // The auxiliary threads unpinned and the five pinned: nothing to
887        // warn about.
888        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        // One of the five unpinned among pinned auxiliaries: it is named,
895        // and nothing else is.
896        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        // The compact layout's documented small-box choice counts too.
904        let (compact, _) = PipelineCores::compact(16).expect("compact fits 16 cores");
905        assert_eq!(compact.unpinned_mandatory(), ["journal-disk"]);
906    }
907
908    /// `all_yielding` is the programmatic "`y` on every entry": it turns
909    /// a packed layout the check would refuse into one it accepts, and
910    /// leaves the cores where they were.
911    #[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    /// The DPDK poll thread busy-polls whatever its entry says, so a
937    /// `y` on the reader is a contradiction and is refused there, while
938    /// the io_uring reader accepts it. A spinning reader sharing a core
939    /// is refused by the ordinary check. An unpinned reader is left as
940    /// the operator wrote it.
941    #[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    /// The disk thread's suffix has to reach the disk thread, and the
968    /// preparer's core the preparer — through the one seam all three
969    /// spawn paths share. Without this the hop from `journal_disk.wait`
970    /// to the stage is correct by inspection only.
971    #[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        // The sequencing thread spins; the disk thread must not inherit
985        // that once the layout says otherwise.
986        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    /// What the node logs at boot is the layout in `--cores` syntax, so
1008    /// an operator can copy it back verbatim — which holds only if the
1009    /// rendering round-trips through the parser, suffixes and all.
1010    #[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    /// The mixed layout through the CLI, resolved on both transports.
1039    #[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}