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