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///
126/// Cache-line isolation: `WorkerStats` instances live in separate `Arc`
127/// allocations, but an `Arc` allocation is a 16-byte header plus these three
128/// 8-byte counters (~40 bytes). The allocator typically places consecutive
129/// allocations 48 bytes apart, so worker *i*'s counters and worker *i+1*'s
130/// counters can share a 64-byte cache line even though no field is
131/// logically shared. Every message processed does three `fetch_add`s on
132/// this struct by the owning worker, so a shared line would ping-pong
133/// between workers on every message (false sharing). `repr(align(64))`
134/// pins the counters to their own line per instance. Each instance has a
135/// single writer (the owning worker); `stats()` readers tolerate the
136/// slightly larger footprint.
137#[derive(Debug, Default)]
138#[repr(align(64))]
139struct WorkerStats {
140    /// Tasks processed by this worker
141    processed: AtomicU64,
142    /// Tasks stolen by this worker
143    stolen: AtomicU64,
144    /// Number of batch tasks processed by this worker
145    batches_processed: AtomicU64,
146}
147
148/// Work-stealing actor scheduler.
149pub struct ActorScheduler {
150    /// Configuration
151    config: SchedulerConfig,
152    /// Global work queue
153    global_queue: Arc<WorkQueue>,
154    /// Priority queue for high-priority tasks
155    priority_queue: Arc<PriorityQueue>,
156    /// Actor registry
157    registry: Arc<ActorRegistry>,
158    /// Shared stealer registry
159    stealer_registry: StealerRegistry,
160    /// Worker thread handles
161    worker_handles: Mutex<Vec<JoinHandle<()>>>,
162    /// Running flag
163    running: Arc<AtomicBool>,
164    /// Total actors spawned
165    total_actors: AtomicU64,
166    /// Per-worker statistics
167    worker_stats: Vec<Arc<WorkerStats>>,
168    /// Optional executor for WASM execution
169    executor: Option<Arc<dyn ActorExecutor>>,
170    /// Optional admission-control policy (spawns and message sends).
171    ///
172    /// Hook point replacing the upstream `QuotaEnforcer` dependency: install a
173    /// custom policy via [`ActorScheduler::with_resource_policy`] to enforce
174    /// actor-count limits, message rates, or per-tenant budgets. See
175    /// [`crate::policy::ResourcePolicy`].
176    resource_policy: Option<Arc<dyn ResourcePolicy>>,
177    /// Optional degradation controller for graceful admission control.
178    /// `Arc<dyn ResourceMonitor>` is the monitor; the `f64` is the
179    /// `elevated_batch_factor` passed to `DegradationController`.
180    degradation: Option<(Arc<dyn ResourceMonitor>, f64)>,
181}
182
183impl ActorScheduler {
184    /// Create a new actor scheduler.
185    pub fn new(config: SchedulerConfig) -> Self {
186        Self::with_options(config, None, None)
187    }
188
189    /// Create a new scheduler with an executor.
190    pub fn with_executor(config: SchedulerConfig, executor: Arc<dyn ActorExecutor>) -> Self {
191        Self::with_options(config, Some(executor), None)
192    }
193
194    /// Create a new scheduler with an executor and a resource policy.
195    ///
196    /// The policy gates actor spawns and message sends; see
197    /// [`crate::policy::ResourcePolicy`] for the (cheap, non-blocking)
198    /// contract.
199    pub fn with_resource_policy(
200        config: SchedulerConfig,
201        executor: Arc<dyn ActorExecutor>,
202        resource_policy: Arc<dyn ResourcePolicy>,
203    ) -> Self {
204        Self::with_options(config, Some(executor), Some(resource_policy))
205    }
206
207    /// Create a new scheduler with optional executor and optional resource policy.
208    fn with_options(
209        config: SchedulerConfig,
210        executor: Option<Arc<dyn ActorExecutor>>,
211        resource_policy: Option<Arc<dyn ResourcePolicy>>,
212    ) -> Self {
213        let worker_count = config.effective_workers();
214        let worker_stats: Vec<_> = (0..worker_count)
215            .map(|_| Arc::new(WorkerStats::default()))
216            .collect();
217
218        // Honor the configured mailbox capacity: the registry creates a
219        // mailbox per actor, so it must see `config.mailbox_config`.
220        // (`ActorRegistry::new()` silently fell back to the 10_000-message
221        // default, making `SchedulerConfig::mailbox_config` a dead knob.)
222        let registry = Arc::new(ActorRegistry::with_mailbox_config(
223            config.mailbox_config.clone(),
224        ));
225
226        Self {
227            config,
228            global_queue: Arc::new(WorkQueue::new()),
229            priority_queue: Arc::new(PriorityQueue::new()),
230            registry,
231            stealer_registry: StealerRegistry::new(),
232            worker_handles: Mutex::new(Vec::new()),
233            running: Arc::new(AtomicBool::new(false)),
234            total_actors: AtomicU64::new(0),
235            worker_stats,
236            executor,
237            resource_policy,
238            degradation: None,
239        }
240    }
241
242    /// Set the executor for WASM execution.
243    pub fn set_executor(&mut self, executor: Arc<dyn ActorExecutor>) {
244        self.executor = Some(executor);
245    }
246
247    /// Set the degradation controller for OS-level admission control.
248    ///
249    /// When set, `spawn_named` will check resource pressure before creating a
250    /// new actor. `Critical` pressure returns an error; `Elevated` logs a
251    /// debug message but allows the spawn.
252    pub fn set_degradation_with_monitor(
253        &mut self,
254        monitor: Arc<dyn ResourceMonitor>,
255        elevated_batch_factor: f64,
256    ) {
257        self.degradation = Some((monitor, elevated_batch_factor));
258    }
259
260    /// Start the scheduler.
261    pub fn start(&self) -> Result<(), Error> {
262        if self.running.swap(true, Ordering::SeqCst) {
263            return Ok(());
264        }
265
266        let mut handles = self.worker_handles.lock();
267
268        for (id, stats) in self.worker_stats.iter().enumerate() {
269            let (worker, stealer) = create_local_queue();
270            self.stealer_registry.add_stealer(id, stealer);
271
272            let global_queue = self.global_queue.clone();
273            let priority_queue = self.priority_queue.clone();
274            let registry = self.registry.clone();
275            let config = self.config.clone();
276            let running_flag = self.running.clone();
277            let stats = stats.clone();
278            let stealer_registry = self.stealer_registry.clone();
279            let executor = self.executor.clone();
280
281            let handle = thread::Builder::new()
282                .name(format!("actor-kit-worker-{}", id))
283                .spawn(move || {
284                    Self::worker_loop(
285                        id,
286                        worker,
287                        global_queue,
288                        priority_queue,
289                        registry,
290                        config,
291                        running_flag,
292                        stats,
293                        stealer_registry,
294                        executor.as_ref(),
295                    );
296                });
297
298            match handle {
299                Ok(h) => handles.push(h),
300                Err(e) => {
301                    tracing::error!("Failed to spawn worker thread {}: {}", id, e);
302                    return Err(Error::internal(format!(
303                        "Failed to spawn worker thread {}: {}",
304                        id, e
305                    )));
306                }
307            }
308        }
309
310        Ok(())
311    }
312
313    /// Stop the scheduler.
314    pub fn stop(&self) {
315        self.running.store(false, Ordering::SeqCst);
316
317        let mut handles = self.worker_handles.lock();
318        for handle in handles.drain(..) {
319            let _ = handle.join();
320        }
321    }
322
323    /// Spawn a new actor.
324    pub fn spawn(&self) -> crate::Result<ActorId> {
325        self.spawn_named(None)
326    }
327
328    /// Spawn a new actor with a name.
329    pub fn spawn_named(&self, name: Option<String>) -> crate::Result<ActorId> {
330        self.check_degradation()?;
331
332        if let Some(ref policy) = self.resource_policy {
333            policy.admit_actor()?;
334        }
335        let id = ActorId::new();
336        self.registry.register_named(id, name)?;
337        self.total_actors.fetch_add(1, Ordering::Relaxed);
338        Ok(id)
339    }
340
341    /// Check the degradation controller before spawning.
342    ///
343    /// Returns `Err` on `Reject`; logs a warning on `Throttle` but allows the spawn.
344    fn check_degradation(&self) -> crate::Result<()> {
345        let (monitor, _) = match &self.degradation {
346            Some(d) => d,
347            None => return Ok(()),
348        };
349
350        match monitor.current_pressure() {
351            crate::supervisor::PressureLevel::Normal => Ok(()),
352            crate::supervisor::PressureLevel::Elevated => {
353                tracing::debug!("actor spawn throttled due to elevated resource pressure");
354                Ok(())
355            }
356            crate::supervisor::PressureLevel::Critical => {
357                tracing::warn!("actor spawn rejected due to critical resource pressure");
358                Err(Error::resource_exhausted(
359                    "system resources critically low; actor spawn rejected",
360                ))
361            }
362        }
363    }
364
365    /// Kill an actor.
366    pub fn kill(&self, id: &ActorId) -> crate::Result<()> {
367        self.registry.set_state(id, ActorState::Stopped)?;
368        if let Some(m) = self.registry.get_mailbox(id) {
369            m.clear()
370        }
371        self.registry.unregister(id)?;
372        if let Some(ref policy) = self.resource_policy {
373            policy.release_actor();
374        }
375        Ok(())
376    }
377
378    /// Set an actor to running state.
379    pub fn set_actor_running(&self, id: &ActorId) -> crate::Result<()> {
380        self.registry.set_state(id, ActorState::Running)
381    }
382
383    /// Set an actor state.
384    pub fn set_actor_state(&self, id: &ActorId, state: ActorState) -> crate::Result<()> {
385        self.registry.set_state(id, state)
386    }
387
388    /// Send a message to an actor.
389    pub async fn send(&self, target: ActorId, message: Message) -> crate::Result<()> {
390        if let Some(ref policy) = self.resource_policy {
391            policy.admit_message(1)?;
392        }
393
394        let mailbox = self
395            .registry
396            .get_mailbox(&target)
397            .ok_or_else(|| Error::actor(format!("actor {:?} not found", target)))?;
398
399        let state = self.registry.get_state(&target);
400        match state {
401            Some(ActorState::Stopped) | Some(ActorState::Failed) => {
402                return Err(Error::actor(format!("actor {:?} is not running", target)));
403            }
404            Some(ActorState::Suspended) => {
405                // Message queued but actor won't process until resumed
406            }
407            _ => {}
408        }
409
410        let priority = message.priority;
411
412        // Clone once for mailbox, move original into task.
413        // Previous implementation cloned twice (mailbox + task).
414        mailbox.send(message.clone()).await?;
415
416        let task = Task {
417            actor_id: target,
418            message, // moved, not cloned
419            priority,
420            additional_messages: Vec::new(),
421        };
422
423        if self.config.priority_scheduling && task.priority >= Priority::High {
424            self.priority_queue.push(task);
425        } else {
426            self.global_queue.push(task);
427        }
428
429        Ok(())
430    }
431
432    /// Send a batch of messages to an actor in a single queue operation.
433    ///
434    /// This is more efficient than calling `send` in a loop because it performs
435    /// one quota/rate-limit check and pushes a single `Task` to the work-stealing
436    /// queue for the entire batch.
437    pub async fn send_batch(&self, target: ActorId, messages: Vec<Message>) -> crate::Result<()> {
438        if messages.is_empty() {
439            return Ok(());
440        }
441
442        if let Some(ref policy) = self.resource_policy {
443            policy.admit_message(messages.len())?;
444        }
445
446        let mailbox = self
447            .registry
448            .get_mailbox(&target)
449            .ok_or_else(|| Error::actor(format!("actor {:?} not found", target)))?;
450
451        let state = self.registry.get_state(&target);
452        match state {
453            Some(ActorState::Stopped) | Some(ActorState::Failed) => {
454                return Err(Error::actor(format!("actor {:?} is not running", target)));
455            }
456            Some(ActorState::Suspended) => {}
457            _ => {}
458        }
459
460        let max_priority = messages
461            .iter()
462            .map(|m| m.priority)
463            .max()
464            .unwrap_or(Priority::Normal);
465
466        for msg in &messages {
467            mailbox.send(msg.clone()).await?;
468        }
469
470        let mut messages_into_iter = messages.into_iter();
471        let first = match messages_into_iter.next() {
472            Some(msg) => msg,
473            None => return Ok(()),
474        };
475        let additional: Vec<Message> = messages_into_iter.collect();
476
477        let task = Task {
478            actor_id: target,
479            message: first,
480            priority: max_priority,
481            additional_messages: additional,
482        };
483
484        if self.config.priority_scheduling && task.priority >= Priority::High {
485            self.priority_queue.push(task);
486        } else {
487            self.global_queue.push(task);
488        }
489
490        Ok(())
491    }
492
493    /// Try to send a message (non-blocking).
494    pub fn try_send(&self, target: ActorId, message: Message) -> crate::Result<()> {
495        let mailbox = self
496            .registry
497            .get_mailbox(&target)
498            .ok_or_else(|| Error::actor(format!("actor {:?} not found", target)))?;
499
500        let priority = message.priority;
501
502        // Clone once for mailbox, move original into task.
503        mailbox.try_send(message.clone()).map_err(|(_, e)| e)?;
504
505        let task = Task {
506            actor_id: target,
507            message, // moved, not cloned
508            priority,
509            additional_messages: Vec::new(),
510        };
511
512        if self.config.priority_scheduling && task.priority >= Priority::High {
513            self.priority_queue.push(task);
514        } else {
515            self.global_queue.push(task);
516        }
517
518        Ok(())
519    }
520
521    /// Get the actor registry.
522    pub fn registry(&self) -> &Arc<ActorRegistry> {
523        &self.registry
524    }
525
526    /// Get scheduler statistics.
527    pub fn stats(&self) -> SchedulerStats {
528        let mut worker_stats = Vec::new();
529        let mut total_processed = 0u64;
530        let mut total_stolen = 0u64;
531        let mut total_batches = 0u64;
532
533        for (id, stats) in self.worker_stats.iter().enumerate() {
534            let processed = stats.processed.load(Ordering::Relaxed);
535            let stolen = stats.stolen.load(Ordering::Relaxed);
536            let batches = stats.batches_processed.load(Ordering::Relaxed);
537
538            total_processed += processed;
539            total_stolen += stolen;
540            total_batches += batches;
541
542            worker_stats.push(WorkerStatsInfo {
543                id,
544                processed,
545                stolen,
546                batches_processed: batches,
547            });
548        }
549
550        SchedulerStats {
551            running: self.running.load(Ordering::Relaxed),
552            total_actors: self.total_actors.load(Ordering::Relaxed),
553            active_actors: self.registry.stats().running,
554            total_messages_processed: total_processed,
555            total_batches_processed: total_batches,
556            total_stolen,
557            worker_count: self.worker_stats.len(),
558            workers: worker_stats,
559        }
560    }
561
562    /// Get an RPC client for making typed RPC calls to actors.
563    #[cfg(feature = "serde")]
564    pub fn rpc_client(self: &Arc<Self>) -> RpcClient {
565        RpcClient::new(self.clone())
566    }
567
568    #[allow(clippy::too_many_arguments)]
569    fn worker_loop(
570        worker_id: usize,
571        worker: crossbeam_deque::Worker<Task>,
572        global_queue: Arc<WorkQueue>,
573        priority_queue: Arc<PriorityQueue>,
574        registry: Arc<ActorRegistry>,
575        config: SchedulerConfig,
576        running: Arc<AtomicBool>,
577        stats: Arc<WorkerStats>,
578        stealer_registry: StealerRegistry,
579        executor: Option<&Arc<dyn ActorExecutor>>,
580    ) {
581        let mut stealer = WorkStealer::new(Vec::new());
582        let mut last_version = 0u64;
583        let mut iteration = 0u32;
584
585        let mut consecutive_empty = 0u32;
586
587        while running.load(Ordering::Acquire) {
588            iteration = iteration.wrapping_add(1);
589
590            // `0` would panic on `% 0`: clamp to "refresh every iteration".
591            // (Pinned by `knob_stealer_refresh_interval_zero_does_not_kill_workers`.)
592            if iteration % config.stealer_refresh_interval.max(1) == 0 {
593                let current_version = stealer_registry.version();
594                if current_version != last_version {
595                    let stealers = stealer_registry.get_stealers(worker_id);
596                    stealer = WorkStealer::new(stealers);
597                    last_version = current_version;
598                }
599            }
600
601            // Try priority queue first
602            if let Some(task) = priority_queue.pop() {
603                Self::process_task_safe(&registry, task, &stats, executor);
604                consecutive_empty = 0;
605                continue;
606            }
607
608            // Try local queue
609            if let Some(task) = worker.pop() {
610                Self::process_task_safe(&registry, task, &stats, executor);
611                consecutive_empty = 0;
612                continue;
613            }
614
615            // Try global queue
616            if let Some(task) = global_queue.steal_global() {
617                Self::process_task_safe(&registry, task, &stats, executor);
618                consecutive_empty = 0;
619                continue;
620            }
621
622            // Try stealing from other workers
623            if let Some(task) = stealer.steal() {
624                stats.stolen.fetch_add(1, Ordering::Relaxed);
625                Self::process_task_safe(&registry, task, &stats, executor);
626                consecutive_empty = 0;
627                continue;
628            }
629
630            // Steal batch from other workers
631            let stolen = stealer.steal_batch(&worker, config.max_steal_batch);
632            if stolen > 0 {
633                stats.stolen.fetch_add(stolen as u64, Ordering::Relaxed);
634                continue;
635            }
636
637            // No work found, back off
638            consecutive_empty += 1;
639            if consecutive_empty > 100 {
640                std::thread::sleep(std::time::Duration::from_micros(config.idle_sleep_us));
641            } else if consecutive_empty > 10 {
642                std::hint::spin_loop();
643            }
644        }
645    }
646
647    /// Process a task with panic protection.
648    ///
649    /// Wraps `process_task` in `catch_unwind` so that a panic in the executor
650    /// or handler does not kill the worker thread. On panic, the actor is marked
651    /// as `Failed` and its mailbox is drained to prevent further processing of
652    /// messages by the panicked actor.
653    fn process_task_safe(
654        registry: &ActorRegistry,
655        task: Task,
656        stats: &WorkerStats,
657        executor: Option<&Arc<dyn ActorExecutor>>,
658    ) {
659        let actor_id = task.actor_id;
660        let batch_size = 1 + task.additional_messages.len();
661        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
662            Self::process_task(registry, task, stats, executor);
663        }));
664
665        if let Err(panic_payload) = result {
666            if batch_size > 1 {
667                stats.batches_processed.fetch_add(1, Ordering::Relaxed);
668            }
669            tracing::error!(
670                actor_id = ?actor_id,
671                batch_size,
672                "worker panicked while processing task; marking actor as Failed and draining mailbox"
673            );
674            let _ = registry.set_state(&actor_id, ActorState::Failed);
675            if let Some(mailbox) = registry.get_mailbox(&actor_id) {
676                mailbox.clear();
677            }
678            if let Some(s) = panic_payload.downcast_ref::<&str>() {
679                tracing::error!(panic_message = %s, "panic details");
680            } else if let Some(s) = panic_payload.downcast_ref::<String>() {
681                tracing::error!(panic_message = %s, "panic details");
682            }
683        }
684    }
685
686    /// Deterministic-simulation seam (feature `sim`).
687    ///
688    /// Runs one task through the *real* processing pipeline
689    /// ([`Self::process_task_safe`] → panic containment → state transitions →
690    /// mailbox slot release) against the real registry and mailboxes — without
691    /// worker threads. The sim owns task selection and calls this exactly once
692    /// per scheduling decision; this is the only place the simulation touches
693    /// production execution code.
694    ///
695    /// Returns the number of messages the runtime counted as processed.
696    #[cfg(feature = "sim")]
697    pub(crate) fn sim_process_task(
698        registry: &ActorRegistry,
699        task: Task,
700        executor: Option<&Arc<dyn ActorExecutor>>,
701    ) -> u64 {
702        let stats = WorkerStats::default();
703        Self::process_task_safe(registry, task, &stats, executor);
704        stats.processed.load(Ordering::Relaxed)
705    }
706
707    fn process_task(
708        registry: &ActorRegistry,
709        mut task: Task,
710        stats: &WorkerStats,
711        executor: Option<&Arc<dyn ActorExecutor>>,
712    ) {
713        if task.additional_messages.is_empty() {
714            // Fast path: single message
715            Self::process_single_message(registry, task.actor_id, &task.message, stats, executor);
716        } else {
717            // Batch path: process primary message, then additional ones
718            stats.batches_processed.fetch_add(1, Ordering::Relaxed);
719
720            Self::process_single_message(registry, task.actor_id, &task.message, stats, executor);
721
722            // Early exit if the actor failed during the first message
723            if matches!(
724                registry.get_state(&task.actor_id),
725                Some(ActorState::Failed) | Some(ActorState::Stopped)
726            ) {
727                return;
728            }
729
730            for message in task.additional_messages.drain(..) {
731                Self::process_single_message(registry, task.actor_id, &message, stats, executor);
732
733                // Stop processing remaining batch if actor is no longer viable
734                if matches!(
735                    registry.get_state(&task.actor_id),
736                    Some(ActorState::Failed) | Some(ActorState::Stopped)
737                ) {
738                    break;
739                }
740            }
741        }
742    }
743
744    fn process_single_message(
745        registry: &ActorRegistry,
746        actor_id: ActorId,
747        message: &Message,
748        stats: &WorkerStats,
749        executor: Option<&Arc<dyn ActorExecutor>>,
750    ) {
751        let actor_state = registry.get_state(&actor_id);
752
753        match actor_state {
754            Some(ActorState::Running) | Some(ActorState::Creating) => {
755                let should_count = if let Some(exec) = executor {
756                    match exec.execute(&actor_id, message) {
757                        ExecutionResult::Success { .. } => {
758                            if matches!(message.payload, MessagePayload::Start) {
759                                let _ = registry.set_state(&actor_id, ActorState::Running);
760                            } else if matches!(message.payload, MessagePayload::Stop) {
761                                let _ = registry.set_state(&actor_id, ActorState::Stopped);
762                            }
763                            true
764                        }
765                        ExecutionResult::FuelExhausted { .. } => {
766                            let _ = registry.set_state(&actor_id, ActorState::Failed);
767                            if let Some(mailbox) = registry.get_mailbox(&actor_id) {
768                                mailbox.clear();
769                            }
770                            false
771                        }
772                        ExecutionResult::Failed { error } => {
773                            tracing::warn!("Actor execution failed: {}", error);
774                            let _ = registry.set_state(&actor_id, ActorState::Failed);
775                            if let Some(mailbox) = registry.get_mailbox(&actor_id) {
776                                mailbox.clear();
777                            }
778                            false
779                        }
780                        ExecutionResult::NotReady => {
781                            Self::handle_state_change_for(actor_id, message, registry);
782                            true
783                        }
784                    }
785                } else {
786                    Self::handle_state_change_for(actor_id, message, registry);
787                    true
788                };
789
790                if should_count {
791                    stats.processed.fetch_add(1, Ordering::Relaxed);
792                    registry.record_processed(&actor_id);
793                    // The message is now consumed: release the mailbox slot
794                    // it parked in. `Mailbox::send`/`try_send` acquire one
795                    // capacity permit per message and `forget()` it so the
796                    // slot stays held until consumption; the worker processes
797                    // the `Task` copy from the work queue, so without this
798                    // pop the permit and slot leak forever. After `capacity`
799                    // cumulative messages the semaphore exhausted and every
800                    // further send stalled (the 0.1.0 drain-stall bug).
801                    if let Some(mailbox) = registry.get_mailbox(&actor_id) {
802                        mailbox.try_recv();
803                    }
804                }
805            }
806            Some(ActorState::Suspended) => {
807                // Control signals must still be *processed* while the actor
808                // is suspended, or the actor can never be resumed: the
809                // ordinary arm (which calls `handle_state_change_for`) is
810                // the only path that leaves `Suspended`, so re-queueing the
811                // Resume signal would make suspension permanent. Everything
812                // else stays in the mailbox, in order, behind the signal.
813                if matches!(
814                    message.payload,
815                    MessagePayload::Signal(_) | MessagePayload::Start | MessagePayload::Stop
816                ) {
817                    Self::handle_state_change_for(actor_id, message, registry);
818                    if let Some(mailbox) = registry.get_mailbox(&actor_id) {
819                        mailbox.try_recv();
820                    }
821                    // A control signal can end the suspension. When it does,
822                    // whatever was queued *behind* it has to be run now: the
823                    // tasks for those messages were consumed and dropped while
824                    // the actor was suspended (each `send` enqueues one task,
825                    // and the suspended arm below re-queues only the message),
826                    // so nothing else will ever wake this actor up. Without
827                    // this drain the last message sent before a resume is
828                    // stranded in the mailbox until an unrelated later send
829                    // happens to schedule the actor again.
830                    //
831                    // Draining inline rather than enqueueing a task needs no
832                    // access to the work queues, and this worker already owns
833                    // the only task there was for this actor.
834                    if matches!(
835                        registry.get_state(&actor_id),
836                        Some(ActorState::Running) | Some(ActorState::Creating)
837                    ) {
838                        while let Some(mailbox) = registry.get_mailbox(&actor_id) {
839                            if mailbox.is_empty() {
840                                break;
841                            }
842                            match mailbox.try_recv() {
843                                Some(queued) => Self::process_single_message(
844                                    registry, actor_id, &queued, stats, executor,
845                                ),
846                                None => break,
847                            }
848                        }
849                    }
850                } else if let Some(mailbox) = registry.get_mailbox(&actor_id) {
851                    // Suspended: re-queue (not receive), so the message keeps
852                    // its place and is not lost when the mailbox is full.
853                    let _ = mailbox.try_send(message.clone());
854                }
855            }
856            _ => {}
857        }
858    }
859
860    fn handle_state_change_for(actor_id: ActorId, message: &Message, registry: &ActorRegistry) {
861        if matches!(message.payload, MessagePayload::Start) {
862            let _ = registry.set_state(&actor_id, ActorState::Running);
863        } else if matches!(message.payload, MessagePayload::Stop) {
864            let _ = registry.set_state(&actor_id, ActorState::Stopped);
865        } else if let Some(MessagePayload::Signal(signal)) = Some(&message.payload) {
866            match signal {
867                crate::Signal::Pause => {
868                    let _ = registry.set_state(&actor_id, ActorState::Suspended);
869                }
870                crate::Signal::Resume => {
871                    let _ = registry.set_state(&actor_id, ActorState::Running);
872                }
873                crate::Signal::Restart => {
874                    let _ = registry.set_state(&actor_id, ActorState::Creating);
875                }
876            }
877        }
878    }
879}
880
881impl Drop for ActorScheduler {
882    fn drop(&mut self) {
883        self.stop();
884    }
885}
886
887/// Statistics for the scheduler.
888#[derive(Debug)]
889pub struct SchedulerStats {
890    /// Whether the scheduler is running
891    pub running: bool,
892    /// Total actors spawned
893    pub total_actors: u64,
894    /// Currently active actors
895    pub active_actors: usize,
896    /// Total messages processed
897    pub total_messages_processed: u64,
898    /// Total batch tasks processed
899    pub total_batches_processed: u64,
900    /// Total tasks stolen
901    pub total_stolen: u64,
902    /// Number of workers
903    pub worker_count: usize,
904    /// Per-worker statistics
905    pub workers: Vec<WorkerStatsInfo>,
906}
907
908/// Statistics for a worker.
909#[derive(Debug)]
910pub struct WorkerStatsInfo {
911    /// Worker ID
912    pub id: usize,
913    /// Tasks processed
914    pub processed: u64,
915    /// Tasks stolen from others
916    pub stolen: u64,
917    /// Batch tasks processed
918    pub batches_processed: u64,
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924    use crate::policy::{NoopPolicy, ResourcePolicy};
925
926    /// Test policy admitting at most `max_actors` live actors, mirroring the
927    /// upstream `QuotaEnforcer` max-actors behavior.
928    struct CountingPolicy {
929        max_actors: usize,
930        live: std::sync::atomic::AtomicUsize,
931    }
932
933    impl CountingPolicy {
934        fn new(max_actors: usize) -> Self {
935            Self {
936                max_actors,
937                live: std::sync::atomic::AtomicUsize::new(0),
938            }
939        }
940    }
941
942    impl ResourcePolicy for CountingPolicy {
943        fn admit_actor(&self) -> crate::Result<()> {
944            let prev = self.live.fetch_add(1, Ordering::SeqCst);
945            if prev >= self.max_actors {
946                self.live.fetch_sub(1, Ordering::SeqCst);
947                return Err(crate::Error::ResourceExhausted(format!(
948                    "actor limit exceeded ({}/{})",
949                    prev, self.max_actors
950                )));
951            }
952            Ok(())
953        }
954
955        fn release_actor(&self) {
956            self.live.fetch_sub(1, Ordering::SeqCst);
957        }
958    }
959
960    #[test]
961    fn test_scheduler_creation() {
962        let config = SchedulerConfig::new().workers(2);
963        let scheduler = ActorScheduler::new(config);
964
965        assert!(!scheduler.running.load(Ordering::Relaxed));
966        assert_eq!(scheduler.worker_stats.len(), 2);
967    }
968
969    #[tokio::test]
970    async fn test_scheduler_spawn_actor() {
971        let scheduler = ActorScheduler::new(SchedulerConfig::new().workers(1));
972        scheduler.start().unwrap();
973
974        let id = scheduler.spawn().unwrap();
975        assert!(scheduler.registry().get_state(&id).is_some());
976
977        scheduler.stop();
978    }
979
980    #[tokio::test]
981    async fn test_scheduler_send_message() {
982        let scheduler = ActorScheduler::new(SchedulerConfig::new().workers(1));
983        scheduler.start().unwrap();
984
985        let id = scheduler.spawn().unwrap();
986
987        scheduler.set_actor_running(&id).unwrap();
988
989        let msg = Message {
990            sender: None,
991            payload: MessagePayload::Custom(vec![1, 2, 3]),
992            priority: Priority::Normal,
993        };
994
995        scheduler.send(id, msg).await.unwrap();
996
997        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
998
999        scheduler.stop();
1000    }
1001
1002    #[test]
1003    fn test_config_effective_workers() {
1004        let config = SchedulerConfig::new().workers(0);
1005        assert!(config.effective_workers() > 0);
1006
1007        let config = SchedulerConfig::new().workers(8);
1008        assert_eq!(config.effective_workers(), 8);
1009    }
1010
1011    #[test]
1012    fn test_stealer_registry() {
1013        let registry = StealerRegistry::new();
1014        let (worker1, stealer1) = create_local_queue();
1015        let (worker2, stealer2) = create_local_queue();
1016
1017        registry.add_stealer(0, stealer1);
1018        registry.add_stealer(1, stealer2);
1019
1020        let stealers_for_0 = registry.get_stealers(0);
1021        assert_eq!(stealers_for_0.len(), 1);
1022
1023        let stealers_for_1 = registry.get_stealers(1);
1024        assert_eq!(stealers_for_1.len(), 1);
1025
1026        drop(worker1);
1027        drop(worker2);
1028    }
1029
1030    #[tokio::test]
1031    async fn test_scheduler_with_null_executor() {
1032        let executor = Arc::new(NullExecutor::new());
1033        let scheduler =
1034            ActorScheduler::with_executor(SchedulerConfig::new().workers(2), executor.clone());
1035        scheduler.start().unwrap();
1036
1037        let id = scheduler.spawn().unwrap();
1038        scheduler.set_actor_running(&id).unwrap();
1039
1040        let msg = Message {
1041            sender: None,
1042            payload: MessagePayload::Custom(vec![1, 2, 3]),
1043            priority: Priority::Normal,
1044        };
1045
1046        scheduler.send(id, msg).await.unwrap();
1047
1048        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
1049
1050        let stats = scheduler.stats();
1051        assert!(stats.total_messages_processed > 0 || stats.total_stolen > 0);
1052
1053        scheduler.stop();
1054    }
1055
1056    #[tokio::test]
1057    async fn test_work_stealing_between_workers() {
1058        let scheduler = ActorScheduler::new(SchedulerConfig::new().workers(4));
1059        scheduler.start().unwrap();
1060
1061        let mut actors = Vec::new();
1062        for _ in 0..10 {
1063            let id = scheduler.spawn().unwrap();
1064            scheduler.set_actor_running(&id).unwrap();
1065            actors.push(id);
1066        }
1067
1068        for actor_id in &actors {
1069            let msg = Message {
1070                sender: None,
1071                payload: MessagePayload::Custom(vec![1, 2, 3]),
1072                priority: Priority::Normal,
1073            };
1074            scheduler.send(*actor_id, msg).await.unwrap();
1075        }
1076
1077        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
1078
1079        let stats = scheduler.stats();
1080        assert!(stats.total_messages_processed >= 5);
1081        assert_eq!(stats.worker_count, 4);
1082
1083        scheduler.stop();
1084    }
1085
1086    #[tokio::test]
1087    async fn test_scheduler_resource_policy_rejects_over_limit() {
1088        let policy = Arc::new(CountingPolicy::new(1));
1089        let scheduler = ActorScheduler::with_resource_policy(
1090            SchedulerConfig::new().workers(1),
1091            Arc::new(NullExecutor::new()),
1092            policy,
1093        );
1094
1095        let first = scheduler.spawn().unwrap();
1096        assert!(scheduler.registry().get_state(&first).is_some());
1097
1098        let result = scheduler.spawn();
1099        assert!(result.is_err());
1100        let err_msg = result.unwrap_err().to_string();
1101        assert!(err_msg.contains("actor limit exceeded"), "got: {err_msg}");
1102
1103        // Releasing the killed actor frees a slot.
1104        scheduler.kill(&first).unwrap();
1105        let second = scheduler.spawn().unwrap();
1106        assert!(scheduler.registry().get_state(&second).is_some());
1107
1108        scheduler.stop();
1109    }
1110
1111    #[test]
1112    fn test_scheduler_noop_policy_admits_all() {
1113        let policy = NoopPolicy;
1114        assert!(policy.admit_actor().is_ok());
1115        assert!(policy.admit_message(100).is_ok());
1116        policy.release_actor();
1117    }
1118
1119    /// Regression: a suspended actor must be resumable and stoppable.
1120    ///
1121    /// The dispatch used to re-queue *every* message while an actor was
1122    /// `Suspended`, including the `Resume`/`Stop` signals — so the only arm
1123    /// that could lift the suspension never ran and `pause()` was a permanent
1124    /// deadlock reachable from two documented handle methods. Control signals
1125    /// are now processed in place; ordinary messages still queue.
1126    #[test]
1127    fn a_suspended_actor_accepts_control_signals_and_resumes() {
1128        let rt = crate::rt();
1129        rt.block_on(async {
1130            let scheduler =
1131                std::sync::Arc::new(ActorScheduler::new(SchedulerConfig::new().workers(2)));
1132            scheduler.start().expect("workers start");
1133            let handle = crate::ActorBuilder::new()
1134                .name("suspending")
1135                .spawn(&scheduler)
1136                .expect("actor spawns");
1137
1138            macro_rules! wait {
1139                ($cond:expr) => {{
1140                    let mut ok = false;
1141                    for _ in 0..200 {
1142                        if $cond {
1143                            ok = true;
1144                            break;
1145                        }
1146                        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1147                    }
1148                    ok
1149                }};
1150            }
1151
1152            handle.start().await.expect("start accepted");
1153            assert!(wait!(handle.is_running()), "actor runs");
1154
1155            handle.pause().await.expect("pause accepted");
1156            assert!(wait!(handle.is_suspended()), "actor suspends");
1157
1158            // A suspended actor still queues ordinary work rather than
1159            // dropping it. The baseline is taken *before* the send, since the
1160            // drain after the resume is what has to move this number.
1161            let processed_before_resume = handle.processed_count();
1162            handle
1163                .send(MessagePayload::Custom(vec![7]))
1164                .await
1165                .expect("a suspended actor accepts messages");
1166            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1167            assert!(handle.is_suspended(), "queued work does not resume it");
1168
1169            // ...and Resume is processed while suspended.
1170            handle.resume().await.expect("resume accepted");
1171            assert!(
1172                wait!(handle.is_running()),
1173                "a suspended actor must be resumable — it used to stay \
1174                 Suspended forever because Resume was re-queued"
1175            );
1176
1177            // ...and the work queued while suspended is actually run. Each
1178            // `send` enqueues one task, and the suspended arm consumed that
1179            // task while re-queueing only the message, so nothing was left to
1180            // wake the actor: the message sat in the mailbox indefinitely and
1181            // was only drained by some unrelated later send. `send` had
1182            // returned `Ok` the whole time.
1183            assert!(
1184                wait!(handle.processed_count() > processed_before_resume),
1185                "the message queued during the suspension must be processed \
1186                 after the resume, not stranded in the mailbox"
1187            );
1188
1189            // Stop works too, which it also did not.
1190            handle.stop().await.expect("stop accepted");
1191            assert!(wait!(handle.is_stopped()), "and stoppable");
1192
1193            scheduler.stop();
1194        });
1195    }
1196
1197    /// A message sent to a suspended actor is accepted, held, and then run once
1198    /// the actor resumes — including when the resume is the last event, with no
1199    /// further sends to nudge the scheduler.
1200    #[test]
1201    fn work_queued_during_suspension_runs_after_the_resume() {
1202        let rt = crate::rt();
1203        rt.block_on(async {
1204            let scheduler =
1205                std::sync::Arc::new(ActorScheduler::new(SchedulerConfig::new().workers(2)));
1206            scheduler.start().expect("workers start");
1207            let handle = crate::ActorBuilder::new()
1208                .name("stranded")
1209                .spawn(&scheduler)
1210                .expect("actor spawns");
1211
1212            macro_rules! wait {
1213                ($cond:expr) => {{
1214                    let mut ok = false;
1215                    for _ in 0..200 {
1216                        if $cond {
1217                            ok = true;
1218                            break;
1219                        }
1220                        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1221                    }
1222                    ok
1223                }};
1224            }
1225
1226            handle.start().await.expect("start accepted");
1227            assert!(wait!(handle.is_running()), "actor runs");
1228
1229            handle.pause().await.expect("pause accepted");
1230            assert!(wait!(handle.is_suspended()), "actor suspends");
1231
1232            let before = handle.processed_count();
1233            for payload in 0..3u8 {
1234                handle
1235                    .send(MessagePayload::Custom(vec![payload]))
1236                    .await
1237                    .expect("a suspended actor accepts messages");
1238            }
1239            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1240            assert!(
1241                handle.processed_count() == before,
1242                "nothing runs while suspended"
1243            );
1244
1245            handle.resume().await.expect("resume accepted");
1246            assert!(
1247                wait!(handle.processed_count() >= before + 3),
1248                "all three queued messages run after the resume: {} of 3",
1249                handle.processed_count() - before
1250            );
1251
1252            handle.stop().await.expect("stop accepted");
1253            assert!(wait!(handle.is_stopped()), "stoppable");
1254            scheduler.stop();
1255        });
1256    }
1257}