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

numinnex 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 15044ea44 refactor: extract shard allocator into a dedicated crate 
(#3578)
15044ea44 is described below

commit 15044ea44972124b57226983fdf09542af28b978
Author: Jaya Kasa <[email protected]>
AuthorDate: Mon Jul 6 06:34:21 2026 -0400

    refactor: extract shard allocator into a dedicated crate (#3578)
---
 Cargo.lock                                         |  23 +-
 Cargo.toml                                         |   4 +
 core/configs/Cargo.toml                            |   1 +
 core/configs/src/server_config/sharding.rs         | 241 +------------
 core/configs/src/server_config/validators.rs       |   2 +-
 core/{configs => cpu_allocation}/Cargo.toml        |  17 +-
 core/cpu_allocation/src/lib.rs                     | 374 +++++++++++++++++++++
 core/server-ng/Cargo.toml                          |   1 +
 core/server-ng/src/bootstrap.rs                    |   2 +-
 core/server-ng/src/server_error.rs                 |   2 +-
 core/server/Cargo.toml                             |   7 +-
 core/server/src/bootstrap.rs                       |   2 +-
 core/server/src/lib.rs                             |   1 -
 core/server/src/main.rs                            |   2 +-
 core/server/src/server_error.rs                    |   2 +-
 core/{configs => shard_allocator}/Cargo.toml       |  27 +-
 core/shard_allocator/build.rs                      |  43 +++
 .../src/lib.rs}                                    |  35 +-
 18 files changed, 510 insertions(+), 276 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 106aa2445..45f9b0a2d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3152,6 +3152,7 @@ name = "configs"
 version = "0.1.0"
 dependencies = [
  "configs_derive",
+ "cpu_allocation",
  "derive_more",
  "err_trail",
  "figment",
@@ -3345,6 +3346,14 @@ dependencies = [
  "libm",
 ]
 
+[[package]]
+name = "cpu_allocation"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "cpufeatures"
 version = "0.2.17"
@@ -11684,7 +11693,6 @@ dependencies = [
  "futures",
  "hash32 1.0.0",
  "human-repr",
- "hwlocality",
  "iggy_binary_protocol",
  "iggy_common",
  "jsonwebtoken",
@@ -11711,6 +11719,7 @@ dependencies = [
  "serde",
  "serde_json",
  "server_common",
+ "shard_allocator",
  "slab",
  "socket2 0.6.4",
  "strum 0.28.0",
@@ -11792,6 +11801,7 @@ dependencies = [
  "server",
  "server_common",
  "shard",
+ "shard_allocator",
  "slab",
  "socket2 0.6.4",
  "strum 0.28.0",
@@ -11915,6 +11925,17 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "shard_allocator"
+version = "0.1.0"
+dependencies = [
+ "cpu_allocation",
+ "hwlocality",
+ "nix",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
diff --git a/Cargo.toml b/Cargo.toml
index 027209cea..5ef321639 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,6 +49,7 @@ members = [
     "core/connectors/sources/postgres_source",
     "core/connectors/sources/random_source",
     "core/consensus",
+    "core/cpu_allocation",
     "core/harness_derive",
     "core/integration",
     "core/journal",
@@ -60,6 +61,7 @@ members = [
     "core/server-ng",
     "core/server_common",
     "core/shard",
+    "core/shard_allocator",
     "core/simulator",
     "core/tools",
     "examples/rust",
@@ -134,6 +136,7 @@ configs = { path = "core/configs", version = "0.1.0" }
 configs_derive = { path = "core/configs_derive", version = "0.1.0" }
 consensus = { path = "core/consensus" }
 console-subscriber = "0.5.0"
+cpu_allocation = { path = "core/cpu_allocation" }
 crossbeam = "0.8.4"
 crossfire = "3.1.16"
 csv = "1.4.0"
@@ -279,6 +282,7 @@ server = { path = "core/server" }
 server-ng = { path = "core/server-ng" }
 server_common = { path = "core/server_common" }
 shard = { path = "core/shard" }
+shard_allocator = { path = "core/shard_allocator" }
 simd-json = { version = "0.17.0", features = ["serde_impl"] }
 slab = "0.4.12"
 smallvec = "1.15"
diff --git a/core/configs/Cargo.toml b/core/configs/Cargo.toml
index c0f606b8a..7d5d7b104 100644
--- a/core/configs/Cargo.toml
+++ b/core/configs/Cargo.toml
@@ -24,6 +24,7 @@ publish = false
 
 [dependencies]
 configs_derive = { workspace = true }
+cpu_allocation = { workspace = true }
 derive_more = { workspace = true }
 err_trail = { workspace = true }
 figment = { workspace = true }
diff --git a/core/configs/src/server_config/sharding.rs 
b/core/configs/src/server_config/sharding.rs
index ec332462b..0d0c1fc6e 100644
--- a/core/configs/src/server_config/sharding.rs
+++ b/core/configs/src/server_config/sharding.rs
@@ -16,13 +16,18 @@
 // under the License.
 
 use iggy_common::IggyDuration;
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use serde::{Deserialize, Serialize};
 use serde_with::{DisplayFromStr, serde_as};
-use std::str::FromStr;
 use std::time::Duration;
 
 use configs::ConfigEnv;
 
+// `CpuAllocation`/`NumaConfig` are pure config types and live in their own
+// leaf crate so both `configs` and `shard_allocator` can share them without
+// pulling each other's heavier dependency trees. Re-exported here to keep the
+// `configs::sharding::*` path stable for existing callers.
+pub use cpu_allocation::{CpuAllocation, NumaConfig};
+
 /// Default capacity of the per-shard inter-shard inbox channel. Sized
 /// comfortably above the consensus working set, which is roughly
 /// `PIPELINE_PREPARE_QUEUE_MAX (= 32) * replica_count * directions`
@@ -178,235 +183,3 @@ impl Default for ShardingConfig {
         }
     }
 }
-
-#[derive(Debug, Clone, PartialEq, Default)]
-pub enum CpuAllocation {
-    #[default]
-    All,
-    Count(usize),
-    Range(usize, usize),
-    NumaAware(NumaConfig),
-}
-
-/// NUMA specific configuration
-#[derive(Debug, Clone, PartialEq, Default)]
-pub struct NumaConfig {
-    /// Which NUMA nodes to use (empty = auto-detect all)
-    pub nodes: Vec<usize>,
-    /// Cores per node to use (0 = use all available)
-    pub cores_per_node: usize,
-    /// skip hyperthread sibling
-    pub avoid_hyperthread: bool,
-}
-
-impl CpuAllocation {
-    fn parse_numa(s: &str) -> Result<CpuAllocation, String> {
-        let params = s
-            .strip_prefix("numa:")
-            .ok_or_else(|| "Numa config must start with 'numa:'".to_string())?;
-
-        if params == "auto" {
-            return Ok(CpuAllocation::NumaAware(NumaConfig {
-                nodes: vec![],
-                cores_per_node: 0,
-                avoid_hyperthread: true,
-            }));
-        }
-
-        let mut nodes = Vec::new();
-        let mut cores_per_node = 0;
-        let mut avoid_hyperthread = true;
-
-        for param in params.split(';') {
-            let kv: Vec<&str> = param.split('=').collect();
-            if kv.len() != 2 {
-                return Err(format!(
-                    "Invalid NUMA parameter: '{param}', only available: 'auto'"
-                ));
-            }
-
-            match kv[0] {
-                "nodes" => {
-                    nodes = kv[1]
-                        .split(',')
-                        .map(|n| {
-                            n.parse::<usize>()
-                                .map_err(|_| format!("Invalid node number: 
{n}"))
-                        })
-                        .collect::<Result<Vec<_>, _>>()?;
-                }
-                "cores" => {
-                    cores_per_node = kv[1]
-                        .parse::<usize>()
-                        .map_err(|_| format!("Invalid cores value: {}", 
kv[1]))?;
-                }
-                "no_ht" => {
-                    avoid_hyperthread = kv[1]
-                        .parse::<bool>()
-                        .map_err(|_| format!("Invalid no ht value: {}", 
kv[1]))?;
-                }
-                _ => {
-                    return Err(format!(
-                        "Unknown NUMA parameter: {}, example: 
numa:nodes=0;cores=4;no_ht=true",
-                        kv[0]
-                    ));
-                }
-            }
-        }
-
-        Ok(CpuAllocation::NumaAware(NumaConfig {
-            nodes,
-            cores_per_node,
-            avoid_hyperthread,
-        }))
-    }
-}
-
-impl FromStr for CpuAllocation {
-    type Err = String;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s {
-            "all" => Ok(CpuAllocation::All),
-            s if s.starts_with("numa:") => Self::parse_numa(s),
-            s if s.contains("..") => {
-                let parts: Vec<&str> = s.split("..").collect();
-                if parts.len() != 2 {
-                    return Err(format!("Invalid range format: {s}. Expected 
'start..end'"));
-                }
-                let start = parts[0]
-                    .parse::<usize>()
-                    .map_err(|_| format!("Invalid start value: {}", 
parts[0]))?;
-                let end = parts[1]
-                    .parse::<usize>()
-                    .map_err(|_| format!("Invalid end value: {}", parts[1]))?;
-                Ok(CpuAllocation::Range(start, end))
-            }
-            s => {
-                let count = s
-                    .parse::<usize>()
-                    .map_err(|_| format!("Invalid shard count: {s}"))?;
-                Ok(CpuAllocation::Count(count))
-            }
-        }
-    }
-}
-
-impl Serialize for CpuAllocation {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: Serializer,
-    {
-        match self {
-            CpuAllocation::All => serializer.serialize_str("all"),
-            CpuAllocation::Count(n) => serializer.serialize_u64(*n as u64),
-            CpuAllocation::Range(start, end) => {
-                serializer.serialize_str(&format!("{start}..{end}"))
-            }
-            CpuAllocation::NumaAware(numa) => {
-                if numa.nodes.is_empty() && numa.cores_per_node == 0 {
-                    serializer.serialize_str("numa:auto")
-                } else {
-                    let nodes_str = numa
-                        .nodes
-                        .iter()
-                        .map(|n| n.to_string())
-                        .collect::<Vec<_>>()
-                        .join(",");
-
-                    let full_str = format!(
-                        "numa:nodes={};cores={};no_ht={}",
-                        nodes_str, numa.cores_per_node, numa.avoid_hyperthread
-                    );
-
-                    serializer.serialize_str(&full_str)
-                }
-            }
-        }
-    }
-}
-
-impl<'de> Deserialize<'de> for CpuAllocation {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum CpuAllocationHelper {
-            String(String),
-            Number(usize),
-        }
-
-        match CpuAllocationHelper::deserialize(deserializer)? {
-            CpuAllocationHelper::String(s) => {
-                CpuAllocation::from_str(&s).map_err(serde::de::Error::custom)
-            }
-            CpuAllocationHelper::Number(n) => Ok(CpuAllocation::Count(n)),
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_parse_all() {
-        assert_eq!(CpuAllocation::from_str("all").unwrap(), 
CpuAllocation::All);
-    }
-
-    #[test]
-    fn test_parse_count() {
-        assert_eq!(
-            CpuAllocation::from_str("4").unwrap(),
-            CpuAllocation::Count(4)
-        );
-    }
-
-    #[test]
-    fn test_parse_range() {
-        assert_eq!(
-            CpuAllocation::from_str("2..8").unwrap(),
-            CpuAllocation::Range(2, 8)
-        );
-    }
-
-    #[test]
-    fn test_parse_numa_auto() {
-        let result = CpuAllocation::from_str("numa:auto").unwrap();
-        match result {
-            CpuAllocation::NumaAware(numa) => {
-                assert!(numa.nodes.is_empty());
-                assert_eq!(numa.cores_per_node, 0);
-                assert!(numa.avoid_hyperthread);
-            }
-            _ => panic!("Expected NumaAware"),
-        }
-    }
-
-    #[test]
-    fn test_parse_numa_explicit() {
-        let result = 
CpuAllocation::from_str("numa:nodes=0,1;cores=4;no_ht=true").unwrap();
-        match result {
-            CpuAllocation::NumaAware(numa) => {
-                assert_eq!(numa.nodes, vec![0, 1]);
-                assert_eq!(numa.cores_per_node, 4);
-                assert!(numa.avoid_hyperthread);
-            }
-            _ => panic!("Expected NumaAware"),
-        }
-    }
-
-    #[test]
-    fn test_numa_explicit_serde_roundtrip() {
-        let original = CpuAllocation::NumaAware(NumaConfig {
-            nodes: vec![0, 1],
-            cores_per_node: 4,
-            avoid_hyperthread: true,
-        });
-        let serialized = serde_json::to_string(&original).unwrap();
-        let deserialized: CpuAllocation = 
serde_json::from_str(&serialized).unwrap();
-        assert_eq!(original, deserialized);
-    }
-}
diff --git a/core/configs/src/server_config/validators.rs 
b/core/configs/src/server_config/validators.rs
index 93c61fb9b..0aafe1ef5 100644
--- a/core/configs/src/server_config/validators.rs
+++ b/core/configs/src/server_config/validators.rs
@@ -497,7 +497,7 @@ impl Validatable<ConfigurationError> for ShardingConfig {
                 Ok(())
             }
             // NUMA topology validation requires hwlocality (runtime dep).
-            // Full NUMA validation happens in server::shard_allocator at 
startup.
+            // Full NUMA validation happens in shard_allocator at startup.
             CpuAllocation::NumaAware(_) => Ok(()),
         }
     }
diff --git a/core/configs/Cargo.toml b/core/cpu_allocation/Cargo.toml
similarity index 69%
copy from core/configs/Cargo.toml
copy to core/cpu_allocation/Cargo.toml
index c0f606b8a..74eea3230 100644
--- a/core/configs/Cargo.toml
+++ b/core/cpu_allocation/Cargo.toml
@@ -16,24 +16,15 @@
 # under the License.
 
 [package]
-name = "configs"
+name = "cpu_allocation"
 version = "0.1.0"
+description = "Shard CPU/NUMA allocation config types (CpuAllocation, 
NumaConfig) parsed from the iggy server config."
 edition = "2024"
 license = "Apache-2.0"
 publish = false
 
 [dependencies]
-configs_derive = { workspace = true }
-derive_more = { workspace = true }
-err_trail = { workspace = true }
-figment = { workspace = true }
-iggy_common = { workspace = true }
-jsonwebtoken = { workspace = true }
 serde = { workspace = true }
+
+[dev-dependencies]
 serde_json = { workspace = true }
-serde_with = { workspace = true }
-server_common = { workspace = true }
-static-toml = { workspace = true }
-strum = { workspace = true }
-tracing = { workspace = true }
-tungstenite = { workspace = true }
diff --git a/core/cpu_allocation/src/lib.rs b/core/cpu_allocation/src/lib.rs
new file mode 100644
index 000000000..5391e73b1
--- /dev/null
+++ b/core/cpu_allocation/src/lib.rs
@@ -0,0 +1,374 @@
+// 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.
+
+//! `cpu_allocation`: tiny config types that say how many CPU cores the
+//! server should grab for its shards, and how.
+//!
+//! These two types ([`CpuAllocation`] and [`NumaConfig`]) are read from
+//! 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.
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use std::str::FromStr;
+
+/// Tell server how many CPU cores to grab for shards, and how.
+///
+/// Server make one shard per core. This say which cores. Pick one:
+/// - `All`: take every core machine have.
+/// - `Count(n)`: take first `n` cores.
+/// - `Range(a, b)`: take cores `a` up to (not including) `b`.
+/// - `NumaAware(..)`: smart pick by NUMA node, keep memory close to core.
+///
+/// Parse from a string in TOML, e.g. `"all"`, `4`, `"2..8"`,
+/// `"numa:auto"`, or `"numa:nodes=0,1;cores=4;no_ht=true"`.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub enum CpuAllocation {
+    #[default]
+    All,
+    Count(usize),
+    Range(usize, usize),
+    NumaAware(NumaConfig),
+}
+
+/// Knobs for NUMA-aware core picking.
+///
+/// NUMA = machine split into groups (nodes). Each node have own cores
+/// and own memory. Memory of same node is fast; far node is slow. This
+/// struct say which nodes to use and how many cores from each.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct NumaConfig {
+    /// Which NUMA nodes to use. Empty means: use all of them.
+    pub nodes: Vec<usize>,
+    /// How many cores to take from each node. `0` means: take all.
+    pub cores_per_node: usize,
+    /// `true` means skip hyperthread twins, use only one thread per core.
+    pub avoid_hyperthread: bool,
+}
+
+impl CpuAllocation {
+    fn parse_numa(s: &str) -> Result<CpuAllocation, String> {
+        let params = s
+            .strip_prefix("numa:")
+            .ok_or_else(|| "Numa config must start with 'numa:'".to_string())?;
+
+        if params == "auto" {
+            return Ok(CpuAllocation::NumaAware(NumaConfig {
+                nodes: vec![],
+                cores_per_node: 0,
+                avoid_hyperthread: true,
+            }));
+        }
+
+        let mut nodes = Vec::new();
+        let mut cores_per_node = 0;
+        let mut avoid_hyperthread = true;
+
+        for param in params.split(';') {
+            let kv: Vec<&str> = param.split('=').collect();
+            if kv.len() != 2 {
+                return Err(format!(
+                    "Invalid NUMA parameter: '{param}', only available: 'auto'"
+                ));
+            }
+
+            match kv[0] {
+                "nodes" => {
+                    nodes = kv[1]
+                        .split(',')
+                        .map(|n| {
+                            n.parse::<usize>()
+                                .map_err(|_| format!("Invalid node number: 
{n}"))
+                        })
+                        .collect::<Result<Vec<_>, _>>()?;
+                }
+                "cores" => {
+                    cores_per_node = kv[1]
+                        .parse::<usize>()
+                        .map_err(|_| format!("Invalid cores value: {}", 
kv[1]))?;
+                }
+                "no_ht" => {
+                    avoid_hyperthread = kv[1]
+                        .parse::<bool>()
+                        .map_err(|_| format!("Invalid no ht value: {}", 
kv[1]))?;
+                }
+                _ => {
+                    return Err(format!(
+                        "Unknown NUMA parameter: {}, example: 
numa:nodes=0;cores=4;no_ht=true",
+                        kv[0]
+                    ));
+                }
+            }
+        }
+
+        Ok(CpuAllocation::NumaAware(NumaConfig {
+            nodes,
+            cores_per_node,
+            avoid_hyperthread,
+        }))
+    }
+}
+
+impl FromStr for CpuAllocation {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "all" => Ok(CpuAllocation::All),
+            s if s.starts_with("numa:") => Self::parse_numa(s),
+            s if s.contains("..") => {
+                let parts: Vec<&str> = s.split("..").collect();
+                if parts.len() != 2 {
+                    return Err(format!("Invalid range format: {s}. Expected 
'start..end'"));
+                }
+                let start = parts[0]
+                    .parse::<usize>()
+                    .map_err(|_| format!("Invalid start value: {}", 
parts[0]))?;
+                let end = parts[1]
+                    .parse::<usize>()
+                    .map_err(|_| format!("Invalid end value: {}", parts[1]))?;
+                Ok(CpuAllocation::Range(start, end))
+            }
+            s => {
+                let count = s
+                    .parse::<usize>()
+                    .map_err(|_| format!("Invalid shard count: {s}"))?;
+                Ok(CpuAllocation::Count(count))
+            }
+        }
+    }
+}
+
+impl Serialize for CpuAllocation {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        match self {
+            CpuAllocation::All => serializer.serialize_str("all"),
+            CpuAllocation::Count(n) => serializer.serialize_u64(*n as u64),
+            CpuAllocation::Range(start, end) => {
+                serializer.serialize_str(&format!("{start}..{end}"))
+            }
+            CpuAllocation::NumaAware(numa) => {
+                if numa.nodes.is_empty() && numa.cores_per_node == 0 {
+                    serializer.serialize_str("numa:auto")
+                } else {
+                    let nodes_str = numa
+                        .nodes
+                        .iter()
+                        .map(|n| n.to_string())
+                        .collect::<Vec<_>>()
+                        .join(",");
+
+                    let full_str = format!(
+                        "numa:nodes={};cores={};no_ht={}",
+                        nodes_str, numa.cores_per_node, numa.avoid_hyperthread
+                    );
+
+                    serializer.serialize_str(&full_str)
+                }
+            }
+        }
+    }
+}
+
+impl<'de> Deserialize<'de> for CpuAllocation {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        #[serde(untagged)]
+        enum CpuAllocationHelper {
+            String(String),
+            Number(usize),
+        }
+
+        match CpuAllocationHelper::deserialize(deserializer)? {
+            CpuAllocationHelper::String(s) => {
+                CpuAllocation::from_str(&s).map_err(serde::de::Error::custom)
+            }
+            CpuAllocationHelper::Number(n) => Ok(CpuAllocation::Count(n)),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_all() {
+        assert_eq!(CpuAllocation::from_str("all").unwrap(), 
CpuAllocation::All);
+    }
+
+    #[test]
+    fn test_parse_count() {
+        assert_eq!(
+            CpuAllocation::from_str("4").unwrap(),
+            CpuAllocation::Count(4)
+        );
+    }
+
+    #[test]
+    fn test_parse_range() {
+        assert_eq!(
+            CpuAllocation::from_str("2..8").unwrap(),
+            CpuAllocation::Range(2, 8)
+        );
+    }
+
+    #[test]
+    fn test_parse_numa_auto() {
+        let result = CpuAllocation::from_str("numa:auto").unwrap();
+        match result {
+            CpuAllocation::NumaAware(numa) => {
+                assert!(numa.nodes.is_empty());
+                assert_eq!(numa.cores_per_node, 0);
+                assert!(numa.avoid_hyperthread);
+            }
+            _ => panic!("Expected NumaAware"),
+        }
+    }
+
+    #[test]
+    fn test_parse_numa_explicit() {
+        let result = 
CpuAllocation::from_str("numa:nodes=0,1;cores=4;no_ht=true").unwrap();
+        match result {
+            CpuAllocation::NumaAware(numa) => {
+                assert_eq!(numa.nodes, vec![0, 1]);
+                assert_eq!(numa.cores_per_node, 4);
+                assert!(numa.avoid_hyperthread);
+            }
+            _ => panic!("Expected NumaAware"),
+        }
+    }
+
+    #[test]
+    fn test_numa_explicit_serde_roundtrip() {
+        let original = CpuAllocation::NumaAware(NumaConfig {
+            nodes: vec![0, 1],
+            cores_per_node: 4,
+            avoid_hyperthread: true,
+        });
+        let serialized = serde_json::to_string(&original).unwrap();
+        let deserialized: CpuAllocation = 
serde_json::from_str(&serialized).unwrap();
+        assert_eq!(original, deserialized);
+    }
+
+    #[test]
+    fn test_parse_invalid_range_too_many_parts() {
+        assert!(CpuAllocation::from_str("1..2..3").is_err());
+    }
+
+    #[test]
+    fn test_parse_invalid_range_start() {
+        assert!(CpuAllocation::from_str("x..8").is_err());
+    }
+
+    #[test]
+    fn test_parse_invalid_range_end() {
+        assert!(CpuAllocation::from_str("2..y").is_err());
+    }
+
+    #[test]
+    fn test_parse_invalid_count() {
+        assert!(CpuAllocation::from_str("abc").is_err());
+    }
+
+    #[test]
+    fn test_parse_numa_missing_prefix() {
+        assert!(CpuAllocation::parse_numa("nodes=0").is_err());
+    }
+
+    #[test]
+    fn test_parse_numa_param_without_equals() {
+        assert!(CpuAllocation::from_str("numa:nodes").is_err());
+    }
+
+    #[test]
+    fn test_parse_numa_invalid_node_number() {
+        assert!(CpuAllocation::from_str("numa:nodes=a").is_err());
+    }
+
+    #[test]
+    fn test_parse_numa_invalid_cores() {
+        assert!(CpuAllocation::from_str("numa:cores=x").is_err());
+    }
+
+    #[test]
+    fn test_parse_numa_invalid_no_ht() {
+        assert!(CpuAllocation::from_str("numa:no_ht=maybe").is_err());
+    }
+
+    #[test]
+    fn test_parse_numa_unknown_param() {
+        assert!(CpuAllocation::from_str("numa:foo=1").is_err());
+    }
+
+    #[test]
+    fn test_serialize_all() {
+        assert_eq!(
+            serde_json::to_string(&CpuAllocation::All).unwrap(),
+            "\"all\""
+        );
+    }
+
+    #[test]
+    fn test_serialize_count() {
+        assert_eq!(
+            serde_json::to_string(&CpuAllocation::Count(4)).unwrap(),
+            "4"
+        );
+    }
+
+    #[test]
+    fn test_serialize_range() {
+        assert_eq!(
+            serde_json::to_string(&CpuAllocation::Range(2, 8)).unwrap(),
+            "\"2..8\""
+        );
+    }
+
+    #[test]
+    fn test_serialize_numa_auto() {
+        let auto = CpuAllocation::NumaAware(NumaConfig {
+            nodes: vec![],
+            cores_per_node: 0,
+            avoid_hyperthread: true,
+        });
+        assert_eq!(serde_json::to_string(&auto).unwrap(), "\"numa:auto\"");
+    }
+
+    #[test]
+    fn test_deserialize_number() {
+        assert_eq!(
+            serde_json::from_str::<CpuAllocation>("4").unwrap(),
+            CpuAllocation::Count(4)
+        );
+    }
+
+    #[test]
+    fn test_deserialize_string() {
+        assert_eq!(
+            serde_json::from_str::<CpuAllocation>("\"all\"").unwrap(),
+            CpuAllocation::All
+        );
+    }
+}
diff --git a/core/server-ng/Cargo.toml b/core/server-ng/Cargo.toml
index bf42650ed..3d2a2ab5a 100644
--- a/core/server-ng/Cargo.toml
+++ b/core/server-ng/Cargo.toml
@@ -149,6 +149,7 @@ serde = { workspace = true }
 server = { workspace = true }
 server_common = { workspace = true }
 shard = { workspace = true }
+shard_allocator = { workspace = true }
 slab = { workspace = true }
 socket2 = { workspace = true }
 strum = { workspace = true }
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index 46bd45c29..e430fed3e 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -72,9 +72,9 @@ use rustls::pki_types::ServerName;
 use server_common::bootstrap::create_directories;
 use server_common::executor::create_shard_executor;
 use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
+use shard_allocator::{ShardAllocator, ShardInfo};
 // TODO: decouple bootstrap/storage helpers and logging from the `server` 
crate.
 use server::log::logger::Logging;
-use server::shard_allocator::{ShardAllocator, ShardInfo};
 use server::streaming::users::user::User as LegacyUser;
 use server::{IGGY_ROOT_PASSWORD_ENV, IGGY_ROOT_USERNAME_ENV};
 use shard::builder::IggyShardBuilder;
diff --git a/core/server-ng/src/server_error.rs 
b/core/server-ng/src/server_error.rs
index a3070dedd..a25ebfec7 100644
--- a/core/server-ng/src/server_error.rs
+++ b/core/server-ng/src/server_error.rs
@@ -18,8 +18,8 @@
 use metadata::impls::recovery::RecoveryError;
 // TODO: decouple logging errors from the `server` crate.
 use server::server_error::LogError;
-use server::shard_allocator::ShardingError;
 use shard::ShardCtorError;
+use shard_allocator::ShardingError;
 use thiserror::Error;
 
 #[derive(Debug, Error)]
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index c48abee12..09c352bae 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -90,6 +90,7 @@ send_wrapper = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 server_common = { workspace = true }
+shard_allocator = { workspace = true }
 slab = { workspace = true }
 socket2 = { workspace = true }
 strum = { workspace = true }
@@ -105,11 +106,5 @@ tracing-subscriber = { workspace = true }
 ulid = { workspace = true }
 uuid = { workspace = true }
 
-[target.'cfg(not(target_env = "musl"))'.dependencies]
-hwlocality = { workspace = true }
-
-[target.'cfg(target_env = "musl")'.dependencies]
-hwlocality = { workspace = true, features = ["vendored"] }
-
 [build-dependencies]
 vergen-git2 = { workspace = true }
diff --git a/core/server/src/bootstrap.rs b/core/server/src/bootstrap.rs
index f35a43a2c..eb42c0539 100644
--- a/core/server/src/bootstrap.rs
+++ b/core/server/src/bootstrap.rs
@@ -33,7 +33,6 @@ use crate::{
             frame::ShardFrame,
         },
     },
-    shard_allocator::ShardInfo,
     state::system::{StreamState, TopicState, UserState},
     streaming::{
         partitions::{
@@ -57,6 +56,7 @@ use iggy_common::{
         MIN_USERNAME_LENGTH,
     },
 };
+use shard_allocator::ShardInfo;
 use slab::Slab;
 use std::{env, sync::Arc};
 use tracing::{info, warn};
diff --git a/core/server/src/lib.rs b/core/server/src/lib.rs
index 6807fb6ba..70a3e6c4d 100644
--- a/core/server/src/lib.rs
+++ b/core/server/src/lib.rs
@@ -41,7 +41,6 @@ pub mod quic;
 pub mod sender;
 pub mod server_error;
 pub mod shard;
-pub mod shard_allocator;
 pub mod state;
 pub mod streaming;
 pub mod tcp;
diff --git a/core/server/src/main.rs b/core/server/src/main.rs
index d9baaac85..fd1908260 100644
--- a/core/server/src/main.rs
+++ b/core/server/src/main.rs
@@ -40,7 +40,6 @@ use server::metadata::{Metadata, create_metadata_handles};
 use server::server_error::ServerError;
 use server::shard::system::info::SystemInfo;
 use server::shard::{IggyShard, calculate_shard_assignment};
-use server::shard_allocator::ShardAllocator;
 use server::state::file::FileState;
 use server::state::system::SystemState;
 use server::streaming::clients::client_manager::{Client, ClientManager};
@@ -49,6 +48,7 @@ use server::streaming::storage::SystemStorage;
 use server::streaming::utils::ptr::EternalPtr;
 use server_common::MemoryPool;
 use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
+use shard_allocator::ShardAllocator;
 use std::panic::AssertUnwindSafe;
 use std::rc::Rc;
 use std::str::FromStr;
diff --git a/core/server/src/server_error.rs b/core/server/src/server_error.rs
index 6a63fac8f..5dde456e4 100644
--- a/core/server/src/server_error.rs
+++ b/core/server/src/server_error.rs
@@ -36,7 +36,7 @@ error_set!(
 
     NumaError := {
         #[display("{0}")]
-        Sharding(crate::shard_allocator::ShardingError),
+        Sharding(shard_allocator::ShardingError),
     }
 
     ConfigurationError := {
diff --git a/core/configs/Cargo.toml b/core/shard_allocator/Cargo.toml
similarity index 66%
copy from core/configs/Cargo.toml
copy to core/shard_allocator/Cargo.toml
index c0f606b8a..d44574376 100644
--- a/core/configs/Cargo.toml
+++ b/core/shard_allocator/Cargo.toml
@@ -16,24 +16,23 @@
 # under the License.
 
 [package]
-name = "configs"
+name = "shard_allocator"
 version = "0.1.0"
+description = "CPU and NUMA shard allocation for the iggy server, backed by 
hwloc."
 edition = "2024"
 license = "Apache-2.0"
 publish = false
 
 [dependencies]
-configs_derive = { workspace = true }
-derive_more = { workspace = true }
-err_trail = { workspace = true }
-figment = { workspace = true }
-iggy_common = { workspace = true }
-jsonwebtoken = { workspace = true }
-serde = { workspace = true }
-serde_json = { workspace = true }
-serde_with = { workspace = true }
-server_common = { workspace = true }
-static-toml = { workspace = true }
-strum = { workspace = true }
+cpu_allocation = { workspace = true }
+thiserror = { workspace = true }
 tracing = { workspace = true }
-tungstenite = { workspace = true }
+
+[target.'cfg(not(target_env = "musl"))'.dependencies]
+hwlocality = { workspace = true }
+
+[target.'cfg(target_env = "musl")'.dependencies]
+hwlocality = { workspace = true, features = ["vendored"] }
+
+[target.'cfg(target_os = "linux")'.dependencies]
+nix = { workspace = true }
diff --git a/core/shard_allocator/build.rs b/core/shard_allocator/build.rs
new file mode 100644
index 000000000..d66e93f07
--- /dev/null
+++ b/core/shard_allocator/build.rs
@@ -0,0 +1,43 @@
+// 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.
+
+// Vendored `hwloc` references `cbrt`, which makes the linker pull in a
+// `libm`. musl folds the math functions into its `libc`, so the Rust
+// musl sysroot ships no `libm.a`. Without one, `-lm` falls through to
+// the host glibc's `libm.a`, whose `cbrt` needs glibc-internal
+// `__frexp`/`__ldexp` symbols that do not exist on musl, and the static
+// link fails. Drop an empty `libm.a` stub on the search path so `-lm`
+// resolves to nothing and `cbrt` is satisfied later by musl's own
+// `libc`. No effect on non-musl targets.
+
+use std::env;
+use std::fs;
+use std::path::Path;
+
+fn main() {
+    if env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("musl") {
+        return;
+    }
+
+    let out_dir = env::var("OUT_DIR").expect("OUT_DIR is set by cargo for 
build scripts");
+    let stub = Path::new(&out_dir).join("libm.a");
+
+    // `!<arch>\n` is the canonical header of an empty `ar` archive.
+    fs::write(&stub, b"!<arch>\n").expect("write empty libm.a stub");
+
+    println!("cargo:rustc-link-search=native={out_dir}");
+}
diff --git a/core/server/src/shard_allocator.rs 
b/core/shard_allocator/src/lib.rs
similarity index 85%
rename from core/server/src/shard_allocator.rs
rename to core/shard_allocator/src/lib.rs
index 26a1f0e38..b02343a53 100644
--- a/core/server/src/shard_allocator.rs
+++ b/core/shard_allocator/src/lib.rs
@@ -15,7 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use configs::sharding::{CpuAllocation, NumaConfig};
+//! `shard_allocator`: decide which CPU cores each shard lives on.
+//!
+//! The server makes many shards and wants each one to run on its own
+//! core so they do not fight over CPU time. This crate reads the
+//! operator's choice ([`CpuAllocation`] from the config), looks at the
+//! real machine with `hwloc`, and hands back one [`ShardInfo`] per
+//! 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 hwlocality::Topology;
 use hwlocality::bitmap::SpecializedBitmapRef;
 use hwlocality::cpu::cpuset::CpuSet;
@@ -28,6 +37,9 @@ use std::sync::Arc;
 use std::thread::available_parallelism;
 use tracing::info;
 
+/// All the ways shard allocation can go wrong: machine has no NUMA,
+/// hwloc cannot read the topology, the operator asked for more cores
+/// than exist, or the OS refused to pin a thread or its memory.
 #[derive(Debug, thiserror::Error)]
 pub enum ShardingError {
     #[error("Failed to detect topology: {msg}")]
@@ -56,6 +68,10 @@ pub enum ShardingError {
     Other { msg: String },
 }
 
+/// A snapshot of the machine's NUMA layout, read once from `hwloc`.
+///
+/// Holds how many NUMA nodes there are and, for each node, how many
+/// real (physical) cores and how many threads (logical cores) it has.
 #[derive(Debug)]
 pub struct NumaTopology {
     topology: Topology,
@@ -65,6 +81,8 @@ pub struct NumaTopology {
 }
 
 impl NumaTopology {
+    /// Ask `hwloc` to read this machine's NUMA layout right now.
+    /// Errors if hwloc fails or the machine reports no NUMA nodes.
     pub fn detect() -> Result<NumaTopology, ShardingError> {
         let topology =
             Topology::new().map_err(|e| ShardingError::TopologyDetection { 
msg: e.to_string() })?;
@@ -110,10 +128,13 @@ impl NumaTopology {
         })
     }
 
+    /// How many real cores this node has. Returns `0` if no such node.
     pub fn physical_cores_for_node(&self, node: usize) -> usize {
         self.physical_cores_per_node.get(node).copied().unwrap_or(0)
     }
 
+    /// How many threads (logical cores) this node has, hyperthreads
+    /// included. Returns `0` if no such node.
     pub fn logical_cores_for_node(&self, node: usize) -> usize {
         self.logical_cores_per_node.get(node).copied().unwrap_or(0)
     }
@@ -161,6 +182,8 @@ impl NumaTopology {
     }
 }
 
+/// One shard's home: which CPU cores it may run on, and which NUMA
+/// node its memory should sit near (`None` means do not pin memory).
 #[derive(Debug, Clone)]
 pub struct ShardInfo {
     pub cpu_set: HashSet<usize>,
@@ -168,6 +191,8 @@ pub struct ShardInfo {
 }
 
 impl ShardInfo {
+    /// Pin the calling thread to this shard's cores. On non-Linux this
+    /// does nothing (no-op). Empty core set also does nothing.
     pub fn bind_cpu(&self) -> Result<(), ShardingError> {
         #[cfg(target_os = "linux")]
         {
@@ -196,6 +221,8 @@ impl ShardInfo {
         Ok(())
     }
 
+    /// Pin the calling thread's memory to this shard's NUMA node so
+    /// allocations stay local and fast. Does nothing if no node is set.
     pub fn bind_memory(&self) -> Result<(), ShardingError> {
         if let Some(node_id) = self.numa_node {
             let topology = Topology::new().map_err(|err| 
ShardingError::TopologyDetection {
@@ -230,12 +257,16 @@ impl ShardInfo {
     }
 }
 
+/// 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,
     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> {
         let topology = if matches!(allocation, CpuAllocation::NumaAware(_)) {
             let numa_topology = NumaTopology::detect()?;
@@ -251,6 +282,8 @@ impl ShardAllocator {
         })
     }
 
+    /// Produce the final list of shards, one [`ShardInfo`] each, based
+    /// on the chosen [`CpuAllocation`]. This is the main entry point.
     pub fn to_shard_assignments(&self) -> Result<Vec<ShardInfo>, 
ShardingError> {
         match &self.allocation {
             CpuAllocation::All => {


Reply via email to