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