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
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