Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,515 @@
package transaction
import (
"context"
"go.uber.org/zap"
)
// Package transaction provides a SAGA pattern implementation for managing distributed transactions.
//
// # What is SAGA Pattern?
//
// SAGA is a pattern for managing distributed transactions through a sequence of local transactions,
// each with a corresponding compensating transaction that undoes its effects if a later step fails.
//
// # When to Use SAGA
//
// Use SAGA when you have multiple database operations that need to succeed or fail together,
// but you can't use traditional ACID transactions (e.g., with Cassandra, distributed services,
// or operations across multiple bounded contexts).
//
// # Key Concepts
//
// - Forward Transaction: A database write operation (e.g., CreateTenant)
// - Compensating Transaction: An undo operation (e.g., DeleteTenant)
// - LIFO Execution: Compensations execute in reverse order (Last In, First Out)
//
// # Example Usage: User Registration Flow
//
// Problem: When registering a user, we create a tenant, then create a user.
// If user creation fails, the tenant becomes orphaned in the database.
//
// Solution: Use SAGA to automatically delete the tenant if user creation fails.
//
// func (s *RegisterService) Register(ctx context.Context, input *RegisterInput) (*RegisterResponse, error) {
// // Step 1: Create SAGA instance
// saga := transaction.NewSaga("user-registration", s.logger)
//
// // Step 2: Validate input (no DB writes, no compensation needed)
// if err := s.validateInputUC.Execute(input); err != nil {
// return nil, err
// }
//
// // Step 3: Create tenant (FIRST DB WRITE - register compensation)
// tenantOutput, err := s.createTenantUC.Execute(ctx, input)
// if err != nil {
// return nil, err // No rollback needed - tenant creation failed
// }
//
// // Register compensation: if anything fails later, delete this tenant
// saga.AddCompensation(func(ctx context.Context) error {
// s.logger.Warn("compensating: deleting tenant",
// zap.String("tenant_id", tenantOutput.ID))
// return s.deleteTenantUC.Execute(ctx, tenantOutput.ID)
// })
//
// // Step 4: Create user (SECOND DB WRITE)
// userOutput, err := s.createUserUC.Execute(ctx, tenantOutput.ID, input)
// if err != nil {
// s.logger.Error("user creation failed - rolling back tenant",
// zap.Error(err))
//
// // Execute SAGA rollback - this will delete the tenant
// saga.Rollback(ctx)
//
// return nil, err
// }
//
// // Success! Both tenant and user created, no rollback needed
// return &RegisterResponse{
// TenantID: tenantOutput.ID,
// UserID: userOutput.ID,
// }, nil
// }
//
// # Example Usage: Multi-Step Saga
//
// For operations with many steps, register multiple compensations:
//
// func (uc *ComplexOperationUseCase) Execute(ctx context.Context) error {
// saga := transaction.NewSaga("complex-operation", uc.logger)
//
// // Step 1: Create resource A
// resourceA, err := uc.createResourceA(ctx)
// if err != nil {
// return err
// }
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteResourceA(ctx, resourceA.ID)
// })
//
// // Step 2: Create resource B
// resourceB, err := uc.createResourceB(ctx)
// if err != nil {
// saga.Rollback(ctx) // Deletes A
// return err
// }
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteResourceB(ctx, resourceB.ID)
// })
//
// // Step 3: Create resource C
// resourceC, err := uc.createResourceC(ctx)
// if err != nil {
// saga.Rollback(ctx) // Deletes B, then A (LIFO order)
// return err
// }
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteResourceC(ctx, resourceC.ID)
// })
//
// // All steps succeeded - no rollback needed
// return nil
// }
//
// # Important Notes for Junior Developers
//
// 1. LIFO Order: Compensations execute in REVERSE order of registration
// If you create: Tenant → User → Email
// Rollback deletes: Email → User → Tenant
//
// 2. Idempotency: Compensating operations should be idempotent (safe to call multiple times)
// Your DeleteTenant should not error if tenant is already deleted
//
// 3. Failures Continue: If one compensation fails, others still execute
// This ensures maximum cleanup even if some operations fail
//
// 4. Logging: All operations are logged with emoji icons (🔴 for errors, 🟡 for warnings)
// Monitor logs for "saga rollback had failures" - indicates manual intervention needed
//
// 5. When NOT to Use SAGA:
// - Single database operation (no need for compensation)
// - Read-only operations (no state changes to rollback)
// - Operations where compensation isn't possible (e.g., sending an email can't be unsent)
//
// 6. Testing: Always test your rollback scenarios!
// Mock the second operation to fail and verify the first is rolled back
//
// # Common Pitfalls to Avoid
//
// - DON'T register compensations before the operation succeeds
// - DON'T forget to call saga.Rollback(ctx) when an operation fails
// - DON'T assume compensations will always succeed (they might fail too)
// - DON'T use SAGA for operations that can use database transactions
// - DO make your compensating operations idempotent
// - DO log all compensation failures for investigation
//
// # See Also
//
// For real-world examples, see:
// - internal/service/auth/refresh_token.go (token refresh with SAGA)
// - internal/service/auth/recovery_complete.go (recovery completion with SAGA)
// Compensator defines a function that undoes a previously executed operation.
//
// A compensator is the "undo" function for a database write operation.
// For example:
// - Forward operation: CreateTenant
// - Compensator: DeleteTenant
//
// Compensators must:
// - Accept a context (for cancellation/timeouts)
// - Return an error if compensation fails
// - Be idempotent (safe to call multiple times)
// - Clean up the exact resources created by the forward operation
//
// Example:
//
// // Forward operation: Create tenant
// tenantID := "tenant-123"
// err := tenantRepo.Create(ctx, tenant)
//
// // Compensator: Delete tenant
// compensator := func(ctx context.Context) error {
// return tenantRepo.Delete(ctx, tenantID)
// }
//
// saga.AddCompensation(compensator)
type Compensator func(ctx context.Context) error
// Saga manages a sequence of operations with compensating transactions.
//
// A Saga coordinates a multi-step workflow where each step that performs a database
// write registers a compensating transaction. If any step fails, all registered
// compensations are executed in reverse order (LIFO) to undo previous changes.
//
// # How it Works
//
// 1. Create a Saga instance with NewSaga()
// 2. Execute your operations in sequence
// 3. After each successful write, call AddCompensation() with the undo operation
// 4. If any operation fails, call Rollback() to undo all previous changes
// 5. If all operations succeed, no action needed (compensations are never called)
//
// # Thread Safety
//
// Saga is NOT thread-safe. Do not share a single Saga instance across goroutines.
// Each workflow execution should create its own Saga instance.
//
// # Fields
//
// - name: Human-readable name for logging (e.g., "user-registration")
// - compensators: Stack of undo functions, executed in LIFO order
// - logger: Structured logger for tracking saga execution and failures
type Saga struct {
name string // Name of the saga (for logging)
compensators []Compensator // Stack of compensating transactions (LIFO)
logger *zap.Logger // Logger for tracking saga execution
}
// NewSaga creates a new SAGA instance with the given name.
//
// The name parameter should be a descriptive identifier for the workflow
// (e.g., "user-registration", "order-processing", "account-setup").
// This name appears in all log messages for easy tracking and debugging.
//
// # Parameters
//
// - name: A descriptive name for this saga workflow (used in logging)
// - logger: A zap logger instance (will be enhanced with saga-specific fields)
//
// # Returns
//
// A new Saga instance ready to coordinate multi-step operations.
//
// # Example
//
// // In your use case
// func (uc *RegisterUseCase) Execute(ctx context.Context, input *Input) error {
// // Create a new saga for this registration workflow
// saga := transaction.NewSaga("user-registration", uc.logger)
//
// // ... use saga for your operations ...
// }
//
// # Important
//
// Each workflow execution should create its own Saga instance.
// Do NOT reuse a Saga instance across multiple workflow executions.
func NewSaga(name string, logger *zap.Logger) *Saga {
return &Saga{
name: name,
compensators: make([]Compensator, 0),
logger: logger.Named("saga").With(zap.String("saga_name", name)),
}
}
// AddCompensation registers a compensating transaction for rollback.
//
// Call this method IMMEDIATELY AFTER a successful database write operation
// to register the corresponding undo operation.
//
// # Execution Order: LIFO (Last In, First Out)
//
// Compensations are executed in REVERSE order of registration during rollback.
// This ensures proper cleanup order:
// - If you create: Tenant → User → Subscription
// - Rollback deletes: Subscription → User → Tenant
//
// # Parameters
//
// - compensate: A function that undoes the operation (e.g., DeleteTenant)
//
// # When to Call
//
// // ✅ CORRECT: Register compensation AFTER operation succeeds
// tenantOutput, err := uc.createTenantUC.Execute(ctx, input)
// if err != nil {
// return nil, err // Operation failed - no compensation needed
// }
// // Operation succeeded - NOW register the undo operation
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteTenantUC.Execute(ctx, tenantOutput.ID)
// })
//
// // ❌ WRONG: Don't register compensation BEFORE operation
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteTenantUC.Execute(ctx, tenantOutput.ID)
// })
// tenantOutput, err := uc.createTenantUC.Execute(ctx, input) // Might fail!
//
// # Example: Basic Usage
//
// // Step 1: Create tenant
// tenant, err := uc.createTenantUC.Execute(ctx, input)
// if err != nil {
// return nil, err
// }
//
// // Step 2: Register compensation for tenant
// saga.AddCompensation(func(ctx context.Context) error {
// uc.logger.Warn("rolling back: deleting tenant",
// zap.String("tenant_id", tenant.ID))
// return uc.deleteTenantUC.Execute(ctx, tenant.ID)
// })
//
// # Example: Capturing Variables in Closure
//
// // Be careful with variable scope in closures!
// for _, item := range items {
// created, err := uc.createItem(ctx, item)
// if err != nil {
// saga.Rollback(ctx)
// return err
// }
//
// // ✅ CORRECT: Capture the variable value
// itemID := created.ID // Capture in local variable
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteItem(ctx, itemID) // Use captured value
// })
//
// // ❌ WRONG: Variable will have wrong value at rollback time
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteItem(ctx, created.ID) // 'created' may change!
// })
// }
//
// # Tips for Writing Good Compensators
//
// 1. Make them idempotent (safe to call multiple times)
// 2. Log what you're compensating for easier debugging
// 3. Capture all necessary IDs before the closure
// 4. Handle "not found" errors gracefully (resource may already be deleted)
// 5. Return errors if compensation truly fails (logged but doesn't stop other compensations)
func (s *Saga) AddCompensation(compensate Compensator) {
s.compensators = append(s.compensators, compensate)
s.logger.Debug("compensation registered",
zap.Int("total_compensations", len(s.compensators)))
}
// Rollback executes all registered compensating transactions in reverse order (LIFO).
//
// Call this method when any operation in your workflow fails AFTER you've started
// registering compensations. This will undo all previously successful operations
// by executing their compensating transactions in reverse order.
//
// # When to Call
//
// tenant, err := uc.createTenantUC.Execute(ctx, input)
// if err != nil {
// return nil, err // No compensations registered yet - no rollback needed
// }
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteTenantUC.Execute(ctx, tenant.ID)
// })
//
// user, err := uc.createUserUC.Execute(ctx, tenant.ID, input)
// if err != nil {
// // Compensations ARE registered - MUST call rollback!
// saga.Rollback(ctx)
// return nil, err
// }
//
// # Execution Behavior
//
// 1. LIFO Order: Compensations execute in REVERSE order of registration
// - If you registered: [DeleteTenant, DeleteUser, DeleteSubscription]
// - Rollback executes: DeleteSubscription → DeleteUser → DeleteTenant
//
// 2. Best Effort: If a compensation fails, it's logged but others still execute
// - This maximizes cleanup even if some operations fail
// - Failed compensations are logged with 🔴 emoji for investigation
//
// 3. No Panic: Rollback never panics, even if all compensations fail
// - Failures are logged for manual intervention
// - Returns without error (compensation failures are logged, not returned)
//
// # Example: Basic Rollback
//
// func (uc *RegisterUseCase) Execute(ctx context.Context, input *Input) error {
// saga := transaction.NewSaga("user-registration", uc.logger)
//
// // Step 1: Create tenant
// tenant, err := uc.createTenantUC.Execute(ctx, input)
// if err != nil {
// return err // No rollback needed
// }
// saga.AddCompensation(func(ctx context.Context) error {
// return uc.deleteTenantUC.Execute(ctx, tenant.ID)
// })
//
// // Step 2: Create user
// user, err := uc.createUserUC.Execute(ctx, tenant.ID, input)
// if err != nil {
// uc.logger.Error("user creation failed", zap.Error(err))
// saga.Rollback(ctx) // ← Deletes tenant
// return err
// }
//
// // Both operations succeeded - no rollback needed
// return nil
// }
//
// # Log Output Example
//
// Successful rollback:
//
// WARN 🟡 executing saga rollback {"saga_name": "user-registration", "compensation_count": 1}
// INFO executing compensation {"step": 1, "index": 0}
// INFO deleting tenant {"tenant_id": "tenant-123"}
// INFO tenant deleted successfully {"tenant_id": "tenant-123"}
// INFO compensation succeeded {"step": 1}
// WARN 🟡 saga rollback completed {"total_compensations": 1, "successes": 1, "failures": 0}
//
// Failed compensation:
//
// WARN 🟡 executing saga rollback
// INFO executing compensation
// ERROR 🔴 failed to delete tenant {"error": "connection lost"}
// ERROR 🔴 compensation failed {"step": 1, "error": "..."}
// WARN 🟡 saga rollback completed {"successes": 0, "failures": 1}
// ERROR 🔴 saga rollback had failures - manual intervention may be required
//
// # Important Notes
//
// 1. Always call Rollback if you've registered ANY compensations and a later step fails
// 2. Don't call Rollback if no compensations have been registered yet
// 3. Rollback is safe to call multiple times (idempotent) but wasteful
// 4. Monitor logs for "saga rollback had failures" - indicates manual cleanup needed
// 5. Context cancellation is respected - compensations will see cancelled context
//
// # Parameters
//
// - ctx: Context for cancellation/timeout (passed to each compensating function)
//
// # What Gets Logged
//
// - Start of rollback (warning level with 🟡 emoji)
// - Each compensation execution attempt
// - Success or failure of each compensation
// - Summary of rollback results
// - Alert if any compensations failed (error level with 🔴 emoji)
func (s *Saga) Rollback(ctx context.Context) {
if len(s.compensators) == 0 {
s.logger.Info("no compensations to execute")
return
}
s.logger.Warn("executing saga rollback",
zap.Int("compensation_count", len(s.compensators)))
successCount := 0
failureCount := 0
// Execute in reverse order (LIFO - Last In, First Out)
for i := len(s.compensators) - 1; i >= 0; i-- {
compensationStep := len(s.compensators) - i
s.logger.Info("executing compensation",
zap.Int("step", compensationStep),
zap.Int("index", i))
if err := s.compensators[i](ctx); err != nil {
failureCount++
// Log with error level (automatically adds emoji)
s.logger.Error("compensation failed",
zap.Int("step", compensationStep),
zap.Int("index", i),
zap.Error(err))
// Continue with other compensations even if one fails
} else {
successCount++
s.logger.Info("compensation succeeded",
zap.Int("step", compensationStep),
zap.Int("index", i))
}
}
s.logger.Warn("saga rollback completed",
zap.Int("total_compensations", len(s.compensators)),
zap.Int("successes", successCount),
zap.Int("failures", failureCount))
// If any compensations failed, this indicates a serious issue
// The operations team should be alerted to investigate
if failureCount > 0 {
s.logger.Error("saga rollback had failures - manual intervention may be required",
zap.Int("failed_compensations", failureCount))
}
}
// MustRollback is a convenience method that executes rollback.
//
// This method currently has the same behavior as Rollback() - it executes
// all compensating transactions but does NOT panic on failure.
//
// # When to Use
//
// Use this method when you want to make it explicit in your code that rollback
// is critical and must be executed, even though the actual behavior is the same
// as Rollback().
//
// # Example
//
// user, err := uc.createUserUC.Execute(ctx, tenant.ID, input)
// if err != nil {
// // Make it explicit that rollback is critical
// saga.MustRollback(ctx)
// return nil, err
// }
//
// # Note for Junior Developers
//
// Despite the name "MustRollback", this method does NOT panic if compensations fail.
// Compensation failures are logged for manual intervention, but the method returns normally.
//
// The name "Must" indicates that YOU must call this method if compensations are registered,
// not that the rollback itself must succeed.
//
// If you need actual panic behavior on compensation failure, you would need to check
// logs or implement custom panic logic.
func (s *Saga) MustRollback(ctx context.Context) {
s.Rollback(ctx)
}

View file

@ -0,0 +1,516 @@
package transaction
import (
"context"
"errors"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
)
// TestNewSaga verifies that NewSaga creates a properly initialized Saga instance
func TestNewSaga(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
if saga == nil {
t.Fatal("NewSaga returned nil")
}
if saga.name != "test-saga" {
t.Errorf("expected name 'test-saga', got '%s'", saga.name)
}
if saga.compensators == nil {
t.Error("compensators slice is nil")
}
if len(saga.compensators) != 0 {
t.Errorf("expected 0 compensators, got %d", len(saga.compensators))
}
if saga.logger == nil {
t.Error("logger is nil")
}
}
// TestAddCompensation_Single verifies that adding a single compensation works
func TestAddCompensation_Single(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
executed := false
compensator := func(ctx context.Context) error {
executed = true
return nil
}
saga.AddCompensation(compensator)
if len(saga.compensators) != 1 {
t.Errorf("expected 1 compensator, got %d", len(saga.compensators))
}
// Verify compensator can be called
ctx := context.Background()
if err := saga.compensators[0](ctx); err != nil {
t.Errorf("compensator returned error: %v", err)
}
if !executed {
t.Error("compensator was not executed")
}
}
// TestAddCompensation_Multiple verifies that adding multiple compensations works
func TestAddCompensation_Multiple(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
// Add three compensations
for i := 0; i < 3; i++ {
saga.AddCompensation(func(ctx context.Context) error {
return nil
})
}
if len(saga.compensators) != 3 {
t.Errorf("expected 3 compensators, got %d", len(saga.compensators))
}
}
// TestRollback_EmptyCompensations verifies that rollback with no compensations is safe
func TestRollback_EmptyCompensations(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
ctx := context.Background()
// Should not panic or error
saga.Rollback(ctx)
}
// TestRollback_SingleCompensation_Success verifies successful rollback with one compensation
func TestRollback_SingleCompensation_Success(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
executed := false
saga.AddCompensation(func(ctx context.Context) error {
executed = true
return nil
})
ctx := context.Background()
saga.Rollback(ctx)
if !executed {
t.Error("compensation was not executed during rollback")
}
}
// TestRollback_SingleCompensation_Failure verifies rollback continues on compensation failure
func TestRollback_SingleCompensation_Failure(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
expectedError := errors.New("compensation failed")
executed := false
saga.AddCompensation(func(ctx context.Context) error {
executed = true
return expectedError
})
ctx := context.Background()
saga.Rollback(ctx)
if !executed {
t.Error("compensation was not executed during rollback")
}
// Rollback should not panic or return error, just log it
}
// TestRollback_LIFO_ExecutionOrder verifies compensations execute in reverse order
func TestRollback_LIFO_ExecutionOrder(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
var executionOrder []int
// Add three compensations
for i := 1; i <= 3; i++ {
index := i // Capture value in local variable
saga.AddCompensation(func(ctx context.Context) error {
executionOrder = append(executionOrder, index)
return nil
})
}
ctx := context.Background()
saga.Rollback(ctx)
// Verify LIFO order: should be [3, 2, 1]
expected := []int{3, 2, 1}
if len(executionOrder) != len(expected) {
t.Fatalf("expected %d executions, got %d", len(expected), len(executionOrder))
}
for i, v := range executionOrder {
if v != expected[i] {
t.Errorf("execution order[%d]: expected %d, got %d", i, expected[i], v)
}
}
}
// TestRollback_MultipleCompensations_AllSuccess verifies all compensations execute
func TestRollback_MultipleCompensations_AllSuccess(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
executedCount := 0
// Add 5 compensations
for i := 0; i < 5; i++ {
saga.AddCompensation(func(ctx context.Context) error {
executedCount++
return nil
})
}
ctx := context.Background()
saga.Rollback(ctx)
if executedCount != 5 {
t.Errorf("expected 5 compensations executed, got %d", executedCount)
}
}
// TestRollback_MultipleCompensations_PartialFailure verifies best-effort behavior
func TestRollback_MultipleCompensations_PartialFailure(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
executedCount := 0
// Add 5 compensations, second one fails
for i := 0; i < 5; i++ {
index := i
saga.AddCompensation(func(ctx context.Context) error {
executedCount++
if index == 1 {
return errors.New("compensation 2 failed")
}
return nil
})
}
ctx := context.Background()
saga.Rollback(ctx)
// All 5 should be attempted despite failure
if executedCount != 5 {
t.Errorf("expected 5 compensations attempted, got %d", executedCount)
}
}
// TestRollback_ContextCancellation verifies context is passed to compensations
func TestRollback_ContextCancellation(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
receivedContext := false
saga.AddCompensation(func(ctx context.Context) error {
if ctx != nil {
receivedContext = true
}
return nil
})
ctx := context.Background()
saga.Rollback(ctx)
if !receivedContext {
t.Error("compensation did not receive context")
}
}
// TestRollback_CancelledContext verifies compensations see cancelled context
func TestRollback_CancelledContext(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
ctxCancelled := false
saga.AddCompensation(func(ctx context.Context) error {
select {
case <-ctx.Done():
ctxCancelled = true
default:
// Context not cancelled
}
return nil
})
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel context before rollback
saga.Rollback(ctx)
if !ctxCancelled {
t.Error("compensation did not receive cancelled context")
}
}
// TestMustRollback verifies MustRollback behaves like Rollback
func TestMustRollback(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
executed := false
saga.AddCompensation(func(ctx context.Context) error {
executed = true
return nil
})
ctx := context.Background()
saga.MustRollback(ctx)
if !executed {
t.Error("MustRollback did not execute compensation")
}
}
// TestMustRollback_DoesNotPanic verifies MustRollback doesn't panic on failure
func TestMustRollback_DoesNotPanic(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
saga.AddCompensation(func(ctx context.Context) error {
return errors.New("compensation failed")
})
ctx := context.Background()
// Should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("MustRollback panicked: %v", r)
}
}()
saga.MustRollback(ctx)
}
// TestRollback_VariableCaptureWarning demonstrates the closure gotcha
func TestRollback_VariableCaptureWarning(t *testing.T) {
logger := zaptest.NewLogger(t)
// Simulate loop with variable capture issue
var executedIDs []int
// WRONG: Capturing loop variable directly
type Resource struct {
ID int
}
resources := []Resource{{ID: 1}, {ID: 2}, {ID: 3}}
sagaWrong := NewSaga("wrong-capture", logger)
for _, resource := range resources {
// This is WRONG but we're demonstrating the problem
sagaWrong.AddCompensation(func(ctx context.Context) error {
// Will always use the last value of 'resource'
executedIDs = append(executedIDs, resource.ID)
return nil
})
}
ctx := context.Background()
sagaWrong.Rollback(ctx)
// All compensations will use resource.ID = 3 (the last value)
for _, id := range executedIDs {
if id != 3 {
t.Logf("Warning: captured variable changed (this is expected in wrong usage)")
}
}
// CORRECT: Capture value in local variable
executedIDsCorrect := []int{}
sagaCorrect := NewSaga("correct-capture", logger)
for _, resource := range resources {
resourceID := resource.ID // Capture value
sagaCorrect.AddCompensation(func(ctx context.Context) error {
executedIDsCorrect = append(executedIDsCorrect, resourceID)
return nil
})
}
sagaCorrect.Rollback(ctx)
// Should execute in reverse: [3, 2, 1]
expected := []int{3, 2, 1}
if len(executedIDsCorrect) != len(expected) {
t.Fatalf("expected %d executions, got %d", len(expected), len(executedIDsCorrect))
}
for i, id := range executedIDsCorrect {
if id != expected[i] {
t.Errorf("execution[%d]: expected %d, got %d", i, expected[i], id)
}
}
}
// TestRollback_Idempotency verifies compensations can be called multiple times
func TestRollback_Idempotency(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
executionCount := 0
saga.AddCompensation(func(ctx context.Context) error {
executionCount++
// Idempotent: safe to call multiple times
return nil
})
ctx := context.Background()
// Call rollback twice
saga.Rollback(ctx)
saga.Rollback(ctx)
// Should execute twice (once per rollback call)
if executionCount != 2 {
t.Errorf("expected 2 executions, got %d", executionCount)
}
}
// TestRollback_RealWorldScenario simulates a registration flow
func TestRollback_RealWorldScenario(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("user-registration", logger)
ctx := context.Background()
// Track what got created and deleted
tenantCreated := false
tenantDeleted := false
userCreated := false
userDeleted := false
// Step 1: Create tenant
tenantID := "tenant-123"
tenantCreated = true
saga.AddCompensation(func(ctx context.Context) error {
tenantDeleted = true
return nil
})
// Step 2: Create user
userID := "user-456"
userCreated = true
saga.AddCompensation(func(ctx context.Context) error {
userDeleted = true
return nil
})
// Step 3: Something fails (e.g., email sending)
emailErr := errors.New("email service unavailable")
if emailErr != nil {
// Rollback should delete user then tenant (LIFO)
saga.Rollback(ctx)
}
// Verify cleanup happened
if !tenantCreated {
t.Error("tenant was not created")
}
if !userCreated {
t.Error("user was not created")
}
if !userDeleted {
t.Error("user was not deleted during rollback")
}
if !tenantDeleted {
t.Error("tenant was not deleted during rollback")
}
// Verify IDs are still accessible (not used in this test but good practice)
_ = tenantID
_ = userID
}
// TestRollback_NoCompensationsRegistered_NoOp verifies rollback is safe when nothing registered
func TestRollback_NoCompensationsRegistered_NoOp(t *testing.T) {
logger := zaptest.NewLogger(t)
saga := NewSaga("test-saga", logger)
ctx := context.Background()
// Should be a no-op, no panic
saga.Rollback(ctx)
saga.MustRollback(ctx)
}
// BenchmarkSaga_AddCompensation benchmarks compensation registration
func BenchmarkSaga_AddCompensation(b *testing.B) {
logger := zap.NewNop()
saga := NewSaga("benchmark-saga", logger)
compensator := func(ctx context.Context) error {
return nil
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
saga.AddCompensation(compensator)
}
}
// BenchmarkSaga_Rollback benchmarks rollback execution
func BenchmarkSaga_Rollback(b *testing.B) {
logger := zap.NewNop()
ctx := context.Background()
// Prepare saga with 10 compensations
compensator := func(ctx context.Context) error {
return nil
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
saga := NewSaga("benchmark-saga", logger)
for j := 0; j < 10; j++ {
saga.AddCompensation(compensator)
}
saga.Rollback(ctx)
}
}
// BenchmarkSaga_RollbackWithFailures benchmarks rollback with compensation failures
func BenchmarkSaga_RollbackWithFailures(b *testing.B) {
logger := zap.NewNop()
ctx := context.Background()
successCompensator := func(ctx context.Context) error {
return nil
}
failureCompensator := func(ctx context.Context) error {
return errors.New("compensation failed")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
saga := NewSaga("benchmark-saga", logger)
for j := 0; j < 5; j++ {
saga.AddCompensation(successCompensator)
saga.AddCompensation(failureCompensator)
}
saga.Rollback(ctx)
}
}