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 2fdd6e84a feat(bench): record cluster topology in reports and surface
it in UI (#3737)
2fdd6e84a is described below
commit 2fdd6e84a209285a6876181dc54b2f68267c4d1c
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Thu Jul 23 15:09:53 2026 +0200
feat(bench): record cluster topology in reports and surface it in UI (#3737)
---
Cargo.lock | 2 +-
core/bench/dashboard/frontend/assets/style.css | 11 ++
.../src/components/layout/benchmark_meta.rs | 3 +
.../frontend/src/components/layout/sidebar.rs | 31 ++++-
.../frontend/src/components/layout/sweep_view.rs | 56 ++++++++
.../src/components/selectors/benchmarks_list.rs | 2 +
.../components/selectors/dense_benchmark_row.rs | 5 +
core/bench/dashboard/frontend/src/state/ui.rs | 68 ++++++++++
core/bench/dashboard/shared/src/lib.rs | 7 +-
core/bench/dashboard/shared/src/subtext.rs | 52 +++++++-
core/bench/report/src/types/cluster.rs | 125 ++++++++++++++++++
core/bench/report/src/types/mod.rs | 1 +
core/bench/report/src/types/report.rs | 49 +++++++
core/bench/src/analytics/report_builder.rs | 147 ++++++++++++++++++++-
core/bench/src/runner.rs | 5 +-
core/server-ng/Cargo.toml | 2 +-
16 files changed, 558 insertions(+), 8 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index ea71b9e89..4bc2420ba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11814,7 +11814,7 @@ dependencies = [
[[package]]
name = "server-ng"
-version = "0.8.0"
+version = "0.9.0-edge.1"
dependencies = [
"ahash 0.8.12",
"argon2",
diff --git a/core/bench/dashboard/frontend/assets/style.css
b/core/bench/dashboard/frontend/assets/style.css
index 21d1681b8..25eb53aa3 100644
--- a/core/bench/dashboard/frontend/assets/style.css
+++ b/core/bench/dashboard/frontend/assets/style.css
@@ -1987,6 +1987,17 @@ body.dark .dense-row.active {
opacity: 0.7;
}
+.dense-row-cluster {
+ flex-shrink: 0;
+ padding: 0 5px;
+ border-radius: 4px;
+ background: rgba(56, 189, 248, 0.14);
+ color: #38bdf8;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+}
+
.dense-row-compare-btn {
display: inline-flex;
align-items: center;
diff --git
a/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs
b/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs
index 91016bf2f..fa1dd8de2 100644
--- a/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/benchmark_meta.rs
@@ -71,6 +71,9 @@ fn render_config_row(benchmark: &BenchmarkReportLight) ->
Html {
let total_bytes = benchmark.total_bytes();
let mut chips = vec![(actors_label(benchmark), actors_value(benchmark))];
+ if let Some(cluster) = &benchmark.cluster {
+ chips.insert(0, ("Topology", cluster.label()));
+ }
if params.streams > 0 {
chips.push(("Streams", params.streams.to_string()));
// Benchmarks currently create 1 topic per stream; expose it so the
diff --git a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
index 72aba0a59..32898b0b7 100644
--- a/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/sidebar.rs
@@ -20,7 +20,7 @@ use
crate::components::selectors::benchmarks_list::BenchmarksList;
use crate::components::selectors::param_filters_panel::ParamFiltersPanel;
use crate::router::AppRoute;
use crate::state::benchmark::{BenchmarkAction, recency_cmp, use_benchmark};
-use crate::state::ui::{KindGroup, SidebarSort, UiAction, use_ui};
+use crate::state::ui::{KindGroup, SidebarSort, TopologyFilter, UiAction,
use_ui};
use bench_dashboard_shared::BenchmarkReportLight;
use gloo::console::log;
use std::cell::Cell;
@@ -110,6 +110,13 @@ pub fn sidebar(_props: &SidebarProps) -> Html {
Callback::from(move |group: KindGroup|
ui.dispatch(UiAction::ToggleKindFilter(group)))
};
+ let on_topology_select = {
+ let ui = ui.clone();
+ Callback::from(move |filter: TopologyFilter| {
+ ui.dispatch(UiAction::SetTopologyFilter(filter))
+ })
+ };
+
let on_sort_change = {
let ui = ui.clone();
Callback::from(move |event: Event| {
@@ -149,6 +156,7 @@ pub fn sidebar(_props: &SidebarProps) -> Html {
let hardware_options = collect_hardware(&benchmarks);
let gitref_options = collect_gitrefs(&benchmarks,
ui.hardware_filter.as_deref());
let active_kind_filter = ui.sidebar_kind_filter.clone();
+ let active_topology = ui.topology_filter;
let current_sort = ui.sidebar_sort;
let current_hardware = ui.hardware_filter.clone().unwrap_or_default();
let current_gitref = ui.gitref_filter.clone().unwrap_or_default();
@@ -239,6 +247,27 @@ pub fn sidebar(_props: &SidebarProps) -> Html {
})}
</div>
+ <div class="sidebar-kind-chips">
+ { for TopologyFilter::all().iter().map(|filter| {
+ let is_active = active_topology == *filter;
+ let filter_copy = *filter;
+ let on_click = {
+ let on_topology_select =
on_topology_select.clone();
+ Callback::from(move |_: MouseEvent|
on_topology_select.emit(filter_copy))
+ };
+ html! {
+ <button
+ type="button"
+ class={classes!("sidebar-chip",
is_active.then_some("active"))}
+ onclick={on_click}
+ aria-pressed={is_active.to_string()}
+ >
+ {filter.label()}
+ </button>
+ }
+ })}
+ </div>
+
<label class="sidebar-sort">
<svg class="sidebar-sort-icon"
xmlns="http://www.w3.org/2000/svg"
width="14" height="14" viewBox="0 0 24 24" fill="none"
diff --git a/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
b/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
index 5e556d570..812479b5d 100644
--- a/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
+++ b/core/bench/dashboard/frontend/src/components/layout/sweep_view.rs
@@ -178,6 +178,13 @@ fn siblings_match(
{
return false;
}
+ // A cluster run and a single-node run with otherwise equal params must not
+ // pool into one sweep line. Distinct node counts are distinct topologies.
+ if selected.cluster.as_ref().map(|c| c.nodes.len())
+ != candidate.cluster.as_ref().map(|c| c.nodes.len())
+ {
+ return false;
+ }
SweepAxis::ALL
.iter()
.filter(|axis| **axis != varying_axis)
@@ -288,3 +295,52 @@ fn project(
};
(screen_x, screen_y)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bench_report::cluster::{BenchmarkClusterInfo, BenchmarkClusterNode};
+
+ fn cluster(nodes: usize) -> BenchmarkClusterInfo {
+ BenchmarkClusterInfo {
+ name: "vsr".to_string(),
+ nodes: (0..nodes)
+ .map(|index| BenchmarkClusterNode {
+ name: format!("node-{index}"),
+ role: "follower".to_string(),
+ status: "healthy".to_string(),
+ })
+ .collect(),
+ }
+ }
+
+ #[test]
+ fn given_equal_params_when_cluster_size_differs_should_not_be_siblings() {
+ let single = BenchmarkReportLight::default();
+ let clustered = BenchmarkReportLight {
+ cluster: Some(cluster(3)),
+ ..Default::default()
+ };
+ assert!(!siblings_match(&single, &clustered, SweepAxis::Producers));
+ }
+
+ #[test]
+ fn given_equal_params_and_equal_cluster_size_should_be_siblings() {
+ let left = BenchmarkReportLight {
+ cluster: Some(cluster(3)),
+ ..Default::default()
+ };
+ let right = BenchmarkReportLight {
+ cluster: Some(cluster(3)),
+ ..Default::default()
+ };
+ assert!(siblings_match(&left, &right, SweepAxis::Producers));
+ }
+
+ #[test]
+ fn given_both_single_node_when_params_equal_should_be_siblings() {
+ let left = BenchmarkReportLight::default();
+ let right = BenchmarkReportLight::default();
+ assert!(siblings_match(&left, &right, SweepAxis::Producers));
+ }
+}
diff --git
a/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs
b/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs
index 84a9c625f..509645928 100644
--- a/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs
+++ b/core/bench/dashboard/frontend/src/components/selectors/benchmarks_list.rs
@@ -48,6 +48,7 @@ pub fn benchmarks_list(props: &BenchmarksListProps) -> Html {
let param_filters = ui_state.param_filters.clone();
let search = ui_state.sidebar_search.to_lowercase();
let kind_filter = ui_state.sidebar_kind_filter.clone();
+ let topology_filter = ui_state.topology_filter;
let hardware_filter = ui_state.hardware_filter.clone();
let gitref_filter = ui_state.gitref_filter.clone();
let sort = ui_state.sidebar_sort;
@@ -123,6 +124,7 @@ pub fn benchmarks_list(props: &BenchmarksListProps) -> Html
{
.iter()
.filter(|benchmark| param_filters.matches(benchmark))
.filter(|benchmark| kind_filter_matches(&kind_filter,
benchmark.params.benchmark_kind))
+ .filter(|benchmark| topology_filter.matches(benchmark))
.filter(|benchmark| hardware_matches(hardware_filter.as_deref(),
benchmark))
.filter(|benchmark| gitref_matches(gitref_filter.as_deref(),
benchmark))
.filter(|benchmark| search_matches(&search, benchmark))
diff --git
a/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs
b/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs
index e94bca615..b2e59b10f 100644
---
a/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs
+++
b/core/bench/dashboard/frontend/src/components/selectors/dense_benchmark_row.rs
@@ -86,6 +86,11 @@ pub fn dense_benchmark_row(props: &DenseBenchmarkRowProps)
-> Html {
<div class="dense-row-body">
<div class="dense-row-title">{display_name}</div>
<div class="dense-row-meta">
+ if let Some(cluster) = &benchmark.cluster {
+ <span class="dense-row-cluster"
title={cluster.label()}>
+ { format!("{}N", cluster.nodes.len()) }
+ </span>
+ }
{ render_metrics(benchmark) }
if props.show_timestamp {
<span class="dense-row-meta-sep">{"·"}</span>
diff --git a/core/bench/dashboard/frontend/src/state/ui.rs
b/core/bench/dashboard/frontend/src/state/ui.rs
index c3b241380..84cc29f5e 100644
--- a/core/bench/dashboard/frontend/src/state/ui.rs
+++ b/core/bench/dashboard/frontend/src/state/ui.rs
@@ -191,6 +191,37 @@ impl KindGroup {
}
}
+/// Single-select topology filter. `None` cluster field means a single-node
run.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TopologyFilter {
+ #[default]
+ All,
+ SingleNode,
+ Cluster,
+}
+
+impl TopologyFilter {
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::All => "All",
+ Self::SingleNode => "Single node",
+ Self::Cluster => "Cluster",
+ }
+ }
+
+ pub fn all() -> [Self; 3] {
+ [Self::All, Self::SingleNode, Self::Cluster]
+ }
+
+ pub fn matches(self, benchmark: &BenchmarkReportLight) -> bool {
+ match self {
+ Self::All => true,
+ Self::SingleNode => benchmark.cluster.is_none(),
+ Self::Cluster => benchmark.cluster.is_some(),
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq)]
pub struct UiState {
pub selected_measurement: MeasurementType,
@@ -203,6 +234,7 @@ pub struct UiState {
pub sidebar_search: String,
pub sidebar_sort: SidebarSort,
pub sidebar_kind_filter: HashSet<KindGroup>,
+ pub topology_filter: TopologyFilter,
pub hardware_filter: Option<String>,
pub gitref_filter: Option<String>,
}
@@ -220,6 +252,7 @@ impl Default for UiState {
sidebar_search: String::new(),
sidebar_sort: SidebarSort::default(),
sidebar_kind_filter: HashSet::new(),
+ topology_filter: TopologyFilter::default(),
hardware_filter: None,
gitref_filter: None,
}
@@ -245,6 +278,7 @@ pub enum UiAction {
SetSidebarSearch(String),
SetSidebarSort(SidebarSort),
ToggleKindFilter(KindGroup),
+ SetTopologyFilter(TopologyFilter),
SetHardwareFilter(Option<String>),
SetGitrefFilter(Option<String>),
}
@@ -349,6 +383,10 @@ impl Reducible for UiState {
..(*self).clone()
}
}
+ UiAction::SetTopologyFilter(filter) => UiState {
+ topology_filter: filter,
+ ..(*self).clone()
+ },
};
next.into()
}
@@ -380,6 +418,7 @@ pub fn use_ui() -> UseReducerHandle<UiState> {
mod tests {
use super::*;
use bench_dashboard_shared::BenchmarkGroupMetricsLight;
+ use bench_report::cluster::{BenchmarkClusterInfo, BenchmarkClusterNode};
use bench_report::group_metrics_kind::GroupMetricsKind;
use bench_report::group_metrics_summary::BenchmarkGroupMetricsSummary;
@@ -511,6 +550,22 @@ mod tests {
assert!(!KindGroup::EndToEnd.matches(BenchmarkKind::BalancedProducer));
}
+ #[test]
+ fn
given_topology_filter_when_matching_should_split_single_node_from_cluster() {
+ let single = benchmark_with(1, 0, 1, 1, 0, 0.0, 0.0);
+ let mut clustered = benchmark_with(1, 0, 1, 1, 0, 0.0, 0.0);
+ clustered.cluster = Some(cluster_info(3));
+
+ assert!(TopologyFilter::All.matches(&single));
+ assert!(TopologyFilter::All.matches(&clustered));
+
+ assert!(TopologyFilter::SingleNode.matches(&single));
+ assert!(!TopologyFilter::SingleNode.matches(&clustered));
+
+ assert!(!TopologyFilter::Cluster.matches(&single));
+ assert!(TopologyFilter::Cluster.matches(&clustered));
+ }
+
fn benchmark_with(
producers: u32,
consumers: u32,
@@ -536,6 +591,19 @@ mod tests {
report
}
+ fn cluster_info(nodes: usize) -> BenchmarkClusterInfo {
+ BenchmarkClusterInfo {
+ name: "vsr".to_string(),
+ nodes: (0..nodes)
+ .map(|index| BenchmarkClusterNode {
+ name: format!("node-{index}"),
+ role: if index == 0 { "leader" } else { "follower"
}.to_string(),
+ status: "healthy".to_string(),
+ })
+ .collect(),
+ }
+ }
+
fn summary_with(throughput_mb_s: f64, p99_ms: f64) ->
BenchmarkGroupMetricsSummary {
BenchmarkGroupMetricsSummary {
kind: GroupMetricsKind::Producers,
diff --git a/core/bench/dashboard/shared/src/lib.rs
b/core/bench/dashboard/shared/src/lib.rs
index 7dd11cf4e..fcbca10f5 100644
--- a/core/bench/dashboard/shared/src/lib.rs
+++ b/core/bench/dashboard/shared/src/lib.rs
@@ -19,8 +19,8 @@ pub mod subtext;
pub mod title;
use bench_report::{
- group_metrics_summary::BenchmarkGroupMetricsSummary,
hardware::BenchmarkHardware,
- individual_metrics_summary::BenchmarkIndividualMetricsSummary,
+ cluster::BenchmarkClusterInfo,
group_metrics_summary::BenchmarkGroupMetricsSummary,
+ hardware::BenchmarkHardware,
individual_metrics_summary::BenchmarkIndividualMetricsSummary,
latency_distribution::LatencyDistribution, params::BenchmarkParams,
server_stats::BenchmarkServerStats,
};
@@ -34,6 +34,9 @@ pub struct BenchmarkReportLight {
pub uuid: Uuid,
pub server_stats: BenchmarkServerStats,
pub params: BenchmarkParams,
+ /// Cluster topology, present only for cluster benchmarks (None = single
node)
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cluster: Option<BenchmarkClusterInfo>,
pub hardware: BenchmarkHardware,
pub group_metrics: Vec<BenchmarkGroupMetricsLight>,
pub individual_metrics: Vec<BenchmarkIndividualMetricsLight>,
diff --git a/core/bench/dashboard/shared/src/subtext.rs
b/core/bench/dashboard/shared/src/subtext.rs
index cd092b4ec..35c943e72 100644
--- a/core/bench/dashboard/shared/src/subtext.rs
+++ b/core/bench/dashboard/shared/src/subtext.rs
@@ -122,9 +122,13 @@ impl BenchmarkReportLight {
format!(" • {} Partitions per Topic", self.params.partitions)
};
let streams = format!("{} Streams", self.params.streams);
+ let cluster = match &self.cluster {
+ Some(info) => format!(" • {}", info.label()),
+ None => String::new(),
+ };
format!(
- "{actors_info} • {streams} • {topics}{partitions} •
{messages_per_batch} Msg/batch • {message_batches} Batches • {message_size}
Bytes/msg • {user_data_print}",
+ "{actors_info} • {streams} • {topics}{partitions} •
{messages_per_batch} Msg/batch • {message_batches} Batches • {message_size}
Bytes/msg • {user_data_print}{cluster}",
)
}
@@ -218,3 +222,49 @@ impl BenchmarkGroupMetricsLight {
)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bench_report::cluster::{BenchmarkClusterInfo, BenchmarkClusterNode};
+
+ fn node(name: &str, role: &str) -> BenchmarkClusterNode {
+ BenchmarkClusterNode {
+ name: name.to_string(),
+ role: role.to_string(),
+ status: "healthy".to_string(),
+ }
+ }
+
+ fn three_node_cluster() -> BenchmarkClusterInfo {
+ BenchmarkClusterInfo {
+ name: "vsr".to_string(),
+ nodes: vec![
+ node("node-1", "leader"),
+ node("node-2", "follower"),
+ node("node-3", "follower"),
+ ],
+ }
+ }
+
+ #[test]
+ fn
given_single_node_report_when_formatting_params_should_omit_cluster_segment() {
+ let report = BenchmarkReportLight::default();
+ assert!(!report.format_params().contains("cluster"));
+ }
+
+ #[test]
+ fn
given_cluster_report_when_formatting_params_should_append_one_label_segment() {
+ let single = BenchmarkReportLight::default();
+ let clustered = BenchmarkReportLight {
+ cluster: Some(three_node_cluster()),
+ ..Default::default()
+ };
+ let expected = format!(
+ "{} • {}",
+ single.format_params(),
+ three_node_cluster().label()
+ );
+ assert_eq!(clustered.format_params(), expected);
+ }
+}
diff --git a/core/bench/report/src/types/cluster.rs
b/core/bench/report/src/types/cluster.rs
new file mode 100644
index 000000000..2eb7fe253
--- /dev/null
+++ b/core/bench/report/src/types/cluster.rs
@@ -0,0 +1,125 @@
+// 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.
+
+use serde::{Deserialize, Serialize};
+
+/// Role string that marks a node as the consensus leader; anything else
counts as a follower.
+const LEADER_ROLE: &str = "leader";
+
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
+pub struct BenchmarkClusterInfo {
+ /// Human-friendly cluster name
+ pub name: String,
+ /// Member nodes of the cluster
+ pub nodes: Vec<BenchmarkClusterNode>,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
+pub struct BenchmarkClusterNode {
+ /// Node name as reported by the roster
+ pub name: String,
+ /// Consensus role, e.g. "leader" or "follower"
+ pub role: String,
+ /// Lifecycle status, e.g. "healthy" or "starting"
+ pub status: String,
+}
+
+impl BenchmarkClusterInfo {
+ /// Human display label such as `3-node cluster (1 leader + 2 followers)`.
+ pub fn label(&self) -> String {
+ if self.nodes.is_empty() {
+ return format!("cluster '{}'", self.name);
+ }
+
+ let leaders = self
+ .nodes
+ .iter()
+ .filter(|node| node.role == LEADER_ROLE)
+ .count();
+ let followers = self.nodes.len() - leaders;
+
+ format!(
+ "{}-node cluster ({} + {})",
+ self.nodes.len(),
+ pluralize(leaders, "leader"),
+ pluralize(followers, "follower"),
+ )
+ }
+}
+
+fn pluralize(count: usize, word: &str) -> String {
+ if count == 1 {
+ format!("{count} {word}")
+ } else {
+ format!("{count} {word}s")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn node(name: &str, role: &str) -> BenchmarkClusterNode {
+ BenchmarkClusterNode {
+ name: name.to_string(),
+ role: role.to_string(),
+ status: "healthy".to_string(),
+ }
+ }
+
+ #[test]
+ fn test_label_three_nodes_one_leader() {
+ let cluster = BenchmarkClusterInfo {
+ name: "vsr".to_string(),
+ nodes: vec![
+ node("node-1", "leader"),
+ node("node-2", "follower"),
+ node("node-3", "follower"),
+ ],
+ };
+ assert_eq!(cluster.label(), "3-node cluster (1 leader + 2 followers)");
+ }
+
+ #[test]
+ fn test_label_single_leader_node() {
+ let cluster = BenchmarkClusterInfo {
+ name: "solo".to_string(),
+ nodes: vec![node("node-1", "leader")],
+ };
+ assert_eq!(cluster.label(), "1-node cluster (1 leader + 0 followers)");
+ }
+
+ #[test]
+ fn test_label_empty_nodes_falls_back_to_name() {
+ let cluster = BenchmarkClusterInfo {
+ name: "unknown".to_string(),
+ nodes: vec![],
+ };
+ assert_eq!(cluster.label(), "cluster 'unknown'");
+ }
+
+ #[test]
+ fn test_cluster_info_round_trip() {
+ let cluster = BenchmarkClusterInfo {
+ name: "vsr".to_string(),
+ nodes: vec![node("node-1", "leader"), node("node-2", "follower")],
+ };
+ let json = serde_json::to_string(&cluster).unwrap();
+ let decoded: BenchmarkClusterInfo =
serde_json::from_str(&json).unwrap();
+ assert_eq!(cluster, decoded);
+ }
+}
diff --git a/core/bench/report/src/types/mod.rs
b/core/bench/report/src/types/mod.rs
index 3661476e0..508dcc08f 100644
--- a/core/bench/report/src/types/mod.rs
+++ b/core/bench/report/src/types/mod.rs
@@ -17,6 +17,7 @@
pub mod actor_kind;
pub mod benchmark_kind;
+pub mod cluster;
pub mod group_metrics;
pub mod group_metrics_kind;
pub mod group_metrics_summary;
diff --git a/core/bench/report/src/types/report.rs
b/core/bench/report/src/types/report.rs
index 01aedceec..75ae23372 100644
--- a/core/bench/report/src/types/report.rs
+++ b/core/bench/report/src/types/report.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+use super::cluster::BenchmarkClusterInfo;
use super::server_stats::BenchmarkServerStats;
use crate::group_metrics::BenchmarkGroupMetrics;
use crate::individual_metrics::BenchmarkIndividualMetrics;
@@ -41,6 +42,10 @@ pub struct BenchmarkReport {
/// Benchmark parameters
pub params: BenchmarkParams,
+ /// Cluster topology, present only for cluster benchmarks (None = single
node)
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cluster: Option<BenchmarkClusterInfo>,
+
/// Benchmark metrics for all actors of same type (all producers, all
consumers or all actors)
pub group_metrics: Vec<BenchmarkGroupMetrics>,
@@ -58,3 +63,47 @@ impl BenchmarkReport {
std::fs::write(report_path, report_json).expect("Failed to write
report to file");
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::cluster::BenchmarkClusterNode;
+
+ #[test]
+ fn test_single_node_report_omits_cluster_and_deserializes_to_none() {
+ let report = BenchmarkReport::default();
+ let json = serde_json::to_string(&report).unwrap();
+ assert!(!json.contains("\"cluster\""));
+
+ let decoded: BenchmarkReport = serde_json::from_str(&json).unwrap();
+ assert_eq!(decoded.cluster, None);
+ }
+
+ #[test]
+ fn test_cluster_report_round_trip_preserves_data() {
+ let report = BenchmarkReport {
+ cluster: Some(BenchmarkClusterInfo {
+ name: "vsr".to_string(),
+ nodes: vec![
+ BenchmarkClusterNode {
+ name: "node-1".to_string(),
+ role: "leader".to_string(),
+ status: "healthy".to_string(),
+ },
+ BenchmarkClusterNode {
+ name: "node-2".to_string(),
+ role: "follower".to_string(),
+ status: "healthy".to_string(),
+ },
+ ],
+ }),
+ ..Default::default()
+ };
+
+ let json = serde_json::to_string(&report).unwrap();
+ assert!(json.contains("\"cluster\""));
+
+ let decoded: BenchmarkReport = serde_json::from_str(&json).unwrap();
+ assert_eq!(decoded, report);
+ }
+}
diff --git a/core/bench/src/analytics/report_builder.rs
b/core/bench/src/analytics/report_builder.rs
index 32e67eed9..35119a32e 100644
--- a/core/bench/src/analytics/report_builder.rs
+++ b/core/bench/src/analytics/report_builder.rs
@@ -22,6 +22,7 @@ use crate::utils::get_server_stats;
use bench_report::{
actor_kind::ActorKind,
benchmark_kind::BenchmarkKind,
+ cluster::{BenchmarkClusterInfo, BenchmarkClusterNode},
hardware::BenchmarkHardware,
individual_metrics::BenchmarkIndividualMetrics,
params::BenchmarkParams,
@@ -29,7 +30,14 @@ use bench_report::{
server_stats::{BenchmarkCacheMetrics, BenchmarkCacheMetricsKey,
BenchmarkServerStats},
};
use chrono::{DateTime, Utc};
-use iggy::prelude::{CacheMetrics, CacheMetricsKey, IggyClient, IggyTimestamp,
Stats};
+use iggy::prelude::{
+ CacheMetrics, CacheMetricsKey, ClusterClient, ClusterMetadata, IggyClient,
IggyTimestamp, Stats,
+};
+use tracing::warn;
+
+/// Both the legacy server and server-ng synthesize exactly this cluster name
+/// for a non-clustered instance, so it is the single-node sentinel.
+const SINGLE_NODE_CLUSTER_NAME: &str = "single-node";
pub struct BenchmarkReportBuilder;
@@ -60,6 +68,22 @@ impl BenchmarkReportBuilder {
params.gitref_date = Some(timestamp.clone());
}
+ // Old servers predate the cluster-metadata command; tolerate the error
+ // and treat the target as a single node rather than failing the run.
+ let cluster = match admin_client.get_cluster_metadata().await {
+ Ok(metadata) => cluster_info_from_metadata(metadata),
+ Err(error) => {
+ warn!("cluster metadata unavailable, assuming single node:
{error}");
+ None
+ }
+ };
+
+ // params_identifier is the dashboard trend grouping key; fork cluster
+ // runs into their own series so they never mix with single-node runs.
+ params
+ .params_identifier
+ .push_str(&cluster_suffix(cluster.as_ref()));
+
let mut group_metrics = Vec::new();
individual_metrics.sort_by_key(|m| (m.summary.actor_kind,
m.summary.actor_id));
@@ -132,12 +156,46 @@ impl BenchmarkReportBuilder {
timestamp,
hardware,
params,
+ cluster,
group_metrics,
individual_metrics,
}
}
}
+/// Classifies raw server metadata into cluster topology, returning `None` for
a
+/// single node so callers can keep single-node reports untouched.
+fn cluster_info_from_metadata(metadata: ClusterMetadata) ->
Option<BenchmarkClusterInfo> {
+ let is_real_cluster = metadata.nodes.len() > 1 || metadata.name !=
SINGLE_NODE_CLUSTER_NAME;
+ if !is_real_cluster {
+ return None;
+ }
+
+ let nodes = metadata
+ .nodes
+ .into_iter()
+ .map(|node| BenchmarkClusterNode {
+ name: node.name,
+ role: node.role.to_string(),
+ status: node.status.to_string(),
+ })
+ .collect();
+
+ Some(BenchmarkClusterInfo {
+ name: metadata.name,
+ nodes,
+ })
+}
+
+/// Trend/directory suffix that forks a cluster run away from the single-node
+/// run sharing the same benchmark params. Empty for single node so existing
+/// identifiers and directory names stay byte-identical.
+pub fn cluster_suffix(cluster: Option<&BenchmarkClusterInfo>) -> String {
+ cluster.map_or_else(String::new, |cluster| {
+ format!("_cluster{}", cluster.nodes.len())
+ })
+}
+
/// This function is a workaround.
/// See `server_stats.rs` in `bench_report` crate for more details.
fn stats_to_benchmark_server_stats(stats: Stats) -> BenchmarkServerStats {
@@ -193,3 +251,90 @@ fn cache_metrics_to_benchmark_cache_metrics(
})
.collect()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use iggy::prelude::{ClusterNode, ClusterNodeRole, ClusterNodeStatus,
TransportEndpoints};
+
+ fn metadata_node(name: &str, role: ClusterNodeRole) -> ClusterNode {
+ ClusterNode {
+ name: name.to_string(),
+ ip: "127.0.0.1".to_string(),
+ endpoints: TransportEndpoints::new(0, 0, 0, 0),
+ role,
+ status: ClusterNodeStatus::Healthy,
+ }
+ }
+
+ fn benchmark_cluster(node_count: usize) -> BenchmarkClusterInfo {
+ BenchmarkClusterInfo {
+ name: "vsr".to_string(),
+ nodes: (0..node_count)
+ .map(|idx| BenchmarkClusterNode {
+ name: format!("node-{idx}"),
+ role: "follower".to_string(),
+ status: "healthy".to_string(),
+ })
+ .collect(),
+ }
+ }
+
+ #[test]
+ fn given_single_node_metadata_when_classified_should_return_none() {
+ let metadata = ClusterMetadata {
+ name: SINGLE_NODE_CLUSTER_NAME.to_string(),
+ nodes: vec![metadata_node("iggy-node", ClusterNodeRole::Leader)],
+ };
+
+ assert!(cluster_info_from_metadata(metadata).is_none());
+ }
+
+ #[test]
+ fn
given_multi_node_metadata_when_classified_should_map_lowercase_roles_and_keep_name()
{
+ let metadata = ClusterMetadata {
+ name: "vsr".to_string(),
+ nodes: vec![
+ metadata_node("node-1", ClusterNodeRole::Leader),
+ metadata_node("node-2", ClusterNodeRole::Follower),
+ metadata_node("node-3", ClusterNodeRole::Follower),
+ ],
+ };
+
+ let info = cluster_info_from_metadata(metadata).expect("real cluster");
+ assert_eq!(info.name, "vsr");
+ assert_eq!(
+ info.nodes
+ .iter()
+ .map(|node| node.role.as_str())
+ .collect::<Vec<_>>(),
+ vec!["leader", "follower", "follower"]
+ );
+ assert!(info.nodes.iter().all(|node| node.status == "healthy"));
+ }
+
+ #[test]
+ fn given_single_node_with_custom_name_when_classified_should_return_some()
{
+ let metadata = ClusterMetadata {
+ name: "vsr".to_string(),
+ nodes: vec![metadata_node("node-1", ClusterNodeRole::Leader)],
+ };
+
+ let info = cluster_info_from_metadata(metadata).expect("named roster
counts as cluster");
+ assert_eq!(info.nodes.len(), 1);
+ }
+
+ #[test]
+ fn given_cluster_when_suffixing_identifier_should_append_node_count() {
+ let mut identifier = "pinned_producer_tcp".to_string();
+ identifier.push_str(&cluster_suffix(Some(&benchmark_cluster(3))));
+ assert_eq!(identifier, "pinned_producer_tcp_cluster3");
+ }
+
+ #[test]
+ fn given_no_cluster_when_suffixing_identifier_should_leave_it_unchanged() {
+ let mut identifier = "pinned_producer_tcp".to_string();
+ identifier.push_str(&cluster_suffix(None));
+ assert_eq!(identifier, "pinned_producer_tcp");
+ }
+}
diff --git a/core/bench/src/runner.rs b/core/bench/src/runner.rs
index 04afd2c85..49f9e8bfe 100644
--- a/core/bench/src/runner.rs
+++ b/core/bench/src/runner.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use crate::analytics::report_builder::BenchmarkReportBuilder;
+use crate::analytics::report_builder::{BenchmarkReportBuilder, cluster_suffix};
use crate::args::common::IggyBenchArgs;
use crate::benchmarks::benchmark::Benchmarkable;
use crate::plot::{ChartType, plot_chart};
@@ -93,6 +93,9 @@ impl BenchmarkRunner {
// Generate the full output path using the directory name generator
let mut dir_name = benchmark.args().generate_dir_name();
append_cpu_name_lowercase(&mut dir_name);
+ // Cluster runs share params (and thus the dir name) with
single-node
+ // runs; suffix keeps them from overwriting each other's results.
+ dir_name.push_str(&cluster_suffix(report.cluster.as_ref()));
let full_output_path = Path::new(&output_dir)
.join(dir_name.clone())
.to_string_lossy()
diff --git a/core/server-ng/Cargo.toml b/core/server-ng/Cargo.toml
index 558504dbc..b42c2a3cb 100644
--- a/core/server-ng/Cargo.toml
+++ b/core/server-ng/Cargo.toml
@@ -17,7 +17,7 @@
[package]
name = "server-ng"
-version = "0.8.0"
+version = "0.9.0-edge.1"
edition = "2024"
license = "Apache-2.0"
publish = false