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 writer selected by `--journal-writer`.
6//! 3. Builds the disruptor pipeline (input ring + output ring).
7//! 4. Spawns 3-5 OS threads: journal, matching, response, [repl-sender], [event-publisher].
8//! 5. Runs the accept loop, registering connections with the io_uring reader.
9//!
10//! Fully synchronous — no async runtime needed. Reader threads use io_uring
11//! with multishot RECV to multiplex connections, eliminating thread
12//! oversubscription. The response thread writes via io_uring SEND.
13
14use std::net::{IpAddr, Ipv4Addr, SocketAddr};
15use std::os::unix::io::AsRawFd;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
19
20use std::hash::{Hash, Hasher};
21
22use tracing::{debug, error, info, warn};
23
24use melin_journal::BufferedWriter;
25use melin_journal::JournalError;
26use melin_journal::JournalWrite;
27use melin_journal::SectorWriter;
28use melin_transport_core::journaled_app::JournaledApp;
29use melin_transport_core::pipeline::{
30    InputSlot, JournalStage, JournalStageRun, OutputSlot as GenericOutputSlot,
31    Pipeline as GenericPipeline, build_pipeline_with_replication,
32};
33/// Internal alias for the disruptor-built pipeline, used only by
34/// destructuring `let Pipeline { … }` patterns inside the boot path.
35/// Not part of the public API — callers reach the underlying type
36/// through `melin_transport_core::pipeline`.
37type Pipeline<A, W> = GenericPipeline<A, W>;
38
39use crate::reader::RequestDecoderArc;
40use crate::response::ResponseEncoderArc;
41use melin_app::Application;
42use melin_app::app_factory::AppFactory;
43use melin_app::auth::AuthorizedKeys;
44use melin_app::auth::Permission;
45use melin_app::decoder::RequestDecoder;
46use melin_app::encoder::ResponseEncoder;
47use melin_pipeline::ring::Consumer;
48
49/// Output-slot sugar parameterised on the application — saves spelling
50/// `<A::Report, A::QueryResponse>` at every pipeline-facing signature
51/// that doesn't already destructure them.
52type OutputSlot<A> =
53    GenericOutputSlot<<A as Application>::Report, <A as Application>::QueryResponse>;
54
55/// Body of the event-publisher thread: a free function with this exact
56/// signature (the trading binary supplies
57/// `melin_server::event_publisher::run`; the
58/// skip-order-exec binary supplies nothing). Passing it as a function
59/// pointer keeps the runtime decoupled from the trading domain — no
60/// `melin_server::*` reference inside server.rs — without paying for a
61/// boxed closure.
62///
63/// Threaded into [`run`] (and `run_dpdk` under `feature = "dpdk"`) as `Option<EventPublisherFn>`:
64/// `None` disables the publisher unconditionally, `Some(_)` wires it up
65/// when `--event-bind` is also set.
66pub type EventPublisherFn<A> = fn(
67    consumer: Consumer<OutputSlot<A>>,
68    bind_addr: SocketAddr,
69    authorized_keys: Arc<AuthorizedKeys>,
70    shutdown: &AtomicBool,
71    busy_spin: bool,
72);
73
74use melin_wire_protocol::blocking::BlockingFrameWriter;
75use melin_wire_protocol::control::ConnectionId;
76use melin_wire_protocol::transport::BlockingTransportListener;
77
78/// Default replica pipeline depth (pending ack queue capacity).
79const DEFAULT_REPLICATION_PIPELINE_DEPTH: usize = 8;
80
81/// Server configuration, parsed from CLI arguments via clap.
82#[derive(clap::Parser)]
83#[command(name = "melin-server", about = "Low-latency matching engine server")]
84pub struct ServerConfig {
85    /// Address to bind the TCP listener.
86    #[arg(long, default_value = "127.0.0.1:9876")]
87    pub bind: SocketAddr,
88    /// Path to the journal file for durable event sourcing.
89    #[arg(long, default_value = "melin.journal")]
90    pub journal: PathBuf,
91    /// Path to a snapshot file for faster recovery.
92    #[arg(long)]
93    pub snapshot: Option<PathBuf>,
94    /// Pipeline core IDs: journal,matching,response,reader,repl-sender,event-publisher,shadow,repl-handler-0,repl-handler-1,journal-prep
95    /// (comma-separated; the tenth entry, journal-prep, is optional and
96    /// defaults to unpinned when omitted). Core 0 is reserved for OS/IRQ
97    /// handling.
98    /// reader pins the io_uring reader (TCP) or DPDK poll thread.
99    /// repl-sender is used when replication is enabled, event-publisher when
100    /// `--event-bind` is set, shadow when `--snapshot-interval-ms` > 0.
101    /// repl-handler-0/1 are for the per-replica TCP handler threads (0 = unpinned).
102    #[arg(long, default_value = "1,2,3,4,5,6,7,8,9,10", value_parser = parse_cores)]
103    pub cores: PipelineCores,
104    /// Group commit coalescing delay in microseconds. Keep at 0 for TCP.
105    #[arg(long, default_value_t = 0)]
106    pub group_commit_us: u64,
107    /// Heartbeat interval in seconds. The server sends a heartbeat to idle
108    /// connections after this many seconds of silence. Set to 0 to disable.
109    #[arg(long, default_value_t = 10)]
110    pub heartbeat_interval_secs: u64,
111    /// Connection timeout in seconds. The server disconnects clients that
112    /// have not sent any data within this window. Set to 0 to disable.
113    #[arg(long, default_value_t = 30)]
114    pub connection_timeout_secs: u64,
115    /// Maximum number of concurrent authenticated connections. New
116    /// connections are rejected (closed immediately) when this limit is
117    /// reached. 0 means unlimited. Prevents fd/memory exhaustion (SEC-02).
118    #[arg(long, default_value_t = 1024)]
119    pub max_connections: u64,
120    /// Number of accounts to seed on first startup. Uses the
121    /// ProvisionAccount event for O(accounts) seeding (~0.5s for 1M).
122    #[arg(long, default_value_t = 100_000)]
123    pub accounts: u32,
124    /// Number of instruments to seed on first startup.
125    #[arg(long, default_value_t = 100)]
126    pub instruments: u32,
127    /// Path to the authorized keys file for Ed25519 challenge-response
128    /// authentication. Every connection must authenticate before trading.
129    /// Required for primary mode; ignored in replica mode (--replica-of).
130    /// See `AuthorizedKeys` for file format.
131    #[arg(long, default_value = "authorized_keys")]
132    pub authorized_keys: PathBuf,
133    /// Maximum journal size in MiB before automatic rotation at startup.
134    /// When the journal exceeds this threshold, the server saves a snapshot
135    /// and starts a fresh journal. Set to 0 to disable. Default: 256 MiB.
136    #[arg(long, default_value_t = 256)]
137    pub max_journal_mib: u64,
138    /// Journal writer mode. `buffered` (default) writes through the page
139    /// cache and calls `fdatasync` per batch — honest durability on any
140    /// drive. `sector` (EXPERIMENTAL) uses `O_DIRECT` with sector-
141    /// aligned writes — lowest latency on enterprise NVMe with
142    /// capacitor-backed PLP (VWC=0), but silently loses acknowledged
143    /// writes on power loss without PLP, and shows unresolved ~1 Hz
144    /// tail latency spikes on some NVMe firmware. Not recommended for
145    /// production. See docs/journal-writer-modes.md.
146    #[arg(
147        long,
148        default_value_t = melin_journal::JournalWriterMode::default(),
149        value_parser = melin_journal::JournalWriterMode::parse,
150    )]
151    pub journal_writer: melin_journal::JournalWriterMode,
152    /// Maximum number of open orders (resting limits + pending stops, across
153    /// all instruments) per account. New submissions are rejected with
154    /// `ExceedsMaxOpenOrders` once an account hits this cap. `0` means
155    /// unlimited. Bounds the per-account contribution to engine memory
156    /// (SEC-03). Must be set to the same value on the primary and every
157    /// replica — the cap shapes Rejected reports, so a mismatch causes
158    /// replay divergence.
159    #[arg(long, default_value_t = 10_000)]
160    pub max_orders_per_account: u32,
161    /// Per-account sustained order-submission rate (orders/sec). Token
162    /// bucket refills at this rate; clients exceeding it (after burning
163    /// the burst) are rejected with `ExceedsOrderRate`. `0` disables the
164    /// limiter. Prevents a single client from monopolizing matching
165    /// throughput (SEC-04). Must match across primary and every replica
166    /// — same determinism caveat as `--max-orders-per-account`. Default
167    /// (1000/s) is a conservative mid-tier ceiling: comfortable for
168    /// algorithmic and retail flow, but tight for active market-makers
169    /// who routinely re-quote past 1k/s on liquid instruments. Operators
170    /// running with significant MM presence should raise this (and the
171    /// burst) to match their book's quote-update profile.
172    #[arg(long, default_value_t = 1_000)]
173    pub max_orders_per_second: u32,
174    /// Per-account burst capacity (max consecutive orders allowed after a
175    /// quiet period). Paired with `--max-orders-per-second`. `0` disables
176    /// the limiter. Default (5000) lets normal trading bursts through —
177    /// e.g. a market open or a strategy initialization batch — without
178    /// false positives, while still capping the absolute spike a single
179    /// account can produce.
180    #[arg(long, default_value_t = 5_000)]
181    pub max_orders_burst: u32,
182
183    /// Address to listen for replica connections (enables synchronous replication).
184    /// Mutually exclusive with `--standalone` and `--replica-of`.
185    #[arg(long)]
186    pub replication_bind: Option<std::net::SocketAddr>,
187
188    /// Disable replication (dev/test mode). The replica quorum cursor stays
189    /// at its no-replica sentinel (health reports zero replication lag) and
190    /// the durability policy evaluates against the primary's journal alone.
191    /// Mutually exclusive with `--replication-bind` and `--replica-of`.
192    #[arg(long, default_value_t = false)]
193    pub standalone: bool,
194
195    /// Run as a replica connected to the given primary address.
196    /// In replica mode, the server does not accept client connections.
197    /// Mutually exclusive with `--replication-bind` and `--standalone`.
198    #[arg(long)]
199    pub replica_of: Option<std::net::SocketAddr>,
200
201    /// Path to the Ed25519 private key for replication authentication.
202    /// Required in replica mode (`--replica-of`). The corresponding
203    /// public key must be listed in the primary's authorized_keys file
204    /// with permission `replication`.
205    #[arg(long)]
206    pub replication_key: Option<std::path::PathBuf>,
207
208    /// Maximum number of replication ring batches to coalesce into a
209    /// single TCP write+flush. Higher values reduce syscall overhead
210    /// but increase per-write latency. Default: 128.
211    #[arg(long, default_value_t = 128)]
212    pub replication_batch_size: usize,
213
214    /// Maximum events per journal fsync batch. Smaller values reduce
215    /// tail latency (less work per sync), larger values improve throughput
216    /// (fewer fsyncs). Default: 4096.
217    #[arg(long, default_value_t = 4096)]
218    pub max_journal_batch: usize,
219
220    /// Replication heartbeat interval in seconds. The primary sends a
221    /// heartbeat to the replica after this many seconds of idle. Used
222    /// for disconnect detection. Default: 5.
223    #[arg(long, default_value_t = 5)]
224    pub replication_heartbeat_secs: u64,
225
226    /// Number of receive batches the replica can have awaiting local
227    /// journal fsync before the receiver applies backpressure. Must be a
228    /// power of two. Higher values allow the primary to stay further ahead
229    /// of the replica's fsync, which helps when group-commit delay is
230    /// non-zero. Default: 8.
231    #[arg(long, default_value_t = DEFAULT_REPLICATION_PIPELINE_DEPTH)]
232    pub replication_pipeline_depth: usize,
233
234    /// Number of slots in each replication ring buffer. Must be a power
235    /// of two. Each slot holds up to 512 KiB. More slots = more buffering
236    /// before eviction. Default: 256 (128 MiB per ring, 256 MiB dual-repl).
237    #[arg(long, default_value_t = 256)]
238    pub replication_ring_size: usize,
239
240    /// Durability mode that gates client responses. One of:
241    ///
242    /// - `local`              `persisted>=1`. Single-node durability;
243    ///   required with `--standalone`. Dev/staging deployments.
244    /// - `hybrid` (default)   `persisted>=1 && in_memory>=2`. Primary's
245    ///   disk plus an in-memory ack from a second node. Single-failure-
246    ///   safe with a brief RAM-only window on the secondary copy. The
247    ///   default — typical live trading deployments. Saves ~50–80 µs
248    ///   per fill vs `durably-replicated`.
249    /// - `durably-replicated` `persisted>=2`. Two durable copies before
250    ///   client ack. Zero RAM-only window; the gate stalls when no
251    ///   replica is connected. Compliance-driven venues.
252    ///
253    /// `--standalone` requires `local`. With `hybrid` or
254    /// `durably-replicated` and no connected replica the gate stalls —
255    /// the correct behaviour for a serious deployment that has lost
256    /// its replicas. See `docs/replication.md` for the operational
257    /// menu.
258    #[arg(long, value_enum, default_value_t = crate::durability_policy::DurabilityMode::Hybrid)]
259    pub durability_mode: crate::durability_policy::DurabilityMode,
260
261    /// Yield to the OS scheduler when pipeline threads are idle instead
262    /// of busy-spinning. Use on shared machines without isolated cores to
263    /// avoid starving other processes. Default (no flag) is busy-spin,
264    /// which gives lowest latency on isolated cores (isolcpus).
265    #[arg(long, default_value_t = false)]
266    pub yield_idle: bool,
267
268    // --- DPDK configuration (only used with --features dpdk) ---
269    /// DPDK EAL arguments (space-separated). Example: "-l 0-7 --huge-dir /dev/hugepages".
270    /// Passed directly to rte_eal_init. Only used when compiled with --features dpdk.
271    #[arg(long, default_value = "", allow_hyphen_values = true)]
272    pub dpdk_eal_args: String,
273
274    /// DPDK port IDs (comma-separated). For LACP bonds, pass both VF ports
275    /// (e.g., "0,1") so traffic arriving on either bond member is received.
276    #[arg(long, default_value = "0", value_delimiter = ',')]
277    pub dpdk_ports: Vec<u16>,
278
279    /// IPv4 address for the DPDK interface (e.g., "10.0.0.1").
280    #[arg(long, default_value = "10.0.0.1")]
281    pub dpdk_ip: String,
282
283    /// IPv4 prefix length for the DPDK interface. Default: 24.
284    #[arg(long, default_value_t = 24)]
285    pub dpdk_prefix_len: u8,
286
287    /// IPv4 gateway for the DPDK interface (optional, needed for cross-subnet traffic).
288    #[arg(long)]
289    pub dpdk_gateway: Option<String>,
290
291    /// Peer IPv4 used for the bifurcated `rte_flow` steering rule. When
292    /// set, the DPDK port opens in isolated mode and only IPv4 packets
293    /// sourced from this address are delivered into DPDK queue 0 —
294    /// everything else stays with the kernel netdev. Required for L3
295    /// setups that share the public NIC with the kernel (SSH, etc.).
296    #[arg(long)]
297    pub dpdk_peer_ip: Option<String>,
298
299    /// Gateway MAC (aa:bb:cc:dd:ee:ff) seeded into smoltcp for the
300    /// `--dpdk-gateway` IP. Required in L3 bifurcated mode because the
301    /// gateway's ARP reply would not match the steering rule and would
302    /// be eaten by the kernel. Source from `ip neigh` on the host.
303    #[arg(long)]
304    pub dpdk_gateway_mac: Option<String>,
305
306    /// MTU for the DPDK interface. Use 9000 for jumbo frames (6x fewer TCP
307    /// segments). Requires switch and PF MTU to be set accordingly.
308    #[arg(long, default_value_t = 1500)]
309    pub dpdk_mtu: usize,
310
311    /// VLAN ID for hardware VLAN strip/insert. Required in dedicated NIC
312    /// mode (dpdk-setup-dedicated.sh) where the kernel doesn't handle VLAN
313    /// tags. Not needed for SR-IOV mode (the PF handles VLAN tagging).
314    #[arg(long)]
315    pub dpdk_vlan: Option<u16>,
316
317    /// Address for the output event publisher. Subscribers connect here
318    /// to receive a real-time stream of all execution events (market data,
319    /// fills, cancellations). Ed25519 auth required (ReadOnly or above).
320    /// Omit to disable (ring has 1 consumer — identical to before).
321    #[arg(long)]
322    pub event_bind: Option<SocketAddr>,
323
324    /// Address for the health/liveness TCP endpoint. On connect, returns
325    /// a one-line status (`OK <conns> <journal_seq> <repl_lag>\n`) and
326    /// closes. No auth required. Set to empty string to disable.
327    #[arg(long, default_value = "127.0.0.1:9878")]
328    pub health_bind: Option<SocketAddr>,
329
330    /// TCP address for the operator admin endpoint. Authenticated with
331    /// operator keys (Ed25519 challenge-response, same as all other
332    /// admin handshakes). Accepts:
333    ///
334    /// - `PROMOTE\n` — replica → primary leadership transition (replica
335    ///   nodes only; rejected with ERR on a primary).
336    /// - `ROTATE\n` — archive the current journal segment at the next
337    ///   fsync boundary (any node where runtime rotation is enabled).
338    ///
339    /// Unset means no admin endpoint is listening; operators can still
340    /// rely on the size-driven rotation trigger (`--max-journal-mib`)
341    /// without exposing a port.
342    #[arg(long)]
343    pub admin_bind: Option<SocketAddr>,
344
345    /// Interval in milliseconds between automatic shadow snapshots. The
346    /// shadow stage replays journaled events on a cloned `A` and saves a
347    /// consistent snapshot at this cadence — no hot-path stall. Set to 0
348    /// to disable shadow snapshots entirely. Default: 3 000 000 ms (50 min).
349    #[arg(long, default_value_t = 3_000_000)]
350    pub snapshot_interval_ms: u64,
351
352    /// Path for shadow snapshots. Defaults to the journal path with a
353    /// `.snapshot` extension (same as the startup snapshot path).
354    #[arg(long)]
355    pub snapshot_path: Option<PathBuf>,
356
357    /// Cadence in milliseconds for the engine-internal scheduler tick.
358    /// The ingress thread (io_uring reader or DPDK poll thread) publishes
359    /// a `JournalEvent::Tick { now_ns }` at this interval so time-driven
360    /// tasks (GTD expiry, volatility halts, session transitions) fire in
361    /// deterministic, journaled lockstep. There is no separate tick
362    /// thread on either transport. Set to 0 to disable tick generation
363    /// entirely (useful for benchmarks that don't exercise time-driven
364    /// features).
365    ///
366    /// Defaults to 250 ms. Under load the matching stage advances its
367    /// scheduler clock at every-event resolution from `slot.timestamp_ns`
368    /// (microsecond precision), so the tick is only the safety net for
369    /// quiet periods. 250 ms keeps quiet-market scheduler firings within
370    /// a quarter-second of their deadline at a cost of ~4 events/sec of
371    /// journal traffic.
372    #[arg(long, default_value_t = 250)]
373    pub tick_interval_ms: u64,
374
375    /// Disable `mlockall(MCL_CURRENT | MCL_FUTURE)` at startup.
376    ///
377    /// By default the server locks all current and future pages into
378    /// RAM to prevent rare multi-millisecond stalls from page faults
379    /// or swap activity on the matching hot path. Locking requires
380    /// `CAP_IPC_LOCK` (or running as root) and `RLIMIT_MEMLOCK` raised
381    /// to a value larger than the process's RSS — the server raises
382    /// the rlimit itself, but only succeeds with the capability.
383    ///
384    /// Use `--no-mlock` for development / containerised runs where
385    /// the privilege isn't available; on a bare-metal exchange host
386    /// leave it on.
387    #[arg(long, default_value_t = false)]
388    pub no_mlock: bool,
389
390    /// Bind address for the control-plane raft RPC listener. Setting this
391    /// enables the control plane on this node: leader election runs and is
392    /// observable via the `melin_raft_*` gauges. Requires `--raft-node-id`
393    /// and a `--raft-peer` entry for this node; requires
394    /// `--replication-key` (peer links authenticate with the replication
395    /// key, same trust domain as the data plane). Raft always runs on
396    /// kernel TCP, including on DPDK nodes.
397    #[arg(long)]
398    pub raft_bind: Option<SocketAddr>,
399
400    /// This node's control-plane raft id (non-zero, unique per cluster).
401    #[arg(long)]
402    pub raft_node_id: Option<u64>,
403
404    /// A control-plane cluster member as `id@host:port#base64-pubkey`
405    /// (repeatable). Give **every node the same list, including an entry
406    /// for the node itself** — the self entry supplies the dialable
407    /// address peers use to reach it, and identical lists keep the
408    /// first-boot membership consistent. The pubkey pins the peer's
409    /// identity: a connection authenticated with a different key cannot
410    /// speak for that id.
411    #[arg(long)]
412    pub raft_peer: Vec<String>,
413
414    /// Directory for durable raft state (vote, log, membership).
415    /// Defaults to the journal path with a `.raft` extension. Must live
416    /// on the same durability class as the journal — losing it can
417    /// double-grant a vote (see docs/replication.md).
418    #[arg(long)]
419    pub raft_dir: Option<PathBuf>,
420
421    /// Act on control-plane election wins: a replica elected leader
422    /// promotes itself (journaling the election term as its fencing
423    /// epoch), and a serving node self-fences when the raft mesh shows
424    /// it superseded. Off by default — automatic failover is an explicit
425    /// operator policy decision. Requires `--raft-bind` and at least
426    /// three configured voters (a two-node cluster cannot elect after
427    /// losing either node, so automation would be dead weight with real
428    /// misconfiguration risk; see docs/replication.md).
429    #[arg(long, default_value_t = false)]
430    pub raft_auto_promote: bool,
431}
432
433/// Delegates to clap so `#[arg(default_value...)]` is the single source of
434/// truth for every default.  Used by the bench crate for struct-literal
435/// construction with `..ServerConfig::default()`.
436impl Default for ServerConfig {
437    fn default() -> Self {
438        // On the dpdk branch we spell out every field so that dpdk-specific
439        // fields (not known to clap on plain builds) get their defaults.
440        // Main uses `Self::parse_from(["melin-server"])` — the values below
441        // mirror those clap defaults plus the dpdk extras.
442        Self {
443            bind: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9876),
444            journal: PathBuf::from("melin.journal"),
445            snapshot: None,
446            cores: PipelineCores {
447                journal: 1,
448                matching: 2,
449                response: 3,
450                reader: 4,
451                repl_sender: 5,
452                event_publisher: 6,
453                shadow: 7,
454                repl_handler_0: 8,
455                repl_handler_1: 9,
456                journal_prep: 10,
457            },
458            group_commit_us: 0,
459            heartbeat_interval_secs: 10,
460            connection_timeout_secs: 30,
461            max_connections: 1024,
462            accounts: 2,
463            instruments: 2,
464            authorized_keys: PathBuf::from("authorized_keys"),
465            max_journal_mib: 256,
466            journal_writer: melin_journal::JournalWriterMode::default(),
467            max_orders_per_account: 10_000,
468            max_orders_per_second: 1_000,
469            max_orders_burst: 5_000,
470
471            replication_bind: None,
472            standalone: false,
473            replica_of: None,
474            replication_key: None,
475            replication_batch_size: 128,
476            max_journal_batch: 1024,
477            replication_heartbeat_secs: 5,
478            replication_pipeline_depth: DEFAULT_REPLICATION_PIPELINE_DEPTH,
479            replication_ring_size: 256,
480            durability_mode: crate::durability_policy::DurabilityMode::Hybrid,
481            yield_idle: false,
482            dpdk_eal_args: String::new(),
483            dpdk_peer_ip: None,
484            dpdk_gateway_mac: None,
485            dpdk_ports: vec![0],
486            dpdk_ip: "10.0.0.1".into(),
487            dpdk_prefix_len: 24,
488            dpdk_gateway: None,
489            dpdk_mtu: 1500,
490            dpdk_vlan: None,
491            event_bind: None,
492            health_bind: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9878)),
493            admin_bind: None,
494            snapshot_interval_ms: 3_000_000,
495            snapshot_path: None,
496            tick_interval_ms: 250,
497            no_mlock: false,
498            raft_bind: None,
499            raft_node_id: None,
500            raft_peer: Vec::new(),
501            raft_dir: None,
502            raft_auto_promote: false,
503        }
504    }
505}
506
507impl ServerConfig {
508    /// Group commit delay as a Duration.
509    pub fn group_commit_delay(&self) -> std::time::Duration {
510        std::time::Duration::from_micros(self.group_commit_us)
511    }
512
513    /// Heartbeat interval as a Duration. Returns `None` if disabled (0).
514    pub fn heartbeat_interval(&self) -> Option<std::time::Duration> {
515        if self.heartbeat_interval_secs == 0 {
516            None
517        } else {
518            Some(std::time::Duration::from_secs(self.heartbeat_interval_secs))
519        }
520    }
521
522    /// Connection timeout as a Duration. Returns `None` if disabled (0).
523    pub fn connection_timeout(&self) -> Option<std::time::Duration> {
524        if self.connection_timeout_secs == 0 {
525            None
526        } else {
527            Some(std::time::Duration::from_secs(self.connection_timeout_secs))
528        }
529    }
530
531    /// Snapshot path for the shadow stage. Uses the explicit `--snapshot-path`
532    /// if set, otherwise derives from the journal path with `.snapshot` extension.
533    pub fn shadow_snapshot_path(&self) -> PathBuf {
534        self.snapshot_path
535            .clone()
536            .unwrap_or_else(|| self.journal.with_extension("snapshot"))
537    }
538
539    /// Tick generator cadence as a `Duration`. Returns `None` when the tick
540    /// thread is disabled (`--tick-interval-ms 0`).
541    pub fn tick_interval(&self) -> Option<std::time::Duration> {
542        if self.tick_interval_ms == 0 {
543            None
544        } else {
545            Some(std::time::Duration::from_millis(self.tick_interval_ms))
546        }
547    }
548}
549
550/// Core assignments for pipeline threads.
551///
552/// All fields are always stored; repl-sender is only used when replication is
553/// enabled, event-publisher only when `--event-bind` is set, and shadow only
554/// when `--snapshot-interval-ms` > 0. repl-handler-0/1 are spawned on replica
555/// connect, not at startup. 0 = unpinned (OS scheduled) for any field.
556#[derive(Debug, Clone, Copy)]
557pub struct PipelineCores {
558    pub journal: usize,
559    pub matching: usize,
560    pub response: usize,
561    /// io_uring reader thread (TCP) or DPDK poll thread.
562    pub reader: usize,
563    pub repl_sender: usize,
564    pub event_publisher: usize,
565    pub shadow: usize,
566    /// Core for replication handler thread 0. 0 = unpinned (OS scheduled).
567    pub repl_handler_0: usize,
568    /// Core for replication handler thread 1. 0 = unpinned (OS scheduled).
569    pub repl_handler_1: usize,
570    /// Core for the journal segment preparer (background staging of the
571    /// next segment). 0 = unpinned (OS scheduled). Optional tenth entry
572    /// of `--cores` — omitted (9-entry) values leave it unpinned so
573    /// existing explicit configurations keep their exact behavior.
574    pub journal_prep: usize,
575}
576
577impl PipelineCores {
578    /// Compact pipeline layout that fits on `num_cpus` physical+logical
579    /// cores while reserving one core for an external client (bench or
580    /// reader). Used by the embedded bench so it doesn't HT-collide with
581    /// the pipeline cores.
582    ///
583    /// On the default workstation `Default` layout the journal (core 1),
584    /// matching (core 2), and shadow/repl_handler cores (7/8/9) are HT
585    /// siblings of each other on an 8-core (16-thread) Ryzen — a layout
586    /// designed for a 10-physical-core box. This packs everything into
587    /// the lower physical cores and leaves core 10+ free.
588    ///
589    /// Returns the chosen layout and the recommended bench/client core.
590    /// Errors if `num_cpus` is too small to host the pipeline.
591    pub fn compact(num_cpus: usize) -> Result<(Self, usize), String> {
592        // Need: journal, matching, response, event_publisher, shadow,
593        // reader (one each) + 1 reserved for bench = 7 cores. Plus
594        // core 0 for the OS / IRQs. So minimum 8 logical cores.
595        if num_cpus < 8 {
596            return Err(format!(
597                "compact layout needs >= 8 logical cores; have {num_cpus}"
598            ));
599        }
600        let cores = PipelineCores {
601            journal: 1,
602            matching: 2,
603            response: 3,
604            reader: 4,
605            // repl_sender not spawned in compact (no --replication-bind).
606            repl_sender: 0,
607            event_publisher: 5,
608            shadow: 6,
609            // repl handlers not spawned in compact; 0 = unpinned.
610            repl_handler_0: 0,
611            repl_handler_1: 0,
612            // Preparer unpinned in compact — the embedded bench doesn't
613            // rotate at production cadence, and raising compact's core
614            // minimum for it isn't worth a core.
615            journal_prep: 0,
616        };
617        Ok((cores, 7))
618    }
619}
620
621/// Parse "j,m,r,rd,rs,ep,sh,h0,h1" into `PipelineCores` for pipeline core affinity.
622fn parse_cores(s: &str) -> Result<PipelineCores, String> {
623    let parts: Vec<&str> = s.split(',').collect();
624    // 9 or 10 entries: the tenth (journal-prep) was added later, so a
625    // 9-entry value — every explicit operator configuration written
626    // before it existed — must keep parsing, with journal-prep left
627    // unpinned.
628    if parts.len() != 9 && parts.len() != 10 {
629        return Err(format!(
630            "expected 9 or 10 comma-separated core IDs (journal,matching,response,reader,repl-sender,event-publisher,shadow,repl-handler-0,repl-handler-1[,journal-prep]), got {}",
631            parts.len()
632        ));
633    }
634    let parse = |p: &str| {
635        p.parse::<usize>()
636            .map_err(|_| format!("invalid core ID: {p}"))
637    };
638    Ok(PipelineCores {
639        journal: parse(parts[0])?,
640        matching: parse(parts[1])?,
641        response: parse(parts[2])?,
642        reader: parse(parts[3])?,
643        repl_sender: parse(parts[4])?,
644        event_publisher: parse(parts[5])?,
645        shadow: parse(parts[6])?,
646        repl_handler_0: parse(parts[7])?,
647        repl_handler_1: parse(parts[8])?,
648        journal_prep: parts.get(9).map(|p| parse(p)).transpose()?.unwrap_or(0),
649    })
650}
651
652/// Run the server with automatic transport selection.
653///
654/// Installs SIGINT/SIGTERM handlers, optionally locks memory, selects
655/// the transport (DPDK under the `dpdk` feature, kernel TCP otherwise),
656/// and enters the pipeline loop. This is the main entry point for
657/// application binaries.
658///
659/// For callers that need a pre-bound listener or an externally
660/// controlled shutdown flag (e.g. benchmarks), use
661/// [`run_with_listener`] instead.
662pub fn run<A, F, D, E>(
663    config: ServerConfig,
664    factory: F,
665    decoder: D,
666    encoder: E,
667    event_publisher: Option<EventPublisherFn<A>>,
668) -> Result<(), Box<dyn std::error::Error>>
669where
670    A: Application + Send + 'static,
671    A::Event: Send + Sync + 'static,
672    A::Report: Send + 'static,
673    A::QueryResponse: Send + 'static,
674    F: AppFactory<App = A> + 'static,
675    D: RequestDecoder<Event = A::Event> + 'static,
676    E: ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
677{
678    let shutdown = Arc::new(AtomicBool::new(false));
679    crate::process::install_shutdown_handler(&shutdown);
680
681    if !config.no_mlock {
682        crate::process::try_lock_memory();
683    }
684
685    #[cfg(feature = "dpdk")]
686    {
687        run_dpdk(
688            config,
689            Arc::new(factory),
690            Arc::new(decoder),
691            Arc::new(encoder),
692            event_publisher,
693            shutdown,
694        )
695    }
696
697    #[cfg(not(feature = "dpdk"))]
698    {
699        let listener = melin_wire_protocol::tcp::BlockingTcpListener::bind(config.bind)?;
700        run_tcp(
701            listener,
702            config,
703            Arc::new(factory),
704            Arc::new(decoder),
705            Arc::new(encoder),
706            event_publisher,
707            shutdown,
708        )
709    }
710}
711
712/// Run the server with a caller-supplied listener.
713///
714/// Use this when the listener must be pre-bound before the server
715/// starts (e.g. benchmarks that need the kernel backlog to queue
716/// connections immediately).
717///
718/// Set `shutdown` to `true` to trigger a clean shutdown of all
719/// pipeline threads.
720pub fn run_with_listener<A, L, F, D, E>(
721    listener: L,
722    config: ServerConfig,
723    factory: F,
724    decoder: D,
725    encoder: E,
726    event_publisher: Option<EventPublisherFn<A>>,
727    shutdown: Arc<AtomicBool>,
728) -> Result<(), Box<dyn std::error::Error>>
729where
730    A: Application + Send + 'static,
731    A::Event: Send + Sync + 'static,
732    A::Report: Send + 'static,
733    A::QueryResponse: Send + 'static,
734    L: BlockingTransportListener,
735    F: AppFactory<App = A> + 'static,
736    D: RequestDecoder<Event = A::Event> + 'static,
737    E: ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
738{
739    run_tcp(
740        listener,
741        config,
742        Arc::new(factory),
743        Arc::new(decoder),
744        Arc::new(encoder),
745        event_publisher,
746        shutdown,
747    )
748}
749
750fn run_tcp<A, L>(
751    listener: L,
752    config: ServerConfig,
753    factory: Arc<dyn AppFactory<App = A>>,
754    decoder: RequestDecoderArc<A>,
755    encoder: ResponseEncoderArc<A>,
756    event_publisher: Option<EventPublisherFn<A>>,
757    shutdown: Arc<AtomicBool>,
758) -> Result<(), Box<dyn std::error::Error>>
759where
760    A: Application + Send + 'static,
761    A::Event: Send + Sync + 'static,
762    A::Report: Send + 'static,
763    A::QueryResponse: Send + 'static,
764    L: BlockingTransportListener,
765{
766    match config.journal_writer {
767        melin_journal::JournalWriterMode::Buffered => run_impl::<A, L, BufferedWriter<A::Event>>(
768            listener,
769            config,
770            factory,
771            decoder,
772            encoder,
773            event_publisher,
774            shutdown,
775        ),
776        melin_journal::JournalWriterMode::Sector => run_impl::<A, L, SectorWriter<A::Event>>(
777            listener,
778            config,
779            factory,
780            decoder,
781            encoder,
782            event_publisher,
783            shutdown,
784        ),
785    }
786}
787
788fn run_impl<A, L, W>(
789    listener: L,
790    config: ServerConfig,
791    factory: Arc<dyn AppFactory<App = A>>,
792    decoder: RequestDecoderArc<A>,
793    encoder: ResponseEncoderArc<A>,
794    event_publisher: Option<EventPublisherFn<A>>,
795    shutdown: Arc<AtomicBool>,
796) -> Result<(), Box<dyn std::error::Error>>
797where
798    A: Application + Send + 'static,
799    A::Event: Send + Sync + 'static,
800    A::Report: Send + 'static,
801    A::QueryResponse: Send + 'static,
802    L: BlockingTransportListener,
803    W: JournalWrite<A::Event> + Send + 'static,
804    JournalStage<A::Event, W>: JournalStageRun<A::Event, Writer = W>,
805{
806    // Shared durability-mode atomic, constructed once per process and
807    // threaded through both modes. Wiring it on the replica path
808    // (where the live node has no response stage) lets an operator
809    // pre-stage the post-promotion mode with `DURABILITY <mode>`
810    // before issuing `PROMOTE`; the same `Arc` becomes the response
811    // stage's source of truth after the replica → primary transition.
812    let durability_mode_atomic = Arc::new(AtomicU8::new(config.durability_mode.as_u8()));
813
814    // Validate before the bind below so an invalid config has no side
815    // effects. `run_as_primary` re-checks (it is also reached from the
816    // DPDK promotion fallback, which does not pass through here).
817    if config.replication_bind.is_some() && config.standalone {
818        return Err("--replication-bind and --standalone are mutually exclusive".into());
819    }
820
821    // Bind the replication listener up front, before any pipeline thread
822    // exists and regardless of role: a bad or stolen --replication-bind
823    // fails the boot cleanly (nothing spawned yet to leak), and a
824    // replica holds its configured port from boot, so a later promotion
825    // can neither lose the port to another process nor die on a
826    // mid-failover bind. Pending connections just sit in the backlog
827    // until a promotion starts the sender's accept loop — replicas dial
828    // the configured primary, not each other, so nothing queues there
829    // in normal operation.
830    let repl_listener = config
831        .replication_bind
832        .map(bind_replication_listener)
833        .transpose()?;
834
835    // Replica mode: connect to primary, receive journal stream, replay.
836    // Must run before init_engine — the replica's journal is created from
837    // the primary's segment lineage during the replication handshake.
838    if let Some(primary_addr) = config.replica_of {
839        info!(primary = %primary_addr, "starting in replica mode");
840
841        // Load replication signing key.
842        let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
843            std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
844        })?;
845        let signing_key = {
846            let seed = std::fs::read(replication_key_path).map_err(|e| {
847                std::io::Error::other(format!(
848                    "failed to read replication key {}: {e}",
849                    replication_key_path.display()
850                ))
851            })?;
852            if seed.len() != 32 {
853                return Err(format!(
854                    "replication key must be 32 bytes, got {} ({})",
855                    seed.len(),
856                    replication_key_path.display()
857                )
858                .into());
859            }
860            let mut bytes = [0u8; 32];
861            bytes.copy_from_slice(&seed);
862            ed25519_dalek::SigningKey::from_bytes(&bytes)
863        };
864
865        // Load authorized keys early — the admin listener needs them for
866        // Ed25519 challenge-response auth (operator keys only).
867        let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
868        info!(
869            keys = authorized_keys.len(),
870            path = %config.authorized_keys.display(),
871            "loaded authorized keys (replica mode)"
872        );
873
874        // The replica's control-plane bundle: promotion request (admin
875        // PROMOTE / raft auto-promotion), tip readiness + advertised
876        // journal tip (vote recency), primary link state, and the
877        // primary's advertised acking mode. Constructed before
878        // mode-detection so it survives a replica → primary transition.
879        // The rotate flag is re-wired into the new primary's journal
880        // stage by `run_as_primary`.
881        let control = crate::replication::ReplicaControlPlane::new();
882        let promotion_request = control.promote.clone();
883        let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
884        let _admin_handle = config
885            .admin_bind
886            .map(|addr| {
887                crate::admin::spawn(
888                    addr,
889                    Some(promotion_request.clone()),
890                    rotate_flag.clone(),
891                    Some(Arc::clone(&durability_mode_atomic)),
892                    Arc::clone(&shutdown),
893                    Arc::clone(&authorized_keys),
894                )
895            })
896            .transpose()?;
897
898        // Shared fencing state, created before mode detection so it
899        // survives a replica → primary transition. Seeded at epoch 0; the
900        // receiver raises it from the replica's recovered journal and the
901        // replication stream, and a promotion bumps it by injecting an
902        // `EpochBump`. Carried into `run_as_primary` post-promotion.
903        let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
904
905        // Control-plane raft (observational election). Spawned before the
906        // receive loop so the driver — like the fence state and admin
907        // flags — survives a replica → primary promotion untouched: it
908        // shares the process shutdown flag and runs on its own thread.
909        // The advertised tip isn't trustworthy until journal recovery
910        // seeds the fence epoch and sequence, so `control.tip_ready`
911        // starts false and the receiver flips it — until then the driver
912        // refuses to grant votes. The receiver owns the sequence half
913        // until a promotion hands it to the new primary's journal stage.
914        let journal_tip = control.journal_tip.clone();
915        let mut raft = match crate::raft::build_raft_config(&config)? {
916            None => crate::raft::RaftDriverGuard::disabled(&shutdown),
917            Some(cfg) => crate::raft::spawn_raft_driver(
918                cfg,
919                config.raft_auto_promote,
920                &signing_key,
921                &authorized_keys,
922                &fence_state,
923                journal_tip.clone(),
924                Arc::clone(&control.tip_ready),
925                // A replica claims to be serving once a promotion is in
926                // flight (see SupersessionPolicy).
927                {
928                    let promote = control.promote.clone();
929                    Arc::new(move || promote.is_requested())
930                },
931                &shutdown,
932            )?,
933        };
934        // Act on election wins when the operator opted in. Genesis
935        // primaries have nothing to promote — replica paths only. The
936        // guard joins the thread on every exit path.
937        if config.raft_auto_promote
938            && let Some(status) = raft.status()
939        {
940            raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
941                status,
942                control.clone(),
943                Arc::clone(&fence_state),
944                Arc::clone(&durability_mode_atomic),
945                Arc::clone(&shutdown),
946            ));
947        }
948        let raft_status = raft.status();
949        // A raft-enabled replica exposes election gauges on --health-bind;
950        // stopped explicitly before promotion so run_as_primary can rebind
951        // the port, and by its guard on every other exit path.
952        let mut replica_health = crate::raft::spawn_replica_health(
953            &config,
954            &fence_state,
955            raft_status.as_ref(),
956            Arc::clone(&control.pipeline_healthy),
957        )?;
958
959        // No local rotation triggers on the replica side: segment
960        // rotation is primary-driven (the replica adopts the boundaries
961        // announced over the replication stream), so replica journals
962        // stay bitwise mirrors of the primary's. `--max-journal-mib`
963        // and the admin `ROTATE` command only act on primaries.
964
965        match crate::replication::run_receiver::<A, W>(
966            primary_addr,
967            &config.journal,
968            &signing_key,
969            &shutdown,
970            &control,
971            config.snapshot_interval_ms,
972            config.shadow_snapshot_path(),
973            config.cores,
974            config.group_commit_delay(),
975            config.replication_pipeline_depth,
976            !config.yield_idle,
977            Arc::clone(&factory),
978            Arc::clone(&fence_state),
979        )? {
980            // Clean shutdown — the raft and health guards tear down on drop.
981            None => return Ok(()),
982            Some((mut exchange, writer)) => {
983                // Promotion! Transition to primary mode. Bump the epoch so a
984                // paused/partitioned ex-primary is fenced when it reconnects.
985                info!("replica promoted — transitioning to primary");
986                // Release --health-bind before run_as_primary rebinds it
987                // with the full primary health state.
988                replica_health.stop();
989                <A as Application>::prefault(&mut exchange);
990
991                // A ROTATE received while this node was a replica latched
992                // the flag but rotated nothing (rotation is primary-driven
993                // and replicas follow the primary's boundaries). Clear it
994                // so the stale latch can't fire a surprise rotation on the
995                // first post-promotion fsync.
996                if let Some(ref flag) = rotate_flag {
997                    flag.store(false, Ordering::Release);
998                }
999
1000                // The raft guard drops — stopping the driver and joining
1001                // the (already exited) promotion thread — after this
1002                // returns, so the driver serves elections and the fencing
1003                // channel for the whole primary tenure.
1004                return run_as_primary::<A, L, W>(
1005                    exchange,
1006                    writer,
1007                    listener,
1008                    repl_listener,
1009                    &config,
1010                    &*factory,
1011                    decoder,
1012                    encoder,
1013                    event_publisher,
1014                    Arc::clone(&shutdown),
1015                    authorized_keys,
1016                    false, // no seeding needed — state comes from replication
1017                    rotate_flag,
1018                    durability_mode_atomic,
1019                    fence_state,
1020                    promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
1021                    raft_status,
1022                    journal_tip,
1023                );
1024            }
1025        }
1026    }
1027    // Load authorized keys for challenge-response authentication.
1028    let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
1029    info!(
1030        keys = authorized_keys.len(),
1031        path = %config.authorized_keys.display(),
1032        "loaded authorized keys"
1033    );
1034
1035    // Spawn the admin listener once if configured. PROMOTE is rejected
1036    // (with ERR) on a primary because no promote flag is wired here —
1037    // promotion is meaningful only on a replica. ROTATE is wired
1038    // whenever the admin endpoint is configured.
1039    let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
1040    let _admin_handle = config
1041        .admin_bind
1042        .map(|addr| {
1043            crate::admin::spawn(
1044                addr,
1045                None,
1046                rotate_flag.clone(),
1047                Some(Arc::clone(&durability_mode_atomic)),
1048                Arc::clone(&shutdown),
1049                Arc::clone(&authorized_keys),
1050            )
1051        })
1052        .transpose()?;
1053
1054    // Initialize or recover the app. `needs_seeding` is true on first
1055    // startup — seed events will flow through the pipeline later.
1056    let (mut exchange, writer, needs_seeding, recovered_epoch) =
1057        init_engine::<A, W>(&config, &*factory)?;
1058
1059    // Pre-fault any application-owned memory (slabs, indices) so page
1060    // faults happen now, not on the hot path. Default trait impl is a
1061    // no-op; `Exchange` overrides.
1062    <A as Application>::prefault(&mut exchange);
1063
1064    // A primary booting directly (not via promotion) keeps whatever epoch
1065    // its journal recovered; no bump.
1066    let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
1067        recovered_epoch,
1068    ));
1069
1070    // Control-plane raft (observational election). The primary needs the
1071    // replication signing key only when raft is on — peer links
1072    // authenticate with it (build_raft_config enforces the flag).
1073    // A primary's fence epoch is already recovered here, so its tip is
1074    // trustworthy from the moment the driver starts. Seed the advertised
1075    // sequence from the recovered journal for the same reason — the
1076    // pipeline's journal stage takes the handle over once it runs.
1077    let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
1078        melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
1079    );
1080    let raft = match crate::raft::build_raft_config(&config)? {
1081        None => crate::raft::RaftDriverGuard::disabled(&shutdown),
1082        Some(cfg) => {
1083            let signing_key = load_replication_key(&config)?;
1084            crate::raft::spawn_raft_driver(
1085                cfg,
1086                config.raft_auto_promote,
1087                &signing_key,
1088                &authorized_keys,
1089                &fence_state,
1090                journal_tip.clone(),
1091                Arc::new(AtomicBool::new(true)),
1092                // A primary always claims to be serving.
1093                Arc::new(|| true),
1094                &shutdown,
1095            )?
1096        }
1097    };
1098    let raft_status = raft.status();
1099
1100    // The raft guard drops — stopping the driver — after this returns.
1101    run_as_primary::<A, L, W>(
1102        exchange,
1103        writer,
1104        listener,
1105        repl_listener,
1106        &config,
1107        &*factory,
1108        decoder,
1109        encoder,
1110        event_publisher,
1111        Arc::clone(&shutdown),
1112        authorized_keys,
1113        needs_seeding,
1114        rotate_flag,
1115        durability_mode_atomic,
1116        fence_state,
1117        None, // not promoted — no EpochBump injection
1118        raft_status,
1119        journal_tip,
1120    )
1121}
1122
1123/// Bind the kernel-TCP replication listener, non-blocking (the sender's
1124/// accept loop polls the shutdown flag between accepts). The kernel path
1125/// binds at boot (`run_impl`) so a failure cannot leak pipeline threads
1126/// and a promotion cannot fail on it; the DPDK promotion fallback binds
1127/// at promotion, where its kernel listeners come up.
1128fn bind_replication_listener(
1129    addr: std::net::SocketAddr,
1130) -> Result<crate::replication::ReplicationListener, Box<dyn std::error::Error>> {
1131    let listener = std::net::TcpListener::bind(addr)
1132        .map_err(|e| format!("failed to bind replication listener on {addr}: {e}"))?;
1133    // The newtype sets non-blocking — the invariant the sender's accept
1134    // loop needs is enforced by construction, not by this call site.
1135    let listener = crate::replication::ReplicationListener::new(listener)
1136        .map_err(|e| format!("failed to set non-blocking on replication listener: {e}"))?;
1137    info!(%addr, "replication listener bound");
1138    Ok(listener)
1139}
1140
1141/// Load the Ed25519 replication signing key from `--replication-key` —
1142/// shared by the replica connect path and raft-enabled primaries.
1143fn load_replication_key(
1144    config: &ServerConfig,
1145) -> Result<ed25519_dalek::SigningKey, Box<dyn std::error::Error>> {
1146    let path = config.replication_key.as_ref().ok_or_else(|| {
1147        std::io::Error::other("--replication-key is required (replica mode or --raft-bind)")
1148    })?;
1149    let seed = std::fs::read(path).map_err(|e| {
1150        std::io::Error::other(format!(
1151            "failed to read replication key {}: {e}",
1152            path.display()
1153        ))
1154    })?;
1155    if seed.len() != 32 {
1156        return Err(format!(
1157            "replication key must be 32 bytes, got {} ({})",
1158            seed.len(),
1159            path.display()
1160        )
1161        .into());
1162    }
1163    let mut bytes = [0u8; 32];
1164    bytes.copy_from_slice(&seed);
1165    Ok(ed25519_dalek::SigningKey::from_bytes(&bytes))
1166}
1167
1168/// Run the server as a primary: build the disruptor pipeline, spawn
1169/// pipeline threads, optionally seed instruments/accounts, then accept
1170/// client connections.
1171///
1172/// Control event for the response stage. The io_uring response path reads
1173/// `fd` for I/O; the `writer` keeps the fd alive via ownership.
1174pub use crate::ControlEvent;
1175
1176/// Joinable handles for every long-lived thread spawned by a primary.
1177/// Optional handles are `None` when their feature is disabled (e.g.,
1178/// no replication, no health endpoint) or unsupported on a transport
1179/// (e.g., DPDK runs without an event publisher or shadow snapshotter).
1180struct PipelineHandles<A: Send + 'static, W: Send + 'static> {
1181    journal: std::thread::JoinHandle<Result<W, JournalError>>,
1182    matching: std::thread::JoinHandle<A>,
1183    response: std::thread::JoinHandle<()>,
1184    replication: Option<std::thread::JoinHandle<()>>,
1185    event_publisher: Option<std::thread::JoinHandle<()>>,
1186    shadow: Option<std::thread::JoinHandle<()>>,
1187    health: Option<std::thread::JoinHandle<()>>,
1188    // No standalone tick thread on either transport: io_uring emits ticks
1189    // from inside the reader (via IORING_OP_TIMEOUT) and DPDK emits them
1190    // from inside the poll thread (via a wall-clock check between bursts).
1191}
1192
1193/// Drain the pipeline and join every worker thread, surfacing panics
1194/// and journal-stage errors as a single `pipeline failure` return.
1195///
1196/// `extras` is a list of pre-joined results (used by the DPDK path,
1197/// which joins its poll threads before draining the pipeline).
1198fn shutdown_pipeline_stages<A: Send + 'static, W: Send + 'static>(
1199    handles: PipelineHandles<A, W>,
1200    extras: Vec<(String, std::thread::Result<()>)>,
1201    pipeline_healthy: &AtomicBool,
1202    shutdown: &AtomicBool,
1203) -> Result<(), Box<dyn std::error::Error>> {
1204    info!("shutdown: draining pipeline");
1205    pipeline_healthy.store(false, Ordering::Relaxed);
1206    shutdown.store(true, Ordering::Relaxed);
1207
1208    let mut thread_panicked = false;
1209    let mut check_join = |name: &str, result: std::thread::Result<()>| {
1210        if let Err(panic) = result {
1211            let msg = panic
1212                .downcast_ref::<&str>()
1213                .copied()
1214                .or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
1215                .unwrap_or("<non-string panic>");
1216            error!(thread = name, message = msg, "pipeline thread panicked");
1217            thread_panicked = true;
1218        }
1219    };
1220
1221    let journal_result = handles.journal.join();
1222    let journal_failed = matches!(&journal_result, Ok(Err(_)));
1223    if let Ok(Err(ref e)) = journal_result {
1224        error!(thread = "journal", error = %e, "journal stage returned error");
1225    }
1226    check_join("journal", journal_result.map(|_| ()));
1227    check_join("matching", handles.matching.join().map(|_| ()));
1228    check_join("response", handles.response.join());
1229    for (name, r) in extras {
1230        check_join(&name, r);
1231    }
1232    if let Some(h) = handles.replication {
1233        check_join("replication-sender", h.join());
1234    }
1235    if let Some(h) = handles.event_publisher {
1236        check_join("event-publisher", h.join());
1237    }
1238    if let Some(h) = handles.shadow {
1239        check_join("shadow", h.join());
1240    }
1241    if let Some(h) = handles.health {
1242        check_join("health", h.join());
1243    }
1244
1245    // After every stage thread has joined, dump the per-stage latency
1246    // histograms to stderr. No-op when `latency-trace` is disabled.
1247    melin_transport_core::trace::print_report_all();
1248
1249    if thread_panicked || journal_failed {
1250        error!("shutdown complete (with pipeline failure)");
1251        return Err("pipeline failure".into());
1252    }
1253
1254    info!("shutdown complete");
1255    Ok(())
1256}
1257
1258/// Used by both the normal primary startup path and the promotion path
1259/// (replica → primary transition).
1260///
1261/// `rotate_flag` is the shared `AtomicBool` toggled by the admin
1262/// endpoint (or `None` if no admin endpoint was configured). On the
1263/// promotion path the replica's journal stage is torn down and a new
1264/// primary stage is built here; passing the flag through means the
1265/// admin endpoint, which was spawned once at process start, keeps
1266/// driving the new stage's rotation.
1267#[allow(clippy::too_many_arguments)]
1268fn run_as_primary<A, L, W>(
1269    exchange: A,
1270    writer: W,
1271    mut listener: L,
1272    // Pre-bound replication listener (non-blocking by construction),
1273    // `Some` iff `--replication-bind` is set. Bound in `run_impl` before
1274    // any pipeline thread exists so a bind failure cannot leak threads,
1275    // and held from boot on replicas so a promotion cannot fail on it.
1276    repl_listener: Option<crate::replication::ReplicationListener>,
1277    config: &ServerConfig,
1278    factory: &dyn AppFactory<App = A>,
1279    decoder: RequestDecoderArc<A>,
1280    encoder: ResponseEncoderArc<A>,
1281    event_publisher: Option<EventPublisherFn<A>>,
1282    shutdown: Arc<AtomicBool>,
1283    authorized_keys: Arc<AuthorizedKeys>,
1284    needs_seeding: bool,
1285    rotate_flag: Option<Arc<AtomicBool>>,
1286    durability_mode_atomic: Arc<AtomicU8>,
1287    fence_state: Arc<melin_transport_core::fence::FenceState>,
1288    promotion: Option<u64>,
1289    raft_status: Option<Arc<melin_transport_core::health::RaftStatus>>,
1290    // Control-plane advertised tip. This primary's journal stage becomes
1291    // its writer (the raft driver keeps reading the same handle across a
1292    // promotion) — see `AdvertisedJournalTip`.
1293    journal_tip: melin_transport_core::AdvertisedJournalTip,
1294) -> Result<(), Box<dyn std::error::Error>>
1295where
1296    A: Application + Send + 'static,
1297    A::Event: Send + Sync + 'static,
1298    A::Report: Send + 'static,
1299    A::QueryResponse: Send + 'static,
1300    L: BlockingTransportListener,
1301    W: JournalWrite<A::Event> + Send + 'static,
1302    JournalStage<A::Event, W>: JournalStageRun<A::Event, Writer = W>,
1303{
1304    // Active connection counter shared between accept loop, response
1305    // stage, and matching stage (for stats queries).
1306    // Incremented on successful auth, decremented on disconnect.
1307    // Used to enforce max_connections (SEC-02).
1308    let active_connections = Arc::new(AtomicU64::new(0));
1309
1310    // Determine replication mode. Both trading and skip-order-exec
1311    // builds ship the full durable transport (journal + replication +
1312    // shadow), so the
1313    // same config knob drives either binary.
1314    let enable_replication = config.replication_bind.is_some();
1315    if enable_replication && config.standalone {
1316        return Err("--replication-bind and --standalone are mutually exclusive".into());
1317    }
1318    // `--standalone` declares "no replicas ever" — only `local` can be
1319    // satisfied. `hybrid` and `durably-replicated` would stall the gate
1320    // forever; reject loudly at startup with the fix in the message.
1321    if config.standalone
1322        && config.durability_mode != crate::durability_policy::DurabilityMode::Local
1323    {
1324        return Err(format!(
1325            "--standalone requires --durability-mode local; got `{}` (this mode needs at least one connected replica)",
1326            config.durability_mode,
1327        )
1328        .into());
1329    }
1330    // Clone the exchange for the shadow snapshot stage before the pipeline
1331    // consumes it. Uses snapshot_state() + restore_state() round-trip since
1332    // `A` doesn't implement Clone (internal data structures are complex).
1333    let enable_shadow = config.snapshot_interval_ms > 0;
1334    let shadow_exchange = if enable_shadow {
1335        Some(<A as Application>::clone_via_snapshot(&exchange)?)
1336    } else {
1337        None
1338    };
1339
1340    // Build the disruptor pipeline with optional replication consumer.
1341    // Event publisher is trading-only (market-data book mirrors); the
1342    // skip-order-exec build silently ignores `--event-bind` so the
1343    // same invocation works against either binary.
1344    // The caller (binary) decides whether an event-publisher fn is
1345    // available; we only allocate the consumer slot when both the fn is
1346    // wired AND `--event-bind` is set.
1347    let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
1348    let Pipeline {
1349        input_producer,
1350        journal_stage,
1351        matching_stage,
1352        mut output_consumers,
1353        events_processed,
1354        input_cursor,
1355        replication_consumers,
1356        replicas_connected,
1357        shadow_consumer,
1358        chain_hash_lock,
1359        replication_ring_progress,
1360        cursors,
1361    } = build_pipeline_with_replication(
1362        exchange,
1363        writer,
1364        config.group_commit_delay(),
1365        Arc::clone(&active_connections),
1366        enable_replication,
1367        config.max_journal_batch,
1368        config.replication_ring_size,
1369        !config.yield_idle,
1370        enable_event_publisher,
1371        enable_shadow,
1372        Arc::clone(&fence_state),
1373    );
1374    // Ring-position cursors for the seed-drain gate below (Acquire loads,
1375    // stronger than the bundle's monitoring reads). The durability gate and
1376    // the replication sender pull their typed handles straight from
1377    // `cursors`; the health endpoint takes `cursors` itself — the quorum
1378    // and fastest-replica gauges are derived from the bundle's per-slot
1379    // cursors at read time.
1380    let journal_cursor = cursors.journal_ring_arc();
1381    let matching_cursor = cursors.matching_ring_arc();
1382
1383    // Consumer 0 is always the response stage. Consumer 1 (if present)
1384    // is the event publisher — only created when --event-bind is set.
1385    let output_consumer = output_consumers.remove(0);
1386    let event_publisher_consumer = if enable_event_publisher {
1387        Some(output_consumers.remove(0))
1388    } else {
1389        None
1390    };
1391
1392    // Control channel for connect/disconnect events → response stage.
1393    let (control_tx, control_rx) = std::sync::mpsc::channel();
1394
1395    // Spawn the io_uring reader thread. A single reader uses multishot RECV
1396    // to multiplex every TCP connection on the server. Pinned to
1397    // cores.reader. The matching stage is the throughput limit, so a
1398    // second reader would not raise throughput — only re-introduce the
1399    // multi-producer ordering race on the input ring.
1400    let connection_timeout = config.connection_timeout();
1401    let heartbeat_interval = config.heartbeat_interval();
1402
1403    // Seed events flow through the disruptor like regular events so they're
1404    // journaled, replicated, and processed by the matching engine via the
1405    // normal pipeline. The input ring is single-producer: main publishes
1406    // seeds through `input_producer`, then moves it into the reader thread
1407    // which becomes the sole steady-state producer. No cloning required.
1408    let mut input_producer = input_producer;
1409
1410    // Spawn pipeline OS threads.
1411    let cores = config.cores;
1412
1413    // Wire runtime journal rotation into the journal stage. The flag is
1414    // shared with the admin endpoint (spawned once at process start in
1415    // `run`) — passing it through here means PROMOTE-then-
1416    // ROTATE on a freshly-promoted node still drives rotation against
1417    // the new primary's stage. The size threshold uses the same
1418    // `max_journal_mib` knob that drives startup rotation.
1419    let mut journal_stage = journal_stage;
1420    let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
1421    journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
1422    journal_stage.set_preparer_core(config.cores.journal_prep);
1423    // On a primary the journal stage owns the control-plane advertised
1424    // tip (durable cursor after each fsync batch). On the promotion path
1425    // this takes over the handle the receiver was advancing.
1426    journal_stage.set_advertised_tip_publisher(journal_tip.clone());
1427    if config.max_journal_mib > 0 {
1428        info!(
1429            max_journal_mib = config.max_journal_mib,
1430            "runtime journal rotation enabled (size threshold)"
1431        );
1432    }
1433
1434    // Extract utilization handles before stages are moved into threads.
1435    let journal_utilization = journal_stage.utilization();
1436    let matching_utilization = matching_stage.utilization();
1437    let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
1438
1439    // Wrap each spawn closure with a tail log so a thread that returns
1440    // (cleanly OR via `Err`) is visible in the trace stream — the
1441    // accept loop's `is_finished` check fires error!("pipeline thread
1442    // died") only on its next tick, which can lag the actual exit by
1443    // up to 100ms and races with the listener teardown.
1444    let s1 = Arc::clone(&shutdown);
1445    let shutdown_for_journal = Arc::clone(&shutdown);
1446    let journal_handle = std::thread::Builder::new()
1447        .name("journal".into())
1448        .spawn(move || {
1449            melin_app::affinity::pin_thread("journal", cores.journal);
1450            let result = journal_stage.run(&s1);
1451            let was_shutdown = shutdown_for_journal.load(Ordering::Relaxed);
1452            match &result {
1453                Ok(_) if was_shutdown => info!("journal thread exited cleanly on shutdown"),
1454                Ok(_) => error!("journal thread returned without shutdown signal"),
1455                Err(e) => error!(error = %e, "journal thread returned Err"),
1456            }
1457            result
1458        })
1459        .map_err(|e| format!("spawn journal thread: {e}"))?;
1460
1461    let s2 = Arc::clone(&shutdown);
1462    let shutdown_for_matching = Arc::clone(&shutdown);
1463    let matching_handle = std::thread::Builder::new()
1464        .name("matching".into())
1465        .spawn(move || {
1466            melin_app::affinity::pin_thread("matching", cores.matching);
1467            let app = matching_stage.run(&s2);
1468            let was_shutdown = shutdown_for_matching.load(Ordering::Relaxed);
1469            if was_shutdown {
1470                info!("matching thread exited cleanly on shutdown");
1471            } else {
1472                error!("matching thread returned without shutdown signal");
1473            }
1474            app
1475        })
1476        .map_err(|e| format!("spawn matching thread: {e}"))?;
1477
1478    let replication_metrics = build_replication_metrics(
1479        replication_consumers.is_some(),
1480        &durability_mode_atomic,
1481        config.durability_mode,
1482    );
1483
1484    // Per-slot active flags exposed by the journal stage's replication
1485    // ring; the response gate filters disconnected slots out of the
1486    // policy's cursor view via these.
1487    let replica_active: Option<[Arc<AtomicBool>; 2]> =
1488        replication_ring_progress.as_ref().map(|rp| {
1489            [
1490                Arc::clone(&rp.active_flags[0]),
1491                Arc::clone(&rp.active_flags[1]),
1492            ]
1493        });
1494
1495    // Typed durable-cursor handle for the response thread's gate.
1496    let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
1497    let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
1498    let replica_active_response = replica_active.clone();
1499    let durability_mode_response = Arc::clone(&durability_mode_atomic);
1500    let s3 = Arc::clone(&shutdown);
1501    let shutdown_for_response = Arc::clone(&shutdown);
1502    let busy_spin = !config.yield_idle;
1503    let response_utilization_thread = Arc::clone(&response_utilization);
1504    let response_fence = Arc::clone(&fence_state);
1505    let active_connections_response = Arc::clone(&active_connections);
1506    let response_handle = std::thread::Builder::new()
1507        .name("response".into())
1508        .spawn(move || {
1509            melin_app::affinity::pin_thread("response", cores.response);
1510            crate::response::run::<A>(
1511                output_consumer,
1512                control_rx,
1513                crate::response::Response::<A> {
1514                    journal_persisted_wire_seq: journal_persisted_wire_seq_response,
1515                    durability_mode: durability_mode_response,
1516                    replication_metrics: replication_metrics_response,
1517                    replica_active: replica_active_response,
1518                    heartbeat_interval,
1519                    busy_spin,
1520                    utilization: response_utilization_thread,
1521                    encoder,
1522                    fence_state: response_fence,
1523                    active_connections: active_connections_response,
1524                },
1525                &s3,
1526            );
1527            let was_shutdown = shutdown_for_response.load(Ordering::Relaxed);
1528            if was_shutdown {
1529                info!("response thread exited cleanly on shutdown");
1530            } else {
1531                error!("response thread returned without shutdown signal");
1532            }
1533        })
1534        .map_err(|e| format!("spawn response thread: {e}"))?;
1535
1536    // Spawn replication sender thread if enabled. The journal stage publishes
1537    // encoded batches to a pre-allocated ring; the sender thread consumes them.
1538    // `replica_ready` is set when the first replica connects — seeding waits
1539    // on this to ensure seed events aren't drained before the replica arrives.
1540    let replica_ready = Arc::new(AtomicBool::new(false));
1541    // Ring depth monitoring: the producer cursors are in ReplicationRingProgress
1542    // (owned by this function), so we compute depth via ring_progress rather
1543    // than storing Box<dyn QueueCursor> in ReplicationMetrics. The health
1544    // snapshot reads consumer cursors from ReplicationMetrics and producer
1545    // cursors are not needed — ring depth is a secondary metric.
1546
1547    let replication_handle = if let Some((repl_consumer_1, repl_consumer_2)) = replication_consumers
1548    {
1549        let s_repl = Arc::clone(&shutdown);
1550        let repl_slots = cursors.replica_slot_cursors();
1551        let ready_flag = Arc::clone(&replica_ready);
1552        let connected_counter = replicas_connected
1553            .clone()
1554            .ok_or("replicas_connected must be Some when replication is enabled")?;
1555
1556        let batch_size = config.replication_batch_size;
1557        let heartbeat_secs = config.replication_heartbeat_secs;
1558        let journal_path = config.journal.clone();
1559        let repl_auth_keys = Arc::clone(&authorized_keys);
1560        let evict_flags = replication_ring_progress
1561            .as_ref()
1562            .map(|rp| {
1563                [
1564                    Arc::clone(&rp.evict_flags[0]),
1565                    Arc::clone(&rp.evict_flags[1]),
1566                ]
1567            })
1568            .unwrap_or_else(|| {
1569                [
1570                    Arc::new(AtomicBool::new(false)),
1571                    Arc::new(AtomicBool::new(false)),
1572                ]
1573            });
1574        let active_flags = replication_ring_progress
1575            .as_ref()
1576            .map(|rp| {
1577                [
1578                    Arc::clone(&rp.active_flags[0]),
1579                    Arc::clone(&rp.active_flags[1]),
1580                ]
1581            })
1582            .unwrap_or_else(|| {
1583                [
1584                    Arc::new(AtomicBool::new(false)),
1585                    Arc::new(AtomicBool::new(false)),
1586                ]
1587            });
1588        let repl_metrics = replication_metrics
1589            .clone()
1590            .ok_or("replication_metrics must be Some when replication is enabled")?;
1591        let handler_cores = [cores.repl_handler_0, cores.repl_handler_1];
1592        let sender_fence = Arc::clone(&fence_state);
1593        let sender_durability = Arc::clone(&durability_mode_atomic);
1594        // Bound in `run_impl` before any pipeline thread was spawned —
1595        // see the `repl_listener` parameter doc.
1596        let repl_listener = repl_listener
1597            .ok_or("replication listener must be pre-bound when replication is enabled")?;
1598        let repl_sender_handle = std::thread::Builder::new()
1599            .name("repl-sender".into())
1600            .spawn(move || {
1601                melin_app::affinity::pin_thread("repl-sender", cores.repl_sender);
1602                crate::replication::run_sender::<A>(
1603                    crate::replication::Sender {
1604                        listener: repl_listener,
1605                        repl_consumer_1,
1606                        repl_consumer_2,
1607                        replica_slots: repl_slots,
1608                        journal_path,
1609                        authorized_keys: repl_auth_keys,
1610                        evict_flags,
1611                        active_flags,
1612                        metrics: repl_metrics,
1613                        handler_cores,
1614                        batch_size,
1615                        heartbeat_secs,
1616                        busy_spin,
1617                        fence_state: sender_fence,
1618                        durability_mode: sender_durability,
1619                    },
1620                    &s_repl,
1621                    &ready_flag,
1622                    &connected_counter,
1623                );
1624            })
1625            .map_err(|e| format!("spawn replication sender thread: {e}"))?;
1626
1627        Some(repl_sender_handle)
1628    } else {
1629        if !config.standalone && config.replica_of.is_none() {
1630            info!("running in standalone mode (no replication)");
1631        }
1632        None
1633    };
1634
1635    // Spawn event publisher thread if enabled. Consumes from output ring
1636    // consumer 1 and broadcasts all execution events to TCP subscribers.
1637    // Caller-supplied (the trading binary wires
1638    // `domain::event_publisher::run`; the skip-order-exec binary passes
1639    // `None`), so the runtime carries no trading-specific reference.
1640    let event_publisher_handle = spawn_event_publisher::<A>(
1641        event_publisher_consumer,
1642        event_publisher,
1643        config,
1644        &cores,
1645        &authorized_keys,
1646        &shutdown,
1647        busy_spin,
1648    )?;
1649
1650    let shadow_handle = spawn_shadow_stage::<A>(
1651        shadow_consumer,
1652        shadow_exchange,
1653        chain_hash_lock,
1654        config,
1655        &cores,
1656        &shutdown,
1657        busy_spin,
1658        fence_state.epoch(),
1659    )?;
1660
1661    // Seed instruments and accounts through the pipeline on first startup.
1662    // Events flow through journal + matching + replication like regular
1663    // trading events. Must happen after pipeline threads start (they
1664    // consume from the disruptor) but before accepting client connections.
1665    //
1666    // When replication is enabled, wait for the first replica to connect
1667    // before publishing. replica_ready is set by the replica handler
1668    // thread after catch-up completes and it enters the live streaming
1669    // loop — this ensures the ring consumer is actively draining before
1670    // seeds start flowing.
1671    // Spawn the health endpoint BEFORE the wait-for-replica gate so
1672    // operators (and the failover test harness) can probe `/healthz`
1673    // to confirm the server has bound its sockets and is ready to
1674    // accept replica connections — even though seeding hasn't started
1675    // yet. The health snapshot legitimately reports `halted` while
1676    // waiting for the first replica; that's accurate, not a
1677    // misreport. Replicas connect to the *replication* endpoint, not
1678    // the client port, so spawning health here doesn't change accept
1679    // semantics.
1680    let pipeline_healthy = Arc::new(AtomicBool::new(true));
1681    let health_handle = spawn_health_endpoint(
1682        config,
1683        &active_connections,
1684        &events_processed,
1685        &cursors,
1686        input_cursor,
1687        &pipeline_healthy,
1688        &replicas_connected,
1689        &fence_state,
1690        &replication_metrics,
1691        &replica_active,
1692        &replication_ring_progress,
1693        &journal_utilization,
1694        &matching_utilization,
1695        &response_utilization,
1696        &shutdown,
1697        &raft_status,
1698    )?;
1699
1700    if enable_replication && needs_seeding {
1701        info!("waiting for replica to connect before seeding...");
1702        while !replica_ready.load(Ordering::Acquire) {
1703            if shutdown.load(Ordering::Relaxed) {
1704                break;
1705            }
1706            std::thread::sleep(std::time::Duration::from_millis(100));
1707        }
1708    }
1709    if needs_seeding {
1710        use melin_app::unix_epoch_nanos;
1711        use melin_journal::JournalEvent;
1712        use melin_transport_core::trace::mono_trace_ns;
1713
1714        let seed_start = std::time::Instant::now();
1715        let seed_events = factory.seed_events();
1716        let seed_count = seed_events.len();
1717
1718        // `sequence: 0` — the journal stage allocates sequences in
1719        // disruptor cursor order at encode time. The factory yields
1720        // the application-shaped events; the runtime wraps each as
1721        // `JournalEvent::App` and stamps transport-level metadata.
1722        let mut last_published_seq = 0u64;
1723        for event in seed_events {
1724            last_published_seq = input_producer.publish(InputSlot {
1725                connection_id: 0,
1726                key_hash: 0,
1727                request_seq: 0,
1728                sequence: 0,
1729                timestamp_ns: unix_epoch_nanos(),
1730                event: JournalEvent::App(event),
1731                publish_ts: mono_trace_ns(),
1732                recv_ts: mono_trace_ns(),
1733            });
1734        }
1735        let publish_elapsed = seed_start.elapsed();
1736
1737        // Wait for all seed events to be fully processed by the pipeline
1738        // before accepting clients. Without this, early client orders
1739        // compete with seed events for pipeline time, contaminating
1740        // benchmark results.
1741        //
1742        // Gates on journal + matching cursors (disruptor sequence space),
1743        // then waits for the replication ring to be fully consumed. This
1744        // confirms sender threads have read all seed batches from the ring
1745        // (sent or being sent to replicas). Stronger than no gate, faster
1746        // than waiting for replica TCP acks, and deadlock-free because the
1747        // ring backpressures instead of dropping batches.
1748        //
1749        // Skip the drain entirely when the factory produced no seed events —
1750        // `last_published_seq` is still 0 (no events on the ring) and the
1751        // cursor will never advance past it.
1752        let drain_start = std::time::Instant::now();
1753        if seed_count > 0 {
1754            let last_seed_seq = last_published_seq + 1; // cursor = next-to-consume
1755
1756            info!(
1757                last_seed_seq,
1758                journal = journal_cursor
1759                    .get()
1760                    .load(std::sync::atomic::Ordering::Relaxed),
1761                matching = matching_cursor
1762                    .get()
1763                    .load(std::sync::atomic::Ordering::Relaxed),
1764                "seed drain: waiting for pipeline cursors"
1765            );
1766
1767            while !shutdown.load(std::sync::atomic::Ordering::Relaxed)
1768                && (journal_cursor
1769                    .get()
1770                    .load(std::sync::atomic::Ordering::Acquire)
1771                    < last_seed_seq
1772                    || matching_cursor
1773                        .get()
1774                        .load(std::sync::atomic::Ordering::Acquire)
1775                        < last_seed_seq)
1776            {
1777                std::hint::spin_loop();
1778            }
1779
1780            info!("seed drain: pipeline cursors reached target");
1781
1782            // After journal + matching are done, wait for each ACTIVE
1783            // replication ring's consumer to have read all published batches.
1784            // Inactive rings (no connected replica) were never published to,
1785            // so their producer cursor is 0 — no wait needed.
1786            if let Some(ref ring_progress) = replication_ring_progress {
1787                for i in 0..ring_progress.producer_cursors.len() {
1788                    if !ring_progress.active_flags[i].load(std::sync::atomic::Ordering::Relaxed) {
1789                        continue;
1790                    }
1791                    let target = ring_progress.producer_cursors[i].load();
1792                    while !shutdown.load(std::sync::atomic::Ordering::Relaxed)
1793                        && ring_progress.consumer_cursors[i]
1794                            .get()
1795                            .load(std::sync::atomic::Ordering::Acquire)
1796                            < target
1797                    {
1798                        std::hint::spin_loop();
1799                    }
1800                }
1801            }
1802
1803            info!("seed drain: replication rings drained");
1804        }
1805        let drain_elapsed = drain_start.elapsed();
1806
1807        info!(
1808            seed_events = seed_count,
1809            publish_ms = publish_elapsed.as_millis(),
1810            drain_ms = drain_elapsed.as_millis(),
1811            total_ms = seed_start.elapsed().as_millis(),
1812            "seeded application state through pipeline"
1813        );
1814    }
1815
1816    // Promotion fencing: a node that reached primary via promotion injects
1817    // an `EpochBump` as the first journaled entry of its tenure, raising the
1818    // cluster epoch so a paused/partitioned ex-primary self-demotes when a
1819    // handshake crosses (see `melin_transport_core::fence`). Genesis primaries
1820    // skip this — their epoch stays at whatever the journal recovered. The
1821    // bump rides the input ring like a seed event (connection_id == 0), so it
1822    // flows through journal + replication to every replica. We wait for the
1823    // matching stage to apply it (epoch advanced) before serving so the first
1824    // handshake already advertises the new epoch.
1825    //
1826    // The new epoch honours the promotion request's floor: a manual
1827    // `PROMOTE` carries `MANUAL` (= 1) and resolves to the classic
1828    // `epoch + 1`; a raft auto-promotion carries its election term
1829    // (strictly above the old epoch by the driver's request rule), so
1830    // tenure epochs align with raft terms and two overlapping
1831    // promotions from different elections always allocate distinct
1832    // epochs — the newer one fences the older.
1833    if let Some(requested_epoch) = promotion {
1834        use melin_app::unix_epoch_nanos;
1835        use melin_journal::JournalEvent;
1836        use melin_transport_core::trace::mono_trace_ns;
1837
1838        let new_epoch = fence_state.epoch().saturating_add(1).max(requested_epoch);
1839        // Re-validate the term↔epoch alignment at the moment the epoch is
1840        // minted, not just at the driver's request-time check: a streamed
1841        // `EpochBump` from a concurrent promotion elsewhere can raise the
1842        // fence during the drain, in which case `max` allocates `epoch+1`
1843        // instead of the election term. Fencing still converges (the
1844        // epochs stay distinct and the higher one wins), but the skew
1845        // makes later "epochs outran raft terms" refusals — so say what
1846        // actually happened while the evidence exists. Manual promotions
1847        // (requested == MANUAL) are exempt: they never claimed alignment.
1848        if requested_epoch > crate::promotion::PromotionRequest::MANUAL
1849            && new_epoch != requested_epoch
1850        {
1851            warn!(
1852                new_epoch,
1853                requested_epoch,
1854                "promotion epoch does not match its election term — a concurrent \
1855                 promotion advanced the fencing epoch mid-drain; auto-promotion \
1856                 refusals may report term/epoch misalignment until a newer election"
1857            );
1858        }
1859        info!(
1860            new_epoch,
1861            requested_epoch, "promotion: injecting epoch bump"
1862        );
1863        input_producer.publish(InputSlot {
1864            connection_id: 0,
1865            key_hash: 0,
1866            request_seq: 0,
1867            sequence: 0,
1868            timestamp_ns: unix_epoch_nanos(),
1869            event: JournalEvent::EpochBump { epoch: new_epoch },
1870            publish_ts: mono_trace_ns(),
1871            recv_ts: mono_trace_ns(),
1872        });
1873        // Spin until the matching stage observes the bump (epoch raised) so
1874        // the node advertises `new_epoch` on the very first handshake. Bounded
1875        // by the shutdown flag so a stuck pipeline can't wedge startup.
1876        while fence_state.epoch() < new_epoch {
1877            if shutdown.load(Ordering::Relaxed) {
1878                break;
1879            }
1880            std::hint::spin_loop();
1881        }
1882    }
1883
1884    // Now that seeding is fully drained, spawn the reader thread. From here
1885    // on the reader is the sole producer on the input ring (the seed loop
1886    // has finished and its clone of `input_producer` was dropped at the end
1887    // of the block above). Any subsequent ticks the reader emits cannot
1888    // race with seed events: there are none in flight.
1889    //
1890    // If shutdown was requested while seed was draining we still spawn the
1891    // reader so the unified shutdown sequence below joins every thread.
1892    let reader_shutdown = Arc::new(AtomicBool::new(false));
1893    let mut reader_handle = crate::reader::spawn_reader::<A, _>(
1894        input_producer,
1895        decoder,
1896        control_tx.clone(),
1897        config.cores.reader,
1898        connection_timeout,
1899        config.tick_interval(),
1900        Arc::clone(&reader_shutdown),
1901    );
1902
1903    // Health endpoint and `pipeline_healthy` were already spawned/created
1904    // earlier (before the wait-for-replica gate) so probes can succeed
1905    // before seeding completes.
1906
1907    // Set the listener to non-blocking so accept() returns immediately
1908    // with WouldBlock when no connection is pending. This lets the accept
1909    // loop check the shutdown flag without blocking indefinitely.
1910    // Rust's std TcpListener retries on EINTR, so signals alone can't
1911    // interrupt a blocking accept().
1912    listener.set_nonblocking(true);
1913
1914    info!(addr = %config.bind, "listening");
1915
1916    // Monotonically increasing connection ID counter. AtomicU64 because
1917    // the accept loop is the only writer, but using atomic for future
1918    // flexibility (e.g., multiple listeners).
1919    let next_connection_id = AtomicU64::new(1);
1920
1921    // Accept loop — non-blocking with 100ms sleep on WouldBlock. Each
1922    // accepted connection is registered with the reader thread (no
1923    // per-connection threads).
1924    loop {
1925        if shutdown.load(Ordering::Relaxed) {
1926            info!("shutdown signal received");
1927            break;
1928        }
1929
1930        // Detect pipeline thread death early so we don't keep accepting
1931        // connections into a broken pipeline. These threads only exit on
1932        // shutdown or panic — if one is finished while shutdown is false,
1933        // it panicked. Re-check shutdown to avoid a TOCTOU race where a
1934        // clean shutdown signal arrives between the two checks.
1935        let event_pub_died = event_publisher_handle
1936            .as_ref()
1937            .is_some_and(|h| h.is_finished());
1938        let shadow_died = shadow_handle.as_ref().is_some_and(|h| h.is_finished());
1939        if (journal_handle.is_finished()
1940            || matching_handle.is_finished()
1941            || response_handle.is_finished()
1942            || event_pub_died
1943            || shadow_died)
1944            && !shutdown.load(Ordering::Relaxed)
1945        {
1946            error!("pipeline thread died, initiating shutdown");
1947            pipeline_healthy.store(false, Ordering::Relaxed);
1948            break;
1949        }
1950
1951        let (mut std_read, mut std_write, addr) = match listener.accept() {
1952            Ok(conn) => conn,
1953            Err(e) => {
1954                if e.kind() == std::io::ErrorKind::WouldBlock {
1955                    // No pending connection — sleep briefly then retry.
1956                    std::thread::sleep(std::time::Duration::from_millis(100));
1957                    continue;
1958                }
1959                error!(error = %e, "accept error");
1960                continue;
1961            }
1962        };
1963
1964        // Enforce max_connections limit (SEC-02). Reject early before
1965        // spending time on auth. The counter is decremented by the response
1966        // stage on disconnect or write error.
1967        if config.max_connections > 0
1968            && active_connections.load(Ordering::Relaxed) >= config.max_connections
1969        {
1970            warn!(addr = %addr, "connection rejected: max_connections reached");
1971            drop(std_read);
1972            drop(std_write);
1973            continue;
1974        }
1975
1976        let connection_id = ConnectionId(next_connection_id.fetch_add(1, Ordering::Relaxed));
1977
1978        debug!(connection_id = connection_id.0, addr = %addr, "new connection");
1979
1980        // Set a 5-second read timeout for the auth handshake to prevent
1981        // slow/malicious clients from blocking the accept loop.
1982        if let Err(e) = set_read_timeout(&std_read, Some(std::time::Duration::from_secs(5))) {
1983            debug!(connection_id = connection_id.0, error = %e, "failed to set auth timeout");
1984        }
1985
1986        // Challenge-response authentication handshake (cold path).
1987        // 1. Send Challenge with random nonce
1988        // 2. Read ChallengeResponse (signature + public key)
1989        // 3. Verify signature and look up key in authorized_keys
1990        // 4. Send ServerReady on success, AuthFailed on failure
1991        let (permission, public_key_bytes) = match authenticate_connection(
1992            connection_id,
1993            addr,
1994            &mut std_read,
1995            &mut std_write,
1996            &authorized_keys,
1997        ) {
1998            Ok(pair) => pair,
1999            Err(e) => {
2000                debug!(connection_id = connection_id.0, addr = %addr, error = %e, "auth failed, dropping");
2001                continue;
2002            }
2003        };
2004
2005        // Hash the client's public key for per-key idempotency dedup.
2006        // FxHash is fast and non-cryptographic — sufficient for dedup
2007        // table keying (the public key itself is already authenticated).
2008        let key_hash = {
2009            let mut hasher = rustc_hash::FxHasher::default();
2010            public_key_bytes.hash(&mut hasher);
2011            hasher.finish()
2012        };
2013
2014        active_connections.fetch_add(1, Ordering::Relaxed);
2015
2016        // Clear the read timeout before handing to the io_uring reader.
2017        // io_uring uses kernel-managed I/O, so the timeout is irrelevant,
2018        // but clearing it avoids surprising behavior if the fd is ever
2019        // used in blocking mode again.
2020        if let Err(e) = set_read_timeout(&std_read, None) {
2021            debug!(connection_id = connection_id.0, error = %e, "failed to clear auth timeout");
2022        }
2023
2024        // Enable SO_BUSY_POLL on the client data socket. The reader
2025        // thread already busy-spins on io_uring CQEs, so the kernel's
2026        // NIC busy-poll happens during cycles that would have been
2027        // spent spinning anyway — net cost is zero, and we avoid the
2028        // softirq → wakeup handoff for every recv on this connection.
2029        if let Err(e) = set_busy_poll(&std_read, BUSY_POLL_US) {
2030            // Best-effort: a kernel without CAP_NET_ADMIN or with
2031            // SO_BUSY_POLL disabled by sysctl will reject this. Logged
2032            // as debug — it's a per-connection event and only affects
2033            // latency, not correctness.
2034            debug!(
2035                connection_id = connection_id.0,
2036                error = %e,
2037                "failed to set SO_BUSY_POLL on client socket"
2038            );
2039        }
2040
2041        // Belt-and-braces write timeout (SEC-01). The real slow-client
2042        // protection is in the response stage — MSG_DONTWAIT sends,
2043        // paced retries, and the blocked-send timeout — since io_uring
2044        // SEND does not honor SO_SNDTIMEO. This bounds any residual
2045        // blocking write(2) on this fd (none exist today; the response
2046        // stage is the sole egress writer).
2047        if let Err(e) = set_write_timeout(&std_write, Some(std::time::Duration::from_secs(5))) {
2048            debug!(connection_id = connection_id.0, error = %e, "failed to set write timeout");
2049        }
2050
2051        // Register the writer with the response thread before the reader.
2052        // This ensures the response stage has the writer before any
2053        // requests arrive from this connection.
2054        let fd = std_write.as_raw_fd();
2055        let boxed_writer: Box<dyn std::io::Write + Send> = Box::new(std_write);
2056        let control_event = ControlEvent::Connected {
2057            connection_id: connection_id.0,
2058            fd,
2059            writer: BlockingFrameWriter::new(boxed_writer),
2060        };
2061        if control_tx.send(control_event).is_err() {
2062            info!("response thread gone, shutting down");
2063            break;
2064        }
2065
2066        // Register the reader fd with the io_uring reader thread.
2067        reader_handle.register(crate::reader::ReaderRegistration {
2068            connection_id,
2069            reader: std_read,
2070            addr,
2071            permission,
2072            key_hash,
2073        });
2074    }
2075
2076    // --- Ordered shutdown sequence ---
2077    // 1. Stop readers first so no new events enter the disruptor.
2078    info!("shutdown: stopping reader thread");
2079    reader_handle.shutdown();
2080    reader_handle.join();
2081
2082    // 2. Now drain the pipeline and join every worker thread.
2083    shutdown_pipeline_stages(
2084        PipelineHandles {
2085            journal: journal_handle,
2086            matching: matching_handle,
2087            response: response_handle,
2088            replication: replication_handle,
2089            event_publisher: event_publisher_handle,
2090            shadow: shadow_handle,
2091            health: health_handle,
2092        },
2093        Vec::new(),
2094        &pipeline_healthy,
2095        &shutdown,
2096    )
2097}
2098
2099/// Run the trading server with DPDK kernel-bypass networking.
2100///
2101/// Replaces the kernel TCP stack entirely. The DPDK poll thread handles
2102/// all NIC I/O and TCP processing via smoltcp. The response stage encodes
2103/// frames and pushes them through an mpsc channel to the poll thread.
2104///
2105/// Thread layout:
2106/// - Core N:   DPDK poll thread (rx_burst, smoltcp, frame decode, tx_burst)
2107/// - Core 1:   Journal stage
2108/// - Core 2:   Matching stage
2109/// - Core 3:   Response stage (encodes to TX channel)
2110///
2111/// See [`run`] for the role of `factory`.
2112#[cfg(feature = "dpdk")]
2113fn run_dpdk<A>(
2114    config: ServerConfig,
2115    factory: Arc<dyn AppFactory<App = A>>,
2116    decoder: RequestDecoderArc<A>,
2117    encoder: ResponseEncoderArc<A>,
2118    event_publisher: Option<EventPublisherFn<A>>,
2119    shutdown: Arc<AtomicBool>,
2120) -> Result<(), Box<dyn std::error::Error>>
2121where
2122    A: Application + Send + 'static,
2123    A::Event: Send + Sync + 'static,
2124    A::Report: Send + 'static,
2125    A::QueryResponse: Send + 'static,
2126{
2127    let dpdk_config = dpdk_config_from(&config);
2128
2129    match config.journal_writer {
2130        melin_journal::JournalWriterMode::Buffered => run_dpdk_impl::<A, BufferedWriter<A::Event>>(
2131            config,
2132            factory,
2133            decoder,
2134            encoder,
2135            event_publisher,
2136            dpdk_config,
2137            shutdown,
2138        ),
2139        melin_journal::JournalWriterMode::Sector => run_dpdk_impl::<A, SectorWriter<A::Event>>(
2140            config,
2141            factory,
2142            decoder,
2143            encoder,
2144            event_publisher,
2145            dpdk_config,
2146            shutdown,
2147        ),
2148    }
2149}
2150
2151#[cfg(feature = "dpdk")]
2152fn dpdk_config_from(cfg: &ServerConfig) -> melin_dpdk::DpdkConfig {
2153    melin_dpdk::DpdkConfig {
2154        eal_args: cfg
2155            .dpdk_eal_args
2156            .split_whitespace()
2157            .map(String::from)
2158            .collect(),
2159        port_ids: cfg.dpdk_ports.clone(),
2160        ip_addr: cfg.dpdk_ip.parse().expect("invalid --dpdk-ip address"),
2161        prefix_len: cfg.dpdk_prefix_len,
2162        gateway: cfg
2163            .dpdk_gateway
2164            .as_deref()
2165            .map(|s| s.parse().expect("invalid --dpdk-gateway address")),
2166        listen_port: cfg.bind.port(),
2167        mtu: cfg.dpdk_mtu,
2168        vlan_id: cfg.dpdk_vlan,
2169        peer_ip: cfg
2170            .dpdk_peer_ip
2171            .as_deref()
2172            .map(|s| s.parse().expect("invalid --dpdk-peer-ip address")),
2173        gateway_mac: cfg.dpdk_gateway_mac.as_deref().map(melin_dpdk::parse_mac),
2174        num_queues: 1,
2175    }
2176}
2177
2178#[cfg(feature = "dpdk")]
2179fn run_dpdk_impl<A, W>(
2180    config: ServerConfig,
2181    factory: Arc<dyn AppFactory<App = A>>,
2182    decoder: RequestDecoderArc<A>,
2183    encoder: ResponseEncoderArc<A>,
2184    event_publisher: Option<EventPublisherFn<A>>,
2185    dpdk_config: melin_dpdk::DpdkConfig,
2186    shutdown: Arc<AtomicBool>,
2187) -> Result<(), Box<dyn std::error::Error>>
2188where
2189    A: Application + Send + 'static,
2190    A::Event: Send + Sync + 'static,
2191    A::Report: Send + 'static,
2192    A::QueryResponse: Send + 'static,
2193    W: JournalWrite<A::Event> + Send + 'static,
2194    JournalStage<A::Event, W>: JournalStageRun<A::Event, Writer = W>,
2195{
2196    // Mirrors the kernel-TCP `run` path: one atomic per
2197    // process, threaded into both replica (pre-staging for promotion)
2198    // and primary admin listeners.
2199    let durability_mode_atomic = Arc::new(AtomicU8::new(config.durability_mode.as_u8()));
2200    // Initialize shared DPDK resources (EAL, mempool, ports with N queues).
2201    let shared = melin_dpdk::DpdkShared::init(&dpdk_config)?;
2202    // Actual queue count may be less than requested (TAP only supports 1).
2203    let num_dpdk_threads = shared.num_queues as usize;
2204
2205    // --- Replica mode (DPDK) ---
2206    // If --replica-of is set, run the DPDK replication receiver instead
2207    // of the primary path. The receiver uses one queue pair for the
2208    // outbound connection to the primary.
2209    if let Some(primary_addr) = config.replica_of {
2210        info!(primary = %primary_addr, "starting in replica mode (DPDK)");
2211
2212        // Load authorized keys early — the admin listener needs them for
2213        // Ed25519 challenge-response auth (operator keys only).
2214        let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
2215        info!(
2216            keys = authorized_keys.len(),
2217            path = %config.authorized_keys.display(),
2218            "loaded authorized keys (DPDK replica mode)"
2219        );
2220
2221        // Load the replication signing key — the replica signs the primary's
2222        // challenge with it. Mirrors the kernel-TCP replica path.
2223        let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
2224            std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
2225        })?;
2226        let signing_key = {
2227            let seed = std::fs::read(replication_key_path).map_err(|e| {
2228                std::io::Error::other(format!(
2229                    "failed to read replication key {}: {e}",
2230                    replication_key_path.display()
2231                ))
2232            })?;
2233            if seed.len() != 32 {
2234                return Err(format!(
2235                    "replication key must be 32 bytes, got {} ({})",
2236                    seed.len(),
2237                    replication_key_path.display()
2238                )
2239                .into());
2240            }
2241            let mut bytes = [0u8; 32];
2242            bytes.copy_from_slice(&seed);
2243            ed25519_dalek::SigningKey::from_bytes(&bytes)
2244        };
2245
2246        // The replica's control-plane bundle, same shape as the kernel
2247        // TCP replica path — see `run` for the rationale on lifetime.
2248        let control = crate::replication::ReplicaControlPlane::new();
2249        let promotion_request = control.promote.clone();
2250        let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2251        let _admin_handle = config
2252            .admin_bind
2253            .map(|addr| {
2254                crate::admin::spawn(
2255                    addr,
2256                    Some(promotion_request.clone()),
2257                    rotate_flag.clone(),
2258                    Some(Arc::clone(&durability_mode_atomic)),
2259                    Arc::clone(&shutdown),
2260                    Arc::clone(&authorized_keys),
2261                )
2262            })
2263            .transpose()?;
2264
2265        // Shared fencing state, created before mode detection so it
2266        // survives a replica → primary transition. Seeded at epoch 0; the
2267        // receiver raises it from the replica's recovered journal and the
2268        // replication stream, and a promotion bumps it by injecting an
2269        // `EpochBump`. Carried into `run_as_primary` post-promotion.
2270        let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
2271
2272        // Control-plane raft runs on kernel TCP even on DPDK nodes — the
2273        // control plane is off the hot path and must not consume a DPDK
2274        // queue. Same promotion-surviving placement and tip ownership as
2275        // the kernel replica path.
2276        let journal_tip = control.journal_tip.clone();
2277        let mut raft = match crate::raft::build_raft_config(&config)? {
2278            None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2279            Some(cfg) => crate::raft::spawn_raft_driver(
2280                cfg,
2281                config.raft_auto_promote,
2282                &signing_key,
2283                &authorized_keys,
2284                &fence_state,
2285                journal_tip.clone(),
2286                Arc::clone(&control.tip_ready),
2287                // A replica claims to be serving once a promotion is in
2288                // flight (see SupersessionPolicy).
2289                {
2290                    let promote = control.promote.clone();
2291                    Arc::new(move || promote.is_requested())
2292                },
2293                &shutdown,
2294            )?,
2295        };
2296        // Act on election wins when the operator opted in. Genesis
2297        // primaries have nothing to promote — replica paths only. The
2298        // guard joins the thread on every exit path.
2299        if config.raft_auto_promote
2300            && let Some(status) = raft.status()
2301        {
2302            raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
2303                status,
2304                control.clone(),
2305                Arc::clone(&fence_state),
2306                Arc::clone(&durability_mode_atomic),
2307                Arc::clone(&shutdown),
2308            ));
2309        }
2310        let raft_status = raft.status();
2311        let mut replica_health = crate::raft::spawn_replica_health(
2312            &config,
2313            &fence_state,
2314            raft_status.as_ref(),
2315            Arc::clone(&control.pipeline_healthy),
2316        )?;
2317
2318        // No local rotation triggers on the replica side — rotation is
2319        // primary-driven (see the kernel-TCP receiver path).
2320
2321        // Use queue 0 for the replication receiver's smoltcp connection.
2322        // Listen port is unused (receiver connects outbound, doesn't listen),
2323        // but DpdkTransport requires one — use an ephemeral port.
2324        let mut repl_transport = melin_dpdk::DpdkTransport::from_shared_with_port(
2325            &shared,
2326            &dpdk_config,
2327            0,
2328            39999, // Ephemeral — receiver connects outbound, doesn't accept.
2329        )?;
2330        repl_transport.send_gratuitous_arp();
2331
2332        let primary_ipv4 = match primary_addr.ip() {
2333            std::net::IpAddr::V4(ip) => ip,
2334            std::net::IpAddr::V6(_) => {
2335                return Err("DPDK replication requires IPv4".into());
2336            }
2337        };
2338
2339        match crate::replication::run_receiver_dpdk::<A, W>(
2340            repl_transport,
2341            primary_ipv4,
2342            primary_addr.port(),
2343            &signing_key,
2344            &config.journal,
2345            &shutdown,
2346            &control,
2347            config.snapshot_interval_ms,
2348            config.shadow_snapshot_path(),
2349            config.cores,
2350            config.group_commit_delay(),
2351            config.replication_pipeline_depth,
2352            !config.yield_idle,
2353            Arc::clone(&factory),
2354            Arc::clone(&fence_state),
2355        )? {
2356            // Clean shutdown — the raft and health guards tear down on drop.
2357            None => return Ok(()),
2358            Some((mut exchange, writer)) => {
2359                // Promotion! Transition to primary mode (DPDK).
2360                info!("replica promoted (DPDK) — transitioning to primary");
2361                // Release --health-bind before run_as_primary rebinds it.
2362                replica_health.stop();
2363                <A as Application>::prefault(&mut exchange);
2364
2365                // Clear a ROTATE latched while this node was a replica —
2366                // see the kernel-TCP promotion path.
2367                if let Some(ref flag) = rotate_flag {
2368                    flag.store(false, Ordering::Release);
2369                }
2370
2371                // TODO: run_as_primary_dpdk — for now, fall back to
2372                // kernel TCP primary after promotion.
2373                warn!("DPDK primary promotion not yet implemented — falling back to kernel TCP");
2374                let listener = melin_wire_protocol::tcp::BlockingTcpListener::bind(config.bind)?;
2375                // Unlike the kernel path (bound at boot in `run_impl`),
2376                // the fallback's kernel listeners only come up here at
2377                // promotion — the DPDK stack carried replication until
2378                // now. See `bind_replication_listener`.
2379                let repl_listener = config
2380                    .replication_bind
2381                    .map(bind_replication_listener)
2382                    .transpose()?;
2383                // The raft guard drops — stopping the driver and joining
2384                // the (already exited) promotion thread — after this
2385                // returns; see the kernel-TCP promotion path.
2386                return run_as_primary::<A, _, W>(
2387                    exchange,
2388                    writer,
2389                    listener,
2390                    repl_listener,
2391                    &config,
2392                    &*factory,
2393                    decoder,
2394                    encoder,
2395                    event_publisher,
2396                    Arc::clone(&shutdown),
2397                    authorized_keys,
2398                    false,
2399                    rotate_flag,
2400                    durability_mode_atomic,
2401                    fence_state,
2402                    promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
2403                    raft_status,
2404                    journal_tip,
2405                );
2406            }
2407        }
2408    }
2409
2410    // Create per-thread transports (each gets its own queue pair + smoltcp stack).
2411    // The single-queue refactor (`feat/dpdk-single-queue`) collapsed
2412    // replication onto the same queue/thread as client traffic — no
2413    // separate queue is reserved any more, so `num_client_queues`
2414    // simply tracks `num_dpdk_threads`.
2415    let num_client_queues = num_dpdk_threads;
2416    let mut transports = Vec::with_capacity(num_client_queues);
2417    for q in 0..num_client_queues {
2418        let mut transport =
2419            melin_dpdk::DpdkTransport::from_shared(&shared, &dpdk_config, q as u16)?;
2420        // Send a gratuitous ARP from the first queue so the switch learns
2421        // our VF's MAC address. Without this, SR-IOV VFs that can't enable
2422        // promiscuous mode are unreachable — the switch has no forwarding
2423        // entry and drops unicast frames to our MAC.
2424        if q == 0 {
2425            transport.send_gratuitous_arp();
2426        }
2427        transports.push(transport);
2428    }
2429
2430    // Load authorized keys for challenge-response authentication.
2431    let authorized_keys = Arc::new(AuthorizedKeys::load(&config.authorized_keys)?);
2432    info!(
2433        keys = authorized_keys.len(),
2434        path = %config.authorized_keys.display(),
2435        "loaded authorized keys"
2436    );
2437
2438    // Initialize or recover the exchange.
2439    let (mut exchange, writer, needs_seeding, recovered_epoch) =
2440        init_engine::<A, W>(&config, &*factory)?;
2441    <A as Application>::prefault(&mut exchange);
2442
2443    // Fencing state for this DPDK primary, seeded with the recovered epoch.
2444    let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
2445        recovered_epoch,
2446    ));
2447
2448    // Control-plane raft (kernel TCP even on DPDK — off the hot path, no
2449    // DPDK queue consumed). Signing key loaded only when raft is on.
2450    // A DPDK primary's fence epoch is recovered here too; seed the
2451    // advertised sequence from the recovered journal (see the kernel
2452    // primary path). The pipeline's journal stage owns the handle below.
2453    let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
2454        melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
2455    );
2456    let raft = match crate::raft::build_raft_config(&config)? {
2457        None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2458        Some(cfg) => {
2459            let signing_key = load_replication_key(&config)?;
2460            crate::raft::spawn_raft_driver(
2461                cfg,
2462                config.raft_auto_promote,
2463                &signing_key,
2464                &authorized_keys,
2465                &fence_state,
2466                journal_tip.clone(),
2467                Arc::new(AtomicBool::new(true)),
2468                // A primary always claims to be serving.
2469                Arc::new(|| true),
2470                &shutdown,
2471            )?
2472        }
2473    };
2474    let raft_status = raft.status();
2475
2476    // Clone exchange state for the shadow snapshot stage before moving
2477    // exchange into the pipeline (same as the kernel TCP path).
2478    let enable_shadow = config.snapshot_interval_ms > 0;
2479    let shadow_exchange = if enable_shadow {
2480        Some(<A as Application>::clone_via_snapshot(&exchange)?)
2481    } else {
2482        None
2483    };
2484
2485    let active_connections = Arc::new(AtomicU64::new(0));
2486
2487    // Replication setup (same as TCP path).
2488    let enable_replication = config.replication_bind.is_some();
2489    if enable_replication && config.standalone {
2490        return Err("--replication-bind and --standalone are mutually exclusive".into());
2491    }
2492    // `--standalone` declares "no replicas ever" — only `local` can be
2493    // satisfied. `hybrid` and `durably-replicated` would stall the gate
2494    // forever; reject loudly at startup with the fix in the message.
2495    if config.standalone
2496        && config.durability_mode != crate::durability_policy::DurabilityMode::Local
2497    {
2498        return Err(format!(
2499            "--standalone requires --durability-mode local; got `{}` (this mode needs at least one connected replica)",
2500            config.durability_mode,
2501        )
2502        .into());
2503    }
2504
2505    // Build disruptor pipeline (same flags as the kernel TCP path).
2506    // DPDK doesn't currently spawn the publisher thread (see
2507    // `event_publisher: None` in the handles bundle further down) but
2508    // gating consumer allocation on the same `Some`/`event_bind` pair
2509    // the kernel-TCP path uses means passing `None` from the binary is
2510    // honoured here too — no orphan consumer slot is wired up.
2511    let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
2512    let enable_shadow = config.snapshot_interval_ms > 0;
2513    let Pipeline {
2514        input_producer,
2515        journal_stage,
2516        matching_stage,
2517        mut output_consumers,
2518        events_processed,
2519        input_cursor,
2520        replication_consumers,
2521        replicas_connected,
2522        shadow_consumer,
2523        chain_hash_lock,
2524        replication_ring_progress,
2525        cursors,
2526    } = build_pipeline_with_replication(
2527        exchange,
2528        writer,
2529        config.group_commit_delay(),
2530        Arc::clone(&active_connections),
2531        enable_replication,
2532        config.max_journal_batch,
2533        config.replication_ring_size,
2534        !config.yield_idle,
2535        enable_event_publisher,
2536        enable_shadow,
2537        Arc::clone(&fence_state),
2538    );
2539    // Ring-position cursors for the seed-drain gate below (Acquire loads,
2540    // stronger than the bundle's monitoring reads). The durability gate and
2541    // the replication sender pull their typed handles straight from
2542    // `cursors`; the health endpoint takes `cursors` itself.
2543    let journal_cursor = cursors.journal_ring_arc();
2544    let matching_cursor = cursors.matching_ring_arc();
2545
2546    let heartbeat_interval = config.heartbeat_interval();
2547
2548    // Seed events flow through the disruptor like regular events. The input
2549    // ring is single-producer: main publishes seeds, then moves the
2550    // producer into the DPDK poll thread. No cloning required.
2551    let mut input_producer = input_producer;
2552
2553    // The DPDK poll thread also generates the engine's scheduler ticks via
2554    // a wall-clock comparison between NIC bursts (see `run_dpdk_poll`). The
2555    // input ring is therefore single-producer in steady state alongside the
2556    // one-shot seed loop — same property as the io_uring transport.
2557    let tick_cadence = config.tick_interval();
2558
2559    // Control channel: DPDK poll thread → response stage (connect/disconnect).
2560    let (control_tx, control_rx) = std::sync::mpsc::channel();
2561
2562    // TX SPSC: response stage → DPDK poll thread (encoded frames).
2563    // Lock-free, fixed-size slots — no heap allocation per frame.
2564    // 4096 slots × ~140 bytes = ~560 KiB. Enough to buffer a burst
2565    // without backpressuring the response stage.
2566    // One SPSC channel per DPDK poll thread. The response stage routes
2567    // frames to the correct thread based on thread_id encoded in
2568    // connection_id bits 56..63.
2569    let mut tx_producers = Vec::with_capacity(num_dpdk_threads);
2570    let mut tx_consumers = Vec::with_capacity(num_dpdk_threads);
2571    for _ in 0..num_dpdk_threads {
2572        let (tx_out, tx_rx) = melin_pipeline::spsc::channel::<crate::dpdk_response::TxFrame>(4096);
2573        tx_producers.push(tx_out);
2574        tx_consumers.push(tx_rx);
2575    }
2576
2577    // Spawn pipeline threads (journal, matching — identical to TCP path).
2578    let cores = config.cores;
2579
2580    // Wire runtime rotation into the journal stage (DPDK primary path).
2581    // PROMOTE is rejected on a primary (no flag wired); ROTATE shares the
2582    // same flag the journal stage observes.
2583    let mut journal_stage = journal_stage;
2584    let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2585    let _admin_handle = config
2586        .admin_bind
2587        .map(|addr| {
2588            crate::admin::spawn(
2589                addr,
2590                None,
2591                rotate_flag.clone(),
2592                Some(Arc::clone(&durability_mode_atomic)),
2593                Arc::clone(&shutdown),
2594                Arc::clone(&authorized_keys),
2595            )
2596        })
2597        .transpose()?;
2598    let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
2599    journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
2600    journal_stage.set_preparer_core(config.cores.journal_prep);
2601    // On a primary the journal stage owns the control-plane advertised
2602    // tip (durable cursor after each fsync batch). On the promotion path
2603    // this takes over the handle the receiver was advancing.
2604    journal_stage.set_advertised_tip_publisher(journal_tip.clone());
2605    if config.max_journal_mib > 0 {
2606        info!(
2607            max_journal_mib = config.max_journal_mib,
2608            "runtime journal rotation enabled (size threshold, DPDK)"
2609        );
2610    }
2611
2612    // Extract utilization handles before stages are moved into threads.
2613    let journal_utilization = journal_stage.utilization();
2614    let matching_utilization = matching_stage.utilization();
2615    let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
2616
2617    let s1 = Arc::clone(&shutdown);
2618    let journal_handle = std::thread::Builder::new()
2619        .name("journal".into())
2620        .spawn(move || {
2621            melin_app::affinity::pin_thread("journal", cores.journal);
2622            journal_stage.run(&s1)
2623        })
2624        .map_err(|e| format!("spawn journal thread: {e}"))?;
2625
2626    let s2 = Arc::clone(&shutdown);
2627    let matching_handle = std::thread::Builder::new()
2628        .name("matching".into())
2629        .spawn(move || {
2630            melin_app::affinity::pin_thread("matching", cores.matching);
2631            matching_stage.run(&s2)
2632        })
2633        .map_err(|e| format!("spawn matching thread: {e}"))?;
2634
2635    let replication_metrics = build_replication_metrics(
2636        replication_consumers.is_some(),
2637        &durability_mode_atomic,
2638        config.durability_mode,
2639    );
2640
2641    let replica_active: Option<[Arc<AtomicBool>; 2]> =
2642        replication_ring_progress.as_ref().map(|rp| {
2643            [
2644                Arc::clone(&rp.active_flags[0]),
2645                Arc::clone(&rp.active_flags[1]),
2646            ]
2647        });
2648
2649    // Spawn DPDK response stage (encodes to TX channel instead of kernel sockets).
2650    let output_consumer = output_consumers.remove(0);
2651    // Typed durable-cursor handle for the response thread's gate.
2652    let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
2653    let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
2654    let replica_active_response = replica_active.clone();
2655    let durability_mode_response = Arc::clone(&durability_mode_atomic);
2656    let active_connections_response = Arc::clone(&active_connections);
2657    let s3 = Arc::clone(&shutdown);
2658    let response_utilization_thread = Arc::clone(&response_utilization);
2659    let busy_spin = !config.yield_idle;
2660    let response_handle = std::thread::Builder::new()
2661        .name("response".into())
2662        .spawn(move || {
2663            melin_app::affinity::pin_thread("response", cores.response);
2664            crate::dpdk_response::run::<A>(
2665                output_consumer,
2666                control_rx,
2667                journal_persisted_wire_seq_response,
2668                durability_mode_response,
2669                replication_metrics_response,
2670                replica_active_response,
2671                &s3,
2672                heartbeat_interval,
2673                active_connections_response,
2674                tx_producers,
2675                response_utilization_thread,
2676                busy_spin,
2677                encoder,
2678            );
2679        })
2680        .map_err(|e| format!("spawn response thread: {e}"))?;
2681
2682    let shadow_handle = spawn_shadow_stage::<A>(
2683        shadow_consumer,
2684        shadow_exchange,
2685        chain_hash_lock,
2686        &config,
2687        &cores,
2688        &shutdown,
2689        busy_spin,
2690        fence_state.epoch(),
2691    )?;
2692
2693    // Spawn DPDK replication sender if enabled. Uses its own DPDK queue pair
2694    // and smoltcp stack so the replication channel goes through kernel bypass.
2695    // `replication_metrics` was constructed above so the response gate can
2696    // read per-slot cursors; the sender thread shares the same instance.
2697    let replica_ready = Arc::new(AtomicBool::new(false));
2698    // Replication, if enabled: build a `DpdkReplicationDriver` for the
2699    // single client poll thread to drive. The driver's accept dispatch
2700    // hangs off the second listener we added on the client transport
2701    // earlier (port == repl_bind.port()).
2702    let (repl_driver, repl_listen_port) = if let Some((repl_consumer_1, repl_consumer_2)) =
2703        replication_consumers
2704    {
2705        let repl_bind = config
2706            .replication_bind
2707            .ok_or("replication_bind must be set when replication is enabled")?;
2708        let repl_port = repl_bind.port();
2709
2710        let repl_slots = cursors.replica_slot_cursors();
2711        let ready_flag = Arc::clone(&replica_ready);
2712        let batch_size = config.replication_batch_size;
2713        let heartbeat_secs = config.replication_heartbeat_secs;
2714        let repl_metrics = replication_metrics
2715            .clone()
2716            .ok_or("replication_metrics must be Some when replication is enabled")?;
2717        let connected_counter = replicas_connected
2718            .clone()
2719            .ok_or("replicas_connected must be Some when replication is enabled")?;
2720        let dpdk_active_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
2721            .as_ref()
2722            .map(|rp| {
2723                [
2724                    Arc::clone(&rp.active_flags[0]),
2725                    Arc::clone(&rp.active_flags[1]),
2726                ]
2727            })
2728            .unwrap_or_else(|| {
2729                [
2730                    Arc::new(AtomicBool::new(false)),
2731                    Arc::new(AtomicBool::new(false)),
2732                ]
2733            });
2734        let dpdk_evict_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
2735            .as_ref()
2736            .map(|rp| {
2737                [
2738                    Arc::clone(&rp.evict_flags[0]),
2739                    Arc::clone(&rp.evict_flags[1]),
2740                ]
2741            })
2742            .unwrap_or_else(|| {
2743                [
2744                    Arc::new(AtomicBool::new(false)),
2745                    Arc::new(AtomicBool::new(false)),
2746                ]
2747            });
2748        let journal_path = config.journal.clone();
2749
2750        // Add the replication listener to the client transport so the
2751        // poll thread accepts both trading and replication connections
2752        // off the same queue.
2753        // Replication needs larger TX buffers than trading: journal batches
2754        // can be 100-200 KiB, far exceeding the 16 KiB client TX buffer.
2755        const REPL_TX_BUF: usize = 512 * 1024;
2756        const REPL_TX_QUEUE: usize = 512 * 1024;
2757        const REPL_RX_BUF: usize = 64 * 1024;
2758        transports[0]
2759            .add_listener_with_buffers(repl_port, REPL_RX_BUF, REPL_TX_BUF, REPL_TX_QUEUE)
2760            .map_err(|e| format!("add replication listener: {e}"))?;
2761
2762        let driver = crate::replication::DpdkReplicationDriver::new(
2763            [repl_consumer_1, repl_consumer_2],
2764            repl_slots,
2765            journal_path,
2766            ready_flag,
2767            connected_counter,
2768            dpdk_evict_flags,
2769            dpdk_active_flags,
2770            repl_metrics,
2771            batch_size,
2772            heartbeat_secs,
2773            Arc::clone(&fence_state),
2774            Arc::clone(&durability_mode_atomic),
2775            Arc::clone(&authorized_keys),
2776        );
2777        // Legacy text match — `lan-bench-suite.sh` `wait_for_log` keys
2778        // off "DPDK replication sender started" to know the primary
2779        // is ready to accept replicas. Kept verbatim for backward
2780        // compatibility with existing bench tooling.
2781        info!(addr = %repl_bind, "DPDK replication sender started (single-queue, in client poll thread)");
2782        (Some(driver), repl_port)
2783    } else {
2784        if !config.standalone && config.replica_of.is_none() {
2785            info!("running in standalone mode (no replication)");
2786        }
2787        (None, 0)
2788    };
2789    // The DPDK replication-sender thread used to be spawned here; with
2790    // the single-queue / single-thread refactor the driver lives inside
2791    // the client poll thread instead. Nothing to join.
2792    let replication_handle: Option<std::thread::JoinHandle<()>> = None;
2793
2794    // Seed through the pipeline if needed.
2795    //
2796    // The previous DPDK path waited here for at least one replica to
2797    // connect before seeding so the replica picked up the seed events
2798    // live (skipping a journal-catch-up step). That gate is incompatible
2799    // with the single-queue / single-thread design: the main thread IS
2800    // the poll thread that accepts replica connections, and main is
2801    // currently blocked here pre-seed → replicas have nowhere to land →
2802    // deadlock until shutdown.
2803    //
2804    // Drop the gate. Seeding proceeds unconditionally; replicas that
2805    // connect after seeding catch up via the journal protocol (a few
2806    // hundred batches at startup — measured in tens of milliseconds, not
2807    // a meaningful operational cost). The kernel-TCP path still has its
2808    // own gate via the spawned `repl-sender` thread which can accept
2809    // replicas independently of seeding; this only changes DPDK behavior.
2810    let _ = (&enable_replication, &replica_ready); // suppress unused warnings on non-DPDK paths
2811    if needs_seeding {
2812        use melin_app::unix_epoch_nanos;
2813        use melin_journal::JournalEvent;
2814        use melin_transport_core::trace::mono_trace_ns;
2815
2816        let seed_events = factory.seed_events();
2817        let seed_count = seed_events.len();
2818
2819        // `sequence: 0` — the journal stage allocates sequences in
2820        // disruptor cursor order at encode time.
2821        let mut last_published_seq = 0u64;
2822        for event in seed_events {
2823            last_published_seq = input_producer.publish(InputSlot {
2824                connection_id: 0,
2825                key_hash: 0,
2826                request_seq: 0,
2827                sequence: 0,
2828                timestamp_ns: unix_epoch_nanos(),
2829                event: JournalEvent::App(event),
2830                publish_ts: mono_trace_ns(),
2831                recv_ts: mono_trace_ns(),
2832            });
2833        }
2834
2835        // Wait for seeding to complete through journal + matching stages,
2836        // then wait for the replication ring to drain. See TCP path comment.
2837        // Skip when the factory produced no seed events — nothing on the ring.
2838        if seed_count > 0 {
2839            let last_seed_seq = last_published_seq + 1;
2840            while !shutdown.load(std::sync::atomic::Ordering::Relaxed)
2841                && (journal_cursor
2842                    .get()
2843                    .load(std::sync::atomic::Ordering::Acquire)
2844                    < last_seed_seq
2845                    || matching_cursor
2846                        .get()
2847                        .load(std::sync::atomic::Ordering::Acquire)
2848                        < last_seed_seq)
2849            {
2850                std::hint::spin_loop();
2851            }
2852            if let Some(ref ring_progress) = replication_ring_progress {
2853                for i in 0..ring_progress.producer_cursors.len() {
2854                    if !ring_progress.active_flags[i].load(std::sync::atomic::Ordering::Relaxed) {
2855                        continue;
2856                    }
2857                    let target = ring_progress.producer_cursors[i].load();
2858                    while !shutdown.load(std::sync::atomic::Ordering::Relaxed)
2859                        && ring_progress.consumer_cursors[i]
2860                            .get()
2861                            .load(std::sync::atomic::Ordering::Acquire)
2862                            < target
2863                    {
2864                        std::hint::spin_loop();
2865                    }
2866                }
2867            }
2868        }
2869
2870        info!(
2871            accounts = config.accounts,
2872            instruments = config.instruments,
2873            "seeded test data through pipeline"
2874        );
2875    }
2876
2877    // Note: the DPDK poll threads are spawned BELOW, after this seed-drain
2878    // block. That ordering is what keeps the input ring single-producer
2879    // during seeding — the same property the TCP path achieves by deferring
2880    // its `spawn_reader` call to after seed-drain.
2881
2882    let pipeline_healthy = Arc::new(AtomicBool::new(true));
2883    let health_handle = spawn_health_endpoint(
2884        &config,
2885        &active_connections,
2886        &events_processed,
2887        &cursors,
2888        input_cursor,
2889        &pipeline_healthy,
2890        &replicas_connected,
2891        &fence_state,
2892        &replication_metrics,
2893        &replica_active,
2894        &replication_ring_progress,
2895        &journal_utilization,
2896        &matching_utilization,
2897        &response_utilization,
2898        &shutdown,
2899        &raft_status,
2900    )?;
2901
2902    info!(
2903        ip = %dpdk_config.ip_addr,
2904        port = dpdk_config.listen_port,
2905        num_dpdk_threads,
2906        "DPDK transport listening"
2907    );
2908
2909    let connection_timeout = config.connection_timeout();
2910    let max_conns = config.max_connections;
2911    let reader_core = config.cores.reader;
2912
2913    // Exactly one client poll queue (LMAX: single reader → single matcher).
2914    // Additional DPDK queues, if any, are dedicated to the replication sender
2915    // on its own thread and don't touch the input ring.
2916    assert_eq!(
2917        transports.len(),
2918        1,
2919        "expected exactly one client DPDK transport"
2920    );
2921    let transport_0 = transports.pop().expect("one client transport");
2922    let tx_rx_0 = tx_consumers.remove(0);
2923    melin_app::affinity::pin_thread("dpdk-poll-0", reader_core);
2924    crate::dpdk_transport::run_dpdk_poll::<A>(
2925        transport_0,
2926        input_producer,
2927        decoder,
2928        control_tx,
2929        tx_rx_0,
2930        &shutdown,
2931        authorized_keys,
2932        connection_timeout,
2933        tick_cadence,
2934        max_conns,
2935        Arc::clone(&active_connections),
2936        0,
2937        repl_driver,
2938        repl_listen_port,
2939    );
2940
2941    // The client poll runs on the main thread, so no extra DPDK threads
2942    // to join here — replication sender (if enabled) is joined below.
2943    let dpdk_extras: Vec<(String, std::thread::Result<()>)> = Vec::new();
2944
2945    // The raft guard drops — stopping the driver — after this returns.
2946    shutdown_pipeline_stages(
2947        PipelineHandles {
2948            journal: journal_handle,
2949            matching: matching_handle,
2950            response: response_handle,
2951            replication: replication_handle,
2952            event_publisher: None,
2953            shadow: shadow_handle,
2954            health: health_handle,
2955        },
2956        dpdk_extras,
2957        &pipeline_healthy,
2958        &shutdown,
2959    )
2960}
2961
2962// The `apply_max_orders` / `empty_app` / `empty_app_for_seed` helpers
2963// that used to live here have moved behind the `AppFactory` trait —
2964// see `melin_server::ExchangeAppFactory`. The runtime
2965// reaches them through the `factory: &dyn AppFactory<App = A>`
2966// parameter threaded into [`init_engine`], [`run_as_primary`], and
2967// the replication receivers; the binary passes a single
2968// `Arc<dyn AppFactory<App = A>>` into [`run`] / [`run_dpdk`] at
2969// startup and the runtime clones it on each call.
2970//
2971// SEC-03 (per-account open-order cap) and SEC-04 (order-submission
2972// rate limit) live in `Exchange` state but are operator policy, not
2973// journaled — primary and replicas must converge on them via
2974// matching factory configuration rather than replay.
2975
2976/// Bootstrap source chosen by [`init_engine`] from the on-disk layout.
2977#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2978enum BootstrapSource {
2979    /// Snapshot plus at least one journal segment (live or archived):
2980    /// restore the snapshot, replay the post-snapshot delta.
2981    SnapshotAndJournal,
2982    /// Snapshot with no journal segment at all: restore the snapshot and
2983    /// open a fresh segment continuing its sequence + chain.
2984    SnapshotOnly,
2985    /// Journal segments but no snapshot: full replay from genesis.
2986    JournalOnly,
2987    /// Nothing on disk: first-ever startup.
2988    Fresh,
2989}
2990
2991/// Pure bootstrap decision, extracted so the matrix is unit-testable.
2992///
2993/// The critical cell: `(snapshot, no live, archives)` must route through
2994/// snapshot+journal recovery — the archives may hold acked events past
2995/// the snapshot (post-rotation crash window), and only the recovery walk
2996/// replays them, verifies the lineage, and synthesizes the missing live
2997/// segment. Likewise `(no snapshot, no live, archives)` is journal
2998/// recovery, not a fresh start.
2999fn choose_bootstrap(
3000    snapshot_exists: bool,
3001    live_exists: bool,
3002    archives_exist: bool,
3003) -> BootstrapSource {
3004    let lineage_exists = live_exists || archives_exist;
3005    match (snapshot_exists, lineage_exists) {
3006        (true, true) => BootstrapSource::SnapshotAndJournal,
3007        (true, false) => BootstrapSource::SnapshotOnly,
3008        (false, true) => BootstrapSource::JournalOnly,
3009        (false, false) => BootstrapSource::Fresh,
3010    }
3011}
3012
3013/// Initialize or recover the journaled application from disk.
3014///
3015/// Returns `(app, writer, needs_seeding, recovered_epoch)`. `needs_seeding`
3016/// is true on first startup (fresh journal) — the caller must publish seed
3017/// events through the pipeline. `recovered_epoch` is the fencing epoch
3018/// recovered from the snapshot + journal (0 for a genesis node); the caller
3019/// seeds the node's `FenceState` with it. The recovery paths
3020/// (snapshot+journal, snapshot only, journal only, fresh) are
3021/// transport-level concerns and work uniformly for any `A: Application`
3022/// via `JournaledApp<A>`. Same engine initialization the TCP / DPDK paths use.
3023pub(crate) fn init_engine<A, W>(
3024    config: &ServerConfig,
3025    factory: &dyn AppFactory<App = A>,
3026) -> Result<(A, W, bool, u64), Box<dyn std::error::Error>>
3027where
3028    A: Application,
3029    W: JournalWrite<A::Event>,
3030{
3031    // Check for a snapshot: either the explicit --snapshot path, or the
3032    // default derived path (used by auto-rotation when --snapshot is not set).
3033    let derived_snap = config.journal.with_extension("snapshot");
3034    let snap_path = config.snapshot.as_deref().or_else(|| {
3035        if derived_snap.exists() {
3036            Some(derived_snap.as_path())
3037        } else {
3038            None
3039        }
3040    });
3041
3042    // The journal *lineage* exists if either the live segment or any
3043    // archived segment is on disk. Deciding on the live file alone is
3044    // how acked events used to vanish: a crash between `archive_live`
3045    // and `create_continuing` leaves archives (holding events past the
3046    // snapshot) with no live file — bootstrapping "snapshot only" from
3047    // that layout would silently rewind every event in the archives.
3048    // `JournaledApp::recover*` handles the missing-live case itself
3049    // (replays archives, synthesizes a fresh live continuing the chain).
3050    let journal_exists = config.journal.exists();
3051    let archives_exist = !melin_journal::segment::list_archives(&config.journal)?.is_empty();
3052    let snapshot_exists = snap_path.is_some_and(|p| p.exists());
3053    let writer_mode = config.journal_writer;
3054    let mut engine: JournaledApp<A, W> = match choose_bootstrap(
3055        snapshot_exists,
3056        journal_exists,
3057        archives_exist,
3058    ) {
3059        BootstrapSource::SnapshotAndJournal => {
3060            let snap_path = snap_path.expect("snapshot_exists implies snap_path");
3061            info!(snapshot = %snap_path.display(), writer_mode = %writer_mode, "recovering from snapshot + journal");
3062            JournaledApp::<A, W>::recover_from_snapshot(snap_path, &config.journal)?
3063        }
3064        BootstrapSource::SnapshotOnly => {
3065            // Snapshot exists but no journal segment survives at all —
3066            // recover from the snapshot alone and start a fresh segment
3067            // continuing its sequence and chain.
3068            let snap_path = snap_path.expect("snapshot_exists implies snap_path");
3069            info!(
3070                snapshot = %snap_path.display(),
3071                writer_mode = %writer_mode,
3072                "recovering from snapshot only (no journal segments on disk)"
3073            );
3074            let (app, snap_sequence, snap_chain_hash, snap_epoch) =
3075                melin_transport_core::snapshot::load::<A>(snap_path)?;
3076            let writer = W::create_continuing(&config.journal, snap_sequence + 1, snap_chain_hash)?;
3077            JournaledApp::<A, W>::from_parts(app, writer, snap_epoch)
3078        }
3079        BootstrapSource::JournalOnly => {
3080            info!(writer_mode = %writer_mode, "recovering from journal");
3081            let mut app = factory.empty();
3082            factory.prefault(&mut app);
3083            JournaledApp::<A, W>::recover(app, &config.journal)?
3084        }
3085        BootstrapSource::Fresh => {
3086            info!(writer_mode = %writer_mode, "creating new journal");
3087            let mut app = factory.empty();
3088            factory.prefault(&mut app);
3089            JournaledApp::<A, W>::create(app, &config.journal)?
3090        }
3091    };
3092
3093    // Seed only on a genuinely fresh start — any surviving lineage
3094    // (live or archived) already contains the seed events.
3095    let needs_seeding = !journal_exists && !archives_exist;
3096
3097    // Apply runtime config knobs that the snapshot doesn't carry. The
3098    // SEC-03 cap and the SEC-04 rate-limit `(rate, burst)` pair are
3099    // operator policy — replica and primary converge on them via
3100    // `ServerConfig`, not replay. Two notes specific to SEC-04 (v18+):
3101    //   * Per-account bucket *state* (`tokens` / `last_refill_ns`) IS
3102    //     journaled in the snapshot and was already restored above; this
3103    //     call only re-applies the *configuration*.
3104    //   * `set_max_orders_per_second` clears buckets only when the new
3105    //     `(rate, burst)` differs from the existing values. A snapshot
3106    //     restore leaves the engine with whatever rate-limit config the
3107    //     primary had at snapshot time, so reapplying the operator's
3108    //     matching config here is a no-op for buckets — the freshly
3109    //     restored state is preserved. An operator mis-set that differs
3110    //     from the primary's config will silently clear those buckets;
3111    //     primary and replica must run with matching values.
3112    factory.apply_operator_policy(engine.app_mut());
3113
3114    // Archive the live journal segment if it exceeds the configured
3115    // size threshold. The shadow exchange owns snapshot writes; here we
3116    // only rotate the segment so disk usage stays bounded across
3117    // restarts. Recovery walks the archive chain forward from the
3118    // latest shadow snapshot.
3119    if config.max_journal_mib > 0 {
3120        let threshold = config.max_journal_mib * 1024 * 1024;
3121        let current_size = engine.journal_size();
3122        if current_size > threshold {
3123            info!(
3124                current_mib = current_size / (1024 * 1024),
3125                threshold_mib = config.max_journal_mib,
3126                "journal exceeds threshold, rotating segment"
3127            );
3128            engine.rotate_segment()?;
3129            info!("journal segment rotated successfully");
3130        }
3131    }
3132
3133    let recovered_epoch = engine.recovered_epoch();
3134    let (app, writer) = engine.into_parts();
3135    Ok((app, writer, needs_seeding, recovered_epoch))
3136}
3137
3138// ---------------------------------------------------------------------------
3139// Shared helpers — used identically by both TCP and DPDK boot paths.
3140// ---------------------------------------------------------------------------
3141
3142fn build_replication_metrics(
3143    has_replication: bool,
3144    durability_mode_atomic: &AtomicU8,
3145    config_mode: crate::durability_policy::DurabilityMode,
3146) -> Option<Arc<crate::replication::ReplicationMetrics>> {
3147    let metrics = if has_replication {
3148        Some(Arc::new(crate::replication::ReplicationMetrics::default()))
3149    } else {
3150        None
3151    };
3152
3153    let active_mode = crate::durability_policy::DurabilityMode::from_u8(
3154        durability_mode_atomic.load(Ordering::Relaxed),
3155    )
3156    .unwrap_or(config_mode);
3157    info!(
3158        mode = %active_mode,
3159        policy = %active_mode.to_policy(),
3160        "durability mode active"
3161    );
3162
3163    metrics
3164}
3165
3166fn spawn_shadow_stage<A: Application + Send + 'static>(
3167    shadow_consumer: Option<Consumer<InputSlot<A::Event>>>,
3168    shadow_exchange: Option<A>,
3169    chain_hash_lock: Option<
3170        melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3171    >,
3172    config: &ServerConfig,
3173    cores: &PipelineCores,
3174    shutdown: &Arc<AtomicBool>,
3175    busy_spin: bool,
3176    initial_epoch: u64,
3177) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
3178where
3179    A::Event: Send + Sync + 'static,
3180{
3181    let Some(shadow_cons) = shadow_consumer else {
3182        return Ok(None);
3183    };
3184    let snap_path = config.shadow_snapshot_path();
3185    let interval = std::time::Duration::from_millis(config.snapshot_interval_ms);
3186    let chain_hash =
3187        chain_hash_lock.ok_or("chain hash lock must be Some when shadow is enabled")?;
3188    let shadow_ex = shadow_exchange.ok_or("shadow exchange must be Some when shadow is enabled")?;
3189    let s_shadow = Arc::clone(shutdown);
3190    let shadow_core = cores.shadow;
3191    let shadow_initial_epoch = initial_epoch;
3192    let handle = std::thread::Builder::new()
3193        .name("shadow".into())
3194        .spawn(move || {
3195            melin_app::affinity::pin_thread("shadow", shadow_core);
3196            melin_transport_core::shadow::run(
3197                shadow_cons,
3198                shadow_ex,
3199                snap_path,
3200                interval,
3201                chain_hash,
3202                &s_shadow,
3203                busy_spin,
3204                shadow_initial_epoch,
3205            );
3206        })
3207        .map_err(|e| format!("spawn shadow thread: {e}"))?;
3208
3209    info!(
3210        interval_ms = config.snapshot_interval_ms,
3211        path = %config.shadow_snapshot_path().display(),
3212        "shadow snapshot stage started"
3213    );
3214    Ok(Some(handle))
3215}
3216
3217#[allow(clippy::too_many_arguments)]
3218fn spawn_health_endpoint(
3219    config: &ServerConfig,
3220    active_connections: &Arc<AtomicU64>,
3221    events_processed: &Arc<AtomicU64>,
3222    cursors: &melin_transport_core::PipelineCursors,
3223    input_cursor: Box<dyn melin_pipeline::ring::QueueCursor>,
3224    pipeline_healthy: &Arc<AtomicBool>,
3225    replicas_connected: &Option<Arc<std::sync::atomic::AtomicU32>>,
3226    fence_state: &Arc<melin_transport_core::fence::FenceState>,
3227    replication_metrics: &Option<Arc<crate::replication::ReplicationMetrics>>,
3228    replica_active: &Option<[Arc<AtomicBool>; 2]>,
3229    replication_ring_progress: &Option<melin_transport_core::pipeline::ReplicationRingProgress>,
3230    journal_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3231    matching_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3232    response_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3233    shutdown: &Arc<AtomicBool>,
3234    raft_status: &Option<Arc<melin_transport_core::health::RaftStatus>>,
3235) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>> {
3236    let Some(health_addr) = config.health_bind else {
3237        return Ok(None);
3238    };
3239
3240    let (repl_ring_producers, repl_ring_consumers) = replication_ring_progress
3241        .as_ref()
3242        .map(|rp| {
3243            (
3244                Some([
3245                    Arc::clone(&rp.producer_cursors[0]),
3246                    Arc::clone(&rp.producer_cursors[1]),
3247                ]),
3248                Some([
3249                    Arc::clone(&rp.consumer_cursors[0]),
3250                    Arc::clone(&rp.consumer_cursors[1]),
3251                ]),
3252            )
3253        })
3254        .unwrap_or((None, None));
3255    Ok(Some(melin_transport_core::health::spawn(
3256        health_addr,
3257        melin_transport_core::health::HealthState {
3258            active_connections: Arc::clone(active_connections),
3259            events_processed: Arc::clone(events_processed),
3260            cursors: cursors.clone(),
3261            input_cursor,
3262            pipeline_healthy: Arc::clone(pipeline_healthy),
3263            replicas_connected: replicas_connected.clone(),
3264            fence_state: Some(Arc::clone(fence_state)),
3265            replication_metrics: replication_metrics.clone(),
3266            replica_active: replica_active.clone(),
3267            replication_ring_producer_cursors: repl_ring_producers,
3268            replication_ring_consumer_cursors: repl_ring_consumers,
3269            journal_utilization: Arc::clone(journal_utilization),
3270            matching_utilization: Arc::clone(matching_utilization),
3271            response_utilization: Arc::clone(response_utilization),
3272            raft: raft_status.clone(),
3273        },
3274        Arc::clone(shutdown),
3275    )?))
3276}
3277
3278/// Spawn the event-publisher thread on the affinity core configured by
3279/// `cores.event_publisher`, delegating the loop body to the
3280/// caller-supplied [`EventPublisherFn`]. Returns `Ok(None)` when either
3281/// the consumer slot wasn't wired (the pipeline build saw
3282/// `enable_event_publisher == false`) or the binary passed no
3283/// publisher fn. The runtime owns thread lifecycle and CPU pinning so
3284/// the caller's fn stays domain-only.
3285fn spawn_event_publisher<A: Application>(
3286    consumer: Option<Consumer<OutputSlot<A>>>,
3287    run_fn: Option<EventPublisherFn<A>>,
3288    config: &ServerConfig,
3289    cores: &PipelineCores,
3290    authorized_keys: &Arc<AuthorizedKeys>,
3291    shutdown: &Arc<AtomicBool>,
3292    busy_spin: bool,
3293) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
3294where
3295    A::Report: Send + 'static,
3296    A::QueryResponse: Send + 'static,
3297{
3298    let (Some(event_consumer), Some(run_fn)) = (consumer, run_fn) else {
3299        return Ok(None);
3300    };
3301    let event_bind = config
3302        .event_bind
3303        .ok_or("event_bind must be set when event publisher is enabled")?;
3304    let s_event = Arc::clone(shutdown);
3305    let event_keys = Arc::clone(authorized_keys);
3306    let event_core = cores.event_publisher;
3307    let event_handle = std::thread::Builder::new()
3308        .name("event-publisher".into())
3309        .spawn(move || {
3310            melin_app::affinity::pin_thread("event-publisher", event_core);
3311            run_fn(event_consumer, event_bind, event_keys, &s_event, busy_spin);
3312        })
3313        .map_err(|e| format!("spawn event publisher thread: {e}"))?;
3314    info!(addr = %event_bind, "event publisher started");
3315    Ok(Some(event_handle))
3316}
3317
3318/// Perform challenge-response authentication on a new connection.
3319///
3320/// Runs on the accept thread (cold path, blocking). The caller must set
3321/// a read timeout on the stream before calling to prevent slow clients
3322/// from stalling the accept loop.
3323///
3324/// Uses raw `read_exact` instead of `BufReader` to avoid over-reading
3325/// bytes that belong to the first post-auth request.
3326///
3327/// Returns `(Permission, public_key_bytes)` on success.
3328fn authenticate_connection<R: std::io::Read, W: std::io::Write>(
3329    connection_id: ConnectionId,
3330    addr: SocketAddr,
3331    reader: &mut R,
3332    writer: &mut W,
3333    authorized_keys: &AuthorizedKeys,
3334) -> Result<(Permission, [u8; 32]), Box<dyn std::error::Error>> {
3335    use std::io;
3336
3337    use ed25519_dalek::{Verifier, VerifyingKey};
3338    use melin_wire_protocol::control::TransportResponse;
3339    use melin_wire_protocol::control_codec;
3340
3341    // Generate a 32-byte random nonce for this connection.
3342    // Explicit OsRng for cryptographic material (SEC-10).
3343    let mut nonce = [0u8; 32];
3344    getrandom::fill(&mut nonce).map_err(|e| io::Error::other(format!("getrandom failed: {e}")))?;
3345
3346    let mut buf = [0u8; 128];
3347    let written =
3348        control_codec::encode_transport_response(&TransportResponse::Challenge { nonce }, &mut buf)
3349            .map_err(|e| io::Error::other(format!("encode Challenge: {e}")))?;
3350    writer.write_all(&buf[..written])?;
3351    writer.flush()?;
3352
3353    // Read ChallengeResponse frame directly (no BufReader). Using raw
3354    // read_exact avoids BufReader over-reading bytes that belong to the
3355    // first post-auth request — those bytes would be lost when the
3356    // BufReader is dropped and the fd moves to the io_uring reader.
3357    let mut len_buf = [0u8; 4];
3358    reader
3359        .read_exact(&mut len_buf)
3360        .map_err(|e| io::Error::other(format!("read auth frame length: {e}")))?;
3361    let frame_len = u32::from_le_bytes(len_buf) as usize;
3362    // ChallengeResponse is 1 (tag) + 64 (signature) + 32 (public key) = 97 bytes.
3363    if frame_len > 256 {
3364        send_auth_failed(writer);
3365        return Err(io::Error::other(format!("auth frame too large: {frame_len}")).into());
3366    }
3367    let mut frame_buf = [0u8; 256];
3368    reader
3369        .read_exact(&mut frame_buf[..frame_len])
3370        .map_err(|e| io::Error::other(format!("read auth frame payload: {e}")))?;
3371
3372    let (_seq, cr) = match control_codec::decode_challenge_response(&frame_buf[..frame_len]) {
3373        Ok(pair) => pair,
3374        Err(e) => {
3375            send_auth_failed(writer);
3376            return Err(io::Error::other(format!("decode ChallengeResponse: {e}")).into());
3377        }
3378    };
3379
3380    let (signature_bytes, public_key_bytes) = (cr.signature, cr.public_key);
3381
3382    // Look up the public key in authorized_keys.
3383    let permission = match authorized_keys.lookup(&public_key_bytes) {
3384        Some(perm) => perm,
3385        None => {
3386            send_auth_failed(writer);
3387            return Err("unknown public key".into());
3388        }
3389    };
3390
3391    // Verify the Ed25519 signature over `nonce ‖ server_eph ‖
3392    // client_eph`. TCP path's ephs are zeros — see Challenge above.
3393    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|e| {
3394        send_auth_failed(writer);
3395        io::Error::other(format!("invalid public key: {e}"))
3396    })?;
3397    let signature = ed25519_dalek::Signature::from_bytes(&signature_bytes);
3398    verifying_key.verify(&nonce, &signature).map_err(|e| {
3399        send_auth_failed(writer);
3400        io::Error::other(format!("signature verification failed: {e}"))
3401    })?;
3402
3403    // Auth succeeded — send ServerReady.
3404    let written =
3405        control_codec::encode_transport_response(&TransportResponse::ServerReady, &mut buf)
3406            .map_err(|e| io::Error::other(format!("encode ServerReady: {e}")))?;
3407    writer.write_all(&buf[..written])?;
3408    writer.flush()?;
3409
3410    debug!(
3411        connection_id = connection_id.0,
3412        addr = %addr,
3413        permission = ?permission,
3414        "authenticated"
3415    );
3416
3417    Ok((permission, public_key_bytes))
3418}
3419
3420/// Set a read timeout on a raw fd via `setsockopt(SO_RCVTIMEO)`.
3421///
3422/// Works for both TCP and UDS since both are sockets. Uses `AsRawFd`
3423/// to avoid requiring a concrete stream type.
3424fn set_read_timeout<F: std::os::unix::io::AsRawFd>(
3425    fd: &F,
3426    timeout: Option<std::time::Duration>,
3427) -> std::io::Result<()> {
3428    let tv = match timeout {
3429        Some(d) => libc::timeval {
3430            tv_sec: d.as_secs() as libc::time_t,
3431            tv_usec: d.subsec_micros() as libc::suseconds_t,
3432        },
3433        None => libc::timeval {
3434            tv_sec: 0,
3435            tv_usec: 0,
3436        },
3437    };
3438    let ret = unsafe {
3439        libc::setsockopt(
3440            fd.as_raw_fd(),
3441            libc::SOL_SOCKET,
3442            libc::SO_RCVTIMEO,
3443            &tv as *const libc::timeval as *const libc::c_void,
3444            std::mem::size_of::<libc::timeval>() as libc::socklen_t,
3445        )
3446    };
3447    if ret < 0 {
3448        return Err(std::io::Error::last_os_error());
3449    }
3450    Ok(())
3451}
3452
3453/// Enable `SO_BUSY_POLL` on a TCP data socket so the kernel busy-polls
3454/// the NIC for incoming data instead of going to sleep on the softirq
3455/// → wakeup handoff. Removes scheduler-wakeup latency from the recv
3456/// path on hardware where IRQ delivery is the dominant per-packet cost
3457/// (e.g. ixgbe-class NICs). 50 µs covers a typical LAN ack RTT.
3458///
3459/// Best-effort: requires `CAP_NET_ADMIN` (or unprivileged operation
3460/// permitted via sysctl). Failures are surfaced as warnings by the
3461/// caller — they only cost latency, not correctness, and we don't want
3462/// a misconfigured kernel to halt connection acceptance.
3463///
3464/// Only beneficial when the receiving thread is already busy-spinning
3465/// (otherwise the spin cost is wasted on idle connections). All
3466/// callers in Melin meet that condition: client reader threads spin on
3467/// io_uring CQEs, the replication sender's ack-recv thread spins, and
3468/// the bench client thread spins.
3469pub(crate) fn set_busy_poll<F: std::os::unix::io::AsRawFd>(
3470    fd: &F,
3471    micros: i32,
3472) -> std::io::Result<()> {
3473    // SAFETY: fd is a live socket fd owned by the caller for the
3474    // duration of the call; the option pointer is to a stack-local i32
3475    // with the right size for SO_BUSY_POLL.
3476    let val: libc::c_int = micros;
3477    let ret = unsafe {
3478        libc::setsockopt(
3479            fd.as_raw_fd(),
3480            libc::SOL_SOCKET,
3481            libc::SO_BUSY_POLL,
3482            &val as *const libc::c_int as *const libc::c_void,
3483            std::mem::size_of::<libc::c_int>() as libc::socklen_t,
3484        )
3485    };
3486    if ret < 0 {
3487        return Err(std::io::Error::last_os_error());
3488    }
3489    Ok(())
3490}
3491
3492/// Default `SO_BUSY_POLL` window in microseconds. Matches the value
3493/// already used on the replica receive socket; chosen to cover a
3494/// typical LAN round-trip without burning excessive CPU on quiet
3495/// connections.
3496pub(crate) const BUSY_POLL_US: i32 = 50;
3497
3498/// Set `SO_SNDTIMEO` on a socket. Prevents blocking writes from stalling
3499/// the response thread when a client stops reading (SEC-01).
3500fn set_write_timeout<F: std::os::unix::io::AsRawFd>(
3501    fd: &F,
3502    timeout: Option<std::time::Duration>,
3503) -> std::io::Result<()> {
3504    let tv = match timeout {
3505        Some(d) => libc::timeval {
3506            tv_sec: d.as_secs() as libc::time_t,
3507            tv_usec: d.subsec_micros() as libc::suseconds_t,
3508        },
3509        None => libc::timeval {
3510            tv_sec: 0,
3511            tv_usec: 0,
3512        },
3513    };
3514    let ret = unsafe {
3515        libc::setsockopt(
3516            fd.as_raw_fd(),
3517            libc::SOL_SOCKET,
3518            libc::SO_SNDTIMEO,
3519            &tv as *const libc::timeval as *const libc::c_void,
3520            std::mem::size_of::<libc::timeval>() as libc::socklen_t,
3521        )
3522    };
3523    if ret < 0 {
3524        return Err(std::io::Error::last_os_error());
3525    }
3526    Ok(())
3527}
3528
3529/// Best-effort send of AuthFailed before dropping a connection.
3530fn send_auth_failed(writer: &mut impl std::io::Write) {
3531    use melin_wire_protocol::control::TransportResponse;
3532    use melin_wire_protocol::control_codec;
3533
3534    let mut buf = [0u8; 8];
3535    if let Ok(written) =
3536        control_codec::encode_transport_response(&TransportResponse::AuthFailed, &mut buf)
3537    {
3538        let _ = writer.write_all(&buf[..written]);
3539        let _ = writer.flush();
3540    }
3541}
3542
3543#[cfg(test)]
3544mod tests {
3545    use std::io::{Read, Write};
3546    use std::os::unix::net::UnixStream;
3547
3548    use ed25519_dalek::{Signer, SigningKey};
3549    use melin_app::auth::AuthorizedKeys;
3550    use melin_app::auth::Permission;
3551    use melin_wire_protocol::control::ConnectionId;
3552    use melin_wire_protocol::control_codec::{
3553        TAG_AUTH_FAILED, TAG_CHALLENGE, TAG_CHALLENGE_RESPONSE, TAG_RESPONSE_HEARTBEAT,
3554        TAG_SERVER_READY,
3555    };
3556
3557    use super::authenticate_connection;
3558    use super::{BootstrapSource, choose_bootstrap};
3559
3560    /// The tenth `--cores` entry (journal-prep) is optional: 9-entry
3561    /// values — every explicit configuration written before the entry
3562    /// existed — parse with the preparer unpinned, 10-entry values pin
3563    /// it, and anything else is rejected.
3564    #[test]
3565    fn parse_cores_accepts_nine_or_ten_entries() {
3566        let nine = super::parse_cores("1,2,3,4,5,6,7,8,9").expect("9 entries must parse");
3567        assert_eq!(nine.journal_prep, 0, "omitted journal-prep = unpinned");
3568        assert_eq!(nine.repl_handler_1, 9);
3569
3570        let ten = super::parse_cores("1,2,3,4,5,6,7,8,9,10").expect("10 entries must parse");
3571        assert_eq!(ten.journal_prep, 10);
3572
3573        assert!(super::parse_cores("1,2,3").is_err());
3574        assert!(super::parse_cores("1,2,3,4,5,6,7,8,9,10,11").is_err());
3575    }
3576
3577    /// Full bootstrap decision matrix. The two archive-only cells are
3578    /// the regression guard: a post-rotation crash leaves archives with
3579    /// no live segment, and bootstrapping "snapshot only" (or "fresh")
3580    /// from that layout silently rewinds every acked event held in the
3581    /// archives. Recovery must own any layout where a lineage survives.
3582    #[test]
3583    fn bootstrap_decision_routes_archives_through_recovery() {
3584        use BootstrapSource::*;
3585        // (snapshot, live, archives) → source
3586        let matrix = [
3587            ((false, false, false), Fresh),
3588            ((false, false, true), JournalOnly), // post-rotation crash, no snapshot
3589            ((false, true, false), JournalOnly),
3590            ((false, true, true), JournalOnly),
3591            ((true, false, false), SnapshotOnly),
3592            ((true, false, true), SnapshotAndJournal), // post-rotation crash
3593            ((true, true, false), SnapshotAndJournal),
3594            ((true, true, true), SnapshotAndJournal),
3595        ];
3596        for ((snap, live, arch), expected) in matrix {
3597            assert_eq!(
3598                choose_bootstrap(snap, live, arch),
3599                expected,
3600                "snapshot={snap} live={live} archives={arch}"
3601            );
3602        }
3603    }
3604
3605    /// Deterministic test key.
3606    fn test_key() -> SigningKey {
3607        SigningKey::from_bytes(&[0xAA; 32])
3608    }
3609
3610    /// Build an `AuthorizedKeys` containing the test key with the given permission.
3611    fn keys_with_test_key(perm: &str) -> AuthorizedKeys {
3612        // Encode the public key bytes as base64 with the local helper so
3613        // the test has no external codec dependency (all test keys produce
3614        // valid base64), then feed it through AuthorizedKeys::parse.
3615        let pub_bytes = test_key().verifying_key().to_bytes();
3616        let pub_b64 = base64_encode(&pub_bytes);
3617        AuthorizedKeys::parse(&format!("{perm} {pub_b64} test\n")).unwrap()
3618    }
3619
3620    /// Minimal base64 encoder for test use only. Avoids adding base64
3621    /// as a dev-dependency to the server crate.
3622    fn base64_encode(bytes: &[u8]) -> String {
3623        const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3624        let mut out = String::new();
3625        for chunk in bytes.chunks(3) {
3626            let b0 = chunk[0] as u32;
3627            let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3628            let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3629            let n = (b0 << 16) | (b1 << 8) | b2;
3630            out.push(ALPHABET[(n >> 18 & 0x3F) as usize] as char);
3631            out.push(ALPHABET[(n >> 12 & 0x3F) as usize] as char);
3632            if chunk.len() > 1 {
3633                out.push(ALPHABET[(n >> 6 & 0x3F) as usize] as char);
3634            } else {
3635                out.push('=');
3636            }
3637            if chunk.len() > 2 {
3638                out.push(ALPHABET[(n & 0x3F) as usize] as char);
3639            } else {
3640                out.push('=');
3641            }
3642        }
3643        out
3644    }
3645
3646    /// Run `authenticate_connection` on one end of a `UnixStream::pair()`,
3647    /// returning the result. Maps the error to `String` so it's `Send`.
3648    fn run_server_auth(
3649        mut stream: UnixStream,
3650        keys: AuthorizedKeys,
3651    ) -> std::thread::JoinHandle<Result<Permission, String>> {
3652        std::thread::spawn(move || {
3653            // Clone the stream so we have independent read/write halves.
3654            let mut writer = stream.try_clone().unwrap();
3655            authenticate_connection(
3656                ConnectionId(1),
3657                "127.0.0.1:0".parse().unwrap(),
3658                &mut stream,
3659                &mut writer,
3660                &keys,
3661            )
3662            .map(|(perm, _pk)| perm)
3663            .map_err(|e| e.to_string())
3664        })
3665    }
3666
3667    /// Read a length-prefixed Challenge frame and return its 32-byte
3668    /// nonce. Frame layout: `[len:u32][TAG_CHALLENGE][nonce:32]`.
3669    fn read_challenge(stream: &mut UnixStream) -> [u8; 32] {
3670        let mut len_buf = [0u8; 4];
3671        stream.read_exact(&mut len_buf).unwrap();
3672        let len = u32::from_le_bytes(len_buf) as usize;
3673        let mut payload = vec![0u8; len];
3674        stream.read_exact(&mut payload).unwrap();
3675        assert_eq!(payload[0], TAG_CHALLENGE, "expected Challenge");
3676        payload[1..33].try_into().unwrap()
3677    }
3678
3679    /// Write a length-prefixed ChallengeResponse frame, matching the
3680    /// layout the runtime's `control_codec::decode_challenge_response`
3681    /// expects: `[len:u32][seq:u64][TAG_CHALLENGE_RESPONSE][sig:64][pubkey:32]`.
3682    fn write_challenge_response(
3683        stream: &mut UnixStream,
3684        signature: [u8; 64],
3685        public_key: [u8; 32],
3686    ) {
3687        let mut frame = Vec::with_capacity(105);
3688        frame.extend_from_slice(&0u64.to_le_bytes()); // request_seq
3689        frame.push(TAG_CHALLENGE_RESPONSE);
3690        frame.extend_from_slice(&signature);
3691        frame.extend_from_slice(&public_key);
3692        stream
3693            .write_all(&(frame.len() as u32).to_le_bytes())
3694            .unwrap();
3695        stream.write_all(&frame).unwrap();
3696        stream.flush().unwrap();
3697    }
3698
3699    /// Read a Challenge frame from the client end, sign the nonce, and
3700    /// write a valid ChallengeResponse back.
3701    fn client_sign_challenge(stream: &mut UnixStream, key: &SigningKey) {
3702        let nonce = read_challenge(stream);
3703        let sig = key.sign(&nonce);
3704        write_challenge_response(stream, sig.to_bytes(), key.verifying_key().to_bytes());
3705    }
3706
3707    /// Like `client_sign_challenge` but corrupts the signature.
3708    fn client_sign_challenge_bad(stream: &mut UnixStream, key: &SigningKey) {
3709        let nonce = read_challenge(stream);
3710        let mut sig_bytes = key.sign(&nonce).to_bytes();
3711        sig_bytes[0] ^= 0xFF;
3712        write_challenge_response(stream, sig_bytes, key.verifying_key().to_bytes());
3713    }
3714
3715    /// Read one length-prefixed control frame and return its tag byte.
3716    fn read_response_tag(stream: &mut UnixStream) -> u8 {
3717        let mut len_buf = [0u8; 4];
3718        stream.read_exact(&mut len_buf).unwrap();
3719        let len = u32::from_le_bytes(len_buf) as usize;
3720        let mut buf = vec![0u8; len];
3721        stream.read_exact(&mut buf).unwrap();
3722        buf[0]
3723    }
3724
3725    #[test]
3726    fn auth_success_returns_permission() {
3727        let keys = keys_with_test_key("trader");
3728        let key = test_key();
3729        let (s1, mut s2) = UnixStream::pair().unwrap();
3730
3731        let handle = run_server_auth(s1, keys);
3732
3733        client_sign_challenge(&mut s2, &key);
3734        let resp = read_response_tag(&mut s2);
3735        assert_eq!(resp, TAG_SERVER_READY);
3736
3737        let result = handle.join().unwrap();
3738        assert_eq!(result.unwrap(), Permission::Trader);
3739    }
3740
3741    #[test]
3742    fn auth_admin_permission() {
3743        let keys = keys_with_test_key("operator");
3744        let key = test_key();
3745        let (s1, mut s2) = UnixStream::pair().unwrap();
3746
3747        let handle = run_server_auth(s1, keys);
3748
3749        client_sign_challenge(&mut s2, &key);
3750        let resp = read_response_tag(&mut s2);
3751        assert_eq!(resp, TAG_SERVER_READY);
3752
3753        assert_eq!(handle.join().unwrap().unwrap(), Permission::Operator);
3754    }
3755
3756    #[test]
3757    fn auth_unknown_key_sends_auth_failed() {
3758        let keys = AuthorizedKeys::parse("").unwrap();
3759        let key = test_key();
3760        let (s1, mut s2) = UnixStream::pair().unwrap();
3761
3762        let handle = run_server_auth(s1, keys);
3763
3764        client_sign_challenge(&mut s2, &key);
3765        let resp = read_response_tag(&mut s2);
3766        assert_eq!(resp, TAG_AUTH_FAILED);
3767
3768        assert!(handle.join().unwrap().is_err());
3769    }
3770
3771    #[test]
3772    fn auth_bad_signature_sends_auth_failed() {
3773        let keys = keys_with_test_key("operator");
3774        let key = test_key();
3775        let (s1, mut s2) = UnixStream::pair().unwrap();
3776
3777        let handle = run_server_auth(s1, keys);
3778
3779        client_sign_challenge_bad(&mut s2, &key);
3780        let resp = read_response_tag(&mut s2);
3781        assert_eq!(resp, TAG_AUTH_FAILED);
3782
3783        assert!(handle.join().unwrap().is_err());
3784    }
3785
3786    #[test]
3787    fn auth_wrong_message_type_sends_auth_failed() {
3788        let keys = keys_with_test_key("trader");
3789        let (s1, mut s2) = UnixStream::pair().unwrap();
3790
3791        let handle = run_server_auth(s1, keys);
3792
3793        // Read and discard the Challenge.
3794        read_challenge(&mut s2);
3795
3796        // Send a frame carrying a transport-heartbeat tag where a
3797        // ChallengeResponse is expected. It is the right length (105) so
3798        // the auth decoder reaches the tag check and rejects the
3799        // unexpected tag, replying AuthFailed.
3800        let mut frame = vec![0u8; 105];
3801        frame[8] = TAG_RESPONSE_HEARTBEAT; // tag byte sits after the u64 seq
3802        s2.write_all(&(frame.len() as u32).to_le_bytes()).unwrap();
3803        s2.write_all(&frame).unwrap();
3804        s2.flush().unwrap();
3805
3806        let resp = read_response_tag(&mut s2);
3807        assert_eq!(resp, TAG_AUTH_FAILED);
3808
3809        assert!(handle.join().unwrap().is_err());
3810    }
3811
3812    #[test]
3813    fn auth_client_disconnects_is_error() {
3814        let keys = keys_with_test_key("trader");
3815        let (s1, s2) = UnixStream::pair().unwrap();
3816
3817        let handle = run_server_auth(s1, keys);
3818
3819        // Drop immediately — server fails reading the ChallengeResponse.
3820        drop(s2);
3821
3822        assert!(handle.join().unwrap().is_err());
3823    }
3824
3825    #[test]
3826    fn auth_different_key_than_authorized_is_rejected() {
3827        // Authorize the test key, but connect with a different key.
3828        let keys = keys_with_test_key("trader");
3829        let wrong_key = SigningKey::from_bytes(&[0xCC; 32]);
3830        let (s1, mut s2) = UnixStream::pair().unwrap();
3831
3832        let handle = run_server_auth(s1, keys);
3833
3834        client_sign_challenge(&mut s2, &wrong_key);
3835        let resp = read_response_tag(&mut s2);
3836        assert_eq!(resp, TAG_AUTH_FAILED);
3837
3838        assert!(handle.join().unwrap().is_err());
3839    }
3840
3841    #[test]
3842    fn auth_oversized_frame_sends_auth_failed() {
3843        let keys = keys_with_test_key("trader");
3844        let (s1, mut s2) = UnixStream::pair().unwrap();
3845
3846        let handle = run_server_auth(s1, keys);
3847
3848        // Read and discard Challenge.
3849        read_challenge(&mut s2);
3850
3851        // Send a frame claiming to be 1000 bytes (way over the 256 limit).
3852        let fake_len: u32 = 1000;
3853        s2.write_all(&fake_len.to_le_bytes()).unwrap();
3854        s2.flush().unwrap();
3855
3856        // Server should send AuthFailed before dropping.
3857        let resp = read_response_tag(&mut s2);
3858        assert_eq!(resp, TAG_AUTH_FAILED);
3859
3860        assert!(handle.join().unwrap().is_err());
3861    }
3862
3863    #[test]
3864    fn auth_zero_length_frame_sends_auth_failed() {
3865        let keys = keys_with_test_key("trader");
3866        let (s1, mut s2) = UnixStream::pair().unwrap();
3867
3868        let handle = run_server_auth(s1, keys);
3869
3870        // Read and discard Challenge.
3871        read_challenge(&mut s2);
3872
3873        // Send a zero-length frame — decode_request will fail on empty input.
3874        let zero_len: u32 = 0;
3875        s2.write_all(&zero_len.to_le_bytes()).unwrap();
3876        s2.flush().unwrap();
3877
3878        // Server should send AuthFailed before dropping.
3879        let resp = read_response_tag(&mut s2);
3880        assert_eq!(resp, TAG_AUTH_FAILED);
3881
3882        assert!(handle.join().unwrap().is_err());
3883    }
3884
3885    #[test]
3886    fn auth_readonly_permission() {
3887        let keys = keys_with_test_key("readonly");
3888        let key = test_key();
3889        let (s1, mut s2) = UnixStream::pair().unwrap();
3890
3891        let handle = run_server_auth(s1, keys);
3892
3893        client_sign_challenge(&mut s2, &key);
3894        let resp = read_response_tag(&mut s2);
3895        assert_eq!(resp, TAG_SERVER_READY);
3896
3897        let perm = handle.join().unwrap().unwrap();
3898        assert_eq!(perm, Permission::ReadOnly);
3899        assert!(!perm.can_trade());
3900    }
3901}