Orleans transações
Orleans suporta transações ACID distribuídas contra o estado de grão persistente. As transações são implementadas usando o Microsoft.Orleans. Pacote NuGet de transações . O código-fonte do aplicativo de exemplo neste artigo é composto por quatro projetos:
- Abstrações: Uma biblioteca de classes contendo as interfaces de grão e classes compartilhadas.
- Grãos: Uma biblioteca de classes contendo as implementações de grãos.
- Servidor: um aplicativo de console que consome as abstrações e bibliotecas de classes de grãos e atua como o Orleans silo.
- Cliente: um aplicativo de console que consome a biblioteca de classes de abstrações que representa o Orleans cliente.
Configurar
Orleans as transações são opt-in. Um silo e um cliente devem ser configurados para usar transações. Se eles não estiverem configurados, todas as chamadas para métodos transacionais em uma implementação de grão receberão o OrleansTransactionsDisabledExceptionarquivo . Para habilitar transações em um silo, chame SiloBuilderExtensions.UseTransactions o construtor de host de silo:
var builder = Host.CreateDefaultBuilder(args)
UseOrleans((context, siloBuilder) =>
{
siloBuilder.UseTransactions();
});
Da mesma forma, para habilitar transações no cliente, chame ClientBuilderExtensions.UseTransactions o construtor de host do cliente:
var builder = Host.CreateDefaultBuilder(args)
UseOrleansClient((context, clientBuilder) =>
{
clientBuilder.UseTransactions();
});
Armazenamento de estado transacional
Para usar transações, você precisa configurar um armazenamento de dados. Para suportar vários armazenamentos de dados com transações, a abstração de ITransactionalStateStorage<TState> armazenamento é usada. Esta abstração é específica para as necessidades das transações, ao contrário do armazenamento genérico de grãos (IGrainStorage). Para usar o armazenamento específico da transação, configure o silo usando qualquer implementação do ITransactionalStateStorage
, como o Azure (AddAzureTableTransactionalStateStorage).
Por exemplo, considere a seguinte configuração do construtor de hosts:
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();
Para fins de desenvolvimento, se o armazenamento específico da transação não estiver disponível para o armazenamento de dados necessário, você poderá usar uma IGrainStorage
implementação. Para qualquer estado transacional que não tenha um armazenamento configurado, as transações tentarão fazer failover para o armazenamento de grãos usando uma ponte. O acesso a um estado transacional por meio de uma ponte para o armazenamento de grãos é menos eficiente e pode não ser suportado no futuro. Por isso, a recomendação é usar isso apenas para fins de desenvolvimento.
Interfaces de grãos
Para que um grão ofereça suporte a transações, os métodos transacionais em uma interface de grão devem ser marcados como parte de uma transação usando o TransactionAttribute. O atributo precisa indicar como a chamada grain se comporta em um ambiente transacional, conforme detalhado com os seguintes TransactionOption valores:
- TransactionOption.Create: A chamada é transacional e sempre criará um novo contexto de transação (inicia uma nova transação), mesmo se chamada dentro de um contexto de transação existente.
- TransactionOption.Join: A chamada é transacional, mas só pode ser chamada dentro do contexto de uma transação existente.
- TransactionOption.CreateOrJoin: A chamada é transacional. Se chamado dentro do contexto de uma transação, ele usará esse contexto, caso contrário, criará um novo contexto.
- TransactionOption.Suppress: A chamada não é transacional, mas pode ser chamada de dentro de uma transação. Se chamado dentro do contexto de uma transação, o contexto não será passado para a chamada.
- TransactionOption.Supported: A chamada não é transacional, mas suporta transações. Se chamado dentro do contexto de uma transação, o contexto será passado para a chamada.
- TransactionOption.NotAllowed: A chamada não é transacional e não pode ser chamada de dentro de uma transação. Se chamado dentro do contexto de uma transação, ele lançará o NotSupportedExceptionarquivo .
As chamadas podem ser marcadas como TransactionOption.Create
, o que significa que a chamada sempre iniciará sua transação. Por exemplo, a Transfer
operação no caixa eletrônico abaixo sempre iniciará uma nova transação que envolva as duas contas referenciadas.
namespace TransactionalExample.Abstractions;
public interface IAtmGrain : IGrainWithIntegerKey
{
[Transaction(TransactionOption.Create)]
Task Transfer(string fromId, string toId, decimal amountToTransfer);
}
As operações Withdraw
transacionais e Deposit
na conta de grãos são marcadas TransactionOption.Join
, indicando que elas só podem ser chamadas no contexto de uma transação existente, o que seria o caso se fossem chamadas durante IAtmGrain.Transfer
. A GetBalance
chamada é marcada CreateOrJoin
para que possa ser chamada de dentro de uma transação existente, como via IAtmGrain.Transfer
, ou por conta própria.
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();
}
Considerações importantes
O OnActivateAsync
não pôde ser marcado como transacional, pois qualquer chamada desse tipo requer uma configuração adequada antes da chamada. Ele existe apenas para a API de aplicativo de grão. Isso significa que uma tentativa de ler o estado transacional como parte desses métodos lançará uma exceção no tempo de execução.
Implementações de grãos
Uma implementação de grãos precisa usar uma ITransactionalState<TState> faceta para gerenciar o estado do grão por meio de transações ACID.
public interface ITransactionalState<TState>
where TState : class, new()
{
Task<TResult> PerformRead<TResult>(
Func<TState, TResult> readFunction);
Task<TResult> PerformUpdate<TResult>(
Func<TState, TResult> updateFunction);
}
Todo o acesso de leitura ou gravação ao estado persistente deve ser executado por meio de funções síncronas passadas para a faceta do estado transacional. Isso permite que o sistema de transações realize ou cancele essas operações transacionalmente. Para usar um estado transacional dentro de um grão, defina uma classe de estado serializável a ser persistida e declare o estado transacional no construtor do grão com um TransactionalStateAttribute. Este último declara o nome do estado e, opcionalmente, qual armazenamento de estado transacional usar. Para obter mais informações, consulte Configuração.
[AttributeUsage(AttributeTargets.Parameter)]
public class TransactionalStateAttribute : Attribute
{
public TransactionalStateAttribute(string stateName, string storageName = null)
{
// ...
}
}
Como exemplo, o Balance
objeto de estado é definido da seguinte forma:
namespace TransactionalExample.Abstractions;
[GenerateSerializer]
public record class Balance
{
[Id(0)]
public decimal Value { get; set; } = 1_000;
}
O objeto de estado anterior:
- É decorado com o GenerateSerializerAttribute para instruir o Orleans gerador de código para gerar um serializador.
- Tem uma
Value
propriedade decorada com oIdAttribute
para identificar exclusivamente o membro.
O Balance
objeto state é então usado na implementação da AccountGrain
seguinte maneira:
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);
}
Importante
Um grão transacional deve ser marcado com o ReentrantAttribute para garantir que o contexto da transação seja passado corretamente para a chamada de grão.
No exemplo anterior, o TransactionalStateAttribute é usado para declarar que o parâmetro do balance
construtor deve ser associado a um estado transacional chamado "balance"
. Com essa declaração, Orleans injetará uma ITransactionalState<TState> instância com um estado carregado do armazenamento de estado transacional chamado "TransactionStore"
. O estado pode ser modificado via PerformUpdate
ou lido via PerformRead
. A infraestrutura de transação garantirá que quaisquer alterações realizadas como parte de uma transação, mesmo entre vários grãos distribuídos em um Orleans cluster, serão todas confirmadas ou todas serão desfeitas após a conclusão da chamada de grãos que criou a transação (IAtmGrain.Transfer
no exemplo anterior).
Chamar métodos de transação de um cliente
A maneira recomendada de chamar um método de grão de transação é usar o ITransactionClient
. O ITransactionClient
é registrado automaticamente com o provedor de serviços de injeção de dependência quando o Orleans cliente é configurado. O ITransactionClient
é usado para criar um contexto de transação e para chamar métodos de grão transacional dentro desse contexto. O exemplo a seguir mostra como usar o ITransactionClient
para chamar métodos de grão transacional.
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));
}
No código do cliente anterior:
- O
IHostBuilder
está configurado comUseOrleansClient
.- O
IClientBuilder
usa clustering e transações localhost.
- O
- As
IClusterClient
interfaces eITransactionClient
são recuperadas do provedor de serviços. - Às
from
variáveis eto
são atribuídas as suasIAccountGrain
referências. - O
ITransactionClient
é usado para criar uma transação, chamando:Withdraw
na referência de cereais dafrom
conta.Deposit
na referência de cereais dato
conta.
As transações são sempre confirmadas, a menos que haja uma exceção que seja lançada no transactionDelegate
ou um contraditório transactionOption
especificado. Embora a maneira recomendada de chamar métodos de grãos transacionais seja usar o ITransactionClient
, você também pode chamar métodos de grãos transacionais diretamente de outro grão.
Chamar métodos de transação de outro grão
Os métodos transacionais em uma interface de grão são chamados como qualquer outro método de grão. Como uma abordagem alternativa usando o ITransactionClient
, a AtmGrain
implementação abaixo chama o Transfer
método (que é transacional) na IAccountGrain
interface.
Considere a AtmGrain
implementação, que resolve os dois grãos de conta referenciados e faz as chamadas apropriadas paraWithdraw
: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));
}
O código do aplicativo cliente pode chamar AtmGrain.Transfer
de maneira transacional da seguinte maneira:
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();
Nas chamadas anteriores, um IAtmGrain
é usado para transferir 100 unidades de moeda de uma conta para outra. Após a conclusão da transferência, ambas as contas são consultadas para obter seu saldo atual. A transferência de moeda, bem como ambas as consultas de conta, são realizadas como transações ACID.
Como mostrado no exemplo anterior, as transações podem retornar valores dentro de um Task
, como outras chamadas de grãos. Mas, em caso de falha na chamada, eles não lançarão exceções de aplicativos, mas sim um OrleansTransactionException ou TimeoutException. Se o aplicativo lançar uma exceção durante a transação e essa exceção fizer com que a transação falhe (em vez de falhar devido a outras falhas do sistema), a exceção do aplicativo será a exceção interna do OrleansTransactionException
.
Se uma exceção de transação for lançada do tipo OrleansTransactionAbortedException, a transação falhou e pode ser repetida. Qualquer outra exceção lançada indica que a transação foi encerrada com um estado desconhecido. Como as transações são operações distribuídas, uma transação em um estado desconhecido pode ter sido bem-sucedida, falhado ou ainda estar em andamento. Por esse motivo, é aconselhável permitir que um período de tempo limite de chamada (SiloMessagingOptions.SystemResponseTimeout) passe, para evitar abortamentos em cascata, antes de verificar o estado ou tentar novamente a operação.