Toccata - Try-Confirm-Cancel Protocol for Elixir
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:
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
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)
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"}
]
endQuick 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}")
endDetailed 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 actionsparams- 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
end2. 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)}
end3. 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
end4. 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 actionTelemetry 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()}
-
Measurements:
[:tcc, :transaction, :stop]- Transaction completed-
Measurements:
%{duration: integer()} -
Metadata:
%{transaction_id: string(), status: atom()} -
Status values:
:success,:cancelled,:confirm_failure,:cancel_failure
-
Measurements:
Action Events
[:tcc, :action, :start]- Action phase started-
Measurements:
%{system_time: integer()} -
Metadata:
%{action: atom(), phase: atom()}
-
Measurements:
[:tcc, :action, :stop]- Action phase completed-
Measurements:
%{duration: integer()} -
Metadata:
%{action: atom(), phase: atom(), status: atom()}
-
Measurements:
[:tcc, :action, :exception]- Action phase raised an exception-
Measurements:
%{duration: integer()} -
Metadata:
%{action: atom(), phase: atom(), kind: atom(), reason: any()}
-
Measurements:
Comparison with Sage
If you're familiar with the Sage library for implementing the Saga pattern, here's how TCC compares:
| Feature | Sage (Saga Pattern) | TCC (This Library) |
|---|---|---|
| Pattern | Saga (compensation) | Try-Confirm-Cancel |
| Phases | Forward + Compensation | Try + Confirm/Cancel |
| When Compensation Runs | After failure | During failure (Cancel) or success (Confirm) |
| Resource Reservation | No built-in support | Core feature (Try phase) |
| Use Case | Sequential operations | Resource coordination |
| Idempotency | Recommended | Required |
| Rollback Strategy | Compensating actions | Release reservations |
Comparison with Apache Seata TCC
This library is inspired by Apache Seata's TCC mode:
| Feature | Apache Seata (Java) | This Library (Elixir) |
|---|---|---|
| Core Protocol | TCC | TCC |
| Programming Model | Annotations | Functions |
| Coordination | Centralized TC | Local coordination |
| Language | Java | Elixir |
| Async Support | Yes | Yes |
| Telemetry | Metrics | Telemetry events |
Testing
Run the test suite:
cd tcc_lib
mix deps.get
mix testRun with coverage:
mix test --coverAdvanced 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
endConcurrent 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:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- 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.