Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
515
cloud/maplefile-backend/pkg/transaction/saga.go
Normal file
515
cloud/maplefile-backend/pkg/transaction/saga.go
Normal 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)
|
||||
}
|
||||
516
cloud/maplefile-backend/pkg/transaction/saga_test.go
Normal file
516
cloud/maplefile-backend/pkg/transaction/saga_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue