Orleans transactions
The Microsoft.Orleans.Transactions package provides distributed ACID transactions across one or more grain calls and transactional state records. Transactional state is distinct from IPersistentState<T>: use ITransactionalState<T> for data that participates in an Orleans transaction.
Enable transactions
Section titled “Enable transactions”Enable transactions on every participating silo with UseTransactions:
var builder = Host.CreateApplicationBuilder(args);
builder.UseOrleans(siloBuilder =>{ siloBuilder.UseTransactions();});External Orleans clients that create or propagate transactions must also call UseTransactions:
builder.UseOrleansClient(clientBuilder =>{ clientBuilder.UseTransactions();});A transactional call without transaction services fails with OrleansTransactionsDisabledException.
Configure transactional storage
Section titled “Configure transactional storage”Transactional storage implements ITransactionalStateStorage<T>. The supported provider package Microsoft.Orleans.Transactions.AzureStorage registers Azure Table transactional storage:
siloBuilder .AddAzureTableTransactionalStateStorage( "TransactionStore", options => { options.TableServiceClient = new TableServiceClient( builder.Configuration.GetConnectionString("transactions") ?? throw new InvalidOperationException( "The transactions connection string isn't configured.")); }) .UseTransactions();If no named transactional provider exists, Orleans can bridge transactional state to a configured IGrainStorage. The bridge is less efficient and is intended for development and compatibility scenarios. Prefer a transactional provider for production workloads.
Declare transaction behavior
Section titled “Declare transaction behavior”Apply TransactionAttribute to grain interface methods:
| Option | Behavior |
|---|---|
| Create | Always starts a new transaction and suppresses any ambient transaction for this call. |
| CreateOrJoin | Joins the ambient transaction or starts one when none exists. |
| Join | Requires an ambient transaction. |
| Suppress | Executes without the ambient transaction. |
| Supported | Receives an ambient transaction when one exists but doesn’t require one. |
| NotAllowed | Fails when called in a transaction. |
namespace TransactionalExample.Abstractions;
public interface IAccountGrain : IGrainWithStringKey{ [Transaction(TransactionOption.Join)] Task Withdraw(decimal amount);
[Transaction(TransactionOption.Join)] Task Deposit(decimal amount);
[Transaction(TransactionOption.CreateOrJoin)] Task<decimal> GetBalance();}Join outside a transaction and NotAllowed inside a transaction throw NotSupportedException.
Read-only transactions
Section titled “Read-only transactions”Apply ReadOnlyAttribute to a transactional method that performs no transactional-state update:
[ReadOnly][Transaction(TransactionOption.CreateOrJoin)]Task<uint> GetBalance();Orleans starts a read-only transaction and can use a reduced commit path. A write attempted by a read-only transaction aborts with OrleansReadOnlyViolatedException. The ReadOnly property is obsolete; use ReadOnlyAttribute.
Exclusive locks
Section titled “Exclusive locks”Transactional state normally permits concurrent readers and upgrades to an exclusive lock when a transaction writes. Competing upgrades can abort under contention. Apply UseExclusiveLockAttribute to acquire exclusive locks even for reads:
[UseExclusiveLock][Transaction(TransactionOption.CreateOrJoin)]Task<uint> ReserveAndGetBalance();This avoids lock-upgrade conflicts at the cost of lower read concurrency. Use it for methods likely to write after reading or for measured contention hot spots, not as a blanket default.
Access transactional state
Section titled “Access transactional state”Inject a named state facet using TransactionalStateAttribute:
namespace TransactionalExample.Grains;
[Reentrant]public class AccountGrain : Grain, IAccountGrain{ private readonly ITransactionalState<Balance> _balance;
public AccountGrain( [TransactionalState(nameof(balance))] ITransactionalState<Balance> balance) => _balance = balance ?? throw new ArgumentNullException(nameof(balance));
public Task Deposit(decimal amount) => _balance.PerformUpdate( balance => balance.Value += amount);
public Task Withdraw(decimal amount) => _balance.PerformUpdate(balance => { if (balance.Value < amount) { throw new InvalidOperationException( $"Withdrawing {amount} credits from account " + $"\"{this.GetPrimaryKeyString()}\" would overdraw it." + $" This account has {balance.Value} credits."); }
balance.Value -= amount; });
public Task<decimal> GetBalance() => _balance.PerformRead(balance => balance.Value);}Read through PerformRead and update through PerformUpdate. The delegates are synchronous because Orleans controls when the state snapshot is read, committed, or discarded. Don’t retain the supplied state object or mutate it outside these delegates.
Transactional state isn’t available during OnActivateAsync; transaction setup occurs as part of a transactional request.
Start a transaction
Section titled “Start a transaction”From a grain call
Section titled “From a grain call”A method marked Create or CreateOrJoin starts a transaction when no ambient transaction exists. Calls made from that method propagate the context according to each target method’s TransactionOption.
namespace TransactionalExample.Grains;
[StatelessWorker]public class AtmGrain : Grain, IAtmGrain{ public Task Transfer( string fromId, string toId, decimal amount) => Task.WhenAll( GrainFactory.GetGrain<IAccountGrain>(fromId).Withdraw(amount), GrainFactory.GetGrain<IAccountGrain>(toId).Deposit(amount));}From an external client
Section titled “From an external client”Resolve ITransactionClient and run a delegate:
using IHost host = Host.CreateDefaultBuilder(args) .UseOrleansClient((_, client) => { client.UseLocalhostClustering() .UseTransactions(); }) .Build();
await host.StartAsync();
var client = host.Services.GetRequiredService<IClusterClient>();var transactionClient= host.Services.GetRequiredService<ITransactionClient>();
var accountNames = new[] { "Xaawo", "Pasqualino", "Derick", "Ida", "Stacy", "Xiao" };var random = Random.Shared;
while (!Console.KeyAvailable){ // Choose some random accounts to exchange money var fromIndex = random.Next(accountNames.Length); var toIndex = random.Next(accountNames.Length); while (toIndex == fromIndex) { // Avoid transferring to/from the same account, since it would be meaningless toIndex = (toIndex + 1) % accountNames.Length; }
var fromKey = accountNames[fromIndex]; var toKey = accountNames[toIndex]; var fromAccount = client.GetGrain<IAccountGrain>(fromKey); var toAccount = client.GetGrain<IAccountGrain>(toKey);
// Perform the transfer and query the results try { var transferAmount = random.Next(200);
await transactionClient.RunTransaction( TransactionOption.Create, async () => { await fromAccount.Withdraw(transferAmount); await toAccount.Deposit(transferAmount); });
var fromBalance = await fromAccount.GetBalance(); var toBalance = await toAccount.GetBalance();
Console.WriteLine( $"We transferred {transferAmount} credits from {fromKey} to " + $"{toKey}.\n{fromKey} balance: {fromBalance}\n{toKey} balance: {toBalance}\n"); } catch (Exception exception) { Console.WriteLine( $"Error transferring credits from " + $"{fromKey} to {toKey}: {exception.Message}");
if (exception.InnerException is { } inner) { Console.WriteLine($"\tInnerException: {inner.Message}\n"); }
Console.WriteLine(); }
// Sleep and run again await Task.Delay(TimeSpan.FromMilliseconds(200));}RunTransaction has delegates returning Task and Task<T>. The generic task form commits only when the delegate returns true; returning false aborts. An overload also accepts useExclusiveLock.
Commit and abort semantics
Section titled “Commit and abort semantics”The request that starts a transaction resolves it before returning:
- A successful delegate commits unless it explicitly returns
false. - An application exception records the failure and aborts the transaction.
- An abort discards all transactional-state updates in that transaction.
- The original application exception can appear as the inner exception of an OrleansTransactionException.
An OrleansTransactionAbortedException reports a known abort and can be retried if the application command is safe to retry. OrleansTransactionInDoubtException means the coordinator couldn’t determine the final outcome. Don’t immediately repeat a non-idempotent command after an in-doubt result; use an operation identifier and query application state after the response-timeout window.
Retries must retry the entire transaction, not an individual participant update. Bound attempts and use backoff. High contention, lock-upgrade conflicts, overload, and storage outages can otherwise create a retry storm.
Contention and timeouts
Section titled “Contention and timeouts”Transactional state uses reader/writer locks and deadlock-prevention rules. Common abort causes include:
- Failure to acquire a lock before LockAcquireTimeout.
- A lock held longer than LockTimeout.
- A lock-upgrade conflict.
- Failure to complete prepare before PrepareTimeout.
- A participant or transaction service becoming unavailable.
The default TransactionalStateOptions values are:
| Option | Default |
|---|---|
| LockTimeout | 8 seconds |
| LockAcquireTimeout | 10 seconds |
| PrepareTimeout | 20 seconds |
| RemoteTransactionPingFrequency | 60 seconds |
| ConfirmationRetryDelay | 30 seconds |
| MaxLockGroupSize | 20 |
Commit confirmation uses ConfirmationRetryLimit, whose default is 3. A newly started transaction uses a 10-second transaction timeout when no debugger is attached.
Configure state options consistently on participating silos:
siloBuilder.Configure<TransactionalStateOptions>(options =>{ options.LockAcquireTimeout = TimeSpan.FromSeconds(5); options.LockTimeout = TimeSpan.FromSeconds(8); options.PrepareTimeout = TimeSpan.FromSeconds(20);});Shorter timeouts fail faster but can abort healthy work during load spikes. Longer timeouts retain locks and resources longer. Measure transaction duration and contention before changing defaults.
Design guidance
Section titled “Design guidance”- Keep transactions short and avoid unrelated remote calls while holding transactional locks.
- Acquire resources in a stable application-level order when practical.
- Use ReadOnlyAttribute for truly read-only operations.
- Use UseExclusiveLockAttribute selectively when lock upgrades are a measured source of aborts.
- Make the initiating command idempotent and include an operation identifier.
- Monitor aborts, in-doubt outcomes, lock timeouts, prepare timeouts, and storage latency separately.
