Skip to main content

melin_server_runtime/
server.rs

1//! Server orchestrator — binds the accept loop, pipeline threads, and reader.
2//!
3//! On startup:
4//! 1. Recovers or creates the `JournaledApp<A>`.
5//! 2. Decomposes it into `(A, W)` via `into_parts()`, where `W` is the journal writer.
6//! 3. Builds the disruptor pipeline (input ring + output ring).
7//! 4. Spawns 3-5 OS threads: journal, matching, response, [repl-accept], [event-publisher].
8//! 5. Runs the accept loop, registering connections with the io_uring reader.
9//!
10//! Fully synchronous — no async runtime needed. Reader threads use io_uring
11//! with multishot RECV to multiplex connections, eliminating thread
12//! oversubscription. The response thread writes via io_uring SEND.
13
14use std::net::{IpAddr, Ipv4Addr, SocketAddr};
15use std::os::unix::io::AsRawFd;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19
20use tracing::{debug, error, info, warn};
21
22use melin_journal::BufferedWriter;
23use melin_journal::JournalError;
24use melin_journal::JournalWrite;
25use melin_transport_core::journaled_app::JournaledApp;
26use melin_transport_core::lineage::{Lineage, LineagePolicy};
27use melin_transport_core::node_store::{NodeStore, NodeStoreError};
28use melin_transport_core::pipeline::{
29    InputSlot, OutputSlot as GenericOutputSlot, Pipeline as GenericPipeline,
30    build_pipeline_with_replication,
31};
32use melin_transport_core::replica_presence::ReplicaPresence;
33/// Internal alias for the disruptor-built pipeline, used only by
34/// destructuring `let Pipeline { … }` patterns inside the boot path.
35/// Not part of the public API — callers reach the underlying type
36/// through `melin_transport_core::pipeline`.
37type Pipeline<A> = GenericPipeline<A>;
38
39use crate::StartupEvents;
40use crate::client_auth::MAX_AUTH_FRAME;
41use crate::reader::RequestDecoderArc;
42use crate::response::ResponseEncoderArc;
43use melin_app::auth::{AuthorizedKeys, ClientRole, KeyRole, RoleId};
44use melin_app::decoder::{ErasedDecoder, RequestDecoder};
45use melin_app::encoder::ResponseEncoder;
46use melin_app::{AppEvent, Application};
47use melin_pipeline::ring::Consumer;
48use melin_pipeline::wait::WaitStrategy;
49
50use crate::layout::{DEFAULT_CORES, parse_cores};
51/// The layout types live in [`crate::layout`]; re-exported here because
52/// `ServerConfig::cores` is one of them and callers reach the config
53/// through this module.
54pub use crate::layout::{PipelineCores, Placement, ReaderThread};
55/// The `--dpdk-*` flags' type, from `melin-dpdk`; re-exported here because
56/// `ServerConfig::dpdk` is one, so that a caller building a config in code
57/// needs no dependency of its own on that crate.
58pub use melin_dpdk::DpdkArgs;
59
60/// How the orchestrator (main) thread waits on pipeline threads during
61/// startup — the on_primary drain and the promotion epoch bump. It is unpinned
62/// and on no latency path, so it yields whatever the layout says: a
63/// spinner without a core of its own is the co-location bug with the
64/// victim chosen by the scheduler.
65const ORCHESTRATOR_WAIT: WaitStrategy = WaitStrategy::SpinThenYield;
66
67/// Output-slot sugar parameterised on the application — saves spelling
68/// `<A::Report, A::QueryResponse>` at every pipeline-facing signature
69/// that doesn't already destructure them.
70type OutputSlot<A> =
71    GenericOutputSlot<<A as Application>::Report, <A as Application>::QueryResponse>;
72
73/// Body of the event-publisher thread: a free function with this exact
74/// signature (an application with an output feed supplies its
75/// publisher; one without supplies nothing). Passing it as a function
76/// pointer keeps the runtime decoupled from the application — no
77/// application reference inside server.rs — without paying for a
78/// boxed closure.
79///
80/// Threaded into [`run`] (and `run_dpdk` under `feature = "dpdk"`) as `Option<EventPublisherFn>`:
81/// `None` disables the publisher unconditionally, `Some(_)` wires it up
82/// when `--event-bind` is also set.
83///
84/// The consumer trails the response stage: it yields a slot only once
85/// the response stage has passed it through the durability gate, so
86/// every slot it yields belongs to an event that holds the copies the
87/// ack policy demands: a report the publisher sees is exactly as durable as the
88/// reply its client was given. While the policy cannot be satisfied (the
89/// replicas it needs are gone), the consumer yields nothing, as the
90/// clients get no replies; the publisher must observe `shutdown` on its
91/// own while it waits, not on a new slot arriving.
92///
93/// Stamp each published frame with the slot's wire sequence
94/// ([`OutputSlot::wire_seq`](melin_transport_core::pipeline::OutputSlot::wire_seq)),
95/// the event's position in the journal: stable across restarts and
96/// failovers, so a subscriber can key on it to resume or deduplicate. The
97/// consumer's own position in the output ring is not: it restarts at
98/// every boot.
99///
100/// The consumer also yields the ack-policy changes the journal records
101/// ([`OutputPayload::AckPolicyChange`](melin_transport_core::pipeline::OutputPayload::AckPolicyChange)),
102/// at their place in the order. They carry no application output: skip
103/// them, or publish them to subscribers that audit the cluster's
104/// durability.
105pub type EventPublisherFn<A> = fn(
106    consumer: Consumer<OutputSlot<A>>,
107    bind_addr: SocketAddr,
108    authorized_keys: Arc<AuthorizedKeys>,
109    shutdown: &AtomicBool,
110    wait: WaitStrategy,
111);
112
113use melin_wire_protocol::blocking::BlockingFrameWriter;
114use melin_wire_protocol::control::ConnectionId;
115use melin_wire_protocol::transport::BlockingTransportListener;
116
117/// Default replica pipeline depth (pending ack queue capacity).
118///
119/// Only bounds persisted-ack *granularity*, never the receive rate: the
120/// queue merges instead of blocking when it fills. 256 entries × 16 B is
121/// 4 KiB, so the depth is chosen to keep granularity through an fsync
122/// hiccup rather than to save memory.
123const DEFAULT_REPLICATION_PIPELINE_DEPTH: usize = 256;
124
125/// A transport buffer size as the `--dpdk-client-*-kib` flags spell it:
126/// their defaults are the transport's own sizes, which are whole KiB (a
127/// test maps them back and compares).
128const fn kib_of(bytes: usize) -> u32 {
129    (bytes / 1024) as u32
130}
131
132/// A `--dpdk-client-*-kib` value as the transport's size in bytes.
133#[cfg(feature = "dpdk")]
134fn kib(value: u32) -> usize {
135    value as usize * 1024
136}
137
138/// CLI spelling of [`melin_journal::StagingMode`].
139///
140/// A separate enum rather than a `clap::ValueEnum` derive on the journal
141/// type: `melin-journal` has no clap dependency and should not acquire
142/// one for a flag: the crate is a library first, and its callers are not
143/// all command-line servers.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
145pub enum JournalStagingMode {
146    /// Pre-write every staged segment so appends generate no
147    /// extent-conversion metadata. Costs one extra pass over each
148    /// segment in device bandwidth.
149    #[default]
150    ZeroFill,
151    /// Allocate staged extents without materialising them. No staging
152    /// bandwidth; appends convert extents and `fdatasync` periodically
153    /// forces the filesystem log.
154    Allocate,
155}
156
157impl From<JournalStagingMode> for melin_journal::StagingMode {
158    fn from(m: JournalStagingMode) -> Self {
159        match m {
160            JournalStagingMode::ZeroFill => melin_journal::StagingMode::ZeroFill,
161            JournalStagingMode::Allocate => melin_journal::StagingMode::Allocate,
162        }
163    }
164}
165
166/// The node's DPDK address when `--dpdk-ip` is not given, as the flag's
167/// default on the node's command line (a client's flag has none): the
168/// address of [`melin_dpdk::NicConfig::default`], which is what a config
169/// built in code gets (a test holds the two together). A string because
170/// that is what clap takes a default as.
171const DEFAULT_DPDK_IP: &str = "10.0.0.1";
172
173/// Server configuration, parsed from CLI arguments via clap.
174#[derive(clap::Parser)]
175#[command(
176    name = "melin-server",
177    about = "A node of the Melin replicated sequencer",
178    mut_arg("dpdk_ip", |arg| arg.default_value(DEFAULT_DPDK_IP))
179)]
180pub struct ServerConfig {
181    /// Address to bind the TCP listener.
182    #[arg(long, default_value = "127.0.0.1:9876")]
183    pub bind: SocketAddr,
184    /// Path to the journal file for durable event sourcing.
185    #[arg(long, default_value = "melin.journal")]
186    pub journal: PathBuf,
187    /// Where each pipeline thread runs, as comma-separated `thread=core`
188    /// entries in any order. Threads: journal-seq, matching, response, reader,
189    /// event-publisher, shadow, repl-handler-0, repl-handler-1, journal-prep,
190    /// journal-disk. Every thread must be named: `0` leaves one unpinned,
191    /// and `none` unpins every thread. The node warns at boot when
192    /// journal-seq, matching, response, reader or journal-disk has no core:
193    /// every request passes through them. Core 0 is reserved for OS/IRQ
194    /// handling.
195    /// reader pins the io_uring reader (TCP) or DPDK poll thread.
196    /// event-publisher applies when `--event-bind` is set, shadow when
197    /// `--snapshot-interval-ms` > 0. repl-handler-0/1 are for the
198    /// per-replica TCP handler threads. journal-disk pins the thread that
199    /// writes and syncs the journal; give it a core on the same CCD as
200    /// journal-seq, since the two exchange a cache line per batch.
201    ///
202    /// Each core may carry a suffix saying how that thread waits: `7`
203    /// (or `7s`) busy-spins and needs the core to itself, `7y` spins
204    /// briefly then yields and may share the core with other `y`
205    /// threads. `0` (unpinned) always yields. Two threads on one core
206    /// where either busy-spins is refused at startup — they would starve
207    /// each other. journal-prep never busy-waits and takes no suffix.
208    #[arg(long, default_value = DEFAULT_CORES, value_parser = parse_cores)]
209    pub cores: PipelineCores,
210    /// Group commit coalescing delay in microseconds. Keep at 0 for TCP.
211    #[arg(long, default_value_t = 0)]
212    pub group_commit_us: u64,
213    /// Heartbeat interval in seconds. The server sends a heartbeat to idle
214    /// connections after this many seconds of silence. Set to 0 to disable.
215    #[arg(long, default_value_t = 10)]
216    pub heartbeat_interval_secs: u64,
217    /// Connection timeout in seconds. The server disconnects clients that
218    /// have not sent any data within this window. Set to 0 to disable.
219    #[arg(long, default_value_t = 30)]
220    pub connection_timeout_secs: u64,
221    /// Maximum number of concurrent authenticated connections, from 1 to
222    /// 8192. New connections are rejected (closed immediately) when this
223    /// limit is reached. Prevents fd/memory exhaustion (SEC-02). The
224    /// io_uring rings are sized from it, and their memory counts against
225    /// the locked-memory limit, so a cap well above the clients the node
226    /// serves costs memory for nothing. There is no "unlimited".
227    #[arg(
228        long,
229        default_value_t = 1024,
230        value_parser = clap::value_parser!(u64).range(1..=crate::connection_limit::MAX_SUPPORTED_CONNECTIONS)
231    )]
232    pub max_connections: u64,
233    /// Path to the authorized keys file for Ed25519 challenge-response
234    /// authentication. Every connection must authenticate before sending
235    /// requests.
236    /// Required for primary mode; ignored in replica mode (--replica-of).
237    /// See `AuthorizedKeys` for file format.
238    #[arg(long, default_value = "authorized_keys")]
239    pub authorized_keys: PathBuf,
240    /// Maximum journal size in MiB before automatic rotation at startup.
241    /// When the journal exceeds this threshold, the server saves a snapshot
242    /// and starts a fresh journal. Set to 0 to disable. Default: 256 MiB.
243    #[arg(long, default_value_t = 256)]
244    pub max_journal_mib: u64,
245    /// How the background preparer materialises the next journal
246    /// segment's extents.
247    ///
248    /// - `zero-fill` (default) physically pre-writes the segment, so
249    ///   appends carry no extent-conversion metadata and each flush's
250    ///   `fdatasync` never forces the filesystem log. It costs one extra
251    ///   pass over every segment in device bandwidth.
252    /// - `allocate` reserves the extents and stops. Staging becomes
253    ///   free, and the periodic log force returns to the flush path.
254    ///
255    /// Keep the default on local NVMe, where sequential bandwidth is
256    /// cheap and the log forces are what hurts. Consider `allocate` on
257    /// network-attached storage (EBS and similar), where staging
258    /// bandwidth is metered and drawn from the same budget as the hot
259    /// path — measure both on the volume in question rather than
260    /// assuming, since which one wins is a property of the device.
261    #[arg(long, value_enum, default_value_t = JournalStagingMode::ZeroFill)]
262    pub journal_staging_mode: JournalStagingMode,
263
264    /// Address to listen for replica connections (enables synchronous replication).
265    /// Mutually exclusive with `--standalone` and `--replica-of`.
266    #[arg(long)]
267    pub replication_bind: Option<std::net::SocketAddr>,
268
269    /// Disable replication (dev/test mode). The replica quorum cursor stays
270    /// at its no-replica sentinel (health reports zero replication lag) and
271    /// the ack policy evaluates against the primary's journal alone.
272    /// Mutually exclusive with `--replication-bind` and `--replica-of`.
273    #[arg(long, default_value_t = false)]
274    pub standalone: bool,
275
276    /// Run as a replica connected to the given primary address.
277    /// In replica mode, the server does not accept client connections.
278    /// Mutually exclusive with `--replication-bind` and `--standalone`.
279    #[arg(long)]
280    pub replica_of: Option<std::net::SocketAddr>,
281
282    /// Path to the Ed25519 private key for replication authentication.
283    /// Required in replica mode (`--replica-of`). The corresponding
284    /// public key must be listed in the primary's authorized_keys file
285    /// under the `replication` role.
286    #[arg(long)]
287    pub replication_key: Option<std::path::PathBuf>,
288
289    /// Maximum number of replication ring batches to coalesce into a
290    /// single TCP write+flush. Higher values reduce syscall overhead
291    /// but increase per-write latency. Default: 128.
292    #[arg(long, default_value_t = 128)]
293    pub replication_batch_size: usize,
294
295    /// Maximum events per journal fsync batch. Smaller values reduce
296    /// tail latency (less work per sync), larger values improve throughput
297    /// (fewer fsyncs). Default: 4096.
298    #[arg(long, default_value_t = 4096)]
299    pub max_journal_batch: usize,
300
301    /// Replication heartbeat interval in seconds. The primary sends a
302    /// heartbeat to the replica after this many seconds of idle. Used
303    /// for disconnect detection. Default: 5.
304    #[arg(long, default_value_t = 5)]
305    pub replication_heartbeat_secs: u64,
306
307    /// Number of receive batches the replica tracks individually while
308    /// they await local journal fsync. Must be a power of two. Beyond
309    /// this depth the receiver keeps receiving and coalesces pending
310    /// acks (a later ack, never an early one) — it never applies
311    /// backpressure to the primary. Default: 256.
312    #[arg(long, default_value_t = DEFAULT_REPLICATION_PIPELINE_DEPTH)]
313    pub replication_pipeline_depth: usize,
314
315    /// Number of slots in each replication ring buffer. Must be a power
316    /// of two. Each slot holds up to 512 KiB. More slots = more buffering
317    /// before eviction. Default: 256 (128 MiB per ring, 256 MiB dual-repl).
318    #[arg(long, default_value_t = 256)]
319    pub replication_ring_size: usize,
320
321    /// Ack policy: which copies of an event must exist before its
322    /// response is released. One of:
323    ///
324    /// - `disk`               `persisted>=1`. One fsynced copy, on
325    ///   whichever node's disk confirms first; required with
326    ///   `--standalone`. Dev/staging deployments.
327    /// - `ram`                `in_memory>=2`. Two nodes hold the event
328    ///   in memory before the ack; every fsync trails off the ack path
329    ///   (the journal still syncs every batch). Lowest ack latency;
330    ///   survives any single node failure via failover; loses only the
331    ///   un-fsynced tail on a whole-cluster power loss. For slow-fsync
332    ///   storage (cloud volumes) or RPO-tolerant apps.
333    /// - `disk+ram` (default) `persisted>=1 && in_memory>=2`. One
334    ///   fsynced copy plus a second copy in another node's memory.
335    ///   Single-failure-safe with a brief RAM-only window for the
336    ///   second copy. Typical live deployments. Faster than `two-disks`:
337    ///   an acknowledgement waits for the second node to receive the
338    ///   event, not to fsync it.
339    /// - `two-disks`          `persisted>=2`. Two fsynced copies before
340    ///   the client ack. Zero RAM-only window; the gate stalls when no
341    ///   replica is connected. Compliance-driven deployments.
342    ///
343    /// `--standalone` requires `disk`. Under every other policy the
344    /// gate stalls while no replica is connected — the correct
345    /// behaviour for a serious deployment that has lost its replicas.
346    /// See `docs/replication.md` for the operational menu.
347    #[arg(long, value_enum, default_value_t = crate::ack_policy::AckPolicy::DiskAndRam)]
348    pub ack_policy: crate::ack_policy::AckPolicy,
349
350    /// Keep a halted primary's held replies waiting for a replica.
351    ///
352    /// A primary that loses its last replica halts: it refuses new
353    /// writes. The replies it holds for the ack policy — writes it took
354    /// before the halt, and queries — cannot be confirmed by a replica
355    /// while none is connected. By default, once the halt has lasted
356    /// `--degraded-ack-grace-ms`, each is sent as soon as this node's own
357    /// journal holds it, ending in `BatchEndDegraded`: the client is told
358    /// only the primary's disk backs it. With this flag every such reply,
359    /// and every refusal and query queued behind one, waits for a replica
360    /// to return or the operator to swap the policy — strict fail-closed,
361    /// with clients seeing only their own read timeout.
362    #[arg(long, default_value_t = false)]
363    pub no_degraded_acks: bool,
364
365    /// How long, in milliseconds, a primary must have had no replica
366    /// before it answers held replies on its own disk (see
367    /// `--no-degraded-acks`). A replica that comes back within it, and
368    /// catches up, confirms them in full.
369    #[arg(long, default_value_t = crate::durability_gate::DEFAULT_DEGRADED_ACK_GRACE_MS)]
370    pub degraded_ack_grace_ms: u64,
371
372    /// The `--dpdk-*` flags (only used with --features dpdk), declared in
373    /// `melin-dpdk` for the node and its clients alike. On the node
374    /// `--dpdk-ip` defaults to 10.0.0.1, `NicConfig::default()`'s address,
375    /// where a client requires it.
376    #[command(flatten)]
377    pub dpdk: DpdkArgs,
378
379    /// Receive buffer, in KiB, of each client connection on the DPDK
380    /// client port: the window advertised to the client.
381    // On the node rather than in `DpdkArgs`: only a node accepts client
382    // connections. `u32` KiB rather than bytes: a value an operator types,
383    // and at most 4 TiB once multiplied out on a 64-bit target.
384    #[arg(
385        long,
386        default_value_t = kib_of(melin_dpdk::SocketBuffers::DEFAULT.rx_buf_size),
387        value_parser = clap::value_parser!(u32).range(1..)
388    )]
389    pub dpdk_client_rx_buf_kib: u32,
390
391    /// Send buffer, in KiB, of each client connection on the DPDK client
392    /// port: the in-flight window, which caps one connection's response
393    /// bandwidth at this much per round trip. The default carries one
394    /// client at a million responses a second with headroom; a single
395    /// connection driven at the stack's full rate wants more (256). Every
396    /// client connection holds one, so it multiplies by their number.
397    #[arg(
398        long,
399        default_value_t = kib_of(melin_dpdk::SocketBuffers::DEFAULT.tx_buf_size),
400        value_parser = clap::value_parser!(u32).range(1..)
401    )]
402    pub dpdk_client_tx_buf_kib: u32,
403
404    /// Responses, in KiB, that may queue behind a client connection's
405    /// send buffer before the connection is dropped as fallen behind.
406    #[arg(
407        long,
408        default_value_t = kib_of(melin_dpdk::SocketBuffers::DEFAULT.tx_queue_limit),
409        value_parser = clap::value_parser!(u32).range(1..)
410    )]
411    pub dpdk_client_tx_queue_kib: u32,
412
413    /// Address for the output event publisher. Subscribers connect here
414    /// to receive a real-time stream of the application's reports, as the
415    /// application's publisher encodes them. Ed25519 auth against the
416    /// node's authorized_keys; which keys may subscribe, and what each may
417    /// see, is the application's publisher's decision.
418    /// Omit to disable (ring has 1 consumer — identical to before).
419    #[arg(long)]
420    pub event_bind: Option<SocketAddr>,
421
422    /// Address for the health/liveness TCP endpoint. On connect, returns
423    /// a one-line status (`OK <conns> <journal_seq> <repl_lag>\n`) and
424    /// closes. No auth required. Set to empty string to disable.
425    #[arg(long, default_value = "127.0.0.1:9878")]
426    pub health_bind: Option<SocketAddr>,
427
428    /// TCP address for the operator admin endpoint. Authenticated with
429    /// operator keys (Ed25519 challenge-response, same as all other
430    /// admin handshakes). Accepts:
431    ///
432    /// - `PROMOTE\n` — replica → primary leadership transition (replica
433    ///   nodes only; rejected with ERR on a primary).
434    /// - `ROTATE\n` — archive the current journal segment at the next
435    ///   fsync boundary (any node where runtime rotation is enabled).
436    ///
437    /// Unset means no admin endpoint is listening; operators can still
438    /// rely on the size-driven rotation trigger (`--max-journal-mib`)
439    /// without exposing a port.
440    #[arg(long)]
441    pub admin_bind: Option<SocketAddr>,
442
443    /// Interval in milliseconds between automatic shadow snapshots. The
444    /// shadow stage replays journaled events on a cloned `A` and saves a
445    /// consistent snapshot at this cadence — no hot-path stall. Set to 0
446    /// to disable shadow snapshots entirely. Default: 3 000 000 ms (50 min).
447    #[arg(long, default_value_t = 3_000_000)]
448    pub snapshot_interval_ms: u64,
449
450    /// The node's snapshot file: read at boot, served to replicas that
451    /// need one, and written by the shadow stage. Defaults to the journal
452    /// path with a `.snapshot` extension. Its directory must exist; its
453    /// temporary file (`.tmp`), its previous copy (`.prev`) and any resync
454    /// archive of it stay in that directory.
455    #[arg(long)]
456    pub snapshot_path: Option<PathBuf>,
457
458    /// Cadence in milliseconds for the application's clock tick.
459    /// The ingress thread (io_uring reader or DPDK poll thread) publishes
460    /// a `JournalEvent::Tick { now_ns }` at this interval so the
461    /// application's time-driven work (expiries, timeouts, scheduled
462    /// transitions) fires in deterministic, journaled lockstep. There is
463    /// no separate tick thread on either transport. Set to 0 to disable
464    /// tick generation entirely (useful for benchmarks that don't
465    /// exercise time-driven features).
466    ///
467    /// Defaults to 250 ms. Under load the matching stage advances the
468    /// application's clock at every-event resolution from `slot.timestamp_ns`
469    /// (microsecond precision), so the tick is only the safety net for
470    /// quiet periods. 250 ms keeps time-driven work in quiet periods within
471    /// a quarter-second of their deadline at a cost of ~4 events/sec of
472    /// journal traffic.
473    #[arg(long, default_value_t = 250)]
474    pub tick_interval_ms: u64,
475
476    /// Disable `mlockall(MCL_CURRENT | MCL_FUTURE)` at startup.
477    ///
478    /// By default the server locks all current and future pages into
479    /// RAM to prevent rare multi-millisecond stalls from page faults
480    /// or swap activity on the matching hot path. Locking requires
481    /// `CAP_IPC_LOCK` (or running as root) and `RLIMIT_MEMLOCK` raised
482    /// to a value larger than the process's RSS — the server raises
483    /// the rlimit itself, but only succeeds with the capability.
484    ///
485    /// Use `--no-mlock` for development / containerised runs where
486    /// the privilege isn't available; on a bare-metal production host
487    /// leave it on.
488    #[arg(long, default_value_t = false)]
489    pub no_mlock: bool,
490
491    /// Bind address for the control-plane raft RPC listener. Setting this
492    /// enables the control plane on this node: leader election runs and is
493    /// observable via the `melin_raft_*` gauges. Requires `--raft-node-id`
494    /// and a `--raft-peer` entry for this node; requires
495    /// `--replication-key` (peer links authenticate with the replication
496    /// key, same trust domain as the data plane). Raft always runs on
497    /// kernel TCP, including on DPDK nodes.
498    #[arg(long)]
499    pub raft_bind: Option<SocketAddr>,
500
501    /// This node's control-plane raft id (non-zero, unique per cluster).
502    #[arg(long)]
503    pub raft_node_id: Option<u64>,
504
505    /// A control-plane cluster member as `id@host:port#base64-pubkey`
506    /// (repeatable). Give **every node the same list, including an entry
507    /// for the node itself** — the self entry supplies the dialable
508    /// address peers use to reach it, and identical lists keep the
509    /// first-boot membership consistent. The pubkey pins the peer's
510    /// identity: a connection authenticated with a different key cannot
511    /// speak for that id.
512    #[arg(long)]
513    pub raft_peer: Vec<String>,
514
515    /// Directory for durable raft state (vote, log, membership).
516    /// Defaults to the journal path with a `.raft` extension. Must live
517    /// on the same durability class as the journal — losing it can
518    /// double-grant a vote (see docs/replication.md).
519    #[arg(long)]
520    pub raft_dir: Option<PathBuf>,
521
522    /// Act on control-plane election wins: a replica elected leader
523    /// promotes itself (journaling the election term as its fencing
524    /// epoch), and a serving node self-fences when the raft mesh shows
525    /// it superseded. Off by default — automatic failover is an explicit
526    /// operator policy decision. Requires `--raft-bind` and at least
527    /// three configured voters (a two-node cluster cannot elect after
528    /// losing either node, so automation would be dead weight with real
529    /// misconfiguration risk; see docs/replication.md).
530    #[arg(long, default_value_t = false)]
531    pub raft_auto_promote: bool,
532}
533
534/// Delegates to clap so `#[arg(default_value...)]` is the single source of
535/// truth for every default.  Used by the bench crate for struct-literal
536/// construction with `..ServerConfig::default()`.
537impl Default for ServerConfig {
538    fn default() -> Self {
539        // On the dpdk branch we spell out every field so that dpdk-specific
540        // fields (not known to clap on plain builds) get their defaults.
541        // Main uses `Self::parse_from(["melin-server"])` — the values below
542        // mirror those clap defaults plus the dpdk extras.
543        Self {
544            bind: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9876),
545            journal: PathBuf::from("melin.journal"),
546            cores: PipelineCores {
547                journal_seq: Placement::spinning(1),
548                matching: Placement::spinning(2),
549                response: Placement::spinning(3),
550                reader: Placement::spinning(4),
551                event_publisher: Placement::spinning(6),
552                shadow: Placement::spinning(7),
553                repl_handler_0: Placement::spinning(8),
554                repl_handler_1: Placement::spinning(9),
555                journal_prep: Placement::yielding(10),
556                journal_disk: Placement::spinning(11),
557            },
558            group_commit_us: 0,
559            heartbeat_interval_secs: 10,
560            connection_timeout_secs: 30,
561            max_connections: 1024,
562            authorized_keys: PathBuf::from("authorized_keys"),
563            max_journal_mib: 256,
564            journal_staging_mode: JournalStagingMode::ZeroFill,
565
566            replication_bind: None,
567            standalone: false,
568            replica_of: None,
569            replication_key: None,
570            replication_batch_size: 128,
571            max_journal_batch: 1024,
572            replication_heartbeat_secs: 5,
573            replication_pipeline_depth: DEFAULT_REPLICATION_PIPELINE_DEPTH,
574            replication_ring_size: 256,
575            ack_policy: crate::ack_policy::AckPolicy::DiskAndRam,
576            no_degraded_acks: false,
577            degraded_ack_grace_ms: crate::durability_gate::DEFAULT_DEGRADED_ACK_GRACE_MS,
578            dpdk: DpdkArgs {
579                ip: Some(melin_dpdk::NicConfig::default().ip_addr),
580                ..DpdkArgs::default()
581            },
582            dpdk_client_rx_buf_kib: kib_of(melin_dpdk::SocketBuffers::DEFAULT.rx_buf_size),
583            dpdk_client_tx_buf_kib: kib_of(melin_dpdk::SocketBuffers::DEFAULT.tx_buf_size),
584            dpdk_client_tx_queue_kib: kib_of(melin_dpdk::SocketBuffers::DEFAULT.tx_queue_limit),
585            event_bind: None,
586            health_bind: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9878)),
587            admin_bind: None,
588            snapshot_interval_ms: 3_000_000,
589            snapshot_path: None,
590            tick_interval_ms: 250,
591            no_mlock: false,
592            raft_bind: None,
593            raft_node_id: None,
594            raft_peer: Vec::new(),
595            raft_dir: None,
596            raft_auto_promote: false,
597        }
598    }
599}
600
601impl ServerConfig {
602    /// Whether this node accepts replicas: whether `--replication-bind`
603    /// is set, in either role (a replica holds the port from boot, and
604    /// serves replicas on it once promoted). The one answer the
605    /// ack-policy mailbox and a primary pipeline (its replica presence,
606    /// hence its halt gate) are both built on.
607    pub(crate) fn replication(&self) -> crate::halt::Replication {
608        match self.replication_bind {
609            Some(_) => crate::halt::Replication::Enabled,
610            None => crate::halt::Replication::Disabled,
611        }
612    }
613
614    /// Group commit delay as a Duration.
615    pub fn group_commit_delay(&self) -> std::time::Duration {
616        std::time::Duration::from_micros(self.group_commit_us)
617    }
618
619    /// Heartbeat interval as a Duration. Returns `None` if disabled (0).
620    pub fn heartbeat_interval(&self) -> Option<std::time::Duration> {
621        if self.heartbeat_interval_secs == 0 {
622            None
623        } else {
624            Some(std::time::Duration::from_secs(self.heartbeat_interval_secs))
625        }
626    }
627
628    /// Connection timeout as a Duration. Returns `None` if disabled (0).
629    pub fn connection_timeout(&self) -> Option<std::time::Duration> {
630        if self.connection_timeout_secs == 0 {
631            None
632        } else {
633            Some(std::time::Duration::from_secs(self.connection_timeout_secs))
634        }
635    }
636
637    /// The node's journal and snapshot locations, resolved from `--journal`
638    /// and `--snapshot-path` (see [`NodeStore::resolve`], which refuses a
639    /// layout the node must not start on). The node builds it once, as it
640    /// starts, and hands it to every role that reads, writes, serves or
641    /// archives the snapshot.
642    pub fn store(&self) -> Result<NodeStore, NodeStoreError> {
643        NodeStore::resolve(self.journal.clone(), self.snapshot_path.clone())
644    }
645
646    /// What the response stage needs to release a halted primary's held
647    /// replies on its own disk, or `None` when it must not: switched off
648    /// (`--no-degraded-acks`), or a node without replication (no replica
649    /// presence), which never halts.
650    fn degraded_release(
651        &self,
652        replicas_connected: Option<&Arc<ReplicaPresence>>,
653    ) -> Option<crate::halt::DegradedRelease> {
654        if self.no_degraded_acks {
655            return None;
656        }
657        Some(crate::halt::DegradedRelease {
658            presence: Arc::clone(replicas_connected?),
659            grace: std::time::Duration::from_millis(self.degraded_ack_grace_ms),
660        })
661    }
662
663    /// Tick generator cadence as a `Duration`. Returns `None` when the tick
664    /// thread is disabled (`--tick-interval-ms 0`).
665    pub fn tick_interval(&self) -> Option<std::time::Duration> {
666        if self.tick_interval_ms == 0 {
667            None
668        } else {
669            Some(std::time::Duration::from_millis(self.tick_interval_ms))
670        }
671    }
672
673    /// The per-thread layout this node actually runs — see
674    /// [`PipelineCores::resolve`]. `reader` says which thread the reader
675    /// entry pins on this transport, since one of the two cannot yield.
676    ///
677    /// An `Err` is a configuration the node refuses to start with.
678    pub fn resolved_cores(&self, reader: ReaderThread) -> Result<PipelineCores, String> {
679        self.cores.resolve(reader)
680    }
681}
682
683/// Run the server with automatic transport selection.
684///
685/// Installs SIGINT/SIGTERM handlers, optionally locks memory, selects
686/// the transport (DPDK under the `dpdk` feature, kernel TCP otherwise),
687/// and enters the pipeline loop. This is the main entry point for
688/// application binaries.
689///
690/// The application starts from `A::default()` on every node; `startup`
691/// is what the node journals on top of that as it becomes primary — see
692/// [`StartupEvents`]. `sizing` is what this node reserves memory for,
693/// handed to [`Application::prefault`] on every instance the node
694/// builds; it is local to the node and never journaled.
695///
696/// For callers that need a pre-bound listener or an externally
697/// controlled shutdown flag (e.g. benchmarks), use
698/// [`run_with_listener`] instead; for an externally controlled flag on
699/// the build's own transport, [`run_with_shutdown`].
700pub fn run<A>(
701    config: ServerConfig,
702    startup: StartupEvents<A::Event>,
703    sizing: A::Sizing,
704    decoder: impl RequestDecoder<Event = A::Event> + 'static,
705    encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
706    event_publisher: Option<EventPublisherFn<A>>,
707) -> Result<(), Box<dyn std::error::Error>>
708where
709    A: Application + Send + 'static,
710    A::Event: Send + Sync + 'static,
711    A::Report: Send + 'static,
712    A::QueryResponse: Send + 'static,
713{
714    // Before anything pins a thread or initialises DPDK, whose EAL can
715    // narrow this thread to one lcore: the CPU set captured here is what
716    // every unpinned thread runs on.
717    melin_app::affinity::capture_home_mask();
718    let authorized_keys = load_authorized_keys(&decoder, &config.authorized_keys)?;
719    let shutdown = Arc::new(AtomicBool::new(false));
720    crate::process::install_shutdown_handler(&shutdown);
721    run_selected::<A>(
722        config,
723        startup,
724        sizing,
725        decoder,
726        encoder,
727        event_publisher,
728        authorized_keys,
729        shutdown,
730    )
731}
732
733/// [`run`], stopped through `shutdown` rather than by a signal: the same
734/// transport selection and the same startup, but no SIGINT/SIGTERM
735/// handler is installed. Set `shutdown` to `true` for a clean shutdown;
736/// the node may also set it itself (a fenced node stops this way).
737///
738/// For a host that owns its process's signals, or runs several nodes in
739/// one process — the signal handler `run` installs is process-wide and
740/// stops one node only. Several DPDK nodes in one process also need a
741/// process-wide EAL (`melin_dpdk::Eal::init_process_wide`) and a port
742/// each. Unlike [`run_with_listener`], the client listener is the
743/// transport's own: on DPDK there is no kernel socket to hand in.
744pub fn run_with_shutdown<A>(
745    config: ServerConfig,
746    startup: StartupEvents<A::Event>,
747    sizing: A::Sizing,
748    decoder: impl RequestDecoder<Event = A::Event> + 'static,
749    encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
750    event_publisher: Option<EventPublisherFn<A>>,
751    shutdown: Arc<AtomicBool>,
752) -> Result<(), Box<dyn std::error::Error>>
753where
754    A: Application + Send + 'static,
755    A::Event: Send + Sync + 'static,
756    A::Report: Send + 'static,
757    A::QueryResponse: Send + 'static,
758{
759    // As in `run`.
760    melin_app::affinity::capture_home_mask();
761    let authorized_keys = load_authorized_keys(&decoder, &config.authorized_keys)?;
762    run_selected::<A>(
763        config,
764        startup,
765        sizing,
766        decoder,
767        encoder,
768        event_publisher,
769        authorized_keys,
770        shutdown,
771    )
772}
773
774/// The part of [`run`] and [`run_with_shutdown`] after the shutdown flag
775/// exists: memory locking, then the build's transport.
776#[allow(clippy::too_many_arguments)] // entry-chain hop, same arguments as run_impl
777fn run_selected<A>(
778    config: ServerConfig,
779    startup: StartupEvents<A::Event>,
780    sizing: A::Sizing,
781    decoder: impl RequestDecoder<Event = A::Event> + 'static,
782    encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
783    event_publisher: Option<EventPublisherFn<A>>,
784    authorized_keys: Arc<AuthorizedKeys>,
785    shutdown: Arc<AtomicBool>,
786) -> Result<(), Box<dyn std::error::Error>>
787where
788    A: Application + Send + 'static,
789    A::Event: Send + Sync + 'static,
790    A::Report: Send + 'static,
791    A::QueryResponse: Send + 'static,
792{
793    if !config.no_mlock {
794        crate::process::try_lock_memory();
795    }
796
797    #[cfg(feature = "dpdk")]
798    {
799        run_dpdk::<A>(
800            config,
801            startup,
802            sizing,
803            Arc::new(decoder),
804            Arc::new(encoder),
805            event_publisher,
806            authorized_keys,
807            shutdown,
808        )
809    }
810
811    #[cfg(not(feature = "dpdk"))]
812    {
813        let listener = melin_wire_protocol::tcp::BlockingTcpListener::bind(config.bind)?;
814        run_tcp::<A, _>(
815            listener,
816            config,
817            startup,
818            sizing,
819            Arc::new(decoder),
820            Arc::new(encoder),
821            event_publisher,
822            authorized_keys,
823            shutdown,
824        )
825    }
826}
827
828/// Load the `authorized_keys` file for the decoder's roles: the one place
829/// the file meets the application's role type. Generic over the decoder so
830/// its role type is named while it is still in scope; past the entry
831/// points the runtime holds the table and an erased decoder, and never
832/// the type.
833fn load_authorized_keys<D: RequestDecoder>(
834    _decoder: &D,
835    path: &std::path::Path,
836) -> Result<Arc<AuthorizedKeys>, Box<dyn std::error::Error>> {
837    let keys = AuthorizedKeys::load::<D::Role>(path)?;
838    info!(keys = keys.len(), path = %path.display(), "loaded authorized keys");
839    Ok(Arc::new(keys))
840}
841
842/// Refuse to start on a keys table parsed for a role type other than the
843/// decoder's: its role indices would name the decoder's roles wrongly,
844/// granting one role another's rights. Correct by construction today, as
845/// both come from the same decoder in the entry point; checked where the
846/// two arrive as separate parameters, which is the seam a later change
847/// could break.
848fn check_keys_match_decoder<E: AppEvent>(
849    decoder: &dyn ErasedDecoder<E>,
850    authorized_keys: &AuthorizedKeys,
851) -> Result<(), Box<dyn std::error::Error>> {
852    if decoder.matches_keys(authorized_keys) {
853        Ok(())
854    } else {
855        Err("authorized keys were parsed for a role type other than the decoder's".into())
856    }
857}
858
859/// Run the server with a caller-supplied listener.
860///
861/// Use this when the listener must be pre-bound before the server
862/// starts (e.g. benchmarks that need the kernel backlog to queue
863/// connections immediately).
864///
865/// Set `shutdown` to `true` to trigger a clean shutdown of all
866/// pipeline threads.
867pub fn run_with_listener<A>(
868    listener: impl BlockingTransportListener,
869    config: ServerConfig,
870    startup: StartupEvents<A::Event>,
871    sizing: A::Sizing,
872    decoder: impl RequestDecoder<Event = A::Event> + 'static,
873    encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
874    event_publisher: Option<EventPublisherFn<A>>,
875    shutdown: Arc<AtomicBool>,
876) -> Result<(), Box<dyn std::error::Error>>
877where
878    A: Application + Send + 'static,
879    A::Event: Send + Sync + 'static,
880    A::Report: Send + 'static,
881    A::QueryResponse: Send + 'static,
882{
883    // As in `run`: the CPU set unpinned threads run on, captured before
884    // anything pins a thread.
885    melin_app::affinity::capture_home_mask();
886    let authorized_keys = load_authorized_keys(&decoder, &config.authorized_keys)?;
887    run_tcp::<A, _>(
888        listener,
889        config,
890        startup,
891        sizing,
892        Arc::new(decoder),
893        Arc::new(encoder),
894        event_publisher,
895        authorized_keys,
896        shutdown,
897    )
898}
899
900#[allow(clippy::too_many_arguments)] // entry-chain hop, same arguments as run_impl
901fn run_tcp<A, L>(
902    listener: L,
903    config: ServerConfig,
904    startup: StartupEvents<A::Event>,
905    sizing: A::Sizing,
906    decoder: RequestDecoderArc<A>,
907    encoder: ResponseEncoderArc<A>,
908    event_publisher: Option<EventPublisherFn<A>>,
909    authorized_keys: Arc<AuthorizedKeys>,
910    shutdown: Arc<AtomicBool>,
911) -> Result<(), Box<dyn std::error::Error>>
912where
913    A: Application + Send + 'static,
914    A::Event: Send + Sync + 'static,
915    A::Report: Send + 'static,
916    A::QueryResponse: Send + 'static,
917    L: BlockingTransportListener,
918{
919    check_keys_match_decoder(&*decoder, &authorized_keys)?;
920    run_impl::<A, L>(
921        listener,
922        config,
923        startup,
924        sizing,
925        decoder,
926        encoder,
927        event_publisher,
928        authorized_keys,
929        shutdown,
930    )
931}
932
933/// Announce at boot that this binary was built without the
934/// tamper-evident journal chain.
935///
936/// A chain-less build is otherwise silent: the journal still writes and
937/// replication still streams, but segment-continuity verification at
938/// recovery, the snapshot/journal cross-check, and cross-node divergence
939/// detection are all absent — a rejoining ex-primary carrying a
940/// journaled-but-unreplicated suffix gets streamed to rather than
941/// resynced. A deployment can arrive here without meaning to, because
942/// `--no-default-features` anywhere in the dependency graph turns the
943/// chain off wholesale, so boot says so out loud rather than leaving the
944/// absence to be discovered during an incident.
945///
946/// `warn!` rather than `error!`: it is a deliberate build configuration,
947/// not a malfunction.
948fn warn_if_chain_disabled() {
949    #[cfg(not(feature = "hash-chain"))]
950    warn!(
951        "built without the `hash-chain` feature: journal tamper evidence, \
952         segment-continuity and snapshot cross-checks at recovery, and \
953         cross-node divergence detection are all disabled"
954    );
955}
956
957/// Log the layout the node runs, and warn when it leaves a mandatory
958/// thread without a core. Every request passes through those threads, so
959/// a core shared with whatever else the scheduler puts there is paid for
960/// on every acknowledgement; the auxiliary threads' placement is the
961/// operator's documented trade and draws no warning. `none` is called
962/// out on its own: nothing pinned is the development layout, and figures
963/// measured under it say nothing about the node. One function for both
964/// transports, so the two paths cannot drift on what they warn about.
965///
966/// `warn!` rather than `error!`: a layout the operator chose, not a
967/// malfunction.
968fn log_layout(cores: &PipelineCores) {
969    info!(cores = %cores, "pipeline layout");
970    if cores.pins_nothing() {
971        warn!(
972            "--cores none: no pipeline thread is pinned. Every thread runs wherever the \
973             scheduler puts it and shares that core with everything else on the host. A \
974             development layout: latency measured under it is not representative. Name a \
975             core per thread in --cores"
976        );
977        return;
978    }
979    let unpinned = cores.unpinned_mandatory();
980    if !unpinned.is_empty() {
981        warn!(
982            threads = %unpinned.join(", "),
983            "--cores: mandatory pipeline threads have no core of their own. Every request \
984             passes through them, and each runs wherever the scheduler puts it, sharing \
985             that core with whatever else is there. Give them cores"
986        );
987    }
988}
989
990#[allow(clippy::too_many_arguments)] // boot assembly point: each argument is one piece the node is built from
991fn run_impl<A, L>(
992    listener: L,
993    config: ServerConfig,
994    mut startup: StartupEvents<A::Event>,
995    sizing: A::Sizing,
996    decoder: RequestDecoderArc<A>,
997    encoder: ResponseEncoderArc<A>,
998    event_publisher: Option<EventPublisherFn<A>>,
999    authorized_keys: Arc<AuthorizedKeys>,
1000    shutdown: Arc<AtomicBool>,
1001) -> Result<(), Box<dyn std::error::Error>>
1002where
1003    A: Application + Send + 'static,
1004    A::Event: Send + Sync + 'static,
1005    A::Report: Send + 'static,
1006    A::QueryResponse: Send + 'static,
1007    L: BlockingTransportListener,
1008{
1009    warn_if_chain_disabled();
1010
1011    // The layout every spawn site below reads, refused outright if two
1012    // threads would starve each other.
1013    let config = ServerConfig {
1014        cores: config.resolved_cores(ReaderThread::IoUring)?,
1015        ..config
1016    };
1017    log_layout(&config.cores);
1018
1019    // The ack-policy mailbox, constructed once per process and threaded
1020    // through both roles. On the replica path (no input ring producer)
1021    // `ACK-POLICY <policy>` stages the post-promotion policy; after the
1022    // replica → primary transition the same mailbox feeds the new
1023    // primary's producer, which journals each change. See
1024    // `crate::policy_control`.
1025    let ack_policy =
1026        crate::policy_control::AckPolicyControl::new(config.ack_policy, config.replication());
1027
1028    // Validate before the bind below and before anything touches the
1029    // journal directory, so a refused boot has no side effects and leaves
1030    // the disk as it found it. Checked in every role: a replica's
1031    // configuration is also the one it serves under once promoted, and a
1032    // configuration that cannot serve is better refused now than in the
1033    // middle of a failover. `run_as_primary` re-checks (a promotion
1034    // reaches it without passing through boot).
1035    validate_primary_config(&config)?;
1036    // Where this node's journal and snapshot are, for every role below.
1037    // Resolved here, with the configuration's other checks: it refuses a
1038    // layout the node must not start on, and writes nothing.
1039    let store = config.store()?;
1040
1041    // Bind the replication listener up front, before any pipeline thread
1042    // exists and regardless of role: a bad or stolen --replication-bind
1043    // fails the boot cleanly (nothing spawned yet to leak), and a
1044    // replica holds its configured port from boot, so a later promotion
1045    // can neither lose the port to another process nor die on a
1046    // mid-failover bind. Pending connections just sit in the backlog
1047    // until a promotion starts the sender's accept loop — replicas dial
1048    // the configured primary, not each other, so nothing queues there
1049    // in normal operation.
1050    let repl_listener = config
1051        .replication_bind
1052        .map(bind_replication_listener)
1053        .transpose()?;
1054
1055    // Replica mode: connect to primary, receive journal stream, replay.
1056    // Must run before init_engine — the replica's journal is created from
1057    // the primary's segment lineage during the replication handshake.
1058    if let Some(primary_addr) = config.replica_of {
1059        info!(primary = %primary_addr, "starting in replica mode");
1060        // A replica receives the genesis events in the primary's history
1061        // and never journals its own, promoted or not — nothing to hold
1062        // them for, and a large genesis is worth freeing. Not even their
1063        // count: the promotion check reads the genesis length the
1064        // primary recorded in the lineage, which this replica's journal
1065        // header holds, never this node's configuration.
1066        startup.genesis = Vec::new();
1067
1068        // Load replication signing key.
1069        let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
1070            std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
1071        })?;
1072        let signing_key = {
1073            let seed = std::fs::read(replication_key_path).map_err(|e| {
1074                std::io::Error::other(format!(
1075                    "failed to read replication key {}: {e}",
1076                    replication_key_path.display()
1077                ))
1078            })?;
1079            if seed.len() != 32 {
1080                return Err(format!(
1081                    "replication key must be 32 bytes, got {} ({})",
1082                    seed.len(),
1083                    replication_key_path.display()
1084                )
1085                .into());
1086            }
1087            let mut bytes = [0u8; 32];
1088            bytes.copy_from_slice(&seed);
1089            ed25519_dalek::SigningKey::from_bytes(&bytes)
1090        };
1091
1092        // The replica's control-plane bundle: promotion request (admin
1093        // PROMOTE / raft auto-promotion), tip readiness + advertised
1094        // journal tip (vote recency), primary link state, and the ack
1095        // policy the replica's lineage holds. Constructed before
1096        // mode-detection so it survives a replica → primary transition.
1097        // The rotate flag is re-wired into the new primary's journal
1098        // stage by `run_as_primary`.
1099        let control = crate::replication::ReplicaControlPlane::new();
1100        let promotion_request = control.promote.clone();
1101        let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
1102        let _admin_handle = config
1103            .admin_bind
1104            .map(|addr| {
1105                crate::admin::spawn(
1106                    addr,
1107                    Some(promotion_request.clone()),
1108                    rotate_flag.clone(),
1109                    Some(Arc::clone(&ack_policy)),
1110                    Arc::clone(&shutdown),
1111                    Arc::clone(&authorized_keys),
1112                )
1113            })
1114            .transpose()?;
1115
1116        // Shared fencing state, created before mode detection so it
1117        // survives a replica → primary transition. Seeded at epoch 0; the
1118        // receiver raises it from the replica's recovered journal and the
1119        // replication stream, and a promotion bumps it by injecting an
1120        // `EpochBump`. Carried into `run_as_primary` post-promotion.
1121        let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
1122
1123        // Control-plane raft (observational election). Spawned before the
1124        // receive loop so the driver — like the fence state and admin
1125        // flags — survives a replica → primary promotion untouched: it
1126        // shares the process shutdown flag and runs on its own thread.
1127        // The advertised tip isn't trustworthy until journal recovery
1128        // seeds the fence epoch and sequence, so `control.tip_ready`
1129        // starts false and the receiver flips it — until then the driver
1130        // refuses to grant votes. The receiver owns the sequence half
1131        // until a promotion hands it to the new primary's journal stage.
1132        let journal_tip = control.journal_tip.clone();
1133        let mut raft = match crate::raft::build_raft_config(&config)? {
1134            None => crate::raft::RaftDriverGuard::disabled(&shutdown),
1135            Some(cfg) => crate::raft::spawn_raft_driver(
1136                cfg,
1137                config.raft_auto_promote,
1138                &signing_key,
1139                &authorized_keys,
1140                &fence_state,
1141                journal_tip.clone(),
1142                Arc::clone(&control.tip_ready),
1143                // A replica claims to be serving once a promotion is in
1144                // flight (see SupersessionPolicy).
1145                {
1146                    let promote = control.promote.clone();
1147                    Arc::new(move || promote.is_requested())
1148                },
1149                &shutdown,
1150            )?,
1151        };
1152        // Act on election wins when the operator opted in. Genesis
1153        // primaries have nothing to promote — replica paths only. The
1154        // guard joins the thread on every exit path.
1155        if config.raft_auto_promote
1156            && let Some(wiring) = raft.promotion_wiring()
1157        {
1158            raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
1159                wiring.status,
1160                control.clone(),
1161                Arc::clone(&fence_state),
1162                Arc::clone(&ack_policy),
1163                wiring.peer_tips,
1164                wiring.peer_ids,
1165                wiring.elect_requested,
1166                wiring.elect_enabled,
1167                Arc::clone(&shutdown),
1168            ));
1169        }
1170        let raft_status = raft.status();
1171        // A raft-enabled replica exposes election gauges on --health-bind;
1172        // stopped explicitly before promotion so run_as_primary can rebind
1173        // the port, and by its guard on every other exit path.
1174        let mut replica_health = crate::raft::spawn_replica_health(
1175            &config,
1176            &fence_state,
1177            raft_status.as_ref(),
1178            Arc::clone(&control.pipeline_healthy),
1179        )?;
1180
1181        // No local rotation triggers on the replica side: segment
1182        // rotation is primary-driven (the replica adopts the boundaries
1183        // announced over the replication stream), so replica journals
1184        // stay bitwise mirrors of the primary's. `--max-journal-mib`
1185        // and the admin `ROTATE` command only act on primaries.
1186
1187        match crate::replication::run_receiver::<A>(
1188            primary_addr,
1189            &store,
1190            &signing_key,
1191            &shutdown,
1192            &control,
1193            config.snapshot_interval_ms,
1194            config.cores,
1195            config.journal_staging_mode.into(),
1196            config.group_commit_delay(),
1197            config.replication_pipeline_depth,
1198            Arc::clone(&fence_state),
1199            &sizing,
1200        )? {
1201            // Clean shutdown — the raft and health guards tear down on drop.
1202            None => return Ok(()),
1203            Some((mut app, writer)) => {
1204                // Promotion! Transition to primary mode. Bump the epoch so a
1205                // paused/partitioned ex-primary is fenced when it reconnects.
1206                info!("replica promoted — transitioning to primary");
1207                check_promotable(&writer)?;
1208                // Release --health-bind before run_as_primary rebinds it
1209                // with the full primary health state.
1210                replica_health.stop();
1211                // A replica that ran a pipeline sized this instance
1212                // already; one promoted before its first session did
1213                // not (recovered from disk, never streamed). Sizing is
1214                // idempotent, so size here either way.
1215                <A as Application>::prefault(&mut app, &sizing);
1216
1217                // A ROTATE received while this node was a replica latched
1218                // the flag but rotated nothing (rotation is primary-driven
1219                // and replicas follow the primary's boundaries). Clear it
1220                // so the stale latch can't fire a surprise rotation on the
1221                // first post-promotion fsync.
1222                if let Some(ref flag) = rotate_flag {
1223                    flag.store(false, Ordering::Release);
1224                }
1225
1226                // The raft guard drops — stopping the driver and joining
1227                // the (already exited) promotion thread — after this
1228                // returns, so the driver serves elections and the fencing
1229                // channel for the whole primary tenure.
1230                return run_as_primary::<A, L>(
1231                    app,
1232                    writer,
1233                    listener,
1234                    repl_listener,
1235                    &config,
1236                    &store,
1237                    startup.on_primary,
1238                    decoder,
1239                    encoder,
1240                    event_publisher,
1241                    Arc::clone(&shutdown),
1242                    authorized_keys,
1243                    rotate_flag,
1244                    ack_policy,
1245                    fence_state,
1246                    control.lineage_ack_policy.load(),
1247                    promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
1248                    false, // a promoted node continues the history it streamed
1249                    raft_status,
1250                    journal_tip,
1251                );
1252            }
1253        }
1254    }
1255
1256    // Spawn the admin listener once if configured. PROMOTE is rejected
1257    // (with ERR) on a primary because no promote flag is wired here —
1258    // promotion is meaningful only on a replica. ROTATE is wired
1259    // whenever the admin endpoint is configured.
1260    let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
1261    let _admin_handle = config
1262        .admin_bind
1263        .map(|addr| {
1264            crate::admin::spawn(
1265                addr,
1266                None,
1267                rotate_flag.clone(),
1268                Some(Arc::clone(&ack_policy)),
1269                Arc::clone(&shutdown),
1270                Arc::clone(&authorized_keys),
1271            )
1272        })
1273        .transpose()?;
1274
1275    // The configuration was validated before the replication bind above,
1276    // so the journal is never created by a boot that is then refused.
1277
1278    // Initialize or recover the app. On a new journal, genesis is
1279    // journaled here, as the journal is created.
1280    let InitializedEngine {
1281        mut app,
1282        writer,
1283        recovered_lineage,
1284        began_history,
1285    } = init_engine::<A, BufferedWriter<A::Event>>(
1286        &config,
1287        &store,
1288        &sizing,
1289        std::mem::take(&mut startup.genesis),
1290    )?;
1291
1292    // Size and pre-fault application-owned memory (slabs, indices) so
1293    // growth and page faults happen now, not on the hot path. Runs on the
1294    // recovered state too: a snapshot restores contents, not capacity,
1295    // and `init_engine` sizes a genesis instance before replay only.
1296    <A as Application>::prefault(&mut app, &sizing);
1297
1298    // A primary booting directly (not via promotion) keeps whatever epoch
1299    // its journal recovered; no bump.
1300    let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
1301        recovered_lineage.epoch,
1302    ));
1303
1304    // Control-plane raft (observational election). The primary needs the
1305    // replication signing key only when raft is on — peer links
1306    // authenticate with it (build_raft_config enforces the flag).
1307    // A primary's fence epoch is already recovered here, so its tip is
1308    // trustworthy from the moment the driver starts. Seed the advertised
1309    // sequence from the recovered journal for the same reason — the
1310    // pipeline's journal stage takes the handle over once it runs.
1311    let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
1312        melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
1313    );
1314    let raft = match crate::raft::build_raft_config(&config)? {
1315        None => crate::raft::RaftDriverGuard::disabled(&shutdown),
1316        Some(cfg) => {
1317            let signing_key = load_replication_key(&config)?;
1318            crate::raft::spawn_raft_driver(
1319                cfg,
1320                config.raft_auto_promote,
1321                &signing_key,
1322                &authorized_keys,
1323                &fence_state,
1324                journal_tip.clone(),
1325                Arc::new(AtomicBool::new(true)),
1326                // A primary always claims to be serving.
1327                Arc::new(|| true),
1328                &shutdown,
1329            )?
1330        }
1331    };
1332    let raft_status = raft.status();
1333
1334    // The raft guard drops — stopping the driver — after this returns.
1335    run_as_primary::<A, L>(
1336        app,
1337        writer,
1338        listener,
1339        repl_listener,
1340        &config,
1341        &store,
1342        startup.on_primary,
1343        decoder,
1344        encoder,
1345        event_publisher,
1346        Arc::clone(&shutdown),
1347        authorized_keys,
1348        rotate_flag,
1349        ack_policy,
1350        fence_state,
1351        recovered_lineage.ack_policy,
1352        None, // not promoted — no EpochBump injection
1353        began_history,
1354        raft_status,
1355        journal_tip,
1356    )
1357}
1358
1359/// Bind the kernel-TCP replication listener, non-blocking (the sender's
1360/// accept loop polls the shutdown flag between accepts). Bound at boot
1361/// (`run_impl`) so a failure cannot leak pipeline threads and a promotion
1362/// cannot fail on it. Kernel TCP only: a DPDK node's replication listener
1363/// is a socket of its own stack, which no other process can take.
1364fn bind_replication_listener(
1365    addr: std::net::SocketAddr,
1366) -> Result<crate::replication::ReplicationListener, Box<dyn std::error::Error>> {
1367    let listener = std::net::TcpListener::bind(addr)
1368        .map_err(|e| format!("failed to bind replication listener on {addr}: {e}"))?;
1369    // The newtype sets non-blocking — the invariant the sender's accept
1370    // loop needs is enforced by construction, not by this call site.
1371    let listener = crate::replication::ReplicationListener::new(listener)
1372        .map_err(|e| format!("failed to set non-blocking on replication listener: {e}"))?;
1373    info!(%addr, "replication listener bound");
1374    Ok(listener)
1375}
1376
1377/// Refuse a configuration no primary can run under. Called at boot,
1378/// before the journal is opened or created, and again by
1379/// `run_as_primary`, which a promotion reaches without passing through
1380/// boot.
1381fn validate_primary_config(config: &ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
1382    // The cap sizes the io_uring rings; one they cannot be sized for
1383    // (0, once "unlimited", or above the ceiling) is refused here, on
1384    // either transport, rather than at the first ring.
1385    crate::connection_limit::RingSizing::for_max_connections(config.max_connections)?;
1386    if config.replication_bind.is_some() && config.standalone {
1387        return Err("--replication-bind and --standalone are mutually exclusive".into());
1388    }
1389    // `--standalone` declares "no replicas ever" — only `disk` can be
1390    // satisfied. Every other policy requires a second node and would
1391    // stall the gate forever; reject loudly at startup with the fix in
1392    // the message.
1393    if config.standalone && config.ack_policy != crate::ack_policy::AckPolicy::Disk {
1394        return Err(format!(
1395            "--standalone requires --ack-policy disk; got `{}` (this policy needs at least one connected replica)",
1396            config.ack_policy,
1397        )
1398        .into());
1399    }
1400    Ok(())
1401}
1402
1403/// Refuse to promote a replica whose history does not yet hold the
1404/// whole genesis.
1405///
1406/// A journal begins with its genesis — sequences `1..=genesis_entries`
1407/// — and a replica copies it entry by entry, by catch-up, like the rest
1408/// of the history. A primary that dies before the replica has copied
1409/// all of it leaves the replica with an empty journal or a genesis
1410/// prefix. Promoting it would serve a state the application never
1411/// configured, silently and for good: a later boot recovers the history
1412/// as it is, and replicas follow it. (Under an ack policy that waits
1413/// for a replica, no client was acknowledged past such a history, since
1414/// every acknowledged request follows the genesis in the stream.)
1415/// Refuse instead, and leave the decision to the operator.
1416///
1417/// `next_sequence` is the promoted writer's; the history holds entries
1418/// `1..next_sequence`. A history that recovered from a snapshot counts
1419/// the snapshot's entries too, which is right: the snapshot's state
1420/// includes them.
1421///
1422/// `genesis_entries` is the lineage's genesis length from the promoted
1423/// writer's journal header — recorded by the primary that began the
1424/// history and learned by this replica when it created its journal, so
1425/// it is there with the primary gone, whatever genesis this node is
1426/// configured with. `None` (a lineage begun before the length was
1427/// recorded) skips the check.
1428fn check_promoted_history_holds_genesis(
1429    next_sequence: u64,
1430    genesis_entries: Option<u64>,
1431) -> Result<(), Box<dyn std::error::Error>> {
1432    if let Some((held, genesis_entries)) = genesis_shortfall(next_sequence, genesis_entries) {
1433        return Err(format!(
1434            "refusing promotion: this replica's history holds {held} entries but the \
1435             lineage's genesis takes {genesis_entries}, so it copied only part of the genesis \
1436             from a primary that stopped before finishing it; restart the original primary, or \
1437             start the cluster again from a new journal"
1438        )
1439        .into());
1440    }
1441    Ok(())
1442}
1443
1444/// [`check_promoted_history_holds_genesis`] on the writer a promotion
1445/// hands over: its next sequence, and the genesis length its journal
1446/// header records. What both promotion paths (kernel TCP and DPDK) run
1447/// before the node serves as primary.
1448pub(crate) fn check_promotable<E, W>(writer: &W) -> Result<(), Box<dyn std::error::Error>>
1449where
1450    E: melin_app::AppEvent,
1451    W: JournalWrite<E>,
1452{
1453    check_promoted_history_holds_genesis(
1454        writer.next_sequence(),
1455        writer.read_header_info()?.genesis_entries,
1456    )
1457}
1458
1459/// Refuse to serve, as a primary booting from disk, a recovered history
1460/// that does not hold the whole genesis.
1461///
1462/// The boot-time twin of [`check_promoted_history_holds_genesis`], on
1463/// the same lineage-recorded length. A journal this release creates
1464/// always begins with its complete genesis, so a shorter history is a
1465/// replica's partial copy restarted on a primary's flags instead of
1466/// being promoted. Recovering it would serve a state the application
1467/// never configured, and replicas would follow it. The node's own
1468/// configured genesis plays no part: a configuration changed since the
1469/// history began changes nothing.
1470///
1471/// `next_sequence` is the recovered writer's, after `init_engine` has
1472/// given an empty journal its genesis (the one shortfall it can make
1473/// good, since nothing was served from it); `genesis_entries` its
1474/// journal header's. `None` — a journal written by a release that did
1475/// not record the length — skips the check, as those releases did.
1476fn check_recovered_history_holds_genesis(
1477    next_sequence: u64,
1478    genesis_entries: Option<u64>,
1479) -> Result<(), Box<dyn std::error::Error>> {
1480    if let Some((held, genesis_entries)) = genesis_shortfall(next_sequence, genesis_entries) {
1481        return Err(format!(
1482            "refusing to start: the recovered history holds {held} entries but the lineage's \
1483             genesis takes {genesis_entries}, so it holds only part of the genesis (a replica's \
1484             partial copy started as a primary); start again from a new history (remove the \
1485             journal, its archives and its snapshots), or restart this node as a replica of a \
1486             primary holding the whole history"
1487        )
1488        .into());
1489    }
1490    Ok(())
1491}
1492
1493/// `(held, genesis_entries)` when a history ending before
1494/// `next_sequence` holds fewer entries than the genesis takes; `None`
1495/// when it holds them all, or when the genesis length is unknown.
1496fn genesis_shortfall(next_sequence: u64, genesis_entries: Option<u64>) -> Option<(u64, u64)> {
1497    let genesis_entries = genesis_entries?;
1498    let held = next_sequence.saturating_sub(1);
1499    (held < genesis_entries).then_some((held, genesis_entries))
1500}
1501
1502/// Load the Ed25519 replication signing key from `--replication-key` —
1503/// shared by the replica connect path and raft-enabled primaries.
1504fn load_replication_key(
1505    config: &ServerConfig,
1506) -> Result<ed25519_dalek::SigningKey, Box<dyn std::error::Error>> {
1507    let path = config.replication_key.as_ref().ok_or_else(|| {
1508        std::io::Error::other("--replication-key is required (replica mode or --raft-bind)")
1509    })?;
1510    let seed = std::fs::read(path).map_err(|e| {
1511        std::io::Error::other(format!(
1512            "failed to read replication key {}: {e}",
1513            path.display()
1514        ))
1515    })?;
1516    if seed.len() != 32 {
1517        return Err(format!(
1518            "replication key must be 32 bytes, got {} ({})",
1519            seed.len(),
1520            path.display()
1521        )
1522        .into());
1523    }
1524    let mut bytes = [0u8; 32];
1525    bytes.copy_from_slice(&seed);
1526    Ok(ed25519_dalek::SigningKey::from_bytes(&bytes))
1527}
1528
1529/// Run the server as a primary: build the disruptor pipeline, spawn
1530/// pipeline threads, journal the application's startup events, then
1531/// accept client connections.
1532///
1533/// Control event for the response stage. The io_uring response path reads
1534/// `fd` for I/O; the `writer` keeps the fd alive via ownership.
1535pub use crate::ControlEvent;
1536
1537/// Joinable handles for every long-lived thread a primary spawns,
1538/// gathered as each one starts.
1539///
1540/// Startup fills it in thread by thread, and the ordered shutdown empties
1541/// it through [`shutdown_pipeline_stages`]. Dropped with threads still in
1542/// it — startup returned early, because a later stage could not start or
1543/// for any other reason — it stops and joins them, so the error goes back
1544/// to the caller with no stage left running. That matters beyond the
1545/// process exiting anyway: [`run_with_shutdown`] hosts several nodes in
1546/// one process, and a stage left spinning would outlive its node.
1547///
1548/// Every handle is optional, because startup has not reached it yet, its
1549/// feature is disabled (no replication, no health endpoint), or its
1550/// transport has no such thread (DPDK runs without an event publisher).
1551struct PipelineHandles<A: Send + 'static, W: Send + 'static> {
1552    journal: Option<std::thread::JoinHandle<Result<W, JournalError>>>,
1553    matching: Option<std::thread::JoinHandle<A>>,
1554    response: Option<std::thread::JoinHandle<()>>,
1555    replication: Option<std::thread::JoinHandle<()>>,
1556    event_publisher: Option<std::thread::JoinHandle<()>>,
1557    shadow: Option<std::thread::JoinHandle<()>>,
1558    health: Option<std::thread::JoinHandle<()>>,
1559    // No standalone tick thread on either transport: io_uring emits ticks
1560    // from inside the reader (via IORING_OP_TIMEOUT) and DPDK emits them
1561    // from inside the poll thread (via a wall-clock check between bursts).
1562    /// The node's stop flag, raised before the threads are joined.
1563    shutdown: Arc<AtomicBool>,
1564    /// Lowered before the threads are joined, once the health endpoint
1565    /// exists, so a probe during the teardown reads unhealthy.
1566    pipeline_healthy: Option<Arc<AtomicBool>>,
1567}
1568
1569impl<A: Send + 'static, W: Send + 'static> PipelineHandles<A, W> {
1570    /// No thread started yet.
1571    fn new(shutdown: Arc<AtomicBool>) -> Self {
1572        Self {
1573            journal: None,
1574            matching: None,
1575            response: None,
1576            replication: None,
1577            event_publisher: None,
1578            shadow: None,
1579            health: None,
1580            shutdown,
1581            pipeline_healthy: None,
1582        }
1583    }
1584
1585    /// Whether a stage thread that only exits on shutdown or a panic has
1586    /// exited: the accept loop's check for a broken pipeline.
1587    fn stage_died(&self) -> bool {
1588        fn finished<T>(handle: &Option<std::thread::JoinHandle<T>>) -> bool {
1589            handle.as_ref().is_some_and(|h| h.is_finished())
1590        }
1591        finished(&self.journal)
1592            || finished(&self.matching)
1593            || finished(&self.response)
1594            || finished(&self.event_publisher)
1595            || finished(&self.shadow)
1596    }
1597
1598    /// Raise the stop flag and join every thread held, logging each that
1599    /// panicked or, for the journal stage, returned an error. Returns
1600    /// whether any did. Leaves nothing held.
1601    fn stop_and_join(&mut self) -> bool {
1602        if let Some(healthy) = &self.pipeline_healthy {
1603            healthy.store(false, Ordering::Relaxed);
1604        }
1605        self.shutdown.store(true, Ordering::Relaxed);
1606
1607        // Whether the joined thread panicked, logged if it did.
1608        fn panicked(name: &str, result: std::thread::Result<()>) -> bool {
1609            let Err(panic) = result else {
1610                return false;
1611            };
1612            let msg = panic
1613                .downcast_ref::<&str>()
1614                .copied()
1615                .or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
1616                .unwrap_or("<non-string panic>");
1617            error!(thread = name, message = msg, "pipeline thread panicked");
1618            true
1619        }
1620
1621        let mut failed = false;
1622        if let Some(h) = self.journal.take() {
1623            let journal_result = h.join();
1624            if let Ok(Err(ref e)) = journal_result {
1625                error!(thread = "journal-seq", error = %e, "journal stage returned error");
1626                failed = true;
1627            }
1628            failed |= panicked("journal-seq", journal_result.map(|_| ()));
1629        }
1630        if let Some(h) = self.matching.take() {
1631            failed |= panicked("matching", h.join().map(|_| ()));
1632        }
1633        if let Some(h) = self.response.take() {
1634            failed |= panicked("response", h.join());
1635        }
1636        if let Some(h) = self.replication.take() {
1637            failed |= panicked("replication-sender", h.join());
1638        }
1639        if let Some(h) = self.event_publisher.take() {
1640            failed |= panicked("event-publisher", h.join());
1641        }
1642        if let Some(h) = self.shadow.take() {
1643            failed |= panicked("shadow", h.join());
1644        }
1645        if let Some(h) = self.health.take() {
1646            failed |= panicked("health", h.join());
1647        }
1648        failed
1649    }
1650}
1651
1652impl<A: Send + 'static, W: Send + 'static> Drop for PipelineHandles<A, W> {
1653    fn drop(&mut self) {
1654        let started = self.journal.is_some()
1655            || self.matching.is_some()
1656            || self.response.is_some()
1657            || self.replication.is_some()
1658            || self.event_publisher.is_some()
1659            || self.shadow.is_some()
1660            || self.health.is_some();
1661        if started {
1662            info!("startup failed: stopping the stages already running");
1663            // The failures this reports are logged as they are found, and
1664            // what the caller needs is the error startup is returning.
1665            self.stop_and_join();
1666        }
1667    }
1668}
1669
1670/// Drain the pipeline and join every worker thread, surfacing panics
1671/// and journal-stage errors as a single `pipeline failure` return.
1672fn shutdown_pipeline_stages<A: Send + 'static, W: Send + 'static>(
1673    mut handles: PipelineHandles<A, W>,
1674) -> Result<(), Box<dyn std::error::Error>> {
1675    info!("shutdown: draining pipeline");
1676    let failed = handles.stop_and_join();
1677
1678    // After every stage thread has joined, dump the per-stage latency
1679    // histograms to stderr. No-op when `latency-trace` is disabled.
1680    melin_transport_core::trace::print_report_all();
1681
1682    if failed {
1683        error!("shutdown complete (with pipeline failure)");
1684        return Err("pipeline failure".into());
1685    }
1686
1687    info!("shutdown complete");
1688    Ok(())
1689}
1690
1691/// Used by both the normal primary startup path and the promotion path
1692/// (replica → primary transition).
1693///
1694/// `rotate_flag` is the shared `AtomicBool` toggled by the admin
1695/// endpoint (or `None` if no admin endpoint was configured). On the
1696/// promotion path the replica's journal stage is torn down and a new
1697/// primary stage is built here; passing the flag through means the
1698/// admin endpoint, which was spawned once at process start, keeps
1699/// driving the new stage's rotation.
1700#[allow(clippy::too_many_arguments)]
1701fn run_as_primary<A, L>(
1702    app: A,
1703    writer: BufferedWriter<A::Event>,
1704    mut listener: L,
1705    // Pre-bound replication listener (non-blocking by construction),
1706    // `Some` iff `--replication-bind` is set. Bound in `run_impl` before
1707    // any pipeline thread exists so a bind failure cannot leak threads,
1708    // and held from boot on replicas so a promotion cannot fail on it.
1709    repl_listener: Option<crate::replication::ReplicationListener>,
1710    config: &ServerConfig,
1711    // The node's journal and snapshot, as resolved at boot: written by the
1712    // shadow stage and served by the replication sender.
1713    store: &NodeStore,
1714    // The `on_primary` half of the application's `StartupEvents`. Genesis
1715    // never reaches here: it is journaled as the journal is created, in
1716    // `init_engine`, and a promoted node has it from the stream.
1717    on_primary: Vec<A::Event>,
1718    decoder: RequestDecoderArc<A>,
1719    encoder: ResponseEncoderArc<A>,
1720    event_publisher: Option<EventPublisherFn<A>>,
1721    shutdown: Arc<AtomicBool>,
1722    authorized_keys: Arc<AuthorizedKeys>,
1723    rotate_flag: Option<Arc<AtomicBool>>,
1724    // The ack-policy mailbox: the policy this primary requests as it
1725    // starts, and the `ACK-POLICY` requests its reader journals.
1726    ack_policy: Arc<crate::policy_control::AckPolicyControl>,
1727    fence_state: Arc<melin_transport_core::fence::FenceState>,
1728    // The ack policy the lineage has in force (`Lineage::ack_policy`): the
1729    // recovered one at boot, the replica's record of it at promotion.
1730    // Seeds the shadow stage's lineage next to the fence epoch, and the
1731    // gate's start: the policy before the newest change, with the change
1732    // pending (`initial_gate_policy`).
1733    lineage_ack_policy: Option<LineagePolicy>,
1734    promotion: Option<u64>,
1735    // This boot began the history (`InitializedEngine::began_history`):
1736    // with replication on, client service waits for the first replica.
1737    began_history: bool,
1738    raft_status: Option<Arc<melin_transport_core::health::RaftStatus>>,
1739    // Control-plane advertised tip. This primary's journal stage becomes
1740    // its writer (the raft driver keeps reading the same handle across a
1741    // promotion) — see `AdvertisedJournalTip`.
1742    journal_tip: melin_transport_core::AdvertisedJournalTip,
1743) -> Result<(), Box<dyn std::error::Error>>
1744where
1745    A: Application + Send + 'static,
1746    A::Event: Send + Sync + 'static,
1747    A::Report: Send + 'static,
1748    A::QueryResponse: Send + 'static,
1749    L: BlockingTransportListener,
1750{
1751    // Active connection counter shared between accept loop, response
1752    // stage, and matching stage (for stats queries).
1753    // Incremented on successful auth, decremented on disconnect.
1754    // Used to enforce max_connections (SEC-02).
1755    let active_connections = Arc::new(AtomicU64::new(0));
1756
1757    // Determine replication mode. Every application's binary ships the
1758    // full durable transport (journal + replication + shadow), so the
1759    // same config knob drives any of them.
1760    let enable_replication = config.replication() == crate::halt::Replication::Enabled;
1761    // Re-checked: a promotion reaches here without the boot-time check.
1762    validate_primary_config(config)?;
1763    // The reader's and the response stage's io_uring sizes, from the
1764    // connection cap the accept loop below enforces.
1765    let ring_sizing =
1766        crate::connection_limit::RingSizing::for_max_connections(config.max_connections)?;
1767    // The lineage's genesis length, read before the writer moves into
1768    // the pipeline: the shadow stage stamps it into every snapshot.
1769    let genesis_entries = writer.read_header_info()?.genesis_entries;
1770    // The wire sequence of the first entry this tenure journals: the
1771    // shadow stage counts from it.
1772    let starting_wire_seq = writer.next_sequence();
1773    // Clone the application for the shadow snapshot stage before the pipeline
1774    // consumes it. `Application` does not require `Clone`, so this goes
1775    // through `clone_via_snapshot` (a snapshot round-trip unless the
1776    // application overrides it with something cheaper).
1777    let enable_shadow = config.snapshot_interval_ms > 0;
1778    let shadow_app = if enable_shadow {
1779        Some(<A as Application>::clone_via_snapshot(&app)?)
1780    } else {
1781        None
1782    };
1783
1784    // Build the disruptor pipeline with optional replication consumer.
1785    // An application without an event publisher silently ignores
1786    // `--event-bind`, so the same invocation works against any binary.
1787    // The caller (binary) decides whether an event-publisher fn is
1788    // available; we only allocate the consumer slot when both the fn is
1789    // wired AND `--event-bind` is set.
1790    let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
1791    let Pipeline {
1792        input_producer,
1793        journal_stage,
1794        matching_stage,
1795        mut output_consumers,
1796        events_processed,
1797        input_cursor,
1798        replication_consumers,
1799        replicas_connected,
1800        shadow_consumer,
1801        chain_hash_lock,
1802        replication_ring_progress,
1803        cursors,
1804    } = build_pipeline_with_replication(
1805        app,
1806        writer,
1807        config.group_commit_delay(),
1808        Arc::clone(&active_connections),
1809        enable_replication,
1810        config.max_journal_batch,
1811        config.replication_ring_size,
1812        config.cores.stage_waits(),
1813        enable_event_publisher,
1814        enable_shadow,
1815        Arc::clone(&fence_state),
1816    );
1817    // Ring-position cursors for the on_primary drain gate below (Acquire loads,
1818    // stronger than the bundle's monitoring reads). The ack gate and
1819    // the replication sender pull their typed handles straight from
1820    // `cursors`; the health endpoint takes `cursors` itself — the quorum
1821    // and fastest-replica gauges are derived from the bundle's per-slot
1822    // cursors at read time.
1823    let journal_cursor = cursors.journal_ring_arc();
1824    let matching_cursor = cursors.matching_ring_arc();
1825
1826    // Consumer 0 is always the response stage. Consumer 1 (if present)
1827    // is the event publisher — only created when --event-bind is set —
1828    // chained behind the response stage, so it reads only slots the
1829    // response stage has committed, which have cleared the durability
1830    // gate (see `build_pipeline_with_replication`).
1831    let output_consumer = output_consumers.remove(0);
1832    let event_publisher_consumer = if enable_event_publisher {
1833        Some(output_consumers.remove(0))
1834    } else {
1835        None
1836    };
1837
1838    // Control channel for connect/disconnect events → response stage.
1839    let (control_tx, control_rx) = std::sync::mpsc::channel();
1840
1841    // Client writes a halted node refuses at ingress, reader → response
1842    // stage, counted for the health endpoint. See `crate::halt`. Beside
1843    // them, the reader's other early answers and its parks.
1844    let refused_writes = Arc::new(AtomicU64::new(0));
1845    let ingress_metrics = Arc::new(melin_transport_core::health::IngressMetrics::default());
1846    let halt_gate = crate::halt::HaltGate::new(
1847        replicas_connected.clone(),
1848        Arc::clone(&fence_state),
1849        Arc::clone(&refused_writes),
1850    );
1851    let (early_tx, early_rx) = crate::early_reply::early_reply_channel::<A::Report>(
1852        crate::early_reply::EARLY_REPLY_QUEUE_CAPACITY,
1853    );
1854
1855    // Spawn the io_uring reader thread. A single reader uses multishot RECV
1856    // to multiplex every TCP connection on the server. Pinned to
1857    // cores.reader. The matching stage is the throughput limit, so a
1858    // second reader would not raise throughput — only re-introduce the
1859    // multi-producer ordering race on the input ring.
1860    let connection_timeout = config.connection_timeout();
1861    let heartbeat_interval = config.heartbeat_interval();
1862
1863    // Startup events flow through the disruptor like regular events so
1864    // they're journaled, replicated, and processed by the matching stage via
1865    // the normal pipeline. The input ring is single-producer: main publishes
1866    // them through `input_producer`, then moves it into the reader thread
1867    // which becomes the sole steady-state producer. No cloning required.
1868    let mut input_producer = input_producer;
1869
1870    // Spawn pipeline OS threads.
1871    let cores = config.cores;
1872
1873    // Wire runtime journal rotation into the journal stage. The flag is
1874    // shared with the admin endpoint (spawned once at process start in
1875    // `run`) — passing it through here means PROMOTE-then-
1876    // ROTATE on a freshly-promoted node still drives rotation against
1877    // the new primary's stage. The size threshold uses the same
1878    // `max_journal_mib` knob that drives startup rotation.
1879    let mut journal_stage = journal_stage;
1880    let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
1881    journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
1882    config.cores.place_journal_children(&mut journal_stage);
1883    journal_stage.set_staging_mode(config.journal_staging_mode.into());
1884    // On a primary the journal stage owns the control-plane advertised
1885    // tip (durable cursor after each fsync batch). On the promotion path
1886    // this takes over the handle the receiver was advancing.
1887    journal_stage.set_advertised_tip_publisher(journal_tip.clone());
1888    if config.max_journal_mib > 0 {
1889        info!(
1890            max_journal_mib = config.max_journal_mib,
1891            "runtime journal rotation enabled (size threshold)"
1892        );
1893    }
1894
1895    // Extract utilization handles before stages are moved into threads.
1896    let journal_utilization = journal_stage.utilization();
1897    let matching_utilization = matching_stage.utilization();
1898    let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
1899    // Resolved before any stage starts, so a lineage under an unknown
1900    // policy refuses here with nothing to unwind. The claim is this
1901    // tenure's: its policy is journaled below, and the reader attaches it.
1902    let ack_policy_claim = ack_policy.claim();
1903    let gate_policy = primary_gate_policy(
1904        lineage_ack_policy,
1905        ack_policy_claim.policy(),
1906        halt_gate.replication(),
1907        &matching_stage,
1908    )?;
1909
1910    // Wrap each spawn closure with a tail log so a thread that returns
1911    // (cleanly OR via `Err`) is visible in the trace stream — the
1912    // accept loop's `is_finished` check fires error!("pipeline thread
1913    // died") only on its next tick, which can lag the actual exit by
1914    // up to 100ms and races with the listener teardown.
1915    let s1 = Arc::clone(&shutdown);
1916    let shutdown_for_journal = Arc::clone(&shutdown);
1917    // Start the journal's disk and preparer threads here rather than on
1918    // journal-seq, which is pinned by the time it runs: a thread inherits
1919    // its creator's placement. See `JournalStage::start`. For the same
1920    // reason every stage thread below is spawned placed, never plainly.
1921    let sequencer = journal_stage
1922        .start()
1923        .map_err(|e| format!("start the journal stage: {e}"))?;
1924    // From here every thread started goes in `stages`, which stops and
1925    // joins them on any early return.
1926    let mut stages = PipelineHandles::new(Arc::clone(&shutdown));
1927    stages.journal = Some(
1928        melin_app::affinity::spawn_placed("journal-seq", cores.journal_seq.core, move || {
1929            let result = sequencer.run(&s1);
1930            let was_shutdown = shutdown_for_journal.load(Ordering::Relaxed);
1931            match &result {
1932                Ok(_) if was_shutdown => info!("journal-seq thread exited cleanly on shutdown"),
1933                Ok(_) => error!("journal-seq thread returned without shutdown signal"),
1934                Err(e) => error!(error = %e, "journal-seq thread returned Err"),
1935            }
1936            result
1937        })
1938        .map_err(|e| format!("spawn journal-seq thread: {e}"))?,
1939    );
1940
1941    let s2 = Arc::clone(&shutdown);
1942    let shutdown_for_matching = Arc::clone(&shutdown);
1943    stages.matching = Some(
1944        melin_app::affinity::spawn_placed("matching", cores.matching.core, move || {
1945            let app = matching_stage.run(&s2);
1946            let was_shutdown = shutdown_for_matching.load(Ordering::Relaxed);
1947            if was_shutdown {
1948                info!("matching thread exited cleanly on shutdown");
1949            } else {
1950                error!("matching thread returned without shutdown signal");
1951            }
1952            app
1953        })
1954        .map_err(|e| format!("spawn matching thread: {e}"))?,
1955    );
1956
1957    let replication_metrics = build_replication_metrics(replication_consumers.is_some());
1958
1959    // Per-slot active flags exposed by the journal stage's replication
1960    // ring; the response gate filters disconnected slots out of the
1961    // policy's cursor view via these.
1962    let replica_active: Option<[Arc<AtomicBool>; 2]> =
1963        replication_ring_progress.as_ref().map(|rp| {
1964            [
1965                Arc::clone(&rp.active_flags[0]),
1966                Arc::clone(&rp.active_flags[1]),
1967            ]
1968        });
1969
1970    // Typed durable-cursor handle for the response thread's gate.
1971    let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
1972    let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
1973    let replica_active_response = replica_active.clone();
1974    let s3 = Arc::clone(&shutdown);
1975    let shutdown_for_response = Arc::clone(&shutdown);
1976    let response_utilization_thread = Arc::clone(&response_utilization);
1977    let response_fence = Arc::clone(&fence_state);
1978    let active_connections_response = Arc::clone(&active_connections);
1979    // The stage's startup report — see `Response::ready`. Capacity one so
1980    // the report never blocks the stage on this thread reaching `recv`.
1981    let (response_ready_tx, response_ready_rx) = std::sync::mpsc::sync_channel(1);
1982    let degraded_release = config.degraded_release(replicas_connected.as_ref());
1983    stages.response = Some(
1984        melin_app::affinity::spawn_placed("response", cores.response.core, move || {
1985            let started = crate::response::run::<A>(
1986                output_consumer,
1987                control_rx,
1988                crate::response::Response::<A> {
1989                    journal_persisted_wire_seq: journal_persisted_wire_seq_response,
1990                    policy: gate_policy,
1991                    replication_metrics: replication_metrics_response,
1992                    replica_active: replica_active_response,
1993                    heartbeat_interval,
1994                    wait: cores.response.wait,
1995                    utilization: response_utilization_thread,
1996                    encoder,
1997                    fence_state: response_fence,
1998                    active_connections: active_connections_response,
1999                    early_replies: early_rx,
2000                    ring_sizing,
2001                    ready: Some(response_ready_tx),
2002                    degraded_release,
2003                    #[cfg(test)]
2004                    pause_after_control_drain: None,
2005                },
2006                &s3,
2007            );
2008            if !started {
2009                // Never ran: the reason went back through the startup
2010                // report, and the spawner turns it into the node's error.
2011                return;
2012            }
2013            let was_shutdown = shutdown_for_response.load(Ordering::Relaxed);
2014            if was_shutdown {
2015                info!("response thread exited cleanly on shutdown");
2016            } else {
2017                error!("response thread returned without shutdown signal");
2018            }
2019        })
2020        .map_err(|e| format!("spawn response thread: {e}"))?,
2021    );
2022
2023    // A stage without its ring is a node that cannot answer anyone:
2024    // refuse to start (`stages` stops the stages already running).
2025    crate::connection_limit::await_startup("response", &response_ready_rx)?;
2026
2027    // Spawn replication sender thread if enabled. The journal stage publishes
2028    // encoded batches to a pre-allocated ring; the sender thread consumes them.
2029    // `replica_ready` is set when the first replica enters live streaming;
2030    // the bring-up gate below waits on it.
2031    let replica_ready = Arc::new(AtomicBool::new(false));
2032    // Ring depth monitoring: the producer cursors are in ReplicationRingProgress
2033    // (owned by this function), so we compute depth via ring_progress rather
2034    // than storing Box<dyn QueueCursor> in ReplicationMetrics. The health
2035    // snapshot reads consumer cursors from ReplicationMetrics and producer
2036    // cursors are not needed — ring depth is a secondary metric.
2037
2038    stages.replication = if let Some((repl_consumer_1, repl_consumer_2)) = replication_consumers {
2039        let s_repl = Arc::clone(&shutdown);
2040        let repl_slots = cursors.replica_slot_cursors();
2041        let ready_flag = Arc::clone(&replica_ready);
2042        let connected_counter = replicas_connected
2043            .clone()
2044            .ok_or("replicas_connected must be Some when replication is enabled")?;
2045
2046        let batch_size = config.replication_batch_size;
2047        let heartbeat_secs = config.replication_heartbeat_secs;
2048        let store = store.clone();
2049        let repl_auth_keys = Arc::clone(&authorized_keys);
2050        let evict_flags = replication_ring_progress
2051            .as_ref()
2052            .map(|rp| {
2053                [
2054                    Arc::clone(&rp.evict_flags[0]),
2055                    Arc::clone(&rp.evict_flags[1]),
2056                ]
2057            })
2058            .unwrap_or_else(|| {
2059                [
2060                    Arc::new(AtomicBool::new(false)),
2061                    Arc::new(AtomicBool::new(false)),
2062                ]
2063            });
2064        let active_flags = replication_ring_progress
2065            .as_ref()
2066            .map(|rp| {
2067                [
2068                    Arc::clone(&rp.active_flags[0]),
2069                    Arc::clone(&rp.active_flags[1]),
2070                ]
2071            })
2072            .unwrap_or_else(|| {
2073                [
2074                    Arc::new(AtomicBool::new(false)),
2075                    Arc::new(AtomicBool::new(false)),
2076                ]
2077            });
2078        let repl_metrics = replication_metrics
2079            .clone()
2080            .ok_or("replication_metrics must be Some when replication is enabled")?;
2081        let handlers = [cores.repl_handler_0, cores.repl_handler_1];
2082        let sender_fence = Arc::clone(&fence_state);
2083        // Bound in `run_impl` before any pipeline thread was spawned —
2084        // see the `repl_listener` parameter doc.
2085        let repl_listener = repl_listener
2086            .ok_or("replication listener must be pre-bound when replication is enabled")?;
2087        let repl_accept_handle = std::thread::Builder::new()
2088            // `repl-accept`, not `repl-sender`: this thread sends nothing.
2089            // The ring consumers are moved into the per-replica handler
2090            // threads, which do all the streaming; this one accepts
2091            // connections, reaps finished handlers, and sleeps 50 ms.
2092            .name("repl-accept".into())
2093            .spawn(move || {
2094                // Deliberately NOT pinned. Reserving a core — at
2095                // SCHED_FIFO, on an isolated one — for a thread that idles
2096                // 99.9% of the time bought nothing. The handler threads it
2097                // spawns are created in their own placement, so this
2098                // thread's does not reach them.
2099                //
2100                // `PipelineCores` consequently has no field for it, and
2101                // `--cores` no name.
2102                crate::replication::run_sender::<A>(
2103                    crate::replication::Sender {
2104                        listener: repl_listener,
2105                        repl_consumer_1,
2106                        repl_consumer_2,
2107                        replica_slots: repl_slots,
2108                        store,
2109                        authorized_keys: repl_auth_keys,
2110                        evict_flags,
2111                        active_flags,
2112                        metrics: repl_metrics,
2113                        handlers,
2114                        batch_size,
2115                        heartbeat_secs,
2116                        fence_state: sender_fence,
2117                    },
2118                    &s_repl,
2119                    &ready_flag,
2120                    &connected_counter,
2121                );
2122            })
2123            .map_err(|e| format!("spawn replication sender thread: {e}"))?;
2124
2125        Some(repl_accept_handle)
2126    } else {
2127        if !config.standalone && config.replica_of.is_none() {
2128            info!("running in standalone mode (no replication)");
2129        }
2130        None
2131    };
2132
2133    // Spawn event publisher thread if enabled. Consumes from output ring
2134    // consumer 1 and broadcasts the application's reports to TCP
2135    // subscribers. Caller-supplied (an application with an output feed
2136    // wires its publisher; one without passes `None`), so the runtime
2137    // carries no application-specific reference.
2138    stages.event_publisher = spawn_event_publisher::<A>(
2139        event_publisher_consumer,
2140        event_publisher,
2141        config,
2142        &cores,
2143        &authorized_keys,
2144        &shutdown,
2145    )?;
2146
2147    stages.shadow = spawn_shadow_stage::<A>(
2148        shadow_consumer,
2149        shadow_app,
2150        chain_hash_lock,
2151        config,
2152        store,
2153        &cores,
2154        &shutdown,
2155        Lineage {
2156            epoch: fence_state.epoch(),
2157            ack_policy: lineage_ack_policy,
2158        },
2159        starting_wire_seq,
2160        genesis_entries,
2161    )?;
2162
2163    // Spawn the health endpoint BEFORE `journal_on_primary_events` and
2164    // the accept loop so operators (and the failover test harness) can
2165    // probe `/healthz` to confirm the server has bound its sockets and
2166    // is ready to accept replica connections. Replicas connect to the
2167    // *replication* endpoint, not the client port, so spawning health
2168    // here doesn't change accept semantics.
2169    let pipeline_healthy = Arc::new(AtomicBool::new(true));
2170    stages.pipeline_healthy = Some(Arc::clone(&pipeline_healthy));
2171    stages.health = spawn_health_endpoint(
2172        config,
2173        &active_connections,
2174        &events_processed,
2175        &refused_writes,
2176        &ingress_metrics,
2177        &cursors,
2178        input_cursor,
2179        &pipeline_healthy,
2180        &replicas_connected,
2181        &fence_state,
2182        &replication_metrics,
2183        &replica_active,
2184        &replication_ring_progress,
2185        &journal_utilization,
2186        &matching_utilization,
2187        &response_utilization,
2188        &shutdown,
2189        &raft_status,
2190    )?;
2191
2192    // Promotion fencing: the bump is the first entry of a promoted node's
2193    // tenure. See `journal_promotion_epoch_bump`.
2194    if let Some(requested_epoch) = promotion {
2195        journal_promotion_epoch_bump(
2196            requested_epoch,
2197            &fence_state,
2198            &mut input_producer,
2199            &shutdown,
2200        );
2201    }
2202    // The policy this tenure requests, after the bump; see
2203    // `PublisherClaim::journal_boot`. Only the booted claim attaches the
2204    // reader.
2205    let ack_policy_claim =
2206        ack_policy_claim.journal_boot(lineage_ack_policy, &mut input_producer, &halt_gate);
2207
2208    // Bring-up gate: a primary that began the history with replication
2209    // on serves no client until its first replica is streaming live. A
2210    // write before then would be refused (no replica connected) under
2211    // every ack policy that requires one, so a new cluster's first
2212    // clients are held instead of refused while it assembles. Genesis is
2213    // already durable in the journal (`init_engine` creates the journal
2214    // with it), so a shutdown during the wait loses nothing; the wait
2215    // yields to it, and the unified shutdown below joins every thread.
2216    // A restarted or promoted primary continues a history and does not
2217    // wait: losing its replicas then is a halt, not a bring-up.
2218    if enable_replication && began_history {
2219        info!("new history: waiting for the first replica before serving clients");
2220        while !replica_ready.load(Ordering::Acquire) && !shutdown.load(Ordering::Relaxed) {
2221            // A sleep, not the orchestrator's spin: the wait lasts as
2222            // long as the operator takes to start a replica.
2223            std::thread::sleep(std::time::Duration::from_millis(100));
2224        }
2225    }
2226
2227    // `on_primary` — after the epoch bump on promotion, so the bump stays
2228    // the first entry of the tenure.
2229    journal_on_primary_events(
2230        on_primary,
2231        &mut input_producer,
2232        &journal_cursor,
2233        &matching_cursor,
2234        &replication_ring_progress,
2235        &shutdown,
2236    );
2237
2238    // Now that the on_primary events are fully drained, spawn the reader
2239    // thread. From here on the reader is the sole producer on the input ring
2240    // (the on_primary loop has finished and its clone of `input_producer`
2241    // was dropped at the end of the block above). Any subsequent ticks the
2242    // reader emits cannot race with on_primary events: there are none in
2243    // flight.
2244    //
2245    // If shutdown was requested while they were draining we still spawn the
2246    // reader so the unified shutdown sequence below joins every thread.
2247    let reader_shutdown = Arc::new(AtomicBool::new(false));
2248    // No reader, no client is ever served: refuse to start (`stages` stops
2249    // every stage already running).
2250    let mut reader_handle = crate::reader::spawn_reader::<A, _>(
2251        input_producer,
2252        decoder,
2253        halt_gate,
2254        early_tx,
2255        ingress_metrics,
2256        ack_policy_claim,
2257        control_tx.clone(),
2258        config.cores.reader.core,
2259        connection_timeout,
2260        config.tick_interval(),
2261        ring_sizing,
2262        Arc::clone(&reader_shutdown),
2263    )?;
2264
2265    // Health endpoint and `pipeline_healthy` were already spawned/created
2266    // earlier (before `journal_on_primary_events`) so probes can succeed
2267    // before the node accepts clients.
2268
2269    // Set the listener to non-blocking so accept() returns immediately
2270    // with WouldBlock when no connection is pending. This lets the accept
2271    // loop check the shutdown flag without blocking indefinitely.
2272    // Rust's std TcpListener retries on EINTR, so signals alone can't
2273    // interrupt a blocking accept().
2274    listener.set_nonblocking(true);
2275
2276    info!(addr = %config.bind, "listening");
2277
2278    // Monotonically increasing connection ID counter. AtomicU64 because
2279    // the accept loop is the only writer, but using atomic for future
2280    // flexibility (e.g., multiple listeners).
2281    let next_connection_id = AtomicU64::new(1);
2282
2283    // Accept loop — non-blocking with 100ms sleep on WouldBlock. Each
2284    // accepted connection is registered with the reader thread (no
2285    // per-connection threads).
2286    loop {
2287        if shutdown.load(Ordering::Relaxed) {
2288            info!("shutdown signal received");
2289            break;
2290        }
2291
2292        // Detect pipeline thread death early so we don't keep accepting
2293        // connections into a broken pipeline. These threads only exit on
2294        // shutdown or panic — if one is finished while shutdown is false,
2295        // it panicked. Re-check shutdown to avoid a TOCTOU race where a
2296        // clean shutdown signal arrives between the two checks.
2297        if stages.stage_died() && !shutdown.load(Ordering::Relaxed) {
2298            error!("pipeline thread died, initiating shutdown");
2299            pipeline_healthy.store(false, Ordering::Relaxed);
2300            break;
2301        }
2302
2303        let (mut std_read, mut std_write, addr) = match listener.accept() {
2304            Ok(conn) => conn,
2305            Err(e) => {
2306                if e.kind() == std::io::ErrorKind::WouldBlock {
2307                    // No pending connection — sleep briefly then retry.
2308                    std::thread::sleep(std::time::Duration::from_millis(100));
2309                    continue;
2310                }
2311                error!(error = %e, "accept error");
2312                continue;
2313            }
2314        };
2315
2316        // Enforce max_connections limit (SEC-02). Reject early before
2317        // spending time on auth. The counter is decremented by the response
2318        // stage on disconnect or write error.
2319        if crate::connection_limit::connection_cap_reached(
2320            active_connections.load(Ordering::Relaxed),
2321            config.max_connections,
2322        ) {
2323            warn!(addr = %addr, "connection rejected: max_connections reached");
2324            drop(std_read);
2325            drop(std_write);
2326            continue;
2327        }
2328
2329        let connection_id = ConnectionId(next_connection_id.fetch_add(1, Ordering::Relaxed));
2330
2331        debug!(connection_id = connection_id.0, addr = %addr, "new connection");
2332
2333        // Set a 5-second read timeout for the auth handshake to prevent
2334        // slow/malicious clients from blocking the accept loop.
2335        if let Err(e) = set_read_timeout(&std_read, Some(std::time::Duration::from_secs(5))) {
2336            debug!(connection_id = connection_id.0, error = %e, "failed to set auth timeout");
2337        }
2338
2339        // Challenge-response authentication handshake (cold path).
2340        // 1. Send Challenge with random nonce
2341        // 2. Read ChallengeResponse (signature + public key)
2342        // 3. Verify signature and look up key in authorized_keys
2343        // 4. Send ServerReady on success, AuthFailed on failure
2344        let (role, public_key_bytes) = match authenticate_connection(
2345            connection_id,
2346            addr,
2347            &mut std_read,
2348            &mut std_write,
2349            &authorized_keys,
2350        ) {
2351            Ok(pair) => pair,
2352            Err(e) => {
2353                debug!(connection_id = connection_id.0, addr = %addr, error = %e, "auth failed, dropping");
2354                continue;
2355            }
2356        };
2357
2358        // The identity the application sees as `ApplyCtx::key_hash`.
2359        let key_hash = melin_app::key_hash(&public_key_bytes);
2360
2361        active_connections.fetch_add(1, Ordering::Relaxed);
2362
2363        // Clear the read timeout before handing to the io_uring reader.
2364        // io_uring uses kernel-managed I/O, so the timeout is irrelevant,
2365        // but clearing it avoids surprising behavior if the fd is ever
2366        // used in blocking mode again.
2367        if let Err(e) = set_read_timeout(&std_read, None) {
2368            debug!(connection_id = connection_id.0, error = %e, "failed to clear auth timeout");
2369        }
2370
2371        // Enable SO_BUSY_POLL on the client data socket. The reader
2372        // thread already busy-spins on io_uring CQEs, so the kernel's
2373        // NIC busy-poll happens during cycles that would have been
2374        // spent spinning anyway — net cost is zero, and we avoid the
2375        // softirq → wakeup handoff for every recv on this connection.
2376        if let Err(e) = set_busy_poll(&std_read, BUSY_POLL_US) {
2377            // Best-effort: a kernel without CAP_NET_ADMIN or with
2378            // SO_BUSY_POLL disabled by sysctl will reject this. Logged
2379            // as debug — it's a per-connection event and only affects
2380            // latency, not correctness.
2381            debug!(
2382                connection_id = connection_id.0,
2383                error = %e,
2384                "failed to set SO_BUSY_POLL on client socket"
2385            );
2386        }
2387
2388        // Belt-and-braces write timeout (SEC-01). The real slow-client
2389        // protection is in the response stage — MSG_DONTWAIT sends,
2390        // paced retries, and the blocked-send timeout — since io_uring
2391        // SEND does not honor SO_SNDTIMEO. This bounds any residual
2392        // blocking write(2) on this fd (none exist today; the response
2393        // stage is the sole egress writer).
2394        if let Err(e) = set_write_timeout(&std_write, Some(std::time::Duration::from_secs(5))) {
2395            debug!(connection_id = connection_id.0, error = %e, "failed to set write timeout");
2396        }
2397
2398        // Register the writer with the response thread before the
2399        // reader, and keep it that way: the response stage reads its
2400        // output ring before it drains this channel, and relies on
2401        // `Connected` being queued before any request of the connection
2402        // can be read — see the drain in `response::run`. Queued, not
2403        // yet applied: the stage picks the event up on its next
2404        // iteration, which that order makes early enough.
2405        let fd = std_write.as_raw_fd();
2406        let boxed_writer: Box<dyn std::io::Write + Send> = Box::new(std_write);
2407        let control_event = ControlEvent::Connected {
2408            connection_id: connection_id.0,
2409            fd,
2410            writer: BlockingFrameWriter::new(boxed_writer),
2411        };
2412        if control_tx.send(control_event).is_err() {
2413            info!("response thread gone, shutting down");
2414            break;
2415        }
2416
2417        // Register the reader fd with the io_uring reader thread.
2418        reader_handle.register(crate::reader::ReaderRegistration {
2419            connection_id,
2420            reader: std_read,
2421            addr,
2422            role,
2423            key_hash,
2424        });
2425    }
2426
2427    // --- Ordered shutdown sequence ---
2428    // 1. Stop readers first so no new events enter the disruptor.
2429    info!("shutdown: stopping reader thread");
2430    reader_handle.shutdown();
2431    reader_handle.join();
2432
2433    // 2. Now drain the pipeline and join every worker thread.
2434    shutdown_pipeline_stages(stages)
2435}
2436
2437/// Run the server with DPDK kernel-bypass networking.
2438///
2439/// Replaces the kernel TCP stack entirely. The DPDK poll thread handles
2440/// all NIC I/O and TCP processing via smoltcp. The response stage encodes
2441/// frames and pushes them through an mpsc channel to the poll thread.
2442///
2443/// Thread layout:
2444/// - Core N:   DPDK poll thread (rx_burst, smoltcp, frame decode, tx_burst)
2445/// - Core 1:   Journal stage
2446/// - Core 2:   Matching stage
2447/// - Core 3:   Response stage (encodes to TX channel)
2448///
2449/// See [`run`] for the role of `startup`.
2450#[cfg(feature = "dpdk")]
2451fn run_dpdk<A>(
2452    config: ServerConfig,
2453    startup: StartupEvents<A::Event>,
2454    sizing: A::Sizing,
2455    decoder: RequestDecoderArc<A>,
2456    encoder: ResponseEncoderArc<A>,
2457    event_publisher: Option<EventPublisherFn<A>>,
2458    authorized_keys: Arc<AuthorizedKeys>,
2459    shutdown: Arc<AtomicBool>,
2460) -> Result<(), Box<dyn std::error::Error>>
2461where
2462    A: Application + Send + 'static,
2463    A::Event: Send + Sync + 'static,
2464    A::Report: Send + 'static,
2465    A::QueryResponse: Send + 'static,
2466{
2467    check_keys_match_decoder(&*decoder, &authorized_keys)?;
2468    let dpdk_config = dpdk_config_from(&config)?;
2469
2470    run_dpdk_impl::<A>(
2471        config,
2472        startup,
2473        sizing,
2474        decoder,
2475        encoder,
2476        event_publisher,
2477        authorized_keys,
2478        dpdk_config,
2479        shutdown,
2480    )
2481}
2482
2483/// Translate the CLI surface into a [`melin_dpdk::DpdkConfig`]: the NIC
2484/// from the `--dpdk-*` flags, with the node's default address where
2485/// `--dpdk-ip` is not given, listening on `--bind`'s port with one queue,
2486/// its client connections sized by the `--dpdk-client-*-kib` flags.
2487///
2488/// The flags are validated by their own mapping, before EAL init and
2489/// before any thread is spawned, and a bad value is returned as an error
2490/// rather than a panic. That ordering is the point: the peer MAC is only
2491/// consumed inside the replica's reconnect loop, so parsing it there
2492/// would turn a typo into a crash of an already-running node.
2493#[cfg(feature = "dpdk")]
2494fn dpdk_config_from(
2495    cfg: &ServerConfig,
2496) -> Result<melin_dpdk::DpdkConfig, melin_dpdk::DpdkArgsError> {
2497    Ok(melin_dpdk::DpdkConfig {
2498        nic: cfg
2499            .dpdk
2500            .nic_config_or(melin_dpdk::NicConfig::default().ip_addr)?,
2501        listen_port: cfg.bind.port(),
2502        client_buffers: melin_dpdk::SocketBuffers {
2503            rx_buf_size: kib(cfg.dpdk_client_rx_buf_kib),
2504            tx_buf_size: kib(cfg.dpdk_client_tx_buf_kib),
2505            tx_queue_limit: kib(cfg.dpdk_client_tx_queue_kib),
2506        },
2507        num_queues: 1,
2508    })
2509}
2510
2511#[cfg(feature = "dpdk")]
2512#[allow(clippy::too_many_arguments)] // boot assembly point, as run_impl
2513fn run_dpdk_impl<A>(
2514    config: ServerConfig,
2515    mut startup: StartupEvents<A::Event>,
2516    sizing: A::Sizing,
2517    decoder: RequestDecoderArc<A>,
2518    encoder: ResponseEncoderArc<A>,
2519    event_publisher: Option<EventPublisherFn<A>>,
2520    authorized_keys: Arc<AuthorizedKeys>,
2521    dpdk_config: melin_dpdk::DpdkConfig,
2522    shutdown: Arc<AtomicBool>,
2523) -> Result<(), Box<dyn std::error::Error>>
2524where
2525    A: Application + Send + 'static,
2526    A::Event: Send + Sync + 'static,
2527    A::Report: Send + 'static,
2528    A::QueryResponse: Send + 'static,
2529{
2530    warn_if_chain_disabled();
2531
2532    // As on the kernel-TCP path: resolve the layout once, up front. The
2533    // reader entry pins the NIC poll thread here, which never yields.
2534    let config = ServerConfig {
2535        cores: config.resolved_cores(ReaderThread::DpdkPoll)?,
2536        ..config
2537    };
2538    log_layout(&config.cores);
2539    // The one placement the layout check cannot make honest: the poll
2540    // thread polls flat out whatever its entry says, and unpinned it
2541    // does so wherever the scheduler puts it — next to whatever else is
2542    // there. Accepted (test hosts run this way), but said out loud.
2543    if !config.cores.reader.is_pinned() {
2544        warn!(
2545            "--cores: the reader is unpinned, but on DPDK it is the NIC poll thread and \
2546             busy-polls regardless; it will take a full core wherever the scheduler places \
2547             it. Give it a core of its own"
2548        );
2549    }
2550
2551    // Mirrors the kernel-TCP `run` path: one ack-policy mailbox per
2552    // process, threaded into both replica (staging for promotion) and
2553    // primary admin listeners.
2554    let ack_policy =
2555        crate::policy_control::AckPolicyControl::new(config.ack_policy, config.replication());
2556    // As on the kernel-TCP path: refuse a configuration no primary can run
2557    // under before EAL init and before anything touches the journal, in
2558    // every role — a replica's configuration is the one it serves under
2559    // once promoted.
2560    validate_primary_config(&config)?;
2561    // As on the kernel-TCP path: the node's journal and snapshot, for
2562    // every role below, refused before anything touches the disk.
2563    let store = config.store()?;
2564    // Initialize shared DPDK resources (EAL, mempool, ports with N queues).
2565    let shared = melin_dpdk::DpdkShared::init(&dpdk_config)?;
2566    // Actual queue count may be less than requested (TAP only supports 1).
2567    let num_dpdk_threads = shared.num_queues as usize;
2568
2569    // --- Replica mode (DPDK) ---
2570    // If --replica-of is set, run the DPDK replication receiver instead
2571    // of the primary path. The receiver uses one queue pair for the
2572    // outbound connection to the primary.
2573    if let Some(primary_addr) = config.replica_of {
2574        info!(primary = %primary_addr, "starting in replica mode (DPDK)");
2575        // As on the kernel-TCP path: a replica never journals genesis,
2576        // and its promotion check reads the lineage's genesis length from
2577        // its journal header, not its configuration.
2578        startup.genesis = Vec::new();
2579
2580        // Load the replication signing key — the replica signs the primary's
2581        // challenge with it. Mirrors the kernel-TCP replica path.
2582        let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
2583            std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
2584        })?;
2585        let signing_key = {
2586            let seed = std::fs::read(replication_key_path).map_err(|e| {
2587                std::io::Error::other(format!(
2588                    "failed to read replication key {}: {e}",
2589                    replication_key_path.display()
2590                ))
2591            })?;
2592            if seed.len() != 32 {
2593                return Err(format!(
2594                    "replication key must be 32 bytes, got {} ({})",
2595                    seed.len(),
2596                    replication_key_path.display()
2597                )
2598                .into());
2599            }
2600            let mut bytes = [0u8; 32];
2601            bytes.copy_from_slice(&seed);
2602            ed25519_dalek::SigningKey::from_bytes(&bytes)
2603        };
2604
2605        // The replica's control-plane bundle, same shape as the kernel
2606        // TCP replica path — see `run` for the rationale on lifetime.
2607        let control = crate::replication::ReplicaControlPlane::new();
2608        let promotion_request = control.promote.clone();
2609        let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2610        let _admin_handle = config
2611            .admin_bind
2612            .map(|addr| {
2613                crate::admin::spawn(
2614                    addr,
2615                    Some(promotion_request.clone()),
2616                    rotate_flag.clone(),
2617                    Some(Arc::clone(&ack_policy)),
2618                    Arc::clone(&shutdown),
2619                    Arc::clone(&authorized_keys),
2620                )
2621            })
2622            .transpose()?;
2623
2624        // Shared fencing state, created before mode detection so it
2625        // survives a replica → primary transition. Seeded at epoch 0; the
2626        // receiver raises it from the replica's recovered journal and the
2627        // replication stream, and a promotion bumps it by injecting an
2628        // `EpochBump`. Carried into `run_as_primary_dpdk` post-promotion.
2629        let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
2630
2631        // Control-plane raft runs on kernel TCP even on DPDK nodes — the
2632        // control plane is off the hot path and must not consume a DPDK
2633        // queue. Same promotion-surviving placement and tip ownership as
2634        // the kernel replica path.
2635        let journal_tip = control.journal_tip.clone();
2636        let mut raft = match crate::raft::build_raft_config(&config)? {
2637            None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2638            Some(cfg) => crate::raft::spawn_raft_driver(
2639                cfg,
2640                config.raft_auto_promote,
2641                &signing_key,
2642                &authorized_keys,
2643                &fence_state,
2644                journal_tip.clone(),
2645                Arc::clone(&control.tip_ready),
2646                // A replica claims to be serving once a promotion is in
2647                // flight (see SupersessionPolicy).
2648                {
2649                    let promote = control.promote.clone();
2650                    Arc::new(move || promote.is_requested())
2651                },
2652                &shutdown,
2653            )?,
2654        };
2655        // Act on election wins when the operator opted in. Genesis
2656        // primaries have nothing to promote — replica paths only. The
2657        // guard joins the thread on every exit path.
2658        if config.raft_auto_promote
2659            && let Some(wiring) = raft.promotion_wiring()
2660        {
2661            raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
2662                wiring.status,
2663                control.clone(),
2664                Arc::clone(&fence_state),
2665                Arc::clone(&ack_policy),
2666                wiring.peer_tips,
2667                wiring.peer_ids,
2668                wiring.elect_requested,
2669                wiring.elect_enabled,
2670                Arc::clone(&shutdown),
2671            ));
2672        }
2673        let raft_status = raft.status();
2674        let mut replica_health = crate::raft::spawn_replica_health(
2675            &config,
2676            &fence_state,
2677            raft_status.as_ref(),
2678            Arc::clone(&control.pipeline_healthy),
2679        )?;
2680
2681        // No local rotation triggers on the replica side — rotation is
2682        // primary-driven (see the kernel-TCP receiver path).
2683
2684        // Queue 0's transport carries the replica's link to its primary
2685        // and, should this node be promoted, everything it serves after:
2686        // a promoted replica is a DPDK primary on this same transport (see
2687        // the promotion arm below). It listens on nothing while the node
2688        // is a replica — a replica serves nothing inbound, and the stack
2689        // refuses a connection attempt — and gains its listeners at
2690        // promotion.
2691        let mut transport =
2692            melin_dpdk::DpdkTransport::from_shared_unlistening(&shared, &dpdk_config, 0)?;
2693        transport.send_gratuitous_arp();
2694
2695        let primary_ipv4 = match primary_addr.ip() {
2696            std::net::IpAddr::V4(ip) => ip,
2697            std::net::IpAddr::V6(_) => {
2698                return Err("DPDK replication requires IPv4".into());
2699            }
2700        };
2701
2702        match crate::replication::run_receiver_dpdk::<A>(
2703            &mut transport,
2704            primary_ipv4,
2705            primary_addr.port(),
2706            &signing_key,
2707            &store,
2708            &shutdown,
2709            &control,
2710            config.snapshot_interval_ms,
2711            config.cores,
2712            config.journal_staging_mode.into(),
2713            config.group_commit_delay(),
2714            config.replication_pipeline_depth,
2715            Arc::clone(&fence_state),
2716            &sizing,
2717        )? {
2718            // Clean shutdown — the raft and health guards tear down on drop.
2719            None => return Ok(()),
2720            Some((mut app, writer)) => {
2721                // Promotion! Transition to primary mode, on DPDK: the node
2722                // serves on the transport it ran on, as a promoted
2723                // kernel-TCP replica does. The receiver tore its pipeline
2724                // down and handed over its application and journal writer;
2725                // every link it opened is reset.
2726                info!("replica promoted (DPDK) — transitioning to primary");
2727                // See the kernel-TCP promotion path.
2728                check_promotable(&writer)?;
2729                // Release --health-bind before run_as_primary_dpdk rebinds it.
2730                replica_health.stop();
2731                // Idempotent; see the kernel-TCP promotion path.
2732                <A as Application>::prefault(&mut app, &sizing);
2733
2734                // Clear a ROTATE latched while this node was a replica —
2735                // see the kernel-TCP promotion path.
2736                if let Some(ref flag) = rotate_flag {
2737                    flag.store(false, Ordering::Release);
2738                }
2739
2740                // This thread ran the receiver, pinned to the reader's core
2741                // (on an isolated core, at real-time priority) once it
2742                // streamed, and it spawns the primary's threads next: a
2743                // child inheriting that placement could never run to pin
2744                // itself. Unpinned first, as the receiver does before it
2745                // builds a pipeline; it is pinned again as the poll thread.
2746                if let Err(e) = melin_app::affinity::clear_affinity() {
2747                    warn!(
2748                        error = e,
2749                        "failed to clear the receiver's affinity before promotion"
2750                    );
2751                }
2752
2753                // What `DpdkTransport::from_shared` gives a primary at
2754                // boot: the client listener, with the client buffers. The
2755                // replication listener is added with the primary's other
2756                // replication wiring, as at boot.
2757                transport
2758                    .add_client_listener(&dpdk_config)
2759                    .map_err(|e| format!("add the client listener on promotion: {e}"))?;
2760                // As at boot: the switch may have aged out this port's MAC
2761                // while the node only dialled out.
2762                transport.send_gratuitous_arp();
2763
2764                // The raft guard drops — stopping the driver and joining
2765                // the (already exited) promotion thread — after this
2766                // returns; see the kernel-TCP promotion path.
2767                return run_as_primary_dpdk::<A>(
2768                    app,
2769                    writer,
2770                    transport,
2771                    &dpdk_config,
2772                    &config,
2773                    &store,
2774                    startup.on_primary,
2775                    decoder,
2776                    encoder,
2777                    event_publisher,
2778                    Arc::clone(&shutdown),
2779                    authorized_keys,
2780                    rotate_flag,
2781                    ack_policy,
2782                    fence_state,
2783                    control.lineage_ack_policy.load(),
2784                    promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
2785                    raft_status,
2786                    journal_tip,
2787                );
2788            }
2789        }
2790    }
2791
2792    // Create per-thread transports (each gets its own queue pair + smoltcp stack).
2793    // The single-queue refactor (`feat/dpdk-single-queue`) collapsed
2794    // replication onto the same queue/thread as client traffic — no
2795    // separate queue is reserved any more, so `num_client_queues`
2796    // simply tracks `num_dpdk_threads`.
2797    let num_client_queues = num_dpdk_threads;
2798    let mut transports = Vec::with_capacity(num_client_queues);
2799    for q in 0..num_client_queues {
2800        let mut transport =
2801            melin_dpdk::DpdkTransport::from_shared(&shared, &dpdk_config, q as u16)?;
2802        // Send a gratuitous ARP from the first queue so the switch learns
2803        // our VF's MAC address. Without this, SR-IOV VFs that can't enable
2804        // promiscuous mode are unreachable — the switch has no forwarding
2805        // entry and drops unicast frames to our MAC.
2806        if q == 0 {
2807            transport.send_gratuitous_arp();
2808        }
2809        transports.push(transport);
2810    }
2811    // Exactly one client poll queue (LMAX: single reader → single
2812    // matcher); `dpdk_config_from` asks for one queue. Checked before
2813    // anything touches the journal or spawns a thread.
2814    let transport = match <[_; 1]>::try_from(transports) {
2815        Ok([transport]) => transport,
2816        Err(transports) => {
2817            return Err(format!(
2818                "expected exactly one client DPDK transport, the port gave {}",
2819                transports.len()
2820            )
2821            .into());
2822        }
2823    };
2824
2825    // The configuration was validated at the top of this function. Now
2826    // initialize or recover the application (journaling genesis on a new
2827    // journal) and size it — see the kernel-TCP primary path.
2828    // `began_history` is not read: the DPDK primary has no bring-up gate
2829    // (its main thread is the poll thread that accepts replicas, so it
2830    // cannot wait for one — see the replication note further down).
2831    let InitializedEngine {
2832        mut app,
2833        writer,
2834        recovered_lineage,
2835        began_history: _,
2836    } = init_engine::<A, BufferedWriter<A::Event>>(
2837        &config,
2838        &store,
2839        &sizing,
2840        std::mem::take(&mut startup.genesis),
2841    )?;
2842    <A as Application>::prefault(&mut app, &sizing);
2843
2844    // Fencing state for this DPDK primary, seeded with the recovered epoch.
2845    let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
2846        recovered_lineage.epoch,
2847    ));
2848
2849    // Control-plane raft (kernel TCP even on DPDK — off the hot path, no
2850    // DPDK queue consumed). Signing key loaded only when raft is on.
2851    // A DPDK primary's fence epoch is recovered here too; seed the
2852    // advertised sequence from the recovered journal (see the kernel
2853    // primary path). The pipeline's journal stage owns the handle below.
2854    let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
2855        melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
2856    );
2857    let raft = match crate::raft::build_raft_config(&config)? {
2858        None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2859        Some(cfg) => {
2860            let signing_key = load_replication_key(&config)?;
2861            crate::raft::spawn_raft_driver(
2862                cfg,
2863                config.raft_auto_promote,
2864                &signing_key,
2865                &authorized_keys,
2866                &fence_state,
2867                journal_tip.clone(),
2868                Arc::new(AtomicBool::new(true)),
2869                // A primary always claims to be serving.
2870                Arc::new(|| true),
2871                &shutdown,
2872            )?
2873        }
2874    };
2875    let raft_status = raft.status();
2876
2877    // The admin endpoint, as on the kernel-TCP primary path: spawned by
2878    // the caller of the primary function, since a promoted replica keeps
2879    // the one it booted with. PROMOTE is rejected on a primary (no flag
2880    // wired); ROTATE shares the flag the journal stage observes.
2881    let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2882    let _admin_handle = config
2883        .admin_bind
2884        .map(|addr| {
2885            crate::admin::spawn(
2886                addr,
2887                None,
2888                rotate_flag.clone(),
2889                Some(Arc::clone(&ack_policy)),
2890                Arc::clone(&shutdown),
2891                Arc::clone(&authorized_keys),
2892            )
2893        })
2894        .transpose()?;
2895
2896    // The raft guard and the admin endpoint drop after this returns.
2897    run_as_primary_dpdk::<A>(
2898        app,
2899        writer,
2900        transport,
2901        &dpdk_config,
2902        &config,
2903        &store,
2904        startup.on_primary,
2905        decoder,
2906        encoder,
2907        event_publisher,
2908        Arc::clone(&shutdown),
2909        authorized_keys,
2910        rotate_flag,
2911        ack_policy,
2912        fence_state,
2913        recovered_lineage.ack_policy,
2914        None, // not promoted — no EpochBump injection
2915        raft_status,
2916        journal_tip,
2917    )
2918}
2919
2920/// Run the server as a DPDK primary: the DPDK twin of [`run_as_primary`],
2921/// used by both the normal DPDK primary startup and the promotion of a
2922/// DPDK replica. Builds the pipeline, spawns its threads, wires the
2923/// response stage, the ack gate, the halt gate and the replication driver
2924/// onto `transport`, journals the promotion's epoch bump (if any) and the
2925/// application's `on_primary` events, then runs the poll loop on this
2926/// thread until shutdown.
2927///
2928/// `transport` is queue 0's, listening on the client port: built at boot
2929/// by a primary, or the transport a promoted replica ran on, with its
2930/// client listener added. The replication listener is added here. The
2931/// EAL, ports and pool behind the transport are left as they are, never
2932/// re-initialised: EAL cannot be initialised twice in a process.
2933///
2934/// The other parameters are [`run_as_primary`]'s, less the bring-up gate's
2935/// `began_history` (a DPDK primary has none; see below).
2936#[cfg(feature = "dpdk")]
2937#[allow(clippy::too_many_arguments)] // boot assembly point, as run_as_primary
2938fn run_as_primary_dpdk<A>(
2939    app: A,
2940    writer: BufferedWriter<A::Event>,
2941    mut transport: melin_dpdk::DpdkTransport,
2942    dpdk_config: &melin_dpdk::DpdkConfig,
2943    config: &ServerConfig,
2944    // As `run_as_primary`'s.
2945    store: &NodeStore,
2946    on_primary: Vec<A::Event>,
2947    decoder: RequestDecoderArc<A>,
2948    encoder: ResponseEncoderArc<A>,
2949    event_publisher: Option<EventPublisherFn<A>>,
2950    shutdown: Arc<AtomicBool>,
2951    authorized_keys: Arc<AuthorizedKeys>,
2952    rotate_flag: Option<Arc<AtomicBool>>,
2953    ack_policy: Arc<crate::policy_control::AckPolicyControl>,
2954    fence_state: Arc<melin_transport_core::fence::FenceState>,
2955    // As `run_as_primary`'s.
2956    lineage_ack_policy: Option<LineagePolicy>,
2957    promotion: Option<u64>,
2958    raft_status: Option<Arc<melin_transport_core::health::RaftStatus>>,
2959    journal_tip: melin_transport_core::AdvertisedJournalTip,
2960) -> Result<(), Box<dyn std::error::Error>>
2961where
2962    A: Application + Send + 'static,
2963    A::Event: Send + Sync + 'static,
2964    A::Report: Send + 'static,
2965    A::QueryResponse: Send + 'static,
2966{
2967    // Re-checked, as in `run_as_primary`: a promotion reaches here without
2968    // the boot-time check.
2969    validate_primary_config(config)?;
2970    // As on the kernel-TCP path: read before the writer moves into the
2971    // pipeline, for the shadow stage's snapshots.
2972    let genesis_entries = writer.read_header_info()?.genesis_entries;
2973    let starting_wire_seq = writer.next_sequence();
2974
2975    // Clone the application's state for the shadow snapshot stage before
2976    // moving it into the pipeline (same as the kernel TCP path).
2977    let enable_shadow = config.snapshot_interval_ms > 0;
2978    let shadow_app = if enable_shadow {
2979        Some(<A as Application>::clone_via_snapshot(&app)?)
2980    } else {
2981        None
2982    };
2983
2984    let active_connections = Arc::new(AtomicU64::new(0));
2985
2986    // Replication setup (same as TCP path). The configuration was
2987    // validated before `init_engine`.
2988    let enable_replication = config.replication() == crate::halt::Replication::Enabled;
2989
2990    // Build disruptor pipeline (same flags as the kernel TCP path).
2991    // DPDK doesn't currently spawn the publisher thread (see
2992    // `event_publisher: None` in the handles bundle further down).
2993    // Consumer allocation is gated on the same `Some`/`event_bind` pair
2994    // the kernel-TCP path uses, so passing `None` wires up no consumer;
2995    // but with `Some` and `--event-bind` a consumer slot is wired up and
2996    // never drained — see "DPDK never runs the event publisher" in
2997    // docs/internal/transport-divergences-2026-10.md.
2998    let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
2999    let enable_shadow = config.snapshot_interval_ms > 0;
3000    let Pipeline {
3001        input_producer,
3002        journal_stage,
3003        matching_stage,
3004        mut output_consumers,
3005        events_processed,
3006        input_cursor,
3007        replication_consumers,
3008        replicas_connected,
3009        shadow_consumer,
3010        chain_hash_lock,
3011        replication_ring_progress,
3012        cursors,
3013    } = build_pipeline_with_replication(
3014        app,
3015        writer,
3016        config.group_commit_delay(),
3017        Arc::clone(&active_connections),
3018        enable_replication,
3019        config.max_journal_batch,
3020        config.replication_ring_size,
3021        config.cores.stage_waits(),
3022        enable_event_publisher,
3023        enable_shadow,
3024        Arc::clone(&fence_state),
3025    );
3026    // Ring-position cursors for the on_primary drain gate below (Acquire loads,
3027    // stronger than the bundle's monitoring reads). The ack gate and
3028    // the replication sender pull their typed handles straight from
3029    // `cursors`; the health endpoint takes `cursors` itself.
3030    let journal_cursor = cursors.journal_ring_arc();
3031    let matching_cursor = cursors.matching_ring_arc();
3032
3033    let heartbeat_interval = config.heartbeat_interval();
3034
3035    // on_primary events flow through the disruptor like regular events. The
3036    // input ring is single-producer: main publishes them, then moves the
3037    // producer into the DPDK poll thread. No cloning required.
3038    let mut input_producer = input_producer;
3039
3040    // The DPDK poll thread also generates the application's clock ticks via
3041    // a wall-clock comparison between NIC bursts (see `run_dpdk_poll`). The
3042    // input ring is therefore single-producer in steady state alongside the
3043    // one-shot on_primary loop — same property as the io_uring transport.
3044    let tick_cadence = config.tick_interval();
3045
3046    // Control channel: DPDK poll thread → response stage (connect/disconnect).
3047    let (control_tx, control_rx) = std::sync::mpsc::channel();
3048
3049    // Client writes a halted node refuses at ingress, poll thread →
3050    // response stage, counted for the health endpoint. See `crate::halt`.
3051    // Beside them, the poll thread's other early answers and its parks.
3052    let refused_writes = Arc::new(AtomicU64::new(0));
3053    let ingress_metrics = Arc::new(melin_transport_core::health::IngressMetrics::default());
3054    let halt_gate = crate::halt::HaltGate::new(
3055        replicas_connected.clone(),
3056        Arc::clone(&fence_state),
3057        Arc::clone(&refused_writes),
3058    );
3059    let (early_tx, early_rx) = crate::early_reply::early_reply_channel::<A::Report>(
3060        crate::early_reply::EARLY_REPLY_QUEUE_CAPACITY,
3061    );
3062
3063    // TX SPSC: response stage → DPDK poll thread (encoded frames).
3064    // Lock-free, fixed-size slots — no heap allocation per frame.
3065    // 4096 slots × ~140 bytes = ~560 KiB. Enough to buffer a burst
3066    // without backpressuring the response stage.
3067    // One SPSC channel per DPDK poll thread (one: see `transport`). The
3068    // response stage routes frames to the correct thread based on
3069    // thread_id encoded in connection_id bits 56..63. A `Vec` because
3070    // that is the response stage's interface.
3071    // The response thread produces into it, so it waits its way.
3072    let wait = config.cores.response.wait;
3073    let (tx_out, tx_rx_0) =
3074        melin_pipeline::spsc::channel::<crate::dpdk_response::TxFrame>(4096, wait);
3075    let tx_producers = vec![tx_out];
3076
3077    // Spawn pipeline threads (journal, matching — identical to TCP path).
3078    let cores = config.cores;
3079
3080    // Wire runtime rotation into the journal stage (DPDK primary path).
3081    // ROTATE shares `rotate_flag` with the admin endpoint the caller
3082    // spawned.
3083    let mut journal_stage = journal_stage;
3084    let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
3085    journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
3086    config.cores.place_journal_children(&mut journal_stage);
3087    journal_stage.set_staging_mode(config.journal_staging_mode.into());
3088    // On a primary the journal stage owns the control-plane advertised
3089    // tip (durable cursor after each fsync batch). On the promotion path
3090    // this takes over the handle the receiver was advancing.
3091    journal_stage.set_advertised_tip_publisher(journal_tip.clone());
3092    if config.max_journal_mib > 0 {
3093        info!(
3094            max_journal_mib = config.max_journal_mib,
3095            "runtime journal rotation enabled (size threshold, DPDK)"
3096        );
3097    }
3098
3099    // Extract utilization handles before stages are moved into threads.
3100    let journal_utilization = journal_stage.utilization();
3101    let matching_utilization = matching_stage.utilization();
3102    let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
3103    // As on kernel TCP: resolved before any stage starts, from this
3104    // tenure's claim, which the poll thread attaches.
3105    let ack_policy_claim = ack_policy.claim();
3106    let gate_policy = primary_gate_policy(
3107        lineage_ack_policy,
3108        ack_policy_claim.policy(),
3109        halt_gate.replication(),
3110        &matching_stage,
3111    )?;
3112
3113    let s1 = Arc::clone(&shutdown);
3114    // As on kernel TCP: start the journal's helper threads here, not on
3115    // journal-seq, and spawn every stage thread placed. See
3116    // `JournalStage::start`.
3117    let sequencer = journal_stage
3118        .start()
3119        .map_err(|e| format!("start the journal stage: {e}"))?;
3120    // As on kernel TCP: from here every thread started goes in `stages`,
3121    // which stops and joins them on any early return.
3122    let mut stages = PipelineHandles::new(Arc::clone(&shutdown));
3123    stages.journal = Some(
3124        melin_app::affinity::spawn_placed("journal-seq", cores.journal_seq.core, move || {
3125            sequencer.run(&s1)
3126        })
3127        .map_err(|e| format!("spawn journal-seq thread: {e}"))?,
3128    );
3129
3130    let s2 = Arc::clone(&shutdown);
3131    stages.matching = Some(
3132        melin_app::affinity::spawn_placed("matching", cores.matching.core, move || {
3133            matching_stage.run(&s2)
3134        })
3135        .map_err(|e| format!("spawn matching thread: {e}"))?,
3136    );
3137
3138    let replication_metrics = build_replication_metrics(replication_consumers.is_some());
3139
3140    let replica_active: Option<[Arc<AtomicBool>; 2]> =
3141        replication_ring_progress.as_ref().map(|rp| {
3142            [
3143                Arc::clone(&rp.active_flags[0]),
3144                Arc::clone(&rp.active_flags[1]),
3145            ]
3146        });
3147
3148    // Spawn DPDK response stage (encodes to TX channel instead of kernel sockets).
3149    let output_consumer = output_consumers.remove(0);
3150    // Typed durable-cursor handle for the response thread's gate.
3151    let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
3152    let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
3153    let replica_active_response = replica_active.clone();
3154    let active_connections_response = Arc::clone(&active_connections);
3155    let s3 = Arc::clone(&shutdown);
3156    let response_utilization_thread = Arc::clone(&response_utilization);
3157    let degraded_release = config.degraded_release(replicas_connected.as_ref());
3158    stages.response = Some(
3159        melin_app::affinity::spawn_placed("response", cores.response.core, move || {
3160            crate::dpdk_response::run::<A>(
3161                output_consumer,
3162                control_rx,
3163                journal_persisted_wire_seq_response,
3164                gate_policy,
3165                replication_metrics_response,
3166                replica_active_response,
3167                &s3,
3168                heartbeat_interval,
3169                active_connections_response,
3170                tx_producers,
3171                response_utilization_thread,
3172                wait,
3173                encoder,
3174                early_rx,
3175                degraded_release,
3176                #[cfg(test)]
3177                None,
3178            );
3179        })
3180        .map_err(|e| format!("spawn response thread: {e}"))?,
3181    );
3182
3183    stages.shadow = spawn_shadow_stage::<A>(
3184        shadow_consumer,
3185        shadow_app,
3186        chain_hash_lock,
3187        config,
3188        store,
3189        &cores,
3190        &shutdown,
3191        Lineage {
3192            epoch: fence_state.epoch(),
3193            ack_policy: lineage_ack_policy,
3194        },
3195        starting_wire_seq,
3196        genesis_entries,
3197    )?;
3198
3199    // `replication_metrics` was constructed above so the response gate can
3200    // read per-slot cursors; the replication driver shares the same
3201    // instance.
3202    let replica_ready = Arc::new(AtomicBool::new(false));
3203    // Replication, if enabled: build a `DpdkReplicationDriver` for the
3204    // single client poll thread to drive. The driver's accept dispatch
3205    // hangs off a second listener on the client transport, added below
3206    // (port == repl_bind.port()).
3207    let (repl_driver, repl_listen_port) = if let Some((repl_consumer_1, repl_consumer_2)) =
3208        replication_consumers
3209    {
3210        let repl_bind = config
3211            .replication_bind
3212            .ok_or("replication_bind must be set when replication is enabled")?;
3213        let repl_port = repl_bind.port();
3214
3215        let repl_slots = cursors.replica_slot_cursors();
3216        let ready_flag = Arc::clone(&replica_ready);
3217        let batch_size = config.replication_batch_size;
3218        let heartbeat_secs = config.replication_heartbeat_secs;
3219        let repl_metrics = replication_metrics
3220            .clone()
3221            .ok_or("replication_metrics must be Some when replication is enabled")?;
3222        let connected_counter = replicas_connected
3223            .clone()
3224            .ok_or("replicas_connected must be Some when replication is enabled")?;
3225        let dpdk_active_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
3226            .as_ref()
3227            .map(|rp| {
3228                [
3229                    Arc::clone(&rp.active_flags[0]),
3230                    Arc::clone(&rp.active_flags[1]),
3231                ]
3232            })
3233            .unwrap_or_else(|| {
3234                [
3235                    Arc::new(AtomicBool::new(false)),
3236                    Arc::new(AtomicBool::new(false)),
3237                ]
3238            });
3239        let dpdk_evict_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
3240            .as_ref()
3241            .map(|rp| {
3242                [
3243                    Arc::clone(&rp.evict_flags[0]),
3244                    Arc::clone(&rp.evict_flags[1]),
3245                ]
3246            })
3247            .unwrap_or_else(|| {
3248                [
3249                    Arc::new(AtomicBool::new(false)),
3250                    Arc::new(AtomicBool::new(false)),
3251                ]
3252            });
3253
3254        // Add the replication listener to the client transport so the
3255        // poll thread accepts both client and replication connections
3256        // off the same queue.
3257        // Replication needs larger TX buffers than clients: journal batches
3258        // can be 100-200 KiB, far exceeding the 16 KiB client TX buffer.
3259        const REPL_TX_BUF: usize = 512 * 1024;
3260        const REPL_TX_QUEUE: usize = 512 * 1024;
3261        const REPL_RX_BUF: usize = 64 * 1024;
3262        transport
3263            .add_listener_with_buffers(
3264                repl_port,
3265                REPL_RX_BUF,
3266                REPL_TX_BUF,
3267                REPL_TX_QUEUE,
3268                crate::replication::REPL_DISPATCH_BURST,
3269            )
3270            .map_err(|e| format!("add replication listener: {e}"))?;
3271
3272        let driver = crate::replication::DpdkReplicationDriver::new(
3273            [repl_consumer_1, repl_consumer_2],
3274            repl_slots,
3275            store.clone(),
3276            ready_flag,
3277            connected_counter,
3278            dpdk_evict_flags,
3279            dpdk_active_flags,
3280            repl_metrics,
3281            batch_size,
3282            heartbeat_secs,
3283            Arc::clone(&fence_state),
3284            Arc::clone(&authorized_keys),
3285        )?;
3286        // Legacy text match — `lan-bench-suite.sh` `wait_for_log` keys
3287        // off "DPDK replication sender started" to know the primary
3288        // is ready to accept replicas. Kept verbatim for backward
3289        // compatibility with existing bench tooling.
3290        info!(addr = %repl_bind, "DPDK replication sender started (single-queue, in client poll thread)");
3291        (Some(driver), repl_port)
3292    } else {
3293        if !config.standalone && config.replica_of.is_none() {
3294            info!("running in standalone mode (no replication)");
3295        }
3296        (None, 0)
3297    };
3298    // No replication-sender thread on DPDK: the driver lives inside the
3299    // client poll thread, so `stages.replication` stays empty.
3300
3301    // Promotion fencing, as on kernel TCP: the bump is the first entry of
3302    // a promoted node's tenure, ahead of `on_primary`, and is applied
3303    // before the poll loop below serves any client or replica. See
3304    // `journal_promotion_epoch_bump`.
3305    if let Some(requested_epoch) = promotion {
3306        journal_promotion_epoch_bump(
3307            requested_epoch,
3308            &fence_state,
3309            &mut input_producer,
3310            &shutdown,
3311        );
3312    }
3313    // As on kernel TCP: the policy this tenure requests, after the bump,
3314    // and the booted claim the poll thread attaches.
3315    let ack_policy_claim =
3316        ack_policy_claim.journal_boot(lineage_ack_policy, &mut input_producer, &halt_gate);
3317
3318    // Journal `on_primary` through the pipeline. Genesis is already in
3319    // the journal (`init_engine` creates the journal with it); replicas
3320    // copy it by catch-up like the rest of the history.
3321    //
3322    // Unlike the kernel-TCP path, a DPDK primary that began the history
3323    // has no bring-up gate holding clients until its first replica
3324    // attaches: the main thread IS the poll thread that accepts replica
3325    // connections, so blocking it here for one would deadlock until
3326    // shutdown. Until a replica attaches, writes under a policy that
3327    // requires one are refused (no replica connected). A promoted primary
3328    // continues a history, which the gate never holds on either transport.
3329    journal_on_primary_events(
3330        on_primary,
3331        &mut input_producer,
3332        &journal_cursor,
3333        &matching_cursor,
3334        &replication_ring_progress,
3335        &shutdown,
3336    );
3337
3338    // Note: the DPDK poll threads are spawned BELOW, after this on_primary
3339    // drain. That ordering is what keeps the input ring single-producer
3340    // while on_primary events are published — the same property the TCP
3341    // path achieves by deferring its `spawn_reader` call to after the drain.
3342
3343    let pipeline_healthy = Arc::new(AtomicBool::new(true));
3344    stages.pipeline_healthy = Some(Arc::clone(&pipeline_healthy));
3345    stages.health = spawn_health_endpoint(
3346        config,
3347        &active_connections,
3348        &events_processed,
3349        &refused_writes,
3350        &ingress_metrics,
3351        &cursors,
3352        input_cursor,
3353        &pipeline_healthy,
3354        &replicas_connected,
3355        &fence_state,
3356        &replication_metrics,
3357        &replica_active,
3358        &replication_ring_progress,
3359        &journal_utilization,
3360        &matching_utilization,
3361        &response_utilization,
3362        &shutdown,
3363        &raft_status,
3364    )?;
3365
3366    info!(
3367        ip = %dpdk_config.nic.ip_addr,
3368        port = dpdk_config.listen_port,
3369        num_dpdk_threads = 1,
3370        "DPDK transport listening"
3371    );
3372
3373    let connection_timeout = config.connection_timeout();
3374    let max_conns = config.max_connections;
3375    let reader_core = config.cores.reader.core;
3376
3377    // Exactly one client poll queue (LMAX: single reader → single
3378    // matcher), on this thread.
3379    melin_app::affinity::pin_thread("dpdk-poll-0", reader_core);
3380    // Attached while this thread still owns the producer alone, before the
3381    // poll loop serves anyone. No wakeup: the poll thread never parks.
3382    let ack_policy = ack_policy_claim.attach(&mut input_producer, &halt_gate, None);
3383    crate::dpdk_transport::run_dpdk_poll::<A>(
3384        transport,
3385        input_producer,
3386        decoder,
3387        halt_gate,
3388        early_tx,
3389        ingress_metrics,
3390        ack_policy,
3391        control_tx,
3392        tx_rx_0,
3393        &shutdown,
3394        authorized_keys,
3395        connection_timeout,
3396        tick_cadence,
3397        max_conns,
3398        Arc::clone(&active_connections),
3399        0,
3400        repl_driver,
3401        repl_listen_port,
3402    );
3403
3404    // The client poll ran on this thread, so there is no poll thread to
3405    // join. The caller's raft guard drops — stopping the driver — after
3406    // this returns.
3407    shutdown_pipeline_stages(stages)
3408}
3409
3410/// Promotion fencing: journal the `EpochBump` that opens a promoted
3411/// node's tenure, and return once it is applied. Both transports' primary
3412/// paths call it, before `on_primary` and before any client or replica is
3413/// served; a primary that booted as one (not promoted) does not, and keeps
3414/// the epoch its journal recovered.
3415///
3416/// The bump raises the cluster epoch so a paused/partitioned ex-primary
3417/// self-demotes when a handshake crosses (see `melin_transport_core::fence`).
3418/// It rides the input ring like a seed event (connection_id == 0), so it
3419/// flows through journal + replication to every replica. The wait for the
3420/// matching stage to apply it (epoch advanced) means the first handshake
3421/// already advertises the new epoch. The caller must be the input ring's
3422/// only producer.
3423///
3424/// The new epoch honours the promotion request's floor: a manual
3425/// `PROMOTE` carries `MANUAL` (= 1) and resolves to the classic
3426/// `epoch + 1`; a raft auto-promotion carries its election term
3427/// (strictly above the old epoch by the driver's request rule), so
3428/// tenure epochs align with raft terms and two overlapping
3429/// promotions from different elections always allocate distinct
3430/// epochs — the newer one fences the older.
3431fn journal_promotion_epoch_bump<E: melin_app::AppEvent>(
3432    requested_epoch: u64,
3433    fence_state: &melin_transport_core::fence::FenceState,
3434    input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
3435    shutdown: &AtomicBool,
3436) {
3437    use melin_app::unix_epoch_nanos;
3438    use melin_journal::JournalEvent;
3439
3440    let new_epoch = fence_state.epoch().saturating_add(1).max(requested_epoch);
3441    // Re-validate the term↔epoch alignment at the moment the epoch is
3442    // minted, not just at the driver's request-time check: a streamed
3443    // `EpochBump` from a concurrent promotion elsewhere can raise the
3444    // fence during the drain, in which case `max` allocates `epoch+1`
3445    // instead of the election term. Fencing still converges (the
3446    // epochs stay distinct and the higher one wins), but the skew
3447    // makes later "epochs outran raft terms" refusals — so say what
3448    // actually happened while the evidence exists. Manual promotions
3449    // (requested == MANUAL) are exempt: they never claimed alignment.
3450    if requested_epoch > crate::promotion::PromotionRequest::MANUAL && new_epoch != requested_epoch
3451    {
3452        warn!(
3453            new_epoch,
3454            requested_epoch,
3455            "promotion epoch does not match its election term — a concurrent \
3456             promotion advanced the fencing epoch mid-drain; auto-promotion \
3457             refusals may report term/epoch misalignment until a newer election"
3458        );
3459    }
3460    info!(
3461        new_epoch,
3462        requested_epoch, "promotion: injecting epoch bump"
3463    );
3464    input_producer.publish(InputSlot::node_event(
3465        JournalEvent::EpochBump { epoch: new_epoch },
3466        unix_epoch_nanos(),
3467    ));
3468    // Wait until the matching stage observes the bump (epoch raised) so
3469    // the node advertises `new_epoch` on the very first handshake. Bounded
3470    // by the shutdown flag so a stuck pipeline can't wedge startup.
3471    ORCHESTRATOR_WAIT
3472        .wait_until(|| fence_state.epoch() >= new_epoch || shutdown.load(Ordering::Relaxed));
3473}
3474
3475/// Journal a primary's `on_primary` [`StartupEvents`] and return once
3476/// they are applied. (Genesis is not journaled here: `init_engine`
3477/// creates a new journal with it already in it.)
3478///
3479/// The events ride the input ring as the node's own (connection 0, key
3480/// hash 0: no client identity), so the normal pipeline
3481/// journals, replicates and applies them. The caller must still be the
3482/// ring's only producer, and must not serve a client until this returns:
3483/// a client request would otherwise run before the configuration in
3484/// `on_primary` is in force.
3485///
3486/// Gates on the journal + matching cursors (disruptor sequence space),
3487/// then on every active replication ring being consumed — the sender
3488/// threads have read every batch (sent or being sent to replicas).
3489/// Stronger than no gate, faster than waiting for replica acks, and
3490/// deadlock-free because the ring backpressures instead of dropping
3491/// batches. Every wait yields to the shutdown flag, and goes through the
3492/// orchestrator wait strategy, never a bare spin: the stage threads may
3493/// share this thread's core on a small box.
3494fn journal_on_primary_events<E: melin_app::AppEvent>(
3495    on_primary: Vec<E>,
3496    input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
3497    journal_cursor: &melin_pipeline::padding::Sequence,
3498    matching_cursor: &melin_pipeline::padding::Sequence,
3499    replication_ring_progress: &Option<melin_transport_core::pipeline::ReplicationRingProgress>,
3500    shutdown: &AtomicBool,
3501) {
3502    use melin_app::unix_epoch_nanos;
3503    use melin_journal::JournalEvent;
3504
3505    let on_primary_count = on_primary.len();
3506    // Nothing published: the cursors never advance past a target, so
3507    // waiting for one would hang the boot.
3508    if on_primary_count == 0 {
3509        return;
3510    }
3511    let start = std::time::Instant::now();
3512
3513    // `sequence: 0` — the journal stage allocates sequences in disruptor
3514    // cursor order at encode time. The runtime wraps each application
3515    // event as `JournalEvent::App` and stamps transport-level metadata.
3516    let mut last_published_seq = 0u64;
3517    for event in on_primary {
3518        last_published_seq = input_producer.publish(InputSlot::node_event(
3519            JournalEvent::App(event),
3520            unix_epoch_nanos(),
3521        ));
3522    }
3523    let publish_elapsed = start.elapsed();
3524
3525    let target = last_published_seq + 1; // cursor = next-to-consume
3526    info!(
3527        target,
3528        journal = journal_cursor.get().load(Ordering::Relaxed),
3529        matching = matching_cursor.get().load(Ordering::Relaxed),
3530        "startup events: waiting for pipeline cursors"
3531    );
3532    ORCHESTRATOR_WAIT.wait_until(|| {
3533        shutdown.load(Ordering::Relaxed)
3534            || (journal_cursor.get().load(Ordering::Acquire) >= target
3535                && matching_cursor.get().load(Ordering::Acquire) >= target)
3536    });
3537
3538    // Inactive rings (no connected replica) were never published to, so
3539    // their producer cursor is 0 — no wait needed.
3540    if let Some(ring_progress) = replication_ring_progress {
3541        for i in 0..ring_progress.producer_cursors.len() {
3542            if !ring_progress.active_flags[i].load(Ordering::Relaxed) {
3543                continue;
3544            }
3545            let ring_target = ring_progress.producer_cursors[i].load();
3546            ORCHESTRATOR_WAIT.wait_until(|| {
3547                shutdown.load(Ordering::Relaxed)
3548                    || ring_progress.consumer_cursors[i]
3549                        .get()
3550                        .load(Ordering::Acquire)
3551                        >= ring_target
3552            });
3553        }
3554    }
3555
3556    info!(
3557        on_primary_events = on_primary_count,
3558        publish_ms = publish_elapsed.as_millis(),
3559        total_ms = start.elapsed().as_millis(),
3560        "journaled startup events through pipeline"
3561    );
3562}
3563
3564/// Bootstrap source chosen by [`init_engine`] from the on-disk layout.
3565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3566enum BootstrapSource {
3567    /// Snapshot plus at least one journal segment (live or archived):
3568    /// restore the snapshot, replay the post-snapshot delta.
3569    SnapshotAndJournal,
3570    /// Snapshot with no journal segment at all: restore the snapshot and
3571    /// open a fresh segment continuing its sequence + chain.
3572    SnapshotOnly,
3573    /// Journal segments but no snapshot: full replay from genesis.
3574    JournalOnly,
3575    /// Nothing on disk: first-ever startup.
3576    Fresh,
3577}
3578
3579/// Pure bootstrap decision, extracted so the matrix is unit-testable.
3580///
3581/// The critical cell: `(snapshot, no live, archives)` must route through
3582/// snapshot+journal recovery — the archives may hold acked events past
3583/// the snapshot (post-rotation crash window), and only the recovery walk
3584/// replays them, verifies the lineage, and synthesizes the missing live
3585/// segment. Likewise `(no snapshot, no live, archives)` is journal
3586/// recovery, not a fresh start.
3587fn choose_bootstrap(
3588    snapshot_exists: bool,
3589    live_exists: bool,
3590    archives_exist: bool,
3591) -> BootstrapSource {
3592    let lineage_exists = live_exists || archives_exist;
3593    match (snapshot_exists, lineage_exists) {
3594        (true, true) => BootstrapSource::SnapshotAndJournal,
3595        (true, false) => BootstrapSource::SnapshotOnly,
3596        (false, true) => BootstrapSource::JournalOnly,
3597        (false, false) => BootstrapSource::Fresh,
3598    }
3599}
3600
3601/// Whether a recovered journal still needs its genesis, decided by
3602/// [`genesis_target`].
3603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3604enum GenesisTarget {
3605    /// The history has begun: genesis is in it, or the history holds no
3606    /// genesis to add. Journal nothing.
3607    None,
3608    /// The journal holds no entry: replace it with one that starts with
3609    /// the genesis.
3610    EmptyJournal,
3611}
3612
3613/// Decide whether a journal recovered without a snapshot still needs
3614/// its genesis.
3615///
3616/// A journal is created with its genesis in it, so any entry at all
3617/// means the history has begun — a node that recovers a history never
3618/// journals genesis into it, whatever its configured genesis says (one
3619/// holding only part of it is refused by `init_engine` instead). The
3620/// one exception is a history with no entry whose header records no
3621/// genesis length (`recorded` is `None`): a live segment starting at
3622/// sequence 1 with nothing after its header, no archive, written by an
3623/// earlier release that created the journal before its genesis — or a
3624/// replica's copy of one. Nothing was ever journaled, served or
3625/// replicated from it, so beginning the history now is safe, and is
3626/// what the first boot that created it would have done.
3627///
3628/// An empty journal whose header records a length is never rewritten:
3629/// the length is lineage metadata fixed when the history began. `Some(0)`
3630/// is a history begun with no genesis, which a genesis configured since
3631/// changes nothing for; `Some(n)` with `n > 0` is a replica's copy that
3632/// received none of its primary's genesis, which `init_engine` refuses as
3633/// the partial copy it is rather than fork the lineage with this node's
3634/// own genesis.
3635///
3636/// A genesis with nothing to journal (empty, or only queries, which are
3637/// never journaled) leaves the journal as it is: rewriting it would
3638/// change nothing but its file.
3639///
3640/// `next_sequence` is the recovered writer's; it is 1 exactly when the
3641/// live segment starts at sequence 1 and holds no entry. `recorded` is
3642/// the genesis length its header records.
3643fn genesis_target<E: melin_app::AppEvent>(
3644    archives_exist: bool,
3645    next_sequence: u64,
3646    recorded: Option<u64>,
3647    genesis: &[E],
3648) -> GenesisTarget {
3649    if !archives_exist
3650        && next_sequence == 1
3651        && recorded.is_none()
3652        && genesis.iter().any(|e| !e.is_query())
3653    {
3654        GenesisTarget::EmptyJournal
3655    } else {
3656        GenesisTarget::None
3657    }
3658}
3659
3660/// What [`init_engine`] hands the boot path.
3661pub(crate) struct InitializedEngine<A, W> {
3662    pub(crate) app: A,
3663    pub(crate) writer: W,
3664    /// The lineage recovered from the snapshot + journal (the genesis
3665    /// lineage for a new node): the caller seeds the node's `FenceState`
3666    /// with its epoch, and its shadow snapshots with the whole of it.
3667    pub(crate) recovered_lineage: melin_transport_core::lineage::Lineage,
3668    /// This boot began the history: it created the journal (with its
3669    /// genesis), or journaled the genesis into a journal that held no
3670    /// entry. A primary uses it to hold client service until its first
3671    /// replica attaches — the bring-up of a new cluster.
3672    pub(crate) began_history: bool,
3673}
3674
3675/// Initialize or recover the journaled application from disk.
3676///
3677/// Returns the application, its journal writer, the recovered fencing
3678/// epoch and whether this boot began the history ([`InitializedEngine`]).
3679/// The recovery paths (snapshot+journal, snapshot only, journal only,
3680/// fresh) are transport-level concerns and work uniformly for any
3681/// `A: Application` via `JournaledApp<A>`. Same engine initialization the
3682/// TCP / DPDK paths use.
3683///
3684/// `genesis` is journaled here, and only when this node starts a new
3685/// history: nothing on disk, or a journal holding no entry at all whose
3686/// header records no genesis length (see [`genesis_target`]). The
3687/// journal is created with the genesis already in it
3688/// ([`write_genesis_journal`]), so a journal at the configured path
3689/// always begins with the complete genesis, and every other layout —
3690/// any surviving segment, or a snapshot, whose state already includes
3691/// genesis — journals nothing. Genesis therefore reaches the returned
3692/// state through replay, like the rest of the history on every later
3693/// boot. The journal's header records the genesis length, and a
3694/// recovered history shorter than the length its header records is
3695/// refused ([`check_recovered_history_holds_genesis`]): it holds only
3696/// part of the genesis.
3697///
3698/// `sizing` is applied to a genesis instance before a journal is replayed
3699/// into it, so the history lands in reserved collections; the caller
3700/// sizes the result again afterwards, which covers the snapshot paths.
3701///
3702/// [`write_genesis_journal`]: melin_transport_core::journaled_app::write_genesis_journal
3703pub(crate) fn init_engine<A, W>(
3704    config: &ServerConfig,
3705    // The node's journal and its one snapshot location — the file the
3706    // shadow stage writes, so the one a restart recovers from.
3707    store: &NodeStore,
3708    sizing: &A::Sizing,
3709    genesis: Vec<A::Event>,
3710) -> Result<InitializedEngine<A, W>, Box<dyn std::error::Error>>
3711where
3712    A: Application,
3713    W: JournalWrite<A::Event>,
3714{
3715    use melin_transport_core::journaled_app::write_genesis_journal;
3716
3717    let journal = store.journal();
3718    // Replay the journal at the configured path into a fresh instance,
3719    // sized before the history is applied to it, as a replica is before
3720    // it applies the stream: replay must not grow the collections the
3721    // sizing would have reserved.
3722    let recover_journal = || -> Result<JournaledApp<A, W>, Box<dyn std::error::Error>> {
3723        let mut app = A::default();
3724        <A as Application>::prefault(&mut app, sizing);
3725        Ok(JournaledApp::<A, W>::recover(app, journal)?)
3726    };
3727
3728    let snap_path = store.snapshot();
3729
3730    // The journal *lineage* exists if either the live segment or any
3731    // archived segment is on disk. Deciding on the live file alone is
3732    // how acked events used to vanish: a crash between `archive_live`
3733    // and `create_continuing` leaves archives (holding events past the
3734    // snapshot) with no live file — bootstrapping "snapshot only" from
3735    // that layout would silently rewind every event in the archives.
3736    // `JournaledApp::recover*` handles the missing-live case itself
3737    // (replays archives, synthesizes a fresh live continuing the chain).
3738    let journal_exists = journal.try_exists()?;
3739    let archives_exist = !melin_journal::segment::list_archives(journal)?.is_empty();
3740    let snapshot_exists = snap_path.try_exists()?;
3741    // An interrupted first boot's staging file is never history: sweep
3742    // it on every boot, not only on one that journals a genesis, so it
3743    // does not linger next to a history begun some other way.
3744    melin_transport_core::journaled_app::discard_genesis_staging(journal)?;
3745    // Set by the two arms that begin the history.
3746    let mut began_history = false;
3747    let mut engine: JournaledApp<A, W> =
3748        match choose_bootstrap(snapshot_exists, journal_exists, archives_exist) {
3749            BootstrapSource::SnapshotAndJournal => {
3750                info!(snapshot = %snap_path.display(), "recovering from snapshot + journal");
3751                JournaledApp::<A, W>::recover_from_snapshot(snap_path, journal)?
3752            }
3753            BootstrapSource::SnapshotOnly => {
3754                // Snapshot exists but no journal segment survives at all —
3755                // recover from the snapshot alone and start a fresh segment
3756                // continuing its sequence and chain. Not a new history:
3757                // the snapshot's state already includes genesis, so the
3758                // segment starts empty (the standard upgrade, which
3759                // starts a new binary on a fresh journal next to a
3760                // snapshot, lands here).
3761                info!(
3762                    snapshot = %snap_path.display(),
3763                    "recovering from snapshot only (no journal segments on disk)"
3764                );
3765                // The lineage's genesis length comes from the snapshot,
3766                // the only record of the lineage left on disk.
3767                let (app, snap) = melin_transport_core::snapshot::load::<A>(snap_path)?;
3768                let writer = W::create_continuing(
3769                    journal,
3770                    snap.sequence + 1,
3771                    snap.chain_hash,
3772                    snap.genesis_entries,
3773                )?;
3774                JournaledApp::<A, W>::from_parts(app, writer, snap.lineage)
3775            }
3776            BootstrapSource::JournalOnly => {
3777                info!("recovering from journal");
3778                let engine = recover_journal()?;
3779                match genesis_target(
3780                    archives_exist,
3781                    engine.next_sequence(),
3782                    engine.genesis_entries()?,
3783                    &genesis,
3784                ) {
3785                    GenesisTarget::None => engine,
3786                    GenesisTarget::EmptyJournal => {
3787                        // An empty history with no recorded genesis
3788                        // length is a first boot that stopped before
3789                        // journaling anything under an earlier release,
3790                        // which created the journal before its genesis,
3791                        // or a replica's copy of one, restarted as a
3792                        // primary. Nothing was served or acknowledged
3793                        // from it either way, so starting the history now
3794                        // is safe. The anchor is kept, so a replica that
3795                        // copied the empty journal still chains to this
3796                        // one.
3797                        drop(engine);
3798                        let anchor = melin_journal::segment::read_header_info(journal)?.anchor_hash;
3799                        warn!(
3800                            journal = %journal.display(),
3801                            "the journal holds no entry and records no genesis \
3802                             length: journaling genesis (an earlier release's \
3803                             first boot stopped before journaling it)"
3804                        );
3805                        write_genesis_journal::<A::Event, W>(journal, Some(anchor), genesis)?;
3806                        began_history = true;
3807                        recover_journal()?
3808                    }
3809                }
3810            }
3811            BootstrapSource::Fresh => {
3812                info!("creating new journal");
3813                write_genesis_journal::<A::Event, W>(journal, None, genesis)?;
3814                began_history = true;
3815                recover_journal()?
3816            }
3817        };
3818
3819    // Every arm now holds the whole genesis, or a layout a primary never
3820    // writes (a replica's partial copy of it): refuse the latter rather
3821    // than serve it, against the genesis length the lineage records —
3822    // not `genesis`, this node's configuration. Checked before rotation,
3823    // so a refused boot rotates nothing and no recorded entry is
3824    // modified. Not quite side-effect free: a snapshot-only arm has
3825    // already left an empty live segment continuing the snapshot, which
3826    // the next boot recovers as snapshot-and-journal and refuses the
3827    // same way.
3828    check_recovered_history_holds_genesis(engine.next_sequence(), engine.genesis_entries()?)?;
3829
3830    // Archive the live journal segment if it exceeds the configured
3831    // size threshold. The shadow stage owns snapshot writes; here we
3832    // only rotate the segment so disk usage stays bounded across
3833    // restarts. Recovery walks the archive chain forward from the
3834    // latest shadow snapshot.
3835    if config.max_journal_mib > 0 {
3836        let threshold = config.max_journal_mib * 1024 * 1024;
3837        let current_size = engine.journal_size();
3838        if current_size > threshold {
3839            info!(
3840                current_mib = current_size / (1024 * 1024),
3841                threshold_mib = config.max_journal_mib,
3842                "journal exceeds threshold, rotating segment"
3843            );
3844            engine.rotate_segment()?;
3845            info!("journal segment rotated successfully");
3846        }
3847    }
3848
3849    let recovered_lineage = engine.recovered_lineage();
3850    let (app, writer) = engine.into_parts();
3851    Ok(InitializedEngine {
3852        app,
3853        writer,
3854        recovered_lineage,
3855        began_history,
3856    })
3857}
3858
3859// ---------------------------------------------------------------------------
3860// Shared helpers — used identically by both TCP and DPDK boot paths.
3861// ---------------------------------------------------------------------------
3862
3863fn build_replication_metrics(
3864    has_replication: bool,
3865) -> Option<Arc<crate::replication::ReplicationMetrics>> {
3866    has_replication.then(|| Arc::new(crate::replication::ReplicationMetrics::default()))
3867}
3868
3869/// The response gate's policy on a new primary pipeline: it starts under
3870/// the lineage's policy, or `requested` (the configured policy, or one an
3871/// operator staged) while the lineage holds none or replication is
3872/// disabled — see [`initial_gate_policy`](crate::durability_gate::initial_gate_policy).
3873/// Seeds the health endpoint's "journaled" gauge with the lineage's
3874/// policy, and wires the matching stage's forced changes to the gate.
3875///
3876/// `replication` is the halt gate's (`HaltGate::replication`), the one
3877/// the producer's entries are decided on, so the gate's start and those
3878/// entries agree on whether this node is standalone.
3879fn primary_gate_policy<A: Application>(
3880    lineage_ack_policy: Option<LineagePolicy>,
3881    requested: crate::ack_policy::AckPolicy,
3882    replication: crate::halt::Replication,
3883    matching_stage: &melin_transport_core::pipeline::MatchingStage<A>,
3884) -> Result<crate::durability_gate::GatePolicy, String> {
3885    let start =
3886        crate::durability_gate::initial_gate_policy(lineage_ack_policy, requested, replication)?;
3887    matching_stage
3888        .utilization()
3889        .ack_policy_journaled
3890        .store(lineage_ack_policy.map(|p| p.policy()));
3891    info!(
3892        ack_policy = %start.in_force,
3893        clauses = %start.in_force.to_policy(),
3894        pending = ?start.pending.map(|p| p.policy.as_str()),
3895        requested = %requested,
3896        from_lineage = lineage_ack_policy.is_some(),
3897        "ack policy active"
3898    );
3899    Ok(crate::durability_gate::GatePolicy {
3900        start,
3901        forced: matching_stage.forced_policy(),
3902    })
3903}
3904
3905#[allow(clippy::too_many_arguments)] // the stage's inputs, as `shadow::run`
3906fn spawn_shadow_stage<A: Application + Send + 'static>(
3907    shadow_consumer: Option<Consumer<InputSlot<A::Event>>>,
3908    shadow_app: Option<A>,
3909    chain_hash_lock: Option<
3910        melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3911    >,
3912    config: &ServerConfig,
3913    // Where the stage writes: the node's one snapshot location.
3914    store: &NodeStore,
3915    cores: &PipelineCores,
3916    shutdown: &Arc<AtomicBool>,
3917    initial_lineage: Lineage,
3918    // The wire sequence of the first entry the shadow will consume (see
3919    // `shadow::run`).
3920    starting_wire_seq: u64,
3921    // The lineage's genesis length, from the journal header — stamped
3922    // into every snapshot (see `shadow::run`).
3923    genesis_entries: Option<u64>,
3924) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
3925where
3926    A::Event: Send + Sync + 'static,
3927{
3928    let Some(shadow_cons) = shadow_consumer else {
3929        return Ok(None);
3930    };
3931    let snap_path = store.snapshot().to_path_buf();
3932    let interval = std::time::Duration::from_millis(config.snapshot_interval_ms);
3933    let chain_hash =
3934        chain_hash_lock.ok_or("chain hash lock must be Some when shadow is enabled")?;
3935    let shadow_ex = shadow_app.ok_or("shadow application must be Some when shadow is enabled")?;
3936    let s_shadow = Arc::clone(shutdown);
3937    let shadow = cores.shadow;
3938    let handle = melin_app::affinity::spawn_placed("shadow", shadow.core, move || {
3939        melin_transport_core::shadow::run(
3940            shadow_cons,
3941            shadow_ex,
3942            snap_path,
3943            interval,
3944            chain_hash,
3945            &s_shadow,
3946            shadow.wait,
3947            initial_lineage,
3948            starting_wire_seq,
3949            genesis_entries,
3950        );
3951    })
3952    .map_err(|e| format!("spawn shadow thread: {e}"))?;
3953
3954    info!(
3955        interval_ms = config.snapshot_interval_ms,
3956        path = %store.snapshot().display(),
3957        "shadow snapshot stage started"
3958    );
3959    Ok(Some(handle))
3960}
3961
3962#[allow(clippy::too_many_arguments)]
3963fn spawn_health_endpoint(
3964    config: &ServerConfig,
3965    active_connections: &Arc<AtomicU64>,
3966    events_processed: &Arc<AtomicU64>,
3967    refused_writes: &Arc<AtomicU64>,
3968    ingress_metrics: &Arc<melin_transport_core::health::IngressMetrics>,
3969    cursors: &melin_transport_core::PipelineCursors,
3970    input_cursor: Box<dyn melin_pipeline::ring::QueueCursor>,
3971    pipeline_healthy: &Arc<AtomicBool>,
3972    replicas_connected: &Option<Arc<ReplicaPresence>>,
3973    fence_state: &Arc<melin_transport_core::fence::FenceState>,
3974    replication_metrics: &Option<Arc<crate::replication::ReplicationMetrics>>,
3975    replica_active: &Option<[Arc<AtomicBool>; 2]>,
3976    replication_ring_progress: &Option<melin_transport_core::pipeline::ReplicationRingProgress>,
3977    journal_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3978    matching_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3979    response_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3980    shutdown: &Arc<AtomicBool>,
3981    raft_status: &Option<Arc<melin_transport_core::health::RaftStatus>>,
3982) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>> {
3983    let Some(health_addr) = config.health_bind else {
3984        return Ok(None);
3985    };
3986
3987    let (repl_ring_producers, repl_ring_consumers) = replication_ring_progress
3988        .as_ref()
3989        .map(|rp| {
3990            (
3991                Some([
3992                    Arc::clone(&rp.producer_cursors[0]),
3993                    Arc::clone(&rp.producer_cursors[1]),
3994                ]),
3995                Some([
3996                    Arc::clone(&rp.consumer_cursors[0]),
3997                    Arc::clone(&rp.consumer_cursors[1]),
3998                ]),
3999            )
4000        })
4001        .unwrap_or((None, None));
4002    Ok(Some(melin_transport_core::health::spawn(
4003        health_addr,
4004        melin_transport_core::health::HealthState {
4005            active_connections: Arc::clone(active_connections),
4006            events_processed: Arc::clone(events_processed),
4007            refused_writes: Arc::clone(refused_writes),
4008            ingress: Arc::clone(ingress_metrics),
4009            cursors: cursors.clone(),
4010            input_cursor,
4011            pipeline_healthy: Arc::clone(pipeline_healthy),
4012            replicas_connected: replicas_connected.clone(),
4013            fence_state: Some(Arc::clone(fence_state)),
4014            replication_metrics: replication_metrics.clone(),
4015            replica_active: replica_active.clone(),
4016            replication_ring_producer_cursors: repl_ring_producers,
4017            replication_ring_consumer_cursors: repl_ring_consumers,
4018            journal_utilization: Arc::clone(journal_utilization),
4019            matching_utilization: Arc::clone(matching_utilization),
4020            response_utilization: Arc::clone(response_utilization),
4021            raft: raft_status.clone(),
4022        },
4023        Arc::clone(shutdown),
4024    )?))
4025}
4026
4027/// Spawn the event-publisher thread on the affinity core configured by
4028/// `cores.event_publisher`, delegating the loop body to the
4029/// caller-supplied [`EventPublisherFn`]. Returns `Ok(None)` when either
4030/// the consumer slot wasn't wired (the pipeline build saw
4031/// `enable_event_publisher == false`) or the binary passed no
4032/// publisher fn. The runtime owns thread lifecycle and CPU pinning so
4033/// the caller's fn stays domain-only.
4034fn spawn_event_publisher<A: Application>(
4035    consumer: Option<Consumer<OutputSlot<A>>>,
4036    run_fn: Option<EventPublisherFn<A>>,
4037    config: &ServerConfig,
4038    cores: &PipelineCores,
4039    authorized_keys: &Arc<AuthorizedKeys>,
4040    shutdown: &Arc<AtomicBool>,
4041) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
4042where
4043    A::Report: Send + 'static,
4044    A::QueryResponse: Send + 'static,
4045{
4046    let (Some(event_consumer), Some(run_fn)) = (consumer, run_fn) else {
4047        return Ok(None);
4048    };
4049    let event_bind = config
4050        .event_bind
4051        .ok_or("event_bind must be set when event publisher is enabled")?;
4052    let s_event = Arc::clone(shutdown);
4053    let event_keys = Arc::clone(authorized_keys);
4054    let publisher = cores.event_publisher;
4055    let event_handle =
4056        melin_app::affinity::spawn_placed("event-publisher", publisher.core, move || {
4057            run_fn(
4058                event_consumer,
4059                event_bind,
4060                event_keys,
4061                &s_event,
4062                publisher.wait,
4063            );
4064        })
4065        .map_err(|e| format!("spawn event publisher thread: {e}"))?;
4066    info!(addr = %event_bind, "event publisher started");
4067    Ok(Some(event_handle))
4068}
4069
4070/// Perform challenge-response authentication on a new connection.
4071///
4072/// Runs on the accept thread (cold path, blocking). The caller must set
4073/// a read timeout on the stream before calling to prevent slow clients
4074/// from stalling the accept loop.
4075///
4076/// Uses raw `read_exact` instead of `BufReader` to avoid over-reading
4077/// bytes that belong to the first post-auth request.
4078///
4079/// Returns `(role, public_key_bytes)` on success.
4080fn authenticate_connection<R: std::io::Read, W: std::io::Write>(
4081    connection_id: ConnectionId,
4082    addr: SocketAddr,
4083    reader: &mut R,
4084    writer: &mut W,
4085    authorized_keys: &AuthorizedKeys,
4086) -> Result<(ClientRole<RoleId>, [u8; 32]), Box<dyn std::error::Error>> {
4087    use std::io;
4088
4089    use melin_wire_protocol::control::TransportResponse;
4090    use melin_wire_protocol::control_codec;
4091
4092    // Generate a 32-byte random nonce for this connection.
4093    // Explicit OsRng for cryptographic material (SEC-10).
4094    let mut nonce = [0u8; 32];
4095    getrandom::fill(&mut nonce).map_err(|e| io::Error::other(format!("getrandom failed: {e}")))?;
4096
4097    let mut buf = [0u8; 128];
4098    let written =
4099        control_codec::encode_transport_response(&TransportResponse::Challenge { nonce }, &mut buf)
4100            .map_err(|e| io::Error::other(format!("encode Challenge: {e}")))?;
4101    writer.write_all(&buf[..written])?;
4102    writer.flush()?;
4103
4104    // Read ChallengeResponse frame directly (no BufReader). Using raw
4105    // read_exact avoids BufReader over-reading bytes that belong to the
4106    // first post-auth request — those bytes would be lost when the
4107    // BufReader is dropped and the fd moves to the io_uring reader.
4108    let mut len_buf = [0u8; 4];
4109    reader
4110        .read_exact(&mut len_buf)
4111        .map_err(|e| io::Error::other(format!("read auth frame length: {e}")))?;
4112    let frame_len = u32::from_le_bytes(len_buf) as usize;
4113    if frame_len > MAX_AUTH_FRAME {
4114        send_auth_failed(writer);
4115        return Err(io::Error::other(format!("auth frame too large: {frame_len}")).into());
4116    }
4117    let mut frame_buf = [0u8; MAX_AUTH_FRAME];
4118    reader
4119        .read_exact(&mut frame_buf[..frame_len])
4120        .map_err(|e| io::Error::other(format!("read auth frame payload: {e}")))?;
4121
4122    let cr = match control_codec::decode_challenge_response(&frame_buf[..frame_len]) {
4123        Ok(cr) => cr,
4124        Err(e) => {
4125            send_auth_failed(writer);
4126            return Err(io::Error::other(format!("decode ChallengeResponse: {e}")).into());
4127        }
4128    };
4129
4130    let public_key_bytes = cr.public_key;
4131    let role = crate::client_auth::verify_client(
4132        authorized_keys,
4133        &nonce,
4134        &public_key_bytes,
4135        &cr.signature,
4136    )
4137    .inspect_err(|_| send_auth_failed(writer))?;
4138
4139    // Auth succeeded — send ServerReady.
4140    let written =
4141        control_codec::encode_transport_response(&TransportResponse::ServerReady, &mut buf)
4142            .map_err(|e| io::Error::other(format!("encode ServerReady: {e}")))?;
4143    writer.write_all(&buf[..written])?;
4144    writer.flush()?;
4145
4146    debug!(
4147        connection_id = connection_id.0,
4148        addr = %addr,
4149        role = authorized_keys.token(KeyRole::Client(role)),
4150        "authenticated"
4151    );
4152
4153    Ok((role, public_key_bytes))
4154}
4155
4156/// Set a read timeout on a raw fd via `setsockopt(SO_RCVTIMEO)`.
4157///
4158/// Works for both TCP and UDS since both are sockets. Uses `AsRawFd`
4159/// to avoid requiring a concrete stream type.
4160fn set_read_timeout<F: std::os::unix::io::AsRawFd>(
4161    fd: &F,
4162    timeout: Option<std::time::Duration>,
4163) -> std::io::Result<()> {
4164    let tv = match timeout {
4165        Some(d) => libc::timeval {
4166            tv_sec: d.as_secs() as libc::time_t,
4167            tv_usec: d.subsec_micros() as libc::suseconds_t,
4168        },
4169        None => libc::timeval {
4170            tv_sec: 0,
4171            tv_usec: 0,
4172        },
4173    };
4174    let ret = unsafe {
4175        libc::setsockopt(
4176            fd.as_raw_fd(),
4177            libc::SOL_SOCKET,
4178            libc::SO_RCVTIMEO,
4179            &tv as *const libc::timeval as *const libc::c_void,
4180            std::mem::size_of::<libc::timeval>() as libc::socklen_t,
4181        )
4182    };
4183    if ret < 0 {
4184        return Err(std::io::Error::last_os_error());
4185    }
4186    Ok(())
4187}
4188
4189/// Enable `SO_BUSY_POLL` on a TCP data socket so the kernel busy-polls
4190/// the NIC for incoming data instead of going to sleep on the softirq
4191/// → wakeup handoff. Removes scheduler-wakeup latency from the recv
4192/// path on hardware where IRQ delivery is the dominant per-packet cost
4193/// (e.g. ixgbe-class NICs). 50 µs covers a typical LAN ack RTT.
4194///
4195/// Best-effort: requires `CAP_NET_ADMIN` (or unprivileged operation
4196/// permitted via sysctl). Failures are surfaced as warnings by the
4197/// caller — they only cost latency, not correctness, and we don't want
4198/// a misconfigured kernel to halt connection acceptance.
4199///
4200/// Only beneficial when the receiving thread is already busy-spinning
4201/// (otherwise the spin cost is wasted on idle connections). All
4202/// callers in Melin meet that condition: client reader threads spin on
4203/// io_uring CQEs, the replication sender's ack-recv thread spins, and
4204/// the bench client thread spins.
4205pub(crate) fn set_busy_poll<F: std::os::unix::io::AsRawFd>(
4206    fd: &F,
4207    micros: i32,
4208) -> std::io::Result<()> {
4209    // SAFETY: fd is a live socket fd owned by the caller for the
4210    // duration of the call; the option pointer is to a stack-local i32
4211    // with the right size for SO_BUSY_POLL.
4212    let val: libc::c_int = micros;
4213    let ret = unsafe {
4214        libc::setsockopt(
4215            fd.as_raw_fd(),
4216            libc::SOL_SOCKET,
4217            libc::SO_BUSY_POLL,
4218            &val as *const libc::c_int as *const libc::c_void,
4219            std::mem::size_of::<libc::c_int>() as libc::socklen_t,
4220        )
4221    };
4222    if ret < 0 {
4223        return Err(std::io::Error::last_os_error());
4224    }
4225    Ok(())
4226}
4227
4228/// Default `SO_BUSY_POLL` window in microseconds. Matches the value
4229/// already used on the replica receive socket; chosen to cover a
4230/// typical LAN round-trip without burning excessive CPU on quiet
4231/// connections.
4232pub(crate) const BUSY_POLL_US: i32 = 50;
4233
4234/// Set `SO_SNDTIMEO` on a socket. Prevents blocking writes from stalling
4235/// the response thread when a client stops reading (SEC-01).
4236fn set_write_timeout<F: std::os::unix::io::AsRawFd>(
4237    fd: &F,
4238    timeout: Option<std::time::Duration>,
4239) -> std::io::Result<()> {
4240    let tv = match timeout {
4241        Some(d) => libc::timeval {
4242            tv_sec: d.as_secs() as libc::time_t,
4243            tv_usec: d.subsec_micros() as libc::suseconds_t,
4244        },
4245        None => libc::timeval {
4246            tv_sec: 0,
4247            tv_usec: 0,
4248        },
4249    };
4250    let ret = unsafe {
4251        libc::setsockopt(
4252            fd.as_raw_fd(),
4253            libc::SOL_SOCKET,
4254            libc::SO_SNDTIMEO,
4255            &tv as *const libc::timeval as *const libc::c_void,
4256            std::mem::size_of::<libc::timeval>() as libc::socklen_t,
4257        )
4258    };
4259    if ret < 0 {
4260        return Err(std::io::Error::last_os_error());
4261    }
4262    Ok(())
4263}
4264
4265/// Best-effort send of AuthFailed before dropping a connection.
4266fn send_auth_failed(writer: &mut impl std::io::Write) {
4267    use melin_wire_protocol::control::TransportResponse;
4268    use melin_wire_protocol::control_codec;
4269
4270    let mut buf = [0u8; 8];
4271    if let Ok(written) =
4272        control_codec::encode_transport_response(&TransportResponse::AuthFailed, &mut buf)
4273    {
4274        let _ = writer.write_all(&buf[..written]);
4275        let _ = writer.flush();
4276    }
4277}
4278
4279#[cfg(test)]
4280mod tests {
4281    use std::io::{Read, Write};
4282    use std::os::unix::net::UnixStream;
4283
4284    use ed25519_dalek::{Signer, SigningKey};
4285    use melin_app::auth::{AuthorizedKeys, ClientRole, KeyRole, NoRoles};
4286    use melin_app::decoder::{Decoded, RequestDecoder};
4287    use melin_wire_protocol::control::ConnectionId;
4288    use melin_wire_protocol::control_codec::{
4289        TAG_AUTH_FAILED, TAG_CHALLENGE_RESPONSE, TAG_RESPONSE_HEARTBEAT, TAG_SERVER_READY,
4290    };
4291
4292    use super::authenticate_connection;
4293    use super::{BootstrapSource, choose_bootstrap};
4294
4295    /// A primary whose startup returns early drops its `PipelineHandles`
4296    /// with stage threads still in it, and the drop must stop and join
4297    /// them: raise the node's stop flag, lower its health, and return only
4298    /// once every thread has exited. Left to themselves they would spin
4299    /// on, which `run_with_shutdown` — several nodes in one process —
4300    /// would carry past the node's failure.
4301    #[test]
4302    fn dropped_pipeline_handles_stop_and_join_the_threads_started() {
4303        use std::sync::Arc;
4304        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4305
4306        let shutdown = Arc::new(AtomicBool::new(false));
4307        let healthy = Arc::new(AtomicBool::new(true));
4308        let exited = Arc::new(AtomicUsize::new(0));
4309        // A stage thread as far as stopping goes: it runs until the stop
4310        // flag rises.
4311        let stage = |value: u32| {
4312            let shutdown = Arc::clone(&shutdown);
4313            let exited = Arc::clone(&exited);
4314            move || {
4315                while !shutdown.load(Ordering::Acquire) {
4316                    std::thread::yield_now();
4317                }
4318                exited.fetch_add(1, Ordering::AcqRel);
4319                value
4320            }
4321        };
4322
4323        let mut stages = super::PipelineHandles::<u32, u32>::new(Arc::clone(&shutdown));
4324        let journal = stage(1);
4325        stages.journal = Some(std::thread::spawn(move || Ok(journal())));
4326        stages.matching = Some(std::thread::spawn(stage(2)));
4327        stages.pipeline_healthy = Some(Arc::clone(&healthy));
4328        drop(stages);
4329
4330        assert!(shutdown.load(Ordering::Acquire), "the stop flag is raised");
4331        assert!(!healthy.load(Ordering::Acquire), "and health lowered");
4332        assert_eq!(
4333            exited.load(Ordering::Acquire),
4334            2,
4335            "every thread started has exited by the time the drop returns"
4336        );
4337    }
4338
4339    /// The node's `--dpdk-*` flags are `melin-dpdk`'s (whose tests cover
4340    /// their mapping), with the node's own default address, 10.0.0.1:
4341    /// `NicConfig::default()`'s, and the one `ServerConfig::default()`
4342    /// has, so a node built in code and one started bare agree. The
4343    /// joined-form rule for `--dpdk-eal-args` holds on the node's command
4344    /// line, where `--no-mlock` is the flag a forgotten value would
4345    /// swallow.
4346    #[test]
4347    fn the_dpdk_flags_are_the_shared_ones_with_the_node_default_address() {
4348        use clap::Parser;
4349        use std::net::Ipv4Addr;
4350        let parsed = super::ServerConfig::try_parse_from(["melin-server"]).expect("parses");
4351        let address: Ipv4Addr = super::DEFAULT_DPDK_IP.parse().expect("an IPv4 address");
4352        assert_eq!(address, melin_dpdk::NicConfig::default().ip_addr);
4353        assert_eq!(parsed.dpdk.ip, Some(address));
4354        assert_eq!(parsed.dpdk, super::ServerConfig::default().dpdk);
4355        assert_eq!(
4356            super::DpdkArgs {
4357                ip: None,
4358                ..parsed.dpdk
4359            },
4360            super::DpdkArgs::default(),
4361            "only the address default is the node's own"
4362        );
4363
4364        let given = super::ServerConfig::try_parse_from(["melin-server", "--dpdk-ip", "10.0.0.7"])
4365            .expect("parses");
4366        assert_eq!(given.dpdk.ip, Some(Ipv4Addr::new(10, 0, 0, 7)));
4367
4368        let forgotten_value =
4369            super::ServerConfig::try_parse_from(["melin-server", "--dpdk-eal-args", "--no-mlock"]);
4370        assert!(
4371            forgotten_value.is_err(),
4372            "a forgotten value must error, not swallow the next flag"
4373        );
4374    }
4375
4376    /// The `--dpdk-client-*-kib` defaults are the transport's own sizes,
4377    /// whole KiB, on a bare command line and in `ServerConfig::default()`
4378    /// alike; a given value parses, and zero is refused.
4379    #[test]
4380    fn the_dpdk_client_buffer_flags_default_to_the_transport_sizes() {
4381        use clap::Parser;
4382        let sizes = melin_dpdk::SocketBuffers::DEFAULT;
4383        for (flag_kib, bytes) in [
4384            (super::kib_of(sizes.rx_buf_size), sizes.rx_buf_size),
4385            (super::kib_of(sizes.tx_buf_size), sizes.tx_buf_size),
4386            (super::kib_of(sizes.tx_queue_limit), sizes.tx_queue_limit),
4387        ] {
4388            assert_eq!(
4389                flag_kib as usize * 1024,
4390                bytes,
4391                "a default not in whole KiB"
4392            );
4393        }
4394
4395        let parsed = super::ServerConfig::try_parse_from(["melin-server"]).expect("parses");
4396        let built = super::ServerConfig::default();
4397        for config in [&parsed, &built] {
4398            assert_eq!(
4399                (
4400                    config.dpdk_client_rx_buf_kib,
4401                    config.dpdk_client_tx_buf_kib,
4402                    config.dpdk_client_tx_queue_kib,
4403                ),
4404                (
4405                    super::kib_of(sizes.rx_buf_size),
4406                    super::kib_of(sizes.tx_buf_size),
4407                    super::kib_of(sizes.tx_queue_limit),
4408                )
4409            );
4410        }
4411
4412        let given = super::ServerConfig::try_parse_from([
4413            "melin-server",
4414            "--dpdk-client-rx-buf-kib",
4415            "256",
4416            "--dpdk-client-tx-buf-kib",
4417            "512",
4418            "--dpdk-client-tx-queue-kib",
4419            "32768",
4420        ])
4421        .expect("parses");
4422        assert_eq!(given.dpdk_client_rx_buf_kib, 256);
4423        assert_eq!(given.dpdk_client_tx_buf_kib, 512);
4424        assert_eq!(given.dpdk_client_tx_queue_kib, 32768);
4425
4426        for flag in [
4427            "--dpdk-client-rx-buf-kib",
4428            "--dpdk-client-tx-buf-kib",
4429            "--dpdk-client-tx-queue-kib",
4430        ] {
4431            assert!(
4432                super::ServerConfig::try_parse_from(["melin-server", flag, "0"]).is_err(),
4433                "{flag} 0 must be refused"
4434            );
4435        }
4436    }
4437
4438    /// `--snapshot` is gone, refused as unknown rather than read as
4439    /// something else; `--snapshot-path` is the store's snapshot, and
4440    /// without it the store derives one from `--journal`.
4441    #[test]
4442    fn the_snapshot_path_flag_is_the_store_s_snapshot() {
4443        use clap::Parser;
4444        let dir = tempfile::tempdir().expect("tempdir");
4445        let journal = dir.path().join("node.journal");
4446        let snapshot = dir.path().join("elsewhere.snap");
4447        let journal_arg = journal.to_str().expect("utf-8 path");
4448        let snapshot_arg = snapshot.to_str().expect("utf-8 path");
4449
4450        let Err(removed) = super::ServerConfig::try_parse_from([
4451            "melin-server",
4452            "--journal",
4453            journal_arg,
4454            "--snapshot",
4455            snapshot_arg,
4456        ]) else {
4457            panic!("--snapshot is no longer a flag");
4458        };
4459        assert_eq!(removed.kind(), clap::error::ErrorKind::UnknownArgument);
4460
4461        let given = super::ServerConfig::try_parse_from([
4462            "melin-server",
4463            "--journal",
4464            journal_arg,
4465            "--snapshot-path",
4466            snapshot_arg,
4467        ])
4468        .expect("parses");
4469        let store = given.store().expect("store");
4470        assert_eq!(store.snapshot(), snapshot);
4471        assert_eq!(store.journal(), journal);
4472
4473        let derived =
4474            super::ServerConfig::try_parse_from(["melin-server", "--journal", journal_arg])
4475                .expect("parses")
4476                .store()
4477                .expect("store");
4478        assert_eq!(derived.snapshot(), dir.path().join("node.snapshot"));
4479
4480        // A refused layout surfaces through `store()`.
4481        let missing = super::ServerConfig {
4482            journal: journal.clone(),
4483            snapshot_path: Some(dir.path().join("missing").join("x.snapshot")),
4484            ..super::ServerConfig::default()
4485        };
4486        assert!(matches!(
4487            missing.store(),
4488            Err(super::NodeStoreError::SnapshotDirMissing { .. })
4489        ));
4490    }
4491
4492    /// Full bootstrap decision matrix. The two archive-only cells are
4493    /// the regression guard: a post-rotation crash leaves archives with
4494    /// no live segment, and bootstrapping "snapshot only" (or "fresh")
4495    /// from that layout silently rewinds every acked event held in the
4496    /// archives. Recovery must own any layout where a lineage survives.
4497    #[test]
4498    fn bootstrap_decision_routes_archives_through_recovery() {
4499        use BootstrapSource::*;
4500        // (snapshot, live, archives) → source
4501        let matrix = [
4502            ((false, false, false), Fresh),
4503            ((false, false, true), JournalOnly), // post-rotation crash, no snapshot
4504            ((false, true, false), JournalOnly),
4505            ((false, true, true), JournalOnly),
4506            ((true, false, false), SnapshotOnly),
4507            ((true, false, true), SnapshotAndJournal), // post-rotation crash
4508            ((true, true, false), SnapshotAndJournal),
4509            ((true, true, true), SnapshotAndJournal),
4510        ];
4511        for ((snap, live, arch), expected) in matrix {
4512            assert_eq!(
4513                choose_bootstrap(snap, live, arch),
4514                expected,
4515                "snapshot={snap} live={live} archives={arch}"
4516            );
4517        }
4518    }
4519
4520    /// Deterministic test key.
4521    fn test_key() -> SigningKey {
4522        SigningKey::from_bytes(&[0xAA; 32])
4523    }
4524
4525    /// Build an `AuthorizedKeys` listing the test key under `role`.
4526    fn keys_with_test_key(role: &str) -> AuthorizedKeys {
4527        // Encode the public key bytes as base64 with the local helper so
4528        // the test has no external codec dependency (all test keys produce
4529        // valid base64), then feed it through AuthorizedKeys::parse.
4530        let pub_bytes = test_key().verifying_key().to_bytes();
4531        let pub_b64 = base64_encode(&pub_bytes);
4532        crate::test_roles::desk_keys(role, &pub_b64)
4533    }
4534
4535    /// Minimal base64 encoder for test use only. Avoids adding base64
4536    /// as a dev-dependency to the server crate.
4537    fn base64_encode(bytes: &[u8]) -> String {
4538        const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4539        let mut out = String::new();
4540        for chunk in bytes.chunks(3) {
4541            let b0 = chunk[0] as u32;
4542            let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
4543            let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
4544            let n = (b0 << 16) | (b1 << 8) | b2;
4545            out.push(ALPHABET[(n >> 18 & 0x3F) as usize] as char);
4546            out.push(ALPHABET[(n >> 12 & 0x3F) as usize] as char);
4547            if chunk.len() > 1 {
4548                out.push(ALPHABET[(n >> 6 & 0x3F) as usize] as char);
4549            } else {
4550                out.push('=');
4551            }
4552            if chunk.len() > 2 {
4553                out.push(ALPHABET[(n & 0x3F) as usize] as char);
4554            } else {
4555                out.push('=');
4556            }
4557        }
4558        out
4559    }
4560
4561    /// Run `authenticate_connection` on one end of a `UnixStream::pair()`,
4562    /// returning the admitted role's token in the keys file. Maps the
4563    /// error to `String` so it's `Send`.
4564    fn run_server_auth(
4565        mut stream: UnixStream,
4566        keys: AuthorizedKeys,
4567    ) -> std::thread::JoinHandle<Result<&'static str, String>> {
4568        std::thread::spawn(move || {
4569            // Clone the stream so we have independent read/write halves.
4570            let mut writer = stream.try_clone().unwrap();
4571            authenticate_connection(
4572                ConnectionId(1),
4573                "127.0.0.1:0".parse().unwrap(),
4574                &mut stream,
4575                &mut writer,
4576                &keys,
4577            )
4578            .map(|(role, _pk)| keys.token(KeyRole::Client(role)))
4579            .map_err(|e| e.to_string())
4580        })
4581    }
4582
4583    /// Read a length-prefixed Challenge frame and return its 32-byte
4584    /// nonce. Frame layout: `[len:u32][TAG_CHALLENGE][version:u16][nonce:32]`
4585    /// (see `decode_challenge`).
4586    fn read_challenge(stream: &mut UnixStream) -> [u8; 32] {
4587        let mut len_buf = [0u8; 4];
4588        stream.read_exact(&mut len_buf).unwrap();
4589        let len = u32::from_le_bytes(len_buf) as usize;
4590        let mut payload = vec![0u8; len];
4591        stream.read_exact(&mut payload).unwrap();
4592        // The wire protocol's one parser of the challenge, which also
4593        // checks the node speaks this build's client protocol.
4594        melin_wire_protocol::control_codec::decode_challenge(&payload).expect("a Challenge")
4595    }
4596
4597    /// Write a length-prefixed ChallengeResponse frame, matching the
4598    /// layout the runtime's `control_codec::decode_challenge_response`
4599    /// expects: `[len:u32][TAG_CHALLENGE_RESPONSE][sig:64][pubkey:32]`.
4600    fn write_challenge_response(
4601        stream: &mut UnixStream,
4602        signature: [u8; 64],
4603        public_key: [u8; 32],
4604    ) {
4605        let mut frame = Vec::with_capacity(97);
4606        frame.push(TAG_CHALLENGE_RESPONSE);
4607        frame.extend_from_slice(&signature);
4608        frame.extend_from_slice(&public_key);
4609        stream
4610            .write_all(&(frame.len() as u32).to_le_bytes())
4611            .unwrap();
4612        stream.write_all(&frame).unwrap();
4613        stream.flush().unwrap();
4614    }
4615
4616    /// Read a Challenge frame from the client end, sign the nonce, and
4617    /// write a valid ChallengeResponse back.
4618    fn client_sign_challenge(stream: &mut UnixStream, key: &SigningKey) {
4619        let nonce = read_challenge(stream);
4620        let sig = key.sign(&nonce);
4621        write_challenge_response(stream, sig.to_bytes(), key.verifying_key().to_bytes());
4622    }
4623
4624    /// Like `client_sign_challenge` but corrupts the signature.
4625    fn client_sign_challenge_bad(stream: &mut UnixStream, key: &SigningKey) {
4626        let nonce = read_challenge(stream);
4627        let mut sig_bytes = key.sign(&nonce).to_bytes();
4628        sig_bytes[0] ^= 0xFF;
4629        write_challenge_response(stream, sig_bytes, key.verifying_key().to_bytes());
4630    }
4631
4632    /// Read one length-prefixed control frame and return its tag byte.
4633    fn read_response_tag(stream: &mut UnixStream) -> u8 {
4634        let mut len_buf = [0u8; 4];
4635        stream.read_exact(&mut len_buf).unwrap();
4636        let len = u32::from_le_bytes(len_buf) as usize;
4637        let mut buf = vec![0u8; len];
4638        stream.read_exact(&mut buf).unwrap();
4639        buf[0]
4640    }
4641
4642    #[test]
4643    fn auth_success_returns_the_application_role() {
4644        let keys = keys_with_test_key("trader");
4645        let key = test_key();
4646        let (s1, mut s2) = UnixStream::pair().unwrap();
4647
4648        let handle = run_server_auth(s1, keys);
4649
4650        client_sign_challenge(&mut s2, &key);
4651        let resp = read_response_tag(&mut s2);
4652        assert_eq!(resp, TAG_SERVER_READY);
4653
4654        let result = handle.join().unwrap();
4655        assert_eq!(result.unwrap(), "trader");
4656    }
4657
4658    #[test]
4659    fn auth_returns_the_operator_role() {
4660        let keys = keys_with_test_key("operator");
4661        let key = test_key();
4662        let (s1, mut s2) = UnixStream::pair().unwrap();
4663
4664        let handle = run_server_auth(s1, keys);
4665
4666        client_sign_challenge(&mut s2, &key);
4667        let resp = read_response_tag(&mut s2);
4668        assert_eq!(resp, TAG_SERVER_READY);
4669
4670        assert_eq!(handle.join().unwrap().unwrap(), "operator");
4671    }
4672
4673    #[test]
4674    fn auth_unknown_key_sends_auth_failed() {
4675        let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
4676        let key = test_key();
4677        let (s1, mut s2) = UnixStream::pair().unwrap();
4678
4679        let handle = run_server_auth(s1, keys);
4680
4681        client_sign_challenge(&mut s2, &key);
4682        let resp = read_response_tag(&mut s2);
4683        assert_eq!(resp, TAG_AUTH_FAILED);
4684
4685        assert!(handle.join().unwrap().is_err());
4686    }
4687
4688    #[test]
4689    fn auth_bad_signature_sends_auth_failed() {
4690        let keys = keys_with_test_key("operator");
4691        let key = test_key();
4692        let (s1, mut s2) = UnixStream::pair().unwrap();
4693
4694        let handle = run_server_auth(s1, keys);
4695
4696        client_sign_challenge_bad(&mut s2, &key);
4697        let resp = read_response_tag(&mut s2);
4698        assert_eq!(resp, TAG_AUTH_FAILED);
4699
4700        assert!(handle.join().unwrap().is_err());
4701    }
4702
4703    #[test]
4704    fn auth_wrong_message_type_sends_auth_failed() {
4705        let keys = keys_with_test_key("trader");
4706        let (s1, mut s2) = UnixStream::pair().unwrap();
4707
4708        let handle = run_server_auth(s1, keys);
4709
4710        // Read and discard the Challenge.
4711        read_challenge(&mut s2);
4712
4713        // Send a frame carrying a transport-heartbeat tag where a
4714        // ChallengeResponse is expected. It is the right length (97) so
4715        // the auth decoder reaches the tag check and rejects the
4716        // unexpected tag, replying AuthFailed.
4717        let mut frame = vec![0u8; 97];
4718        frame[0] = TAG_RESPONSE_HEARTBEAT;
4719        s2.write_all(&(frame.len() as u32).to_le_bytes()).unwrap();
4720        s2.write_all(&frame).unwrap();
4721        s2.flush().unwrap();
4722
4723        let resp = read_response_tag(&mut s2);
4724        assert_eq!(resp, TAG_AUTH_FAILED);
4725
4726        assert!(handle.join().unwrap().is_err());
4727    }
4728
4729    #[test]
4730    fn auth_client_disconnects_is_error() {
4731        let keys = keys_with_test_key("trader");
4732        let (s1, s2) = UnixStream::pair().unwrap();
4733
4734        let handle = run_server_auth(s1, keys);
4735
4736        // Drop immediately — server fails reading the ChallengeResponse.
4737        drop(s2);
4738
4739        assert!(handle.join().unwrap().is_err());
4740    }
4741
4742    #[test]
4743    fn auth_different_key_than_authorized_is_rejected() {
4744        // Authorize the test key, but connect with a different key.
4745        let keys = keys_with_test_key("trader");
4746        let wrong_key = SigningKey::from_bytes(&[0xCC; 32]);
4747        let (s1, mut s2) = UnixStream::pair().unwrap();
4748
4749        let handle = run_server_auth(s1, keys);
4750
4751        client_sign_challenge(&mut s2, &wrong_key);
4752        let resp = read_response_tag(&mut s2);
4753        assert_eq!(resp, TAG_AUTH_FAILED);
4754
4755        assert!(handle.join().unwrap().is_err());
4756    }
4757
4758    #[test]
4759    fn auth_oversized_frame_sends_auth_failed() {
4760        let keys = keys_with_test_key("trader");
4761        let (s1, mut s2) = UnixStream::pair().unwrap();
4762
4763        let handle = run_server_auth(s1, keys);
4764
4765        // Read and discard Challenge.
4766        read_challenge(&mut s2);
4767
4768        // Send a frame claiming to be 1000 bytes (way over the 256 limit).
4769        let fake_len: u32 = 1000;
4770        s2.write_all(&fake_len.to_le_bytes()).unwrap();
4771        s2.flush().unwrap();
4772
4773        // Server should send AuthFailed before dropping.
4774        let resp = read_response_tag(&mut s2);
4775        assert_eq!(resp, TAG_AUTH_FAILED);
4776
4777        assert!(handle.join().unwrap().is_err());
4778    }
4779
4780    #[test]
4781    fn auth_zero_length_frame_sends_auth_failed() {
4782        let keys = keys_with_test_key("trader");
4783        let (s1, mut s2) = UnixStream::pair().unwrap();
4784
4785        let handle = run_server_auth(s1, keys);
4786
4787        // Read and discard Challenge.
4788        read_challenge(&mut s2);
4789
4790        // Send a zero-length frame — decode_request will fail on empty input.
4791        let zero_len: u32 = 0;
4792        s2.write_all(&zero_len.to_le_bytes()).unwrap();
4793        s2.flush().unwrap();
4794
4795        // Server should send AuthFailed before dropping.
4796        let resp = read_response_tag(&mut s2);
4797        assert_eq!(resp, TAG_AUTH_FAILED);
4798
4799        assert!(handle.join().unwrap().is_err());
4800    }
4801
4802    #[test]
4803    fn auth_readonly_role() {
4804        let keys = keys_with_test_key("readonly");
4805        let key = test_key();
4806        let (s1, mut s2) = UnixStream::pair().unwrap();
4807
4808        let handle = run_server_auth(s1, keys);
4809
4810        client_sign_challenge(&mut s2, &key);
4811        let resp = read_response_tag(&mut s2);
4812        assert_eq!(resp, TAG_SERVER_READY);
4813
4814        assert_eq!(handle.join().unwrap().unwrap(), "readonly");
4815    }
4816
4817    /// A decoder with no application roles, for the pairing check below.
4818    struct OperatorOnlyDecoder;
4819
4820    impl RequestDecoder for OperatorOnlyDecoder {
4821        type Event = counter_server::CounterEvent;
4822        type Role = NoRoles;
4823
4824        fn decode(&self, _body: &[u8], _role: ClientRole<NoRoles>) -> Decoded<Self::Event> {
4825            Decoded::Filter
4826        }
4827    }
4828
4829    /// A keys table parsed for another role type than the decoder's is
4830    /// refused before the node starts: its role indices would name the
4831    /// decoder's roles wrongly.
4832    #[test]
4833    fn keys_parsed_for_another_role_type_are_refused() {
4834        let decoder = OperatorOnlyDecoder;
4835        let other_roles = keys_with_test_key("operator");
4836        let err = super::check_keys_match_decoder(&decoder, &other_roles).unwrap_err();
4837        assert_eq!(
4838            err.to_string(),
4839            "authorized keys were parsed for a role type other than the decoder's"
4840        );
4841        let own_roles = AuthorizedKeys::parse::<NoRoles>("").unwrap();
4842        super::check_keys_match_decoder(&decoder, &own_roles).unwrap();
4843    }
4844
4845    /// A replication key signs correctly and is in the keys file, but it
4846    /// authorizes node-to-node streaming only: the client listener refuses
4847    /// it at the handshake, before any request could reach the decoder.
4848    #[test]
4849    fn auth_replication_key_refused_on_client_listener() {
4850        let keys = keys_with_test_key("replication");
4851        let key = test_key();
4852        let (s1, mut s2) = UnixStream::pair().unwrap();
4853
4854        let handle = run_server_auth(s1, keys);
4855
4856        client_sign_challenge(&mut s2, &key);
4857        let resp = read_response_tag(&mut s2);
4858        assert_eq!(resp, TAG_AUTH_FAILED);
4859
4860        let err = handle.join().unwrap().unwrap_err();
4861        assert!(
4862            err.contains("may not connect as a client"),
4863            "unexpected error: {err}"
4864        );
4865    }
4866}
4867
4868/// Genesis is journaled once per lineage (audit findings 3 and 4): a new
4869/// journal is created with its genesis in it, and no other layout
4870/// journals genesis — a snapshot-only boot, a recovered history, a crash
4871/// in the middle of a first boot. Each test drives `init_engine` on a
4872/// directory laid out as the case leaves it.
4873#[cfg(test)]
4874mod genesis_tests {
4875    use std::path::Path;
4876
4877    use counter_server::{Counter, CounterEvent};
4878    use melin_journal::{BufferedWriter, JournalEvent, JournalReader, JournalWrite};
4879    use melin_transport_core::journaled_app::genesis_staging_path;
4880
4881    use super::{
4882        GenesisTarget, ServerConfig, check_promotable, check_recovered_history_holds_genesis,
4883        genesis_target, init_engine, validate_primary_config,
4884    };
4885
4886    type Writer = BufferedWriter<CounterEvent>;
4887
4888    pub(super) fn config(dir: &Path) -> ServerConfig {
4889        ServerConfig {
4890            journal: dir.join("counter.journal"),
4891            ..ServerConfig::default()
4892        }
4893    }
4894
4895    fn increments(amounts: &[u64]) -> Vec<CounterEvent> {
4896        amounts
4897            .iter()
4898            .map(|&amount| CounterEvent::Increment { amount })
4899            .collect()
4900    }
4901
4902    /// The counter keeps its value private; its snapshot is the value in
4903    /// little-endian.
4904    fn value(app: &Counter) -> u64 {
4905        use melin_app::Application;
4906        let mut buf = Vec::new();
4907        app.snapshot(&mut buf).expect("snapshot to a vec");
4908        u64::from_le_bytes(buf[..8].try_into().expect("8-byte value"))
4909    }
4910
4911    /// Boot `init_engine` on `cfg` with `genesis`: the recovered value and
4912    /// the writer's next sequence.
4913    fn boot(cfg: &ServerConfig, genesis: &[u64]) -> (u64, u64) {
4914        let engine = init_engine::<Counter, Writer>(
4915            cfg,
4916            &cfg.store().expect("store"),
4917            &(),
4918            increments(genesis),
4919        )
4920        .expect("init_engine");
4921        (value(&engine.app), engine.writer.next_sequence())
4922    }
4923
4924    /// Whether a boot of `init_engine` on `cfg` with `genesis` began the
4925    /// history — what holds a new primary's clients until its first
4926    /// replica attaches.
4927    fn began_history(cfg: &ServerConfig, genesis: &[u64]) -> bool {
4928        init_engine::<Counter, Writer>(cfg, &cfg.store().expect("store"), &(), increments(genesis))
4929            .expect("init_engine")
4930            .began_history
4931    }
4932
4933    #[test]
4934    fn only_a_boot_that_begins_the_history_reports_it() {
4935        let dir = tempfile::tempdir().expect("tempdir");
4936        let cfg = config(dir.path());
4937
4938        // Nothing on disk: a new history, with or without a genesis.
4939        assert!(began_history(&cfg, &[]));
4940        // The journal now exists: every later boot continues it.
4941        assert!(!began_history(&cfg, &[]));
4942
4943        let dir = tempfile::tempdir().expect("tempdir");
4944        let cfg = config(dir.path());
4945        assert!(began_history(&cfg, &[1_000]));
4946        assert!(!began_history(&cfg, &[1_000]));
4947
4948        // An empty journal left by an earlier release's interrupted
4949        // first boot (a v15 header, no recorded genesis length):
4950        // journaling its genesis now begins the history.
4951        let dir = tempfile::tempdir().expect("tempdir");
4952        let cfg = config(dir.path());
4953        drop(Writer::create_continuing(&cfg.journal, 1, [0x15; 32], None).expect("v15 journal"));
4954        assert!(began_history(&cfg, &[1_000]));
4955        assert!(!began_history(&cfg, &[1_000]));
4956    }
4957
4958    /// Every application amount in the journal file at `path`, in order —
4959    /// what a replica's catch-up would copy.
4960    fn journaled_amounts(path: &Path) -> Vec<u64> {
4961        let mut reader = JournalReader::<CounterEvent>::open(path).expect("open journal");
4962        let mut out = Vec::new();
4963        while let Some(entry) = reader.next_entry().expect("read entry") {
4964            if let JournalEvent::App(CounterEvent::Increment { amount }) = entry.event {
4965                out.push(amount);
4966            }
4967        }
4968        out
4969    }
4970
4971    fn anchor(path: &Path) -> [u8; 32] {
4972        melin_journal::segment::read_header_info(path)
4973            .expect("journal header")
4974            .anchor_hash
4975    }
4976
4977    #[test]
4978    fn a_new_journal_is_created_with_its_genesis_on_disk() {
4979        let dir = tempfile::tempdir().expect("tempdir");
4980        let cfg = config(dir.path());
4981
4982        assert_eq!(boot(&cfg, &[1_000, 2_000]), (3_000, 3));
4983        // On disk before `init_engine` returns, so before any replica
4984        // can be served: a replica only ever copies the whole genesis.
4985        assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000]);
4986        assert!(!genesis_staging_path(&cfg.journal).exists());
4987
4988        // Recovered on every later boot, never journaled again.
4989        assert_eq!(boot(&cfg, &[1_000, 2_000]), (3_000, 3));
4990        assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000]);
4991    }
4992
4993    /// Finding 3: the standard upgrade (snapshot, new binary, fresh
4994    /// journal) boots from the snapshot alone, whose state already holds
4995    /// the genesis.
4996    #[test]
4997    fn a_snapshot_only_boot_journals_no_genesis() {
4998        let dir = tempfile::tempdir().expect("tempdir");
4999        let cfg = config(dir.path());
5000        let engine = init_engine::<Counter, Writer>(
5001            &cfg,
5002            &cfg.store().expect("store"),
5003            &(),
5004            increments(&[1_000_000]),
5005        )
5006        .expect("boot");
5007        save_snapshot(&engine, cfg.store().expect("store").snapshot());
5008        drop(engine);
5009        std::fs::remove_file(&cfg.journal).expect("move the journal aside");
5010
5011        let (value, next_sequence) = boot(&cfg, &[1_000_000]);
5012        assert_eq!(value, 1_000_000, "genesis applied once, from the snapshot");
5013        assert_eq!(
5014            next_sequence, 2,
5015            "the new segment continues the snapshot, empty"
5016        );
5017        assert!(journaled_amounts(&cfg.journal).is_empty());
5018        // The new segment's header carries the lineage's genesis length
5019        // on from the snapshot, the only record of it left on disk.
5020        assert_eq!(genesis_entries(&cfg.journal), Some(1));
5021    }
5022
5023    /// Save a snapshot of a booted engine as the shadow stage would:
5024    /// its state, position, chain, lineage and lineage genesis length.
5025    pub(super) fn save_snapshot(engine: &super::InitializedEngine<Counter, Writer>, path: &Path) {
5026        melin_transport_core::snapshot::save::<Counter>(
5027            &engine.app,
5028            melin_transport_core::WireSeq::new(engine.writer.next_sequence() - 1),
5029            engine.writer.chain_hash().unwrap_or([0u8; 32]),
5030            engine.recovered_lineage,
5031            engine
5032                .writer
5033                .read_header_info()
5034                .expect("journal header")
5035                .genesis_entries,
5036            path,
5037        )
5038        .expect("save snapshot");
5039    }
5040
5041    /// The genesis length the live segment's header at `path` records.
5042    fn genesis_entries(path: &Path) -> Option<u64> {
5043        melin_journal::segment::read_header_info(path)
5044            .expect("journal header")
5045            .genesis_entries
5046    }
5047
5048    /// Finding 4, crash variant: a first boot that dies part-way through
5049    /// its genesis leaves the partial journal in the staging file, not at
5050    /// the journal's path, so the next boot is still a first boot.
5051    #[test]
5052    fn a_crash_mid_genesis_leaves_no_journal_and_the_next_boot_journals_it_whole() {
5053        let dir = tempfile::tempdir().expect("tempdir");
5054        let cfg = config(dir.path());
5055        let staging = genesis_staging_path(&cfg.journal);
5056        let mut partial = Writer::create(&staging).expect("staging journal");
5057        partial
5058            .append(&JournalEvent::App(CounterEvent::Increment {
5059                amount: 1_000,
5060            }))
5061            .expect("first genesis event");
5062        drop(partial);
5063        assert!(!cfg.journal.exists());
5064
5065        assert_eq!(boot(&cfg, &[1_000, 2_000, 4_000]), (7_000, 4));
5066        assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000, 4_000]);
5067        assert!(!staging.exists(), "the partial journal is discarded");
5068        assert_eq!(genesis_entries(&cfg.journal), Some(3));
5069    }
5070
5071    /// An empty journal whose header records a genesis length is never
5072    /// re-genesised: the length is fixed when the history began. A
5073    /// history begun with no genesis (`Some(0)`) stays one, whatever
5074    /// genesis is configured since; a replica's copy that received none
5075    /// of its primary's genesis (`Some(n)`, `n > 0`) is refused as the
5076    /// partial copy it is, rather than forked with this node's genesis.
5077    #[test]
5078    fn an_empty_journal_with_a_recorded_length_is_not_rewritten() {
5079        let dir = tempfile::tempdir().expect("tempdir");
5080        let cfg = config(dir.path());
5081        drop(Writer::create(&cfg.journal).expect("empty journal"));
5082        assert_eq!(genesis_entries(&cfg.journal), Some(0));
5083        let before = anchor(&cfg.journal);
5084
5085        assert_eq!(boot(&cfg, &[1_000_000]), (0, 1));
5086        assert!(journaled_amounts(&cfg.journal).is_empty());
5087        assert_eq!(anchor(&cfg.journal), before);
5088        assert_eq!(genesis_entries(&cfg.journal), Some(0));
5089
5090        std::fs::remove_file(&cfg.journal).expect("move the journal aside");
5091        drop(
5092            Writer::create_continuing(&cfg.journal, 1, [0x2C; 32], Some(2))
5093                .expect("a replica's empty copy"),
5094        );
5095        for configured in [&[][..], &[1_000, 2_000], &[1_000, 2_000, 4_000]] {
5096            let refused = init_engine::<Counter, Writer>(
5097                &cfg,
5098                &cfg.store().expect("store"),
5099                &(),
5100                increments(configured),
5101            )
5102            .err()
5103            .expect("an empty copy of a genesis is refused");
5104            assert!(
5105                refused.to_string().contains("refusing to start"),
5106                "{configured:?}: {refused}"
5107            );
5108        }
5109        assert!(journaled_amounts(&cfg.journal).is_empty());
5110        assert_eq!(anchor(&cfg.journal), [0x2C; 32]);
5111        assert_eq!(genesis_entries(&cfg.journal), Some(2));
5112    }
5113
5114    /// Finding 4, as an earlier release left it: the journal was created
5115    /// before a refused boot journaled anything — a v15 header, genesis
5116    /// length unknown. Nothing was served from it, so it gets its
5117    /// genesis, under the same anchor (a replica that copied the empty
5118    /// journal still chains to it), and the replacement records the
5119    /// genesis it journals.
5120    #[test]
5121    fn an_empty_old_format_journal_gets_its_genesis_and_its_length() {
5122        let dir = tempfile::tempdir().expect("tempdir");
5123        let cfg = config(dir.path());
5124        drop(Writer::create_continuing(&cfg.journal, 1, [0x15; 32], None).expect("v15 journal"));
5125        assert_eq!(genesis_entries(&cfg.journal), None);
5126
5127        assert_eq!(boot(&cfg, &[1_000, 2_000]), (3_000, 3));
5128        assert_eq!(anchor(&cfg.journal), [0x15; 32]);
5129        assert_eq!(genesis_entries(&cfg.journal), Some(2));
5130    }
5131
5132    /// A stale staging file is swept by a boot that journals no genesis
5133    /// too, and the history beside it is untouched.
5134    #[test]
5135    fn a_stale_staging_file_is_swept_on_any_boot() {
5136        let dir = tempfile::tempdir().expect("tempdir");
5137        let cfg = config(dir.path());
5138        assert_eq!(boot(&cfg, &[1_000]), (1_000, 2));
5139        let staging = genesis_staging_path(&cfg.journal);
5140        drop(Writer::create(&staging).expect("orphaned staging file"));
5141
5142        assert_eq!(boot(&cfg, &[1_000]), (1_000, 2));
5143        assert!(!staging.exists());
5144        assert_eq!(journaled_amounts(&cfg.journal), vec![1_000]);
5145    }
5146
5147    /// With nothing to journal, an empty journal stays the file it was.
5148    #[test]
5149    fn an_empty_journal_without_genesis_is_left_alone() {
5150        let dir = tempfile::tempdir().expect("tempdir");
5151        let cfg = config(dir.path());
5152        drop(Writer::create(&cfg.journal).expect("empty journal"));
5153        let before = anchor(&cfg.journal);
5154
5155        assert_eq!(boot(&cfg, &[]), (0, 1));
5156        assert_eq!(anchor(&cfg.journal), before);
5157        assert!(!genesis_staging_path(&cfg.journal).exists());
5158    }
5159
5160    /// A journal with history is recovered as it is, whatever the
5161    /// configured genesis: its header records the genesis it began with
5162    /// (none, here), and the configuration plays no part.
5163    #[test]
5164    fn a_journal_with_history_never_gets_genesis() {
5165        let dir = tempfile::tempdir().expect("tempdir");
5166        let cfg = config(dir.path());
5167        let mut writer = Writer::create(&cfg.journal).expect("journal");
5168        writer
5169            .append(&JournalEvent::App(CounterEvent::Increment { amount: 5 }))
5170            .expect("one entry");
5171        drop(writer);
5172
5173        assert_eq!(boot(&cfg, &[1_000_000]), (5, 2));
5174        assert_eq!(journaled_amounts(&cfg.journal), vec![5]);
5175    }
5176
5177    /// A replica's journal as its primary's death left it: the header
5178    /// records the lineage's genesis of three entries (learned from the
5179    /// primary), and only the first two arrived.
5180    fn partial_replica_copy(path: &Path) {
5181        let mut writer =
5182            Writer::create_continuing(path, 1, [0x2B; 32], Some(3)).expect("replica journal");
5183        for amount in [1_000, 2_000] {
5184            writer
5185                .append(&JournalEvent::App(CounterEvent::Increment { amount }))
5186                .expect("genesis prefix");
5187        }
5188    }
5189
5190    /// A history holding part of the lineage's genesis — a replica's
5191    /// partial copy started on a primary's flags — is refused, from the
5192    /// journal alone or from a snapshot, and left as it was. Whatever
5193    /// genesis the node is configured with: none, the same, or more.
5194    #[test]
5195    fn a_genesis_prefix_is_refused_at_boot() {
5196        let dir = tempfile::tempdir().expect("tempdir");
5197        let cfg = config(dir.path());
5198        partial_replica_copy(&cfg.journal);
5199
5200        for configured in [&[][..], &[1_000, 2_000, 4_000], &[1, 2, 3, 4, 5]] {
5201            let refused = init_engine::<Counter, Writer>(
5202                &cfg,
5203                &cfg.store().expect("store"),
5204                &(),
5205                increments(configured),
5206            )
5207            .err()
5208            .expect("a genesis prefix is refused");
5209            assert!(
5210                refused.to_string().contains("refusing to start"),
5211                "{configured:?}: {refused}"
5212            );
5213        }
5214        assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000]);
5215
5216        // The same history behind a snapshot: still a prefix, whose
5217        // snapshot records the genesis length too.
5218        melin_transport_core::JournaledApp::<Counter, Writer>::recover(
5219            Counter::default(),
5220            &cfg.journal,
5221        )
5222        .expect("recover the partial copy")
5223        .save_snapshot(cfg.store().expect("store").snapshot())
5224        .expect("save snapshot");
5225        init_engine::<Counter, Writer>(&cfg, &cfg.store().expect("store"), &(), Vec::new())
5226            .err()
5227            .expect("snapshot + journal holding a prefix is refused");
5228        std::fs::remove_file(&cfg.journal).expect("move the journal aside");
5229        let refused =
5230            init_engine::<Counter, Writer>(&cfg, &cfg.store().expect("store"), &(), Vec::new())
5231                .err()
5232                .expect("a snapshot holding a prefix is refused");
5233        assert!(
5234            refused.to_string().contains("refusing to start"),
5235            "{refused}"
5236        );
5237    }
5238
5239    /// A genesis configuration grown since the history began changes
5240    /// nothing: the history's own header says how long its genesis was,
5241    /// and a short history holding all of it boots.
5242    #[test]
5243    fn a_grown_genesis_configuration_does_not_refuse_a_short_history() {
5244        let dir = tempfile::tempdir().expect("tempdir");
5245        let cfg = config(dir.path());
5246        assert_eq!(boot(&cfg, &[1_000]), (1_000, 2));
5247        assert_eq!(genesis_entries(&cfg.journal), Some(1));
5248
5249        assert_eq!(boot(&cfg, &[1_000, 2_000, 4_000, 8_000]), (1_000, 2));
5250        assert_eq!(journaled_amounts(&cfg.journal), vec![1_000]);
5251        assert_eq!(genesis_entries(&cfg.journal), Some(1));
5252    }
5253
5254    /// A journal written by an earlier release records no genesis
5255    /// length: it boots as it did there, with no check — even when it is
5256    /// shorter than the configured genesis — stays a v15 lineage, and a
5257    /// promotion from it is not checked either. The same from a v2
5258    /// snapshot alone, which records no length either.
5259    #[test]
5260    fn an_old_format_journal_boots_without_the_check() {
5261        let dir = tempfile::tempdir().expect("tempdir");
5262        let cfg = config(dir.path());
5263        let mut writer =
5264            Writer::create_continuing(&cfg.journal, 1, [0x15; 32], None).expect("v15 journal");
5265        writer
5266            .append(&JournalEvent::App(CounterEvent::Increment { amount: 7 }))
5267            .expect("one entry");
5268        drop(writer);
5269
5270        let engine = init_engine::<Counter, Writer>(
5271            &cfg,
5272            &cfg.store().expect("store"),
5273            &(),
5274            increments(&[1, 2, 3]),
5275        )
5276        .expect("an unknown genesis length is not checked");
5277        assert_eq!(value(&engine.app), 7);
5278        assert_eq!(engine.writer.next_sequence(), 2);
5279        assert_eq!(genesis_entries(&cfg.journal), None, "still a v15 lineage");
5280        check_promotable(&engine.writer).expect("promotion is not checked either");
5281
5282        // Snapshot-only: the v2 snapshot (unknown length) starts a v15
5283        // segment, and the boot is not checked.
5284        save_snapshot(&engine, cfg.store().expect("store").snapshot());
5285        drop(engine);
5286        std::fs::remove_file(&cfg.journal).expect("move the journal aside");
5287        assert_eq!(boot(&cfg, &[1, 2, 3]), (7, 2));
5288        assert_eq!(genesis_entries(&cfg.journal), None);
5289    }
5290
5291    #[test]
5292    fn genesis_target_starts_only_an_empty_history() {
5293        let genesis = increments(&[1]);
5294        let none: Vec<CounterEvent> = Vec::new();
5295        let queries = vec![CounterEvent::GetValue];
5296        // (archives, next_sequence, recorded length, genesis) → target
5297        type Case<'a> = (bool, u64, Option<u64>, &'a [CounterEvent], GenesisTarget);
5298        let cases: [Case<'_>; 8] = [
5299            (false, 1, None, &genesis, GenesisTarget::EmptyJournal),
5300            (false, 2, None, &genesis, GenesisTarget::None),
5301            // An empty live segment after a rotation still has history.
5302            (true, 1, None, &genesis, GenesisTarget::None),
5303            (true, 5, None, &genesis, GenesisTarget::None),
5304            (false, 1, None, &none, GenesisTarget::None),
5305            (false, 1, None, &queries, GenesisTarget::None),
5306            // A recorded length is fixed: a history begun with no
5307            // genesis, or a replica's empty copy of one.
5308            (false, 1, Some(0), &genesis, GenesisTarget::None),
5309            (false, 1, Some(3), &genesis, GenesisTarget::None),
5310        ];
5311        for (archives, next_sequence, recorded, genesis, expected) in cases {
5312            assert_eq!(
5313                genesis_target(archives, next_sequence, recorded, genesis),
5314                expected,
5315                "archives={archives} next_sequence={next_sequence} recorded={recorded:?} \
5316                 genesis={genesis:?}"
5317            );
5318        }
5319    }
5320
5321    /// Finding 4, configuration variant: a refused configuration is
5322    /// refused by the check `run_impl` runs before the journal is
5323    /// touched.
5324    #[test]
5325    fn standalone_needs_the_disk_policy() {
5326        let refused = ServerConfig {
5327            standalone: true,
5328            ..ServerConfig::default()
5329        };
5330        let err = validate_primary_config(&refused).expect_err("default policy with --standalone");
5331        assert!(err.to_string().contains("--ack-policy disk"), "{err}");
5332        let accepted = ServerConfig {
5333            standalone: true,
5334            ack_policy: crate::ack_policy::AckPolicy::Disk,
5335            ..ServerConfig::default()
5336        };
5337        validate_primary_config(&accepted).expect("standalone under disk");
5338    }
5339
5340    /// A connection cap the rings cannot be sized for is a startup error
5341    /// on either transport — `0`, which once meant "unlimited", included —
5342    /// and the CLI refuses it before the config exists.
5343    #[test]
5344    fn max_connections_must_be_a_supported_cap() {
5345        use crate::connection_limit::MAX_SUPPORTED_CONNECTIONS;
5346        use clap::Parser as _;
5347
5348        for refused in [0, MAX_SUPPORTED_CONNECTIONS + 1] {
5349            let config = ServerConfig {
5350                max_connections: refused,
5351                ..ServerConfig::default()
5352            };
5353            let err = validate_primary_config(&config).expect_err("an unsupported cap");
5354            assert!(err.to_string().contains("--max-connections"), "{err}");
5355            assert!(
5356                ServerConfig::try_parse_from([
5357                    "melin-server",
5358                    "--max-connections",
5359                    &refused.to_string()
5360                ])
5361                .is_err(),
5362                "the CLI accepted --max-connections {refused}"
5363            );
5364        }
5365        for accepted in [1, 1024, MAX_SUPPORTED_CONNECTIONS] {
5366            let config = ServerConfig {
5367                max_connections: accepted,
5368                ..ServerConfig::default()
5369            };
5370            validate_primary_config(&config).expect("a supported cap");
5371            let parsed = ServerConfig::try_parse_from([
5372                "melin-server",
5373                "--max-connections",
5374                &accepted.to_string(),
5375            ])
5376            .expect("the CLI accepts a supported cap");
5377            assert_eq!(parsed.max_connections, accepted);
5378        }
5379        assert_eq!(
5380            ServerConfig::try_parse_from(["melin-server"])
5381                .expect("defaults parse")
5382                .max_connections,
5383            ServerConfig::default().max_connections,
5384            "the CLI default and `Default` agree"
5385        );
5386    }
5387
5388    /// A replica that copied only part of the lineage's genesis — an
5389    /// empty journal (its primary died right after the handshake) or a
5390    /// prefix (died mid catch-up) — is refused promotion; one holding all
5391    /// of it, with or without history after it, is promoted. The length
5392    /// comes from the replica's journal header alone: no configuration
5393    /// reaches the check.
5394    #[test]
5395    fn promotion_needs_the_whole_genesis() {
5396        let dir = tempfile::tempdir().expect("tempdir");
5397        let path = dir.path().join("replica.journal");
5398        let genesis = increments(&[1_000, 2_000, 4_000]);
5399
5400        let mut writer =
5401            Writer::create_continuing(&path, 1, [0x2B; 32], Some(3)).expect("replica journal");
5402        let refused = check_promotable(&writer).expect_err("an empty history");
5403        assert!(
5404            refused.to_string().contains("refusing promotion"),
5405            "{refused}"
5406        );
5407        for event in &genesis[..2] {
5408            writer
5409                .append(&JournalEvent::App(*event))
5410                .expect("genesis prefix");
5411        }
5412        check_promotable(&writer).expect_err("a genesis prefix");
5413
5414        writer
5415            .append(&JournalEvent::App(genesis[2]))
5416            .expect("last genesis event");
5417        check_promotable(&writer).expect("the whole genesis");
5418        writer
5419            .append(&JournalEvent::App(CounterEvent::Increment { amount: 5 }))
5420            .expect("client history");
5421        check_promotable(&writer).expect("genesis and history after it");
5422
5423        // No genesis: any history, even an empty one, is promotable.
5424        let none = dir.path().join("none.journal");
5425        check_promotable(&Writer::create(&none).expect("journal")).expect("nothing to hold");
5426        // Unknown (a lineage begun before the length was recorded): not
5427        // checked, as before.
5428        let unknown = dir.path().join("unknown.journal");
5429        check_promotable(
5430            &Writer::create_continuing(&unknown, 1, [0x15; 32], None).expect("journal"),
5431        )
5432        .expect("unknown is not checked");
5433        // A history continuing a snapshot counts the snapshot's entries.
5434        let continued = dir.path().join("continued.journal");
5435        check_promotable(
5436            &Writer::create_continuing(&continued, 10, [0x2B; 32], Some(3)).expect("journal"),
5437        )
5438        .expect("snapshot covers genesis");
5439    }
5440
5441    /// The boot check draws the same line as the promotion check, and
5442    /// skips an unknown length.
5443    #[test]
5444    fn the_boot_check_needs_the_whole_genesis() {
5445        check_recovered_history_holds_genesis(1, Some(3)).expect_err("empty");
5446        check_recovered_history_holds_genesis(3, Some(3)).expect_err("a prefix");
5447        check_recovered_history_holds_genesis(4, Some(3)).expect("the whole genesis");
5448        check_recovered_history_holds_genesis(100, Some(3)).expect("and history after it");
5449        check_recovered_history_holds_genesis(1, Some(0)).expect("no genesis");
5450        check_recovered_history_holds_genesis(1, None).expect("unknown: not checked");
5451        check_recovered_history_holds_genesis(3, None).expect("unknown: not checked");
5452    }
5453}
5454
5455/// How a new primary pipeline resolves the policy its response gate
5456/// starts under, and what it seeds alongside.
5457#[cfg(test)]
5458mod primary_gate_policy_tests {
5459    use std::sync::Arc;
5460    use std::sync::atomic::AtomicU64;
5461
5462    use counter_server::{Counter, CounterEvent};
5463    use melin_pipeline::ring::DisruptorBuilder;
5464    use melin_pipeline::wait::WaitStrategy;
5465    use melin_transport_core::fence::FenceState;
5466    use melin_transport_core::lineage::ForcedPolicy;
5467    use melin_transport_core::pipeline::{InputSlot, MatchingStage, OutputSlot};
5468    use melin_transport_core::{DurableWireSeqCursor, WireSeq};
5469
5470    use super::primary_gate_policy;
5471    use crate::ack_policy::AckPolicy;
5472    use crate::halt::Replication::Enabled;
5473
5474    fn matching_stage() -> MatchingStage<Counter> {
5475        let (_, mut inputs) = DisruptorBuilder::<InputSlot<CounterEvent>>::new(8)
5476            .add_consumer()
5477            .build(WaitStrategy::SpinThenYield);
5478        let (output, _) = DisruptorBuilder::<
5479            OutputSlot<
5480                <Counter as melin_app::Application>::Report,
5481                <Counter as melin_app::Application>::QueryResponse,
5482            >,
5483        >::new(8)
5484        .add_consumer()
5485        .build(WaitStrategy::SpinThenYield);
5486        MatchingStage::new(
5487            Counter::default(),
5488            inputs.pop().expect("one consumer"),
5489            output,
5490            Arc::new(AtomicU64::new(0)),
5491            DurableWireSeqCursor::detached(WireSeq::new(0)),
5492            Arc::new(AtomicU64::new(0)),
5493            Arc::new(FenceState::new(0)),
5494            WaitStrategy::SpinThenYield,
5495            1,
5496        )
5497    }
5498
5499    /// The lineage's policy wins over the requested one: the gate starts
5500    /// under the policy before its newest change, with the change pending;
5501    /// the newest seeds the "journaled" gauge, and the gate reads the
5502    /// stage's forced cell. The rest of the resolution (no lineage policy,
5503    /// the lineage's first change, standalone, an unknown byte) is
5504    /// `initial_gate_policy`'s, pinned in `durability_gate.rs`.
5505    #[test]
5506    fn the_lineage_policy_wins_and_seeds_the_journaled_gauge() {
5507        let stage = matching_stage();
5508        let lineage = melin_transport_core::lineage::LineagePolicy {
5509            change: melin_journal::AckPolicyChange {
5510                policy: AckPolicy::TwoDisks.to_byte(),
5511                unwitnessed: false,
5512            },
5513            wire_seq: WireSeq::new(7),
5514            previous: Some(AckPolicy::Ram.to_byte()),
5515        };
5516        let policy = primary_gate_policy(Some(lineage), AckPolicy::Disk, Enabled, &stage)
5517            .expect("a known policy");
5518        assert_eq!(
5519            policy.start,
5520            crate::durability_gate::GateStart {
5521                in_force: AckPolicy::Ram,
5522                pending: Some(crate::durability_gate::PendingChange {
5523                    wire_seq: WireSeq::new(7),
5524                    policy: AckPolicy::TwoDisks,
5525                    unwitnessed: false,
5526                }),
5527            }
5528        );
5529        assert_eq!(
5530            stage.utilization().ack_policy_journaled.load(),
5531            Some(AckPolicy::TwoDisks.to_byte())
5532        );
5533        assert_eq!(policy.forced.load(), None::<ForcedPolicy>);
5534    }
5535
5536    /// A boot from a v4 snapshot whose history before it is gone restores
5537    /// the whole transition, not just the policy: the policy before the
5538    /// newest change, the change's own wire sequence and its unwitnessed
5539    /// flag reach the gate, which starts under the policy before with the
5540    /// change pending. Both boots `init_engine` takes on such a layout:
5541    /// the snapshot alone (no segment left), and the snapshot with the
5542    /// empty segment that boot began, holding nothing before it.
5543    #[test]
5544    fn a_boot_from_a_snapshot_alone_starts_the_gate_with_its_change_pending() {
5545        use melin_journal::{AckPolicyChange, JournalEvent};
5546        use melin_transport_core::lineage::{Lineage, LineagePolicy};
5547
5548        type Writer = melin_journal::BufferedWriter<CounterEvent>;
5549        let boot = |cfg: &super::ServerConfig| {
5550            super::init_engine::<Counter, Writer>(
5551                cfg,
5552                &cfg.store().expect("store"),
5553                &(),
5554                Vec::new(),
5555            )
5556            .expect("boot")
5557        };
5558        let change = |policy: AckPolicy, unwitnessed| {
5559            JournalEvent::AckPolicyChange(AckPolicyChange {
5560                policy: policy.to_byte(),
5561                unwitnessed,
5562            })
5563        };
5564        let dir = tempfile::tempdir().expect("tempdir");
5565        let cfg = super::genesis_tests::config(dir.path());
5566
5567        // The lineage's first policy at 1, a write, a forced weakening at
5568        // 3, a write: the transition to restore is `disk+ram` → `disk` at
5569        // 3, unwitnessed.
5570        let mut engine = boot(&cfg);
5571        for event in [
5572            change(AckPolicy::DiskAndRam, false),
5573            JournalEvent::App(CounterEvent::Increment { amount: 1 }),
5574            change(AckPolicy::Disk, true),
5575            JournalEvent::App(CounterEvent::Increment { amount: 2 }),
5576        ] {
5577            let seq = engine.writer.allocate_sequence();
5578            engine
5579                .writer
5580                .encode_event(seq, 0, &event, 0)
5581                .expect("encode");
5582        }
5583        engine.writer.flush_batch_sync().expect("flush");
5584        drop(engine);
5585        let expected = Lineage {
5586            epoch: 0,
5587            ack_policy: Some(LineagePolicy {
5588                change: AckPolicyChange {
5589                    policy: AckPolicy::Disk.to_byte(),
5590                    unwitnessed: true,
5591                },
5592                wire_seq: WireSeq::new(3),
5593                previous: Some(AckPolicy::DiskAndRam.to_byte()),
5594            }),
5595        };
5596
5597        // The shadow's snapshot of the recovered state, then the journal
5598        // gone: every entry it held, the change's included, is the
5599        // snapshot's alone.
5600        let engine = boot(&cfg);
5601        assert_eq!(
5602            engine.recovered_lineage, expected,
5603            "recovered from the journal"
5604        );
5605        super::genesis_tests::save_snapshot(&engine, cfg.store().expect("store").snapshot());
5606        drop(engine);
5607        std::fs::remove_file(&cfg.journal).expect("prune the journal");
5608        assert!(
5609            melin_journal::segment::list_archives(&cfg.journal)
5610                .expect("list archives")
5611                .is_empty()
5612        );
5613
5614        let pending = crate::durability_gate::GateStart {
5615            in_force: AckPolicy::DiskAndRam,
5616            pending: Some(crate::durability_gate::PendingChange {
5617                wire_seq: WireSeq::new(3),
5618                policy: AckPolicy::Disk,
5619                unwitnessed: true,
5620            }),
5621        };
5622        // The first boot opens a fresh, empty segment beside the snapshot,
5623        // so the second recovers through the other arm.
5624        for (layout, segment) in [
5625            ("the snapshot alone", false),
5626            ("the snapshot and an empty segment", true),
5627        ] {
5628            assert_eq!(cfg.journal.exists(), segment, "{layout}");
5629            let engine = boot(&cfg);
5630            assert_eq!(engine.writer.next_sequence(), 5, "{layout}");
5631            assert_eq!(engine.recovered_lineage, expected, "{layout}");
5632            let policy = primary_gate_policy(
5633                engine.recovered_lineage.ack_policy,
5634                AckPolicy::Disk,
5635                Enabled,
5636                &matching_stage(),
5637            )
5638            .expect("a known policy");
5639            assert_eq!(policy.start, pending, "{layout}");
5640        }
5641    }
5642
5643    /// A promotion seeds its gate the same way, from the record the
5644    /// replica's receive thread folded the stream's changes into
5645    /// (`ReplicaControlPlane::lineage_ack_policy`): a weakening the
5646    /// replica received is pending under the policy before it, and a
5647    /// forced re-send of it makes it pending unwitnessed, applied once on
5648    /// the promoted node's disk.
5649    #[test]
5650    fn a_promotion_seeds_its_gate_from_the_replicas_record() {
5651        let streamed = |policy: AckPolicy, unwitnessed| melin_journal::AckPolicyChange {
5652            policy: policy.to_byte(),
5653            unwitnessed,
5654        };
5655        let record = melin_transport_core::lineage::SharedLineagePolicy::default();
5656        record.observe(WireSeq::new(1), streamed(AckPolicy::DiskAndRam, false));
5657        record.observe(WireSeq::new(6), streamed(AckPolicy::Disk, false));
5658        let start = |record: &melin_transport_core::lineage::SharedLineagePolicy| {
5659            primary_gate_policy(record.load(), AckPolicy::Disk, Enabled, &matching_stage())
5660                .expect("a known policy")
5661                .start
5662        };
5663        let pending = |unwitnessed| crate::durability_gate::GateStart {
5664            in_force: AckPolicy::DiskAndRam,
5665            pending: Some(crate::durability_gate::PendingChange {
5666                wire_seq: WireSeq::new(6),
5667                policy: AckPolicy::Disk,
5668                unwitnessed,
5669            }),
5670        };
5671        assert_eq!(start(&record), pending(false));
5672        record.observe(WireSeq::new(9), streamed(AckPolicy::Disk, true));
5673        assert_eq!(start(&record), pending(true));
5674    }
5675}
5676
5677/// The node's DPDK configuration as composed from its own: the flags'
5678/// mapping (tested with them, in `melin-dpdk`) plus the node's default
5679/// address, `--bind`'s port and one queue.
5680///
5681/// The value the error cases pin is *when* a bad flag is caught.
5682/// `--dpdk-peer-mac` is consumed inside the replica's reconnect loop, so
5683/// the failure has to surface here — during config translation, before
5684/// EAL init and before any thread exists — rather than as a panic in a
5685/// node that is already serving.
5686#[cfg(all(test, feature = "dpdk"))]
5687mod dpdk_config_tests {
5688    use super::{DpdkArgs, ServerConfig, dpdk_config_from};
5689    use std::net::Ipv4Addr;
5690
5691    #[test]
5692    fn the_node_listens_on_its_bind_port_with_one_queue() {
5693        let cfg = ServerConfig {
5694            bind: "127.0.0.1:9999".parse().expect("an address"),
5695            ..Default::default()
5696        };
5697        let dpdk = dpdk_config_from(&cfg).expect("valid config");
5698        assert_eq!(dpdk.listen_port, 9999);
5699        assert_eq!(dpdk.num_queues, 1);
5700        assert_eq!(dpdk.nic.ip_addr, Ipv4Addr::new(10, 0, 0, 1));
5701        assert_eq!(dpdk.nic.peer_mac, None);
5702    }
5703
5704    #[test]
5705    fn the_client_buffers_default_to_the_transport_s() {
5706        let dpdk = dpdk_config_from(&ServerConfig::default()).expect("valid config");
5707        assert_eq!(dpdk.client_buffers, melin_dpdk::SocketBuffers::default());
5708    }
5709
5710    #[test]
5711    fn the_client_buffer_flags_are_in_kib() {
5712        let cfg = ServerConfig {
5713            dpdk_client_rx_buf_kib: 256,
5714            dpdk_client_tx_buf_kib: 512,
5715            dpdk_client_tx_queue_kib: 32768,
5716            ..Default::default()
5717        };
5718        let dpdk = dpdk_config_from(&cfg).expect("valid config");
5719        assert_eq!(
5720            dpdk.client_buffers,
5721            melin_dpdk::SocketBuffers {
5722                rx_buf_size: 256 * 1024,
5723                tx_buf_size: 512 * 1024,
5724                tx_queue_limit: 32768 * 1024,
5725            }
5726        );
5727    }
5728
5729    /// A config built in code with no address at all (not even the one
5730    /// `ServerConfig::default()` carries) still gets the node's.
5731    #[test]
5732    fn the_address_defaults_to_the_node_s() {
5733        let cfg = ServerConfig {
5734            dpdk: DpdkArgs::default(),
5735            ..Default::default()
5736        };
5737        assert_eq!(cfg.dpdk.ip, None);
5738        let dpdk = dpdk_config_from(&cfg).expect("valid config");
5739        assert_eq!(dpdk.nic.ip_addr, Ipv4Addr::new(10, 0, 0, 1));
5740    }
5741
5742    #[test]
5743    fn a_supplied_peer_mac_reaches_the_transport_config() {
5744        let mut cfg = ServerConfig::default();
5745        cfg.dpdk.peer_mac = Some("0c:42:a1:5b:2e:80".into());
5746        let dpdk = dpdk_config_from(&cfg).expect("valid config");
5747        assert_eq!(
5748            dpdk.nic.peer_mac,
5749            Some([0x0c, 0x42, 0xa1, 0x5b, 0x2e, 0x80])
5750        );
5751    }
5752
5753    #[test]
5754    fn a_malformed_peer_mac_fails_config_translation() {
5755        let mut cfg = ServerConfig::default();
5756        cfg.dpdk.peer_mac = Some("0c:42:a1:5b:2e".into());
5757        let err = dpdk_config_from(&cfg)
5758            .expect_err("a 5-octet MAC must be rejected")
5759            .to_string();
5760        assert!(err.starts_with("invalid --dpdk-peer-mac: "), "{err}");
5761    }
5762}