Skip to content

Commit ebaffde

Browse files
authored
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.
1 parent e195f6c commit ebaffde

16 files changed

Lines changed: 753 additions & 50 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/common/src/types/stats/mod.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ pub struct Stats {
2626
pub process_id: u32,
2727
/// The CPU usage of the process.
2828
pub cpu_usage: f32,
29-
/// the total CPU usage of the system.
29+
/// The total CPU usage of the system, scoped to the cores this process may run on
30+
/// when confined by an affinity/cpuset mask.
3031
pub total_cpu_usage: f32,
3132
/// The memory usage of the process.
3233
pub memory_usage: IggyByteSize,
33-
/// The total memory of the system.
34+
/// The total memory of the system, or the effective cgroup memory limit when the
35+
/// server runs inside a memory-capped cgroup (container, systemd slice).
3436
pub total_memory: IggyByteSize,
35-
/// The available memory of the system.
37+
/// The available memory of the system, scoped to the cgroup limit when one applies.
3638
pub available_memory: IggyByteSize,
3739
/// The run time of the process.
3840
pub run_time: IggyDuration,

core/configs/src/server_config/sharding.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ const fn default_inbox_capacity() -> usize {
101101
DEFAULT_INBOX_CAPACITY
102102
}
103103

104+
const fn default_pin_cores() -> bool {
105+
true
106+
}
107+
104108
fn default_shutdown_drain_timeout() -> IggyDuration {
105109
IggyDuration::new(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
106110
}
@@ -119,6 +123,18 @@ pub struct ShardingConfig {
119123
#[serde(default)]
120124
#[config_env(leaf)]
121125
pub cpu_allocation: CpuAllocation,
126+
/// Whether shard threads are pinned to dedicated CPU cores
127+
/// (`sched_setaffinity`). Pinning maximizes cache locality when this
128+
/// server owns its cores (dedicated host, `numa:` allocations). Set to
129+
/// `false` when the server shares cores with other workloads — e.g. a
130+
/// multi-tenant host slicing CPU via cgroup quotas — where every process
131+
/// pinning to the same low-numbered cores would pile onto one core while
132+
/// the rest sit idle; unpinned shards let the kernel scheduler place
133+
/// threads freely within the allowed set. With a NUMA-aware allocation,
134+
/// `false` drops both the CPU and memory-node bindings (and logs a
135+
/// warning, since NUMA placement without pinning is meaningless).
136+
#[serde(default = "default_pin_cores")]
137+
pub pin_cores: bool,
122138
/// Per-shard inter-shard inbox channel capacity. Bounded by design.
123139
/// Drops on full inbox of consensus frames are recovered by VSR
124140
/// retransmit. Drops of cross-shard client Reply frames are terminal:
@@ -176,6 +192,7 @@ impl Default for ShardingConfig {
176192
fn default() -> Self {
177193
Self {
178194
cpu_allocation: CpuAllocation::default(),
195+
pin_cores: default_pin_cores(),
179196
inbox_capacity: DEFAULT_INBOX_CAPACITY,
180197
shutdown_drain_timeout: default_shutdown_drain_timeout(),
181198
shutdown_poll_interval: default_shutdown_poll_interval(),

core/configs/src/server_config/validators.rs

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use super::sharding::{
2828
use super::system::SegmentConfig;
2929
use super::system::{CompressionConfig, LoggingConfig, PartitionConfig};
3030
use crate::ConfigurationError;
31+
use cpu_allocation::allowed_cpus;
3132
use err_trail::ErrContext;
3233
use iggy_common::CompressionAlgorithm;
3334
use iggy_common::IggyExpiry;
@@ -488,9 +489,20 @@ impl Validatable<ConfigurationError> for ShardingConfig {
488489
);
489490
return Err(ConfigurationError::InvalidConfigurationValue);
490491
}
491-
if *end > available_cpus {
492+
if *end - *start > available_cpus {
492493
eprintln!(
493-
"Invalid sharding configuration: cpu_allocation range {start}..{end} exceeds available CPU cores (max: {available_cpus})"
494+
"Invalid sharding configuration: cpu_allocation range {start}..{end} yields {} shards, exceeding available CPU cores {available_cpus}",
495+
*end - *start
496+
);
497+
return Err(ConfigurationError::InvalidConfigurationValue);
498+
}
499+
if !self.pin_cores {
500+
return Ok(());
501+
}
502+
let allowed = allowed_cpus();
503+
if let Some(cpu) = (*start..*end).find(|cpu| !allowed.contains(cpu)) {
504+
eprintln!(
505+
"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)"
494506
);
495507
return Err(ConfigurationError::InvalidConfigurationValue);
496508
}
@@ -1008,3 +1020,71 @@ mod sharding_shutdown_knob_tests {
10081020
assert!(cfg.validate().is_err());
10091021
}
10101022
}
1023+
1024+
#[cfg(test)]
1025+
mod sharding_cpu_range_tests {
1026+
use super::*;
1027+
1028+
#[test]
1029+
fn inverted_range_is_rejected() {
1030+
let cfg = ShardingConfig {
1031+
cpu_allocation: CpuAllocation::Range(2, 2),
1032+
..ShardingConfig::default()
1033+
};
1034+
assert!(cfg.validate().is_err());
1035+
}
1036+
1037+
#[test]
1038+
fn pinned_range_within_allowed_set_is_accepted() {
1039+
let first = allowed_cpus()[0];
1040+
let cfg = ShardingConfig {
1041+
cpu_allocation: CpuAllocation::Range(first, first + 1),
1042+
..ShardingConfig::default()
1043+
};
1044+
assert!(cfg.validate().is_ok());
1045+
}
1046+
1047+
#[test]
1048+
fn pinned_range_outside_allowed_set_is_rejected() {
1049+
let past_last = allowed_cpus().last().copied().unwrap() + 1;
1050+
let cfg = ShardingConfig {
1051+
cpu_allocation: CpuAllocation::Range(past_last, past_last + 1),
1052+
..ShardingConfig::default()
1053+
};
1054+
assert!(cfg.validate().is_err());
1055+
}
1056+
1057+
#[test]
1058+
fn pinned_range_wider_than_parallelism_is_rejected() {
1059+
// Under a cgroup CPU quota the affinity mask stays full while
1060+
// `available_parallelism` shrinks, so membership alone would
1061+
// accept this; the shard-count cap must reject it.
1062+
let first = allowed_cpus()[0];
1063+
let available = available_parallelism().unwrap().get();
1064+
let cfg = ShardingConfig {
1065+
cpu_allocation: CpuAllocation::Range(first, first + available + 1),
1066+
..ShardingConfig::default()
1067+
};
1068+
assert!(cfg.validate().is_err());
1069+
}
1070+
1071+
#[test]
1072+
fn unpinned_range_is_capped_by_shard_count_not_core_ids() {
1073+
// Core ids outside the machine are fine unpinned; only the
1074+
// resulting shard count matters.
1075+
let cfg = ShardingConfig {
1076+
cpu_allocation: CpuAllocation::Range(1 << 20, (1 << 20) + 1),
1077+
pin_cores: false,
1078+
..ShardingConfig::default()
1079+
};
1080+
assert!(cfg.validate().is_ok());
1081+
1082+
let available = available_parallelism().unwrap().get();
1083+
let cfg = ShardingConfig {
1084+
cpu_allocation: CpuAllocation::Range(0, available + 1),
1085+
pin_cores: false,
1086+
..ShardingConfig::default()
1087+
};
1088+
assert!(cfg.validate().is_err());
1089+
}
1090+
}

core/cpu_allocation/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818
[package]
1919
name = "cpu_allocation"
2020
version = "0.1.0"
21-
description = "Shard CPU/NUMA allocation config types (CpuAllocation, NumaConfig) parsed from the iggy server config."
21+
description = "Shard CPU/NUMA allocation config types (CpuAllocation, NumaConfig) parsed from the iggy server config, plus the allowed-CPU-set probe."
2222
edition = "2024"
2323
license = "Apache-2.0"
2424
publish = false
2525

2626
[dependencies]
2727
serde = { workspace = true }
2828

29+
[target.'cfg(target_os = "linux")'.dependencies]
30+
nix = { workspace = true }
31+
2932
[dev-dependencies]
3033
serde_json = { workspace = true }
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
#[cfg(target_os = "linux")]
19+
use nix::{
20+
sched::{CpuSet, sched_getaffinity},
21+
unistd::Pid,
22+
};
23+
use std::thread::available_parallelism;
24+
25+
/// CPUs the calling process is currently allowed to run on, ascending.
26+
///
27+
/// Respects restrictions imposed by the parent environment (systemd
28+
/// `AllowedCPUs=`, container cpusets, `taskset`), which absolute core ids
29+
/// `0..n` would silently violate: `sched_setaffinity` to a core outside the
30+
/// allowed set fails with `EINVAL`. Falls back to `0..available_parallelism()`
31+
/// where the affinity mask is unavailable (non-Linux).
32+
pub fn allowed_cpus() -> Vec<usize> {
33+
#[cfg(target_os = "linux")]
34+
{
35+
if let Ok(mask) = sched_getaffinity(Pid::from_raw(0)) {
36+
let cpus: Vec<usize> = (0..CpuSet::count())
37+
.filter(|&cpu| mask.is_set(cpu).unwrap_or(false))
38+
.collect();
39+
if !cpus.is_empty() {
40+
return cpus;
41+
}
42+
}
43+
}
44+
45+
let fallback = available_parallelism().map(|n| n.get()).unwrap_or(1);
46+
(0..fallback).collect()
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
53+
#[test]
54+
fn allowed_cpus_is_non_empty_and_ascending() {
55+
let allowed = allowed_cpus();
56+
assert!(!allowed.is_empty());
57+
assert!(allowed.windows(2).all(|pair| pair[0] < pair[1]));
58+
}
59+
}

core/cpu_allocation/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@
2222
//! the server config (TOML). The config crate re-exports them, and the
2323
//! `shard_allocator` crate turns them into a real plan. Kept in their
2424
//! own little crate so neither side has to pull in the other's heavy
25-
//! dependencies just to share two small enums.
25+
//! dependencies just to share two small enums. Also home to
26+
//! [`allowed_cpus`], the probe for the process's allowed CPU set, which
27+
//! both sides consult when validating and pinning cores.
2628
2729
use serde::{Deserialize, Deserializer, Serialize, Serializer};
2830
use std::str::FromStr;
2931

32+
mod allowed_cpus;
33+
34+
pub use allowed_cpus::allowed_cpus;
35+
3036
/// Tell server how many CPU cores to grab for shards, and how.
3137
///
3238
/// Server make one shard per core. This say which cores. Pick one:

core/server-ng/config.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,15 @@ ports = { tcp = 8091, quic = 8081, http = 3001, websocket = 8093, tcp_replica =
630630
# TODO(hubcio): revert to "numa:auto" once multi-shard server-ng is stable.
631631
cpu_allocation = 1
632632

633+
# Whether shard threads are pinned to dedicated CPU cores (default: true).
634+
# Pinned cores are drawn from the process's allowed CPU set (affinity/cpuset
635+
# mask), so the server cooperates with systemd `AllowedCPUs=` and container
636+
# cpusets. Set to false when the server shares cores with other workloads
637+
# (e.g. a multi-tenant host slicing CPU via cgroup quotas): unpinned shards
638+
# let the kernel scheduler place threads freely instead of piling every
639+
# process onto the same low-numbered cores.
640+
# pin_cores = true
641+
633642
# Wall-clock budget for a single shard's bus drain on shutdown. Drives
634643
# the per-shard watchdog and the parallel-join survivor path; sized
635644
# larger than typical TCP RTT times in-flight write-batch so writers

core/server-ng/src/bootstrap.rs

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,35 @@ pub async fn load_config(logging: &mut Logging) -> Result<ServerNgConfig, Server
451451
Ok(config)
452452
}
453453

454+
/// Resolve the operator's `cpu_allocation` into concrete shard
455+
/// assignments plus the checked `u16` shard count.
456+
///
457+
/// Shard ids index `ReplicaOwnerTable` slots as `u16`. `OWNER_NONE`
458+
/// (`u16::MAX`) is reserved as the empty-slot sentinel, so a server
459+
/// configured with `u16::MAX` shards would mint a shard id that
460+
/// collides with the sentinel and an owner-table lookup could never
461+
/// tell that shard apart from an unowned slot. Reject at boot so the
462+
/// invariant is held by the type system, not by hoping the operator
463+
/// never configures 65535 cores worth of shards.
464+
fn resolve_shard_assignments(
465+
sharding: &configs::sharding::ShardingConfig,
466+
) -> Result<(Vec<ShardInfo>, u16), ServerNgError> {
467+
let allocator = ShardAllocator::new(&sharding.cpu_allocation, sharding.pin_cores)
468+
.map_err(ServerNgError::ShardAllocator)?;
469+
let assignments = allocator
470+
.to_shard_assignments()
471+
.map_err(ServerNgError::ShardAllocator)?;
472+
if assignments.is_empty() {
473+
return Err(ServerNgError::ShardsCountZero);
474+
}
475+
match u16::try_from(assignments.len()) {
476+
Ok(count) if count < message_bus::OWNER_NONE => Ok((assignments, count)),
477+
_ => Err(ServerNgError::ShardsCountOverflow {
478+
count: assignments.len(),
479+
}),
480+
}
481+
}
482+
454483
/// Re-validate the runtime sharding knobs that the per-shard runtime
455484
/// consumes directly. Mirrors `ShardingConfig::validate` so a caller
456485
/// that built the config without running it (e.g. tests, embedded
@@ -523,30 +552,8 @@ pub fn bootstrap(
523552
current_replica_id: Option<u8>,
524553
) -> Result<ShardHandles, ServerNgError> {
525554
warm_dummy_password_hash();
526-
let allocator = ShardAllocator::new(&config.system.sharding.cpu_allocation)
527-
.map_err(ServerNgError::ShardAllocator)?;
528-
let assignments = allocator
529-
.to_shard_assignments()
530-
.map_err(ServerNgError::ShardAllocator)?;
555+
let (assignments, total_shards) = resolve_shard_assignments(&config.system.sharding)?;
531556
let shards_count = assignments.len();
532-
if shards_count == 0 {
533-
return Err(ServerNgError::ShardsCountZero);
534-
}
535-
// Shard ids index `ReplicaOwnerTable` slots as `u16`. `OWNER_NONE`
536-
// (`u16::MAX`) is reserved as the empty-slot sentinel, so a server
537-
// configured with `u16::MAX` shards would mint a shard id that
538-
// collides with the sentinel and an owner-table lookup could never
539-
// tell that shard apart from an unowned slot. Reject at boot so the
540-
// invariant is held by the type system above this line, not by hoping
541-
// the operator never configures 65535 cores worth of shards.
542-
let total_shards = match u16::try_from(shards_count) {
543-
Ok(count) if count < message_bus::OWNER_NONE => count,
544-
_ => {
545-
return Err(ServerNgError::ShardsCountOverflow {
546-
count: shards_count,
547-
});
548-
}
549-
};
550557

551558
// Re-check the full valid range, not just the zero floor: a caller
552559
// that built the config without running `ShardingConfig::validate`

core/server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ chrono = { workspace = true }
4848
clap = { workspace = true }
4949
compio = { workspace = true }
5050
configs = { workspace = true }
51+
cpu_allocation = { workspace = true }
5152
ctrlc = { workspace = true }
5253
cyper = { workspace = true }
5354
cyper-axum = { workspace = true }

0 commit comments

Comments
 (0)