Orleans transazioni

Orleans supporta transazioni ACID distribuite rispetto allo stato granulare persistente. Le transazioni vengono implementate tramite il pacchetto NuGet Microsoft.Orleans.Transazioni. Il codice sorgente per l'app di esempio in questo articolo è costituito da quattro progetti:

  • Astrazioni: libreria di classi contenente le interfacce di granularità e le classi condivise.
  • Granularità: libreria di classi contenente le implementazioni di granularità.
  • Server: un'app console che utilizza le astrazioni e le librerie di classi di grani e funge da silo Orleans.
  • Client: un'app console che utilizza la libreria di classi astrazioni che rappresenta il client Orleans.

Attrezzaggio

Le transazioni Orleans sono di consenso esplicito. Un silo e un client devono essere entrambi configurati per l'uso delle transazioni. Se non sono configurati, qualsiasi chiamata a metodi transazionali in un'implementazione granulare riceverà OrleansTransactionsDisabledException. Per abilitare le transazioni in un silo, chiamare SiloBuilderExtensions.UseTransactions sul generatore di host silo:

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

Analogamente, per abilitare le transazioni nel client, chiamare ClientBuilderExtensions.UseTransactions sul generatore host client:

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

Archiviazione dello stato transazionale

Per usare le transazioni, è necessario configurare un archivio dati. Per supportare vari archivi dati con transazioni, viene usata l'astrazione di archiviazione ITransactionalStateStorage<TState>. Questa astrazione è specifica per le esigenze delle transazioni, a differenza dell'archiviazione granulare generica (IGrainStorage). Per usare l'archiviazione specifica della transazione, configurare il silo usando qualsiasi implementazione di ITransactionalStateStorage, ad esempio Azure (AddAzureTableTransactionalStateStorage).

Si consideri, ad esempio, la configurazione del generatore host seguente:

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

Ai fini dello sviluppo, se l'archiviazione specifica della transazione non è disponibile per l'archivio dati necessario, è possibile usare invece un'implementazione IGrainStorage. Per qualsiasi stato transazionale che non dispone di un archivio configurato, le transazioni tenteranno di eseguire il failover nello spazio di archiviazione granulare usando un bridge. L'accesso a uno stato transazionale tramite un bridge per l'archiviazione granulare è meno efficiente e potrebbe non essere supportato in futuro. Di conseguenza, è consigliabile usarlo solo a scopo di sviluppo.

Interfacce dei grani

Per supportare le transazioni, i metodi transazionali in un'interfaccia granulare devono essere contrassegnati come parte di una transazione tramite TransactionAttribute. L'attributo deve indicare il comportamento della chiamata granulare in un ambiente transazionale come descritto in dettaglio con i valori TransactionOption seguenti:

  • TransactionOption.Create: la chiamata è transazionale e creerà sempre un nuovo contesto di transazione (avvia una nuova transazione), anche se viene chiamato all'interno di un contesto di transazione esistente.
  • TransactionOption.Join: la chiamata è transazionale, ma può essere chiamato solo all'interno del contesto di una transazione esistente.
  • TransactionOption.CreateOrJoin: la chiamata è transazionale. Se viene chiamato all'interno del contesto di una transazione, userà tale contesto, altrimenti creerà un nuovo contesto.
  • TransactionOption.Suppress: la chiamata non è transazionale, ma può essere chiamato dall'interno di una transazione. Se viene chiamato all'interno del contesto di una transazione, il contesto non verrà passato alla chiamata.
  • TransactionOption.Supported: la chiamata non è transazionale, ma supporta le transazioni. Se viene chiamato all'interno del contesto di una transazione, il contesto verrà passato alla chiamata.
  • TransactionOption.NotAllowed: la chiamata non è transazionale e non può essere chiamata dall'interno di una transazione. Se viene chiamato all'interno del contesto di una transazione, genererà NotSupportedException.

Le chiamate possono essere contrassegnate come TransactionOption.Create, ovvero la chiamata avvierà sempre la relativa transazione. Ad esempio, l'operazione di Transfer nella granularità ATM seguente avvierà sempre una nuova transazione che coinvolge i due conti a cui si fa riferimento.

namespace TransactionalExample.Abstractions;

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

Le operazioni transazionali Withdraw e Deposit sulla granularità dell'account sono contrassegnate TransactionOption.Join, a indicare che possono essere chiamate solo all'interno del contesto di una transazione esistente, che sarebbe il caso in cui fossero state chiamate durante IAtmGrain.Transfer. La chiamata GetBalance è contrassegnata CreateOrJoin in modo che possa essere chiamata dall'interno di una transazione esistente, ad esempio tramite IAtmGrain.Transfer o autonomamente.

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

Considerazioni importanti

Non è stato possibile contrassegnare il OnActivateAsync come transazionale perché una chiamata di questo tipo richiede una configurazione corretta prima della chiamata. Esiste solo per l'API dell'applicazione granulare. Ciò significa che un tentativo di leggere lo stato transazionale come parte di questi metodi genererà un'eccezione nel runtime.

Implementazioni granulari

Un'implementazione granulare deve usare un facet ITransactionalState<TState> per gestire lo stato di granularità tramite transazioni 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);
}

Tutti gli accessi in lettura o scrittura allo stato persistente devono essere eseguiti tramite funzioni sincrone passate al facet dello stato transazionale. In questo modo il sistema delle transazioni può eseguire o annullare queste operazioni in modo transazionale. Per usare uno stato transazionale all'interno di una granularità, definire una classe di stato serializzabile da rendere persistente e dichiarare lo stato transazionale nel costruttore della granularità con un oggetto TransactionalStateAttribute. Quest'ultimo dichiara il nome dello stato e, facoltativamente, l'archiviazione dello stato transazionale da usare. Per altre informazioni, vedere setup.

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

Ad esempio, l'oggetto di stato Balance viene definito come segue:

namespace TransactionalExample.Abstractions;

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

Oggetto stato precedente:

  • Viene decorato con GenerateSerializerAttribute per indicare al generatore di codice Orleans di generare un serializzatore.
  • Dispone di una proprietà Value decorata con IdAttribute per identificare in modo univoco il membro.

L'oggetto di stato Balance viene quindi usato nell'implementazione AccountGrain come segue:

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

Una granularità transazionale deve essere contrassegnata con l’oggetto ReentrantAttribute per assicurarsi che il contesto della transazione venga passato correttamente alla chiamata granulare.

Nell'esempio precedente, il TransactionalStateAttribute viene usato per dichiarare che il parametro del costruttore balance deve essere associato a uno stato transazionale denominato "balance". Con questa dichiarazione, Orleans inietta un'istanza ITransactionalState<TState> con uno stato caricato dall'archivio di stato transazionale denominato "TransactionStore". Lo stato può essere modificato tramite PerformUpdate o letto tramite PerformRead. L'infrastruttura delle transazioni garantisce che tutte le modifiche eseguite come parte di una transazione, anche tra più grani distribuiti su un cluster Orleans, vengano sottoposte a commit, o tutte verranno annullate al completamento della chiamata granulare che ha creato la transazione (IAtmGrain.Transfer nell'esempio precedente).

Chiamare i metodi di transazione da un client

Il modo consigliato per chiamare un metodo granulare della transazione consiste nell'usare ITransactionClient. L'oggetto ITransactionClient viene registrato automaticamente con il provider di servizi di inserimento delle dipendenze quando il client Orleans è configurato. ITransactionClient viene utilizzato per creare un contesto di transazione e per chiamare metodi di granularità transazionale all'interno di tale contesto. Nell'esempio seguente, viene illustrato come utilizzare ITransactionClient per chiamare metodi di granularità transazionale.

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

Nel codice client precedente:

  • L'oggetto IHostBuilder è configurato con UseOrleansClient.
    • IClientBuilder usa il clustering localhost e le transazioni.
  • Le interfacce IClusterClient e ITransactionClient vengono recuperate dal provider di servizi.
  • Alle variabili from e to vengono assegnati i riferimenti IAccountGrain.
  • ITransactionClient viene usato per creare una transazione, chiamando:
    • Withdraw nel riferimento granulare dell'account from.
    • Deposit nel riferimento granulare dell'account to.

Il commit delle transazioni viene eseguito sempre a meno che non sia presente un'eccezione generata in transactionDelegate o in un oggetto contraddittorio transactionOption specificato. Anche se il modo consigliato per chiamare metodi granulari transazionali consiste nell'usare ITransactionClient, è anche possibile chiamare metodi granulari transazionali direttamente da un altro granulare.

Chiamare metodi di transazione da un'altra granularità

I metodi transazionali su un'interfaccia granulare vengono chiamati come qualsiasi altro metodo granulare. Come approccio alternativo che usa ITransactionClient, l'implementazione AtmGrain seguente chiama il metodo Transfer (transazionale) nell'interfaccia IAccountGrain.

Si consideri l'implementazione AtmGrain, che risolve i due grani di account a cui si fa riferimento e effettua le chiamate appropriate a 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));
}

Il codice dell'app client può chiamare AtmGrain.Transfer in modo transazionale come segue:

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

Nelle chiamate precedenti, un oggetto IAtmGrain viene utilizzato per trasferire 100 unità di valuta da un conto a un altro. Al termine del trasferimento, vengono eseguite query su entrambi i conti per ottenere il saldo corrente. Il trasferimento di valuta, nonché entrambe le query di conto, vengono eseguite come delle transazioni ACID.

Come illustrato nell'esempio precedente, le transazioni possono restituire valori all'interno di un oggetto Task, come altre chiamate di granularità. Tuttavia, in caso di errore di chiamata, non genereranno eccezioni dell'applicazione, ma piuttosto un oggetto OrleansTransactionException o TimeoutException. Se l'applicazione genera un'eccezione durante la transazione e tale eccezione causa l'esito negativo della transazione (anziché causare l'esito negativo a causa di altri errori di sistema), l'eccezione dell'applicazione sarà l'eccezione interna di OrleansTransactionException.

Se viene generata un'eccezione di transazione di tipo OrleansTransactionAbortedException, la transazione non è riuscita e può essere ritentata. Qualsiasi altra eccezione generata indica che la transazione è stata terminata con uno stato sconosciuto. Poiché le transazioni sono operazioni distribuite, una transazione in uno stato sconosciuto potrebbe avere avuto esito positivo, non riuscito o ancora in corso. Per questo motivo, è consigliabile consentire il passaggio di un periodo di timeout di chiamata (SiloMessagingOptions.SystemResponseTimeout) per evitare interruzioni a catena, prima di verificare lo stato o ritentare l'operazione.