This is an automated email from the ASF dual-hosted git repository.
mmodzelewski 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 2ec7d8964 feat: respect CPU and cgroup limits in server-ng and
connectors stats (#3647)
2ec7d8964 is described below
commit 2ec7d89649539416de9c5c083f07e0d78806d8cf
Author: Maciej Modzelewski <[email protected]>
AuthorDate: Sat Jul 11 07:00:40 2026 +0200
feat: respect CPU and cgroup limits in server-ng and connectors stats
(#3647)
The server-ng and connectors runtime stats endpoints reported
host-wide sysinfo numbers: a cpuset-confined or memory-capped
process showed its neighbors' CPU load and a memory total it can
never allocate, leaking host sizing on multi-tenant machines. The
legacy server already scoped these (#3615) but kept the logic
inline.
Extract that logic into a shared system_stats crate: SystemProbe
scopes total CPU usage to the allowed core set (snapshotted at
startup, before shard threads pin themselves) and memory totals
to the effective cgroup cap, falling back to host-wide values for
unconfined processes. All three binaries now probe through it.
On confined hosts the reported total_cpu_usage, total_memory and
available_memory now reflect the confinement, so dashboards and
alerts keyed to the old host-wide values will see lower numbers.
The connectors runtime also keeps its sysinfo System alive across
captures, so CPU usage reflects real deltas instead of the zero
first sample of a freshly created System on every request.
---
Cargo.lock | 13 +-
Cargo.toml | 2 +
core/connectors/runtime/Cargo.toml | 1 +
core/connectors/runtime/src/main.rs | 2 +
core/connectors/runtime/src/stats.rs | 44 ++--
core/connectors/sdk/src/api.rs | 18 ++
core/server-ng/Cargo.toml | 1 +
core/server-ng/src/main.rs | 4 +
core/server-ng/src/responses.rs | 69 +++---
core/server/Cargo.toml | 2 +-
core/server/src/main.rs | 4 +
core/server/src/shard/system/mod.rs | 1 -
core/server/src/shard/system/stats.rs | 224 +++++++-----------
core/system_stats/Cargo.toml | 31 +++
.../system => system_stats/src}/cgroup_memory.rs | 0
core/system_stats/src/lib.rs | 259 +++++++++++++++++++++
16 files changed, 462 insertions(+), 213 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index e5ed364e9..c75598082 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6751,6 +6751,7 @@ dependencies = [
"serde_yaml_ng",
"strum 0.28.0",
"sysinfo 0.39.5",
+ "system_stats",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -11698,7 +11699,6 @@ dependencies = [
"clap",
"compio",
"configs",
- "cpu_allocation",
"ctrlc",
"cyper",
"cyper-axum",
@@ -11737,6 +11737,7 @@ dependencies = [
"socket2 0.6.4",
"strum 0.28.0",
"sysinfo 0.39.5",
+ "system_stats",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -11817,6 +11818,7 @@ dependencies = [
"socket2 0.6.4",
"strum 0.28.0",
"sysinfo 0.39.5",
+ "system_stats",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -12759,6 +12761,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "system_stats"
+version = "0.1.0"
+dependencies = [
+ "cpu_allocation",
+ "sysinfo 0.39.5",
+ "tempfile",
+]
+
[[package]]
name = "tagptr"
version = "0.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 5990f44e7..0c31e253f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,6 +64,7 @@ members = [
"core/shard",
"core/shard_allocator",
"core/simulator",
+ "core/system_stats",
"core/tools",
"examples/rust",
]
@@ -305,6 +306,7 @@ strum = { version = "0.28.0", features = ["derive"] }
strum_macros = "0.28.0"
syn = { version = "2", features = ["full", "extra-traits"] }
sysinfo = "0.39.5"
+system_stats = { path = "core/system_stats" }
tempfile = "3.27.0"
terminal_size = { version = "0.4.4" }
test-case = "3.3.1"
diff --git a/core/connectors/runtime/Cargo.toml
b/core/connectors/runtime/Cargo.toml
index e193c5325..648300999 100644
--- a/core/connectors/runtime/Cargo.toml
+++ b/core/connectors/runtime/Cargo.toml
@@ -70,6 +70,7 @@ serde_with = { workspace = true }
serde_yaml_ng = { workspace = true }
strum = { workspace = true }
sysinfo = { workspace = true }
+system_stats = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
diff --git a/core/connectors/runtime/src/main.rs
b/core/connectors/runtime/src/main.rs
index dfd149c4d..5c5ebe777 100644
--- a/core/connectors/runtime/src/main.rs
+++ b/core/connectors/runtime/src/main.rs
@@ -39,6 +39,7 @@ use std::{
env,
sync::{Arc, atomic::AtomicU32},
};
+use system_stats::capture_allowed_cpus;
use tracing::{error, info};
mod api;
@@ -114,6 +115,7 @@ fn print_ascii_art(text: &str) {
#[tokio::main]
async fn main() -> Result<(), RuntimeError> {
+ capture_allowed_cpus();
Args::parse();
print_ascii_art("Iggy Connectors");
diff --git a/core/connectors/runtime/src/stats.rs
b/core/connectors/runtime/src/stats.rs
index ed3f9053b..1ed8e9271 100644
--- a/core/connectors/runtime/src/stats.rs
+++ b/core/connectors/runtime/src/stats.rs
@@ -20,31 +20,17 @@ use crate::metrics::ConnectorType;
use iggy_common::{IggyTimestamp, SemanticVersion};
use iggy_connector_sdk::api::{ConnectorRuntimeStats, ConnectorStats};
use std::str::FromStr;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use sysinfo::System;
+use system_stats::SystemProbe;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const SEMANTIC_VERSION: SemanticVersion =
SemanticVersion::parse_const(VERSION);
-pub async fn get_runtime_stats(context: &Arc<RuntimeContext>) ->
ConnectorRuntimeStats {
- let pid = std::process::id();
-
- let mut system = System::new_all();
- system.refresh_cpu_all();
- system.refresh_memory();
- system.refresh_processes(
- sysinfo::ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]),
- true,
- );
-
- let total_cpu_usage = system.global_cpu_usage();
- let total_memory = system.total_memory();
- let available_memory = system.available_memory();
+static SYSINFO: OnceLock<Mutex<System>> = OnceLock::new();
- let (cpu_usage, memory_usage) = system
- .process(sysinfo::Pid::from_u32(pid))
- .map(|p| (p.cpu_usage(), p.memory()))
- .unwrap_or((0.0, 0));
+pub async fn get_runtime_stats(context: &Arc<RuntimeContext>) ->
ConnectorRuntimeStats {
+ let system = probe_system();
let sources = context.sources.get_all().await;
let sinks = context.sinks.get_all().await;
@@ -113,12 +99,12 @@ pub async fn get_runtime_stats(context:
&Arc<RuntimeContext>) -> ConnectorRuntim
ConnectorRuntimeStats {
connectors_runtime_version: VERSION.to_owned(),
connectors_runtime_version_semver:
SEMANTIC_VERSION.get_numeric_version().ok(),
- process_id: pid,
- cpu_usage,
- total_cpu_usage,
- memory_usage,
- total_memory,
- available_memory,
+ process_id: system.process_id,
+ cpu_usage: system.cpu_usage,
+ total_cpu_usage: system.total_cpu_usage,
+ memory_usage: system.memory_usage,
+ total_memory: system.total_memory,
+ available_memory: system.available_memory,
run_time,
start_time: start,
sources_total,
@@ -128,3 +114,11 @@ pub async fn get_runtime_stats(context:
&Arc<RuntimeContext>) -> ConnectorRuntim
connectors,
}
}
+
+fn probe_system() -> SystemProbe {
+ let mut system = SYSINFO
+ .get_or_init(|| Mutex::new(System::new()))
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner);
+ SystemProbe::capture(&mut system)
+}
diff --git a/core/connectors/sdk/src/api.rs b/core/connectors/sdk/src/api.rs
index d690d96c6..b5edeba3f 100644
--- a/core/connectors/sdk/src/api.rs
+++ b/core/connectors/sdk/src/api.rs
@@ -73,21 +73,39 @@ impl ConnectorError {
/// Runtime statistics response from `/stats` endpoint.
#[derive(Debug, Serialize, Deserialize)]
pub struct ConnectorRuntimeStats {
+ /// The version of the connectors runtime.
pub connectors_runtime_version: String,
+ /// The semantic version of the connectors runtime in the numeric format,
+ /// e.g. 1.2.3 -> 1002003 (major followed by zero-padded three-digit minor
and patch).
#[serde(skip_serializing_if = "Option::is_none")]
pub connectors_runtime_version_semver: Option<u32>,
+ /// The unique identifier of the runtime process.
pub process_id: u32,
+ /// The CPU usage of the runtime process.
pub cpu_usage: f32,
+ /// The total CPU usage of the system, scoped to the cores this process
may run on
+ /// when confined by an affinity/cpuset mask.
pub total_cpu_usage: f32,
+ /// The memory usage of the runtime process in bytes.
pub memory_usage: u64,
+ /// The total memory of the system in bytes, or the effective cgroup
memory limit when
+ /// the runtime runs inside a memory-capped cgroup (container, systemd
slice).
pub total_memory: u64,
+ /// The available memory of the system in bytes, scoped to the cgroup
limit when one applies.
pub available_memory: u64,
+ /// The elapsed time since the runtime started, in microseconds.
pub run_time: u64,
+ /// The time the runtime started, in microseconds since the UNIX epoch.
pub start_time: u64,
+ /// The number of configured source connectors, including disabled and
failed ones.
pub sources_total: u32,
+ /// The number of currently running source connectors.
pub sources_running: u32,
+ /// The number of configured sink connectors, including disabled and
failed ones.
pub sinks_total: u32,
+ /// The number of currently running sink connectors.
pub sinks_running: u32,
+ /// Per-connector statistics for every configured source and sink.
pub connectors: Vec<ConnectorStats>,
}
diff --git a/core/server-ng/Cargo.toml b/core/server-ng/Cargo.toml
index 26f397d00..91523299b 100644
--- a/core/server-ng/Cargo.toml
+++ b/core/server-ng/Cargo.toml
@@ -154,6 +154,7 @@ slab = { workspace = true }
socket2 = { workspace = true }
strum = { workspace = true }
sysinfo = { workspace = true }
+system_stats = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
diff --git a/core/server-ng/src/main.rs b/core/server-ng/src/main.rs
index 4aabb4df4..a3e11fd64 100644
--- a/core/server-ng/src/main.rs
+++ b/core/server-ng/src/main.rs
@@ -23,9 +23,13 @@ use args::Args;
use clap::Parser;
use server_ng::bootstrap::{bootstrap, load_config};
use server_ng::server_error::ServerNgError;
+use system_stats::capture_allowed_cpus;
use tracing::{error, info};
fn main() -> Result<(), ServerNgError> {
+ // Before shard threads pin themselves: a pinned capture sees one core.
+ capture_allowed_cpus();
+
let bootstrap_runtime = match server_common::create_shard_executor() {
Ok(rt) => rt,
Err(e) => {
diff --git a/core/server-ng/src/responses.rs b/core/server-ng/src/responses.rs
index 1fd86e0a7..8080a8194 100644
--- a/core/server-ng/src/responses.rs
+++ b/core/server-ng/src/responses.rs
@@ -87,7 +87,8 @@ use shard::ConnectedClientInfo;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, OnceLock};
-use sysinfo::{Pid, ProcessesToUpdate, System as SysinfoSystem};
+use sysinfo::System as SysinfoSystem;
+use system_stats::SystemProbe;
/// Build the `get_me` reply for the requesting connection. Identity
/// (`user_id`, transport kind, peer address) comes from the per-shard
@@ -727,50 +728,32 @@ impl HostIdentity {
static HOST_IDENTITY: OnceLock<HostIdentity> = OnceLock::new();
fn probe_system_stats() -> SystemStats {
- let process_id = std::process::id();
let host = HOST_IDENTITY.get_or_init(HostIdentity::probe);
- SYSINFO.with_borrow_mut(|slot| {
- let sys = slot.get_or_insert_with(SysinfoSystem::new_all);
- sys.refresh_cpu_all();
- sys.refresh_memory();
-
sys.refresh_processes(ProcessesToUpdate::Some(&[Pid::from_u32(process_id)]),
true);
-
- let mut stats = SystemStats {
- process_id,
- cpu_usage: 0.0,
- total_cpu_usage: sys.global_cpu_usage(),
- memory_usage: 0,
- total_memory: sys.total_memory(),
- available_memory: sys.available_memory(),
- run_time: 0,
- start_time: 0,
- read_bytes: 0,
- written_bytes: 0,
- threads_count: 0,
- hostname: host.hostname.clone(),
- os_name: host.os_name.clone(),
- os_version: host.os_version.clone(),
- kernel_version: host.kernel_version.clone(),
- };
-
- if let Some(process) = sys.process(Pid::from_u32(process_id)) {
- stats.cpu_usage = process.cpu_usage();
- stats.memory_usage = process.memory();
- // sysinfo reports whole seconds; the wire fields are micros (the
- // SDK decodes them via `IggyDuration` / `IggyTimestamp::from`,
both
- // micro-based).
- stats.run_time = process.run_time().saturating_mul(1_000_000);
- stats.start_time = process.start_time().saturating_mul(1_000_000);
- let disk_usage = process.disk_usage();
- stats.read_bytes = disk_usage.total_read_bytes;
- stats.written_bytes = disk_usage.total_written_bytes;
- stats.threads_count = process
- .tasks()
- .map_or(0, |tasks|
u32::try_from(tasks.len()).unwrap_or(u32::MAX));
- }
+ let probe = SYSINFO.with_borrow_mut(|slot| {
+ let sys = slot.get_or_insert_with(SysinfoSystem::new);
+ SystemProbe::capture(sys)
+ });
- stats
- })
+ SystemStats {
+ process_id: probe.process_id,
+ cpu_usage: probe.cpu_usage,
+ total_cpu_usage: probe.total_cpu_usage,
+ memory_usage: probe.memory_usage,
+ total_memory: probe.total_memory,
+ available_memory: probe.available_memory,
+ // sysinfo reports whole seconds; the wire fields are micros (the
+ // SDK decodes them via `IggyDuration` / `IggyTimestamp::from`, both
+ // micro-based).
+ run_time: probe.run_time_secs.saturating_mul(1_000_000),
+ start_time: probe.start_time_secs.saturating_mul(1_000_000),
+ read_bytes: probe.read_bytes,
+ written_bytes: probe.written_bytes,
+ threads_count: probe.threads_count,
+ hostname: host.hostname.clone(),
+ os_name: host.os_name.clone(),
+ os_version: host.os_version.clone(),
+ kernel_version: host.kernel_version.clone(),
+ }
}
fn build_get_stream_response(
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index 037bd30b9..f40538611 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -48,7 +48,6 @@ chrono = { workspace = true }
clap = { workspace = true }
compio = { workspace = true }
configs = { workspace = true }
-cpu_allocation = { workspace = true }
ctrlc = { workspace = true }
cyper = { workspace = true }
cyper-axum = { workspace = true }
@@ -87,6 +86,7 @@ slab = { workspace = true }
socket2 = { workspace = true }
strum = { workspace = true }
sysinfo = { workspace = true }
+system_stats = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
diff --git a/core/server/src/main.rs b/core/server/src/main.rs
index 2f598c75c..b5c0110f7 100644
--- a/core/server/src/main.rs
+++ b/core/server/src/main.rs
@@ -57,6 +57,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread::JoinHandle;
+use system_stats::capture_allowed_cpus;
use tracing::{error, info, instrument, warn};
const COMPONENT: &str = "MAIN";
@@ -116,6 +117,9 @@ fn print_ascii_art(text: &str) {
#[instrument(skip_all, name = "trace_start_server")]
fn main() -> Result<(), ServerError> {
+ // Before shard threads pin themselves: a pinned capture sees one core.
+ capture_allowed_cpus();
+
let rt = match compio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
diff --git a/core/server/src/shard/system/mod.rs
b/core/server/src/shard/system/mod.rs
index 907d2c8ab..f326ea08e 100644
--- a/core/server/src/shard/system/mod.rs
+++ b/core/server/src/shard/system/mod.rs
@@ -15,7 +15,6 @@
// specific language governing permissions and limitations
// under the License.
-mod cgroup_memory;
pub mod clients;
pub mod cluster;
pub mod consumer_groups;
diff --git a/core/server/src/shard/system/stats.rs
b/core/server/src/shard/system/stats.rs
index 6da51fce0..a1d0114cf 100644
--- a/core/server/src/shard/system/stats.rs
+++ b/core/server/src/shard/system/stats.rs
@@ -16,12 +16,11 @@
// under the License.
use crate::shard::IggyShard;
-use crate::shard::system::cgroup_memory::cgroup_available_memory;
use crate::{SEMANTIC_VERSION, VERSION};
-use cpu_allocation::allowed_cpus;
use iggy_common::{IggyDuration, IggyError, Stats};
use std::cell::RefCell;
-use sysinfo::{Pid, ProcessesToUpdate, System as SysinfoSystem};
+use sysinfo::System as SysinfoSystem;
+use system_stats::SystemProbe;
thread_local! {
static SYSINFO: RefCell<Option<SysinfoSystem>> = const {
RefCell::new(None) };
@@ -31,150 +30,91 @@ impl IggyShard {
pub async fn get_stats(&self) -> Result<Stats, IggyError> {
assert_eq!(self.id, 0, "GetStats should only be called on shard0");
- SYSINFO.with(|sysinfo_cell| {
- let mut sysinfo_opt = sysinfo_cell.borrow_mut();
-
- if sysinfo_opt.is_none() {
- let mut sys = SysinfoSystem::new_all();
- sys.refresh_all();
- *sysinfo_opt = Some(sys);
- }
-
- let sys = sysinfo_opt.as_mut().unwrap();
- let process_id = std::process::id();
- sys.refresh_cpu_all();
- sys.refresh_memory();
-
sys.refresh_processes(ProcessesToUpdate::Some(&[Pid::from_u32(process_id)]),
true);
-
- let total_cpu_usage =
- allowed_cores_cpu_usage(sys).unwrap_or_else(||
sys.global_cpu_usage());
- let total_memory = sys.total_memory().into();
- let available_memory = sys.available_memory().into();
- let clients_count = self.client_manager.get_clients().len() as u32;
- let hostname =
sysinfo::System::host_name().unwrap_or("unknown_hostname".to_string());
- let os_name =
sysinfo::System::name().unwrap_or("unknown_os_name".to_string());
- let os_version =
-
sysinfo::System::long_os_version().unwrap_or("unknown_os_version".to_string());
- let kernel_version =
-
sysinfo::System::kernel_version().unwrap_or("unknown_kernel_version".to_string());
-
- let mut stats = Stats {
- process_id,
- total_cpu_usage,
- total_memory,
- available_memory,
- clients_count,
- hostname,
- os_name,
- os_version,
- kernel_version,
- iggy_server_version: VERSION.to_owned(),
- iggy_server_semver:
SEMANTIC_VERSION.get_numeric_version().ok(),
- ..Default::default()
- };
-
- if let Some(process) = sys
- .processes()
- .values()
- .find(|p| p.pid() == Pid::from_u32(process_id))
- {
- stats.process_id = process.pid().as_u32();
- stats.cpu_usage = process.cpu_usage();
- stats.memory_usage = process.memory().into();
- stats.run_time =
IggyDuration::new_from_secs(process.run_time());
- stats.start_time =
IggyDuration::new_from_secs(process.start_time())
- .as_micros()
- .into();
-
- let disk_usage = process.disk_usage();
- stats.read_bytes = disk_usage.total_read_bytes.into();
- stats.written_bytes = disk_usage.total_written_bytes.into();
-
- stats.threads_count = process.tasks().map(|t| t.len() as
u32).unwrap_or(0);
-
- if let Some(limits) = process
- .cgroup_limits()
- .filter(|limits| limits.total_memory < sys.total_memory())
- {
- stats.total_memory = limits.total_memory.into();
- stats.available_memory =
cgroup_available_memory(sys.total_memory())
- .unwrap_or(limits.free_memory)
- .min(limits.total_memory)
- .into();
- }
- }
-
- let (streams_count, topics_count, partitions_count,
consumer_groups_count, stream_ids) =
- self.metadata.with_metadata(|m| {
- let mut topics = 0u32;
- let mut partitions = 0u32;
- let mut cg = 0u32;
- let ids: Vec<_> = m.streams.iter().map(|(k, _)|
k).collect();
- for (_, stream) in m.streams.iter() {
- topics += stream.topics.len() as u32;
- for (_, topic) in stream.topics.iter() {
- partitions += topic.partitions.len() as u32;
- cg += topic.consumer_groups.len() as u32;
- }
+ let probe = SYSINFO.with_borrow_mut(|slot| {
+ let sys = slot.get_or_insert_with(SysinfoSystem::new);
+ SystemProbe::capture(sys)
+ });
+
+ let clients_count = self.client_manager.get_clients().len() as u32;
+ let hostname =
sysinfo::System::host_name().unwrap_or("unknown_hostname".to_string());
+ let os_name =
sysinfo::System::name().unwrap_or("unknown_os_name".to_string());
+ let os_version =
+
sysinfo::System::long_os_version().unwrap_or("unknown_os_version".to_string());
+ let kernel_version =
+
sysinfo::System::kernel_version().unwrap_or("unknown_kernel_version".to_string());
+
+ let mut stats = Stats {
+ process_id: probe.process_id,
+ cpu_usage: probe.cpu_usage,
+ total_cpu_usage: probe.total_cpu_usage,
+ memory_usage: probe.memory_usage.into(),
+ total_memory: probe.total_memory.into(),
+ available_memory: probe.available_memory.into(),
+ run_time: IggyDuration::new_from_secs(probe.run_time_secs),
+ start_time: IggyDuration::new_from_secs(probe.start_time_secs)
+ .as_micros()
+ .into(),
+ read_bytes: probe.read_bytes.into(),
+ written_bytes: probe.written_bytes.into(),
+ threads_count: probe.threads_count,
+ clients_count,
+ hostname,
+ os_name,
+ os_version,
+ kernel_version,
+ iggy_server_version: VERSION.to_owned(),
+ iggy_server_semver: SEMANTIC_VERSION.get_numeric_version().ok(),
+ ..Default::default()
+ };
+
+ let (streams_count, topics_count, partitions_count,
consumer_groups_count, stream_ids) =
+ self.metadata.with_metadata(|m| {
+ let mut topics = 0u32;
+ let mut partitions = 0u32;
+ let mut cg = 0u32;
+ let ids: Vec<_> = m.streams.iter().map(|(k, _)| k).collect();
+ for (_, stream) in m.streams.iter() {
+ topics += stream.topics.len() as u32;
+ for (_, topic) in stream.topics.iter() {
+ partitions += topic.partitions.len() as u32;
+ cg += topic.consumer_groups.len() as u32;
}
- (m.streams.len() as u32, topics, partitions, cg, ids)
- });
-
- stats.streams_count = streams_count;
- stats.topics_count = topics_count;
- stats.partitions_count = partitions_count;
- stats.consumer_groups_count = consumer_groups_count;
-
- for stream_id in stream_ids {
- if let Some(stream_stat) =
self.metadata.get_stream_stats(stream_id) {
- stats.messages_count +=
stream_stat.messages_count_inconsistent();
- stats.segments_count +=
stream_stat.segments_count_inconsistent();
- stats.messages_size_bytes +=
stream_stat.size_bytes_inconsistent().into();
}
+ (m.streams.len() as u32, topics, partitions, cg, ids)
+ });
+
+ stats.streams_count = streams_count;
+ stats.topics_count = topics_count;
+ stats.partitions_count = partitions_count;
+ stats.consumer_groups_count = consumer_groups_count;
+
+ for stream_id in stream_ids {
+ if let Some(stream_stat) =
self.metadata.get_stream_stats(stream_id) {
+ stats.messages_count +=
stream_stat.messages_count_inconsistent();
+ stats.segments_count +=
stream_stat.segments_count_inconsistent();
+ stats.messages_size_bytes +=
stream_stat.size_bytes_inconsistent().into();
}
-
- match fs2::available_space(&self.config.system.path) {
- Ok(space) => stats.free_disk_space = space.into(),
- Err(err) => {
- tracing::warn!(
- "Failed to get available disk space for '{}': {err}",
- self.config.system.path
- );
- }
+ }
+
+ match fs2::available_space(&self.config.system.path) {
+ Ok(space) => stats.free_disk_space = space.into(),
+ Err(err) => {
+ tracing::warn!(
+ "Failed to get available disk space for '{}': {err}",
+ self.config.system.path
+ );
}
- match fs2::total_space(&self.config.system.path) {
- Ok(space) => stats.total_disk_space = space.into(),
- Err(err) => {
- tracing::warn!(
- "Failed to get total disk space for '{}': {err}",
- self.config.system.path
- );
- }
+ }
+ match fs2::total_space(&self.config.system.path) {
+ Ok(space) => stats.total_disk_space = space.into(),
+ Err(err) => {
+ tracing::warn!(
+ "Failed to get total disk space for '{}': {err}",
+ self.config.system.path
+ );
}
+ }
- Ok(stats)
- })
+ Ok(stats)
}
}
-
-/// Average usage over the cores this process may run on.
-///
-/// `global_cpu_usage` averages every host core, so a cpuset-confined
-/// instance would report its neighbors' load. `None` when the process
-/// is unrestricted (or an allowed core is missing from `sys.cpus()`),
-/// where the host-global number is already the right one.
-fn allowed_cores_cpu_usage(sys: &SysinfoSystem) -> Option<f32> {
- let cpus = sys.cpus();
- let allowed = allowed_cpus();
- if allowed.is_empty() || allowed.len() >= cpus.len() {
- return None;
- }
-
- let mut total_usage = 0.0f32;
- for cpu_id in &allowed {
- let name = format!("cpu{cpu_id}");
- total_usage += cpus.iter().find(|cpu| cpu.name() == name)?.cpu_usage();
- }
-
- Some(total_usage / allowed.len() as f32)
-}
diff --git a/core/system_stats/Cargo.toml b/core/system_stats/Cargo.toml
new file mode 100644
index 000000000..c3f596057
--- /dev/null
+++ b/core/system_stats/Cargo.toml
@@ -0,0 +1,31 @@
+# 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.
+
+[package]
+name = "system_stats"
+version = "0.1.0"
+description = "System stats scoped to the resources a process may actually
use: CPU usage over the allowed core set and cgroup-capped memory totals,
shared by the stats endpoints of the servers and the connectors runtime."
+edition = "2024"
+license = "Apache-2.0"
+publish = false
+
+[dependencies]
+cpu_allocation = { workspace = true }
+sysinfo = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/core/server/src/shard/system/cgroup_memory.rs
b/core/system_stats/src/cgroup_memory.rs
similarity index 100%
rename from core/server/src/shard/system/cgroup_memory.rs
rename to core/system_stats/src/cgroup_memory.rs
diff --git a/core/system_stats/src/lib.rs b/core/system_stats/src/lib.rs
new file mode 100644
index 000000000..eb6fd4fa6
--- /dev/null
+++ b/core/system_stats/src/lib.rs
@@ -0,0 +1,259 @@
+// 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.
+
+//! Stats scoped to the resources a process may actually use.
+//!
+//! `sysinfo`'s host-wide numbers describe the whole machine, so a process
+//! confined by a cpuset or a memory-capped cgroup (systemd `AllowedCPUs=` /
+//! `MemoryMax=`, container limits) reports its neighbors' CPU load and a
+//! memory total it can never allocate. On a multi-tenant host that leaks
+//! host sizing to anyone who can read a stats endpoint.
+//! [`SystemProbe::capture`] scopes the numbers to the process's allowed CPU
+//! set and effective cgroup memory cap, and falls back to the host-wide
+//! values when the process is unconfined.
+
+use cpu_allocation::allowed_cpus;
+use std::sync::OnceLock;
+use sysinfo::{Pid, Process, ProcessesToUpdate, System};
+
+mod cgroup_memory;
+
+use cgroup_memory::cgroup_available_memory;
+
+/// One sample of the calling process's resource usage plus system totals
+/// scoped to what the process may actually use.
+///
+/// The CPU fields are deltas over the passed `System`'s refresh history,
+/// so the first capture on a fresh `System` reports zero. `run_time_secs`
+/// and `start_time_secs` are whole seconds (sysinfo's granularity);
+/// callers convert to their wire units.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct SystemProbe {
+ pub process_id: u32,
+ pub cpu_usage: f32,
+ pub total_cpu_usage: f32,
+ pub memory_usage: u64,
+ pub total_memory: u64,
+ pub available_memory: u64,
+ pub run_time_secs: u64,
+ pub start_time_secs: u64,
+ pub read_bytes: u64,
+ pub written_bytes: u64,
+ pub threads_count: u32,
+}
+
+impl SystemProbe {
+ /// Refresh `sys` and sample the calling process.
+ ///
+ /// Refreshes everything it reads, so `System::new()` is enough;
+ /// `System::new_all()` would keep the full host process table alive
+ /// for no benefit. Keep `sys` alive across captures: the CPU numbers
+ /// are deltas since its previous refresh.
+ pub fn capture(sys: &mut System) -> Self {
+ let process_id = std::process::id();
+ let pid = Pid::from_u32(process_id);
+ sys.refresh_cpu_all();
+ sys.refresh_memory();
+ sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
+
+ let mut probe = Self {
+ process_id,
+ cpu_usage: 0.0,
+ total_cpu_usage: scoped_total_cpu_usage(sys),
+ memory_usage: 0,
+ total_memory: sys.total_memory(),
+ available_memory: sys.available_memory(),
+ run_time_secs: 0,
+ start_time_secs: 0,
+ read_bytes: 0,
+ written_bytes: 0,
+ threads_count: 0,
+ };
+
+ if let Some(process) = sys.process(pid) {
+ probe.cpu_usage = process.cpu_usage();
+ probe.memory_usage = process.memory();
+ probe.run_time_secs = process.run_time();
+ probe.start_time_secs = process.start_time();
+ let disk_usage = process.disk_usage();
+ probe.read_bytes = disk_usage.total_read_bytes;
+ probe.written_bytes = disk_usage.total_written_bytes;
+ probe.threads_count = process
+ .tasks()
+ .map_or(0, |tasks|
u32::try_from(tasks.len()).unwrap_or(u32::MAX));
+
+ if let Some(memory) = cgroup_scoped_memory(sys, process) {
+ probe.total_memory = memory.total;
+ probe.available_memory = memory.available;
+ }
+ }
+
+ probe
+ }
+}
+
+static ALLOWED_CPUS: OnceLock<Vec<usize>> = OnceLock::new();
+
+/// Snapshot the process's allowed CPU set for the scoped total CPU usage.
+///
+/// Call once from the main thread at startup, before any thread pins
+/// itself to a core: `sched_getaffinity` reports the calling thread's
+/// mask, so a capture from a pinned shard thread would record that single
+/// core as the whole process's set.
+pub fn capture_allowed_cpus() {
+ ALLOWED_CPUS.get_or_init(allowed_cpus);
+}
+
+/// Memory totals scoped to the process's effective cgroup cap.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct CgroupMemory {
+ total: u64,
+ available: u64,
+}
+
+/// Average CPU usage over the cores this process may run on, or the
+/// host-global average when the process is unrestricted.
+///
+/// `global_cpu_usage` averages every host core, so a cpuset-confined
+/// process would report its neighbors' load. The allowed set is the
+/// [`capture_allowed_cpus`] boot snapshot; without one this stays on the
+/// host-global average.
+fn scoped_total_cpu_usage(sys: &System) -> f32 {
+ ALLOWED_CPUS
+ .get()
+ .and_then(|allowed| allowed_cores_cpu_usage(sys, allowed))
+ .unwrap_or_else(|| sys.global_cpu_usage())
+}
+
+/// Memory totals scoped to the process's memory cgroup. `None` when no
+/// ancestor caps memory below the host total; callers keep the host
+/// numbers then.
+///
+/// `process` must be the calling process: the `available` refinement
+/// walks `/proc/self/cgroup`, so a foreign process would silently get
+/// self's reclaim numbers.
+///
+/// `available` adds reclaimable file cache back rather than taking the
+/// kernel's `limit - current`, which trends toward zero on a cache-heavy
+/// workload long before real OOM pressure.
+fn cgroup_scoped_memory(sys: &System, process: &Process) ->
Option<CgroupMemory> {
+ let limits = process
+ .cgroup_limits()
+ .filter(|limits| limits.total_memory < sys.total_memory())?;
+ let available = cgroup_available_memory(sys.total_memory())
+ .unwrap_or(limits.free_memory)
+ .min(limits.total_memory);
+ Some(CgroupMemory {
+ total: limits.total_memory,
+ available,
+ })
+}
+
+/// `None` when the allowed set is not a strict subset of the host's cores
+/// (or an allowed core is missing from `sys.cpus()`), where the
+/// host-global number is already the right one.
+fn allowed_cores_cpu_usage(sys: &System, allowed: &[usize]) -> Option<f32> {
+ let cpus = sys.cpus();
+ if allowed.is_empty() || allowed.len() >= cpus.len() {
+ return None;
+ }
+
+ let mut total_usage = 0.0f32;
+ for cpu_id in allowed {
+ let name = format!("cpu{cpu_id}");
+ total_usage += cpus.iter().find(|cpu| cpu.name() == name)?.cpu_usage();
+ }
+
+ Some(total_usage / allowed.len() as f32)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use sysinfo::{Pid, ProcessesToUpdate};
+
+ #[test]
+ fn given_fresh_system_when_capturing_probe_should_sample_own_process() {
+ let mut sys = System::new();
+
+ let probe = SystemProbe::capture(&mut sys);
+
+ assert_eq!(probe.process_id, std::process::id());
+ assert!(probe.memory_usage > 0);
+ assert!(probe.total_memory > 0);
+ assert!(probe.available_memory <= probe.total_memory);
+ }
+
+ #[test]
+ fn
given_unrefreshed_system_when_probing_scoped_cpu_should_fall_back_to_global() {
+ let sys = System::new();
+
+ assert_eq!(scoped_total_cpu_usage(&sys), sys.global_cpu_usage());
+ }
+
+ // Linux-only: the by-name lookup relies on the kernel's cpu0..cpuN
+ // naming, which is also the only platform where confinement exists.
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn
given_allowed_core_subset_when_probing_scoped_cpu_should_average_those_cores() {
+ let mut sys = System::new();
+ sys.refresh_cpu_all();
+ std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
+ sys.refresh_cpu_all();
+
+ let cpus = sys.cpus();
+ if cpus.len() < 2 {
+ return;
+ }
+
+ let allowed: Vec<usize> = (0..cpus.len() - 1).collect();
+ let expected = allowed
+ .iter()
+ .map(|cpu_id| {
+ cpus.iter()
+ .find(|cpu| cpu.name() == format!("cpu{cpu_id}"))
+ .expect("linux names cores cpu0..cpuN")
+ .cpu_usage()
+ })
+ .sum::<f32>()
+ / allowed.len() as f32;
+
+ assert_eq!(allowed_cores_cpu_usage(&sys, &allowed), Some(expected));
+ }
+
+ #[test]
+ fn
given_own_process_when_probing_cgroup_memory_should_reflect_confinement() {
+ let mut sys = System::new();
+ sys.refresh_memory();
+ let pid = Pid::from_u32(std::process::id());
+ sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
+ let process = sys.process(pid).expect("own process must be visible");
+
+ match cgroup_scoped_memory(&sys, process) {
+ Some(memory) => {
+ assert!(memory.total < sys.total_memory());
+ assert!(memory.available <= memory.total);
+ }
+ None => assert!(
+ process
+ .cgroup_limits()
+ .is_none_or(|limits| limits.total_memory >=
sys.total_memory()),
+ "None must mean no ancestor caps memory below the host total"
+ ),
+ }
+ }
+}