use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Semaphore;
use super::slot_token::SlotToken;
use super::task_handle::{with_task_handle, TaskHandle};
use super::types::{ClaimedTask, ExecutorConfig};
use crate::dal::DAL;
use crate::database::universal_types::UniversalUuid;
use crate::dispatcher::{
DispatchError, ExecutionResult, ExecutorMetrics, TaskExecutor, TaskReadyEvent,
};
use crate::error::ExecutorError;
use crate::Runtime;
use crate::{parse_namespace, Context, Database, Task, TaskRegistry};
use async_trait::async_trait;
#[cfg(test)]
fn failure_reason(err: &ExecutorError) -> &'static str {
match err {
ExecutorError::TaskTimeout => "timeout",
ExecutorError::TaskExecution(_) => "task_error",
ExecutorError::Validation(_) => "validation_failed",
ExecutorError::ClaimLost => "claim_lost",
ExecutorError::Database(_)
| ExecutorError::ConnectionPool(_)
| ExecutorError::Context(_) => "infrastructure",
ExecutorError::ContextLoadFailed(_) => "context_load_failed",
ExecutorError::TaskNotFound(_) | ExecutorError::WorkflowExecutionNotFound(_) => {
"task_not_found"
}
ExecutorError::Serialization(_)
| ExecutorError::InvalidScope(_)
| ExecutorError::Semaphore(_) => "unknown",
}
}
pub struct ThreadTaskExecutor {
database: Database,
dal: DAL,
task_registry: Arc<TaskRegistry>,
runtime: Arc<Runtime>,
instance_id: UniversalUuid,
config: ExecutorConfig,
semaphore: Arc<Semaphore>,
total_executed: Arc<AtomicU64>,
total_failed: Arc<AtomicU64>,
result_handler: crate::executor::TaskResultHandler,
}
impl ThreadTaskExecutor {
pub fn new(
database: Database,
task_registry: Arc<TaskRegistry>,
config: ExecutorConfig,
) -> Self {
Self::with_runtime_and_registry(database, task_registry, Arc::new(Runtime::new()), config)
}
pub fn with_runtime_and_registry(
database: Database,
task_registry: Arc<TaskRegistry>,
runtime: Arc<Runtime>,
config: ExecutorConfig,
) -> Self {
let dal = DAL::new(database.clone());
let max_concurrent = config.max_concurrent_tasks;
let instance_id = UniversalUuid::new_v4();
let total_executed = Arc::new(AtomicU64::new(0));
let total_failed = Arc::new(AtomicU64::new(0));
let runner_id = if config.enable_claiming {
Some(instance_id)
} else {
None
};
let result_handler = crate::executor::TaskResultHandler::new(
dal.clone(),
total_executed.clone(),
total_failed.clone(),
runner_id,
);
Self {
database,
dal,
task_registry,
runtime,
instance_id,
config,
semaphore: Arc::new(Semaphore::new(max_concurrent)),
total_executed,
total_failed,
result_handler,
}
}
pub fn with_runtime(mut self, runtime: Arc<Runtime>) -> Self {
self.runtime = runtime;
self
}
pub fn semaphore(&self) -> &Arc<Semaphore> {
&self.semaphore
}
async fn build_task_context(
&self,
claimed_task: &ClaimedTask,
dependencies: &[crate::task::TaskNamespace],
) -> Result<Context<serde_json::Value>, ExecutorError> {
crate::executor::TaskContextBuilder::new(self.dal.clone())
.build(claimed_task, dependencies)
.await
}
#[cfg(test)]
fn merge_context_values(
existing: &serde_json::Value,
new: &serde_json::Value,
) -> serde_json::Value {
crate::executor::TaskContextBuilder::merge_context_values(existing, new)
}
async fn execute_with_timeout(
&self,
task: &dyn Task,
context: Context<serde_json::Value>,
) -> Result<Context<serde_json::Value>, ExecutorError> {
match tokio::time::timeout(self.config.task_timeout, task.execute(context)).await {
Ok(result) => result.map_err(ExecutorError::TaskExecution),
Err(_) => Err(ExecutorError::TaskTimeout),
}
}
async fn execute_with_cancellation(
&self,
task: &dyn Task,
context: Context<serde_json::Value>,
mut cancel_rx: tokio::sync::watch::Receiver<bool>,
) -> Result<Context<serde_json::Value>, ExecutorError> {
let wait_cancelled = async { cancel_rx.wait_for(|&v| v).await.is_ok() };
tokio::select! {
biased;
r = self.execute_with_timeout(task, context) => r,
fired = wait_cancelled => {
if fired {
Err(ExecutorError::ClaimLost)
} else {
std::future::pending().await
}
}
}
}
}
impl Clone for ThreadTaskExecutor {
fn clone(&self) -> Self {
Self {
database: self.database.clone(),
dal: self.dal.clone(),
task_registry: Arc::clone(&self.task_registry),
runtime: Arc::clone(&self.runtime),
instance_id: self.instance_id,
config: self.config.clone(),
semaphore: Arc::clone(&self.semaphore),
total_executed: Arc::clone(&self.total_executed),
total_failed: Arc::clone(&self.total_failed),
result_handler: self.result_handler.clone(),
}
}
}
#[async_trait]
impl TaskExecutor for ThreadTaskExecutor {
async fn execute(&self, event: TaskReadyEvent) -> Result<ExecutionResult, DispatchError> {
let start = Instant::now();
if self.config.enable_claiming {
use crate::dal::unified::task_execution::RunnerClaimResult;
let claim_result = self
.dal
.task_execution()
.claim_for_runner(event.task_execution_id, self.instance_id)
.await;
match claim_result {
Ok(RunnerClaimResult::Claimed) => {
metrics::counter!(
"cloacina_scheduler_claim_attempts_total",
"outcome" => "claimed",
)
.increment(1);
tracing::debug!(
task_id = %event.task_execution_id,
runner_id = %self.instance_id,
"Task claimed for execution"
);
}
Ok(RunnerClaimResult::AlreadyClaimed) => {
metrics::counter!(
"cloacina_scheduler_claim_attempts_total",
"outcome" => "contended",
)
.increment(1);
tracing::debug!(
task_id = %event.task_execution_id,
"Task already claimed by another runner — skipping"
);
return Ok(ExecutionResult::skipped(event.task_execution_id));
}
Err(e) => {
tracing::warn!(
task_id = %event.task_execution_id,
error = %e,
"Failed to claim task — proceeding without claim"
);
}
}
}
if let Err(e) = self
.dal
.workflow_execution()
.update_status(event.workflow_execution_id, "Running")
.await
{
tracing::warn!(
workflow_id = %event.workflow_execution_id,
error = %e,
"Failed to mark workflow execution Running"
);
}
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let heartbeat_handle = if self.config.enable_claiming {
let dal = self.dal.clone();
let task_id = event.task_execution_id;
let runner_id = self.instance_id;
let interval = self.config.heartbeat_interval;
let cancel_tx = cancel_tx.clone();
Some(tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
match dal.task_execution().heartbeat(task_id, runner_id).await {
Ok(crate::dal::unified::task_execution::HeartbeatResult::Ok) => {
metrics::counter!("cloacina_scheduler_heartbeat_writes_total")
.increment(1);
tracing::trace!(task_id = %task_id, "Heartbeat sent");
}
Ok(crate::dal::unified::task_execution::HeartbeatResult::ClaimLost) => {
tracing::warn!(
task_id = %task_id,
"Heartbeat failed — claim lost, signaling cancellation"
);
let _ = cancel_tx.send(true);
break;
}
Err(e) => {
tracing::warn!(
task_id = %task_id,
error = %e,
"Heartbeat error"
);
}
}
}
}))
} else {
None
};
let permit = self
.semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| DispatchError::ExecutorNotFound("semaphore closed".into()))?;
let claim_runner_id = if self.config.enable_claiming {
Some(self.instance_id)
} else {
None
};
let claimed_task = ClaimedTask {
task_execution_id: event.task_execution_id,
workflow_execution_id: event.workflow_execution_id,
task_name: event.task_name.clone(),
attempt: event.attempt,
};
let namespace = match parse_namespace(&claimed_task.task_name) {
Ok(ns) => ns,
Err(e) => {
self.total_failed.fetch_add(1, Ordering::SeqCst);
let error_msg = format!("Invalid namespace: {}", e);
let _ = self
.dal
.task_execution()
.mark_failed(event.task_execution_id, &error_msg, claim_runner_id)
.await;
return Ok(ExecutionResult::failure(
event.task_execution_id,
error_msg,
start.elapsed(),
));
}
};
let task = match self.runtime.get_task(&namespace) {
Some(t) => t,
None => {
self.total_failed.fetch_add(1, Ordering::SeqCst);
let error_msg = format!("Task not found: {}", claimed_task.task_name);
let _ = self
.dal
.task_execution()
.mark_failed(event.task_execution_id, &error_msg, claim_runner_id)
.await;
return Ok(ExecutionResult::failure(
event.task_execution_id,
error_msg,
start.elapsed(),
));
}
};
let dependencies = task.dependencies();
let context = match self.build_task_context(&claimed_task, dependencies).await {
Ok(ctx) => ctx,
Err(e) => {
self.total_failed.fetch_add(1, Ordering::SeqCst);
let error_msg = format!("Context build failed: {}", e);
let _ = self
.dal
.task_execution()
.mark_failed(event.task_execution_id, &error_msg, claim_runner_id)
.await;
return Ok(ExecutionResult::failure(
event.task_execution_id,
error_msg,
start.elapsed(),
));
}
};
let execution_result = if task.requires_handle() {
let slot_token = SlotToken::new(permit, self.semaphore.clone());
let handle = TaskHandle::with_dal_and_cancel(
slot_token,
event.task_execution_id,
self.dal.clone(),
cancel_rx.clone(),
);
if let Err(e) = self
.dal
.task_execution()
.set_sub_status(event.task_execution_id, Some("Active"))
.await
{
tracing::warn!(
task_execution_id = %event.task_execution_id,
error = %e,
"Failed to set initial sub_status to Active"
);
}
let (result, _returned_handle) = with_task_handle(
handle,
self.execute_with_cancellation(task.as_ref(), context, cancel_rx.clone()),
)
.await;
if let Err(e) = self
.dal
.task_execution()
.set_sub_status(event.task_execution_id, None)
.await
{
tracing::warn!(
task_execution_id = %event.task_execution_id,
error = %e,
"Failed to clear sub_status after execution"
);
}
result
} else {
let _permit = permit;
self.execute_with_cancellation(task.as_ref(), context, cancel_rx.clone())
.await
};
drop(cancel_tx);
let duration = start.elapsed();
metrics::histogram!("cloacina_task_duration_seconds").record(duration.as_secs_f64());
if let Some(handle) = heartbeat_handle {
handle.abort();
let _ = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await;
}
let retry_policy = task.retry_policy();
let result = Ok(self
.result_handler
.handle_outcome(
&event,
&claimed_task,
execution_result,
&retry_policy,
duration,
)
.await);
if self.config.enable_claiming {
if let Err(e) = self
.dal
.task_execution()
.release_runner_claim(event.task_execution_id)
.await
{
tracing::warn!(
task_id = %event.task_execution_id,
error = %e,
"Failed to release runner claim"
);
}
}
result
}
fn has_capacity(&self) -> bool {
self.semaphore.available_permits() > 0
}
fn metrics(&self) -> ExecutorMetrics {
let available = self.semaphore.available_permits();
let active = self.config.max_concurrent_tasks.saturating_sub(available);
ExecutorMetrics {
active_tasks: active,
max_concurrent: self.config.max_concurrent_tasks,
total_executed: self.total_executed.load(Ordering::SeqCst),
total_failed: self.total_failed.load(Ordering::SeqCst),
avg_duration_ms: 0, }
}
fn name(&self) -> &str {
"ThreadTaskExecutor"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn failure_reason_covers_every_variant_with_bounded_values() {
use crate::error::TaskError;
let cases: Vec<(ExecutorError, &str)> = vec![
(ExecutorError::TaskTimeout, "timeout"),
(
ExecutorError::TaskExecution(TaskError::ExecutionFailed {
message: "boom".into(),
task_id: "t".into(),
timestamp: chrono::Utc::now(),
}),
"task_error",
),
(
ExecutorError::Validation(crate::error::ValidationError::InvalidTaskName(
"x".into(),
)),
"validation_failed",
),
(
ExecutorError::ConnectionPool("pool exhausted".into()),
"infrastructure",
),
(
ExecutorError::ContextLoadFailed("bad".into()),
"context_load_failed",
),
(
ExecutorError::TaskNotFound("missing".into()),
"task_not_found",
),
(ExecutorError::ClaimLost, "claim_lost"),
(ExecutorError::InvalidScope("scope".into()), "unknown"),
];
let allowed: std::collections::HashSet<&'static str> = [
"timeout",
"task_error",
"validation_failed",
"infrastructure",
"context_load_failed",
"task_not_found",
"claim_lost",
"unknown",
]
.into_iter()
.collect();
for (err, expected) in cases {
let got = failure_reason(&err);
assert_eq!(got, expected, "wrong reason for {:?}", err);
assert!(
allowed.contains(got),
"reason {} is not in the bounded set",
got
);
}
}
#[test]
fn test_merge_primitives_latest_wins() {
let existing = json!(42);
let new = json!(99);
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!(99));
}
#[test]
fn test_merge_string_latest_wins() {
let existing = json!("old");
let new = json!("new");
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!("new"));
}
#[test]
fn test_merge_different_types_latest_wins() {
let existing = json!(42);
let new = json!("now_a_string");
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!("now_a_string"));
}
#[test]
fn test_merge_arrays_deduplicates() {
let existing = json!([1, 2, 3]);
let new = json!([2, 3, 4, 5]);
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!([1, 2, 3, 4, 5]));
}
#[test]
fn test_merge_arrays_no_overlap() {
let existing = json!(["a", "b"]);
let new = json!(["c", "d"]);
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!(["a", "b", "c", "d"]));
}
#[test]
fn test_merge_arrays_complete_overlap() {
let existing = json!([1, 2, 3]);
let new = json!([1, 2, 3]);
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!([1, 2, 3]));
}
#[test]
fn test_merge_objects_no_conflict() {
let existing = json!({"a": 1, "b": 2});
let new = json!({"c": 3, "d": 4});
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!({"a": 1, "b": 2, "c": 3, "d": 4}));
}
#[test]
fn test_merge_objects_conflicting_keys() {
let existing = json!({"a": 1, "b": "old"});
let new = json!({"b": "new", "c": 3});
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!({"a": 1, "b": "new", "c": 3}));
}
#[test]
fn test_merge_objects_recursive() {
let existing = json!({"nested": {"x": 1, "y": 2}});
let new = json!({"nested": {"y": 99, "z": 3}});
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!({"nested": {"x": 1, "y": 99, "z": 3}}));
}
#[test]
fn test_merge_nested_arrays_in_objects() {
let existing = json!({"items": [1, 2]});
let new = json!({"items": [2, 3]});
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!({"items": [1, 2, 3]}));
}
#[test]
fn test_merge_null_latest_wins() {
let existing = json!(42);
let new = json!(null);
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!(null));
}
#[test]
fn test_merge_bool_latest_wins() {
let existing = json!(true);
let new = json!(false);
let merged = ThreadTaskExecutor::merge_context_values(&existing, &new);
assert_eq!(merged, json!(false));
}
#[cfg(feature = "sqlite")]
mod sqlite_tests {
use super::*;
fn test_executor() -> ThreadTaskExecutor {
let db = Database::new("sqlite://:memory:", "", 1);
let registry = Arc::new(TaskRegistry::new());
let config = ExecutorConfig::default();
ThreadTaskExecutor::new(db, registry, config)
}
#[test]
fn test_executor_has_capacity_initially() {
let exec = test_executor();
assert!(exec.has_capacity());
}
#[test]
fn test_executor_metrics_initial() {
let exec = test_executor();
let metrics = exec.metrics();
assert_eq!(metrics.active_tasks, 0);
assert_eq!(metrics.max_concurrent, 4);
assert_eq!(metrics.total_executed, 0);
assert_eq!(metrics.total_failed, 0);
}
#[test]
fn test_executor_name() {
let exec = test_executor();
assert_eq!(exec.name(), "ThreadTaskExecutor");
}
#[test]
fn test_executor_clone_shares_semaphore() {
let exec = test_executor();
let cloned = exec.clone();
assert_eq!(
exec.semaphore().available_permits(),
cloned.semaphore().available_permits()
);
}
#[test]
fn test_executor_custom_config() {
let db = Database::new("sqlite://:memory:", "", 1);
let registry = Arc::new(TaskRegistry::new());
let config = ExecutorConfig {
max_concurrent_tasks: 8,
task_timeout: std::time::Duration::from_secs(60),
enable_claiming: false,
heartbeat_interval: std::time::Duration::from_secs(5),
};
let exec = ThreadTaskExecutor::new(db, registry, config);
let metrics = exec.metrics();
assert_eq!(metrics.max_concurrent, 8);
assert_eq!(exec.semaphore().available_permits(), 8);
}
}
#[cfg(feature = "sqlite")]
#[test]
fn test_new_uses_empty_runtime_not_from_global() {
let db = Database::new("sqlite://:memory:", "test", 1);
let config = ExecutorConfig::default();
let exec = ThreadTaskExecutor::new(db, Arc::new(TaskRegistry::new()), config);
assert!(
exec.runtime.workflow_names().is_empty(),
"new() executor should have an empty runtime with no workflows"
);
}
#[cfg(feature = "sqlite")]
#[test]
fn test_with_runtime_and_registry_uses_provided_runtime() {
let db = Database::new("sqlite://:memory:", "test", 1);
let config = ExecutorConfig::default();
let runtime = Arc::new(Runtime::new());
let wf = crate::workflow::Workflow::new("test_wf");
runtime.register_workflow("test_wf".to_string(), move || wf.clone());
let exec = ThreadTaskExecutor::with_runtime_and_registry(
db,
Arc::new(TaskRegistry::new()),
runtime,
config,
);
assert!(
exec.runtime.get_workflow("test_wf").is_some(),
"Executor should use the provided runtime"
);
}
}