Skip to main content

actor_kit/
registry.rs

1//! Actor registry for tracking and looking up actors.
2//!
3//! Provides O(1) lookup by ID and name with state tracking.
4
5use dashmap::DashMap;
6use parking_lot::RwLock;
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::Error;
11use crate::{ActorId, Mailbox, MailboxConfig};
12
13/// Current UNIX timestamp in seconds (replaces the upstream chrono dep).
14fn unix_now() -> i64 {
15    std::time::SystemTime::now()
16        .duration_since(std::time::UNIX_EPOCH)
17        .map(|d| d.as_secs() as i64)
18        .unwrap_or(0)
19}
20
21/// State of an actor.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
23pub enum ActorState {
24    /// Actor is being created
25    #[default]
26    Creating,
27    /// Actor is running and processing messages
28    Running,
29    /// Actor is paused (not processing messages)
30    Suspended,
31    /// Actor is stopped
32    Stopped,
33    /// Actor failed with an error
34    Failed,
35}
36
37/// Entry in the actor registry.
38struct ActorEntry {
39    /// Actor name (optional)
40    name: Option<String>,
41    /// Actor state (stored as atomic u8)
42    state: std::sync::atomic::AtomicU8,
43    /// Actor mailbox
44    mailbox: Arc<Mailbox>,
45    /// Number of messages processed
46    processed: std::sync::atomic::AtomicU64,
47    /// Last activity timestamp
48    last_active: std::sync::atomic::AtomicI64,
49}
50
51impl ActorState {
52    /// Convert the actor state to its numeric representation.
53    pub fn to_u8(self) -> u8 {
54        match self {
55            ActorState::Creating => 0,
56            ActorState::Running => 1,
57            ActorState::Suspended => 2,
58            ActorState::Stopped => 3,
59            ActorState::Failed => 4,
60        }
61    }
62
63    /// Create an actor state from its numeric representation.
64    /// Returns `ActorState::Failed` for invalid values.
65    pub fn from_u8(v: u8) -> Self {
66        match v {
67            0 => ActorState::Creating,
68            1 => ActorState::Running,
69            2 => ActorState::Suspended,
70            3 => ActorState::Stopped,
71            4 => ActorState::Failed,
72            _ => ActorState::Creating,
73        }
74    }
75}
76
77/// Actor registry for tracking actors.
78pub struct ActorRegistry {
79    /// Actors by ID (lock-free concurrent map)
80    by_id: DashMap<ActorId, Arc<ActorEntry>>,
81    /// Name to ID mapping
82    by_name: RwLock<HashMap<String, ActorId>>,
83    /// Default mailbox config
84    mailbox_config: MailboxConfig,
85}
86
87impl ActorRegistry {
88    /// Create a new actor registry.
89    pub fn new() -> Self {
90        Self {
91            by_id: DashMap::new(),
92            by_name: RwLock::new(HashMap::new()),
93            mailbox_config: MailboxConfig::default(),
94        }
95    }
96
97    /// Create a new registry with custom mailbox config.
98    pub fn with_mailbox_config(config: MailboxConfig) -> Self {
99        Self {
100            by_id: DashMap::new(),
101            by_name: RwLock::new(HashMap::new()),
102            mailbox_config: config,
103        }
104    }
105
106    /// Register a new actor.
107    pub fn register(&self, id: ActorId) -> crate::Result<Arc<Mailbox>> {
108        self.register_named(id, None)
109    }
110
111    /// Register a new actor with a name.
112    ///
113    /// This operation is fully atomic: both the ID and name mappings are created
114    /// (or rejected) under a single critical section. The `by_name` write lock
115    /// serializes all registrations, preventing TOCTOU races where a concurrent
116    /// registration could observe or corrupt partial state.
117    pub fn register_named(&self, id: ActorId, name: Option<String>) -> crate::Result<Arc<Mailbox>> {
118        let mailbox = Arc::new(Mailbox::new(id, self.mailbox_config.clone()));
119
120        let entry = Arc::new(ActorEntry {
121            name: name.clone(),
122            state: std::sync::atomic::AtomicU8::new(ActorState::Creating.to_u8()),
123            mailbox: mailbox.clone(),
124            processed: std::sync::atomic::AtomicU64::new(0),
125            last_active: std::sync::atomic::AtomicI64::new(unix_now()),
126        });
127
128        // Serialize ALL registrations through the name lock, even unnamed ones.
129        // This prevents a TOCTOU race where thread A (named) inserts into by_name
130        // then thread B (unnamed, same ID) inserts into by_id before A does,
131        // causing A's entry to silently replace B's.
132        let mut by_name = self.by_name.write();
133
134        // Check for duplicate ID.
135        if self.by_id.contains_key(&id) {
136            return Err(Error::actor(format!(
137                "actor with id {:?} already exists",
138                id
139            )));
140        }
141
142        // Check for duplicate name (if a name is provided).
143        if let Some(ref actor_name) = name {
144            if by_name.contains_key(actor_name) {
145                return Err(Error::actor(format!(
146                    "actor with name '{}' already exists",
147                    actor_name
148                )));
149            }
150        }
151
152        // Insert into by_id first. This must not fail given the check above,
153        // but we handle it defensively to avoid corrupting the name mapping.
154        if self.by_id.insert(id, entry).is_some() {
155            return Err(Error::actor(format!(
156                "actor with id {:?} already exists",
157                id
158            )));
159        }
160
161        // Insert name mapping (if provided) now that by_id is consistent.
162        if let Some(ref actor_name) = name {
163            by_name.insert(actor_name.clone(), id);
164        }
165
166        // Release by_name lock (implicit drop at end of scope).
167        Ok(mailbox)
168    }
169
170    /// Unregister an actor.
171    pub fn unregister(&self, id: &ActorId) -> crate::Result<()> {
172        if let Some((_, entry)) = self.by_id.remove(id) {
173            if let Some(name) = &entry.name {
174                self.by_name.write().remove(name);
175            }
176            Ok(())
177        } else {
178            Err(Error::actor(format!("actor {:?} not found", id)))
179        }
180    }
181
182    /// Get an actor's mailbox by ID.
183    pub fn get_mailbox(&self, id: &ActorId) -> Option<Arc<Mailbox>> {
184        self.by_id.get(id).map(|e| e.mailbox.clone())
185    }
186
187    /// Get an actor's state by ID.
188    pub fn get_state(&self, id: &ActorId) -> Option<ActorState> {
189        self.by_id
190            .get(id)
191            .map(|e| ActorState::from_u8(e.state.load(std::sync::atomic::Ordering::Relaxed)))
192    }
193
194    /// Set an actor's state.
195    pub fn set_state(&self, id: &ActorId, state: ActorState) -> crate::Result<()> {
196        if let Some(entry) = self.by_id.get(id) {
197            entry
198                .state
199                .store(state.to_u8(), std::sync::atomic::Ordering::Relaxed);
200            Ok(())
201        } else {
202            Err(Error::actor(format!("actor {:?} not found", id)))
203        }
204    }
205
206    /// Look up an actor by name.
207    pub fn lookup(&self, name: &str) -> Option<ActorId> {
208        self.by_name.read().get(name).copied()
209    }
210
211    /// Look up an actor's mailbox by name.
212    pub fn lookup_mailbox(&self, name: &str) -> Option<Arc<Mailbox>> {
213        let id = self.lookup(name)?;
214        self.get_mailbox(&id)
215    }
216
217    /// Record a message processed by an actor.
218    pub fn record_processed(&self, id: &ActorId) {
219        if let Some(entry) = self.by_id.get(id) {
220            entry
221                .processed
222                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
223            entry
224                .last_active
225                .store(unix_now(), std::sync::atomic::Ordering::Relaxed);
226        }
227    }
228
229    /// Get the number of messages processed by an actor.
230    pub fn get_processed_count(&self, id: &ActorId) -> u64 {
231        self.by_id
232            .get(id)
233            .map(|e| e.processed.load(std::sync::atomic::Ordering::Relaxed))
234            .unwrap_or(0)
235    }
236
237    /// Get the last activity timestamp for an actor.
238    pub fn get_last_active(&self, id: &ActorId) -> Option<i64> {
239        self.by_id
240            .get(id)
241            .map(|e| e.last_active.load(std::sync::atomic::Ordering::Relaxed))
242    }
243
244    /// Get the total number of actors.
245    pub fn len(&self) -> usize {
246        self.by_id.len()
247    }
248
249    /// Check if the registry is empty.
250    pub fn is_empty(&self) -> bool {
251        self.by_id.is_empty()
252    }
253
254    /// List all actor IDs.
255    pub fn list_actors(&self) -> Vec<ActorId> {
256        self.by_id.iter().map(|e| *e.key()).collect()
257    }
258
259    /// List actors by state.
260    pub fn list_by_state(&self, state: ActorState) -> Vec<ActorId> {
261        let target = state.to_u8();
262        self.by_id
263            .iter()
264            .filter(|e| e.state.load(std::sync::atomic::Ordering::Relaxed) == target)
265            .map(|e| *e.key())
266            .collect()
267    }
268
269    /// Get statistics about the registry.
270    pub fn stats(&self) -> RegistryStats {
271        let mut stats = RegistryStats::default();
272
273        for entry in self.by_id.iter() {
274            let state = ActorState::from_u8(entry.state.load(std::sync::atomic::Ordering::Relaxed));
275            match state {
276                ActorState::Creating => stats.creating += 1,
277                ActorState::Running => stats.running += 1,
278                ActorState::Suspended => stats.suspended += 1,
279                ActorState::Stopped => stats.stopped += 1,
280                ActorState::Failed => stats.failed += 1,
281            }
282        }
283        stats.total = self.by_id.len();
284        stats
285    }
286}
287
288impl Default for ActorRegistry {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294/// Statistics about the actor registry.
295#[derive(Debug, Default)]
296pub struct RegistryStats {
297    /// Total number of actors
298    pub total: usize,
299    /// Number of actors being created
300    pub creating: usize,
301    /// Number of running actors
302    pub running: usize,
303    /// Number of suspended actors
304    pub suspended: usize,
305    /// Number of stopped actors
306    pub stopped: usize,
307    /// Number of failed actors
308    pub failed: usize,
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_registry_basic() {
317        let registry = ActorRegistry::new();
318        let id = ActorId::new();
319
320        let _mailbox = registry.register(id).unwrap();
321        assert_eq!(registry.len(), 1);
322        assert_eq!(registry.get_state(&id), Some(ActorState::Creating));
323
324        registry.set_state(&id, ActorState::Running).unwrap();
325        assert_eq!(registry.get_state(&id), Some(ActorState::Running));
326
327        registry.unregister(&id).unwrap();
328        assert!(registry.is_empty());
329    }
330
331    #[test]
332    fn test_registry_named() {
333        let registry = ActorRegistry::new();
334        let id = ActorId::new();
335
336        registry
337            .register_named(id, Some("test-actor".to_string()))
338            .unwrap();
339
340        let looked_up = registry.lookup("test-actor");
341        assert_eq!(looked_up, Some(id));
342
343        let mailbox = registry.lookup_mailbox("test-actor");
344        assert!(mailbox.is_some());
345    }
346
347    #[test]
348    fn test_registry_duplicate_name() {
349        let registry = ActorRegistry::new();
350
351        registry
352            .register_named(ActorId::new(), Some("test".to_string()))
353            .unwrap();
354
355        let result = registry.register_named(ActorId::new(), Some("test".to_string()));
356        assert!(result.is_err());
357    }
358
359    #[test]
360    fn test_registry_stats() {
361        let registry = ActorRegistry::new();
362
363        let id1 = ActorId::new();
364        let id2 = ActorId::new();
365        let id3 = ActorId::new();
366
367        registry.register(id1).unwrap();
368        registry.register(id2).unwrap();
369        registry.register(id3).unwrap();
370
371        registry.set_state(&id1, ActorState::Running).unwrap();
372        registry.set_state(&id2, ActorState::Running).unwrap();
373        registry.set_state(&id3, ActorState::Suspended).unwrap();
374
375        let stats = registry.stats();
376        assert_eq!(stats.total, 3);
377        assert_eq!(stats.running, 2);
378        assert_eq!(stats.suspended, 1);
379    }
380
381    #[test]
382    fn test_registry_concurrent_named_registration() {
383        use std::sync::Arc;
384        use std::thread;
385
386        let registry = Arc::new(ActorRegistry::new());
387        let mut handles = Vec::new();
388
389        // Spawn many threads all trying to register actors with the same name.
390        // At most one should succeed; the rest should get duplicate-name errors.
391        for _ in 0..16 {
392            let reg = registry.clone();
393            handles.push(thread::spawn(move || {
394                let id = ActorId::new();
395                reg.register_named(id, Some("shared-name".to_string()))
396            }));
397        }
398
399        let mut success_count = 0;
400        let mut error_count = 0;
401        for handle in handles {
402            match handle.join().unwrap() {
403                Ok(_) => success_count += 1,
404                Err(_) => error_count += 1,
405            }
406        }
407
408        assert_eq!(
409            success_count, 1,
410            "exactly one registration with shared name should succeed"
411        );
412        assert_eq!(error_count, 15, "all other registrations should fail");
413        assert_eq!(registry.len(), 1);
414    }
415
416    #[test]
417    fn test_registry_no_orphaned_name_on_id_conflict() {
418        // Verify that if ID registration fails after name registration succeeds,
419        // the name mapping is cleaned up (no orphan).
420        let registry = ActorRegistry::new();
421        let id = ActorId::new();
422
423        // Register once with name.
424        registry
425            .register_named(id, Some("my-actor".to_string()))
426            .unwrap();
427
428        // Try to register a different actor with the same name (should fail).
429        let result = registry.register_named(ActorId::new(), Some("my-actor".to_string()));
430        assert!(result.is_err());
431
432        // The original name mapping should still be intact.
433        assert_eq!(registry.lookup("my-actor"), Some(id));
434    }
435
436    #[test]
437    fn test_registry_toctou_unnamed_vs_named_same_id() {
438        // Verify that two threads registering the same ID (one unnamed, one named)
439        // cannot both succeed. Previously this was a TOCTOU race: the unnamed path
440        // skipped the name lock, so it could insert into by_id between the named
441        // path's contains_key check and its insert.
442        use std::sync::atomic::{AtomicUsize, Ordering};
443        use std::thread;
444
445        let registry = Arc::new(ActorRegistry::new());
446        let id = ActorId::new();
447        let success_count = Arc::new(AtomicUsize::new(0));
448
449        // Pre-generate the named registration closure (shares the same ID).
450        let named_id = id;
451        let unnamed_id = id;
452        let reg1 = registry.clone();
453        let reg2 = registry.clone();
454        let sc1 = success_count.clone();
455        let sc2 = success_count.clone();
456
457        let h1 = thread::spawn(move || {
458            if reg1
459                .register_named(named_id, Some("named-actor".to_string()))
460                .is_ok()
461            {
462                sc1.fetch_add(1, Ordering::Relaxed);
463            }
464        });
465
466        let h2 = thread::spawn(move || {
467            if reg2.register_named(unnamed_id, None).is_ok() {
468                sc2.fetch_add(1, Ordering::Relaxed);
469            }
470        });
471
472        h1.join().unwrap();
473        h2.join().unwrap();
474
475        assert_eq!(
476            success_count.load(Ordering::Relaxed),
477            1,
478            "exactly one registration with the same ID should succeed"
479        );
480        assert_eq!(registry.len(), 1);
481    }
482}