Transacciones de Orleans

Orleans admite transacciones ACID distribuidas en el estado de grano persistente. Las transacciones se implementan mediante el paquete NuGet Microsoft.Orleans.Transactions. El código fuente de la aplicación de ejemplo de este artículo consta de cuatro proyectos:

  • Abstracciones: biblioteca de clases que contiene las interfaces de grano y las clases compartidas.
  • Granos: biblioteca de clases que contiene las implementaciones de grano.
  • Servidor: aplicación de consola que consume las bibliotecas de clases de abstracciones y granos y actúa como silo de Orleans.
  • Cliente: aplicación de consola que consume la biblioteca de clases de abstracciones que representa al cliente de Orleans.

Instalación

Las transacciones de Orleans son opcionales. Se deben configurar un silo y un cliente para usar transacciones. Si no están configurados, las llamadas a métodos transaccionales en una implementación de grano recibirán la excepción OrleansTransactionsDisabledException. Para habilitar las transacciones en un silo, llame a SiloBuilderExtensions.UseTransactions en el generador del host del silo:

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

Del mismo modo, para habilitar las transacciones en el cliente, llame a ClientBuilderExtensions.UseTransactions en el generador del host del cliente:

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

Almacenamiento de estado transaccional

Para usar transacciones, debe configurar un almacén de datos. Para admitir varios almacenes de datos con las transacciones, se utiliza la abstracción de almacenamiento ITransactionalStateStorage<TState>. Esta abstracción es específica de las necesidades de las transacciones, a diferencia del almacenamiento de grano específico (IGrainStorage). Para usar el almacenamiento específico de la transacción, configure el silo mediante cualquier implementación de ITransactionalStateStorage, como Azure (AddAzureTableTransactionalStateStorage).

Por ejemplo, considere la siguiente configuración del generador del 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();

Con fines de desarrollo, si el almacenamiento específico de la transacción no está disponible para el almacén de datos que necesita, se puede usar una implementación de IGrainStorage en su lugar. Para cualquier estado transaccional que no tenga un almacén configurado, las transacciones intentarán conmutar por error al almacenamiento de grano mediante un puente. El acceso a un estado transaccional mediante un puente al almacenamiento de grano es menos eficaz y es posible que no se admita en el futuro. Por lo tanto, la recomendación es usar esto solo con fines de desarrollo.

Interfaces de grano

Para que un grano admita transacciones, los métodos transaccionales de una interfaz de grano se deben marcar como parte de una transacción mediante TransactionAttribute. El atributo debe indicar cómo se comporta la llamada de grano en un entorno transaccional, tal como se detalla con los siguientes valores de TransactionOption:

  • TransactionOption.Create: la llamada es transaccional y siempre creará un nuevo contexto de transacción (inicia una nueva transacción), incluso si se llama dentro de un contexto de transacción existente.
  • TransactionOption.Join: la llamada es transaccional, pero solo se puede llamar dentro del contexto de una transacción existente.
  • TransactionOption.CreateOrJoin: la llamada es transaccional. Si se llama dentro del contexto de una transacción, usará ese contexto; de lo contrario, creará uno nuevo.
  • TransactionOption.Suppress: la llamada no es transaccional, pero se puede llamar desde dentro de una transacción. Si se llama dentro del contexto de una transacción, el contexto no se pasará a la llamada.
  • TransactionOption.Supported: la llamada no es transaccional, pero admite transacciones. Si se llama dentro del contexto de una transacción, el contexto se pasará a la llamada.
  • TransactionOption.NotAllowed: la llamada no es transaccional y no se puede llamar desde dentro de una transacción. Si se llama dentro del contexto de una transacción, se producirá la excepción NotSupportedException.

Las llamadas se pueden marcar como TransactionOption.Create, lo que significa que la llamada siempre iniciará su transacción. Por ejemplo, la operación Transfer del siguiente grano de ATM siempre iniciará una nueva transacción que implique las dos cuentas a las que se hace referencia.

namespace TransactionalExample.Abstractions;

public interface IAtmGrain : IGrainWithIntegerKey
{
    [Transaction(TransactionOption.Create)]
    Task Transfer(string fromId, string toId, decimal amountToTransfer);
}

Las operaciones transaccionales Withdraw y Deposit en el grano de la cuenta se marcan como TransactionOption.Join, lo que indica que solo se las puede llamar en el contexto de una transacción existente, que sería el caso si se las llamara durante IAtmGrain.Transfer. La llamada GetBalance se marca CreateOrJoin para que se pueda llamar desde una transacción existente, como a través de IAtmGrain.Transfer o por sí sola.

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

Consideraciones importantes

OnActivateAsync no se puede marcar como transaccional, ya que cualquier llamada de este tipo requiere una configuración adecuada antes de la llamada. Solo existe para la API de la aplicación de grano. Esto significa que un intento de leer el estado transaccional como parte de estos métodos producirá una excepción en tiempo de ejecución.

Implementaciones de grano

Una implementación de grano debe usar una faceta ITransactionalState<TState> para administrar el estado del grano mediante transacciones 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 el acceso de lectura o escritura al estado persistente se debe realizar a través de funciones sincrónicas pasadas a la faceta de estado transaccional. Esto permite que el sistema de transacciones realice o cancele estas operaciones de forma transaccional. Para usar un estado transaccional dentro de un grano, defina una clase de estado serializable que se va a conservar y declare el estado transaccional en el constructor del grano con un elemento TransactionalStateAttribute. Este último declara el nombre de estado y, de forma opcional, el almacenamiento de estado transaccional que se va a usar. Para obtener más información, consulte Configuración.

[AttributeUsage(AttributeTargets.Parameter)]
public class TransactionalStateAttribute : Attribute
{
    public TransactionalStateAttribute(string stateName, string storageName = null)
    {
        // ...
    }
}

Por ejemplo, el objeto de estado Balance se define de la siguiente manera:

namespace TransactionalExample.Abstractions;

[GenerateSerializer]
public record class Balance
{
    [Id(0)]
    public decimal Value { get; set; } = 1_000;
}

El objeto de estado anterior:

  • Está decorado con GenerateSerializerAttribute para indicar al generador de código de Orleans que genere un serializador.
  • Tiene una propiedad Value decorada con IdAttribute para identificar de forma única al miembro.

A continuación, el objeto de estado Balance se usa en la implementación de AccountGrain de la siguiente manera:

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

Un grano transaccional se debe marcar con ReentrantAttribute para asegurarse de que se pase correctamente el contexto de transacción a la llamada del grano.

En el ejemplo anterior, se usa TransactionalStateAttribute para declarar que el parámetro del constructor de balance debe estar asociado a un estado transaccional llamado "balance". Con esta declaración, Orleans insertará una instancia de ITransactionalState<TState> con un estado cargado desde el almacenamiento de estado transaccional llamado "TransactionStore". El estado se puede modificar a través de PerformUpdate o leer a través de PerformRead. La infraestructura de transacción garantizará que todos estos cambios realizados como parte de una transacción, incluso entre varios granos distribuidos en un clúster de Orleans, se confirmen o se deshagan todos al finalizar la llamada del grano que creó la transacción (IAtmGrain.Transfer en los ejemplos anteriores).

Llamada a métodos de transacción desde un cliente

La manera recomendada de llamar a un método de grano de transacción es usar ITransactionClient. ITransactionClient se registra automáticamente con el proveedor de servicios de inserción de dependencias cuando se configura el cliente de Orleans. ITransactionClient se usa para crear un contexto de transacción y para llamar a métodos de grano transaccional dentro de ese contexto. En el ejemplo siguiente, se muestra cómo usar ITransactionClient para llamar a métodos de grano transaccional.

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

En el código de cliente anterior:

  • IHostBuilder está configurado con UseOrleansClient.
    • IClientBuilder usa las transacciones y la agrupación en clústeres de localhost.
  • Las interfaces IClusterClient e ITransactionClient se recuperan del proveedor de servicios.
  • A las variables from y to se les asignan sus referencias a IAccountGrain.
  • ITransactionClient se usa para crear una transacción, llamando a:
    • Withdraw en la referencia del grano de la cuenta from.
    • Deposit en la referencia del grano de la cuenta to.

Las transacciones siempre se confirman a menos que se genere una excepción que se produzca en transactionDelegate o en un elemento transactionOption contradictorio especificado. Aunque la manera recomendada de llamar a métodos de grano transaccional es usar ITransactionClient, también puede llamar a métodos de grano transaccional directamente desde otro grano.

Llamada a métodos de transacción desde otro grano

Se llama a los métodos transaccionales de una interfaz de grano como a cualquier otro método de grano. Como enfoque alternativo mediante ITransactionClient, la implementación de AtmGrain siguiente llama al método Transfer (que es transaccional) de la interfaz IAccountGrain.

Tenga en cuenta la implementación de AtmGrain, que resuelve los dos granos de cuenta a los que se hace referencia y realiza las llamadas adecuadas a Withdraw y 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));
}

El código de la aplicación cliente puede llamar a AtmGrain.Transfer de forma transaccional como se indica a continuación:

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

En las llamadas anteriores, se usa un elemento IAtmGrain para transferir 100 unidades de moneda de una cuenta a otra. Una vez completada la transferencia, se consultan ambas cuentas para obtener su saldo actual. La transferencia de moneda, así como las consultas de cuentas, se realizan como transacciones ACID.

Como se muestra en el ejemplo anterior, las transacciones pueden devolver valores dentro de un elemento Task, como otras llamadas de grano. Pero, cuando se produce un error en la llamada, no generan excepciones de aplicación, sino una excepción OrleansTransactionException o TimeoutException. Si la aplicación produce una excepción durante la transacción y esa excepción hace que se produzca un error en la transacción (en lugar de que se produzcan errores debido a otros errores del sistema), la excepción de aplicación será la excepción interna de OrleansTransactionException.

Si se produce una excepción de transacción de tipo OrleansTransactionAbortedException, se producirá un error en la transacción y se podrá reintentar. Cualquier otra excepción que se haya producido indicará que la transacción finalizó con un estado desconocido. Dado que las transacciones son operaciones distribuidas, una transacción en un estado desconocido podría haber sido correcta, con errores o seguir en curso. Por este motivo, es aconsejable permitir que pase un período de tiempo de espera de llamada (SiloMessagingOptions.SystemResponseTimeout), para evitar anulaciones en cascada, antes de comprobar el estado o volver a intentar la operación.