This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 4c30a88a0 feat(server-ng): add cluster.heartbeat_timeout for VSR 
liveness window (#3738)
4c30a88a0 is described below

commit 4c30a88a0f9eb811a6263d83636e5c8ebed06e10
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Thu Jul 23 15:49:14 2026 +0200

    feat(server-ng): add cluster.heartbeat_timeout for VSR liveness window 
(#3738)
---
 core/configs/src/lib.rs                            |   4 +-
 core/configs/src/server_ng_config/cluster.rs       | 619 +++++++++++++++++++++
 core/configs/src/server_ng_config/defaults.rs      |  57 +-
 core/configs/src/server_ng_config/mod.rs           |   1 +
 core/configs/src/server_ng_config/server_ng.rs     |   2 +-
 core/consensus/src/impls.rs                        |   8 +
 core/consensus/src/lib.rs                          |   1 +
 core/consensus/src/vsr_timeout.rs                  |  15 +-
 core/integration/tests/server/message_retrieval.rs |  13 +
 core/server-ng/config.toml                         |   7 +
 core/server-ng/src/bootstrap.rs                    |  42 +-
 core/server-ng/src/cluster_meta.rs                 |   2 +-
 core/server-ng/src/http.rs                         |   2 +-
 core/server-ng/src/http/error.rs                   |   2 +-
 core/server-ng/src/partition_helpers.rs            |   1 +
 15 files changed, 758 insertions(+), 18 deletions(-)

diff --git a/core/configs/src/lib.rs b/core/configs/src/lib.rs
index c9322f002..5c82dbd3a 100644
--- a/core/configs/src/lib.rs
+++ b/core/configs/src/lib.rs
@@ -30,6 +30,6 @@ pub use server_config::{
     tcp, validators, websocket,
 };
 pub use server_ng_config::{
-    COMPONENT_NG, message_bus, metadata as ng_metadata, quic as ng_quic, 
server_ng,
-    sharding as ng_sharding, tcp as ng_tcp, websocket as ng_websocket,
+    COMPONENT_NG, cluster as ng_cluster, message_bus, metadata as ng_metadata, 
quic as ng_quic,
+    server_ng, sharding as ng_sharding, tcp as ng_tcp, websocket as 
ng_websocket,
 };
diff --git a/core/configs/src/server_ng_config/cluster.rs 
b/core/configs/src/server_ng_config/cluster.rs
new file mode 100644
index 000000000..b8dc2d7f4
--- /dev/null
+++ b/core/configs/src/server_ng_config/cluster.rs
@@ -0,0 +1,619 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Server-ng cluster schema.
+//!
+//! Field shape mirrors the legacy [`crate::cluster::ClusterConfig`]
+//! plus the ng-only `heartbeat_timeout` knob; the type is forked into
+//! `server_ng_config` so server-ng can evolve its cluster surface
+//! (VSR consensus tunables) independently of the legacy server.
+
+use super::defaults::SERVER_NG_CONFIG;
+use crate::ConfigurationError;
+use configs::ConfigEnv;
+use iggy_common::{IggyDuration, Validatable};
+use serde::{Deserialize, Serialize};
+use serde_with::{DisplayFromStr, serde_as};
+use std::time::Duration;
+
+/// The primary heartbeats roughly every second (`PING_TICKS`); a window at
+/// or below one ping interval would elect on every scheduling hiccup.
+pub const MIN_CLUSTER_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(2);
+
+/// Length floor for the replica-auth PSK, in raw bytes. The 32-byte MAC key
+/// is KDF-derived from these bytes at use-site, so any encoding clearing this
+/// length is accepted.
+const MIN_SHARED_SECRET_LEN: usize = 32;
+
+/// serde fallback for configs written before the field existed; the value
+/// itself lives in `core/server-ng/config.toml` like every other default.
+fn default_heartbeat_timeout() -> IggyDuration {
+    SERVER_NG_CONFIG.cluster.heartbeat_timeout.parse().unwrap()
+}
+
+#[serde_as]
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
+#[serde(deny_unknown_fields)]
+pub struct ClusterConfig {
+    pub enabled: bool,
+    pub name: String,
+    /// Backup-side liveness window for a plane's primary. A replica that
+    /// sees no primary traffic for this long starts a view change
+    /// (`normal_heartbeat_timeout`). Raise it on oversubscribed hosts where
+    /// scheduling stalls fake primary death; 
sub-`MIN_CLUSTER_HEARTBEAT_TIMEOUT`
+    /// values (including the `0` / `disabled` / `unlimited` sentinels, which
+    /// all parse to zero) are rejected at boot.
+    #[serde(default = "default_heartbeat_timeout")]
+    #[serde_as(as = "DisplayFromStr")]
+    #[config_env(leaf)]
+    pub heartbeat_timeout: IggyDuration,
+    /// Full roster of cluster members. Intended to be byte-identical across
+    /// every node so operators ship one config. The running node's identity
+    /// is supplied out-of-band via the `--replica-id` CLI flag, which
+    /// selects the entry in this list that describes the current node.
+    #[serde(default)]
+    pub nodes: Vec<ClusterNodeConfig>,
+    /// Replica-to-replica authentication settings (PSK + BLAKE3 handshake).
+    #[serde(default)]
+    pub auth: ClusterAuthConfig,
+    /// Replica-to-replica TLS settings for the consensus (`tcp_replica`) port.
+    #[serde(default)]
+    pub tls: ClusterTlsConfig,
+}
+
+/// Replica-to-replica authentication for the consensus (`tcp_replica`) port.
+#[derive(Debug, Default, Deserialize, Serialize, Clone, ConfigEnv)]
+#[serde(deny_unknown_fields)]
+pub struct ClusterAuthConfig {
+    /// When true, every replica peer must complete the authenticated handshake
+    /// or be rejected, and [`Self::shared_secret`] is mandatory. When false
+    /// (default) the replica handshake stays in legacy unauthenticated mode 
and
+    /// `shared_secret` is not used for authentication. A configured non-empty
+    /// `shared_secret` must still meet the 32-byte minimum whenever the 
cluster
+    /// is enabled (a short value fails boot even with auth off).
+    ///
+    /// Enabling auth is a coordinated-restart change, and not the only one: 
the
+    /// consensus `cluster_id` is derived from `ClusterConfig::name`
+    /// unconditionally, so a mixed-version roster fails to connect regardless 
of
+    /// this flag. Flip every node in one restart.
+    #[serde(default)]
+    pub enabled: bool,
+    /// Cluster-wide pre-shared key for replica-to-replica authentication.
+    ///
+    /// At least 32 bytes of CSPRNG output, byte-identical across every node.
+    /// Provisioned out-of-band, normally via `IGGY_CLUSTER_AUTH_SHARED_SECRET`
+    /// rather than the on-disk config.
+    // skip_serializing keeps the PSK out of the runtime `current_config.toml`
+    // (and the `ServerConfig` diagnostic snapshot that cats it). The live
+    // secret is read from env / on-disk config at boot, never from the
+    // snapshot, so it must never be persisted there. Deserialize is retained.
+    #[serde(default, skip_serializing)]
+    #[config_env(secret)]
+    pub shared_secret: String,
+}
+
+/// Replica-to-replica TLS for the consensus (`tcp_replica`) port.
+///
+/// Mirrors the legacy [`crate::tcp::TcpTlsConfig`] shape plus `ca_file`:
+/// the replica plane DIALS its peers (a TLS client role the
+/// client-facing server plane never has), so the dialer needs a trust
+/// anchor to verify the acceptor's certificate against.
+#[derive(Debug, Default, Deserialize, Serialize, Clone, ConfigEnv)]
+#[serde(deny_unknown_fields)]
+pub struct ClusterTlsConfig {
+    /// When true every replica connection is wrapped in TLS (1.3 only)
+    /// before the replica handshake runs. Requires `cluster.auth.enabled`:
+    /// TLS carries no client certificates, so it authenticates the
+    /// acceptor only; the PSK handshake authenticates the peer while TLS
+    /// supplies confidentiality. Enabling is a coordinated-restart
+    /// change: a TLS dialer cannot talk to a plaintext acceptor or vice
+    /// versa. Flip every node in one restart.
+    #[serde(default)]
+    pub enabled: bool,
+    /// When true the node auto-generates a self-signed certificate at
+    /// boot and the dialer accepts ANY peer certificate. With the
+    /// default `false`, `cert_file` / `key_file` / `ca_file` are all
+    /// required.
+    #[serde(default)]
+    pub self_signed: bool,
+    /// PEM certificate chain presented by this node's acceptor side.
+    #[serde(default)]
+    pub cert_file: String,
+    /// PEM private key matching `cert_file`.
+    #[serde(default)]
+    pub key_file: String,
+    /// PEM trust anchor(s) the dialer verifies peer certificates
+    /// against. Unused when `self_signed` is true.
+    #[serde(default)]
+    pub ca_file: String,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
+pub struct ClusterNodeConfig {
+    pub name: String,
+    pub ip: String,
+    /// Numeric replica ID for VSR consensus (0-based).
+    ///
+    /// Must be unique across [`ClusterConfig::nodes`] and strictly less than
+    /// `nodes.len()`. Validated by [`ClusterConfig::validate`].
+    pub replica_id: u8,
+    pub ports: TransportPorts,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone, Default, ConfigEnv)]
+pub struct TransportPorts {
+    pub tcp: Option<u16>,
+    pub quic: Option<u16>,
+    pub http: Option<u16>,
+    pub websocket: Option<u16>,
+    /// Dedicated port for replica-to-replica consensus traffic.
+    pub tcp_replica: Option<u16>,
+}
+
+impl Validatable<ConfigurationError> for ClusterConfig {
+    fn validate(&self) -> Result<(), ConfigurationError> {
+        if !self.enabled {
+            return Ok(());
+        }
+
+        if self.name.trim().is_empty() {
+            eprintln!("Invalid cluster configuration: cluster name cannot be 
empty");
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+
+        // `0` / `disabled` / `unlimited` all parse to a zero duration and
+        // land here too: there is no way to switch the liveness window off.
+        if self.heartbeat_timeout.get_duration() < 
MIN_CLUSTER_HEARTBEAT_TIMEOUT {
+            eprintln!(
+                "Invalid cluster configuration: cluster.heartbeat_timeout '{}' 
must be at least {}s \
+                 (the primary heartbeats every second; a shorter window elects 
on every hiccup)",
+                self.heartbeat_timeout,
+                MIN_CLUSTER_HEARTBEAT_TIMEOUT.as_secs()
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+
+        if self.nodes.is_empty() {
+            eprintln!(
+                "Invalid cluster configuration: cluster.nodes must contain at 
least one entry when cluster is enabled"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+
+        // VSR needs every replica to have a stable, unique id strictly
+        // less than the total replica count. Duplicate ids would split the
+        // cluster into two replicas claiming the same slot; out-of-range
+        // ids never win a primary election. Both are unrecoverable at
+        // runtime - fail fast at startup.
+        let total_replicas = u8::try_from(self.nodes.len()).map_err(|_| {
+            eprintln!("Invalid cluster configuration: more than 255 replicas 
is unsupported");
+            ConfigurationError::InvalidConfigurationValue
+        })?;
+
+        let mut seen_ids = std::collections::HashSet::new();
+        let mut seen_names = std::collections::HashSet::new();
+        let mut used_endpoints = std::collections::HashSet::new();
+
+        for node in &self.nodes {
+            if node.name.trim().is_empty() {
+                eprintln!("Invalid cluster configuration: node name cannot be 
empty");
+                return Err(ConfigurationError::InvalidConfigurationValue);
+            }
+
+            if node.ip.trim().is_empty() {
+                eprintln!(
+                    "Invalid cluster configuration: IP cannot be empty for 
node '{}'",
+                    node.name
+                );
+                return Err(ConfigurationError::InvalidConfigurationValue);
+            }
+
+            if !seen_names.insert(node.name.clone()) {
+                eprintln!(
+                    "Invalid cluster configuration: duplicate node name '{}' 
found",
+                    node.name
+                );
+                return Err(ConfigurationError::InvalidConfigurationValue);
+            }
+
+            if node.replica_id >= total_replicas {
+                eprintln!(
+                    "Invalid cluster configuration: replica_id {} for node 
'{}' must be < total replica count {total_replicas}",
+                    node.replica_id, node.name
+                );
+                return Err(ConfigurationError::InvalidConfigurationValue);
+            }
+
+            if !seen_ids.insert(node.replica_id) {
+                eprintln!(
+                    "Invalid cluster configuration: duplicate replica_id {} 
(two nodes claim the same slot)",
+                    node.replica_id
+                );
+                return Err(ConfigurationError::InvalidConfigurationValue);
+            }
+
+            let port_list = [
+                ("TCP", node.ports.tcp),
+                ("QUIC", node.ports.quic),
+                ("HTTP", node.ports.http),
+                ("WebSocket", node.ports.websocket),
+                ("TCP_REPLICA", node.ports.tcp_replica),
+            ];
+
+            for (name, port_opt) in &port_list {
+                if let Some(port) = port_opt {
+                    if *port == 0 {
+                        eprintln!(
+                            "Invalid cluster configuration: {} port cannot be 
0 for node '{}'",
+                            name, node.name
+                        );
+                        return 
Err(ConfigurationError::InvalidConfigurationValue);
+                    }
+
+                    let endpoint = format!("{}:{}", node.ip, port);
+                    if !used_endpoints.insert(endpoint.clone()) {
+                        eprintln!(
+                            "Invalid cluster configuration: port conflict - 
{endpoint} is already bound (node '{}', transport {name})",
+                            node.name
+                        );
+                        return 
Err(ConfigurationError::InvalidConfigurationValue);
+                    }
+                }
+            }
+        }
+
+        // Replica-auth PSK (only reached when the cluster is enabled; the 
early
+        // return above skips these while it is disabled). When auth is enabled
+        // the key is mandatory; any configured key must clear the length 
floor -
+        // a typo guard that fires with auth off too, though only while the
+        // cluster itself is enabled.
+        let secret_len = self.auth.shared_secret.len();
+        if self.auth.enabled && self.auth.shared_secret.is_empty() {
+            eprintln!(
+                "Invalid cluster configuration: cluster.auth.shared_secret 
must be set when cluster.auth.enabled is true"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+        if !self.auth.shared_secret.is_empty() && secret_len < 
MIN_SHARED_SECRET_LEN {
+            eprintln!(
+                "Invalid cluster configuration: cluster.auth.shared_secret 
must be >= {MIN_SHARED_SECRET_LEN} bytes"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+
+        // Replica TLS. Both cert modes run one-directional TLS (no client
+        // certificate anywhere), so TLS only authenticates the acceptor to
+        // the dialer; peer authentication comes solely from the PSK
+        // handshake. Without it any TLS-capable host could register as a
+        // replica - require auth in both modes. CA mode (the default)
+        // additionally needs all three PEM paths: cert/key for this node's
+        // acceptor side, ca_file as the dialer's trust anchor.
+        if self.tls.enabled {
+            if !self.auth.enabled {
+                eprintln!(
+                    "Invalid cluster configuration: cluster.tls.enabled = true 
requires cluster.auth.enabled = true (TLS authenticates the acceptor only; the 
PSK handshake authenticates the peer)"
+                );
+                return Err(ConfigurationError::InvalidConfigurationValue);
+            }
+            if !self.tls.self_signed {
+                for (field, value) in [
+                    ("cert_file", &self.tls.cert_file),
+                    ("key_file", &self.tls.key_file),
+                    ("ca_file", &self.tls.ca_file),
+                ] {
+                    if value.trim().is_empty() {
+                        eprintln!(
+                            "Invalid cluster configuration: 
cluster.tls.{field} must be set when cluster.tls.enabled = true and self_signed 
= false"
+                        );
+                        return 
Err(ConfigurationError::InvalidConfigurationValue);
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn shared_secret_is_never_serialized() {
+        // Regression guard: the runtime current_config.toml (and the
+        // ServerConfig diagnostic snapshot that cats it) are produced by
+        // serializing this struct, so the PSK must not survive serialize.
+        // skip_serializing is format-agnostic, so a JSON dump proves the toml
+        // path too.
+        let config = ClusterConfig {
+            enabled: true,
+            name: "iggy-cluster".to_owned(),
+            heartbeat_timeout: default_heartbeat_timeout(),
+            nodes: Vec::new(),
+            auth: ClusterAuthConfig {
+                enabled: true,
+                shared_secret: "current-psk-MUST-NOT-be-persisted".to_owned(),
+            },
+            tls: ClusterTlsConfig::default(),
+        };
+        let serialized = serde_json::to_string(&config).expect("serialize 
cluster config");
+        assert!(
+            !serialized.contains("MUST-NOT-be-persisted"),
+            "PSK leaked into serialized config: {serialized}"
+        );
+        assert!(
+            !serialized.contains("shared_secret"),
+            "shared_secret field present in serialized config: {serialized}"
+        );
+    }
+}
+
+#[cfg(test)]
+mod cluster_validate_tests {
+    use super::*;
+
+    fn node(name: &str, id: u8) -> ClusterNodeConfig {
+        ClusterNodeConfig {
+            name: name.to_string(),
+            ip: "127.0.0.1".to_string(),
+            replica_id: id,
+            ports: TransportPorts::default(),
+        }
+    }
+
+    fn cfg(nodes: Vec<ClusterNodeConfig>) -> ClusterConfig {
+        ClusterConfig {
+            enabled: true,
+            name: "iggy-cluster".to_string(),
+            heartbeat_timeout: default_heartbeat_timeout(),
+            nodes,
+            auth: ClusterAuthConfig::default(),
+            tls: ClusterTlsConfig::default(),
+        }
+    }
+
+    #[test]
+    fn validate_rejects_sub_minimum_heartbeat_timeout() {
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.heartbeat_timeout = IggyDuration::new(Duration::from_millis(500));
+        assert!(c.validate().is_err());
+        // The "disabled" / "unlimited" sentinels collapse to zero and must
+        // be rejected the same way.
+        c.heartbeat_timeout = IggyDuration::new(Duration::ZERO);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_empty_nodes() {
+        let c = cfg(vec![]);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_duplicate_replica_ids() {
+        let c = cfg(vec![node("n1", 0), node("n2", 0)]);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_duplicate_names() {
+        let c = cfg(vec![node("n1", 0), node("n1", 1)]);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_out_of_range_replica_id() {
+        // 2 nodes total, so id 2 is out of range.
+        let c = cfg(vec![node("n1", 0), node("n2", 2)]);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_accepts_unique_contiguous_replica_ids() {
+        let c = cfg(vec![node("n1", 0), node("n2", 1), node("n3", 2)]);
+        assert!(c.validate().is_ok());
+    }
+
+    #[test]
+    fn validate_skips_checks_when_disabled() {
+        let mut c = cfg(vec![]);
+        c.enabled = false;
+        assert!(c.validate().is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_duplicate_tcp_replica_port() {
+        let ports = TransportPorts {
+            tcp: None,
+            quic: None,
+            http: None,
+            websocket: None,
+            tcp_replica: Some(9090),
+        };
+        let mut n1 = node("n1", 0);
+        n1.ports = ports.clone();
+        let mut n2 = node("n2", 1);
+        n2.ports = ports;
+        let c = cfg(vec![n1, n2]);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_cross_transport_port_reuse() {
+        let mut n1 = node("n1", 0);
+        n1.ports = TransportPorts {
+            tcp: Some(8090),
+            quic: None,
+            http: Some(8090),
+            websocket: None,
+            tcp_replica: None,
+        };
+        let c = cfg(vec![n1]);
+        assert!(
+            c.validate().is_err(),
+            "same port on TCP and HTTP of the same node must be rejected"
+        );
+    }
+
+    #[test]
+    fn validate_accepts_same_port_on_different_ips() {
+        let mut n1 = node("n1", 0);
+        n1.ip = "127.0.0.1".to_string();
+        n1.ports = TransportPorts {
+            tcp: Some(8090),
+            quic: None,
+            http: None,
+            websocket: None,
+            tcp_replica: None,
+        };
+        let mut n2 = node("n2", 1);
+        n2.ip = "127.0.0.2".to_string();
+        n2.ports = TransportPorts {
+            tcp: Some(8090),
+            quic: None,
+            http: None,
+            websocket: None,
+            tcp_replica: None,
+        };
+        let c = cfg(vec![n1, n2]);
+        assert!(c.validate().is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_zero_tcp_replica_port() {
+        let ports = TransportPorts {
+            tcp: None,
+            quic: None,
+            http: None,
+            websocket: None,
+            tcp_replica: Some(0),
+        };
+        let mut n1 = node("n1", 0);
+        n1.ports = ports;
+        let c = cfg(vec![n1]);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_accepts_empty_secret_when_auth_disabled() {
+        // Default: no secret, auth off -> legacy mode, must pass.
+        let c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        assert!(c.validate().is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_missing_secret_when_auth_enabled() {
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.auth.enabled = true;
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_short_secret_when_auth_enabled() {
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.auth.enabled = true;
+        c.auth.shared_secret = "a".repeat(MIN_SHARED_SECRET_LEN - 1);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_short_secret_even_when_auth_disabled() {
+        // Typo guard: a configured-but-short key fails even with auth off.
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.auth.shared_secret = "a".repeat(MIN_SHARED_SECRET_LEN - 1);
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_accepts_valid_secret_when_auth_enabled() {
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.auth.enabled = true;
+        c.auth.shared_secret = "a".repeat(MIN_SHARED_SECRET_LEN);
+        assert!(c.validate().is_ok());
+    }
+
+    fn tls_files() -> ClusterTlsConfig {
+        ClusterTlsConfig {
+            enabled: true,
+            self_signed: false,
+            cert_file: "cert.pem".to_string(),
+            key_file: "key.pem".to_string(),
+            ca_file: "ca.pem".to_string(),
+        }
+    }
+
+    #[test]
+    fn validate_rejects_tls_ca_mode_with_missing_files() {
+        // Auth on so the failure exercises the file check, not the auth gate.
+        for missing in ["cert_file", "key_file", "ca_file"] {
+            let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+            c.auth.enabled = true;
+            c.auth.shared_secret = "a".repeat(MIN_SHARED_SECRET_LEN);
+            c.tls = tls_files();
+            match missing {
+                "cert_file" => c.tls.cert_file.clear(),
+                "key_file" => c.tls.key_file.clear(),
+                _ => c.tls.ca_file.clear(),
+            }
+            assert!(c.validate().is_err(), "missing {missing} must be 
rejected");
+        }
+    }
+
+    #[test]
+    fn validate_rejects_tls_self_signed_without_auth() {
+        // Accept-any certificate without the PSK handshake = MITM-able.
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.tls = ClusterTlsConfig {
+            enabled: true,
+            self_signed: true,
+            ..ClusterTlsConfig::default()
+        };
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_accepts_tls_self_signed_with_auth() {
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.auth.enabled = true;
+        c.auth.shared_secret = "a".repeat(MIN_SHARED_SECRET_LEN);
+        c.tls = ClusterTlsConfig {
+            enabled: true,
+            self_signed: true,
+            ..ClusterTlsConfig::default()
+        };
+        assert!(c.validate().is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_tls_ca_mode_without_auth() {
+        // TLS never authenticates the dialer (no client certificates);
+        // only the PSK handshake does, so it is mandatory with TLS on.
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.tls = tls_files();
+        assert!(c.validate().is_err());
+    }
+
+    #[test]
+    fn validate_accepts_tls_ca_mode_with_auth() {
+        let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
+        c.auth.enabled = true;
+        c.auth.shared_secret = "a".repeat(MIN_SHARED_SECRET_LEN);
+        c.tls = tls_files();
+        assert!(c.validate().is_ok());
+    }
+}
diff --git a/core/configs/src/server_ng_config/defaults.rs 
b/core/configs/src/server_ng_config/defaults.rs
index 1c9b20483..58887a70f 100644
--- a/core/configs/src/server_ng_config/defaults.rs
+++ b/core/configs/src/server_ng_config/defaults.rs
@@ -17,16 +17,19 @@
 
 //! `Default` impls for the server-ng config surface.
 //!
-//! Sections that fork (`tcp`, `websocket`, `quic`, `message_bus`) have
-//! their own `Default` impls here, sourced from
+//! Sections that fork (`tcp`, `websocket`, `quic`, `cluster`,
+//! `message_bus`) have their own `Default` impls here, sourced from
 //! `core/server-ng/config.toml` via [`SERVER_NG_CONFIG`]. Sections that
-//! still reuse legacy types (`http`, `cluster`, `system`, `telemetry`,
+//! still reuse legacy types (`http`, `system`, `telemetry`,
 //! `consumer_group`, `data_maintenance`, `message_saver`,
 //! `personal_access_token`, `heartbeat`) delegate to the legacy
 //! `Default` impls; overrides land at the consumer level once
 //! [`super::server_ng::ServerNgConfig::load`] is wired into server-ng's
 //! bootstrap.
 
+use super::cluster::{
+    ClusterAuthConfig, ClusterConfig, ClusterNodeConfig, ClusterTlsConfig, 
TransportPorts,
+};
 use super::message_bus::MessageBusConfig;
 use super::metadata::MetadataConfig;
 use super::quic::{QuicCertificateConfig, QuicConfig, QuicSocketConfig};
@@ -34,7 +37,6 @@ use super::server_ng::NgSystemConfig;
 use super::server_ng::{ExtraConfig, ServerNgConfig};
 use super::tcp::{TcpConfig, TcpSocketConfig, TcpTlsConfig};
 use super::websocket::{WebSocketConfig, WebSocketTlsConfig};
-use crate::server_config::cluster::ClusterConfig;
 use crate::server_config::http::HttpConfig;
 use crate::server_config::server::{
     ConsumerGroupConfig, DataMaintenanceConfig, HeartbeatConfig, 
MessageSaverConfig,
@@ -69,6 +71,53 @@ impl Default for ServerNgConfig {
     }
 }
 
+impl Default for ClusterConfig {
+    fn default() -> ClusterConfig {
+        ClusterConfig {
+            enabled: SERVER_NG_CONFIG.cluster.enabled,
+            name: SERVER_NG_CONFIG.cluster.name.parse().unwrap(),
+            heartbeat_timeout: 
SERVER_NG_CONFIG.cluster.heartbeat_timeout.parse().unwrap(),
+            nodes: SERVER_NG_CONFIG
+                .cluster
+                .nodes
+                .iter()
+                .map(|node| ClusterNodeConfig {
+                    name: node.name.parse().unwrap(),
+                    ip: node.ip.parse().unwrap(),
+                    replica_id: u8::try_from(node.replica_id).expect(
+                        "static_toml replica_id must fit in u8 (0..=255); \
+                         fix core/server-ng/config.toml",
+                    ),
+                    ports: TransportPorts {
+                        tcp: Some(u16::try_from(node.ports.tcp).expect(
+                            "static_toml cluster.nodes.ports.tcp must fit in 
u16 (0..=65535); \
+                             fix core/server-ng/config.toml",
+                        )),
+                        quic: Some(u16::try_from(node.ports.quic).expect(
+                            "static_toml cluster.nodes.ports.quic must fit in 
u16 (0..=65535); \
+                             fix core/server-ng/config.toml",
+                        )),
+                        http: Some(u16::try_from(node.ports.http).expect(
+                            "static_toml cluster.nodes.ports.http must fit in 
u16 (0..=65535); \
+                             fix core/server-ng/config.toml",
+                        )),
+                        websocket: 
Some(u16::try_from(node.ports.websocket).expect(
+                            "static_toml cluster.nodes.ports.websocket must 
fit in u16 (0..=65535); \
+                             fix core/server-ng/config.toml",
+                        )),
+                        tcp_replica: 
Some(u16::try_from(node.ports.tcp_replica).expect(
+                            "static_toml cluster.nodes.ports.tcp_replica must 
fit in u16 (0..=65535); \
+                             fix core/server-ng/config.toml",
+                        )),
+                    },
+                })
+                .collect(),
+            auth: ClusterAuthConfig::default(),
+            tls: ClusterTlsConfig::default(),
+        }
+    }
+}
+
 impl Default for MetadataConfig {
     fn default() -> MetadataConfig {
         // Read from the embedded TOML so the Default impl and the on-disk
diff --git a/core/configs/src/server_ng_config/mod.rs 
b/core/configs/src/server_ng_config/mod.rs
index 16de8fdea..159d0c2f5 100644
--- a/core/configs/src/server_ng_config/mod.rs
+++ b/core/configs/src/server_ng_config/mod.rs
@@ -27,6 +27,7 @@
 //! wiring PR for `core/server-ng`'s bootstrap and the message_bus crate's
 //! runtime type is a separate change.
 
+pub mod cluster;
 pub mod defaults;
 pub mod displays;
 pub mod message_bus;
diff --git a/core/configs/src/server_ng_config/server_ng.rs 
b/core/configs/src/server_ng_config/server_ng.rs
index e60bcc894..3cd8f5376 100644
--- a/core/configs/src/server_ng_config/server_ng.rs
+++ b/core/configs/src/server_ng_config/server_ng.rs
@@ -16,13 +16,13 @@
 // under the License.
 
 use super::COMPONENT_NG;
+use super::cluster::ClusterConfig;
 use super::message_bus::MessageBusConfig;
 use super::metadata::MetadataConfig;
 use super::quic::QuicConfig;
 use super::tcp::TcpConfig;
 use super::websocket::WebSocketConfig;
 use crate::ConfigurationError;
-use crate::server_config::cluster::ClusterConfig;
 use crate::server_config::http::HttpConfig;
 use crate::server_config::server::{
     ConsumerGroupConfig, DataMaintenanceConfig, HeartbeatConfig, 
MessageSaverConfig,
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 250b06d8b..8869f870f 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -926,6 +926,14 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> 
VsrConsensus<B, P> {
         }
     }
 
+    /// Override the normal-heartbeat (primary liveness) window, in consensus
+    /// ticks. Sized from `[cluster] heartbeat_timeout` by the runtime. Must
+    /// run before `init` / `init_as_backup`: the override discards any
+    /// countdown already in flight.
+    pub fn set_normal_heartbeat_ticks(&self, ticks: u64) {
+        self.timeouts.borrow_mut().set_normal_heartbeat_ticks(ticks);
+    }
+
     pub fn init(&self) {
         self.status.set(Status::Normal);
         let mut timeouts = self.timeouts.borrow_mut();
diff --git a/core/consensus/src/lib.rs b/core/consensus/src/lib.rs
index 4ae31c5df..9c68f17ac 100644
--- a/core/consensus/src/lib.rs
+++ b/core/consensus/src/lib.rs
@@ -162,3 +162,4 @@ pub use observability::*;
 mod view_change_quorum;
 pub use view_change_quorum::*;
 mod vsr_timeout;
+pub use vsr_timeout::TimeoutManager;
diff --git a/core/consensus/src/vsr_timeout.rs 
b/core/consensus/src/vsr_timeout.rs
index eb7076a11..50e6482c8 100644
--- a/core/consensus/src/vsr_timeout.rs
+++ b/core/consensus/src/vsr_timeout.rs
@@ -133,13 +133,15 @@ impl TimeoutManager {
     const PING_TICKS: u64 = 100;
     const PREPARE_TICKS: u64 = 25;
     const COMMIT_MESSAGE_TICKS: u64 = 50;
-    const NORMAL_HEARTBEAT_TICKS: u64 = 500;
+    /// Public so the runtime can pin its config default (`[cluster]
+    /// heartbeat_timeout`) against this built-in with a static assert.
+    pub const NORMAL_HEARTBEAT_TICKS: u64 = 500;
     const START_VIEW_CHANGE_MESSAGE_TICKS: u64 = 50;
     const VIEW_CHANGE_STATUS_TICKS: u64 = 500;
     const DO_VIEW_CHANGE_MESSAGE_TICKS: u64 = 50;
     const REQUEST_START_VIEW_MESSAGE_TICKS: u64 = 100;
 
-    // TODO: add #[must_use] -- constructor, discarding is always a bug.
+    #[must_use]
     pub fn new(replica_id: u128) -> Self {
         Self {
             ping: Timeout::new(replica_id, Self::PING_TICKS),
@@ -162,6 +164,14 @@ impl TimeoutManager {
         }
     }
 
+    /// Override the backup's primary-liveness window (`[cluster]
+    /// heartbeat_timeout`). Replaces the timeout object, so any countdown
+    /// already in flight is discarded: call before the replica starts
+    /// ticking (i.e. before `init` / `init_as_backup`).
+    pub const fn set_normal_heartbeat_ticks(&mut self, ticks: u64) {
+        self.normal_heartbeat = Timeout::new(self.normal_heartbeat.id, ticks);
+    }
+
     /// Tick all timeouts
     /// This is the first phase of the two-phase tick-based timeout mechanism.
     /// 2nd phase is checking which timeouts have fired and calling the 
appropriate handlers.
@@ -181,6 +191,7 @@ impl TimeoutManager {
         self.get(kind).fired()
     }
 
+    #[must_use]
     pub const fn get(&self, kind: TimeoutKind) -> &Timeout {
         match kind {
             TimeoutKind::Ping => &self.ping,
diff --git a/core/integration/tests/server/message_retrieval.rs 
b/core/integration/tests/server/message_retrieval.rs
index 100b34e89..ad417cb0d 100644
--- a/core/integration/tests/server/message_retrieval.rs
+++ b/core/integration/tests/server/message_retrieval.rs
@@ -84,6 +84,19 @@ fn build_server_config(
         "true".to_string(),
     );
     extra_envs.insert("IGGY_TCP_SOCKET_NODELAY".to_string(), 
"true".to_string());
+    // Under vsr these scenarios exercise retrieval, not failover, yet run
+    // on the default 3-node cluster. Under a parallel nextest run the box
+    // is oversubscribed and scheduling stalls exceed the 5s default
+    // liveness window, so backups elect a new primary mid-scenario and the
+    // client session dies with it (observed stalls reach ~20s; 60s rides
+    // them out). The knob exists only on server-ng: the legacy flavor's
+    // strict env provider aborts boot on unknown IGGY_ vars, so the gate
+    // is load-bearing.
+    #[cfg(feature = "vsr")]
+    extra_envs.insert(
+        "IGGY_CLUSTER_HEARTBEAT_TIMEOUT".to_string(),
+        "60s".to_string(),
+    );
 
     TestServerConfig::builder().extra_envs(extra_envs).build()
 }
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index 6ed7195a1..832e6b33e 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -577,6 +577,13 @@ enabled = false
 # This prevents accidental cross-cluster communication.
 name = "iggy-cluster"
 
+# Backup-side liveness window for a consensus plane's primary (duration).
+# A replica that sees no primary traffic for this long starts a view change.
+# Raise it on oversubscribed hosts where scheduling stalls fake primary
+# death. Must be at least "2s": the primary heartbeats every second, and a
+# window at or below one ping interval would elect on every hiccup.
+heartbeat_timeout = "5s"
+
 # Replica-to-replica authentication (PSK + BLAKE3 keyed-MAC handshake).
 [cluster.auth]
 # When true, every replica peer must complete the authenticated handshake or be
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index 7d815d29e..b7d578a3f 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -957,6 +957,7 @@ async fn shard_main(
                 topology.replica_count,
                 Rc::clone(&bus),
                 config.metadata.prepare_queue_depth,
+                cluster_heartbeat_ticks(config),
             );
             (Some(consensus), Some(journal), snapshot)
         } else {
@@ -1490,7 +1491,7 @@ fn build_cluster_roster(
         name: config.cluster.name.clone(),
         nodes: config.cluster.nodes.clone(),
         self_ip: topology.client_listen_addr.ip().to_string(),
-        self_ports: configs::cluster::TransportPorts {
+        self_ports: configs::ng_cluster::TransportPorts {
             tcp: Some(topology.client_listen_addr.port()),
             quic: topology.quic_listen_addr.map(|addr| addr.port()),
             http: http_port,
@@ -1689,6 +1690,14 @@ const _: () = assert!(
     configs::ng_metadata::DEFAULT_METADATA_JOURNAL_SLOTS
         == journal::prepare_journal::DEFAULT_SLOT_COUNT
 );
+/// `[cluster] heartbeat_timeout` in consensus ticks, floored at one tick.
+/// Every consensus group (metadata and per-partition planes alike) gets the
+/// same window: the failure it guards against - a primary that stopped
+/// heartbeating - is host-level, not per-plane.
+pub(crate) fn cluster_heartbeat_ticks(config: &ServerNgConfig) -> u64 {
+    let window = config.cluster.heartbeat_timeout.get_duration().as_millis();
+    u64::try_from((window / 
shard::CONSENSUS_TICK_INTERVAL.as_millis()).max(1)).unwrap_or(u64::MAX)
+}
 
 #[allow(clippy::too_many_arguments)]
 fn restore_metadata_consensus(
@@ -1700,6 +1709,7 @@ fn restore_metadata_consensus(
     replica_count: u8,
     bus: Rc<IggyMessageBus>,
     prepare_queue_depth: usize,
+    normal_heartbeat_ticks: u64,
 ) -> VsrConsensus<Rc<IggyMessageBus>> {
     let mut consensus = VsrConsensus::new(
         cluster_id,
@@ -1712,6 +1722,7 @@ fn restore_metadata_consensus(
         // in-flight prepares and drain as prepares commit.
         LocalPipeline::with_capacities(prepare_queue_depth, 
prepare_queue_depth * 2),
     );
+    consensus.set_normal_heartbeat_ticks(normal_heartbeat_ticks);
 
     let last_header = journal
         .last_op()
@@ -1835,6 +1846,7 @@ async fn load_partition(
         bus,
         LocalPipeline::new(),
     );
+    consensus.set_normal_heartbeat_ticks(cluster_heartbeat_ticks(config));
     // A recovered partition lost its consensus state with the process: the
     // partition journal is in-memory and segments carry no op numbers, so
     // this replica cannot know the group's (op, commit). In a cluster it
@@ -2101,7 +2113,7 @@ fn resolve_optional_listener_addr(
 }
 
 fn resolve_cluster_client_addrs(
-    self_node: &configs::cluster::ClusterNodeConfig,
+    self_node: &configs::ng_cluster::ClusterNodeConfig,
     default_client_addr: SocketAddr,
     default_ws_addr: Option<SocketAddr>,
     default_quic_addr: Option<SocketAddr>,
@@ -2128,10 +2140,10 @@ fn resolve_cluster_client_addrs(
 }
 
 fn resolve_cluster_optional_addr(
-    self_node: &configs::cluster::ClusterNodeConfig,
+    self_node: &configs::ng_cluster::ClusterNodeConfig,
     context: &'static str,
     default_addr: Option<SocketAddr>,
-    port_selector: impl Fn(&configs::cluster::TransportPorts) -> Option<u16>,
+    port_selector: impl Fn(&configs::ng_cluster::TransportPorts) -> 
Option<u16>,
 ) -> Result<Option<SocketAddr>, ServerNgError> {
     let Some(default_addr) = default_addr else {
         return Ok(None);
@@ -2141,7 +2153,7 @@ fn resolve_cluster_optional_addr(
 }
 
 fn resolve_cluster_replica_peers(
-    nodes: &[configs::cluster::ClusterNodeConfig],
+    nodes: &[configs::ng_cluster::ClusterNodeConfig],
     self_replica_id: u8,
 ) -> Result<Vec<(u8, SocketAddr)>, ServerNgError> {
     let mut peers = Vec::with_capacity(nodes.len().saturating_sub(1));
@@ -2198,7 +2210,7 @@ async fn start_tcp_runtime(
     // caller of this function.
     if config.http.enabled {
         let http_addr = parse_socket_addr("http.address", 
&config.http.address)?;
-        let self_ports = configs::cluster::TransportPorts {
+        let self_ports = configs::ng_cluster::TransportPorts {
             tcp: config
                 .tcp
                 .enabled
@@ -3053,6 +3065,24 @@ const fn operation_triggers_partition_reconcile(op: 
Operation) -> bool {
 mod tests {
     use super::*;
 
+    #[test]
+    fn default_cluster_heartbeat_timeout_matches_consensus_constant() {
+        // The config default lives in core/server-ng/config.toml (a string,
+        // so no static assert can pin it); keep it in lockstep with the
+        // built-in the simulator and un-configured replicas run on.
+        let config_default = configs::ng_cluster::ClusterConfig::default()
+            .heartbeat_timeout
+            .get_duration()
+            .as_millis();
+        let built_in = 
u128::from(consensus::TimeoutManager::NORMAL_HEARTBEAT_TICKS)
+            * shard::CONSENSUS_TICK_INTERVAL.as_millis();
+        assert_eq!(
+            config_default, built_in,
+            "[cluster] heartbeat_timeout default drifted from \
+             TimeoutManager::NORMAL_HEARTBEAT_TICKS"
+        );
+    }
+
     #[test]
     fn shutdown_on_drop_armed_flips_flag() {
         let flag = Arc::new(AtomicBool::new(false));
diff --git a/core/server-ng/src/cluster_meta.rs 
b/core/server-ng/src/cluster_meta.rs
index 1be045fec..9e969f3df 100644
--- a/core/server-ng/src/cluster_meta.rs
+++ b/core/server-ng/src/cluster_meta.rs
@@ -28,7 +28,7 @@
 //! leader, but the full roster is still returned). The self-synthesized single
 //! node is the cluster-disabled fallback, shared by both callers.
 
-use configs::cluster::{ClusterNodeConfig, TransportPorts};
+use configs::ng_cluster::{ClusterNodeConfig, TransportPorts};
 use iggy_common::{
     ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus, 
TransportEndpoints,
 };
diff --git a/core/server-ng/src/http.rs b/core/server-ng/src/http.rs
index 26aab3afc..37a5535b4 100644
--- a/core/server-ng/src/http.rs
+++ b/core/server-ng/src/http.rs
@@ -50,8 +50,8 @@ use axum::extract::{DefaultBodyLimit, Request};
 use axum::http::{HeaderName, HeaderValue, Method};
 use axum::middleware::{Next, from_fn};
 use axum::routing::{delete, get, post, put};
-use configs::cluster::{ClusterConfig, TransportPorts};
 use configs::http::{HttpConfig, HttpCorsConfig};
+use configs::ng_cluster::{ClusterConfig, TransportPorts};
 use configs::server_ng::NgSystemConfig;
 use iggy_common::IggyError;
 use message_bus::client_listener;
diff --git a/core/server-ng/src/http/error.rs b/core/server-ng/src/http/error.rs
index a001d16fa..63fbe935a 100644
--- a/core/server-ng/src/http/error.rs
+++ b/core/server-ng/src/http/error.rs
@@ -491,7 +491,7 @@ pub(in crate::http) fn primary_redirect_location(
 mod tests {
     use super::*;
 
-    use configs::cluster::{ClusterNodeConfig, TransportPorts};
+    use configs::ng_cluster::{ClusterNodeConfig, TransportPorts};
 
     const READ_PATH: &str = "/streams?consistency=linearizable";
     fn node(replica_id: u8, ip: &str, http: Option<u16>) -> ClusterNodeConfig {
diff --git a/core/server-ng/src/partition_helpers.rs 
b/core/server-ng/src/partition_helpers.rs
index 923330e76..e7c33ecd9 100644
--- a/core/server-ng/src/partition_helpers.rs
+++ b/core/server-ng/src/partition_helpers.rs
@@ -495,6 +495,7 @@ pub async fn build_partition_fresh(
         bus,
         LocalPipeline::new(),
     );
+    
consensus.set_normal_heartbeat_ticks(crate::bootstrap::cluster_heartbeat_ticks(config));
     // A partition directory that already holds segment bytes is a RESTART
     // materialization, not a fresh create: this replica's group state died
     // with the process, so claiming view-0 primaryship would heartbeat


Reply via email to