Files
horus/lib/runner/runner_trait.rs
2025-11-13 20:44:00 +01:00

273 lines
13 KiB
Rust

//! # 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<Duration>, // 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<String, Box<dyn std::error::Error + Send + Sync>>;
/// 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<Self>,
mut shutdown_rx: mpsc::Receiver<()>,
) -> JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
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<dyn std::error::Error + Send + Sync>);
}
};
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<W: Runner>(
runner: Arc<W>,
shutdown_rx: mpsc::Receiver<()>,
) -> JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
runner.spawn(shutdown_rx)
}