Simulation & Testing¶
The acteon-simulation crate provides comprehensive end-to-end testing tools for the Acteon gateway.
Overview¶
flowchart LR
subgraph Simulation Harness
RC[RecordingProvider]
FP[FailingProvider]
FM[FailureMode]
end
subgraph Gateway Under Test
GW[Gateway]
ST[(State Store)]
AU[(Audit Store)]
end
subgraph Assertions
OA[OutcomeAssertion]
PA[Provider Assertions]
end
RC --> GW
FP --> GW
GW --> ST
GW --> AU
GW --> OA
RC --> PA Quick Start¶
use acteon_simulation::prelude::*;
use acteon_core::Action;
#[tokio::test]
async fn test_basic() {
let harness = SimulationHarness::start(
SimulationConfig::builder()
.nodes(1)
.add_recording_provider("email")
.add_rule_yaml(r#"
rules:
- name: dedup
condition:
field: action.action_type
eq: "notify"
action:
type: deduplicate
ttl_seconds: 60
"#)
.build()
).await.unwrap();
let action = Action::new("ns", "t1", "email", "notify", json!({}))
.with_dedup_key("key");
harness.dispatch(&action).await.unwrap().assert_executed();
harness.dispatch(&action).await.unwrap().assert_deduplicated();
harness.provider("email").unwrap().assert_called(1);
harness.teardown().await.unwrap();
}
RecordingProvider¶
Captures all provider calls for verification:
let provider = Arc::new(RecordingProvider::new("email"));
// With simulated latency
let slow = Arc::new(RecordingProvider::new("api").with_delay(Duration::from_millis(100)));
// With failure injection
let flaky = Arc::new(RecordingProvider::new("flaky")
.with_failure_mode(FailureMode::EveryN(5)));
// With custom response logic
let smart = Arc::new(RecordingProvider::new("smart")
.with_response_fn(|action| {
Ok(ProviderResponse::success(json!({"processed": true})))
}));
// Assertions
provider.assert_called(1);
provider.assert_not_called();
provider.assert_called_at_least(5);
// Inspect calls
for call in provider.calls() {
println!("Action: {}", call.action.id);
}
// Reset
provider.clear();
FailureMode Options¶
| Mode | Description |
|---|---|
FailureMode::None | Never fail (default) |
FailureMode::Always | Always fail |
FailureMode::FirstN(n) | Fail first N calls |
FailureMode::EveryN(n) | Fail every Nth call |
FailureMode::Probabilistic(p) | Fail with probability p |
FailingProvider¶
Simulates specific error types:
// Connection error (retryable)
let failing = FailingProvider::connection_error("webhook", "Connection refused");
// Timeout (retryable)
let timeout = FailingProvider::timeout("api", Duration::from_secs(30));
// Rate limited (retryable)
let limited = FailingProvider::rate_limited("api");
// Execution error (non-retryable)
let broken = FailingProvider::execution_failed("broken", "Internal error");
// Transient: fail first N, then recover
let recovering = FailingProvider::execution_failed("flaky", "Temp error")
.fail_until(3);
OutcomeAssertion¶
Fluent assertions for dispatch results:
harness.dispatch(&action).await.unwrap()
.assert_executed(); // Provider executed
.assert_deduplicated(); // Was deduplicated
.assert_suppressed(); // Was suppressed
.assert_throttled(); // Was throttled
.assert_failed(); // Provider failed
.assert_grouped(); // Added to group
.assert_state_changed(); // State transitioned
.assert_pending_approval(); // Needs approval
.assert_chain_started(); // Chain initiated
.assert_dry_run(); // Dry-run verdict returned
Dry-Run Dispatch¶
// Dry-run: evaluate rules without executing
let outcome = harness.dispatch_dry_run(&action).await.unwrap();
outcome.assert_dry_run();
// Provider was NOT called
harness.provider("email").unwrap().assert_not_called();
Test Scenarios¶
Deduplication¶
let action = Action::new("ns", "t1", "email", "notify", json!({}))
.with_dedup_key("unique");
harness.dispatch(&action).await.unwrap().assert_executed();
harness.dispatch(&action).await.unwrap().assert_deduplicated();
harness.provider("email").unwrap().assert_called(1);
Suppression¶
let spam = Action::new("ns", "t1", "email", "spam", json!({}));
harness.dispatch(&spam).await.unwrap().assert_suppressed();
harness.provider("email").unwrap().assert_not_called();
Throttling¶
for i in 0..15 {
let action = Action::new("ns", "t1", "sms", "alert", json!({"seq": i}));
let outcome = harness.dispatch(&action).await.unwrap();
if i < 10 {
outcome.assert_executed();
} else {
outcome.assert_throttled();
}
}
Failure Recovery¶
let recovering = FailingProvider::execution_failed("api", "Temp")
.fail_until(2);
// First 2 calls fail, third succeeds (with retries)
let action = Action::new("ns", "t1", "api", "call", json!({}));
let outcome = harness.dispatch(&action).await.unwrap();
// With max_retries >= 2, this will eventually execute
Multi-Node Concurrent Dispatch¶
let harness = SimulationHarness::multi_node_memory(3).await.unwrap();
let action = Action::new("ns", "t1", "email", "notify", json!({}))
.with_dedup_key("concurrent-key");
// Dispatch to all 3 nodes concurrently
let futures: Vec<_> = (0..3)
.map(|i| harness.dispatch_to(i, &action))
.collect();
let outcomes = futures::future::join_all(futures).await;
let executed = outcomes.iter()
.filter(|o| matches!(o.as_ref().unwrap().outcome(), ActionOutcome::Executed(_)))
.count();
assert_eq!(executed, 1); // Only one node executes
Running Backend-Specific Simulations¶
Prerequisites¶
# Redis
docker run -d --name acteon-redis -p 6379:6379 redis:7-alpine
# PostgreSQL
docker run -d --name acteon-postgres -p 5433:5432 \
-e POSTGRES_PASSWORD=postgres postgres:16-alpine
# DynamoDB Local
docker run -d --name acteon-dynamodb -p 8000:8000 \
amazon/dynamodb-local:latest
Single Backend Simulations¶
cargo run -p acteon-simulation --example redis_simulation --features redis
cargo run -p acteon-simulation --example postgres_simulation --features postgres
cargo run -p acteon-simulation --example dynamodb_simulation --features dynamodb
Dry-Run Simulation¶
Tests dry-run dispatch across multiple rule types:
- Allow verdict — No rules match, action would be allowed
- Suppression verdict — Rule would suppress the action
- Rerouting verdict — Rule would reroute to a different provider
- Batch dry-run — Multiple actions evaluated without executing
- Provider not called — Verifies no side effects during dry-run
Time-Based Rules Simulation¶
Tests time-based rule conditions using time.* fields:
- Temporal suppression — Rules using
time.yearto conditionally suppress actions - Business hours pattern — Demonstrates
time.hourandtime.weekday_numconditions - Combined conditions — Time fields combined with action field conditions
- Dry-run with time — Dry-run evaluation of time-based rules
Webhook Simulation¶
Tests webhook dispatch, rerouting to webhooks, and deduplication of webhook calls:
- Basic dispatch — Action sent directly to webhook provider
- Rerouting — Actions rerouted from email to webhook based on rules
- Deduplication — Duplicate webhook calls are blocked
Mixed Backend Simulations¶
# Redis state + PostgreSQL audit
cargo run -p acteon-simulation --example mixed_backends_simulation \
--features "redis,postgres" -- redis-postgres
# Redis state + ClickHouse audit
cargo run -p acteon-simulation --example mixed_backends_simulation \
--features "redis,clickhouse" -- redis-clickhouse
Benchmarks¶
cargo bench -p acteon-simulation --bench throughput
cargo bench -p acteon-simulation --bench latency
See Performance Guide for benchmark results.