initial commit
This commit is contained in:
1
core/job/.gitignore
vendored
Normal file
1
core/job/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
14
core/job/Cargo.toml
Normal file
14
core/job/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "hero_job"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
redis = { version = "0.25", features = ["tokio-comp"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
log = "0.4"
|
||||
thiserror = "1.0"
|
381
core/job/src/lib.rs
Normal file
381
core/job/src/lib.rs
Normal file
@@ -0,0 +1,381 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
use redis::AsyncCommands;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Redis namespace prefix for all Hero job-related keys
|
||||
pub const NAMESPACE_PREFIX: &str = "hero:job:";
|
||||
|
||||
/// Script type enumeration for different script engines
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ScriptType {
|
||||
/// HeroScript - Hero's native scripting language
|
||||
HeroScript,
|
||||
/// Rhai SAL - Rhai Script Abstraction Layer
|
||||
RhaiSAL,
|
||||
/// Rhai DSL - Rhai Domain Specific Language
|
||||
RhaiDSL,
|
||||
}
|
||||
|
||||
impl ScriptType {
|
||||
/// Get the worker queue suffix for this script type
|
||||
pub fn worker_queue_suffix(&self) -> &'static str {
|
||||
match self {
|
||||
ScriptType::HeroScript => "heroscript",
|
||||
ScriptType::RhaiSAL => "rhai_sal",
|
||||
ScriptType::RhaiDSL => "rhai_dsl",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ScriptType::HeroScript => "heroscript",
|
||||
ScriptType::RhaiSAL => "rhai_sal",
|
||||
ScriptType::RhaiDSL => "rhai_dsl",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Job status enumeration
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JobStatus {
|
||||
Dispatched,
|
||||
WaitingForPrerequisites,
|
||||
Started,
|
||||
Error,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
JobStatus::Dispatched => "dispatched",
|
||||
JobStatus::WaitingForPrerequisites => "waiting_for_prerequisites",
|
||||
JobStatus::Started => "started",
|
||||
JobStatus::Error => "error",
|
||||
JobStatus::Finished => "finished",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"dispatched" => Some(JobStatus::Dispatched),
|
||||
"waiting_for_prerequisites" => Some(JobStatus::WaitingForPrerequisites),
|
||||
"started" => Some(JobStatus::Started),
|
||||
"error" => Some(JobStatus::Error),
|
||||
"finished" => Some(JobStatus::Finished),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Representation of a script execution request.
|
||||
///
|
||||
/// This structure contains all the information needed to execute a script
|
||||
/// on a worker service, including the script content, dependencies, and metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Job {
|
||||
pub id: String,
|
||||
pub caller_id: String,
|
||||
pub context_id: String,
|
||||
pub script: String,
|
||||
pub script_type: ScriptType,
|
||||
pub timeout: Duration,
|
||||
pub retries: u8, // retries on script execution
|
||||
pub concurrent: bool, // whether to execute script in separate thread
|
||||
pub log_path: Option<String>, // path to write logs of script execution to
|
||||
pub env_vars: HashMap<String, String>, // environment variables for script execution
|
||||
pub prerequisites: Vec<String>, // job IDs that must complete before this job can run
|
||||
pub dependents: Vec<String>, // job IDs that depend on this job completing
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Error types for job operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum JobError {
|
||||
#[error("Redis error: {0}")]
|
||||
RedisError(#[from] redis::RedisError),
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
#[error("Job not found: {0}")]
|
||||
JobNotFound(String),
|
||||
#[error("Invalid job data: {0}")]
|
||||
InvalidJobData(String),
|
||||
#[error("Missing required field: {0}")]
|
||||
MissingField(String),
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// Create a new job with the given parameters
|
||||
pub fn new(
|
||||
caller_id: String,
|
||||
context_id: String,
|
||||
script: String,
|
||||
script_type: ScriptType,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
caller_id,
|
||||
context_id,
|
||||
script,
|
||||
script_type,
|
||||
timeout: Duration::from_secs(30),
|
||||
retries: 0,
|
||||
concurrent: false,
|
||||
log_path: None,
|
||||
env_vars: HashMap::new(),
|
||||
prerequisites: Vec::new(),
|
||||
dependents: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store this job in Redis
|
||||
pub async fn store_in_redis(&self, conn: &mut redis::aio::MultiplexedConnection) -> Result<(), JobError> {
|
||||
let job_key = format!("{}{}", NAMESPACE_PREFIX, self.id);
|
||||
|
||||
let mut hset_args: Vec<(String, String)> = vec![
|
||||
("jobId".to_string(), self.id.clone()),
|
||||
("script".to_string(), self.script.clone()),
|
||||
("script_type".to_string(), format!("{:?}", self.script_type)),
|
||||
("callerId".to_string(), self.caller_id.clone()),
|
||||
("contextId".to_string(), self.context_id.clone()),
|
||||
("status".to_string(), "pending".to_string()),
|
||||
("timeout".to_string(), self.timeout.as_secs().to_string()),
|
||||
("retries".to_string(), self.retries.to_string()),
|
||||
("concurrent".to_string(), self.concurrent.to_string()),
|
||||
("createdAt".to_string(), self.created_at.to_rfc3339()),
|
||||
("updatedAt".to_string(), self.updated_at.to_rfc3339()),
|
||||
];
|
||||
|
||||
// Add optional log path
|
||||
if let Some(log_path) = &self.log_path {
|
||||
hset_args.push(("log_path".to_string(), log_path.clone()));
|
||||
}
|
||||
|
||||
// Add environment variables as JSON string if any are provided
|
||||
if !self.env_vars.is_empty() {
|
||||
let env_vars_json = serde_json::to_string(&self.env_vars)?;
|
||||
hset_args.push(("env_vars".to_string(), env_vars_json));
|
||||
}
|
||||
|
||||
// Add prerequisites as JSON string if any are provided
|
||||
if !self.prerequisites.is_empty() {
|
||||
let prerequisites_json = serde_json::to_string(&self.prerequisites)?;
|
||||
hset_args.push(("prerequisites".to_string(), prerequisites_json));
|
||||
}
|
||||
|
||||
// Add dependents as JSON string if any are provided
|
||||
if !self.dependents.is_empty() {
|
||||
let dependents_json = serde_json::to_string(&self.dependents)?;
|
||||
hset_args.push(("dependents".to_string(), dependents_json));
|
||||
}
|
||||
|
||||
conn.hset_multiple::<_, _, _, ()>(&job_key, &hset_args).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a job from Redis by ID
|
||||
pub async fn load_from_redis(
|
||||
conn: &mut redis::aio::MultiplexedConnection,
|
||||
job_id: &str,
|
||||
) -> Result<Self, JobError> {
|
||||
let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
|
||||
let job_data: HashMap<String, String> = conn.hgetall(&job_key).await?;
|
||||
|
||||
if job_data.is_empty() {
|
||||
return Err(JobError::JobNotFound(job_id.to_string()));
|
||||
}
|
||||
|
||||
// Parse required fields
|
||||
let id = job_data.get("jobId")
|
||||
.ok_or_else(|| JobError::MissingField("jobId".to_string()))?
|
||||
.clone();
|
||||
|
||||
let script = job_data.get("script")
|
||||
.ok_or_else(|| JobError::MissingField("script".to_string()))?
|
||||
.clone();
|
||||
|
||||
let script_type_str = job_data.get("script_type")
|
||||
.ok_or_else(|| JobError::MissingField("script_type".to_string()))?;
|
||||
|
||||
let script_type = match script_type_str.as_str() {
|
||||
"HeroScript" => ScriptType::HeroScript,
|
||||
"RhaiSAL" => ScriptType::RhaiSAL,
|
||||
"RhaiDSL" => ScriptType::RhaiDSL,
|
||||
_ => return Err(JobError::InvalidJobData(format!("Unknown script type: {}", script_type_str))),
|
||||
};
|
||||
|
||||
let caller_id = job_data.get("callerId")
|
||||
.ok_or_else(|| JobError::MissingField("callerId".to_string()))?
|
||||
.clone();
|
||||
|
||||
let context_id = job_data.get("contextId")
|
||||
.ok_or_else(|| JobError::MissingField("contextId".to_string()))?
|
||||
.clone();
|
||||
|
||||
let timeout_secs: u64 = job_data.get("timeout")
|
||||
.ok_or_else(|| JobError::MissingField("timeout".to_string()))?
|
||||
.parse()
|
||||
.map_err(|_| JobError::InvalidJobData("Invalid timeout value".to_string()))?;
|
||||
|
||||
let retries: u8 = job_data.get("retries")
|
||||
.unwrap_or(&"0".to_string())
|
||||
.parse()
|
||||
.map_err(|_| JobError::InvalidJobData("Invalid retries value".to_string()))?;
|
||||
|
||||
let concurrent: bool = job_data.get("concurrent")
|
||||
.unwrap_or(&"false".to_string())
|
||||
.parse()
|
||||
.map_err(|_| JobError::InvalidJobData("Invalid concurrent value".to_string()))?;
|
||||
|
||||
let created_at = job_data.get("createdAt")
|
||||
.ok_or_else(|| JobError::MissingField("createdAt".to_string()))?
|
||||
.parse()
|
||||
.map_err(|_| JobError::InvalidJobData("Invalid createdAt timestamp".to_string()))?;
|
||||
|
||||
let updated_at = job_data.get("updatedAt")
|
||||
.ok_or_else(|| JobError::MissingField("updatedAt".to_string()))?
|
||||
.parse()
|
||||
.map_err(|_| JobError::InvalidJobData("Invalid updatedAt timestamp".to_string()))?;
|
||||
|
||||
// Parse optional fields
|
||||
let log_path = job_data.get("log_path").cloned();
|
||||
|
||||
let env_vars = if let Some(env_vars_json) = job_data.get("env_vars") {
|
||||
serde_json::from_str(env_vars_json)?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let prerequisites = if let Some(prerequisites_json) = job_data.get("prerequisites") {
|
||||
serde_json::from_str(prerequisites_json)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let dependents = if let Some(dependents_json) = job_data.get("dependents") {
|
||||
serde_json::from_str(dependents_json)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
caller_id,
|
||||
context_id,
|
||||
script,
|
||||
script_type,
|
||||
timeout: Duration::from_secs(timeout_secs),
|
||||
retries,
|
||||
concurrent,
|
||||
log_path,
|
||||
env_vars,
|
||||
prerequisites,
|
||||
dependents,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update job status in Redis
|
||||
pub async fn update_status(
|
||||
conn: &mut redis::aio::MultiplexedConnection,
|
||||
job_id: &str,
|
||||
status: JobStatus,
|
||||
) -> Result<(), JobError> {
|
||||
let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
|
||||
let now = Utc::now();
|
||||
|
||||
conn.hset::<_, _, _, ()>(&job_key, "status", status.as_str()).await?;
|
||||
conn.hset::<_, _, _, ()>(&job_key, "updatedAt", now.to_rfc3339()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get job status from Redis
|
||||
pub async fn get_status(
|
||||
conn: &mut redis::aio::MultiplexedConnection,
|
||||
job_id: &str,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
|
||||
let status_str: String = conn.hget(&job_key, "status").await?;
|
||||
|
||||
JobStatus::from_str(&status_str)
|
||||
.ok_or_else(|| JobError::InvalidJobData(format!("Unknown status: {}", status_str)))
|
||||
}
|
||||
|
||||
/// Set job result in Redis
|
||||
pub async fn set_result(
|
||||
conn: &mut redis::aio::MultiplexedConnection,
|
||||
job_id: &str,
|
||||
result: &str,
|
||||
) -> Result<(), JobError> {
|
||||
let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
|
||||
let now = Utc::now();
|
||||
|
||||
conn.hset::<_, _, _, ()>(&job_key, "output", result).await?;
|
||||
conn.hset::<_, _, _, ()>(&job_key, "status", JobStatus::Finished.as_str()).await?;
|
||||
conn.hset::<_, _, _, ()>(&job_key, "updatedAt", now.to_rfc3339()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set job error in Redis
|
||||
pub async fn set_error(
|
||||
conn: &mut redis::aio::MultiplexedConnection,
|
||||
job_id: &str,
|
||||
error: &str,
|
||||
) -> Result<(), JobError> {
|
||||
let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
|
||||
let now = Utc::now();
|
||||
|
||||
conn.hset::<_, _, _, ()>(&job_key, "error", error).await?;
|
||||
conn.hset::<_, _, _, ()>(&job_key, "status", JobStatus::Error.as_str()).await?;
|
||||
conn.hset::<_, _, _, ()>(&job_key, "updatedAt", now.to_rfc3339()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete job from Redis
|
||||
pub async fn delete_from_redis(
|
||||
conn: &mut redis::aio::MultiplexedConnection,
|
||||
job_id: &str,
|
||||
) -> Result<(), JobError> {
|
||||
let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
|
||||
conn.del::<_, ()>(&job_key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all job IDs from Redis
|
||||
pub async fn list_all_job_ids(
|
||||
conn: &mut redis::aio::MultiplexedConnection,
|
||||
) -> Result<Vec<String>, JobError> {
|
||||
// Search specifically for job keys with the exact job pattern
|
||||
let job_keys: Vec<String> = conn.keys(format!("{}*", NAMESPACE_PREFIX)).await?;
|
||||
let job_ids: Vec<String> = job_keys
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
// Only include keys that exactly match the job key pattern hero:job:*
|
||||
if key.starts_with(NAMESPACE_PREFIX) {
|
||||
let potential_id = key.strip_prefix(NAMESPACE_PREFIX)?;
|
||||
// Validate that this looks like a UUID (job IDs are UUIDs)
|
||||
if potential_id.len() == 36 && potential_id.chars().filter(|&c| c == '-').count() == 4 {
|
||||
Some(potential_id.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(job_ids)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user