Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/integration_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:
docker compose logs

- name: Run integration tests
env:
TEST_FN_DISABLED: true
run: |
cd test
go test -v -p=1 ./... -cover -coverpkg=../... -coverprofile cover.out && go tool cover -func cover.out
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
go-version: 1.25.1

- name: Test
env:
TEST_FN_DISABLED: true
run: go test -v -p=1 ./... -cover -coverprofile cover.out && go tool cover -func cover.out

- name: Build
Expand Down
103 changes: 51 additions & 52 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,70 +435,69 @@ Each step contains:
- **Compensation**: A rollback operation that undoes the action if later steps fail

Steps execute sequentially. If any step fails, all previous steps are automatically
compensated in reverse order, ensuring system consistency
compensated, ensuring system consistency.

# <img src=".github/assets/sage_usage_1.png" alt="drawing" width="700" />

Example:
```go
// Use StepBuilder for more complex configuration
// This approach provides access to all library features:
// - Panic recovery
// - Retry policies
// - Custom backoff strategies
// - Jitter for load distribution
steps := []saga.Step{
saga.NewStep("first_step").
WithAction(
// Add action with decorators
saga.NewAction(func(ctx context.Context) error {
// Simulate error to demonstrate retry
return fmt.Errorf("first_step_Error")
}).
// Protection against panics — important for production!
// If the action panics, the panic will be caught
// and returned as an error with ErrPanicRecovered
WithPanicRecovery().
// Add retry for action
WithRetry(
// 2 attempts, 1s between attempts
saga.NewBaseRetryOpt(2, 1*time.Second).
// Return all errors which arise during retries
WithReturnAllAroseErr(), ), ).
// Add compensation
WithCompensation(
saga.NewCompensation(func(ctx context.Context, aroseErr error) error {
// Compensation logic.
// aroseErr — error from action that triggered compensation
// This can be useful for logging or strategy selection
return nil
}).
// Compensation can also have retry logic
WithRetry(
saga.NewAdvanceRetryPolicy(
2, // max attempts
1*time.Second, // initial delay
saga.NewExponentialBackoff(), // exponential backoff
).
// Jitter prevents "thundering herd"
WithJitter(
// random delay
saga.NewFullJitter(),
).
// maximum delay
WithMaxDelay(10 * time.Second), ), ),}
saga.NewStep("first_step").
WithAction(
// Add action with decorators
saga.NewAction(func(ctx context.Context, track saga.Track) error {
err := fmt.Errorf("first_step_Error")
return err
}).
// Protection against panics — important for production!
// If the action panics, the panic will be caught
// and returned as an error with ErrPanicRecovered
WithPanicRecovery().
// Add retry for action
WithRetry(
// 2 attempts, 1s between attempts
saga.NewBaseRetryOpt(2, 1*time.Second),
),
).
// Add compensation
WithCompensation(
saga.NewCompensation(func(ctx context.Context, track saga.Track) error {
// Compensation logic.
// Use track.GetData() to inspect what failed
data := track.GetData()
if len(data.Action.Errors) > 0 {
log.Printf("Compensating for error: %v", data.Action.Errors[0])
}
return performCompensation(ctx)
}).
// Compensation can also have retry logic
WithRetry(
saga.NewAdvanceRetryPolicy(
2, // max attempts
1*time.Second, // initial delay
saga.NewExponentialBackoff(), // exponential backoff
).
// Jitter prevents "thundering herd" during mass failures
WithJitter(
saga.NewFullJitter(), // random delay
).
// maximum delay cap
WithMaxDelay(10*time.Second),
),
).
WithCompensationRequired(),
}

// Execute the saga
//
// With this approach:
// 1. If action fails, there will be 2 attempts with exponential backoff
// 1. If action fails, it will be retried according to the retry policy
// 2. If all attempts fail, compensations will run
// 3. Compensations will also retry on failure
// 4. Jitter distributes load during mass failures
err := saga.NewSaga(steps).Execute(context.Background())

// 3. Compensations will also retry on failure with exponential backoff
// 4. Jitter distributes load during mass failure scenarios
result, err := saga.NewSaga(steps).Execute(context.Background())
if err != nil {
// Handle error
// Handle the `Result` and errors
}
```

Expand Down
102 changes: 74 additions & 28 deletions examples/sage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package examples

import (
"context"
"errors"
"fmt"
"testing"
"time"
Expand All @@ -17,12 +18,23 @@ import (
func Test_Saga_example(t *testing.T) {
t.Skip()

var (
// ErrPaymentFailed is an example error type for demonstration
ErrPaymentFailed = fmt.Errorf("payment failed")

// refundPayment is an example compensation function
refundPayment = func(ctx context.Context) error {
// Implementation would refund a payment
return nil
}
)

t.Run("first_example: simple declarative approach", func(t *testing.T) {
// Create saga steps as simple structs
// This approach is ideal when:
// - You have simple actions, compensation and retries logic
// - You have simple actions and compensation logic
// - You want maximum readability
// - You don't need additional decorators
// - You don't need additional decorators (retry, panic recovery)
steps := []saga.Step{
{
// Name is used for logging and debugging
Expand All @@ -31,42 +43,64 @@ func Test_Saga_example(t *testing.T) {

// Action — the main function of the step
// Executes business logic and returns an error on failure
Action: func(ctx context.Context) error {
// The track parameter provides access to execution context:
// - track.GetData() — retrieve step execution data
// - track.SetFailedOnError(err) — record errors
// - track.AddError(err) — append errors without changing status
Action: func(ctx context.Context, track saga.Track) error {
// This could be:
// - Database query via mtx.Transactor
// - External API call
// - Any other operation
//
// Use track to record intermediate errors:
// if err := someOperation(ctx); err != nil {
// track.SetFailedOnError(err)
// return err
// }
return nil
},

// Compensation — rollback function
// Called if subsequent steps fail
// Important: compensation must be idempotent!
Compensation: func(ctx context.Context, aroseErr error) error {
// aroseErr — the error that triggered compensation
// This allows making different decisions based on error type
// The track contains information about the failed action:
// - track.GetData().Action.Errors — errors from the action
// - track.GetData().Action.Status — status of the action
Compensation: func(ctx context.Context, track saga.Track) error {
// Get execution data to make decisions based on error type
data := track.GetData()

// Example: if errors.Is(aroseErr, ErrPaymentFailed) {
// return refundPayment(ctx)
// }
// Example: conditional compensation based on error
if len(data.Action.Errors) > 0 {
if errors.Is(data.Action.Errors[0], ErrPaymentFailed) {
// Handle specific error type
return refundPayment(ctx)
}
}

// Default compensation logic
return nil
},

// CompensationOnFail determines whether this step needs compensation
// CompensationRequired determines whether this step needs compensation
// true: if step changes state and requires rollback
// false: for read-only operations or non-compensatable actions (email, notifications)
CompensationOnFail: true,
CompensationRequired: true,
},
}

// Create and execute the saga
// Create and execute the Saga.
// Saga automatically manages the order: actions execute sequentially,
// on error, compensations run in reverse order
err := saga.NewSaga(steps).Execute(context.Background())
result, err := saga.NewSaga(steps).Execute(context.Background())

// Handle the error
// Handle the result
if err != nil {
// Important: err may contain both execution errors and compensation errors
// err contains detailed information about failures
// Use result to get detailed step-by-step execution data
t.Logf("Saga failed: %v\n", err)
fmt.Printf("Result status: %s\n", result.Status)
}
})

Expand All @@ -81,9 +115,12 @@ func Test_Saga_example(t *testing.T) {
saga.NewStep("first_step").
WithAction(
// Add action with decorators
saga.NewAction(func(ctx context.Context) error {
saga.NewAction(func(ctx context.Context, track saga.Track) error {
// Simulate error to demonstrate retry
return fmt.Errorf("first_step_Error")
// Record the error in track
err := fmt.Errorf("first_step_Error")
track.SetFailedOnError(err)
return err
}).
// Protection against panics — important for production!
// If the action panics, the panic will be caught
Expand All @@ -92,17 +129,22 @@ func Test_Saga_example(t *testing.T) {
// Add retry for action
WithRetry(
// 2 attempts, 1s between attempts
saga.NewBaseRetryOpt(2, 1*time.Second).
// Return all errors which arise during retries
WithReturnAllAroseErr(),
saga.NewBaseRetryOpt(2, 1*time.Second),
),
).
// Add compensation
WithCompensation(
saga.NewCompensation(func(ctx context.Context, aroseErr error) error {
saga.NewCompensation(func(ctx context.Context, track saga.Track) error {
// Compensation logic.
// aroseErr — error from action that triggered compensation
// This can be useful for logging or strategy selection
// Get data to understand what failed
data := track.GetData()

// Log the error that triggered compensation
if len(data.Action.Errors) > 0 {
fmt.Printf("Compensating for error: %v\n", data.Action.Errors[0])
}

// Perform compensation
return nil
}).
// Compensation can also have retry logic
Expand All @@ -120,20 +162,24 @@ func Test_Saga_example(t *testing.T) {
// maximum delay
WithMaxDelay(10 * time.Second),
),
),
).
// Mark that this step requires compensation
WithCompensationRequired(),
}

// Execute the saga
//
// With this approach:
// 1. If action fails, there will be 2 attempts with exponential backoff
// 1. If action fails, there will be 2 attempts with fixed delay
// 2. If all attempts fail, compensations will run
// 3. Compensations will also retry on failure
// 3. Compensations will also retry on failure with exponential backoff
// 4. Jitter distributes load during mass failures
err := saga.NewSaga(steps).Execute(context.Background())
result, err := saga.NewSaga(steps).Execute(context.Background())

if err != nil {
// Handle error
// Handle error with full context
fmt.Printf("Saga execution failed: %v\n", err)
fmt.Printf("Result status: %s\n", result.Status)
}
})
}
37 changes: 0 additions & 37 deletions internal/testtool/assert.go

This file was deleted.

Loading
Loading