This commit is contained in:
Timur Gordon
2025-08-20 11:26:55 +02:00
60 changed files with 7065 additions and 356 deletions

View File

@@ -1,4 +1,34 @@
use hero_job::Job;
//! # Actor Trait Abstraction
//!
//! This module provides a trait-based abstraction for Rhai actors that eliminates
//! code duplication between synchronous and asynchronous actor implementations.
//!
//! The `Actor` trait defines the common interface and behavior, while specific
//! implementations handle job processing differently (sync vs async).
//!
//! ## Architecture
//!
//! ```text
//! ┌─────────────────┐ ┌─────────────────┐
//! │ SyncActor │ │ AsyncActor │
//! │ │ │ │
//! │ process_job() │ │ process_job() │
//! │ (sequential) │ │ (concurrent) │
//! └─────────────────┘ └─────────────────┘
//! │ │
//! └───────┬───────────────┘
//! │
//! ┌───────▼───────┐
//! │ Actor Trait │
//! │ │
//! │ spawn() │
//! │ config │
//! │ common loop │
//! └───────────────┘
//! ```
use hero_job::{Job, ScriptType};
use hero_job::keys;
use log::{debug, error, info};
use redis::AsyncCommands;
@@ -7,7 +37,7 @@ use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::{initialize_redis_connection, NAMESPACE_PREFIX, BLPOP_TIMEOUT_SECONDS};
use crate::{initialize_redis_connection, BLPOP_TIMEOUT_SECONDS};
/// Configuration for actor instances
#[derive(Debug, Clone)]
@@ -91,11 +121,14 @@ pub trait Actor: Send + Sync + 'static {
tokio::spawn(async move {
let actor_id = self.actor_id();
let redis_url = self.redis_url();
let queue_key = format!("{}{}", NAMESPACE_PREFIX, actor_id);
// Canonical work queue based on script type (instance/group selection can be added later)
let script_type = derive_script_type_from_actor_id(actor_id);
let queue_key = keys::work_type(&script_type);
info!(
"{} Actor '{}' starting. Connecting to Redis at {}. Listening on queue: {}",
"{} Actor '{}' starting. Type {:?}. Connecting to Redis at {}. Listening on queue: {}",
self.actor_type(),
actor_id,
script_type,
redis_url,
queue_key
);
@@ -222,78 +255,18 @@ pub fn spawn_actor<W: Actor>(
actor.spawn(shutdown_rx)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::create_heromodels_engine;
// Mock actor for testing
struct MockActor;
#[async_trait::async_trait]
impl Actor for MockActor {
async fn process_job(
&self,
_job: Job,
_redis_conn: &mut redis::aio::MultiplexedConnection,
) {
// Mock implementation - do nothing
// Engine would be owned by the actor implementation as a field
}
fn actor_type(&self) -> &'static str {
"Mock"
}
fn actor_id(&self) -> &str {
"mock_actor"
}
fn redis_url(&self) -> &str {
"redis://localhost:6379"
}
fn derive_script_type_from_actor_id(actor_id: &str) -> ScriptType {
let lower = actor_id.to_lowercase();
if lower.contains("sal") {
ScriptType::SAL
} else if lower.contains("osis") {
ScriptType::OSIS
} else if lower.contains("python") {
ScriptType::Python
} else if lower.contains("v") {
ScriptType::V
} else {
// Default to OSIS when uncertain
ScriptType::OSIS
}
#[tokio::test]
async fn test_actor_config_creation() {
let config = ActorConfig::new(
"test_actor".to_string(),
"/tmp".to_string(),
"redis://localhost:6379".to_string(),
false,
);
assert_eq!(config.actor_id, "test_actor");
assert_eq!(config.db_path, "/tmp");
assert_eq!(config.redis_url, "redis://localhost:6379");
assert!(!config.preserve_tasks);
assert!(config.default_timeout.is_none());
}
#[tokio::test]
async fn test_actor_config_with_timeout() {
let timeout = Duration::from_secs(300);
let config = ActorConfig::new(
"test_actor".to_string(),
"/tmp".to_string(),
"redis://localhost:6379".to_string(),
false,
).with_default_timeout(timeout);
assert_eq!(config.default_timeout, Some(timeout));
}
#[tokio::test]
async fn test_spawn_actor_function() {
let (_shutdown_tx, shutdown_rx) = mpsc::channel(1);
let actor = Arc::new(MockActor);
let handle = spawn_actor(actor, shutdown_rx);
// The actor should be created successfully
assert!(!handle.is_finished());
// Abort the actor for cleanup
handle.abort();
}
}
}

View File

@@ -1,4 +1,5 @@
use hero_job::{Job, JobStatus};
use hero_job::{Job, JobStatus, ScriptType};
use hero_job::keys;
use log::{debug, error, info};
use redis::AsyncCommands;
use rhai::{Dynamic, Engine};
@@ -217,10 +218,11 @@ pub fn spawn_rhai_actor(
preserve_tasks: bool,
) -> JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
tokio::spawn(async move {
let queue_key = format!("{}{}", NAMESPACE_PREFIX, actor_id);
let script_type = derive_script_type_from_actor_id(&actor_id);
let queue_key = keys::work_type(&script_type);
info!(
"Rhai Actor for Actor ID '{}' starting. Connecting to Redis at {}. Listening on queue: {}. Waiting for tasks or shutdown signal.",
actor_id, redis_url, queue_key
"Rhai Actor '{}' starting. Type {:?}. Connecting to Redis at {}. Listening on queue: {}. Waiting for tasks or shutdown signal.",
actor_id, script_type, redis_url, queue_key
);
let mut redis_conn = initialize_redis_connection(&actor_id, &redis_url).await?;
@@ -259,6 +261,23 @@ pub fn spawn_rhai_actor(
})
}
// Helper to derive script type from actor_id for canonical queue selection
fn derive_script_type_from_actor_id(actor_id: &str) -> ScriptType {
let lower = actor_id.to_lowercase();
if lower.contains("sal") {
ScriptType::SAL
} else if lower.contains("osis") {
ScriptType::OSIS
} else if lower.contains("python") {
ScriptType::Python
} else if lower == "v" || lower.contains(":v") || lower.contains(" v") {
ScriptType::V
} else {
// Default to OSIS when uncertain
ScriptType::OSIS
}
}
// Re-export the main trait-based interface for convenience
pub use actor_trait::{Actor, ActorConfig, spawn_actor};

View File

@@ -10,6 +10,7 @@ use crossterm::{
execute,
};
use hero_job::{Job, JobStatus, ScriptType};
use hero_job::keys;
use ratatui::{
backend::{Backend, CrosstermBackend},
@@ -457,9 +458,9 @@ impl App {
let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
job.store_in_redis(&mut conn).await?;
// Add to work queue
let queue_name = format!("hero:job:actor_queue:{}", self.actor_id.to_lowercase());
let _: () = conn.lpush(&queue_name, &job_id).await?;
// Add to work queue (canonical type queue)
let queue_name = keys::work_type(&self.job_form.script_type);
let _: () = conn.lpush(&queue_name, &job.id).await?;
self.status_message = Some(format!("Job {} dispatched successfully", job_id));