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 ebaffde44 feat(server): respect CPU affinity and cgroup limits in
shards and stats (#3615)
ebaffde44 is described below
commit ebaffde44afb46f792a512a044ec7553c922faf2
Author: Maciej Modzelewski <[email protected]>
AuthorDate: Wed Jul 8 11:43:54 2026 +0200
feat(server): respect CPU affinity and cgroup limits in shards and stats
(#3615)
The server assumed it owned the whole host. Shards pinned to
absolute core ids 0..n, which fails with EINVAL (or lands on
forbidden cores) under systemd AllowedCPUs=, container cpusets,
or taskset. Stats reported host-wide CPU and memory, so a
confined instance showed its neighbors' load and a meaningless
host total.
Draw pinned cores from the process's allowed CPU set
(sched_getaffinity) instead, and validate configured ranges
against it at boot. A new pin_cores knob (default true) turns
pinning off for shared-core hosts (cgroup CPU quotas), where
every tenant pinning to the same low-numbered cores would pile
onto one core; unpinned shards let the kernel scheduler place
threads freely.
Stats now scope total_cpu_usage to the allowed cores and report
the effective cgroup memory limit as total_memory. Available
memory adds reclaimable file cache back
(limit - (current - inactive_file - active_file), minimum over
capped ancestors, v1 and v2), since memory.current charges page
cache as used and the naive limit - current trends to zero on a
cache-heavy server long before real OOM pressure.
---
Cargo.lock | 2 +
core/common/src/types/stats/mod.rs | 8 +-
core/configs/src/server_config/sharding.rs | 17 ++
core/configs/src/server_config/validators.rs | 84 +++++++-
core/cpu_allocation/Cargo.toml | 5 +-
core/cpu_allocation/src/allowed_cpus.rs | 59 ++++++
core/cpu_allocation/src/lib.rs | 8 +-
core/server-ng/config.toml | 9 +
core/server-ng/src/bootstrap.rs | 53 +++--
core/server/Cargo.toml | 1 +
core/server/config.toml | 8 +
core/server/src/main.rs | 5 +-
core/server/src/shard/system/cgroup_memory.rs | 293 ++++++++++++++++++++++++++
core/server/src/shard/system/mod.rs | 1 +
core/server/src/shard/system/stats.rs | 38 +++-
core/shard_allocator/src/lib.rs | 212 +++++++++++++++++--
16 files changed, 753 insertions(+), 50 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 4e7a3fce9..ed13f356d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3349,6 +3349,7 @@ dependencies = [
name = "cpu_allocation"
version = "0.1.0"
dependencies = [
+ "nix",
"serde",
"serde_json",
]
@@ -11679,6 +11680,7 @@ dependencies = [
"clap",
"compio",
"configs",
+ "cpu_allocation",
"ctrlc",
"cyper",
"cyper-axum",
diff --git a/core/common/src/types/stats/mod.rs
b/core/common/src/types/stats/mod.rs
index fa848858d..5de7f03b4 100644
--- a/core/common/src/types/stats/mod.rs
+++ b/core/common/src/types/stats/mod.rs
@@ -26,13 +26,15 @@ pub struct Stats {
pub process_id: u32,
/// The CPU usage of the process.
pub cpu_usage: f32,
- /// the total CPU usage of the system.
+ /// 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 process.
pub memory_usage: IggyByteSize,
- /// The total memory of the system.
+ /// The total memory of the system, or the effective cgroup memory limit
when the
+ /// server runs inside a memory-capped cgroup (container, systemd slice).
pub total_memory: IggyByteSize,
- /// The available memory of the system.
+ /// The available memory of the system, scoped to the cgroup limit when
one applies.
pub available_memory: IggyByteSize,
/// The run time of the process.
pub run_time: IggyDuration,
diff --git a/core/configs/src/server_config/sharding.rs
b/core/configs/src/server_config/sharding.rs
index 0d0c1fc6e..2f23cfdb7 100644
--- a/core/configs/src/server_config/sharding.rs
+++ b/core/configs/src/server_config/sharding.rs
@@ -101,6 +101,10 @@ const fn default_inbox_capacity() -> usize {
DEFAULT_INBOX_CAPACITY
}
+const fn default_pin_cores() -> bool {
+ true
+}
+
fn default_shutdown_drain_timeout() -> IggyDuration {
IggyDuration::new(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
}
@@ -119,6 +123,18 @@ pub struct ShardingConfig {
#[serde(default)]
#[config_env(leaf)]
pub cpu_allocation: CpuAllocation,
+ /// Whether shard threads are pinned to dedicated CPU cores
+ /// (`sched_setaffinity`). Pinning maximizes cache locality when this
+ /// server owns its cores (dedicated host, `numa:` allocations). Set to
+ /// `false` when the server shares cores with other workloads — e.g. a
+ /// multi-tenant host slicing CPU via cgroup quotas — where every process
+ /// pinning to the same low-numbered cores would pile onto one core while
+ /// the rest sit idle; unpinned shards let the kernel scheduler place
+ /// threads freely within the allowed set. With a NUMA-aware allocation,
+ /// `false` drops both the CPU and memory-node bindings (and logs a
+ /// warning, since NUMA placement without pinning is meaningless).
+ #[serde(default = "default_pin_cores")]
+ pub pin_cores: bool,
/// Per-shard inter-shard inbox channel capacity. Bounded by design.
/// Drops on full inbox of consensus frames are recovered by VSR
/// retransmit. Drops of cross-shard client Reply frames are terminal:
@@ -176,6 +192,7 @@ impl Default for ShardingConfig {
fn default() -> Self {
Self {
cpu_allocation: CpuAllocation::default(),
+ pin_cores: default_pin_cores(),
inbox_capacity: DEFAULT_INBOX_CAPACITY,
shutdown_drain_timeout: default_shutdown_drain_timeout(),
shutdown_poll_interval: default_shutdown_poll_interval(),
diff --git a/core/configs/src/server_config/validators.rs
b/core/configs/src/server_config/validators.rs
index 0aafe1ef5..ca8410e28 100644
--- a/core/configs/src/server_config/validators.rs
+++ b/core/configs/src/server_config/validators.rs
@@ -28,6 +28,7 @@ use super::sharding::{
use super::system::SegmentConfig;
use super::system::{CompressionConfig, LoggingConfig, PartitionConfig};
use crate::ConfigurationError;
+use cpu_allocation::allowed_cpus;
use err_trail::ErrContext;
use iggy_common::CompressionAlgorithm;
use iggy_common::IggyExpiry;
@@ -488,9 +489,20 @@ impl Validatable<ConfigurationError> for ShardingConfig {
);
return Err(ConfigurationError::InvalidConfigurationValue);
}
- if *end > available_cpus {
+ if *end - *start > available_cpus {
eprintln!(
- "Invalid sharding configuration: cpu_allocation range
{start}..{end} exceeds available CPU cores (max: {available_cpus})"
+ "Invalid sharding configuration: cpu_allocation range
{start}..{end} yields {} shards, exceeding available CPU cores
{available_cpus}",
+ *end - *start
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
+ if !self.pin_cores {
+ return Ok(());
+ }
+ let allowed = allowed_cpus();
+ if let Some(cpu) = (*start..*end).find(|cpu|
!allowed.contains(cpu)) {
+ eprintln!(
+ "Invalid sharding configuration: cpu_allocation range
{start}..{end} includes CPU {cpu}, which is outside the set of cores allowed
for this process (affinity/cpuset mask)"
);
return Err(ConfigurationError::InvalidConfigurationValue);
}
@@ -1008,3 +1020,71 @@ mod sharding_shutdown_knob_tests {
assert!(cfg.validate().is_err());
}
}
+
+#[cfg(test)]
+mod sharding_cpu_range_tests {
+ use super::*;
+
+ #[test]
+ fn inverted_range_is_rejected() {
+ let cfg = ShardingConfig {
+ cpu_allocation: CpuAllocation::Range(2, 2),
+ ..ShardingConfig::default()
+ };
+ assert!(cfg.validate().is_err());
+ }
+
+ #[test]
+ fn pinned_range_within_allowed_set_is_accepted() {
+ let first = allowed_cpus()[0];
+ let cfg = ShardingConfig {
+ cpu_allocation: CpuAllocation::Range(first, first + 1),
+ ..ShardingConfig::default()
+ };
+ assert!(cfg.validate().is_ok());
+ }
+
+ #[test]
+ fn pinned_range_outside_allowed_set_is_rejected() {
+ let past_last = allowed_cpus().last().copied().unwrap() + 1;
+ let cfg = ShardingConfig {
+ cpu_allocation: CpuAllocation::Range(past_last, past_last + 1),
+ ..ShardingConfig::default()
+ };
+ assert!(cfg.validate().is_err());
+ }
+
+ #[test]
+ fn pinned_range_wider_than_parallelism_is_rejected() {
+ // Under a cgroup CPU quota the affinity mask stays full while
+ // `available_parallelism` shrinks, so membership alone would
+ // accept this; the shard-count cap must reject it.
+ let first = allowed_cpus()[0];
+ let available = available_parallelism().unwrap().get();
+ let cfg = ShardingConfig {
+ cpu_allocation: CpuAllocation::Range(first, first + available + 1),
+ ..ShardingConfig::default()
+ };
+ assert!(cfg.validate().is_err());
+ }
+
+ #[test]
+ fn unpinned_range_is_capped_by_shard_count_not_core_ids() {
+ // Core ids outside the machine are fine unpinned; only the
+ // resulting shard count matters.
+ let cfg = ShardingConfig {
+ cpu_allocation: CpuAllocation::Range(1 << 20, (1 << 20) + 1),
+ pin_cores: false,
+ ..ShardingConfig::default()
+ };
+ assert!(cfg.validate().is_ok());
+
+ let available = available_parallelism().unwrap().get();
+ let cfg = ShardingConfig {
+ cpu_allocation: CpuAllocation::Range(0, available + 1),
+ pin_cores: false,
+ ..ShardingConfig::default()
+ };
+ assert!(cfg.validate().is_err());
+ }
+}
diff --git a/core/cpu_allocation/Cargo.toml b/core/cpu_allocation/Cargo.toml
index 74eea3230..24cfb9b17 100644
--- a/core/cpu_allocation/Cargo.toml
+++ b/core/cpu_allocation/Cargo.toml
@@ -18,7 +18,7 @@
[package]
name = "cpu_allocation"
version = "0.1.0"
-description = "Shard CPU/NUMA allocation config types (CpuAllocation,
NumaConfig) parsed from the iggy server config."
+description = "Shard CPU/NUMA allocation config types (CpuAllocation,
NumaConfig) parsed from the iggy server config, plus the allowed-CPU-set probe."
edition = "2024"
license = "Apache-2.0"
publish = false
@@ -26,5 +26,8 @@ publish = false
[dependencies]
serde = { workspace = true }
+[target.'cfg(target_os = "linux")'.dependencies]
+nix = { workspace = true }
+
[dev-dependencies]
serde_json = { workspace = true }
diff --git a/core/cpu_allocation/src/allowed_cpus.rs
b/core/cpu_allocation/src/allowed_cpus.rs
new file mode 100644
index 000000000..df7fe5490
--- /dev/null
+++ b/core/cpu_allocation/src/allowed_cpus.rs
@@ -0,0 +1,59 @@
+// 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.
+
+#[cfg(target_os = "linux")]
+use nix::{
+ sched::{CpuSet, sched_getaffinity},
+ unistd::Pid,
+};
+use std::thread::available_parallelism;
+
+/// CPUs the calling process is currently allowed to run on, ascending.
+///
+/// Respects restrictions imposed by the parent environment (systemd
+/// `AllowedCPUs=`, container cpusets, `taskset`), which absolute core ids
+/// `0..n` would silently violate: `sched_setaffinity` to a core outside the
+/// allowed set fails with `EINVAL`. Falls back to `0..available_parallelism()`
+/// where the affinity mask is unavailable (non-Linux).
+pub fn allowed_cpus() -> Vec<usize> {
+ #[cfg(target_os = "linux")]
+ {
+ if let Ok(mask) = sched_getaffinity(Pid::from_raw(0)) {
+ let cpus: Vec<usize> = (0..CpuSet::count())
+ .filter(|&cpu| mask.is_set(cpu).unwrap_or(false))
+ .collect();
+ if !cpus.is_empty() {
+ return cpus;
+ }
+ }
+ }
+
+ let fallback = available_parallelism().map(|n| n.get()).unwrap_or(1);
+ (0..fallback).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn allowed_cpus_is_non_empty_and_ascending() {
+ let allowed = allowed_cpus();
+ assert!(!allowed.is_empty());
+ assert!(allowed.windows(2).all(|pair| pair[0] < pair[1]));
+ }
+}
diff --git a/core/cpu_allocation/src/lib.rs b/core/cpu_allocation/src/lib.rs
index 5391e73b1..48719f889 100644
--- a/core/cpu_allocation/src/lib.rs
+++ b/core/cpu_allocation/src/lib.rs
@@ -22,11 +22,17 @@
//! the server config (TOML). The config crate re-exports them, and the
//! `shard_allocator` crate turns them into a real plan. Kept in their
//! own little crate so neither side has to pull in the other's heavy
-//! dependencies just to share two small enums.
+//! dependencies just to share two small enums. Also home to
+//! [`allowed_cpus`], the probe for the process's allowed CPU set, which
+//! both sides consult when validating and pinning cores.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::str::FromStr;
+mod allowed_cpus;
+
+pub use allowed_cpus::allowed_cpus;
+
/// Tell server how many CPU cores to grab for shards, and how.
///
/// Server make one shard per core. This say which cores. Pick one:
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index 11de70d34..70af981f5 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -630,6 +630,15 @@ ports = { tcp = 8091, quic = 8081, http = 3001, websocket
= 8093, tcp_replica =
# TODO(hubcio): revert to "numa:auto" once multi-shard server-ng is stable.
cpu_allocation = 1
+# Whether shard threads are pinned to dedicated CPU cores (default: true).
+# Pinned cores are drawn from the process's allowed CPU set (affinity/cpuset
+# mask), so the server cooperates with systemd `AllowedCPUs=` and container
+# cpusets. Set to false when the server shares cores with other workloads
+# (e.g. a multi-tenant host slicing CPU via cgroup quotas): unpinned shards
+# let the kernel scheduler place threads freely instead of piling every
+# process onto the same low-numbered cores.
+# pin_cores = true
+
# Wall-clock budget for a single shard's bus drain on shutdown. Drives
# the per-shard watchdog and the parallel-join survivor path; sized
# larger than typical TCP RTT times in-flight write-batch so writers
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index ca7710148..1c3a6799a 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -451,6 +451,35 @@ pub async fn load_config(logging: &mut Logging) ->
Result<ServerNgConfig, Server
Ok(config)
}
+/// Resolve the operator's `cpu_allocation` into concrete shard
+/// assignments plus the checked `u16` shard count.
+///
+/// Shard ids index `ReplicaOwnerTable` slots as `u16`. `OWNER_NONE`
+/// (`u16::MAX`) is reserved as the empty-slot sentinel, so a server
+/// configured with `u16::MAX` shards would mint a shard id that
+/// collides with the sentinel and an owner-table lookup could never
+/// tell that shard apart from an unowned slot. Reject at boot so the
+/// invariant is held by the type system, not by hoping the operator
+/// never configures 65535 cores worth of shards.
+fn resolve_shard_assignments(
+ sharding: &configs::sharding::ShardingConfig,
+) -> Result<(Vec<ShardInfo>, u16), ServerNgError> {
+ let allocator = ShardAllocator::new(&sharding.cpu_allocation,
sharding.pin_cores)
+ .map_err(ServerNgError::ShardAllocator)?;
+ let assignments = allocator
+ .to_shard_assignments()
+ .map_err(ServerNgError::ShardAllocator)?;
+ if assignments.is_empty() {
+ return Err(ServerNgError::ShardsCountZero);
+ }
+ match u16::try_from(assignments.len()) {
+ Ok(count) if count < message_bus::OWNER_NONE => Ok((assignments,
count)),
+ _ => Err(ServerNgError::ShardsCountOverflow {
+ count: assignments.len(),
+ }),
+ }
+}
+
/// Re-validate the runtime sharding knobs that the per-shard runtime
/// consumes directly. Mirrors `ShardingConfig::validate` so a caller
/// that built the config without running it (e.g. tests, embedded
@@ -523,30 +552,8 @@ pub fn bootstrap(
current_replica_id: Option<u8>,
) -> Result<ShardHandles, ServerNgError> {
warm_dummy_password_hash();
- let allocator = ShardAllocator::new(&config.system.sharding.cpu_allocation)
- .map_err(ServerNgError::ShardAllocator)?;
- let assignments = allocator
- .to_shard_assignments()
- .map_err(ServerNgError::ShardAllocator)?;
+ let (assignments, total_shards) =
resolve_shard_assignments(&config.system.sharding)?;
let shards_count = assignments.len();
- if shards_count == 0 {
- return Err(ServerNgError::ShardsCountZero);
- }
- // Shard ids index `ReplicaOwnerTable` slots as `u16`. `OWNER_NONE`
- // (`u16::MAX`) is reserved as the empty-slot sentinel, so a server
- // configured with `u16::MAX` shards would mint a shard id that
- // collides with the sentinel and an owner-table lookup could never
- // tell that shard apart from an unowned slot. Reject at boot so the
- // invariant is held by the type system above this line, not by hoping
- // the operator never configures 65535 cores worth of shards.
- let total_shards = match u16::try_from(shards_count) {
- Ok(count) if count < message_bus::OWNER_NONE => count,
- _ => {
- return Err(ServerNgError::ShardsCountOverflow {
- count: shards_count,
- });
- }
- };
// Re-check the full valid range, not just the zero floor: a caller
// that built the config without running `ShardingConfig::validate`
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index c2f11503c..cb8f78640 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -48,6 +48,7 @@ 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 }
diff --git a/core/server/config.toml b/core/server/config.toml
index d6d0bdf24..b31bf8a1c 100644
--- a/core/server/config.toml
+++ b/core/server/config.toml
@@ -592,6 +592,14 @@ ports = { tcp = 8091, quic = 8081, http = 3001, websocket
= 8093, tcp_replica =
# + "numa:auto": Use all available numa node, cores
# + "numa:nodes=0,1;cores=4;no_ht=true": Use NUMA node 0 and 1, each nodes
use 4 cores, and no hyperthreads
cpu_allocation = "numa:auto"
+# Whether shard threads are pinned to dedicated CPU cores (default: true).
+# Pinned cores are drawn from the process's allowed CPU set (affinity/cpuset
+# mask), so the server cooperates with systemd `AllowedCPUs=` and container
+# cpusets. Set to false when the server shares cores with other workloads
+# (e.g. a multi-tenant host slicing CPU via cgroup quotas): unpinned shards
+# let the kernel scheduler place threads freely instead of piling every
+# process onto the same low-numbered cores.
+# pin_cores = true
[websocket]
enabled = true
diff --git a/core/server/src/main.rs b/core/server/src/main.rs
index 542ec354a..2f598c75c 100644
--- a/core/server/src/main.rs
+++ b/core/server/src/main.rs
@@ -303,7 +303,10 @@ fn main() -> Result<(), ServerError> {
);
// ELEVENTH DISCRETE LOADING STEP.
- let shard_allocator =
ShardAllocator::new(&config.system.sharding.cpu_allocation)?;
+ let shard_allocator = ShardAllocator::new(
+ &config.system.sharding.cpu_allocation,
+ config.system.sharding.pin_cores,
+ )?;
let shard_assignment = shard_allocator.to_shard_assignments()?;
#[cfg(feature = "disable-mimalloc")]
diff --git a/core/server/src/shard/system/cgroup_memory.rs
b/core/server/src/shard/system/cgroup_memory.rs
new file mode 100644
index 000000000..35a6d9df8
--- /dev/null
+++ b/core/server/src/shard/system/cgroup_memory.rs
@@ -0,0 +1,293 @@
+// 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.
+
+//! MemAvailable-equivalent headroom for a memory-capped cgroup.
+//!
+//! `memory.current` charges reclaimable page cache as used, so
+//! `limit - current` trends toward zero on a cache-heavy server even
+//! though the kernel reclaims that cache long before OOM. The honest
+//! number adds the reclaimable file cache back:
+//! `limit - (current - inactive_file - active_file)`, evaluated at
+//! every capped ancestor with the minimum kept, since a parent's cap
+//! binds descendants even when the leaf itself is unlimited.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// Reclaimable-aware available memory for the calling process's memory
+/// cgroup. `None` when no ancestor caps memory below the host total or
+/// the cgroup fs is unreadable; callers keep their fallback then.
+pub fn cgroup_available_memory(host_total_memory: u64) -> Option<u64> {
+ let cgroup = fs::read_to_string("/proc/self/cgroup").ok()?;
+
+ let from_v2 = v2_cgroup_path(&cgroup).and_then(|path| {
+ let root = Path::new("/sys/fs/cgroup");
+ available_memory_within(&root.join(path), root, &CGROUP_V2,
host_total_memory)
+ });
+ if from_v2.is_some() {
+ return from_v2;
+ }
+
+ v1_cgroup_path(&cgroup).and_then(|path| {
+ let root = Path::new("/sys/fs/cgroup/memory");
+ available_memory_within(&root.join(path), root, &CGROUP_V1,
host_total_memory)
+ })
+}
+
+/// v1 and v2 name the limit/usage files differently, and v1 needs the
+/// `total_`-prefixed keys for hierarchical (descendants included) stats,
+/// which v2 reports by default.
+struct CgroupMemoryFiles {
+ limit: &'static str,
+ usage: &'static str,
+ reclaimable_keys: [&'static str; 2],
+}
+
+const CGROUP_V2: CgroupMemoryFiles = CgroupMemoryFiles {
+ limit: "memory.max",
+ usage: "memory.current",
+ reclaimable_keys: ["inactive_file", "active_file"],
+};
+
+const CGROUP_V1: CgroupMemoryFiles = CgroupMemoryFiles {
+ limit: "memory.limit_in_bytes",
+ usage: "memory.usage_in_bytes",
+ reclaimable_keys: ["total_inactive_file", "total_active_file"],
+};
+
+fn available_memory_within(
+ base: &Path,
+ root: &Path,
+ files: &CgroupMemoryFiles,
+ host_total_memory: u64,
+) -> Option<u64> {
+ let mut available: Option<u64> = None;
+
+ for level in base.ancestors() {
+ // An unlimited level reads as "max" (v2, unparsable) or a value
+ // beyond the host total (v1); neither constrains anything.
+ if let Some(limit) =
+ read_u64(&level.join(files.limit)).filter(|limit| *limit <=
host_total_memory)
+ {
+ let usage = read_u64(&level.join(files.usage))?;
+ let reclaimable =
+ read_reclaimable(&level.join("memory.stat"),
&files.reclaimable_keys)?;
+ let level_available =
limit.saturating_sub(usage.saturating_sub(reclaimable));
+ available =
+ Some(available.map_or(level_available, |tightest|
tightest.min(level_available)));
+ }
+ if level == root {
+ break;
+ }
+ }
+
+ available
+}
+
+fn v2_cgroup_path(cgroup: &str) -> Option<PathBuf> {
+ cgroup_relative_path(cgroup, |hierarchy_id, controllers| {
+ hierarchy_id == "0" && controllers.is_empty()
+ })
+}
+
+fn v1_cgroup_path(cgroup: &str) -> Option<PathBuf> {
+ cgroup_relative_path(cgroup, |_, controllers| {
+ controllers
+ .split(',')
+ .any(|controller| controller == "memory")
+ })
+}
+
+fn cgroup_relative_path(
+ cgroup: &str,
+ line_matches: impl Fn(&str, &str) -> bool,
+) -> Option<PathBuf> {
+ cgroup.lines().find_map(|line| {
+ let mut fields = line.splitn(3, ':');
+ let hierarchy_id = fields.next()?;
+ let controllers = fields.next()?;
+ let path = fields.next()?;
+
+ if !line_matches(hierarchy_id, controllers) {
+ return None;
+ }
+
+ Some(Path::new(path).strip_prefix("/").ok()?.to_path_buf())
+ })
+}
+
+fn read_u64(path: &Path) -> Option<u64> {
+ fs::read_to_string(path).ok()?.trim().parse().ok()
+}
+
+/// A key absent from `memory.stat` counts as zero (kernels omit
+/// feature-gated counters); only an unreadable file yields `None`.
+fn read_reclaimable(stat_path: &Path, keys: &[&'static str; 2]) -> Option<u64>
{
+ let stat = fs::read_to_string(stat_path).ok()?;
+ let mut reclaimable = 0u64;
+
+ for key in keys {
+ let value = stat.lines().find_map(|line| {
+ let (name, value) = line.split_once(' ')?;
+ if name != *key {
+ return None;
+ }
+ value.trim().parse::<u64>().ok()
+ });
+ reclaimable = reclaimable.saturating_add(value.unwrap_or(0));
+ }
+
+ Some(reclaimable)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs::{create_dir_all, write};
+ use tempfile::tempdir;
+
+ const HOST_TOTAL: u64 = 10_000;
+
+ #[test]
+ fn v2_available_memory_adds_reclaimable_file_cache_back() {
+ let root = tempdir().unwrap();
+ let leaf = root.path().join("iggy.slice");
+ create_dir_all(&leaf).unwrap();
+ write(root.path().join("memory.max"), "max").unwrap();
+ write(leaf.join("memory.max"), "1000").unwrap();
+ write(leaf.join("memory.current"), "900").unwrap();
+ write(
+ leaf.join("memory.stat"),
+ "anon 300\ninactive_file 400\nactive_file 100\n",
+ )
+ .unwrap();
+
+ let available = available_memory_within(&leaf, root.path(),
&CGROUP_V2, HOST_TOTAL);
+
+ // limit - current would report 100; 500 of the 900 is reclaimable
cache.
+ assert_eq!(available, Some(600));
+ }
+
+ #[test]
+ fn v2_tighter_parent_cap_wins_over_unlimited_leaf() {
+ let root = tempdir().unwrap();
+ let parent = root.path().join("parent");
+ let leaf = parent.join("leaf");
+ create_dir_all(&leaf).unwrap();
+ write(root.path().join("memory.max"), "max").unwrap();
+ write(parent.join("memory.max"), "500").unwrap();
+ write(parent.join("memory.current"), "450").unwrap();
+ write(
+ parent.join("memory.stat"),
+ "inactive_file 50\nactive_file 0\n",
+ )
+ .unwrap();
+ write(leaf.join("memory.max"), "max").unwrap();
+ write(leaf.join("memory.current"), "100").unwrap();
+ write(
+ leaf.join("memory.stat"),
+ "inactive_file 10\nactive_file 0\n",
+ )
+ .unwrap();
+
+ let available = available_memory_within(&leaf, root.path(),
&CGROUP_V2, HOST_TOTAL);
+
+ assert_eq!(available, Some(100));
+ }
+
+ #[test]
+ fn v2_missing_reclaimable_key_counts_as_zero() {
+ let root = tempdir().unwrap();
+ let leaf = root.path().join("leaf");
+ create_dir_all(&leaf).unwrap();
+ write(root.path().join("memory.max"), "max").unwrap();
+ write(leaf.join("memory.max"), "1000").unwrap();
+ write(leaf.join("memory.current"), "900").unwrap();
+ write(leaf.join("memory.stat"), "anon 300\ninactive_file
400\n").unwrap();
+
+ let available = available_memory_within(&leaf, root.path(),
&CGROUP_V2, HOST_TOTAL);
+
+ assert_eq!(available, Some(500));
+ }
+
+ #[test]
+ fn v2_no_capped_ancestor_yields_none() {
+ let root = tempdir().unwrap();
+ let leaf = root.path().join("leaf");
+ create_dir_all(&leaf).unwrap();
+ write(root.path().join("memory.max"), "max").unwrap();
+ write(leaf.join("memory.max"), "max").unwrap();
+ write(leaf.join("memory.current"), "100").unwrap();
+ write(
+ leaf.join("memory.stat"),
+ "inactive_file 10\nactive_file 0\n",
+ )
+ .unwrap();
+
+ let available = available_memory_within(&leaf, root.path(),
&CGROUP_V2, HOST_TOTAL);
+
+ assert_eq!(available, None);
+ }
+
+ #[test]
+ fn v2_unreadable_usage_at_capped_level_yields_none() {
+ let root = tempdir().unwrap();
+ let leaf = root.path().join("leaf");
+ create_dir_all(&leaf).unwrap();
+ write(root.path().join("memory.max"), "max").unwrap();
+ write(leaf.join("memory.max"), "1000").unwrap();
+
+ let available = available_memory_within(&leaf, root.path(),
&CGROUP_V2, HOST_TOTAL);
+
+ assert_eq!(available, None);
+ }
+
+ #[test]
+ fn v1_available_memory_uses_hierarchical_stat_keys() {
+ let root = tempdir().unwrap();
+ let leaf = root.path().join("iggy");
+ create_dir_all(&leaf).unwrap();
+ write(
+ root.path().join("memory.limit_in_bytes"),
+ u64::MAX.to_string(),
+ )
+ .unwrap();
+ write(leaf.join("memory.limit_in_bytes"), "1000").unwrap();
+ write(leaf.join("memory.usage_in_bytes"), "800").unwrap();
+ write(
+ leaf.join("memory.stat"),
+ "inactive_file 999\ntotal_inactive_file 250\ntotal_active_file
50\n",
+ )
+ .unwrap();
+
+ let available = available_memory_within(&leaf, root.path(),
&CGROUP_V1, HOST_TOTAL);
+
+ assert_eq!(available, Some(500));
+ }
+
+ #[test]
+ fn cgroup_paths_parse_v2_and_v1_lines() {
+ let hybrid =
"12:cpuset:/\n11:memory,cpuacct:/system.slice/iggy.service\n0::/user.slice";
+
+ assert_eq!(v2_cgroup_path(hybrid), Some(PathBuf::from("user.slice")));
+ assert_eq!(
+ v1_cgroup_path(hybrid),
+ Some(PathBuf::from("system.slice/iggy.service"))
+ );
+ assert_eq!(v1_cgroup_path("12:cpuset:/\n10:cpu:/"), None);
+ }
+}
diff --git a/core/server/src/shard/system/mod.rs
b/core/server/src/shard/system/mod.rs
index f326ea08e..907d2c8ab 100644
--- a/core/server/src/shard/system/mod.rs
+++ b/core/server/src/shard/system/mod.rs
@@ -15,6 +15,7 @@
// 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 dc1e92e07..6da51fce0 100644
--- a/core/server/src/shard/system/stats.rs
+++ b/core/server/src/shard/system/stats.rs
@@ -16,7 +16,9 @@
// 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};
@@ -44,7 +46,8 @@ impl IggyShard {
sys.refresh_memory();
sys.refresh_processes(ProcessesToUpdate::Some(&[Pid::from_u32(process_id)]),
true);
- let total_cpu_usage = sys.global_cpu_usage();
+ 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;
@@ -88,6 +91,17 @@ impl IggyShard {
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) =
@@ -142,3 +156,25 @@ impl IggyShard {
})
}
}
+
+/// 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/shard_allocator/src/lib.rs b/core/shard_allocator/src/lib.rs
index b02343a53..c1cf2395a 100644
--- a/core/shard_allocator/src/lib.rs
+++ b/core/shard_allocator/src/lib.rs
@@ -24,7 +24,7 @@
//! shard. On Linux it also pins each shard's thread to its core and
//! pins memory to the right NUMA node, so memory stays close and fast.
-use cpu_allocation::{CpuAllocation, NumaConfig};
+use cpu_allocation::{CpuAllocation, NumaConfig, allowed_cpus};
use hwlocality::Topology;
use hwlocality::bitmap::SpecializedBitmapRef;
use hwlocality::cpu::cpuset::CpuSet;
@@ -61,6 +61,19 @@ pub enum ShardingError {
node: usize,
},
+ #[error(
+ "Requested {requested} shard(s) but only {available} CPU core(s) are
allowed for this process (affinity/cpuset mask)"
+ )]
+ AllowedCpusExceeded { requested: usize, available: usize },
+
+ #[error(
+ "Configured CPU {cpu} is outside the set of cores allowed for this
process (affinity/cpuset mask)"
+ )]
+ CpuNotAllowed { cpu: usize },
+
+ #[error("Invalid CPU range {start}..{end}: start must be less than end")]
+ InvalidRange { start: usize, end: usize },
+
#[error("Invalid NUMA node: requested {requested}, only available
{available} node")]
InvalidNode { requested: usize, available: usize },
@@ -257,17 +270,54 @@ impl ShardInfo {
}
}
+/// One shard per core drawn from `allowed`, each pinned to its own core.
+fn pinned_from_allowed(allowed: &[usize], count: usize) ->
Result<Vec<ShardInfo>, ShardingError> {
+ if count > allowed.len() {
+ return Err(ShardingError::AllowedCpusExceeded {
+ requested: count,
+ available: allowed.len(),
+ });
+ }
+
+ Ok(allowed
+ .iter()
+ .take(count)
+ .map(|&cpu_id| ShardInfo {
+ cpu_set: HashSet::from([cpu_id]),
+ numa_node: None,
+ })
+ .collect())
+}
+
+/// `count` shards with no CPU affinity: `bind_cpu` becomes a no-op and the
+/// kernel scheduler places shard threads freely. This is the right mode when
+/// the process shares its cores with other workloads (e.g. a multi-tenant
+/// host slicing CPU via cgroup quotas), where per-process pinning to the same
+/// low-numbered cores would pile every tenant onto one core.
+fn unpinned(count: usize) -> Vec<ShardInfo> {
+ (0..count)
+ .map(|_| ShardInfo {
+ cpu_set: HashSet::new(),
+ numa_node: None,
+ })
+ .collect()
+}
+
/// Turns the operator's [`CpuAllocation`] choice into a concrete plan
/// of shards. Reads the NUMA topology only when the choice needs it.
pub struct ShardAllocator {
allocation: CpuAllocation,
+ pin_cores: bool,
topology: Option<Arc<NumaTopology>>,
}
impl ShardAllocator {
/// Build an allocator for the given choice. Only `NumaAware` reads
/// the machine topology up front; the simpler modes do not.
- pub fn new(allocation: &CpuAllocation) -> Result<ShardAllocator,
ShardingError> {
+ pub fn new(
+ allocation: &CpuAllocation,
+ pin_cores: bool,
+ ) -> Result<ShardAllocator, ShardingError> {
let topology = if matches!(allocation, CpuAllocation::NumaAware(_)) {
let numa_topology = NumaTopology::detect()?;
@@ -278,6 +328,7 @@ impl ShardAllocator {
Ok(Self {
allocation: allocation.clone(),
+ pin_cores,
topology,
})
}
@@ -287,39 +338,73 @@ impl ShardAllocator {
pub fn to_shard_assignments(&self) -> Result<Vec<ShardInfo>,
ShardingError> {
match &self.allocation {
CpuAllocation::All => {
+ // `available_parallelism` already accounts for both the
+ // affinity mask and any cgroup CPU quota, so a
+ // quota-restricted process gets proportionally fewer shards.
let available_cpus = available_parallelism()
.map_err(|err| ShardingError::Other {
msg: format!("Failed to get available_parallelism:
{:?}", err),
})?
.get();
- let shard_assignments: Vec<_> = (0..available_cpus)
- .map(|cpu_id| ShardInfo {
- cpu_set: HashSet::from([cpu_id]),
- numa_node: None,
- })
- .collect();
+ if !self.pin_cores {
+ info!("Using all available CPU cores ({available_cpus}
shards, unpinned)");
+ return Ok(unpinned(available_cpus));
+ }
+
+ let allowed = allowed_cpus();
+ let shard_assignments =
+ pinned_from_allowed(&allowed,
available_cpus.min(allowed.len()))?;
info!(
- "Using all available CPU cores ({} shards with affinity)",
- shard_assignments.len()
+ "Using all available CPU cores ({} shards pinned within
allowed set {:?})",
+ shard_assignments.len(),
+ allowed
);
Ok(shard_assignments)
}
CpuAllocation::Count(count) => {
- let shard_assignments = (0..*count)
- .map(|cpu_id| ShardInfo {
- cpu_set: HashSet::from([cpu_id]),
- numa_node: None,
- })
- .collect();
+ if !self.pin_cores {
+ info!("Using {count} shard(s), unpinned");
+ return Ok(unpinned(*count));
+ }
- info!("Using {count} shards with affinity to cores
0..{count}");
+ let allowed = allowed_cpus();
+ let shard_assignments = pinned_from_allowed(&allowed, *count)?;
+
+ info!(
+ "Using {count} shard(s) with affinity to cores {:?}",
+ &allowed[..*count]
+ );
Ok(shard_assignments)
}
CpuAllocation::Range(start, end) => {
+ if start >= end {
+ return Err(ShardingError::InvalidRange {
+ start: *start,
+ end: *end,
+ });
+ }
+
+ if !self.pin_cores {
+ info!(
+ "Using {} shard(s) for range {start}..{end}, unpinned",
+ end - start
+ );
+ return Ok(unpinned(end - start));
+ }
+
+ // An explicit range names exact cores and is never remapped
+ // into the allowed set; each core must be a member of it, or
+ // we fail fast instead of letting `sched_setaffinity` EINVAL
+ // later.
+ let allowed = allowed_cpus();
+ if let Some(cpu) = (*start..*end).find(|cpu|
!allowed.contains(cpu)) {
+ return Err(ShardingError::CpuNotAllowed { cpu });
+ }
+
let shard_assignments = (*start..*end)
.map(|cpu_id| ShardInfo {
cpu_set: HashSet::from([cpu_id]),
@@ -336,7 +421,16 @@ impl ShardAllocator {
}
CpuAllocation::NumaAware(numa_config) => {
let topology =
self.topology.as_ref().ok_or(ShardingError::NoTopology)?;
- self.compute_numa_assignments(topology, numa_config)
+ let assignments = self.compute_numa_assignments(topology,
numa_config)?;
+
+ if !self.pin_cores {
+ tracing::warn!(
+ "pin_cores = false with a NUMA-aware cpu_allocation:
NUMA bindings are ignored"
+ );
+ return Ok(unpinned(assignments.len()));
+ }
+
+ Ok(assignments)
}
}
}
@@ -408,3 +502,85 @@ impl ShardAllocator {
Ok(shard_infos)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn pinned_from_allowed_draws_cores_from_the_allowed_set() {
+ let allowed = vec![2, 3, 6, 7];
+ let shards = pinned_from_allowed(&allowed, 2).unwrap();
+ assert_eq!(shards.len(), 2);
+ assert_eq!(shards[0].cpu_set, HashSet::from([2]));
+ assert_eq!(shards[1].cpu_set, HashSet::from([3]));
+ assert!(shards.iter().all(|shard| shard.numa_node.is_none()));
+ }
+
+ #[test]
+ fn pinned_from_allowed_rejects_more_shards_than_allowed_cores() {
+ let allowed = vec![0, 1];
+ let err = pinned_from_allowed(&allowed, 3).unwrap_err();
+ assert!(matches!(
+ err,
+ ShardingError::AllowedCpusExceeded {
+ requested: 3,
+ available: 2,
+ }
+ ));
+ }
+
+ #[test]
+ fn unpinned_shards_have_empty_cpu_sets() {
+ let shards = unpinned(4);
+ assert_eq!(shards.len(), 4);
+ assert!(shards.iter().all(|shard| shard.cpu_set.is_empty()));
+ assert!(shards.iter().all(|shard| shard.numa_node.is_none()));
+ }
+
+ #[test]
+ fn count_without_pinning_yields_unpinned_shards() {
+ let allocator = ShardAllocator::new(&CpuAllocation::Count(1),
false).unwrap();
+ let shards = allocator.to_shard_assignments().unwrap();
+ assert_eq!(shards.len(), 1);
+ assert!(shards[0].cpu_set.is_empty());
+ }
+
+ #[test]
+ fn count_with_pinning_stays_within_the_allowed_set() {
+ let allocator = ShardAllocator::new(&CpuAllocation::Count(1),
true).unwrap();
+ let shards = allocator.to_shard_assignments().unwrap();
+ assert_eq!(shards.len(), 1);
+ let allowed = allowed_cpus();
+ assert!(shards[0].cpu_set.iter().all(|cpu| allowed.contains(cpu)));
+ }
+
+ #[test]
+ fn range_without_pinning_keeps_shard_count() {
+ let allocator = ShardAllocator::new(&CpuAllocation::Range(0, 1),
false).unwrap();
+ let shards = allocator.to_shard_assignments().unwrap();
+ assert_eq!(shards.len(), 1);
+ assert!(shards[0].cpu_set.is_empty());
+ }
+
+ #[test]
+ fn inverted_range_is_rejected_regardless_of_pinning() {
+ for pin_cores in [false, true] {
+ let allocator = ShardAllocator::new(&CpuAllocation::Range(2, 1),
pin_cores).unwrap();
+ let err = allocator.to_shard_assignments().unwrap_err();
+ assert!(matches!(
+ err,
+ ShardingError::InvalidRange { start: 2, end: 1 }
+ ));
+ }
+ }
+
+ #[test]
+ fn bind_cpu_with_empty_set_is_a_no_op() {
+ let shard = ShardInfo {
+ cpu_set: HashSet::new(),
+ numa_node: None,
+ };
+ assert!(shard.bind_cpu().is_ok());
+ }
+}