Skip to main content

mako_engine/
registry.rs

1//! Process routing registry.
2//!
3//! Maps string routing keys to [`ProcessIdentity`] values so inbound
4//! EDIFACT messages can be dispatched to the correct running process without
5//! the caller managing a bespoke routing table.
6//!
7//! # Routing key conventions
8//!
9//! Any stable string that uniquely identifies a process for a given message
10//! type works as a routing key. Common patterns:
11//!
12//! | Message type | Recommended key |
13//! |---|---|
14//! | UTILMD waiting for APERAK | `RegistryKey::from_conversation_and_sender(conversation_id, sender_mp_id)` |
15//! | Route follow-up by correlation | `RegistryKey::from_correlation(correlation_id)` |
16//! | Direct lookup by process | `RegistryKey::from_process(process_id)` |
17//!
18//! One process may be registered under multiple keys when it handles several
19//! different message types simultaneously.
20//!
21//! # Tenant scoping
22//!
23//! All registry operations are scoped to a `TenantId`. This prevents routing
24//! keys from leaking across tenant boundaries when the engine handles multiple
25//! market participants in a single deployment.
26//!
27//! # Usage
28//!
29//! ```rust,ignore
30//! // After spawning a process, register it under the UTILMD conversation ID + sender GLN:
31//! ctx.registry
32//!     .register(tenant_id, &RegistryKey::from_conversation_and_sender(utilmd_conv_id, sender_mp_id), process.identity())
33//!     .await?;
34//!
35//! // When the APERAK arrives, look up by conversation ID + APERAK sender GLN:
36//! let identity = ctx.registry
37//!     .lookup(tenant_id, &RegistryKey::from_conversation_and_sender(aperak_conv_id, aperak_sender_gln))
38//!     .await?
39//!     .ok_or(EngineError::registry("unknown conversation"))?;
40//!
41//! let process = ctx.resume::<SupplierChangeWorkflow>(identity);
42//! process.execute(HandleAperak { .. }).await?;
43//!
44//! // Clean up after process completion:
45//! ctx.registry.remove(tenant_id, &RegistryKey::from_conversation_and_sender(utilmd_conv_id, sender_mp_id)).await?;
46//! ```
47
48use std::{fmt, sync::Arc};
49
50#[cfg(any(test, feature = "testing"))]
51use std::collections::HashMap;
52#[cfg(any(test, feature = "testing"))]
53use tokio::sync::RwLock;
54
55use crate::{
56    error::EngineError,
57    ids::{ConversationId, CorrelationId, ProcessId, ProcessIdentity, TenantId},
58};
59
60/// Maximum byte length for a [`RegistryKey`] routing key.
61///
62/// Keys beyond this limit are rejected by [`RegistryKey::parse`] to
63/// prevent oversized LSM keys from bloating the `pr/` key namespace in
64/// SlateDB. AS4 `MessageId` values and EDIFACT correlation identifiers are
65/// typically ≤ 36 bytes (UUID); 256 bytes provides ample headroom.
66pub const MAX_REGISTRY_KEY_LEN: usize = 256;
67
68// ── RegistryKey ───────────────────────────────────────────────────────────────
69
70/// A typed routing key for the [`ProcessRegistry`].
71///
72/// Using a newtype instead of a bare `&str` prevents accidental key-format
73/// mismatches (e.g. mixing `conversation_id` and `correlation_id` keys at
74/// different call sites) and makes the key derivation convention explicit at
75/// the type level.
76///
77/// # Named constructors
78///
79/// | Constructor | Use case |
80/// |---|---|
81/// | Constructor | Use case |
82/// |---|---|
83/// | [`from_conversation_and_sender`] | Route inbound APERAK by UTILMD conversation ID + sender GLN |
84/// | [`from_correlation`] | Route follow-up messages by root correlation |
85/// | [`from_process`] | Direct lookup by process instance |
86/// | [`parse`] | Primary validated constructor for runtime-derived keys |
87/// | [`from_static`] | Infallible constructor for compile-time-known string literals |
88///
89/// [`from_conversation_and_sender`]: RegistryKey::from_conversation_and_sender
90/// [`from_correlation`]: RegistryKey::from_correlation
91/// [`from_process`]: RegistryKey::from_process
92/// [`parse`]: RegistryKey::parse
93/// [`from_static`]: RegistryKey::from_static
94#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
95pub struct RegistryKey(Box<str>);
96
97impl RegistryKey {
98    /// Key derived from a conversation ID **and sender GLN**.
99    ///
100    /// This is the correct constructor for UTILMD ↔ APERAK routing in
101    /// multi-market-participant deployments.
102    ///
103    /// # Why sender is required
104    ///
105    /// EDIFACT conversation IDs are assigned by the sender within their own
106    /// numbering space.  Two senders may independently assign the same
107    /// conversation-ID string, which would collide if keyed on conversation
108    /// alone.  Including the sender GLN as a discriminator makes the key
109    /// globally unique within the tenant's namespace.
110    ///
111    /// # Key format
112    ///
113    /// `"{sender_mp_id}:{conversation_id}"` — stable, URL-safe, human-readable.
114    #[must_use]
115    pub fn from_conversation_and_sender(id: ConversationId, sender_mp_id: &str) -> Self {
116        let key = format!("{sender_mp_id}:{id}");
117        Self(key.into_boxed_str())
118    }
119
120    /// Key derived from a correlation ID (route all messages in the same root trace).
121    #[must_use]
122    pub fn from_correlation(id: CorrelationId) -> Self {
123        Self(id.to_string().into_boxed_str())
124    }
125
126    /// Key derived from a process ID (direct process lookup).
127    #[must_use]
128    pub fn from_process(id: ProcessId) -> Self {
129        Self(id.to_string().into_boxed_str())
130    }
131
132    /// Primary validated constructor for runtime-derived keys.
133    ///
134    /// Returns [`EngineError::Registry`] when `s` contains a NUL byte or
135    /// exceeds [`MAX_REGISTRY_KEY_LEN`] bytes. Use this for all keys derived
136    /// from untrusted input (e.g. EDIFACT `MessageId`, AS4 `conversation_id`,
137    /// or user-supplied strings).
138    ///
139    /// # Errors
140    ///
141    /// - [`EngineError::Registry`] when `s` contains `\0`.
142    /// - [`EngineError::Registry`] when `s.len() > MAX_REGISTRY_KEY_LEN`.
143    pub fn parse(s: &str) -> Result<Self, EngineError> {
144        if s.contains('\0') {
145            return Err(EngineError::registry(
146                "registry key must not contain NUL bytes",
147            ));
148        }
149        if s.len() > MAX_REGISTRY_KEY_LEN {
150            return Err(EngineError::registry(format!(
151                "registry key is {} bytes, exceeds maximum of {MAX_REGISTRY_KEY_LEN}",
152                s.len()
153            )));
154        }
155        Ok(Self(s.into()))
156    }
157
158    /// Infallible constructor for **compile-time-known** string literals.
159    ///
160    /// Panics at runtime if `s` contains a NUL byte or exceeds
161    /// [`MAX_REGISTRY_KEY_LEN`] — but since this is only correct to call with
162    /// string literals, any violation would be caught immediately in tests.
163    ///
164    /// # Panics
165    ///
166    /// Panics when `s` contains a NUL byte or exceeds [`MAX_REGISTRY_KEY_LEN`]
167    /// bytes. **Only use this with string literals.** Use [`parse`] for any
168    /// value that may be runtime-derived.
169    ///
170    /// [`parse`]: RegistryKey::parse
171    #[must_use]
172    pub fn from_static(s: &'static str) -> Self {
173        assert!(
174            !s.contains('\0'),
175            "RegistryKey::from_static: key must not contain NUL bytes"
176        );
177        assert!(
178            s.len() <= MAX_REGISTRY_KEY_LEN,
179            "RegistryKey::from_static: key exceeds MAX_REGISTRY_KEY_LEN ({MAX_REGISTRY_KEY_LEN} bytes)"
180        );
181        Self(s.into())
182    }
183
184    /// The raw key string.
185    #[must_use]
186    pub fn as_str(&self) -> &str {
187        &self.0
188    }
189}
190
191impl fmt::Display for RegistryKey {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        f.write_str(&self.0)
194    }
195}
196
197impl std::str::FromStr for RegistryKey {
198    type Err = EngineError;
199
200    fn from_str(s: &str) -> Result<Self, Self::Err> {
201        RegistryKey::parse(s)
202    }
203}
204
205// ── ProcessRegistry ───────────────────────────────────────────────────────────
206
207/// Routes inbound messages to their target processes by string key.
208///
209/// A `ProcessRegistry` decouples message routing from process creation.
210/// Register a [`ProcessIdentity`] under a stable key at process creation
211/// time, then look it up by that key when routing subsequent inbound messages.
212///
213/// All operations are scoped to a [`TenantId`] so routing entries from
214/// different market participants cannot collide.
215///
216/// ## Correlated-process lookup (1:many)
217///
218/// The standard `register`/`lookup` API maps a key 1:1 to a single process.
219/// For cases where multiple processes share a common business identifier —
220/// for example, all MSCONS measurement-data processes for a single MaLo ID
221/// in MABIS billing aggregation — use the correlated index:
222///
223/// - [`register_correlated`]: associate a `(tenant, tag, process_id)` triple.
224/// - [`lookup_correlated`]: retrieve **all** `ProcessIdentity` values for a tag.
225/// - [`remove_correlated`]: remove a single process from the tag's fan-out set.
226///
227/// The tag is an arbitrary opaque string (e.g. a MaLo ID such as
228/// `"DE0001234567890"`). Key validation rules match [`RegistryKey`].
229///
230/// ```rust,ignore
231/// // Register all MSCONS processes for a Bilanzkreis MaLo:
232/// for process in &mscons_processes {
233///     ctx.registry
234///         .register_correlated(tenant_id, malo_id, process.process_id(), process.identity())
235///         .await?;
236/// }
237///
238/// // Retrieve all processes for billing aggregation:
239/// let identities = ctx.registry
240///     .lookup_correlated(tenant_id, malo_id)
241///     .await?;
242/// ```
243///
244/// ## Blanket `Arc` implementation
245///
246/// `Arc<S>` implements `ProcessRegistry` whenever `S: ProcessRegistry`,
247/// enabling shared access from multiple concurrent message handlers.
248///
249/// [`register_correlated`]: ProcessRegistry::register_correlated
250/// [`lookup_correlated`]:   ProcessRegistry::lookup_correlated
251/// [`remove_correlated`]:   ProcessRegistry::remove_correlated
252#[allow(async_fn_in_trait)]
253pub trait ProcessRegistry: Send + Sync {
254    /// Associate `key` with `identity` for the given `tenant_id`.
255    ///
256    /// Overwrites any existing mapping for the `(tenant_id, key)` pair
257    /// (upsert semantics).
258    ///
259    /// # Errors
260    ///
261    /// Returns [`EngineError::Registry`] on storage failure.
262    #[must_use = "dropping a register Result silently loses a process routing key"]
263    async fn register(
264        &self,
265        tenant_id: TenantId,
266        key: &RegistryKey,
267        identity: ProcessIdentity,
268    ) -> Result<(), EngineError>;
269
270    /// Return the identity associated with `(tenant_id, key)`, or `None`
271    /// if not registered.
272    ///
273    /// # Errors
274    ///
275    /// Returns [`EngineError::Registry`] on storage failure.
276    #[must_use = "dropping a lookup Result silently discards a routing error"]
277    async fn lookup(
278        &self,
279        tenant_id: TenantId,
280        key: &RegistryKey,
281    ) -> Result<Option<ProcessIdentity>, EngineError>;
282
283    /// Remove the mapping for `(tenant_id, key)`. No-op if not found.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`EngineError::Registry`] on storage failure.
288    #[must_use = "dropping a remove Result silently hides a store error"]
289    async fn remove(&self, tenant_id: TenantId, key: &RegistryKey) -> Result<(), EngineError>;
290
291    /// Return `true` when `(tenant_id, key)` has a registered mapping.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`EngineError::Registry`] on storage failure.
296    async fn contains(&self, tenant_id: TenantId, key: &RegistryKey) -> Result<bool, EngineError> {
297        Ok(self.lookup(tenant_id, key).await?.is_some())
298    }
299
300    /// Total number of registered routing keys across all tenants.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`EngineError::Registry`] on storage failure.
305    #[must_use = "dropping a len Result silently discards a store error"]
306    async fn len(&self) -> Result<usize, EngineError>;
307
308    /// Return `true` when no routing keys are registered.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`EngineError::Registry`] on storage failure.
313    async fn is_empty(&self) -> Result<bool, EngineError> {
314        Ok(self.len().await? == 0)
315    }
316
317    // ── Correlated (1:many) index ────────────────────────────────────────────
318
319    /// Associate `process_id`/`identity` with the correlation `tag` for the
320    /// given `tenant_id`.
321    ///
322    /// Multiple processes can be registered under the same `(tenant_id, tag)`,
323    /// making `lookup_correlated` return all of them. This is the fan-out
324    /// counterpart to the 1:1 `register`/`lookup` API.
325    ///
326    /// # Tag constraints
327    ///
328    /// Same validation as [`RegistryKey`]: must not contain `\0`, must be
329    /// ≤ [`MAX_REGISTRY_KEY_LEN`] bytes.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`EngineError::Registry`] on storage failure or invalid tag.
334    async fn register_correlated(
335        &self,
336        tenant_id: TenantId,
337        tag: &str,
338        process_id: crate::ids::ProcessId,
339        identity: ProcessIdentity,
340    ) -> Result<(), EngineError>;
341
342    /// Return all `ProcessIdentity` values registered under `(tenant_id, tag)`.
343    ///
344    /// Returns an empty `Vec` when no entries exist for the tag.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`EngineError::Registry`] on storage failure.
349    async fn lookup_correlated(
350        &self,
351        tenant_id: TenantId,
352        tag: &str,
353    ) -> Result<Vec<ProcessIdentity>, EngineError>;
354
355    /// Remove the `process_id` entry from the `(tenant_id, tag)` fan-out set.
356    ///
357    /// No-op when the entry does not exist.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`EngineError::Registry`] on storage failure.
362    async fn remove_correlated(
363        &self,
364        tenant_id: TenantId,
365        tag: &str,
366        process_id: crate::ids::ProcessId,
367    ) -> Result<(), EngineError>;
368}
369
370// ── Arc<S> blanket impl ───────────────────────────────────────────────────────
371
372impl<S: ProcessRegistry> ProcessRegistry for Arc<S> {
373    async fn register(
374        &self,
375        tenant_id: TenantId,
376        key: &RegistryKey,
377        identity: ProcessIdentity,
378    ) -> Result<(), EngineError> {
379        self.as_ref().register(tenant_id, key, identity).await
380    }
381
382    async fn lookup(
383        &self,
384        tenant_id: TenantId,
385        key: &RegistryKey,
386    ) -> Result<Option<ProcessIdentity>, EngineError> {
387        self.as_ref().lookup(tenant_id, key).await
388    }
389
390    async fn remove(&self, tenant_id: TenantId, key: &RegistryKey) -> Result<(), EngineError> {
391        self.as_ref().remove(tenant_id, key).await
392    }
393
394    async fn len(&self) -> Result<usize, EngineError> {
395        self.as_ref().len().await
396    }
397
398    async fn register_correlated(
399        &self,
400        tenant_id: TenantId,
401        tag: &str,
402        process_id: crate::ids::ProcessId,
403        identity: ProcessIdentity,
404    ) -> Result<(), EngineError> {
405        self.as_ref()
406            .register_correlated(tenant_id, tag, process_id, identity)
407            .await
408    }
409
410    async fn lookup_correlated(
411        &self,
412        tenant_id: TenantId,
413        tag: &str,
414    ) -> Result<Vec<ProcessIdentity>, EngineError> {
415        self.as_ref().lookup_correlated(tenant_id, tag).await
416    }
417
418    async fn remove_correlated(
419        &self,
420        tenant_id: TenantId,
421        tag: &str,
422        process_id: crate::ids::ProcessId,
423    ) -> Result<(), EngineError> {
424        self.as_ref()
425            .remove_correlated(tenant_id, tag, process_id)
426            .await
427    }
428}
429
430// ── NoopProcessRegistry ───────────────────────────────────────────────────────
431
432/// A [`ProcessRegistry`] that never stores any mappings.
433///
434/// Every `lookup` returns `None`. Use this as the default when routing is
435/// managed externally or not required.
436///
437/// # ⚠️ Routing loss warning
438///
439/// `NoopProcessRegistry` **discards every routing registration silently**.
440/// All `lookup` calls return `None`. Inbound messages for existing processes
441/// will not be routed. Do not use in production when message routing is needed.
442///
443/// This type is available in all build configurations so it can serve as a
444/// default type parameter in [`EngineBuilder`]. However, `EngineBuilder::new`
445/// (which wires this as the default) is only available with the `testing`
446/// feature or in `cfg(test)`. Production binaries must call
447/// [`EngineBuilder::with_stores`] instead.
448///
449/// [`EngineBuilder`]: crate::builder::EngineBuilder
450/// [`EngineBuilder::with_stores`]: crate::builder::EngineBuilder::with_stores
451#[derive(Debug, Clone, Copy, Default)]
452#[must_use = "NoopProcessRegistry discards all routing registrations silently — use a persistent ProcessRegistry in production"]
453#[cfg_attr(
454    not(any(test, feature = "testing")),
455    deprecated = "NoopProcessRegistry must not be instantiated in production builds; use a durable ProcessRegistry instead"
456)]
457pub struct NoopProcessRegistry;
458
459#[cfg(any(test, feature = "testing"))]
460impl ProcessRegistry for NoopProcessRegistry {
461    async fn register(
462        &self,
463        _tenant_id: TenantId,
464        _key: &RegistryKey,
465        _identity: ProcessIdentity,
466    ) -> Result<(), EngineError> {
467        Ok(())
468    }
469
470    async fn lookup(
471        &self,
472        _tenant_id: TenantId,
473        _key: &RegistryKey,
474    ) -> Result<Option<ProcessIdentity>, EngineError> {
475        Ok(None)
476    }
477
478    async fn remove(&self, _tenant_id: TenantId, _key: &RegistryKey) -> Result<(), EngineError> {
479        Ok(())
480    }
481
482    async fn len(&self) -> Result<usize, EngineError> {
483        Ok(0)
484    }
485
486    async fn register_correlated(
487        &self,
488        _tenant_id: TenantId,
489        _tag: &str,
490        _process_id: crate::ids::ProcessId,
491        _identity: ProcessIdentity,
492    ) -> Result<(), EngineError> {
493        Ok(())
494    }
495
496    async fn lookup_correlated(
497        &self,
498        _tenant_id: TenantId,
499        _tag: &str,
500    ) -> Result<Vec<ProcessIdentity>, EngineError> {
501        Ok(vec![])
502    }
503
504    async fn remove_correlated(
505        &self,
506        _tenant_id: TenantId,
507        _tag: &str,
508        _process_id: crate::ids::ProcessId,
509    ) -> Result<(), EngineError> {
510        Ok(())
511    }
512}
513
514// ── InMemoryProcessRegistry ───────────────────────────────────────────────────
515
516/// An in-memory [`ProcessRegistry`] for tests and development.
517///
518/// Backed by a `HashMap<(TenantId, String), ProcessIdentity>` protected by a
519/// `RwLock`. Cloning shares the underlying data via `Arc` — all clones see the
520/// same mappings.
521///
522/// Use this with [`EngineContext`] to verify message routing without
523/// depending on an external registry service.
524///
525/// Only available in `#[cfg(test)]` or with the `testing` feature enabled.
526///
527/// [`EngineContext`]: crate::builder::EngineContext
528#[cfg(any(test, feature = "testing"))]
529#[derive(Debug, Default, Clone)]
530pub struct InMemoryProcessRegistry {
531    #[expect(clippy::type_complexity)]
532    inner: Arc<RwLock<HashMap<(TenantId, Box<str>), ProcessIdentity>>>,
533    /// Correlated 1:many index: `(tenant_id, tag, process_id)` → `ProcessIdentity`.
534    #[expect(clippy::type_complexity)]
535    correlated: Arc<RwLock<HashMap<(TenantId, Box<str>, crate::ids::ProcessId), ProcessIdentity>>>,
536}
537
538#[cfg(any(test, feature = "testing"))]
539impl InMemoryProcessRegistry {
540    /// Create an empty registry.
541    #[must_use]
542    pub fn new() -> Self {
543        Self::default()
544    }
545
546    /// Return `true` when no routing keys are registered.
547    pub async fn is_empty_async(&self) -> bool {
548        self.inner.read().await.is_empty()
549    }
550}
551
552#[cfg(any(test, feature = "testing"))]
553impl ProcessRegistry for InMemoryProcessRegistry {
554    async fn register(
555        &self,
556        tenant_id: TenantId,
557        key: &RegistryKey,
558        identity: ProcessIdentity,
559    ) -> Result<(), EngineError> {
560        self.inner
561            .write()
562            .await
563            .insert((tenant_id, key.0.clone()), identity);
564        Ok(())
565    }
566
567    async fn lookup(
568        &self,
569        tenant_id: TenantId,
570        key: &RegistryKey,
571    ) -> Result<Option<ProcessIdentity>, EngineError> {
572        Ok(self
573            .inner
574            .read()
575            .await
576            .get(&(tenant_id, key.0.clone()))
577            .cloned())
578    }
579
580    async fn remove(&self, tenant_id: TenantId, key: &RegistryKey) -> Result<(), EngineError> {
581        self.inner.write().await.remove(&(tenant_id, key.0.clone()));
582        Ok(())
583    }
584
585    async fn len(&self) -> Result<usize, EngineError> {
586        Ok(self.inner.read().await.len())
587    }
588
589    async fn register_correlated(
590        &self,
591        tenant_id: TenantId,
592        tag: &str,
593        process_id: crate::ids::ProcessId,
594        identity: ProcessIdentity,
595    ) -> Result<(), EngineError> {
596        self.correlated
597            .write()
598            .await
599            .insert((tenant_id, tag.into(), process_id), identity);
600        Ok(())
601    }
602
603    async fn lookup_correlated(
604        &self,
605        tenant_id: TenantId,
606        tag: &str,
607    ) -> Result<Vec<ProcessIdentity>, EngineError> {
608        let guard = self.correlated.read().await;
609        let result = guard
610            .iter()
611            .filter(|((tid, t, _), _)| *tid == tenant_id && t.as_ref() == tag)
612            .map(|(_, identity)| identity.clone())
613            .collect();
614        Ok(result)
615    }
616
617    async fn remove_correlated(
618        &self,
619        tenant_id: TenantId,
620        tag: &str,
621        process_id: crate::ids::ProcessId,
622    ) -> Result<(), EngineError> {
623        self.correlated
624            .write()
625            .await
626            .remove(&(tenant_id, tag.into(), process_id));
627        Ok(())
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::{
635        ids::{ProcessId, TenantId},
636        version::WorkflowId,
637    };
638
639    fn make_identity() -> ProcessIdentity {
640        let pid = ProcessId::new();
641        ProcessIdentity::new(
642            pid,
643            TenantId::new(),
644            WorkflowId::new("test", "FV2024-10-01"),
645        )
646    }
647
648    fn tid() -> TenantId {
649        TenantId::new()
650    }
651
652    fn key(s: &str) -> RegistryKey {
653        RegistryKey::parse(s).expect("valid test key")
654    }
655
656    #[tokio::test]
657    async fn register_and_lookup() {
658        let reg = InMemoryProcessRegistry::new();
659        let tenant = tid();
660        let id = make_identity();
661        reg.register(tenant, &key("conv:abc"), id.clone())
662            .await
663            .unwrap();
664        let found = reg
665            .lookup(tenant, &key("conv:abc"))
666            .await
667            .unwrap()
668            .expect("must be found");
669        assert_eq!(found.process_id, id.process_id);
670    }
671
672    #[tokio::test]
673    async fn lookup_returns_none_for_unknown_key() {
674        let reg = InMemoryProcessRegistry::new();
675        assert!(reg.lookup(tid(), &key("unknown")).await.unwrap().is_none());
676    }
677
678    #[tokio::test]
679    async fn remove_clears_mapping() {
680        let reg = InMemoryProcessRegistry::new();
681        let tenant = tid();
682        let id = make_identity();
683        reg.register(tenant, &key("k1"), id).await.unwrap();
684        reg.remove(tenant, &key("k1")).await.unwrap();
685        assert!(reg.lookup(tenant, &key("k1")).await.unwrap().is_none());
686    }
687
688    #[tokio::test]
689    async fn upsert_overwrites_existing() {
690        let reg = InMemoryProcessRegistry::new();
691        let tenant = tid();
692        let id1 = make_identity();
693        let id2 = make_identity();
694        reg.register(tenant, &key("k1"), id1).await.unwrap();
695        reg.register(tenant, &key("k1"), id2.clone()).await.unwrap();
696        let found = reg.lookup(tenant, &key("k1")).await.unwrap().unwrap();
697        assert_eq!(found.process_id, id2.process_id);
698        assert_eq!(
699            reg.len().await.unwrap(),
700            1,
701            "upsert must not duplicate the key"
702        );
703    }
704
705    #[tokio::test]
706    async fn contains_matches_register() {
707        let reg = InMemoryProcessRegistry::new();
708        let tenant = tid();
709        assert!(!reg.contains(tenant, &key("k1")).await.unwrap());
710        reg.register(tenant, &key("k1"), make_identity())
711            .await
712            .unwrap();
713        assert!(reg.contains(tenant, &key("k1")).await.unwrap());
714    }
715
716    #[tokio::test]
717    async fn clone_shares_state() {
718        let reg1 = InMemoryProcessRegistry::new();
719        let reg2 = reg1.clone();
720        let tenant = tid();
721        reg1.register(tenant, &key("k1"), make_identity())
722            .await
723            .unwrap();
724        assert!(reg2.contains(tenant, &key("k1")).await.unwrap());
725    }
726
727    /// `from_conversation_and_sender` key contains sender prefix.
728    #[test]
729    fn from_conversation_and_sender_key_contains_sender() {
730        use crate::ids::ConversationId;
731        let conv = ConversationId::new();
732        let k = RegistryKey::from_conversation_and_sender(conv, "4012345000023");
733        assert!(k.as_str().starts_with("4012345000023:"));
734        assert!(k.as_str().ends_with(&conv.to_string()));
735    }
736
737    #[tokio::test]
738    async fn tenant_keys_are_isolated() {
739        let reg = InMemoryProcessRegistry::new();
740        let t1 = tid();
741        let t2 = tid();
742        reg.register(t1, &key("k1"), make_identity()).await.unwrap();
743        assert!(
744            reg.contains(t1, &key("k1")).await.unwrap(),
745            "tenant1 must see key"
746        );
747        assert!(
748            !reg.contains(t2, &key("k1")).await.unwrap(),
749            "tenant2 must not see key"
750        );
751    }
752}