Key Concepts
The seven scope levels, the eleven StateHandlerContext fields, the seven IdempotencyStatus values, and the concurrency lock pattern. Understanding these is the foundation for using any DataStateMachine handler.
Scope Hierarchy
Scope determines how broadly a state record applies. A record at App scope is shared across all workflows, threads, and executions for a tenant. A record at ElementExecution scope is specific to a single node's single run.
| ScopeID | Scope Name | Applies To | Typical Use |
|---|---|---|---|
| 1 | App | Entire application | Global rate limits, once-per-day operations |
| 2 | Process | One workflow definition | Process-level deduplication |
| 3 | Thread | One thread within a workflow | Thread-level sequencing |
| 4 | Element | One node definition | Node-level idempotency (most common) |
| 5 | ProcessExecution | One specific workflow run | Per-execution tracking |
| 6 | ThreadExecution | One specific thread run | Per-thread-run tracking |
| 7 | ElementExecution | One specific node run | Finest granularity |
StateHandlerContext — 11 Fields
StateHandlerContext is a C# immutable record. The factory populates 7 fields once. ContextualDataStateMachine adds 4 item key fields per-call using a C# with expression before dispatching to the handler.
| Field | Type | Set By | Purpose |
|---|---|---|---|
| StateLogScopeID | int | Factory | Scope level (1–7) |
| ScopeCode | string | Factory | Human-readable scope name |
| EntityID | int | Factory | The node's entity DB ID |
| EntityExecutionID | int? | Factory | Execution ID (null if not execution-scoped) |
| TenantID | int | Factory | Multi-tenant isolation |
| SourceAppID | int? | Factory | Originating application ID |
| HandlerConfig | DataStateMachineConfig | Factory | Raw handler config (options dictionary) |
| ItemKey | string | ContextualDataStateMachine | Primary key for this item (e.g. invoice ID) |
| SubItemKey | string | ContextualDataStateMachine | Secondary discriminator (optional) |
| ItemEntityKey | string | ContextualDataStateMachine | Entity type (e.g. "invoice", "customer") |
| SubItemEntityKey | string | ContextualDataStateMachine | Sub-entity type (optional) |
// Inside ContextualDataStateMachine — developer never sees this
private StateHandlerContext CallCtx(
string itemKey, string itemEntityKey,
string subItemKey = "", string subItemEntityKey = "")
=> _baseCtx with
{
ItemKey = itemKey,
SubItemKey = subItemKey,
ItemEntityKey = itemEntityKey,
SubItemEntityKey = subItemEntityKey
};
// Every public method on ContextualDataStateMachine builds a fresh ctx
public Task<bool> ExistsAsync(
string itemKey, string itemEntityKey,
string subItemKey = "", string subItemEntityKey = "",
CancellationToken ct = default)
=> _handler.ExistsAsync(
CallCtx(itemKey, itemEntityKey, subItemKey, subItemEntityKey), ct);
IdempotencyStatus — 7 Values
| Value | Int | Meaning | Terminal? |
|---|---|---|---|
| None | 0 | No record exists | — |
| Processed | 1 | Successfully completed | Yes |
| Processing | 2 | Lock held — currently being processed | No (lock expires after 30 min) |
| Failed | 3 | Processing failed; lock released | No (can be retried) |
| Skipped | 4 | Deliberately skipped | Yes |
| Superseded | 5 | Replaced by a newer record (UpsertAsync) | Yes |
| Expired | 6 | Record beyond retention policy | Yes |
TryAcquireLock → Act → SetAsync Pattern
This three-step pattern is the standard concurrency guard for any node that must not process the same entity twice concurrently.
// Step 1: Check terminal state — skip if already done
if (await DataStateMachine.ExistsAsync(entityId, entityKey))
return NodeExecutionResult.Skip("Already processed");
// Step 2: Acquire processing lock — only ONE caller succeeds
bool locked = await DataStateMachine.TryAcquireLockAsync(entityId, entityKey);
if (!locked)
return NodeExecutionResult.Skip("Concurrent processing in progress");
// Step 3: Act — do the work inside a try/catch
try {
var result = await DoWorkAsync(entityId);
await DataStateMachine.SetAsync(entityId, entityKey, IdempotencyStatus.Processed, data: result);
return NodeExecutionResult.Success(result);
}
catch (Exception ex) {
await DataStateMachine.ReleaseLockAsFailedAsync(entityId, entityKey, ex.Message);
throw;
}
NullDataStateMachine — The Safe Default
When a node has no dataStateMachine config block, the factory assigns NullDataStateMachine.Instance. This is a singleton no-op: all methods return safe values (ExistsAsync → false, TryAcquireLockAsync → true, etc.) and nothing is written to the database.
This is the designed behaviour for parallel fork lanes, which often process independent data and don't need state tracking. Adding the config block opts in. Removing it opts out cleanly.
// NullDataStateMachine — all operations are no-ops
public class NullDataStateMachine : IDataStateMachine
{
public static readonly NullDataStateMachine Instance = new();
public Task<bool> ExistsAsync(...) => Task.FromResult(false);
public Task<bool> TryAcquireLockAsync(...) => Task.FromResult(true);
public Task SetAsync(...) => Task.CompletedTask;
public Task UpsertAsync(...) => Task.CompletedTask;
public Task ReleaseLockAsFailedAsync(...) => Task.CompletedTask;
public Task<StateRecord?> GetAsync(...) => Task.FromResult<StateRecord?>(null);
public Task<IReadOnlyList<StateRecord>> QueryAsync(...) =>
Task.FromResult<IReadOnlyList<StateRecord>>(Array.Empty<StateRecord>());
}