Transações do Orleans

O Orleans dá suporte a transações ACID distribuídas em relação ao estado persistente de granularidade. As transações são implementadas usando o pacote NuGet Microsoft.Orleans.Transactions. O código-fonte do aplicativo de amostra neste artigo é composto por quatro projetos:

  • Abstrações: uma biblioteca de classes que contém as interfaces de granularidade e classes compartilhadas.
  • Granularidades: uma biblioteca de classes que contém as implementações de granularidade.
  • Servidor: um aplicativo de console que consome as bibliotecas de classes abstrações e granularidades e atua como o silo do Orleans.
  • Cliente: um aplicativo de console que consome a biblioteca de classes abstrações que representa o cliente Orleans.

Instalação

As transações do Orleans são aceitas. 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 granularidade receberão o OrleansTransactionsDisabledException. Para habilitar transações em um silo, chame SiloBuilderExtensions.UseTransactions no 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 no construtor de host do cliente:

var builder = Host.CreateDefaultBuilder(args)
    UseOrleansClient((context, clientBuilder) =>
    {
        clientBuilder.UseTransactions();
    });

Armazenamento de estado transacional

Para usar transações, é necessário configurar um armazenamento de dados. Para dar suporte a vários armazenamentos de dados com transações, a abstração de armazenamento ITransactionalStateStorage<TState> é usada. Essa abstração é específica às 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 de ITransactionalStateStorage, como o Azure (AddAzureTableTransactionalStateStorage).

Por exemplo, considere a seguinte configuração de construtor de host:

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 implementação de IGrainStorage em vez disso. Para qualquer estado transacional que não tenha um armazenamento configurado, as transações tentarão fazer failover para o armazenamento de granularidade usando uma ponte. O acesso a um estado transacional por meio de uma ponte para o armazenamento de granularidade é menos eficiente e pode não ter suporte no futuro. Portanto, a recomendação é usar isso apenas para fins de desenvolvimento.

Interfaces de granularidade

Para que uma granularidade tenha suporte para transações, os métodos transacionais em uma interface de granularidade devem ser marcados como parte de uma transação usando o TransactionAttribute. O atributo precisa indicar como a chamada de granularidade se comporta em um ambiente transacional conforme detalhado com os seguintes valores TransactionOption:

  • 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 for chamada dentro do contexto de uma transação, ela 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 chamada dentro do contexto de uma transação, o contexto não será passado para a chamada.
  • TransactionOption.Supported: a chamada não é transacional, mas tem suporte para 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 chamada dentro do contexto de uma transação, ele lançará um NotSupportedException.

As chamadas podem ser marcadas como TransactionOption.Create, o que significa que a chamada sempre iniciará sua transação. Por exemplo, a operação Transfer no granularidade ATM 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 transacionais Withdraw e Deposit na granularidade de conta são marcadas como TransactionOption.Join, indicando que só podem ser chamadas dentro do contexto de uma transação existente, o que seria o caso se fossem chamados durante IAtmGrain.Transfer. A chamada GetBalance é marcada CreateOrJoin para que possa ser chamada de dentro de uma transação existente, como por meio IAtmGrain.Transferou 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

A OnActivateAsync não pode ser marcada como transacional, pois qualquer chamada desse tipo requer uma configuração adequada antes da chamada. Existe apenas para a API de aplicativo de granularidade. Isso significa que uma tentativa de ler o estado transacional como parte desses métodos gerará uma exceção no tempo de execução.

Implementações de granularidade

Uma implementação de granularidade precisa usar uma faceta ITransactionalState<TState> para gerenciar o estado de granularidade 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 precisa ser executado por meio de funções síncronas passadas para a faceta de estado transacional. Isso permite que o sistema de transações execute ou cancele essas operações transacionalmente. Para usar um estado transacional dentro de uma granularidade, você define uma classe de estado serializável para ser persistida e declara o estado transacional no construtor da granularidade com um TransactionalStateAttribute. O ú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 objeto de estado Balance é 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 gerador de código do Orleans a gerar um serializador.
  • Tem uma propriedade Value decorada com o IdAttribute para identificar exclusivamente o membro.

Em seguida, o objeto de estado Balance é usado na implementação do AccountGrain da 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

Uma granularidade transacional deve ser marcada com o ReentrantAttribute para garantir que o contexto da transação seja passado corretamente para a chamada de granularidade.

No exemplo acima, o TransactionalStateAttribute é usado para declarar que o parâmetro do construtor balance deve ser associado a um estado transacional chamado "balance". Com essa declaração, o Orleans injetará uma instância ITransactionalState<TState> com um estado carregado do armazenamento de estado transacional chamado "TransactionStore". O estado pode ser modificado por meio de PerformUpdate ou lido por PerformRead. A infraestrutura da transação garantirá que qualquer alteração realizada como parte de uma transação, mesmo entre várias granularidades distribuídas em um cluster Orleans, será confirmada ou desfeita após a conclusão da chamada da granularidade 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 granularidade de transação é usar o ITransactionClient. O ITransactionClient é registrado automaticamente com o provedor de serviço de injeção de dependência quando o cliente do Orleans é configurado. O ITransactionClient é usado para criar um contexto de transação e chamar métodos de granularidade transacional dentro desse contexto. O exemplo a seguir mostra como usar o ITransactionClient para chamar métodos de granularidade 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 é configurado com UseOrleansClient.
    • O IClientBuilder usa clustering localhost e transações.
  • As interfaces IClusterClient e ITransactionClient são recuperadas do provedor de serviços.
  • As variáveis from e to recebem suas referências IAccountGrain.
  • O ITransactionClient é usado para criar uma transação, chamando:
    • Withdraw na referência de granularidade da conta from.
    • Deposit na referência de granularidade da conta to.

As transações são sempre confirmadas, a menos que haja uma exceção gerada no transactionDelegate ou um transactionOption contraditório especificado. Embora a maneira recomendada de chamar métodos de granularidade transacional seja usar o ITransactionClient, você também poderá chamar métodos de granularidade transacional diretamente de outra granularidade.

Chamar métodos de transação de outra granularidade

Métodos transacionais em uma interface de granularidade são chamados como qualquer outro método de granularidade. Como uma abordagem alternativa usando o ITransactionClient, a implementação AtmGrain abaixo chama o método Transfer (que é transacional) na interface IAccountGrain.

Considere a implementação AtmGrain, que resolve as duas granularidades de conta referenciadas 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 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, uma IAtmGrain é usada para transferir 100 unidades monetárias de uma conta para outra. Depois que a transferência for concluída, ambas as contas serão consultadas para obter o saldo atual. A transferência de moeda, bem como ambas as consultas de conta, são executadas como transações ACID.

Conforme mostrado no exemplo anterior, as transações podem retornar valores dentro de um Task, como outras chamadas de granularidade. Mas, após a falha de chamada, eles não gerarão exceções de aplicativo, mas sim um OrleansTransactionException ou TimeoutException. Se o aplicativo gerar 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 gerada do tipo OrleansTransactionAbortedException, a transação falhará e poderá ser repetida novamente. Qualquer outra exceção gerada 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 poderia ter sido bem-sucedida, com falha ou ainda em andamento. Por esse motivo, recomendamos permitir que um tempo de limite de chamada (SiloMessagingOptions.SystemResponseTimeout) passe para evitar anulações em cascata, antes de verificar o estado ou tentar novamente a operação.