Teilen über


Orleans-Transaktionen

Orleans unterstützt verteilte ACID-Transaktionen für persistenten Grainzustand. Transaktionen werden mithilfe des NuGet-Pakets Microsoft.Orleans.Transactions implementiert. Der Quellcode für die Beispiel-App in diesem Artikel besteht aus vier Projekten:

  • Abstractions: Eine Klassenbibliothek, die Grainschnittstellen und freigegebene Klassen enthält.
  • Grains: Eine Klassenbibliothek, die die Grainimplementierungen enthält.
  • Server: Eine Konsolen-App, die die Klassenbibliotheken abstractions und grains nutzt und als Orleans-Silo dient.
  • Client: Eine Konsolen-App, die die Klassenbibliothek abstractions nutzt, die den Orleans-Client darstellt.

Einrichten

Orleans-Transaktionen werden abonniert. Silo und Client müssen für die Verwendung von Transaktionen konfiguriert werden. Werden sie nicht konfiguriert, empfangen Aufrufe für Transaktionsmethoden in einer Grainimplementierung die OrleansTransactionsDisabledException. Wenn Transaktionen in einem Silo ermöglicht werden sollen, muss SiloBuilderExtensions.UseTransactions im Hostgenerator des Silos aufgerufen werden:

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

Entsprechend muss ClientBuilderExtensions.UseTransactions im Hostgenerator des Clients aufgerufen werden, wenn Transaktionen im Client ermöglicht werden sollen:

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

Speicher für den Zustand von Transaktionen

Wenn Transaktionen verwendet werden sollen, müssen Sie einen Datenspeicher konfigurieren. Zur Unterstützung verschiedener Datenspeicher mit Transaktionen wird die Speicherabstraktion ITransactionalStateStorage<TState> verwendet. Diese Abstraktion ist im Gegensatz zum generischen Grainspeicher speziell auf die Anforderungen von Transaktionen zugeschnitten (IGrainStorage). Wenn ein transaktionsspezifischer Speicher verwendet werden soll, müssen Sie den Silo mit einer Implementierung von ITransactionalStateStorage wie Azure (AddAzureTableTransactionalStateStorage) konfigurieren.

Betrachten Sie beispielsweise die folgende Hostgeneratorkonfiguration:

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

Wenn bei der Entwicklung kein transaktionsspezifischer Speicher für den erforderlichen Datenspeicher verfügbar ist, können Sie stattdessen auch eine IGrainStorage-Implementierung verwenden. Bei jedem Transaktionszustand ohne konfigurierten Speicher wird mithilfe einer Brücke ein Failover zum Grainspeicher ausgeführt. Der Zugriff auf einen Transaktionszustand über eine Brücke zum Grainspeicher ist weniger effizient und wird in Zukunft möglicherweise nicht mehr unterstützt. Daher sollte diese Lösung nur für Entwicklungszwecke verwendet werden.

Grainschnittstellen

Damit Transaktionen von einem Grain unterstützt werden können, müssen Transaktionsmethoden in einer Grainschnittstelle mithilfe von TransactionAttribute als Teil einer Transaktion gekennzeichnet werden. Das Attribut muss wie beschrieben mit den folgenden TransactionOption Werten angeben, wie sich der Grainaufruf in einer Transaktionsumgebung verhält:

  • TransactionOption.Create: Der Aufruf ist transaktional und erstellt immer einen neuen Transaktionskontext (er startet eine neue Transaktion), auch bei einem Aufruf innerhalb eines bestehenden Transaktionskontexts.
  • TransactionOption.Join: Der Aufruf ist transaktional, kann jedoch nur im Kontext einer bereits vorhandenen Transaktion erfolgen.
  • TransactionOption.CreateOrJoin: Der Anruf ist transaktional. Bei einem Aufruf im Kontext einer Transaktion wird dieser Kontext verwendet, da sonst ein neuer Kontext erstellt wird.
  • TransactionOption.Suppress: Der Anruf ist nicht transaktional, kann aber innerhalb einer Transaktion aufgerufen werden. Bei einem Aufruf im Kontext einer Transaktion wird der Kontext nicht an den Aufruf weitergegeben.
  • TransactionOption.Supported: Der Anruf ist nicht transaktional, unterstützt jedoch Transaktionen. Bei einem Aufruf im Kontext einer Transaktion wird der Kontext an den Aufruf weitergegeben.
  • TransactionOption.NotAllowed: Der Anruf ist nicht transaktional und kann innerhalb einer Transaktion nicht aufgerufen werden. Bei einem Aufruf im Kontext einer Transaktion wird die NotSupportedException ausgelöst.

Aufruf können als TransactionOption.Create gekennzeichnet werden, was bedeutet, dass der Aufruf immer eine eigene Transaktion startet. Der Vorgang Transfer im folgenden ATM-Grain startet beispielsweise immer eine neue Transaktion, an der beide referenzierten Konten beteiligt sind.

namespace TransactionalExample.Abstractions;

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

Die Transaktionsvorgänge Withdraw und Deposit im Kontograin sind als TransactionOption.Joingekennzeichnet. Das bedeutet, dass sie nur im Kontext einer vorhandenen Transaktion aufgerufen werden können, was der Fall wäre, wenn sie während IAtmGrain.Transferaufgerufen werden. Der Aufruf GetBalance ist als CreateOrJoin gekennzeichnet und kann somit innerhalb einer vorhandenen Transaktion beispielsweise mittels IAtmGrain.Transfer oder unabhängig aufgerufen werden.

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

Wichtige Hinweise

OnActivateAsync kann nicht als transaktional gekennzeichnet werden, da ein solcher Aufruf bereits vor dem Aufrufen ordnungsgemäß eingerichtet werden muss. Er ist nur für die Grainanwendungs-API vorhanden. Das bedeutet, dass beim Lesen des Transaktionszustands im Rahmen dieser Methoden in der Runtime eine Ausnahme ausgelöst wird.

Grainimplementierungen

Für eine Grainimplementierung muss eine ITransactionalState<TState>-Facette verwendet werden, um den Grainzustand mithilfe von ACID-Transaktionen zu verwalten.

public interface ITransactionalState<TState>
    where TState : class, new()
{
    Task<TResult> PerformRead<TResult>(
        Func<TState, TResult> readFunction);

    Task<TResult> PerformUpdate<TResult>(
        Func<TState, TResult> updateFunction);
}

Alle Lese- oder Schreibzugriffe auf den persistenten Zustand müssen über synchrone Funktionen erfolgen, die an die Facette für den Zustand einer Transaktion übergeben werden. Dadurch ist es möglich, dass das Transaktionssystem diese Vorgänge transaktional durchführt oder abbricht. Wenn ein Transaktionszustand innerhalb eines Grains verwendet werden soll, muss eine serialisierbare Zustandsklasse als persistent definiert und der Transaktionszustand im Konstruktor des Grains mit einem TransactionalStateAttribute deklariert werden. Mit letzterem wird der Zustandsname deklariert und optional angegeben, welcher Speicher für den Zustand von Transaktionen verwendet werden soll. Weitere Informationen finden Sie unter Einrichten.

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

Als Beispiel wird das Zustandsobjekt Balance wie folgt definiert:

namespace TransactionalExample.Abstractions;

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

Das obige Zustandsobjekt gilt Folgendes:

  • Es wurde mit dem GenerateSerializerAttribute erweitert, sodass der Orleans-Codegenerator ein Serialisierungsmodul erstellt.
  • Es verfügt über eine Value-Eigenschaft, die mit dem IdAttribute erweitert wurde, sodass das Element eindeutig identifiziert wird.

Anschließend wird das Zustandsobjekt Balance in der AccountGrain-Implementierung wie folgt verwendet:

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

Wichtig

Ein Transaktionsgrain muss mit dem ReentrantAttribute gekennzeichnet sein, um sicherzustellen, dass der Transaktionskontext ordnungsgemäß an den Grainaufruf weitergegeben wird.

Im obigen Beispiel wird das TransactionalStateAttribute verwendet, um zu deklarieren, dass der Konstruktorparameter balance dem Transaktionszustand "balance" zugeordnet werden muss. Mit dieser Deklaration fügt Orleans eine ITransactionalState<TState>-Instanz mit einem aus dem Speicher für den Zustand von Transaktionen (mit dem Namen "TransactionStore") geladenen Zustand ein. Der Zustand kann mithilfe von PerformUpdate geändert oder mithilfe von PerformRead gelesen werden. In der Transaktionsinfrastruktur ist sichergestellt, dass alle derartigen Änderungen, die im Rahmen einer Transaktion durchgeführt werden, selbst bei mehreren über einen Orleans-Cluster verteilten Grains entweder alle committet oder nach Abschluss des Grainaufrufs, der die Transaktion erzeugt hat (IAtmGrain.Transfer im obigen Beispiel), rückgängig gemacht werden.

Aufrufen von Transaktionsmethoden über einen Client

Es wird empfohlen, eine Transaktionsgrainmethode mithilfe von ITransactionClient aufzurufen. ITransactionClient wird beim Konfigurieren des Orleans-Clients automatisch beim Anbieter für den Abhängigkeitinjektionsdienst registriert. ITransactionClient wird verwendet, um einen Transaktionskontext zu erstellen und um Transaktionsgrainmethoden innerhalb dieses Kontexts aufzurufen. Das folgende Beispiel zeigt, wie ITransactionClient zum Aufrufen von Transaktionsgrainmethoden verwendet wird.

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

Für den obigen Clientcode gilt Folgendes:

  • IHostBuilder wurde mit UseOrleansClient konfiguriert.
    • IClientBuilder verwendet Localhostclustering und -transaktionen.
  • Die Schnittstellen IClusterClient und ITransactionClient werden beim Dienstanbieter abgerufen.
  • Den Variablen from und to werden die jeweiligen IAccountGrain-Verweise zugewiesen.
  • ITransactionClient wird zum Erstellen einer Transaktion verwendet, wobei Folgendes aufgerufen wird:
    • Withdraw im from-Kontograinverweis
    • Deposit im to-Kontograinverweis

Transaktionen werden immer committet, es sei denn, es liegt eine Ausnahme vor, die in transactionDelegate ausgelöst wird, oder es ist eine widersprüchliche transactionOption angegeben. Es wird zwar empfohlen, Transaktionsgrainmethoden mithilfe von ITransactionClient aufzurufen. Transaktionsgrainmethoden können jedoch auch direkt über ein anderes Grain aufgerufen werden.

Aufrufen von Transaktionsmethoden über ein anderes Grain

Transaktionsmethoden in einer Grainschnittstelle werden wie jede andere Grainmethode aufgerufen. Alternativ kann die folgende AtmGrain-Implementierung mithilfe von ITransactionClient die Transfer-Methode (die transaktional ist) in der IAccountGrain-Schnittstelle aufrufen.

Betrachten Sie die AtmGrain-Implementierung, mit der die beiden referenzierten Kontograins aufgelöst und Withdraw und Deposit entsprechend aufgerufen werden:

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

Mit dem Clientanwendungscode kann AtmGrain.Transfer wie folgt transaktional aufgerufen werden:

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

Bei den obigen Aufrufen wird IAtmGrain verwendet, um 100 Währungseinheiten von einem Konto an ein anderes zu übertragen. Nach Abschluss der Übertragung wird für beide Konten der aktuelle Saldo abgefragt. Die Währungsübertragung sowie beide Kontoabfragen werden als ACID-Transaktionen ausgeführt.

Transaktionen können wie im obigen Beispiel innerhalb einer Task (z. B. Grainaufrufe) Werte zurückgeben. Bei einem Aufruffehler wird jedoch keine Anwendungsausnahme, sondern eine OrleansTransactionException oder TimeoutException ausgelöst. Wenn die Ausnahme während der Transaktion eine Ausnahme auslöst und diese Ausnahme (und nicht ein anderer Systemfehler) dafür verantwortlich ist, dass bei der Transaktion ein Fehler auftritt, ist die Anwendungsausnahme die innere Ausnahme der OrleansTransactionException.

Wenn eine Transaktionsausnahme vom Typ OrleansTransactionAbortedException ausgelöst wird, tritt bei der Transaktion ein Fehler auf und die Transaktion kann wiederholt werden. Wird eine andere Ausnahme ausgelöst, bedeutet das, dass die Transaktion mit einem unbekannten Zustand beendet wurde. Da es sich bei Transaktionen um verteilte Vorgänge handelt, kann es sich bei einer Transaktion mit unbekanntem Zustand erfolgreich oder fehlerhaft gewesen sein oder noch ausgeführt werden. Daher sollte für Aufrufe vor der Überprüfung des Zustands oder der Wiederholung des Vorgangs eine Zeitdauer für Zeitüberschreitungen (SiloMessagingOptions.SystemResponseTimeout) vorgesehen werden, um kaskadierende Abbrüche zu vermeiden.