//! # Runner Trait Abstraction //! //! This module provides a trait-based abstraction for Rhai runners that eliminates //! code duplication between synchronous and asynchronous runner implementations. //! //! The `Runner` trait defines the common interface and behavior, while specific //! implementations handle job processing differently (sync vs async). //! //! ## Architecture //! //! ```text //! ┌─────────────────┐ ┌─────────────────┐ //! │ SyncRunner │ │ AsyncRunner │ //! │ │ │ │ //! │ process_job() │ │ process_job() │ //! │ (sequential) │ │ (concurrent) │ //! └─────────────────┘ └─────────────────┘ //! │ │ //! └───────┬───────────────┘ //! │ //! ┌───────▼───────┐ //! │ Runner Trait │ //! │ │ //! │ spawn() │ //! │ config │ //! │ common loop │ //! └───────────────┘ //! ``` use crate::{Job, JobStatus, Client}; use log::{debug, error, info}; use redis::AsyncCommands; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; use tokio::task::JoinHandle; use crate::{initialize_redis_connection, BLPOP_TIMEOUT_SECONDS}; /// Configuration for runner instances #[derive(Debug, Clone)] pub struct RunnerConfig { pub runner_id: String, pub db_path: String, pub redis_url: String, pub default_timeout: Option, // Only used by async runners } impl RunnerConfig { /// Create a new runner configuration pub fn new( runner_id: String, db_path: String, redis_url: String, ) -> Self { Self { runner_id, db_path, redis_url, default_timeout: None, } } /// Set default timeout for async runners pub fn with_default_timeout(mut self, timeout: Duration) -> Self { self.default_timeout = Some(timeout); self } } /// Trait defining the common interface for Rhai runners /// /// This trait abstracts the common functionality between synchronous and /// asynchronous runners, allowing them to share the same spawn logic and /// Redis polling loop while implementing different job processing strategies. pub trait Runner: Send + Sync + 'static { /// Process a single job /// /// This is the core method that differentiates runner implementations: /// - Sync runners process jobs sequentially, one at a time /// - Async runners spawn concurrent tasks for each job /// /// # Arguments /// /// * `job` - The job to process /// /// Note: The engine is now owned by the runner implementation as a field /// For sync runners, this should be a blocking operation /// For async runners, this can spawn tasks and return immediately fn process_job(&self, job: Job) -> Result>; /// Get the runner type name for logging fn runner_type(&self) -> &'static str; /// Get runner ID for this runner instance fn runner_id(&self) -> &str; /// Get Redis URL for this runner instance fn redis_url(&self) -> &str; /// Spawn the runner /// /// This method provides the common runner loop implementation that both /// sync and async runners can use. It handles: /// - Redis connection setup /// - Job polling from Redis queue /// - Shutdown signal handling /// - Delegating job processing to the implementation /// /// Note: The engine is now owned by the runner implementation as a field fn spawn( self: Arc, mut shutdown_rx: mpsc::Receiver<()>, ) -> JoinHandle>> { tokio::spawn(async move { let runner_id = self.runner_id(); let redis_url = self.redis_url(); // Create client to get the proper queue key let client = Client::builder() .redis_url(redis_url) .build() .await .map_err(|e| format!("Failed to create client: {}", e))?; let queue_key = client.runner_key(runner_id); info!( "{} Runner '{}' starting. Connecting to Redis at {}. Listening on queue: {}", self.runner_type(), runner_id, redis_url, queue_key ); let mut redis_conn = initialize_redis_connection(runner_id, redis_url).await?; loop { let blpop_keys = vec![queue_key.clone()]; tokio::select! { // Listen for shutdown signal _ = shutdown_rx.recv() => { info!("{} Runner '{}': Shutdown signal received. Terminating loop.", self.runner_type(), runner_id); break; } // Listen for tasks from Redis blpop_result = redis_conn.blpop(&blpop_keys, BLPOP_TIMEOUT_SECONDS as f64) => { debug!("{} Runner '{}': Attempting BLPOP on queue: {}", self.runner_type(), runner_id, queue_key); let response: Option<(String, String)> = match blpop_result { Ok(resp) => resp, Err(e) => { error!("{} Runner '{}': Redis BLPOP error on queue {}: {}. Runner for this circle might stop.", self.runner_type(), runner_id, queue_key, e); return Err(Box::new(e) as Box); } }; if let Some((_queue_name_recv, job_id)) = response { info!("{} Runner '{}' received job_id: {} from queue: {}", self.runner_type(), runner_id, job_id, _queue_name_recv); // Load the job from Redis match client.load_job_from_redis(&job_id).await { Ok(job) => { // Check for ping job and handle it directly if job.payload.trim() == "ping" { info!("{} Runner '{}': Received ping job '{}', responding with pong", self.runner_type(), runner_id, job_id); // Update job status to started if let Err(e) = client.set_job_status(&job_id, JobStatus::Started).await { error!("{} Runner '{}': Failed to update ping job '{}' status to Started: {}", self.runner_type(), runner_id, job_id, e); } // Set result to "pong" and mark as finished if let Err(e) = client.set_result(&job_id, "pong").await { error!("{} Runner '{}': Failed to set ping job '{}' result: {}", self.runner_type(), runner_id, job_id, e); } if let Err(e) = client.set_job_status(&job_id, JobStatus::Finished).await { error!("{} Runner '{}': Failed to update ping job '{}' status to Finished: {}", self.runner_type(), runner_id, job_id, e); } info!("{} Runner '{}': Successfully responded to ping job '{}' with pong", self.runner_type(), runner_id, job_id); } else { // Update job status to started if let Err(e) = client.set_job_status(&job_id, JobStatus::Started).await { error!("{} Runner '{}': Failed to update job '{}' status to Started: {}", self.runner_type(), runner_id, job_id, e); } // Delegate job processing to the implementation match self.process_job(job) { Ok(result) => { // Set result and mark as finished if let Err(e) = client.set_result(&job_id, &result).await { error!("{} Runner '{}': Failed to set job '{}' result: {}", self.runner_type(), runner_id, job_id, e); } if let Err(e) = client.set_job_status(&job_id, JobStatus::Finished).await { error!("{} Runner '{}': Failed to update job '{}' status to Finished: {}", self.runner_type(), runner_id, job_id, e); } } Err(e) => { let error_str = format!("{:?}", e); error!("{} Runner '{}': Job '{}' processing failed: {}", self.runner_type(), runner_id, job_id, error_str); // Set error and mark as error if let Err(e) = client.set_error(&job_id, &error_str).await { error!("{} Runner '{}': Failed to set job '{}' error: {}", self.runner_type(), runner_id, job_id, e); } if let Err(e) = client.set_job_status(&job_id, JobStatus::Error).await { error!("{} Runner '{}': Failed to update job '{}' status to Error: {}", self.runner_type(), runner_id, job_id, e); } } } } } Err(e) => { error!("{} Runner '{}': Failed to load job '{}': {}", self.runner_type(), runner_id, job_id, e); } } } else { debug!("{} Runner '{}': BLPOP timed out on queue {}. No new tasks.", self.runner_type(), runner_id, queue_key); } } } } info!("{} Runner '{}' has shut down.", self.runner_type(), runner_id); Ok(()) }) } } /// Convenience function to spawn a runner with the trait-based interface /// /// This function provides a unified interface for spawning any runner implementation /// that implements the Runner trait. /// /// # Arguments /// /// * `runner` - The runner implementation to spawn /// * `shutdown_rx` - Channel receiver for shutdown signals /// /// # Returns /// /// Returns a `JoinHandle` that can be awaited to wait for runner shutdown. pub fn spawn_runner( runner: Arc, shutdown_rx: mpsc::Receiver<()>, ) -> JoinHandle>> { runner.spawn(shutdown_rx) }