Skip to main content

relux_runtime/effect/
registry.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::sync::Arc;
4
5use tokio::sync::Mutex as TokioMutex;
6
7use crate::report::result::Failure;
8use crate::vm::Vm;
9use crate::vm::context::Scope;
10use relux_core::diagnostics::EffectId as DiagEffectId;
11use relux_core::pure::Env;
12use relux_ir::IrCleanupBlock;
13
14// ─── EffectInstanceKey ──────────────────────────────────────
15
16#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17pub struct EffectInstanceKey {
18    pub effect_id: DiagEffectId,
19    pub evaluated_overlay: String,
20}
21
22impl EffectInstanceKey {
23    /// Build from effect ID and the expected-variable values in declaration order.
24    ///
25    /// Only the values of variables declared in `expect` participate in identity.
26    /// The order comes from the `expect` declaration, so no sorting is needed.
27    /// Values are joined with `\0` (null byte) to avoid ambiguity — overlay
28    /// values are shell strings and cannot contain null bytes.
29    pub fn from_expects(
30        effect_id: DiagEffectId,
31        expect_names: &[&str],
32        evaluated_overlay: &Env,
33    ) -> Self {
34        let identity: String = expect_names
35            .iter()
36            .map(|name| {
37                let val = evaluated_overlay.get(name).unwrap_or("");
38                format!("{name}\0{val}")
39            })
40            .collect::<Vec<_>>()
41            .join("\0");
42        Self {
43            effect_id,
44            evaluated_overlay: identity,
45        }
46    }
47}
48
49// ─── EffectHandle ───────────────────────────────────────────
50
51pub struct EffectHandle {
52    pub scope: Scope,
53    /// All shells owned by this effect (both exposed and internal).
54    pub shells: HashMap<String, Arc<TokioMutex<Vm>>>,
55    /// Names of shells that are exposed to the caller.
56    pub exposed: HashSet<String>,
57    pub dependencies: Vec<EffectInstanceKey>,
58    pub cleanup: Option<IrCleanupBlock>,
59}
60
61impl EffectHandle {
62    /// Return only the shells that are exposed to the caller.
63    pub fn exposed_shells(&self) -> HashMap<String, Arc<TokioMutex<Vm>>> {
64        self.shells
65            .iter()
66            .filter(|(name, _)| self.exposed.contains(name.as_str()))
67            .map(|(k, v)| (k.clone(), v.clone()))
68            .collect()
69    }
70}
71
72// ─── EffectSlot ─────────────────────────────────────────────
73
74pub enum EffectSlot {
75    Empty,
76    Ready {
77        refcount: usize,
78        handle: EffectHandle,
79    },
80    Failed(Failure),
81}
82
83// ─── EffectRegistry ─────────────────────────────────────────
84
85pub struct EffectRegistry {
86    slots: std::sync::Mutex<HashMap<EffectInstanceKey, Arc<TokioMutex<EffectSlot>>>>,
87    /// Ordered log of every acquisition (with duplicates for deduped effects).
88    /// Mirrors the order in which `acquire` was called, so cleanup can run
89    /// one `run_cleanup` per acquisition — correctly draining refcounts.
90    acquisition_order: std::sync::Mutex<Vec<EffectInstanceKey>>,
91}
92
93impl Default for EffectRegistry {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl EffectRegistry {
100    pub fn new() -> Self {
101        Self {
102            slots: std::sync::Mutex::new(HashMap::new()),
103            acquisition_order: std::sync::Mutex::new(Vec::new()),
104        }
105    }
106
107    /// Get or create the slot for a given key.
108    /// The outer std::sync::Mutex is held only briefly for the HashMap lookup.
109    pub fn slot(&self, key: &EffectInstanceKey) -> Arc<TokioMutex<EffectSlot>> {
110        self.slots
111            .lock()
112            .expect("slot map mutex poisoned")
113            .entry(key.clone())
114            .or_insert_with(|| Arc::new(TokioMutex::new(EffectSlot::Empty)))
115            .clone()
116    }
117
118    /// Record that a key was acquired (called once per `acquire`, including dedup hits).
119    pub fn record_acquisition(&self, key: EffectInstanceKey) {
120        self.acquisition_order
121            .lock()
122            .expect("acquisition order mutex poisoned")
123            .push(key);
124    }
125
126    /// Return the full ordered acquisition log (with duplicates).
127    pub fn acquired_keys(&self) -> Vec<EffectInstanceKey> {
128        self.acquisition_order
129            .lock()
130            .expect("acquisition order mutex poisoned")
131            .clone()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn test_key(name: &str) -> EffectInstanceKey {
140        EffectInstanceKey {
141            effect_id: DiagEffectId {
142                module: relux_core::diagnostics::ModulePath("test.relux".into()),
143                name: relux_core::diagnostics::EffectName(name.to_string()),
144            },
145            evaluated_overlay: String::new(),
146        }
147    }
148
149    fn test_key_with_overlay(name: &str, overlay: &str) -> EffectInstanceKey {
150        EffectInstanceKey {
151            effect_id: DiagEffectId {
152                module: relux_core::diagnostics::ModulePath("test.relux".into()),
153                name: relux_core::diagnostics::EffectName(name.to_string()),
154            },
155            evaluated_overlay: overlay.to_string(),
156        }
157    }
158
159    #[test]
160    fn key_equality_same() {
161        let k1 = test_key("Db");
162        let k2 = test_key("Db");
163        assert_eq!(k1, k2);
164    }
165
166    #[test]
167    fn key_equality_different_name() {
168        let k1 = test_key("Db");
169        let k2 = test_key("Redis");
170        assert_ne!(k1, k2);
171    }
172
173    #[test]
174    fn key_equality_different_overlay() {
175        let k1 = test_key_with_overlay("Db", "PORT=5432");
176        let k2 = test_key_with_overlay("Db", "PORT=5433");
177        assert_ne!(k1, k2);
178    }
179
180    #[test]
181    fn key_hash_consistent() {
182        use std::collections::hash_map::DefaultHasher;
183        use std::hash::Hash;
184        use std::hash::Hasher;
185        let k1 = test_key("Db");
186        let k2 = test_key("Db");
187        let mut h1 = DefaultHasher::new();
188        let mut h2 = DefaultHasher::new();
189        k1.hash(&mut h1);
190        k2.hash(&mut h2);
191        assert_eq!(h1.finish(), h2.finish());
192    }
193
194    #[test]
195    fn registry_new_is_empty() {
196        let reg = EffectRegistry::new();
197        assert!(reg.slots.lock().unwrap().is_empty());
198    }
199
200    #[tokio::test]
201    async fn slot_creates_empty_on_first_access() {
202        let reg = EffectRegistry::new();
203        let key = test_key("Db");
204        let slot = reg.slot(&key);
205        let guard = slot.lock().await;
206        assert!(matches!(*guard, EffectSlot::Empty));
207    }
208
209    #[tokio::test]
210    async fn slot_returns_same_arc_for_same_key() {
211        let reg = EffectRegistry::new();
212        let key = test_key("Db");
213        let s1 = reg.slot(&key);
214        let s2 = reg.slot(&key);
215        assert!(Arc::ptr_eq(&s1, &s2));
216    }
217
218    #[tokio::test]
219    async fn slot_returns_different_arcs_for_different_keys() {
220        let reg = EffectRegistry::new();
221        let k1 = test_key("Db");
222        let k2 = test_key("Redis");
223        let s1 = reg.slot(&k1);
224        let s2 = reg.slot(&k2);
225        assert!(!Arc::ptr_eq(&s1, &s2));
226    }
227
228    #[test]
229    fn acquired_keys_empty_registry() {
230        let reg = EffectRegistry::new();
231        assert!(reg.acquired_keys().is_empty());
232    }
233
234    #[test]
235    fn acquired_keys_preserves_order_and_duplicates() {
236        let reg = EffectRegistry::new();
237        let k1 = test_key("Db");
238        let k2 = test_key("Redis");
239        reg.record_acquisition(k1.clone());
240        reg.record_acquisition(k2.clone());
241        reg.record_acquisition(k1.clone());
242        let keys = reg.acquired_keys();
243        assert_eq!(keys.len(), 3);
244        assert_eq!(keys[0].effect_id.name.0, "Db");
245        assert_eq!(keys[1].effect_id.name.0, "Redis");
246        assert_eq!(keys[2].effect_id.name.0, "Db");
247    }
248
249    #[test]
250    fn from_expects_no_collision_when_value_contains_separator() {
251        // Two structurally different overlays must produce different keys.
252        // Effect expects A only. Overlay 1: A = "x\0y", Overlay 2: A = "x".
253        // With naive join these could collide; null-byte framing prevents it.
254        use std::collections::HashMap;
255        let effect_id = DiagEffectId {
256            module: relux_core::diagnostics::ModulePath("test.relux".into()),
257            name: relux_core::diagnostics::EffectName("E".to_string()),
258        };
259
260        let mut overlay1 = HashMap::new();
261        overlay1.insert("A".into(), "x,B=y".into());
262        let env1 = relux_core::pure::Env::from_map(overlay1);
263
264        let mut overlay2 = HashMap::new();
265        overlay2.insert("A".into(), "x".into());
266        overlay2.insert("B".into(), "y".into());
267        let env2 = relux_core::pure::Env::from_map(overlay2);
268
269        let expects = &["A"];
270        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
271        let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
272        assert_ne!(
273            k1, k2,
274            "different expect values must produce different keys"
275        );
276    }
277
278    #[test]
279    fn from_expects_uses_only_expected_keys() {
280        // Extra overlay keys beyond what the effect expects should not
281        // affect identity — only expected variable values matter.
282        use std::collections::HashMap;
283        let effect_id = DiagEffectId {
284            module: relux_core::diagnostics::ModulePath("test.relux".into()),
285            name: relux_core::diagnostics::EffectName("E".to_string()),
286        };
287
288        let mut overlay1 = HashMap::new();
289        overlay1.insert("PORT".into(), "5432".into());
290        overlay1.insert("EXTRA".into(), "foo".into());
291        let env1 = relux_core::pure::Env::from_map(overlay1);
292
293        let mut overlay2 = HashMap::new();
294        overlay2.insert("PORT".into(), "5432".into());
295        overlay2.insert("EXTRA".into(), "bar".into());
296        let env2 = relux_core::pure::Env::from_map(overlay2);
297
298        let expects = &["PORT"];
299        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
300        let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
301        assert_eq!(
302            k1, k2,
303            "extra overlay keys beyond expects should not affect identity"
304        );
305    }
306
307    #[test]
308    fn from_expects_declaration_order_is_stable() {
309        use std::collections::HashMap;
310        let effect_id = DiagEffectId {
311            module: relux_core::diagnostics::ModulePath("test.relux".into()),
312            name: relux_core::diagnostics::EffectName("E".to_string()),
313        };
314
315        let mut overlay = HashMap::new();
316        overlay.insert("A".into(), "1".into());
317        overlay.insert("B".into(), "2".into());
318        let env = relux_core::pure::Env::from_map(overlay);
319
320        // Same expects in same order → same key
321        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), &["A", "B"], &env);
322        let k2 = EffectInstanceKey::from_expects(effect_id, &["A", "B"], &env);
323        assert_eq!(k1, k2);
324    }
325
326    #[test]
327    fn from_expects_empty_expects_produces_equal_keys() {
328        use std::collections::HashMap;
329        let effect_id = DiagEffectId {
330            module: relux_core::diagnostics::ModulePath("test.relux".into()),
331            name: relux_core::diagnostics::EffectName("E".to_string()),
332        };
333
334        let mut overlay1 = HashMap::new();
335        overlay1.insert("X".into(), "1".into());
336        let env1 = relux_core::pure::Env::from_map(overlay1);
337        let env2 = relux_core::pure::Env::from_map(HashMap::new());
338
339        let expects: &[&str] = &[];
340        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
341        let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
342        assert_eq!(
343            k1, k2,
344            "effects with no expects should always share identity"
345        );
346    }
347
348    #[test]
349    fn acquired_keys_not_recorded_for_failed_slots() {
350        // Failed acquisitions should not be recorded — only successful ones.
351        let reg = EffectRegistry::new();
352        // Slot exists but no acquisition was recorded.
353        let key = test_key("Broken");
354        reg.slot(&key);
355        assert!(reg.acquired_keys().is_empty());
356    }
357}