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}