Partilhar via


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.Transactions Pacote NuGet. O código-fonte do aplicativo de exemplo neste artigo consiste em quatro projetos:

  • Abstrações: Uma biblioteca de classes que contém interfaces grain e classes partilhadas.
  • 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.

Configuração

Orleans as transações requerem consentimento prévio. Tanto o silo quanto o cliente devem ser configurados para usar transações. Se eles não estiverem configurados, qualquer chamada para métodos transacionais em uma implementação de grão receberá um OrleansTransactionsDisabledException. 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, Orleans usa a abstração de armazenamento ITransactionalStateStorage<TState>. 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 de que necessitas, poderás usar uma IGrainStorage implementação. Para qualquer estado transacional sem um armazenamento configurado, as transações tentam fazer failover para o armazenamento de grãos usando uma ponte. O acesso ao estado transacional por meio de uma ponte para o armazenamento de grãos é menos eficiente e pode não ser suportado no futuro. Portanto, recomendamos o uso dessa abordagem apenas para fins de desenvolvimento.

Interfaces de grãos

Para que um grão ofereça suporte a transações, você deve marcar métodos transacionais em sua interface de grão como parte de uma transação usando o TransactionAttribute. O atributo precisa indicar como a chamada de grão se comporta em um ambiente transacional, conforme detalhado pelos 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 for chamado dentro do contexto de uma transação, o contexto não será passado para a ligação.
  • TransactionOption.Supported: A chamada não é transacional, mas suporta transações. Se for 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 NotSupportedException.

Você pode marcar chamadas como TransactionOption.Create, o que significa que a chamada sempre inicia sua transação. Por exemplo, a Transfer operação no caixa eletrônico abaixo sempre inicia uma nova transação envolvendo 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 transacionais Withdraw e Deposit no grão de conta estão marcadas TransactionOption.Join. Isso indica que eles só podem ser chamados dentro do contexto de uma transação existente, o que seria o caso se chamados durante IAtmGrain.Transfer. A GetBalance chamada é marcada CreateOrJoin, para que você possa chamá-la 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

Não é possível marcar OnActivateAsync como transacional porque 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 tentar ler o estado transacional como parte desses métodos lança uma exceção durante a 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);
}

Execute todo o acesso de leitura ou gravação ao estado persistente 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 o 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 usando um TransactionalStateAttribute. Esse atributo 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:

  • Está decorado com o GenerateSerializerAttribute para instruir o Orleans gerador de código a criar um serializador.
  • Tem uma propriedade Value que está decorada com o IdAttribute para identificar exclusivamente o membro.

O objeto Balance state é então usado na implementação de AccountGrain da seguinte forma.

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 declara que o balance parâmetro do construtor deve ser associado a um estado transacional chamado "balance". Com essa declaração, Orleans injeta uma ITransactionalState<TState> instância com o estado carregado do armazenamento de estado transacional chamado "TransactionStore". Você pode modificar o estado via PerformUpdate ou lê-lo via PerformRead. A infraestrutura de transação garante que quaisquer alterações realizadas como parte de uma transação (mesmo entre vários grãos distribuídos em um Orleans cluster) sejam todas confirmadas ou todas desfeitas após a conclusão da chamada de grão que criou a transação (IAtmGrain.Transfer no exemplo anterior).

Chamar métodos de transação de um cliente

A forma recomendada de invocar um método de grão transacional é usar o ITransactionClient. Orleans Registra-se automaticamente com o provedor de ITransactionClient serviços de injeção de dependência quando você configura o Orleans cliente. Use ITransactionClient para criar um contexto de transação e chamar métodos de grão transacional dentro desse contexto. O exemplo a seguir mostra como usar 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 com UseOrleansClient.
    • O IClientBuilder utiliza clustering em localhost e transações.
  • As interfaces IClusterClient e ITransactionClient são recuperadas do provedor de serviços.
  • Às from variáveis e to são atribuídas as suas IAccountGrain referências.
  • O ITransactionClient é usado para criar uma transação, chamando:
    • Withdraw na referência de grão da conta from.
    • Deposit na referência de grão da conta to.

As transações são sempre confirmadas, a menos que uma exceção seja lançada em transactionDelegate ou seja especificado um contraditório transactionOption. Embora o uso ITransactionClient seja a maneira recomendada de chamar métodos de grãos transacionais, você também pode chamá-los diretamente de outro grão.

Chamar métodos de transação de outro grão

Chame métodos transacionais numa interface de grain como quaisquer outros métodos de grain. Como alternativa ao uso do ITransactionClient, a implementação AtmGrain abaixo chama o método Transfer (que é transacional) na interface IAccountGrain.

Considere a AtmGrain implementação, que resolve os dois componentes de conta referenciados e faz as chamadas apropriadas para Withdraw e 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 transacionalmente 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. No entanto, em caso de falha na chamada, eles não lançam exceções de aplicação, 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 se tornará a exceção interna do OrleansTransactionException.

Se uma exceção de transação do tipo OrleansTransactionAbortedException for lançada, a transação falhou e pode ser repetida. Qualquer outra exceção lançada indica que a transação terminou 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 antes de verificar o estado ou tentar novamente a operação para evitar abortamentos em cascata.