Skip to content

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 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.

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.

Apply TransactionAttribute to grain interface methods:

OptionBehavior
CreateAlways starts a new transaction and suppresses any ambient transaction for this call.
CreateOrJoinJoins the ambient transaction or starts one when none exists.
JoinRequires an ambient transaction.
SuppressExecutes without the ambient transaction.
SupportedReceives an ambient transaction when one exists but doesn’t require one.
NotAllowedFails 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.

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.

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.

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.

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

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.

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.

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:

OptionDefault
LockTimeout8 seconds
LockAcquireTimeout10 seconds
PrepareTimeout20 seconds
RemoteTransactionPingFrequency60 seconds
ConfirmationRetryDelay30 seconds
MaxLockGroupSize20

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.

  • 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.