Compartir a través de


Orleans Transacciones

Orleans admite transacciones ACID distribuidas con el estado de grano persistente. Las transacciones se implementan usando 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: una biblioteca de clases que contiene las interfaces de "grain" y las clases compartidas.
  • Granos: una biblioteca de clases que contiene las implementaciones de grano.
  • Servidor: una aplicación de consola que consume las abstracciones y las bibliotecas de clases de granos y actúa como silo Orleans .
  • Cliente: una aplicación de consola que consume la biblioteca de clases de abstracciones que representa al Orleans cliente.

Configuración

Orleans las transacciones son opcionales. Tanto el silo como el cliente deben configurarse para usar transacciones. Si no están configurados, las llamadas a métodos transaccionales en una implementación de 'grain' reciben un OrleansTransactionsDisabledException. Para habilitar transacciones en un silo, llame al SiloBuilderExtensions.UseTransactions generador de hosts de 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 de hosts 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 transacciones, Orleans usa la abstracción ITransactionalStateStorage<TState>de almacenamiento . Esta abstracción es específica de las necesidades de las transacciones, a diferencia del almacenamiento de grano genérico (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 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 fines de desarrollo, si el almacenamiento específico de transacción no está disponible para el almacén de datos que necesita, puede usar una IGrainStorage implementación como alternativa. Para cualquier estado transaccional sin un almacén configurado, las transacciones intentan cambiar automáticamente al almacenamiento de grain utilizando un puente. El acceso al estado transaccional a través de un puente al almacenamiento de granos es menos eficiente y podría no ser compatible en el futuro. Por lo tanto, se recomienda usar este enfoque solo con fines de desarrollo.

Interfaces de grano

Para que un grano soporte transacciones, debe marcar los métodos transaccionales en su interfaz de grano como parte de una transacción utilizando el TransactionAttribute. El atributo debe indicar cómo se comporta la llamada de grano en un entorno transaccional, como se detalla en los valores siguientes 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á un nuevo contexto.
  • 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, lanzará el NotSupportedException.

Puede marcar llamadas como TransactionOption.Create, lo que significa que la llamada siempre inicia su transacción. Por ejemplo, la Transfer operación en el grano de ATM siguiente siempre inicia una nueva transacción que implica las dos cuentas referenciadas.

namespace TransactionalExample.Abstractions;

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

Las operaciones Withdraw y Deposit transaccionales en el conjunto de cuentas se marcan TransactionOption.Join. Esto indica que solo se puede llamar a dentro del contexto de una transacción existente, que sería el caso si se llama durante IAtmGrain.Transfer. La GetBalance llamada se marca como CreateOrJoin, por lo que puede llamarla desde una transacción existente (como a través de IAtmGrain.Transfer) o por sí misma.

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

No se puede marcar OnActivateAsync como transaccional porque ninguna llamada de este tipo requiere una configuración adecuada antes de la llamada. Solo existe para la API de aplicación de granos. Esto significa que al intentar leer el estado transaccional como parte de estos métodos se produce una excepción en el tiempo de ejecución.

Implementaciones granulares

Una implementación de grano debe usar una ITransactionalState<TState> faceta para administrar el estado de grano a través de 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);
}

Realice todo el acceso de lectura o escritura al estado persistente a través de funciones síncronas pasadas a la faceta del estado transaccional. Esto permite que el sistema de transacciones realice o cancele estas operaciones transaccionalmente. Para usar el 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 mediante un TransactionalStateAttribute. Este atributo declara el nombre de estado y, opcionalmente, qué almacenamiento de estado transaccional 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 Balance objeto de estado se define de la siguiente manera:

namespace TransactionalExample.Abstractions;

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

Objeto de estado anterior:

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

A continuación, el objeto de estado se utiliza en la implementación de la siguiente manera: BalanceAccountGrain

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 debe marcarse con ReentrantAttribute para asegurarse de que el contexto de transacción se pasa correctamente a la llamada de grano.

En el ejemplo anterior, declara TransactionalStateAttribute que el balance parámetro constructor debe estar asociado a un estado transaccional denominado "balance". Con esta declaración, Orleans inserta una ITransactionalState<TState> instancia con el estado cargado desde el almacenamiento de estado transaccional denominado "TransactionStore". Puede modificar el estado mediante PerformUpdate o leerlo utilizando PerformRead. La infraestructura de transacciones garantiza que todos los cambios realizados como parte de una transacción (incluso entre varios granos distribuidos en un Orleans clúster) se confirmen o se deshacen al finalizar la llamada de grano que creó la transacción (IAtmGrain.Transfer en el ejemplo anterior).

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

La manera recomendada de llamar a un método de grano transaccional es usar .ITransactionClient Orleans se registra automáticamente con el proveedor de servicios de inserción de dependencias ITransactionClient al configurar el cliente Orleans. Use ITransactionClient para crear un contexto de transacción y 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 transaccionales.

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 la agrupación en clústeres y transacciones de localhost.
  • Las interfaces IClusterClient y ITransactionClient se recuperan del proveedor de servicios.
  • A las from variables y to se les asignan sus IAccountGrain referencias.
  • ITransactionClient se usa para crear una transacción, llamando a:
    • Withdraw en la cuenta de referencia de grano from.
    • Deposit en la cuenta de referencia de grano to.

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

Llamar a métodos de transacción desde otro módulo

Llame a métodos transaccionales en una interfaz de grano como cualquier otro método de grano. Como alternativa a usar ITransactionClient, la implementación AtmGrain siguiente llama al método Transfer (que es transaccional) en 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 correspondientes 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 aplicación cliente puede llamar a AtmGrain.Transfer de manera transaccional de la siguiente manera:

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, IAtmGrain se usa 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 cuenta, se realizan como transacciones ACID.

Como se muestra en el ejemplo anterior, las transacciones pueden devolver valores dentro de un Task, al igual que otras llamadas de procesamiento por lotes. Sin embargo, tras un error de llamada, no lanzan excepciones de aplicación, sino un 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 producir errores debido a otros errores del sistema), la excepción de aplicación se convierte en la excepción interna de OrleansTransactionException.

Si se produce una excepción de transacción de tipo OrleansTransactionAbortedException , se produce un error en la transacción y se puede reintentar. Cualquier otra excepción lanzada indica que la transacción ha finalizado con un estado desconocido. Dado que las transacciones son operaciones distribuidas, una transacción en un estado desconocido podría haberse realizado correctamente, con errores o seguir en curso. Por este motivo, es aconsejable permitir que transcurra un período de tiempo de espera para la llamada (SiloMessagingOptions.SystemResponseTimeout) antes de comprobar el estado o volver a intentar la operación para evitar abortos en cascada.