[smg] import official wfaas crate to replace self managed one (#17724)

This commit is contained in:
Simo Lin
2026-01-25 18:20:58 -05:00
committed by GitHub
parent 592603d77b
commit 52d0ca944b
11 changed files with 2 additions and 3938 deletions

View File

@@ -85,6 +85,7 @@ openai-protocol = { version = "1.0.0", features = ["axum"] }
tool-parser = "1.0.0"
llm-tokenizer = "1.0.0"
smg-auth = "1.0.0"
wfaas = "1.0.0"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
rustls-pemfile = "2.2"
openssl = "0.10.73"
@@ -153,10 +154,6 @@ tonic-v12 = { version = "0.12.3", package = "tonic" }
serial_test = "3.0"
rsa = { version = "0.9", features = ["sha2"] }
[[bench]]
name = "special_token_search"
harness = false
path = "benches/special_token_search.rs"
[[bench]]
name = "wasm_middleware_latency"
harness = false

View File

@@ -1,53 +0,0 @@
use aho_corasick::AhoCorasick;
use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};
fn find_special_token_boundaries_naive(
text: &str,
special_tokens: &[String],
) -> Vec<(usize, usize)> {
let mut boundaries = Vec::new();
for token in special_tokens {
let mut start = 0;
while let Some(pos) = text[start..].find(token) {
let actual_pos = start + pos;
boundaries.push((actual_pos, actual_pos + token.len()));
start = actual_pos + token.len();
}
}
boundaries.sort_by_key(|b| b.0);
boundaries
}
fn find_special_token_boundaries_aho(text: &str, ac: &AhoCorasick) -> Vec<(usize, usize)> {
ac.find_iter(text)
.map(|mat| (mat.start(), mat.end()))
.collect()
}
fn bench_token_search_comparison(c: &mut Criterion) {
let mut group = c.benchmark_group("token_boundary_search");
let text = "User: Hello! Assistant: How can I help you today? ".repeat(1000);
for token_count in [5, 50] {
let special_tokens: Vec<String> = (0..token_count)
.map(|i| format!("<|stop_sequence_{}|>", i))
.collect();
let ac = AhoCorasick::new(&special_tokens).unwrap();
group.throughput(Throughput::Bytes(text.len() as u64));
group.bench_function(format!("naive_tokens_{}", token_count), |b| {
b.iter(|| {
find_special_token_boundaries_naive(black_box(&text), black_box(&special_tokens))
})
});
group.bench_function(format!("aho_tokens_{}", token_count), |b| {
b.iter(|| find_special_token_boundaries_aho(black_box(&text), black_box(&ac)))
});
}
group.finish();
}
criterion_group!(benches, bench_token_search_comparison);
criterion_main!(benches);

View File

@@ -19,4 +19,4 @@ pub use llm_tokenizer as tokenizer;
pub use tool_parser;
pub mod version;
pub mod wasm;
pub mod workflow;
pub use wfaas as workflow;

View File

@@ -1,364 +0,0 @@
//! Workflow definition types
use std::{
collections::{HashMap, HashSet},
fmt,
sync::Arc,
time::Duration,
};
use chrono::{DateTime, Utc};
use super::{
executor::StepExecutor,
types::{FailureAction, RetryPolicy, StepId, WorkflowContext, WorkflowData, WorkflowId},
};
/// A condition function that determines whether a step should run.
pub type StepCondition<D> = Arc<dyn Fn(&WorkflowContext<D>) -> bool + Send + Sync>;
/// Errors that can occur during workflow validation
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
/// A step depends on another step that doesn't exist
#[error("Step '{step}' depends on non-existent step '{dependency}'")]
MissingDependency { step: StepId, dependency: StepId },
/// A cycle was detected in the workflow DAG
#[error("Cycle detected involving step '{0}'")]
CycleDetected(StepId),
}
/// Definition of a single step within a workflow
pub struct StepDefinition<D: WorkflowData> {
pub id: StepId,
pub name: String,
pub executor: Arc<dyn StepExecutor<D>>,
pub retry_policy: Option<RetryPolicy>,
pub timeout: Option<Duration>,
pub on_failure: FailureAction,
/// Dependencies that must ALL complete before this step runs
pub depends_on: Vec<StepId>,
/// Dependencies where ANY completing triggers this step (used with depends_on)
pub depends_on_any: Vec<StepId>,
/// Delay before starting the step (after dependencies satisfied)
pub delay: Option<Duration>,
/// Run step at or after this time (after dependencies satisfied)
pub scheduled_at: Option<DateTime<Utc>>,
/// Condition to evaluate; if false, step is skipped
pub run_if: Option<StepCondition<D>>,
}
impl<D: WorkflowData> fmt::Debug for StepDefinition<D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StepDefinition")
.field("id", &self.id)
.field("name", &self.name)
.field("retry_policy", &self.retry_policy)
.field("timeout", &self.timeout)
.field("on_failure", &self.on_failure)
.field("depends_on", &self.depends_on)
.field("depends_on_any", &self.depends_on_any)
.field("delay", &self.delay)
.field("scheduled_at", &self.scheduled_at)
.field("run_if", &self.run_if.as_ref().map(|_| "<condition>"))
.finish_non_exhaustive()
}
}
impl<D: WorkflowData> StepDefinition<D> {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
executor: Arc<dyn StepExecutor<D>>,
) -> Self {
Self {
id: StepId::new(id.into()),
name: name.into(),
executor,
retry_policy: None,
timeout: None,
on_failure: FailureAction::FailWorkflow,
depends_on: Vec::new(),
depends_on_any: Vec::new(),
delay: None,
scheduled_at: None,
run_if: None,
}
}
pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = Some(policy);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn with_failure_action(mut self, action: FailureAction) -> Self {
self.on_failure = action;
self
}
/// Set dependencies for this step.
/// The step will only run after ALL specified dependencies have completed successfully.
/// Empty slice means no dependencies - step can run immediately in parallel with others.
pub fn depends_on(mut self, deps: &[&str]) -> Self {
self.depends_on = deps.iter().map(|s| StepId::new(*s)).collect();
self
}
/// Set "any of" dependencies for this step.
/// The step will run when ANY of these dependencies complete (in addition to
/// all `depends_on` dependencies).
///
/// **Combined semantics** (when both `depends_on` and `depends_on_any` are set):
/// - ALL `depends_on` must complete successfully, AND
/// - AT LEAST ONE `depends_on_any` must complete successfully
///
/// **Failure handling**:
/// - For `depends_on`: if ANY fails, this step is blocked
/// - For `depends_on_any`: only blocked if ALL fail (since we only need one)
///
/// **Skipped dependencies**: A skipped dependency (e.g., via `run_if`) counts as
/// "completed" for dependency satisfaction purposes.
pub fn depends_on_any(mut self, deps: &[&str]) -> Self {
self.depends_on_any = deps.iter().map(|s| StepId::new(*s)).collect();
self
}
/// Set a delay before starting the step (after dependencies are satisfied).
///
/// If both `delay` and `scheduled_at` are set, the step will wait for the
/// scheduled time AND THEN wait for the delay duration (they stack).
pub fn with_delay(mut self, delay: Duration) -> Self {
self.delay = Some(delay);
self
}
/// Schedule the step to run at or after the specified time.
/// The step will wait until this time even if dependencies are satisfied earlier.
///
/// If the scheduled time is in the past when the step becomes ready, it will
/// proceed immediately (a debug log is emitted).
///
/// If both `delay` and `scheduled_at` are set, the step will wait for the
/// scheduled time AND THEN wait for the delay duration (they stack).
pub fn scheduled_at(mut self, time: DateTime<Utc>) -> Self {
self.scheduled_at = Some(time);
self
}
/// Set a condition for running this step.
/// If the condition returns false, the step is skipped.
///
/// **Skipped step semantics**: When a step is skipped:
/// - It is marked as `StepStatus::Skipped`
/// - Downstream steps that depend on it (via `depends_on`) will consider it satisfied
/// - This allows conditional branches without blocking the workflow
///
/// **Error handling**: If the context cannot be retrieved to evaluate the condition,
/// the step will **fail** (not proceed blindly). This is a safety measure.
///
/// **Note**: The condition closure is not serializable, so workflows with `run_if`
/// cannot be persisted and resumed from external storage.
pub fn run_if<F>(mut self, condition: F) -> Self
where
F: Fn(&WorkflowContext<D>) -> bool + Send + Sync + 'static,
{
self.run_if = Some(Arc::new(condition));
self
}
}
/// Complete workflow definition
pub struct WorkflowDefinition<D: WorkflowData> {
pub id: WorkflowId,
pub name: String,
pub steps: Vec<StepDefinition<D>>,
pub default_retry_policy: RetryPolicy,
pub default_timeout: Duration,
/// Pre-computed reverse dependencies: step_id -> indices of steps that depend on it
reverse_deps: HashMap<StepId, Vec<usize>>,
/// Pre-computed indices of steps with no dependencies (can start immediately)
initial_step_indices: Vec<usize>,
}
impl<D: WorkflowData> fmt::Debug for WorkflowDefinition<D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WorkflowDefinition")
.field("id", &self.id)
.field("name", &self.name)
.field("steps", &self.steps)
.field("default_retry_policy", &self.default_retry_policy)
.field("default_timeout", &self.default_timeout)
.finish_non_exhaustive()
}
}
impl<D: WorkflowData> WorkflowDefinition<D> {
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: WorkflowId::new(id.into()),
name: name.into(),
steps: Vec::new(),
default_retry_policy: RetryPolicy::default(),
default_timeout: Duration::from_secs(300), // 5 minutes
reverse_deps: HashMap::new(),
initial_step_indices: Vec::new(),
}
}
pub fn add_step(mut self, step: StepDefinition<D>) -> Self {
self.steps.push(step);
self
}
pub fn with_default_retry(mut self, policy: RetryPolicy) -> Self {
self.default_retry_policy = policy;
self
}
pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
self.default_timeout = timeout;
self
}
/// Get the retry policy for a step (step-specific or default)
pub fn get_retry_policy<'a>(&'a self, step: &'a StepDefinition<D>) -> &'a RetryPolicy {
step.retry_policy
.as_ref()
.unwrap_or(&self.default_retry_policy)
}
/// Get the timeout for a step (step-specific or default)
pub fn get_timeout(&self, step: &StepDefinition<D>) -> Duration {
step.timeout.unwrap_or(self.default_timeout)
}
/// Validate the workflow DAG structure and build dependency graph.
/// Returns an error if:
/// - A step depends on a non-existent step
/// - There's a cycle in the dependencies
///
/// On success, pre-computes reverse dependencies for O(1) dependent lookup.
#[must_use = "validation result should be checked"]
pub fn validate(&mut self) -> Result<(), ValidationError> {
// Build HashMap for O(1) lookup instead of O(n) linear search
let steps_map: HashMap<&StepId, &StepDefinition<D>> =
self.steps.iter().map(|s| (&s.id, s)).collect();
// Check all dependencies exist (both depends_on and depends_on_any)
for step in &self.steps {
for dep in &step.depends_on {
if !steps_map.contains_key(dep) {
return Err(ValidationError::MissingDependency {
step: step.id.clone(),
dependency: dep.clone(),
});
}
}
for dep in &step.depends_on_any {
if !steps_map.contains_key(dep) {
return Err(ValidationError::MissingDependency {
step: step.id.clone(),
dependency: dep.clone(),
});
}
}
}
// Check for cycles using DFS (considers both dependency types)
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
for step in &self.steps {
if !visited.contains(&step.id)
&& Self::has_cycle(&step.id, &steps_map, &mut visited, &mut rec_stack)
{
return Err(ValidationError::CycleDetected(step.id.clone()));
}
}
// Build reverse dependency map: for each step, which steps depend on it?
// Include both depends_on and depends_on_any
self.reverse_deps.clear();
for (idx, step) in self.steps.iter().enumerate() {
for dep_id in &step.depends_on {
self.reverse_deps
.entry(dep_id.clone())
.or_default()
.push(idx);
}
for dep_id in &step.depends_on_any {
self.reverse_deps
.entry(dep_id.clone())
.or_default()
.push(idx);
}
}
// Cache indices of steps with no dependencies (can start immediately)
// A step with only depends_on_any still needs at least one to complete
self.initial_step_indices = self
.steps
.iter()
.enumerate()
.filter(|(_, s)| s.depends_on.is_empty() && s.depends_on_any.is_empty())
.map(|(i, _)| i)
.collect();
Ok(())
}
/// DFS helper for cycle detection with O(1) HashMap lookup
fn has_cycle<'a>(
step_id: &'a StepId,
steps_map: &HashMap<&'a StepId, &'a StepDefinition<D>>,
visited: &mut HashSet<&'a StepId>,
rec_stack: &mut HashSet<&'a StepId>,
) -> bool {
if rec_stack.contains(step_id) {
return true; // Back edge found - cycle!
}
if visited.contains(step_id) {
return false; // Already fully processed
}
visited.insert(step_id);
rec_stack.insert(step_id);
// O(1) lookup instead of linear search
// Check both depends_on and depends_on_any for cycles
if let Some(step) = steps_map.get(step_id) {
for dep in &step.depends_on {
if Self::has_cycle(dep, steps_map, visited, rec_stack) {
return true;
}
}
for dep in &step.depends_on_any {
if Self::has_cycle(dep, steps_map, visited, rec_stack) {
return true;
}
}
}
rec_stack.remove(step_id);
false
}
/// Get indices of steps that depend on the given step
pub fn get_dependent_indices(&self, step_id: &StepId) -> &[usize] {
self.reverse_deps
.get(step_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Get indices of steps with no dependencies
pub fn get_initial_step_indices(&self) -> &[usize] {
&self.initial_step_indices
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,282 +0,0 @@
//! Workflow event system for observability and monitoring
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
use super::types::{StepId, WorkflowId, WorkflowInstanceId};
/// Default timeout for subscriber event handlers
const DEFAULT_SUBSCRIBER_TIMEOUT: Duration = Duration::from_secs(30);
/// Events emitted by the workflow engine
#[derive(Debug, Clone)]
pub enum WorkflowEvent {
WorkflowStarted {
instance_id: WorkflowInstanceId,
definition_id: WorkflowId,
},
StepStarted {
instance_id: WorkflowInstanceId,
step_id: StepId,
attempt: u32,
},
StepSucceeded {
instance_id: WorkflowInstanceId,
step_id: StepId,
duration: Duration,
},
StepFailed {
instance_id: WorkflowInstanceId,
step_id: StepId,
error: String,
will_retry: bool,
},
StepRetrying {
instance_id: WorkflowInstanceId,
step_id: StepId,
attempt: u32,
delay: Duration,
},
WorkflowCompleted {
instance_id: WorkflowInstanceId,
duration: Duration,
},
WorkflowFailed {
instance_id: WorkflowInstanceId,
failed_step: StepId,
error: String,
},
WorkflowCancelled {
instance_id: WorkflowInstanceId,
},
}
/// Trait for subscribing to workflow events
#[async_trait]
pub trait EventSubscriber: Send + Sync {
async fn on_event(&self, event: &WorkflowEvent);
}
/// Event bus for publishing and subscribing to workflow events
///
/// # Subscriber Isolation
///
/// Each subscriber is notified in a separate spawned task with a timeout.
/// This ensures that:
/// - A slow subscriber doesn't block other subscribers
/// - A panicking subscriber doesn't affect other subscribers
/// - Event publishing returns quickly regardless of subscriber behavior
pub struct EventBus {
subscribers: Arc<RwLock<Vec<Arc<dyn EventSubscriber>>>>,
/// Timeout for each subscriber's event handler
subscriber_timeout: Duration,
}
impl EventBus {
pub fn new() -> Self {
Self {
subscribers: Arc::new(RwLock::new(Vec::new())),
subscriber_timeout: DEFAULT_SUBSCRIBER_TIMEOUT,
}
}
/// Create an EventBus with a custom subscriber timeout
pub fn with_timeout(timeout: Duration) -> Self {
Self {
subscribers: Arc::new(RwLock::new(Vec::new())),
subscriber_timeout: timeout,
}
}
/// Subscribe to workflow events
pub async fn subscribe(&self, subscriber: Arc<dyn EventSubscriber>) {
self.subscribers.write().await.push(subscriber);
}
/// Unsubscribe from workflow events
///
/// Removes the subscriber by Arc pointer equality.
/// Returns true if the subscriber was found and removed.
pub async fn unsubscribe(&self, subscriber: &Arc<dyn EventSubscriber>) -> bool {
let mut subs = self.subscribers.write().await;
let len_before = subs.len();
subs.retain(|s| !Arc::ptr_eq(s, subscriber));
subs.len() < len_before
}
/// Publish an event to all subscribers concurrently
///
/// Each subscriber is notified in a separate spawned task with a timeout.
/// This method returns after spawning all notification tasks, without
/// waiting for subscribers to complete (fire-and-forget).
///
/// Subscriber failures (timeout or panic) are logged but don't affect
/// other subscribers or the caller.
pub async fn publish(&self, event: WorkflowEvent) {
let subscribers: Vec<_> = self.subscribers.read().await.iter().cloned().collect();
let timeout = self.subscriber_timeout;
for (idx, subscriber) in subscribers.into_iter().enumerate() {
let event = event.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(timeout, subscriber.on_event(&event)).await;
match result {
Ok(()) => {}
Err(_) => {
warn!(
subscriber_index = idx,
timeout_secs = timeout.as_secs(),
"Event subscriber timed out"
);
}
}
});
}
}
/// Publish an event and wait for all subscribers to complete
///
/// Unlike `publish`, this method waits for all subscribers to finish
/// (or timeout). Use this when you need to ensure all subscribers
/// have processed the event before continuing.
pub async fn publish_and_wait(&self, event: WorkflowEvent) {
let subscribers: Vec<_> = self.subscribers.read().await.iter().cloned().collect();
let timeout = self.subscriber_timeout;
let handles: Vec<_> = subscribers
.into_iter()
.enumerate()
.map(|(idx, subscriber)| {
let event = event.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(timeout, subscriber.on_event(&event)).await;
if result.is_err() {
warn!(
subscriber_index = idx,
timeout_secs = timeout.as_secs(),
"Event subscriber timed out"
);
}
})
})
.collect();
// Wait for all spawned tasks, ignoring individual failures (panics)
for handle in handles {
let _ = handle.await;
}
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for EventBus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventBus").finish_non_exhaustive()
}
}
/// Logging subscriber that logs events using tracing
pub struct LoggingSubscriber;
#[async_trait]
impl EventSubscriber for LoggingSubscriber {
async fn on_event(&self, event: &WorkflowEvent) {
match event {
WorkflowEvent::WorkflowStarted {
instance_id,
definition_id,
} => {
info!(
instance_id = %instance_id,
definition_id = %definition_id,
"Workflow started"
);
}
WorkflowEvent::StepStarted {
instance_id,
step_id,
attempt,
} => {
info!(
instance_id = %instance_id,
step_id = %step_id,
attempt = attempt,
"Step started"
);
}
WorkflowEvent::StepSucceeded {
instance_id,
step_id,
duration,
} => {
info!(
instance_id = %instance_id,
step_id = %step_id,
duration_ms = duration.as_millis(),
"Step succeeded"
);
}
WorkflowEvent::StepFailed {
instance_id,
step_id,
error,
will_retry,
} => {
warn!(
instance_id = %instance_id,
step_id = %step_id,
error = error,
will_retry = will_retry,
"Step failed"
);
}
WorkflowEvent::StepRetrying {
instance_id,
step_id,
attempt,
delay,
} => {
info!(
instance_id = %instance_id,
step_id = %step_id,
attempt = attempt,
delay_ms = delay.as_millis(),
"Step retrying"
);
}
WorkflowEvent::WorkflowCompleted {
instance_id,
duration,
} => {
info!(
instance_id = %instance_id,
duration_ms = duration.as_millis(),
"Workflow completed"
);
}
WorkflowEvent::WorkflowFailed {
instance_id,
failed_step,
error,
} => {
error!(
instance_id = %instance_id,
failed_step = %failed_step,
error = error,
"Workflow failed"
);
}
WorkflowEvent::WorkflowCancelled { instance_id } => {
info!(instance_id = %instance_id, "Workflow cancelled");
}
}
}
}

View File

@@ -1,152 +0,0 @@
//! Step executor trait and implementations
use async_trait::async_trait;
use super::types::{StepResult, WorkflowContext, WorkflowData, WorkflowError, WorkflowResult};
/// Trait for executing individual workflow steps
#[async_trait]
pub trait StepExecutor<D: WorkflowData>: Send + Sync {
/// Execute the step with the given context
async fn execute(&self, context: &mut WorkflowContext<D>) -> WorkflowResult<StepResult>;
/// Check if an error is retry-able
///
/// Override this method to customize which errors should trigger retries.
/// By default, all errors are considered retry-able.
fn is_retryable(&self, _error: &WorkflowError) -> bool {
true
}
/// Called when the step succeeds
///
/// This hook allows steps to perform cleanup or additional actions
/// after successful execution.
async fn on_success(&self, _context: &WorkflowContext<D>) -> WorkflowResult<()> {
Ok(())
}
/// Called when the step fails after all retries
///
/// This hook allows steps to perform cleanup or compensation logic
/// when the step cannot complete successfully.
async fn on_failure(
&self,
_context: &WorkflowContext<D>,
_error: &WorkflowError,
) -> WorkflowResult<()> {
Ok(())
}
}
/// Simple function-based step executor
pub struct FunctionStep<D, F>
where
D: WorkflowData,
F: Fn(
&mut WorkflowContext<D>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = WorkflowResult<StepResult>> + Send + '_>,
> + Send
+ Sync,
{
func: F,
_phantom: std::marker::PhantomData<D>,
}
impl<D, F> FunctionStep<D, F>
where
D: WorkflowData,
F: Fn(
&mut WorkflowContext<D>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = WorkflowResult<StepResult>> + Send + '_>,
> + Send
+ Sync,
{
pub fn new(func: F) -> Self {
Self {
func,
_phantom: std::marker::PhantomData,
}
}
}
#[async_trait]
impl<D, F> StepExecutor<D> for FunctionStep<D, F>
where
D: WorkflowData,
F: Fn(
&mut WorkflowContext<D>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = WorkflowResult<StepResult>> + Send + '_>,
> + Send
+ Sync,
{
async fn execute(&self, context: &mut WorkflowContext<D>) -> WorkflowResult<StepResult> {
(self.func)(context).await
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
use super::*;
use crate::workflow::types::WorkflowInstanceId;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TestData {
value: i32,
}
impl WorkflowData for TestData {
fn workflow_type() -> &'static str {
"test"
}
}
struct TestStep {
should_succeed: bool,
}
#[async_trait]
impl StepExecutor<TestData> for TestStep {
async fn execute(
&self,
_context: &mut WorkflowContext<TestData>,
) -> WorkflowResult<StepResult> {
if self.should_succeed {
Ok(StepResult::Success)
} else {
Err(WorkflowError::StepFailed {
step_id: crate::workflow::types::StepId::new("test"),
message: "test error".to_string(),
})
}
}
}
#[tokio::test]
async fn test_step_executor_success() {
let step = TestStep {
should_succeed: true,
};
let mut context = WorkflowContext::new(WorkflowInstanceId::new(), TestData { value: 42 });
let result = step.execute(&mut context).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), StepResult::Success);
}
#[tokio::test]
async fn test_step_executor_failure() {
let step = TestStep {
should_succeed: false,
};
let mut context = WorkflowContext::new(WorkflowInstanceId::new(), TestData { value: 42 });
let result = step.execute(&mut context).await;
assert!(result.is_err());
}
}

View File

@@ -1,15 +0,0 @@
//! Workflow engine for managing multi-step operations
mod definition;
mod engine;
mod event;
mod executor;
mod state;
pub mod types;
pub use definition::{StepDefinition, ValidationError, WorkflowDefinition};
pub use engine::WorkflowEngine;
pub use event::{EventBus, EventSubscriber, LoggingSubscriber, WorkflowEvent};
pub use executor::{FunctionStep, StepExecutor};
pub use state::{InMemoryStore, StateStore};
pub use types::*;

View File

@@ -1,204 +0,0 @@
//! Workflow state management
use std::{collections::HashMap, marker::PhantomData, sync::Arc, time::Duration};
use async_trait::async_trait;
use parking_lot::RwLock;
use super::types::{
WorkflowContext, WorkflowData, WorkflowError, WorkflowInstanceId, WorkflowResult,
WorkflowState, WorkflowStatus,
};
/// Trait for workflow state persistence.
///
/// Implement this trait to provide custom storage backends (e.g., PostgreSQL, Redis).
/// The default implementation is `InMemoryStore` which keeps state in memory.
#[async_trait]
pub trait StateStore<D: WorkflowData>: Send + Sync + Clone {
/// Save workflow state
async fn save(&self, state: WorkflowState<D>) -> WorkflowResult<()>;
/// Load workflow state by instance ID
async fn load(&self, instance_id: WorkflowInstanceId) -> WorkflowResult<WorkflowState<D>>;
/// Update workflow state using a closure
async fn update<F>(&self, instance_id: WorkflowInstanceId, f: F) -> WorkflowResult<()>
where
F: FnOnce(&mut WorkflowState<D>) + Send;
/// Delete workflow state
async fn delete(&self, instance_id: WorkflowInstanceId) -> WorkflowResult<()>;
/// List all active workflows (Running or Pending)
async fn list_active(&self) -> WorkflowResult<Vec<WorkflowState<D>>>;
/// List all workflows
async fn list_all(&self) -> WorkflowResult<Vec<WorkflowState<D>>>;
/// Check if workflow is cancelled without loading full state
async fn is_cancelled(&self, instance_id: WorkflowInstanceId) -> WorkflowResult<bool>;
/// Clean up old completed/failed/cancelled workflows beyond a time threshold
async fn cleanup_old_workflows(&self, ttl: Duration) -> usize;
/// Get just the workflow context without cloning the entire state
async fn get_context(
&self,
instance_id: WorkflowInstanceId,
) -> WorkflowResult<WorkflowContext<D>>;
/// Clean up a specific workflow immediately if it's in a terminal state
/// Returns true if the workflow was removed, false otherwise
async fn cleanup_if_terminal(&self, instance_id: WorkflowInstanceId) -> bool;
}
/// In-memory state storage for workflow instances
#[derive(Clone)]
pub struct InMemoryStore<D: WorkflowData> {
states: Arc<RwLock<HashMap<WorkflowInstanceId, WorkflowState<D>>>>,
_phantom: PhantomData<D>,
}
impl<D: WorkflowData> InMemoryStore<D> {
pub fn new() -> Self {
Self {
states: Arc::new(RwLock::new(HashMap::new())),
_phantom: PhantomData,
}
}
/// Get count of workflows by status
pub fn count_by_status(&self, status: WorkflowStatus) -> usize {
self.states
.read()
.values()
.filter(|s| s.status == status)
.count()
}
/// Get total count of all workflows
pub fn count(&self) -> usize {
self.states.read().len()
}
}
impl<D: WorkflowData> Default for InMemoryStore<D> {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl<D: WorkflowData> StateStore<D> for InMemoryStore<D> {
async fn save(&self, state: WorkflowState<D>) -> WorkflowResult<()> {
self.states.write().insert(state.instance_id, state);
Ok(())
}
async fn load(&self, instance_id: WorkflowInstanceId) -> WorkflowResult<WorkflowState<D>> {
self.states
.read()
.get(&instance_id)
.cloned()
.ok_or(WorkflowError::NotFound(instance_id))
}
async fn update<F>(&self, instance_id: WorkflowInstanceId, f: F) -> WorkflowResult<()>
where
F: FnOnce(&mut WorkflowState<D>) + Send,
{
let mut states = self.states.write();
let state = states
.get_mut(&instance_id)
.ok_or(WorkflowError::NotFound(instance_id))?;
f(state);
state.updated_at = chrono::Utc::now();
Ok(())
}
async fn delete(&self, instance_id: WorkflowInstanceId) -> WorkflowResult<()> {
self.states.write().remove(&instance_id);
Ok(())
}
async fn list_active(&self) -> WorkflowResult<Vec<WorkflowState<D>>> {
let states = self.states.read();
Ok(states
.values()
.filter(|s| matches!(s.status, WorkflowStatus::Running | WorkflowStatus::Pending))
.cloned()
.collect())
}
async fn list_all(&self) -> WorkflowResult<Vec<WorkflowState<D>>> {
let states = self.states.read();
Ok(states.values().cloned().collect())
}
async fn is_cancelled(&self, instance_id: WorkflowInstanceId) -> WorkflowResult<bool> {
self.states
.read()
.get(&instance_id)
.map(|s| s.status == WorkflowStatus::Cancelled)
.ok_or(WorkflowError::NotFound(instance_id))
}
async fn cleanup_old_workflows(&self, ttl: Duration) -> usize {
let now = chrono::Utc::now();
let mut states = self.states.write();
let initial_count = states.len();
states.retain(|_, state| {
// Keep active workflows
if matches!(
state.status,
WorkflowStatus::Running | WorkflowStatus::Pending | WorkflowStatus::Paused
) {
return true;
}
// For terminal workflows, check age
let age = now
.signed_duration_since(state.updated_at)
.to_std()
.unwrap_or_default();
age < ttl
});
let removed_count = initial_count - states.len();
if removed_count > 0 {
tracing::info!(
removed = removed_count,
remaining = states.len(),
"Cleaned up old workflow states"
);
}
removed_count
}
async fn get_context(
&self,
instance_id: WorkflowInstanceId,
) -> WorkflowResult<WorkflowContext<D>> {
self.states
.read()
.get(&instance_id)
.map(|s| s.context.clone())
.ok_or(WorkflowError::NotFound(instance_id))
}
async fn cleanup_if_terminal(&self, instance_id: WorkflowInstanceId) -> bool {
let mut states = self.states.write();
if let Some(state) = states.get(&instance_id) {
if matches!(
state.status,
WorkflowStatus::Completed | WorkflowStatus::Failed | WorkflowStatus::Cancelled
) {
states.remove(&instance_id);
return true;
}
}
false
}
}

View File

@@ -1,277 +0,0 @@
//! Core workflow types and definitions
use std::{collections::HashMap, fmt, time::Duration};
use chrono::{DateTime, Utc};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use uuid::Uuid;
/// Trait for workflow data that can be passed through workflow steps.
///
/// Implementing this trait allows your data type to be used as the typed
/// context for a workflow. The data must be serializable for state persistence.
///
/// # Example
///
/// ```ignore
/// #[derive(Debug, Clone, Serialize, Deserialize)]
/// pub struct MyWorkflowData {
/// pub config: MyConfig,
/// pub result: Option<MyResult>,
/// #[serde(skip, default)]
/// pub app_context: Option<Arc<AppContext>>,
/// }
///
/// impl WorkflowData for MyWorkflowData {
/// fn workflow_type() -> &'static str { "my_workflow" }
/// }
/// ```
pub trait WorkflowData: Serialize + DeserializeOwned + Send + Sync + Clone + 'static {
/// Human-readable name for logging and identification
fn workflow_type() -> &'static str;
}
/// Unique identifier for a workflow definition
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorkflowId(String);
impl WorkflowId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl fmt::Display for WorkflowId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Unique identifier for a workflow instance
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorkflowInstanceId(Uuid);
impl WorkflowInstanceId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for WorkflowInstanceId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for WorkflowInstanceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Unique identifier for a workflow step
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StepId(String);
impl StepId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl fmt::Display for StepId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Retry policy configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub backoff: BackoffStrategy,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
backoff: BackoffStrategy::Exponential {
base: Duration::from_secs(1),
max: Duration::from_secs(30),
},
}
}
}
/// Backoff strategy for retries
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BackoffStrategy {
/// Fixed delay between retries
Fixed(Duration),
/// Exponential backoff with base and max duration
Exponential { base: Duration, max: Duration },
/// Linear backoff with increment and max duration
Linear { increment: Duration, max: Duration },
}
/// Action to take when a step fails
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FailureAction {
/// Stop the entire workflow
FailWorkflow,
/// Skip this step and continue to the next
ContinueNextStep,
/// Keep retrying indefinitely until manual intervention
RetryIndefinitely,
}
/// Workflow execution status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkflowStatus {
Pending,
Running,
Paused,
Completed,
Failed,
Cancelled,
}
/// Step execution status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StepStatus {
Pending,
Running,
Succeeded,
Failed,
Retrying,
Skipped,
}
/// State of a workflow step
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepState {
pub status: StepStatus,
pub attempt: u32,
pub last_error: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
impl Default for StepState {
fn default() -> Self {
Self {
status: StepStatus::Pending,
attempt: 0,
last_error: None,
started_at: None,
completed_at: None,
}
}
}
/// Workflow instance state
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound(
serialize = "D: Serialize",
deserialize = "D: serde::de::DeserializeOwned"
))]
pub struct WorkflowState<D: WorkflowData> {
pub instance_id: WorkflowInstanceId,
pub definition_id: WorkflowId,
pub status: WorkflowStatus,
pub current_step: Option<StepId>,
pub step_states: HashMap<StepId, StepState>,
pub context: WorkflowContext<D>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl<D: WorkflowData> WorkflowState<D> {
pub fn new(instance_id: WorkflowInstanceId, definition_id: WorkflowId, data: D) -> Self {
let now = Utc::now();
Self {
instance_id,
definition_id,
status: WorkflowStatus::Pending,
current_step: None,
step_states: HashMap::new(),
context: WorkflowContext::new(instance_id, data),
created_at: now,
updated_at: now,
}
}
}
/// Shared context passed between workflow steps.
///
/// The context contains typed workflow data that is fully serializable,
/// enabling state persistence and workflow recovery.
///
/// # Type Parameter
///
/// `D` - The workflow-specific data type implementing `WorkflowData`.
/// This type holds all the state needed by workflow steps and must be
/// serializable (except for fields marked with `#[serde(skip)]`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound(
serialize = "D: Serialize",
deserialize = "D: serde::de::DeserializeOwned"
))]
pub struct WorkflowContext<D: WorkflowData> {
pub instance_id: WorkflowInstanceId,
pub data: D,
}
impl<D: WorkflowData> WorkflowContext<D> {
pub fn new(instance_id: WorkflowInstanceId, data: D) -> Self {
Self { instance_id, data }
}
}
/// Result returned by a step execution
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepResult {
Success,
Failure,
Skip,
}
/// Error kinds for workflow operations
#[derive(Debug, Clone, thiserror::Error)]
pub enum WorkflowError {
#[error("Workflow not found: {0}")]
NotFound(WorkflowInstanceId),
#[error("Workflow definition not found: {0}")]
DefinitionNotFound(WorkflowId),
#[error("Step failed: {step_id} - {message}")]
StepFailed { step_id: StepId, message: String },
#[error("Step timeout: {step_id}")]
StepTimeout { step_id: StepId },
#[error("Workflow cancelled: {0}")]
Cancelled(WorkflowInstanceId),
#[error("Invalid state transition: {from:?} -> {to:?}")]
InvalidStateTransition {
from: WorkflowStatus,
to: WorkflowStatus,
},
#[error("Context value not found: {0}")]
ContextValueNotFound(String),
#[error("Type mismatch: expected {expected}, got {actual}")]
TypeMismatch {
expected: &'static str,
actual: &'static str,
},
#[error("Engine is shutting down, not accepting new workflows")]
ShuttingDown,
}
pub type WorkflowResult<T> = Result<T, WorkflowError>;

File diff suppressed because it is too large Load Diff