Orleans 트랜잭션

Orleans는 영구 조직 상태에 대해 분산 ACID 트랜잭션을 지원합니다. 트랜잭션은 Microsoft.Orleans.Transactions NuGet 패키지를 사용하여 구현됩니다. 이 문서의 샘플 앱에 대한 소스 코드는 다음 4개의 프로젝트로 구성됩니다.

  • Abstractions: 그레인 인터페이스 및 공유 클래스를 포함하는 클래스 라이브러리입니다.
  • Grains: 그레인 구현을 포함하는 클래스 라이브러리입니다.
  • 서버: 추상화 및 조직 클래스 라이브러리를 사용하고 Orleans 사일로 역할을 하는 콘솔 앱입니다.
  • 클라이언트: Orleans 클라이언트를 나타내는 추상화 클래스 라이브러리를 사용하는 콘솔 앱입니다.

설정

Orleans 트랜잭션은 옵트인됩니다. 사일로와 클라이언트는 모두 트랜잭션을 사용하도록 구성해야 합니다. 구성되지 않은 경우 그레인 구현에서 트랜잭션 메서드를 호출하면 OrleansTransactionsDisabledException이 수신됩니다. 사일로에서 트랜잭션을 사용하도록 설정하려면 사일로 호스트 작성기에서 SiloBuilderExtensions.UseTransactions를 호출합니다.

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

마찬가지로 클라이언트에서 트랜잭션을 사용하도록 설정하려면 클라이언트 호스트 작성기에서 ClientBuilderExtensions.UseTransactions을 호출합니다.

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

트랜잭션 상태 스토리지

트랜잭션을 사용하려면 데이터 저장소를 구성해야 합니다. 트랜잭션으로 다양한 데이터 저장소를 지원하기 위해 스토리지 추상화 ITransactionalStateStorage<TState>가 사용됩니다. 이 추상화는 일반 조직 스토리지(IGrainStorage)와 달리 트랜잭션의 요구 사항에 따라 다릅니다. 트랜잭션별 스토리지를 사용하기 위해 Azure(AddAzureTableTransactionalStateStorage)와 같은 ITransactionalStateStorage 구현을 사용하여 사일로를 구성합니다.

예를 들어 다음 호스트 작성기 구성을 고려합니다.

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

개발을 위해 필요한 데이터 저장소에 트랜잭션별 스토리지를 사용할 수 없는 경우 IGrainStorage 구현을 대신 사용할 수 있습니다. 저장소가 구성되지 않은 트랜잭션 상태의 경우 트랜잭션은 브리지를 사용하여 조직 스토리지로 장애 조치를 시도합니다. 그레인 스토리지 브리지를 통해 트랜잭션 상태에 액세스하는 것은 덜 효율적이며 나중에는 지원되지 않을 수 있습니다. 따라서 개발 목적으로만 사용하는 것이 좋습니다.

조직 인터페이스

조직이 트랜잭션을 지원하기 위해 조직 인터페이스의 트랜잭션 메서드는 TransactionAttribute를 사용하여 트랜잭션의 일부로 표시되어야 합니다. 이 특성은 다음 TransactionOption에 자세히 설명된 것처럼 트랜잭션 환경에서 조직 호출이 작동하는 방식을 나타내야 합니다.

  • TransactionOption.Create - 호출은 트랜잭션이며, 기존 트랜잭션 컨텍스트 내에서 호출된 경우에도 항상 새 트랜잭션 컨텍스트를 만듭니다(즉, 새 트랜잭션을 시작함).
  • TransactionOption.Join - 호출은 트랜잭션이지만 기존 트랜잭션의 컨텍스트 내에서만 호출할 수 있습니다.
  • TransactionOption.CreateOrJoin - 호출이 트랜잭션입니다. 트랜잭션의 컨텍스트 내에서 호출되는 경우 해당 컨텍스트를 사용하고, 그렇지 않으면 새 컨텍스트를 만듭니다.
  • TransactionOption.Suppress - 호출은 트랜잭션이 아니지만 트랜잭션 내에서 호출할 수 있습니다. 트랜잭션의 컨텍스트 내에서 호출되는 경우 컨텍스트가 호출에 전달되지 않습니다.
  • TransactionOption.Supported - 호출은 트랜잭션이 아니지만 트랜잭션을 지원합니다. 트랜잭션의 컨텍스트 내에서 호출되는 경우 컨텍스트가 호출로 전달됩니다.
  • TransactionOption.NotAllowed - 호출은 트랜잭션이 아니며 트랜잭션 내에서 호출할 수 없습니다. 트랜잭션의 컨텍스트 내에서 호출되는 경우 NotSupportedException이 throw됩니다.

호출은 TransactionOption.Create로 표시될 수 있습니다. 즉, 호출이 항상 트랜잭션을 시작합니다. 예를 들어 아래 ATM 조직의 Transfer 작업은 항상 참조된 두 개의 계정을 포함하는 새 트랜잭션을 시작합니다.

namespace TransactionalExample.Abstractions;

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

계정 조직의 트랜잭션 작업 WithdrawDepositTransactionOption.Join으로 표시되어 기존 트랜잭션의 컨텍스트 내에서만 호출할 수 있음을 나타내며 이는 IAtmGrain.Transfer 중에 호출된 경우입니다. GetBalance 호출은 CreateOrJoin로 표시되므로 IAtmGrain.Transfer를 통해 또는 자체적으로 기존 트랜잭션 내에서 호출할 수 있습니다.

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

중요 고려 사항

이러한 호출에는 호출 전에 적절한 설정이 필요하므로 OnActivateAsync를 트랜잭션으로 표시할 수 없습니다. 조직 애플리케이션 API에 대해서만 존재합니다. 즉, 이러한 메서드의 일부로 트랜잭션 상태를 읽으려고 하면 런타임에서 예외가 throw됩니다.

조직 구현

조직 구현은 ITransactionalState<TState> 패싯을 사용하여 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);
}

지속형 상태에 대한 모든 읽기 또는 쓰기 액세스는 트랜잭션 상태 패싯에 전달되는 동기 함수를 통해 수행되어야 합니다. 이렇게 하면 트랜잭션 시스템에서 트랜잭션 방식으로 이러한 작업을 수행하거나 취소할 수 있습니다. 조직 내에서 트랜잭션 상태를 사용하려면 지속할 직렬화 가능한 상태 클래스를 정의하고 TransactionalStateAttribute를 사용하여 조직의 생성자에서 트랜잭션 상태를 선언하기만 하면 됩니다. 후자는 상태 이름과 사용할 트랜잭션 상태 스토리지(선택 사항)를 선언합니다. 자세한 내용은 설정을 참조하세요.

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

예를 들어 Balance 상태 개체는 다음과 같이 정의됩니다.

namespace TransactionalExample.Abstractions;

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

이전 상태 개체의 경우:

  • GenerateSerializerAttribute로 데코레이팅되어 Orleans 코드 생성기에 직렬 변환기를 생성하도록 지시합니다.
  • 멤버를 고유하게 식별하기 위해 Value으로 데코레이팅된 IdAttribute 속성이 있습니다.

그런 후 Balance 상태 개체는 다음과 같이 AccountGrain 구현에서 사용됩니다.

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

중요

트랜잭션 그레인이 ReentrantAttribute로 표시되어야 트랜잭션 컨텍스트가 그레인 호출에 올바르게 전달됩니다.

위의 예제에서 TransactionalStateAttributebalance 생성자 매개 변수가 "balance"라는 트랜잭션 상태와 연결되어야 함을 선언하는 데 사용됩니다. 이 선언을 통해 Orleans는 "TransactionStore"라는 트랜잭션 상태 스토리지에서 로드된 상태의 ITransactionalState<TState> 인스턴스를 삽입합니다. 상태는 PerformUpdate를 통해 수정하거나 PerformRead를 통해 읽을 수 있습니다. 트랜잭션 인프라는 트랜잭션의 일부로 수행된 모든 변경 내용이 Orleans 클러스터에 분산된 여러 조직 중에서도 모두 커밋되거나 트랜잭션을 만든 조직 호출이 완료될 때 모두 실행 취소되도록 합니다(이전 예제의 IAtmGrain.Transfer).

클라이언트에서 트랜잭션 메서드 호출

트랜잭션 그레인 메서드를 호출하는 권장 방법은 ITransactionClient를 사용하는 것입니다. ITransactionClient는 Orleans 클라이언트가 구성될 때 종속성 주입 서비스 공급자에 자동으로 등록됩니다. ITransactionClient는 트랜잭션 컨텍스트를 만들고 해당 컨텍스트 내에서 트랜잭션 그레인 메서드를 호출하는 데 사용됩니다. 다음 예제에서는 ITransactionClient를 사용하여 트랜잭션 그레인 메서드를 호출하는 방법을 보여 줍니다.

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

이전 클라이언트 코드:

  • IHostBuilderUseOrleansClient를 사용하여 구성됩니다.
    • IClientBuilder는 localhost 클러스터링 및 트랜잭션을 사용합니다.
  • IClusterClientITransactionClient 인터페이스는 서비스 공급자에서 검색됩니다.
  • fromto 변수에는 해당 IAccountGrain 참조가 할당됩니다.
  • ITransactionClient는 다음을 호출하여 트랜잭션을 만드는 데 사용됩니다.
    • from 계정 그레인 참조의 Withdraw
    • to 계정 그레인 참조의 Deposit

트랜잭션은 transactionDelegate에 throw된 예외가 없거나 모순되는 transactionOption이 지정되지 않는 한, 항상 커밋됩니다. 트랜잭션 그레인 메서드를 호출하는 권장 방법은 ITransactionClient를 사용하는 것이지만, 다른 그레인에서 직접 트랜잭션 그레인 메서드를 호출할 수도 있습니다.

다른 그레인에서 트랜잭션 메서드 호출

조직 인터페이스의 트랜잭션 메서드는 다른 그레인 메서드와 마찬가지로 호출됩니다. ITransactionClient를 사용하는 대체 방법으로, 아래의 AtmGrain 구현은 IAccountGrain 인터페이스에서 Transfer 메서드(트랜잭션)를 호출합니다.

AtmGrain 구현을 고려합니다. 이 구현은 두 개의 참조된 계정 그레인을 확인하고 WithdrawDeposit을 적절히 호출합니다.

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

클라이언트 앱 코드는 다음과 같이 트랜잭션 방식으로 AtmGrain.Transfer를 호출할 수 있습니다.

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

위의 호출에서 IAtmGrain은 한 계정에서 다른 계정으로 100단위 통화를 전송하는 데 사용됩니다. 전송이 완료되면 두 계정이 모두 쿼리되어 현재 잔액을 확인합니다. 통화 전송과 두 계정 쿼리는 ACID 트랜잭션으로 수행됩니다.

앞의 예제와 같이 트랜잭션은 다른 그레인 호출과 같이 Task 내에서 값을 반환할 수 있습니다. 그러나 호출 실패 시 애플리케이션 예외를 throw하지 않고 OrleansTransactionException 또는 TimeoutException을 throw합니다. 애플리케이션이 트랜잭션 중에 예외를 throw하고 해당 예외로 인해 트랜잭션이 실패하는 경우(다른 시스템 오류로 인해 실패하는 것이 아니라) 애플리케이션 예외는 OrleansTransactionException의 내부 예외가 됩니다.

OrleansTransactionAbortedException 형식의 트랜잭션 예외가 throw되면 트랜잭션이 실패하고 다시 시도할 수 있습니다. throw된 다른 예외는 트랜잭션이 알 수 없는 상태로 종료되었음을 나타냅니다. 트랜잭션은 분산 작업이므로 알 수 없는 상태의 트랜잭션이 성공하거나 실패했거나 계속 진행 중일 수 있습니다. 이러한 이유로 상태를 확인하거나 작업을 다시 시도하기 전에 연속 중단을 방지하기 위해 호출 시간 제한 기간(SiloMessagingOptions.SystemResponseTimeout)을 통과하는 것이 좋습니다.