Toccata - Try-Confirm-Cancel Protocol for Elixir

Copy Markdown View Source

Hex.pm Documentation

A robust implementation of the TCC (Try-Confirm-Cancel) distributed transaction protocol for Elixir, inspired by Apache Seata's TCC mode and designed with a similar API to the Sage library.

Overview

TCC is a two-phase commit protocol that ensures data consistency across distributed services without relying on the underlying database's transaction support. It's particularly useful for microservices architectures where you need to coordinate operations across multiple independent services.

The TCC Protocol

The protocol consists of three phases:

  1. Try Phase: Reserve resources and validate business rules

    • Each service reserves the necessary resources
    • Validates that the operation can be completed
    • Does not commit any changes yet
    • Returns success or failure
  2. Confirm Phase: Commit the transaction

    • Executed only if ALL Try operations succeed
    • Each service commits the reserved resources
    • Makes the changes permanent
    • Should be idempotent (can be retried safely)
  3. Cancel Phase: Rollback the transaction

    • Executed if ANY Try operation fails
    • Each service releases the reserved resources
    • Restores the system to its previous state
    • Should be idempotent (can be retried safely)

Why Use TCC?

  • Distributed Consistency: Maintain data consistency across multiple services
  • Fine-grained Control: Explicit control over each phase of the transaction
  • No Database Dependency: Works across different databases and systems
  • Flexible Error Handling: Custom logic for rollback scenarios
  • Idempotent Operations: Built-in retry logic for reliability

Installation

Add toccata to your list of dependencies in mix.exs:

def deps do
  [
    {:toccata, "~> 0.1.0"}
  ]
end

Quick Start

Here's a simple example of transferring money between two accounts:

# Define your Try-Confirm-Cancel operations
defmodule BankService do
  def try_debit(effects, params) do
    case reserve_funds(params.account, params.amount) do
      {:ok, reservation_id} ->
        new_params = Map.put(params, :reservation_id, reservation_id)
        {:ok, Map.put(effects, :funds_reserved, true), new_params}
      {:error, reason} ->
        {:error, reason}
    end
  end

  def confirm_debit(effects, params) do
    case commit_reservation(params.reservation_id) do
      :ok -> {:ok, Map.put(effects, :debit_confirmed, true), params}
      {:error, reason} -> {:error, reason}
    end
  end

  def cancel_debit(effects, params) do
    release_reservation(params.reservation_id)
    {:ok, Map.put(effects, :debit_cancelled, true), params}
  end
end

# Build and execute the transaction
transaction =
  TCC.new()
  |> TCC.run(:debit, &BankService.try_debit/2,
                     &BankService.confirm_debit/2,
                     &BankService.cancel_debit/2)
  |> TCC.run(:credit, &BankService.try_credit/2,
                      &BankService.confirm_credit/2,
                      &BankService.cancel_credit/2)

case TCC.execute(transaction, %{account: "123", amount: 100.0}) do
  {:ok, effects, result} ->
    IO.puts("Transfer successful!")
  {:error, stage, reason, effects} ->
    IO.puts("Transfer failed at #{stage}: #{reason}")
end

Detailed Examples

E-Commerce Order Processing

See the complete example in examples/e_commerce.ex that demonstrates:

  • Payment processing with reservation
  • Inventory management
  • Shipping arrangement
  • Coordinated rollback on failure
# Process an order across multiple services
params = %{
  order_id: "ORD-001",
  product_id: "PROD-123",
  quantity: 2,
  amount: 99.99,
  address: "123 Main St"
}

transaction =
  TCC.new(timeout: 60_000, retry_limit: 3)
  |> TCC.run(:payment, 
             &PaymentService.try_payment/2,
             &PaymentService.confirm_payment/2,
             &PaymentService.cancel_payment/2)
  |> TCC.run(:inventory,
             &InventoryService.try_reserve/2,
             &InventoryService.confirm_reserve/2,
             &InventoryService.cancel_reserve/2)
  |> TCC.run(:shipping,
             &ShippingService.try_arrange/2,
             &ShippingService.confirm_arrange/2,
             &ShippingService.cancel_arrange/2)

TCC.execute(transaction, params)

Simple Money Transfer

See examples/simple_transfer.ex for a complete working example with an in-memory account database.

# Start the example account database
{:ok, _} = TCC.Examples.SimpleTransfer.AccountDB.start_link([])

# Transfer money
TCC.Examples.SimpleTransfer.transfer("account_1", "account_2", 100.0)

# Check balances
{:ok, account} = TCC.Examples.SimpleTransfer.get_balance("account_1")
IO.inspect(account)

API Reference

Creating a Transaction

TCC.new(opts \\ [])

Options:

  • :timeout - Maximum time in milliseconds for the entire transaction (default: 30_000)
  • :retry_limit - Number of retries for confirm/cancel phases (default: 3)
  • :async - Whether to execute confirm/cancel phases asynchronously (default: false)
  • :telemetry_prefix - Prefix for telemetry events (default: [:tcc])

Adding Actions

TCC.run(transaction, name, try_fun, confirm_fun, cancel_fun)

Each function should have the signature:

@spec phase_function(effects :: map(), params :: any()) ::
  {:ok, effects :: map(), params :: any()} |
  {:error, reason :: any()}
  • effects - Accumulated effects/state from previous actions
  • params - Parameters passed through the transaction
  • Returns updated effects and params, or an error

Adding Actions with Options

TCC.run_with_opts(transaction, name, try_fun, confirm_fun, cancel_fun, opts)

Per-action options:

  • :timeout - Override global timeout for this action
  • :retry_limit - Override global retry limit for this action

Executing a Transaction

TCC.execute(transaction, params)

Returns:

  • {:ok, effects, result} - All operations succeeded
  • {:error, stage_name, reason, effects} - A stage failed

Async Execution

task = TCC.async_execute(transaction, params)
result = Task.await(task, :infinity)

Best Practices

1. Idempotency

All Confirm and Cancel functions must be idempotent:

def confirm_payment(effects, params) do
  # Check if already confirmed
  case get_reservation(params.reservation_id) do
    {:ok, %{status: :confirmed}} ->
      {:ok, effects, params}  # Already confirmed, return success
    {:ok, %{status: :reserved}} ->
      # Proceed with confirmation
      do_confirm(params.reservation_id)
      {:ok, effects, params}
  end
end

2. Resource Reservation

Always reserve resources in the Try phase:

def try_reserve(effects, params) do
  # Create a reservation record
  reservation = %{
    id: generate_id(),
    resource: params.resource_id,
    amount: params.amount,
    status: :reserved,
    expires_at: DateTime.add(DateTime.utc_now(), 300, :second)
  }
  
  DB.insert_reservation(reservation)
  {:ok, effects, Map.put(params, :reservation_id, reservation.id)}
end

3. Error Handling

Provide meaningful error messages:

def try_payment(effects, params) do
  case check_balance(params.account_id, params.amount) do
    :ok ->
      reserve_funds(params)
    {:error, :insufficient_funds} ->
      {:error, {:insufficient_funds, params.account_id, params.amount}}
    {:error, :account_not_found} ->
      {:error, {:account_not_found, params.account_id}}
  end
end

4. Logging and Monitoring

Use the built-in telemetry events:

:telemetry.attach(
  "tcc-handler",
  [:tcc, :transaction, :stop],
  fn event, measurements, metadata, _config ->
    Logger.info("Transaction #{metadata.transaction_id} completed",
      status: metadata.status,
      duration: measurements.duration
    )
  end,
  nil
)

5. Timeouts

Set appropriate timeouts based on your services:

TCC.new()
|> TCC.run_with_opts(:slow_service,
     &SlowService.try/2,
     &SlowService.confirm/2,
     &SlowService.cancel/2,
     timeout: 60_000)  # 60 seconds for this specific action

Telemetry Events

The library emits the following telemetry events:

Transaction Events

  • [:tcc, :transaction, :start] - Transaction started

    • Measurements: %{system_time: integer()}
    • Metadata: %{transaction_id: string(), action_count: integer()}
  • [:tcc, :transaction, :stop] - Transaction completed

    • Measurements: %{duration: integer()}
    • Metadata: %{transaction_id: string(), status: atom()}
    • Status values: :success, :cancelled, :confirm_failure, :cancel_failure

Action Events

  • [:tcc, :action, :start] - Action phase started

    • Measurements: %{system_time: integer()}
    • Metadata: %{action: atom(), phase: atom()}
  • [:tcc, :action, :stop] - Action phase completed

    • Measurements: %{duration: integer()}
    • Metadata: %{action: atom(), phase: atom(), status: atom()}
  • [:tcc, :action, :exception] - Action phase raised an exception

    • Measurements: %{duration: integer()}
    • Metadata: %{action: atom(), phase: atom(), kind: atom(), reason: any()}

Comparison with Sage

If you're familiar with the Sage library for implementing the Saga pattern, here's how TCC compares:

FeatureSage (Saga Pattern)TCC (This Library)
PatternSaga (compensation)Try-Confirm-Cancel
PhasesForward + CompensationTry + Confirm/Cancel
When Compensation RunsAfter failureDuring failure (Cancel) or success (Confirm)
Resource ReservationNo built-in supportCore feature (Try phase)
Use CaseSequential operationsResource coordination
IdempotencyRecommendedRequired
Rollback StrategyCompensating actionsRelease reservations

Comparison with Apache Seata TCC

This library is inspired by Apache Seata's TCC mode:

FeatureApache Seata (Java)This Library (Elixir)
Core ProtocolTCCTCC
Programming ModelAnnotationsFunctions
CoordinationCentralized TCLocal coordination
LanguageJavaElixir
Async SupportYesYes
TelemetryMetricsTelemetry events

Testing

Run the test suite:

cd tcc_lib
mix deps.get
mix test

Run with coverage:

mix test --cover

Advanced Usage

Custom Telemetry Handler

defmodule MyApp.TCCTelemetry do
  require Logger

  def setup do
    events = [
      [:tcc, :transaction, :start],
      [:tcc, :transaction, :stop],
      [:tcc, :action, :stop]
    ]

    :telemetry.attach_many(
      "my-app-tcc-handler",
      events,
      &handle_event/4,
      nil
    )
  end

  def handle_event([:tcc, :transaction, :stop], measurements, metadata, _config) do
    Logger.info("TCC Transaction completed",
      transaction_id: metadata.transaction_id,
      status: metadata.status,
      duration_ms: System.convert_time_unit(measurements.duration, :native, :millisecond)
    )
  end

  def handle_event([:tcc, :action, :stop], measurements, metadata, _config) do
    if metadata.status == :error do
      Logger.error("TCC Action failed",
        action: metadata.action,
        phase: metadata.phase,
        reason: metadata.reason
      )
    end
  end

  def handle_event(_event, _measurements, _metadata, _config), do: :ok
end

Concurrent Transactions

# Process multiple orders concurrently
orders = [
  %{order_id: "ORD-001", amount: 100.0, ...},
  %{order_id: "ORD-002", amount: 200.0, ...},
  %{order_id: "ORD-003", amount: 150.0, ...}
]

tasks = Enum.map(orders, fn order ->
  Task.async(fn ->
    transaction
    |> TCC.execute(order)
  end)
end)

results = Task.await_many(tasks, :infinity)

Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new functionality
  4. Ensure all tests pass
  5. Submit a pull request

License

MIT License - see LICENSE file for details

Acknowledgments

  • Inspired by Apache Seata TCC mode
  • API design influenced by the Sage library
  • Based on the TCC protocol pattern for distributed transactions

Further Reading

Support

For questions, issues, or feature requests, please open an issue on GitHub.