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