Transakcje w warstwie Orleans

Orleans obsługuje rozproszone transakcje ACID względem trwałego stanu ziarna. Transakcje są implementowane przy użyciu platformy Microsoft.Orleans. Pakiet NuGet transakcji . Kod źródłowy przykładowej aplikacji w tym artykule składa się z czterech projektów:

  • Abstrakcje: biblioteka klas zawierająca interfejsy ziarna i udostępnione klasy.
  • Ziarna: biblioteka klas zawierająca implementacje ziarna.
  • Serwer: aplikacja konsolowa, która korzysta z bibliotek klas abstrakcji i ziarna i działa jako Orleans silos.
  • Klient: aplikacja konsolowa, która korzysta z biblioteki klas abstrakcji, która reprezentuje Orleans klienta.

Ustawienia

Orleans transakcje są opt-in. Zarówno silos, jak i klient muszą być skonfigurowane do używania transakcji. Jeśli nie są skonfigurowane, wszystkie wywołania metod transakcyjnych w implementacji ziarna otrzymają .OrleansTransactionsDisabledException Aby włączyć transakcje na silosie, wywołaj SiloBuilderExtensions.UseTransactions konstruktora hosta silosu:

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

Podobnie, aby włączyć transakcje na kliencie, wywołaj ClientBuilderExtensions.UseTransactions konstruktor hosta klienta:

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

Magazyn stanu transakcyjnego

Aby korzystać z transakcji, należy skonfigurować magazyn danych. Aby obsługiwać różne magazyny danych z transakcjami, używana jest abstrakcja ITransactionalStateStorage<TState> magazynu. Ta abstrakcja jest specyficzna dla potrzeb transakcji, w przeciwieństwie do ogólnego magazynu ziarna (IGrainStorage). Aby użyć magazynu specyficznego dla transakcji, skonfiguruj silos przy użyciu dowolnej implementacji ITransactionalStateStorage, takiej jak Azure (AddAzureTableTransactionalStateStorage).

Rozważmy na przykład następującą konfigurację konstruktora hostów:

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

W celach programistycznych, jeśli magazyn specyficzny dla transakcji nie jest dostępny dla potrzebnego magazynu danych, możesz zamiast tego użyć IGrainStorage implementacji. W przypadku dowolnego stanu transakcyjnego, który nie ma skonfigurowanego magazynu, transakcje będą próbować przejść w tryb failover do magazynu ziarna przy użyciu mostka. Uzyskiwanie dostępu do stanu transakcyjnego za pośrednictwem mostka do magazynowania ziarna jest mniej wydajne i może nie być obsługiwane w przyszłości. W związku z tym zaleceniem jest użycie go tylko do celów programistycznych.

Interfejsy ziarna

Aby ziarno obsługiwało transakcje, metody transakcyjne w interfejsie ziarna muszą być oznaczone jako część transakcji przy użyciu .TransactionAttribute Atrybut musi wskazywać, jak działa wywołanie ziarna w środowisku transakcyjnym zgodnie z poniższymi TransactionOption wartościami:

  • TransactionOption.Create: Wywołanie jest transakcyjne i zawsze tworzy nowy kontekst transakcji (uruchamia nową transakcję), nawet jeśli jest wywoływane w istniejącym kontekście transakcji.
  • TransactionOption.Join: Wywołanie jest transakcyjne, ale może być wywoływane tylko w kontekście istniejącej transakcji.
  • TransactionOption.CreateOrJoin: Wywołanie jest transakcyjne. Jeśli zostanie wywołana w kontekście transakcji, będzie używać tego kontekstu, w przeciwnym razie utworzy nowy kontekst.
  • TransactionOption.Suppress: Wywołanie nie jest transakcyjne, ale może być wywoływane z poziomu transakcji. Jeśli zostanie wywołana w kontekście transakcji, kontekst nie zostanie przekazany do wywołania.
  • TransactionOption.Supported: Wywołanie nie jest transakcyjne, ale obsługuje transakcje. Jeśli zostanie wywołana w kontekście transakcji, kontekst zostanie przekazany do wywołania.
  • TransactionOption.NotAllowed: Wywołanie nie jest transakcyjne i nie może być wywoływane z poziomu transakcji. Jeśli wywołana w kontekście transakcji, zwróci wartość NotSupportedException.

Wywołania mogą być oznaczone jako TransactionOption.Create, co oznacza, że wywołanie zawsze rozpocznie transakcję. Na przykład Transfer operacja w poniższym ziarnie atm zawsze rozpoczyna nową transakcję, która obejmuje dwa przywołyzowane konta.

namespace TransactionalExample.Abstractions;

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

Operacje Withdraw transakcyjne i Deposit ziarna konta są oznaczone jako TransactionOption.Join, co wskazuje, że mogą być wywoływane tylko w kontekście istniejącej transakcji, co byłoby przypadkiem wywołania podczas .IAtmGrain.Transfer Wywołanie GetBalance jest oznaczone CreateOrJoin , aby można było wywołać je z poziomu istniejącej transakcji, na przykład za pośrednictwem IAtmGrain.Transfermetody lub samodzielnie.

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

Ważne uwagi

OnActivateAsync Nie można oznaczyć elementu jako transakcyjnego, ponieważ takie wywołanie wymaga odpowiedniej konfiguracji przed wywołaniem. Istnieje tylko dla interfejsu API aplikacji ziarna. Oznacza to, że próba odczytania stanu transakcyjnego w ramach tych metod zgłosi wyjątek w środowisku uruchomieniowym.

Implementacje ziarna

Implementacja ziarna musi używać ITransactionalState<TState> aspektu do zarządzania stanem ziarna za pośrednictwem transakcji 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);
}

Cały dostęp do odczytu lub zapisu do stanu utrwalonego musi być wykonywany za pośrednictwem funkcji synchronicznych przekazywanych do aspektu stanu transakcyjnego. Dzięki temu system transakcji może wykonywać lub anulować te operacje transakcyjnie. Aby użyć stanu transakcyjnego w ziarnie, zdefiniuj klasę stanu z możliwością serializacji i zadeklaruj stan transakcyjny w konstruktorze ziarna za pomocą klasy TransactionalStateAttribute. Ten ostatni deklaruje nazwę stanu i, opcjonalnie, który transakcyjny magazyn stanu ma być używany. Aby uzyskać więcej informacji, zobacz Setup (Konfiguracja).

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

Na przykład Balance obiekt stanu jest definiowany w następujący sposób:

namespace TransactionalExample.Abstractions;

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

Poprzedni obiekt stanu:

  • Element jest ozdobiony elementem GenerateSerializerAttribute w celu poinstruowania generatora kodu o wygenerowaniu Orleans serializatora.
  • Value Ma właściwość, która jest ozdobiona elementem IdAttribute , aby jednoznacznie zidentyfikować element członkowski.

Obiekt Balance stanu jest następnie używany w implementacji AccountGrain w następujący sposób:

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

Ważne

Ziarno transakcyjne musi być oznaczone znakiem , ReentrantAttribute aby upewnić się, że kontekst transakcji jest poprawnie przekazywany do wywołania ziarna.

W poprzednim przykładzie parametr jest używany do deklarowania, TransactionalStateAttribute że balance parametr konstruktora powinien być skojarzony ze stanem transakcyjnym o nazwie "balance". Ta deklaracja Orleans spowoduje wstrzyknięcie ITransactionalState<TState> wystąpienia ze stanem załadowanym z magazynu stanu transakcyjnego o nazwie "TransactionStore". Stan można modyfikować za pomocą polecenia lub odczytywać za pomocą PerformUpdate polecenia PerformRead. Infrastruktura transakcji zapewni, że wszelkie takie zmiany wykonywane w ramach transakcji, nawet między wieloma ziarnami rozproszonymi w Orleans klastrze, zostaną zatwierdzone lub wszystkie zostaną cofnięte po zakończeniu wywołania ziarna, które utworzyły transakcję (IAtmGrain.Transfer w poprzednim przykładzie).

Wywoływanie metod transakcji od klienta

Zalecanym sposobem wywoływania metody ziarna transakcji jest użycie metody ITransactionClient. Element ITransactionClient jest automatycznie rejestrowany u dostawcy usługi wstrzykiwania zależności po skonfigurowaniu Orleans klienta. Służy ITransactionClient do tworzenia kontekstu transakcji i wywoływania metod ziarna transakcyjnego w tym kontekście. W poniższym przykładzie pokazano, jak używać ITransactionClient metody do wywoływania metod ziarna transakcyjnego.

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

W poprzednim kodzie klienta:

  • Element IHostBuilder jest skonfigurowany za pomocą polecenia UseOrleansClient.
    • Funkcja IClientBuilder używa klastrowania localhost i transakcji.
  • Interfejsy IClusterClient i ITransactionClient są pobierane z dostawcy usług.
  • Zmienne from i to są przypisane do ich IAccountGrain odwołań.
  • Element ITransactionClient służy do tworzenia transakcji, wywołując:
    • Withdraw w odwołaniu from do ziarna konta.
    • Deposit w odwołaniu to do ziarna konta.

Transakcje są zawsze zatwierdzane, chyba że istnieje wyjątek zgłaszany w transactionDelegate określonym elem lub sprzeczne transactionOption . Chociaż zalecanym sposobem wywoływania metod ziarna transakcyjnego jest użycie ITransactionClientmetody , można również wywołać metody ziarna transakcyjnego bezpośrednio z innego ziarna.

Wywoływanie metod transakcji z innego ziarna

Metody transakcyjne w interfejsie ziarna są wywoływane jak każda inna metoda ziarna. Jako alternatywne podejście przy użyciu ITransactionClientAtmGrain metody implementacja poniżej wywołuje Transfer metodę (która jest transakcyjna) w interfejsieIAccountGrain.

Rozważ implementację AtmGrain , która rozpoznaje dwa przywołyte ziarna kont i wykonuje odpowiednie wywołania do Withdraw i 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));
}

Kod aplikacji klienckiej może wywoływać AtmGrain.Transfer w sposób transakcyjny w następujący sposób:

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

W poprzednich wywołaniach IAtmGrain element jest używany do transferu 100 jednostek waluty z jednego konta do drugiego. Po zakończeniu transferu oba konta są odpytywane, aby uzyskać bieżące saldo. Transfer waluty, a także zapytania dotyczące konta, są wykonywane jako transakcje ACID.

Jak pokazano w poprzednim przykładzie, transakcje mogą zwracać wartości w obiekcie Task, podobnie jak inne wywołania ziarna. Jednak po niepowodzeniu wywołania nie będą zgłaszać wyjątków aplikacji, ale raczej błędu OrleansTransactionException lub TimeoutException. Jeśli aplikacja zgłasza wyjątek podczas transakcji i że wyjątek powoduje niepowodzenie transakcji (w przeciwieństwie do niepowodzenia z powodu innych awarii systemu), wyjątek aplikacji będzie wyjątkiem OrleansTransactionExceptionwewnętrznym .

Jeśli zgłaszany jest wyjątek transakcji typu OrleansTransactionAbortedException, transakcja nie powiodła się i może zostać ponowiona. Każdy inny zgłoszony wyjątek wskazuje, że transakcja została zakończona z nieznanym stanem. Ponieważ transakcje są operacjami rozproszonymi, transakcja w nieznanym stanie mogła zakończyć się powodzeniem, niepowodzeniem lub nadal trwać. Z tego powodu zaleca się zezwolenie na przekazywanie limitu czasu wywołania (SiloMessagingOptions.SystemResponseTimeout), aby uniknąć przerywania kaskadowego przed zweryfikowaniem stanu lub ponowieniu próby wykonania operacji.