Stateflows.Testing 0.21.1-beta

Prefix Reserved
This is a prerelease version of Stateflows.Testing.
dotnet add package Stateflows.Testing --version 0.21.1-beta
                    
NuGet\Install-Package Stateflows.Testing -Version 0.21.1-beta
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Stateflows.Testing" Version="0.21.1-beta" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Stateflows.Testing" Version="0.21.1-beta" />
                    
Directory.Packages.props
<PackageReference Include="Stateflows.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Stateflows.Testing --version 0.21.1-beta
                    
#r "nuget: Stateflows.Testing, 0.21.1-beta"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Stateflows.Testing@0.21.1-beta
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Stateflows.Testing&version=0.21.1-beta&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Stateflows.Testing&version=0.21.1-beta&prerelease
                    
Install as a Cake Tool

Introduction

Stateflows is a .NET-based framework for building executable State Machines. It targets .NET 8.0+ and uses actor model to achieve scalability and robustness. It comes with minimum dependencies and enables developers to write state-oriented business logic in a testable, object-oriented, enterprise-class code.

Getting Started

Go to Stateflows homepage to learn more.

Execution failures

State machines, activities, actions, and entities execute against a detached copy of their stored context. Their shared persistence flow commits once, only after execution and executor cleanup succeed. A failed result or any recorded execution exception (including one handled by the behavior) returns EventStatus.Failed without saving context changes, initialization, configuration, or scheduling metadata. Stateless actions continue to discard their context on every execution.

Notifications are buffered until publisher state commits. Failed execution or a failed state commit discards buffered notifications. Immediate notifications, external side effects, and separate events sent to other behaviors or to itself are not rolled back. Contexts must support the framework's polymorphic JSON serialization used to isolate the working copy, including nested values.

Embedded activities, like embedded state machines, do not bubble unconsumed events marked with NoBubblingAttribute. Ordinary unconsumed events still bubble to the parent with forwarding disabled. State exit continues to send Finalize to embedded behaviors, even when immediate finalization has already taken effect.

Entity observers and interceptors

Entities support asynchronous, filter-style hooks, globally or for a single entity:

builder.AddEntities(entities => entities
    .AddObserver<TelemetryObserver>()
    .AddInterceptor<AuthorizationInterceptor>()
    .AddEntity<ICounter>("counter", entity => entity
        .AddObserver<CounterObserver>()
        .AddInterceptor<CounterInterceptor>()
    )
);

Generic registrations activate hooks through dependency injection, including constructor dependencies. Both registration levels also accept synchronous or asynchronous factories with (IServiceProvider services, IBehaviorActionContext context) parameters:

entities.AddObserver((services, context) => new TelemetryObserver());
entities.AddInterceptor(async (services, context) =>
{
    var policy = await services.GetRequiredService<PolicyProvider>().GetAsync(context.Behavior.Id);
    return new AuthorizationInterceptor(policy);
});

Hooks are resolved once per incoming event and reused throughout that event's execution. Global hooks wrap per-entity hooks; within each group, registration order is outermost first. Asynchronous factories, continuations, and hook cleanup are awaited.

Observing entity operations

Implement Stateflows.Entities.IEntityObserver with these filter-style methods:

Task OnMutationAsync<TTemplate, TMutationEvent>(
    IMutationObservationContext<TTemplate, TMutationEvent> context, Func<Task> next);
Task OnComputedFieldAsync<TTemplate, TField>(
    IComputedFieldObservationContext<TTemplate> context, Func<Task<TField>> next);
Task OnProjectionAsync<TTemplate, TProjection>(
    IProjectionObservationContext<TTemplate> context, Func<Task<TProjection>> next);

Each context exposes the typed Entity and IBehaviorActionContext capabilities, including Behavior. Mutation contexts also expose MutationEvent, computed-field contexts expose FieldName, and projection contexts expose their configured PublishScope. Awaiting next() exposes computed-field and projection results for inspection:

public async Task OnProjectionAsync<TTemplate, TProjection>(
    IProjectionObservationContext<TTemplate> context, Func<Task<TProjection>> next)
{
    try
    {
        var projection = await next();
        // Record telemetry about the result without changing it or the entity.
    }
    finally
    {
        // Cleanup/telemetry also runs if projection evaluation throws.
    }
}

Observers are observational: call and await next exactly once, propagate its exceptions, and do not change entity state or results. Missing or repeated continuation calls fail execution; leaving entity state unchanged and propagating exceptions remain the observer implementation's responsibility. Continuation results are preserved by the pipeline, not replaced by observers. Use an interceptor, not an observer, to reject events.

Computed-field and projection hooks run during initialization and dependency-driven recalculation, including cascades after mutations or external field writes. Mutation hooks wrap registered mutations, not field writes or initialization. Projection requests evaluate the projection again and invoke its hooks; ordinary field reads use cached values and do not invoke observer hooks. There are no entity lifecycle observer hooks. Field reads performed by observers do not become computed-field or projection dependencies.

Handling entity exceptions

Implement Stateflows.Entities.IEntityExceptionHandler to observe failures from entity initialization, registered mutations, computed fields, or projections. Register handlers globally or for an individual entity using DI activation or synchronous/asynchronous factories that receive (IServiceProvider services, IBehaviorActionContext context):

builder.AddEntities(entities => entities
    .AddExceptionHandler<TelemetryExceptionHandler>()
    .AddEntity<ICounter>("counter", entity => entity
        .AddExceptionHandler<CounterExceptionHandler>()
    )
);

IEntityInitializationExceptionContext exposes ImplicitInitialization and inherits the execution trigger metadata. Initialization handlers run for explicit initialization, custom initialization, and implicit initialization; failures in computed fields or projections during initialization also invoke their operation-specific handler. Mutation handlers wrap only the registered mutation delegate. Computed-field and projection handlers also run for dependency recalculations and projection requests. Handler contexts expose isolated entity proxies, so reads made by a handler do not add reactive dependencies.

Each asynchronous handler returns true to classify its exception as handled or false otherwise. This classification does not suppress the exception or fabricate a result: the original failure still propagates through normal execution, producing EventStatus.Failed and rolling back the entity's partial state. Global handlers run before per-entity handlers in registration order, and all handlers are invoked even after one returns true. A handler that throws is logged and recorded; remaining handlers still run and the original operation exception remains the failure.

Intercepting events

Implement Stateflows.Entities.IEntityInterceptor:

public async Task<EventStatus> ProcessEventAsync<TEvent>(
    Stateflows.Common.Context.Interfaces.IEventContext<TEvent> context,
    Func<Task<EventStatus>> next)
{
    if (!IsAllowed(context.Event))
        return EventStatus.Rejected; // No continuation: no implicit initialization or mutation.

    var status = await next();
    return status; // An interceptor may instead return a replacement status.
}

Interceptors wrap incoming event processing, including requests and explicit or implicit initialization. They may short-circuit without calling next, or call and await it at most once and replace its returned status. Repeated continuation calls fail execution. Replacing a status cannot undo external side effects already performed; reject before invoking the continuation to prevent execution.

These hooks belong to the shared entity runtime, not a particular hosting integration. When hosted in Orleans, the grain awaits the complete asynchronous hook pipeline before completing event execution. Without hooks, entity behavior is unchanged. Unhandled hook or operation exceptions follow the normal execution-failure and rollback rules above.

Entity OpenTelemetry tracing

The existing Stateflows.Extensions.OpenTelemetry package automatically instruments entities when registered with AddOpenTelemetry(); no separate entity package or per-entity tracing registration is needed:

builder.AddOpenTelemetry()
    .AddEntities(entities => entities.AddEntity<ICounter>("counter"));

The Stateflows activity source emits an internal event span named Entity 'counter:instance' processing 'EventName', tagged with EntityId, Event, and EventStatus. Its internal child spans describe Mutation 'EventName', Computed field 'FieldName', and Projection 'ProjectionType', with EntityId and the corresponding Mutation, Field, or Projection tag. Incoming serialized W3C trace context is honored, falling back to the ambient activity; parent-based sampling applies to the event and its operations.

Mark an event type with [NoTracing] or send a NoTracing event header to suppress all entity spans and tracer error logs for that event. Operation exceptions are recorded as exception events with error status, and failed event spans receive error status without marking the caller's span. Tracing observes failures without handling or suppressing them: normal EventStatus.Failed and entity rollback semantics remain unchanged, including initialization failures.

Orleans hosting

Stateflows.Runtimes.Orleans hosts registered state machines, activities, and actions through one shared Orleans grain contract and implementation. Each tenant and BehaviorId identifies a separate grain; no per-definition grain generation is needed.

Orleans hosting does not automatically dispatch Startup events or maintain startup indexes. Default instances are still initialized for configured tenants when their hosting silo starts. The shared framework's Startup event remains available for explicit sends and other runtimes. Previously persisted startup indexes are unused and can be removed from storage.

Register each silo's definitions using ISiloBuilder.AddStateflows(...). Definitions must remain fixed after startup, but different silos can register different sets. Every silo pulls the behavior classes and definition versions of each Active silo directly from that silo (through a small grain pinned to it) once per second. Capabilities are cached for the silo's lifetime (a restarted silo has a new address); failed or timed-out pulls are retried on every refresh, so recovery does not wait for a membership change, and silos that leave or die are dropped. Orleans silo metadata is not used. A silo always treats its own definitions as available, even before discovery completes.

Placement is filtered by the behavior class in the grain key. Among silos hosting the class, only those registering every version of it registered by any candidate silo are eligible. This keeps rolling upgrades safe when new silos keep the old versions (v1 → v1+v2): any persisted version can be processed, and new instances initialize with the newest version wherever they land. Existing activations keep running where they are until deactivated. If no silo hosts every version (an incompatible deployment, e.g. v1-only and v2-only silos), silos hosting the newest version are used and a warning is logged. An activation whose persisted version is not registered locally fails with an explicit error and never runs under another version; the instance becomes reachable again once a silo hosting all versions joins. Orleans selects among eligible silos using its configured placement strategy. If no silo hosting the class is known (e.g. the last one was lost), sends fail fast with "No eligible silo hosts Stateflows behavior"; persisted state is left intact and the instance resumes once a hosting silo rejoins. Reminders firing meanwhile are retried on later ticks. After abrupt termination, an instance reactivates on another eligible silo with its last committed state once Orleans declares the silo dead.

Locators discover remote definitions asynchronously. Discovery is eventually consistent: allow for membership propagation and the one-second refresh interval before locating newly available remote definitions. The same definition name and version on multiple silos must have compatible code, dependencies, and event/response types; this is not verified. Runtime registration does not distribute that code.

Resource budgets configured with AddResource(...).SetMaxConcurrentBehaviorExecutions(...) and behavior SetResourceName(...) are enforced independently on each silo, across all instances and behavior classes assigned to that resource (including tenants). Normal events, initialization, and reminder-triggered events acquire the same local budget. Other Orleans infrastructure grains do not consume it. Unconfigured limits remain unlimited; configured values of zero or less use the local processor count, as in the legacy runtime.

Budgets are strict: a behavior awaiting another behavior retains its permit. Awaited dependency chains must use separate resources or have sufficient capacity; otherwise they can deadlock when all permits are occupied.

Admission and overload

A concurrency limit bounds running executions, not queued work. A grain processes one event at a time, so each behavior instance waits for at most one permit; further messages for it queue in its Orleans mailbox. Two optional per-resource limits shed load before it accumulates (both require a concurrency limit):

.AddResource("database", r => r
    .SetMaxConcurrentBehaviorExecutions(8)
    .SetMaxQueuedBehaviorExecutions(100)               // permit waiters per silo; default unlimited
    .SetMaxQueueWaitTime(TimeSpan.FromSeconds(5)))     // default unlimited

An event rejected by either limit, or arriving while the silo is stopping, returns EventStatus.Overloaded (HTTP 503 in Minimal APIs). It is not executed, committed, or recorded as received, so retrying the same event ID executes it once. Requests (including status requests) are admitted the same way. Reminder-triggered time events and durable notification deliveries treat Overloaded as retryable and retry on their next reminder tick. Default-instance initialization only logs it. Without a wait limit, a waiting caller is still bounded by the Orleans request timeout. Orleans' own load shedding (SiloMessagingOptions.LoadSheddingEnabled) protects the silo as a whole and is configured separately.

Graceful shutdown

When a silo stops gracefully, permit waiters are released immediately with Overloaded, and new executions are rejected. Executions that already hold a permit run to completion, commit and publish before the silo starts deactivating grains. The drain is bounded by the host shutdown timeout. An abrupt stop or crash does not drain; uncommitted executions are retried by their senders, reminders, or deliveries.

Metrics and diagnostics

IStateflowsResources is independent of the legacy execution engine and reports running executions (BehaviorExecutionsCount) and executions waiting for a local resource permit (WaitingExecutionsCount). Waiters are not Orleans mailbox depth: messages queued inside Orleans for a busy grain are not counted. Use Orleans' own metrics for mailbox and messaging load.

AddOpenTelemetry() publishes, per resource and silo, the execution counter, the duration histogram, the running gauge and the waiting gauge (stateflows.resource.{name}.execution.waiting). queueLength is deprecated; it equals waiting under Orleans. Execution metrics are tagged with behavior.class, event.name and event.status only. They do not use behavior instance IDs, so cardinality is bounded by the definitions. Instruments are owned by each silo's service provider. AddOpenTelemetry() also subscribes to the Stateflows.Orleans meter, which counts operational failures. These counters use low-cardinality tags only:

Counter Tags
stateflows.orleans.persistence.failures state, behavior.class (for behavior state)
stateflows.orleans.reminder.failures operation (register, unregister, restore, notifications.*), behavior.class
stateflows.orleans.index.failures operation (add, remove), behavior.class
stateflows.orleans.publication.failures operation (publish, signal, delivery.*)
stateflows.orleans.admission.rejections resource, reason (QueueFull, WaitTimeout, ShuttingDown), behavior.class
stateflows.orleans.discovery.failures none

Failures are also logged. Startup logs the number of hosted classes, the progress and outcome of default-instance initialization, and changes in discovered silos and classes.

Orleans hosting does not register StateflowsEngine or StateflowsService; the legacy in-process registration still provides them.

HTTP hosts should also configure WithTracing(tracing => tracing.AddAspNetCoreInstrumentation()) (from OpenTelemetry.Instrumentation.AspNetCore). Stateflows propagates the incoming trace's sampling decision; without HTTP instrumentation, an unrecorded ASP.NET Core request activity can suppress behavior spans under the default parent-based sampler.

Storage contract

Under Orleans, behavior grains own behavior state. No separate Stateflows storage (IStateflowsStorage implementation) is needed or used for it. Configure:

  • Orleans default grain storage, or an explicitly selected named provider. It holds all Stateflows grain state:
    • Stateflows.BehaviorContext: behavior context, pending time events, event receipts and durable publisher outbox, or infrastructure-only tombstones;
    • Stateflows.Notifications: each publisher's notification history, subscriptions, pending deliveries and acceptance watermarks;
    • Stateflows.BehaviorInstances: instance index of each tenant and behavior class;
    • Stateflows.RetainedNotifications and Stateflows.Subscriptions: auxiliary records read by the older notification-state import path.
  • A durable reminder table, used for time events and notification delivery retries.

By default no provider name is necessary:

silo.AddMemoryGrainStorageAsDefault()
    .UseInMemoryReminderService()
    .AddStateflowsRuntime();

To select an existing named provider instead:

silo.AddAdoNetGrainStorage("workflow-storage", options =>
    {
        options.Invariant = "Microsoft.Data.SqlClient";
        options.ConnectionString = connectionString;
    })
    .UseAdoNetReminderService(options =>
    {
        options.Invariant = "Microsoft.Data.SqlClient";
        options.ConnectionString = connectionString;
    })
    .AddStateflowsRuntime(options => options.StorageProviderName = "workflow-storage");

StorageProviderName = null selects Orleans' default provider. Explicit names must be nonempty and never fall back to default. All Stateflows state facets use the same selection; reminder storage is configured separately through Orleans. Keep provider selection consistent across silos and restarts. Changing provider/backend or state names does not migrate existing records.

For production, use a durable grain storage provider that enforces ETag concurrency (for example Azure Table/Blob, ADO.NET or Cosmos DB). ETags make failover safe by rejecting commits from a lost activation. AddMemoryGrainStorageAsDefault() and UseInMemoryReminderService() are for development only: state and reminders are lost with the silo.

Stateflows grains use string keys made of /-separated, URI-escaped segments: tenant/Type/Name/Instance for behaviors (also the key of their subscriptions and notifications grains), tenant/Type/Name for instance indexes and publisherKey|subscriberKey for notification deliveries. A null tenant or instance is encoded as %. Keys never contain {, } or ". Orleans ADO.NET storage (10.4.0) passes grain keys to string.Format, so braces in a key would make every write fail.

The Orleans integration tests include SQL Server tests (TestCategory=SqlServer) that run against ADO.NET grain storage and reminders. They use Server=localhost;Database=Stateflows;Integrated Security=True;TrustServerCertificate=True, or the STATEFLOWS_SQLSERVER environment variable when set. They are inconclusive when the server is unreachable. The official Orleans scripts in Tests/Orleans.IntegrationTests/SqlServer are installed if missing. Each test uses its own ServiceId and deletes its rows.

Behavior contexts are persisted as a Stateflows JSON payload inside a [GenerateSerializer] wrapper (BehaviorContextState), so application-defined context values do not need to be added to the Orleans storage serializer type allow-list.

State is accessible only inside the behavior's grain. Under Orleans, IStateflowsStorage.HydrateAsync/DehydrateAsync throw InvalidOperationException when called from anywhere else. GetAllContextsAsync and the time- and startup-triggered scans throw NotSupportedException. To read or change state, send events or requests to the behavior. A failed behavior write/clear restores the activation's previously committed context and requests deactivation, so the next activation reloads authoritative storage. A provider error after an actual commit is an ambiguous outcome, not a guarantee that the durable write was rolled back. Orleans stages dehydration until its AfterExecute interceptors have also succeeded; an interceptor failure therefore does not commit the staged context. Standalone Orleans client registration and live definition updates are not provided by this integration.

Instance listing

IStateflowsStorage.GetAllContextIdsAsync and the Minimal APIs listing endpoints (GET /stateflows, /stateflows/stateMachines/{name}, ...) are served by an instance index. The index is one StateflowsInstances grain per tenant and behavior class, persisted in the selected Orleans storage. Listing uses the tenant returned by IStateflowsTenantProvider, which defaults to host.

  • An instance is listed once it has committed state of a live behavior: one that is initialized or holds runtime data (time events, subscriptions, ...). A context that is deleted, or still not initialized, is persisted at most as a receipts-only tombstone and is never listed. A status request with the NoImplicitInitialization header commits nothing, so it neither creates an entry nor resurrects a reset instance.
  • The entry is added before the first commit. If adding fails, the event fails with nothing committed and can be retried. A committed instance is therefore never missing from listings.
  • The entry is removed (best effort) after deletion, for example after a full Reset. If removal fails or the silo crashes in between, a stale entry remains until that instance is next activated and deactivated without state. Pruning is skipped on silo shutdown and after an ambiguous commit failure.
  • Each index grain serializes all first commits and deletions of its class and keeps the whole instance set in one state record. A class with very many instances, or a high rate of instance creation/deletion, makes it a hot grain with a large record. Listing returns the full set without paging.

Entity API validation and authorization

Generated entity endpoints are public unless the application configures authorization. Protect the complete entity surface, not only mutations or projections:

app.MapStateflowsMinimalAPIsEndpoints(api => api.ConfigureEntities(entities =>
    entities.ConfigureAllEndpoints(endpoint =>
        endpoint.ConfigureHandler(handler => handler.RequireAuthorization("entity-access"))
    )
));

Register the authentication scheme and authorization policy in the host. Endpoint policies apply to listing, status, notifications, reset, initialization, mutations, fields and projection streams. Instance ownership and tenant access must be enforced by application policies or entity interceptors; field Get/Set access is not user authorization.

Initializer/mutation event types and projection types can additionally declare [Authorize]. These attributes use ASP.NET Core policy evaluation, including the configured default policy for bare [Authorize], named policies, roles and actual authentication-scheme selection. Anonymous failures challenge authentication; authenticated users denied access receive a forbidden result. These attributes do not automatically protect other routes exposing the same data, including field reads and notification history. SSE authorization is checked when connecting, not re-evaluated for every notification.

Entity field accessors can independently protect their generated REST endpoints:

[Field(FieldAccess.Get | FieldAccess.Set)]
int Count
{
    [Authorize(Policy = "entity-reader")]
    get;
    [Authorize(Policy = "entity-writer")]
    set;
}

Getter attributes apply to GET and setter attributes to PUT, including inherited fields and computed getters. Multiple attributes and configured endpoint policies are combined by ASP.NET Core authorization. Bare [Authorize] uses the host's default policy; roles and authentication schemes are supported. Accessors without attributes retain the endpoint's existing authorization configuration. These are REST endpoint restrictions, not checks on internal entity property access or direct FieldState<T> messages. They do not protect other routes such as projections, status or notification history that may expose the same data.

Entity template mutation methods and projection getter methods can also declare [Authorize]:

[Mutation, Authorize(Policy = "entity-writer")]
void Rename(RenameBook mutation) { Title = mutation.Title; }

[Projection(PublishScope.Self)]
BookView View
{
    [Authorize(Policy = "entity-reader")]
    get => new() { Title = Title };
}

Inherited template members are included. Method/getter attributes protect the generated endpoint, including streaming requests, and are enforced in addition to event/result-type attributes and configured endpoint policies. Multiple attributes, default policies, roles and authentication schemes use ASP.NET Core authorization. These checks do not apply to direct behavior messages or internal entity execution; SSE authorization is evaluated at connection time, not on each notification.

Excluding entity elements from generated APIs

Use [NoApiMapping] from Stateflows.Extensions.MinimalAPIs.Attributes on a mutation event type or projection result type to omit its generated endpoint (including projection streaming). The attribute supports class, struct and interface types. On a field property it excludes both GET and PUT; on the getter or setter it excludes only that accessor's endpoint:

[Field(FieldAccess.Get | FieldAccess.Set), NoApiMapping]
string InternalNote { get; set; }

[Field(FieldAccess.Get | FieldAccess.Set)]
int Count
{
    get;
    [NoApiMapping]
    set;
}

Exclusions also apply to inherited field properties/accessors and remove the corresponding HATEOAS links. FieldAccess still determines which remaining endpoints are eligible. This only controls API generation: direct behavior messages, internal mutations, and other endpoints that expose the same data are unaffected.

All external FieldState<T> writes, including REST field PUT and Orleans/direct messages, validate the field's template-property DataAnnotations and invoke registered IStateflowsValidators on its value. DataAnnotations are also checked on nested objects and collection elements, with reference cycles visited once. A null field value requires [Required] or a custom validator to reject it. Invalid writes return EventStatus.Invalid (HTTP 400 for field PUT), before implicit initialization, assignment, computed-field updates or persistence. This does not enforce field invariants on assignments made inside trusted mutations or initializers.

Other event validation retains its existing contract: DataAnnotations validate the event object, not its entire nested object graph. HTTP JSON/type binding and domain validation are separate concerns.

Minimal API endpoint contexts

Custom endpoints declared with AddEndpoints(...) never hydrate or save behavior state. The endpoint filter reads the behavior's status with a NoImplicitInitialization request, so it still passes resource admission. It returns 404 unless the instance is initialized, or unless the endpoint's state or node is active. Endpoint contexts (IBehaviorEndpointContext, IStateMachineEndpointContext, IStateEndpointContext, IActivityEndpointContext, IActivityNodeEndpointContext, IActionEndpointContext) expose:

  • behavior information: ID, current states or active nodes, expected events, links;
  • messaging: Send, subscribe/unsubscribe, and entity operations.

They expose no Values and no Publish. Sends are completed after the handler returns. To change state, send an event to the behavior.

The Orleans integration tests also start two silo hosts as separate operating-system processes and verify cross-silo discovery and execution for state machines, activities, and actions.

Orleans watches reuse one observer reference per behavior handle while watches are active and renew every four minutes. Removing the last watch stops renewal and deletes the observer reference. Dispose handles before stopping their silo/client: Dispose() disables local delivery immediately and starts remote cleanup; GrainBehavior.DisposeAsync() also waits for that cleanup and propagates failures. Already-started handlers may finish after unwatch/disposal; pending callbacks are discarded. Handlers execute independently on the thread pool, may overlap, and are awaited only inside their background dispatch, never by the publisher or subscription grain. Handler, renewal, and cleanup failures are logged. Watches support timestamp replay and replay on renewal after subscription-grain reactivation. Reconnection can repeat notifications, and observer acknowledgment does not confirm that a user handler has completed. Watches are not durable behavior subscriptions.

Orleans notification publication waits for durable notification and pending-delivery storage, not subscriber execution. In both Orleans and legacy runtimes, synchronous behavior Publish calls buffer notifications until successful publisher state commit (or successful execution for stateless actions). Buffered notifications are discarded on execution/commit failure. The turn waits for publication persistence before returning success.

For progress reporting, bypass the buffer with either a notification-type attribute:

[ImmediateDispatch]
public record Progress(int Percent);

or a per-publication header:

context.Behavior.Publish(new Progress(25),
    new Dictionary<string, EventHeader> { [nameof(ImmediateDispatch)] = new ImmediateDispatch() });

Both markers are in Stateflows.Common. Immediate dispatch starts during execution; the synchronous Publish call does not wait for remote delivery. The turn awaits immediate publication completion before committing, and publication failures fail the turn. Interceptors may also add the header. Immediate notifications bypass earlier buffered notifications and remain visible if the turn later fails. They retain ordinary TTL, retention, history, watcher and subscription semantics.

Orleans: buffered envelopes and the incoming event receipt are committed atomically with publisher state. A dedicated persistent reminder retries the outbox after dispatch failure or silo loss, without waiting for subscriber execution. Post-commit dispatch failures are logged and counted but preserve the successful execution result. Subscription acceptance is idempotent across replay, including lost acknowledgments. Original notification IDs, headers, sender, timestamps, TTL and retention are preserved; TTL starts at publication request time, so a notification may expire before dispatch. Reset preserves pending publications. Stateless actions persist only infrastructure metadata/outbox obligations, not domain state or incoming-event deduplication. Use durable Orleans grain storage and a durable reminder provider in production. Memory providers do not survive cluster loss.

AddStateflowsRuntime() validates these registrations during Orleans host startup: the default or explicitly selected IGrainStorage provider, plus IReminderService and IReminderTable. Missing registrations throw OrleansConfigurationException before behavior execution; a missing named provider never falls back to default. Providers may be registered before or after AddStateflowsRuntime(). This checks DI registrations, not provider durability or backend connectivity.

Accepted-batch watermarks survive history pruning. Publishers retain a sequence counter after the outbox drains (including after reset), and subscription grains retain one watermark per source behavior identity; this metadata grows with distinct publishers, not with publication count. Do not delete/reseed publisher infrastructure records independently of subscription state. This is replay-safe acceptance, not an exactly-once guarantee for subscriber side effects or immediate progress notifications.

Legacy: buffering remains in memory. Post-commit dispatch failure fails the event without rolling back state; a crash between state commit and publication can lose notifications. No durable publisher outbox is provided for legacy storage.

The publisher's StateflowsSubscriptions grain stores history, subscriptions, and pending deliveries together. Separate publisher/subscriber-pair delivery grains await behavior processing and persist delivery confirmations without holding the subscription grain open. Persistent reminders retry unconfirmed deliveries and periodically remove expired history and pending work. Initialized, Deferred, Consumed, Forwarded, NotConsumed, Invalid, and Duplicated confirm delivery; other results and exceptions remain retryable until expiration or unsubscription. Each publisher/subscriber pair processes sequentially; a failed notification blocks later notifications for that pair until it succeeds, expires, or is unsubscribed. Other pairs run independently. A permanently failing notification with no TTL can therefore block its pair indefinitely. Long-running processing is subject to the application's configured Orleans request timeout, not an additional delivery timeout.

TTL applies to retained notifications too; zero means no expiration. Retain selects the latest retained value of a notification name for immediate delivery upon subscription/watch registration; it does not prevent expiration. Timestamp replay uses unexpired history. Confirmation removes pending work, not replay history. Subscription registration does not await retained or replay delivery.

Delivery is at-least-once while eligible. Orleans stateful behaviors deduplicate all incoming events, including notifications, commands, and requests, by event ID at the behavior grain boundary. Confirmed event IDs persist atomically with their behavior state, returning Duplicated on retries without executing again. Receipts survive reactivation, reset, and logical deletion. Positive-TTL receipts expire at SentAt + TTL, and expired incoming events never execute. Zero-TTL receipts expire one hour after successful processing; a later retry or replay may execute again. Expired receipts are pruned on subsequent state writes. Failed/retryable outcomes do not record receipts. Stateless actions are deliberately not deduplicated. Internal deferred-event processing still executes normally inside the processor. Duplicate requests return Duplicated with no response; previous responses are not cached. Calls without an EventId header create new IDs and are independent events.

Deduplication can be disabled for idempotent events. Event types marked with [NoDeduplication] are never deduplicated: a redelivery executes again and no receipt is recorded. The built-in read-only requests are marked: BehaviorInfoRequest, StateMachineInfoRequest, ActivityInfoRequest, EntityInfoRequest, NotificationsRequest, TokensOutputRequest, TokensOutputRequest<T>, ProjectionRequest<T>, and FieldStateRequest<T>. A retried status request is therefore answered again instead of returning Duplicated. For a single event, add a NoDeduplication header instead.

An execution that changes nothing is not persisted. Before committing, the grain compares the new context with the committed one, ignoring LastExecutedAt; if they are equal and no receipt was recorded, no storage write, reminder change, or index update happens. Status requests on an existing behavior therefore never write state, while a status request that implicitly initializes a behavior does. Under Orleans, LastExecutedAt therefore records the last persisted execution, not the last delivered event.

External channel adapters should supply the same EventId header on every redelivery. Both SendAsync(payload, headers) and RequestAsync(request, headers) use its Guid Id when constructing the event holder, including through client proxies and the Orleans transport. The header's dictionary key is arbitrary; the header type identifies it. Supply at most one EventId header per call. For example:

var headers = new Dictionary<string, EventHeader>
{
    [nameof(EventId)] = new EventId(messageId) // stable Guid from the external channel
};
var result = await behavior.SendAsync(command, headers);
// Reusing messageId during redelivery returns Duplicated after a committed execution.

Existing envelopes retain their original EventHolder.Id during retries and transport deserialization; an EventId header does not replace an existing envelope's ID. The header is applied only when constructing a new holder.

This is not exactly-once execution of external side effects: an uncommitted execution can be retried, and publication is not transactional with behavior state. Expiration cannot recall a notification already processing. Unsubscribe cancels pending work but cannot recall an in-flight call. Publisher reset/deletion currently does not remove the separate subscription grain's history or registrations. Existing retained values and subscription registrations are imported into the new combined notification state on activation. Historical ordinary notifications were not previously persisted by this Orleans adapter and cannot be reconstructed.

Time events are scheduled in UTC in every runtime. TimeEvent.TriggerTime and StateflowsContext.TriggerTime convert local values to UTC and treat unspecified values as UTC; registrations use DateTime.UtcNow. Calendar-aligned built-ins (AfterOneHour, EveryFifteenMinutes, AtEvery9AM, ...) align to UTC boundaries, so AtEvery9AM means 09:00 UTC. DelayEvent applies its delay once.

Orleans schedules each behavior's earliest pending time event with one Orleans reminder (Stateflows.TimeTrigger). The reminder is registered or updated before the behavior state that needs it is committed; if registration fails, the event fails and nothing is committed. It is unregistered after a commit leaves no pending time events; an unregistration failure does not fail the event, and the leftover reminder removes itself on its next tick. Events that do not change the earliest trigger time do not access the reminder table, so traffic cannot postpone a due time event. The reminder period is ReminderOptions.MinimumReminderPeriod (Orleans default: one minute); it is the retry interval for failed or interrupted callbacks. Use a durable reminder table and grain storage with ETag concurrency checks in production: UseInMemoryReminderService() loses reminders with the silo that holds its table, and failover relies on ETags to reject commits from a lost activation.

A time event never executes before its trigger time, as measured by the clock of the silo hosting the behavior; keep silo clocks synchronized. While the silo is running it normally executes within about a second after the trigger time. After downtime, failover, or a failed callback it executes on a later tick: Orleans keeps ticks on the reminder's original StartAt + N × Period schedule, so allow up to one period after recovery (plus silo-failure detection time). Each overdue occurrence executes once. Due occurrences run in trigger-time order, each against the state committed by the previous one, so an earlier occurrence can clear or reschedule a later one. Recurring events re-arm from their processing time; missed intervals are skipped, not replayed. Reminder ticks are at-least-once. A tick uses the occurrence's ID as the event ID, and an occurrence is only accepted while still pending, so it commits at most once. Side effects of an interrupted, uncommitted execution can run again.

This adapter is still under development: immediate-finalization signals contain unfinished implementations. Capability-aware placement does not complete them.

Beehaviors runtime bridge

Stateflows.Runtimes.Beehaviors keeps Stateflows runtime-agnostic while executing all registered state machines, activities, actions, and entities through the in-process Beehaviors actor runtime.

Usage:

services.AddBeehaviors(b => b
    .AddStateflows(sf => sf
        .AddStateMachines(...)
        .AddActivities(...)
        .AddActions(...)
        .AddEntities(...)));
Product Compatible and additional computed target framework versions.
.NET 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.21.1-beta 0 10/20/2026
0.21.0-beta 45 10/19/2026
0.20.13-beta 71 9/25/2026
0.20.12-beta 78 9/8/2026
0.20.11-beta 72 9/7/2026
0.20.10-beta 76 9/2/2026
0.20.9-beta 78 9/1/2026
0.20.8-beta 95 8/17/2026
0.20.7-beta 84 8/6/2026
0.20.6-beta 86 8/5/2026
0.20.5-beta 87 7/23/2026
0.20.4-beta 87 6/24/2026
0.20.3-beta 97 6/24/2026
0.20.2-beta 87 6/23/2026
0.20.1-beta 81 6/23/2026
0.20.0-beta 94 6/23/2026
0.19.6-beta 86 6/1/2026
0.19.5-beta 132 5/11/2026
0.19.4-beta 91 4/20/2026
0.19.3-beta 93 4/16/2026
Loading failed