The DataStateMachine Life Cycle
From the moment a workflow node is triggered to the point its state reaches a terminal status — every phase, every transition, every recovery path.
Phase Overview
The DataStateMachine lifecycle spans five phases. Each phase has a clear owner and defined responsibilities.
| Phase | Owner | What Happens |
|---|---|---|
| 1. Initialisation | BaseNodeExecutor.OnPreProcess |
Factory resolves handler; builds base StateHandlerContext; wraps in ContextualDataStateMachine |
| 2. Node Logic | Node executor (developer code) | Developer calls TryAcquireLockAsync / ExistsAsync / SetAsync / UpsertAsync on IDataStateMachine |
| 3. State Transition | Handler (BuiltIn or Custom) | Records written to backing store; Processing lock set; status transitions managed |
| 4. Completion or Failure | Node executor (try/catch) | SetAsync(Processed) or ReleaseLockAsFailedAsync on success/exception |
| 5. Lock Recovery | CatchupWorkerService | Background job resets expired Processing records (LockedUntil < now → Failed) |
Full Lifecycle Diagram
Workflow Engine
│ node execution triggered
▼
BaseNodeExecutor.OnPreProcess
│
├── InitialiseDataStateMachineAsync()
│ │
│ ├── read node config → DataStateMachineConfig (handler key, scope, options)
│ │
│ ├── DataStateMachineFactory.CreateAsync(elem, thread, config)
│ │ │
│ │ ├── resolve IDataStateMachineHandler by HandlerKey
│ │ │ └── if key not found → throw (misconfiguration)
│ │ │
│ │ ├── resolve ScopeID from scope name (e.g. "Element" → 4)
│ │ │
│ │ └── build StateHandlerContext {
│ │ StateLogScopeID, ScopeCode,
│ │ EntityID, EntityExecutionID,
│ │ TenantID, SourceAppID,
│ │ HandlerConfig
│ │ // ItemKey fields left at "" — set per-call
│ │ }
│ │
│ └── assign to _dataStateMachine = new ContextualDataStateMachine(handler, baseCtx)
│ OR NullDataStateMachine.Instance (if no config block)
│
└── node proceeds to Execute phase
Node.ExecuteAsync(ctx, settings, ct)
│
├── await DataStateMachine.ExistsAsync(itemKey, entityKey)
│ └── ContextualDataStateMachine: builds callCtx = baseCtx with { ItemKey=itemKey, ItemEntityKey=entityKey }
│ → handler.ExistsAsync(callCtx, ct)
│
├── [if not exists] await DataStateMachine.TryAcquireLockAsync(itemKey, entityKey)
│ └── handler writes Processing record with LockedUntil = UtcNow + 30 min
│ → returns true (lock acquired) or false (already locked by another thread)
│
├── [if locked] perform business operation
│
├── await DataStateMachine.SetAsync(itemKey, entityKey, Processed, data)
│ └── handler updates record to Processed; clears lock
│
└── [on exception] await DataStateMachine.ReleaseLockAsFailedAsync(itemKey, entityKey, error)
└── handler updates record to Failed; clears lock
RECOVERY PATH (background)
CatchupWorkerService (scheduled)
│
└── ProcessStateLogRepository.ResetExpiredLocksAsync()
│ WHERE Status = Processing AND LockedUntil < UtcNow
└── UPDATE Status = Failed, LockedUntil = NULL
STATUS TRANSITION DIAGRAM
None ──→ Processing ──→ Processed (happy path)
├──→ Failed (exception / releaseLockAsFailed)
└──→ [timeout] → Failed (via CatchupWorker)
Processed ──→ Superseded (when UpsertAsync adds a newer record for same key)
Any ──→ Skipped (node explicitly calls SetAsync(Skipped))
Any ──→ Expired (record exceeds retention policy)
Annotated Sample — Invoice Processing Node
Walk through a concrete example: an InvoiceDispatchExecutor that uses the built-in handler at Element scope.
Step 1 — Node Configuration
// Node settings in Flow Studio
{
"invoiceApiEndpoint": "https://api.billing.internal/dispatch",
"dataStateMachine": {
"handler": "built-in",
"scope": "Element",
"options": {}
}
}
Step 2 — Initialisation (automatic, BaseNodeExecutor)
// Called automatically in OnPreProcess — developer writes none of this
var config = Settings.DataStateMachineConfig; // parsed from JSON above
_dataStateMachine = await _factory.CreateAsync(element, thread, config);
// → ContextualDataStateMachine wrapping BuiltInDataStateMachineHandler
// → base context: ScopeID=4(Element), EntityID=invoiceElementId, TenantID=tenant
Step 3 — Node Execute (developer writes this)
protected override async Task<NodeExecutionResult> ExecuteAsync(
NodeExecutionContext ctx, InvoiceSettings settings, CancellationToken ct)
{
var invoiceId = ctx.Input.GetString("invoiceId"); // e.g. "INV-2024-00892"
// Guard: has this invoice already been dispatched?
if (await DataStateMachine.ExistsAsync(invoiceId, entityKey: "invoice", ct: ct))
return NodeExecutionResult.Skip($"Invoice {invoiceId} already dispatched");
// Acquire processing lock — prevents concurrent duplicate runs
bool locked = await DataStateMachine.TryAcquireLockAsync(invoiceId, entityKey: "invoice", ct: ct);
if (!locked)
return NodeExecutionResult.Skip($"Invoice {invoiceId} is being processed by another thread");
try
{
// Perform the actual work
var response = await _billingApi.DispatchAsync(invoiceId, settings.ApiEndpoint, ct);
// Mark complete — store response envelope
await DataStateMachine.SetAsync(
itemKey: invoiceId,
itemEntityKey: "invoice",
status: IdempotencyStatus.Processed,
data: JsonSerializer.Serialize(new { dispatchedAt = DateTime.UtcNow, response }),
ct: ct);
return NodeExecutionResult.Success(new { invoiceId, dispatched = true });
}
catch (Exception ex)
{
// Release lock so CatchupWorker (or manual retry) can re-process
await DataStateMachine.ReleaseLockAsFailedAsync(invoiceId, entityKey: "invoice",
errorDetail: ex.Message, ct: ct);
throw;
}
}
Step 4 — What Gets Written to Process_StateLogs
| Field | Value | Notes |
|---|---|---|
StateLogScopeID |
4 | Element scope |
EntityID |
7381 | The invoice element's DB ID |
ItemKey |
"INV-2024-00892" | The specific invoice |
ItemEntityKey |
"invoice" | Entity type discriminator |
Status |
1 (Processed) | Terminal — will not be re-processed |
LockedUntil |
NULL | Cleared on success |
Data |
{"dispatchedAt":"...", ...} | Standard data envelope |
Duplicate Run — What Happens
If the same workflow runs again with the same invoiceId (e.g. from a retry trigger or duplicate message), ExistsAsync returns true immediately. The node returns Skip without ever calling the billing API. No duplicate charge. No race condition. One line of guard code.
TryAcquireLockAsync returns false, it means another thread currently holds the Processing lock. The node should Skip, not throw. The other thread will either complete (Processed) or time out (Failed via CatchupWorker after 30 minutes). Design your retry logic accordingly.