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;
6use tokio::sync::Notify;
7
8use crate::observe::structured::SpanId;
9use crate::report::result::ExecError;
10use crate::vm::Vm;
11use crate::vm::context::Scope;
12use relux_core::diagnostics::EffectId as DiagEffectId;
13use relux_core::pure::Env;
14use relux_ir::IrCleanupBlock;
15
16// --- Type Aliases ----------------------------------------
17
18pub type ShellMap = HashMap<String, Arc<TokioMutex<Vm>>>;
19pub type VarMap = HashMap<String, String>;
20
21// --- ExportedEffect / AcquiredEffect ---------------------
22
23/// Result of instantiating a single effect: identity key + exposed shells and vars.
24pub struct ExportedEffect {
25    pub key: EffectInstanceKey,
26    pub shells: ShellMap,
27    pub vars: VarMap,
28}
29
30/// Result of acquiring a single effect instance: exposed shells and vars (no key).
31pub struct AcquiredEffect {
32    pub shells: ShellMap,
33    pub vars: VarMap,
34}
35
36// --- EffectInstanceKey -----------------------------------
37
38#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct EffectInstanceKey {
40    pub effect_id: DiagEffectId,
41    pub evaluated_overlay: String,
42}
43
44impl EffectInstanceKey {
45    /// Build from effect ID and the expected-variable values in declaration order.
46    ///
47    /// Only the values of variables declared in `expect` participate in identity.
48    /// The order comes from the `expect` declaration, so no sorting is needed.
49    /// Values are joined with `\0` (null byte) to avoid ambiguity - overlay
50    /// values are shell strings and cannot contain null bytes.
51    pub fn from_expects(
52        effect_id: DiagEffectId,
53        expect_names: &[&str],
54        evaluated_overlay: &Env,
55    ) -> Self {
56        let identity: String = expect_names
57            .iter()
58            .map(|name| {
59                let val = evaluated_overlay.get(name).unwrap_or("");
60                format!("{name}\0{val}")
61            })
62            .collect::<Vec<_>>()
63            .join("\0");
64        Self {
65            effect_id,
66            evaluated_overlay: identity,
67        }
68    }
69
70    /// Stable mnemonic computed from the dedup identity. Same key ->
71    /// same marker; two acquires of the same effect-instance (one
72    /// bootstrap + N dedup'd reuses) all share this string.
73    pub fn marker(&self) -> String {
74        use relux_core::hash::StableHasher;
75        use std::hash::Hasher;
76        let mut hasher = StableHasher::new();
77        std::hash::Hash::hash(self, &mut hasher);
78        relux_core::diagnostics::format_mnemonic(hasher.finish())
79    }
80}
81
82// --- ShellInstanceKey ------------------------------------
83
84/// Stable identity for a shell, regardless of how the shell is
85/// renamed by `reset_for_export` later in its lifetime. The marker
86/// hashed from this key is stored on the VM at spawn time and
87/// threaded through every shell-bearing event and buffer event.
88///
89/// `Effect`: shells owned by an effect instance (`shell foo { ... }`
90/// inside an effect body), including the synthetic `__cleanup`
91/// shell each effect cleanup opens. Identity composes the dedup
92/// `EffectInstanceKey` with the shell's local name.
93///
94/// `Test`: shells owned directly by the test span (`shell foo { ... }`
95/// at test scope, plus the synthetic test-level `__cleanup`).
96#[derive(Clone, Debug, PartialEq, Eq, Hash)]
97pub enum ShellInstanceKey {
98    Effect {
99        effect: EffectInstanceKey,
100        shell_name: String,
101    },
102    Test {
103        shell_name: String,
104    },
105}
106
107impl ShellInstanceKey {
108    /// Stable mnemonic computed from the identity. Same key -> same
109    /// marker across runs.
110    pub fn marker(&self) -> String {
111        use relux_core::hash::StableHasher;
112        use std::hash::Hasher;
113        let mut hasher = StableHasher::new();
114        std::hash::Hash::hash(self, &mut hasher);
115        relux_core::diagnostics::format_mnemonic(hasher.finish())
116    }
117}
118
119// --- EffectHandle ----------------------------------------
120
121pub struct EffectHandle {
122    pub scope: Scope,
123    /// All shells owned by this effect (both exposed and internal).
124    pub shells: ShellMap,
125    /// Names of shells that are exposed to the caller.
126    pub exposed: HashSet<String>,
127    /// Variables exposed to the caller (name -> value).
128    pub exposed_vars: VarMap,
129    /// Guards held for each acquired dependency. Dropping a guard via
130    /// `release_and_teardown` decrements the dep's refcount; the last
131    /// holder triggers the dep's cleanup body.
132    pub dep_guards: Vec<EffectGuard>,
133    pub cleanup: Option<IrCleanupBlock>,
134    /// The `EffectSetup` span this handle represents. Threaded into the
135    /// `EffectCleanup` span at teardown so the viewer can resolve a
136    /// cleanup shell's scope back to the owning effect - cleanups
137    /// themselves are now parented directly under the test span, so this
138    /// is the only link from cleanup back to the originating setup.
139    pub setup_span: SpanId,
140    /// Dedup key for this effect instance. Needed at cleanup time to
141    /// derive a `ShellInstanceKey::Effect` for the synthetic
142    /// `__cleanup` shell. `marker` below is `key.marker()`,
143    /// pre-computed at construction.
144    pub key: EffectInstanceKey,
145    /// Identity marker mirrored from the dedup key. Threaded into every
146    /// `EffectCleanup` span this handle drives at teardown so partner
147    /// lookup by marker works without re-deriving from the key.
148    pub marker: String,
149    /// Alias supplied at the first acquisition (`start <FX> as <alias>`).
150    /// `None` when no alias was used. Threaded into the `EffectCleanup`
151    /// span so the cleanup card can mirror `EffectSetup`'s alias display.
152    pub alias: Option<String>,
153}
154
155impl EffectHandle {
156    /// Return only the shells that are exposed to the caller.
157    pub fn exposed_shells(&self) -> ShellMap {
158        self.shells
159            .iter()
160            .filter(|(name, _)| self.exposed.contains(name.as_str()))
161            .map(|(k, v)| (k.clone(), v.clone()))
162            .collect()
163    }
164
165    /// Return the exposed variables.
166    pub fn exposed_vars(&self) -> &VarMap {
167        &self.exposed_vars
168    }
169}
170
171// --- EffectSlot ------------------------------------------
172
173pub enum EffectSlot {
174    Empty,
175    /// Bootstrap is in flight on another task. Acquirers that hit
176    /// this state clone the `Notify`, drop the slot lock, and await
177    /// `notified()`; the bootstrapping task transitions the slot to
178    /// `Ready` or `Failed` and calls `notify_waiters()`.
179    Loading(Arc<Notify>),
180    Ready {
181        refcount: usize,
182        handle: Box<EffectHandle>,
183    },
184    Failed(ExecError),
185}
186
187// --- EffectGuard -----------------------------------------
188
189/// What `EffectGuard::release` returns.
190///
191/// - `LastHolder` - refcount went to zero. Caller takes ownership of
192///   the handle and runs the cleanup body.
193/// - `Deferred` - refcount stayed positive. Caller emits a
194///   zero-duration deferred `EffectCleanup` span using the supplied
195///   metadata; the actual cleanup span (and body) will be opened by a
196///   later releaser.
197/// - `Drift` - slot wasn't `Ready` (only reachable on a bug: a guard
198///   was released against a slot it doesn't belong to). A
199///   `debug_assert!` fires inside `release`; release builds short-circuit.
200pub enum ReleaseOutcome {
201    LastHolder {
202        handle: Box<EffectHandle>,
203    },
204    Deferred {
205        effect: String,
206        alias: Option<String>,
207        setup_span: SpanId,
208        marker: String,
209    },
210    Drift,
211}
212
213/// Outstanding handle on one acquired refcount of an `EffectSlot`.
214///
215/// Constructed only by `EffectManager::acquire` via `EffectGuard::new`
216/// (one guard per successful acquire, including dedup hits). Consumed
217/// by `release`, which atomically decrements the slot's refcount under
218/// the slot mutex and returns the `EffectHandle` to the releaser whose
219/// decrement hit zero (i.e. exactly once per fully-acquired slot).
220///
221/// Not `Clone`, not `Copy`, with a private `slot` field: the
222/// type-level non-cloneability plus the crate-internal-only
223/// constructor keep refcount and outstanding-guard count in lockstep.
224pub struct EffectGuard {
225    slot: Arc<TokioMutex<EffectSlot>>,
226}
227
228impl EffectGuard {
229    /// Crate-internal constructor. Caller MUST have just incremented
230    /// (or initialized to 1) the refcount on the slot this guard
231    /// points at; otherwise the refcount and outstanding-guard count
232    /// will drift.
233    pub(crate) fn new(slot: Arc<TokioMutex<EffectSlot>>) -> Self {
234        Self { slot }
235    }
236
237    /// Atomic decrement-and-take under the slot mutex.
238    ///
239    /// Returns `LastHolder { handle }` exactly once per slot (the call
240    /// that drove `refcount` from 1 to 0) and `Deferred { ... }` from
241    /// every other release on the same slot, carrying the metadata the
242    /// caller needs to emit a zero-duration deferred-cleanup span.
243    /// The handle is moved out of the slot; the slot becomes
244    /// `EffectSlot::Empty`. Callers run the returned handle's cleanup
245    /// body *after* this method returns, so the slot mutex is not held
246    /// during cleanup.
247    pub async fn release(self) -> ReleaseOutcome {
248        let mut guard = self.slot.lock().await;
249        match &mut *guard {
250            EffectSlot::Ready { refcount, handle } => {
251                *refcount -= 1;
252                if *refcount == 0 {
253                    let taken = std::mem::replace(&mut *guard, EffectSlot::Empty);
254                    match taken {
255                        EffectSlot::Ready { handle, .. } => ReleaseOutcome::LastHolder { handle },
256                        _ => unreachable!("matched Ready above"),
257                    }
258                } else {
259                    ReleaseOutcome::Deferred {
260                        effect: handle.scope.name().to_string(),
261                        alias: handle.alias.clone(),
262                        setup_span: handle.setup_span,
263                        marker: handle.marker.clone(),
264                    }
265                }
266            }
267            EffectSlot::Empty | EffectSlot::Loading(_) | EffectSlot::Failed(_) => {
268                debug_assert!(
269                    false,
270                    r#"EffectGuard::release on non-Ready slot indicates a refcount/outstanding-guard drift; every guard must point at a Ready slot until it is consumed"#,
271                );
272                ReleaseOutcome::Drift
273            }
274        }
275    }
276}
277
278// --- EffectRegistry --------------------------------------
279
280pub struct EffectRegistry {
281    slots: std::sync::Mutex<HashMap<EffectInstanceKey, Arc<TokioMutex<EffectSlot>>>>,
282}
283
284impl Default for EffectRegistry {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290impl EffectRegistry {
291    pub fn new() -> Self {
292        Self {
293            slots: std::sync::Mutex::new(HashMap::new()),
294        }
295    }
296
297    /// Get or create the slot for a given key.
298    /// The outer std::sync::Mutex is held only briefly for the HashMap lookup.
299    pub fn slot(&self, key: &EffectInstanceKey) -> Arc<TokioMutex<EffectSlot>> {
300        self.slots
301            .lock()
302            .expect("slot map mutex poisoned")
303            .entry(key.clone())
304            .or_insert_with(|| Arc::new(TokioMutex::new(EffectSlot::Empty)))
305            .clone()
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn test_key(name: &str) -> EffectInstanceKey {
314        EffectInstanceKey {
315            effect_id: DiagEffectId {
316                module: relux_core::diagnostics::ModulePath("test.relux".into()),
317                name: relux_core::diagnostics::EffectName(name.to_string()),
318            },
319            evaluated_overlay: String::new(),
320        }
321    }
322
323    fn test_key_with_overlay(name: &str, overlay: &str) -> EffectInstanceKey {
324        EffectInstanceKey {
325            effect_id: DiagEffectId {
326                module: relux_core::diagnostics::ModulePath("test.relux".into()),
327                name: relux_core::diagnostics::EffectName(name.to_string()),
328            },
329            evaluated_overlay: overlay.to_string(),
330        }
331    }
332
333    fn stub_handle() -> EffectHandle {
334        use crate::vm::context::Scope;
335        use relux_core::pure::VarScope;
336        EffectHandle {
337            scope: Scope::Test {
338                name: "stub".into(),
339                vars: Arc::new(TokioMutex::new(VarScope::new())),
340                timeout: None,
341            },
342            shells: HashMap::new(),
343            exposed: HashSet::new(),
344            exposed_vars: HashMap::new(),
345            dep_guards: Vec::new(),
346            cleanup: None,
347            setup_span: 0u64,
348            key: test_key("stub"),
349            marker: "stub-marker-0000".into(),
350            alias: None,
351        }
352    }
353
354    fn ready_slot(refcount: usize) -> Arc<TokioMutex<EffectSlot>> {
355        Arc::new(TokioMutex::new(EffectSlot::Ready {
356            refcount,
357            handle: Box::new(stub_handle()),
358        }))
359    }
360
361    #[test]
362    fn key_equality_same() {
363        let k1 = test_key("Db");
364        let k2 = test_key("Db");
365        assert_eq!(k1, k2);
366    }
367
368    #[test]
369    fn key_equality_different_name() {
370        let k1 = test_key("Db");
371        let k2 = test_key("Redis");
372        assert_ne!(k1, k2);
373    }
374
375    #[test]
376    fn key_equality_different_overlay() {
377        let k1 = test_key_with_overlay("Db", "PORT=5432");
378        let k2 = test_key_with_overlay("Db", "PORT=5433");
379        assert_ne!(k1, k2);
380    }
381
382    #[test]
383    fn key_hash_consistent() {
384        use relux_core::hash::StableHasher;
385        use std::hash::Hash;
386        use std::hash::Hasher;
387        let k1 = test_key("Db");
388        let k2 = test_key("Db");
389        let mut h1 = StableHasher::new();
390        let mut h2 = StableHasher::new();
391        k1.hash(&mut h1);
392        k2.hash(&mut h2);
393        assert_eq!(h1.finish(), h2.finish());
394    }
395
396    #[test]
397    fn registry_new_is_empty() {
398        let reg = EffectRegistry::new();
399        assert!(reg.slots.lock().unwrap().is_empty());
400    }
401
402    #[tokio::test]
403    async fn slot_creates_empty_on_first_access() {
404        let reg = EffectRegistry::new();
405        let key = test_key("Db");
406        let slot = reg.slot(&key);
407        let guard = slot.lock().await;
408        assert!(matches!(*guard, EffectSlot::Empty));
409    }
410
411    #[tokio::test]
412    async fn slot_returns_same_arc_for_same_key() {
413        let reg = EffectRegistry::new();
414        let key = test_key("Db");
415        let s1 = reg.slot(&key);
416        let s2 = reg.slot(&key);
417        assert!(Arc::ptr_eq(&s1, &s2));
418    }
419
420    #[tokio::test]
421    async fn slot_returns_different_arcs_for_different_keys() {
422        let reg = EffectRegistry::new();
423        let k1 = test_key("Db");
424        let k2 = test_key("Redis");
425        let s1 = reg.slot(&k1);
426        let s2 = reg.slot(&k2);
427        assert!(!Arc::ptr_eq(&s1, &s2));
428    }
429
430    #[tokio::test]
431    async fn release_deferred_carries_metadata_and_decrements() {
432        let slot = ready_slot(2);
433        let g = EffectGuard::new(slot.clone());
434        match g.release().await {
435            ReleaseOutcome::Deferred { marker, .. } => {
436                assert_eq!(marker, "stub-marker-0000");
437            }
438            _ => panic!("non-last release should produce Deferred"),
439        }
440        let guard = slot.lock().await;
441        match &*guard {
442            EffectSlot::Ready { refcount, .. } => assert_eq!(*refcount, 1),
443            _ => panic!("slot should remain Ready with refcount 1"),
444        }
445    }
446
447    #[tokio::test]
448    async fn release_last_holder_returns_handle_and_empties_slot() {
449        let slot = ready_slot(1);
450        let g = EffectGuard::new(slot.clone());
451        match g.release().await {
452            ReleaseOutcome::LastHolder { handle } => {
453                assert_eq!(handle.marker, "stub-marker-0000");
454            }
455            _ => panic!("last release should produce LastHolder"),
456        }
457        let guard = slot.lock().await;
458        assert!(matches!(*guard, EffectSlot::Empty), "slot should be Empty");
459    }
460
461    #[tokio::test]
462    async fn concurrent_releases_serialize_via_slot_mutex() {
463        let slot = ready_slot(2);
464        let g1 = EffectGuard::new(slot.clone());
465        let g2 = EffectGuard::new(slot.clone());
466        let (a, b) = tokio::join!(g1.release(), g2.release());
467        let mut last_holder = 0usize;
468        let mut deferred = 0usize;
469        for outcome in [a, b] {
470            match outcome {
471                ReleaseOutcome::LastHolder { .. } => last_holder += 1,
472                ReleaseOutcome::Deferred { .. } => deferred += 1,
473                ReleaseOutcome::Drift => panic!("unexpected Drift"),
474            }
475        }
476        assert_eq!(last_holder, 1, "exactly one releaser is the last holder");
477        assert_eq!(deferred, 1, "exactly one releaser is deferred");
478        let guard = slot.lock().await;
479        assert!(matches!(*guard, EffectSlot::Empty));
480    }
481
482    #[test]
483    fn from_expects_no_collision_when_value_contains_separator() {
484        // Two structurally different overlays must produce different keys.
485        // Effect expects A only. Overlay 1: A = "x\0y", Overlay 2: A = "x".
486        // With naive join these could collide; null-byte framing prevents it.
487        use std::collections::HashMap;
488        let effect_id = DiagEffectId {
489            module: relux_core::diagnostics::ModulePath("test.relux".into()),
490            name: relux_core::diagnostics::EffectName("E".to_string()),
491        };
492
493        let mut overlay1 = HashMap::new();
494        overlay1.insert("A".into(), "x,B=y".into());
495        let env1 = relux_core::pure::Env::from_map(overlay1);
496
497        let mut overlay2 = HashMap::new();
498        overlay2.insert("A".into(), "x".into());
499        overlay2.insert("B".into(), "y".into());
500        let env2 = relux_core::pure::Env::from_map(overlay2);
501
502        let expects = &["A"];
503        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
504        let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
505        assert_ne!(
506            k1, k2,
507            "different expect values must produce different keys"
508        );
509    }
510
511    #[test]
512    fn from_expects_uses_only_expected_keys() {
513        // Extra overlay keys beyond what the effect expects should not
514        // affect identity - only expected variable values matter.
515        use std::collections::HashMap;
516        let effect_id = DiagEffectId {
517            module: relux_core::diagnostics::ModulePath("test.relux".into()),
518            name: relux_core::diagnostics::EffectName("E".to_string()),
519        };
520
521        let mut overlay1 = HashMap::new();
522        overlay1.insert("PORT".into(), "5432".into());
523        overlay1.insert("EXTRA".into(), "foo".into());
524        let env1 = relux_core::pure::Env::from_map(overlay1);
525
526        let mut overlay2 = HashMap::new();
527        overlay2.insert("PORT".into(), "5432".into());
528        overlay2.insert("EXTRA".into(), "bar".into());
529        let env2 = relux_core::pure::Env::from_map(overlay2);
530
531        let expects = &["PORT"];
532        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
533        let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
534        assert_eq!(
535            k1, k2,
536            "extra overlay keys beyond expects should not affect identity"
537        );
538    }
539
540    #[test]
541    fn from_expects_declaration_order_is_stable() {
542        use std::collections::HashMap;
543        let effect_id = DiagEffectId {
544            module: relux_core::diagnostics::ModulePath("test.relux".into()),
545            name: relux_core::diagnostics::EffectName("E".to_string()),
546        };
547
548        let mut overlay = HashMap::new();
549        overlay.insert("A".into(), "1".into());
550        overlay.insert("B".into(), "2".into());
551        let env = relux_core::pure::Env::from_map(overlay);
552
553        // Same expects in same order -> same key
554        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), &["A", "B"], &env);
555        let k2 = EffectInstanceKey::from_expects(effect_id, &["A", "B"], &env);
556        assert_eq!(k1, k2);
557    }
558
559    #[test]
560    fn from_expects_empty_expects_produces_equal_keys() {
561        use std::collections::HashMap;
562        let effect_id = DiagEffectId {
563            module: relux_core::diagnostics::ModulePath("test.relux".into()),
564            name: relux_core::diagnostics::EffectName("E".to_string()),
565        };
566
567        let mut overlay1 = HashMap::new();
568        overlay1.insert("X".into(), "1".into());
569        let env1 = relux_core::pure::Env::from_map(overlay1);
570        let env2 = relux_core::pure::Env::from_map(HashMap::new());
571
572        let expects: &[&str] = &[];
573        let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
574        let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
575        assert_eq!(
576            k1, k2,
577            "effects with no expects should always share identity"
578        );
579    }
580
581    #[test]
582    fn marker_is_stable_for_same_key() {
583        let k = test_key("FX");
584        assert_eq!(k.marker(), k.marker());
585    }
586
587    #[test]
588    fn marker_differs_for_different_overlay() {
589        let a = test_key_with_overlay("FX", "alpha");
590        let b = test_key_with_overlay("FX", "beta");
591        assert_ne!(a.marker(), b.marker());
592    }
593
594    #[test]
595    fn effect_overlay_source_uses_instance_marker() {
596        use std::sync::Arc;
597
598        use relux_core::pure::Env;
599        use relux_core::pure::LayeredEnv;
600        use relux_core::pure::LayeredEnvSource;
601        // The runtime tags an effect's overlay layer with EffectOverlay(marker).
602        // This pins that the marker string is what lands in the source tag.
603        let marker = "brave-yak-0001".to_string();
604        let parent = Arc::new(LayeredEnv::root(Env::new()));
605        let env = LayeredEnv::child_with_source(
606            parent,
607            Env::new(),
608            LayeredEnvSource::EffectOverlay(marker.clone()),
609        );
610        assert_eq!(env.source(), &LayeredEnvSource::EffectOverlay(marker));
611    }
612
613    #[test]
614    fn marker_matches_mnemonic_format() {
615        let k = test_key("FX");
616        let m = k.marker();
617        let parts: Vec<&str> = m.split('-').collect();
618        assert_eq!(parts.len(), 3, "marker {m:?} should be adj-noun-NNNN");
619        assert!(parts[0].chars().all(|c| c.is_ascii_lowercase()));
620        assert!(parts[1].chars().all(|c| c.is_ascii_lowercase()));
621        assert_eq!(parts[2].len(), 4);
622        assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
623    }
624
625    #[test]
626    fn shell_key_effect_marker_is_stable() {
627        let key = ShellInstanceKey::Effect {
628            effect: test_key("Db"),
629            shell_name: "redis".into(),
630        };
631        assert_eq!(key.marker(), key.marker());
632    }
633
634    #[test]
635    fn shell_key_test_marker_is_stable() {
636        let key = ShellInstanceKey::Test {
637            shell_name: "default".into(),
638        };
639        assert_eq!(key.marker(), key.marker());
640    }
641
642    #[test]
643    fn shell_key_marker_matches_mnemonic_format() {
644        let key = ShellInstanceKey::Test {
645            shell_name: "default".into(),
646        };
647        let m = key.marker();
648        let parts: Vec<&str> = m.split('-').collect();
649        assert_eq!(parts.len(), 3, "marker {m:?} should be adj-noun-NNNN");
650        assert_eq!(parts[2].len(), 4);
651        assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
652    }
653
654    #[test]
655    fn shell_key_effect_vs_test_dont_collide_for_same_name() {
656        let effect_key = ShellInstanceKey::Effect {
657            effect: test_key("Db"),
658            shell_name: "default".into(),
659        };
660        let test_key_shell = ShellInstanceKey::Test {
661            shell_name: "default".into(),
662        };
663        assert_ne!(effect_key.marker(), test_key_shell.marker());
664    }
665
666    #[test]
667    fn shell_key_different_shell_names_produce_distinct_markers() {
668        let a = ShellInstanceKey::Effect {
669            effect: test_key("Db"),
670            shell_name: "redis".into(),
671        };
672        let b = ShellInstanceKey::Effect {
673            effect: test_key("Db"),
674            shell_name: "postgres".into(),
675        };
676        assert_ne!(a.marker(), b.marker());
677    }
678
679    #[test]
680    fn shell_key_cleanup_distinct_per_effect_instance() {
681        let a = ShellInstanceKey::Effect {
682            effect: test_key("Db"),
683            shell_name: "__cleanup".into(),
684        };
685        let b = ShellInstanceKey::Effect {
686            effect: test_key("Redis"),
687            shell_name: "__cleanup".into(),
688        };
689        assert_ne!(a.marker(), b.marker());
690    }
691}