Flow Studio
DataStateMachine
A pluggable, per-node state and idempotency engine. Every workflow node can own its state, lock against duplicate runs, and persist records to any backing store — built-in SQL, custom databases, REST APIs, or blockchain ledgers.
The Problem It Solves
Workflow engines orchestrate steps efficiently but treat state as a byproduct — logs belong to the engine, not your business. When you need "did this entity already get processed in the last 72 hours?", you write custom code. Every time. For every node. DataStateMachine makes per-node, per-entity state tracking a first-class platform feature.
Architecture Overview
Node PreProcess
│
▼
DataStateMachineFactory (Scoped DI)
│ resolves handler by HandlerKey from node config
▼
IDataStateMachineHandler ←── plugin assembly (built-in, sales-leads, or custom)
│
▼
ContextualDataStateMachine
│ wraps base context + per-call item keys via C# `with`
▼
IDataStateMachine ←── what the node developer calls
│
├── TryAcquireLockAsync(itemKey, ...) → locks entity for processing
├── SetAsync(itemKey, Processed, data) → marks complete
├── UpsertAsync(itemKey, data) → append-only record with SupersededByID
└── ExistsAsync / GetAsync / QueryAsync → read state
Three Modes of Operation
| Mode | Config | Behaviour |
|---|---|---|
| Built-In Handler | handler: "built-in" |
Writes to Process_StateLogs SQL table. Full idempotency, locks, history. |
| Custom Handler | handler: "your-key" |
Your plugin assembly. Any backing store — DB, API, blockchain, Redis. |
| No-Op (Null) | (config block absent) | NullDataStateMachine.Instance — safe default, no DB writes, safe for parallel fork lanes. |
What a Node Developer Sees
// In your node executor — IDataStateMachine is pre-bound to scope/entity/tenant
var exists = await DataStateMachine.ExistsAsync(invoice.Id, entityKey: "invoice");
if (exists) return NodeExecutionResult.Skip("Already processed");
bool locked = await DataStateMachine.TryAcquireLockAsync(invoice.Id, entityKey: "invoice");
if (!locked) return NodeExecutionResult.Skip("Being processed by another thread");
try {
await ProcessInvoiceAsync(invoice);
await DataStateMachine.SetAsync(invoice.Id, entityKey: "invoice",
status: IdempotencyStatus.Processed, data: result);
}
catch (Exception ex) {
await DataStateMachine.ReleaseLockAsFailedAsync(invoice.Id, entityKey: "invoice", error: ex.Message);
throw;
}
Guide Pages
| # | Page | What You'll Learn |
|---|---|---|
| 1 | Plugin Architecture | How to bring your own handler; BYOD concept; plugin discovery |
| 2 | Life Cycle | Complete state machine lifecycle; annotated invoice walkthrough |
| 3 | Key Concepts | Scope hierarchy, StateHandlerContext, IdempotencyStatus enum |
| 4 | Node Configuration | JSON config block; handler/scope/options; NullDataStateMachine default |
| 5 | Built-In Handler | Process_StateLogs schema; handler code; lock/unlock pattern |
| 6 | SalesLeads Demo | Custom handler walkthrough; CRMSimple_SalesLeads table |
| 7 | Node as Entity Owner | Binding nodes to DB tables, REST APIs, blockchain |
| 8 | NodeCapabilities | Node as DataStateMachine + DataService provider |
| 9 | NodePolicies | Security enforcement; handler/scope access control |
| 10 | GuardRails Integration | State-driven validation; custom guard engines |
| 11 | Business Use Cases | 12 real-world scenarios across industries |
| 12 | Data Engineering | Lineage, dedup, quality gates, event sourcing |
| 13 | Data Science & Enrichment | ML labels, enrichment pipelines, feature stores |
Tip: If the
dataStateMachine config block is absent from a node, NullDataStateMachine.Instance is injected automatically — a no-op singleton that never writes to the database. No configuration is required to use the safe default.