Skip to main content

melin_server_runtime/
server.rs

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