516 lines
12 KiB
Go
516 lines
12 KiB
Go
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)
|
|
}
|
|
}
|