Skip to main content

actor_kit/
scheduler.rs

1//! Work-stealing scheduler for efficient actor execution.
2//!
3//! Implements a multi-worker scheduler with work stealing for load balancing.
4
5use parking_lot::{Mutex, RwLock};
6use std::panic::AssertUnwindSafe;
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::Arc;
9use std::thread::{self, JoinHandle};
10
11#[cfg(test)]
12use crate::executor::NullExecutor;
13use crate::executor::{ActorExecutor, ExecutionResult};
14use crate::policy::ResourcePolicy;
15use crate::queue::{create_local_queue, PriorityQueue, Task, WorkQueue, WorkStealer};
16#[cfg(feature = "serde")]
17use crate::rpc::RpcClient;
18use crate::supervisor::ResourceMonitor;
19use crate::Error;
20use crate::{ActorId, ActorRegistry, ActorState, MailboxConfig, Message, MessagePayload, Priority};
21
22/// Type alias for worker stealer entry (worker_id, stealer)
23type WorkerStealer = (usize, crossbeam_deque::Stealer<Task>);
24
25/// Shared stealer registry for work stealing between workers.
26#[derive(Clone)]
27pub struct StealerRegistry {
28    stealers: Arc<RwLock<Vec<WorkerStealer>>>,
29    version: Arc<AtomicU64>,
30}
31
32impl StealerRegistry {
33    /// Create an empty stealer registry.
34    pub fn new() -> Self {
35        Self {
36            stealers: Arc::new(RwLock::new(Vec::new())),
37            version: Arc::new(AtomicU64::new(0)),
38        }
39    }
40
41    /// Register a worker's stealer under the given worker ID.
42    pub fn add_stealer(&self, worker_id: usize, stealer: crossbeam_deque::Stealer<Task>) {
43        let mut stealers = self.stealers.write();
44        stealers.push((worker_id, stealer));
45        self.version.fetch_add(1, Ordering::Release);
46    }
47
48    /// Get stealers for all workers except `exclude_worker_id`.
49    pub fn get_stealers(&self, exclude_worker_id: usize) -> Vec<crossbeam_deque::Stealer<Task>> {
50        let stealers = self.stealers.read();
51        stealers
52            .iter()
53            .filter(|(id, _)| *id != exclude_worker_id)
54            .map(|(_, s)| s.clone())
55            .collect()
56    }
57
58    /// Get the registry version (bumped on every `add_stealer`).
59    pub fn version(&self) -> u64 {
60        self.version.load(Ordering::Acquire)
61    }
62}
63
64impl Default for StealerRegistry {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70/// Configuration for the actor scheduler.
71#[derive(Debug, Clone)]
72pub struct SchedulerConfig {
73    /// Number of worker threads (0 = auto-detect)
74    pub workers: usize,
75    /// Mailbox configuration
76    pub mailbox_config: MailboxConfig,
77    /// Enable priority scheduling
78    pub priority_scheduling: bool,
79    /// Maximum steal batch size
80    pub max_steal_batch: usize,
81    /// Idle sleep duration in microseconds
82    pub idle_sleep_us: u64,
83    /// Stealer refresh interval (in iterations)
84    pub stealer_refresh_interval: u32,
85}
86
87impl Default for SchedulerConfig {
88    fn default() -> Self {
89        Self {
90            workers: 0,
91            mailbox_config: MailboxConfig::default(),
92            priority_scheduling: true,
93            max_steal_batch: 32,
94            idle_sleep_us: 100,
95            stealer_refresh_interval: 1000,
96        }
97    }
98}
99
100impl SchedulerConfig {
101    /// Create a new scheduler config.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Set the number of workers.
107    pub fn workers(mut self, workers: usize) -> Self {
108        self.workers = workers;
109        self
110    }
111
112    /// Get the effective number of workers.
113    pub fn effective_workers(&self) -> usize {
114        if self.workers == 0 {
115            thread::available_parallelism()
116                .map(|p| p.get())
117                .unwrap_or(4)
118        } else {
119            self.workers
120        }
121    }
122}
123
124/// Per-worker statistics.
125#[derive(Debug, Default)]
126struct WorkerStats {
127    /// Tasks processed by this worker
128    processed: AtomicU64,
129    /// Tasks stolen by this worker
130    stolen: AtomicU64,
131    /// Number of batch tasks processed by this worker
132    batches_processed: AtomicU64,
133}
134
135/// Work-stealing actor scheduler.
136pub struct ActorScheduler {
137    /// Configuration
138    config: SchedulerConfig,
139    /// Global work queue
140    global_queue: Arc<WorkQueue>,
141    /// Priority queue for high-priority tasks
142    priority_queue: Arc<PriorityQueue>,
143    /// Actor registry
144    registry: Arc<ActorRegistry>,
145    /// Shared stealer registry
146    stealer_registry: StealerRegistry,
147    /// Worker thread handles
148    worker_handles: Mutex<Vec<JoinHandle<()>>>,
149    /// Running flag
150    running: Arc<AtomicBool>,
151    /// Total actors spawned
152    total_actors: AtomicU64,
153    /// Total messages processed
154    total_processed: Arc<AtomicU64>,
155    /// Per-worker statistics
156    worker_stats: Vec<Arc<WorkerStats>>,
157    /// Optional executor for WASM execution
158    executor: Option<Arc<dyn ActorExecutor>>,
159    /// Optional admission-control policy (spawns and message sends).
160    ///
161    /// Hook point replacing the upstream `QuotaEnforcer` dependency: install a
162    /// custom policy via [`ActorScheduler::with_resource_policy`] to enforce
163    /// actor-count limits, message rates, or per-tenant budgets. See
164    /// [`crate::policy::ResourcePolicy`].
165    resource_policy: Option<Arc<dyn ResourcePolicy>>,
166    /// Optional degradation controller for graceful admission control.
167    /// `Arc<dyn ResourceMonitor>` is the monitor; the `f64` is the
168    /// `elevated_batch_factor` passed to `DegradationController`.
169    degradation: Option<(Arc<dyn ResourceMonitor>, f64)>,
170}
171
172impl ActorScheduler {
173    /// Create a new actor scheduler.
174    pub fn new(config: SchedulerConfig) -> Self {
175        Self::with_options(config, None, None)
176    }
177
178    /// Create a new scheduler with an executor.
179    pub fn with_executor(config: SchedulerConfig, executor: Arc<dyn ActorExecutor>) -> Self {
180        Self::with_options(config, Some(executor), None)
181    }
182
183    /// Create a new scheduler with an executor and a resource policy.
184    ///
185    /// The policy gates actor spawns and message sends; see
186    /// [`crate::policy::ResourcePolicy`] for the (cheap, non-blocking)
187    /// contract.
188    pub fn with_resource_policy(
189        config: SchedulerConfig,
190        executor: Arc<dyn ActorExecutor>,
191        resource_policy: Arc<dyn ResourcePolicy>,
192    ) -> Self {
193        Self::with_options(config, Some(executor), Some(resource_policy))
194    }
195
196    /// Create a new scheduler with optional executor and optional resource policy.
197    fn with_options(
198        config: SchedulerConfig,
199        executor: Option<Arc<dyn ActorExecutor>>,
200        resource_policy: Option<Arc<dyn ResourcePolicy>>,
201    ) -> Self {
202        let worker_count = config.effective_workers();
203        let worker_stats: Vec<_> = (0..worker_count)
204            .map(|_| Arc::new(WorkerStats::default()))
205            .collect();
206
207        // Honor the configured mailbox capacity: the registry creates a
208        // mailbox per actor, so it must see `config.mailbox_config`.
209        // (`ActorRegistry::new()` silently fell back to the 10_000-message
210        // default, making `SchedulerConfig::mailbox_config` a dead knob.)
211        let registry = Arc::new(ActorRegistry::with_mailbox_config(
212            config.mailbox_config.clone(),
213        ));
214
215        Self {
216            config,
217            global_queue: Arc::new(WorkQueue::new()),
218            priority_queue: Arc::new(PriorityQueue::new()),
219            registry,
220            stealer_registry: StealerRegistry::new(),
221            worker_handles: Mutex::new(Vec::new()),
222            running: Arc::new(AtomicBool::new(false)),
223            total_actors: AtomicU64::new(0),
224            total_processed: Arc::new(AtomicU64::new(0)),
225            worker_stats,
226            executor,
227            resource_policy,
228            degradation: None,
229        }
230    }
231
232    /// Set the executor for WASM execution.
233    pub fn set_executor(&mut self, executor: Arc<dyn ActorExecutor>) {
234        self.executor = Some(executor);
235    }
236
237    /// Set the degradation controller for OS-level admission control.
238    ///
239    /// When set, `spawn_named` will check resource pressure before creating a
240    /// new actor. `Critical` pressure returns an error; `Elevated` logs a
241    /// debug message but allows the spawn.
242    pub fn set_degradation_with_monitor(
243        &mut self,
244        monitor: Arc<dyn ResourceMonitor>,
245        elevated_batch_factor: f64,
246    ) {
247        self.degradation = Some((monitor, elevated_batch_factor));
248    }
249
250    /// Start the scheduler.
251    pub fn start(&self) -> Result<(), Error> {
252        if self.running.swap(true, Ordering::SeqCst) {
253            return Ok(());
254        }
255
256        let mut handles = self.worker_handles.lock();
257
258        for (id, stats) in self.worker_stats.iter().enumerate() {
259            let (worker, stealer) = create_local_queue();
260            self.stealer_registry.add_stealer(id, stealer);
261
262            let global_queue = self.global_queue.clone();
263            let priority_queue = self.priority_queue.clone();
264            let registry = self.registry.clone();
265            let config = self.config.clone();
266            let running_flag = self.running.clone();
267            let total_processed = self.total_processed.clone();
268            let stats = stats.clone();
269            let stealer_registry = self.stealer_registry.clone();
270            let executor = self.executor.clone();
271
272            let handle = thread::Builder::new()
273                .name(format!("actor-kit-worker-{}", id))
274                .spawn(move || {
275                    Self::worker_loop(
276                        id,
277                        worker,
278                        global_queue,
279                        priority_queue,
280                        registry,
281                        config,
282                        running_flag,
283                        total_processed,
284                        stats,
285                        stealer_registry,
286                        executor.as_ref(),
287                    );
288                });
289
290            match handle {
291                Ok(h) => handles.push(h),
292                Err(e) => {
293                    tracing::error!("Failed to spawn worker thread {}: {}", id, e);
294                    return Err(Error::internal(format!(
295                        "Failed to spawn worker thread {}: {}",
296                        id, e
297                    )));
298                }
299            }
300        }
301
302        Ok(())
303    }
304
305    /// Stop the scheduler.
306    pub fn stop(&self) {
307        self.running.store(false, Ordering::SeqCst);
308
309        let mut handles = self.worker_handles.lock();
310        for handle in handles.drain(..) {
311            let _ = handle.join();
312        }
313    }
314
315    /// Spawn a new actor.
316    pub fn spawn(&self) -> crate::Result<ActorId> {
317        self.spawn_named(None)
318    }
319
320    /// Spawn a new actor with a name.
321    pub fn spawn_named(&self, name: Option<String>) -> crate::Result<ActorId> {
322        self.check_degradation()?;
323
324        if let Some(ref policy) = self.resource_policy {
325            policy.admit_actor()?;
326        }
327        let id = ActorId::new();
328        self.registry.register_named(id, name)?;
329        self.total_actors.fetch_add(1, Ordering::Relaxed);
330        Ok(id)
331    }
332
333    /// Check the degradation controller before spawning.
334    ///
335    /// Returns `Err` on `Reject`; logs a warning on `Throttle` but allows the spawn.
336    fn check_degradation(&self) -> crate::Result<()> {
337        let (monitor, _) = match &self.degradation {
338            Some(d) => d,
339            None => return Ok(()),
340        };
341
342        match monitor.current_pressure() {
343            crate::supervisor::PressureLevel::Normal => Ok(()),
344            crate::supervisor::PressureLevel::Elevated => {
345                tracing::debug!("actor spawn throttled due to elevated resource pressure");
346                Ok(())
347            }
348            crate::supervisor::PressureLevel::Critical => {
349                tracing::warn!("actor spawn rejected due to critical resource pressure");
350                Err(Error::resource_exhausted(
351                    "system resources critically low; actor spawn rejected",
352                ))
353            }
354        }
355    }
356
357    /// Kill an actor.
358    pub fn kill(&self, id: &ActorId) -> crate::Result<()> {
359        self.registry.set_state(id, ActorState::Stopped)?;
360        if let Some(m) = self.registry.get_mailbox(id) {
361            m.clear()
362        }
363        self.registry.unregister(id)?;
364        if let Some(ref policy) = self.resource_policy {
365            policy.release_actor();
366        }
367        Ok(())
368    }
369
370    /// Set an actor to running state.
371    pub fn set_actor_running(&self, id: &ActorId) -> crate::Result<()> {
372        self.registry.set_state(id, ActorState::Running)
373    }
374
375    /// Set an actor state.
376    pub fn set_actor_state(&self, id: &ActorId, state: ActorState) -> crate::Result<()> {
377        self.registry.set_state(id, state)
378    }
379
380    /// Send a message to an actor.
381    pub async fn send(&self, target: ActorId, message: Message) -> crate::Result<()> {
382        if let Some(ref policy) = self.resource_policy {
383            policy.admit_message(1)?;
384        }
385
386        let mailbox = self
387            .registry
388            .get_mailbox(&target)
389            .ok_or_else(|| Error::actor(format!("actor {:?} not found", target)))?;
390
391        let state = self.registry.get_state(&target);
392        match state {
393            Some(ActorState::Stopped) | Some(ActorState::Failed) => {
394                return Err(Error::actor(format!("actor {:?} is not running", target)));
395            }
396            Some(ActorState::Suspended) => {
397                // Message queued but actor won't process until resumed
398            }
399            _ => {}
400        }
401
402        let priority = message.priority;
403
404        // Clone once for mailbox, move original into task.
405        // Previous implementation cloned twice (mailbox + task).
406        mailbox.send(message.clone()).await?;
407
408        let task = Task {
409            actor_id: target,
410            message, // moved, not cloned
411            priority,
412            additional_messages: Vec::new(),
413        };
414
415        if self.config.priority_scheduling && task.priority >= Priority::High {
416            self.priority_queue.push(task);
417        } else {
418            self.global_queue.push(task);
419        }
420
421        Ok(())
422    }
423
424    /// Send a batch of messages to an actor in a single queue operation.
425    ///
426    /// This is more efficient than calling `send` in a loop because it performs
427    /// one quota/rate-limit check and pushes a single `Task` to the work-stealing
428    /// queue for the entire batch.
429    pub async fn send_batch(&self, target: ActorId, messages: Vec<Message>) -> crate::Result<()> {
430        if messages.is_empty() {
431            return Ok(());
432        }
433
434        if let Some(ref policy) = self.resource_policy {
435            policy.admit_message(messages.len())?;
436        }
437
438        let mailbox = self
439            .registry
440            .get_mailbox(&target)
441            .ok_or_else(|| Error::actor(format!("actor {:?} not found", target)))?;
442
443        let state = self.registry.get_state(&target);
444        match state {
445            Some(ActorState::Stopped) | Some(ActorState::Failed) => {
446                return Err(Error::actor(format!("actor {:?} is not running", target)));
447            }
448            Some(ActorState::Suspended) => {}
449            _ => {}
450        }
451
452        let max_priority = messages
453            .iter()
454            .map(|m| m.priority)
455            .max()
456            .unwrap_or(Priority::Normal);
457
458        for msg in &messages {
459            mailbox.send(msg.clone()).await?;
460        }
461
462        let mut messages_into_iter = messages.into_iter();
463        let first = match messages_into_iter.next() {
464            Some(msg) => msg,
465            None => return Ok(()),
466        };
467        let additional: Vec<Message> = messages_into_iter.collect();
468
469        let task = Task {
470            actor_id: target,
471            message: first,
472            priority: max_priority,
473            additional_messages: additional,
474        };
475
476        if self.config.priority_scheduling && task.priority >= Priority::High {
477            self.priority_queue.push(task);
478        } else {
479            self.global_queue.push(task);
480        }
481
482        Ok(())
483    }
484
485    /// Try to send a message (non-blocking).
486    pub fn try_send(&self, target: ActorId, message: Message) -> crate::Result<()> {
487        let mailbox = self
488            .registry
489            .get_mailbox(&target)
490            .ok_or_else(|| Error::actor(format!("actor {:?} not found", target)))?;
491
492        let priority = message.priority;
493
494        // Clone once for mailbox, move original into task.
495        mailbox.try_send(message.clone()).map_err(|(_, e)| e)?;
496
497        let task = Task {
498            actor_id: target,
499            message, // moved, not cloned
500            priority,
501            additional_messages: Vec::new(),
502        };
503
504        if self.config.priority_scheduling && task.priority >= Priority::High {
505            self.priority_queue.push(task);
506        } else {
507            self.global_queue.push(task);
508        }
509
510        Ok(())
511    }
512
513    /// Get the actor registry.
514    pub fn registry(&self) -> &Arc<ActorRegistry> {
515        &self.registry
516    }
517
518    /// Get scheduler statistics.
519    pub fn stats(&self) -> SchedulerStats {
520        let mut worker_stats = Vec::new();
521        let mut total_processed = 0u64;
522        let mut total_stolen = 0u64;
523        let mut total_batches = 0u64;
524
525        for (id, stats) in self.worker_stats.iter().enumerate() {
526            let processed = stats.processed.load(Ordering::Relaxed);
527            let stolen = stats.stolen.load(Ordering::Relaxed);
528            let batches = stats.batches_processed.load(Ordering::Relaxed);
529
530            total_processed += processed;
531            total_stolen += stolen;
532            total_batches += batches;
533
534            worker_stats.push(WorkerStatsInfo {
535                id,
536                processed,
537                stolen,
538                batches_processed: batches,
539            });
540        }
541
542        SchedulerStats {
543            running: self.running.load(Ordering::Relaxed),
544            total_actors: self.total_actors.load(Ordering::Relaxed),
545            active_actors: self.registry.stats().running,
546            total_messages_processed: total_processed,
547            total_batches_processed: total_batches,
548            total_stolen,
549            worker_count: self.worker_stats.len(),
550            workers: worker_stats,
551        }
552    }
553
554    /// Get an RPC client for making typed RPC calls to actors.
555    #[cfg(feature = "serde")]
556    pub fn rpc_client(self: &Arc<Self>) -> RpcClient {
557        RpcClient::new(self.clone())
558    }
559
560    #[allow(clippy::too_many_arguments)]
561    fn worker_loop(
562        worker_id: usize,
563        worker: crossbeam_deque::Worker<Task>,
564        global_queue: Arc<WorkQueue>,
565        priority_queue: Arc<PriorityQueue>,
566        registry: Arc<ActorRegistry>,
567        config: SchedulerConfig,
568        running: Arc<AtomicBool>,
569        total_processed: Arc<AtomicU64>,
570        stats: Arc<WorkerStats>,
571        stealer_registry: StealerRegistry,
572        executor: Option<&Arc<dyn ActorExecutor>>,
573    ) {
574        let mut stealer = WorkStealer::new(Vec::new());
575        let mut last_version = 0u64;
576        let mut iteration = 0u32;
577
578        let mut consecutive_empty = 0u32;
579
580        while running.load(Ordering::Acquire) {
581            iteration = iteration.wrapping_add(1);
582
583            if iteration % config.stealer_refresh_interval == 0 {
584                let current_version = stealer_registry.version();
585                if current_version != last_version {
586                    let stealers = stealer_registry.get_stealers(worker_id);
587                    stealer = WorkStealer::new(stealers);
588                    last_version = current_version;
589                }
590            }
591
592            // Try priority queue first
593            if let Some(task) = priority_queue.pop() {
594                Self::process_task_safe(&registry, task, &total_processed, &stats, executor);
595                consecutive_empty = 0;
596                continue;
597            }
598
599            // Try local queue
600            if let Some(task) = worker.pop() {
601                Self::process_task_safe(&registry, task, &total_processed, &stats, executor);
602                consecutive_empty = 0;
603                continue;
604            }
605
606            // Try global queue
607            if let Some(task) = global_queue.steal_global() {
608                Self::process_task_safe(&registry, task, &total_processed, &stats, executor);
609                consecutive_empty = 0;
610                continue;
611            }
612
613            // Try stealing from other workers
614            if let Some(task) = stealer.steal() {
615                stats.stolen.fetch_add(1, Ordering::Relaxed);
616                Self::process_task_safe(&registry, task, &total_processed, &stats, executor);
617                consecutive_empty = 0;
618                continue;
619            }
620
621            // Steal batch from other workers
622            let stolen = stealer.steal_batch(&worker, config.max_steal_batch);
623            if stolen > 0 {
624                stats.stolen.fetch_add(stolen as u64, Ordering::Relaxed);
625                continue;
626            }
627
628            // No work found, back off
629            consecutive_empty += 1;
630            if consecutive_empty > 100 {
631                std::thread::sleep(std::time::Duration::from_micros(config.idle_sleep_us));
632            } else if consecutive_empty > 10 {
633                std::hint::spin_loop();
634            }
635        }
636    }
637
638    /// Process a task with panic protection.
639    ///
640    /// Wraps `process_task` in `catch_unwind` so that a panic in the executor
641    /// or handler does not kill the worker thread. On panic, the actor is marked
642    /// as `Failed` and its mailbox is drained to prevent further processing of
643    /// messages by the panicked actor.
644    fn process_task_safe(
645        registry: &ActorRegistry,
646        task: Task,
647        total_processed: &AtomicU64,
648        stats: &WorkerStats,
649        executor: Option<&Arc<dyn ActorExecutor>>,
650    ) {
651        let actor_id = task.actor_id;
652        let batch_size = 1 + task.additional_messages.len();
653        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
654            Self::process_task(registry, task, total_processed, stats, executor);
655        }));
656
657        if let Err(panic_payload) = result {
658            if batch_size > 1 {
659                stats.batches_processed.fetch_add(1, Ordering::Relaxed);
660            }
661            tracing::error!(
662                actor_id = ?actor_id,
663                batch_size,
664                "worker panicked while processing task; marking actor as Failed and draining mailbox"
665            );
666            let _ = registry.set_state(&actor_id, ActorState::Failed);
667            if let Some(mailbox) = registry.get_mailbox(&actor_id) {
668                mailbox.clear();
669            }
670            if let Some(s) = panic_payload.downcast_ref::<&str>() {
671                tracing::error!(panic_message = %s, "panic details");
672            } else if let Some(s) = panic_payload.downcast_ref::<String>() {
673                tracing::error!(panic_message = %s, "panic details");
674            }
675        }
676    }
677
678    fn process_task(
679        registry: &ActorRegistry,
680        mut task: Task,
681        total_processed: &AtomicU64,
682        stats: &WorkerStats,
683        executor: Option<&Arc<dyn ActorExecutor>>,
684    ) {
685        if task.additional_messages.is_empty() {
686            // Fast path: single message
687            Self::process_single_message(
688                registry,
689                task.actor_id,
690                &task.message,
691                total_processed,
692                stats,
693                executor,
694            );
695        } else {
696            // Batch path: process primary message, then additional ones
697            stats.batches_processed.fetch_add(1, Ordering::Relaxed);
698
699            Self::process_single_message(
700                registry,
701                task.actor_id,
702                &task.message,
703                total_processed,
704                stats,
705                executor,
706            );
707
708            // Early exit if the actor failed during the first message
709            if matches!(
710                registry.get_state(&task.actor_id),
711                Some(ActorState::Failed) | Some(ActorState::Stopped)
712            ) {
713                return;
714            }
715
716            for message in task.additional_messages.drain(..) {
717                Self::process_single_message(
718                    registry,
719                    task.actor_id,
720                    &message,
721                    total_processed,
722                    stats,
723                    executor,
724                );
725
726                // Stop processing remaining batch if actor is no longer viable
727                if matches!(
728                    registry.get_state(&task.actor_id),
729                    Some(ActorState::Failed) | Some(ActorState::Stopped)
730                ) {
731                    break;
732                }
733            }
734        }
735    }
736
737    fn process_single_message(
738        registry: &ActorRegistry,
739        actor_id: ActorId,
740        message: &Message,
741        total_processed: &AtomicU64,
742        stats: &WorkerStats,
743        executor: Option<&Arc<dyn ActorExecutor>>,
744    ) {
745        let actor_state = registry.get_state(&actor_id);
746
747        match actor_state {
748            Some(ActorState::Running) | Some(ActorState::Creating) => {
749                let should_count = if let Some(exec) = executor {
750                    match exec.execute(&actor_id, message) {
751                        ExecutionResult::Success { .. } => {
752                            if matches!(message.payload, MessagePayload::Start) {
753                                let _ = registry.set_state(&actor_id, ActorState::Running);
754                            } else if matches!(message.payload, MessagePayload::Stop) {
755                                let _ = registry.set_state(&actor_id, ActorState::Stopped);
756                            }
757                            true
758                        }
759                        ExecutionResult::FuelExhausted { .. } => {
760                            let _ = registry.set_state(&actor_id, ActorState::Failed);
761                            if let Some(mailbox) = registry.get_mailbox(&actor_id) {
762                                mailbox.clear();
763                            }
764                            false
765                        }
766                        ExecutionResult::Failed { error } => {
767                            tracing::warn!("Actor execution failed: {}", error);
768                            let _ = registry.set_state(&actor_id, ActorState::Failed);
769                            if let Some(mailbox) = registry.get_mailbox(&actor_id) {
770                                mailbox.clear();
771                            }
772                            false
773                        }
774                        ExecutionResult::NotReady => {
775                            Self::handle_state_change_for(actor_id, message, registry);
776                            true
777                        }
778                    }
779                } else {
780                    Self::handle_state_change_for(actor_id, message, registry);
781                    true
782                };
783
784                if should_count {
785                    stats.processed.fetch_add(1, Ordering::Relaxed);
786                    total_processed.fetch_add(1, Ordering::Relaxed);
787                    registry.record_processed(&actor_id);
788                    // The message is now consumed: release the mailbox slot
789                    // it parked in. `Mailbox::send`/`try_send` acquire one
790                    // capacity permit per message and `forget()` it so the
791                    // slot stays held until consumption; the worker processes
792                    // the `Task` copy from the work queue, so without this
793                    // pop the permit and slot leak forever. After `capacity`
794                    // cumulative messages the semaphore exhausted and every
795                    // further send stalled (the 0.1.0 drain-stall bug).
796                    if let Some(mailbox) = registry.get_mailbox(&actor_id) {
797                        mailbox.try_recv();
798                    }
799                }
800            }
801            Some(ActorState::Suspended) => {
802                if let Some(mailbox) = registry.get_mailbox(&actor_id) {
803                    let _ = mailbox.try_send(message.clone());
804                }
805            }
806            _ => {}
807        }
808    }
809
810    fn handle_state_change_for(actor_id: ActorId, message: &Message, registry: &ActorRegistry) {
811        if matches!(message.payload, MessagePayload::Start) {
812            let _ = registry.set_state(&actor_id, ActorState::Running);
813        } else if matches!(message.payload, MessagePayload::Stop) {
814            let _ = registry.set_state(&actor_id, ActorState::Stopped);
815        } else if let Some(MessagePayload::Signal(signal)) = Some(&message.payload) {
816            match signal {
817                crate::Signal::Pause => {
818                    let _ = registry.set_state(&actor_id, ActorState::Suspended);
819                }
820                crate::Signal::Resume => {
821                    let _ = registry.set_state(&actor_id, ActorState::Running);
822                }
823                crate::Signal::Restart => {
824                    let _ = registry.set_state(&actor_id, ActorState::Creating);
825                }
826            }
827        }
828    }
829}
830
831impl Drop for ActorScheduler {
832    fn drop(&mut self) {
833        self.stop();
834    }
835}
836
837/// Statistics for the scheduler.
838#[derive(Debug)]
839pub struct SchedulerStats {
840    /// Whether the scheduler is running
841    pub running: bool,
842    /// Total actors spawned
843    pub total_actors: u64,
844    /// Currently active actors
845    pub active_actors: usize,
846    /// Total messages processed
847    pub total_messages_processed: u64,
848    /// Total batch tasks processed
849    pub total_batches_processed: u64,
850    /// Total tasks stolen
851    pub total_stolen: u64,
852    /// Number of workers
853    pub worker_count: usize,
854    /// Per-worker statistics
855    pub workers: Vec<WorkerStatsInfo>,
856}
857
858/// Statistics for a worker.
859#[derive(Debug)]
860pub struct WorkerStatsInfo {
861    /// Worker ID
862    pub id: usize,
863    /// Tasks processed
864    pub processed: u64,
865    /// Tasks stolen from others
866    pub stolen: u64,
867    /// Batch tasks processed
868    pub batches_processed: u64,
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874    use crate::policy::{NoopPolicy, ResourcePolicy};
875
876    /// Test policy admitting at most `max_actors` live actors, mirroring the
877    /// upstream `QuotaEnforcer` max-actors behavior.
878    struct CountingPolicy {
879        max_actors: usize,
880        live: std::sync::atomic::AtomicUsize,
881    }
882
883    impl CountingPolicy {
884        fn new(max_actors: usize) -> Self {
885            Self {
886                max_actors,
887                live: std::sync::atomic::AtomicUsize::new(0),
888            }
889        }
890    }
891
892    impl ResourcePolicy for CountingPolicy {
893        fn admit_actor(&self) -> crate::Result<()> {
894            let prev = self.live.fetch_add(1, Ordering::SeqCst);
895            if prev >= self.max_actors {
896                self.live.fetch_sub(1, Ordering::SeqCst);
897                return Err(crate::Error::ResourceExhausted(format!(
898                    "actor limit exceeded ({}/{})",
899                    prev, self.max_actors
900                )));
901            }
902            Ok(())
903        }
904
905        fn release_actor(&self) {
906            self.live.fetch_sub(1, Ordering::SeqCst);
907        }
908    }
909
910    #[test]
911    fn test_scheduler_creation() {
912        let config = SchedulerConfig::new().workers(2);
913        let scheduler = ActorScheduler::new(config);
914
915        assert!(!scheduler.running.load(Ordering::Relaxed));
916        assert_eq!(scheduler.worker_stats.len(), 2);
917    }
918
919    #[tokio::test]
920    async fn test_scheduler_spawn_actor() {
921        let scheduler = ActorScheduler::new(SchedulerConfig::new().workers(1));
922        scheduler.start().unwrap();
923
924        let id = scheduler.spawn().unwrap();
925        assert!(scheduler.registry().get_state(&id).is_some());
926
927        scheduler.stop();
928    }
929
930    #[tokio::test]
931    async fn test_scheduler_send_message() {
932        let scheduler = ActorScheduler::new(SchedulerConfig::new().workers(1));
933        scheduler.start().unwrap();
934
935        let id = scheduler.spawn().unwrap();
936
937        scheduler.set_actor_running(&id).unwrap();
938
939        let msg = Message {
940            sender: None,
941            payload: MessagePayload::Custom(vec![1, 2, 3]),
942            priority: Priority::Normal,
943        };
944
945        scheduler.send(id, msg).await.unwrap();
946
947        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
948
949        scheduler.stop();
950    }
951
952    #[test]
953    fn test_config_effective_workers() {
954        let config = SchedulerConfig::new().workers(0);
955        assert!(config.effective_workers() > 0);
956
957        let config = SchedulerConfig::new().workers(8);
958        assert_eq!(config.effective_workers(), 8);
959    }
960
961    #[test]
962    fn test_stealer_registry() {
963        let registry = StealerRegistry::new();
964        let (worker1, stealer1) = create_local_queue();
965        let (worker2, stealer2) = create_local_queue();
966
967        registry.add_stealer(0, stealer1);
968        registry.add_stealer(1, stealer2);
969
970        let stealers_for_0 = registry.get_stealers(0);
971        assert_eq!(stealers_for_0.len(), 1);
972
973        let stealers_for_1 = registry.get_stealers(1);
974        assert_eq!(stealers_for_1.len(), 1);
975
976        drop(worker1);
977        drop(worker2);
978    }
979
980    #[tokio::test]
981    async fn test_scheduler_with_null_executor() {
982        let executor = Arc::new(NullExecutor::new());
983        let scheduler =
984            ActorScheduler::with_executor(SchedulerConfig::new().workers(2), executor.clone());
985        scheduler.start().unwrap();
986
987        let id = scheduler.spawn().unwrap();
988        scheduler.set_actor_running(&id).unwrap();
989
990        let msg = Message {
991            sender: None,
992            payload: MessagePayload::Custom(vec![1, 2, 3]),
993            priority: Priority::Normal,
994        };
995
996        scheduler.send(id, msg).await.unwrap();
997
998        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
999
1000        let stats = scheduler.stats();
1001        assert!(stats.total_messages_processed > 0 || stats.total_stolen > 0);
1002
1003        scheduler.stop();
1004    }
1005
1006    #[tokio::test]
1007    async fn test_work_stealing_between_workers() {
1008        let scheduler = ActorScheduler::new(SchedulerConfig::new().workers(4));
1009        scheduler.start().unwrap();
1010
1011        let mut actors = Vec::new();
1012        for _ in 0..10 {
1013            let id = scheduler.spawn().unwrap();
1014            scheduler.set_actor_running(&id).unwrap();
1015            actors.push(id);
1016        }
1017
1018        for actor_id in &actors {
1019            let msg = Message {
1020                sender: None,
1021                payload: MessagePayload::Custom(vec![1, 2, 3]),
1022                priority: Priority::Normal,
1023            };
1024            scheduler.send(*actor_id, msg).await.unwrap();
1025        }
1026
1027        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
1028
1029        let stats = scheduler.stats();
1030        assert!(stats.total_messages_processed >= 5);
1031        assert_eq!(stats.worker_count, 4);
1032
1033        scheduler.stop();
1034    }
1035
1036    #[tokio::test]
1037    async fn test_scheduler_resource_policy_rejects_over_limit() {
1038        let policy = Arc::new(CountingPolicy::new(1));
1039        let scheduler = ActorScheduler::with_resource_policy(
1040            SchedulerConfig::new().workers(1),
1041            Arc::new(NullExecutor::new()),
1042            policy,
1043        );
1044
1045        let first = scheduler.spawn().unwrap();
1046        assert!(scheduler.registry().get_state(&first).is_some());
1047
1048        let result = scheduler.spawn();
1049        assert!(result.is_err());
1050        let err_msg = result.unwrap_err().to_string();
1051        assert!(err_msg.contains("actor limit exceeded"), "got: {err_msg}");
1052
1053        // Releasing the killed actor frees a slot.
1054        scheduler.kill(&first).unwrap();
1055        let second = scheduler.spawn().unwrap();
1056        assert!(scheduler.registry().get_state(&second).is_some());
1057
1058        scheduler.stop();
1059    }
1060
1061    #[test]
1062    fn test_scheduler_noop_policy_admits_all() {
1063        let policy = NoopPolicy;
1064        assert!(policy.admit_actor().is_ok());
1065        assert!(policy.admit_message(100).is_ok());
1066        policy.release_actor();
1067    }
1068}