Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
Orleans supports distributed ACID transactions against persistent grain state. Transactions are implemented using the Microsoft.Orleans.Transactions NuGet package. The source code for the sample app in this article consists of four projects:
- Abstractions: A class library containing the grain interfaces and shared classes.
- Grains: A class library containing the grain implementations.
- Server: A console app that consumes the abstractions and grains class libraries and acts as the Orleans silo.
- Client: A console app that consumes the abstractions class library that represents the Orleans client.
Setup
Orleans transactions are opt-in. Both the silo and the client must be configured to use transactions. If they aren't configured, any calls to transactional methods on a grain implementation receive an OrleansTransactionsDisabledException. To enable transactions on a silo, call SiloBuilderExtensions.UseTransactions on the silo host builder:
var builder = Host.CreateDefaultBuilder(args)
UseOrleans((context, siloBuilder) =>
{
siloBuilder.UseTransactions();
});
Likewise, to enable transactions on the client, call ClientBuilderExtensions.UseTransactions on the client host builder:
var builder = Host.CreateDefaultBuilder(args)
UseOrleansClient((context, clientBuilder) =>
{
clientBuilder.UseTransactions();
});
Transactional state storage
To use transactions, you need to configure a data store. To support various data stores with transactions, Orleans uses the storage abstraction ITransactionalStateStorage<TState>. This abstraction is specific to the needs of transactions, unlike generic grain storage (IGrainStorage). To use transaction-specific storage, configure the silo using any implementation of ITransactionalStateStorage
, such as Azure (AddAzureTableTransactionalStateStorage).
For example, consider the following host builder configuration:
await Host.CreateDefaultBuilder(args)
.UseOrleans((_, silo) =>
{
silo.UseLocalhostClustering();
if (Environment.GetEnvironmentVariable(
"ORLEANS_STORAGE_CONNECTION_STRING") is { } connectionString)
{
silo.AddAzureTableTransactionalStateStorage(
"TransactionStore",
options => options.ConfigureTableServiceClient(connectionString));
}
else
{
silo.AddMemoryGrainStorageAsDefault();
}
silo.UseTransactions();
})
.RunConsoleAsync();
For development purposes, if transaction-specific storage isn't available for the datastore you need, you can use an IGrainStorage
implementation instead. For any transactional state without a configured store, transactions attempt to fail over to the grain storage using a bridge. Accessing transactional state via a bridge to grain storage is less efficient and might not be supported in the future. Therefore, we recommend using this approach only for development purposes.
Grain interfaces
For a grain to support transactions, you must mark transactional methods on its grain interface as part of a transaction using the TransactionAttribute. The attribute needs to indicate how the grain call behaves in a transactional environment, as detailed by the following TransactionOption values:
- TransactionOption.Create: Call is transactional and will always create a new transaction context (it starts a new transaction), even if called within an existing transaction context.
- TransactionOption.Join: Call is transactional but can only be called within the context of an existing transaction.
- TransactionOption.CreateOrJoin: Call is transactional. If called within the context of a transaction, it will use that context, else it will create a new context.
- TransactionOption.Suppress: Call is not transactional but can be called from within a transaction. If called within the context of a transaction, the context will not be passed to the call.
- TransactionOption.Supported: Call is not transactional but supports transactions. If called within the context of a transaction, the context will be passed to the call.
- TransactionOption.NotAllowed: Call is not transactional and cannot be called from within a transaction. If called within the context of a transaction, it will throw the NotSupportedException.
You can mark calls as TransactionOption.Create
, meaning the call always starts its transaction. For example, the Transfer
operation in the ATM grain below always starts a new transaction involving the two referenced accounts.
namespace TransactionalExample.Abstractions;
public interface IAtmGrain : IGrainWithIntegerKey
{
[Transaction(TransactionOption.Create)]
Task Transfer(string fromId, string toId, decimal amountToTransfer);
}
The transactional operations Withdraw
and Deposit
on the account grain are marked TransactionOption.Join
. This indicates they can only be called within the context of an existing transaction, which would be the case if called during IAtmGrain.Transfer
. The GetBalance
call is marked CreateOrJoin
, so you can call it either from within an existing transaction (like via IAtmGrain.Transfer
) or on its own.
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();
}
Important considerations
You cannot mark OnActivateAsync
as transactional because any such call requires proper setup before the call. It exists only for the grain application API. This means attempting to read transactional state as part of these methods throws an exception in the runtime.
Grain implementations
A grain implementation needs to use an ITransactionalState<TState> facet to manage grain state via ACID transactions.
public interface ITransactionalState<TState>
where TState : class, new()
{
Task<TResult> PerformRead<TResult>(
Func<TState, TResult> readFunction);
Task<TResult> PerformUpdate<TResult>(
Func<TState, TResult> updateFunction);
}
Perform all read or write access to the persisted state via synchronous functions passed to the transactional state facet. This allows the transaction system to perform or cancel these operations transactionally. To use transactional state within a grain, define a serializable state class to be persisted and declare the transactional state in the grain's constructor using a TransactionalStateAttribute. This attribute declares the state name and, optionally, which transactional state storage to use. For more information, see Setup.
[AttributeUsage(AttributeTargets.Parameter)]
public class TransactionalStateAttribute : Attribute
{
public TransactionalStateAttribute(string stateName, string storageName = null)
{
// ...
}
}
As an example, the Balance
state object is defined as follows:
namespace TransactionalExample.Abstractions;
[GenerateSerializer]
public record class Balance
{
[Id(0)]
public decimal Value { get; set; } = 1_000;
}
The preceding state object:
- Is decorated with the GenerateSerializerAttribute to instruct the Orleans code generator to generate a serializer.
- Has a
Value
property that's decorated with theIdAttribute
to uniquely identify the member.
The Balance
state object is then used in the AccountGrain
implementation as follows:
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);
}
Important
A transactional grain must be marked with the ReentrantAttribute to ensure that the transaction context is correctly passed to the grain call.
In the preceding example, the TransactionalStateAttribute declares that the balance
constructor parameter should be associated with a transactional state named "balance"
. With this declaration, Orleans injects an ITransactionalState<TState> instance with state loaded from the transactional state storage named "TransactionStore"
. You can modify the state via PerformUpdate
or read it via PerformRead
. The transaction infrastructure ensures that any such changes performed as part of a transaction (even among multiple grains distributed across an Orleans cluster) are either all committed or all undone upon completion of the grain call that created the transaction (IAtmGrain.Transfer
in the preceding example).
Call transaction methods from a client
The recommended way to call a transactional grain method is to use the ITransactionClient
. Orleans automatically registers ITransactionClient
with the dependency injection service provider when you configure the Orleans client. Use ITransactionClient
to create a transaction context and call transactional grain methods within that context. The following example shows how to use ITransactionClient
to call transactional grain methods.
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));
}
In the preceding client code:
- The
IHostBuilder
is configured withUseOrleansClient
.- The
IClientBuilder
uses localhost clustering and transactions.
- The
- The
IClusterClient
andITransactionClient
interfaces are retrieved from the service provider. - The
from
andto
variables are assigned theirIAccountGrain
references. - The
ITransactionClient
is used to create a transaction, calling:Withdraw
on thefrom
account grain reference.Deposit
on theto
account grain reference.
Transactions are always committed unless an exception is thrown in the transactionDelegate
or a contradictory transactionOption
is specified. While using ITransactionClient
is the recommended way to call transactional grain methods, you can also call them directly from another grain.
Call transaction methods from another grain
Call transactional methods on a grain interface like any other grain method. As an alternative to using ITransactionClient
, the AtmGrain
implementation below calls the Transfer
method (which is transactional) on the IAccountGrain
interface.
Consider the AtmGrain
implementation, which resolves the two referenced account grains and makes the appropriate calls to Withdraw
and Deposit
:
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));
}
Your client app code can call AtmGrain.Transfer
transactionally as follows:
IAtmGrain atmOne = client.GetGrain<IAtmGrain>(0);
Guid from = Guid.NewGuid();
Guid to = Guid.NewGuid();
await atmOne.Transfer(from, to, 100);
uint fromBalance = await client.GetGrain<IAccountGrain>(from).GetBalance();
uint toBalance = await client.GetGrain<IAccountGrain>(to).GetBalance();
In the preceding calls, an IAtmGrain
is used to transfer 100 units of currency from one account to another. After the transfer is complete, both accounts are queried to get their current balance. The currency transfer, as well as both account queries, are performed as ACID transactions.
As shown in the preceding example, transactions can return values within a Task
, like other grain calls. However, upon call failure, they don't throw application exceptions but rather an OrleansTransactionException or TimeoutException. If the application throws an exception during the transaction, and that exception causes the transaction to fail (as opposed to failing due to other system failures), the application exception becomes the inner exception of the OrleansTransactionException
.
If a transaction exception of type OrleansTransactionAbortedException is thrown, the transaction failed and can be retried. Any other exception thrown indicates the transaction terminated with an unknown state. Since transactions are distributed operations, a transaction in an unknown state could have succeeded, failed, or still be in progress. For this reason, it's advisable to allow a call timeout period (SiloMessagingOptions.SystemResponseTimeout) to pass before verifying the state or retrying the operation to avoid cascading aborts.