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