Share via


Transactions Orleans

Orleans prend en charge les transactions ACID distribuées par rapport à l’état de grain persistant. Les transactions sont implémentées à l’aide du package NuGet Microsoft.Orleans.Transactions. Le code source de l’exemple d’application de cet article comprend quatre projets :

  • Abstractions : bibliothèque de classes contenant les interfaces de grain et les classes partagées.
  • Grains : bibliothèque de classes contenant les implémentations de grain.
  • Serveur : application console qui consomme les bibliothèques de classes d’abstractions et de grains et agit comme silo Orleans.
  • Client : application console qui consomme la bibliothèque de classes d’abstractions qui représente le client Orleans.

Programme d’installation

Les transactions Orleans sont opt-in. Un silo et un client doivent être configurés pour utiliser des transactions. S’ils ne sont pas configurés, tous les appels aux méthodes transactionnelles sur une implémentation de grain recevront le OrleansTransactionsDisabledException. Pour activer les transactions sur un silo, appelez SiloBuilderExtensions.UseTransactions sur le générateur d’hôte de silo :

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

De même, pour activer les transactions sur le client, appelez ClientBuilderExtensions.UseTransactions sur le générateur d’hôte client :

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

Stockage d’état transactionnel

Pour utiliser des transactions, vous devez configurer un magasin de données. Pour prendre en charge différents magasins de données avec des transactions, le ITransactionalStateStorage<TState> d’abstraction de stockage est utilisé. Cette abstraction est spécifique aux besoins des transactions, contrairement au stockage de grain générique (IGrainStorage). Pour utiliser le stockage spécifique à la transaction, configurez le silo à l’aide de n’importe quelle implémentation de ITransactionalStateStorage, par exemple Azure (AddAzureTableTransactionalStateStorage).

Par exemple, considérez la configuration du générateur d’hôte suivante :

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

À des fins de développement, si le stockage spécifique à la transaction n’est pas disponible pour le magasin de données dont vous avez besoin, vous pouvez utiliser une implémentation IGrainStorage à la place. Pour tout état transactionnel qui n’a pas de magasin configuré, les transactions tenteront de basculer vers le stockage de grain à l’aide d’un pont. L’accès à un état transactionnel via un pont vers le stockage de grain est moins efficace et peut ne pas être pris en charge à l’avenir. Par conséquent, la recommandation est de ne l’utiliser qu’à des fins de développement.

Interfaces de grain

Pour qu’un grain puisse prendre en charge les transactions, les méthodes transactionnelles d’une interface de grain doivent être marquées comme faisant partie d’une transaction à l’aide du TransactionAttribute. L’attribut doit indiquer le comportement de l’appel de grain dans un environnement transactionnel, comme indiqué avec les valeurs TransactionOption suivantes :

  • TransactionOption.Create : l’appel est transactionnel et crée toujours un nouveau contexte de transaction (il démarre une nouvelle transaction), même s’il est appelé dans un contexte de transaction existant.
  • TransactionOption.Join : l’appel est transactionnel, mais ne peut être appelé que dans le contexte d’une transaction existante.
  • TransactionOption.CreateOrJoin : l’appel est transactionnel. S’il est appelé dans le contexte d’une transaction, il utilise ce contexte, sinon il crée un nouveau contexte.
  • TransactionOption.Suppress : l’appel n’est pas transactionnel, mais peut être appelé à partir d’une transaction. S’il est appelé dans le contexte d’une transaction, le contexte ne sera pas passé à l’appel.
  • TransactionOption.Supported : l’appel n’est pas transactionnel, mais prend en charge les transactions. S’il est appelé dans le contexte d’une transaction, le contexte sera passé à l’appel.
  • TransactionOption.NotAllowed : l’appel n’est pas transactionnel et ne peut pas être appelé à partir d’une transaction. S’il est appelé dans le contexte d’une transaction, il lève le NotSupportedException.

Les appels peuvent être marqués comme étant TransactionOption.Create, ce qui signifie que l’appel démarre toujours sa transaction. Par exemple, l’opération Transfer dans le grain ATM ci-dessous démarre toujours une nouvelle transaction qui implique les deux comptes référencés.

namespace TransactionalExample.Abstractions;

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

Les opérations transactionnelles Withdraw et Deposit sur le grain du compte sont marquées TransactionOption.Join, ce qui indique qu’elles ne peuvent être appelées que dans le contexte d’une transaction existante, ce qui serait le cas si elles étaient appelées pendant IAtmGrain.Transfer. L’appel GetBalance est marqué CreateOrJoin, de sorte qu’il puisse être appelé à partir d’une transaction existante, par exemple via IAtmGrain.Transfer, ou par lui-même.

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

Considérations importantes

Le OnActivateAsync n’a pas pu être marqué comme transactionnel, car un tel appel nécessite une configuration préalable appropriée. Il existe uniquement pour l’API d’application de grain. Cela signifie qu’une tentative de lecture de l’état transactionnel dans le cadre de ces méthodes lève une exception dans le runtime.

Implémentations de grains

Une implémentation de grain doit utiliser une facette ITransactionalState<TState> pour gérer l’état du grain via des transactions 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);
}

Tous les accès en lecture ou en écriture à l’état persistant doivent être effectués via des fonctions synchrones passées à la facette d’état transactionnel. Cela permet au système de transaction d’effectuer ou d’annuler ces opérations de façon transactionnelle. Pour utiliser un état transactionnel dans un grain, vous définissez une classe d’état sérialisable à rendre persistante et déclarez l’état transactionnel dans le constructeur du grain avec un TransactionalStateAttribute. Ce dernier déclare le nom de l’état et éventuellement le stockage d’état transactionnel à utiliser. Pour plus d’informations, consultez Configurer.

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

Par exemple, l’objet d’état Balance est défini comme suit :

namespace TransactionalExample.Abstractions;

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

L’objet d’état précédent :

  • Est décoré avec le GenerateSerializerAttribute pour indiquer au générateur de code Orleans de générer un sérialiseur.
  • Possède une propriété Value décorée avec le IdAttribute pour identifier le membre de manière unique.

L’objet d’état Balance est ensuite utilisé dans l’implémentation AccountGrain comme suit :

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

Important

Un grain transactionnel doit être marqué avec le ReentrantAttribute pour s’assurer que le contexte de transaction est correctement passé à l’appel de grain.

Dans l’exemple précédent, le TransactionalStateAttribute est utilisé pour déclarer que le paramètre du constructeur balance doit être associé à un état transactionnel nommé "balance". Avec cette déclaration, Orleans injecte une instance ITransactionalState<TState> avec un état chargé à partir du stockage d’état transactionnel nommé "TransactionStore". L’état peut être modifié via PerformUpdate ou lu via PerformRead. L’infrastructure transactionnelle garantit que les modifications effectuées dans le cadre d’une transaction, même parmi plusieurs grains distribués sur un cluster Orleans, seront toutes validées ou annulées à la fin de l’appel de grain qui a créé la transaction (IAtmGrain.Transfer dans l’exemple précédent).

Appeler des méthodes de transaction à partir d’un client

La méthode recommandée pour appeler une méthode de grain de transaction consiste à utiliser le ITransactionClient. Le ITransactionClient est automatiquement inscrit auprès du fournisseur de services d’injection de dépendances lorsque le client Orleans est configuré. Le ITransactionClient est utilisé pour créer un contexte de transaction et appeler des méthodes de grain transactionnelles dans ce contexte. L’exemple suivant montre comment utiliser le ITransactionClient pour appeler les méthodes de grain transactionnelles.

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

Dans le code client précédent :

  • Le IHostBuilder est configuré avec UseOrleansClient.
    • Le IClientBuilder utilise le clustering et les transactions localhost.
  • Les interfaces IClusterClient et ITransactionClient sont récupérées à partir du fournisseur de services.
  • Les variables from et to sont affectées à leurs références IAccountGrain.
  • Le ITransactionClient est utilisé pour créer une transaction, en appelant :
    • Withdraw sur la référence de grain du compte from.
    • Deposit sur la référence de grain du compte to.

Les transactions sont toujours validées, sauf s’il existe une exception levée dans le transactionDelegate ou une transactionOption contradictoire spécifiée. Bien que le moyen recommandé d’appeler des méthodes de grain transactionnelles est d’utiliser le ITransactionClient, vous pouvez également appeler des méthodes de grain transactionnelles directement à partir d’un autre grain.

Appeler des méthodes de transaction à partir d’un autre grain

Les méthodes transactionnelles sur une interface de grain sont appelées comme n’importe quelle autre méthode de grain. En guise d’approche alternative à l’aide du ITransactionClient, l’implémentation AtmGrain ci-dessous appelle la méthode Transfer (qui est transactionnelle) sur l’interface IAccountGrain.

Considérez l’implémentation AtmGrain, qui résout les deux grains de compte référencés et effectue les appels appropriés à Withdraw et 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));
}

Votre code d’application cliente peut appeler AtmGrain.Transfer de manière transactionnelle comme suit :

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

Dans les appels précédents, un IAtmGrain est utilisé pour transférer 100 unités de devise d’un compte à un autre. Une fois le transfert terminé, les deux comptes sont interrogés pour obtenir leur solde actuel. Le transfert monétaire, ainsi que les deux requêtes de compte, sont effectués en tant que transactions ACID.

Comme illustré dans l’exemple précédent, les transactions peuvent renvoyer des valeurs dans un Task, comme d’autres appels de grain. Mais lors de l’échec de l’appel, ils ne lèvent pas d’exceptions d’application, mais plutôt un OrleansTransactionException ou TimeoutException. Si l’application lève une exception pendant la transaction et que cette exception provoque l’échec de la transaction (par opposition à l’échec en raison d’autres défaillances système), l’exception de l’application est l’exception interne du OrleansTransactionException.

Si une exception de transaction de type OrleansTransactionAbortedException est levée, la transaction a échoué et peut être retentée. Toute autre exception levée indique que la transaction s’est terminée avec un état inconnu. Étant donné que les transactions sont des opérations distribuées, une transaction dans un état inconnu peut avoir réussi, échoué ou être encore en cours. Pour cette raison, il est conseillé d’autoriser une période d’expiration d’appel (SiloMessagingOptions.SystemResponseTimeout) à passer, pour éviter les abandons en cascade, avant de vérifier l’état ou de réessayer l’opération.