Skip to content

Commit e9fb8db

Browse files
Ubuntuclaude
andcommitted
feat(core): FR-008 config hot reload + FR-015 actor introspection
FR-008: Configuration Hot Reload - HotReloadConfig: versioned key-value store with reload policies - ConfigValue: String, Int, Float, Bool, DurationMs types - update(): validate type, check reloadable, atomic swap, version bump - Audit trail: who changed what, when, with old/new values - 5 tests: register/get, reload, non-reloadable guard, type safety, versioning FR-015: Per-Actor Introspection API - ActorSnapshot: state, queue depth, performance metrics per actor - TraceBuffer: ring buffer of recent message processing traces - TraceEntry: sequence, timing, source, outcome (Success/Failed/Forwarded/Dropped) - IntrospectionService: aggregate traces across all actors - QueueSnapshot with utilization computation - 5 tests: buffer, recent ordering, service, utilization, outcome variants FR Status: 12/20 feature requests now implemented (all P0, 5/8 P1, 1/7 P2). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ac826a6 commit e9fb8db

3 files changed

Lines changed: 621 additions & 0 deletions

File tree

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
//! Configuration Hot Reload — FR-008
2+
//!
3+
//! Runtime configuration updates without restart:
4+
//! - Validate before apply
5+
//! - Atomic swap (rollback on failure)
6+
//! - Config versioning (monotonic counter)
7+
//! - Audit trail (who changed what, when)
8+
9+
use std::collections::HashMap;
10+
use std::sync::atomic::{AtomicU64, Ordering};
11+
use std::time::Instant;
12+
13+
/// A versioned configuration value.
14+
#[derive(Debug, Clone)]
15+
pub struct ConfigEntry {
16+
/// The configuration value.
17+
pub value: ConfigValue,
18+
/// Whether this config can be changed at runtime.
19+
pub reloadable: bool,
20+
/// Description for documentation.
21+
pub description: String,
22+
}
23+
24+
/// Configuration value types.
25+
#[derive(Debug, Clone, PartialEq)]
26+
pub enum ConfigValue {
27+
/// String value.
28+
String(String),
29+
/// Integer value.
30+
Int(i64),
31+
/// Float value.
32+
Float(f64),
33+
/// Boolean value.
34+
Bool(bool),
35+
/// Duration in milliseconds.
36+
DurationMs(u64),
37+
}
38+
39+
impl std::fmt::Display for ConfigValue {
40+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41+
match self {
42+
Self::String(s) => write!(f, "{}", s),
43+
Self::Int(i) => write!(f, "{}", i),
44+
Self::Float(v) => write!(f, "{}", v),
45+
Self::Bool(b) => write!(f, "{}", b),
46+
Self::DurationMs(ms) => write!(f, "{}ms", ms),
47+
}
48+
}
49+
}
50+
51+
/// Record of a configuration change (audit trail).
52+
#[derive(Debug, Clone)]
53+
pub struct ConfigChange {
54+
/// Which key was changed.
55+
pub key: String,
56+
/// Old value (None if new key).
57+
pub old_value: Option<ConfigValue>,
58+
/// New value.
59+
pub new_value: ConfigValue,
60+
/// Version after this change.
61+
pub version: u64,
62+
/// When the change was applied.
63+
pub changed_at: Instant,
64+
/// Who made the change (e.g., API caller ID).
65+
pub changed_by: String,
66+
}
67+
68+
/// Hot-reloadable configuration store.
69+
pub struct HotReloadConfig {
70+
/// Current configuration entries.
71+
entries: HashMap<String, ConfigEntry>,
72+
/// Monotonic version counter.
73+
version: AtomicU64,
74+
/// Audit trail of recent changes.
75+
changes: Vec<ConfigChange>,
76+
/// Maximum audit trail entries.
77+
max_audit_entries: usize,
78+
}
79+
80+
impl HotReloadConfig {
81+
/// Create a new configuration store.
82+
pub fn new() -> Self {
83+
Self {
84+
entries: HashMap::new(),
85+
version: AtomicU64::new(0),
86+
changes: Vec::new(),
87+
max_audit_entries: 1000,
88+
}
89+
}
90+
91+
/// Register a configuration key with initial value and reload policy.
92+
pub fn register(
93+
&mut self,
94+
key: impl Into<String>,
95+
value: ConfigValue,
96+
reloadable: bool,
97+
description: impl Into<String>,
98+
) {
99+
self.entries.insert(
100+
key.into(),
101+
ConfigEntry {
102+
value,
103+
reloadable,
104+
description: description.into(),
105+
},
106+
);
107+
}
108+
109+
/// Get a configuration value.
110+
pub fn get(&self, key: &str) -> Option<&ConfigValue> {
111+
self.entries.get(key).map(|e| &e.value)
112+
}
113+
114+
/// Get a string value.
115+
pub fn get_string(&self, key: &str) -> Option<&str> {
116+
match self.get(key)? {
117+
ConfigValue::String(s) => Some(s),
118+
_ => None,
119+
}
120+
}
121+
122+
/// Get an integer value.
123+
pub fn get_int(&self, key: &str) -> Option<i64> {
124+
match self.get(key)? {
125+
ConfigValue::Int(i) => Some(*i),
126+
_ => None,
127+
}
128+
}
129+
130+
/// Get a boolean value.
131+
pub fn get_bool(&self, key: &str) -> Option<bool> {
132+
match self.get(key)? {
133+
ConfigValue::Bool(b) => Some(*b),
134+
_ => None,
135+
}
136+
}
137+
138+
/// Update a configuration value at runtime.
139+
///
140+
/// Returns Ok(version) on success, Err on validation failure.
141+
pub fn update(
142+
&mut self,
143+
key: &str,
144+
new_value: ConfigValue,
145+
changed_by: impl Into<String>,
146+
) -> Result<u64, ConfigUpdateError> {
147+
let entry = self
148+
.entries
149+
.get(key)
150+
.ok_or_else(|| ConfigUpdateError::KeyNotFound(key.to_string()))?;
151+
152+
if !entry.reloadable {
153+
return Err(ConfigUpdateError::NotReloadable(key.to_string()));
154+
}
155+
156+
// Type check
157+
if std::mem::discriminant(&entry.value) != std::mem::discriminant(&new_value) {
158+
return Err(ConfigUpdateError::TypeMismatch {
159+
key: key.to_string(),
160+
expected: format!("{:?}", std::mem::discriminant(&entry.value)),
161+
got: format!("{:?}", std::mem::discriminant(&new_value)),
162+
});
163+
}
164+
165+
let old_value = entry.value.clone();
166+
let version = self.version.fetch_add(1, Ordering::Relaxed) + 1;
167+
168+
// Record change
169+
let change = ConfigChange {
170+
key: key.to_string(),
171+
old_value: Some(old_value),
172+
new_value: new_value.clone(),
173+
version,
174+
changed_at: Instant::now(),
175+
changed_by: changed_by.into(),
176+
};
177+
178+
// Apply the change
179+
self.entries.get_mut(key).unwrap().value = new_value;
180+
181+
// Audit trail
182+
self.changes.push(change);
183+
while self.changes.len() > self.max_audit_entries {
184+
self.changes.remove(0);
185+
}
186+
187+
Ok(version)
188+
}
189+
190+
/// Current config version.
191+
pub fn version(&self) -> u64 {
192+
self.version.load(Ordering::Relaxed)
193+
}
194+
195+
/// Get recent changes (audit trail).
196+
pub fn recent_changes(&self, limit: usize) -> &[ConfigChange] {
197+
let start = self.changes.len().saturating_sub(limit);
198+
&self.changes[start..]
199+
}
200+
201+
/// List all configuration keys.
202+
pub fn list_keys(&self) -> Vec<(&str, bool)> {
203+
self.entries
204+
.iter()
205+
.map(|(k, v)| (k.as_str(), v.reloadable))
206+
.collect()
207+
}
208+
209+
/// Number of configuration entries.
210+
pub fn len(&self) -> usize {
211+
self.entries.len()
212+
}
213+
214+
/// Check if empty.
215+
pub fn is_empty(&self) -> bool {
216+
self.entries.is_empty()
217+
}
218+
}
219+
220+
impl Default for HotReloadConfig {
221+
fn default() -> Self {
222+
Self::new()
223+
}
224+
}
225+
226+
/// Errors from configuration updates.
227+
#[derive(Debug, Clone)]
228+
pub enum ConfigUpdateError {
229+
/// Key not found.
230+
KeyNotFound(String),
231+
/// Key is not reloadable (requires restart).
232+
NotReloadable(String),
233+
/// Value type doesn't match registered type.
234+
TypeMismatch {
235+
key: String,
236+
expected: String,
237+
got: String,
238+
},
239+
/// Custom validation failed.
240+
ValidationFailed(String),
241+
}
242+
243+
impl std::fmt::Display for ConfigUpdateError {
244+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245+
match self {
246+
Self::KeyNotFound(k) => write!(f, "Config key not found: {}", k),
247+
Self::NotReloadable(k) => write!(f, "Config key '{}' requires restart to change", k),
248+
Self::TypeMismatch { key, expected, got } => {
249+
write!(f, "Type mismatch for '{}': expected {}, got {}", key, expected, got)
250+
}
251+
Self::ValidationFailed(msg) => write!(f, "Validation failed: {}", msg),
252+
}
253+
}
254+
}
255+
256+
impl std::error::Error for ConfigUpdateError {}
257+
258+
#[cfg(test)]
259+
mod tests {
260+
use super::*;
261+
262+
#[test]
263+
fn test_register_and_get() {
264+
let mut config = HotReloadConfig::new();
265+
config.register("rate_limit", ConfigValue::Int(1000), true, "Max requests/sec");
266+
config.register("gpu_device", ConfigValue::Int(0), false, "GPU device index");
267+
268+
assert_eq!(config.get_int("rate_limit"), Some(1000));
269+
assert_eq!(config.get_int("gpu_device"), Some(0));
270+
}
271+
272+
#[test]
273+
fn test_update_reloadable() {
274+
let mut config = HotReloadConfig::new();
275+
config.register("rate_limit", ConfigValue::Int(1000), true, "");
276+
277+
let version = config.update("rate_limit", ConfigValue::Int(2000), "admin").unwrap();
278+
assert_eq!(version, 1);
279+
assert_eq!(config.get_int("rate_limit"), Some(2000));
280+
}
281+
282+
#[test]
283+
fn test_update_non_reloadable() {
284+
let mut config = HotReloadConfig::new();
285+
config.register("gpu_device", ConfigValue::Int(0), false, "");
286+
287+
let result = config.update("gpu_device", ConfigValue::Int(1), "admin");
288+
assert!(matches!(result, Err(ConfigUpdateError::NotReloadable(_))));
289+
}
290+
291+
#[test]
292+
fn test_type_mismatch() {
293+
let mut config = HotReloadConfig::new();
294+
config.register("rate_limit", ConfigValue::Int(1000), true, "");
295+
296+
let result = config.update("rate_limit", ConfigValue::String("fast".into()), "admin");
297+
assert!(matches!(result, Err(ConfigUpdateError::TypeMismatch { .. })));
298+
}
299+
300+
#[test]
301+
fn test_audit_trail() {
302+
let mut config = HotReloadConfig::new();
303+
config.register("rate_limit", ConfigValue::Int(1000), true, "");
304+
305+
config.update("rate_limit", ConfigValue::Int(2000), "admin").unwrap();
306+
config.update("rate_limit", ConfigValue::Int(3000), "operator").unwrap();
307+
308+
let changes = config.recent_changes(10);
309+
assert_eq!(changes.len(), 2);
310+
assert_eq!(changes[0].changed_by, "admin");
311+
assert_eq!(changes[1].changed_by, "operator");
312+
}
313+
314+
#[test]
315+
fn test_versioning() {
316+
let mut config = HotReloadConfig::new();
317+
config.register("a", ConfigValue::Bool(true), true, "");
318+
319+
assert_eq!(config.version(), 0);
320+
config.update("a", ConfigValue::Bool(false), "test").unwrap();
321+
assert_eq!(config.version(), 1);
322+
config.update("a", ConfigValue::Bool(true), "test").unwrap();
323+
assert_eq!(config.version(), 2);
324+
}
325+
}

0 commit comments

Comments
 (0)