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