Skip to main content

apiary_core/
config.rs

1//! Node configuration with automatic system detection.
2//!
3//! [`NodeConfig`] captures a node's identity, storage configuration,
4//! hardware capacity, and tuning parameters. The [`NodeConfig::detect`]
5//! constructor auto-detects cores and memory from the system.
6
7use std::path::PathBuf;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::types::NodeId;
13
14/// Configuration for an Apiary node, including auto-detected system capacity.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct NodeConfig {
17    /// Unique identifier for this node.
18    pub node_id: NodeId,
19
20    /// Storage URI: `"local://~/.apiary/data"` or `"s3://bucket/prefix"`.
21    pub storage_uri: String,
22
23    /// Local directory for cell cache and scratch files.
24    pub cache_dir: PathBuf,
25
26    /// Number of CPU cores (auto-detected).
27    pub cores: usize,
28
29    /// Total memory in bytes (auto-detected).
30    pub memory_bytes: u64,
31
32    /// Memory budget per bee in bytes (`memory_bytes / cores`).
33    pub memory_per_bee: u64,
34
35    /// Target cell size in bytes for leafcutter sizing (`memory_per_bee / 4`).
36    pub target_cell_size: u64,
37
38    /// Maximum cell size in bytes (`target_cell_size * 2`).
39    pub max_cell_size: u64,
40
41    /// Minimum cell size in bytes (floor to amortise S3 overhead).
42    pub min_cell_size: u64,
43
44    /// Maximum cache size in bytes for local cell storage.
45    pub max_cache_size: u64,
46
47    /// Interval between heartbeat writes.
48    pub heartbeat_interval: Duration,
49
50    /// Duration after which a node with no heartbeat is considered dead.
51    pub dead_threshold: Duration,
52}
53
54/// Minimum cell size floor: 16 MB.
55const MIN_CELL_SIZE_FLOOR: u64 = 16 * 1024 * 1024;
56
57/// Default cache size: 2 GB.
58const DEFAULT_MAX_CACHE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
59
60/// Default heartbeat interval: 5 seconds.
61const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
62
63/// Default dead threshold: 30 seconds (6 missed heartbeats).
64const DEFAULT_DEAD_THRESHOLD: Duration = Duration::from_secs(30);
65
66impl NodeConfig {
67    /// Create a new `NodeConfig` by auto-detecting system resources.
68    ///
69    /// # Arguments
70    ///
71    /// * `storage_uri` — The storage backend URI (e.g., `"local://~/.apiary/data"`
72    ///   or `"s3://bucket/prefix"`).
73    ///
74    /// # Example
75    ///
76    /// ```
77    /// use apiary_core::config::NodeConfig;
78    ///
79    /// let config = NodeConfig::detect("local://~/.apiary/data");
80    /// assert!(config.cores > 0);
81    /// assert!(config.memory_bytes > 0);
82    /// ```
83    pub fn detect(storage_uri: impl Into<String>) -> Self {
84        let storage_uri = storage_uri.into();
85        let node_id = NodeId::generate();
86
87        let cores = std::thread::available_parallelism()
88            .map(|p| p.get())
89            .unwrap_or(1);
90
91        // Detect available system memory.
92        let memory_bytes = detect_memory();
93
94        let memory_per_bee = memory_bytes / cores as u64;
95        let target_cell_size = memory_per_bee / 4;
96        let max_cell_size = target_cell_size * 2;
97        let min_cell_size = MIN_CELL_SIZE_FLOOR;
98
99        let cache_dir = dirs_cache_dir().join("apiary").join("cache");
100
101        Self {
102            node_id,
103            storage_uri,
104            cache_dir,
105            cores,
106            memory_bytes,
107            memory_per_bee,
108            target_cell_size,
109            max_cell_size,
110            min_cell_size,
111            max_cache_size: DEFAULT_MAX_CACHE_SIZE,
112            heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
113            dead_threshold: DEFAULT_DEAD_THRESHOLD,
114        }
115    }
116}
117
118/// Detect total system memory in bytes.
119/// Falls back to 1 GB if detection fails.
120fn detect_memory() -> u64 {
121    // Use platform-specific detection
122    #[cfg(target_os = "linux")]
123    {
124        if let Ok(contents) = std::fs::read_to_string("/proc/meminfo") {
125            for line in contents.lines() {
126                if let Some(rest) = line.strip_prefix("MemTotal:") {
127                    let rest = rest.trim();
128                    if let Some(kb_str) = rest.strip_suffix("kB") {
129                        if let Ok(kb) = kb_str.trim().parse::<u64>() {
130                            return kb * 1024;
131                        }
132                    }
133                }
134            }
135        }
136    }
137
138    #[cfg(target_os = "windows")]
139    {
140        use std::mem;
141
142        #[repr(C)]
143        #[allow(non_snake_case)]
144        struct MEMORYSTATUSEX {
145            dwLength: u32,
146            dwMemoryLoad: u32,
147            ullTotalPhys: u64,
148            ullAvailPhys: u64,
149            ullTotalPageFile: u64,
150            ullAvailPageFile: u64,
151            ullTotalVirtual: u64,
152            ullAvailVirtual: u64,
153            ullAvailExtendedVirtual: u64,
154        }
155
156        extern "system" {
157            fn GlobalMemoryStatusEx(lpBuffer: *mut MEMORYSTATUSEX) -> i32;
158        }
159
160        unsafe {
161            let mut status: MEMORYSTATUSEX = mem::zeroed();
162            status.dwLength = mem::size_of::<MEMORYSTATUSEX>() as u32;
163            if GlobalMemoryStatusEx(&mut status) != 0 {
164                return status.ullTotalPhys;
165            }
166        }
167    }
168
169    #[cfg(target_os = "macos")]
170    {
171        use std::mem;
172
173        extern "C" {
174            fn sysctl(
175                name: *const i32,
176                namelen: u32,
177                oldp: *mut std::ffi::c_void,
178                oldlenp: *mut usize,
179                newp: *const std::ffi::c_void,
180                newlen: usize,
181            ) -> i32;
182        }
183
184        unsafe {
185            // CTL_HW = 6, HW_MEMSIZE = 24
186            let mib: [i32; 2] = [6, 24];
187            let mut memsize: u64 = 0;
188            let mut len = mem::size_of::<u64>();
189            if sysctl(
190                mib.as_ptr(),
191                2,
192                &mut memsize as *mut u64 as *mut std::ffi::c_void,
193                &mut len,
194                std::ptr::null(),
195                0,
196            ) == 0
197            {
198                return memsize;
199            }
200        }
201    }
202
203    // Fallback: 1 GB
204    1024 * 1024 * 1024
205}
206
207/// Return a suitable base directory for cache, falling back to `.` if the
208/// home directory cannot be determined.
209fn dirs_cache_dir() -> PathBuf {
210    home_dir().unwrap_or_else(|| PathBuf::from("."))
211}
212
213/// Best-effort home directory detection without pulling in a heavy crate.
214fn home_dir() -> Option<PathBuf> {
215    #[cfg(target_os = "windows")]
216    {
217        std::env::var("USERPROFILE").ok().map(PathBuf::from)
218    }
219    #[cfg(not(target_os = "windows"))]
220    {
221        std::env::var("HOME").ok().map(PathBuf::from)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_detect_creates_valid_config() {
231        let config = NodeConfig::detect("local://test");
232        assert!(config.cores > 0, "cores must be > 0");
233        assert!(config.memory_bytes > 0, "memory must be > 0");
234        assert_eq!(
235            config.memory_per_bee,
236            config.memory_bytes / config.cores as u64
237        );
238        assert_eq!(config.target_cell_size, config.memory_per_bee / 4);
239        assert_eq!(config.max_cell_size, config.target_cell_size * 2);
240        assert_eq!(config.min_cell_size, MIN_CELL_SIZE_FLOOR);
241        assert_eq!(config.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
242        assert_eq!(config.dead_threshold, DEFAULT_DEAD_THRESHOLD);
243    }
244
245    #[test]
246    fn test_detect_unique_node_ids() {
247        let c1 = NodeConfig::detect("local://a");
248        let c2 = NodeConfig::detect("local://b");
249        assert_ne!(c1.node_id, c2.node_id);
250    }
251
252    #[test]
253    fn test_config_serialization() {
254        let config = NodeConfig::detect("s3://bucket/prefix");
255        let json = serde_json::to_string(&config).unwrap();
256        let deserialized: NodeConfig = serde_json::from_str(&json).unwrap();
257        assert_eq!(deserialized.storage_uri, config.storage_uri);
258        assert_eq!(deserialized.cores, config.cores);
259    }
260}