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