Partage via


Orleans Transactions

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 se compose de 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 abstractions et les bibliothèques de classes de grains et endosse le rôle de Orleans silo.
  • Client : application console qui consomme la bibliothèque de classes d’abstractions qui représente le Orleans client.

Configuration

Orleans les transactions nécessitent une adhésion. Le silo et le 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 reçoivent un OrleansTransactionsDisabledException. Pour activer les transactions sur un silo, appelez la méthode SiloBuilderExtensions.UseTransactions sur le constructeur 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 constructeur 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 gérer différents magasins de données avec des transactions, Orleans utilise l’abstraction ITransactionalStateStorage<TState> de stockage. Cette abstraction est spécifique aux besoins des transactions, contrairement au stockage générique des grains (IGrainStorage). Pour utiliser le stockage spécifique à la transaction, configurez le silo à l’aide de n’importe quelle implémentation d’Azure ITransactionalStateStorage(AddAzureTableTransactionalStateStorage, par exemple).

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

using Azure.Data.Tables;

await Host.CreateDefaultBuilder(args)
    .UseOrleans((_, silo) =>
    {
        silo.UseLocalhostClustering();

        if (Environment.GetEnvironmentVariable(
                "ORLEANS_STORAGE_CONNECTION_STRING") is { } connectionString)
        {
            silo.AddAzureTableTransactionalStateStorage(
                "TransactionStore", 
                options => options.TableServiceClient = new TableServiceClient(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 IGrainStorage implémentation à la place. Pour tout état transactionnel sans magasin configuré, les transactions tentent de basculer vers le stockage des grains à l’aide d’un pont. L’accès à l’état transactionnel via un pont vers le stockage des grains est moins efficace et peut ne pas être pris en charge à l’avenir. Par conséquent, nous vous recommandons d’utiliser cette approche uniquement à des fins de développement.

Interfaces de grains

Pour qu’un grain prenne en charge les transactions, vous devez marquer les méthodes transactionnelles sur son interface de grain à l’aide du TransactionAttribute dans le cadre d’une transaction. L’attribut doit indiquer comment l'appel de grain se comporte dans un environnement transactionnel, comme spécifié par les valeurs suivantes TransactionOption :

  • TransactionOption.Create: l’appel est transactionnel et crée toujours un 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 contexte.
  • TransactionOption.Suppress: l’appel n’est pas transactionnel, mais peut être appelé à partir d’une transaction. S'il est appelé dans le cadre d'une transaction, le contexte ne sera pas transmis à l'appel.
  • TransactionOption.Supported: l’appel n’est pas transactionnel, mais prend en charge les transactions. Si elle est appelée dans le contexte d'une transaction, le contexte sera transmis à l'appel.
  • TransactionOption.NotAllowed: l’appel n’est pas transactionnel et ne peut pas être appelé à partir d’une transaction. Si elle est appelée dans le contexte d’une transaction, elle lève le NotSupportedException.

Vous pouvez marquer les appels comme TransactionOption.Create, ce qui signifie que l’appel commence toujours sa transaction. Par exemple, l’opération Transfer dans le grain ATM ci-dessous démarre toujours une nouvelle transaction impliquant 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 Withdraw et Deposit sur le grain de données du compte sont marquées TransactionOption.Join. Cela indique qu’ils ne peuvent être appelés que dans le contexte d’une transaction existante, ce qui serait le cas si elle est appelée pendant IAtmGrain.Transfer. L’appel GetBalance est marqué CreateOrJoin, de sorte que vous pouvez l’appeler soit à partir d’une transaction existante (comme via IAtmGrain.Transfer), soit de manière autonome.

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

Vous ne pouvez pas marquer OnActivateAsync comme transactionnel, car un tel appel nécessite une configuration appropriée avant l’appel. Il existe uniquement pour l'API de l'application Grain. Cela signifie que la tentative de lecture de l'état transactionnel dans le cadre de ces méthodes provoque une exception à l'exécution.

Implémentations de grain

Une implémentation de grain doit utiliser une ITransactionalState<TState> facette 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);
}

Effectuez tout accès en lecture ou écriture à l'état persistant via des fonctions synchrones transmises à la facette d'état transactionnelle. Cela permet au système de transaction d’effectuer ou d’annuler ces opérations de façon transactionnelle. Pour utiliser l’état transactionnel dans un grain, définissez une classe d’état sérialisable à conserver et déclarez l’état transactionnel dans le constructeur du grain à l’aide d’un TransactionalStateAttribute. Cet attribut déclare le nom de l’état et, éventuellement, le stockage d’état transactionnel à utiliser. Pour plus d’informations, consultez Le programme d’installation.

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

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

namespace TransactionalExample.Abstractions;

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

Objet d’état précédent :

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

L’objet Balance d’état 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 garantir que le contexte de transaction est correctement transmis à l'appel du grain.

Dans l’exemple précédent, le TransactionalStateAttribute paramètre de balance constructeur doit être associé à un état transactionnel nommé "balance". Avec cette déclaration, Orleans injecte une instance avec l’état ITransactionalState<TState> chargé à partir du stockage d’état transactionnel nommé "TransactionStore". Vous pouvez modifier l’état via PerformUpdate ou le lire via PerformRead. L’infrastructure transactionnelle garantit que toutes les modifications effectuées dans le cadre d’une transaction (même parmi plusieurs grains répartis sur un Orleans cluster) sont 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 transactionnel consiste à utiliser le ITransactionClient. Orleans s’inscrit automatiquement auprès du fournisseur de services d’injection de dépendances ITransactionClient lorsque vous configurez le Orleans client. Utilisez ITransactionClient pour créer un contexte transactionnel et appeler des méthodes transactionnelles dans ce contexte. L'exemple suivant montre comment utiliser ITransactionClient pour appeler des méthodes transactionnelles de grain.

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 IHostApplicationBuilder est configuré avec UseOrleansClient.
    • IClientBuilder utilise le clustering et les transactions avec localhost.
  • Les interfaces IClusterClient et ITransactionClient sont récupérées par le fournisseur de services.
  • Les variables from et to se voient attribuer 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 si une exception est levée dans le transactionDelegate ou si un transactionOption contradictoire est spécifié. Bien que l'utilisation de ITransactionClient soit la méthode recommandée pour l'appel de méthodes transactionnelles de grain, vous pouvez également les appeler directement depuis un autre grain.

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

Appelez des méthodes transactionnelles sur une interface de grain comme pour toute autre méthode de grain. En guise d’alternative à l’utilisation ITransactionClient, l’implémentation AtmGrain ci-dessous appelle la Transfer méthode (qui est transactionnelle) sur l’interface IAccountGrain .

Considérez l’implémentation AtmGrain, qui résout les deux grains de comptes 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 transactionnellement 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();

Lors des appels précédents, un IAtmGrain est utilisé pour transférer 100 unités de monnaie 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, tout comme d'autres appels de grain, peuvent retourner des valeurs dans un Task. Toutefois, 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 devient l’exception interne du OrleansTransactionException.

Si une exception de type OrleansTransactionAbortedException de transaction 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 distribuées, une transaction dans un état inconnu peut avoir réussi, échoué ou encore être en cours. Pour cette raison, il est conseillé de permettre à un délai d'attente d'appel (SiloMessagingOptions.SystemResponseTimeout) de s'écouler avant de vérifier l’état ou de réessayer l’opération pour éviter les abandons en cascade.