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::client_auth::MAX_AUTH_FRAME;
38use crate::reader::RequestDecoderArc;
39use crate::response::ResponseEncoderArc;
40use melin_app::auth::{AuthorizedKeys, ClientRole, KeyRole, RoleId};
41use melin_app::decoder::{ErasedDecoder, RequestDecoder};
42use melin_app::encoder::ResponseEncoder;
43use melin_app::{AppEvent, Application};
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 on_primary 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 /// under the `replication` role.
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 against the
363 /// node's authorized_keys; which keys may subscribe, and what each may
364 /// see, is the application's publisher's decision.
365 /// Omit to disable (ring has 1 consumer — identical to before).
366 #[arg(long)]
367 pub event_bind: Option<SocketAddr>,
368
369 /// Address for the health/liveness TCP endpoint. On connect, returns
370 /// a one-line status (`OK <conns> <journal_seq> <repl_lag>\n`) and
371 /// closes. No auth required. Set to empty string to disable.
372 #[arg(long, default_value = "127.0.0.1:9878")]
373 pub health_bind: Option<SocketAddr>,
374
375 /// TCP address for the operator admin endpoint. Authenticated with
376 /// operator keys (Ed25519 challenge-response, same as all other
377 /// admin handshakes). Accepts:
378 ///
379 /// - `PROMOTE\n` — replica → primary leadership transition (replica
380 /// nodes only; rejected with ERR on a primary).
381 /// - `ROTATE\n` — archive the current journal segment at the next
382 /// fsync boundary (any node where runtime rotation is enabled).
383 ///
384 /// Unset means no admin endpoint is listening; operators can still
385 /// rely on the size-driven rotation trigger (`--max-journal-mib`)
386 /// without exposing a port.
387 #[arg(long)]
388 pub admin_bind: Option<SocketAddr>,
389
390 /// Interval in milliseconds between automatic shadow snapshots. The
391 /// shadow stage replays journaled events on a cloned `A` and saves a
392 /// consistent snapshot at this cadence — no hot-path stall. Set to 0
393 /// to disable shadow snapshots entirely. Default: 3 000 000 ms (50 min).
394 #[arg(long, default_value_t = 3_000_000)]
395 pub snapshot_interval_ms: u64,
396
397 /// Path for shadow snapshots. Defaults to the journal path with a
398 /// `.snapshot` extension (same as the startup snapshot path).
399 #[arg(long)]
400 pub snapshot_path: Option<PathBuf>,
401
402 /// Cadence in milliseconds for the application's clock tick.
403 /// The ingress thread (io_uring reader or DPDK poll thread) publishes
404 /// a `JournalEvent::Tick { now_ns }` at this interval so the
405 /// application's time-driven work (expiries, timeouts, scheduled
406 /// transitions) fires in deterministic, journaled lockstep. There is
407 /// no separate tick thread on either transport. Set to 0 to disable
408 /// tick generation entirely (useful for benchmarks that don't
409 /// exercise time-driven features).
410 ///
411 /// Defaults to 250 ms. Under load the matching stage advances the
412 /// application's clock at every-event resolution from `slot.timestamp_ns`
413 /// (microsecond precision), so the tick is only the safety net for
414 /// quiet periods. 250 ms keeps time-driven work in quiet periods within
415 /// a quarter-second of their deadline at a cost of ~4 events/sec of
416 /// journal traffic.
417 #[arg(long, default_value_t = 250)]
418 pub tick_interval_ms: u64,
419
420 /// Disable `mlockall(MCL_CURRENT | MCL_FUTURE)` at startup.
421 ///
422 /// By default the server locks all current and future pages into
423 /// RAM to prevent rare multi-millisecond stalls from page faults
424 /// or swap activity on the matching hot path. Locking requires
425 /// `CAP_IPC_LOCK` (or running as root) and `RLIMIT_MEMLOCK` raised
426 /// to a value larger than the process's RSS — the server raises
427 /// the rlimit itself, but only succeeds with the capability.
428 ///
429 /// Use `--no-mlock` for development / containerised runs where
430 /// the privilege isn't available; on a bare-metal production host
431 /// leave it on.
432 #[arg(long, default_value_t = false)]
433 pub no_mlock: bool,
434
435 /// Bind address for the control-plane raft RPC listener. Setting this
436 /// enables the control plane on this node: leader election runs and is
437 /// observable via the `melin_raft_*` gauges. Requires `--raft-node-id`
438 /// and a `--raft-peer` entry for this node; requires
439 /// `--replication-key` (peer links authenticate with the replication
440 /// key, same trust domain as the data plane). Raft always runs on
441 /// kernel TCP, including on DPDK nodes.
442 #[arg(long)]
443 pub raft_bind: Option<SocketAddr>,
444
445 /// This node's control-plane raft id (non-zero, unique per cluster).
446 #[arg(long)]
447 pub raft_node_id: Option<u64>,
448
449 /// A control-plane cluster member as `id@host:port#base64-pubkey`
450 /// (repeatable). Give **every node the same list, including an entry
451 /// for the node itself** — the self entry supplies the dialable
452 /// address peers use to reach it, and identical lists keep the
453 /// first-boot membership consistent. The pubkey pins the peer's
454 /// identity: a connection authenticated with a different key cannot
455 /// speak for that id.
456 #[arg(long)]
457 pub raft_peer: Vec<String>,
458
459 /// Directory for durable raft state (vote, log, membership).
460 /// Defaults to the journal path with a `.raft` extension. Must live
461 /// on the same durability class as the journal — losing it can
462 /// double-grant a vote (see docs/replication.md).
463 #[arg(long)]
464 pub raft_dir: Option<PathBuf>,
465
466 /// Act on control-plane election wins: a replica elected leader
467 /// promotes itself (journaling the election term as its fencing
468 /// epoch), and a serving node self-fences when the raft mesh shows
469 /// it superseded. Off by default — automatic failover is an explicit
470 /// operator policy decision. Requires `--raft-bind` and at least
471 /// three configured voters (a two-node cluster cannot elect after
472 /// losing either node, so automation would be dead weight with real
473 /// misconfiguration risk; see docs/replication.md).
474 #[arg(long, default_value_t = false)]
475 pub raft_auto_promote: bool,
476}
477
478/// Delegates to clap so `#[arg(default_value...)]` is the single source of
479/// truth for every default. Used by the bench crate for struct-literal
480/// construction with `..ServerConfig::default()`.
481impl Default for ServerConfig {
482 fn default() -> Self {
483 // On the dpdk branch we spell out every field so that dpdk-specific
484 // fields (not known to clap on plain builds) get their defaults.
485 // Main uses `Self::parse_from(["melin-server"])` — the values below
486 // mirror those clap defaults plus the dpdk extras.
487 Self {
488 bind: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9876),
489 journal: PathBuf::from("melin.journal"),
490 snapshot: None,
491 cores: PipelineCores {
492 journal_seq: Placement::spinning(1),
493 matching: Placement::spinning(2),
494 response: Placement::spinning(3),
495 reader: Placement::spinning(4),
496 event_publisher: Placement::spinning(6),
497 shadow: Placement::spinning(7),
498 repl_handler_0: Placement::spinning(8),
499 repl_handler_1: Placement::spinning(9),
500 journal_prep: Placement::yielding(10),
501 journal_disk: Placement::spinning(11),
502 },
503 group_commit_us: 0,
504 heartbeat_interval_secs: 10,
505 connection_timeout_secs: 30,
506 max_connections: 1024,
507 authorized_keys: PathBuf::from("authorized_keys"),
508 max_journal_mib: 256,
509 journal_staging_mode: JournalStagingMode::ZeroFill,
510
511 replication_bind: None,
512 standalone: false,
513 replica_of: None,
514 replication_key: None,
515 replication_batch_size: 128,
516 max_journal_batch: 1024,
517 replication_heartbeat_secs: 5,
518 replication_pipeline_depth: DEFAULT_REPLICATION_PIPELINE_DEPTH,
519 replication_ring_size: 256,
520 ack_policy: crate::ack_policy::AckPolicy::DiskAndRam,
521 dpdk_eal_args: String::new(),
522 dpdk_peer_ip: None,
523 dpdk_gateway_mac: None,
524 dpdk_peer_mac: None,
525 dpdk_ports: vec![0],
526 dpdk_ip: "10.0.0.1".into(),
527 dpdk_prefix_len: 24,
528 dpdk_gateway: None,
529 dpdk_mtu: 1500,
530 dpdk_vlan: None,
531 event_bind: None,
532 health_bind: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9878)),
533 admin_bind: None,
534 snapshot_interval_ms: 3_000_000,
535 snapshot_path: None,
536 tick_interval_ms: 250,
537 no_mlock: false,
538 raft_bind: None,
539 raft_node_id: None,
540 raft_peer: Vec::new(),
541 raft_dir: None,
542 raft_auto_promote: false,
543 }
544 }
545}
546
547impl ServerConfig {
548 /// Group commit delay as a Duration.
549 pub fn group_commit_delay(&self) -> std::time::Duration {
550 std::time::Duration::from_micros(self.group_commit_us)
551 }
552
553 /// Heartbeat interval as a Duration. Returns `None` if disabled (0).
554 pub fn heartbeat_interval(&self) -> Option<std::time::Duration> {
555 if self.heartbeat_interval_secs == 0 {
556 None
557 } else {
558 Some(std::time::Duration::from_secs(self.heartbeat_interval_secs))
559 }
560 }
561
562 /// Connection timeout as a Duration. Returns `None` if disabled (0).
563 pub fn connection_timeout(&self) -> Option<std::time::Duration> {
564 if self.connection_timeout_secs == 0 {
565 None
566 } else {
567 Some(std::time::Duration::from_secs(self.connection_timeout_secs))
568 }
569 }
570
571 /// Snapshot path for the shadow stage. Uses the explicit `--snapshot-path`
572 /// if set, otherwise derives from the journal path with `.snapshot` extension.
573 pub fn shadow_snapshot_path(&self) -> PathBuf {
574 self.snapshot_path
575 .clone()
576 .unwrap_or_else(|| self.journal.with_extension("snapshot"))
577 }
578
579 /// Tick generator cadence as a `Duration`. Returns `None` when the tick
580 /// thread is disabled (`--tick-interval-ms 0`).
581 pub fn tick_interval(&self) -> Option<std::time::Duration> {
582 if self.tick_interval_ms == 0 {
583 None
584 } else {
585 Some(std::time::Duration::from_millis(self.tick_interval_ms))
586 }
587 }
588
589 /// The per-thread layout this node actually runs — see
590 /// [`PipelineCores::resolve`]. `reader` says which thread the reader
591 /// entry pins on this transport, since one of the two cannot yield.
592 ///
593 /// An `Err` is a configuration the node refuses to start with.
594 pub fn resolved_cores(&self, reader: ReaderThread) -> Result<PipelineCores, String> {
595 self.cores.resolve(reader)
596 }
597}
598
599/// Run the server with automatic transport selection.
600///
601/// Installs SIGINT/SIGTERM handlers, optionally locks memory, selects
602/// the transport (DPDK under the `dpdk` feature, kernel TCP otherwise),
603/// and enters the pipeline loop. This is the main entry point for
604/// application binaries.
605///
606/// The application starts from `A::default()` on every node; `startup`
607/// is what the node journals on top of that as it becomes primary — see
608/// [`StartupEvents`]. `sizing` is what this node reserves memory for,
609/// handed to [`Application::prefault`] on every instance the node
610/// builds; it is local to the node and never journaled.
611///
612/// For callers that need a pre-bound listener or an externally
613/// controlled shutdown flag (e.g. benchmarks), use
614/// [`run_with_listener`] instead; for an externally controlled flag on
615/// the build's own transport, [`run_with_shutdown`].
616pub fn run<A>(
617 config: ServerConfig,
618 startup: StartupEvents<A::Event>,
619 sizing: A::Sizing,
620 decoder: impl RequestDecoder<Event = A::Event> + 'static,
621 encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
622 event_publisher: Option<EventPublisherFn<A>>,
623) -> Result<(), Box<dyn std::error::Error>>
624where
625 A: Application + Send + 'static,
626 A::Event: Send + Sync + 'static,
627 A::Report: Send + 'static,
628 A::QueryResponse: Send + 'static,
629{
630 // Before anything pins a thread or initialises DPDK, whose EAL can
631 // narrow this thread to one lcore: the CPU set captured here is what
632 // every unpinned thread runs on.
633 melin_app::affinity::capture_home_mask();
634 let authorized_keys = load_authorized_keys(&decoder, &config.authorized_keys)?;
635 let shutdown = Arc::new(AtomicBool::new(false));
636 crate::process::install_shutdown_handler(&shutdown);
637 run_selected::<A>(
638 config,
639 startup,
640 sizing,
641 decoder,
642 encoder,
643 event_publisher,
644 authorized_keys,
645 shutdown,
646 )
647}
648
649/// [`run`], stopped through `shutdown` rather than by a signal: the same
650/// transport selection and the same startup, but no SIGINT/SIGTERM
651/// handler is installed. Set `shutdown` to `true` for a clean shutdown;
652/// the node may also set it itself (a fenced node stops this way).
653///
654/// For a host that owns its process's signals, or runs several nodes in
655/// one process — the signal handler `run` installs is process-wide and
656/// stops one node only. Several DPDK nodes in one process also need a
657/// process-wide EAL (`melin_dpdk::Eal::init_process_wide`) and a port
658/// each. Unlike [`run_with_listener`], the client listener is the
659/// transport's own: on DPDK there is no kernel socket to hand in.
660pub fn run_with_shutdown<A>(
661 config: ServerConfig,
662 startup: StartupEvents<A::Event>,
663 sizing: A::Sizing,
664 decoder: impl RequestDecoder<Event = A::Event> + 'static,
665 encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
666 event_publisher: Option<EventPublisherFn<A>>,
667 shutdown: Arc<AtomicBool>,
668) -> Result<(), Box<dyn std::error::Error>>
669where
670 A: Application + Send + 'static,
671 A::Event: Send + Sync + 'static,
672 A::Report: Send + 'static,
673 A::QueryResponse: Send + 'static,
674{
675 // As in `run`.
676 melin_app::affinity::capture_home_mask();
677 let authorized_keys = load_authorized_keys(&decoder, &config.authorized_keys)?;
678 run_selected::<A>(
679 config,
680 startup,
681 sizing,
682 decoder,
683 encoder,
684 event_publisher,
685 authorized_keys,
686 shutdown,
687 )
688}
689
690/// The part of [`run`] and [`run_with_shutdown`] after the shutdown flag
691/// exists: memory locking, then the build's transport.
692#[allow(clippy::too_many_arguments)] // entry-chain hop, same arguments as run_impl
693fn run_selected<A>(
694 config: ServerConfig,
695 startup: StartupEvents<A::Event>,
696 sizing: A::Sizing,
697 decoder: impl RequestDecoder<Event = A::Event> + 'static,
698 encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
699 event_publisher: Option<EventPublisherFn<A>>,
700 authorized_keys: Arc<AuthorizedKeys>,
701 shutdown: Arc<AtomicBool>,
702) -> Result<(), Box<dyn std::error::Error>>
703where
704 A: Application + Send + 'static,
705 A::Event: Send + Sync + 'static,
706 A::Report: Send + 'static,
707 A::QueryResponse: Send + 'static,
708{
709 if !config.no_mlock {
710 crate::process::try_lock_memory();
711 }
712
713 #[cfg(feature = "dpdk")]
714 {
715 run_dpdk::<A>(
716 config,
717 startup,
718 sizing,
719 Arc::new(decoder),
720 Arc::new(encoder),
721 event_publisher,
722 authorized_keys,
723 shutdown,
724 )
725 }
726
727 #[cfg(not(feature = "dpdk"))]
728 {
729 let listener = melin_wire_protocol::tcp::BlockingTcpListener::bind(config.bind)?;
730 run_tcp::<A, _>(
731 listener,
732 config,
733 startup,
734 sizing,
735 Arc::new(decoder),
736 Arc::new(encoder),
737 event_publisher,
738 authorized_keys,
739 shutdown,
740 )
741 }
742}
743
744/// Load the `authorized_keys` file for the decoder's roles: the one place
745/// the file meets the application's role type. Generic over the decoder so
746/// its role type is named while it is still in scope; past the entry
747/// points the runtime holds the table and an erased decoder, and never
748/// the type.
749fn load_authorized_keys<D: RequestDecoder>(
750 _decoder: &D,
751 path: &std::path::Path,
752) -> Result<Arc<AuthorizedKeys>, Box<dyn std::error::Error>> {
753 let keys = AuthorizedKeys::load::<D::Role>(path)?;
754 info!(keys = keys.len(), path = %path.display(), "loaded authorized keys");
755 Ok(Arc::new(keys))
756}
757
758/// Refuse to start on a keys table parsed for a role type other than the
759/// decoder's: its role indices would name the decoder's roles wrongly,
760/// granting one role another's rights. Correct by construction today, as
761/// both come from the same decoder in the entry point; checked where the
762/// two arrive as separate parameters, which is the seam a later change
763/// could break.
764fn check_keys_match_decoder<E: AppEvent>(
765 decoder: &dyn ErasedDecoder<E>,
766 authorized_keys: &AuthorizedKeys,
767) -> Result<(), Box<dyn std::error::Error>> {
768 if decoder.matches_keys(authorized_keys) {
769 Ok(())
770 } else {
771 Err("authorized keys were parsed for a role type other than the decoder's".into())
772 }
773}
774
775/// Run the server with a caller-supplied listener.
776///
777/// Use this when the listener must be pre-bound before the server
778/// starts (e.g. benchmarks that need the kernel backlog to queue
779/// connections immediately).
780///
781/// Set `shutdown` to `true` to trigger a clean shutdown of all
782/// pipeline threads.
783pub fn run_with_listener<A>(
784 listener: impl BlockingTransportListener,
785 config: ServerConfig,
786 startup: StartupEvents<A::Event>,
787 sizing: A::Sizing,
788 decoder: impl RequestDecoder<Event = A::Event> + 'static,
789 encoder: impl ResponseEncoder<Report = A::Report, Query = A::QueryResponse> + 'static,
790 event_publisher: Option<EventPublisherFn<A>>,
791 shutdown: Arc<AtomicBool>,
792) -> Result<(), Box<dyn std::error::Error>>
793where
794 A: Application + Send + 'static,
795 A::Event: Send + Sync + 'static,
796 A::Report: Send + 'static,
797 A::QueryResponse: Send + 'static,
798{
799 // As in `run`: the CPU set unpinned threads run on, captured before
800 // anything pins a thread.
801 melin_app::affinity::capture_home_mask();
802 let authorized_keys = load_authorized_keys(&decoder, &config.authorized_keys)?;
803 run_tcp::<A, _>(
804 listener,
805 config,
806 startup,
807 sizing,
808 Arc::new(decoder),
809 Arc::new(encoder),
810 event_publisher,
811 authorized_keys,
812 shutdown,
813 )
814}
815
816#[allow(clippy::too_many_arguments)] // entry-chain hop, same arguments as run_impl
817fn run_tcp<A, L>(
818 listener: L,
819 config: ServerConfig,
820 startup: StartupEvents<A::Event>,
821 sizing: A::Sizing,
822 decoder: RequestDecoderArc<A>,
823 encoder: ResponseEncoderArc<A>,
824 event_publisher: Option<EventPublisherFn<A>>,
825 authorized_keys: Arc<AuthorizedKeys>,
826 shutdown: Arc<AtomicBool>,
827) -> Result<(), Box<dyn std::error::Error>>
828where
829 A: Application + Send + 'static,
830 A::Event: Send + Sync + 'static,
831 A::Report: Send + 'static,
832 A::QueryResponse: Send + 'static,
833 L: BlockingTransportListener,
834{
835 check_keys_match_decoder(&*decoder, &authorized_keys)?;
836 run_impl::<A, L>(
837 listener,
838 config,
839 startup,
840 sizing,
841 decoder,
842 encoder,
843 event_publisher,
844 authorized_keys,
845 shutdown,
846 )
847}
848
849/// Announce at boot that this binary was built without the
850/// tamper-evident journal chain.
851///
852/// A chain-less build is otherwise silent: the journal still writes and
853/// replication still streams, but segment-continuity verification at
854/// recovery, the snapshot/journal cross-check, and cross-node divergence
855/// detection are all absent — a rejoining ex-primary carrying a
856/// journaled-but-unreplicated suffix gets streamed to rather than
857/// resynced. A deployment can arrive here without meaning to, because
858/// `--no-default-features` anywhere in the dependency graph turns the
859/// chain off wholesale, so boot says so out loud rather than leaving the
860/// absence to be discovered during an incident.
861///
862/// `warn!` rather than `error!`: it is a deliberate build configuration,
863/// not a malfunction.
864fn warn_if_chain_disabled() {
865 #[cfg(not(feature = "hash-chain"))]
866 warn!(
867 "built without the `hash-chain` feature: journal tamper evidence, \
868 segment-continuity and snapshot cross-checks at recovery, and \
869 cross-node divergence detection are all disabled"
870 );
871}
872
873/// Log the layout the node runs, and warn when it leaves a mandatory
874/// thread without a core. Every request passes through those threads, so
875/// a core shared with whatever else the scheduler puts there is paid for
876/// on every acknowledgement; the auxiliary threads' placement is the
877/// operator's documented trade and draws no warning. `none` is called
878/// out on its own: nothing pinned is the development layout, and figures
879/// measured under it say nothing about the node. One function for both
880/// transports, so the two paths cannot drift on what they warn about.
881///
882/// `warn!` rather than `error!`: a layout the operator chose, not a
883/// malfunction.
884fn log_layout(cores: &PipelineCores) {
885 info!(cores = %cores, "pipeline layout");
886 if cores.pins_nothing() {
887 warn!(
888 "--cores none: no pipeline thread is pinned. Every thread runs wherever the \
889 scheduler puts it and shares that core with everything else on the host. A \
890 development layout: latency measured under it is not representative. Name a \
891 core per thread in --cores"
892 );
893 return;
894 }
895 let unpinned = cores.unpinned_mandatory();
896 if !unpinned.is_empty() {
897 warn!(
898 threads = %unpinned.join(", "),
899 "--cores: mandatory pipeline threads have no core of their own. Every request \
900 passes through them, and each runs wherever the scheduler puts it, sharing \
901 that core with whatever else is there. Give them cores"
902 );
903 }
904}
905
906#[allow(clippy::too_many_arguments)] // boot assembly point: each argument is one piece the node is built from
907fn run_impl<A, L>(
908 listener: L,
909 config: ServerConfig,
910 mut startup: StartupEvents<A::Event>,
911 sizing: A::Sizing,
912 decoder: RequestDecoderArc<A>,
913 encoder: ResponseEncoderArc<A>,
914 event_publisher: Option<EventPublisherFn<A>>,
915 authorized_keys: Arc<AuthorizedKeys>,
916 shutdown: Arc<AtomicBool>,
917) -> Result<(), Box<dyn std::error::Error>>
918where
919 A: Application + Send + 'static,
920 A::Event: Send + Sync + 'static,
921 A::Report: Send + 'static,
922 A::QueryResponse: Send + 'static,
923 L: BlockingTransportListener,
924{
925 warn_if_chain_disabled();
926
927 // The layout every spawn site below reads, refused outright if two
928 // threads would starve each other.
929 let config = ServerConfig {
930 cores: config.resolved_cores(ReaderThread::IoUring)?,
931 ..config
932 };
933 log_layout(&config.cores);
934
935 // Shared ack-policy atomic, constructed once per process and
936 // threaded through both roles. Wiring it on the replica path
937 // (where the live node has no response stage) lets an operator
938 // pre-stage the post-promotion policy with `ACK-POLICY <policy>`
939 // before issuing `PROMOTE`; the same `Arc` becomes the response
940 // stage's source of truth after the replica → primary transition.
941 let ack_policy_atomic = Arc::new(AtomicU8::new(config.ack_policy.as_u8()));
942
943 // Validate before the bind below and before anything touches the
944 // journal directory, so a refused boot has no side effects and leaves
945 // the disk as it found it. Checked in every role: a replica's
946 // configuration is also the one it serves under once promoted, and a
947 // configuration that cannot serve is better refused now than in the
948 // middle of a failover. `run_as_primary` re-checks (a promotion
949 // reaches it without passing through boot).
950 validate_primary_config(&config)?;
951
952 // Bind the replication listener up front, before any pipeline thread
953 // exists and regardless of role: a bad or stolen --replication-bind
954 // fails the boot cleanly (nothing spawned yet to leak), and a
955 // replica holds its configured port from boot, so a later promotion
956 // can neither lose the port to another process nor die on a
957 // mid-failover bind. Pending connections just sit in the backlog
958 // until a promotion starts the sender's accept loop — replicas dial
959 // the configured primary, not each other, so nothing queues there
960 // in normal operation.
961 let repl_listener = config
962 .replication_bind
963 .map(bind_replication_listener)
964 .transpose()?;
965
966 // Replica mode: connect to primary, receive journal stream, replay.
967 // Must run before init_engine — the replica's journal is created from
968 // the primary's segment lineage during the replication handshake.
969 if let Some(primary_addr) = config.replica_of {
970 info!(primary = %primary_addr, "starting in replica mode");
971 // A replica receives the genesis events in the primary's history
972 // and never journals its own, promoted or not — nothing to hold
973 // them for, and a large genesis is worth freeing. Not even their
974 // count: the promotion check reads the genesis length the
975 // primary recorded in the lineage, which this replica's journal
976 // header holds, never this node's configuration.
977 startup.genesis = Vec::new();
978
979 // Load replication signing key.
980 let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
981 std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
982 })?;
983 let signing_key = {
984 let seed = std::fs::read(replication_key_path).map_err(|e| {
985 std::io::Error::other(format!(
986 "failed to read replication key {}: {e}",
987 replication_key_path.display()
988 ))
989 })?;
990 if seed.len() != 32 {
991 return Err(format!(
992 "replication key must be 32 bytes, got {} ({})",
993 seed.len(),
994 replication_key_path.display()
995 )
996 .into());
997 }
998 let mut bytes = [0u8; 32];
999 bytes.copy_from_slice(&seed);
1000 ed25519_dalek::SigningKey::from_bytes(&bytes)
1001 };
1002
1003 // The replica's control-plane bundle: promotion request (admin
1004 // PROMOTE / raft auto-promotion), tip readiness + advertised
1005 // journal tip (vote recency), primary link state, and the
1006 // primary's advertised ack policy. Constructed before
1007 // mode-detection so it survives a replica → primary transition.
1008 // The rotate flag is re-wired into the new primary's journal
1009 // stage by `run_as_primary`.
1010 let control = crate::replication::ReplicaControlPlane::new();
1011 let promotion_request = control.promote.clone();
1012 let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
1013 let _admin_handle = config
1014 .admin_bind
1015 .map(|addr| {
1016 crate::admin::spawn(
1017 addr,
1018 Some(promotion_request.clone()),
1019 rotate_flag.clone(),
1020 Some(Arc::clone(&ack_policy_atomic)),
1021 Arc::clone(&shutdown),
1022 Arc::clone(&authorized_keys),
1023 )
1024 })
1025 .transpose()?;
1026
1027 // Shared fencing state, created before mode detection so it
1028 // survives a replica → primary transition. Seeded at epoch 0; the
1029 // receiver raises it from the replica's recovered journal and the
1030 // replication stream, and a promotion bumps it by injecting an
1031 // `EpochBump`. Carried into `run_as_primary` post-promotion.
1032 let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
1033
1034 // Control-plane raft (observational election). Spawned before the
1035 // receive loop so the driver — like the fence state and admin
1036 // flags — survives a replica → primary promotion untouched: it
1037 // shares the process shutdown flag and runs on its own thread.
1038 // The advertised tip isn't trustworthy until journal recovery
1039 // seeds the fence epoch and sequence, so `control.tip_ready`
1040 // starts false and the receiver flips it — until then the driver
1041 // refuses to grant votes. The receiver owns the sequence half
1042 // until a promotion hands it to the new primary's journal stage.
1043 let journal_tip = control.journal_tip.clone();
1044 let mut raft = match crate::raft::build_raft_config(&config)? {
1045 None => crate::raft::RaftDriverGuard::disabled(&shutdown),
1046 Some(cfg) => crate::raft::spawn_raft_driver(
1047 cfg,
1048 config.raft_auto_promote,
1049 &signing_key,
1050 &authorized_keys,
1051 &fence_state,
1052 journal_tip.clone(),
1053 Arc::clone(&control.tip_ready),
1054 // A replica claims to be serving once a promotion is in
1055 // flight (see SupersessionPolicy).
1056 {
1057 let promote = control.promote.clone();
1058 Arc::new(move || promote.is_requested())
1059 },
1060 &shutdown,
1061 )?,
1062 };
1063 // Act on election wins when the operator opted in. Genesis
1064 // primaries have nothing to promote — replica paths only. The
1065 // guard joins the thread on every exit path.
1066 if config.raft_auto_promote
1067 && let Some(wiring) = raft.promotion_wiring()
1068 {
1069 raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
1070 wiring.status,
1071 control.clone(),
1072 Arc::clone(&fence_state),
1073 Arc::clone(&ack_policy_atomic),
1074 wiring.peer_tips,
1075 wiring.peer_ids,
1076 wiring.elect_requested,
1077 wiring.elect_enabled,
1078 Arc::clone(&shutdown),
1079 ));
1080 }
1081 let raft_status = raft.status();
1082 // A raft-enabled replica exposes election gauges on --health-bind;
1083 // stopped explicitly before promotion so run_as_primary can rebind
1084 // the port, and by its guard on every other exit path.
1085 let mut replica_health = crate::raft::spawn_replica_health(
1086 &config,
1087 &fence_state,
1088 raft_status.as_ref(),
1089 Arc::clone(&control.pipeline_healthy),
1090 )?;
1091
1092 // No local rotation triggers on the replica side: segment
1093 // rotation is primary-driven (the replica adopts the boundaries
1094 // announced over the replication stream), so replica journals
1095 // stay bitwise mirrors of the primary's. `--max-journal-mib`
1096 // and the admin `ROTATE` command only act on primaries.
1097
1098 match crate::replication::run_receiver::<A>(
1099 primary_addr,
1100 &config.journal,
1101 &signing_key,
1102 &shutdown,
1103 &control,
1104 config.snapshot_interval_ms,
1105 config.shadow_snapshot_path(),
1106 config.cores,
1107 config.journal_staging_mode.into(),
1108 config.group_commit_delay(),
1109 config.replication_pipeline_depth,
1110 Arc::clone(&fence_state),
1111 &sizing,
1112 )? {
1113 // Clean shutdown — the raft and health guards tear down on drop.
1114 None => return Ok(()),
1115 Some((mut app, writer)) => {
1116 // Promotion! Transition to primary mode. Bump the epoch so a
1117 // paused/partitioned ex-primary is fenced when it reconnects.
1118 info!("replica promoted — transitioning to primary");
1119 check_promotable(&writer)?;
1120 // Release --health-bind before run_as_primary rebinds it
1121 // with the full primary health state.
1122 replica_health.stop();
1123 // A replica that ran a pipeline sized this instance
1124 // already; one promoted before its first session did
1125 // not (recovered from disk, never streamed). Sizing is
1126 // idempotent, so size here either way.
1127 <A as Application>::prefault(&mut app, &sizing);
1128
1129 // A ROTATE received while this node was a replica latched
1130 // the flag but rotated nothing (rotation is primary-driven
1131 // and replicas follow the primary's boundaries). Clear it
1132 // so the stale latch can't fire a surprise rotation on the
1133 // first post-promotion fsync.
1134 if let Some(ref flag) = rotate_flag {
1135 flag.store(false, Ordering::Release);
1136 }
1137
1138 // The raft guard drops — stopping the driver and joining
1139 // the (already exited) promotion thread — after this
1140 // returns, so the driver serves elections and the fencing
1141 // channel for the whole primary tenure.
1142 return run_as_primary::<A, L>(
1143 app,
1144 writer,
1145 listener,
1146 repl_listener,
1147 &config,
1148 startup.on_primary,
1149 decoder,
1150 encoder,
1151 event_publisher,
1152 Arc::clone(&shutdown),
1153 authorized_keys,
1154 rotate_flag,
1155 ack_policy_atomic,
1156 fence_state,
1157 promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
1158 false, // a promoted node continues the history it streamed
1159 raft_status,
1160 journal_tip,
1161 );
1162 }
1163 }
1164 }
1165
1166 // Spawn the admin listener once if configured. PROMOTE is rejected
1167 // (with ERR) on a primary because no promote flag is wired here —
1168 // promotion is meaningful only on a replica. ROTATE is wired
1169 // whenever the admin endpoint is configured.
1170 let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
1171 let _admin_handle = config
1172 .admin_bind
1173 .map(|addr| {
1174 crate::admin::spawn(
1175 addr,
1176 None,
1177 rotate_flag.clone(),
1178 Some(Arc::clone(&ack_policy_atomic)),
1179 Arc::clone(&shutdown),
1180 Arc::clone(&authorized_keys),
1181 )
1182 })
1183 .transpose()?;
1184
1185 // The configuration was validated before the replication bind above,
1186 // so the journal is never created by a boot that is then refused.
1187
1188 // Initialize or recover the app. On a new journal, genesis is
1189 // journaled here, as the journal is created.
1190 let InitializedEngine {
1191 mut app,
1192 writer,
1193 recovered_epoch,
1194 began_history,
1195 } = init_engine::<A, BufferedWriter<A::Event>>(
1196 &config,
1197 &sizing,
1198 std::mem::take(&mut startup.genesis),
1199 )?;
1200
1201 // Size and pre-fault application-owned memory (slabs, indices) so
1202 // growth and page faults happen now, not on the hot path. Runs on the
1203 // recovered state too: a snapshot restores contents, not capacity,
1204 // and `init_engine` sizes a genesis instance before replay only.
1205 <A as Application>::prefault(&mut app, &sizing);
1206
1207 // A primary booting directly (not via promotion) keeps whatever epoch
1208 // its journal recovered; no bump.
1209 let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
1210 recovered_epoch,
1211 ));
1212
1213 // Control-plane raft (observational election). The primary needs the
1214 // replication signing key only when raft is on — peer links
1215 // authenticate with it (build_raft_config enforces the flag).
1216 // A primary's fence epoch is already recovered here, so its tip is
1217 // trustworthy from the moment the driver starts. Seed the advertised
1218 // sequence from the recovered journal for the same reason — the
1219 // pipeline's journal stage takes the handle over once it runs.
1220 let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
1221 melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
1222 );
1223 let raft = match crate::raft::build_raft_config(&config)? {
1224 None => crate::raft::RaftDriverGuard::disabled(&shutdown),
1225 Some(cfg) => {
1226 let signing_key = load_replication_key(&config)?;
1227 crate::raft::spawn_raft_driver(
1228 cfg,
1229 config.raft_auto_promote,
1230 &signing_key,
1231 &authorized_keys,
1232 &fence_state,
1233 journal_tip.clone(),
1234 Arc::new(AtomicBool::new(true)),
1235 // A primary always claims to be serving.
1236 Arc::new(|| true),
1237 &shutdown,
1238 )?
1239 }
1240 };
1241 let raft_status = raft.status();
1242
1243 // The raft guard drops — stopping the driver — after this returns.
1244 run_as_primary::<A, L>(
1245 app,
1246 writer,
1247 listener,
1248 repl_listener,
1249 &config,
1250 startup.on_primary,
1251 decoder,
1252 encoder,
1253 event_publisher,
1254 Arc::clone(&shutdown),
1255 authorized_keys,
1256 rotate_flag,
1257 ack_policy_atomic,
1258 fence_state,
1259 None, // not promoted — no EpochBump injection
1260 began_history,
1261 raft_status,
1262 journal_tip,
1263 )
1264}
1265
1266/// Bind the kernel-TCP replication listener, non-blocking (the sender's
1267/// accept loop polls the shutdown flag between accepts). Bound at boot
1268/// (`run_impl`) so a failure cannot leak pipeline threads and a promotion
1269/// cannot fail on it. Kernel TCP only: a DPDK node's replication listener
1270/// is a socket of its own stack, which no other process can take.
1271fn bind_replication_listener(
1272 addr: std::net::SocketAddr,
1273) -> Result<crate::replication::ReplicationListener, Box<dyn std::error::Error>> {
1274 let listener = std::net::TcpListener::bind(addr)
1275 .map_err(|e| format!("failed to bind replication listener on {addr}: {e}"))?;
1276 // The newtype sets non-blocking — the invariant the sender's accept
1277 // loop needs is enforced by construction, not by this call site.
1278 let listener = crate::replication::ReplicationListener::new(listener)
1279 .map_err(|e| format!("failed to set non-blocking on replication listener: {e}"))?;
1280 info!(%addr, "replication listener bound");
1281 Ok(listener)
1282}
1283
1284/// Refuse a configuration no primary can run under. Called at boot,
1285/// before the journal is opened or created, and again by
1286/// `run_as_primary`, which a promotion reaches without passing through
1287/// boot.
1288fn validate_primary_config(config: &ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
1289 if config.replication_bind.is_some() && config.standalone {
1290 return Err("--replication-bind and --standalone are mutually exclusive".into());
1291 }
1292 // `--standalone` declares "no replicas ever" — only `disk` can be
1293 // satisfied. Every other policy requires a second node and would
1294 // stall the gate forever; reject loudly at startup with the fix in
1295 // the message.
1296 if config.standalone && config.ack_policy != crate::ack_policy::AckPolicy::Disk {
1297 return Err(format!(
1298 "--standalone requires --ack-policy disk; got `{}` (this policy needs at least one connected replica)",
1299 config.ack_policy,
1300 )
1301 .into());
1302 }
1303 Ok(())
1304}
1305
1306/// Refuse to promote a replica whose history does not yet hold the
1307/// whole genesis.
1308///
1309/// A journal begins with its genesis — sequences `1..=genesis_entries`
1310/// — and a replica copies it entry by entry, by catch-up, like the rest
1311/// of the history. A primary that dies before the replica has copied
1312/// all of it leaves the replica with an empty journal or a genesis
1313/// prefix. Promoting it would serve a state the application never
1314/// configured, silently and for good: a later boot recovers the history
1315/// as it is, and replicas follow it. (Under an ack policy that waits
1316/// for a replica, no client was acknowledged past such a history, since
1317/// every acknowledged request follows the genesis in the stream.)
1318/// Refuse instead, and leave the decision to the operator.
1319///
1320/// `next_sequence` is the promoted writer's; the history holds entries
1321/// `1..next_sequence`. A history that recovered from a snapshot counts
1322/// the snapshot's entries too, which is right: the snapshot's state
1323/// includes them.
1324///
1325/// `genesis_entries` is the lineage's genesis length from the promoted
1326/// writer's journal header — recorded by the primary that began the
1327/// history and learned by this replica when it created its journal, so
1328/// it is there with the primary gone, whatever genesis this node is
1329/// configured with. `None` (a lineage begun before the length was
1330/// recorded) skips the check.
1331fn check_promoted_history_holds_genesis(
1332 next_sequence: u64,
1333 genesis_entries: Option<u64>,
1334) -> Result<(), Box<dyn std::error::Error>> {
1335 if let Some((held, genesis_entries)) = genesis_shortfall(next_sequence, genesis_entries) {
1336 return Err(format!(
1337 "refusing promotion: this replica's history holds {held} entries but the \
1338 lineage's genesis takes {genesis_entries}, so it copied only part of the genesis \
1339 from a primary that stopped before finishing it; restart the original primary, or \
1340 start the cluster again from a new journal"
1341 )
1342 .into());
1343 }
1344 Ok(())
1345}
1346
1347/// [`check_promoted_history_holds_genesis`] on the writer a promotion
1348/// hands over: its next sequence, and the genesis length its journal
1349/// header records. What both promotion paths (kernel TCP and DPDK) run
1350/// before the node serves as primary.
1351pub(crate) fn check_promotable<E, W>(writer: &W) -> Result<(), Box<dyn std::error::Error>>
1352where
1353 E: melin_app::AppEvent,
1354 W: JournalWrite<E>,
1355{
1356 check_promoted_history_holds_genesis(
1357 writer.next_sequence(),
1358 writer.read_header_info()?.genesis_entries,
1359 )
1360}
1361
1362/// Refuse to serve, as a primary booting from disk, a recovered history
1363/// that does not hold the whole genesis.
1364///
1365/// The boot-time twin of [`check_promoted_history_holds_genesis`], on
1366/// the same lineage-recorded length. A journal this release creates
1367/// always begins with its complete genesis, so a shorter history is a
1368/// replica's partial copy restarted on a primary's flags instead of
1369/// being promoted. Recovering it would serve a state the application
1370/// never configured, and replicas would follow it. The node's own
1371/// configured genesis plays no part: a configuration changed since the
1372/// history began changes nothing.
1373///
1374/// `next_sequence` is the recovered writer's, after `init_engine` has
1375/// given an empty journal its genesis (the one shortfall it can make
1376/// good, since nothing was served from it); `genesis_entries` its
1377/// journal header's. `None` — a journal written by a release that did
1378/// not record the length — skips the check, as those releases did.
1379fn check_recovered_history_holds_genesis(
1380 next_sequence: u64,
1381 genesis_entries: Option<u64>,
1382) -> Result<(), Box<dyn std::error::Error>> {
1383 if let Some((held, genesis_entries)) = genesis_shortfall(next_sequence, genesis_entries) {
1384 return Err(format!(
1385 "refusing to start: the recovered history holds {held} entries but the lineage's \
1386 genesis takes {genesis_entries}, so it holds only part of the genesis (a replica's \
1387 partial copy started as a primary); start again from a new history (remove the \
1388 journal, its archives and its snapshots), or restart this node as a replica of a \
1389 primary holding the whole history"
1390 )
1391 .into());
1392 }
1393 Ok(())
1394}
1395
1396/// `(held, genesis_entries)` when a history ending before
1397/// `next_sequence` holds fewer entries than the genesis takes; `None`
1398/// when it holds them all, or when the genesis length is unknown.
1399fn genesis_shortfall(next_sequence: u64, genesis_entries: Option<u64>) -> Option<(u64, u64)> {
1400 let genesis_entries = genesis_entries?;
1401 let held = next_sequence.saturating_sub(1);
1402 (held < genesis_entries).then_some((held, genesis_entries))
1403}
1404
1405/// Load the Ed25519 replication signing key from `--replication-key` —
1406/// shared by the replica connect path and raft-enabled primaries.
1407fn load_replication_key(
1408 config: &ServerConfig,
1409) -> Result<ed25519_dalek::SigningKey, Box<dyn std::error::Error>> {
1410 let path = config.replication_key.as_ref().ok_or_else(|| {
1411 std::io::Error::other("--replication-key is required (replica mode or --raft-bind)")
1412 })?;
1413 let seed = std::fs::read(path).map_err(|e| {
1414 std::io::Error::other(format!(
1415 "failed to read replication key {}: {e}",
1416 path.display()
1417 ))
1418 })?;
1419 if seed.len() != 32 {
1420 return Err(format!(
1421 "replication key must be 32 bytes, got {} ({})",
1422 seed.len(),
1423 path.display()
1424 )
1425 .into());
1426 }
1427 let mut bytes = [0u8; 32];
1428 bytes.copy_from_slice(&seed);
1429 Ok(ed25519_dalek::SigningKey::from_bytes(&bytes))
1430}
1431
1432/// Run the server as a primary: build the disruptor pipeline, spawn
1433/// pipeline threads, journal the application's startup events, then
1434/// accept client connections.
1435///
1436/// Control event for the response stage. The io_uring response path reads
1437/// `fd` for I/O; the `writer` keeps the fd alive via ownership.
1438pub use crate::ControlEvent;
1439
1440/// Joinable handles for every long-lived thread spawned by a primary.
1441/// Optional handles are `None` when their feature is disabled (e.g.,
1442/// no replication, no health endpoint) or unsupported on a transport
1443/// (e.g., DPDK runs without an event publisher or shadow snapshotter).
1444struct PipelineHandles<A: Send + 'static, W: Send + 'static> {
1445 journal: std::thread::JoinHandle<Result<W, JournalError>>,
1446 matching: std::thread::JoinHandle<A>,
1447 response: std::thread::JoinHandle<()>,
1448 replication: Option<std::thread::JoinHandle<()>>,
1449 event_publisher: Option<std::thread::JoinHandle<()>>,
1450 shadow: Option<std::thread::JoinHandle<()>>,
1451 health: Option<std::thread::JoinHandle<()>>,
1452 // No standalone tick thread on either transport: io_uring emits ticks
1453 // from inside the reader (via IORING_OP_TIMEOUT) and DPDK emits them
1454 // from inside the poll thread (via a wall-clock check between bursts).
1455}
1456
1457/// Drain the pipeline and join every worker thread, surfacing panics
1458/// and journal-stage errors as a single `pipeline failure` return.
1459///
1460/// `extras` is a list of pre-joined results (used by the DPDK path,
1461/// which joins its poll threads before draining the pipeline).
1462fn shutdown_pipeline_stages<A: Send + 'static, W: Send + 'static>(
1463 handles: PipelineHandles<A, W>,
1464 extras: Vec<(String, std::thread::Result<()>)>,
1465 pipeline_healthy: &AtomicBool,
1466 shutdown: &AtomicBool,
1467) -> Result<(), Box<dyn std::error::Error>> {
1468 info!("shutdown: draining pipeline");
1469 pipeline_healthy.store(false, Ordering::Relaxed);
1470 shutdown.store(true, Ordering::Relaxed);
1471
1472 let mut thread_panicked = false;
1473 let mut check_join = |name: &str, result: std::thread::Result<()>| {
1474 if let Err(panic) = result {
1475 let msg = panic
1476 .downcast_ref::<&str>()
1477 .copied()
1478 .or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
1479 .unwrap_or("<non-string panic>");
1480 error!(thread = name, message = msg, "pipeline thread panicked");
1481 thread_panicked = true;
1482 }
1483 };
1484
1485 let journal_result = handles.journal.join();
1486 let journal_failed = matches!(&journal_result, Ok(Err(_)));
1487 if let Ok(Err(ref e)) = journal_result {
1488 error!(thread = "journal-seq", error = %e, "journal stage returned error");
1489 }
1490 check_join("journal-seq", journal_result.map(|_| ()));
1491 check_join("matching", handles.matching.join().map(|_| ()));
1492 check_join("response", handles.response.join());
1493 for (name, r) in extras {
1494 check_join(&name, r);
1495 }
1496 if let Some(h) = handles.replication {
1497 check_join("replication-sender", h.join());
1498 }
1499 if let Some(h) = handles.event_publisher {
1500 check_join("event-publisher", h.join());
1501 }
1502 if let Some(h) = handles.shadow {
1503 check_join("shadow", h.join());
1504 }
1505 if let Some(h) = handles.health {
1506 check_join("health", h.join());
1507 }
1508
1509 // After every stage thread has joined, dump the per-stage latency
1510 // histograms to stderr. No-op when `latency-trace` is disabled.
1511 melin_transport_core::trace::print_report_all();
1512
1513 if thread_panicked || journal_failed {
1514 error!("shutdown complete (with pipeline failure)");
1515 return Err("pipeline failure".into());
1516 }
1517
1518 info!("shutdown complete");
1519 Ok(())
1520}
1521
1522/// Used by both the normal primary startup path and the promotion path
1523/// (replica → primary transition).
1524///
1525/// `rotate_flag` is the shared `AtomicBool` toggled by the admin
1526/// endpoint (or `None` if no admin endpoint was configured). On the
1527/// promotion path the replica's journal stage is torn down and a new
1528/// primary stage is built here; passing the flag through means the
1529/// admin endpoint, which was spawned once at process start, keeps
1530/// driving the new stage's rotation.
1531#[allow(clippy::too_many_arguments)]
1532fn run_as_primary<A, L>(
1533 app: A,
1534 writer: BufferedWriter<A::Event>,
1535 mut listener: L,
1536 // Pre-bound replication listener (non-blocking by construction),
1537 // `Some` iff `--replication-bind` is set. Bound in `run_impl` before
1538 // any pipeline thread exists so a bind failure cannot leak threads,
1539 // and held from boot on replicas so a promotion cannot fail on it.
1540 repl_listener: Option<crate::replication::ReplicationListener>,
1541 config: &ServerConfig,
1542 // The `on_primary` half of the application's `StartupEvents`. Genesis
1543 // never reaches here: it is journaled as the journal is created, in
1544 // `init_engine`, and a promoted node has it from the stream.
1545 on_primary: Vec<A::Event>,
1546 decoder: RequestDecoderArc<A>,
1547 encoder: ResponseEncoderArc<A>,
1548 event_publisher: Option<EventPublisherFn<A>>,
1549 shutdown: Arc<AtomicBool>,
1550 authorized_keys: Arc<AuthorizedKeys>,
1551 rotate_flag: Option<Arc<AtomicBool>>,
1552 ack_policy_atomic: Arc<AtomicU8>,
1553 fence_state: Arc<melin_transport_core::fence::FenceState>,
1554 promotion: Option<u64>,
1555 // This boot began the history (`InitializedEngine::began_history`):
1556 // with replication on, client service waits for the first replica.
1557 began_history: bool,
1558 raft_status: Option<Arc<melin_transport_core::health::RaftStatus>>,
1559 // Control-plane advertised tip. This primary's journal stage becomes
1560 // its writer (the raft driver keeps reading the same handle across a
1561 // promotion) — see `AdvertisedJournalTip`.
1562 journal_tip: melin_transport_core::AdvertisedJournalTip,
1563) -> Result<(), Box<dyn std::error::Error>>
1564where
1565 A: Application + Send + 'static,
1566 A::Event: Send + Sync + 'static,
1567 A::Report: Send + 'static,
1568 A::QueryResponse: Send + 'static,
1569 L: BlockingTransportListener,
1570{
1571 // Active connection counter shared between accept loop, response
1572 // stage, and matching stage (for stats queries).
1573 // Incremented on successful auth, decremented on disconnect.
1574 // Used to enforce max_connections (SEC-02).
1575 let active_connections = Arc::new(AtomicU64::new(0));
1576
1577 // Determine replication mode. Every application's binary ships the
1578 // full durable transport (journal + replication + shadow), so the
1579 // same config knob drives any of them.
1580 let enable_replication = config.replication_bind.is_some();
1581 // Re-checked: a promotion reaches here without the boot-time check.
1582 validate_primary_config(config)?;
1583 // The lineage's genesis length, read before the writer moves into
1584 // the pipeline: the shadow stage stamps it into every snapshot.
1585 let genesis_entries = writer.read_header_info()?.genesis_entries;
1586 // Clone the application for the shadow snapshot stage before the pipeline
1587 // consumes it. `Application` does not require `Clone`, so this goes
1588 // through `clone_via_snapshot` (a snapshot round-trip unless the
1589 // application overrides it with something cheaper).
1590 let enable_shadow = config.snapshot_interval_ms > 0;
1591 let shadow_app = if enable_shadow {
1592 Some(<A as Application>::clone_via_snapshot(&app)?)
1593 } else {
1594 None
1595 };
1596
1597 // Build the disruptor pipeline with optional replication consumer.
1598 // An application without an event publisher silently ignores
1599 // `--event-bind`, so the same invocation works against any binary.
1600 // The caller (binary) decides whether an event-publisher fn is
1601 // available; we only allocate the consumer slot when both the fn is
1602 // wired AND `--event-bind` is set.
1603 let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
1604 let Pipeline {
1605 input_producer,
1606 journal_stage,
1607 matching_stage,
1608 mut output_consumers,
1609 events_processed,
1610 input_cursor,
1611 replication_consumers,
1612 replicas_connected,
1613 shadow_consumer,
1614 chain_hash_lock,
1615 replication_ring_progress,
1616 cursors,
1617 } = build_pipeline_with_replication(
1618 app,
1619 writer,
1620 config.group_commit_delay(),
1621 Arc::clone(&active_connections),
1622 enable_replication,
1623 config.max_journal_batch,
1624 config.replication_ring_size,
1625 config.cores.stage_waits(),
1626 enable_event_publisher,
1627 enable_shadow,
1628 Arc::clone(&fence_state),
1629 );
1630 // Ring-position cursors for the on_primary drain gate below (Acquire loads,
1631 // stronger than the bundle's monitoring reads). The ack gate and
1632 // the replication sender pull their typed handles straight from
1633 // `cursors`; the health endpoint takes `cursors` itself — the quorum
1634 // and fastest-replica gauges are derived from the bundle's per-slot
1635 // cursors at read time.
1636 let journal_cursor = cursors.journal_ring_arc();
1637 let matching_cursor = cursors.matching_ring_arc();
1638
1639 // Consumer 0 is always the response stage. Consumer 1 (if present)
1640 // is the event publisher — only created when --event-bind is set.
1641 let output_consumer = output_consumers.remove(0);
1642 let event_publisher_consumer = if enable_event_publisher {
1643 Some(output_consumers.remove(0))
1644 } else {
1645 None
1646 };
1647
1648 // Control channel for connect/disconnect events → response stage.
1649 let (control_tx, control_rx) = std::sync::mpsc::channel();
1650
1651 // Client writes a halted node refuses at ingress, reader → response
1652 // stage, counted for the health endpoint. See `crate::halt`.
1653 let refused_writes = Arc::new(AtomicU64::new(0));
1654 let halt_gate = crate::halt::HaltGate::new(
1655 replicas_connected.clone(),
1656 Arc::clone(&fence_state),
1657 Arc::clone(&refused_writes),
1658 );
1659 let (refusal_tx, refusal_rx) =
1660 crate::halt::refusal_channel::<A::Report>(Arc::clone(&matching_cursor));
1661
1662 // Spawn the io_uring reader thread. A single reader uses multishot RECV
1663 // to multiplex every TCP connection on the server. Pinned to
1664 // cores.reader. The matching stage is the throughput limit, so a
1665 // second reader would not raise throughput — only re-introduce the
1666 // multi-producer ordering race on the input ring.
1667 let connection_timeout = config.connection_timeout();
1668 let heartbeat_interval = config.heartbeat_interval();
1669
1670 // Startup events flow through the disruptor like regular events so
1671 // they're journaled, replicated, and processed by the matching stage via
1672 // the normal pipeline. The input ring is single-producer: main publishes
1673 // them through `input_producer`, then moves it into the reader thread
1674 // which becomes the sole steady-state producer. No cloning required.
1675 let mut input_producer = input_producer;
1676
1677 // Spawn pipeline OS threads.
1678 let cores = config.cores;
1679
1680 // Wire runtime journal rotation into the journal stage. The flag is
1681 // shared with the admin endpoint (spawned once at process start in
1682 // `run`) — passing it through here means PROMOTE-then-
1683 // ROTATE on a freshly-promoted node still drives rotation against
1684 // the new primary's stage. The size threshold uses the same
1685 // `max_journal_mib` knob that drives startup rotation.
1686 let mut journal_stage = journal_stage;
1687 let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
1688 journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
1689 config.cores.place_journal_children(&mut journal_stage);
1690 journal_stage.set_staging_mode(config.journal_staging_mode.into());
1691 // On a primary the journal stage owns the control-plane advertised
1692 // tip (durable cursor after each fsync batch). On the promotion path
1693 // this takes over the handle the receiver was advancing.
1694 journal_stage.set_advertised_tip_publisher(journal_tip.clone());
1695 if config.max_journal_mib > 0 {
1696 info!(
1697 max_journal_mib = config.max_journal_mib,
1698 "runtime journal rotation enabled (size threshold)"
1699 );
1700 }
1701
1702 // Extract utilization handles before stages are moved into threads.
1703 let journal_utilization = journal_stage.utilization();
1704 let matching_utilization = matching_stage.utilization();
1705 let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
1706
1707 // Wrap each spawn closure with a tail log so a thread that returns
1708 // (cleanly OR via `Err`) is visible in the trace stream — the
1709 // accept loop's `is_finished` check fires error!("pipeline thread
1710 // died") only on its next tick, which can lag the actual exit by
1711 // up to 100ms and races with the listener teardown.
1712 let s1 = Arc::clone(&shutdown);
1713 let shutdown_for_journal = Arc::clone(&shutdown);
1714 // Start the journal's disk and preparer threads here rather than on
1715 // journal-seq, which is pinned by the time it runs: a thread inherits
1716 // its creator's placement. See `JournalStage::start`.
1717 let sequencer = journal_stage
1718 .start()
1719 .map_err(|e| format!("start the journal stage: {e}"))?;
1720 let journal_handle = std::thread::Builder::new()
1721 .name("journal-seq".into())
1722 .spawn(move || {
1723 melin_app::affinity::pin_thread("journal-seq", cores.journal_seq.core);
1724 let result = sequencer.run(&s1);
1725 let was_shutdown = shutdown_for_journal.load(Ordering::Relaxed);
1726 match &result {
1727 Ok(_) if was_shutdown => info!("journal-seq thread exited cleanly on shutdown"),
1728 Ok(_) => error!("journal-seq thread returned without shutdown signal"),
1729 Err(e) => error!(error = %e, "journal-seq thread returned Err"),
1730 }
1731 result
1732 })
1733 .map_err(|e| format!("spawn journal-seq thread: {e}"))?;
1734
1735 let s2 = Arc::clone(&shutdown);
1736 let shutdown_for_matching = Arc::clone(&shutdown);
1737 let matching_handle = std::thread::Builder::new()
1738 .name("matching".into())
1739 .spawn(move || {
1740 melin_app::affinity::pin_thread("matching", cores.matching.core);
1741 let app = matching_stage.run(&s2);
1742 let was_shutdown = shutdown_for_matching.load(Ordering::Relaxed);
1743 if was_shutdown {
1744 info!("matching thread exited cleanly on shutdown");
1745 } else {
1746 error!("matching thread returned without shutdown signal");
1747 }
1748 app
1749 })
1750 .map_err(|e| format!("spawn matching thread: {e}"))?;
1751
1752 let replication_metrics = build_replication_metrics(
1753 replication_consumers.is_some(),
1754 &ack_policy_atomic,
1755 config.ack_policy,
1756 );
1757
1758 // Per-slot active flags exposed by the journal stage's replication
1759 // ring; the response gate filters disconnected slots out of the
1760 // policy's cursor view via these.
1761 let replica_active: Option<[Arc<AtomicBool>; 2]> =
1762 replication_ring_progress.as_ref().map(|rp| {
1763 [
1764 Arc::clone(&rp.active_flags[0]),
1765 Arc::clone(&rp.active_flags[1]),
1766 ]
1767 });
1768
1769 // Typed durable-cursor handle for the response thread's gate.
1770 let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
1771 let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
1772 let replica_active_response = replica_active.clone();
1773 let ack_policy_response = Arc::clone(&ack_policy_atomic);
1774 let s3 = Arc::clone(&shutdown);
1775 let shutdown_for_response = Arc::clone(&shutdown);
1776 let response_utilization_thread = Arc::clone(&response_utilization);
1777 let response_fence = Arc::clone(&fence_state);
1778 let active_connections_response = Arc::clone(&active_connections);
1779 let response_handle = std::thread::Builder::new()
1780 .name("response".into())
1781 .spawn(move || {
1782 melin_app::affinity::pin_thread("response", cores.response.core);
1783 crate::response::run::<A>(
1784 output_consumer,
1785 control_rx,
1786 crate::response::Response::<A> {
1787 journal_persisted_wire_seq: journal_persisted_wire_seq_response,
1788 ack_policy: ack_policy_response,
1789 replication_metrics: replication_metrics_response,
1790 replica_active: replica_active_response,
1791 heartbeat_interval,
1792 wait: cores.response.wait,
1793 utilization: response_utilization_thread,
1794 encoder,
1795 fence_state: response_fence,
1796 active_connections: active_connections_response,
1797 refusals: refusal_rx,
1798 #[cfg(test)]
1799 pause_after_control_drain: None,
1800 },
1801 &s3,
1802 );
1803 let was_shutdown = shutdown_for_response.load(Ordering::Relaxed);
1804 if was_shutdown {
1805 info!("response thread exited cleanly on shutdown");
1806 } else {
1807 error!("response thread returned without shutdown signal");
1808 }
1809 })
1810 .map_err(|e| format!("spawn response thread: {e}"))?;
1811
1812 // Spawn replication sender thread if enabled. The journal stage publishes
1813 // encoded batches to a pre-allocated ring; the sender thread consumes them.
1814 // `replica_ready` is set when the first replica enters live streaming;
1815 // the bring-up gate below waits on it.
1816 let replica_ready = Arc::new(AtomicBool::new(false));
1817 // Ring depth monitoring: the producer cursors are in ReplicationRingProgress
1818 // (owned by this function), so we compute depth via ring_progress rather
1819 // than storing Box<dyn QueueCursor> in ReplicationMetrics. The health
1820 // snapshot reads consumer cursors from ReplicationMetrics and producer
1821 // cursors are not needed — ring depth is a secondary metric.
1822
1823 let replication_handle = if let Some((repl_consumer_1, repl_consumer_2)) = replication_consumers
1824 {
1825 let s_repl = Arc::clone(&shutdown);
1826 let repl_slots = cursors.replica_slot_cursors();
1827 let ready_flag = Arc::clone(&replica_ready);
1828 let connected_counter = replicas_connected
1829 .clone()
1830 .ok_or("replicas_connected must be Some when replication is enabled")?;
1831
1832 let batch_size = config.replication_batch_size;
1833 let heartbeat_secs = config.replication_heartbeat_secs;
1834 let journal_path = config.journal.clone();
1835 let repl_auth_keys = Arc::clone(&authorized_keys);
1836 let evict_flags = replication_ring_progress
1837 .as_ref()
1838 .map(|rp| {
1839 [
1840 Arc::clone(&rp.evict_flags[0]),
1841 Arc::clone(&rp.evict_flags[1]),
1842 ]
1843 })
1844 .unwrap_or_else(|| {
1845 [
1846 Arc::new(AtomicBool::new(false)),
1847 Arc::new(AtomicBool::new(false)),
1848 ]
1849 });
1850 let active_flags = replication_ring_progress
1851 .as_ref()
1852 .map(|rp| {
1853 [
1854 Arc::clone(&rp.active_flags[0]),
1855 Arc::clone(&rp.active_flags[1]),
1856 ]
1857 })
1858 .unwrap_or_else(|| {
1859 [
1860 Arc::new(AtomicBool::new(false)),
1861 Arc::new(AtomicBool::new(false)),
1862 ]
1863 });
1864 let repl_metrics = replication_metrics
1865 .clone()
1866 .ok_or("replication_metrics must be Some when replication is enabled")?;
1867 let handlers = [cores.repl_handler_0, cores.repl_handler_1];
1868 let sender_fence = Arc::clone(&fence_state);
1869 let sender_ack_policy = Arc::clone(&ack_policy_atomic);
1870 // Bound in `run_impl` before any pipeline thread was spawned —
1871 // see the `repl_listener` parameter doc.
1872 let repl_listener = repl_listener
1873 .ok_or("replication listener must be pre-bound when replication is enabled")?;
1874 let repl_accept_handle = std::thread::Builder::new()
1875 // `repl-accept`, not `repl-sender`: this thread sends nothing.
1876 // The ring consumers are moved into the per-replica handler
1877 // threads, which do all the streaming; this one accepts
1878 // connections, reaps finished handlers, and sleeps 50 ms.
1879 .name("repl-accept".into())
1880 .spawn(move || {
1881 // Deliberately NOT pinned. Reserving a core — at
1882 // SCHED_FIFO, on an isolated one — for a thread that idles
1883 // 99.9% of the time bought nothing.
1884 //
1885 // Staying unpinned is now load-bearing, not just thrifty.
1886 // The handler threads are spawned from here and inherit
1887 // this thread's affinity and scheduling policy at
1888 // creation; a child of a pinned real-time parent cannot
1889 // move itself off that core, because moving itself
1890 // requires running. That is the DPDK handshake hang, and
1891 // this path escaped it only because the 50 ms sleep
1892 // happened to yield the core. See
1893 // `replication::validation_worker`.
1894 //
1895 // `PipelineCores` consequently has no field for it, and
1896 // `--cores` no name.
1897 crate::replication::run_sender::<A>(
1898 crate::replication::Sender {
1899 listener: repl_listener,
1900 repl_consumer_1,
1901 repl_consumer_2,
1902 replica_slots: repl_slots,
1903 journal_path,
1904 authorized_keys: repl_auth_keys,
1905 evict_flags,
1906 active_flags,
1907 metrics: repl_metrics,
1908 handlers,
1909 batch_size,
1910 heartbeat_secs,
1911 fence_state: sender_fence,
1912 ack_policy: sender_ack_policy,
1913 },
1914 &s_repl,
1915 &ready_flag,
1916 &connected_counter,
1917 );
1918 })
1919 .map_err(|e| format!("spawn replication sender thread: {e}"))?;
1920
1921 Some(repl_accept_handle)
1922 } else {
1923 if !config.standalone && config.replica_of.is_none() {
1924 info!("running in standalone mode (no replication)");
1925 }
1926 None
1927 };
1928
1929 // Spawn event publisher thread if enabled. Consumes from output ring
1930 // consumer 1 and broadcasts the application's reports to TCP
1931 // subscribers. Caller-supplied (an application with an output feed
1932 // wires its publisher; one without passes `None`), so the runtime
1933 // carries no application-specific reference.
1934 let event_publisher_handle = spawn_event_publisher::<A>(
1935 event_publisher_consumer,
1936 event_publisher,
1937 config,
1938 &cores,
1939 &authorized_keys,
1940 &shutdown,
1941 )?;
1942
1943 let shadow_handle = spawn_shadow_stage::<A>(
1944 shadow_consumer,
1945 shadow_app,
1946 chain_hash_lock,
1947 config,
1948 &cores,
1949 &shutdown,
1950 fence_state.epoch(),
1951 genesis_entries,
1952 )?;
1953
1954 // Spawn the health endpoint BEFORE `journal_on_primary_events` and
1955 // the accept loop so operators (and the failover test harness) can
1956 // probe `/healthz` to confirm the server has bound its sockets and
1957 // is ready to accept replica connections. Replicas connect to the
1958 // *replication* endpoint, not the client port, so spawning health
1959 // here doesn't change accept semantics.
1960 let pipeline_healthy = Arc::new(AtomicBool::new(true));
1961 let health_handle = spawn_health_endpoint(
1962 config,
1963 &active_connections,
1964 &events_processed,
1965 &refused_writes,
1966 &cursors,
1967 input_cursor,
1968 &pipeline_healthy,
1969 &replicas_connected,
1970 &fence_state,
1971 &replication_metrics,
1972 &replica_active,
1973 &replication_ring_progress,
1974 &journal_utilization,
1975 &matching_utilization,
1976 &response_utilization,
1977 &shutdown,
1978 &raft_status,
1979 )?;
1980
1981 // Promotion fencing: the bump is the first entry of a promoted node's
1982 // tenure. See `journal_promotion_epoch_bump`.
1983 if let Some(requested_epoch) = promotion {
1984 journal_promotion_epoch_bump(
1985 requested_epoch,
1986 &fence_state,
1987 &mut input_producer,
1988 &shutdown,
1989 );
1990 }
1991
1992 // Bring-up gate: a primary that began the history with replication
1993 // on serves no client until its first replica is streaming live. A
1994 // write before then would be refused (no replica connected) under
1995 // every ack policy that requires one, so a new cluster's first
1996 // clients are held instead of refused while it assembles. Genesis is
1997 // already durable in the journal (`init_engine` creates the journal
1998 // with it), so a shutdown during the wait loses nothing; the wait
1999 // yields to it, and the unified shutdown below joins every thread.
2000 // A restarted or promoted primary continues a history and does not
2001 // wait: losing its replicas then is a halt, not a bring-up.
2002 if enable_replication && began_history {
2003 info!("new history: waiting for the first replica before serving clients");
2004 while !replica_ready.load(Ordering::Acquire) && !shutdown.load(Ordering::Relaxed) {
2005 // A sleep, not the orchestrator's spin: the wait lasts as
2006 // long as the operator takes to start a replica.
2007 std::thread::sleep(std::time::Duration::from_millis(100));
2008 }
2009 }
2010
2011 // `on_primary` — after the epoch bump on promotion, so the bump stays
2012 // the first entry of the tenure.
2013 journal_on_primary_events(
2014 on_primary,
2015 &mut input_producer,
2016 &journal_cursor,
2017 &matching_cursor,
2018 &replication_ring_progress,
2019 &shutdown,
2020 );
2021
2022 // Now that the on_primary events are fully drained, spawn the reader
2023 // thread. From here on the reader is the sole producer on the input ring
2024 // (the on_primary loop has finished and its clone of `input_producer`
2025 // was dropped at the end of the block above). Any subsequent ticks the
2026 // reader emits cannot race with on_primary events: there are none in
2027 // flight.
2028 //
2029 // If shutdown was requested while they were draining we still spawn the
2030 // reader so the unified shutdown sequence below joins every thread.
2031 let reader_shutdown = Arc::new(AtomicBool::new(false));
2032 let mut reader_handle = crate::reader::spawn_reader::<A, _>(
2033 input_producer,
2034 decoder,
2035 halt_gate,
2036 refusal_tx,
2037 control_tx.clone(),
2038 config.cores.reader.core,
2039 connection_timeout,
2040 config.tick_interval(),
2041 Arc::clone(&reader_shutdown),
2042 );
2043
2044 // Health endpoint and `pipeline_healthy` were already spawned/created
2045 // earlier (before `journal_on_primary_events`) so probes can succeed
2046 // before the node accepts clients.
2047
2048 // Set the listener to non-blocking so accept() returns immediately
2049 // with WouldBlock when no connection is pending. This lets the accept
2050 // loop check the shutdown flag without blocking indefinitely.
2051 // Rust's std TcpListener retries on EINTR, so signals alone can't
2052 // interrupt a blocking accept().
2053 listener.set_nonblocking(true);
2054
2055 info!(addr = %config.bind, "listening");
2056
2057 // Monotonically increasing connection ID counter. AtomicU64 because
2058 // the accept loop is the only writer, but using atomic for future
2059 // flexibility (e.g., multiple listeners).
2060 let next_connection_id = AtomicU64::new(1);
2061
2062 // Accept loop — non-blocking with 100ms sleep on WouldBlock. Each
2063 // accepted connection is registered with the reader thread (no
2064 // per-connection threads).
2065 loop {
2066 if shutdown.load(Ordering::Relaxed) {
2067 info!("shutdown signal received");
2068 break;
2069 }
2070
2071 // Detect pipeline thread death early so we don't keep accepting
2072 // connections into a broken pipeline. These threads only exit on
2073 // shutdown or panic — if one is finished while shutdown is false,
2074 // it panicked. Re-check shutdown to avoid a TOCTOU race where a
2075 // clean shutdown signal arrives between the two checks.
2076 let event_pub_died = event_publisher_handle
2077 .as_ref()
2078 .is_some_and(|h| h.is_finished());
2079 let shadow_died = shadow_handle.as_ref().is_some_and(|h| h.is_finished());
2080 if (journal_handle.is_finished()
2081 || matching_handle.is_finished()
2082 || response_handle.is_finished()
2083 || event_pub_died
2084 || shadow_died)
2085 && !shutdown.load(Ordering::Relaxed)
2086 {
2087 error!("pipeline thread died, initiating shutdown");
2088 pipeline_healthy.store(false, Ordering::Relaxed);
2089 break;
2090 }
2091
2092 let (mut std_read, mut std_write, addr) = match listener.accept() {
2093 Ok(conn) => conn,
2094 Err(e) => {
2095 if e.kind() == std::io::ErrorKind::WouldBlock {
2096 // No pending connection — sleep briefly then retry.
2097 std::thread::sleep(std::time::Duration::from_millis(100));
2098 continue;
2099 }
2100 error!(error = %e, "accept error");
2101 continue;
2102 }
2103 };
2104
2105 // Enforce max_connections limit (SEC-02). Reject early before
2106 // spending time on auth. The counter is decremented by the response
2107 // stage on disconnect or write error.
2108 if config.max_connections > 0
2109 && active_connections.load(Ordering::Relaxed) >= config.max_connections
2110 {
2111 warn!(addr = %addr, "connection rejected: max_connections reached");
2112 drop(std_read);
2113 drop(std_write);
2114 continue;
2115 }
2116
2117 let connection_id = ConnectionId(next_connection_id.fetch_add(1, Ordering::Relaxed));
2118
2119 debug!(connection_id = connection_id.0, addr = %addr, "new connection");
2120
2121 // Set a 5-second read timeout for the auth handshake to prevent
2122 // slow/malicious clients from blocking the accept loop.
2123 if let Err(e) = set_read_timeout(&std_read, Some(std::time::Duration::from_secs(5))) {
2124 debug!(connection_id = connection_id.0, error = %e, "failed to set auth timeout");
2125 }
2126
2127 // Challenge-response authentication handshake (cold path).
2128 // 1. Send Challenge with random nonce
2129 // 2. Read ChallengeResponse (signature + public key)
2130 // 3. Verify signature and look up key in authorized_keys
2131 // 4. Send ServerReady on success, AuthFailed on failure
2132 let (role, public_key_bytes) = match authenticate_connection(
2133 connection_id,
2134 addr,
2135 &mut std_read,
2136 &mut std_write,
2137 &authorized_keys,
2138 ) {
2139 Ok(pair) => pair,
2140 Err(e) => {
2141 debug!(connection_id = connection_id.0, addr = %addr, error = %e, "auth failed, dropping");
2142 continue;
2143 }
2144 };
2145
2146 // The identity the application sees as `ApplyCtx::key_hash`.
2147 let key_hash = melin_app::key_hash(&public_key_bytes);
2148
2149 active_connections.fetch_add(1, Ordering::Relaxed);
2150
2151 // Clear the read timeout before handing to the io_uring reader.
2152 // io_uring uses kernel-managed I/O, so the timeout is irrelevant,
2153 // but clearing it avoids surprising behavior if the fd is ever
2154 // used in blocking mode again.
2155 if let Err(e) = set_read_timeout(&std_read, None) {
2156 debug!(connection_id = connection_id.0, error = %e, "failed to clear auth timeout");
2157 }
2158
2159 // Enable SO_BUSY_POLL on the client data socket. The reader
2160 // thread already busy-spins on io_uring CQEs, so the kernel's
2161 // NIC busy-poll happens during cycles that would have been
2162 // spent spinning anyway — net cost is zero, and we avoid the
2163 // softirq → wakeup handoff for every recv on this connection.
2164 if let Err(e) = set_busy_poll(&std_read, BUSY_POLL_US) {
2165 // Best-effort: a kernel without CAP_NET_ADMIN or with
2166 // SO_BUSY_POLL disabled by sysctl will reject this. Logged
2167 // as debug — it's a per-connection event and only affects
2168 // latency, not correctness.
2169 debug!(
2170 connection_id = connection_id.0,
2171 error = %e,
2172 "failed to set SO_BUSY_POLL on client socket"
2173 );
2174 }
2175
2176 // Belt-and-braces write timeout (SEC-01). The real slow-client
2177 // protection is in the response stage — MSG_DONTWAIT sends,
2178 // paced retries, and the blocked-send timeout — since io_uring
2179 // SEND does not honor SO_SNDTIMEO. This bounds any residual
2180 // blocking write(2) on this fd (none exist today; the response
2181 // stage is the sole egress writer).
2182 if let Err(e) = set_write_timeout(&std_write, Some(std::time::Duration::from_secs(5))) {
2183 debug!(connection_id = connection_id.0, error = %e, "failed to set write timeout");
2184 }
2185
2186 // Register the writer with the response thread before the
2187 // reader, and keep it that way: the response stage reads its
2188 // output ring before it drains this channel, and relies on
2189 // `Connected` being queued before any request of the connection
2190 // can be read — see the drain in `response::run`. Queued, not
2191 // yet applied: the stage picks the event up on its next
2192 // iteration, which that order makes early enough.
2193 let fd = std_write.as_raw_fd();
2194 let boxed_writer: Box<dyn std::io::Write + Send> = Box::new(std_write);
2195 let control_event = ControlEvent::Connected {
2196 connection_id: connection_id.0,
2197 fd,
2198 writer: BlockingFrameWriter::new(boxed_writer),
2199 };
2200 if control_tx.send(control_event).is_err() {
2201 info!("response thread gone, shutting down");
2202 break;
2203 }
2204
2205 // Register the reader fd with the io_uring reader thread.
2206 reader_handle.register(crate::reader::ReaderRegistration {
2207 connection_id,
2208 reader: std_read,
2209 addr,
2210 role,
2211 key_hash,
2212 });
2213 }
2214
2215 // --- Ordered shutdown sequence ---
2216 // 1. Stop readers first so no new events enter the disruptor.
2217 info!("shutdown: stopping reader thread");
2218 reader_handle.shutdown();
2219 reader_handle.join();
2220
2221 // 2. Now drain the pipeline and join every worker thread.
2222 shutdown_pipeline_stages(
2223 PipelineHandles {
2224 journal: journal_handle,
2225 matching: matching_handle,
2226 response: response_handle,
2227 replication: replication_handle,
2228 event_publisher: event_publisher_handle,
2229 shadow: shadow_handle,
2230 health: health_handle,
2231 },
2232 Vec::new(),
2233 &pipeline_healthy,
2234 &shutdown,
2235 )
2236}
2237
2238/// Run the server with DPDK kernel-bypass networking.
2239///
2240/// Replaces the kernel TCP stack entirely. The DPDK poll thread handles
2241/// all NIC I/O and TCP processing via smoltcp. The response stage encodes
2242/// frames and pushes them through an mpsc channel to the poll thread.
2243///
2244/// Thread layout:
2245/// - Core N: DPDK poll thread (rx_burst, smoltcp, frame decode, tx_burst)
2246/// - Core 1: Journal stage
2247/// - Core 2: Matching stage
2248/// - Core 3: Response stage (encodes to TX channel)
2249///
2250/// See [`run`] for the role of `startup`.
2251#[cfg(feature = "dpdk")]
2252fn run_dpdk<A>(
2253 config: ServerConfig,
2254 startup: StartupEvents<A::Event>,
2255 sizing: A::Sizing,
2256 decoder: RequestDecoderArc<A>,
2257 encoder: ResponseEncoderArc<A>,
2258 event_publisher: Option<EventPublisherFn<A>>,
2259 authorized_keys: Arc<AuthorizedKeys>,
2260 shutdown: Arc<AtomicBool>,
2261) -> Result<(), Box<dyn std::error::Error>>
2262where
2263 A: Application + Send + 'static,
2264 A::Event: Send + Sync + 'static,
2265 A::Report: Send + 'static,
2266 A::QueryResponse: Send + 'static,
2267{
2268 check_keys_match_decoder(&*decoder, &authorized_keys)?;
2269 let dpdk_config = dpdk_config_from(&config)?;
2270
2271 run_dpdk_impl::<A>(
2272 config,
2273 startup,
2274 sizing,
2275 decoder,
2276 encoder,
2277 event_publisher,
2278 authorized_keys,
2279 dpdk_config,
2280 shutdown,
2281 )
2282}
2283
2284/// Translate the CLI surface into a [`melin_dpdk::DpdkConfig`].
2285///
2286/// Every operator-supplied string is validated here, before EAL init and
2287/// before any thread is spawned, and a bad value is returned as an error
2288/// rather than a panic. That ordering is the point: the peer MAC is only
2289/// consumed inside the replica's reconnect loop, so parsing it there
2290/// would turn a typo into a crash of an already-running node.
2291#[cfg(feature = "dpdk")]
2292fn dpdk_config_from(cfg: &ServerConfig) -> Result<melin_dpdk::DpdkConfig, String> {
2293 let parse_ip = |value: &str, flag: &str| -> Result<std::net::Ipv4Addr, String> {
2294 value
2295 .parse()
2296 .map_err(|e| format!("invalid {flag} address '{value}': {e}"))
2297 };
2298 let parse_mac = |value: &str, flag: &str| -> Result<[u8; 6], String> {
2299 melin_dpdk::try_parse_mac(value).map_err(|e| format!("invalid {flag}: {e}"))
2300 };
2301
2302 Ok(melin_dpdk::DpdkConfig {
2303 eal_args: cfg
2304 .dpdk_eal_args
2305 .split_whitespace()
2306 .map(String::from)
2307 .collect(),
2308 port_ids: cfg.dpdk_ports.clone(),
2309 ip_addr: parse_ip(&cfg.dpdk_ip, "--dpdk-ip")?,
2310 prefix_len: cfg.dpdk_prefix_len,
2311 gateway: cfg
2312 .dpdk_gateway
2313 .as_deref()
2314 .map(|s| parse_ip(s, "--dpdk-gateway"))
2315 .transpose()?,
2316 listen_port: cfg.bind.port(),
2317 mtu: cfg.dpdk_mtu,
2318 vlan_id: cfg.dpdk_vlan,
2319 peer_ip: cfg
2320 .dpdk_peer_ip
2321 .as_deref()
2322 .map(|s| parse_ip(s, "--dpdk-peer-ip"))
2323 .transpose()?,
2324 gateway_mac: cfg
2325 .dpdk_gateway_mac
2326 .as_deref()
2327 .map(|s| parse_mac(s, "--dpdk-gateway-mac"))
2328 .transpose()?,
2329 peer_mac: cfg
2330 .dpdk_peer_mac
2331 .as_deref()
2332 .map(|s| parse_mac(s, "--dpdk-peer-mac"))
2333 .transpose()?,
2334 num_queues: 1,
2335 })
2336}
2337
2338#[cfg(feature = "dpdk")]
2339#[allow(clippy::too_many_arguments)] // boot assembly point, as run_impl
2340fn run_dpdk_impl<A>(
2341 config: ServerConfig,
2342 mut startup: StartupEvents<A::Event>,
2343 sizing: A::Sizing,
2344 decoder: RequestDecoderArc<A>,
2345 encoder: ResponseEncoderArc<A>,
2346 event_publisher: Option<EventPublisherFn<A>>,
2347 authorized_keys: Arc<AuthorizedKeys>,
2348 dpdk_config: melin_dpdk::DpdkConfig,
2349 shutdown: Arc<AtomicBool>,
2350) -> Result<(), Box<dyn std::error::Error>>
2351where
2352 A: Application + Send + 'static,
2353 A::Event: Send + Sync + 'static,
2354 A::Report: Send + 'static,
2355 A::QueryResponse: Send + 'static,
2356{
2357 warn_if_chain_disabled();
2358
2359 // As on the kernel-TCP path: resolve the layout once, up front. The
2360 // reader entry pins the NIC poll thread here, which never yields.
2361 let config = ServerConfig {
2362 cores: config.resolved_cores(ReaderThread::DpdkPoll)?,
2363 ..config
2364 };
2365 log_layout(&config.cores);
2366 // The one placement the layout check cannot make honest: the poll
2367 // thread polls flat out whatever its entry says, and unpinned it
2368 // does so wherever the scheduler puts it — next to whatever else is
2369 // there. Accepted (test hosts run this way), but said out loud.
2370 if !config.cores.reader.is_pinned() {
2371 warn!(
2372 "--cores: the reader is unpinned, but on DPDK it is the NIC poll thread and \
2373 busy-polls regardless; it will take a full core wherever the scheduler places \
2374 it. Give it a core of its own"
2375 );
2376 }
2377
2378 // Mirrors the kernel-TCP `run` path: one atomic per
2379 // process, threaded into both replica (pre-staging for promotion)
2380 // and primary admin listeners.
2381 let ack_policy_atomic = Arc::new(AtomicU8::new(config.ack_policy.as_u8()));
2382 // As on the kernel-TCP path: refuse a configuration no primary can run
2383 // under before EAL init and before anything touches the journal, in
2384 // every role — a replica's configuration is the one it serves under
2385 // once promoted.
2386 validate_primary_config(&config)?;
2387 // Initialize shared DPDK resources (EAL, mempool, ports with N queues).
2388 let shared = melin_dpdk::DpdkShared::init(&dpdk_config)?;
2389 // Actual queue count may be less than requested (TAP only supports 1).
2390 let num_dpdk_threads = shared.num_queues as usize;
2391
2392 // --- Replica mode (DPDK) ---
2393 // If --replica-of is set, run the DPDK replication receiver instead
2394 // of the primary path. The receiver uses one queue pair for the
2395 // outbound connection to the primary.
2396 if let Some(primary_addr) = config.replica_of {
2397 info!(primary = %primary_addr, "starting in replica mode (DPDK)");
2398 // As on the kernel-TCP path: a replica never journals genesis,
2399 // and its promotion check reads the lineage's genesis length from
2400 // its journal header, not its configuration.
2401 startup.genesis = Vec::new();
2402
2403 // Load the replication signing key — the replica signs the primary's
2404 // challenge with it. Mirrors the kernel-TCP replica path.
2405 let replication_key_path = config.replication_key.as_ref().ok_or_else(|| {
2406 std::io::Error::other("--replication-key is required in replica mode (--replica-of)")
2407 })?;
2408 let signing_key = {
2409 let seed = std::fs::read(replication_key_path).map_err(|e| {
2410 std::io::Error::other(format!(
2411 "failed to read replication key {}: {e}",
2412 replication_key_path.display()
2413 ))
2414 })?;
2415 if seed.len() != 32 {
2416 return Err(format!(
2417 "replication key must be 32 bytes, got {} ({})",
2418 seed.len(),
2419 replication_key_path.display()
2420 )
2421 .into());
2422 }
2423 let mut bytes = [0u8; 32];
2424 bytes.copy_from_slice(&seed);
2425 ed25519_dalek::SigningKey::from_bytes(&bytes)
2426 };
2427
2428 // The replica's control-plane bundle, same shape as the kernel
2429 // TCP replica path — see `run` for the rationale on lifetime.
2430 let control = crate::replication::ReplicaControlPlane::new();
2431 let promotion_request = control.promote.clone();
2432 let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2433 let _admin_handle = config
2434 .admin_bind
2435 .map(|addr| {
2436 crate::admin::spawn(
2437 addr,
2438 Some(promotion_request.clone()),
2439 rotate_flag.clone(),
2440 Some(Arc::clone(&ack_policy_atomic)),
2441 Arc::clone(&shutdown),
2442 Arc::clone(&authorized_keys),
2443 )
2444 })
2445 .transpose()?;
2446
2447 // Shared fencing state, created before mode detection so it
2448 // survives a replica → primary transition. Seeded at epoch 0; the
2449 // receiver raises it from the replica's recovered journal and the
2450 // replication stream, and a promotion bumps it by injecting an
2451 // `EpochBump`. Carried into `run_as_primary_dpdk` post-promotion.
2452 let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(0));
2453
2454 // Control-plane raft runs on kernel TCP even on DPDK nodes — the
2455 // control plane is off the hot path and must not consume a DPDK
2456 // queue. Same promotion-surviving placement and tip ownership as
2457 // the kernel replica path.
2458 let journal_tip = control.journal_tip.clone();
2459 let mut raft = match crate::raft::build_raft_config(&config)? {
2460 None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2461 Some(cfg) => crate::raft::spawn_raft_driver(
2462 cfg,
2463 config.raft_auto_promote,
2464 &signing_key,
2465 &authorized_keys,
2466 &fence_state,
2467 journal_tip.clone(),
2468 Arc::clone(&control.tip_ready),
2469 // A replica claims to be serving once a promotion is in
2470 // flight (see SupersessionPolicy).
2471 {
2472 let promote = control.promote.clone();
2473 Arc::new(move || promote.is_requested())
2474 },
2475 &shutdown,
2476 )?,
2477 };
2478 // Act on election wins when the operator opted in. Genesis
2479 // primaries have nothing to promote — replica paths only. The
2480 // guard joins the thread on every exit path.
2481 if config.raft_auto_promote
2482 && let Some(wiring) = raft.promotion_wiring()
2483 {
2484 raft.arm_promotion(crate::raft_promotion::spawn_auto_promotion(
2485 wiring.status,
2486 control.clone(),
2487 Arc::clone(&fence_state),
2488 Arc::clone(&ack_policy_atomic),
2489 wiring.peer_tips,
2490 wiring.peer_ids,
2491 wiring.elect_requested,
2492 wiring.elect_enabled,
2493 Arc::clone(&shutdown),
2494 ));
2495 }
2496 let raft_status = raft.status();
2497 let mut replica_health = crate::raft::spawn_replica_health(
2498 &config,
2499 &fence_state,
2500 raft_status.as_ref(),
2501 Arc::clone(&control.pipeline_healthy),
2502 )?;
2503
2504 // No local rotation triggers on the replica side — rotation is
2505 // primary-driven (see the kernel-TCP receiver path).
2506
2507 // Queue 0's transport carries the replica's link to its primary
2508 // and, should this node be promoted, everything it serves after:
2509 // a promoted replica is a DPDK primary on this same transport (see
2510 // the promotion arm below). It listens on nothing while the node
2511 // is a replica — a replica serves nothing inbound, and the stack
2512 // refuses a connection attempt — and gains its listeners at
2513 // promotion.
2514 let mut transport =
2515 melin_dpdk::DpdkTransport::from_shared_unlistening(&shared, &dpdk_config, 0)?;
2516 transport.send_gratuitous_arp();
2517
2518 let primary_ipv4 = match primary_addr.ip() {
2519 std::net::IpAddr::V4(ip) => ip,
2520 std::net::IpAddr::V6(_) => {
2521 return Err("DPDK replication requires IPv4".into());
2522 }
2523 };
2524
2525 match crate::replication::run_receiver_dpdk::<A>(
2526 &mut transport,
2527 primary_ipv4,
2528 primary_addr.port(),
2529 &signing_key,
2530 &config.journal,
2531 &shutdown,
2532 &control,
2533 config.snapshot_interval_ms,
2534 config.shadow_snapshot_path(),
2535 config.cores,
2536 config.journal_staging_mode.into(),
2537 config.group_commit_delay(),
2538 config.replication_pipeline_depth,
2539 Arc::clone(&fence_state),
2540 &sizing,
2541 )? {
2542 // Clean shutdown — the raft and health guards tear down on drop.
2543 None => return Ok(()),
2544 Some((mut app, writer)) => {
2545 // Promotion! Transition to primary mode, on DPDK: the node
2546 // serves on the transport it ran on, as a promoted
2547 // kernel-TCP replica does. The receiver tore its pipeline
2548 // down and handed over its application and journal writer;
2549 // every link it opened is reset.
2550 info!("replica promoted (DPDK) — transitioning to primary");
2551 // See the kernel-TCP promotion path.
2552 check_promotable(&writer)?;
2553 // Release --health-bind before run_as_primary_dpdk rebinds it.
2554 replica_health.stop();
2555 // Idempotent; see the kernel-TCP promotion path.
2556 <A as Application>::prefault(&mut app, &sizing);
2557
2558 // Clear a ROTATE latched while this node was a replica —
2559 // see the kernel-TCP promotion path.
2560 if let Some(ref flag) = rotate_flag {
2561 flag.store(false, Ordering::Release);
2562 }
2563
2564 // This thread ran the receiver, pinned to the reader's core
2565 // (on an isolated core, at real-time priority) once it
2566 // streamed, and it spawns the primary's threads next: a
2567 // child inheriting that placement could never run to pin
2568 // itself. Unpinned first, as the receiver does before it
2569 // builds a pipeline; it is pinned again as the poll thread.
2570 if let Err(e) = melin_app::affinity::clear_affinity() {
2571 warn!(
2572 error = e,
2573 "failed to clear the receiver's affinity before promotion"
2574 );
2575 }
2576
2577 // What `DpdkTransport::from_shared` gives a primary at
2578 // boot: the client listener, with the client buffers. The
2579 // replication listener is added with the primary's other
2580 // replication wiring, as at boot.
2581 transport
2582 .add_listener(dpdk_config.listen_port)
2583 .map_err(|e| format!("add the client listener on promotion: {e}"))?;
2584 // As at boot: the switch may have aged out this port's MAC
2585 // while the node only dialled out.
2586 transport.send_gratuitous_arp();
2587
2588 // The raft guard drops — stopping the driver and joining
2589 // the (already exited) promotion thread — after this
2590 // returns; see the kernel-TCP promotion path.
2591 return run_as_primary_dpdk::<A>(
2592 app,
2593 writer,
2594 transport,
2595 &dpdk_config,
2596 &config,
2597 startup.on_primary,
2598 decoder,
2599 encoder,
2600 event_publisher,
2601 Arc::clone(&shutdown),
2602 authorized_keys,
2603 rotate_flag,
2604 ack_policy_atomic,
2605 fence_state,
2606 promotion_request.pending(), // promoted — EpochBump with the request's epoch floor
2607 raft_status,
2608 journal_tip,
2609 );
2610 }
2611 }
2612 }
2613
2614 // Create per-thread transports (each gets its own queue pair + smoltcp stack).
2615 // The single-queue refactor (`feat/dpdk-single-queue`) collapsed
2616 // replication onto the same queue/thread as client traffic — no
2617 // separate queue is reserved any more, so `num_client_queues`
2618 // simply tracks `num_dpdk_threads`.
2619 let num_client_queues = num_dpdk_threads;
2620 let mut transports = Vec::with_capacity(num_client_queues);
2621 for q in 0..num_client_queues {
2622 let mut transport =
2623 melin_dpdk::DpdkTransport::from_shared(&shared, &dpdk_config, q as u16)?;
2624 // Send a gratuitous ARP from the first queue so the switch learns
2625 // our VF's MAC address. Without this, SR-IOV VFs that can't enable
2626 // promiscuous mode are unreachable — the switch has no forwarding
2627 // entry and drops unicast frames to our MAC.
2628 if q == 0 {
2629 transport.send_gratuitous_arp();
2630 }
2631 transports.push(transport);
2632 }
2633 // Exactly one client poll queue (LMAX: single reader → single
2634 // matcher); `dpdk_config_from` asks for one queue. Checked before
2635 // anything touches the journal or spawns a thread.
2636 let transport = match <[_; 1]>::try_from(transports) {
2637 Ok([transport]) => transport,
2638 Err(transports) => {
2639 return Err(format!(
2640 "expected exactly one client DPDK transport, the port gave {}",
2641 transports.len()
2642 )
2643 .into());
2644 }
2645 };
2646
2647 // The configuration was validated at the top of this function. Now
2648 // initialize or recover the application (journaling genesis on a new
2649 // journal) and size it — see the kernel-TCP primary path.
2650 // `began_history` is not read: the DPDK primary has no bring-up gate
2651 // (its main thread is the poll thread that accepts replicas, so it
2652 // cannot wait for one — see the replication note further down).
2653 let InitializedEngine {
2654 mut app,
2655 writer,
2656 recovered_epoch,
2657 began_history: _,
2658 } = init_engine::<A, BufferedWriter<A::Event>>(
2659 &config,
2660 &sizing,
2661 std::mem::take(&mut startup.genesis),
2662 )?;
2663 <A as Application>::prefault(&mut app, &sizing);
2664
2665 // Fencing state for this DPDK primary, seeded with the recovered epoch.
2666 let fence_state = Arc::new(melin_transport_core::fence::FenceState::new(
2667 recovered_epoch,
2668 ));
2669
2670 // Control-plane raft (kernel TCP even on DPDK — off the hot path, no
2671 // DPDK queue consumed). Signing key loaded only when raft is on.
2672 // A DPDK primary's fence epoch is recovered here too; seed the
2673 // advertised sequence from the recovered journal (see the kernel
2674 // primary path). The pipeline's journal stage owns the handle below.
2675 let journal_tip = melin_transport_core::AdvertisedJournalTip::new(
2676 melin_transport_core::WireSeq::new(writer.next_sequence().saturating_sub(1)),
2677 );
2678 let raft = match crate::raft::build_raft_config(&config)? {
2679 None => crate::raft::RaftDriverGuard::disabled(&shutdown),
2680 Some(cfg) => {
2681 let signing_key = load_replication_key(&config)?;
2682 crate::raft::spawn_raft_driver(
2683 cfg,
2684 config.raft_auto_promote,
2685 &signing_key,
2686 &authorized_keys,
2687 &fence_state,
2688 journal_tip.clone(),
2689 Arc::new(AtomicBool::new(true)),
2690 // A primary always claims to be serving.
2691 Arc::new(|| true),
2692 &shutdown,
2693 )?
2694 }
2695 };
2696 let raft_status = raft.status();
2697
2698 // The admin endpoint, as on the kernel-TCP primary path: spawned by
2699 // the caller of the primary function, since a promoted replica keeps
2700 // the one it booted with. PROMOTE is rejected on a primary (no flag
2701 // wired); ROTATE shares the flag the journal stage observes.
2702 let rotate_flag = config.admin_bind.map(|_| Arc::new(AtomicBool::new(false)));
2703 let _admin_handle = config
2704 .admin_bind
2705 .map(|addr| {
2706 crate::admin::spawn(
2707 addr,
2708 None,
2709 rotate_flag.clone(),
2710 Some(Arc::clone(&ack_policy_atomic)),
2711 Arc::clone(&shutdown),
2712 Arc::clone(&authorized_keys),
2713 )
2714 })
2715 .transpose()?;
2716
2717 // The raft guard and the admin endpoint drop after this returns.
2718 run_as_primary_dpdk::<A>(
2719 app,
2720 writer,
2721 transport,
2722 &dpdk_config,
2723 &config,
2724 startup.on_primary,
2725 decoder,
2726 encoder,
2727 event_publisher,
2728 Arc::clone(&shutdown),
2729 authorized_keys,
2730 rotate_flag,
2731 ack_policy_atomic,
2732 fence_state,
2733 None, // not promoted — no EpochBump injection
2734 raft_status,
2735 journal_tip,
2736 )
2737}
2738
2739/// Run the server as a DPDK primary: the DPDK twin of [`run_as_primary`],
2740/// used by both the normal DPDK primary startup and the promotion of a
2741/// DPDK replica. Builds the pipeline, spawns its threads, wires the
2742/// response stage, the ack gate, the halt gate and the replication driver
2743/// onto `transport`, journals the promotion's epoch bump (if any) and the
2744/// application's `on_primary` events, then runs the poll loop on this
2745/// thread until shutdown.
2746///
2747/// `transport` is queue 0's, listening on the client port: built at boot
2748/// by a primary, or the transport a promoted replica ran on, with its
2749/// client listener added. The replication listener is added here. The
2750/// EAL, ports and pool behind the transport are left as they are, never
2751/// re-initialised: EAL cannot be initialised twice in a process.
2752///
2753/// The other parameters are [`run_as_primary`]'s, less the bring-up gate's
2754/// `began_history` (a DPDK primary has none; see below).
2755#[cfg(feature = "dpdk")]
2756#[allow(clippy::too_many_arguments)] // boot assembly point, as run_as_primary
2757fn run_as_primary_dpdk<A>(
2758 app: A,
2759 writer: BufferedWriter<A::Event>,
2760 mut transport: melin_dpdk::DpdkTransport,
2761 dpdk_config: &melin_dpdk::DpdkConfig,
2762 config: &ServerConfig,
2763 on_primary: Vec<A::Event>,
2764 decoder: RequestDecoderArc<A>,
2765 encoder: ResponseEncoderArc<A>,
2766 event_publisher: Option<EventPublisherFn<A>>,
2767 shutdown: Arc<AtomicBool>,
2768 authorized_keys: Arc<AuthorizedKeys>,
2769 rotate_flag: Option<Arc<AtomicBool>>,
2770 ack_policy_atomic: Arc<AtomicU8>,
2771 fence_state: Arc<melin_transport_core::fence::FenceState>,
2772 promotion: Option<u64>,
2773 raft_status: Option<Arc<melin_transport_core::health::RaftStatus>>,
2774 journal_tip: melin_transport_core::AdvertisedJournalTip,
2775) -> Result<(), Box<dyn std::error::Error>>
2776where
2777 A: Application + Send + 'static,
2778 A::Event: Send + Sync + 'static,
2779 A::Report: Send + 'static,
2780 A::QueryResponse: Send + 'static,
2781{
2782 // Re-checked, as in `run_as_primary`: a promotion reaches here without
2783 // the boot-time check.
2784 validate_primary_config(config)?;
2785 // As on the kernel-TCP path: read before the writer moves into the
2786 // pipeline, for the shadow stage's snapshots.
2787 let genesis_entries = writer.read_header_info()?.genesis_entries;
2788
2789 // Clone the application's state for the shadow snapshot stage before
2790 // moving it into the pipeline (same as the kernel TCP path).
2791 let enable_shadow = config.snapshot_interval_ms > 0;
2792 let shadow_app = if enable_shadow {
2793 Some(<A as Application>::clone_via_snapshot(&app)?)
2794 } else {
2795 None
2796 };
2797
2798 let active_connections = Arc::new(AtomicU64::new(0));
2799
2800 // Replication setup (same as TCP path). The configuration was
2801 // validated before `init_engine`.
2802 let enable_replication = config.replication_bind.is_some();
2803
2804 // Build disruptor pipeline (same flags as the kernel TCP path).
2805 // DPDK doesn't currently spawn the publisher thread (see
2806 // `event_publisher: None` in the handles bundle further down).
2807 // Consumer allocation is gated on the same `Some`/`event_bind` pair
2808 // the kernel-TCP path uses, so passing `None` wires up no consumer;
2809 // but with `Some` and `--event-bind` a consumer slot is wired up and
2810 // never drained — see "DPDK never runs the event publisher" in
2811 // docs/internal/transport-divergences-2026-10.md.
2812 let enable_event_publisher = event_publisher.is_some() && config.event_bind.is_some();
2813 let enable_shadow = config.snapshot_interval_ms > 0;
2814 let Pipeline {
2815 input_producer,
2816 journal_stage,
2817 matching_stage,
2818 mut output_consumers,
2819 events_processed,
2820 input_cursor,
2821 replication_consumers,
2822 replicas_connected,
2823 shadow_consumer,
2824 chain_hash_lock,
2825 replication_ring_progress,
2826 cursors,
2827 } = build_pipeline_with_replication(
2828 app,
2829 writer,
2830 config.group_commit_delay(),
2831 Arc::clone(&active_connections),
2832 enable_replication,
2833 config.max_journal_batch,
2834 config.replication_ring_size,
2835 config.cores.stage_waits(),
2836 enable_event_publisher,
2837 enable_shadow,
2838 Arc::clone(&fence_state),
2839 );
2840 // Ring-position cursors for the on_primary drain gate below (Acquire loads,
2841 // stronger than the bundle's monitoring reads). The ack gate and
2842 // the replication sender pull their typed handles straight from
2843 // `cursors`; the health endpoint takes `cursors` itself.
2844 let journal_cursor = cursors.journal_ring_arc();
2845 let matching_cursor = cursors.matching_ring_arc();
2846
2847 let heartbeat_interval = config.heartbeat_interval();
2848
2849 // on_primary events flow through the disruptor like regular events. The
2850 // input ring is single-producer: main publishes them, then moves the
2851 // producer into the DPDK poll thread. No cloning required.
2852 let mut input_producer = input_producer;
2853
2854 // The DPDK poll thread also generates the application's clock ticks via
2855 // a wall-clock comparison between NIC bursts (see `run_dpdk_poll`). The
2856 // input ring is therefore single-producer in steady state alongside the
2857 // one-shot on_primary loop — same property as the io_uring transport.
2858 let tick_cadence = config.tick_interval();
2859
2860 // Control channel: DPDK poll thread → response stage (connect/disconnect).
2861 let (control_tx, control_rx) = std::sync::mpsc::channel();
2862
2863 // Client writes a halted node refuses at ingress, poll thread →
2864 // response stage, counted for the health endpoint. See `crate::halt`.
2865 let refused_writes = Arc::new(AtomicU64::new(0));
2866 let halt_gate = crate::halt::HaltGate::new(
2867 replicas_connected.clone(),
2868 Arc::clone(&fence_state),
2869 Arc::clone(&refused_writes),
2870 );
2871 let (refusal_tx, refusal_rx) =
2872 crate::halt::refusal_channel::<A::Report>(Arc::clone(&matching_cursor));
2873
2874 // TX SPSC: response stage → DPDK poll thread (encoded frames).
2875 // Lock-free, fixed-size slots — no heap allocation per frame.
2876 // 4096 slots × ~140 bytes = ~560 KiB. Enough to buffer a burst
2877 // without backpressuring the response stage.
2878 // One SPSC channel per DPDK poll thread (one: see `transport`). The
2879 // response stage routes frames to the correct thread based on
2880 // thread_id encoded in connection_id bits 56..63. A `Vec` because
2881 // that is the response stage's interface.
2882 // The response thread produces into it, so it waits its way.
2883 let wait = config.cores.response.wait;
2884 let (tx_out, tx_rx_0) =
2885 melin_pipeline::spsc::channel::<crate::dpdk_response::TxFrame>(4096, wait);
2886 let tx_producers = vec![tx_out];
2887
2888 // Spawn pipeline threads (journal, matching — identical to TCP path).
2889 let cores = config.cores;
2890
2891 // Wire runtime rotation into the journal stage (DPDK primary path).
2892 // ROTATE shares `rotate_flag` with the admin endpoint the caller
2893 // spawned.
2894 let mut journal_stage = journal_stage;
2895 let max_journal_bytes = config.max_journal_mib.saturating_mul(1024 * 1024);
2896 journal_stage.set_rotation(max_journal_bytes, rotate_flag.clone());
2897 config.cores.place_journal_children(&mut journal_stage);
2898 journal_stage.set_staging_mode(config.journal_staging_mode.into());
2899 // On a primary the journal stage owns the control-plane advertised
2900 // tip (durable cursor after each fsync batch). On the promotion path
2901 // this takes over the handle the receiver was advancing.
2902 journal_stage.set_advertised_tip_publisher(journal_tip.clone());
2903 if config.max_journal_mib > 0 {
2904 info!(
2905 max_journal_mib = config.max_journal_mib,
2906 "runtime journal rotation enabled (size threshold, DPDK)"
2907 );
2908 }
2909
2910 // Extract utilization handles before stages are moved into threads.
2911 let journal_utilization = journal_stage.utilization();
2912 let matching_utilization = matching_stage.utilization();
2913 let response_utilization = Arc::new(melin_transport_core::pipeline::StageUtilization::new());
2914
2915 let s1 = Arc::clone(&shutdown);
2916 // As on kernel TCP: start the journal's helper threads here, not on
2917 // journal-seq. See `JournalStage::start`.
2918 let sequencer = journal_stage
2919 .start()
2920 .map_err(|e| format!("start the journal stage: {e}"))?;
2921 let journal_handle = std::thread::Builder::new()
2922 .name("journal-seq".into())
2923 .spawn(move || {
2924 melin_app::affinity::pin_thread("journal-seq", cores.journal_seq.core);
2925 sequencer.run(&s1)
2926 })
2927 .map_err(|e| format!("spawn journal-seq thread: {e}"))?;
2928
2929 let s2 = Arc::clone(&shutdown);
2930 let matching_handle = std::thread::Builder::new()
2931 .name("matching".into())
2932 .spawn(move || {
2933 melin_app::affinity::pin_thread("matching", cores.matching.core);
2934 matching_stage.run(&s2)
2935 })
2936 .map_err(|e| format!("spawn matching thread: {e}"))?;
2937
2938 let replication_metrics = build_replication_metrics(
2939 replication_consumers.is_some(),
2940 &ack_policy_atomic,
2941 config.ack_policy,
2942 );
2943
2944 let replica_active: Option<[Arc<AtomicBool>; 2]> =
2945 replication_ring_progress.as_ref().map(|rp| {
2946 [
2947 Arc::clone(&rp.active_flags[0]),
2948 Arc::clone(&rp.active_flags[1]),
2949 ]
2950 });
2951
2952 // Spawn DPDK response stage (encodes to TX channel instead of kernel sockets).
2953 let output_consumer = output_consumers.remove(0);
2954 // Typed durable-cursor handle for the response thread's gate.
2955 let journal_persisted_wire_seq_response = cursors.durable_wire_seq();
2956 let replication_metrics_response = replication_metrics.as_ref().map(Arc::clone);
2957 let replica_active_response = replica_active.clone();
2958 let ack_policy_response = Arc::clone(&ack_policy_atomic);
2959 let active_connections_response = Arc::clone(&active_connections);
2960 let s3 = Arc::clone(&shutdown);
2961 let response_utilization_thread = Arc::clone(&response_utilization);
2962 let response_handle = std::thread::Builder::new()
2963 .name("response".into())
2964 .spawn(move || {
2965 melin_app::affinity::pin_thread("response", cores.response.core);
2966 crate::dpdk_response::run::<A>(
2967 output_consumer,
2968 control_rx,
2969 journal_persisted_wire_seq_response,
2970 ack_policy_response,
2971 replication_metrics_response,
2972 replica_active_response,
2973 &s3,
2974 heartbeat_interval,
2975 active_connections_response,
2976 tx_producers,
2977 response_utilization_thread,
2978 wait,
2979 encoder,
2980 refusal_rx,
2981 );
2982 })
2983 .map_err(|e| format!("spawn response thread: {e}"))?;
2984
2985 let shadow_handle = spawn_shadow_stage::<A>(
2986 shadow_consumer,
2987 shadow_app,
2988 chain_hash_lock,
2989 config,
2990 &cores,
2991 &shutdown,
2992 fence_state.epoch(),
2993 genesis_entries,
2994 )?;
2995
2996 // `replication_metrics` was constructed above so the response gate can
2997 // read per-slot cursors; the replication driver shares the same
2998 // instance.
2999 let replica_ready = Arc::new(AtomicBool::new(false));
3000 // Replication, if enabled: build a `DpdkReplicationDriver` for the
3001 // single client poll thread to drive. The driver's accept dispatch
3002 // hangs off a second listener on the client transport, added below
3003 // (port == repl_bind.port()).
3004 let (repl_driver, repl_listen_port) = if let Some((repl_consumer_1, repl_consumer_2)) =
3005 replication_consumers
3006 {
3007 let repl_bind = config
3008 .replication_bind
3009 .ok_or("replication_bind must be set when replication is enabled")?;
3010 let repl_port = repl_bind.port();
3011
3012 let repl_slots = cursors.replica_slot_cursors();
3013 let ready_flag = Arc::clone(&replica_ready);
3014 let batch_size = config.replication_batch_size;
3015 let heartbeat_secs = config.replication_heartbeat_secs;
3016 let repl_metrics = replication_metrics
3017 .clone()
3018 .ok_or("replication_metrics must be Some when replication is enabled")?;
3019 let connected_counter = replicas_connected
3020 .clone()
3021 .ok_or("replicas_connected must be Some when replication is enabled")?;
3022 let dpdk_active_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
3023 .as_ref()
3024 .map(|rp| {
3025 [
3026 Arc::clone(&rp.active_flags[0]),
3027 Arc::clone(&rp.active_flags[1]),
3028 ]
3029 })
3030 .unwrap_or_else(|| {
3031 [
3032 Arc::new(AtomicBool::new(false)),
3033 Arc::new(AtomicBool::new(false)),
3034 ]
3035 });
3036 let dpdk_evict_flags: [Arc<AtomicBool>; 2] = replication_ring_progress
3037 .as_ref()
3038 .map(|rp| {
3039 [
3040 Arc::clone(&rp.evict_flags[0]),
3041 Arc::clone(&rp.evict_flags[1]),
3042 ]
3043 })
3044 .unwrap_or_else(|| {
3045 [
3046 Arc::new(AtomicBool::new(false)),
3047 Arc::new(AtomicBool::new(false)),
3048 ]
3049 });
3050 let journal_path = config.journal.clone();
3051
3052 // Add the replication listener to the client transport so the
3053 // poll thread accepts both client and replication connections
3054 // off the same queue.
3055 // Replication needs larger TX buffers than clients: journal batches
3056 // can be 100-200 KiB, far exceeding the 16 KiB client TX buffer.
3057 const REPL_TX_BUF: usize = 512 * 1024;
3058 const REPL_TX_QUEUE: usize = 512 * 1024;
3059 const REPL_RX_BUF: usize = 64 * 1024;
3060 transport
3061 .add_listener_with_buffers(
3062 repl_port,
3063 REPL_RX_BUF,
3064 REPL_TX_BUF,
3065 REPL_TX_QUEUE,
3066 crate::replication::REPL_DISPATCH_BURST,
3067 )
3068 .map_err(|e| format!("add replication listener: {e}"))?;
3069
3070 let driver = crate::replication::DpdkReplicationDriver::new(
3071 [repl_consumer_1, repl_consumer_2],
3072 repl_slots,
3073 journal_path,
3074 ready_flag,
3075 connected_counter,
3076 dpdk_evict_flags,
3077 dpdk_active_flags,
3078 repl_metrics,
3079 batch_size,
3080 heartbeat_secs,
3081 Arc::clone(&fence_state),
3082 Arc::clone(&ack_policy_atomic),
3083 Arc::clone(&authorized_keys),
3084 )?;
3085 // Legacy text match — `lan-bench-suite.sh` `wait_for_log` keys
3086 // off "DPDK replication sender started" to know the primary
3087 // is ready to accept replicas. Kept verbatim for backward
3088 // compatibility with existing bench tooling.
3089 info!(addr = %repl_bind, "DPDK replication sender started (single-queue, in client poll thread)");
3090 (Some(driver), repl_port)
3091 } else {
3092 if !config.standalone && config.replica_of.is_none() {
3093 info!("running in standalone mode (no replication)");
3094 }
3095 (None, 0)
3096 };
3097 // The DPDK replication-sender thread used to be spawned here; with
3098 // the single-queue / single-thread refactor the driver lives inside
3099 // the client poll thread instead. Nothing to join.
3100 let replication_handle: Option<std::thread::JoinHandle<()>> = None;
3101
3102 // Promotion fencing, as on kernel TCP: the bump is the first entry of
3103 // a promoted node's tenure, ahead of `on_primary`, and is applied
3104 // before the poll loop below serves any client or replica. See
3105 // `journal_promotion_epoch_bump`.
3106 if let Some(requested_epoch) = promotion {
3107 journal_promotion_epoch_bump(
3108 requested_epoch,
3109 &fence_state,
3110 &mut input_producer,
3111 &shutdown,
3112 );
3113 }
3114
3115 // Journal `on_primary` through the pipeline. Genesis is already in
3116 // the journal (`init_engine` creates the journal with it); replicas
3117 // copy it by catch-up like the rest of the history.
3118 //
3119 // Unlike the kernel-TCP path, a DPDK primary that began the history
3120 // has no bring-up gate holding clients until its first replica
3121 // attaches: the main thread IS the poll thread that accepts replica
3122 // connections, so blocking it here for one would deadlock until
3123 // shutdown. Until a replica attaches, writes under a policy that
3124 // requires one are refused (no replica connected). A promoted primary
3125 // continues a history, which the gate never holds on either transport.
3126 journal_on_primary_events(
3127 on_primary,
3128 &mut input_producer,
3129 &journal_cursor,
3130 &matching_cursor,
3131 &replication_ring_progress,
3132 &shutdown,
3133 );
3134
3135 // Note: the DPDK poll threads are spawned BELOW, after this on_primary
3136 // drain. That ordering is what keeps the input ring single-producer
3137 // while on_primary events are published — the same property the TCP
3138 // path achieves by deferring its `spawn_reader` call to after the drain.
3139
3140 let pipeline_healthy = Arc::new(AtomicBool::new(true));
3141 let health_handle = spawn_health_endpoint(
3142 config,
3143 &active_connections,
3144 &events_processed,
3145 &refused_writes,
3146 &cursors,
3147 input_cursor,
3148 &pipeline_healthy,
3149 &replicas_connected,
3150 &fence_state,
3151 &replication_metrics,
3152 &replica_active,
3153 &replication_ring_progress,
3154 &journal_utilization,
3155 &matching_utilization,
3156 &response_utilization,
3157 &shutdown,
3158 &raft_status,
3159 )?;
3160
3161 info!(
3162 ip = %dpdk_config.ip_addr,
3163 port = dpdk_config.listen_port,
3164 num_dpdk_threads = 1,
3165 "DPDK transport listening"
3166 );
3167
3168 let connection_timeout = config.connection_timeout();
3169 let max_conns = config.max_connections;
3170 let reader_core = config.cores.reader.core;
3171
3172 // Exactly one client poll queue (LMAX: single reader → single
3173 // matcher), on this thread.
3174 melin_app::affinity::pin_thread("dpdk-poll-0", reader_core);
3175 crate::dpdk_transport::run_dpdk_poll::<A>(
3176 transport,
3177 input_producer,
3178 decoder,
3179 halt_gate,
3180 refusal_tx,
3181 control_tx,
3182 tx_rx_0,
3183 &shutdown,
3184 authorized_keys,
3185 connection_timeout,
3186 tick_cadence,
3187 max_conns,
3188 Arc::clone(&active_connections),
3189 0,
3190 repl_driver,
3191 repl_listen_port,
3192 );
3193
3194 // The client poll runs on the main thread, so no extra DPDK threads
3195 // to join here — replication sender (if enabled) is joined below.
3196 let dpdk_extras: Vec<(String, std::thread::Result<()>)> = Vec::new();
3197
3198 // The caller's raft guard drops — stopping the driver — after this
3199 // returns.
3200 shutdown_pipeline_stages(
3201 PipelineHandles {
3202 journal: journal_handle,
3203 matching: matching_handle,
3204 response: response_handle,
3205 replication: replication_handle,
3206 event_publisher: None,
3207 shadow: shadow_handle,
3208 health: health_handle,
3209 },
3210 dpdk_extras,
3211 &pipeline_healthy,
3212 &shutdown,
3213 )
3214}
3215
3216/// Promotion fencing: journal the `EpochBump` that opens a promoted
3217/// node's tenure, and return once it is applied. Both transports' primary
3218/// paths call it, before `on_primary` and before any client or replica is
3219/// served; a primary that booted as one (not promoted) does not, and keeps
3220/// the epoch its journal recovered.
3221///
3222/// The bump raises the cluster epoch so a paused/partitioned ex-primary
3223/// self-demotes when a handshake crosses (see `melin_transport_core::fence`).
3224/// It rides the input ring like a seed event (connection_id == 0), so it
3225/// flows through journal + replication to every replica. The wait for the
3226/// matching stage to apply it (epoch advanced) means the first handshake
3227/// already advertises the new epoch. The caller must be the input ring's
3228/// only producer.
3229///
3230/// The new epoch honours the promotion request's floor: a manual
3231/// `PROMOTE` carries `MANUAL` (= 1) and resolves to the classic
3232/// `epoch + 1`; a raft auto-promotion carries its election term
3233/// (strictly above the old epoch by the driver's request rule), so
3234/// tenure epochs align with raft terms and two overlapping
3235/// promotions from different elections always allocate distinct
3236/// epochs — the newer one fences the older.
3237fn journal_promotion_epoch_bump<E: melin_app::AppEvent>(
3238 requested_epoch: u64,
3239 fence_state: &melin_transport_core::fence::FenceState,
3240 input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
3241 shutdown: &AtomicBool,
3242) {
3243 use melin_app::unix_epoch_nanos;
3244 use melin_journal::JournalEvent;
3245 use melin_transport_core::trace::mono_trace_ns;
3246
3247 let new_epoch = fence_state.epoch().saturating_add(1).max(requested_epoch);
3248 // Re-validate the term↔epoch alignment at the moment the epoch is
3249 // minted, not just at the driver's request-time check: a streamed
3250 // `EpochBump` from a concurrent promotion elsewhere can raise the
3251 // fence during the drain, in which case `max` allocates `epoch+1`
3252 // instead of the election term. Fencing still converges (the
3253 // epochs stay distinct and the higher one wins), but the skew
3254 // makes later "epochs outran raft terms" refusals — so say what
3255 // actually happened while the evidence exists. Manual promotions
3256 // (requested == MANUAL) are exempt: they never claimed alignment.
3257 if requested_epoch > crate::promotion::PromotionRequest::MANUAL && new_epoch != requested_epoch
3258 {
3259 warn!(
3260 new_epoch,
3261 requested_epoch,
3262 "promotion epoch does not match its election term — a concurrent \
3263 promotion advanced the fencing epoch mid-drain; auto-promotion \
3264 refusals may report term/epoch misalignment until a newer election"
3265 );
3266 }
3267 info!(
3268 new_epoch,
3269 requested_epoch, "promotion: injecting epoch bump"
3270 );
3271 input_producer.publish(InputSlot {
3272 connection_id: 0,
3273 key_hash: 0,
3274 sequence: 0,
3275 timestamp_ns: unix_epoch_nanos(),
3276 event: JournalEvent::EpochBump { epoch: new_epoch },
3277 publish_ts: mono_trace_ns(),
3278 recv_ts: mono_trace_ns(),
3279 });
3280 // Wait until the matching stage observes the bump (epoch raised) so
3281 // the node advertises `new_epoch` on the very first handshake. Bounded
3282 // by the shutdown flag so a stuck pipeline can't wedge startup.
3283 ORCHESTRATOR_WAIT
3284 .wait_until(|| fence_state.epoch() >= new_epoch || shutdown.load(Ordering::Relaxed));
3285}
3286
3287/// Journal a primary's `on_primary` [`StartupEvents`] and return once
3288/// they are applied. (Genesis is not journaled here: `init_engine`
3289/// creates a new journal with it already in it.)
3290///
3291/// The events ride the input ring as the node's own (connection 0, key
3292/// hash 0: no client identity), so the normal pipeline
3293/// journals, replicates and applies them. The caller must still be the
3294/// ring's only producer, and must not serve a client until this returns:
3295/// a client request would otherwise run before the configuration in
3296/// `on_primary` is in force.
3297///
3298/// Gates on the journal + matching cursors (disruptor sequence space),
3299/// then on every active replication ring being consumed — the sender
3300/// threads have read every batch (sent or being sent to replicas).
3301/// Stronger than no gate, faster than waiting for replica acks, and
3302/// deadlock-free because the ring backpressures instead of dropping
3303/// batches. Every wait yields to the shutdown flag, and goes through the
3304/// orchestrator wait strategy, never a bare spin: the stage threads may
3305/// share this thread's core on a small box.
3306fn journal_on_primary_events<E: melin_app::AppEvent>(
3307 on_primary: Vec<E>,
3308 input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
3309 journal_cursor: &melin_pipeline::padding::Sequence,
3310 matching_cursor: &melin_pipeline::padding::Sequence,
3311 replication_ring_progress: &Option<melin_transport_core::pipeline::ReplicationRingProgress>,
3312 shutdown: &AtomicBool,
3313) {
3314 use melin_app::unix_epoch_nanos;
3315 use melin_journal::JournalEvent;
3316 use melin_transport_core::trace::mono_trace_ns;
3317
3318 let on_primary_count = on_primary.len();
3319 // Nothing published: the cursors never advance past a target, so
3320 // waiting for one would hang the boot.
3321 if on_primary_count == 0 {
3322 return;
3323 }
3324 let start = std::time::Instant::now();
3325
3326 // `sequence: 0` — the journal stage allocates sequences in disruptor
3327 // cursor order at encode time. The runtime wraps each application
3328 // event as `JournalEvent::App` and stamps transport-level metadata.
3329 let mut last_published_seq = 0u64;
3330 for event in on_primary {
3331 last_published_seq = input_producer.publish(InputSlot {
3332 connection_id: 0,
3333 key_hash: 0,
3334 sequence: 0,
3335 timestamp_ns: unix_epoch_nanos(),
3336 event: JournalEvent::App(event),
3337 publish_ts: mono_trace_ns(),
3338 recv_ts: mono_trace_ns(),
3339 });
3340 }
3341 let publish_elapsed = start.elapsed();
3342
3343 let target = last_published_seq + 1; // cursor = next-to-consume
3344 info!(
3345 target,
3346 journal = journal_cursor.get().load(Ordering::Relaxed),
3347 matching = matching_cursor.get().load(Ordering::Relaxed),
3348 "startup events: waiting for pipeline cursors"
3349 );
3350 ORCHESTRATOR_WAIT.wait_until(|| {
3351 shutdown.load(Ordering::Relaxed)
3352 || (journal_cursor.get().load(Ordering::Acquire) >= target
3353 && matching_cursor.get().load(Ordering::Acquire) >= target)
3354 });
3355
3356 // Inactive rings (no connected replica) were never published to, so
3357 // their producer cursor is 0 — no wait needed.
3358 if let Some(ring_progress) = replication_ring_progress {
3359 for i in 0..ring_progress.producer_cursors.len() {
3360 if !ring_progress.active_flags[i].load(Ordering::Relaxed) {
3361 continue;
3362 }
3363 let ring_target = ring_progress.producer_cursors[i].load();
3364 ORCHESTRATOR_WAIT.wait_until(|| {
3365 shutdown.load(Ordering::Relaxed)
3366 || ring_progress.consumer_cursors[i]
3367 .get()
3368 .load(Ordering::Acquire)
3369 >= ring_target
3370 });
3371 }
3372 }
3373
3374 info!(
3375 on_primary_events = on_primary_count,
3376 publish_ms = publish_elapsed.as_millis(),
3377 total_ms = start.elapsed().as_millis(),
3378 "journaled startup events through pipeline"
3379 );
3380}
3381
3382/// Bootstrap source chosen by [`init_engine`] from the on-disk layout.
3383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3384enum BootstrapSource {
3385 /// Snapshot plus at least one journal segment (live or archived):
3386 /// restore the snapshot, replay the post-snapshot delta.
3387 SnapshotAndJournal,
3388 /// Snapshot with no journal segment at all: restore the snapshot and
3389 /// open a fresh segment continuing its sequence + chain.
3390 SnapshotOnly,
3391 /// Journal segments but no snapshot: full replay from genesis.
3392 JournalOnly,
3393 /// Nothing on disk: first-ever startup.
3394 Fresh,
3395}
3396
3397/// Pure bootstrap decision, extracted so the matrix is unit-testable.
3398///
3399/// The critical cell: `(snapshot, no live, archives)` must route through
3400/// snapshot+journal recovery — the archives may hold acked events past
3401/// the snapshot (post-rotation crash window), and only the recovery walk
3402/// replays them, verifies the lineage, and synthesizes the missing live
3403/// segment. Likewise `(no snapshot, no live, archives)` is journal
3404/// recovery, not a fresh start.
3405fn choose_bootstrap(
3406 snapshot_exists: bool,
3407 live_exists: bool,
3408 archives_exist: bool,
3409) -> BootstrapSource {
3410 let lineage_exists = live_exists || archives_exist;
3411 match (snapshot_exists, lineage_exists) {
3412 (true, true) => BootstrapSource::SnapshotAndJournal,
3413 (true, false) => BootstrapSource::SnapshotOnly,
3414 (false, true) => BootstrapSource::JournalOnly,
3415 (false, false) => BootstrapSource::Fresh,
3416 }
3417}
3418
3419/// Whether a recovered journal still needs its genesis, decided by
3420/// [`genesis_target`].
3421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3422enum GenesisTarget {
3423 /// The history has begun: genesis is in it, or the history holds no
3424 /// genesis to add. Journal nothing.
3425 None,
3426 /// The journal holds no entry: replace it with one that starts with
3427 /// the genesis.
3428 EmptyJournal,
3429}
3430
3431/// Decide whether a journal recovered without a snapshot still needs
3432/// its genesis.
3433///
3434/// A journal is created with its genesis in it, so any entry at all
3435/// means the history has begun — a node that recovers a history never
3436/// journals genesis into it, whatever its configured genesis says (one
3437/// holding only part of it is refused by `init_engine` instead). The
3438/// one exception is a history with no entry whose header records no
3439/// genesis length (`recorded` is `None`): a live segment starting at
3440/// sequence 1 with nothing after its header, no archive, written by an
3441/// earlier release that created the journal before its genesis — or a
3442/// replica's copy of one. Nothing was ever journaled, served or
3443/// replicated from it, so beginning the history now is safe, and is
3444/// what the first boot that created it would have done.
3445///
3446/// An empty journal whose header records a length is never rewritten:
3447/// the length is lineage metadata fixed when the history began. `Some(0)`
3448/// is a history begun with no genesis, which a genesis configured since
3449/// changes nothing for; `Some(n)` with `n > 0` is a replica's copy that
3450/// received none of its primary's genesis, which `init_engine` refuses as
3451/// the partial copy it is rather than fork the lineage with this node's
3452/// own genesis.
3453///
3454/// A genesis with nothing to journal (empty, or only queries, which are
3455/// never journaled) leaves the journal as it is: rewriting it would
3456/// change nothing but its file.
3457///
3458/// `next_sequence` is the recovered writer's; it is 1 exactly when the
3459/// live segment starts at sequence 1 and holds no entry. `recorded` is
3460/// the genesis length its header records.
3461fn genesis_target<E: melin_app::AppEvent>(
3462 archives_exist: bool,
3463 next_sequence: u64,
3464 recorded: Option<u64>,
3465 genesis: &[E],
3466) -> GenesisTarget {
3467 if !archives_exist
3468 && next_sequence == 1
3469 && recorded.is_none()
3470 && genesis.iter().any(|e| !e.is_query())
3471 {
3472 GenesisTarget::EmptyJournal
3473 } else {
3474 GenesisTarget::None
3475 }
3476}
3477
3478/// What [`init_engine`] hands the boot path.
3479pub(crate) struct InitializedEngine<A, W> {
3480 pub(crate) app: A,
3481 pub(crate) writer: W,
3482 /// The fencing epoch recovered from the snapshot + journal (0 for a
3483 /// genesis node); the caller seeds the node's `FenceState` with it.
3484 pub(crate) recovered_epoch: u64,
3485 /// This boot began the history: it created the journal (with its
3486 /// genesis), or journaled the genesis into a journal that held no
3487 /// entry. A primary uses it to hold client service until its first
3488 /// replica attaches — the bring-up of a new cluster.
3489 pub(crate) began_history: bool,
3490}
3491
3492/// Initialize or recover the journaled application from disk.
3493///
3494/// Returns the application, its journal writer, the recovered fencing
3495/// epoch and whether this boot began the history ([`InitializedEngine`]).
3496/// The recovery paths (snapshot+journal, snapshot only, journal only,
3497/// fresh) are transport-level concerns and work uniformly for any
3498/// `A: Application` via `JournaledApp<A>`. Same engine initialization the
3499/// TCP / DPDK paths use.
3500///
3501/// `genesis` is journaled here, and only when this node starts a new
3502/// history: nothing on disk, or a journal holding no entry at all whose
3503/// header records no genesis length (see [`genesis_target`]). The
3504/// journal is created with the genesis already in it
3505/// ([`write_genesis_journal`]), so a journal at the configured path
3506/// always begins with the complete genesis, and every other layout —
3507/// any surviving segment, or a snapshot, whose state already includes
3508/// genesis — journals nothing. Genesis therefore reaches the returned
3509/// state through replay, like the rest of the history on every later
3510/// boot. The journal's header records the genesis length, and a
3511/// recovered history shorter than the length its header records is
3512/// refused ([`check_recovered_history_holds_genesis`]): it holds only
3513/// part of the genesis.
3514///
3515/// `sizing` is applied to a genesis instance before a journal is replayed
3516/// into it, so the history lands in reserved collections; the caller
3517/// sizes the result again afterwards, which covers the snapshot paths.
3518///
3519/// [`write_genesis_journal`]: melin_transport_core::journaled_app::write_genesis_journal
3520pub(crate) fn init_engine<A, W>(
3521 config: &ServerConfig,
3522 sizing: &A::Sizing,
3523 genesis: Vec<A::Event>,
3524) -> Result<InitializedEngine<A, W>, Box<dyn std::error::Error>>
3525where
3526 A: Application,
3527 W: JournalWrite<A::Event>,
3528{
3529 use melin_transport_core::journaled_app::write_genesis_journal;
3530
3531 // Replay the journal at the configured path into a fresh instance,
3532 // sized before the history is applied to it, as a replica is before
3533 // it applies the stream: replay must not grow the collections the
3534 // sizing would have reserved.
3535 let recover_journal = || -> Result<JournaledApp<A, W>, Box<dyn std::error::Error>> {
3536 let mut app = A::default();
3537 <A as Application>::prefault(&mut app, sizing);
3538 Ok(JournaledApp::<A, W>::recover(app, &config.journal)?)
3539 };
3540
3541 // Check for a snapshot: either the explicit --snapshot path, or the
3542 // default derived path (used by auto-rotation when --snapshot is not set).
3543 let derived_snap = config.journal.with_extension("snapshot");
3544 let snap_path = config.snapshot.as_deref().or_else(|| {
3545 if derived_snap.exists() {
3546 Some(derived_snap.as_path())
3547 } else {
3548 None
3549 }
3550 });
3551
3552 // The journal *lineage* exists if either the live segment or any
3553 // archived segment is on disk. Deciding on the live file alone is
3554 // how acked events used to vanish: a crash between `archive_live`
3555 // and `create_continuing` leaves archives (holding events past the
3556 // snapshot) with no live file — bootstrapping "snapshot only" from
3557 // that layout would silently rewind every event in the archives.
3558 // `JournaledApp::recover*` handles the missing-live case itself
3559 // (replays archives, synthesizes a fresh live continuing the chain).
3560 let journal_exists = config.journal.exists();
3561 let archives_exist = !melin_journal::segment::list_archives(&config.journal)?.is_empty();
3562 let snapshot_exists = snap_path.is_some_and(|p| p.exists());
3563 // An interrupted first boot's staging file is never history: sweep
3564 // it on every boot, not only on one that journals a genesis, so it
3565 // does not linger next to a history begun some other way.
3566 melin_transport_core::journaled_app::discard_genesis_staging(&config.journal)?;
3567 // Set by the two arms that begin the history.
3568 let mut began_history = false;
3569 let mut engine: JournaledApp<A, W> =
3570 match choose_bootstrap(snapshot_exists, journal_exists, archives_exist) {
3571 BootstrapSource::SnapshotAndJournal => {
3572 let snap_path = snap_path.expect("snapshot_exists implies snap_path");
3573 info!(snapshot = %snap_path.display(), "recovering from snapshot + journal");
3574 JournaledApp::<A, W>::recover_from_snapshot(snap_path, &config.journal)?
3575 }
3576 BootstrapSource::SnapshotOnly => {
3577 // Snapshot exists but no journal segment survives at all —
3578 // recover from the snapshot alone and start a fresh segment
3579 // continuing its sequence and chain. Not a new history:
3580 // the snapshot's state already includes genesis, so the
3581 // segment starts empty (the standard upgrade, which
3582 // starts a new binary on a fresh journal next to a
3583 // snapshot, lands here).
3584 let snap_path = snap_path.expect("snapshot_exists implies snap_path");
3585 info!(
3586 snapshot = %snap_path.display(),
3587 "recovering from snapshot only (no journal segments on disk)"
3588 );
3589 // The lineage's genesis length comes from the snapshot,
3590 // the only record of the lineage left on disk.
3591 let (app, snap) = melin_transport_core::snapshot::load_with_header::<A>(snap_path)?;
3592 let writer = W::create_continuing(
3593 &config.journal,
3594 snap.sequence + 1,
3595 snap.chain_hash,
3596 snap.genesis_entries,
3597 )?;
3598 JournaledApp::<A, W>::from_parts(app, writer, snap.epoch)
3599 }
3600 BootstrapSource::JournalOnly => {
3601 info!("recovering from journal");
3602 let engine = recover_journal()?;
3603 match genesis_target(
3604 archives_exist,
3605 engine.next_sequence(),
3606 engine.genesis_entries()?,
3607 &genesis,
3608 ) {
3609 GenesisTarget::None => engine,
3610 GenesisTarget::EmptyJournal => {
3611 // An empty history with no recorded genesis
3612 // length is a first boot that stopped before
3613 // journaling anything under an earlier release,
3614 // which created the journal before its genesis,
3615 // or a replica's copy of one, restarted as a
3616 // primary. Nothing was served or acknowledged
3617 // from it either way, so starting the history now
3618 // is safe. The anchor is kept, so a replica that
3619 // copied the empty journal still chains to this
3620 // one.
3621 drop(engine);
3622 let anchor =
3623 melin_journal::segment::read_header_info(&config.journal)?.anchor_hash;
3624 warn!(
3625 journal = %config.journal.display(),
3626 "the journal holds no entry and records no genesis \
3627 length: journaling genesis (an earlier release's \
3628 first boot stopped before journaling it)"
3629 );
3630 write_genesis_journal::<A::Event, W>(
3631 &config.journal,
3632 Some(anchor),
3633 genesis,
3634 )?;
3635 began_history = true;
3636 recover_journal()?
3637 }
3638 }
3639 }
3640 BootstrapSource::Fresh => {
3641 info!("creating new journal");
3642 write_genesis_journal::<A::Event, W>(&config.journal, None, genesis)?;
3643 began_history = true;
3644 recover_journal()?
3645 }
3646 };
3647
3648 // Every arm now holds the whole genesis, or a layout a primary never
3649 // writes (a replica's partial copy of it): refuse the latter rather
3650 // than serve it, against the genesis length the lineage records —
3651 // not `genesis`, this node's configuration. Checked before rotation,
3652 // so a refused boot rotates nothing and no recorded entry is
3653 // modified. Not quite side-effect free: a snapshot-only arm has
3654 // already left an empty live segment continuing the snapshot, which
3655 // the next boot recovers as snapshot-and-journal and refuses the
3656 // same way.
3657 check_recovered_history_holds_genesis(engine.next_sequence(), engine.genesis_entries()?)?;
3658
3659 // Archive the live journal segment if it exceeds the configured
3660 // size threshold. The shadow stage owns snapshot writes; here we
3661 // only rotate the segment so disk usage stays bounded across
3662 // restarts. Recovery walks the archive chain forward from the
3663 // latest shadow snapshot.
3664 if config.max_journal_mib > 0 {
3665 let threshold = config.max_journal_mib * 1024 * 1024;
3666 let current_size = engine.journal_size();
3667 if current_size > threshold {
3668 info!(
3669 current_mib = current_size / (1024 * 1024),
3670 threshold_mib = config.max_journal_mib,
3671 "journal exceeds threshold, rotating segment"
3672 );
3673 engine.rotate_segment()?;
3674 info!("journal segment rotated successfully");
3675 }
3676 }
3677
3678 let recovered_epoch = engine.recovered_epoch();
3679 let (app, writer) = engine.into_parts();
3680 Ok(InitializedEngine {
3681 app,
3682 writer,
3683 recovered_epoch,
3684 began_history,
3685 })
3686}
3687
3688// ---------------------------------------------------------------------------
3689// Shared helpers — used identically by both TCP and DPDK boot paths.
3690// ---------------------------------------------------------------------------
3691
3692fn build_replication_metrics(
3693 has_replication: bool,
3694 ack_policy_atomic: &AtomicU8,
3695 config_policy: crate::ack_policy::AckPolicy,
3696) -> Option<Arc<crate::replication::ReplicationMetrics>> {
3697 let metrics = if has_replication {
3698 Some(Arc::new(crate::replication::ReplicationMetrics::default()))
3699 } else {
3700 None
3701 };
3702
3703 let active_policy =
3704 crate::ack_policy::AckPolicy::from_u8(ack_policy_atomic.load(Ordering::Relaxed))
3705 .unwrap_or(config_policy);
3706 info!(
3707 ack_policy = %active_policy,
3708 clauses = %active_policy.to_policy(),
3709 "ack policy active"
3710 );
3711
3712 metrics
3713}
3714
3715fn spawn_shadow_stage<A: Application + Send + 'static>(
3716 shadow_consumer: Option<Consumer<InputSlot<A::Event>>>,
3717 shadow_app: Option<A>,
3718 chain_hash_lock: Option<
3719 melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3720 >,
3721 config: &ServerConfig,
3722 cores: &PipelineCores,
3723 shutdown: &Arc<AtomicBool>,
3724 initial_epoch: u64,
3725 // The lineage's genesis length, from the journal header — stamped
3726 // into every snapshot (see `shadow::run`).
3727 genesis_entries: Option<u64>,
3728) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
3729where
3730 A::Event: Send + Sync + 'static,
3731{
3732 let Some(shadow_cons) = shadow_consumer else {
3733 return Ok(None);
3734 };
3735 let snap_path = config.shadow_snapshot_path();
3736 let interval = std::time::Duration::from_millis(config.snapshot_interval_ms);
3737 let chain_hash =
3738 chain_hash_lock.ok_or("chain hash lock must be Some when shadow is enabled")?;
3739 let shadow_ex = shadow_app.ok_or("shadow application must be Some when shadow is enabled")?;
3740 let s_shadow = Arc::clone(shutdown);
3741 let shadow = cores.shadow;
3742 let shadow_initial_epoch = initial_epoch;
3743 let handle = std::thread::Builder::new()
3744 .name("shadow".into())
3745 .spawn(move || {
3746 melin_app::affinity::pin_thread("shadow", shadow.core);
3747 melin_transport_core::shadow::run(
3748 shadow_cons,
3749 shadow_ex,
3750 snap_path,
3751 interval,
3752 chain_hash,
3753 &s_shadow,
3754 shadow.wait,
3755 shadow_initial_epoch,
3756 genesis_entries,
3757 );
3758 })
3759 .map_err(|e| format!("spawn shadow thread: {e}"))?;
3760
3761 info!(
3762 interval_ms = config.snapshot_interval_ms,
3763 path = %config.shadow_snapshot_path().display(),
3764 "shadow snapshot stage started"
3765 );
3766 Ok(Some(handle))
3767}
3768
3769#[allow(clippy::too_many_arguments)]
3770fn spawn_health_endpoint(
3771 config: &ServerConfig,
3772 active_connections: &Arc<AtomicU64>,
3773 events_processed: &Arc<AtomicU64>,
3774 refused_writes: &Arc<AtomicU64>,
3775 cursors: &melin_transport_core::PipelineCursors,
3776 input_cursor: Box<dyn melin_pipeline::ring::QueueCursor>,
3777 pipeline_healthy: &Arc<AtomicBool>,
3778 replicas_connected: &Option<Arc<std::sync::atomic::AtomicU32>>,
3779 fence_state: &Arc<melin_transport_core::fence::FenceState>,
3780 replication_metrics: &Option<Arc<crate::replication::ReplicationMetrics>>,
3781 replica_active: &Option<[Arc<AtomicBool>; 2]>,
3782 replication_ring_progress: &Option<melin_transport_core::pipeline::ReplicationRingProgress>,
3783 journal_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3784 matching_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3785 response_utilization: &Arc<melin_transport_core::pipeline::StageUtilization>,
3786 shutdown: &Arc<AtomicBool>,
3787 raft_status: &Option<Arc<melin_transport_core::health::RaftStatus>>,
3788) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>> {
3789 let Some(health_addr) = config.health_bind else {
3790 return Ok(None);
3791 };
3792
3793 let (repl_ring_producers, repl_ring_consumers) = replication_ring_progress
3794 .as_ref()
3795 .map(|rp| {
3796 (
3797 Some([
3798 Arc::clone(&rp.producer_cursors[0]),
3799 Arc::clone(&rp.producer_cursors[1]),
3800 ]),
3801 Some([
3802 Arc::clone(&rp.consumer_cursors[0]),
3803 Arc::clone(&rp.consumer_cursors[1]),
3804 ]),
3805 )
3806 })
3807 .unwrap_or((None, None));
3808 Ok(Some(melin_transport_core::health::spawn(
3809 health_addr,
3810 melin_transport_core::health::HealthState {
3811 active_connections: Arc::clone(active_connections),
3812 events_processed: Arc::clone(events_processed),
3813 refused_writes: Arc::clone(refused_writes),
3814 cursors: cursors.clone(),
3815 input_cursor,
3816 pipeline_healthy: Arc::clone(pipeline_healthy),
3817 replicas_connected: replicas_connected.clone(),
3818 fence_state: Some(Arc::clone(fence_state)),
3819 replication_metrics: replication_metrics.clone(),
3820 replica_active: replica_active.clone(),
3821 replication_ring_producer_cursors: repl_ring_producers,
3822 replication_ring_consumer_cursors: repl_ring_consumers,
3823 journal_utilization: Arc::clone(journal_utilization),
3824 matching_utilization: Arc::clone(matching_utilization),
3825 response_utilization: Arc::clone(response_utilization),
3826 raft: raft_status.clone(),
3827 },
3828 Arc::clone(shutdown),
3829 )?))
3830}
3831
3832/// Spawn the event-publisher thread on the affinity core configured by
3833/// `cores.event_publisher`, delegating the loop body to the
3834/// caller-supplied [`EventPublisherFn`]. Returns `Ok(None)` when either
3835/// the consumer slot wasn't wired (the pipeline build saw
3836/// `enable_event_publisher == false`) or the binary passed no
3837/// publisher fn. The runtime owns thread lifecycle and CPU pinning so
3838/// the caller's fn stays domain-only.
3839fn spawn_event_publisher<A: Application>(
3840 consumer: Option<Consumer<OutputSlot<A>>>,
3841 run_fn: Option<EventPublisherFn<A>>,
3842 config: &ServerConfig,
3843 cores: &PipelineCores,
3844 authorized_keys: &Arc<AuthorizedKeys>,
3845 shutdown: &Arc<AtomicBool>,
3846) -> Result<Option<std::thread::JoinHandle<()>>, Box<dyn std::error::Error>>
3847where
3848 A::Report: Send + 'static,
3849 A::QueryResponse: Send + 'static,
3850{
3851 let (Some(event_consumer), Some(run_fn)) = (consumer, run_fn) else {
3852 return Ok(None);
3853 };
3854 let event_bind = config
3855 .event_bind
3856 .ok_or("event_bind must be set when event publisher is enabled")?;
3857 let s_event = Arc::clone(shutdown);
3858 let event_keys = Arc::clone(authorized_keys);
3859 let publisher = cores.event_publisher;
3860 let event_handle = std::thread::Builder::new()
3861 .name("event-publisher".into())
3862 .spawn(move || {
3863 melin_app::affinity::pin_thread("event-publisher", publisher.core);
3864 run_fn(
3865 event_consumer,
3866 event_bind,
3867 event_keys,
3868 &s_event,
3869 publisher.wait,
3870 );
3871 })
3872 .map_err(|e| format!("spawn event publisher thread: {e}"))?;
3873 info!(addr = %event_bind, "event publisher started");
3874 Ok(Some(event_handle))
3875}
3876
3877/// Perform challenge-response authentication on a new connection.
3878///
3879/// Runs on the accept thread (cold path, blocking). The caller must set
3880/// a read timeout on the stream before calling to prevent slow clients
3881/// from stalling the accept loop.
3882///
3883/// Uses raw `read_exact` instead of `BufReader` to avoid over-reading
3884/// bytes that belong to the first post-auth request.
3885///
3886/// Returns `(role, public_key_bytes)` on success.
3887fn authenticate_connection<R: std::io::Read, W: std::io::Write>(
3888 connection_id: ConnectionId,
3889 addr: SocketAddr,
3890 reader: &mut R,
3891 writer: &mut W,
3892 authorized_keys: &AuthorizedKeys,
3893) -> Result<(ClientRole<RoleId>, [u8; 32]), Box<dyn std::error::Error>> {
3894 use std::io;
3895
3896 use melin_wire_protocol::control::TransportResponse;
3897 use melin_wire_protocol::control_codec;
3898
3899 // Generate a 32-byte random nonce for this connection.
3900 // Explicit OsRng for cryptographic material (SEC-10).
3901 let mut nonce = [0u8; 32];
3902 getrandom::fill(&mut nonce).map_err(|e| io::Error::other(format!("getrandom failed: {e}")))?;
3903
3904 let mut buf = [0u8; 128];
3905 let written =
3906 control_codec::encode_transport_response(&TransportResponse::Challenge { nonce }, &mut buf)
3907 .map_err(|e| io::Error::other(format!("encode Challenge: {e}")))?;
3908 writer.write_all(&buf[..written])?;
3909 writer.flush()?;
3910
3911 // Read ChallengeResponse frame directly (no BufReader). Using raw
3912 // read_exact avoids BufReader over-reading bytes that belong to the
3913 // first post-auth request — those bytes would be lost when the
3914 // BufReader is dropped and the fd moves to the io_uring reader.
3915 let mut len_buf = [0u8; 4];
3916 reader
3917 .read_exact(&mut len_buf)
3918 .map_err(|e| io::Error::other(format!("read auth frame length: {e}")))?;
3919 let frame_len = u32::from_le_bytes(len_buf) as usize;
3920 if frame_len > MAX_AUTH_FRAME {
3921 send_auth_failed(writer);
3922 return Err(io::Error::other(format!("auth frame too large: {frame_len}")).into());
3923 }
3924 let mut frame_buf = [0u8; MAX_AUTH_FRAME];
3925 reader
3926 .read_exact(&mut frame_buf[..frame_len])
3927 .map_err(|e| io::Error::other(format!("read auth frame payload: {e}")))?;
3928
3929 let cr = match control_codec::decode_challenge_response(&frame_buf[..frame_len]) {
3930 Ok(cr) => cr,
3931 Err(e) => {
3932 send_auth_failed(writer);
3933 return Err(io::Error::other(format!("decode ChallengeResponse: {e}")).into());
3934 }
3935 };
3936
3937 let public_key_bytes = cr.public_key;
3938 let role = crate::client_auth::verify_client(
3939 authorized_keys,
3940 &nonce,
3941 &public_key_bytes,
3942 &cr.signature,
3943 )
3944 .inspect_err(|_| send_auth_failed(writer))?;
3945
3946 // Auth succeeded — send ServerReady.
3947 let written =
3948 control_codec::encode_transport_response(&TransportResponse::ServerReady, &mut buf)
3949 .map_err(|e| io::Error::other(format!("encode ServerReady: {e}")))?;
3950 writer.write_all(&buf[..written])?;
3951 writer.flush()?;
3952
3953 debug!(
3954 connection_id = connection_id.0,
3955 addr = %addr,
3956 role = authorized_keys.token(KeyRole::Client(role)),
3957 "authenticated"
3958 );
3959
3960 Ok((role, public_key_bytes))
3961}
3962
3963/// Set a read timeout on a raw fd via `setsockopt(SO_RCVTIMEO)`.
3964///
3965/// Works for both TCP and UDS since both are sockets. Uses `AsRawFd`
3966/// to avoid requiring a concrete stream type.
3967fn set_read_timeout<F: std::os::unix::io::AsRawFd>(
3968 fd: &F,
3969 timeout: Option<std::time::Duration>,
3970) -> std::io::Result<()> {
3971 let tv = match timeout {
3972 Some(d) => libc::timeval {
3973 tv_sec: d.as_secs() as libc::time_t,
3974 tv_usec: d.subsec_micros() as libc::suseconds_t,
3975 },
3976 None => libc::timeval {
3977 tv_sec: 0,
3978 tv_usec: 0,
3979 },
3980 };
3981 let ret = unsafe {
3982 libc::setsockopt(
3983 fd.as_raw_fd(),
3984 libc::SOL_SOCKET,
3985 libc::SO_RCVTIMEO,
3986 &tv as *const libc::timeval as *const libc::c_void,
3987 std::mem::size_of::<libc::timeval>() as libc::socklen_t,
3988 )
3989 };
3990 if ret < 0 {
3991 return Err(std::io::Error::last_os_error());
3992 }
3993 Ok(())
3994}
3995
3996/// Enable `SO_BUSY_POLL` on a TCP data socket so the kernel busy-polls
3997/// the NIC for incoming data instead of going to sleep on the softirq
3998/// → wakeup handoff. Removes scheduler-wakeup latency from the recv
3999/// path on hardware where IRQ delivery is the dominant per-packet cost
4000/// (e.g. ixgbe-class NICs). 50 µs covers a typical LAN ack RTT.
4001///
4002/// Best-effort: requires `CAP_NET_ADMIN` (or unprivileged operation
4003/// permitted via sysctl). Failures are surfaced as warnings by the
4004/// caller — they only cost latency, not correctness, and we don't want
4005/// a misconfigured kernel to halt connection acceptance.
4006///
4007/// Only beneficial when the receiving thread is already busy-spinning
4008/// (otherwise the spin cost is wasted on idle connections). All
4009/// callers in Melin meet that condition: client reader threads spin on
4010/// io_uring CQEs, the replication sender's ack-recv thread spins, and
4011/// the bench client thread spins.
4012pub(crate) fn set_busy_poll<F: std::os::unix::io::AsRawFd>(
4013 fd: &F,
4014 micros: i32,
4015) -> std::io::Result<()> {
4016 // SAFETY: fd is a live socket fd owned by the caller for the
4017 // duration of the call; the option pointer is to a stack-local i32
4018 // with the right size for SO_BUSY_POLL.
4019 let val: libc::c_int = micros;
4020 let ret = unsafe {
4021 libc::setsockopt(
4022 fd.as_raw_fd(),
4023 libc::SOL_SOCKET,
4024 libc::SO_BUSY_POLL,
4025 &val as *const libc::c_int as *const libc::c_void,
4026 std::mem::size_of::<libc::c_int>() as libc::socklen_t,
4027 )
4028 };
4029 if ret < 0 {
4030 return Err(std::io::Error::last_os_error());
4031 }
4032 Ok(())
4033}
4034
4035/// Default `SO_BUSY_POLL` window in microseconds. Matches the value
4036/// already used on the replica receive socket; chosen to cover a
4037/// typical LAN round-trip without burning excessive CPU on quiet
4038/// connections.
4039pub(crate) const BUSY_POLL_US: i32 = 50;
4040
4041/// Set `SO_SNDTIMEO` on a socket. Prevents blocking writes from stalling
4042/// the response thread when a client stops reading (SEC-01).
4043fn set_write_timeout<F: std::os::unix::io::AsRawFd>(
4044 fd: &F,
4045 timeout: Option<std::time::Duration>,
4046) -> std::io::Result<()> {
4047 let tv = match timeout {
4048 Some(d) => libc::timeval {
4049 tv_sec: d.as_secs() as libc::time_t,
4050 tv_usec: d.subsec_micros() as libc::suseconds_t,
4051 },
4052 None => libc::timeval {
4053 tv_sec: 0,
4054 tv_usec: 0,
4055 },
4056 };
4057 let ret = unsafe {
4058 libc::setsockopt(
4059 fd.as_raw_fd(),
4060 libc::SOL_SOCKET,
4061 libc::SO_SNDTIMEO,
4062 &tv as *const libc::timeval as *const libc::c_void,
4063 std::mem::size_of::<libc::timeval>() as libc::socklen_t,
4064 )
4065 };
4066 if ret < 0 {
4067 return Err(std::io::Error::last_os_error());
4068 }
4069 Ok(())
4070}
4071
4072/// Best-effort send of AuthFailed before dropping a connection.
4073fn send_auth_failed(writer: &mut impl std::io::Write) {
4074 use melin_wire_protocol::control::TransportResponse;
4075 use melin_wire_protocol::control_codec;
4076
4077 let mut buf = [0u8; 8];
4078 if let Ok(written) =
4079 control_codec::encode_transport_response(&TransportResponse::AuthFailed, &mut buf)
4080 {
4081 let _ = writer.write_all(&buf[..written]);
4082 let _ = writer.flush();
4083 }
4084}
4085
4086#[cfg(test)]
4087mod tests {
4088 use std::io::{Read, Write};
4089 use std::os::unix::net::UnixStream;
4090
4091 use ed25519_dalek::{Signer, SigningKey};
4092 use melin_app::auth::{AuthorizedKeys, ClientRole, KeyRole, NoRoles};
4093 use melin_app::decoder::{Decoded, RequestDecoder};
4094 use melin_wire_protocol::control::ConnectionId;
4095 use melin_wire_protocol::control_codec::{
4096 TAG_AUTH_FAILED, TAG_CHALLENGE, TAG_CHALLENGE_RESPONSE, TAG_RESPONSE_HEARTBEAT,
4097 TAG_SERVER_READY,
4098 };
4099
4100 use super::authenticate_connection;
4101 use super::{BootstrapSource, choose_bootstrap};
4102
4103 /// `--dpdk-eal-args` takes its value only in the joined form. The
4104 /// value itself starts with a dash, and the space form let a
4105 /// forgotten value silently swallow the next flag as the EAL
4106 /// string; both mistakes must be startup errors instead.
4107 #[test]
4108 fn dpdk_eal_args_requires_the_joined_form() {
4109 use clap::Parser;
4110 let cfg = super::ServerConfig::try_parse_from([
4111 "melin-server",
4112 "--dpdk-eal-args=-l 0-7 --huge-dir /dev/hugepages",
4113 ])
4114 .expect("joined form must parse");
4115 assert_eq!(cfg.dpdk_eal_args, "-l 0-7 --huge-dir /dev/hugepages");
4116
4117 let space_form =
4118 super::ServerConfig::try_parse_from(["melin-server", "--dpdk-eal-args", "-l 0-7"]);
4119 assert!(space_form.is_err(), "space-separated form must be rejected");
4120
4121 let forgotten_value =
4122 super::ServerConfig::try_parse_from(["melin-server", "--dpdk-eal-args", "--no-mlock"]);
4123 assert!(
4124 forgotten_value.is_err(),
4125 "a forgotten value must error, not swallow the next flag"
4126 );
4127 }
4128
4129 /// Full bootstrap decision matrix. The two archive-only cells are
4130 /// the regression guard: a post-rotation crash leaves archives with
4131 /// no live segment, and bootstrapping "snapshot only" (or "fresh")
4132 /// from that layout silently rewinds every acked event held in the
4133 /// archives. Recovery must own any layout where a lineage survives.
4134 #[test]
4135 fn bootstrap_decision_routes_archives_through_recovery() {
4136 use BootstrapSource::*;
4137 // (snapshot, live, archives) → source
4138 let matrix = [
4139 ((false, false, false), Fresh),
4140 ((false, false, true), JournalOnly), // post-rotation crash, no snapshot
4141 ((false, true, false), JournalOnly),
4142 ((false, true, true), JournalOnly),
4143 ((true, false, false), SnapshotOnly),
4144 ((true, false, true), SnapshotAndJournal), // post-rotation crash
4145 ((true, true, false), SnapshotAndJournal),
4146 ((true, true, true), SnapshotAndJournal),
4147 ];
4148 for ((snap, live, arch), expected) in matrix {
4149 assert_eq!(
4150 choose_bootstrap(snap, live, arch),
4151 expected,
4152 "snapshot={snap} live={live} archives={arch}"
4153 );
4154 }
4155 }
4156
4157 /// Deterministic test key.
4158 fn test_key() -> SigningKey {
4159 SigningKey::from_bytes(&[0xAA; 32])
4160 }
4161
4162 /// Build an `AuthorizedKeys` listing the test key under `role`.
4163 fn keys_with_test_key(role: &str) -> AuthorizedKeys {
4164 // Encode the public key bytes as base64 with the local helper so
4165 // the test has no external codec dependency (all test keys produce
4166 // valid base64), then feed it through AuthorizedKeys::parse.
4167 let pub_bytes = test_key().verifying_key().to_bytes();
4168 let pub_b64 = base64_encode(&pub_bytes);
4169 crate::test_roles::desk_keys(role, &pub_b64)
4170 }
4171
4172 /// Minimal base64 encoder for test use only. Avoids adding base64
4173 /// as a dev-dependency to the server crate.
4174 fn base64_encode(bytes: &[u8]) -> String {
4175 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4176 let mut out = String::new();
4177 for chunk in bytes.chunks(3) {
4178 let b0 = chunk[0] as u32;
4179 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
4180 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
4181 let n = (b0 << 16) | (b1 << 8) | b2;
4182 out.push(ALPHABET[(n >> 18 & 0x3F) as usize] as char);
4183 out.push(ALPHABET[(n >> 12 & 0x3F) as usize] as char);
4184 if chunk.len() > 1 {
4185 out.push(ALPHABET[(n >> 6 & 0x3F) as usize] as char);
4186 } else {
4187 out.push('=');
4188 }
4189 if chunk.len() > 2 {
4190 out.push(ALPHABET[(n & 0x3F) as usize] as char);
4191 } else {
4192 out.push('=');
4193 }
4194 }
4195 out
4196 }
4197
4198 /// Run `authenticate_connection` on one end of a `UnixStream::pair()`,
4199 /// returning the admitted role's token in the keys file. Maps the
4200 /// error to `String` so it's `Send`.
4201 fn run_server_auth(
4202 mut stream: UnixStream,
4203 keys: AuthorizedKeys,
4204 ) -> std::thread::JoinHandle<Result<&'static str, String>> {
4205 std::thread::spawn(move || {
4206 // Clone the stream so we have independent read/write halves.
4207 let mut writer = stream.try_clone().unwrap();
4208 authenticate_connection(
4209 ConnectionId(1),
4210 "127.0.0.1:0".parse().unwrap(),
4211 &mut stream,
4212 &mut writer,
4213 &keys,
4214 )
4215 .map(|(role, _pk)| keys.token(KeyRole::Client(role)))
4216 .map_err(|e| e.to_string())
4217 })
4218 }
4219
4220 /// Read a length-prefixed Challenge frame and return its 32-byte
4221 /// nonce. Frame layout: `[len:u32][TAG_CHALLENGE][nonce:32]`.
4222 fn read_challenge(stream: &mut UnixStream) -> [u8; 32] {
4223 let mut len_buf = [0u8; 4];
4224 stream.read_exact(&mut len_buf).unwrap();
4225 let len = u32::from_le_bytes(len_buf) as usize;
4226 let mut payload = vec![0u8; len];
4227 stream.read_exact(&mut payload).unwrap();
4228 assert_eq!(payload[0], TAG_CHALLENGE, "expected Challenge");
4229 payload[1..33].try_into().unwrap()
4230 }
4231
4232 /// Write a length-prefixed ChallengeResponse frame, matching the
4233 /// layout the runtime's `control_codec::decode_challenge_response`
4234 /// expects: `[len:u32][TAG_CHALLENGE_RESPONSE][sig:64][pubkey:32]`.
4235 fn write_challenge_response(
4236 stream: &mut UnixStream,
4237 signature: [u8; 64],
4238 public_key: [u8; 32],
4239 ) {
4240 let mut frame = Vec::with_capacity(97);
4241 frame.push(TAG_CHALLENGE_RESPONSE);
4242 frame.extend_from_slice(&signature);
4243 frame.extend_from_slice(&public_key);
4244 stream
4245 .write_all(&(frame.len() as u32).to_le_bytes())
4246 .unwrap();
4247 stream.write_all(&frame).unwrap();
4248 stream.flush().unwrap();
4249 }
4250
4251 /// Read a Challenge frame from the client end, sign the nonce, and
4252 /// write a valid ChallengeResponse back.
4253 fn client_sign_challenge(stream: &mut UnixStream, key: &SigningKey) {
4254 let nonce = read_challenge(stream);
4255 let sig = key.sign(&nonce);
4256 write_challenge_response(stream, sig.to_bytes(), key.verifying_key().to_bytes());
4257 }
4258
4259 /// Like `client_sign_challenge` but corrupts the signature.
4260 fn client_sign_challenge_bad(stream: &mut UnixStream, key: &SigningKey) {
4261 let nonce = read_challenge(stream);
4262 let mut sig_bytes = key.sign(&nonce).to_bytes();
4263 sig_bytes[0] ^= 0xFF;
4264 write_challenge_response(stream, sig_bytes, key.verifying_key().to_bytes());
4265 }
4266
4267 /// Read one length-prefixed control frame and return its tag byte.
4268 fn read_response_tag(stream: &mut UnixStream) -> u8 {
4269 let mut len_buf = [0u8; 4];
4270 stream.read_exact(&mut len_buf).unwrap();
4271 let len = u32::from_le_bytes(len_buf) as usize;
4272 let mut buf = vec![0u8; len];
4273 stream.read_exact(&mut buf).unwrap();
4274 buf[0]
4275 }
4276
4277 #[test]
4278 fn auth_success_returns_the_application_role() {
4279 let keys = keys_with_test_key("trader");
4280 let key = test_key();
4281 let (s1, mut s2) = UnixStream::pair().unwrap();
4282
4283 let handle = run_server_auth(s1, keys);
4284
4285 client_sign_challenge(&mut s2, &key);
4286 let resp = read_response_tag(&mut s2);
4287 assert_eq!(resp, TAG_SERVER_READY);
4288
4289 let result = handle.join().unwrap();
4290 assert_eq!(result.unwrap(), "trader");
4291 }
4292
4293 #[test]
4294 fn auth_returns_the_operator_role() {
4295 let keys = keys_with_test_key("operator");
4296 let key = test_key();
4297 let (s1, mut s2) = UnixStream::pair().unwrap();
4298
4299 let handle = run_server_auth(s1, keys);
4300
4301 client_sign_challenge(&mut s2, &key);
4302 let resp = read_response_tag(&mut s2);
4303 assert_eq!(resp, TAG_SERVER_READY);
4304
4305 assert_eq!(handle.join().unwrap().unwrap(), "operator");
4306 }
4307
4308 #[test]
4309 fn auth_unknown_key_sends_auth_failed() {
4310 let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
4311 let key = test_key();
4312 let (s1, mut s2) = UnixStream::pair().unwrap();
4313
4314 let handle = run_server_auth(s1, keys);
4315
4316 client_sign_challenge(&mut s2, &key);
4317 let resp = read_response_tag(&mut s2);
4318 assert_eq!(resp, TAG_AUTH_FAILED);
4319
4320 assert!(handle.join().unwrap().is_err());
4321 }
4322
4323 #[test]
4324 fn auth_bad_signature_sends_auth_failed() {
4325 let keys = keys_with_test_key("operator");
4326 let key = test_key();
4327 let (s1, mut s2) = UnixStream::pair().unwrap();
4328
4329 let handle = run_server_auth(s1, keys);
4330
4331 client_sign_challenge_bad(&mut s2, &key);
4332 let resp = read_response_tag(&mut s2);
4333 assert_eq!(resp, TAG_AUTH_FAILED);
4334
4335 assert!(handle.join().unwrap().is_err());
4336 }
4337
4338 #[test]
4339 fn auth_wrong_message_type_sends_auth_failed() {
4340 let keys = keys_with_test_key("trader");
4341 let (s1, mut s2) = UnixStream::pair().unwrap();
4342
4343 let handle = run_server_auth(s1, keys);
4344
4345 // Read and discard the Challenge.
4346 read_challenge(&mut s2);
4347
4348 // Send a frame carrying a transport-heartbeat tag where a
4349 // ChallengeResponse is expected. It is the right length (97) so
4350 // the auth decoder reaches the tag check and rejects the
4351 // unexpected tag, replying AuthFailed.
4352 let mut frame = vec![0u8; 97];
4353 frame[0] = TAG_RESPONSE_HEARTBEAT;
4354 s2.write_all(&(frame.len() as u32).to_le_bytes()).unwrap();
4355 s2.write_all(&frame).unwrap();
4356 s2.flush().unwrap();
4357
4358 let resp = read_response_tag(&mut s2);
4359 assert_eq!(resp, TAG_AUTH_FAILED);
4360
4361 assert!(handle.join().unwrap().is_err());
4362 }
4363
4364 #[test]
4365 fn auth_client_disconnects_is_error() {
4366 let keys = keys_with_test_key("trader");
4367 let (s1, s2) = UnixStream::pair().unwrap();
4368
4369 let handle = run_server_auth(s1, keys);
4370
4371 // Drop immediately — server fails reading the ChallengeResponse.
4372 drop(s2);
4373
4374 assert!(handle.join().unwrap().is_err());
4375 }
4376
4377 #[test]
4378 fn auth_different_key_than_authorized_is_rejected() {
4379 // Authorize the test key, but connect with a different key.
4380 let keys = keys_with_test_key("trader");
4381 let wrong_key = SigningKey::from_bytes(&[0xCC; 32]);
4382 let (s1, mut s2) = UnixStream::pair().unwrap();
4383
4384 let handle = run_server_auth(s1, keys);
4385
4386 client_sign_challenge(&mut s2, &wrong_key);
4387 let resp = read_response_tag(&mut s2);
4388 assert_eq!(resp, TAG_AUTH_FAILED);
4389
4390 assert!(handle.join().unwrap().is_err());
4391 }
4392
4393 #[test]
4394 fn auth_oversized_frame_sends_auth_failed() {
4395 let keys = keys_with_test_key("trader");
4396 let (s1, mut s2) = UnixStream::pair().unwrap();
4397
4398 let handle = run_server_auth(s1, keys);
4399
4400 // Read and discard Challenge.
4401 read_challenge(&mut s2);
4402
4403 // Send a frame claiming to be 1000 bytes (way over the 256 limit).
4404 let fake_len: u32 = 1000;
4405 s2.write_all(&fake_len.to_le_bytes()).unwrap();
4406 s2.flush().unwrap();
4407
4408 // Server should send AuthFailed before dropping.
4409 let resp = read_response_tag(&mut s2);
4410 assert_eq!(resp, TAG_AUTH_FAILED);
4411
4412 assert!(handle.join().unwrap().is_err());
4413 }
4414
4415 #[test]
4416 fn auth_zero_length_frame_sends_auth_failed() {
4417 let keys = keys_with_test_key("trader");
4418 let (s1, mut s2) = UnixStream::pair().unwrap();
4419
4420 let handle = run_server_auth(s1, keys);
4421
4422 // Read and discard Challenge.
4423 read_challenge(&mut s2);
4424
4425 // Send a zero-length frame — decode_request will fail on empty input.
4426 let zero_len: u32 = 0;
4427 s2.write_all(&zero_len.to_le_bytes()).unwrap();
4428 s2.flush().unwrap();
4429
4430 // Server should send AuthFailed before dropping.
4431 let resp = read_response_tag(&mut s2);
4432 assert_eq!(resp, TAG_AUTH_FAILED);
4433
4434 assert!(handle.join().unwrap().is_err());
4435 }
4436
4437 #[test]
4438 fn auth_readonly_role() {
4439 let keys = keys_with_test_key("readonly");
4440 let key = test_key();
4441 let (s1, mut s2) = UnixStream::pair().unwrap();
4442
4443 let handle = run_server_auth(s1, keys);
4444
4445 client_sign_challenge(&mut s2, &key);
4446 let resp = read_response_tag(&mut s2);
4447 assert_eq!(resp, TAG_SERVER_READY);
4448
4449 assert_eq!(handle.join().unwrap().unwrap(), "readonly");
4450 }
4451
4452 /// A decoder with no application roles, for the pairing check below.
4453 struct OperatorOnlyDecoder;
4454
4455 impl RequestDecoder for OperatorOnlyDecoder {
4456 type Event = counter_server::CounterEvent;
4457 type Role = NoRoles;
4458
4459 fn decode(&self, _body: &[u8], _role: ClientRole<NoRoles>) -> Decoded<Self::Event> {
4460 Decoded::Filter
4461 }
4462 }
4463
4464 /// A keys table parsed for another role type than the decoder's is
4465 /// refused before the node starts: its role indices would name the
4466 /// decoder's roles wrongly.
4467 #[test]
4468 fn keys_parsed_for_another_role_type_are_refused() {
4469 let decoder = OperatorOnlyDecoder;
4470 let other_roles = keys_with_test_key("operator");
4471 let err = super::check_keys_match_decoder(&decoder, &other_roles).unwrap_err();
4472 assert_eq!(
4473 err.to_string(),
4474 "authorized keys were parsed for a role type other than the decoder's"
4475 );
4476 let own_roles = AuthorizedKeys::parse::<NoRoles>("").unwrap();
4477 super::check_keys_match_decoder(&decoder, &own_roles).unwrap();
4478 }
4479
4480 /// A replication key signs correctly and is in the keys file, but it
4481 /// authorizes node-to-node streaming only: the client listener refuses
4482 /// it at the handshake, before any request could reach the decoder.
4483 #[test]
4484 fn auth_replication_key_refused_on_client_listener() {
4485 let keys = keys_with_test_key("replication");
4486 let key = test_key();
4487 let (s1, mut s2) = UnixStream::pair().unwrap();
4488
4489 let handle = run_server_auth(s1, keys);
4490
4491 client_sign_challenge(&mut s2, &key);
4492 let resp = read_response_tag(&mut s2);
4493 assert_eq!(resp, TAG_AUTH_FAILED);
4494
4495 let err = handle.join().unwrap().unwrap_err();
4496 assert!(
4497 err.contains("may not connect as a client"),
4498 "unexpected error: {err}"
4499 );
4500 }
4501}
4502
4503/// Genesis is journaled once per lineage (audit findings 3 and 4): a new
4504/// journal is created with its genesis in it, and no other layout
4505/// journals genesis — a snapshot-only boot, a recovered history, a crash
4506/// in the middle of a first boot. Each test drives `init_engine` on a
4507/// directory laid out as the case leaves it.
4508#[cfg(test)]
4509mod genesis_tests {
4510 use std::path::Path;
4511
4512 use counter_server::{Counter, CounterEvent};
4513 use melin_journal::{BufferedWriter, JournalEvent, JournalReader, JournalWrite};
4514 use melin_transport_core::journaled_app::genesis_staging_path;
4515
4516 use super::{
4517 GenesisTarget, ServerConfig, check_promotable, check_recovered_history_holds_genesis,
4518 genesis_target, init_engine, validate_primary_config,
4519 };
4520
4521 type Writer = BufferedWriter<CounterEvent>;
4522
4523 fn config(dir: &Path) -> ServerConfig {
4524 ServerConfig {
4525 journal: dir.join("counter.journal"),
4526 ..ServerConfig::default()
4527 }
4528 }
4529
4530 fn increments(amounts: &[u64]) -> Vec<CounterEvent> {
4531 amounts
4532 .iter()
4533 .map(|&amount| CounterEvent::Increment { amount })
4534 .collect()
4535 }
4536
4537 /// The counter keeps its value private; its snapshot is the value in
4538 /// little-endian.
4539 fn value(app: &Counter) -> u64 {
4540 use melin_app::Application;
4541 let mut buf = Vec::new();
4542 app.snapshot(&mut buf).expect("snapshot to a vec");
4543 u64::from_le_bytes(buf[..8].try_into().expect("8-byte value"))
4544 }
4545
4546 /// Boot `init_engine` on `cfg` with `genesis`: the recovered value and
4547 /// the writer's next sequence.
4548 fn boot(cfg: &ServerConfig, genesis: &[u64]) -> (u64, u64) {
4549 let engine =
4550 init_engine::<Counter, Writer>(cfg, &(), increments(genesis)).expect("init_engine");
4551 (value(&engine.app), engine.writer.next_sequence())
4552 }
4553
4554 /// Whether a boot of `init_engine` on `cfg` with `genesis` began the
4555 /// history — what holds a new primary's clients until its first
4556 /// replica attaches.
4557 fn began_history(cfg: &ServerConfig, genesis: &[u64]) -> bool {
4558 init_engine::<Counter, Writer>(cfg, &(), increments(genesis))
4559 .expect("init_engine")
4560 .began_history
4561 }
4562
4563 #[test]
4564 fn only_a_boot_that_begins_the_history_reports_it() {
4565 let dir = tempfile::tempdir().expect("tempdir");
4566 let cfg = config(dir.path());
4567
4568 // Nothing on disk: a new history, with or without a genesis.
4569 assert!(began_history(&cfg, &[]));
4570 // The journal now exists: every later boot continues it.
4571 assert!(!began_history(&cfg, &[]));
4572
4573 let dir = tempfile::tempdir().expect("tempdir");
4574 let cfg = config(dir.path());
4575 assert!(began_history(&cfg, &[1_000]));
4576 assert!(!began_history(&cfg, &[1_000]));
4577
4578 // An empty journal left by an earlier release's interrupted
4579 // first boot (a v15 header, no recorded genesis length):
4580 // journaling its genesis now begins the history.
4581 let dir = tempfile::tempdir().expect("tempdir");
4582 let cfg = config(dir.path());
4583 drop(Writer::create_continuing(&cfg.journal, 1, [0x15; 32], None).expect("v15 journal"));
4584 assert!(began_history(&cfg, &[1_000]));
4585 assert!(!began_history(&cfg, &[1_000]));
4586 }
4587
4588 /// Every application amount in the journal file at `path`, in order —
4589 /// what a replica's catch-up would copy.
4590 fn journaled_amounts(path: &Path) -> Vec<u64> {
4591 let mut reader = JournalReader::<CounterEvent>::open(path).expect("open journal");
4592 let mut out = Vec::new();
4593 while let Some(entry) = reader.next_entry().expect("read entry") {
4594 if let JournalEvent::App(CounterEvent::Increment { amount }) = entry.event {
4595 out.push(amount);
4596 }
4597 }
4598 out
4599 }
4600
4601 fn anchor(path: &Path) -> [u8; 32] {
4602 melin_journal::segment::read_header_info(path)
4603 .expect("journal header")
4604 .anchor_hash
4605 }
4606
4607 #[test]
4608 fn a_new_journal_is_created_with_its_genesis_on_disk() {
4609 let dir = tempfile::tempdir().expect("tempdir");
4610 let cfg = config(dir.path());
4611
4612 assert_eq!(boot(&cfg, &[1_000, 2_000]), (3_000, 3));
4613 // On disk before `init_engine` returns, so before any replica
4614 // can be served: a replica only ever copies the whole genesis.
4615 assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000]);
4616 assert!(!genesis_staging_path(&cfg.journal).exists());
4617
4618 // Recovered on every later boot, never journaled again.
4619 assert_eq!(boot(&cfg, &[1_000, 2_000]), (3_000, 3));
4620 assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000]);
4621 }
4622
4623 /// Finding 3: the standard upgrade (snapshot, new binary, fresh
4624 /// journal) boots from the snapshot alone, whose state already holds
4625 /// the genesis.
4626 #[test]
4627 fn a_snapshot_only_boot_journals_no_genesis() {
4628 let dir = tempfile::tempdir().expect("tempdir");
4629 let cfg = config(dir.path());
4630 let engine =
4631 init_engine::<Counter, Writer>(&cfg, &(), increments(&[1_000_000])).expect("boot");
4632 save_snapshot(&engine, &cfg.journal.with_extension("snapshot"));
4633 drop(engine);
4634 std::fs::remove_file(&cfg.journal).expect("move the journal aside");
4635
4636 let (value, next_sequence) = boot(&cfg, &[1_000_000]);
4637 assert_eq!(value, 1_000_000, "genesis applied once, from the snapshot");
4638 assert_eq!(
4639 next_sequence, 2,
4640 "the new segment continues the snapshot, empty"
4641 );
4642 assert!(journaled_amounts(&cfg.journal).is_empty());
4643 // The new segment's header carries the lineage's genesis length
4644 // on from the snapshot, the only record of it left on disk.
4645 assert_eq!(genesis_entries(&cfg.journal), Some(1));
4646 }
4647
4648 /// Save a snapshot of a booted engine as the shadow stage would:
4649 /// its state, position, chain, epoch and lineage genesis length.
4650 fn save_snapshot(engine: &super::InitializedEngine<Counter, Writer>, path: &Path) {
4651 melin_transport_core::snapshot::save::<Counter>(
4652 &engine.app,
4653 melin_transport_core::WireSeq::new(engine.writer.next_sequence() - 1),
4654 engine.writer.chain_hash().unwrap_or([0u8; 32]),
4655 engine.recovered_epoch,
4656 engine
4657 .writer
4658 .read_header_info()
4659 .expect("journal header")
4660 .genesis_entries,
4661 path,
4662 )
4663 .expect("save snapshot");
4664 }
4665
4666 /// The genesis length the live segment's header at `path` records.
4667 fn genesis_entries(path: &Path) -> Option<u64> {
4668 melin_journal::segment::read_header_info(path)
4669 .expect("journal header")
4670 .genesis_entries
4671 }
4672
4673 /// Finding 4, crash variant: a first boot that dies part-way through
4674 /// its genesis leaves the partial journal in the staging file, not at
4675 /// the journal's path, so the next boot is still a first boot.
4676 #[test]
4677 fn a_crash_mid_genesis_leaves_no_journal_and_the_next_boot_journals_it_whole() {
4678 let dir = tempfile::tempdir().expect("tempdir");
4679 let cfg = config(dir.path());
4680 let staging = genesis_staging_path(&cfg.journal);
4681 let mut partial = Writer::create(&staging).expect("staging journal");
4682 partial
4683 .append(&JournalEvent::App(CounterEvent::Increment {
4684 amount: 1_000,
4685 }))
4686 .expect("first genesis event");
4687 drop(partial);
4688 assert!(!cfg.journal.exists());
4689
4690 assert_eq!(boot(&cfg, &[1_000, 2_000, 4_000]), (7_000, 4));
4691 assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000, 4_000]);
4692 assert!(!staging.exists(), "the partial journal is discarded");
4693 assert_eq!(genesis_entries(&cfg.journal), Some(3));
4694 }
4695
4696 /// An empty journal whose header records a genesis length is never
4697 /// re-genesised: the length is fixed when the history began. A
4698 /// history begun with no genesis (`Some(0)`) stays one, whatever
4699 /// genesis is configured since; a replica's copy that received none
4700 /// of its primary's genesis (`Some(n)`, `n > 0`) is refused as the
4701 /// partial copy it is, rather than forked with this node's genesis.
4702 #[test]
4703 fn an_empty_journal_with_a_recorded_length_is_not_rewritten() {
4704 let dir = tempfile::tempdir().expect("tempdir");
4705 let cfg = config(dir.path());
4706 drop(Writer::create(&cfg.journal).expect("empty journal"));
4707 assert_eq!(genesis_entries(&cfg.journal), Some(0));
4708 let before = anchor(&cfg.journal);
4709
4710 assert_eq!(boot(&cfg, &[1_000_000]), (0, 1));
4711 assert!(journaled_amounts(&cfg.journal).is_empty());
4712 assert_eq!(anchor(&cfg.journal), before);
4713 assert_eq!(genesis_entries(&cfg.journal), Some(0));
4714
4715 std::fs::remove_file(&cfg.journal).expect("move the journal aside");
4716 drop(
4717 Writer::create_continuing(&cfg.journal, 1, [0x2C; 32], Some(2))
4718 .expect("a replica's empty copy"),
4719 );
4720 for configured in [&[][..], &[1_000, 2_000], &[1_000, 2_000, 4_000]] {
4721 let refused = init_engine::<Counter, Writer>(&cfg, &(), increments(configured))
4722 .err()
4723 .expect("an empty copy of a genesis is refused");
4724 assert!(
4725 refused.to_string().contains("refusing to start"),
4726 "{configured:?}: {refused}"
4727 );
4728 }
4729 assert!(journaled_amounts(&cfg.journal).is_empty());
4730 assert_eq!(anchor(&cfg.journal), [0x2C; 32]);
4731 assert_eq!(genesis_entries(&cfg.journal), Some(2));
4732 }
4733
4734 /// Finding 4, as an earlier release left it: the journal was created
4735 /// before a refused boot journaled anything — a v15 header, genesis
4736 /// length unknown. Nothing was served from it, so it gets its
4737 /// genesis, under the same anchor (a replica that copied the empty
4738 /// journal still chains to it), and the replacement records the
4739 /// genesis it journals.
4740 #[test]
4741 fn an_empty_old_format_journal_gets_its_genesis_and_its_length() {
4742 let dir = tempfile::tempdir().expect("tempdir");
4743 let cfg = config(dir.path());
4744 drop(Writer::create_continuing(&cfg.journal, 1, [0x15; 32], None).expect("v15 journal"));
4745 assert_eq!(genesis_entries(&cfg.journal), None);
4746
4747 assert_eq!(boot(&cfg, &[1_000, 2_000]), (3_000, 3));
4748 assert_eq!(anchor(&cfg.journal), [0x15; 32]);
4749 assert_eq!(genesis_entries(&cfg.journal), Some(2));
4750 }
4751
4752 /// A stale staging file is swept by a boot that journals no genesis
4753 /// too, and the history beside it is untouched.
4754 #[test]
4755 fn a_stale_staging_file_is_swept_on_any_boot() {
4756 let dir = tempfile::tempdir().expect("tempdir");
4757 let cfg = config(dir.path());
4758 assert_eq!(boot(&cfg, &[1_000]), (1_000, 2));
4759 let staging = genesis_staging_path(&cfg.journal);
4760 drop(Writer::create(&staging).expect("orphaned staging file"));
4761
4762 assert_eq!(boot(&cfg, &[1_000]), (1_000, 2));
4763 assert!(!staging.exists());
4764 assert_eq!(journaled_amounts(&cfg.journal), vec![1_000]);
4765 }
4766
4767 /// With nothing to journal, an empty journal stays the file it was.
4768 #[test]
4769 fn an_empty_journal_without_genesis_is_left_alone() {
4770 let dir = tempfile::tempdir().expect("tempdir");
4771 let cfg = config(dir.path());
4772 drop(Writer::create(&cfg.journal).expect("empty journal"));
4773 let before = anchor(&cfg.journal);
4774
4775 assert_eq!(boot(&cfg, &[]), (0, 1));
4776 assert_eq!(anchor(&cfg.journal), before);
4777 assert!(!genesis_staging_path(&cfg.journal).exists());
4778 }
4779
4780 /// A journal with history is recovered as it is, whatever the
4781 /// configured genesis: its header records the genesis it began with
4782 /// (none, here), and the configuration plays no part.
4783 #[test]
4784 fn a_journal_with_history_never_gets_genesis() {
4785 let dir = tempfile::tempdir().expect("tempdir");
4786 let cfg = config(dir.path());
4787 let mut writer = Writer::create(&cfg.journal).expect("journal");
4788 writer
4789 .append(&JournalEvent::App(CounterEvent::Increment { amount: 5 }))
4790 .expect("one entry");
4791 drop(writer);
4792
4793 assert_eq!(boot(&cfg, &[1_000_000]), (5, 2));
4794 assert_eq!(journaled_amounts(&cfg.journal), vec![5]);
4795 }
4796
4797 /// A replica's journal as its primary's death left it: the header
4798 /// records the lineage's genesis of three entries (learned from the
4799 /// primary), and only the first two arrived.
4800 fn partial_replica_copy(path: &Path) {
4801 let mut writer =
4802 Writer::create_continuing(path, 1, [0x2B; 32], Some(3)).expect("replica journal");
4803 for amount in [1_000, 2_000] {
4804 writer
4805 .append(&JournalEvent::App(CounterEvent::Increment { amount }))
4806 .expect("genesis prefix");
4807 }
4808 }
4809
4810 /// A history holding part of the lineage's genesis — a replica's
4811 /// partial copy started on a primary's flags — is refused, from the
4812 /// journal alone or from a snapshot, and left as it was. Whatever
4813 /// genesis the node is configured with: none, the same, or more.
4814 #[test]
4815 fn a_genesis_prefix_is_refused_at_boot() {
4816 let dir = tempfile::tempdir().expect("tempdir");
4817 let cfg = config(dir.path());
4818 partial_replica_copy(&cfg.journal);
4819
4820 for configured in [&[][..], &[1_000, 2_000, 4_000], &[1, 2, 3, 4, 5]] {
4821 let refused = init_engine::<Counter, Writer>(&cfg, &(), increments(configured))
4822 .err()
4823 .expect("a genesis prefix is refused");
4824 assert!(
4825 refused.to_string().contains("refusing to start"),
4826 "{configured:?}: {refused}"
4827 );
4828 }
4829 assert_eq!(journaled_amounts(&cfg.journal), vec![1_000, 2_000]);
4830
4831 // The same history behind a snapshot: still a prefix, whose
4832 // snapshot records the genesis length too.
4833 let snapshot = cfg.journal.with_extension("snapshot");
4834 melin_transport_core::JournaledApp::<Counter, Writer>::recover(
4835 Counter::default(),
4836 &cfg.journal,
4837 )
4838 .expect("recover the partial copy")
4839 .save_snapshot(&snapshot)
4840 .expect("save snapshot");
4841 init_engine::<Counter, Writer>(&cfg, &(), Vec::new())
4842 .err()
4843 .expect("snapshot + journal holding a prefix is refused");
4844 std::fs::remove_file(&cfg.journal).expect("move the journal aside");
4845 let refused = init_engine::<Counter, Writer>(&cfg, &(), Vec::new())
4846 .err()
4847 .expect("a snapshot holding a prefix is refused");
4848 assert!(
4849 refused.to_string().contains("refusing to start"),
4850 "{refused}"
4851 );
4852 }
4853
4854 /// A genesis configuration grown since the history began changes
4855 /// nothing: the history's own header says how long its genesis was,
4856 /// and a short history holding all of it boots.
4857 #[test]
4858 fn a_grown_genesis_configuration_does_not_refuse_a_short_history() {
4859 let dir = tempfile::tempdir().expect("tempdir");
4860 let cfg = config(dir.path());
4861 assert_eq!(boot(&cfg, &[1_000]), (1_000, 2));
4862 assert_eq!(genesis_entries(&cfg.journal), Some(1));
4863
4864 assert_eq!(boot(&cfg, &[1_000, 2_000, 4_000, 8_000]), (1_000, 2));
4865 assert_eq!(journaled_amounts(&cfg.journal), vec![1_000]);
4866 assert_eq!(genesis_entries(&cfg.journal), Some(1));
4867 }
4868
4869 /// A journal written by an earlier release records no genesis
4870 /// length: it boots as it did there, with no check — even when it is
4871 /// shorter than the configured genesis — stays a v15 lineage, and a
4872 /// promotion from it is not checked either. The same from a v2
4873 /// snapshot alone, which records no length either.
4874 #[test]
4875 fn an_old_format_journal_boots_without_the_check() {
4876 let dir = tempfile::tempdir().expect("tempdir");
4877 let cfg = config(dir.path());
4878 let mut writer =
4879 Writer::create_continuing(&cfg.journal, 1, [0x15; 32], None).expect("v15 journal");
4880 writer
4881 .append(&JournalEvent::App(CounterEvent::Increment { amount: 7 }))
4882 .expect("one entry");
4883 drop(writer);
4884
4885 let engine = init_engine::<Counter, Writer>(&cfg, &(), increments(&[1, 2, 3]))
4886 .expect("an unknown genesis length is not checked");
4887 assert_eq!(value(&engine.app), 7);
4888 assert_eq!(engine.writer.next_sequence(), 2);
4889 assert_eq!(genesis_entries(&cfg.journal), None, "still a v15 lineage");
4890 check_promotable(&engine.writer).expect("promotion is not checked either");
4891
4892 // Snapshot-only: the v2 snapshot (unknown length) starts a v15
4893 // segment, and the boot is not checked.
4894 save_snapshot(&engine, &cfg.journal.with_extension("snapshot"));
4895 drop(engine);
4896 std::fs::remove_file(&cfg.journal).expect("move the journal aside");
4897 assert_eq!(boot(&cfg, &[1, 2, 3]), (7, 2));
4898 assert_eq!(genesis_entries(&cfg.journal), None);
4899 }
4900
4901 #[test]
4902 fn genesis_target_starts_only_an_empty_history() {
4903 let genesis = increments(&[1]);
4904 let none: Vec<CounterEvent> = Vec::new();
4905 let queries = vec![CounterEvent::GetValue];
4906 // (archives, next_sequence, recorded length, genesis) → target
4907 type Case<'a> = (bool, u64, Option<u64>, &'a [CounterEvent], GenesisTarget);
4908 let cases: [Case<'_>; 8] = [
4909 (false, 1, None, &genesis, GenesisTarget::EmptyJournal),
4910 (false, 2, None, &genesis, GenesisTarget::None),
4911 // An empty live segment after a rotation still has history.
4912 (true, 1, None, &genesis, GenesisTarget::None),
4913 (true, 5, None, &genesis, GenesisTarget::None),
4914 (false, 1, None, &none, GenesisTarget::None),
4915 (false, 1, None, &queries, GenesisTarget::None),
4916 // A recorded length is fixed: a history begun with no
4917 // genesis, or a replica's empty copy of one.
4918 (false, 1, Some(0), &genesis, GenesisTarget::None),
4919 (false, 1, Some(3), &genesis, GenesisTarget::None),
4920 ];
4921 for (archives, next_sequence, recorded, genesis, expected) in cases {
4922 assert_eq!(
4923 genesis_target(archives, next_sequence, recorded, genesis),
4924 expected,
4925 "archives={archives} next_sequence={next_sequence} recorded={recorded:?} \
4926 genesis={genesis:?}"
4927 );
4928 }
4929 }
4930
4931 /// Finding 4, configuration variant: a refused configuration is
4932 /// refused by the check `run_impl` runs before the journal is
4933 /// touched.
4934 #[test]
4935 fn standalone_needs_the_disk_policy() {
4936 let refused = ServerConfig {
4937 standalone: true,
4938 ..ServerConfig::default()
4939 };
4940 let err = validate_primary_config(&refused).expect_err("default policy with --standalone");
4941 assert!(err.to_string().contains("--ack-policy disk"), "{err}");
4942 let accepted = ServerConfig {
4943 standalone: true,
4944 ack_policy: crate::ack_policy::AckPolicy::Disk,
4945 ..ServerConfig::default()
4946 };
4947 validate_primary_config(&accepted).expect("standalone under disk");
4948 }
4949
4950 /// A replica that copied only part of the lineage's genesis — an
4951 /// empty journal (its primary died right after the handshake) or a
4952 /// prefix (died mid catch-up) — is refused promotion; one holding all
4953 /// of it, with or without history after it, is promoted. The length
4954 /// comes from the replica's journal header alone: no configuration
4955 /// reaches the check.
4956 #[test]
4957 fn promotion_needs_the_whole_genesis() {
4958 let dir = tempfile::tempdir().expect("tempdir");
4959 let path = dir.path().join("replica.journal");
4960 let genesis = increments(&[1_000, 2_000, 4_000]);
4961
4962 let mut writer =
4963 Writer::create_continuing(&path, 1, [0x2B; 32], Some(3)).expect("replica journal");
4964 let refused = check_promotable(&writer).expect_err("an empty history");
4965 assert!(
4966 refused.to_string().contains("refusing promotion"),
4967 "{refused}"
4968 );
4969 for event in &genesis[..2] {
4970 writer
4971 .append(&JournalEvent::App(*event))
4972 .expect("genesis prefix");
4973 }
4974 check_promotable(&writer).expect_err("a genesis prefix");
4975
4976 writer
4977 .append(&JournalEvent::App(genesis[2]))
4978 .expect("last genesis event");
4979 check_promotable(&writer).expect("the whole genesis");
4980 writer
4981 .append(&JournalEvent::App(CounterEvent::Increment { amount: 5 }))
4982 .expect("client history");
4983 check_promotable(&writer).expect("genesis and history after it");
4984
4985 // No genesis: any history, even an empty one, is promotable.
4986 let none = dir.path().join("none.journal");
4987 check_promotable(&Writer::create(&none).expect("journal")).expect("nothing to hold");
4988 // Unknown (a lineage begun before the length was recorded): not
4989 // checked, as before.
4990 let unknown = dir.path().join("unknown.journal");
4991 check_promotable(
4992 &Writer::create_continuing(&unknown, 1, [0x15; 32], None).expect("journal"),
4993 )
4994 .expect("unknown is not checked");
4995 // A history continuing a snapshot counts the snapshot's entries.
4996 let continued = dir.path().join("continued.journal");
4997 check_promotable(
4998 &Writer::create_continuing(&continued, 10, [0x2B; 32], Some(3)).expect("journal"),
4999 )
5000 .expect("snapshot covers genesis");
5001 }
5002
5003 /// The boot check draws the same line as the promotion check, and
5004 /// skips an unknown length.
5005 #[test]
5006 fn the_boot_check_needs_the_whole_genesis() {
5007 check_recovered_history_holds_genesis(1, Some(3)).expect_err("empty");
5008 check_recovered_history_holds_genesis(3, Some(3)).expect_err("a prefix");
5009 check_recovered_history_holds_genesis(4, Some(3)).expect("the whole genesis");
5010 check_recovered_history_holds_genesis(100, Some(3)).expect("and history after it");
5011 check_recovered_history_holds_genesis(1, Some(0)).expect("no genesis");
5012 check_recovered_history_holds_genesis(1, None).expect("unknown: not checked");
5013 check_recovered_history_holds_genesis(3, None).expect("unknown: not checked");
5014 }
5015}
5016
5017/// Startup-time validation of the DPDK CLI surface.
5018///
5019/// The value these pin is *when* a bad flag is caught. `--dpdk-peer-mac`
5020/// is consumed inside the replica's reconnect loop, so the failure has to
5021/// surface here — during config translation, before EAL init and before
5022/// any thread exists — rather than as a panic in a node that is already
5023/// serving.
5024#[cfg(all(test, feature = "dpdk"))]
5025mod dpdk_config_tests {
5026 use super::{ServerConfig, dpdk_config_from};
5027
5028 #[test]
5029 fn a_supplied_peer_mac_reaches_the_transport_config() {
5030 let cfg = ServerConfig {
5031 dpdk_peer_mac: Some("0c:42:a1:5b:2e:80".into()),
5032 ..Default::default()
5033 };
5034 let dpdk = dpdk_config_from(&cfg).expect("valid config");
5035 assert_eq!(dpdk.peer_mac, Some([0x0c, 0x42, 0xa1, 0x5b, 0x2e, 0x80]));
5036 }
5037
5038 #[test]
5039 fn an_absent_peer_mac_leaves_the_transport_to_derive_one() {
5040 let cfg = ServerConfig::default();
5041 let dpdk = dpdk_config_from(&cfg).expect("valid config");
5042 assert_eq!(dpdk.peer_mac, None);
5043 }
5044
5045 #[test]
5046 fn a_malformed_peer_mac_fails_config_translation() {
5047 let cfg = ServerConfig {
5048 dpdk_peer_mac: Some("0c:42:a1:5b:2e".into()),
5049 ..Default::default()
5050 };
5051 let err = dpdk_config_from(&cfg).expect_err("a 5-octet MAC must be rejected");
5052 assert!(err.contains("--dpdk-peer-mac"), "{err}");
5053 }
5054
5055 #[test]
5056 fn a_malformed_gateway_mac_fails_config_translation() {
5057 // The same guarantee for the pre-existing flag, which used to
5058 // panic out of this function.
5059 let cfg = ServerConfig {
5060 dpdk_gateway_mac: Some("not-a-mac".into()),
5061 ..Default::default()
5062 };
5063 let err = dpdk_config_from(&cfg).expect_err("a non-MAC must be rejected");
5064 assert!(err.contains("--dpdk-gateway-mac"), "{err}");
5065 }
5066
5067 #[test]
5068 fn a_malformed_ip_names_the_flag_it_came_from() {
5069 let cfg = ServerConfig {
5070 dpdk_peer_ip: Some("10.0.0".into()),
5071 ..Default::default()
5072 };
5073 let err = dpdk_config_from(&cfg).expect_err("a truncated IPv4 must be rejected");
5074 assert!(err.contains("--dpdk-peer-ip"), "{err}");
5075 }
5076
5077 #[test]
5078 fn a_valid_configuration_translates() {
5079 let cfg = ServerConfig {
5080 dpdk_gateway: Some("10.0.0.254".into()),
5081 dpdk_gateway_mac: Some("0c:42:a1:5b:2e:80".into()),
5082 ..Default::default()
5083 };
5084 let dpdk = dpdk_config_from(&cfg).expect("valid config");
5085 assert_eq!(dpdk.gateway_mac, Some([0x0c, 0x42, 0xa1, 0x5b, 0x2e, 0x80]));
5086 }
5087}