SiLA2.StateMachine
10.2.6
dotnet add package SiLA2.StateMachine --version 10.2.6
NuGet\Install-Package SiLA2.StateMachine -Version 10.2.6
<PackageReference Include="SiLA2.StateMachine" Version="10.2.6" />
<PackageVersion Include="SiLA2.StateMachine" Version="10.2.6" />
<PackageReference Include="SiLA2.StateMachine" />
paket add SiLA2.StateMachine --version 10.2.6
#r "nuget: SiLA2.StateMachine, 10.2.6"
#:package SiLA2.StateMachine@10.2.6
#addin nuget:?package=SiLA2.StateMachine&version=10.2.6
#tool nuget:?package=SiLA2.StateMachine&version=10.2.6
SiLA2.StateMachine
A state machine module for orchestrating multi-step SiLA2 lab automation workflows. Built on the Stateless library, it provides a fluent API for defining workflows with automatic retry policies, OpenTelemetry tracing, and dependency injection support.
| NuGet Package | SiLA2.StateMachine |
| Repository | https://gitlab.com/SiLA2/sila_csharp |
| SiLA Standard | https://sila-standard.com |
| License | MIT |
Features
- Fluent Builder API: Define states, transitions, and actions with method chaining
- Async Entry/Exit Actions: Execute SiLA2 service calls on state transitions
- Workflow Context: Thread-safe
ConcurrentDictionary-backed data sharing between workflow steps - Retry Policies: Configurable retry with constant or exponential backoff (with capped max delay)
- Auto-Advance: Automatic state progression via completion triggers with infinite-loop protection
- Progress Events: Subscribe to workflow state changes, retries, and errors via
EventHandler<WorkflowProgress<TState>> - OpenTelemetry: Built-in distributed tracing via
System.Diagnostics.ActivitySource - DOT Graph Export: Visualize state machines with Graphviz (UML DOT format)
- Dependency Injection: First-class ASP.NET Core
IServiceCollectionintegration - Cancellation Support: All async operations respect
CancellationTokenpropagation - Thread Safety: Workflow execution is protected with
SemaphoreSlimlocking
Installation
dotnet add package SiLA2.StateMachine
Platform Compatibility
| Target Framework | Supported |
|---|---|
| .NET 10.0+ | Yes (full feature support) |
| .NET Standard 2.0 | Yes |
| .NET Framework 4.6.1+ | Yes (via netstandard2.0) |
| .NET Core 2.0+ | Yes (via netstandard2.0) |
This package multi-targets net10.0 and netstandard2.0, allowing it to be used from both modern .NET and legacy .NET Framework projects. PolySharp is used to backport modern C# language features (e.g. required, init) to netstandard2.0.
Dependencies
| Package | Purpose |
|---|---|
| Stateless 5.20.1 | Underlying finite state machine engine |
| Microsoft.Extensions.Logging.Abstractions | ILogger integration (no concrete logger required) |
| PolySharp | Polyfills for C# 11+ features on older targets |
Quick Start
using SiLA2.StateMachine;
using SiLA2.StateMachine.Models;
// Define states and triggers
enum State { Idle, Processing, Done, Error }
enum Trigger { Start, Complete, Fault }
// Build workflow
var workflow = new SilaWorkflowBuilder<State, Trigger>()
.StartIn(State.Idle)
.Permit(State.Idle, Trigger.Start, State.Processing)
.Permit(State.Processing, Trigger.Complete, State.Done)
.Permit(State.Processing, Trigger.Fault, State.Error)
.InState(State.Processing)
.OnEntry(async (ctx, sp, ct) =>
{
// Call SiLA2 services here
ctx.Set("result", 42);
})
.OnCompleted(Trigger.Complete)
.OnError(Trigger.Fault, maxRetries: 3, retryDelayMs: 1000)
.Build();
// Run workflow
await workflow.FireAsync(Trigger.Start);
Console.WriteLine($"State: {workflow.CurrentState}"); // Done
Console.WriteLine($"Result: {workflow.Context.Get<int>("result")}"); // 42
Architecture
Key Types
| Type | Description |
|---|---|
SilaWorkflowBuilder<TState, TTrigger> |
Fluent builder — separates workflow definition from execution |
ISilaWorkflow<TState, TTrigger> |
Runtime workflow interface — fire triggers, query state, subscribe to progress |
SilaWorkflow<TState, TTrigger> |
Internal engine wrapping Stateless with retry logic and tracing |
IWorkflowContext |
Thread-safe key-value data bag flowing through the workflow |
WorkflowContext |
Default ConcurrentDictionary-backed implementation |
WorkflowErrorPolicy |
Retry configuration: NoRetry, SimpleRetry, or ExponentialBackoff |
WorkflowProgress<TState> |
Event payload with FromState, ToState, Exception, RetryAttempt, and Timestamp |
WorkflowStatus |
Enum: Created → Running → Completed / Faulted / Aborted |
ServiceCollectionExtensions |
AddSilaStateMachine() and AddSilaWorkflow<>() DI helpers |
WorkflowActivitySource |
Static ActivitySource named "SiLA2.StateMachine" for OpenTelemetry |
Execution Flow
When RunAsync() or FireAsync() is called, the engine follows this sequence:
- Transition — The Stateless state machine validates and executes the trigger
- Entry Action — The configured
OnEntryaction runs (typically a SiLA2 gRPC call) - Retry Loop — If the entry action throws, the
WorkflowErrorPolicydetermines whether to retry or escalate - Auto-Advance — If
OnCompleted(trigger)is configured, that trigger fires automatically, returning to step 1 for the next state - Completion — When no more triggers are permitted,
Statustransitions toCompleted
Example: Plate Assay Workflow
The following diagram shows a complete lab automation workflow orchestrating multiple SiLA2 services — from network discovery through liquid handling, incubation, measurement, and reporting.
State Diagram
stateDiagram-v2
[*] --> Idle
Idle --> Discovering : Start
Discovering --> Aspirating : ServersFound
Discovering --> Error : Fault
Aspirating --> Dispensing : AspirateComplete
Aspirating --> Error : Fault
Dispensing --> Incubating : DispenseComplete
Dispensing --> Error : Fault
Incubating --> Measuring : IncubationComplete
Incubating --> Error : Fault
Measuring --> Reporting : MeasurementComplete
Measuring --> Error : Fault
Reporting --> Completed : ReportComplete
Error --> Idle : Retry
Error --> Idle : Reset
Completed --> Idle : Reset
Completed --> [*]
State Descriptions
| State | SiLA2 Service | Action |
|---|---|---|
| Discovering | — | Discover SiLA2 servers on the network via mDNS; store host endpoints in context |
| Aspirating | LiquidHandler | Aspirate sample from source plate; store aspiratedVolume in context |
| Dispensing | LiquidHandler | Dispense sample into target plate wells (A1–H12) |
| Incubating | TemperatureController | Set target temperature (37 °C) and wait for incubation to complete |
| Measuring | PlateReader | Read absorbance across all wells; store absorbanceResults array in context |
| Reporting | — | Compute min/max/average and generate the assay report |
| Error | — | Halt — awaiting Retry or Reset trigger to recover |
Full Example Code
The complete working example is at src/Examples/WorkflowOrchestration/:
dotnet run --project src/Examples/WorkflowOrchestration/SiLA2.StateMachine.Example.csproj
<details> <summary>Defining the workflow (click to expand)</summary>
var workflow = new SilaWorkflowBuilder<AssayState, AssayTrigger>()
.StartIn(AssayState.Idle)
// Transitions
.Permit(AssayState.Idle, AssayTrigger.Start, AssayState.Discovering)
.Permit(AssayState.Discovering, AssayTrigger.ServersFound, AssayState.Aspirating)
.Permit(AssayState.Discovering, AssayTrigger.Fault, AssayState.Error)
.Permit(AssayState.Aspirating, AssayTrigger.AspirateComplete, AssayState.Dispensing)
.Permit(AssayState.Aspirating, AssayTrigger.Fault, AssayState.Error)
// ... (remaining transitions)
// State configuration with SiLA2 service calls
.InState(AssayState.Aspirating)
.WithDescription("Aspirate sample from source plate using LiquidHandler")
.OnEntry(async (ctx, sp, ct) =>
{
var liquidHandler = sp.GetRequiredService<ILiquidHandlerClient>();
var host = ctx.Get<string>("liquidHandler.host");
await liquidHandler.AspirateAsync(100.0, ct);
ctx.Set("aspiratedVolume", 100.0);
})
.OnCompleted(AssayTrigger.AspirateComplete)
.OnError(AssayTrigger.Fault, maxRetries: 2, retryDelayMs: 1000)
.Build(services, logger);
</details>
Core Concepts
States and Triggers
States and triggers are defined as enums (or any struct). The workflow transitions between states when triggers are fired.
enum AssayState { Idle, Aspirating, Dispensing, Incubating, Measuring, Done, Error }
enum AssayTrigger { Start, AspirateComplete, DispenseComplete, IncubateComplete, MeasureComplete, Fault }
Workflow Builder
The SilaWorkflowBuilder<TState, TTrigger> separates workflow definition from execution. This means you can register a builder as a singleton in DI and create many workflow instances from it, each with their own context and state.
var workflow = new SilaWorkflowBuilder<AssayState, AssayTrigger>()
.StartIn(AssayState.Idle)
// Define transitions
.Permit(AssayState.Idle, AssayTrigger.Start, AssayState.Aspirating)
.Permit(AssayState.Aspirating, AssayTrigger.AspirateComplete, AssayState.Dispensing)
// Configure state behavior
.InState(AssayState.Aspirating)
.WithDescription("Aspirate sample from source plate")
.OnEntry(async (ctx, sp, ct) =>
{
var liquidHandler = sp.GetRequiredService<ILiquidHandlerClient>();
await liquidHandler.AspirateAsync(100.0, ct);
ctx.Set("aspiratedVolume", 100.0);
})
.OnCompleted(AssayTrigger.AspirateComplete)
.OnError(AssayTrigger.Fault, maxRetries: 2, retryDelayMs: 1000)
.Build(services, logger);
Note:
InState()sets the "current" state being configured — all subsequentOnEntry,OnExit,OnCompleted, andOnErrorcalls apply to that state untilInState()is called again.
Workflow Context
The IWorkflowContext is a thread-safe data bag (backed by ConcurrentDictionary<string, object> with case-insensitive keys) for passing values between states:
// In state A — store results
ctx.Set("temperature", 37.5);
ctx.Set("sampleId", "SAMPLE-001");
// In state B — retrieve results
var temp = ctx.Get<double>("temperature"); // throws KeyNotFoundException if missing
if (ctx.TryGet<string>("sampleId", out var id)) // safe retrieval
{
Console.WriteLine($"Processing {id}");
}
// Other operations
ctx.ContainsKey("temperature"); // true
ctx.Keys; // ["temperature", "sampleId"]
ctx.Remove("temperature");
ctx.Clear();
Entry and Exit Actions
Entry actions receive three parameters: the workflow context, a service provider for resolving dependencies, and a cancellation token:
// Full signature with CancellationToken
.OnEntry(async (IWorkflowContext ctx, IServiceProvider sp, CancellationToken ct) =>
{
var client = sp.GetRequiredService<ILiquidHandlerClient>();
await client.AspirateAsync(100.0, ct);
})
// Convenience overload without CancellationToken
.OnEntry(async (ctx, sp) =>
{
await Task.Delay(1000);
})
// Synchronous overload
.OnEntry((ctx, sp) =>
{
ctx.Set("ready", true);
})
Exit actions run during cleanup when leaving a state (no retry support):
.OnExit(async (ctx, sp, ct) =>
{
// Release resources, close channels, etc.
})
Error Policies
Configure retry behavior for transient hardware or network failures:
// Exponential backoff — delays: 1s, 2s, 4s, 8s, 16s (capped at MaxRetryDelay)
.InState(State.Measuring)
.OnEntry(async (ctx, sp, ct) => { /* may fail */ })
.OnError(Trigger.Fault, WorkflowErrorPolicy.ExponentialBackoff(
maxRetries: 5,
initialDelay: TimeSpan.FromSeconds(1),
multiplier: 2.0))
| Factory Method | Behavior |
|---|---|
NoRetry |
Fail immediately — fire error trigger on first exception (default) |
SimpleRetry(n, delay) |
Retry n times with constant delay between attempts |
ExponentialBackoff(n, delay, mult) |
Retry n times with delay × mult^attempt (capped at MaxRetryDelay, default 1 min) |
When all retries are exhausted, the configured ErrorTrigger fires (if permitted), and Status becomes Faulted.
Auto-Advance
When OnCompleted(trigger) is set on a state, that trigger fires automatically after the entry action succeeds. This enables fully automatic "step-through" workflows:
Idle --Start--> Discovering --ServersFound--> Aspirating --AspirateComplete--> ...
(auto) (auto)
A safety guard limits auto-advance to 100 consecutive steps to prevent infinite loops. Exceeding this sets Status = Faulted.
Progress Events
Subscribe to real-time workflow progress including retries and errors:
workflow.ProgressChanged += (sender, progress) =>
{
Console.WriteLine($"[{progress.Timestamp:HH:mm:ss}] {progress.FromState} → {progress.ToState}");
if (progress.Exception != null)
Console.WriteLine($" Error (attempt {progress.RetryAttempt}): {progress.Exception.Message}");
if (progress.Message != null)
Console.WriteLine($" {progress.Message}");
};
The WorkflowProgress<TState> payload contains:
| Property | Type | Description |
|---|---|---|
FromState |
TState |
Origin state of the transition |
ToState |
TState |
Destination state |
Timestamp |
DateTimeOffset |
UTC timestamp of the event |
Message |
string? |
Human-readable description (e.g. "Auto-advanced via 'Complete'") |
Exception |
Exception? |
The exception that caused a retry or fault |
RetryAttempt |
int |
Current retry attempt number (0 if no retries) |
Workflow Status Lifecycle
| Status | Description |
|---|---|
Created |
Workflow built via Build() but not yet started |
Running |
RunAsync() or FireAsync() has been called |
Paused |
Reserved for future use |
Completed |
No more permitted triggers — workflow finished successfully |
Aborted |
AbortAsync() was called |
Faulted |
Unrecoverable error — retries exhausted or auto-advance loop detected |
ASP.NET Core Integration
Register the state machine infrastructure and pre-configured workflow builders via dependency injection:
// Program.cs
using SiLA2.StateMachine.Extensions;
// Option 1: Register infrastructure only (create builders manually)
builder.Services.AddSilaStateMachine();
// Option 2: Register a pre-configured workflow builder as a singleton
builder.Services.AddSilaWorkflow<AssayState, AssayTrigger>(builder =>
{
builder
.StartIn(AssayState.Idle)
.Permit(AssayState.Idle, AssayTrigger.Start, AssayState.Processing)
// ... configure workflow
});
// In a controller or service — inject the builder and create workflow instances
public class AssayController(
SilaWorkflowBuilder<AssayState, AssayTrigger> builder,
IServiceProvider services,
ILogger<AssayController> logger)
{
public async Task<IActionResult> RunAssay()
{
// Each call creates a fresh workflow instance with its own context
var workflow = builder.Build(services, logger);
await workflow.FireAsync(AssayTrigger.Start);
return Ok(new { workflow.Status, State = workflow.CurrentState });
}
}
AddSilaStateMachine() registers IWorkflowContext as scoped, so each HTTP request (or scope) gets its own context. AddSilaWorkflow<>() registers the builder as a singleton factory.
OpenTelemetry Integration
The library emits distributed traces via System.Diagnostics.ActivitySource under the source name SiLA2.StateMachine:
// Subscribe to workflow traces
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddSource("SiLA2.StateMachine"); // or WorkflowActivitySource.Name
});
Traced Activities
| Activity Name | Scope | Tags |
|---|---|---|
Workflow.Run |
Full RunAsync() execution |
workflow.initial_state |
Workflow.Fire |
Single FireAsync() call |
workflow.trigger, workflow.from_state |
Workflow.StateEntry |
Entry action execution (per attempt) | workflow.state, workflow.attempt |
Advanced Patterns
Hierarchical (Sub) States
.SubstateOf(State.Processing, State.Running)
// Processing is a substate of Running — triggers permitted on Running
// are also permitted on Processing
Conditional Transitions (Guard Clauses)
.PermitIf(State.Idle, Trigger.Start, State.FastPath,
() => _config.UseFastPath, "Fast path enabled")
.PermitIf(State.Idle, Trigger.Start, State.SlowPath,
() => !_config.UseFastPath, "Slow path enabled")
Reentrant Transitions
.PermitReentry(State.Polling, Trigger.Poll)
// The state transitions to itself — entry/exit actions re-execute
Ignoring Triggers
.Ignore(State.Running, Trigger.Start)
// The Start trigger is silently ignored in the Running state (no exception)
Raw Stateless Access
For advanced Stateless features not exposed by the fluent API:
.ConfigureStateMachine(sm =>
{
sm.Configure(State.Active)
.InternalTransition(Trigger.Heartbeat, () => { /* no state change */ });
})
DOT Graph Export
Generate a Graphviz-compatible DOT graph for documentation or debugging:
var dotGraph = workflow.ToDotGraph();
File.WriteAllText("workflow.dot", dotGraph);
// Render: dot -Tpng workflow.dot -o workflow.png
// Or paste into https://dreampuf.github.io/GraphvizOnline/
Pre-populated Context
Pass initial data into the workflow at build time:
var context = new WorkflowContext();
context.Set("batchId", "BATCH-2025-001");
context.Set("targetTemperature", 37.0);
var workflow = builder.Build(services, logger, context);
Manual Step-Through
Omit OnCompleted() to pause the workflow at each state, allowing external control:
.InState(State.WaitingForApproval)
.OnEntry(async (ctx, sp, ct) =>
{
// Notify approver, then workflow pauses here
})
// No .OnCompleted() — workflow waits for manual FireAsync()
// Later, after approval is received:
if (workflow.CanFire(Trigger.Approved))
await workflow.FireAsync(Trigger.Approved);
Builder API Reference
| Method | Description |
|---|---|
StartIn(state) |
Set the initial state (required before Build()) |
InState(state) |
Begin configuring a specific state |
WithDescription(text) |
Add a human-readable description to the current state |
OnEntry(action) |
Async action executed when entering the state (3 overloads) |
OnExit(action) |
Async cleanup action when leaving the state |
OnCompleted(trigger) |
Auto-fire this trigger after successful entry (enables auto-advance) |
OnError(trigger, policy) |
Error trigger + retry policy when entry action fails |
OnError(trigger, maxRetries, retryDelayMs) |
Convenience overload creating a SimpleRetry policy |
Permit(from, trigger, to) |
Define a permitted transition |
PermitIf(from, trigger, to, guard, desc?) |
Conditional transition with a guard function |
PermitReentry(state, trigger) |
Self-transition (re-executes entry/exit actions) |
Ignore(state, trigger) |
Silently ignore a trigger in a state |
SubstateOf(sub, super) |
Declare a hierarchical state relationship |
ConfigureStateMachine(action) |
Raw access to the underlying StateMachine<TState, TTrigger> |
Build(services?, logger?, context?) |
Create an ISilaWorkflow<TState, TTrigger> instance |
Workflow Instance API
| Member | Description |
|---|---|
CurrentState |
The current state of the workflow |
Status |
High-level status: Created, Running, Completed, Faulted, Aborted |
PermittedTriggers |
Triggers currently valid from the current state |
Context |
The IWorkflowContext data bag |
RunAsync(ct) |
Start the workflow (executes initial state's entry action + auto-advance) |
FireAsync(trigger, ct) |
Manually fire a trigger to cause a state transition |
CanFire(trigger) |
Check if a trigger is permitted without firing |
AbortAsync(reason?) |
Abort the workflow (sets Status = Aborted) |
ToDotGraph() |
Export the state machine as a UML DOT graph string |
ProgressChanged |
Event raised on every transition, retry, and error |
Troubleshooting
| Error | Cause | Solution |
|---|---|---|
Initial state not set |
Missing StartIn() call |
Call StartIn(state) before Build() |
No state selected |
Missing InState() call |
Call InState(state) before OnEntry() / OnError() / etc. |
Trigger 'X' is not permitted in state 'Y' |
Invalid transition | Check CanFire() first, or add a Permit() for that state/trigger pair |
Max auto-advance depth exceeded |
Infinite auto-advance loop | Ensure the workflow reaches a terminal state (no OnCompleted) |
KeyNotFoundException in context |
Missing ctx.Set() in prior state |
Use ctx.TryGet() for defensive access, or verify data flow between states |
No IServiceProvider services available |
Build() called without services |
Pass your DI IServiceProvider to Build(services, logger) |
Related Packages
- SiLA2.Client — Server discovery and gRPC channel management
- SiLA2.Core — Core SiLA2 server implementation
- SiLA2.AspNetCore — ASP.NET Core hosting integration
- Stateless — The underlying state machine library
License
MIT License — See LICENSE file for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Stateless (>= 5.20.1)
-
net10.0
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Stateless (>= 5.20.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.