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