Portal Community

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
Element scope (4) is the recommended default for most idempotency use cases. It ensures the same node won't re-process the same entity key across any execution of that workflow.

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
Failed is NOT terminal. A Failed record can be re-processed. Processed and Skipped are terminal — ExistsAsync returns true for both, preventing re-processing.

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>());
}